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