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:
jaap-jan committed 2026-10-02 13:45:40 +02:00
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;
} }
+131 -23
View File
@@ -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();
}
}
}