Compare commits

...
7 Commits
29 changed files with 1109 additions and 69 deletions

No files matched your search

@@ -59,7 +59,7 @@
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="danger" Content="DELETE" Command="{Binding ConfirmDeleteCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteCommand}" />
<Button x:Name="CancelButton" Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteCommand}" />
</StackPanel>
</StackPanel>
@@ -1,4 +1,5 @@
using Avalonia.Controls;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views;
@@ -12,5 +13,10 @@ namespace DodoSSH.Client.App.Views;
/// </remarks>
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
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>
<!-- ============ UNKNOWN HOST KEY ============ -->
@@ -83,7 +87,7 @@
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="TRUST AND CONNECT"
Command="{Binding TrustHostKeyCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding RejectHostKeyCommand}" />
<Button x:Name="CancelButton" Classes="ghost" Content="CANCEL" Command="{Binding RejectHostKeyCommand}" />
</StackPanel>
</StackPanel>
@@ -119,7 +123,7 @@
is not an answer, because this half of the control has none to give. Compare the accent button
above, which connects.
-->
<Button Classes="ghost" Content="CLOSE" HorizontalAlignment="Left"
<Button x:Name="CloseButton" Classes="ghost" Content="CLOSE" HorizontalAlignment="Left"
Command="{Binding RejectHostKeyCommand}" />
</StackPanel>
@@ -17,4 +17,14 @@ namespace DodoSSH.Client.App.Views;
internal sealed partial class HostKeyCard : UserControl
{
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;
}
@@ -122,6 +122,13 @@ internal sealed partial class HostsScreen : UserControl
// touched; see KeyboardTarget for the other half of who has the keyboard on this screen.
Board.KeyDown += OnGridKey;
// The arrows' half of the selection, which the pointer's half writes by hand in OnPointerPressed. See
// OnCardSelectionChanged for why this is not simply a two-way binding.
Board.AddHandler(SelectingItemsControl.SelectionChangedEvent, OnCardSelectionChanged);
// Tunnelled, because a section's list answers Enter itself and nothing bubbles past it.
Board.AddHandler(KeyDownEvent, OnCardEnter, RoutingStrategies.Tunnel);
// â—† The band is the scroller's rather than any one section list's, because the space it is dragged
// out over is mostly not a list's: a WrapPanel of cards is exactly as tall as its cards, and the gaps
// between sections and below the last one belong to the stack around them. Tunnelled for the reason
@@ -315,6 +322,16 @@ internal sealed partial class HostsScreen : UserControl
return;
}
// An arrow with the board itself focused — which is where this screen puts the keyboard, see
// KeyboardTarget — goes into the cards: onto the selected one, or the first. Otherwise no arrow ever
// reaches a section's list, since none of them has the keyboard until a card has been clicked.
if (e.Key is Key.Up or Key.Down or Key.Left or Key.Right && ReferenceEquals(e.Source, Board))
{
e.Handled = EnterTheCards(vault);
return;
}
if (e.Key is Key.A && e.KeyModifiers.HasFlag(KeyModifiers.Control) && vault.HasHostBoardEntries)
{
var order = vault.HostBoardOrder.ToList();
@@ -347,6 +364,80 @@ internal sealed partial class HostsScreen : UserControl
/// on the empty space means is decided on the release. See <see cref="OnBandReleased"/>.
/// </para>
/// </remarks>
/// <summary>Enter on a card is the double-press, for somebody who got there with the arrows.</summary>
/// <remarks>
/// Only on a card, for the reason <see cref="OnHostActivated"/> gives: Enter with the board itself focused
/// would connect to whatever was selected before, which is not something anyone is looking at. And not
/// while cards are ticked, where the selection is not what the set is about.
/// </remarks>
private void OnCardEnter(object? sender, KeyEventArgs e)
{
if (e.Key is not Key.Enter
|| e.KeyModifiers != KeyModifiers.None
|| Vault is not { IsChoosingHosts: false } vault
|| RowUnder(e.Source) is not HostRowViewModel row)
{
return;
}
vault.SelectedHost = row;
_ = vault.ConnectCommand.ExecuteAsync(null);
e.Handled = true;
}
/// <summary>Writes a selection the arrows made in one section's list back to the vault.</summary>
/// <remarks>
/// <para>
/// The cards' <c>SelectedItem</c> is one-way, for the reason the markup gives: several lists rebuilt
/// independently would each null the vault's selection on every filter keystroke and every sync pass. So
/// the pointer writes it by hand, and until this nothing wrote it for the arrows — a card lit by the
/// keyboard was not the one the drawer's CONNECT and DELETE then acted on, which is the same mistake the
/// right-click handler exists to prevent.
/// </para>
/// <para>
/// â—† Only from the list that has the keyboard, and only for a card. A rebuild's reset arrives as a change
/// to nothing, and the binding's own refills of the lists nobody is in arrive without the keyboard — both
/// are exactly the writes the one-way binding keeps out, and they stay out. A click arrives here too, after
/// <see cref="OnPointerPressed"/> has already written the same card, and changes nothing.
/// </para>
/// </remarks>
private void OnCardSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (Vault is not { } vault
|| e.Source is not ListBox { IsKeyboardFocusWithin: true, SelectedItem: HostRowViewModel row }
|| ReferenceEquals(row, vault.SelectedHost))
{
return;
}
if (vault.IsChoosingHosts)
{
vault.ClearHostChoiceCommand.Execute(null);
}
anchor = row;
vault.SelectedHost = row;
}
/// <summary>Hands the keyboard from the board to a card: the selected one if it is drawn, else the first.</summary>
private bool EnterTheCards(VaultViewModel vault)
{
var cards = Board.GetVisualDescendants().OfType<ListBoxItem>().Where(card => card.IsEffectivelyVisible).ToList();
var card = cards.Find(card => ReferenceEquals(card.DataContext, vault.SelectedHost)) ?? cards.FirstOrDefault();
if (card?.DataContext is not HostRowViewModel row)
{
return false;
}
vault.SelectedHost = row;
anchor = row;
return card.Focus(NavigationMethod.Directional);
}
private void OnBandPressed(object? sender, PointerPressedEventArgs e)
{
EndBand();
@@ -256,7 +256,8 @@
</Border>
<!-- ============ The table ============ -->
<Grid Grid.Column="1" RowDefinitions="Auto,Auto,*" Background="{StaticResource Pane}">
<Grid Grid.Column="1" RowDefinitions="Auto,Auto,*" Background="{StaticResource Pane}"
Grid.IsSharedSizeScope="True">
<Border Grid.Row="0" Padding="16,0" Height="44"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
@@ -275,12 +276,24 @@
item this application stores carries a timestamp; see the file-level remark above and
docs/design-import-gaps.md.
-->
<Grid Grid.Row="1" ColumnDefinitions="2,3*,1.1*,0.9*,1.2*" Margin="0,10,16,6"
<!--
VAULT is sized to what is in it, and shared between this heading and every row so the two agree:
with one vault no row names one, and a fixed column was a heading over a stripe of nothing.
-->
<Grid Grid.Row="1" Margin="0,10,16,6"
IsVisible="{Binding HasVaultItems}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2" />
<ColumnDefinition Width="3*" />
<ColumnDefinition Width="1.1*" />
<ColumnDefinition Width="Auto" SharedSizeGroup="KeychainVault" />
<ColumnDefinition Width="1.2*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" LetterSpacing="1"
Margin="12,0,8,0" />
<TextBlock Grid.Column="2" Classes="label" Text="TYPE" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="VAULT" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="VAULT" FontSize="9.5" LetterSpacing="1"
Margin="0,0,16,0" IsVisible="{Binding ShowsVaultColumn}" />
<TextBlock Grid.Column="4" Classes="label" Text="USED BY" FontSize="9.5" LetterSpacing="1" />
</Grid>
@@ -290,7 +303,14 @@
Margin="8,0,8,10">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultItemRowViewModel">
<Grid ColumnDefinitions="2,3*,1.1*,0.9*,1.2*" Height="40" Margin="6,0,10,0">
<Grid Height="40" Margin="6,0,10,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2" />
<ColumnDefinition Width="3*" />
<ColumnDefinition Width="1.1*" />
<ColumnDefinition Width="Auto" SharedSizeGroup="KeychainVault" />
<ColumnDefinition Width="1.2*" />
</Grid.ColumnDefinitions>
<!--
A Grid rather than a horizontal StackPanel: the StackPanel measured the name with infinite
width, so TextTrimming never fired and a long name printed over the TYPE and USED BY
@@ -307,7 +327,7 @@
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Type}" FontSize="10.5"
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<Border Grid.Column="3" Classes="chip" HorizontalAlignment="Left"
<Border Grid.Column="3" Classes="chip" HorizontalAlignment="Left" Margin="0,0,16,0"
VerticalAlignment="Center" IsVisible="{Binding HasVaultBadge}">
<TextBlock Text="{Binding VaultBadge}" FontSize="10" />
</Border>
+14 -3
View File
@@ -383,7 +383,12 @@
not replayed. See MainWindow.axaml.cs.
-->
<TextBox x:Name="ServerUrlBox" Text="{Binding ServerUrl}"
PlaceholderText="https://dodossh.example" />
PlaceholderText="https://dodossh.example">
<!-- Enter signs in, as it unlocks on the unlock card. -->
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding SignInCommand}" />
</TextBox.KeyBindings>
</TextBox>
<Button Classes="accent" Content="SIGN IN WITH YOUR BROWSER" Command="{Binding SignInCommand}"
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
@@ -395,8 +400,14 @@
<TextBlock Classes="heading" Text="Choose a keychain passphrase" />
<TextBlock Classes="hint"
Text="This passphrase never leaves this machine, and the server cannot reset it. It is the only thing standing between a stolen copy of the database and every credential in your keychain." />
<TextBox Text="{Binding Passphrase}" PlaceholderText="passphrase" PasswordChar="•" />
<TextBox Text="{Binding ConfirmPassphrase}" PlaceholderText="again" PasswordChar="•" />
<TextBox x:Name="NewPassphraseBox" Text="{Binding Passphrase}" PlaceholderText="passphrase"
PasswordChar="•" />
<TextBox Text="{Binding ConfirmPassphrase}" PlaceholderText="again" PasswordChar="•">
<!-- On the second box only: Enter in the first moves nowhere useful, Enter here is "done". -->
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding EnrollCommand}" />
</TextBox.KeyBindings>
</TextBox>
<Button Classes="accent" Content="CREATE MY KEYCHAIN" Command="{Binding EnrollCommand}"
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
+165 -23
View File
@@ -33,6 +33,11 @@ internal sealed partial class MainWindow : Window
private bool wasUnlocked;
/// <summary>Which closed-vault card the keyboard was last sent to, so it is sent once per card.</summary>
private ShellState? closedState;
private bool wasAskingAboutHostKey;
public MainWindow()
{
InitializeComponent();
@@ -184,6 +189,7 @@ internal sealed partial class MainWindow : Window
{
ShellState.Locked => UnlockPane.PassphraseBox,
ShellState.NeedsServer => ServerUrlBox,
ShellState.NeedsEnrollment => NewPassphraseBox,
_ => this,
};
@@ -226,40 +232,96 @@ internal sealed partial class MainWindow : Window
size.Execute(null);
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)
{
Palette.HandleKey(e);
}
// v5c: Escape leaves settings mode, the same full-window-state idiom the palette's own Escape
// 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)
else if (e.Key == Key.Escape && CloseTheNearestThing(viewModel))
{
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;
}
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>
/// The text-size chords, when the terminal is not the thing hearing them.
/// </summary>
@@ -307,6 +369,15 @@ internal sealed partial class MainWindow : Window
Terminal.Source = viewModel.TerminalPageUrl;
wasUnlocked = viewModel.IsUnlocked;
closedState = null;
wasAskingAboutHostKey = viewModel.IsHostKeyDecisionShowing;
// The shell may have reached its card before this window was given it, and then no change would
// ever say so.
if (!viewModel.IsUnlocked)
{
FocusClosedVaultCard(viewModel);
}
viewModel.TerminalSessionOpened += OnTerminalSessionOpened;
viewModel.TerminalFocusRequested += OnTerminalFocusRequested;
@@ -365,6 +436,10 @@ internal sealed partial class MainWindow : Window
OnSelectedTabChanged(viewModel);
break;
case nameof(MainWindowViewModel.IsHostKeyDecisionShowing):
OnHostKeyDecision(viewModel);
break;
default:
break;
}
@@ -380,10 +455,77 @@ internal sealed partial class MainWindow : Window
{
ReleaseKeyboardTo(ClosedVaultKeyboardHome);
}
else if (!unlocked && viewModel.State != closedState)
{
FocusClosedVaultCard(viewModel);
}
closedState = unlocked ? null : viewModel.State;
wasUnlocked = unlocked;
}
/// <summary>Hands the keyboard to the box on whichever card a closed vault is showing.</summary>
/// <remarks>
/// For arriving at a card other than by locking — launch landing on the unlock card, a sign-in finishing
/// on the enrolment one — which used to leave the passphrase somebody started typing going nowhere. Once
/// per card, which is what keeps it from moving the keyboard while somebody is mid-sign-in: IsUnlocked is
/// raised for every state change, and the state does not change while they type. Posted, because the card
/// is revealed by a binding the same change has not reached yet.
/// </remarks>
private void FocusClosedVaultCard(MainWindowViewModel viewModel)
{
closedState = viewModel.State;
var home = ClosedVaultKeyboardHome;
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>
/// 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,
@@ -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);
}
};
}
}
@@ -86,7 +86,14 @@
Classes.connecting="{Binding IsConnecting}"
Classes.dropped="{Binding IsDropped}"
VerticalAlignment="Center" />
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" />
<!--
Bounded. A label is whatever the host was called, or the address typed into the palette,
and one long one used to push every tab after it and the "+" past the strip's edge. The
whole of it is on the label's own tooltip; the tab's is the address, as before.
-->
<TextBlock Text="{Binding Label}" VerticalAlignment="Center"
MaxWidth="200" TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding Label}" />
<Button Classes="flat close inline" Width="16" Height="16" Padding="0"
VerticalAlignment="Center"
@@ -136,7 +136,7 @@
<Border Classes="settingscard">
<StackPanel Margin="24,20" Spacing="10">
<TextBlock Classes="settingsrowcaption"
Text="Unlocking with Windows Hello moved to Security — it registers this machine rather than a preference of this screen's, and Security is where the rest of this machine's trust facts live." />
Text="Unlocking with this machine's device key moved to Security — it registers this machine rather than a preference of this screen's, and Security is where the rest of this machine's trust facts live." />
<TextBlock Classes="settingsrowcaption"
Text="Terminal font, cursor and scrollback — the renderer hard-codes those three. Text size above is the one that is not." />
<TextBlock Classes="settingsrowcaption"
@@ -70,15 +70,15 @@
<Border Classes="settingsrow last">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0" MaxWidth="640">
<TextBlock Classes="settingsrowtitle" Text="Windows Hello on this machine" />
<TextBlock Classes="settingsrowtitle" Text="{Binding DeviceUnlock.Name}" />
<TextBlock Classes="settingsrowcaption"
Text="Registers this machine so a later launch can open the keychain with a Windows confirmation instead of your passphrase. Your passphrase keeps working."
Text="{Binding DeviceUnlock.Offer}"
IsVisible="{Binding CanRegisterDevice}" />
<TextBlock Classes="settingsrowcaption"
Text="Registered · the TPM holds a device key that can open the keychain with a Windows confirmation."
Text="{Binding DeviceUnlock.Registered}"
IsVisible="{Binding CanForgetDevice}" />
<TextBlock Classes="settingsrowcaption"
Text="This machine has nowhere to keep a device key, so the keychain will keep asking for your passphrase. That needs a TPM and a Windows keystore willing to release the key."
Text="{Binding DeviceUnlock.Unavailable}"
IsVisible="{Binding HasNoDeviceKeyOption}" />
</StackPanel>
<Button Grid.Column="1" Classes="accent" Height="38" Content="REGISTER"
@@ -81,11 +81,15 @@
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel>
<Grid ColumnDefinitions="1.4*,*,*,Auto" Margin="20,14,20,10">
<!--
No VAULT column, although the design draws one: TagRowViewModel does not carry which vault a tag
lives in (tags are read from the active vault alone; see VaultViewModel.NewTag's own remark), so
the column was a heading over nothing on every row.
-->
<Grid ColumnDefinitions="1.4*,2*,Auto" Margin="20,14,20,10">
<TextBlock Grid.Column="0" Classes="label" Text="TAG" />
<TextBlock Grid.Column="1" Classes="label" Text="USED BY" />
<TextBlock Grid.Column="2" Classes="label" Text="VAULT" />
<TextBlock Grid.Column="3" Classes="label" Text=" " />
<TextBlock Grid.Column="2" Classes="label" Text=" " />
</Grid>
<ItemsControl ItemsSource="{Binding Vault.Tags}">
@@ -97,7 +101,7 @@
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TagRowViewModel">
<Border Classes="settingsrow last" Padding="20,10">
<Grid ColumnDefinitions="1.4*,*,*,Auto">
<Grid ColumnDefinitions="1.4*,2*,Auto">
<Border Grid.Column="0" Classes="chip" HorizontalAlignment="Left" VerticalAlignment="Center">
<TextBlock Text="{Binding Label}" />
@@ -107,14 +111,7 @@
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Text="{Binding Description}" />
<!--
No vault chip here — unlike a group, TagRowViewModel does not carry which vault it lives
in (tags are read from the active vault alone; see VaultViewModel.NewTag's own remark),
so there is no second vault this column could honestly name.
-->
<TextBlock Grid.Column="2" />
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<Button Classes="paneicon" Width="30" Height="30" FontSize="13"
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.EditTagRowCommand}"
CommandParameter="{Binding}" ToolTip.Tip="Rename this tag.">
@@ -55,7 +55,7 @@
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="danger" Content="SIGN OUT AND DELETE"
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>
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
@@ -1,9 +1,15 @@
using Avalonia.Controls;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views;
/// <summary>The sign-out confirmation, shown on the preferences screen and on the unlock screen.</summary>
internal sealed partial class SignOutCard : UserControl
{
public SignOutCard() => InitializeComponent();
public SignOutCard()
{
InitializeComponent();
SafeAnswerFocus.Follow(this, nameof(MainWindowViewModel.IsConfirmingSignOut), () => CancelButton);
}
}
@@ -167,8 +167,12 @@
TextAlignment="Center" MaxWidth="360"
IsVisible="{Binding !HasVisible}" />
<!--
Only with a snippet chosen. Three greyed buttons under a list nobody has clicked in read as
something broken rather than something waiting, and the pane on the right already says what to do.
-->
<Border Grid.Row="1" Padding="16,10" BorderBrush="{StaticResource BorderSubtle}"
BorderThickness="0,1,0,0">
BorderThickness="0,1,0,0" IsVisible="{Binding HasSelection}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="ghost" Content="Edit" Command="{Binding EditCommand}"
IsEnabled="{Binding HasSelection}" />
@@ -44,15 +44,15 @@
IsEnabled="{Binding !IsBusy}" />
<!--
Shown only when this machine has both a registered wrap and a keystore still willing to release the
key. Absent rather than disabled, because a greyed-out "Use Windows Hello" on a machine that never
key. Absent rather than disabled, because a greyed-out "Use Touch ID" on a machine that never
had it invites the reading that something is broken — and the passphrase box beside it is not a
fallback, it is the ordinary way in.
-->
<Button Classes="ghost" Content="USE WINDOWS HELLO"
<Button Classes="ghost" Content="{Binding DeviceUnlock.Button}"
Command="{Binding UnlockWithDeviceCommand}"
IsEnabled="{Binding !IsBusy}"
IsVisible="{Binding CanUnlockWithDevice}"
ToolTip.Tip="Opens the keychain with this machine's device key. Windows will ask you to confirm." />
ToolTip.Tip="{Binding DeviceUnlock.Tip}" />
</StackPanel>
<TextBlock Classes="hint" Text="{Binding StatusMessage}" TextWrapping="Wrap" />
@@ -0,0 +1,85 @@
namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>
/// What unlocking with this machine's device key is called here, and what it asks the person for.
/// </summary>
/// <remarks>
/// <para>
/// The words used to be Windows's everywhere: "USE WINDOWS HELLO" on a Mac, "Waiting for Windows…" over a
/// Linux PIN prompt, and a settings row that told a MacBook it needed a TPM. Each desktop has its own device
/// key store with its own gesture — see <c>WindowsDeviceKeyStore</c>, <c>MacDeviceKeyStore</c> and
/// <c>LinuxDeviceKeyStore</c> — and a button has to name the one the person is about to be shown, or it reads
/// as a feature for some other machine.
/// </para>
/// <para>
/// One record rather than a property per sentence on the shell, so that the five places these words appear
/// cannot disagree about which platform they are on.
/// </para>
/// </remarks>
/// <param name="Name">What the feature is called, as a settings row's title.</param>
/// <param name="Button">The unlock card's button, in the capitals every button here uses.</param>
/// <param name="Wait">The status line while the platform's prompt is up.</param>
/// <param name="Gesture">What opens the keychain instead of the passphrase, to finish a sentence.</param>
/// <param name="Keeper">What holds the key, to finish "Registered · …".</param>
/// <param name="Needs">What a machine with no device key store lacks, as a sentence of its own.</param>
internal sealed record DeviceUnlockWording(
string Name,
string Button,
string Wait,
string Gesture,
string Keeper,
string Needs)
{
internal static DeviceUnlockWording Windows { get; } = new(
"Windows Hello on this machine",
"USE WINDOWS HELLO",
"Waiting for Windows…",
"a Windows confirmation",
"the TPM holds a device key",
"That needs a TPM and a Windows keystore willing to release the key.");
internal static DeviceUnlockWording Mac { get; } = new(
"Touch ID on this Mac",
"USE TOUCH ID",
"Waiting for Touch ID…",
"Touch ID or your login password",
"the Secure Enclave holds a device key",
"That needs a Mac with a Secure Enclave and a login password set.");
internal static DeviceUnlockWording Linux { get; } = new(
"A device PIN on this machine",
"USE DEVICE PIN",
"Waiting for your PIN…",
"a PIN the TPM checks",
"the TPM holds a device key",
"That needs a TPM 2.0 this account is allowed to use.");
internal static DeviceUnlockWording Android { get; } = new(
"Fingerprint unlock on this phone",
"USE FINGERPRINT",
"Waiting for your fingerprint…",
"your fingerprint",
"the phone's keystore holds a device key",
"That needs a screen lock and a keystore willing to release the key.");
/// <summary>The wording for the platform this process is running on.</summary>
internal static DeviceUnlockWording ForThisPlatform { get; } =
OperatingSystem.IsAndroid() ? Android
: OperatingSystem.IsMacOS() ? Mac
: OperatingSystem.IsLinux() ? Linux
: Windows;
/// <summary>The settings caption for a machine that can register.</summary>
internal string Offer =>
$"Registers this machine so a later launch can open the keychain with {Gesture} instead of your passphrase. Your passphrase keeps working.";
/// <summary>The settings caption for a machine that has.</summary>
internal string Registered => $"Registered · {Keeper} that can open the keychain with {Gesture}.";
/// <summary>The settings caption for a machine that cannot.</summary>
internal string Unavailable =>
$"This machine has nowhere to keep a device key, so the keychain will keep asking for your passphrase. {Needs}";
/// <summary>The unlock button's tooltip.</summary>
internal string Tip => $"Opens the keychain with this machine's device key, after {Gesture}.";
}
@@ -2819,8 +2819,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// Naming the wrong operating system here is not cosmetic — it is the sentence a user reads while
/// deciding whether the prompt in front of them is the one this application asked for.
/// </remarks>
private static string GestureWait =>
OperatingSystem.IsAndroid() ? "Waiting for your fingerprint…" : "Waiting for Windows…";
private static string GestureWait => DeviceUnlockWording.ForThisPlatform.Wait;
/// <summary>What unlocking with the device key is called on this platform; the views read it whole.</summary>
internal DeviceUnlockWording DeviceUnlock { get; } = DeviceUnlockWording.ForThisPlatform;
/// <summary>Opens the vault with this machine's device key instead of the passphrase.</summary>
/// <remarks>
@@ -4516,7 +4518,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
{
if (Tabs.FirstOrDefault(tab => tab.SessionId == e.SessionId) is { } tab)
{
tab.Ended(e.Outcome.End, e.Outcome.Description);
tab.Ended(e.Outcome.End, e.Outcome.Description, clock.GetUtcNow());
// Only for the tab being looked at, which is what these three already ask. The rectangle does not
// change — a dead tab keeps its pane and its scrollback — but the status bar under it does, and
@@ -4996,7 +4998,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
return null;
}
var elapsed = clock.GetUtcNow() - started;
// Up to the end, for a shell that has one: how long it ran, not how long ago it started.
var until = Surface is ShellSurface.Terminal && SelectedTab?.EndedAt is { } ended
? ended
: clock.GetUtcNow();
var elapsed = until - started;
if (elapsed < TimeSpan.Zero)
{
@@ -274,6 +274,15 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
/// </remarks>
internal DateTimeOffset? StartedAt { get; set; }
/// <summary>When the session behind this tab ended, or null while it has not.</summary>
/// <remarks>
/// What stops the status bar's timer. It used to go on counting from <see cref="StartedAt"/> to now over a
/// shell that had exited an hour earlier, which made "how long that shell ran" — the fact the remark above
/// keeps <see cref="StartedAt"/> for — the one thing it no longer said. Stamped by <see cref="Ended"/> from
/// the shell's clock, and cleared whenever a new session takes the tab.
/// </remarks>
internal DateTimeOffset? EndedAt { get; private set; }
/// <summary>
/// The negotiated server-to-client cipher, for the status bar — or null while there is no session.
/// </summary>
@@ -620,6 +629,7 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
// stale-fact failure this pair exists to remove.
EndReason = null;
EndDescription = null;
EndedAt = null;
IsLive = true;
State = TerminalTabState.Open;
@@ -679,13 +689,14 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
/// live attempt dead. <see cref="Advance"/> guards its own reports for the same reason.
/// </para>
/// </remarks>
internal void Ended(SshSessionEnd end, string description)
internal void Ended(SshSessionEnd end, string description, DateTimeOffset at)
{
if (State is not TerminalTabState.Open)
{
return;
}
EndedAt ??= at;
EndReason = end;
EndDescription = description;
IsLive = false;
@@ -733,6 +744,7 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
// Cleared with the session they were facts about. Keeping them would put the previous connection's
// cipher in the status bar beside a handshake that has not negotiated one yet.
StartedAt = null;
EndedAt = null;
Cipher = null;
HostKeyAlgorithm = null;
IdentityLabel = null;
@@ -2404,6 +2404,14 @@ internal sealed partial class VaultViewModel(
internal bool HasVaultItems => VaultItems.Count > 0;
/// <summary>Whether any row in the keychain table names a vault, which is whether its VAULT column has a heading.</summary>
/// <remarks>
/// Read off the rows rather than off how many vaults there are, because a row's badge is what the column
/// holds: the two agree today — see <see cref="VaultItemRowViewModel.VaultBadge"/> — and if they ever stop
/// agreeing, a heading over nothing is the thing this exists to avoid.
/// </remarks>
internal bool ShowsVaultColumn => VaultItems.Any(row => row.HasVaultBadge);
internal bool HasSelectedVaultItem => SelectedVaultItem is not null;
/// <summary>
@@ -13457,6 +13465,16 @@ internal sealed partial class VaultViewModel(
};
}
/// <summary>Re-reads what the keychain table says about its rows as a whole.</summary>
private void RaiseVaultItemFacts()
{
OnPropertyChanged(nameof(SectionSummary));
OnPropertyChanged(nameof(HasVaultItems));
OnPropertyChanged(nameof(ShowsVaultColumn));
OnPropertyChanged(nameof(TotalItemCount));
OnPropertyChanged(nameof(EmptySectionMessage));
}
/// <summary>── v5b ── Narrows <see cref="VaultItems"/> to rows <see cref="ItemFilter"/> matches.</summary>
/// <remarks>
/// Its own method for the reason <see cref="AddTagRows"/> is out of <see cref="RebuildVaultItems"/> —
@@ -13552,10 +13570,7 @@ internal sealed partial class VaultViewModel(
// sync every minute would otherwise move the detail pane out from under whoever was reading it.
SelectedVaultItem = VaultItems.FirstOrDefault(row => row.EntityId == selectedId);
OnPropertyChanged(nameof(SectionSummary));
OnPropertyChanged(nameof(HasVaultItems));
OnPropertyChanged(nameof(TotalItemCount));
OnPropertyChanged(nameof(EmptySectionMessage));
RaiseVaultItemFacts();
// A reload replaces every row object, and the selection is restored by id — so the setter above may
// not have fired even though the row this answers about is a different instance. Asked again here,
@@ -0,0 +1,74 @@
using Avalonia.Controls;
using Avalonia.Input;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Session;
using DodoSSH.Client.Session.Tests;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Storage;
namespace DodoSSH.Client.App.Layout.Tests;
/// <summary>
/// The keyboard on the cards a closed vault shows: where it is when the card appears, and what Enter does.
/// </summary>
/// <remarks>
/// <para>
/// Launch used to leave the keyboard nowhere. Focus only moved on the way out of an unlocked vault, so the
/// passphrase somebody started typing the moment the window appeared went nowhere until they clicked the box,
/// and Enter in the server address did nothing at all.
/// </para>
/// <para>
/// The whole window, shown the way <see cref="WholeWindow"/> describes.
/// </para>
/// </remarks>
public sealed class ClosedVaultFocusTests
{
private static CancellationToken Token => TestContext.Current.CancellationToken;
[Fact]
public async Task AFreshMachine_PutsTheKeyboardInTheServerAddressAndEnterSignsIn()
{
using var caches = ClientCacheFactory.ForMemory($"focus-{Guid.CreateVersion7():N}");
await caches.MigrateAsync(Token);
await using var workspace = WholeWindow.Workspace();
await using var shell = WholeWindow.Shell(caches, workspace);
await shell.StartAsync(Token);
shell.State.ShouldBe(ShellState.NeedsServer);
await LayoutHarness.OnTheUiThreadAsync(
() => WholeWindow.Show(shell, window =>
{
var box = window.FindControl<TextBox>("ServerUrlBox").ShouldNotBeNull();
window.FocusManager!.GetFocusedElement().ShouldBeSameAs(box);
box.KeyBindings.ShouldContain(binding =>
binding.Gesture == new KeyGesture(Key.Enter) && binding.Command == shell.SignInCommand);
}),
Token);
}
[Fact]
public async Task AnEnrolledMachine_PutsTheKeyboardInThePassphraseBox()
{
using var caches = ClientCacheFactory.ForMemory($"focus-{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);
await using var workspace = WholeWindow.Workspace();
await using var shell = WholeWindow.Shell(caches, workspace);
await shell.StartAsync(Token);
shell.State.ShouldBe(ShellState.Locked);
await LayoutHarness.OnTheUiThreadAsync(
() => WholeWindow.Show(shell, window =>
window.FocusManager!.GetFocusedElement()
.ShouldBeSameAs(window.FindControl<UnlockCard>("UnlockPane").ShouldNotBeNull().PassphraseBox)),
Token);
}
}
@@ -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;
}
}
@@ -465,6 +465,61 @@ public sealed class HostGridTests : IAsyncLifetime
});
}
/// <remarks>
/// The arrows' half of the selection. The cards' <c>SelectedItem</c> is one-way, so a card the keyboard lit
/// used to stay out of the vault — and the drawer's CONNECT and DELETE then acted on the card clicked
/// before it, the mistake <see cref="ARightClickSelectsTheHostUnderThePointer"/> is about.
/// </remarks>
[Fact]
public async Task TheArrowsMoveTheVaultsSelection_AndEnterConnectsToIt()
{
await OnTheBoardAsync((screen, window) =>
{
var first = Row(vault, "prod-db");
var other = Row(vault, "stage-web");
Click(CardFor(screen, first), window);
vault.SelectedHost.ShouldBeSameAs(first);
window.KeyPressQwerty(PhysicalKey.ArrowRight, RawInputModifiers.None);
Dispatcher.UIThread.RunJobs();
vault.SelectedHost.ShouldBeSameAs(other, "the card the arrow lit, not the one clicked before it");
vault.ConnectCommand.ExecutionTask.ShouldBeNull();
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
Dispatcher.UIThread.RunJobs();
// The command, not a connection: these hosts were saved with nothing to sign in with, so it stops
// at saying so — about the card Enter was pressed on, which is the selection checked above.
vault.ConnectCommand.ExecutionTask.ShouldNotBeNull("Enter on a card is the double-press");
});
}
/// <remarks>
/// The board is where this screen puts the keyboard, and none of the section lists inside it has it until
/// a card has been clicked — so without this an arrow pressed on arriving went nowhere at all.
/// </remarks>
[Fact]
public async Task AnArrowOnTheBoardItselfGoesIntoTheCards()
{
await OnTheBoardAsync((screen, window) =>
{
vault.SelectedHost = null;
screen.Board.Focus();
Dispatcher.UIThread.RunJobs();
window.KeyPressQwerty(PhysicalKey.ArrowDown, RawInputModifiers.None);
Dispatcher.UIThread.RunJobs();
var focused = window.FocusManager!.GetFocusedElement().ShouldBeOfType<ListBoxItem>();
vault.SelectedHost.ShouldNotBeNull().ShouldBeSameAs(focused.DataContext);
});
}
// ---- Helpers ----
private static void RightClick(Visual card, Visual window)
@@ -2296,6 +2296,66 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
},
Token);
/// <remarks>
/// Three greyed buttons under a list nobody has clicked in read as something broken rather than something
/// waiting. The row appears with a snippet to act on, and goes with it.
/// </remarks>
[Fact]
public async Task TheSnippetButtonsAppearWithASnippetToActOn()
{
await SeedSnippetsAsync();
var snippets = NewSnippetsScreen();
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new SnippetsScreen { DataContext = snippets };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
try
{
var edit = screen.GetVisualDescendants().OfType<Button>()
.Single(button => button.Content is "Edit");
snippets.Selected = null;
LayoutHarness.Settle(window, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
edit.IsEffectivelyVisible.ShouldBeFalse("nothing is chosen for it to edit");
snippets.Selected = snippets.Visible.First();
LayoutHarness.Settle(window, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
edit.IsEffectivelyVisible.ShouldBeTrue();
}
finally
{
window.Close();
}
},
Token);
}
/// <remarks>
/// With one vault no row names one, and a fixed VAULT column was a heading over a stripe of nothing
/// between TYPE and USED BY. It is sized to what its rows hold now, and its heading goes with them.
/// </remarks>
[Fact]
public async Task TheKeychainsVaultColumn_TakesNoRoomWithOneVault()
{
await OnTheVaultAsync((screen, _) =>
{
vault.HasVaultItems.ShouldBeTrue("an empty table has no columns to judge");
vault.ShowsVaultColumn.ShouldBeFalse("this suite's keychain has one vault");
var heading = screen.GetVisualDescendants().OfType<TextBlock>()
.Single(block => block.Text is "VAULT");
heading.IsEffectivelyVisible.ShouldBeFalse();
heading.Parent.ShouldBeOfType<Grid>().ColumnDefinitions[3].ActualWidth.ShouldBe(0);
});
}
private SnippetsViewModel NewSnippetsScreen(InsertTarget? target = null) =>
new(
vault,
@@ -250,6 +250,42 @@ public sealed class SessionTabRowTests : IAsyncLifetime
Token);
}
/// <remarks>
/// A label is whatever the host was called or the address typed into the palette, and nothing bounded it:
/// one long one pushed the "+" past the edge of the strip, behind a scroll bar nobody looks for.
/// </remarks>
[Fact]
public async Task ALongLabelIsTrimmed_AndLeavesThePlusOnTheStrip()
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var label = string.Concat(Enumerable.Repeat("a-very-long-host-name-", 12));
shell.Tabs.Add(new TerminalTabViewModel(1, label, "deploy@long.internal:22"));
var strip = new SessionTabRow { DataContext = shell, TabCommand = shell.SelectTabCommand };
var window = LayoutHarness.HostAtMinimumSize(
strip, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
try
{
var text = strip.GetVisualDescendants().OfType<TextBlock>()
.Single(block => string.Equals(block.Text, label, StringComparison.Ordinal));
text.Bounds.Width.ShouldBeLessThanOrEqualTo(200);
ToolTip.GetTip(text).ShouldBe(label, "the whole of it is still one hover away");
LayoutHarness.Unreachable(window).ShouldBeEmpty();
}
finally
{
window.Close();
}
},
Token);
}
/// <remarks>
/// v5b's tab, unlike the pill it replaced, paints no fill at all until it is either active or hovered —
/// so "the plus differs from a tab" can no longer be proven against a resting, inactive one, which reads
@@ -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();
}
}
}
@@ -0,0 +1,64 @@
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// That unlocking with the device key is described in the words of the platform it runs on.
/// </summary>
/// <remarks>
/// The unlock card offered "USE WINDOWS HELLO" on a Mac and the settings page told a Linux machine it needed a
/// Windows keystore. Each platform's wording is checked for every other platform's names, because that is the
/// mistake: a sentence borrowed from the one platform the feature was written on first.
/// </remarks>
public sealed class DeviceUnlockWordingTests
{
public static TheoryData<string> Platforms => ["Windows", "Mac", "Linux", "Android"];
[Theory]
[MemberData(nameof(Platforms))]
public void EachPlatform_NamesOnlyItsOwnGesture(string platform)
{
var wording = For(platform);
var foreign = new Dictionary<string, string[]>(StringComparer.Ordinal)
{
["Windows"] = ["Touch ID", "Secure Enclave", "fingerprint", "PIN"],
["Mac"] = ["Windows", "TPM", "fingerprint", "PIN"],
["Linux"] = ["Windows", "Touch ID", "Secure Enclave", "fingerprint"],
["Android"] = ["Windows", "TPM", "Touch ID", "Secure Enclave"],
}[platform];
string[] said =
[wording.Name, wording.Button, wording.Wait, wording.Offer, wording.Registered, wording.Unavailable, wording.Tip];
foreach (var sentence in said)
{
sentence.ShouldNotBeNullOrWhiteSpace();
foreach (var name in foreign)
{
sentence.ShouldNotContain(name, Case.Sensitive, $"{platform}: \"{sentence}\"");
}
}
wording.Button.ShouldBe(wording.Button.ToUpperInvariant(), "every button on these screens is in capitals");
}
[Fact]
public void TheWordingInUse_IsThisMachines()
{
var expected = OperatingSystem.IsMacOS() ? DeviceUnlockWording.Mac
: OperatingSystem.IsLinux() ? DeviceUnlockWording.Linux
: DeviceUnlockWording.Windows;
DeviceUnlockWording.ForThisPlatform.ShouldBeSameAs(expected);
}
private static DeviceUnlockWording For(string platform) => platform switch
{
"Windows" => DeviceUnlockWording.Windows,
"Mac" => DeviceUnlockWording.Mac,
"Linux" => DeviceUnlockWording.Linux,
_ => DeviceUnlockWording.Android,
};
}
@@ -2075,6 +2075,15 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
// Still worth offering, because the tab and the machine are both still there — what it is not is an
// alarm.
shell.CanReconnectSelectedTab.ShouldBeTrue();
// And the timer stopped with it: how long that shell ran, rather than how long ago it started. The
// shell's clock here is the system's, so the second has to actually pass for the two to differ.
await Task.Delay(TimeSpan.FromSeconds(1.2), Token);
var ran = tab.EndedAt.ShouldNotBeNull() - tab.StartedAt.ShouldNotBeNull();
shell.SessionElapsedText.ShouldBe(string.Create(
CultureInfo.InvariantCulture, $"session {(int)ran.TotalHours:00}:{ran.Minutes:00}:{ran.Seconds:00}"));
}
/// <remarks>