feat(launcher): LU2/LU3 — one update question at startup, and it restarts itself
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 <noreply@anthropic.com>
This commit is contained in:
parent
00d1278228
commit
a01ff42640
9 changed files with 600 additions and 516 deletions
|
|
@ -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, []);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
///
|
||||
/// <para>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.</para>
|
||||
///
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
public static async Task<bool> TryApplyStagedUpdateNowAsync(
|
||||
LauncherSelfUpdateManager manager,
|
||||
string launcherBaseDirectory,
|
||||
string currentExecutablePath,
|
||||
IReadOnlyList<string> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the staged payload in helper mode against the CURRENT process.
|
||||
/// Shared by the ordinary startup path and
|
||||
/// <see cref="TryApplyStagedUpdateNowAsync"/> so both hand off identically;
|
||||
/// the helper's own trust checks (<see cref="RunHelperAsync"/>) pin the
|
||||
/// payload directory and executable it will accept.
|
||||
/// </summary>
|
||||
private static void StartStagedHelper(
|
||||
LauncherSelfUpdateManager manager,
|
||||
SelfUpdatePlan plan,
|
||||
string baseDirectory,
|
||||
IReadOnlyList<string> 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<int> RunHelperAsync(
|
||||
LauncherSelfUpdateManager manager,
|
||||
string helperBaseDirectory,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue