fix(launcher): close LA4 review findings
This commit is contained in:
parent
d0a9c65d85
commit
10a712d66b
19 changed files with 1631 additions and 134 deletions
|
|
@ -10,6 +10,7 @@
|
|||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
<SelfContained Condition="'$(RuntimeIdentifier)' == 'linux-x64'">true</SelfContained>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -85,7 +85,8 @@
|
|||
<Button Content="+ Account" Command="{Binding AddAccountCommand}" />
|
||||
<Button Content="+ Character" Command="{Binding AddCharacterCommand}" />
|
||||
</StackPanel>
|
||||
<TreeView Grid.Row="2"
|
||||
<TreeView x:Name="ProfilesTree"
|
||||
Grid.Row="2"
|
||||
ItemsSource="{Binding Servers}"
|
||||
SelectedItem="{Binding SelectedNode, Mode=TwoWay}">
|
||||
<TreeView.DataTemplates>
|
||||
|
|
@ -136,8 +137,11 @@
|
|||
<TextBlock Text="Passwords stay only in launcher-profiles.json and are handed to children through standard input."
|
||||
TextWrapping="Wrap" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Refresh characters"
|
||||
<Button Content="Open character select"
|
||||
Classes="primary"
|
||||
AutomationProperties.Name="Open character select for account"
|
||||
Command="{Binding LaunchAccountGuiSelectCommand}" />
|
||||
<Button Content="Refresh characters"
|
||||
Command="{Binding RefreshCharactersCommand}" />
|
||||
<Button Content="Add cached character"
|
||||
Command="{Binding AddCharacterCommand}" />
|
||||
|
|
@ -145,6 +149,9 @@
|
|||
<TextBlock Text="{Binding ProbeDisabledReason}"
|
||||
Classes="muted"
|
||||
TextWrapping="Wrap" />
|
||||
<TextBlock Text="{Binding AccountGuiSelectDisabledReason}"
|
||||
Classes="muted"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
|
|
@ -192,11 +199,11 @@
|
|||
<TextBlock Text="{Binding GuiLaunchDisabledReason}"
|
||||
Classes="muted"
|
||||
TextWrapping="Wrap"
|
||||
IsVisible="{Binding ShowLinuxGraphicalNotice}" />
|
||||
IsVisible="{Binding ShowGuiLaunchDisabledReason}" />
|
||||
<TextBlock Text="{Binding HeadlessLaunchDisabledReason}"
|
||||
Classes="muted"
|
||||
TextWrapping="Wrap"
|
||||
IsVisible="{Binding IsFirstRunRequired}" />
|
||||
IsVisible="{Binding ShowHeadlessLaunchDisabledReason}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
|
@ -260,6 +267,10 @@
|
|||
<Border Grid.RowSpan="4"
|
||||
ZIndex="20"
|
||||
Background="#C010151D"
|
||||
Focusable="True"
|
||||
KeyboardNavigation.TabNavigation="Cycle"
|
||||
KeyDown="OnModalKeyDown"
|
||||
AutomationProperties.Name="Profile editor modal dialog"
|
||||
IsVisible="{Binding EditorDialog.IsOpen}">
|
||||
<Border Classes="card"
|
||||
Width="480"
|
||||
|
|
@ -271,26 +282,36 @@
|
|||
|
||||
<StackPanel IsVisible="{Binding EditorDialog.IsServerEditor}" Spacing="6">
|
||||
<TextBlock Text="Name" />
|
||||
<TextBox Text="{Binding EditorDialog.Name, Mode=TwoWay}" />
|
||||
<TextBox x:Name="ServerNameTextBox"
|
||||
AutomationProperties.Name="Server name"
|
||||
Text="{Binding EditorDialog.Name, Mode=TwoWay}" />
|
||||
<TextBlock Text="Host" />
|
||||
<TextBox Text="{Binding EditorDialog.Host, Mode=TwoWay}" />
|
||||
<TextBox AutomationProperties.Name="Server host"
|
||||
Text="{Binding EditorDialog.Host, Mode=TwoWay}" />
|
||||
<TextBlock Text="Port" />
|
||||
<TextBox Text="{Binding EditorDialog.Port, Mode=TwoWay}" />
|
||||
<TextBox AutomationProperties.Name="Server port"
|
||||
Text="{Binding EditorDialog.Port, Mode=TwoWay}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel IsVisible="{Binding EditorDialog.IsAccountEditor}" Spacing="6">
|
||||
<TextBlock Text="Account name" />
|
||||
<TextBox Text="{Binding EditorDialog.Name, Mode=TwoWay}" />
|
||||
<TextBox x:Name="AccountNameTextBox"
|
||||
AutomationProperties.Name="Account name"
|
||||
Text="{Binding EditorDialog.Name, Mode=TwoWay}" />
|
||||
<TextBlock Text="Password" />
|
||||
<TextBox Text="{Binding EditorDialog.Password, Mode=TwoWay}"
|
||||
AutomationProperties.Name="Account password"
|
||||
PasswordChar="●" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel IsVisible="{Binding EditorDialog.IsCharacterEditor}" Spacing="6">
|
||||
<TextBlock Text="Character name" />
|
||||
<TextBox Text="{Binding EditorDialog.Name, Mode=TwoWay}" />
|
||||
<TextBox x:Name="CharacterNameTextBox"
|
||||
AutomationProperties.Name="Character name"
|
||||
Text="{Binding EditorDialog.Name, Mode=TwoWay}" />
|
||||
<TextBlock Text="Character id (optional, 0x-prefixed)" />
|
||||
<TextBox Text="{Binding EditorDialog.CharacterId, Mode=TwoWay}" />
|
||||
<TextBox AutomationProperties.Name="Character id"
|
||||
Text="{Binding EditorDialog.CharacterId, Mode=TwoWay}" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="{Binding EditorDialog.Error}"
|
||||
|
|
@ -298,9 +319,15 @@
|
|||
IsVisible="{Binding EditorDialog.HasError}"
|
||||
TextWrapping="Wrap" />
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
|
||||
<Button Content="Cancel" Command="{Binding EditorDialog.CancelCommand}" />
|
||||
<Button Content="{Binding EditorDialog.SubmitText}"
|
||||
<Button Content="Cancel"
|
||||
IsCancel="True"
|
||||
AutomationProperties.Name="Cancel profile change"
|
||||
Command="{Binding EditorDialog.CancelCommand}" />
|
||||
<Button x:Name="EditorSubmitButton"
|
||||
Content="{Binding EditorDialog.SubmitText}"
|
||||
Classes="primary"
|
||||
IsDefault="True"
|
||||
AutomationProperties.Name="Confirm profile change"
|
||||
Command="{Binding EditorDialog.SubmitCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
|
@ -310,6 +337,10 @@
|
|||
<Border Grid.RowSpan="4"
|
||||
ZIndex="30"
|
||||
Background="#C010151D"
|
||||
Focusable="True"
|
||||
KeyboardNavigation.TabNavigation="Cycle"
|
||||
KeyDown="OnModalKeyDown"
|
||||
AutomationProperties.Name="First-run setup modal dialog"
|
||||
IsVisible="{Binding FirstRunWizardShell.IsOpen}">
|
||||
<Border Classes="card" Width="560" HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<StackPanel Spacing="12">
|
||||
|
|
@ -318,8 +349,12 @@
|
|||
<Border Background="#24344B" Padding="10" CornerRadius="5">
|
||||
<TextBlock Text="{Binding FirstRunWizardShell.Status}" TextWrapping="Wrap" />
|
||||
</Border>
|
||||
<Button Content="Close"
|
||||
<Button x:Name="FirstRunCloseButton"
|
||||
Content="Close"
|
||||
HorizontalAlignment="Right"
|
||||
IsCancel="True"
|
||||
IsDefault="True"
|
||||
AutomationProperties.Name="Close first-run setup"
|
||||
Command="{Binding FirstRunWizardShell.CloseCommand}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
|
@ -328,6 +363,10 @@
|
|||
<Border Grid.RowSpan="4"
|
||||
ZIndex="30"
|
||||
Background="#C010151D"
|
||||
Focusable="True"
|
||||
KeyboardNavigation.TabNavigation="Cycle"
|
||||
KeyDown="OnModalKeyDown"
|
||||
AutomationProperties.Name="Update prompt modal dialog"
|
||||
IsVisible="{Binding UpdatePromptShell.IsOpen}">
|
||||
<Border Classes="card" Width="560" HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<StackPanel Spacing="12">
|
||||
|
|
@ -336,8 +375,12 @@
|
|||
<Border Background="#24344B" Padding="10" CornerRadius="5">
|
||||
<TextBlock Text="{Binding UpdatePromptShell.Status}" TextWrapping="Wrap" />
|
||||
</Border>
|
||||
<Button Content="Close"
|
||||
<Button x:Name="UpdateCloseButton"
|
||||
Content="Close"
|
||||
HorizontalAlignment="Right"
|
||||
IsCancel="True"
|
||||
IsDefault="True"
|
||||
AutomationProperties.Name="Close update prompt"
|
||||
Command="{Binding UpdatePromptShell.CloseCommand}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
using System.ComponentModel;
|
||||
using AcDream.Launcher.ViewModels;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
|
||||
|
|
@ -8,6 +10,9 @@ namespace AcDream.Launcher;
|
|||
public sealed partial class MainWindow : Window
|
||||
{
|
||||
private readonly DispatcherTimer _statusTimer;
|
||||
private LauncherWindowViewModel? _observedViewModel;
|
||||
private Control? _focusBeforeModal;
|
||||
private bool _wasModalOpen;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
|
|
@ -17,6 +22,7 @@ public sealed partial class MainWindow : Window
|
|||
Interval = TimeSpan.FromMilliseconds(250),
|
||||
};
|
||||
_statusTimer.Tick += OnStatusTimerTick;
|
||||
DataContextChanged += OnDataContextChanged;
|
||||
Opened += OnOpened;
|
||||
Closed += OnClosed;
|
||||
}
|
||||
|
|
@ -27,6 +33,8 @@ public sealed partial class MainWindow : Window
|
|||
{
|
||||
_statusTimer.Stop();
|
||||
_statusTimer.Tick -= OnStatusTimerTick;
|
||||
DataContextChanged -= OnDataContextChanged;
|
||||
ObserveViewModel(null);
|
||||
Opened -= OnOpened;
|
||||
Closed -= OnClosed;
|
||||
}
|
||||
|
|
@ -38,4 +46,92 @@ public sealed partial class MainWindow : Window
|
|||
viewModel.PollStatus();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDataContextChanged(object? sender, EventArgs e) =>
|
||||
ObserveViewModel(DataContext as LauncherWindowViewModel);
|
||||
|
||||
private void ObserveViewModel(LauncherWindowViewModel? viewModel)
|
||||
{
|
||||
if (ReferenceEquals(_observedViewModel, viewModel))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_observedViewModel is not null)
|
||||
{
|
||||
_observedViewModel.PropertyChanged -= OnViewModelPropertyChanged;
|
||||
}
|
||||
|
||||
_observedViewModel = viewModel;
|
||||
if (_observedViewModel is not null)
|
||||
{
|
||||
_observedViewModel.PropertyChanged += OnViewModelPropertyChanged;
|
||||
}
|
||||
|
||||
_wasModalOpen = viewModel?.IsModalOpen == true;
|
||||
}
|
||||
|
||||
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName != nameof(LauncherWindowViewModel.IsModalOpen)
|
||||
|| sender is not LauncherWindowViewModel viewModel)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool isModalOpen = viewModel.IsModalOpen;
|
||||
if (isModalOpen && !_wasModalOpen)
|
||||
{
|
||||
_focusBeforeModal = TopLevel.GetTopLevel(this)?.FocusManager?.GetFocusedElement() as Control;
|
||||
Dispatcher.UIThread.Post(() => FocusActiveModal(viewModel));
|
||||
}
|
||||
else if (!isModalOpen && _wasModalOpen)
|
||||
{
|
||||
Control? focusToRestore = _focusBeforeModal;
|
||||
_focusBeforeModal = null;
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (focusToRestore?.Focus() != true)
|
||||
{
|
||||
ProfilesTree.Focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_wasModalOpen = isModalOpen;
|
||||
}
|
||||
|
||||
private void FocusActiveModal(LauncherWindowViewModel viewModel)
|
||||
{
|
||||
if (viewModel.EditorDialog.IsOpen)
|
||||
{
|
||||
Control target = viewModel.EditorDialog.Kind switch
|
||||
{
|
||||
ProfileEditorKind.AddServer or ProfileEditorKind.EditServer => ServerNameTextBox,
|
||||
ProfileEditorKind.AddAccount or ProfileEditorKind.EditAccount => AccountNameTextBox,
|
||||
ProfileEditorKind.AddCharacter or ProfileEditorKind.EditCharacter => CharacterNameTextBox,
|
||||
_ => EditorSubmitButton,
|
||||
};
|
||||
target.Focus();
|
||||
}
|
||||
else if (viewModel.FirstRunWizardShell.IsOpen)
|
||||
{
|
||||
FirstRunCloseButton.Focus();
|
||||
}
|
||||
else if (viewModel.UpdatePromptShell.IsOpen)
|
||||
{
|
||||
UpdateCloseButton.Focus();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnModalKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key != Key.Escape || DataContext is not LauncherWindowViewModel viewModel)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
viewModel.CloseActiveModal();
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,18 @@ namespace AcDream.Launcher;
|
|||
internal static class Program
|
||||
{
|
||||
[STAThread]
|
||||
public static void Main(string[] args) =>
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
public static int Main(string[] args)
|
||||
{
|
||||
if (args is ["--verify-publish"])
|
||||
{
|
||||
// A display-free execution probe for the packaged artifact. CI
|
||||
// runs this with DOTNET_ROOT pointing at a missing directory; a
|
||||
// framework-dependent publish cannot reach this return statement.
|
||||
return 0;
|
||||
}
|
||||
|
||||
return BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
}
|
||||
|
||||
public static AppBuilder BuildAvaloniaApp() =>
|
||||
AppBuilder.Configure<App>()
|
||||
|
|
|
|||
|
|
@ -27,7 +27,13 @@ public sealed class RelayCommand : ICommand
|
|||
|
||||
public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;
|
||||
|
||||
public void Execute(object? parameter) => _execute(parameter);
|
||||
public void Execute(object? parameter)
|
||||
{
|
||||
if (CanExecute(parameter))
|
||||
{
|
||||
_execute(parameter);
|
||||
}
|
||||
}
|
||||
|
||||
public void NotifyCanExecuteChanged() =>
|
||||
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ public sealed class LauncherSessionRowViewModel
|
|||
{
|
||||
public LauncherSessionRowViewModel(
|
||||
LauncherSessionSnapshot snapshot,
|
||||
Func<string, Task> stop)
|
||||
Func<string, Task> stop,
|
||||
Func<bool>? canStop = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
ArgumentNullException.ThrowIfNull(stop);
|
||||
|
|
@ -24,7 +25,7 @@ public sealed class LauncherSessionRowViewModel
|
|||
IsActive = snapshot.IsActive;
|
||||
StopCommand = new AsyncRelayCommand(
|
||||
() => stop(SessionId),
|
||||
() => IsActive);
|
||||
() => IsActive && (canStop?.Invoke() ?? true));
|
||||
}
|
||||
|
||||
public string SessionId { get; }
|
||||
|
|
@ -44,4 +45,6 @@ public sealed class LauncherSessionRowViewModel
|
|||
public bool IsActive { get; }
|
||||
|
||||
public AsyncRelayCommand StopCommand { get; }
|
||||
|
||||
public void NotifyCommandState() => StopCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,16 @@ public sealed class LauncherShellViewModel : ObservableObject
|
|||
{
|
||||
private bool _isOpen;
|
||||
|
||||
public LauncherShellViewModel(string title, string body, string status)
|
||||
public LauncherShellViewModel(
|
||||
string title,
|
||||
string body,
|
||||
string status,
|
||||
Func<bool>? canOpen = null)
|
||||
{
|
||||
Title = title;
|
||||
Body = body;
|
||||
Status = status;
|
||||
OpenCommand = new RelayCommand(() => IsOpen = true);
|
||||
OpenCommand = new RelayCommand(() => IsOpen = true, canOpen);
|
||||
CloseCommand = new RelayCommand(() => IsOpen = false);
|
||||
}
|
||||
|
||||
|
|
@ -28,4 +32,10 @@ public sealed class LauncherShellViewModel : ObservableObject
|
|||
public RelayCommand OpenCommand { get; }
|
||||
|
||||
public RelayCommand CloseCommand { get; }
|
||||
|
||||
public void NotifyCommandStates()
|
||||
{
|
||||
OpenCommand.NotifyCanExecuteChanged();
|
||||
CloseCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using AcDream.Launcher.Core.Orchestration;
|
||||
using AcDream.Launcher.Core.Profiles;
|
||||
|
||||
|
|
@ -33,22 +34,28 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
"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.");
|
||||
"Installer shell ready — implementation arrives in LA9.",
|
||||
() => CanInteract);
|
||||
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.");
|
||||
"Updater shell ready — implementation arrives in LA10.",
|
||||
() => CanInteract);
|
||||
|
||||
AddServerCommand = new RelayCommand(OpenAddServerDialog, () => !IsBusy);
|
||||
EditorDialog.PropertyChanged += OnModalPropertyChanged;
|
||||
FirstRunWizardShell.PropertyChanged += OnModalPropertyChanged;
|
||||
UpdatePromptShell.PropertyChanged += OnModalPropertyChanged;
|
||||
|
||||
AddServerCommand = new RelayCommand(OpenAddServerDialog, () => CanInteract);
|
||||
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);
|
||||
() => IsCharacterSelected && CanInteract);
|
||||
RefreshCharactersCommand = new AsyncRelayCommand(
|
||||
RefreshCharactersAsync,
|
||||
() => CanProbe);
|
||||
|
|
@ -58,15 +65,18 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
LaunchGuiSelectCommand = new AsyncRelayCommand(
|
||||
() => LaunchSelectedAsync(LaunchMode.GuiSelect),
|
||||
() => CanLaunchGuiSelect);
|
||||
LaunchAccountGuiSelectCommand = new AsyncRelayCommand(
|
||||
LaunchSelectedAccountGuiSelectAsync,
|
||||
() => CanLaunchAccountGuiSelect);
|
||||
LaunchHeadlessCommand = new AsyncRelayCommand(
|
||||
() => LaunchSelectedAsync(LaunchMode.Headless),
|
||||
() => CanLaunchHeadless);
|
||||
CancelOperationCommand = new RelayCommand(
|
||||
CancelOperation,
|
||||
() => IsBusy && _operationCancellation is not null);
|
||||
() => !IsModalOpen && IsBusy && _operationCancellation is not null);
|
||||
ClearFinishedSessionsCommand = new RelayCommand(
|
||||
_orchestrator.ClearFinishedSessions,
|
||||
() => Sessions.Any(session => !session.IsActive) && !IsBusy);
|
||||
() => Sessions.Any(session => !session.IsActive) && CanInteract);
|
||||
}
|
||||
|
||||
public ObservableCollection<LauncherTreeNodeViewModel> Servers { get; } = [];
|
||||
|
|
@ -99,6 +109,9 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
OnPropertyChanged(nameof(CanLaunchGui));
|
||||
OnPropertyChanged(nameof(CanLaunchGuiSelect));
|
||||
OnPropertyChanged(nameof(CanLaunchHeadless));
|
||||
OnPropertyChanged(nameof(CanLaunchAccountGuiSelect));
|
||||
OnPropertyChanged(nameof(ShowGuiLaunchDisabledReason));
|
||||
OnPropertyChanged(nameof(ShowHeadlessLaunchDisabledReason));
|
||||
NotifyCommandStates();
|
||||
}
|
||||
}
|
||||
|
|
@ -118,6 +131,13 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
|
||||
public bool HasError => !string.IsNullOrWhiteSpace(LastError);
|
||||
|
||||
public bool IsModalOpen =>
|
||||
EditorDialog.IsOpen
|
||||
|| FirstRunWizardShell.IsOpen
|
||||
|| UpdatePromptShell.IsOpen;
|
||||
|
||||
private bool CanInteract => !IsBusy && !IsModalOpen;
|
||||
|
||||
public string OperationStatus
|
||||
{
|
||||
get => _operationStatus;
|
||||
|
|
@ -172,7 +192,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
_snapshot?.Platform.GraphicalLaunchDisabledReason ?? string.Empty;
|
||||
|
||||
public bool CanProbe =>
|
||||
!IsBusy
|
||||
CanInteract
|
||||
&& TryGetSelectedAccount(out string server, out string account)
|
||||
&& _orchestrator.GetProbeCapability(server, account).IsAvailable;
|
||||
|
||||
|
|
@ -196,14 +216,49 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
|
||||
public bool CanLaunchHeadless => CanLaunch(LaunchMode.Headless);
|
||||
|
||||
public bool CanLaunchAccountGuiSelect =>
|
||||
CanInteract
|
||||
&& IsAccountSelected
|
||||
&& TryGetSelectedAccount(out string server, out string account)
|
||||
&& _orchestrator.GetAccountLaunchCapability(
|
||||
server,
|
||||
account,
|
||||
LaunchMode.GuiSelect).IsAvailable;
|
||||
|
||||
public string AccountGuiSelectDisabledReason
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!TryGetSelectedAccount(out string server, out string account))
|
||||
{
|
||||
return "Select an account.";
|
||||
}
|
||||
|
||||
return _orchestrator.GetAccountLaunchCapability(
|
||||
server,
|
||||
account,
|
||||
LaunchMode.GuiSelect).Reason
|
||||
?? "Character-select launch is available.";
|
||||
}
|
||||
}
|
||||
|
||||
public string GuiLaunchDisabledReason =>
|
||||
_orchestrator.GetLaunchCapability(LaunchMode.Gui).Reason
|
||||
GetSelectedAccountLaunchCapability(LaunchMode.Gui).Reason
|
||||
?? "Graphical launch is available.";
|
||||
|
||||
public string HeadlessLaunchDisabledReason =>
|
||||
_orchestrator.GetLaunchCapability(LaunchMode.Headless).Reason
|
||||
GetSelectedAccountLaunchCapability(LaunchMode.Headless).Reason
|
||||
?? "Headless launch is available.";
|
||||
|
||||
public bool ShowGuiLaunchDisabledReason =>
|
||||
IsCharacterSelected
|
||||
&& (!GetSelectedAccountLaunchCapability(LaunchMode.Gui).IsAvailable
|
||||
|| !GetSelectedAccountLaunchCapability(LaunchMode.GuiSelect).IsAvailable);
|
||||
|
||||
public bool ShowHeadlessLaunchDisabledReason =>
|
||||
IsCharacterSelected
|
||||
&& !GetSelectedAccountLaunchCapability(LaunchMode.Headless).IsAvailable;
|
||||
|
||||
public RelayCommand AddServerCommand { get; }
|
||||
|
||||
public RelayCommand AddAccountCommand { get; }
|
||||
|
|
@ -222,6 +277,8 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
|
||||
public AsyncRelayCommand LaunchGuiSelectCommand { get; }
|
||||
|
||||
public AsyncRelayCommand LaunchAccountGuiSelectCommand { get; }
|
||||
|
||||
public AsyncRelayCommand LaunchHeadlessCommand { get; }
|
||||
|
||||
public RelayCommand CancelOperationCommand { get; }
|
||||
|
|
@ -272,6 +329,9 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
_operationCancellation?.Dispose();
|
||||
_operationCancellation = null;
|
||||
_orchestrator.StateChanged -= OnOrchestratorStateChanged;
|
||||
EditorDialog.PropertyChanged -= OnModalPropertyChanged;
|
||||
FirstRunWizardShell.PropertyChanged -= OnModalPropertyChanged;
|
||||
UpdatePromptShell.PropertyChanged -= OnModalPropertyChanged;
|
||||
}
|
||||
|
||||
private void OnOrchestratorStateChanged(object? sender, EventArgs e) =>
|
||||
|
|
@ -283,6 +343,41 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
}
|
||||
});
|
||||
|
||||
private void OnModalPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName != nameof(ProfileEditorDialogViewModel.IsOpen)
|
||||
&& e.PropertyName != nameof(LauncherShellViewModel.IsOpen))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(IsModalOpen));
|
||||
OnPropertyChanged(nameof(CanProbe));
|
||||
OnPropertyChanged(nameof(CanLaunchGui));
|
||||
OnPropertyChanged(nameof(CanLaunchGuiSelect));
|
||||
OnPropertyChanged(nameof(CanLaunchHeadless));
|
||||
OnPropertyChanged(nameof(CanLaunchAccountGuiSelect));
|
||||
OnPropertyChanged(nameof(ShowGuiLaunchDisabledReason));
|
||||
OnPropertyChanged(nameof(ShowHeadlessLaunchDisabledReason));
|
||||
NotifyCommandStates();
|
||||
}
|
||||
|
||||
public void CloseActiveModal()
|
||||
{
|
||||
if (EditorDialog.IsOpen)
|
||||
{
|
||||
EditorDialog.Close();
|
||||
}
|
||||
else if (FirstRunWizardShell.IsOpen)
|
||||
{
|
||||
FirstRunWizardShell.IsOpen = false;
|
||||
}
|
||||
else if (UpdatePromptShell.IsOpen)
|
||||
{
|
||||
UpdatePromptShell.IsOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshFromCore(SelectionKey? preferredSelection = null)
|
||||
{
|
||||
SelectionKey? previousSelection = preferredSelection ?? SelectionKey.From(SelectedNode);
|
||||
|
|
@ -298,7 +393,10 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
Sessions.Clear();
|
||||
foreach (LauncherSessionSnapshot session in snapshot.Sessions)
|
||||
{
|
||||
Sessions.Add(new LauncherSessionRowViewModel(session, StopSessionAsync));
|
||||
Sessions.Add(new LauncherSessionRowViewModel(
|
||||
session,
|
||||
StopSessionAsync,
|
||||
() => CanInteract));
|
||||
}
|
||||
|
||||
LauncherTreeNodeViewModel? restored = previousSelection is null
|
||||
|
|
@ -317,8 +415,12 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
OnPropertyChanged(nameof(CanLaunchGui));
|
||||
OnPropertyChanged(nameof(CanLaunchGuiSelect));
|
||||
OnPropertyChanged(nameof(CanLaunchHeadless));
|
||||
OnPropertyChanged(nameof(CanLaunchAccountGuiSelect));
|
||||
OnPropertyChanged(nameof(AccountGuiSelectDisabledReason));
|
||||
OnPropertyChanged(nameof(GuiLaunchDisabledReason));
|
||||
OnPropertyChanged(nameof(HeadlessLaunchDisabledReason));
|
||||
OnPropertyChanged(nameof(ShowGuiLaunchDisabledReason));
|
||||
OnPropertyChanged(nameof(ShowHeadlessLaunchDisabledReason));
|
||||
NotifyCommandStates();
|
||||
}
|
||||
|
||||
|
|
@ -353,6 +455,12 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
OnPropertyChanged(nameof(CanLaunchGui));
|
||||
OnPropertyChanged(nameof(CanLaunchGuiSelect));
|
||||
OnPropertyChanged(nameof(CanLaunchHeadless));
|
||||
OnPropertyChanged(nameof(CanLaunchAccountGuiSelect));
|
||||
OnPropertyChanged(nameof(AccountGuiSelectDisabledReason));
|
||||
OnPropertyChanged(nameof(GuiLaunchDisabledReason));
|
||||
OnPropertyChanged(nameof(HeadlessLaunchDisabledReason));
|
||||
OnPropertyChanged(nameof(ShowGuiLaunchDisabledReason));
|
||||
OnPropertyChanged(nameof(ShowHeadlessLaunchDisabledReason));
|
||||
NotifyCommandStates();
|
||||
}
|
||||
|
||||
|
|
@ -387,7 +495,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
null));
|
||||
});
|
||||
|
||||
private bool CanAddAccount() => !IsBusy && SelectedNode is not null;
|
||||
private bool CanAddAccount() => CanInteract && SelectedNode is not null;
|
||||
|
||||
private void OpenAddAccountDialog()
|
||||
{
|
||||
|
|
@ -411,7 +519,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
}
|
||||
|
||||
private bool CanAddCharacter() =>
|
||||
!IsBusy && TryGetSelectedAccount(out _, out _);
|
||||
CanInteract && TryGetSelectedAccount(out _, out _);
|
||||
|
||||
private void OpenAddCharacterDialog()
|
||||
{
|
||||
|
|
@ -441,7 +549,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
+ "let you configure a known character while the server is unavailable.");
|
||||
}
|
||||
|
||||
private bool CanEditSelected() => !IsBusy && SelectedNode is not null;
|
||||
private bool CanEditSelected() => CanInteract && SelectedNode is not null;
|
||||
|
||||
private void OpenEditSelectedDialog()
|
||||
{
|
||||
|
|
@ -642,6 +750,24 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
$"{mode} session started for {character.Name}.");
|
||||
}
|
||||
|
||||
private Task LaunchSelectedAccountGuiSelectAsync()
|
||||
{
|
||||
if (!TryGetSelectedAccount(out string serverName, out string accountName))
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return RunOperationAsync(
|
||||
token => _orchestrator.LaunchAsync(
|
||||
serverName,
|
||||
accountName,
|
||||
null,
|
||||
LaunchMode.GuiSelect,
|
||||
token),
|
||||
$"Opening character select for {accountName}…",
|
||||
$"Character-select session started for {accountName}.");
|
||||
}
|
||||
|
||||
private async Task RunOperationAsync(
|
||||
Func<CancellationToken, Task<LauncherSessionSnapshot>> operation,
|
||||
string activeStatus,
|
||||
|
|
@ -728,9 +854,15 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
private void CancelOperation() => _operationCancellation?.Cancel();
|
||||
|
||||
private bool CanLaunch(LaunchMode mode) =>
|
||||
!IsBusy
|
||||
CanInteract
|
||||
&& IsCharacterSelected
|
||||
&& _orchestrator.GetLaunchCapability(mode).IsAvailable;
|
||||
&& TryGetSelectedAccount(out string server, out string account)
|
||||
&& _orchestrator.GetAccountLaunchCapability(server, account, mode).IsAvailable;
|
||||
|
||||
private LauncherCapability GetSelectedAccountLaunchCapability(LaunchMode mode) =>
|
||||
TryGetSelectedAccount(out string server, out string account)
|
||||
? _orchestrator.GetAccountLaunchCapability(server, account, mode)
|
||||
: _orchestrator.GetLaunchCapability(mode);
|
||||
|
||||
private bool TryGetSelectedAccount(out string serverName, out string accountName)
|
||||
{
|
||||
|
|
@ -843,9 +975,16 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
RefreshCharactersCommand.NotifyCanExecuteChanged();
|
||||
LaunchGuiCommand.NotifyCanExecuteChanged();
|
||||
LaunchGuiSelectCommand.NotifyCanExecuteChanged();
|
||||
LaunchAccountGuiSelectCommand.NotifyCanExecuteChanged();
|
||||
LaunchHeadlessCommand.NotifyCanExecuteChanged();
|
||||
CancelOperationCommand.NotifyCanExecuteChanged();
|
||||
ClearFinishedSessionsCommand.NotifyCanExecuteChanged();
|
||||
foreach (LauncherSessionRowViewModel session in Sessions)
|
||||
{
|
||||
session.NotifyCommandState();
|
||||
}
|
||||
FirstRunWizardShell.NotifyCommandStates();
|
||||
UpdatePromptShell.NotifyCommandStates();
|
||||
}
|
||||
|
||||
private readonly record struct SelectionKey(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue