Files
Matt Sijbers f311429bc2 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/.
2026-05-20 22:07:03 +01:00

139 lines
5.8 KiB
C#

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);
}
}