fix(launcher): close LA4 review findings

This commit is contained in:
Erik 2026-08-14 19:02:20 +02:00
parent d0a9c65d85
commit 10a712d66b
19 changed files with 1631 additions and 134 deletions

View file

@ -1,4 +1,5 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using AcDream.Launcher.Core.Orchestration;
using AcDream.Launcher.Core.Profiles;
@ -33,22 +34,28 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
"Choose and validate the retail DAT directory, build acdream.pak, "
+ "and record the installed client. The installer transaction and "
+ "progress body land in Campaign LA slice LA9.",
"Installer shell ready — implementation arrives in LA9.");
"Installer shell ready — implementation arrives in LA9.",
() => CanInteract);
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.");
"Updater shell ready — implementation arrives in LA10.",
() => CanInteract);
AddServerCommand = new RelayCommand(OpenAddServerDialog, () => !IsBusy);
EditorDialog.PropertyChanged += OnModalPropertyChanged;
FirstRunWizardShell.PropertyChanged += OnModalPropertyChanged;
UpdatePromptShell.PropertyChanged += OnModalPropertyChanged;
AddServerCommand = new RelayCommand(OpenAddServerDialog, () => CanInteract);
AddAccountCommand = new RelayCommand(OpenAddAccountDialog, CanAddAccount);
AddCharacterCommand = new RelayCommand(OpenAddCharacterDialog, CanAddCharacter);
EditSelectedCommand = new RelayCommand(OpenEditSelectedDialog, CanEditSelected);
RemoveSelectedCommand = new RelayCommand(OpenRemoveSelectedDialog, CanEditSelected);
SaveCharacterSettingsCommand = new RelayCommand(
SaveCharacterSettings,
() => IsCharacterSelected && !IsBusy);
() => IsCharacterSelected && CanInteract);
RefreshCharactersCommand = new AsyncRelayCommand(
RefreshCharactersAsync,
() => CanProbe);
@ -58,15 +65,18 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
LaunchGuiSelectCommand = new AsyncRelayCommand(
() => LaunchSelectedAsync(LaunchMode.GuiSelect),
() => CanLaunchGuiSelect);
LaunchAccountGuiSelectCommand = new AsyncRelayCommand(
LaunchSelectedAccountGuiSelectAsync,
() => CanLaunchAccountGuiSelect);
LaunchHeadlessCommand = new AsyncRelayCommand(
() => LaunchSelectedAsync(LaunchMode.Headless),
() => CanLaunchHeadless);
CancelOperationCommand = new RelayCommand(
CancelOperation,
() => IsBusy && _operationCancellation is not null);
() => !IsModalOpen && IsBusy && _operationCancellation is not null);
ClearFinishedSessionsCommand = new RelayCommand(
_orchestrator.ClearFinishedSessions,
() => Sessions.Any(session => !session.IsActive) && !IsBusy);
() => Sessions.Any(session => !session.IsActive) && CanInteract);
}
public ObservableCollection<LauncherTreeNodeViewModel> Servers { get; } = [];
@ -99,6 +109,9 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
OnPropertyChanged(nameof(CanLaunchGui));
OnPropertyChanged(nameof(CanLaunchGuiSelect));
OnPropertyChanged(nameof(CanLaunchHeadless));
OnPropertyChanged(nameof(CanLaunchAccountGuiSelect));
OnPropertyChanged(nameof(ShowGuiLaunchDisabledReason));
OnPropertyChanged(nameof(ShowHeadlessLaunchDisabledReason));
NotifyCommandStates();
}
}
@ -118,6 +131,13 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
public bool HasError => !string.IsNullOrWhiteSpace(LastError);
public bool IsModalOpen =>
EditorDialog.IsOpen
|| FirstRunWizardShell.IsOpen
|| UpdatePromptShell.IsOpen;
private bool CanInteract => !IsBusy && !IsModalOpen;
public string OperationStatus
{
get => _operationStatus;
@ -172,7 +192,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
_snapshot?.Platform.GraphicalLaunchDisabledReason ?? string.Empty;
public bool CanProbe =>
!IsBusy
CanInteract
&& TryGetSelectedAccount(out string server, out string account)
&& _orchestrator.GetProbeCapability(server, account).IsAvailable;
@ -196,14 +216,49 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
public bool CanLaunchHeadless => CanLaunch(LaunchMode.Headless);
public bool CanLaunchAccountGuiSelect =>
CanInteract
&& IsAccountSelected
&& TryGetSelectedAccount(out string server, out string account)
&& _orchestrator.GetAccountLaunchCapability(
server,
account,
LaunchMode.GuiSelect).IsAvailable;
public string AccountGuiSelectDisabledReason
{
get
{
if (!TryGetSelectedAccount(out string server, out string account))
{
return "Select an account.";
}
return _orchestrator.GetAccountLaunchCapability(
server,
account,
LaunchMode.GuiSelect).Reason
?? "Character-select launch is available.";
}
}
public string GuiLaunchDisabledReason =>
_orchestrator.GetLaunchCapability(LaunchMode.Gui).Reason
GetSelectedAccountLaunchCapability(LaunchMode.Gui).Reason
?? "Graphical launch is available.";
public string HeadlessLaunchDisabledReason =>
_orchestrator.GetLaunchCapability(LaunchMode.Headless).Reason
GetSelectedAccountLaunchCapability(LaunchMode.Headless).Reason
?? "Headless launch is available.";
public bool ShowGuiLaunchDisabledReason =>
IsCharacterSelected
&& (!GetSelectedAccountLaunchCapability(LaunchMode.Gui).IsAvailable
|| !GetSelectedAccountLaunchCapability(LaunchMode.GuiSelect).IsAvailable);
public bool ShowHeadlessLaunchDisabledReason =>
IsCharacterSelected
&& !GetSelectedAccountLaunchCapability(LaunchMode.Headless).IsAvailable;
public RelayCommand AddServerCommand { get; }
public RelayCommand AddAccountCommand { get; }
@ -222,6 +277,8 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
public AsyncRelayCommand LaunchGuiSelectCommand { get; }
public AsyncRelayCommand LaunchAccountGuiSelectCommand { get; }
public AsyncRelayCommand LaunchHeadlessCommand { get; }
public RelayCommand CancelOperationCommand { get; }
@ -272,6 +329,9 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
_operationCancellation?.Dispose();
_operationCancellation = null;
_orchestrator.StateChanged -= OnOrchestratorStateChanged;
EditorDialog.PropertyChanged -= OnModalPropertyChanged;
FirstRunWizardShell.PropertyChanged -= OnModalPropertyChanged;
UpdatePromptShell.PropertyChanged -= OnModalPropertyChanged;
}
private void OnOrchestratorStateChanged(object? sender, EventArgs e) =>
@ -283,6 +343,41 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
}
});
private void OnModalPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName != nameof(ProfileEditorDialogViewModel.IsOpen)
&& e.PropertyName != nameof(LauncherShellViewModel.IsOpen))
{
return;
}
OnPropertyChanged(nameof(IsModalOpen));
OnPropertyChanged(nameof(CanProbe));
OnPropertyChanged(nameof(CanLaunchGui));
OnPropertyChanged(nameof(CanLaunchGuiSelect));
OnPropertyChanged(nameof(CanLaunchHeadless));
OnPropertyChanged(nameof(CanLaunchAccountGuiSelect));
OnPropertyChanged(nameof(ShowGuiLaunchDisabledReason));
OnPropertyChanged(nameof(ShowHeadlessLaunchDisabledReason));
NotifyCommandStates();
}
public void CloseActiveModal()
{
if (EditorDialog.IsOpen)
{
EditorDialog.Close();
}
else if (FirstRunWizardShell.IsOpen)
{
FirstRunWizardShell.IsOpen = false;
}
else if (UpdatePromptShell.IsOpen)
{
UpdatePromptShell.IsOpen = false;
}
}
private void RefreshFromCore(SelectionKey? preferredSelection = null)
{
SelectionKey? previousSelection = preferredSelection ?? SelectionKey.From(SelectedNode);
@ -298,7 +393,10 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
Sessions.Clear();
foreach (LauncherSessionSnapshot session in snapshot.Sessions)
{
Sessions.Add(new LauncherSessionRowViewModel(session, StopSessionAsync));
Sessions.Add(new LauncherSessionRowViewModel(
session,
StopSessionAsync,
() => CanInteract));
}
LauncherTreeNodeViewModel? restored = previousSelection is null
@ -317,8 +415,12 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
OnPropertyChanged(nameof(CanLaunchGui));
OnPropertyChanged(nameof(CanLaunchGuiSelect));
OnPropertyChanged(nameof(CanLaunchHeadless));
OnPropertyChanged(nameof(CanLaunchAccountGuiSelect));
OnPropertyChanged(nameof(AccountGuiSelectDisabledReason));
OnPropertyChanged(nameof(GuiLaunchDisabledReason));
OnPropertyChanged(nameof(HeadlessLaunchDisabledReason));
OnPropertyChanged(nameof(ShowGuiLaunchDisabledReason));
OnPropertyChanged(nameof(ShowHeadlessLaunchDisabledReason));
NotifyCommandStates();
}
@ -353,6 +455,12 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
OnPropertyChanged(nameof(CanLaunchGui));
OnPropertyChanged(nameof(CanLaunchGuiSelect));
OnPropertyChanged(nameof(CanLaunchHeadless));
OnPropertyChanged(nameof(CanLaunchAccountGuiSelect));
OnPropertyChanged(nameof(AccountGuiSelectDisabledReason));
OnPropertyChanged(nameof(GuiLaunchDisabledReason));
OnPropertyChanged(nameof(HeadlessLaunchDisabledReason));
OnPropertyChanged(nameof(ShowGuiLaunchDisabledReason));
OnPropertyChanged(nameof(ShowHeadlessLaunchDisabledReason));
NotifyCommandStates();
}
@ -387,7 +495,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
null));
});
private bool CanAddAccount() => !IsBusy && SelectedNode is not null;
private bool CanAddAccount() => CanInteract && SelectedNode is not null;
private void OpenAddAccountDialog()
{
@ -411,7 +519,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
}
private bool CanAddCharacter() =>
!IsBusy && TryGetSelectedAccount(out _, out _);
CanInteract && TryGetSelectedAccount(out _, out _);
private void OpenAddCharacterDialog()
{
@ -441,7 +549,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
+ "let you configure a known character while the server is unavailable.");
}
private bool CanEditSelected() => !IsBusy && SelectedNode is not null;
private bool CanEditSelected() => CanInteract && SelectedNode is not null;
private void OpenEditSelectedDialog()
{
@ -642,6 +750,24 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
$"{mode} session started for {character.Name}.");
}
private Task LaunchSelectedAccountGuiSelectAsync()
{
if (!TryGetSelectedAccount(out string serverName, out string accountName))
{
return Task.CompletedTask;
}
return RunOperationAsync(
token => _orchestrator.LaunchAsync(
serverName,
accountName,
null,
LaunchMode.GuiSelect,
token),
$"Opening character select for {accountName}…",
$"Character-select session started for {accountName}.");
}
private async Task RunOperationAsync(
Func<CancellationToken, Task<LauncherSessionSnapshot>> operation,
string activeStatus,
@ -728,9 +854,15 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
private void CancelOperation() => _operationCancellation?.Cancel();
private bool CanLaunch(LaunchMode mode) =>
!IsBusy
CanInteract
&& IsCharacterSelected
&& _orchestrator.GetLaunchCapability(mode).IsAvailable;
&& TryGetSelectedAccount(out string server, out string account)
&& _orchestrator.GetAccountLaunchCapability(server, account, mode).IsAvailable;
private LauncherCapability GetSelectedAccountLaunchCapability(LaunchMode mode) =>
TryGetSelectedAccount(out string server, out string account)
? _orchestrator.GetAccountLaunchCapability(server, account, mode)
: _orchestrator.GetLaunchCapability(mode);
private bool TryGetSelectedAccount(out string serverName, out string accountName)
{
@ -843,9 +975,16 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
RefreshCharactersCommand.NotifyCanExecuteChanged();
LaunchGuiCommand.NotifyCanExecuteChanged();
LaunchGuiSelectCommand.NotifyCanExecuteChanged();
LaunchAccountGuiSelectCommand.NotifyCanExecuteChanged();
LaunchHeadlessCommand.NotifyCanExecuteChanged();
CancelOperationCommand.NotifyCanExecuteChanged();
ClearFinishedSessionsCommand.NotifyCanExecuteChanged();
foreach (LauncherSessionRowViewModel session in Sessions)
{
session.NotifyCommandState();
}
FirstRunWizardShell.NotifyCommandStates();
UpdatePromptShell.NotifyCommandStates();
}
private readonly record struct SelectionKey(