866 lines
30 KiB
C#
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);
|
|
}
|
|
}
|