diff --git a/launcher/App.axaml.cs b/launcher/App.axaml.cs
index 8372e5e..459506b 100644
--- a/launcher/App.axaml.cs
+++ b/launcher/App.axaml.cs
@@ -1,6 +1,7 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
+using ModpackLauncher.Services;
namespace ModpackLauncher;
@@ -13,6 +14,11 @@ public partial class App : Application
public override void OnFrameworkInitializationCompleted()
{
+ // Clean up any .old launcher left behind by a self-update on a prior run.
+ // Best-effort: if the previous process hasn't fully released the handle yet
+ // we just retry on the next startup.
+ SelfUpdateService.CleanupAfterUpdate();
+
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow();
diff --git a/launcher/MainWindow.axaml b/launcher/MainWindow.axaml
index c5dbf52..edd63e6 100644
--- a/launcher/MainWindow.axaml
+++ b/launcher/MainWindow.axaml
@@ -259,7 +259,7 @@
Foreground="#E8DFC8" FontSize="13" TextWrapping="Wrap"
Text="A newer launcher is available." />
diff --git a/launcher/MainWindow.axaml.cs b/launcher/MainWindow.axaml.cs
index bbf42d3..40aba38 100644
--- a/launcher/MainWindow.axaml.cs
+++ b/launcher/MainWindow.axaml.cs
@@ -128,22 +128,57 @@ public partial class MainWindow : Window
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";
+ UpdateBannerDownloadButton.Tag = manifest.LauncherUrl ?? "https://bns.sijbers.uk/launcher/BrassAndSigil-Launcher.exe";
UpdateBanner.IsVisible = true;
AppendLog($"[update] Newer launcher available: v{advertised} (running v{current})");
}
- private void OnUpdateBannerDownloadClick(object? sender, RoutedEventArgs e)
+ private async void OnUpdateBannerDownloadClick(object? sender, RoutedEventArgs e)
{
- var url = UpdateBannerDownloadButton.Tag as string
- ?? "https://sijbers.uk/pack/BrassAndSigil-Launcher.exe";
+ var url = UpdateBannerDownloadButton.Tag as string;
+ if (string.IsNullOrEmpty(url))
+ {
+ AppendLog("[update] No download URL configured in manifest.");
+ return;
+ }
+
+ // First click: try in-place self-update. On failure (network blip, write-protected
+ // dir, antivirus blocking), fall back to opening the browser so the user can still
+ // grab the new exe manually.
+ UpdateBannerDownloadButton.IsEnabled = false;
+ var originalLabel = UpdateBannerDownloadButton.Content as string ?? "Install update";
+ UpdateBannerDownloadButton.Content = "Updating...";
+
try
{
- Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true });
+ var svc = new ModpackLauncher.Services.SelfUpdateService();
+ var progress = new Progress<(long bytes, long? total)>(p =>
+ {
+ var mb = p.bytes / 1024.0 / 1024.0;
+ UpdateBannerDownloadButton.Content = p.total.HasValue
+ ? $"{(double)p.bytes * 100 / p.total.Value:F0}%"
+ : $"{mb:F1} MB";
+ });
+ await svc.DownloadAndInstallAsync(url, progress, AppendLog);
+ // If the method returns, it means it didn't exit -- that shouldn't normally
+ // happen, but reset the UI just in case.
+ UpdateBannerDownloadButton.Content = originalLabel;
+ UpdateBannerDownloadButton.IsEnabled = true;
}
catch (Exception ex)
{
- AppendLog($"[update] Couldn't open browser: {ex.Message}");
+ AppendLog($"[update] Auto-update failed: {ex.Message}");
+ AppendLog("[update] Opening browser as fallback...");
+ try
+ {
+ Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true });
+ }
+ catch (Exception ex2)
+ {
+ AppendLog($"[update] Browser fallback also failed: {ex2.Message}");
+ }
+ UpdateBannerDownloadButton.Content = originalLabel;
+ UpdateBannerDownloadButton.IsEnabled = true;
}
}
diff --git a/launcher/ModpackLauncher.csproj b/launcher/ModpackLauncher.csproj
index 4a3d16e..a46f9ca 100644
--- a/launcher/ModpackLauncher.csproj
+++ b/launcher/ModpackLauncher.csproj
@@ -10,7 +10,7 @@
trueModpackLauncherModpackLauncher
- 0.4.6
+ 0.4.7Assets\icon.ico
diff --git a/launcher/Services/SelfUpdateService.cs b/launcher/Services/SelfUpdateService.cs
new file mode 100644
index 0000000..6c0c814
--- /dev/null
+++ b/launcher/Services/SelfUpdateService.cs
@@ -0,0 +1,138 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace ModpackLauncher.Services;
+
+///
+/// In-place launcher self-update. Pattern matches Chrome/VS Code/Discord:
+/// 1. Download new exe to ".new"
+/// 2. Rename running exe to ".old" (Windows allows MoveFile on a running exe)
+/// 3. Rename ".new" to the canonical name
+/// 4. Process.Start the new exe with our args
+/// 5. Exit -- the OS releases the .old file once the old process is gone
+/// 6. Next launcher start deletes any leftover .old via CleanupAfterUpdate()
+///
+/// Fails loudly (throws) on any step. Caller falls back to browser-open download.
+///
+public sealed class SelfUpdateService
+{
+ private static readonly HttpClient _http = new()
+ {
+ Timeout = TimeSpan.FromMinutes(5),
+ DefaultRequestVersion = new Version(2, 0),
+ };
+
+ public static string CurrentExePath =>
+ Environment.ProcessPath
+ ?? throw new InvalidOperationException("Environment.ProcessPath was null (self-update needs a real exe path).");
+
+ public static string OldExePath => CurrentExePath + ".old";
+ public static string NewExePath => CurrentExePath + ".new";
+
+ ///
+ /// Delete any leftover .old file from a prior self-update. Safe to call on every startup;
+ /// quietly retries next start if the file is still locked (rare timing edge case).
+ ///
+ public static void CleanupAfterUpdate(Action? log = null)
+ {
+ var old = OldExePath;
+ if (!File.Exists(old)) return;
+ try
+ {
+ File.Delete(old);
+ log?.Invoke($"[update] Removed previous launcher backup: {Path.GetFileName(old)}");
+ }
+ catch (Exception ex)
+ {
+ // The old process may still be releasing the file handle. Not fatal --
+ // we'll retry next start. Worst case the .old file sits there harmlessly.
+ log?.Invoke($"[update] Couldn't remove {Path.GetFileName(old)} yet ({ex.GetType().Name}); will retry next start.");
+ }
+ }
+
+ ///
+ /// Download the new launcher, swap it in, restart with the same command line, and exit.
+ /// Returns only on failure (i.e. throws) -- on success the current process exits.
+ ///
+ public async Task DownloadAndInstallAsync(
+ string downloadUrl,
+ IProgress<(long bytesReceived, long? totalBytes)>? progress = null,
+ Action? log = null,
+ CancellationToken ct = default)
+ {
+ var curPath = CurrentExePath;
+ var newPath = NewExePath;
+ var oldPath = OldExePath;
+
+ log?.Invoke($"[update] Downloading {downloadUrl}");
+
+ // 1. Download to .new, replacing any stale partial from a failed prior attempt
+ if (File.Exists(newPath)) File.Delete(newPath);
+
+ using (var resp = await _http.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead, ct))
+ {
+ resp.EnsureSuccessStatusCode();
+ var total = resp.Content.Headers.ContentLength;
+ await using var src = await resp.Content.ReadAsStreamAsync(ct);
+ await using var dst = new FileStream(newPath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
+
+ var buffer = new byte[81920];
+ long received = 0;
+ int read;
+ while ((read = await src.ReadAsync(buffer, ct)) > 0)
+ {
+ await dst.WriteAsync(buffer.AsMemory(0, read), ct);
+ received += read;
+ progress?.Report((received, total));
+ }
+ log?.Invoke($"[update] Downloaded {received:N0} bytes");
+ }
+
+ // Sanity: refuse to swap in something laughably small (a captive portal
+ // intercepted us, or the server returned an error body as 200 OK).
+ var fi = new FileInfo(newPath);
+ if (fi.Length < 1_000_000)
+ {
+ File.Delete(newPath);
+ throw new InvalidDataException($"Downloaded launcher is only {fi.Length:N0} bytes -- almost certainly not the real binary.");
+ }
+
+ // 2. Move running exe to .old (this works while we're executing — NTFS resolves
+ // running processes by handle, not by path).
+ if (File.Exists(oldPath)) File.Delete(oldPath);
+ File.Move(curPath, oldPath);
+ log?.Invoke($"[update] Backed up current launcher to {Path.GetFileName(oldPath)}");
+
+ // 3. Move new exe into the canonical slot
+ File.Move(newPath, curPath);
+ log?.Invoke($"[update] New launcher staged at {Path.GetFileName(curPath)}");
+
+ // 4. Build the restart command. Pass through any args the current process was
+ // launched with (e.g. URI handler args from a future deep-link integration),
+ // skipping argv[0] which is the exe itself.
+ var args = Environment.GetCommandLineArgs();
+ var psi = new ProcessStartInfo
+ {
+ FileName = curPath,
+ UseShellExecute = false,
+ WorkingDirectory = Path.GetDirectoryName(curPath) ?? Environment.CurrentDirectory,
+ };
+ for (int i = 1; i < args.Length; i++) psi.ArgumentList.Add(args[i]);
+
+ log?.Invoke("[update] Restarting with the new launcher...");
+ Process.Start(psi);
+
+ // Tiny grace period so the spawn has actually entered the kernel before we
+ // pull our pid out from under it. 100 ms is more than enough.
+ await Task.Delay(100, CancellationToken.None);
+
+ // Hard exit. We don't go through Avalonia shutdown -- Application.Lifetime.Exit()
+ // can hang on window close handlers, and we DO want to release our exe handle
+ // promptly so the new process's CleanupAfterUpdate() can delete our .old file.
+ Environment.Exit(0);
+ }
+}