From a01ff42640c800a9c36ce03bc4caa4899bea5774 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 19 Aug 2026 18:49:19 +0200 Subject: [PATCH] =?UTF-8?q?feat(launcher):=20LU2/LU3=20=E2=80=94=20one=20u?= =?UTF-8?q?pdate=20question=20at=20startup,=20and=20it=20restarts=20itself?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The update surface was a panel the user had to reason about: Check again, Rollback client, Stage launcher, Install client, Cancel, Close, plus an installed/available version table, a minimum-launcher-version sentence, and a "restart required" banner they had to act on. Reaching it meant knowing to press "Check for updates" in the header. Now: the feed is checked once at startup. If nothing is out of date, nothing appears. If something is, one dialog says what is new and offers Update or Not now. Launcher before client, deliberately. A client release can declare a minimum launcher version, so updating the launcher first is what makes the client update installable at all — and it means nobody is ever shown "install launcher X or newer before the client update", which is not a sentence a player should have to read. A launcher update now restarts into the new build by itself. That reuses the existing, proven handoff rather than inventing a second one: LauncherSelfUpdate Bootstrap.TryApplyStagedUpdateNowAsync starts the staged payload in helper mode against the CURRENT process, exactly as ordinary startup does, and the launcher then shuts down. Restarting by spawning a fresh copy of the current launcher and letting its startup notice the staged plan would look simpler and be wrong: the helper would wait on the new copy while the old one still held its own executable mapped, so the file replacement could fail. The staged-helper launch is extracted into one private method both paths call, so they cannot drift. Deleted: the header "Check for updates" button, OpenCommand, CheckCommand, InstallClientCommand, StageLauncherCommand, RollbackCommand, CloseCommand, the version table, IsLauncherMinimumBlocked/MinimumLauncherStatus, the restart banner, and LauncherUpdatePhase plumbing through the view model. NOT deleted — none of the safety changed: manifest validation, bounded verified download, safe ZIP extraction, versioned install with an atomic current.json switch, the update session barrier, and rollback all still live in AcDream.Launcher.Core/Updates. Rollback simply has no button; it remains reachable as Core API with its own tests. The complexity the user objected to was the panel, not the machinery underneath it. An unreachable feed stays silent. A friend with no internet must still reach their characters, so a failed startup check shows nothing at all rather than an error to dismiss. Tests: LauncherUpdateViewModelTests rewritten against the new surface (8 tests — nothing-to-do stays silent, client update installs, launcher update stages then restarts without touching the client, no-restart-seam fallback, silent offline, Not now, refused while a session runs, failed install reports why). Tests for the deleted commands are removed with them, not skipped. Launcher 59 passed, Launcher.Core 335 passed. Campaign LU slices LU2 and LU3, landed together because the new prompt replaces the old one in the same files. Co-Authored-By: Claude Opus 5 --- .../Updates/LauncherSelfUpdateBootstrap.cs | 111 ++++- src/AcDream.Launcher/App.axaml.cs | 21 +- .../LauncherUpdateComposition.cs | 18 +- src/AcDream.Launcher/MainWindow.axaml | 67 +-- .../ViewModels/LauncherUpdateViewModel.cs | 451 +++++++----------- .../ViewModels/LauncherWindowViewModel.cs | 8 +- .../LauncherUpdateViewModelTests.cs | 335 +++++++------ .../LauncherWindowViewModelTests.cs | 9 +- .../MainWindowViewTests.cs | 96 +++- 9 files changed, 600 insertions(+), 516 deletions(-) diff --git a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs index 6decb4b9..9adf3f67 100644 --- a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs +++ b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs @@ -211,30 +211,101 @@ public static class LauncherSelfUpdateBootstrap $"Self-update state '{plan.State}' cannot start a helper."); } - string helperPath = manager.GetStagedLauncherPath(plan); - var startInfo = new ProcessStartInfo(helperPath) - { - UseShellExecute = false, - WorkingDirectory = manager.GetPayloadDirectory(plan.TransactionId), - }; - startInfo.ArgumentList.Add(HelperArgument); - startInfo.ArgumentList.Add( - Environment.ProcessId.ToString( - System.Globalization.CultureInfo.InvariantCulture)); - startInfo.ArgumentList.Add(baseDirectory); - startInfo.ArgumentList.Add(plan.TransactionId); - foreach (string argument in args) - { - startInfo.ArgumentList.Add(argument); - } - - _ = Process.Start(startInfo) - ?? throw new LauncherUpdateException( - "The launcher self-update helper could not be started."); + StartStagedHelper(manager, plan, baseDirectory, args); return new SelfUpdateStartupResult(true, 0, []); } } + /// + /// LU2: apply an already-staged launcher update NOW, from a running + /// launcher, instead of waiting for the next ordinary startup to notice it. + /// Returns true when the helper was started, in which case the caller MUST + /// exit promptly — the helper is waiting on THIS process id and cannot + /// replace files the running launcher still holds open. + /// + /// Deliberately the same handoff as the startup path rather than a + /// second mechanism: it starts the staged payload in helper mode against + /// the current process, so the helper waits for exactly the process that + /// locks the launcher executable, applies the replacement, and restarts + /// the updated launcher. Restarting by spawning a fresh copy of the + /// CURRENT launcher and letting its startup notice the plan would look + /// simpler and be wrong: the helper would then wait on the new copy while + /// the old one still held its own image mapped. + /// + /// Returns false, rather than throwing, when there is nothing staged + /// or another process holds the update barrier. Both mean "not now" — the + /// staged plan stays on disk and the ordinary startup path applies it. + /// + public static async Task TryApplyStagedUpdateNowAsync( + LauncherSelfUpdateManager manager, + string launcherBaseDirectory, + string currentExecutablePath, + IReadOnlyList publicArguments, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(manager); + ArgumentNullException.ThrowIfNull(publicArguments); + string baseDirectory = Path.TrimEndingDirectorySeparator( + Path.GetFullPath(launcherBaseDirectory)); + string executable = Path.GetFullPath(currentExecutablePath); + + if (!manager.Barrier.TryAcquireExclusive( + out UpdateSessionBarrier.ExclusiveLease? applyLease)) + { + return false; + } + + using (UpdateSessionBarrier.ExclusiveLease lease = applyLease + ?? throw new InvalidOperationException("Exclusive apply lease is missing.")) + { + SelfUpdatePlan? plan = await manager.LoadPendingAsync(cancellationToken) + .ConfigureAwait(false); + if (plan is null || plan.State != SelfUpdatePlanState.Staged) + { + return false; + } + + ValidateCanonicalStartup(plan, baseDirectory, executable); + StartStagedHelper(manager, plan, baseDirectory, publicArguments); + return true; + } + } + + /// + /// Starts the staged payload in helper mode against the CURRENT process. + /// Shared by the ordinary startup path and + /// so both hand off identically; + /// the helper's own trust checks () pin the + /// payload directory and executable it will accept. + /// + private static void StartStagedHelper( + LauncherSelfUpdateManager manager, + SelfUpdatePlan plan, + string baseDirectory, + IReadOnlyList publicArguments) + { + string helperPath = manager.GetStagedLauncherPath(plan); + var startInfo = new ProcessStartInfo(helperPath) + { + UseShellExecute = false, + WorkingDirectory = manager.GetPayloadDirectory(plan.TransactionId), + }; + startInfo.ArgumentList.Add(HelperArgument); + startInfo.ArgumentList.Add( + Environment.ProcessId.ToString( + System.Globalization.CultureInfo.InvariantCulture)); + startInfo.ArgumentList.Add(baseDirectory); + startInfo.ArgumentList.Add(plan.TransactionId); + foreach (string argument in publicArguments) + { + startInfo.ArgumentList.Add(argument); + } + + _ = Process.Start(startInfo) + ?? throw new LauncherUpdateException( + "The launcher self-update helper could not be started."); + } + private static async Task RunHelperAsync( LauncherSelfUpdateManager manager, string helperBaseDirectory, diff --git a/src/AcDream.Launcher/App.axaml.cs b/src/AcDream.Launcher/App.axaml.cs index b348cf07..73ed6bb3 100644 --- a/src/AcDream.Launcher/App.axaml.cs +++ b/src/AcDream.Launcher/App.axaml.cs @@ -84,11 +84,30 @@ public sealed partial class App : Application verification.Record, installationStatus: verification.Status, updateSessionBarrier: updates.Versions.Barrier); + // LU2: a launcher update installs and restarts by itself. The + // helper waits on THIS process id and cannot replace files the + // running launcher holds open, so applying and shutting down are + // one pair — see LauncherSelfUpdateBootstrap.TryApplyStagedUpdateNowAsync. + LauncherSelfUpdateManager? selfUpdates = updates.SelfUpdates; + Func>? applyLauncherUpdate = + selfUpdates is null + ? null + : token => LauncherSelfUpdateBootstrap.TryApplyStagedUpdateNowAsync( + selfUpdates, + AppContext.BaseDirectory, + Environment.ProcessPath + ?? throw new InvalidOperationException( + "The launcher executable path is unavailable."), + startupOptions.PublicArguments, + token); + _viewModel = new LauncherWindowViewModel( _orchestrator, new AvaloniaUiDispatcher(), installer, - updates.Updater); + updates.Updater, + applyLauncherUpdate, + () => desktop.Shutdown()); _viewModel.Initialize(); desktop.MainWindow = new MainWindow diff --git a/src/AcDream.Launcher/LauncherUpdateComposition.cs b/src/AcDream.Launcher/LauncherUpdateComposition.cs index 22af717f..e828c8b6 100644 --- a/src/AcDream.Launcher/LauncherUpdateComposition.cs +++ b/src/AcDream.Launcher/LauncherUpdateComposition.cs @@ -24,7 +24,8 @@ internal sealed class LauncherUpdateComposition : IDisposable ILauncherUpdater updater, Uri updateManifestUri, HttpClient? artifactClient, - ReleaseManifestClient? manifestClient) + ReleaseManifestClient? manifestClient, + LauncherSelfUpdateManager? selfUpdates) { Versions = versions; Executables = executables; @@ -32,8 +33,17 @@ internal sealed class LauncherUpdateComposition : IDisposable UpdateManifestUri = updateManifestUri; _artifactClient = artifactClient; _manifestClient = manifestClient; + SelfUpdates = selfUpdates; } + /// + /// LU2: needed so a running launcher can apply an already-staged launcher + /// update and restart into it, instead of telling the user to close and + /// reopen. Null only in the storage-failure branch below, where no update + /// can be staged in the first place. + /// + public LauncherSelfUpdateManager? SelfUpdates { get; } + public ClientVersionStore Versions { get; } public LauncherExecutableSet Executables { get; } @@ -93,7 +103,8 @@ internal sealed class LauncherUpdateComposition : IDisposable updater, manifestUri, artifactClient, - manifestClient); + manifestClient, + selfUpdates); } catch (Exception ex) when (IsStorageFailure(ex)) { @@ -116,7 +127,8 @@ internal sealed class LauncherUpdateComposition : IDisposable new UnavailableLauncherUpdater(status, resolution), manifestUri, artifactClient: null, - manifestClient: null); + manifestClient: null, + selfUpdates: null); } } diff --git a/src/AcDream.Launcher/MainWindow.axaml b/src/AcDream.Launcher/MainWindow.axaml index 9580ac42..f5029129 100644 --- a/src/AcDream.Launcher/MainWindow.axaml +++ b/src/AcDream.Launcher/MainWindow.axaml @@ -47,8 +47,6 @@