acdream/src/AcDream.Launcher/ViewModels/LauncherWindowViewModel.cs

866 lines
30 KiB
C#

using System.Collections.ObjectModel;
using AcDream.Launcher.Core.Orchestration;
using AcDream.Launcher.Core.Profiles;
namespace AcDream.Launcher.ViewModels;
public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
{
private readonly ILauncherOrchestrator _orchestrator;
private readonly IUiDispatcher _dispatcher;
private LauncherStateSnapshot? _snapshot;
private LauncherTreeNodeViewModel? _selectedNode;
private CancellationTokenSource? _operationCancellation;
private bool _isBusy;
private bool _disposed;
private string? _lastError;
private string _operationStatus = "Ready";
private LaunchMode _characterLaunchMode;
private string _characterPluginsText = string.Empty;
private string _characterLoginCommandsText = string.Empty;
public LauncherWindowViewModel(
ILauncherOrchestrator orchestrator,
IUiDispatcher dispatcher)
{
_orchestrator = orchestrator ?? throw new ArgumentNullException(nameof(orchestrator));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
_orchestrator.StateChanged += OnOrchestratorStateChanged;
EditorDialog = new ProfileEditorDialogViewModel();
FirstRunWizardShell = new LauncherShellViewModel(
"First-run setup",
"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.");
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.");
AddServerCommand = new RelayCommand(OpenAddServerDialog, () => !IsBusy);
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);
RefreshCharactersCommand = new AsyncRelayCommand(
RefreshCharactersAsync,
() => CanProbe);
LaunchGuiCommand = new AsyncRelayCommand(
() => LaunchSelectedAsync(LaunchMode.Gui),
() => CanLaunchGui);
LaunchGuiSelectCommand = new AsyncRelayCommand(
() => LaunchSelectedAsync(LaunchMode.GuiSelect),
() => CanLaunchGuiSelect);
LaunchHeadlessCommand = new AsyncRelayCommand(
() => LaunchSelectedAsync(LaunchMode.Headless),
() => CanLaunchHeadless);
CancelOperationCommand = new RelayCommand(
CancelOperation,
() => IsBusy && _operationCancellation is not null);
ClearFinishedSessionsCommand = new RelayCommand(
_orchestrator.ClearFinishedSessions,
() => Sessions.Any(session => !session.IsActive) && !IsBusy);
}
public ObservableCollection<LauncherTreeNodeViewModel> Servers { get; } = [];
public ObservableCollection<LauncherSessionRowViewModel> Sessions { get; } = [];
public IReadOnlyList<LaunchMode> AvailableLaunchModes { get; } =
[LaunchMode.Gui, LaunchMode.GuiSelect, LaunchMode.Headless];
public ProfileEditorDialogViewModel EditorDialog { get; }
public LauncherShellViewModel FirstRunWizardShell { get; }
public LauncherShellViewModel UpdatePromptShell { get; }
public LauncherTreeNodeViewModel? SelectedNode
{
get => _selectedNode;
set => SetSelectedNode(value, preserveCharacterDraft: false);
}
public bool IsBusy
{
get => _isBusy;
private set
{
if (SetProperty(ref _isBusy, value))
{
OnPropertyChanged(nameof(CanProbe));
OnPropertyChanged(nameof(CanLaunchGui));
OnPropertyChanged(nameof(CanLaunchGuiSelect));
OnPropertyChanged(nameof(CanLaunchHeadless));
NotifyCommandStates();
}
}
}
public string? LastError
{
get => _lastError;
private set
{
if (SetProperty(ref _lastError, value))
{
OnPropertyChanged(nameof(HasError));
}
}
}
public bool HasError => !string.IsNullOrWhiteSpace(LastError);
public string OperationStatus
{
get => _operationStatus;
private set => SetProperty(ref _operationStatus, value);
}
public bool IsServerSelected => SelectedNode?.Kind == LauncherTreeNodeKind.Server;
public bool IsAccountSelected => SelectedNode?.Kind == LauncherTreeNodeKind.Account;
public bool IsCharacterSelected => SelectedNode?.Kind == LauncherTreeNodeKind.Character;
public bool HasSelection => SelectedNode is not null;
public string SelectionTitle => SelectedNode?.DisplayName ?? "Select a profile";
public string SelectionSubtitle => SelectedNode?.SecondaryText
?? "Add a server to begin.";
public string SelectedServerName => SelectedNode?.ServerName ?? string.Empty;
public string SelectedAccountName => SelectedNode?.AccountName ?? string.Empty;
public string SelectedCharacterName => SelectedNode?.CharacterName ?? string.Empty;
public LaunchMode CharacterLaunchMode
{
get => _characterLaunchMode;
set => SetProperty(ref _characterLaunchMode, value);
}
public string CharacterPluginsText
{
get => _characterPluginsText;
set => SetProperty(ref _characterPluginsText, value);
}
public string CharacterLoginCommandsText
{
get => _characterLoginCommandsText;
set => SetProperty(ref _characterLoginCommandsText, value);
}
public bool IsFirstRunRequired => _snapshot is { IsInstallationReady: false };
public string InstallationStatus => _snapshot?.InstallationStatus
?? "Installation state is loading.";
public bool ShowLinuxGraphicalNotice => _snapshot?.Platform.IsLinux == true;
public string LinuxGraphicalNotice =>
_snapshot?.Platform.GraphicalLaunchDisabledReason ?? string.Empty;
public bool CanProbe =>
!IsBusy
&& TryGetSelectedAccount(out string server, out string account)
&& _orchestrator.GetProbeCapability(server, account).IsAvailable;
public string ProbeDisabledReason
{
get
{
if (!TryGetSelectedAccount(out string server, out string account))
{
return "Select an account or one of its characters.";
}
return _orchestrator.GetProbeCapability(server, account).Reason
?? "Character refresh is available.";
}
}
public bool CanLaunchGui => CanLaunch(LaunchMode.Gui);
public bool CanLaunchGuiSelect => CanLaunch(LaunchMode.GuiSelect);
public bool CanLaunchHeadless => CanLaunch(LaunchMode.Headless);
public string GuiLaunchDisabledReason =>
_orchestrator.GetLaunchCapability(LaunchMode.Gui).Reason
?? "Graphical launch is available.";
public string HeadlessLaunchDisabledReason =>
_orchestrator.GetLaunchCapability(LaunchMode.Headless).Reason
?? "Headless launch is available.";
public RelayCommand AddServerCommand { get; }
public RelayCommand AddAccountCommand { get; }
public RelayCommand AddCharacterCommand { get; }
public RelayCommand EditSelectedCommand { get; }
public RelayCommand RemoveSelectedCommand { get; }
public RelayCommand SaveCharacterSettingsCommand { get; }
public AsyncRelayCommand RefreshCharactersCommand { get; }
public AsyncRelayCommand LaunchGuiCommand { get; }
public AsyncRelayCommand LaunchGuiSelectCommand { get; }
public AsyncRelayCommand LaunchHeadlessCommand { get; }
public RelayCommand CancelOperationCommand { get; }
public RelayCommand ClearFinishedSessionsCommand { get; }
public void Initialize()
{
try
{
_orchestrator.LoadProfiles();
LastError = null;
}
catch (Exception ex)
{
LastError = SafeDisplayError(ex, secret: null);
}
RefreshFromCore();
}
public void PollStatus()
{
if (_disposed)
{
return;
}
try
{
_orchestrator.PollStatus();
}
catch (Exception ex)
{
LastError = SafeDisplayError(ex, secret: null);
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_operationCancellation?.Cancel();
_operationCancellation?.Dispose();
_operationCancellation = null;
_orchestrator.StateChanged -= OnOrchestratorStateChanged;
}
private void OnOrchestratorStateChanged(object? sender, EventArgs e) =>
_dispatcher.Post(() =>
{
if (!_disposed)
{
RefreshFromCore();
}
});
private void RefreshFromCore(SelectionKey? preferredSelection = null)
{
SelectionKey? previousSelection = preferredSelection ?? SelectionKey.From(SelectedNode);
LauncherStateSnapshot snapshot = _orchestrator.GetSnapshot();
_snapshot = snapshot;
Servers.Clear();
foreach (LauncherServerSnapshot server in snapshot.Servers)
{
Servers.Add(LauncherTreeNodeViewModel.FromServer(server));
}
Sessions.Clear();
foreach (LauncherSessionSnapshot session in snapshot.Sessions)
{
Sessions.Add(new LauncherSessionRowViewModel(session, StopSessionAsync));
}
LauncherTreeNodeViewModel? restored = previousSelection is null
? Servers.FirstOrDefault()
: FindNode(previousSelection.Value) ?? Servers.FirstOrDefault();
bool preserveDraft = previousSelection is not null
&& SelectionKey.From(restored) == previousSelection;
SetSelectedNode(restored, preserveDraft);
OnPropertyChanged(nameof(IsFirstRunRequired));
OnPropertyChanged(nameof(InstallationStatus));
OnPropertyChanged(nameof(ShowLinuxGraphicalNotice));
OnPropertyChanged(nameof(LinuxGraphicalNotice));
OnPropertyChanged(nameof(CanProbe));
OnPropertyChanged(nameof(ProbeDisabledReason));
OnPropertyChanged(nameof(CanLaunchGui));
OnPropertyChanged(nameof(CanLaunchGuiSelect));
OnPropertyChanged(nameof(CanLaunchHeadless));
OnPropertyChanged(nameof(GuiLaunchDisabledReason));
OnPropertyChanged(nameof(HeadlessLaunchDisabledReason));
NotifyCommandStates();
}
private void SetSelectedNode(
LauncherTreeNodeViewModel? value,
bool preserveCharacterDraft)
{
if (ReferenceEquals(_selectedNode, value))
{
return;
}
_selectedNode = value;
OnPropertyChanged(nameof(SelectedNode));
OnPropertyChanged(nameof(IsServerSelected));
OnPropertyChanged(nameof(IsAccountSelected));
OnPropertyChanged(nameof(IsCharacterSelected));
OnPropertyChanged(nameof(HasSelection));
OnPropertyChanged(nameof(SelectionTitle));
OnPropertyChanged(nameof(SelectionSubtitle));
OnPropertyChanged(nameof(SelectedServerName));
OnPropertyChanged(nameof(SelectedAccountName));
OnPropertyChanged(nameof(SelectedCharacterName));
if (!preserveCharacterDraft)
{
LoadCharacterDraft();
}
OnPropertyChanged(nameof(CanProbe));
OnPropertyChanged(nameof(ProbeDisabledReason));
OnPropertyChanged(nameof(CanLaunchGui));
OnPropertyChanged(nameof(CanLaunchGuiSelect));
OnPropertyChanged(nameof(CanLaunchHeadless));
NotifyCommandStates();
}
private void LoadCharacterDraft()
{
LauncherCharacterSnapshot? character = GetSelectedCharacterSnapshot();
CharacterLaunchMode = character?.LaunchMode ?? LaunchMode.GuiSelect;
CharacterPluginsText = character is null
? string.Empty
: string.Join(Environment.NewLine, character.Plugins);
CharacterLoginCommandsText = character is null
? string.Empty
: string.Join(Environment.NewLine, character.LoginCommands);
}
private void OpenAddServerDialog() =>
EditorDialog.Open(
ProfileEditorKind.AddServer,
"Add server",
dialog =>
{
if (!dialog.TryGetPort(out int port))
{
throw new LauncherOperationException("Port must be between 1 and 65535.");
}
_orchestrator.AddServer(dialog.Name.Trim(), dialog.Host.Trim(), port);
RefreshFromCore(new SelectionKey(
LauncherTreeNodeKind.Server,
dialog.Name.Trim(),
null,
null));
});
private bool CanAddAccount() => !IsBusy && SelectedNode is not null;
private void OpenAddAccountDialog()
{
string serverName = SelectedNode?.ServerName
?? throw new LauncherOperationException("Select a server first.");
EditorDialog.Open(
ProfileEditorKind.AddAccount,
$"Add account to {serverName}",
dialog =>
{
_orchestrator.AddAccount(
serverName,
dialog.Name.Trim(),
dialog.Password);
RefreshFromCore(new SelectionKey(
LauncherTreeNodeKind.Account,
serverName,
dialog.Name.Trim(),
null));
});
}
private bool CanAddCharacter() =>
!IsBusy && TryGetSelectedAccount(out _, out _);
private void OpenAddCharacterDialog()
{
if (!TryGetSelectedAccount(out string serverName, out string accountName))
{
throw new LauncherOperationException("Select an account first.");
}
EditorDialog.Open(
ProfileEditorKind.AddCharacter,
$"Add cached character to {accountName}",
dialog =>
{
string name = dialog.Name.Trim();
_orchestrator.AddCharacter(
serverName,
accountName,
name,
NullIfWhiteSpace(dialog.CharacterId));
RefreshFromCore(new SelectionKey(
LauncherTreeNodeKind.Character,
serverName,
accountName,
name));
},
message: "Normally Refresh Characters fills this list. Manual rows "
+ "let you configure a known character while the server is unavailable.");
}
private bool CanEditSelected() => !IsBusy && SelectedNode is not null;
private void OpenEditSelectedDialog()
{
LauncherTreeNodeViewModel node = SelectedNode
?? throw new LauncherOperationException("Select a profile first.");
switch (node.Kind)
{
case LauncherTreeNodeKind.Server:
OpenEditServerDialog(node);
break;
case LauncherTreeNodeKind.Account:
OpenEditAccountDialog(node);
break;
case LauncherTreeNodeKind.Character:
OpenEditCharacterDialog(node);
break;
}
}
private void OpenEditServerDialog(LauncherTreeNodeViewModel node)
{
LauncherServerSnapshot server = FindServerSnapshot(node.ServerName);
EditorDialog.Open(
ProfileEditorKind.EditServer,
$"Edit {server.Name}",
dialog =>
{
if (!dialog.TryGetPort(out int port))
{
throw new LauncherOperationException("Port must be between 1 and 65535.");
}
string newName = dialog.Name.Trim();
_orchestrator.EditServer(
server.Name,
newName,
dialog.Host.Trim(),
port);
RefreshFromCore(new SelectionKey(
LauncherTreeNodeKind.Server,
newName,
null,
null));
},
server.Name,
server.Host,
server.Port);
}
private void OpenEditAccountDialog(LauncherTreeNodeViewModel node)
{
LauncherAccountSnapshot account = FindAccountSnapshot(
node.ServerName,
node.AccountName!);
EditorDialog.Open(
ProfileEditorKind.EditAccount,
$"Edit {account.AccountName}",
dialog =>
{
string newName = dialog.Name.Trim();
_orchestrator.EditAccount(
account.ServerName,
account.AccountName,
newName,
string.IsNullOrEmpty(dialog.Password) ? null : dialog.Password);
RefreshFromCore(new SelectionKey(
LauncherTreeNodeKind.Account,
account.ServerName,
newName,
null));
},
account.AccountName,
message: "Leave Password blank to keep the stored credential unchanged.");
}
private void OpenEditCharacterDialog(LauncherTreeNodeViewModel node)
{
LauncherCharacterSnapshot character = FindCharacterSnapshot(
node.ServerName,
node.AccountName!,
node.CharacterName!);
EditorDialog.Open(
ProfileEditorKind.EditCharacter,
$"Edit {character.Name}",
dialog =>
{
string newName = dialog.Name.Trim();
_orchestrator.EditCharacterIdentity(
character.ServerName,
character.AccountName,
character.Name,
newName,
dialog.CharacterId.Trim());
RefreshFromCore(new SelectionKey(
LauncherTreeNodeKind.Character,
character.ServerName,
character.AccountName,
newName));
},
character.Name,
characterId: character.Id ?? string.Empty,
message: "A later character refresh remains authoritative for name and id.");
}
private void OpenRemoveSelectedDialog()
{
LauncherTreeNodeViewModel node = SelectedNode
?? throw new LauncherOperationException("Select a profile first.");
EditorDialog.Open(
ProfileEditorKind.Remove,
$"Remove {node.DisplayName}?",
_ =>
{
switch (node.Kind)
{
case LauncherTreeNodeKind.Server:
_orchestrator.RemoveServer(node.ServerName);
break;
case LauncherTreeNodeKind.Account:
_orchestrator.RemoveAccount(node.ServerName, node.AccountName!);
break;
case LauncherTreeNodeKind.Character:
_orchestrator.RemoveCharacter(
node.ServerName,
node.AccountName!,
node.CharacterName!);
break;
}
RefreshFromCore();
},
message: node.Kind switch
{
LauncherTreeNodeKind.Server =>
"This removes the server and every account/character profile beneath it.",
LauncherTreeNodeKind.Account =>
"This removes the account, its plaintext credential, and cached characters.",
_ => "This removes the cached character and its launch settings.",
});
}
private void SaveCharacterSettings()
{
LauncherCharacterSnapshot character = GetSelectedCharacterSnapshot()
?? throw new LauncherOperationException("Select a character first.");
try
{
_orchestrator.UpdateCharacterSettings(
character.ServerName,
character.AccountName,
character.Name,
CharacterLaunchMode,
ParseLines(CharacterPluginsText, distinct: true),
ParseLines(CharacterLoginCommandsText, distinct: false));
LastError = null;
OperationStatus = $"Saved launch settings for {character.Name}.";
RefreshFromCore(new SelectionKey(
LauncherTreeNodeKind.Character,
character.ServerName,
character.AccountName,
character.Name));
}
catch (Exception ex)
{
LastError = SafeDisplayError(ex, secret: null);
}
}
private Task RefreshCharactersAsync()
{
if (!TryGetSelectedAccount(out string serverName, out string accountName))
{
return Task.CompletedTask;
}
return RunOperationAsync(
token => _orchestrator.ProbeAsync(serverName, accountName, token),
$"Refreshing characters for {accountName}…",
"Character refresh started. The roster will update from host status.");
}
private Task LaunchSelectedAsync(LaunchMode mode)
{
LauncherCharacterSnapshot? character = GetSelectedCharacterSnapshot();
if (character is null)
{
return Task.CompletedTask;
}
return RunOperationAsync(
token => _orchestrator.LaunchAsync(
character.ServerName,
character.AccountName,
character.Name,
mode,
token),
$"Launching {character.Name} ({mode})…",
$"{mode} session started for {character.Name}.");
}
private async Task RunOperationAsync(
Func<CancellationToken, Task<LauncherSessionSnapshot>> operation,
string activeStatus,
string completedStatus)
{
if (IsBusy)
{
return;
}
using var cancellation = new CancellationTokenSource();
_operationCancellation = cancellation;
IsBusy = true;
LastError = null;
OperationStatus = activeStatus;
try
{
await operation(cancellation.Token).ConfigureAwait(true);
OperationStatus = completedStatus;
}
catch (OperationCanceledException)
{
OperationStatus = "Operation cancelled.";
}
catch (Exception ex)
{
LastError = SafeDisplayError(ex, secret: null);
OperationStatus = "Operation failed.";
}
finally
{
if (ReferenceEquals(_operationCancellation, cancellation))
{
_operationCancellation = null;
}
IsBusy = false;
RefreshFromCore();
}
}
private async Task StopSessionAsync(string sessionId)
{
if (IsBusy)
{
return;
}
using var cancellation = new CancellationTokenSource();
_operationCancellation = cancellation;
IsBusy = true;
LastError = null;
OperationStatus = "Stopping session…";
try
{
await _orchestrator.StopSessionAsync(
sessionId,
TimeSpan.FromSeconds(5),
cancellation.Token)
.ConfigureAwait(true);
OperationStatus = "Stop requested.";
}
catch (OperationCanceledException)
{
OperationStatus = "Stop cancelled.";
}
catch (Exception ex)
{
LastError = SafeDisplayError(ex, secret: null);
OperationStatus = "Stop failed.";
}
finally
{
if (ReferenceEquals(_operationCancellation, cancellation))
{
_operationCancellation = null;
}
IsBusy = false;
RefreshFromCore();
}
}
private void CancelOperation() => _operationCancellation?.Cancel();
private bool CanLaunch(LaunchMode mode) =>
!IsBusy
&& IsCharacterSelected
&& _orchestrator.GetLaunchCapability(mode).IsAvailable;
private bool TryGetSelectedAccount(out string serverName, out string accountName)
{
serverName = SelectedNode?.ServerName ?? string.Empty;
accountName = SelectedNode?.AccountName ?? string.Empty;
return !string.IsNullOrWhiteSpace(serverName)
&& !string.IsNullOrWhiteSpace(accountName);
}
private LauncherCharacterSnapshot? GetSelectedCharacterSnapshot()
{
if (_snapshot is null
|| SelectedNode is not { Kind: LauncherTreeNodeKind.Character } node)
{
return null;
}
return FindCharacterSnapshotOrDefault(
node.ServerName,
node.AccountName!,
node.CharacterName!);
}
private LauncherServerSnapshot FindServerSnapshot(string serverName) =>
_snapshot?.Servers.FirstOrDefault(server =>
string.Equals(server.Name, serverName, StringComparison.Ordinal))
?? throw new LauncherOperationException($"No server named '{serverName}'.");
private LauncherAccountSnapshot FindAccountSnapshot(
string serverName,
string accountName) =>
FindServerSnapshot(serverName).Accounts.FirstOrDefault(account =>
string.Equals(account.AccountName, accountName, StringComparison.Ordinal))
?? throw new LauncherOperationException(
$"No account '{accountName}' on server '{serverName}'.");
private LauncherCharacterSnapshot FindCharacterSnapshot(
string serverName,
string accountName,
string characterName) =>
FindCharacterSnapshotOrDefault(serverName, accountName, characterName)
?? throw new LauncherOperationException(
$"No character '{characterName}' on account '{accountName}'.");
private LauncherCharacterSnapshot? FindCharacterSnapshotOrDefault(
string serverName,
string accountName,
string characterName) =>
FindAccountSnapshot(serverName, accountName).Characters.FirstOrDefault(character =>
string.Equals(character.Name, characterName, StringComparison.Ordinal));
private LauncherTreeNodeViewModel? FindNode(SelectionKey key)
{
LauncherTreeNodeViewModel? server = Servers.FirstOrDefault(candidate =>
string.Equals(candidate.ServerName, key.ServerName, StringComparison.Ordinal));
if (server is null || key.Kind == LauncherTreeNodeKind.Server)
{
return server;
}
LauncherTreeNodeViewModel? account = server.Children.FirstOrDefault(candidate =>
string.Equals(candidate.AccountName, key.AccountName, StringComparison.Ordinal));
if (account is null || key.Kind == LauncherTreeNodeKind.Account)
{
return account;
}
return account.Children.FirstOrDefault(candidate =>
string.Equals(candidate.CharacterName, key.CharacterName, StringComparison.Ordinal));
}
private static IReadOnlyList<string> ParseLines(string text, bool distinct)
{
IEnumerable<string> values = text
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
.Select(value => value.Trim())
.Where(value => value.Length > 0);
if (distinct)
{
values = values.Distinct(StringComparer.Ordinal);
}
return values.ToArray();
}
private static string? NullIfWhiteSpace(string value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string SafeDisplayError(Exception exception, string? secret)
{
string message = exception.Message;
if (!string.IsNullOrEmpty(secret))
{
message = message.Replace(secret, "[redacted]", StringComparison.Ordinal);
}
return string.IsNullOrWhiteSpace(message)
? "The launcher operation failed."
: message;
}
private void NotifyCommandStates()
{
AddServerCommand.NotifyCanExecuteChanged();
AddAccountCommand.NotifyCanExecuteChanged();
AddCharacterCommand.NotifyCanExecuteChanged();
EditSelectedCommand.NotifyCanExecuteChanged();
RemoveSelectedCommand.NotifyCanExecuteChanged();
SaveCharacterSettingsCommand.NotifyCanExecuteChanged();
RefreshCharactersCommand.NotifyCanExecuteChanged();
LaunchGuiCommand.NotifyCanExecuteChanged();
LaunchGuiSelectCommand.NotifyCanExecuteChanged();
LaunchHeadlessCommand.NotifyCanExecuteChanged();
CancelOperationCommand.NotifyCanExecuteChanged();
ClearFinishedSessionsCommand.NotifyCanExecuteChanged();
}
private readonly record struct SelectionKey(
LauncherTreeNodeKind Kind,
string ServerName,
string? AccountName,
string? CharacterName)
{
public static SelectionKey? From(LauncherTreeNodeViewModel? node) =>
node is null
? null
: new SelectionKey(
node.Kind,
node.ServerName,
node.AccountName,
node.CharacterName);
}
}