feat(launcher): Campaign LA add Avalonia desktop shell
This commit is contained in:
parent
6c4cd2bbc6
commit
d0a9c65d85
31 changed files with 4831 additions and 16 deletions
866
src/AcDream.Launcher/ViewModels/LauncherWindowViewModel.cs
Normal file
866
src/AcDream.Launcher/ViewModels/LauncherWindowViewModel.cs
Normal file
|
|
@ -0,0 +1,866 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue