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 @@
-
@@ -438,70 +436,47 @@
KeyDown="OnModalKeyDown"
AutomationProperties.Name="Update prompt modal dialog"
IsVisible="{Binding UpdatePrompt.IsOpen}">
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+ IsIndeterminate="{Binding UpdatePrompt.IsProgressIndeterminate}" />
+
-
-
-
-
-
-
-
+ AutomationProperties.Name="Skip this update"
+ Command="{Binding UpdatePrompt.NotNowCommand}" />
+
diff --git a/src/AcDream.Launcher/ViewModels/LauncherUpdateViewModel.cs b/src/AcDream.Launcher/ViewModels/LauncherUpdateViewModel.cs
index c196cdd8..bdfb9a5b 100644
--- a/src/AcDream.Launcher/ViewModels/LauncherUpdateViewModel.cs
+++ b/src/AcDream.Launcher/ViewModels/LauncherUpdateViewModel.cs
@@ -2,78 +2,105 @@ using AcDream.Launcher.Core.Updates;
namespace AcDream.Launcher.ViewModels;
+///
+/// LU2/LU3: the launcher's ONE update question.
+///
+/// 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: Update and Not now. 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.
+///
+/// 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
+/// AcDream.Launcher.Core/Updates. 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.
+///
public sealed class LauncherUpdateViewModel : ObservableObject, IDisposable
{
private readonly ILauncherUpdater _updater;
private readonly IUiDispatcher _dispatcher;
private readonly Action _onClientChanged;
- private readonly Func _canOpen;
private readonly Func _canMutate;
+ private readonly Func>? _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;
+ /// 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.
+ /// Closes the launcher so the helper can
+ /// replace files this process holds open.
public LauncherUpdateViewModel(
ILauncherUpdater updater,
IUiDispatcher dispatcher,
Action onClientChanged,
Func? canOpen = null,
- Func? canMutate = null)
+ Func? canMutate = null,
+ Func>? 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.";
+ /// What is out of date, in one sentence, in a player's words.
+ 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);
+ /// The single affirmative action. See .
+ 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; }
///
- /// 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.
///
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(
- _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,
- CancellationToken,
- Task> operation,
- string initialStatus,
- string completedStatus,
- bool clientChanged)
+ ///
+ /// 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.
+ ///
+ 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(
_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;
+ }
}
+ ///
+ /// 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.
+ ///
private static string SafeDisplayError(Exception exception) =>
string.IsNullOrWhiteSpace(exception.Message)
? "The update operation failed."
diff --git a/src/AcDream.Launcher/ViewModels/LauncherWindowViewModel.cs b/src/AcDream.Launcher/ViewModels/LauncherWindowViewModel.cs
index 88b8ee11..75ec841b 100644
--- a/src/AcDream.Launcher/ViewModels/LauncherWindowViewModel.cs
+++ b/src/AcDream.Launcher/ViewModels/LauncherWindowViewModel.cs
@@ -28,7 +28,9 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
ILauncherOrchestrator orchestrator,
IUiDispatcher dispatcher,
ILauncherInstaller? installer = null,
- ILauncherUpdater? updater = null)
+ ILauncherUpdater? updater = null,
+ Func>? 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;
diff --git a/tests/AcDream.Launcher.Tests/LauncherUpdateViewModelTests.cs b/tests/AcDream.Launcher.Tests/LauncherUpdateViewModelTests.cs
index f953b64f..7dee88b8 100644
--- a/tests/AcDream.Launcher.Tests/LauncherUpdateViewModelTests.cs
+++ b/tests/AcDream.Launcher.Tests/LauncherUpdateViewModelTests.cs
@@ -3,176 +3,212 @@ using AcDream.Launcher.ViewModels;
namespace AcDream.Launcher.Tests;
+///
+/// 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.
+///
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);
+ }
+
+ ///
+ /// 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.
+ ///
+ [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);
+ }
+
+ ///
+ /// A friend with no internet must still reach their characters. An
+ /// unreachable feed is not an error the user has to dismiss.
+ ///
+ [Fact]
+ public async Task AnUnreachableFeedStaysSilent()
{
var updater = new FakeUpdater
{
CheckHandler = _ => Task.FromException(
- 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(
- 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(
+ 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? canMutate = null) => new(
+ Action? onClientChanged = null,
+ Func? canMutate = null,
+ Func>? applyLauncherUpdateAsync = null,
+ Action? requestShutdown = null) =>
+ new(
updater,
new ImmediateUiDispatcher(),
- changed ?? (() => { }),
+ onClientChanged ?? (() => { }),
canOpen: () => true,
- canMutate: canMutate ?? (() => true));
-
- private static async Task WaitUntilAsync(Func 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>? CheckHandler
{
get;
- set;
+ init;
}
public Func<
IProgress?,
CancellationToken,
- Task>? InstallHandler { get; set; }
+ Task>? InstallHandler { get; init; }
public Task InitializeAsync(
- CancellationToken cancellationToken = default) => Task.FromResult(CurrentClient);
+ CancellationToken cancellationToken = default) =>
+ Task.FromResult(CurrentClient);
public Task 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 RollbackClientAsync(
IProgress? 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);
}
}
diff --git a/tests/AcDream.Launcher.Tests/LauncherWindowViewModelTests.cs b/tests/AcDream.Launcher.Tests/LauncherWindowViewModelTests.cs
index e7adc45e..7bd44358 100644
--- a/tests/AcDream.Launcher.Tests/LauncherWindowViewModelTests.cs
+++ b/tests/AcDream.Launcher.Tests/LauncherWindowViewModelTests.cs
@@ -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.
diff --git a/tests/AcDream.Launcher.Tests/MainWindowViewTests.cs b/tests/AcDream.Launcher.Tests/MainWindowViewTests.cs
index 6228f64f..f9227e46 100644
--- a/tests/AcDream.Launcher.Tests/MainWindowViewTests.cs
+++ b/tests/AcDream.Launcher.Tests/MainWindowViewTests.cs
@@ -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;
}
+ ///
+ /// 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.
+ ///
+ 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
+ InitializeAsync(CancellationToken cancellationToken = default) =>
+ Task.FromResult(CurrentClient);
+
+ public Task 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
+ {
+ ["win-x64"] = artifact,
+ },
+ new Dictionary
+ {
+ ["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
+ InstallClientAsync(
+ AcDream.Launcher.Core.Updates.LauncherUpdateCheckResult check,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default) =>
+ Task.FromResult(CurrentClient);
+
+ public Task
+ StageLauncherAsync(
+ AcDream.Launcher.Core.Updates.LauncherUpdateCheckResult check,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default) =>
+ Task.FromResult(new AcDream.Launcher.Core.Updates.SelfUpdateStageResult(
+ Two,
+ "pending.json",
+ "Launcher staged."));
+
+ public Task
+ RollbackClientAsync(
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default) =>
+ Task.FromResult(CurrentClient);
+ }
+
private static string FindRepositoryRoot()
{
foreach (string start in new[] { AppContext.BaseDirectory, Environment.CurrentDirectory })