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