feat(launcher): implement verified atomic updates
This commit is contained in:
parent
2198a0cc8e
commit
2d2a5b5046
34 changed files with 6755 additions and 61 deletions
|
|
@ -1,7 +1,9 @@
|
|||
using System.Reflection;
|
||||
using AcDream.Launcher.Core.Installation;
|
||||
using AcDream.Launcher.Core.Launching;
|
||||
using AcDream.Launcher.Core.Orchestration;
|
||||
using AcDream.Launcher.Core.Profiles;
|
||||
using AcDream.Launcher.Core.Updates;
|
||||
using AcDream.Launcher.ViewModels;
|
||||
using AcDream.Platform;
|
||||
using Avalonia;
|
||||
|
|
@ -14,6 +16,8 @@ public sealed partial class App : Application
|
|||
{
|
||||
private LauncherOrchestrator? _orchestrator;
|
||||
private LauncherWindowViewModel? _viewModel;
|
||||
private HttpClient? _updateHttpClient;
|
||||
private ReleaseManifestClient? _manifestClient;
|
||||
|
||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
|
|
@ -23,6 +27,7 @@ public sealed partial class App : Application
|
|||
{
|
||||
ApplicationPathSet paths = ApplicationPathSet.Resolve();
|
||||
LauncherProfileStore profiles = LauncherProfileStore.ForApplicationPaths(paths);
|
||||
string rid = LauncherRuntimeIdentity.DetectRid();
|
||||
string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty;
|
||||
var installer = new LauncherInstaller(
|
||||
paths,
|
||||
|
|
@ -47,16 +52,38 @@ public sealed partial class App : Application
|
|||
$"Client content verification failed: {ex.Message}");
|
||||
}
|
||||
|
||||
var clientVersions = new ClientVersionStore(paths);
|
||||
_ = clientVersions.LoadAndRecoverAsync(rid)
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
|
||||
_orchestrator = new LauncherOrchestrator(
|
||||
profiles,
|
||||
paths,
|
||||
LauncherExecutableSet.FromDirectory(AppContext.BaseDirectory),
|
||||
LauncherExecutableSet.FromCurrentVersionStore(clientVersions),
|
||||
verification.Record,
|
||||
installationStatus: verification.Status);
|
||||
installationStatus: verification.Status,
|
||||
updateSessionBarrier: clientVersions.Barrier);
|
||||
_updateHttpClient = new HttpClient();
|
||||
_updateHttpClient.Timeout = TimeSpan.FromSeconds(15);
|
||||
_updateHttpClient.DefaultRequestHeaders.UserAgent.ParseAdd(
|
||||
"acdream-launcher/1");
|
||||
_manifestClient = new ReleaseManifestClient(_updateHttpClient);
|
||||
var selfUpdates = new LauncherSelfUpdateManager(paths, _updateHttpClient);
|
||||
var updater = new LauncherUpdater(
|
||||
_manifestClient,
|
||||
_updateHttpClient,
|
||||
clientVersions,
|
||||
selfUpdates,
|
||||
GetLauncherVersion(),
|
||||
rid,
|
||||
AppContext.BaseDirectory,
|
||||
() => _orchestrator.GetSnapshot().Sessions.Any(session => session.IsActive));
|
||||
_viewModel = new LauncherWindowViewModel(
|
||||
_orchestrator,
|
||||
new AvaloniaUiDispatcher(),
|
||||
installer);
|
||||
installer,
|
||||
updater);
|
||||
_viewModel.Initialize();
|
||||
|
||||
desktop.MainWindow = new MainWindow
|
||||
|
|
@ -73,7 +100,25 @@ public sealed partial class App : Application
|
|||
{
|
||||
_viewModel?.Dispose();
|
||||
_orchestrator?.Dispose();
|
||||
_manifestClient?.Dispose();
|
||||
_updateHttpClient?.Dispose();
|
||||
_viewModel = null;
|
||||
_orchestrator = null;
|
||||
_manifestClient = null;
|
||||
_updateHttpClient = null;
|
||||
}
|
||||
|
||||
private static LauncherVersion GetLauncherVersion()
|
||||
{
|
||||
string? informationalVersion = typeof(App).Assembly
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
|
||||
.InformationalVersion;
|
||||
if (!LauncherVersion.TryParse(informationalVersion, out LauncherVersion? version))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Launcher informational version '{informationalVersion}' is not SemVer 2.0.");
|
||||
}
|
||||
|
||||
return version;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@
|
|||
<Button Content="First-run setup"
|
||||
Command="{Binding FirstRunWizardShell.OpenCommand}" />
|
||||
<Button Content="Check for updates"
|
||||
Command="{Binding UpdatePromptShell.OpenCommand}" />
|
||||
Command="{Binding UpdatePrompt.OpenCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
|
@ -434,21 +434,72 @@
|
|||
KeyboardNavigation.TabNavigation="Cycle"
|
||||
KeyDown="OnModalKeyDown"
|
||||
AutomationProperties.Name="Update prompt modal dialog"
|
||||
IsVisible="{Binding UpdatePromptShell.IsOpen}">
|
||||
<Border Classes="card" Width="560" HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
IsVisible="{Binding UpdatePrompt.IsOpen}">
|
||||
<Border Classes="card" Width="640" HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="{Binding UpdatePromptShell.Title}" FontSize="24" FontWeight="Bold" />
|
||||
<TextBlock Text="{Binding UpdatePromptShell.Body}" TextWrapping="Wrap" />
|
||||
<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">
|
||||
<TextBlock Text="{Binding UpdatePromptShell.Status}" TextWrapping="Wrap" />
|
||||
<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}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Button x:Name="UpdateCloseButton"
|
||||
Content="Close"
|
||||
HorizontalAlignment="Right"
|
||||
IsCancel="True"
|
||||
IsDefault="True"
|
||||
AutomationProperties.Name="Close update prompt"
|
||||
Command="{Binding UpdatePromptShell.CloseCommand}" />
|
||||
<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"
|
||||
Command="{Binding UpdatePrompt.CancelCommand}" />
|
||||
<Button x:Name="UpdateCloseButton"
|
||||
Content="Close"
|
||||
IsCancel="True"
|
||||
AutomationProperties.Name="Close update prompt"
|
||||
Command="{Binding UpdatePrompt.CloseCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Border>
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ public sealed partial class MainWindow : Window
|
|||
{
|
||||
FirstRunDatDirectoryTextBox.Focus();
|
||||
}
|
||||
else if (viewModel.UpdatePromptShell.IsOpen)
|
||||
else if (viewModel.UpdatePrompt.IsOpen)
|
||||
{
|
||||
UpdateCloseButton.Focus();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
using AcDream.Launcher.Core.Updates;
|
||||
using AcDream.Platform;
|
||||
using Avalonia;
|
||||
|
||||
namespace AcDream.Launcher;
|
||||
|
|
@ -15,7 +17,34 @@ internal static class Program
|
|||
return 0;
|
||||
}
|
||||
|
||||
return BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
try
|
||||
{
|
||||
ApplicationPathSet paths = ApplicationPathSet.Resolve();
|
||||
using var httpClient = new HttpClient();
|
||||
var selfUpdates = new LauncherSelfUpdateManager(paths, httpClient);
|
||||
string executable = Environment.ProcessPath
|
||||
?? throw new InvalidOperationException(
|
||||
"The launcher executable path is unavailable.");
|
||||
SelfUpdateStartupResult startup = LauncherSelfUpdateBootstrap.HandleAsync(
|
||||
args,
|
||||
selfUpdates,
|
||||
AppContext.BaseDirectory,
|
||||
executable)
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
if (startup.ShouldExit)
|
||||
{
|
||||
return startup.ExitCode;
|
||||
}
|
||||
|
||||
return BuildAvaloniaApp().StartWithClassicDesktopLifetime(
|
||||
startup.RemainingArguments);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Launcher startup failed safely: {ex.Message}");
|
||||
return 74;
|
||||
}
|
||||
}
|
||||
|
||||
public static AppBuilder BuildAvaloniaApp() =>
|
||||
|
|
|
|||
520
src/AcDream.Launcher/ViewModels/LauncherUpdateViewModel.cs
Normal file
520
src/AcDream.Launcher/ViewModels/LauncherUpdateViewModel.cs
Normal file
|
|
@ -0,0 +1,520 @@
|
|||
using AcDream.Launcher.Core.Updates;
|
||||
|
||||
namespace AcDream.Launcher.ViewModels;
|
||||
|
||||
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 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? _error;
|
||||
private LauncherUpdatePhase _phase = LauncherUpdatePhase.Idle;
|
||||
private double _progressPercent;
|
||||
private bool _isProgressIndeterminate;
|
||||
private string? _launcherRestartStatus;
|
||||
|
||||
public LauncherUpdateViewModel(
|
||||
ILauncherUpdater updater,
|
||||
IUiDispatcher dispatcher,
|
||||
Action onClientChanged,
|
||||
Func<bool>? canOpen = null,
|
||||
Func<bool>? canMutate = 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);
|
||||
_canMutate = canMutate ?? (() => true);
|
||||
|
||||
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));
|
||||
CancelCommand = new RelayCommand(
|
||||
() => _cancellation?.Cancel(),
|
||||
() => IsBusy && _cancellation is not null);
|
||||
}
|
||||
|
||||
public string Title => "Client and launcher updates";
|
||||
|
||||
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.";
|
||||
|
||||
public bool IsOpen
|
||||
{
|
||||
get => _isOpen;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _isOpen, value))
|
||||
{
|
||||
NotifyCommandStates();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsBusy
|
||||
{
|
||||
get => _isBusy;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _isBusy, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(CanClose));
|
||||
NotifyCommandStates();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool CanClose => !IsBusy;
|
||||
|
||||
public string Status
|
||||
{
|
||||
get => _status;
|
||||
private set => SetProperty(ref _status, value);
|
||||
}
|
||||
|
||||
public string? Error
|
||||
{
|
||||
get => _error;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _error, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(HasError));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasError => !string.IsNullOrWhiteSpace(Error);
|
||||
|
||||
public LauncherUpdatePhase Phase
|
||||
{
|
||||
get => _phase;
|
||||
private set => SetProperty(ref _phase, value);
|
||||
}
|
||||
|
||||
public double ProgressPercent
|
||||
{
|
||||
get => _progressPercent;
|
||||
private set => SetProperty(ref _progressPercent, value);
|
||||
}
|
||||
|
||||
public bool IsProgressIndeterminate
|
||||
{
|
||||
get => _isProgressIndeterminate;
|
||||
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);
|
||||
|
||||
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 CancelCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Launch-time polling is deliberately nonfatal: an offline or malformed
|
||||
/// feed changes only this status and never prevents profile/session use.
|
||||
/// </summary>
|
||||
public async Task StartupCheckAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await CheckAsync(startup: true).ConfigureAwait(true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// CheckAsync owns visible state and never lets startup polling
|
||||
// escape into the Avalonia initialization transaction.
|
||||
}
|
||||
}
|
||||
|
||||
public void Close()
|
||||
{
|
||||
if (!IsBusy)
|
||||
{
|
||||
IsOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void NotifyCommandStates()
|
||||
{
|
||||
OpenCommand.NotifyCanExecuteChanged();
|
||||
CloseCommand.NotifyCanExecuteChanged();
|
||||
CheckCommand.NotifyCanExecuteChanged();
|
||||
InstallClientCommand.NotifyCanExecuteChanged();
|
||||
StageLauncherCommand.NotifyCanExecuteChanged();
|
||||
RollbackCommand.NotifyCanExecuteChanged();
|
||||
CancelCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_cancellation?.Cancel();
|
||||
_cancellation?.Dispose();
|
||||
_cancellation = null;
|
||||
}
|
||||
|
||||
private async Task OpenAndCheckAsync()
|
||||
{
|
||||
IsOpen = true;
|
||||
await CheckAsync(startup: false).ConfigureAwait(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)
|
||||
{
|
||||
if (IsBusy || _disposed || _check is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
_cancellation = cancellation;
|
||||
IsBusy = true;
|
||||
Error = null;
|
||||
Status = initialStatus;
|
||||
IsProgressIndeterminate = true;
|
||||
ProgressPercent = 0;
|
||||
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
|
||||
{
|
||||
_check = await _updater.CheckAsync(CancellationToken.None)
|
||||
.ConfigureAwait(true);
|
||||
if (!IsLauncherRestartRequired)
|
||||
{
|
||||
Status = _check.Status;
|
||||
}
|
||||
|
||||
RefreshVersionProperties();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Status += " Release status refresh is unavailable: "
|
||||
+ SafeDisplayError(ex);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Status = "Update operation cancelled; published state was not changed.";
|
||||
Phase = LauncherUpdatePhase.Cancelled;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Error = SafeDisplayError(ex);
|
||||
Status = "Update operation failed.";
|
||||
Phase = LauncherUpdatePhase.Failed;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsProgressIndeterminate = false;
|
||||
if (ReferenceEquals(_cancellation, cancellation))
|
||||
{
|
||||
_cancellation = null;
|
||||
}
|
||||
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyProgress(LauncherUpdateProgress value)
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
private static string SafeDisplayError(Exception exception) =>
|
||||
string.IsNullOrWhiteSpace(exception.Message)
|
||||
? "The update operation failed."
|
||||
: exception.Message;
|
||||
|
||||
private sealed class UiProgress<T>(IUiDispatcher dispatcher, Action<T> callback)
|
||||
: IProgress<T>
|
||||
{
|
||||
public void Report(T value) => dispatcher.Post(() => callback(value));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class UnavailableLauncherUpdater : ILauncherUpdater
|
||||
{
|
||||
private static readonly ClientVersionResolution Missing = new(
|
||||
ClientVersionState.Missing,
|
||||
"Versioned client updater is unavailable.",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
|
||||
public ClientVersionResolution CurrentClient => Missing;
|
||||
|
||||
public Task<ClientVersionResolution> InitializeAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(Missing);
|
||||
|
||||
public Task<LauncherUpdateCheckResult> CheckAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<LauncherUpdateCheckResult>(
|
||||
new LauncherUpdateException("Versioned client updater is unavailable."));
|
||||
|
||||
public Task<ClientVersionResolution> InstallClientAsync(
|
||||
LauncherUpdateCheckResult check,
|
||||
IProgress<LauncherUpdateProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<ClientVersionResolution>(
|
||||
new LauncherUpdateException("Versioned client updater is unavailable."));
|
||||
|
||||
public Task<SelfUpdateStageResult> StageLauncherAsync(
|
||||
LauncherUpdateCheckResult check,
|
||||
IProgress<LauncherUpdateProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<SelfUpdateStageResult>(
|
||||
new LauncherUpdateException("Versioned client updater is unavailable."));
|
||||
|
||||
public Task<ClientVersionResolution> RollbackClientAsync(
|
||||
IProgress<LauncherUpdateProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<ClientVersionResolution>(
|
||||
new LauncherUpdateException("Versioned client updater is unavailable."));
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ using AcDream.Launcher.Core.Installation;
|
|||
using AcDream.Launcher.Core.Launching;
|
||||
using AcDream.Launcher.Core.Orchestration;
|
||||
using AcDream.Launcher.Core.Profiles;
|
||||
using AcDream.Launcher.Core.Updates;
|
||||
|
||||
namespace AcDream.Launcher.ViewModels;
|
||||
|
||||
|
|
@ -25,7 +26,8 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
public LauncherWindowViewModel(
|
||||
ILauncherOrchestrator orchestrator,
|
||||
IUiDispatcher dispatcher,
|
||||
ILauncherInstaller? installer = null)
|
||||
ILauncherInstaller? installer = null,
|
||||
ILauncherUpdater? updater = null)
|
||||
{
|
||||
_orchestrator = orchestrator ?? throw new ArgumentNullException(nameof(orchestrator));
|
||||
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||||
|
|
@ -38,17 +40,16 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
OnInstallCompleted,
|
||||
() => CanInteract,
|
||||
() => !IsBusy && Sessions.All(session => !session.IsActive));
|
||||
UpdatePromptShell = new LauncherShellViewModel(
|
||||
"Client update",
|
||||
"Review a signed release manifest, verify the downloaded archive, "
|
||||
+ "and atomically switch the installed client version. The updater "
|
||||
+ "transaction lands in Campaign LA slice LA10.",
|
||||
"Updater shell ready — implementation arrives in LA10.",
|
||||
() => CanInteract);
|
||||
UpdatePrompt = new LauncherUpdateViewModel(
|
||||
updater ?? new UnavailableLauncherUpdater(),
|
||||
dispatcher,
|
||||
OnClientVersionChanged,
|
||||
() => CanInteract,
|
||||
() => !IsBusy && Sessions.All(session => !session.IsActive));
|
||||
|
||||
EditorDialog.PropertyChanged += OnModalPropertyChanged;
|
||||
FirstRunWizardShell.PropertyChanged += OnModalPropertyChanged;
|
||||
UpdatePromptShell.PropertyChanged += OnModalPropertyChanged;
|
||||
UpdatePrompt.PropertyChanged += OnModalPropertyChanged;
|
||||
|
||||
AddServerCommand = new RelayCommand(OpenAddServerDialog, () => CanInteract);
|
||||
AddAccountCommand = new RelayCommand(OpenAddAccountDialog, CanAddAccount);
|
||||
|
|
@ -92,7 +93,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
|
||||
public FirstRunInstallerViewModel FirstRunWizardShell { get; }
|
||||
|
||||
public LauncherShellViewModel UpdatePromptShell { get; }
|
||||
public LauncherUpdateViewModel UpdatePrompt { get; }
|
||||
|
||||
public LauncherTreeNodeViewModel? SelectedNode
|
||||
{
|
||||
|
|
@ -136,7 +137,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
public bool IsModalOpen =>
|
||||
EditorDialog.IsOpen
|
||||
|| FirstRunWizardShell.IsOpen
|
||||
|| UpdatePromptShell.IsOpen;
|
||||
|| UpdatePrompt.IsOpen;
|
||||
|
||||
private bool CanInteract => !IsBusy && !IsModalOpen;
|
||||
|
||||
|
|
@ -300,6 +301,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
}
|
||||
|
||||
RefreshFromCore();
|
||||
_ = UpdatePrompt.StartupCheckAsync();
|
||||
}
|
||||
|
||||
public void PollStatus()
|
||||
|
|
@ -333,8 +335,9 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
_orchestrator.StateChanged -= OnOrchestratorStateChanged;
|
||||
EditorDialog.PropertyChanged -= OnModalPropertyChanged;
|
||||
FirstRunWizardShell.PropertyChanged -= OnModalPropertyChanged;
|
||||
UpdatePromptShell.PropertyChanged -= OnModalPropertyChanged;
|
||||
UpdatePrompt.PropertyChanged -= OnModalPropertyChanged;
|
||||
FirstRunWizardShell.Dispose();
|
||||
UpdatePrompt.Dispose();
|
||||
}
|
||||
|
||||
private void OnOrchestratorStateChanged(object? sender, EventArgs e) =>
|
||||
|
|
@ -350,7 +353,8 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
{
|
||||
if (e.PropertyName != nameof(ProfileEditorDialogViewModel.IsOpen)
|
||||
&& e.PropertyName != nameof(LauncherShellViewModel.IsOpen)
|
||||
&& e.PropertyName != nameof(FirstRunInstallerViewModel.IsOpen))
|
||||
&& e.PropertyName != nameof(FirstRunInstallerViewModel.IsOpen)
|
||||
&& e.PropertyName != nameof(LauncherUpdateViewModel.IsOpen))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
@ -376,9 +380,9 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
{
|
||||
FirstRunWizardShell.Close();
|
||||
}
|
||||
else if (UpdatePromptShell.IsOpen)
|
||||
else if (UpdatePrompt.IsOpen)
|
||||
{
|
||||
UpdatePromptShell.IsOpen = false;
|
||||
UpdatePrompt.Close();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -976,6 +980,13 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
RefreshFromCore();
|
||||
}
|
||||
|
||||
private void OnClientVersionChanged()
|
||||
{
|
||||
OperationStatus = "Versioned client activation changed.";
|
||||
LastError = null;
|
||||
RefreshFromCore();
|
||||
}
|
||||
|
||||
private void NotifyCommandStates()
|
||||
{
|
||||
AddServerCommand.NotifyCanExecuteChanged();
|
||||
|
|
@ -996,7 +1007,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
session.NotifyCommandState();
|
||||
}
|
||||
FirstRunWizardShell.NotifyCommandStates();
|
||||
UpdatePromptShell.NotifyCommandStates();
|
||||
UpdatePrompt.NotifyCommandStates();
|
||||
}
|
||||
|
||||
private readonly record struct SelectionKey(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue