launcher: in-place self-update via download + swap + restart

Adds Services/SelfUpdateService.cs implementing the Chrome-style pattern:
  1. Download new exe to "<current>.new"
  2. Rename running exe to "<current>.old" (NTFS allows MoveFile on a
     running exe -- it resolves processes by handle, not by path)
  3. Rename ".new" to canonical path
  4. Spawn the new exe with our argv
  5. Environment.Exit(0)
  6. Next start of the new exe runs CleanupAfterUpdate() in App.OFIC()
     which deletes the leftover ".old"

UI: the existing "Download" banner button is now "Install update". Click
runs the self-update flow with a percent/MB progress label; any failure
(network, AV write-block, dir not writable) falls back to opening the
URL in the browser so users always have a path forward.

Bumps to 0.4.7. Also fixes a stale fallback URL that pointed at
sijbers.uk/pack/... -- now correctly points at bns.sijbers.uk/launcher/.
This commit is contained in:
Matt Sijbers
2026-05-20 22:07:03 +01:00
parent ed70ff52e9
commit f311429bc2
5 changed files with 187 additions and 8 deletions
+6
View File
@@ -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();
+1 -1
View File
@@ -259,7 +259,7 @@
Foreground="#E8DFC8" FontSize="13" TextWrapping="Wrap"
Text="A newer launcher is available." />
<Button Grid.Column="2" Name="UpdateBannerDownloadButton"
Classes="secondary" Content="Download"
Classes="secondary" Content="Install update"
Click="OnUpdateBannerDownloadClick" />
</Grid>
</Border>
+41 -6
View File
@@ -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;
}
}
+1 -1
View File
@@ -10,7 +10,7 @@
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<RootNamespace>ModpackLauncher</RootNamespace>
<AssemblyName>ModpackLauncher</AssemblyName>
<Version>0.4.6</Version>
<Version>0.4.7</Version>
<ApplicationIcon Condition="Exists('Assets\icon.ico')">Assets\icon.ico</ApplicationIcon>
<!-- Single-file self-contained publish defaults (Windows-only now due to WebView2) -->
+138
View File
@@ -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;
/// <summary>
/// In-place launcher self-update. Pattern matches Chrome/VS Code/Discord:
/// 1. Download new exe to "<current>.new"
/// 2. Rename running exe to "<current>.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.
/// </summary>
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";
/// <summary>
/// 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).
/// </summary>
public static void CleanupAfterUpdate(Action<string>? 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.");
}
}
/// <summary>
/// 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.
/// </summary>
public async Task DownloadAndInstallAsync(
string downloadUrl,
IProgress<(long bytesReceived, long? totalBytes)>? progress = null,
Action<string>? 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);
}
}