feat(launcher): implement verified atomic updates

This commit is contained in:
Erik 2026-08-14 22:09:34 +02:00
parent 2198a0cc8e
commit 2d2a5b5046
34 changed files with 6755 additions and 61 deletions

View file

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

View file

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

View file

@ -120,7 +120,7 @@ public sealed partial class MainWindow : Window
{
FirstRunDatDirectoryTextBox.Focus();
}
else if (viewModel.UpdatePromptShell.IsOpen)
else if (viewModel.UpdatePrompt.IsOpen)
{
UpdateCloseButton.Focus();
}

View file

@ -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() =>

View 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."));
}

View file

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