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 Servers { get; } = []; public ObservableCollection Sessions { get; } = []; public IReadOnlyList 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> 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 ParseLines(string text, bool distinct) { IEnumerable 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); } }