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 @@