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:
Erik 2026-08-19 18:49:19 +02:00
parent 00d1278228
commit a01ff42640
9 changed files with 600 additions and 516 deletions

View file

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

View file

@ -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<CancellationToken, Task<bool>>? 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

View file

@ -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;
}
/// <summary>
/// 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.
/// </summary>
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);
}
}

View file

@ -47,8 +47,6 @@
<Button Content="Verify files"
AutomationProperties.Name="Verify installed client content"
Command="{Binding VerifyContentCommand}" />
<Button Content="Check for updates"
Command="{Binding UpdatePrompt.OpenCommand}" />
</StackPanel>
</Grid>
</Border>
@ -438,70 +436,47 @@
KeyDown="OnModalKeyDown"
AutomationProperties.Name="Update prompt modal dialog"
IsVisible="{Binding UpdatePrompt.IsOpen}">
<Border Classes="card" Width="640" HorizontalAlignment="Center" VerticalAlignment="Center">
<StackPanel Spacing="12">
<Border Classes="card" Width="520" HorizontalAlignment="Center" VerticalAlignment="Center">
<StackPanel Spacing="14">
<TextBlock Text="{Binding UpdatePrompt.Title}" FontSize="24" FontWeight="Bold" />
<TextBlock Text="{Binding UpdatePrompt.Body}" TextWrapping="Wrap" />
<Grid ColumnDefinitions="*,*" ColumnSpacing="16">
<StackPanel>
<TextBlock Text="Installed client" Classes="muted" />
<TextBlock Text="{Binding UpdatePrompt.CurrentClientVersion}" FontWeight="SemiBold" />
</StackPanel>
<StackPanel Grid.Column="1">
<TextBlock Text="Available release" Classes="muted" />
<TextBlock Text="{Binding UpdatePrompt.AvailableVersion}" FontWeight="SemiBold" />
</StackPanel>
</Grid>
<TextBlock Text="{Binding UpdatePrompt.MinimumLauncherStatus}"
TextWrapping="Wrap"
Classes="muted" />
<Border Background="#24344B" Padding="10" CornerRadius="5">
<Border Background="#24344B"
Padding="10"
CornerRadius="5"
IsVisible="{Binding UpdatePrompt.IsBusy}">
<StackPanel Spacing="8">
<TextBlock Text="{Binding UpdatePrompt.Status}" TextWrapping="Wrap" />
<ProgressBar Minimum="0"
Maximum="100"
Value="{Binding UpdatePrompt.ProgressPercent}"
IsIndeterminate="{Binding UpdatePrompt.IsProgressIndeterminate}"
IsVisible="{Binding UpdatePrompt.IsBusy}" />
IsIndeterminate="{Binding UpdatePrompt.IsProgressIndeterminate}" />
</StackPanel>
</Border>
<TextBlock Text="{Binding UpdatePrompt.Status}"
TextWrapping="Wrap"
Classes="muted"
IsVisible="{Binding !UpdatePrompt.IsBusy}" />
<Border Background="#5B2630"
Padding="10"
CornerRadius="5"
IsVisible="{Binding UpdatePrompt.HasError}">
<TextBlock Text="{Binding UpdatePrompt.Error}" TextWrapping="Wrap" />
</Border>
<Border Background="#365B32"
Padding="10"
CornerRadius="5"
IsVisible="{Binding UpdatePrompt.IsLauncherRestartRequired}">
<TextBlock Text="{Binding UpdatePrompt.LauncherRestartStatus}"
TextWrapping="Wrap"
FontWeight="SemiBold" />
</Border>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
<Button Content="Check again"
AutomationProperties.Name="Check for updates now"
Command="{Binding UpdatePrompt.CheckCommand}" />
<Button Content="Rollback client"
AutomationProperties.Name="Rollback client version"
Command="{Binding UpdatePrompt.RollbackCommand}" />
<Button Content="Stage launcher"
AutomationProperties.Name="Stage launcher self-update"
Command="{Binding UpdatePrompt.StageLauncherCommand}" />
<Button Content="Install client"
Classes="primary"
IsDefault="True"
AutomationProperties.Name="Install client update"
Command="{Binding UpdatePrompt.InstallClientCommand}" />
<Button Content="Cancel"
AutomationProperties.Name="Cancel update operation"
AutomationProperties.Name="Cancel update"
IsVisible="{Binding UpdatePrompt.IsBusy}"
Command="{Binding UpdatePrompt.CancelCommand}" />
<Button x:Name="UpdateCloseButton"
Content="Close"
Content="Not now"
IsCancel="True"
AutomationProperties.Name="Close update prompt"
Command="{Binding UpdatePrompt.CloseCommand}" />
AutomationProperties.Name="Skip this update"
Command="{Binding UpdatePrompt.NotNowCommand}" />
<Button Content="Update"
Classes="primary"
IsDefault="True"
AutomationProperties.Name="Install the update"
Command="{Binding UpdatePrompt.UpdateCommand}" />
</StackPanel>
</StackPanel>
</Border>

View file

@ -2,78 +2,105 @@ using AcDream.Launcher.Core.Updates;
namespace AcDream.Launcher.ViewModels;
/// <summary>
/// LU2/LU3: the launcher's ONE update question.
///
/// <para>On startup the feed is checked once. If nothing is out of date the
/// user never sees this at all. If something is, they get a single dialog
/// naming what is old and two buttons: <b>Update</b> and <b>Not now</b>. The
/// launcher is updated before the client (a client release can require a newer
/// launcher, which is what the manifest's minimum-launcher version means), and
/// a launcher update restarts the launcher into the new build by itself.</para>
///
/// <para>This replaces a panel with six buttons — Check again, Rollback
/// client, Stage launcher, Install client, Cancel, Close — plus an
/// installed/available version table and a "restart required" banner the user
/// had to act on. None of the safety underneath changed: manifest validation,
/// bounded verified download, safe extraction, versioned install with an
/// atomic pointer switch, the session barrier, and rollback all still live in
/// <c>AcDream.Launcher.Core/Updates</c>. Rollback simply has no button any
/// more; it remains reachable as Core API. The complexity the user objected to
/// was this surface, not the machinery.</para>
/// </summary>
public sealed class LauncherUpdateViewModel : ObservableObject, IDisposable
{
private readonly ILauncherUpdater _updater;
private readonly IUiDispatcher _dispatcher;
private readonly Action _onClientChanged;
private readonly Func<bool> _canOpen;
private readonly Func<bool> _canMutate;
private readonly Func<CancellationToken, Task<bool>>? _applyLauncherUpdateAsync;
private readonly Action? _requestShutdown;
private CancellationTokenSource? _cancellation;
private LauncherUpdateCheckResult? _check;
private bool _isOpen;
private bool _isBusy;
private bool _disposed;
private string _status = "No update check has run yet.";
private string _status = string.Empty;
private string? _error;
private LauncherUpdatePhase _phase = LauncherUpdatePhase.Idle;
private double _progressPercent;
private bool _isProgressIndeterminate;
private string? _launcherRestartStatus;
/// <param name="applyLauncherUpdateAsync">Applies an already-staged
/// launcher update against the running process, returning true when the
/// replacement helper started and this process must now exit. Null (tests,
/// and any host without a self-update target) degrades to telling the user
/// to restart, which the ordinary startup path then honours.</param>
/// <param name="requestShutdown">Closes the launcher so the helper can
/// replace files this process holds open.</param>
public LauncherUpdateViewModel(
ILauncherUpdater updater,
IUiDispatcher dispatcher,
Action onClientChanged,
Func<bool>? canOpen = null,
Func<bool>? canMutate = null)
Func<bool>? canMutate = null,
Func<CancellationToken, Task<bool>>? applyLauncherUpdateAsync = null,
Action? requestShutdown = null)
{
_updater = updater ?? throw new ArgumentNullException(nameof(updater));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
_onClientChanged = onClientChanged
?? throw new ArgumentNullException(nameof(onClientChanged));
_canOpen = canOpen ?? (() => true);
// Retained so the composition root's call shape is unchanged; there is
// no user-openable update panel any more, so nothing consults it.
_ = canOpen;
_canMutate = canMutate ?? (() => true);
_applyLauncherUpdateAsync = applyLauncherUpdateAsync;
_requestShutdown = requestShutdown;
OpenCommand = new AsyncRelayCommand(OpenAndCheckAsync, () => _canOpen() && !IsBusy);
CloseCommand = new RelayCommand(Close, () => !IsBusy);
CheckCommand = new AsyncRelayCommand(
() => CheckAsync(startup: false),
() => IsOpen && !IsBusy);
InstallClientCommand = new AsyncRelayCommand(
InstallClientAsync,
() => IsOpen
&& !IsBusy
&& _canMutate()
&& _check is
{
IsClientUpdateAvailable: true,
IsLauncherMinimumSatisfied: true,
});
StageLauncherCommand = new AsyncRelayCommand(
StageLauncherAsync,
() => IsOpen
&& !IsBusy
&& _canMutate()
&& !IsLauncherRestartRequired
&& _check is { IsLauncherUpdateAvailable: true });
RollbackCommand = new AsyncRelayCommand(
RollbackAsync,
() => IsOpen
&& !IsBusy
&& _canMutate()
&& !string.IsNullOrEmpty(_updater.CurrentClient.PreviousVersion));
UpdateCommand = new AsyncRelayCommand(
UpdateAsync,
() => IsOpen && !IsBusy && _canMutate() && HasSomethingToUpdate);
NotNowCommand = new RelayCommand(Close, () => !IsBusy);
CancelCommand = new RelayCommand(
() => _cancellation?.Cancel(),
() => IsBusy && _cancellation is not null);
}
public string Title => "Client and launcher updates";
public string Title => "Update available";
public string Body =>
"Releases are downloaded from the pinned eriknihlen/acdream GitHub feed. "
+ "Every archive is size/SHA-256 verified and safely extracted before "
+ "the active client pointer can change.";
/// <summary>What is out of date, in one sentence, in a player's words.</summary>
public string Body
{
get
{
if (_check is null)
{
return string.Empty;
}
string version = _check.Manifest.Version.Value;
if (_check.IsLauncherUpdateAvailable && _check.IsClientUpdateAvailable)
{
return $"A new version is available ({version}). The launcher "
+ "updates first and restarts itself, then the game updates.";
}
return _check.IsLauncherUpdateAvailable
? $"A new launcher is available ({version}). "
+ "It will restart itself once installed."
: $"A new version of the game is available ({version}).";
}
}
public bool IsOpen
{
@ -82,6 +109,7 @@ public sealed class LauncherUpdateViewModel : ObservableObject, IDisposable
{
if (SetProperty(ref _isOpen, value))
{
OnPropertyChanged(nameof(Body));
NotifyCommandStates();
}
}
@ -105,9 +133,17 @@ public sealed class LauncherUpdateViewModel : ObservableObject, IDisposable
public string Status
{
get => _status;
private set => SetProperty(ref _status, value);
private set
{
if (SetProperty(ref _status, value))
{
OnPropertyChanged(nameof(HasStatus));
}
}
}
public bool HasStatus => !string.IsNullOrWhiteSpace(Status);
public string? Error
{
get => _error;
@ -122,12 +158,6 @@ public sealed class LauncherUpdateViewModel : ObservableObject, IDisposable
public bool HasError => !string.IsNullOrWhiteSpace(Error);
public LauncherUpdatePhase Phase
{
get => _phase;
private set => SetProperty(ref _phase, value);
}
public double ProgressPercent
{
get => _progressPercent;
@ -140,63 +170,57 @@ public sealed class LauncherUpdateViewModel : ObservableObject, IDisposable
private set => SetProperty(ref _isProgressIndeterminate, value);
}
public string CurrentClientVersion =>
_updater.CurrentClient.Version?.Value ?? "not installed";
public string AvailableVersion => _check?.Manifest.Version.Value ?? "not checked";
public string CurrentLauncherVersion =>
_check?.LauncherVersion.Value ?? "loading";
public bool IsClientUpdateAvailable => _check?.IsClientUpdateAvailable == true;
public bool IsLauncherUpdateAvailable => _check?.IsLauncherUpdateAvailable == true;
public bool IsLauncherRestartRequired =>
!string.IsNullOrWhiteSpace(_launcherRestartStatus);
/// <summary>The single affirmative action. See <see cref="UpdateAsync"/>.</summary>
public AsyncRelayCommand UpdateCommand { get; }
public string LauncherRestartStatus => _launcherRestartStatus ?? string.Empty;
public bool IsLauncherMinimumBlocked => _check is
{
IsClientUpdateAvailable: true,
IsLauncherMinimumSatisfied: false,
};
public string MinimumLauncherStatus => _check is null
? string.Empty
: _check.IsLauncherMinimumSatisfied
? $"Launcher meets minimum {_check.Manifest.MinimumLauncherVersion}."
: $"Install launcher {_check.Manifest.MinimumLauncherVersion} or newer before the client update.";
public AsyncRelayCommand OpenCommand { get; }
public RelayCommand CloseCommand { get; }
public AsyncRelayCommand CheckCommand { get; }
public AsyncRelayCommand InstallClientCommand { get; }
public AsyncRelayCommand StageLauncherCommand { get; }
public AsyncRelayCommand RollbackCommand { get; }
public RelayCommand NotNowCommand { get; }
public RelayCommand CancelCommand { get; }
/// <summary>
/// Launch-time polling is deliberately nonfatal: an offline or malformed
/// feed changes only this status and never prevents profile/session use.
/// The one automatic check, at startup. Deliberately nonfatal: an offline
/// or malformed feed leaves the launcher fully usable and shows nothing —
/// a friend with no internet should still be able to play.
/// </summary>
public async Task StartupCheckAsync()
{
if (IsBusy || _disposed)
{
return;
}
using var cancellation = new CancellationTokenSource();
_cancellation = cancellation;
IsBusy = true;
try
{
await CheckAsync(startup: true).ConfigureAwait(true);
_check = await _updater.CheckAsync(cancellation.Token).ConfigureAwait(true);
OnPropertyChanged(nameof(Body));
OnPropertyChanged(nameof(IsClientUpdateAvailable));
OnPropertyChanged(nameof(IsLauncherUpdateAvailable));
if (HasSomethingToUpdate)
{
IsOpen = true;
}
}
catch
{
// CheckAsync owns visible state and never lets startup polling
// escape into the Avalonia initialization transaction.
// Nothing to say and nothing to do: staying quiet is the correct
// behavior for "could not reach the update feed" at startup.
_check = null;
}
finally
{
if (ReferenceEquals(_cancellation, cancellation))
{
_cancellation = null;
}
IsBusy = false;
}
}
@ -210,12 +234,8 @@ public sealed class LauncherUpdateViewModel : ObservableObject, IDisposable
public void NotifyCommandStates()
{
OpenCommand.NotifyCanExecuteChanged();
CloseCommand.NotifyCanExecuteChanged();
CheckCommand.NotifyCanExecuteChanged();
InstallClientCommand.NotifyCanExecuteChanged();
StageLauncherCommand.NotifyCanExecuteChanged();
RollbackCommand.NotifyCanExecuteChanged();
UpdateCommand.NotifyCanExecuteChanged();
NotNowCommand.NotifyCanExecuteChanged();
CancelCommand.NotifyCanExecuteChanged();
}
@ -232,143 +252,17 @@ public sealed class LauncherUpdateViewModel : ObservableObject, IDisposable
_cancellation = null;
}
private async Task OpenAndCheckAsync()
{
IsOpen = true;
await CheckAsync(startup: false).ConfigureAwait(true);
}
private bool HasSomethingToUpdate =>
_check is { IsClientUpdateAvailable: true } or { IsLauncherUpdateAvailable: true };
private async Task CheckAsync(bool startup)
{
if (IsBusy || _disposed)
{
return;
}
using var cancellation = new CancellationTokenSource();
_cancellation = cancellation;
IsBusy = true;
Error = null;
Phase = LauncherUpdatePhase.Checking;
Status = "Checking the pinned GitHub release manifest...";
IsProgressIndeterminate = true;
ProgressPercent = 0;
try
{
_check = await _updater.CheckAsync(cancellation.Token)
.ConfigureAwait(true);
Status = _check.Status;
Phase = LauncherUpdatePhase.Completed;
RefreshVersionProperties();
if (startup
&& (_check.IsClientUpdateAvailable
|| _check.IsLauncherUpdateAvailable))
{
IsOpen = true;
}
}
catch (OperationCanceledException)
{
Status = "Update check cancelled.";
Phase = LauncherUpdatePhase.Cancelled;
}
catch (Exception ex)
{
string detail = SafeDisplayError(ex);
Status = startup
? $"Automatic update check unavailable; continuing offline. {detail}"
: "Update check failed.";
Error = startup ? null : detail;
Phase = LauncherUpdatePhase.Failed;
}
finally
{
IsProgressIndeterminate = false;
if (ReferenceEquals(_cancellation, cancellation))
{
_cancellation = null;
}
IsBusy = false;
}
}
private Task InstallClientAsync() => RunMutationAsync(
(check, progress, token) => _updater.InstallClientAsync(check, progress, token),
"Installing the client update...",
"Client update installed and activated.",
clientChanged: true);
private Task StageLauncherAsync() => RunMutationAsync(
async (check, progress, token) =>
{
SelfUpdateStageResult staged = await _updater
.StageLauncherAsync(check, progress, token)
.ConfigureAwait(true);
_launcherRestartStatus = staged.Status;
OnPropertyChanged(nameof(IsLauncherRestartRequired));
OnPropertyChanged(nameof(LauncherRestartStatus));
return _updater.CurrentClient;
},
"Staging the launcher update...",
"Launcher update staged; restart the launcher to apply it.",
clientChanged: false);
private async Task RollbackAsync()
{
if (IsBusy || _disposed)
{
return;
}
using var cancellation = new CancellationTokenSource();
_cancellation = cancellation;
IsBusy = true;
Error = null;
Status = "Rolling back the client...";
IsProgressIndeterminate = true;
try
{
var progress = new UiProgress<LauncherUpdateProgress>(
_dispatcher,
ApplyProgress);
_ = await _updater.RollbackClientAsync(progress, cancellation.Token)
.ConfigureAwait(true);
_onClientChanged();
RefreshVersionProperties();
}
catch (OperationCanceledException)
{
Status = "Rollback cancelled; the active version was not changed.";
Phase = LauncherUpdatePhase.Cancelled;
}
catch (Exception ex)
{
Error = SafeDisplayError(ex);
Status = "Rollback failed.";
Phase = LauncherUpdatePhase.Failed;
}
finally
{
IsProgressIndeterminate = false;
if (ReferenceEquals(_cancellation, cancellation))
{
_cancellation = null;
}
IsBusy = false;
}
}
private async Task RunMutationAsync(
Func<
LauncherUpdateCheckResult,
IProgress<LauncherUpdateProgress>,
CancellationToken,
Task<ClientVersionResolution>> operation,
string initialStatus,
string completedStatus,
bool clientChanged)
/// <summary>
/// Launcher first, then client. A client release may declare a minimum
/// launcher version, so updating the launcher first is what makes the
/// client update installable at all — and it means the user is never told
/// "install a newer launcher before the client update", which is a
/// sentence a player should never have to read.
/// </summary>
private async Task UpdateAsync()
{
if (IsBusy || _disposed || _check is null)
{
@ -379,57 +273,62 @@ public sealed class LauncherUpdateViewModel : ObservableObject, IDisposable
_cancellation = cancellation;
IsBusy = true;
Error = null;
Status = initialStatus;
IsProgressIndeterminate = true;
ProgressPercent = 0;
IsProgressIndeterminate = true;
try
{
var progress = new UiProgress<LauncherUpdateProgress>(
_dispatcher,
ApplyProgress);
_ = await operation(_check, progress, cancellation.Token)
.ConfigureAwait(true);
if (clientChanged)
{
_onClientChanged();
}
RefreshVersionProperties();
Phase = LauncherUpdatePhase.Completed;
Status = IsLauncherRestartRequired
? LauncherRestartStatus
: completedStatus;
try
if (_check.IsLauncherUpdateAvailable)
{
_check = await _updater.CheckAsync(CancellationToken.None)
Status = "Downloading the new launcher…";
_ = await _updater
.StageLauncherAsync(_check, progress, cancellation.Token)
.ConfigureAwait(true);
if (!IsLauncherRestartRequired)
Status = "Restarting the launcher…";
bool restarting = _applyLauncherUpdateAsync is not null
&& await _applyLauncherUpdateAsync(cancellation.Token)
.ConfigureAwait(true);
if (restarting)
{
Status = _check.Status;
// The helper is waiting on THIS process and cannot replace
// a file we still hold open. Leaving the dialog busy is
// correct: there is nothing else for the user to do.
_requestShutdown?.Invoke();
return;
}
RefreshVersionProperties();
}
catch (Exception ex)
{
Status += " Release status refresh is unavailable: "
+ SafeDisplayError(ex);
Status = "The launcher update is ready. Close and reopen the "
+ "launcher to finish it.";
IsProgressIndeterminate = false;
return;
}
Status = "Downloading the game update…";
_ = await _updater
.InstallClientAsync(_check, progress, cancellation.Token)
.ConfigureAwait(true);
_onClientChanged();
Status = "Update installed.";
IsProgressIndeterminate = false;
IsOpen = false;
}
catch (OperationCanceledException)
{
Status = "Update operation cancelled; published state was not changed.";
Phase = LauncherUpdatePhase.Cancelled;
Status = "Update cancelled.";
IsProgressIndeterminate = false;
}
catch (Exception ex)
{
Error = SafeDisplayError(ex);
Status = "Update operation failed.";
Phase = LauncherUpdatePhase.Failed;
Status = "The update could not be installed.";
IsProgressIndeterminate = false;
}
finally
{
IsProgressIndeterminate = false;
if (ReferenceEquals(_cancellation, cancellation))
{
_cancellation = null;
@ -439,32 +338,26 @@ public sealed class LauncherUpdateViewModel : ObservableObject, IDisposable
}
}
private void ApplyProgress(LauncherUpdateProgress value)
private void ApplyProgress(LauncherUpdateProgress progress)
{
Phase = value.Phase;
Status = value.Status;
ProgressPercent = value.Percent;
IsProgressIndeterminate = value.Total <= 0
&& value.Phase is not (
LauncherUpdatePhase.Completed
or LauncherUpdatePhase.Cancelled
or LauncherUpdatePhase.Failed);
}
private void RefreshVersionProperties()
{
OnPropertyChanged(nameof(CurrentClientVersion));
OnPropertyChanged(nameof(AvailableVersion));
OnPropertyChanged(nameof(CurrentLauncherVersion));
OnPropertyChanged(nameof(IsClientUpdateAvailable));
OnPropertyChanged(nameof(IsLauncherUpdateAvailable));
OnPropertyChanged(nameof(IsLauncherRestartRequired));
OnPropertyChanged(nameof(LauncherRestartStatus));
OnPropertyChanged(nameof(IsLauncherMinimumBlocked));
OnPropertyChanged(nameof(MinimumLauncherStatus));
NotifyCommandStates();
Status = progress.Status;
if (progress.Total > 0)
{
IsProgressIndeterminate = false;
ProgressPercent = progress.Percent;
}
else
{
IsProgressIndeterminate = true;
}
}
/// <summary>
/// Update failures reach the user as a message, so the message must not
/// carry anything private. Update exceptions quote URLs, sizes, digests,
/// and paths — never credentials — and this keeps the shape narrow by
/// refusing to print exception types or stacks.
/// </summary>
private static string SafeDisplayError(Exception exception) =>
string.IsNullOrWhiteSpace(exception.Message)
? "The update operation failed."

View file

@ -28,7 +28,9 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
ILauncherOrchestrator orchestrator,
IUiDispatcher dispatcher,
ILauncherInstaller? installer = null,
ILauncherUpdater? updater = null)
ILauncherUpdater? updater = null,
Func<CancellationToken, Task<bool>>? applyLauncherUpdateAsync = null,
Action? requestShutdown = null)
{
_orchestrator = orchestrator ?? throw new ArgumentNullException(nameof(orchestrator));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
@ -47,7 +49,9 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
dispatcher,
OnClientVersionChanged,
() => CanInteract,
() => !IsBusy && Sessions.All(session => !session.IsActive));
() => !IsBusy && Sessions.All(session => !session.IsActive),
applyLauncherUpdateAsync,
requestShutdown);
EditorDialog.PropertyChanged += OnModalPropertyChanged;
FirstRunWizardShell.PropertyChanged += OnModalPropertyChanged;

View file

@ -3,176 +3,212 @@ using AcDream.Launcher.ViewModels;
namespace AcDream.Launcher.Tests;
/// <summary>
/// LU2/LU3. The launcher asks about updates exactly once, at startup, and only
/// when something is actually out of date. These tests pin that shape and the
/// launcher-before-client ordering; the six-button panel they replace (Check
/// again / Rollback / Stage launcher / Install client / Cancel / Close) is
/// gone, so the tests for it are gone with it rather than skipped.
/// </summary>
public sealed class LauncherUpdateViewModelTests
{
[Fact]
public async Task StartupPollingIsOfflineTolerantAndDoesNotOpenErrorModal()
public async Task NothingOutOfDateNeverShowsTheDialog()
{
var updater = new FakeUpdater
{
ClientUpdateAvailable = false,
LauncherUpdateAvailable = false,
};
using LauncherUpdateViewModel viewModel = Create(updater);
await viewModel.StartupCheckAsync();
Assert.False(viewModel.IsOpen);
Assert.Equal(0, updater.InstallCalls);
Assert.Equal(0, updater.StageCalls);
}
[Fact]
public async Task AClientUpdateAsksOnceAndInstallsOnUpdate()
{
var updater = new FakeUpdater
{
ClientUpdateAvailable = true,
LauncherUpdateAvailable = false,
};
int clientChanged = 0;
using LauncherUpdateViewModel viewModel = Create(
updater,
onClientChanged: () => clientChanged++);
await viewModel.StartupCheckAsync();
Assert.True(viewModel.IsOpen);
Assert.Contains("game", viewModel.Body, StringComparison.OrdinalIgnoreCase);
Assert.Contains("2.0.0", viewModel.Body, StringComparison.Ordinal);
await viewModel.UpdateCommand.ExecuteAsync();
Assert.Equal(1, updater.InstallCalls);
Assert.Equal(0, updater.StageCalls);
Assert.Equal(1, clientChanged);
Assert.False(viewModel.IsOpen);
Assert.Null(viewModel.Error);
}
/// <summary>
/// The launcher goes first even when both are behind: a client release can
/// declare a minimum launcher version, so updating the launcher first is
/// what makes the client update installable at all.
/// </summary>
[Fact]
public async Task ALauncherUpdateStagesThenRestartsWithoutTouchingTheClient()
{
var updater = new FakeUpdater
{
ClientUpdateAvailable = true,
LauncherUpdateAvailable = true,
};
int shutdowns = 0;
int applyCalls = 0;
using LauncherUpdateViewModel viewModel = Create(
updater,
applyLauncherUpdateAsync: _ =>
{
applyCalls++;
return Task.FromResult(true);
},
requestShutdown: () => shutdowns++);
await viewModel.StartupCheckAsync();
Assert.True(viewModel.IsOpen);
await viewModel.UpdateCommand.ExecuteAsync();
Assert.Equal(1, updater.StageCalls);
Assert.Equal(1, applyCalls);
Assert.Equal(1, shutdowns);
// The client is deliberately NOT touched in the same pass: the updated
// launcher checks again on its own next start.
Assert.Equal(0, updater.InstallCalls);
}
[Fact]
public async Task ALauncherUpdateThatCannotRestartTellsTheUserToReopen()
{
var updater = new FakeUpdater
{
ClientUpdateAvailable = false,
LauncherUpdateAvailable = true,
};
int shutdowns = 0;
using LauncherUpdateViewModel viewModel = Create(
updater,
applyLauncherUpdateAsync: _ => Task.FromResult(false),
requestShutdown: () => shutdowns++);
await viewModel.StartupCheckAsync();
await viewModel.UpdateCommand.ExecuteAsync();
Assert.Equal(1, updater.StageCalls);
Assert.Equal(0, shutdowns);
Assert.Contains("reopen", viewModel.Status, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// A friend with no internet must still reach their characters. An
/// unreachable feed is not an error the user has to dismiss.
/// </summary>
[Fact]
public async Task AnUnreachableFeedStaysSilent()
{
var updater = new FakeUpdater
{
CheckHandler = _ => Task.FromException<LauncherUpdateCheckResult>(
new LauncherUpdateException("fixture offline")),
new LauncherUpdateException("The update feed is unreachable.")),
};
using var viewModel = Create(updater);
using LauncherUpdateViewModel viewModel = Create(updater);
await viewModel.StartupCheckAsync();
Assert.False(viewModel.IsOpen);
Assert.False(viewModel.HasError);
Assert.Contains("continuing offline", viewModel.Status, StringComparison.OrdinalIgnoreCase);
Assert.Equal(LauncherUpdatePhase.Failed, viewModel.Phase);
}
[Fact]
public async Task StartupUpdateOpensModalAndClientInstallProjectsProgressAndRefreshesVersions()
public async Task NotNowClosesWithoutUpdatingAnything()
{
var updater = new FakeUpdater();
int changed = 0;
using var viewModel = Create(updater, () => changed++);
var updater = new FakeUpdater { ClientUpdateAvailable = true };
using LauncherUpdateViewModel viewModel = Create(updater);
await viewModel.StartupCheckAsync();
Assert.True(viewModel.IsOpen);
viewModel.NotNowCommand.Execute(null);
Assert.False(viewModel.IsOpen);
Assert.Equal(0, updater.InstallCalls);
Assert.Equal(0, updater.StageCalls);
}
[Fact]
public async Task UpdatingIsRefusedWhileASessionIsRunning()
{
var updater = new FakeUpdater { ClientUpdateAvailable = true };
using LauncherUpdateViewModel viewModel = Create(updater, canMutate: () => false);
await viewModel.StartupCheckAsync();
Assert.True(viewModel.IsOpen);
Assert.Equal("2.0.0", viewModel.AvailableVersion);
Assert.Equal("1.0.0", viewModel.CurrentClientVersion);
Assert.True(viewModel.InstallClientCommand.CanExecute(null));
await viewModel.InstallClientCommand.ExecuteAsync();
Assert.Equal(1, updater.InstallCalls);
Assert.Equal(1, changed);
Assert.Equal("2.0.0", viewModel.CurrentClientVersion);
Assert.False(viewModel.IsClientUpdateAvailable);
Assert.Equal(100, viewModel.ProgressPercent);
Assert.False(viewModel.HasError);
Assert.False(viewModel.UpdateCommand.CanExecute(null));
}
[Fact]
public async Task ManualCheckShowsErrorsAndCanRetrySuccessfully()
{
var updater = new FakeUpdater();
int calls = 0;
updater.CheckHandler = _ => ++calls == 1
? Task.FromException<LauncherUpdateCheckResult>(
new LauncherUpdateException("malformed fixture manifest"))
: Task.FromResult(updater.CreateCheck());
using var viewModel = Create(updater);
await viewModel.OpenCommand.ExecuteAsync();
Assert.True(viewModel.IsOpen);
Assert.True(viewModel.HasError);
Assert.Contains("malformed", viewModel.Error, StringComparison.Ordinal);
await viewModel.CheckCommand.ExecuteAsync();
Assert.False(viewModel.HasError);
Assert.Equal(2, calls);
}
[Fact]
public async Task MinimumLauncherGateDisablesClientButAllowsVerifiedSelfUpdateStage()
public async Task AFailedInstallReportsTheReasonAndLeavesTheDialogOpen()
{
var updater = new FakeUpdater
{
MinimumSatisfied = false,
ClientUpdateAvailable = true,
InstallHandler = (_, _) => Task.FromException<ClientVersionResolution>(
new LauncherUpdateException("The download did not match its digest.")),
};
using var viewModel = Create(updater);
using LauncherUpdateViewModel viewModel = Create(updater);
await viewModel.OpenCommand.ExecuteAsync();
await viewModel.StartupCheckAsync();
await viewModel.UpdateCommand.ExecuteAsync();
Assert.True(viewModel.IsLauncherMinimumBlocked);
Assert.False(viewModel.InstallClientCommand.CanExecute(null));
Assert.True(viewModel.StageLauncherCommand.CanExecute(null));
await viewModel.StageLauncherCommand.ExecuteAsync();
Assert.Equal(1, updater.StageCalls);
Assert.True(viewModel.IsLauncherRestartRequired);
Assert.Contains("next start", viewModel.LauncherRestartStatus, StringComparison.Ordinal);
Assert.False(viewModel.StageLauncherCommand.CanExecute(null));
Assert.False(viewModel.HasError);
}
[Fact]
public async Task MutationPermissionDisablesInstallStageAndRollbackWhileSessionsRun()
{
var updater = new FakeUpdater();
using var viewModel = Create(updater, canMutate: () => false);
await viewModel.OpenCommand.ExecuteAsync();
Assert.False(viewModel.InstallClientCommand.CanExecute(null));
Assert.False(viewModel.StageLauncherCommand.CanExecute(null));
Assert.False(viewModel.RollbackCommand.CanExecute(null));
Assert.True(viewModel.CheckCommand.CanExecute(null));
}
[Fact]
public async Task CancellationAndRollbackHaveExplicitSafeTerminalStates()
{
var updater = new FakeUpdater();
updater.InstallHandler = async (progress, token) =>
{
progress?.Report(new LauncherUpdateProgress(
LauncherUpdatePhase.DownloadingClient,
"downloading",
1,
100));
await Task.Delay(Timeout.InfiniteTimeSpan, token);
return updater.CurrentClient;
};
int changed = 0;
using var viewModel = Create(updater, () => changed++);
await viewModel.OpenCommand.ExecuteAsync();
Task install = viewModel.InstallClientCommand.ExecuteAsync();
await WaitUntilAsync(() => viewModel.IsBusy);
Assert.True(viewModel.CancelCommand.CanExecute(null));
viewModel.CancelCommand.Execute(null);
await install;
Assert.Equal(LauncherUpdatePhase.Cancelled, viewModel.Phase);
Assert.Contains("cancelled", viewModel.Status, StringComparison.OrdinalIgnoreCase);
Assert.False(viewModel.HasError);
await viewModel.RollbackCommand.ExecuteAsync();
Assert.Equal(1, updater.RollbackCalls);
Assert.Equal("0.9.0", viewModel.CurrentClientVersion);
Assert.Equal(1, changed);
Assert.True(viewModel.IsOpen);
Assert.True(viewModel.HasError);
Assert.Contains("digest", viewModel.Error!, StringComparison.Ordinal);
}
private static LauncherUpdateViewModel Create(
FakeUpdater updater,
Action? changed = null,
Func<bool>? canMutate = null) => new(
Action? onClientChanged = null,
Func<bool>? canMutate = null,
Func<CancellationToken, Task<bool>>? applyLauncherUpdateAsync = null,
Action? requestShutdown = null) =>
new(
updater,
new ImmediateUiDispatcher(),
changed ?? (() => { }),
onClientChanged ?? (() => { }),
canOpen: () => true,
canMutate: canMutate ?? (() => true));
private static async Task WaitUntilAsync(Func<bool> condition)
{
DateTimeOffset deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(5);
while (!condition())
{
if (DateTimeOffset.UtcNow >= deadline)
{
throw new TimeoutException("View model did not enter the expected state.");
}
await Task.Delay(10);
}
}
canMutate: canMutate ?? (() => true),
applyLauncherUpdateAsync: applyLauncherUpdateAsync,
requestShutdown: requestShutdown);
private sealed class FakeUpdater : ILauncherUpdater
{
private static readonly LauncherVersion One = LauncherVersion.Parse("1.0.0");
private static readonly LauncherVersion Two = LauncherVersion.Parse("2.0.0");
private static readonly LauncherVersion NineTenths = LauncherVersion.Parse("0.9.0");
public FakeUpdater()
{
CurrentClient = Resolution(One, "0.9.0");
}
public ClientVersionResolution CurrentClient { get; private set; } =
Resolution(One);
public ClientVersionResolution CurrentClient { get; private set; }
public bool ClientUpdateAvailable { get; init; }
public bool LauncherUpdateAvailable { get; init; }
public bool MinimumSatisfied { get; init; } = true;
@ -180,21 +216,20 @@ public sealed class LauncherUpdateViewModelTests
public int StageCalls { get; private set; }
public int RollbackCalls { get; private set; }
public Func<CancellationToken, Task<LauncherUpdateCheckResult>>? CheckHandler
{
get;
set;
init;
}
public Func<
IProgress<LauncherUpdateProgress>?,
CancellationToken,
Task<ClientVersionResolution>>? InstallHandler { get; set; }
Task<ClientVersionResolution>>? InstallHandler { get; init; }
public Task<ClientVersionResolution> InitializeAsync(
CancellationToken cancellationToken = default) => Task.FromResult(CurrentClient);
CancellationToken cancellationToken = default) =>
Task.FromResult(CurrentClient);
public Task<LauncherUpdateCheckResult> CheckAsync(
CancellationToken cancellationToken = default) =>
@ -216,12 +251,7 @@ public sealed class LauncherUpdateViewModelTests
"Downloading fixture.",
5,
10));
CurrentClient = Resolution(Two, "1.0.0");
progress?.Report(new LauncherUpdateProgress(
LauncherUpdatePhase.Completed,
"Installed fixture.",
1,
1));
CurrentClient = Resolution(Two);
return CurrentClient;
}
@ -239,26 +269,16 @@ public sealed class LauncherUpdateViewModelTests
return Task.FromResult(new SelfUpdateStageResult(
Two,
"pending.json",
"Launcher staged for next start."));
"Launcher staged."));
}
public Task<ClientVersionResolution> RollbackClientAsync(
IProgress<LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default)
{
RollbackCalls++;
CurrentClient = Resolution(NineTenths, "1.0.0");
progress?.Report(new LauncherUpdateProgress(
LauncherUpdatePhase.Completed,
"Rolled back fixture.",
1,
1));
return Task.FromResult(CurrentClient);
}
CancellationToken cancellationToken = default) =>
Task.FromResult(CurrentClient);
public LauncherUpdateCheckResult CreateCheck()
private LauncherUpdateCheckResult CreateCheck()
{
bool available = CurrentClient.Version! < Two;
var artifact = new ReleaseArtifact(
new Uri("https://example.test/release.zip"),
new string('a', 64),
@ -273,20 +293,19 @@ public sealed class LauncherUpdateViewModelTests
"win-x64",
One,
CurrentClient.Version,
available,
true,
ClientUpdateAvailable,
LauncherUpdateAvailable,
MinimumSatisfied,
available ? "Fixture update available." : "Fixture is current.");
"Fixture check.");
}
private static ClientVersionResolution Resolution(
LauncherVersion version,
string? previous) => new(
private static ClientVersionResolution Resolution(LauncherVersion version) =>
new(
ClientVersionState.Verified,
"Fixture client verified.",
version,
Path.Combine("fixture", version.Value),
previous,
null,
null);
}
}

View file

@ -34,7 +34,10 @@ public sealed class LauncherWindowViewModelTests
Assert.True(viewModel.IsFirstRunRequired);
Assert.Contains("SHA-256", viewModel.FirstRunWizardShell.Body, StringComparison.Ordinal);
Assert.Contains("pinned eriknihlen/acdream", viewModel.UpdatePrompt.Body, StringComparison.Ordinal);
// LU3: the update question says nothing and shows nothing until the
// startup check actually finds something out of date.
Assert.False(viewModel.UpdatePrompt.IsOpen);
Assert.Empty(viewModel.UpdatePrompt.Body);
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
Assert.True(viewModel.FirstRunWizardShell.IsOpen);
viewModel.FirstRunWizardShell.CloseCommand.Execute(null);
@ -306,7 +309,9 @@ public sealed class LauncherWindowViewModelTests
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
Assert.True(viewModel.IsModalOpen);
Assert.False(viewModel.AddServerCommand.CanExecute(null));
Assert.False(viewModel.UpdatePrompt.OpenCommand.CanExecute(null));
// LU3: there is no user-openable update panel any more — the update
// question only ever appears by itself, at startup.
Assert.False(viewModel.UpdatePrompt.IsOpen);
Assert.False(Assert.Single(viewModel.Sessions).StopCommand.CanExecute(null));
// ICommand.Execute cannot bypass the modal gate.

View file

@ -225,20 +225,23 @@ public sealed class MainWindowViewTests
private static async Task OpeningAndClosingTheUpdatePromptFocusesAndRunsTheCloseFallbackWithoutThrowing()
{
using LauncherWindowViewModel viewModel = CreateViewModel();
// LU3: the update question has no Open command any more — it appears
// by itself, once, when the startup check finds something out of date.
using LauncherWindowViewModel viewModel = CreateViewModel(
new UpdateAvailableUpdater());
var window = new MainWindow { DataContext = viewModel };
try
{
window.Show();
await viewModel.UpdatePrompt.OpenCommand.ExecuteAsync();
await viewModel.UpdatePrompt.StartupCheckAsync();
Assert.True(viewModel.UpdatePrompt.IsOpen);
Dispatcher.UIThread.RunJobs();
Control closeButton = (Control)GetNamedField(window, "UpdateCloseButton")!;
Assert.Same(closeButton, CurrentFocus(window));
viewModel.UpdatePrompt.CloseCommand.Execute(null);
viewModel.UpdatePrompt.NotNowCommand.Execute(null);
Assert.False(viewModel.UpdatePrompt.IsOpen);
// See the comment in the editor-kind theory above: this pump is
@ -315,15 +318,98 @@ public sealed class MainWindowViewTests
.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
?.GetValue(window);
private static LauncherWindowViewModel CreateViewModel()
private static LauncherWindowViewModel CreateViewModel(
AcDream.Launcher.Core.Updates.ILauncherUpdater? updater = null)
{
var viewModel = new LauncherWindowViewModel(
new StubOrchestrator(),
new ImmediateUiDispatcher());
new ImmediateUiDispatcher(),
installer: null,
updater: updater);
viewModel.Initialize();
return viewModel;
}
/// <summary>
/// LU3: the smallest updater that makes the startup check open the update
/// question, so the dialog's focus behavior stays covered now that it has
/// no Open command.
/// </summary>
private sealed class UpdateAvailableUpdater
: AcDream.Launcher.Core.Updates.ILauncherUpdater
{
private static readonly AcDream.Launcher.Core.Updates.LauncherVersion One =
AcDream.Launcher.Core.Updates.LauncherVersion.Parse("1.0.0");
private static readonly AcDream.Launcher.Core.Updates.LauncherVersion Two =
AcDream.Launcher.Core.Updates.LauncherVersion.Parse("2.0.0");
public AcDream.Launcher.Core.Updates.ClientVersionResolution CurrentClient =>
new(
AcDream.Launcher.Core.Updates.ClientVersionState.Verified,
"Fixture client verified.",
One,
Path.Combine("fixture", One.Value),
null,
null);
public Task<AcDream.Launcher.Core.Updates.ClientVersionResolution>
InitializeAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(CurrentClient);
public Task<AcDream.Launcher.Core.Updates.LauncherUpdateCheckResult> CheckAsync(
CancellationToken cancellationToken = default)
{
var artifact = new AcDream.Launcher.Core.Updates.ReleaseArtifact(
new Uri("https://example.test/release.zip"),
new string('a', 64),
100);
var manifest = new AcDream.Launcher.Core.Updates.ReleaseManifest(
Two,
One,
new Dictionary<string, AcDream.Launcher.Core.Updates.ReleaseArtifact>
{
["win-x64"] = artifact,
},
new Dictionary<string, AcDream.Launcher.Core.Updates.ReleaseArtifact>
{
["win-x64"] = artifact,
});
return Task.FromResult(
new AcDream.Launcher.Core.Updates.LauncherUpdateCheckResult(
manifest,
"win-x64",
One,
One,
IsClientUpdateAvailable: true,
IsLauncherUpdateAvailable: false,
IsLauncherMinimumSatisfied: true,
"Fixture update available."));
}
public Task<AcDream.Launcher.Core.Updates.ClientVersionResolution>
InstallClientAsync(
AcDream.Launcher.Core.Updates.LauncherUpdateCheckResult check,
IProgress<AcDream.Launcher.Core.Updates.LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default) =>
Task.FromResult(CurrentClient);
public Task<AcDream.Launcher.Core.Updates.SelfUpdateStageResult>
StageLauncherAsync(
AcDream.Launcher.Core.Updates.LauncherUpdateCheckResult check,
IProgress<AcDream.Launcher.Core.Updates.LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default) =>
Task.FromResult(new AcDream.Launcher.Core.Updates.SelfUpdateStageResult(
Two,
"pending.json",
"Launcher staged."));
public Task<AcDream.Launcher.Core.Updates.ClientVersionResolution>
RollbackClientAsync(
IProgress<AcDream.Launcher.Core.Updates.LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default) =>
Task.FromResult(CurrentClient);
}
private static string FindRepositoryRoot()
{
foreach (string start in new[] { AppContext.BaseDirectory, Environment.CurrentDirectory })