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"> <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;
} }
@@ -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. // touched; see KeyboardTarget for the other half of who has the keyboard on this screen.
Board.KeyDown += OnGridKey; 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 // ◆ 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 // 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 // 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; 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) if (e.Key is Key.A && e.KeyModifiers.HasFlag(KeyModifiers.Control) && vault.HasHostBoardEntries)
{ {
var order = vault.HostBoardOrder.ToList(); 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"/>. /// on the empty space means is decided on the release. See <see cref="OnBandReleased"/>.
/// </para> /// </para>
/// </remarks> /// </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) private void OnBandPressed(object? sender, PointerPressedEventArgs e)
{ {
EndBand(); EndBand();
@@ -256,7 +256,8 @@
</Border> </Border>
<!-- ============ The table ============ --> <!-- ============ 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" <Border Grid.Row="0" Padding="16,0" Height="44"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"> 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 item this application stores carries a timestamp; see the file-level remark above and
docs/design-import-gaps.md. 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}"> 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" <TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" LetterSpacing="1"
Margin="12,0,8,0" /> Margin="12,0,8,0" />
<TextBlock Grid.Column="2" Classes="label" Text="TYPE" FontSize="9.5" LetterSpacing="1" /> <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" /> <TextBlock Grid.Column="4" Classes="label" Text="USED BY" FontSize="9.5" LetterSpacing="1" />
</Grid> </Grid>
@@ -290,7 +303,14 @@
Margin="8,0,8,10"> Margin="8,0,8,10">
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultItemRowViewModel"> <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 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 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" <TextBlock Grid.Column="2" Classes="mono" Text="{Binding Type}" FontSize="10.5"
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" Foreground="{StaticResource TextGhost}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" /> 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}"> VerticalAlignment="Center" IsVisible="{Binding HasVaultBadge}">
<TextBlock Text="{Binding VaultBadge}" FontSize="10" /> <TextBlock Text="{Binding VaultBadge}" FontSize="10" />
</Border> </Border>
+14 -3
View File
@@ -383,7 +383,12 @@
not replayed. See MainWindow.axaml.cs. not replayed. See MainWindow.axaml.cs.
--> -->
<TextBox x:Name="ServerUrlBox" Text="{Binding ServerUrl}" <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}" <Button Classes="accent" Content="SIGN IN WITH YOUR BROWSER" Command="{Binding SignInCommand}"
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" /> IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
<TextBlock Classes="hint" Text="{Binding StatusMessage}" /> <TextBlock Classes="hint" Text="{Binding StatusMessage}" />
@@ -395,8 +400,14 @@
<TextBlock Classes="heading" Text="Choose a keychain passphrase" /> <TextBlock Classes="heading" Text="Choose a keychain passphrase" />
<TextBlock Classes="hint" <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." /> 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 x:Name="NewPassphraseBox" Text="{Binding Passphrase}" PlaceholderText="passphrase"
<TextBox Text="{Binding ConfirmPassphrase}" PlaceholderText="again" PasswordChar="•" /> 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}" <Button Classes="accent" Content="CREATE MY KEYCHAIN" Command="{Binding EnrollCommand}"
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" /> IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
<TextBlock Classes="hint" Text="{Binding StatusMessage}" /> <TextBlock Classes="hint" Text="{Binding StatusMessage}" />
+165 -23
View File
@@ -33,6 +33,11 @@ internal sealed partial class MainWindow : Window
private bool wasUnlocked; 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() public MainWindow()
{ {
InitializeComponent(); InitializeComponent();
@@ -184,6 +189,7 @@ internal sealed partial class MainWindow : Window
{ {
ShellState.Locked => UnlockPane.PassphraseBox, ShellState.Locked => UnlockPane.PassphraseBox,
ShellState.NeedsServer => ServerUrlBox, ShellState.NeedsServer => ServerUrlBox,
ShellState.NeedsEnrollment => NewPassphraseBox,
_ => this, _ => this,
}; };
@@ -226,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>
@@ -307,6 +369,15 @@ internal sealed partial class MainWindow : Window
Terminal.Source = viewModel.TerminalPageUrl; Terminal.Source = viewModel.TerminalPageUrl;
wasUnlocked = viewModel.IsUnlocked; 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.TerminalSessionOpened += OnTerminalSessionOpened;
viewModel.TerminalFocusRequested += OnTerminalFocusRequested; viewModel.TerminalFocusRequested += OnTerminalFocusRequested;
@@ -365,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;
} }
@@ -380,10 +455,77 @@ internal sealed partial class MainWindow : Window
{ {
ReleaseKeyboardTo(ClosedVaultKeyboardHome); ReleaseKeyboardTo(ClosedVaultKeyboardHome);
} }
else if (!unlocked && viewModel.State != closedState)
{
FocusClosedVaultCard(viewModel);
}
closedState = unlocked ? null : viewModel.State;
wasUnlocked = unlocked; 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> /// <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);
}
};
}
}
@@ -86,7 +86,14 @@
Classes.connecting="{Binding IsConnecting}" Classes.connecting="{Binding IsConnecting}"
Classes.dropped="{Binding IsDropped}" Classes.dropped="{Binding IsDropped}"
VerticalAlignment="Center" /> 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" <Button Classes="flat close inline" Width="16" Height="16" Padding="0"
VerticalAlignment="Center" VerticalAlignment="Center"
@@ -136,7 +136,7 @@
<Border Classes="settingscard"> <Border Classes="settingscard">
<StackPanel Margin="24,20" Spacing="10"> <StackPanel Margin="24,20" Spacing="10">
<TextBlock Classes="settingsrowcaption" <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" <TextBlock Classes="settingsrowcaption"
Text="Terminal font, cursor and scrollback — the renderer hard-codes those three. Text size above is the one that is not." /> Text="Terminal font, cursor and scrollback — the renderer hard-codes those three. Text size above is the one that is not." />
<TextBlock Classes="settingsrowcaption" <TextBlock Classes="settingsrowcaption"
@@ -70,15 +70,15 @@
<Border Classes="settingsrow last"> <Border Classes="settingsrow last">
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0" MaxWidth="640"> <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" <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}" /> IsVisible="{Binding CanRegisterDevice}" />
<TextBlock Classes="settingsrowcaption" <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}" /> IsVisible="{Binding CanForgetDevice}" />
<TextBlock Classes="settingsrowcaption" <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}" /> IsVisible="{Binding HasNoDeviceKeyOption}" />
</StackPanel> </StackPanel>
<Button Grid.Column="1" Classes="accent" Height="38" Content="REGISTER" <Button Grid.Column="1" Classes="accent" Height="38" Content="REGISTER"
@@ -81,11 +81,15 @@
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}"> IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel> <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="0" Classes="label" Text="TAG" />
<TextBlock Grid.Column="1" Classes="label" Text="USED BY" /> <TextBlock Grid.Column="1" Classes="label" Text="USED BY" />
<TextBlock Grid.Column="2" Classes="label" Text="VAULT" /> <TextBlock Grid.Column="2" Classes="label" Text=" " />
<TextBlock Grid.Column="3" Classes="label" Text=" " />
</Grid> </Grid>
<ItemsControl ItemsSource="{Binding Vault.Tags}"> <ItemsControl ItemsSource="{Binding Vault.Tags}">
@@ -97,7 +101,7 @@
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TagRowViewModel"> <DataTemplate x:DataType="vm:TagRowViewModel">
<Border Classes="settingsrow last" Padding="20,10"> <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"> <Border Grid.Column="0" Classes="chip" HorizontalAlignment="Left" VerticalAlignment="Center">
<TextBlock Text="{Binding Label}" /> <TextBlock Text="{Binding Label}" />
@@ -107,14 +111,7 @@
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Text="{Binding Description}" /> Text="{Binding Description}" />
<!-- <StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
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">
<Button Classes="paneicon" Width="30" Height="30" FontSize="13" <Button Classes="paneicon" Width="30" Height="30" FontSize="13"
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.EditTagRowCommand}" Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.EditTagRowCommand}"
CommandParameter="{Binding}" ToolTip.Tip="Rename this tag."> CommandParameter="{Binding}" ToolTip.Tip="Rename this tag.">
@@ -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);
}
} }
@@ -167,8 +167,12 @@
TextAlignment="Center" MaxWidth="360" TextAlignment="Center" MaxWidth="360"
IsVisible="{Binding !HasVisible}" /> 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}" <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"> <StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="ghost" Content="Edit" Command="{Binding EditCommand}" <Button Classes="ghost" Content="Edit" Command="{Binding EditCommand}"
IsEnabled="{Binding HasSelection}" /> IsEnabled="{Binding HasSelection}" />
@@ -44,15 +44,15 @@
IsEnabled="{Binding !IsBusy}" /> IsEnabled="{Binding !IsBusy}" />
<!-- <!--
Shown only when this machine has both a registered wrap and a keystore still willing to release the 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 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. fallback, it is the ordinary way in.
--> -->
<Button Classes="ghost" Content="USE WINDOWS HELLO" <Button Classes="ghost" Content="{Binding DeviceUnlock.Button}"
Command="{Binding UnlockWithDeviceCommand}" Command="{Binding UnlockWithDeviceCommand}"
IsEnabled="{Binding !IsBusy}" IsEnabled="{Binding !IsBusy}"
IsVisible="{Binding CanUnlockWithDevice}" 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> </StackPanel>
<TextBlock Classes="hint" Text="{Binding StatusMessage}" TextWrapping="Wrap" /> <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 /// 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. /// deciding whether the prompt in front of them is the one this application asked for.
/// </remarks> /// </remarks>
private static string GestureWait => private static string GestureWait => DeviceUnlockWording.ForThisPlatform.Wait;
OperatingSystem.IsAndroid() ? "Waiting for your fingerprint…" : "Waiting for Windows…";
/// <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> /// <summary>Opens the vault with this machine's device key instead of the passphrase.</summary>
/// <remarks> /// <remarks>
@@ -4516,7 +4518,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
{ {
if (Tabs.FirstOrDefault(tab => tab.SessionId == e.SessionId) is { } tab) 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 // 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 // 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; 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) if (elapsed < TimeSpan.Zero)
{ {
@@ -274,6 +274,15 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
/// </remarks> /// </remarks>
internal DateTimeOffset? StartedAt { get; set; } 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> /// <summary>
/// The negotiated server-to-client cipher, for the status bar — or null while there is no session. /// The negotiated server-to-client cipher, for the status bar — or null while there is no session.
/// </summary> /// </summary>
@@ -620,6 +629,7 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
// stale-fact failure this pair exists to remove. // stale-fact failure this pair exists to remove.
EndReason = null; EndReason = null;
EndDescription = null; EndDescription = null;
EndedAt = null;
IsLive = true; IsLive = true;
State = TerminalTabState.Open; 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. /// live attempt dead. <see cref="Advance"/> guards its own reports for the same reason.
/// </para> /// </para>
/// </remarks> /// </remarks>
internal void Ended(SshSessionEnd end, string description) internal void Ended(SshSessionEnd end, string description, DateTimeOffset at)
{ {
if (State is not TerminalTabState.Open) if (State is not TerminalTabState.Open)
{ {
return; return;
} }
EndedAt ??= at;
EndReason = end; EndReason = end;
EndDescription = description; EndDescription = description;
IsLive = false; 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 // 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. // cipher in the status bar beside a handshake that has not negotiated one yet.
StartedAt = null; StartedAt = null;
EndedAt = null;
Cipher = null; Cipher = null;
HostKeyAlgorithm = null; HostKeyAlgorithm = null;
IdentityLabel = null; IdentityLabel = null;
@@ -2404,6 +2404,14 @@ internal sealed partial class VaultViewModel(
internal bool HasVaultItems => VaultItems.Count > 0; 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; internal bool HasSelectedVaultItem => SelectedVaultItem is not null;
/// <summary> /// <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> /// <summary>── v5b ── Narrows <see cref="VaultItems"/> to rows <see cref="ItemFilter"/> matches.</summary>
/// <remarks> /// <remarks>
/// Its own method for the reason <see cref="AddTagRows"/> is out of <see cref="RebuildVaultItems"/> — /// 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. // sync every minute would otherwise move the detail pane out from under whoever was reading it.
SelectedVaultItem = VaultItems.FirstOrDefault(row => row.EntityId == selectedId); SelectedVaultItem = VaultItems.FirstOrDefault(row => row.EntityId == selectedId);
OnPropertyChanged(nameof(SectionSummary)); RaiseVaultItemFacts();
OnPropertyChanged(nameof(HasVaultItems));
OnPropertyChanged(nameof(TotalItemCount));
OnPropertyChanged(nameof(EmptySectionMessage));
// A reload replaces every row object, and the selection is restored by id — so the setter above may // 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, // 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 ---- // ---- Helpers ----
private static void RightClick(Visual card, Visual window) private static void RightClick(Visual card, Visual window)
@@ -2296,6 +2296,66 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
}, },
Token); 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) => private SnippetsViewModel NewSnippetsScreen(InsertTarget? target = null) =>
new( new(
vault, vault,
@@ -250,6 +250,42 @@ public sealed class SessionTabRowTests : IAsyncLifetime
Token); 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> /// <remarks>
/// v5b's tab, unlike the pill it replaced, paints no fill at all until it is either active or hovered — /// 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 /// 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 // Still worth offering, because the tab and the machine are both still there — what it is not is an
// alarm. // alarm.
shell.CanReconnectSelectedTab.ShouldBeTrue(); 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> /// <remarks>