Public Access
Desktop: give the host-key, delete and sign-out questions the keyboard on their harmless answer, and let Escape say no
This commit is contained in:
1 parent
372a7cbdc7
commit
6c09bdecb6
11 files changed
+498
-92
No files matched your search
@@ -59,7 +59,7 @@
|
|||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
<Button Classes="danger" Content="DELETE" Command="{Binding ConfirmDeleteCommand}"
|
<Button Classes="danger" Content="DELETE" Command="{Binding ConfirmDeleteCommand}"
|
||||||
IsEnabled="{Binding !IsBusy}" />
|
IsEnabled="{Binding !IsBusy}" />
|
||||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteCommand}" />
|
<Button x:Name="CancelButton" Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteCommand}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
namespace DodoSSH.Client.App.Views;
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
@@ -12,5 +13,10 @@ namespace DodoSSH.Client.App.Views;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed partial class ConfirmDeleteCard : UserControl
|
internal sealed partial class ConfirmDeleteCard : UserControl
|
||||||
{
|
{
|
||||||
public ConfirmDeleteCard() => InitializeComponent();
|
public ConfirmDeleteCard()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
SafeAnswerFocus.Follow(this, nameof(VaultViewModel.PendingDeletion), () => CancelButton);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -45,7 +45,11 @@
|
|||||||
80% of Canvas, written out because a scrim is a brush with an alpha and the palette holds no alpha
|
80% of Canvas, written out because a scrim is a brush with an alpha and the palette holds no alpha
|
||||||
variant of a surface; the pre-multiplied ones there are accent washes.
|
variant of a surface; the pre-multiplied ones there are accent washes.
|
||||||
-->
|
-->
|
||||||
<Border Background="#CC05050A">
|
<!--
|
||||||
|
Tab cycles inside the card rather than walking the page under the wash, which it used to: a question
|
||||||
|
that cannot be answered by clicking away should not be answerable by tabbing away either.
|
||||||
|
-->
|
||||||
|
<Border Background="#CC05050A" KeyboardNavigation.TabNavigation="Cycle">
|
||||||
<Panel>
|
<Panel>
|
||||||
|
|
||||||
<!-- ============ UNKNOWN HOST KEY ============ -->
|
<!-- ============ UNKNOWN HOST KEY ============ -->
|
||||||
@@ -83,7 +87,7 @@
|
|||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
<Button Classes="accent" Content="TRUST AND CONNECT"
|
<Button Classes="accent" Content="TRUST AND CONNECT"
|
||||||
Command="{Binding TrustHostKeyCommand}" />
|
Command="{Binding TrustHostKeyCommand}" />
|
||||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding RejectHostKeyCommand}" />
|
<Button x:Name="CancelButton" Classes="ghost" Content="CANCEL" Command="{Binding RejectHostKeyCommand}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -119,7 +123,7 @@
|
|||||||
is not an answer, because this half of the control has none to give. Compare the accent button
|
is not an answer, because this half of the control has none to give. Compare the accent button
|
||||||
above, which connects.
|
above, which connects.
|
||||||
-->
|
-->
|
||||||
<Button Classes="ghost" Content="CLOSE" HorizontalAlignment="Left"
|
<Button x:Name="CloseButton" Classes="ghost" Content="CLOSE" HorizontalAlignment="Left"
|
||||||
Command="{Binding RejectHostKeyCommand}" />
|
Command="{Binding RejectHostKeyCommand}" />
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -17,4 +17,14 @@ namespace DodoSSH.Client.App.Views;
|
|||||||
internal sealed partial class HostKeyCard : UserControl
|
internal sealed partial class HostKeyCard : UserControl
|
||||||
{
|
{
|
||||||
public HostKeyCard() => InitializeComponent();
|
public HostKeyCard() => InitializeComponent();
|
||||||
|
|
||||||
|
/// <summary>The answer the keyboard is given when the card appears: CANCEL, or CLOSE for a changed key.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Never TRUST AND CONNECT, for the reason <see cref="SafeAnswerFocus"/> gives about every confirmation
|
||||||
|
/// and with more force here: an Enter meant for whatever was focused before the card appeared must not
|
||||||
|
/// be what pins a key nobody has compared. The window moves the keyboard rather than the card, because
|
||||||
|
/// this one is raised over a terminal that may be holding it natively — see
|
||||||
|
/// <c>MainWindow.OnHostKeyDecision</c>.
|
||||||
|
/// </remarks>
|
||||||
|
internal Control SafeAnswer => CancelButton.IsEffectivelyVisible ? CancelButton : CloseButton;
|
||||||
}
|
}
|
||||||
@@ -36,6 +36,8 @@ internal sealed partial class MainWindow : Window
|
|||||||
/// <summary>Which closed-vault card the keyboard was last sent to, so it is sent once per card.</summary>
|
/// <summary>Which closed-vault card the keyboard was last sent to, so it is sent once per card.</summary>
|
||||||
private ShellState? closedState;
|
private ShellState? closedState;
|
||||||
|
|
||||||
|
private bool wasAskingAboutHostKey;
|
||||||
|
|
||||||
public MainWindow()
|
public MainWindow()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
@@ -230,40 +232,96 @@ internal sealed partial class MainWindow : Window
|
|||||||
size.Execute(null);
|
size.Execute(null);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
|
// A host-key decision before the palette, because it is drawn over the palette: Escape is CANCEL or
|
||||||
|
// CLOSE, both of which refuse — never the answer that connects. See HostKeyCard.SafeAnswer.
|
||||||
|
else if (e.Key == Key.Escape && viewModel.IsHostKeyDecisionShowing)
|
||||||
|
{
|
||||||
|
viewModel.Vault?.RejectHostKeyCommand.Execute(null);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
else if (viewModel.IsSearching)
|
else if (viewModel.IsSearching)
|
||||||
{
|
{
|
||||||
Palette.HandleKey(e);
|
Palette.HandleKey(e);
|
||||||
}
|
}
|
||||||
// v5c: Escape leaves settings mode, the same full-window-state idiom the palette's own Escape
|
else if (e.Key == Key.Escape && CloseTheNearestThing(viewModel))
|
||||||
// already follows one branch up. Checked after the palette rather than before it: the two states
|
|
||||||
// are mutually exclusive in practice — opening the palette does not enter settings mode and entering
|
|
||||||
// settings does not open the palette — but an Escape while both were somehow true should close the
|
|
||||||
// thing drawn on top, which is the palette.
|
|
||||||
//
|
|
||||||
// v5c: with the importer up, Escape closes only that — the same "closest thing first" rule, and the
|
|
||||||
// same one the titlebar's own back button follows by showing "Back to preferences" rather than
|
|
||||||
// "Back to application" while IsImportOpen is true.
|
|
||||||
else if (e.Key == Key.Escape && viewModel.IsImportOpen)
|
|
||||||
{
|
{
|
||||||
viewModel.CloseImportCommand.Execute(null);
|
|
||||||
e.Handled = true;
|
|
||||||
}
|
|
||||||
// The members dialog on Settings → Vaults, by the same rule: it is drawn over the page, so it goes
|
|
||||||
// first and settings stays.
|
|
||||||
else if (e.Key == Key.Escape && viewModel.IsSettingsMode && viewModel.Vaults.IsMembersPanelOpen)
|
|
||||||
{
|
|
||||||
viewModel.Vaults.CloseMembersPanelCommand.Execute(null);
|
|
||||||
e.Handled = true;
|
|
||||||
}
|
|
||||||
else if (e.Key == Key.Escape && viewModel.IsSettingsMode)
|
|
||||||
{
|
|
||||||
viewModel.LeaveSettingsCommand.Execute(null);
|
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
base.OnKeyDown(e);
|
base.OnKeyDown(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>What Escape closes when the palette is not up: the thing drawn nearest the user.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// One rule throughout — closest thing first — so the order of these arms is the order things are drawn
|
||||||
|
/// over one another. A confirmation before anything, because it is a question in front of whatever page
|
||||||
|
/// it is on, including a settings page: Escape on the sign-out question cancels it and stays in settings.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// v5c: Escape leaves settings mode, the same full-window-state idiom the palette's own Escape already
|
||||||
|
/// follows in the caller. Checked after the palette rather than before it: the two states are mutually
|
||||||
|
/// exclusive in practice — opening the palette does not enter settings mode and entering settings does
|
||||||
|
/// not open the palette — but an Escape while both were somehow true should close the thing drawn on
|
||||||
|
/// top, which is the palette.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// v5c: with the importer up, Escape closes only that — the same rule, and the same one the titlebar's own
|
||||||
|
/// back button follows by showing "Back to preferences" rather than "Back to application" while
|
||||||
|
/// IsImportOpen is true. The members dialog on Settings → Vaults likewise: it is drawn over the page, so
|
||||||
|
/// it goes first and settings stays.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Last, because it is the furthest thing back: the hosts drawer, which Escape puts away exactly as its
|
||||||
|
/// own arrow does — an open editor abandoned, the selection kept. See VaultViewModel.CloseDrawer.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static bool CloseTheNearestThing(MainWindowViewModel viewModel)
|
||||||
|
{
|
||||||
|
if (AnswerNoToTheQuestionShowing(viewModel))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var close = viewModel switch
|
||||||
|
{
|
||||||
|
{ IsImportOpen: true } => viewModel.CloseImportCommand,
|
||||||
|
{ IsSettingsMode: true, Vaults.IsMembersPanelOpen: true } => viewModel.Vaults.CloseMembersPanelCommand,
|
||||||
|
{ IsSettingsMode: true } => viewModel.LeaveSettingsCommand,
|
||||||
|
{ IsUnlocked: true, IsTerminalShowing: false, Screen: ShellScreen.Hosts, Vault: { IsDrawerOpen: true } vault }
|
||||||
|
=> vault.CloseDrawerCommand,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
close?.Execute(null);
|
||||||
|
|
||||||
|
return close is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Escape on a confirmation drawn in place: the sign-out one, or a deletion's.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Both answers it gives are CANCEL, which is the only thing a key with no label is allowed to mean on a
|
||||||
|
/// question about destroying something. A deletion question left armed on a screen somebody navigated away
|
||||||
|
/// from is cancelled by it too, which is harmless — it is the same answer the question would get if it
|
||||||
|
/// were on screen — and cheaper than deciding which of the six places that card is drawn is showing.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool AnswerNoToTheQuestionShowing(MainWindowViewModel viewModel)
|
||||||
|
{
|
||||||
|
if (viewModel.IsConfirmingSignOut)
|
||||||
|
{
|
||||||
|
viewModel.CancelSignOutCommand.Execute(null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewModel is { IsUnlocked: true, IsTerminalShowing: false, Vault: { IsConfirmingDeletion: true } vault })
|
||||||
|
{
|
||||||
|
vault.CancelDeleteCommand.Execute(null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The text-size chords, when the terminal is not the thing hearing them.
|
/// The text-size chords, when the terminal is not the thing hearing them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -312,6 +370,7 @@ internal sealed partial class MainWindow : Window
|
|||||||
|
|
||||||
wasUnlocked = viewModel.IsUnlocked;
|
wasUnlocked = viewModel.IsUnlocked;
|
||||||
closedState = null;
|
closedState = null;
|
||||||
|
wasAskingAboutHostKey = viewModel.IsHostKeyDecisionShowing;
|
||||||
|
|
||||||
// The shell may have reached its card before this window was given it, and then no change would
|
// The shell may have reached its card before this window was given it, and then no change would
|
||||||
// ever say so.
|
// ever say so.
|
||||||
@@ -377,6 +436,10 @@ internal sealed partial class MainWindow : Window
|
|||||||
OnSelectedTabChanged(viewModel);
|
OnSelectedTabChanged(viewModel);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case nameof(MainWindowViewModel.IsHostKeyDecisionShowing):
|
||||||
|
OnHostKeyDecision(viewModel);
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -418,6 +481,51 @@ internal sealed partial class MainWindow : Window
|
|||||||
Dispatcher.UIThread.Post(() => home.Focus(), DispatcherPriority.Loaded);
|
Dispatcher.UIThread.Post(() => home.Focus(), DispatcherPriority.Loaded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Gives the host-key card the keyboard when it appears, and gives it back when it goes.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// It used to appear with the keyboard wherever it had been — often inside the terminal it covers, whose
|
||||||
|
/// native window goes on holding it after the WebView is collapsed, so the card took no keys at all and
|
||||||
|
/// Tab walked the page under it. Hence <see cref="ReleaseKeyboardTo"/> rather than <c>Focus()</c>, and
|
||||||
|
/// posted, because the binding that reveals the card has not run yet.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Latched, because the flag is re-raised with every change to the terminal's state and the keyboard must
|
||||||
|
/// move once per question, not once per raise. On the way out the keyboard goes where the window would
|
||||||
|
/// put it for whatever is now showing; a connection that went ahead moves it again when its terminal opens.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private void OnHostKeyDecision(MainWindowViewModel viewModel)
|
||||||
|
{
|
||||||
|
var asking = viewModel.IsHostKeyDecisionShowing;
|
||||||
|
|
||||||
|
if (asking == wasAskingAboutHostKey)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wasAskingAboutHostKey = asking;
|
||||||
|
|
||||||
|
Dispatcher.UIThread.Post(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
if (shell != viewModel || viewModel.IsHostKeyDecisionShowing != asking)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asking)
|
||||||
|
{
|
||||||
|
ReleaseKeyboardTo(HostKeyPane.SafeAnswer);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
OnShowingSomethingElse(viewModel);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
DispatcherPriority.Loaded);
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Closing only. Opening also has to move the keyboard — the palette is a text box somebody is expected
|
/// Closing only. Opening also has to move the keyboard — the palette is a text box somebody is expected
|
||||||
/// to start typing into immediately — but the palette does that for itself when it becomes visible,
|
/// to start typing into immediately — but the palette does that for itself when it becomes visible,
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hands the keyboard to a confirmation's harmless answer when the question appears.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// For the questions drawn in place on a page — the deletion and the sign-out confirmations — which used to
|
||||||
|
/// appear with the keyboard still on the button that asked them, or nowhere. Tab then walked the page behind
|
||||||
|
/// the question, Enter did nothing useful, and somebody working from the keyboard had to reach for the mouse
|
||||||
|
/// to say no.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>The harmless answer, never the one the question is about.</b> Enter on a focused button presses it,
|
||||||
|
/// so the button that takes the keyboard is the one an accidental Enter is allowed to press: CANCEL. Putting it
|
||||||
|
/// on DELETE or SIGN OUT AND DELETE would make two keystrokes — the one that asked and the one after — a way to
|
||||||
|
/// destroy something without reading the sentence in between.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Each card follows its own question rather than the window following all of them, because the same card is
|
||||||
|
/// drawn in several places and only the one somebody can see should take the keyboard — which the posted
|
||||||
|
/// check on <c>IsEffectivelyVisible</c> decides, after the binding that reveals it has run. Subscribed only
|
||||||
|
/// while the card is in the tree: settings pages are rebuilt as they are visited, and a card that subscribed
|
||||||
|
/// for good would leave one handler on the long-lived view model per visit.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class SafeAnswerFocus
|
||||||
|
{
|
||||||
|
internal static void Follow(Control card, string question, Func<Control> safeAnswer)
|
||||||
|
{
|
||||||
|
INotifyPropertyChanged? watched = null;
|
||||||
|
|
||||||
|
void OnChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!string.Equals(e.PropertyName, question, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Dispatcher.UIThread.Post(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
if (safeAnswer() is { IsEffectivelyVisible: true } answer)
|
||||||
|
{
|
||||||
|
answer.Focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
DispatcherPriority.Loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Watch(INotifyPropertyChanged? next)
|
||||||
|
{
|
||||||
|
if (watched is not null)
|
||||||
|
{
|
||||||
|
watched.PropertyChanged -= OnChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
watched = next;
|
||||||
|
|
||||||
|
if (watched is not null)
|
||||||
|
{
|
||||||
|
watched.PropertyChanged += OnChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
card.AttachedToVisualTree += (_, _) => Watch(card.DataContext as INotifyPropertyChanged);
|
||||||
|
card.DetachedFromVisualTree += (_, _) => Watch(null);
|
||||||
|
card.DataContextChanged += (_, _) =>
|
||||||
|
{
|
||||||
|
if (card.IsAttachedToVisualTree())
|
||||||
|
{
|
||||||
|
Watch(card.DataContext as INotifyPropertyChanged);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
<Button Classes="danger" Content="SIGN OUT AND DELETE"
|
<Button Classes="danger" Content="SIGN OUT AND DELETE"
|
||||||
Command="{Binding ConfirmSignOutCommand}" IsEnabled="{Binding !IsBusy}" />
|
Command="{Binding ConfirmSignOutCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelSignOutCommand}" />
|
<Button x:Name="CancelButton" Classes="ghost" Content="CANCEL" Command="{Binding CancelSignOutCommand}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
namespace DodoSSH.Client.App.Views;
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
/// <summary>The sign-out confirmation, shown on the preferences screen and on the unlock screen.</summary>
|
/// <summary>The sign-out confirmation, shown on the preferences screen and on the unlock screen.</summary>
|
||||||
internal sealed partial class SignOutCard : UserControl
|
internal sealed partial class SignOutCard : UserControl
|
||||||
{
|
{
|
||||||
public SignOutCard() => InitializeComponent();
|
public SignOutCard()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
SafeAnswerFocus.Follow(this, nameof(MainWindowViewModel.IsConfirmingSignOut), () => CancelButton);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,10 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using Avalonia.Threading;
|
|
||||||
using DodoSSH.Client.App.Views;
|
using DodoSSH.Client.App.Views;
|
||||||
using DodoSSH.Client.Session;
|
using DodoSSH.Client.Session;
|
||||||
using DodoSSH.Client.Session.Tests;
|
using DodoSSH.Client.Session.Tests;
|
||||||
using DodoSSH.Client.Shell.ViewModels;
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
using DodoSSH.Client.Ssh;
|
|
||||||
using DodoSSH.Client.Storage;
|
using DodoSSH.Client.Storage;
|
||||||
using DodoSSH.Client.Terminal;
|
|
||||||
using DodoSSH.Client.Transfer;
|
|
||||||
using DodoSSH.Crypto;
|
|
||||||
using NSubstitute;
|
|
||||||
|
|
||||||
namespace DodoSSH.Client.App.Layout.Tests;
|
namespace DodoSSH.Client.App.Layout.Tests;
|
||||||
|
|
||||||
@@ -24,18 +18,11 @@ namespace DodoSSH.Client.App.Layout.Tests;
|
|||||||
/// and Enter in the server address did nothing at all.
|
/// and Enter in the server address did nothing at all.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The whole window, which <see cref="LayoutHarnessTests.WhyTheWindowItselfIsNeverShown"/> says cannot be shown
|
/// The whole window, shown the way <see cref="WholeWindow"/> describes.
|
||||||
/// here: the terminal's WebView refuses this thread's apartment when it is attached. It is taken out of the
|
|
||||||
/// tree before the window is shown, which is everything that test is about and nothing these are.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class ClosedVaultFocusTests
|
public sealed class ClosedVaultFocusTests
|
||||||
{
|
{
|
||||||
private const string Passphrase = "a sufficiently long passphrase";
|
|
||||||
|
|
||||||
private static readonly Argon2Profile CheapProfile =
|
|
||||||
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
|
|
||||||
|
|
||||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -44,14 +31,14 @@ public sealed class ClosedVaultFocusTests
|
|||||||
using var caches = ClientCacheFactory.ForMemory($"focus-{Guid.CreateVersion7():N}");
|
using var caches = ClientCacheFactory.ForMemory($"focus-{Guid.CreateVersion7():N}");
|
||||||
await caches.MigrateAsync(Token);
|
await caches.MigrateAsync(Token);
|
||||||
|
|
||||||
await using var workspace = Workspace();
|
await using var workspace = WholeWindow.Workspace();
|
||||||
await using var shell = Shell(caches, workspace);
|
await using var shell = WholeWindow.Shell(caches, workspace);
|
||||||
|
|
||||||
await shell.StartAsync(Token);
|
await shell.StartAsync(Token);
|
||||||
shell.State.ShouldBe(ShellState.NeedsServer);
|
shell.State.ShouldBe(ShellState.NeedsServer);
|
||||||
|
|
||||||
await LayoutHarness.OnTheUiThreadAsync(
|
await LayoutHarness.OnTheUiThreadAsync(
|
||||||
() => WithWindow(shell, window =>
|
() => WholeWindow.Show(shell, window =>
|
||||||
{
|
{
|
||||||
var box = window.FindControl<TextBox>("ServerUrlBox").ShouldNotBeNull();
|
var box = window.FindControl<TextBox>("ServerUrlBox").ShouldNotBeNull();
|
||||||
|
|
||||||
@@ -69,59 +56,19 @@ public sealed class ClosedVaultFocusTests
|
|||||||
using var caches = ClientCacheFactory.ForMemory($"focus-{Guid.CreateVersion7():N}");
|
using var caches = ClientCacheFactory.ForMemory($"focus-{Guid.CreateVersion7():N}");
|
||||||
await caches.MigrateAsync(Token);
|
await caches.MigrateAsync(Token);
|
||||||
|
|
||||||
await new AccountProvisioner(new FakeAccountServer(), new StubKeyBinding(), caches, TimeProvider.System, CheapProfile)
|
await new AccountProvisioner(new FakeAccountServer(), new StubKeyBinding(), caches, TimeProvider.System, WholeWindow.CheapProfile)
|
||||||
.EnrollAsync("https://dodossh.example", Passphrase, "laptop", "Personal", Token);
|
.EnrollAsync("https://dodossh.example", WholeWindow.Passphrase, "laptop", "Personal", Token);
|
||||||
|
|
||||||
await using var workspace = Workspace();
|
await using var workspace = WholeWindow.Workspace();
|
||||||
await using var shell = Shell(caches, workspace);
|
await using var shell = WholeWindow.Shell(caches, workspace);
|
||||||
|
|
||||||
await shell.StartAsync(Token);
|
await shell.StartAsync(Token);
|
||||||
shell.State.ShouldBe(ShellState.Locked);
|
shell.State.ShouldBe(ShellState.Locked);
|
||||||
|
|
||||||
await LayoutHarness.OnTheUiThreadAsync(
|
await LayoutHarness.OnTheUiThreadAsync(
|
||||||
() => WithWindow(shell, window =>
|
() => WholeWindow.Show(shell, window =>
|
||||||
window.FocusManager!.GetFocusedElement()
|
window.FocusManager!.GetFocusedElement()
|
||||||
.ShouldBeSameAs(window.FindControl<UnlockCard>("UnlockPane").ShouldNotBeNull().PassphraseBox)),
|
.ShouldBeSameAs(window.FindControl<UnlockCard>("UnlockPane").ShouldNotBeNull().PassphraseBox)),
|
||||||
Token);
|
Token);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static TerminalWorkspace Workspace() =>
|
|
||||||
new(
|
|
||||||
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
|
|
||||||
Substitute.For<ISshConnectionFactory>(),
|
|
||||||
TimeProvider.System);
|
|
||||||
|
|
||||||
private static MainWindowViewModel Shell(ClientCacheFactory caches, TerminalWorkspace workspace) =>
|
|
||||||
new(
|
|
||||||
new ClientPaths(Path.Combine(Path.GetTempPath(), $"dodossh-focus-{Guid.CreateVersion7():N}")),
|
|
||||||
caches,
|
|
||||||
workspace,
|
|
||||||
new VaultKnownHostStore(),
|
|
||||||
new UnavailableDeviceKeyStore(),
|
|
||||||
static (_, _) => throw new InvalidOperationException("A layout test has no network."),
|
|
||||||
TimeProvider.System,
|
|
||||||
Substitute.For<ISftpSessionFactory>(),
|
|
||||||
CheapProfile);
|
|
||||||
|
|
||||||
private static void WithWindow(MainWindowViewModel shell, Action<MainWindow> assert)
|
|
||||||
{
|
|
||||||
var window = new MainWindow { DataContext = shell };
|
|
||||||
|
|
||||||
if (window.FindControl<NativeWebView>("Terminal") is { Parent: Panel panel } web)
|
|
||||||
{
|
|
||||||
panel.Children.Remove(web);
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
LayoutHarness.Settle(window, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
|
|
||||||
Dispatcher.UIThread.RunJobs();
|
|
||||||
|
|
||||||
assert(window);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
window.Close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Headless;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
|
using DodoSSH.Client.App.Views;
|
||||||
|
using DodoSSH.Client.Session;
|
||||||
|
using DodoSSH.Client.Session.Tests;
|
||||||
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
|
using DodoSSH.Client.Ssh;
|
||||||
|
using DodoSSH.Client.Storage;
|
||||||
|
using DodoSSH.Client.Terminal;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Layout.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The keyboard on the questions the window asks over itself: where it is when one appears, and what Escape
|
||||||
|
/// answers.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// They used to appear with the keyboard wherever it had been, Escape did nothing, and Tab walked the page
|
||||||
|
/// behind them. The answer they are given here is always the harmless one — see <see cref="SafeAnswerFocus"/>
|
||||||
|
/// — and that is what each test pins: not merely that focus moved, but which button it moved to.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ConfirmationKeyboardTests
|
||||||
|
{
|
||||||
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheSignOutQuestion_TakesTheKeyboardOnCancel_AndEscapeSaysNo()
|
||||||
|
{
|
||||||
|
using var caches = await EnrolledAsync();
|
||||||
|
|
||||||
|
await using var workspace = WholeWindow.Workspace();
|
||||||
|
await using var shell = WholeWindow.Shell(caches, workspace);
|
||||||
|
|
||||||
|
await shell.StartAsync(Token);
|
||||||
|
|
||||||
|
await LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() => WholeWindow.Show(shell, window =>
|
||||||
|
{
|
||||||
|
shell.SignOutCommand.Execute(null);
|
||||||
|
Dispatcher.UIThread.RunJobs();
|
||||||
|
|
||||||
|
var card = window.GetVisualDescendants().OfType<SignOutCard>().Single(card => card.IsEffectivelyVisible);
|
||||||
|
|
||||||
|
window.FocusManager!.GetFocusedElement().ShouldBeSameAs(card.FindControl<Button>("CancelButton"));
|
||||||
|
|
||||||
|
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
|
||||||
|
|
||||||
|
shell.IsConfirmingSignOut.ShouldBeFalse();
|
||||||
|
}),
|
||||||
|
Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AnUnknownHostKey_TakesTheKeyboardOnCancel_KeepsTabInside_AndEscapeRefuses()
|
||||||
|
{
|
||||||
|
using var caches = await EnrolledAsync();
|
||||||
|
|
||||||
|
await using var workspace = WholeWindow.Workspace();
|
||||||
|
await using var shell = await UnlockedAsync(caches, workspace);
|
||||||
|
|
||||||
|
var vault = shell.Vault.ShouldNotBeNull();
|
||||||
|
|
||||||
|
await LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() => WholeWindow.Show(shell, window =>
|
||||||
|
{
|
||||||
|
vault.PendingHostKey = new HostKeyPresentation("web.example", 22, "ssh-ed25519", "SHA256:abc");
|
||||||
|
Dispatcher.UIThread.RunJobs();
|
||||||
|
|
||||||
|
var card = window.FindControl<HostKeyCard>("HostKeyPane").ShouldNotBeNull();
|
||||||
|
var focused = window.FocusManager!.GetFocusedElement().ShouldBeOfType<Button>();
|
||||||
|
|
||||||
|
focused.Content.ShouldBe("CANCEL");
|
||||||
|
|
||||||
|
for (var press = 0; press < 4; press++)
|
||||||
|
{
|
||||||
|
window.KeyPressQwerty(PhysicalKey.Tab, RawInputModifiers.None);
|
||||||
|
|
||||||
|
window.FocusManager.GetFocusedElement().ShouldBeAssignableTo<Visual>()
|
||||||
|
.ShouldNotBeNull().GetVisualAncestors().ShouldContain(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
|
||||||
|
|
||||||
|
vault.HasPendingHostKey.ShouldBeFalse();
|
||||||
|
}),
|
||||||
|
Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ADeletionQuestion_TakesTheKeyboardOnCancel_AndEscapeSaysNo()
|
||||||
|
{
|
||||||
|
using var caches = await EnrolledAsync();
|
||||||
|
|
||||||
|
await using var workspace = WholeWindow.Workspace();
|
||||||
|
await using var shell = await UnlockedAsync(caches, workspace);
|
||||||
|
|
||||||
|
var vault = shell.Vault.ShouldNotBeNull();
|
||||||
|
|
||||||
|
await LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() => WholeWindow.Show(shell, window =>
|
||||||
|
{
|
||||||
|
shell.EnterSettingsCommand.Execute(SettingsPage.Tags);
|
||||||
|
Dispatcher.UIThread.RunJobs();
|
||||||
|
|
||||||
|
vault.PendingDeletion = new DeletionRequest(
|
||||||
|
DeletionTarget.Tag, Guid.CreateVersion7(), "Delete the tag prod?", "It goes.", string.Empty);
|
||||||
|
Dispatcher.UIThread.RunJobs();
|
||||||
|
|
||||||
|
var card = window.GetVisualDescendants().OfType<ConfirmDeleteCard>().Single(card => card.IsEffectivelyVisible);
|
||||||
|
|
||||||
|
window.FocusManager!.GetFocusedElement().ShouldBeSameAs(card.FindControl<Button>("CancelButton"));
|
||||||
|
|
||||||
|
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
|
||||||
|
|
||||||
|
vault.IsConfirmingDeletion.ShouldBeFalse();
|
||||||
|
shell.IsSettingsMode.ShouldBeTrue();
|
||||||
|
}),
|
||||||
|
Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EscapeOnTheHostsScreen_PutsTheDrawerAway_LikeItsOwnArrow()
|
||||||
|
{
|
||||||
|
using var caches = await EnrolledAsync();
|
||||||
|
|
||||||
|
await using var workspace = WholeWindow.Workspace();
|
||||||
|
await using var shell = await UnlockedAsync(caches, workspace);
|
||||||
|
|
||||||
|
var vault = shell.Vault.ShouldNotBeNull();
|
||||||
|
|
||||||
|
await LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() => WholeWindow.Show(shell, window =>
|
||||||
|
{
|
||||||
|
vault.NewHostCommand.Execute(null);
|
||||||
|
Dispatcher.UIThread.RunJobs();
|
||||||
|
|
||||||
|
vault.IsDrawerOpen.ShouldBeTrue();
|
||||||
|
|
||||||
|
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
|
||||||
|
|
||||||
|
vault.IsDrawerOpen.ShouldBeFalse();
|
||||||
|
vault.IsEditing.ShouldBeFalse();
|
||||||
|
}),
|
||||||
|
Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<ClientCacheFactory> EnrolledAsync()
|
||||||
|
{
|
||||||
|
var caches = ClientCacheFactory.ForMemory($"confirm-{Guid.CreateVersion7():N}");
|
||||||
|
await caches.MigrateAsync(Token);
|
||||||
|
|
||||||
|
await new AccountProvisioner(new FakeAccountServer(), new StubKeyBinding(), caches, TimeProvider.System, WholeWindow.CheapProfile)
|
||||||
|
.EnrollAsync("https://dodossh.example", WholeWindow.Passphrase, "laptop", "Personal", Token);
|
||||||
|
|
||||||
|
return caches;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<MainWindowViewModel> UnlockedAsync(ClientCacheFactory caches, TerminalWorkspace workspace)
|
||||||
|
{
|
||||||
|
var shell = WholeWindow.Shell(caches, workspace);
|
||||||
|
|
||||||
|
await shell.StartAsync(Token);
|
||||||
|
|
||||||
|
shell.Passphrase = WholeWindow.Passphrase;
|
||||||
|
await shell.UnlockCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.IsUnlocked.ShouldBeTrue();
|
||||||
|
|
||||||
|
return shell;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using DodoSSH.Client.App.Views;
|
||||||
|
using DodoSSH.Client.Session;
|
||||||
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
|
using DodoSSH.Client.Ssh;
|
||||||
|
using DodoSSH.Client.Storage;
|
||||||
|
using DodoSSH.Client.Terminal;
|
||||||
|
using DodoSSH.Client.Transfer;
|
||||||
|
using DodoSSH.Crypto;
|
||||||
|
using NSubstitute;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Layout.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The real main window over a real shell, for the tests that are about where the keyboard goes.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="LayoutHarnessTests.WhyTheWindowItselfIsNeverShown"/> says this window cannot be shown here: the
|
||||||
|
/// terminal's WebView refuses this thread's apartment when it is attached. It is taken out of the tree before
|
||||||
|
/// the window is shown, which is everything that test is about and nothing these are.
|
||||||
|
/// </remarks>
|
||||||
|
internal static class WholeWindow
|
||||||
|
{
|
||||||
|
internal const string Passphrase = "a sufficiently long passphrase";
|
||||||
|
|
||||||
|
internal static readonly Argon2Profile CheapProfile =
|
||||||
|
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
|
||||||
|
|
||||||
|
internal static TerminalWorkspace Workspace() =>
|
||||||
|
new(
|
||||||
|
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
|
||||||
|
Substitute.For<ISshConnectionFactory>(),
|
||||||
|
TimeProvider.System);
|
||||||
|
|
||||||
|
internal static MainWindowViewModel Shell(ClientCacheFactory caches, TerminalWorkspace workspace) =>
|
||||||
|
new(
|
||||||
|
new ClientPaths(Path.Combine(Path.GetTempPath(), $"dodossh-focus-{Guid.CreateVersion7():N}")),
|
||||||
|
caches,
|
||||||
|
workspace,
|
||||||
|
new VaultKnownHostStore(),
|
||||||
|
new UnavailableDeviceKeyStore(),
|
||||||
|
static (_, _) => throw new InvalidOperationException("A layout test has no network."),
|
||||||
|
TimeProvider.System,
|
||||||
|
Substitute.For<ISftpSessionFactory>(),
|
||||||
|
CheapProfile);
|
||||||
|
|
||||||
|
internal static void Show(MainWindowViewModel shell, Action<MainWindow> assert)
|
||||||
|
{
|
||||||
|
var window = new MainWindow { DataContext = shell };
|
||||||
|
|
||||||
|
if (window.FindControl<NativeWebView>("Terminal") is { Parent: Panel panel } web)
|
||||||
|
{
|
||||||
|
panel.Children.Remove(web);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
LayoutHarness.Settle(window, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
|
||||||
|
Dispatcher.UIThread.RunJobs();
|
||||||
|
|
||||||
|
assert(window);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user