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
88
src/AcDream.Launcher/ViewModels/Commands.cs
Normal file
88
src/AcDream.Launcher/ViewModels/Commands.cs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
using System.Windows.Input;
|
||||
|
||||
namespace AcDream.Launcher.ViewModels;
|
||||
|
||||
public sealed class RelayCommand : ICommand
|
||||
{
|
||||
private readonly Action<object?> _execute;
|
||||
private readonly Func<object?, bool>? _canExecute;
|
||||
|
||||
public RelayCommand(Action execute, Func<bool>? canExecute = null)
|
||||
: this(
|
||||
_ => execute(),
|
||||
canExecute is null ? null : _ => canExecute())
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(execute);
|
||||
}
|
||||
|
||||
public RelayCommand(
|
||||
Action<object?> execute,
|
||||
Func<object?, bool>? canExecute = null)
|
||||
{
|
||||
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
|
||||
_canExecute = canExecute;
|
||||
}
|
||||
|
||||
public event EventHandler? CanExecuteChanged;
|
||||
|
||||
public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;
|
||||
|
||||
public void Execute(object? parameter) => _execute(parameter);
|
||||
|
||||
public void NotifyCanExecuteChanged() =>
|
||||
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public sealed class AsyncRelayCommand : ICommand
|
||||
{
|
||||
private readonly Func<object?, Task> _execute;
|
||||
private readonly Func<object?, bool>? _canExecute;
|
||||
private bool _isExecuting;
|
||||
|
||||
public AsyncRelayCommand(Func<Task> execute, Func<bool>? canExecute = null)
|
||||
: this(
|
||||
_ => execute(),
|
||||
canExecute is null ? null : _ => canExecute())
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(execute);
|
||||
}
|
||||
|
||||
public AsyncRelayCommand(
|
||||
Func<object?, Task> execute,
|
||||
Func<object?, bool>? canExecute = null)
|
||||
{
|
||||
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
|
||||
_canExecute = canExecute;
|
||||
}
|
||||
|
||||
public event EventHandler? CanExecuteChanged;
|
||||
|
||||
public bool CanExecute(object? parameter) =>
|
||||
!_isExecuting && (_canExecute?.Invoke(parameter) ?? true);
|
||||
|
||||
public async void Execute(object? parameter) =>
|
||||
await ExecuteAsync(parameter).ConfigureAwait(true);
|
||||
|
||||
public async Task ExecuteAsync(object? parameter = null)
|
||||
{
|
||||
if (!CanExecute(parameter))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isExecuting = true;
|
||||
NotifyCanExecuteChanged();
|
||||
try
|
||||
{
|
||||
await _execute(parameter).ConfigureAwait(true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isExecuting = false;
|
||||
NotifyCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public void NotifyCanExecuteChanged() =>
|
||||
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
26
src/AcDream.Launcher/ViewModels/IUiDispatcher.cs
Normal file
26
src/AcDream.Launcher/ViewModels/IUiDispatcher.cs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
using Avalonia.Threading;
|
||||
|
||||
namespace AcDream.Launcher.ViewModels;
|
||||
|
||||
public interface IUiDispatcher
|
||||
{
|
||||
void Post(Action action);
|
||||
}
|
||||
|
||||
public sealed class AvaloniaUiDispatcher : IUiDispatcher
|
||||
{
|
||||
public void Post(Action action)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(action);
|
||||
Dispatcher.UIThread.Post(action);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ImmediateUiDispatcher : IUiDispatcher
|
||||
{
|
||||
public void Post(Action action)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(action);
|
||||
action();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
using AcDream.Launcher.Core.Orchestration;
|
||||
|
||||
namespace AcDream.Launcher.ViewModels;
|
||||
|
||||
public sealed class LauncherSessionRowViewModel
|
||||
{
|
||||
public LauncherSessionRowViewModel(
|
||||
LauncherSessionSnapshot snapshot,
|
||||
Func<string, Task> stop)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
ArgumentNullException.ThrowIfNull(stop);
|
||||
|
||||
SessionId = snapshot.SessionId;
|
||||
Target = snapshot.Kind == LauncherActivityKind.Probe
|
||||
? $"{snapshot.ServerName} / {snapshot.AccountName} / character refresh"
|
||||
: $"{snapshot.ServerName} / {snapshot.AccountName} / {snapshot.CharacterName}";
|
||||
Mode = snapshot.Kind == LauncherActivityKind.Probe
|
||||
? "Probe"
|
||||
: snapshot.LaunchMode?.ToString() ?? "Session";
|
||||
State = snapshot.State.ToString();
|
||||
Status = snapshot.Status;
|
||||
Error = snapshot.Error;
|
||||
IsActive = snapshot.IsActive;
|
||||
StopCommand = new AsyncRelayCommand(
|
||||
() => stop(SessionId),
|
||||
() => IsActive);
|
||||
}
|
||||
|
||||
public string SessionId { get; }
|
||||
|
||||
public string Target { get; }
|
||||
|
||||
public string Mode { get; }
|
||||
|
||||
public string State { get; }
|
||||
|
||||
public string Status { get; }
|
||||
|
||||
public string? Error { get; }
|
||||
|
||||
public bool HasError => !string.IsNullOrWhiteSpace(Error);
|
||||
|
||||
public bool IsActive { get; }
|
||||
|
||||
public AsyncRelayCommand StopCommand { get; }
|
||||
}
|
||||
31
src/AcDream.Launcher/ViewModels/LauncherShellViewModel.cs
Normal file
31
src/AcDream.Launcher/ViewModels/LauncherShellViewModel.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
namespace AcDream.Launcher.ViewModels;
|
||||
|
||||
public sealed class LauncherShellViewModel : ObservableObject
|
||||
{
|
||||
private bool _isOpen;
|
||||
|
||||
public LauncherShellViewModel(string title, string body, string status)
|
||||
{
|
||||
Title = title;
|
||||
Body = body;
|
||||
Status = status;
|
||||
OpenCommand = new RelayCommand(() => IsOpen = true);
|
||||
CloseCommand = new RelayCommand(() => IsOpen = false);
|
||||
}
|
||||
|
||||
public string Title { get; }
|
||||
|
||||
public string Body { get; }
|
||||
|
||||
public string Status { get; }
|
||||
|
||||
public bool IsOpen
|
||||
{
|
||||
get => _isOpen;
|
||||
set => SetProperty(ref _isOpen, value);
|
||||
}
|
||||
|
||||
public RelayCommand OpenCommand { get; }
|
||||
|
||||
public RelayCommand CloseCommand { get; }
|
||||
}
|
||||
90
src/AcDream.Launcher/ViewModels/LauncherTreeNodeViewModel.cs
Normal file
90
src/AcDream.Launcher/ViewModels/LauncherTreeNodeViewModel.cs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
using System.Collections.ObjectModel;
|
||||
using AcDream.Launcher.Core.Orchestration;
|
||||
|
||||
namespace AcDream.Launcher.ViewModels;
|
||||
|
||||
public enum LauncherTreeNodeKind
|
||||
{
|
||||
Server,
|
||||
Account,
|
||||
Character,
|
||||
}
|
||||
|
||||
public sealed class LauncherTreeNodeViewModel
|
||||
{
|
||||
private LauncherTreeNodeViewModel(
|
||||
LauncherTreeNodeKind kind,
|
||||
string serverName,
|
||||
string? accountName,
|
||||
string? characterName,
|
||||
string displayName,
|
||||
string secondaryText)
|
||||
{
|
||||
Kind = kind;
|
||||
ServerName = serverName;
|
||||
AccountName = accountName;
|
||||
CharacterName = characterName;
|
||||
DisplayName = displayName;
|
||||
SecondaryText = secondaryText;
|
||||
}
|
||||
|
||||
public LauncherTreeNodeKind Kind { get; }
|
||||
|
||||
public string ServerName { get; }
|
||||
|
||||
public string? AccountName { get; }
|
||||
|
||||
public string? CharacterName { get; }
|
||||
|
||||
public string DisplayName { get; }
|
||||
|
||||
public string SecondaryText { get; }
|
||||
|
||||
public ObservableCollection<LauncherTreeNodeViewModel> Children { get; } = [];
|
||||
|
||||
public static LauncherTreeNodeViewModel FromServer(LauncherServerSnapshot server)
|
||||
{
|
||||
var node = new LauncherTreeNodeViewModel(
|
||||
LauncherTreeNodeKind.Server,
|
||||
server.Name,
|
||||
accountName: null,
|
||||
characterName: null,
|
||||
server.Name,
|
||||
$"{server.Host}:{server.Port}");
|
||||
foreach (LauncherAccountSnapshot account in server.Accounts)
|
||||
{
|
||||
node.Children.Add(FromAccount(account));
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
private static LauncherTreeNodeViewModel FromAccount(LauncherAccountSnapshot account)
|
||||
{
|
||||
var node = new LauncherTreeNodeViewModel(
|
||||
LauncherTreeNodeKind.Account,
|
||||
account.ServerName,
|
||||
account.AccountName,
|
||||
characterName: null,
|
||||
account.AccountName,
|
||||
account.ActivityStatus);
|
||||
foreach (LauncherCharacterSnapshot character in account.Characters)
|
||||
{
|
||||
node.Children.Add(FromCharacter(character));
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
private static LauncherTreeNodeViewModel FromCharacter(
|
||||
LauncherCharacterSnapshot character) =>
|
||||
new(
|
||||
LauncherTreeNodeKind.Character,
|
||||
character.ServerName,
|
||||
character.AccountName,
|
||||
character.Name,
|
||||
character.Name,
|
||||
character.HasRunningSession
|
||||
? character.SessionStatus
|
||||
: character.LaunchMode.ToString());
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
27
src/AcDream.Launcher/ViewModels/ObservableObject.cs
Normal file
27
src/AcDream.Launcher/ViewModels/ObservableObject.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AcDream.Launcher.ViewModels;
|
||||
|
||||
public abstract class ObservableObject : INotifyPropertyChanged
|
||||
{
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
protected bool SetProperty<T>(
|
||||
ref T field,
|
||||
T value,
|
||||
[CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
field = value;
|
||||
OnPropertyChanged(propertyName);
|
||||
return true;
|
||||
}
|
||||
|
||||
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) =>
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
196
src/AcDream.Launcher/ViewModels/ProfileEditorDialogViewModel.cs
Normal file
196
src/AcDream.Launcher/ViewModels/ProfileEditorDialogViewModel.cs
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
namespace AcDream.Launcher.ViewModels;
|
||||
|
||||
public enum ProfileEditorKind
|
||||
{
|
||||
AddServer,
|
||||
EditServer,
|
||||
AddAccount,
|
||||
EditAccount,
|
||||
AddCharacter,
|
||||
EditCharacter,
|
||||
Remove,
|
||||
}
|
||||
|
||||
public sealed class ProfileEditorDialogViewModel : ObservableObject
|
||||
{
|
||||
private Action<ProfileEditorDialogViewModel>? _submit;
|
||||
private bool _isOpen;
|
||||
private string _title = string.Empty;
|
||||
private string _message = string.Empty;
|
||||
private string _name = string.Empty;
|
||||
private string _host = string.Empty;
|
||||
private string _port = "9000";
|
||||
private string _password = string.Empty;
|
||||
private string _characterId = string.Empty;
|
||||
private string? _error;
|
||||
private ProfileEditorKind _kind;
|
||||
|
||||
public ProfileEditorDialogViewModel()
|
||||
{
|
||||
SubmitCommand = new RelayCommand(Submit, () => IsOpen);
|
||||
CancelCommand = new RelayCommand(Close, () => IsOpen);
|
||||
}
|
||||
|
||||
public bool IsOpen
|
||||
{
|
||||
get => _isOpen;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _isOpen, value))
|
||||
{
|
||||
SubmitCommand.NotifyCanExecuteChanged();
|
||||
CancelCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ProfileEditorKind Kind
|
||||
{
|
||||
get => _kind;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _kind, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(IsServerEditor));
|
||||
OnPropertyChanged(nameof(IsAccountEditor));
|
||||
OnPropertyChanged(nameof(IsCharacterEditor));
|
||||
OnPropertyChanged(nameof(IsRemoveConfirmation));
|
||||
OnPropertyChanged(nameof(SubmitText));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string Title
|
||||
{
|
||||
get => _title;
|
||||
private set => SetProperty(ref _title, value);
|
||||
}
|
||||
|
||||
public string Message
|
||||
{
|
||||
get => _message;
|
||||
private set => SetProperty(ref _message, value);
|
||||
}
|
||||
|
||||
public string Name
|
||||
{
|
||||
get => _name;
|
||||
set => SetProperty(ref _name, value);
|
||||
}
|
||||
|
||||
public string Host
|
||||
{
|
||||
get => _host;
|
||||
set => SetProperty(ref _host, value);
|
||||
}
|
||||
|
||||
public string Port
|
||||
{
|
||||
get => _port;
|
||||
set => SetProperty(ref _port, value);
|
||||
}
|
||||
|
||||
public string Password
|
||||
{
|
||||
get => _password;
|
||||
set => SetProperty(ref _password, value);
|
||||
}
|
||||
|
||||
public string CharacterId
|
||||
{
|
||||
get => _characterId;
|
||||
set => SetProperty(ref _characterId, value);
|
||||
}
|
||||
|
||||
public string? Error
|
||||
{
|
||||
get => _error;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _error, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(HasError));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasError => !string.IsNullOrWhiteSpace(Error);
|
||||
|
||||
public bool IsServerEditor => Kind is ProfileEditorKind.AddServer or ProfileEditorKind.EditServer;
|
||||
|
||||
public bool IsAccountEditor => Kind is ProfileEditorKind.AddAccount or ProfileEditorKind.EditAccount;
|
||||
|
||||
public bool IsCharacterEditor => Kind is ProfileEditorKind.AddCharacter or ProfileEditorKind.EditCharacter;
|
||||
|
||||
public bool IsRemoveConfirmation => Kind == ProfileEditorKind.Remove;
|
||||
|
||||
public string SubmitText => IsRemoveConfirmation ? "Remove" : "Save";
|
||||
|
||||
public RelayCommand SubmitCommand { get; }
|
||||
|
||||
public RelayCommand CancelCommand { get; }
|
||||
|
||||
public void Open(
|
||||
ProfileEditorKind kind,
|
||||
string title,
|
||||
Action<ProfileEditorDialogViewModel> submit,
|
||||
string name = "",
|
||||
string host = "",
|
||||
int port = 9000,
|
||||
string characterId = "",
|
||||
string message = "")
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(submit);
|
||||
Kind = kind;
|
||||
Title = title;
|
||||
Message = message;
|
||||
Name = name;
|
||||
Host = host;
|
||||
Port = port.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
Password = string.Empty;
|
||||
CharacterId = characterId;
|
||||
Error = null;
|
||||
_submit = submit;
|
||||
IsOpen = true;
|
||||
}
|
||||
|
||||
public bool TryGetPort(out int port) =>
|
||||
int.TryParse(
|
||||
Port,
|
||||
System.Globalization.NumberStyles.None,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out port)
|
||||
&& port is >= 1 and <= 65535;
|
||||
|
||||
public void Close()
|
||||
{
|
||||
Password = string.Empty;
|
||||
Error = null;
|
||||
_submit = null;
|
||||
IsOpen = false;
|
||||
}
|
||||
|
||||
private void Submit()
|
||||
{
|
||||
try
|
||||
{
|
||||
_submit?.Invoke(this);
|
||||
Close();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
string message = ex.Message;
|
||||
if (!string.IsNullOrEmpty(Password))
|
||||
{
|
||||
message = message.Replace(
|
||||
Password,
|
||||
"[redacted]",
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
Error = string.IsNullOrWhiteSpace(message)
|
||||
? "The profile change could not be saved."
|
||||
: message;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue