Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d71f057bde | ||
|
|
3c8c0980c2 |
No files matched your search
@@ -1404,6 +1404,23 @@ MACHINE box, which takes the same path — `VaultViewModel.ConnectToEndpointAsyn
|
||||
the two is in how the palette hands the password over, not in the connection. The headless half of this is
|
||||
`QuickConnectTests`; what it cannot do is dial.
|
||||
|
||||
### 7.10b Pressing Terminal with nothing open asks what to connect to
|
||||
|
||||
Close every terminal tab, then press **Terminal** on the rail.
|
||||
|
||||
**Pass:** the terminal's frame holds a prompt — `connect to:` with the caret already on it, so typing works
|
||||
without a click. Type part of a saved host's name: the matching hosts appear under the line, Tab completes to
|
||||
the first, and Enter connects to it — asking `user@host:port's password:` first only if that host has no key
|
||||
or saved password. Type `user@host` for a machine that is not saved instead: Enter asks for its password, and
|
||||
the second Enter opens a tab for it. Escape steps back from the password to the name. Afterwards HOSTS has
|
||||
nothing new on it, and no credential was saved — even with "remember" ticked in a host's drawer earlier.
|
||||
|
||||
**Failure means:** a prompt that needs a click before it takes typing is the terminal's WebView still holding
|
||||
the native keyboard focus — see `NativeKeyboardFocus`, which `ConnectPrompt.TakeKeyboard` calls. Anything drawn
|
||||
*under* a blank rectangle is the occlusion rule broken: the prompt may only show while no tab exists. A new
|
||||
credential in KEYS after connecting is the prompt's attempt acting on the drawer's tick, which
|
||||
`ShellFlowTests.ThePrompt_NeverSavesAPassword_EvenWithTheDrawersTickLeftOn` exists to stop.
|
||||
|
||||
### 7.11 A host or a bucket made now can be picked on FILES now
|
||||
|
||||
With the files screen's picker open on an empty or short list, go to HOSTS and add a host — or to KEYS and
|
||||
|
||||
@@ -873,6 +873,29 @@
|
||||
<Setter Property="BorderBrush" Value="{StaticResource AccentSoft}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
◆ THE EMPTY TERMINAL'S PROMPT LINES, which are typed into the way a shell is: no fill, no border and no
|
||||
padding in any state, so the text sits on the pane exactly where the label before it leaves off, and the
|
||||
caret is the only sign the line takes input. A box drawn around it would turn a prompt back into a form.
|
||||
See ConnectPrompt.axaml. After the base rules and .pathbox, for the reason this file keeps restating.
|
||||
-->
|
||||
<Style Selector="TextBox.prompt">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="CaretBrush" Value="{StaticResource AccentText}" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="TextBox.prompt /template/ Border#PART_BorderElement,
|
||||
TextBox.prompt:pointerover /template/ Border#PART_BorderElement,
|
||||
TextBox.prompt:focus /template/ Border#PART_BorderElement">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
Lists. The rows in this design are drawn by their own templates and marked as selected with an accent
|
||||
strip down the left edge, so the theme's rounded blue selection block has to go — it would sit behind
|
||||
@@ -1200,10 +1223,14 @@
|
||||
The two buttons in the drawer's header: the overflow menu and the arrow that puts the drawer away.
|
||||
Chrome rather than controls — they are the frame around whichever panel is showing, which is why they
|
||||
are flat and quiet until the pointer finds them.
|
||||
|
||||
No padding, because the box is fixed: Fluent's own 8,5,8,6 left a glyph ten pixels of width inside
|
||||
twenty-six, and every 15px Material icon on the SFTP panes was drawn with its sides cut off.
|
||||
-->
|
||||
<Style Selector="Button.paneicon">
|
||||
<Setter Property="Width" Value="26" />
|
||||
<Setter Property="Height" Value="26" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||
x:Class="DodoSSH.Client.App.Views.ConnectPrompt"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Background="{StaticResource Pane}">
|
||||
|
||||
<!--
|
||||
What the terminal's rectangle holds when Terminal is pressed with nothing open: a prompt, because asking
|
||||
is what a terminal with nothing in it does. It used to be an empty frame, with the tab row's "+" the only
|
||||
thing on it that did anything.
|
||||
|
||||
Two lines, in the order ssh itself asks. The first takes a saved host's name — matched exactly, with the
|
||||
names that match so far printed under it for Tab or a click to take — or user@host[:port] for a machine
|
||||
that is not saved. The second appears only when a password is needed, worded as ssh words it, and is
|
||||
never offered for a host whose keychain binding supplies its own credential.
|
||||
|
||||
Drawn by Avalonia in the WebView's own rectangle, which the occlusion rule allows only because the two
|
||||
are exclusive: this shows while there are no tabs, and the WebView only for a selected tab with a
|
||||
session. See MainWindowViewModel.IsConnectPromptShowing.
|
||||
|
||||
Everything it does goes through the vault's own connect paths — ConnectToHostAsync for a saved host,
|
||||
ConnectToEndpointAsync for an address — so every refusal those make is made here too, and nothing typed
|
||||
at this prompt is written to the keychain.
|
||||
-->
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Margin="18,16" Spacing="3" TextElement.FontFamily="{StaticResource MonoFont}">
|
||||
|
||||
<TextBlock Classes="mono" FontSize="13" Foreground="{StaticResource TextFaint}" TextWrapping="Wrap"
|
||||
Text="No session open. Connect to a saved host by name, or to any machine as user@host[:port]." />
|
||||
|
||||
<!-- ============ what to connect to ============ -->
|
||||
<Grid ColumnDefinitions="Auto,*" Margin="0,14,0,0">
|
||||
<TextBlock Grid.Column="0" Classes="mono" FontSize="13" FontWeight="Bold"
|
||||
Foreground="{StaticResource AccentText}" Text="connect to:" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Column="1" x:Name="Target" Classes="prompt" Margin="8,0,0,0"
|
||||
Text="{Binding PromptText}" IsReadOnly="{Binding IsPromptAskingForPassword}" />
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
The saved hosts that match so far, best first, the first one Tab's. A wrapped row rather than a list,
|
||||
because this is a shell's completion line and not a picker: a name and an address, nothing to scroll.
|
||||
-->
|
||||
<ItemsControl ItemsSource="{Binding PromptMatches}" Margin="0,2,0,0"
|
||||
IsVisible="{Binding ShowsPromptMatches}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HostRowViewModel">
|
||||
<Button Classes="flat" Focusable="False" Padding="6,2" Margin="0,0,6,0" CornerRadius="5"
|
||||
Cursor="Hand" ToolTip.Tip="{Binding Address}"
|
||||
Command="{Binding $parent[views:ConnectPrompt].((vm:MainWindowViewModel)DataContext).ChoosePromptMatchCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<TextBlock Classes="mono" FontSize="12" Foreground="{StaticResource TextDim}"
|
||||
Text="{Binding Label}" />
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- ============ the password, when one is needed ============ -->
|
||||
<Grid x:Name="PasswordLine" ColumnDefinitions="Auto,*" IsVisible="{Binding IsPromptAskingForPassword}">
|
||||
<TextBlock Grid.Column="0" Classes="mono" FontSize="13" Foreground="{StaticResource Text}"
|
||||
Text="{Binding PromptPasswordLabel}" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Column="1" x:Name="Password" Classes="prompt" Margin="8,0,0,0" PasswordChar="•"
|
||||
Text="{Binding PromptPassword}" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="mono" FontSize="12.5" TextWrapping="Wrap" Margin="0,4,0,0"
|
||||
Foreground="{StaticResource WarnText}"
|
||||
Text="{Binding PromptRefusal}" IsVisible="{Binding HasPromptRefusal}" />
|
||||
|
||||
<TextBlock Classes="mono" FontSize="10.5" Margin="0,10,0,0" Foreground="{StaticResource TextGhost}"
|
||||
Text="ENTER to connect · TAB to complete · ESC to go back · CTRL+K to search hosts" />
|
||||
|
||||
<!--
|
||||
============ where you have been ============
|
||||
|
||||
The machines most recently connected to, newest first — the vault's own connection log, deduplicated
|
||||
by address, so it survives a restart and arrives on a new machine with the keychain. A click puts the
|
||||
row on the prompt line and carries on from there exactly as if it had been typed: a saved host
|
||||
connects, or asks for its password; an address asks for its password.
|
||||
-->
|
||||
<StackPanel Margin="0,22,0,0" Spacing="1" IsVisible="{Binding HasRecentConnections}">
|
||||
<TextBlock Classes="mono" FontSize="11" Foreground="{StaticResource TextFaint}" Text="recent"
|
||||
Margin="0,0,0,4" />
|
||||
<ItemsControl ItemsSource="{Binding RecentConnections}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConnectionLogRowViewModel">
|
||||
<Button Classes="flat" Focusable="False" Padding="6,3" CornerRadius="5" Cursor="Hand"
|
||||
HorizontalAlignment="Left"
|
||||
Command="{Binding $parent[views:ConnectPrompt].((vm:MainWindowViewModel)DataContext).ChoosePromptRecentCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="14">
|
||||
<TextBlock Classes="mono" FontSize="12.5" Foreground="{StaticResource TextDim}"
|
||||
Text="{Binding Address}" />
|
||||
<TextBlock Classes="mono" FontSize="12" Foreground="{StaticResource TextFaint}"
|
||||
Text="{Binding HostLabel}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,125 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Threading;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>The empty terminal's prompt: what to connect to, and its password when one is needed.</summary>
|
||||
/// <remarks>
|
||||
/// The keys and the caret live here, as the palette's do in <see cref="QuickConnect"/> and for its reason: a
|
||||
/// <see cref="UserControl"/> hosts in a bare window under the headless harness, and the window itself cannot
|
||||
/// be shown there at all.
|
||||
/// </remarks>
|
||||
internal sealed partial class ConnectPrompt : UserControl
|
||||
{
|
||||
public ConnectPrompt()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
// Tunnelled, for the reason QuickConnect gives: the prompt owns Enter, Escape and Tab while the caret is
|
||||
// in it, and a text box on the route must not get to them first.
|
||||
AddHandler(KeyDownEvent, OnPromptKey, RoutingStrategies.Tunnel);
|
||||
|
||||
// The password line appearing, or going, moves the caret with it: Enter on a name that needs a password
|
||||
// should leave somebody typing the password, and Escape off it should leave them editing the name.
|
||||
PasswordLine.PropertyChanged += OnLineChanged;
|
||||
}
|
||||
|
||||
/// <summary>The first line's box, for the caret and for the tests.</summary>
|
||||
internal TextBox TargetBox => Target;
|
||||
|
||||
/// <summary>The password line's box, for the caret and for the tests.</summary>
|
||||
internal TextBox PasswordBox => Password;
|
||||
|
||||
private MainWindowViewModel? Shell => DataContext as MainWindowViewModel;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||
{
|
||||
base.OnPropertyChanged(change);
|
||||
|
||||
// Posted, for the reason QuickConnect.TakeKeyboard gives at length: at the instant this becomes
|
||||
// visible its boxes are not in the visual tree yet, and focus is refused to anything that is not.
|
||||
if (change.Property == IsVisibleProperty && change.GetNewValue<bool>())
|
||||
{
|
||||
Dispatcher.UIThread.Post(TakeKeyboard, DispatcherPriority.Loaded);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnLineChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.Property == IsVisibleProperty && IsVisible)
|
||||
{
|
||||
Dispatcher.UIThread.Post(TakeKeyboard, DispatcherPriority.Loaded);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Puts the caret on whichever line is asking.</summary>
|
||||
/// <remarks>
|
||||
/// Both halves, for the reason <see cref="NativeKeyboardFocus"/> gives: the terminal's WebView can still
|
||||
/// hold the native window's focus after it has been collapsed, and an Avalonia focus without the native
|
||||
/// call is a caret that receives nothing.
|
||||
/// </remarks>
|
||||
private void TakeKeyboard()
|
||||
{
|
||||
if (!IsEffectivelyVisible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (TopLevel.GetTopLevel(this) is Window window)
|
||||
{
|
||||
NativeKeyboardFocus.ReturnTo(window);
|
||||
}
|
||||
|
||||
if (PasswordLine.IsVisible)
|
||||
{
|
||||
Password.Focus();
|
||||
}
|
||||
else
|
||||
{
|
||||
Target.Focus();
|
||||
Target.CaretIndex = Target.Text?.Length ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPromptKey(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (Shell is not { IsConnectPromptShowing: true } shell || e.KeyModifiers != KeyModifiers.None)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.Key)
|
||||
{
|
||||
case Key.Enter:
|
||||
// Fire-and-forget, as the palette's is: the command reports its own refusals on the prompt,
|
||||
// and a connection's progress and failure in the tab it opens.
|
||||
_ = shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Escape:
|
||||
shell.CancelPromptCommand.Execute(null);
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Tab:
|
||||
// Only when there is something to complete to; otherwise Tab moves the focus, as it always does.
|
||||
e.Handled = shell.CompletePrompt();
|
||||
|
||||
if (e.Handled)
|
||||
{
|
||||
Target.CaretIndex = Target.Text?.Length ?? 0;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -285,9 +285,8 @@
|
||||
<!--
|
||||
v5c-4: two rows rather than three, the same as the SFTP wrapper above and for the same
|
||||
reason — the host header is gone and the terminal has its 60 pixels. The empty state it
|
||||
used to print ("no terminals open · press + or Ctrl+K…") went with it rather than moving:
|
||||
this Grid is only drawn on the terminal surface, and the surface with no tab open already
|
||||
answers for itself in the tab row's own "+" and in the connecting card below.
|
||||
used to print ("no terminals open · press + or Ctrl+K…") went with it, and the surface with
|
||||
no tab open is answered by the prompt below instead — see ConnectPrompt.axaml.
|
||||
-->
|
||||
<!--
|
||||
Three rows since the disconnect strip arrived, where v5c-4 left two. The extra one is Auto
|
||||
@@ -309,6 +308,16 @@
|
||||
<views:ConnectingCard x:Name="ConnectingPane"
|
||||
IsVisible="{Binding IsConnectingShowing, FallbackValue=False}" />
|
||||
|
||||
<!--
|
||||
The third: nothing open at all, which is what pressing Terminal on the rail with no
|
||||
session reaches. A prompt asking what to connect to, where an empty frame used to be.
|
||||
Exclusive with the WebView for the same reason the card above is — no tab, no session —
|
||||
and FallbackValue=False for the reason the WebView's own binding gives. See
|
||||
ConnectPrompt.axaml.
|
||||
-->
|
||||
<views:ConnectPrompt x:Name="PromptPane"
|
||||
IsVisible="{Binding IsConnectPromptShowing, FallbackValue=False}" />
|
||||
|
||||
<!--
|
||||
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate
|
||||
browser process tree, so twenty tabs would cost twenty of them.
|
||||
|
||||
@@ -209,10 +209,9 @@
|
||||
design's, unlike HOST/BUCKET across the arrow column, which keeps its own words for a reason
|
||||
recorded where it is drawn.
|
||||
|
||||
UP is arrow_upward here and arrow_downward on the host/bucket pane below, matching the design's own
|
||||
two glyphs exactly rather than picking one "up a directory" icon for both — both buttons do the same
|
||||
thing (go to the parent directory), and the pair reading differently is the design's own choice
|
||||
faithfully carried over rather than a functional difference invented to justify it.
|
||||
UP is arrow_upward on both panes. The design drew arrow_downward on the host/bucket pane, and that
|
||||
was carried over until it was reported as a wrong icon — which it is: both buttons go to the parent
|
||||
directory, and a down arrow under the tooltip "Up one directory" says the opposite of what it does.
|
||||
-->
|
||||
<!--
|
||||
◆ THE PATH SITS IN THE STAR COLUMN, ALONE, AND THAT PLACEMENT IS LOAD-BEARING.
|
||||
@@ -398,8 +397,8 @@
|
||||
directory tree and a flat namespace with inferred folders — and that reason still holds, so only
|
||||
the style moves to the design's 10px tracked label, not the wording.
|
||||
|
||||
UP is arrow_downward here, matching arrow_upward on the local pane above per the design's own two
|
||||
distinct glyphs; see that pane's own remark on why the pair differs without the actions differing.
|
||||
UP is arrow_upward, the same as the local pane's; see that pane's own remark for why it is no longer
|
||||
the design's arrow_downward.
|
||||
-->
|
||||
<!--
|
||||
◆ THE PATH IS TYPED HERE, and that is what the star column is now holding.
|
||||
@@ -446,7 +445,7 @@
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4" Margin="8,0,0,0">
|
||||
<Button Classes="paneicon" Command="{Binding RemoteUpCommand}"
|
||||
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Up one directory">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="" />
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="" />
|
||||
</Button>
|
||||
<Button Classes="paneicon" Command="{Binding RefreshRemoteCommand}"
|
||||
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Refresh">
|
||||
@@ -546,7 +545,7 @@
|
||||
</Button>
|
||||
<Button Classes="paneicon" Command="{Binding RenameRemoteCommand}"
|
||||
IsEnabled="{Binding CanRenameRemote}" ToolTip.Tip="Rename on the host">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="" />
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="" />
|
||||
</Button>
|
||||
<Button Classes="paneicon" Command="{Binding MoveRemoteCommand}"
|
||||
IsEnabled="{Binding CanMoveRemote}" ToolTip.Tip="Move to another directory">
|
||||
|
||||
@@ -1521,10 +1521,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// to the same value.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Not gated on there being a tab.</b> Closing the last tab returns <see cref="Surface"/> to
|
||||
/// <see cref="ShellSurface.Page"/> instead, so the empty case never arises — and gating here as well
|
||||
/// would be a second answer to one question. The empty-state sentence lives in the tab strip, which
|
||||
/// Avalonia draws and nothing occludes.
|
||||
/// <b>Not gated on there being a tab, because the last clause already is.</b> A selected tab with a
|
||||
/// session is what this needs, so the surface with nothing open — which the rail's Terminal entry reaches
|
||||
/// deliberately — has the WebView collapsed, and that is the rectangle <see cref="IsConnectPromptShowing"/>
|
||||
/// draws its prompt in.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Revealing and focusing now happen in the same turn, routinely.</b> Opening a terminal from the
|
||||
@@ -1852,16 +1852,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// <summary>Switches to the terminal surface.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The other half of <see cref="ShowScreenCommand"/>, and it exists because the phone's bottom bar
|
||||
/// names the terminal beside the pages. The desktop reaches this surface only implicitly — opening a
|
||||
/// session or clicking a tab — because its tab strip is always on screen and is itself the way back.
|
||||
/// A phone has no room for a permanent strip beside a full-height screen, so the destination needs a
|
||||
/// button, and a button needs a command.
|
||||
/// The other half of <see cref="ShowScreenCommand"/>: the phone's bottom bar and the desktop rail's
|
||||
/// Terminal entry both name the terminal beside the pages, so the destination needs a button, and a
|
||||
/// button needs a command.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Not gated on there being a tab, for the same reason <see cref="IsTerminalShowing"/> is not: closing
|
||||
/// the last tab returns the surface to a page, so the empty case does not arise here — and the terminal
|
||||
/// screen carries an empty state anyway, which is worth being able to reach deliberately.
|
||||
/// Not gated on there being a tab. With nothing open the surface is where a connection is made — the
|
||||
/// phone's terminal screen draws its manual box there, and the desktop its prompt; see
|
||||
/// <see cref="IsConnectPromptShowing"/> — which is worth being able to reach deliberately.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
@@ -2365,7 +2363,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
internal bool HasTabs => Tabs.Count > 0;
|
||||
|
||||
private void RaiseTabState() => OnPropertyChanged(nameof(HasTabs));
|
||||
private void RaiseTabState()
|
||||
{
|
||||
OnPropertyChanged(nameof(HasTabs));
|
||||
OnPropertyChanged(nameof(IsConnectPromptShowing));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes one terminal, ending its shell.
|
||||
@@ -2436,6 +2438,333 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
await workspace.CloseSessionAsync(tab.SessionId).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
// ---- The empty terminal's prompt ----
|
||||
|
||||
/// <summary>
|
||||
/// Whether the terminal's rectangle is asking what to connect to: the terminal surface, with nothing open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The desktop's answer to pressing Terminal with no session.</b> The surface used to draw an empty
|
||||
/// frame there — the tab row's "+" was the only thing on it that did anything. A terminal with nothing in
|
||||
/// it should do what a terminal does, which is ask: so the frame holds a prompt that takes a saved host's
|
||||
/// name, or <c>user@host</c> for a machine that is not saved, and asks for a password when one is needed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Exclusive with the renderer by construction: the WebView shows only for a selected tab with a session,
|
||||
/// and this only while there are no tabs at all. That is what lets Avalonia draw it in the WebView's
|
||||
/// rectangle, the way <c>ConnectingCard</c> already is. See <see cref="IsTerminalShowing"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool IsConnectPromptShowing => IsTerminalSurface && !HasTabs;
|
||||
|
||||
/// <summary>What was typed at the prompt: a saved host's name, or <c>user@host[:port]</c>.</summary>
|
||||
[ObservableProperty]
|
||||
private string promptText = string.Empty;
|
||||
|
||||
/// <summary>The password typed at the prompt's second line.</summary>
|
||||
/// <remarks>
|
||||
/// Cleared the moment it is used or abandoned. It is never written into the vault's own password box
|
||||
/// either: that box carries a remember-this tick the prompt never offered — see
|
||||
/// <c>VaultViewModel.ConnectToHostAsync</c>.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string promptPassword = string.Empty;
|
||||
|
||||
/// <summary>Whether the prompt has moved on to asking for a password.</summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsPromptAskingForTarget))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowsPromptMatches))]
|
||||
private bool isPromptAskingForPassword;
|
||||
|
||||
/// <summary>Whether the prompt is still asking what to connect to.</summary>
|
||||
internal bool IsPromptAskingForTarget => !IsPromptAskingForPassword;
|
||||
|
||||
/// <summary>The second line's prompt, in <c>ssh</c>'s own words: whose password it is.</summary>
|
||||
[ObservableProperty]
|
||||
private string promptPasswordLabel = string.Empty;
|
||||
|
||||
/// <summary>Why the prompt refused what was typed, or empty.</summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasPromptRefusal))]
|
||||
private string promptRefusal = string.Empty;
|
||||
|
||||
/// <summary>Whether there is a refusal to print under the prompt.</summary>
|
||||
internal bool HasPromptRefusal => PromptRefusal.Length > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Saved hosts whose name or address matches what has been typed so far, best first; Tab takes the first.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Empty for a query with an <c>@</c> in it, which is an address rather than a name, and for an empty
|
||||
/// one: a list of every host under a prompt nobody has typed into yet is the hosts screen, drawn worse.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<HostRowViewModel> PromptMatches { get; } = [];
|
||||
|
||||
/// <summary>Whether there are matches to print under the prompt.</summary>
|
||||
internal bool HasPromptMatches => PromptMatches.Count > 0;
|
||||
|
||||
/// <summary>Whether the matches are drawn: only while the name is being asked for, not under its password.</summary>
|
||||
internal bool ShowsPromptMatches => HasPromptMatches && !IsPromptAskingForPassword;
|
||||
|
||||
/// <summary>The saved host the password line is for, when it is for one.</summary>
|
||||
private HostRowViewModel? promptHost;
|
||||
|
||||
/// <summary>The typed address the password line is for, when it is for one.</summary>
|
||||
private VaultViewModel.ManualEndpoint? promptEndpoint;
|
||||
|
||||
/// <summary>
|
||||
/// Enter at the prompt: takes what was typed, or — on the password line — connects.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A name is matched exactly, ignoring case, because Enter on a name is a decision and not a search: the
|
||||
/// matches printed under the prompt are how a partial name becomes a whole one, by Tab or by a click.
|
||||
/// Two hosts with the same name in two vaults are refused rather than guessed between, and pointed at
|
||||
/// the palette, which shows which vault each came from.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Concurrent executions allowed, for the reason <see cref="ConnectToAdHocTargetAsync"/> gives.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand(AllowConcurrentExecutions = true)]
|
||||
private async Task SubmitPromptAsync()
|
||||
{
|
||||
if (Vault is not { } vault)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsPromptAskingForPassword)
|
||||
{
|
||||
await ConnectFromPromptAsync(vault).ConfigureAwait(true);
|
||||
return;
|
||||
}
|
||||
|
||||
var typed = PromptText.Trim();
|
||||
|
||||
if (typed.Length == 0)
|
||||
{
|
||||
PromptRefusal = "Type the name of a saved host, or user@host for a machine that is not saved.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (typed.Contains('@', StringComparison.Ordinal))
|
||||
{
|
||||
if (!VaultViewModel.TryParseManualTarget(typed, out var endpoint, out var reason))
|
||||
{
|
||||
PromptRefusal = reason;
|
||||
return;
|
||||
}
|
||||
|
||||
AskForPromptPassword(endpoint);
|
||||
return;
|
||||
}
|
||||
|
||||
var named = vault.Hosts
|
||||
.Where(host => string.Equals(host.Label, typed, StringComparison.OrdinalIgnoreCase))
|
||||
.Take(2)
|
||||
.ToList();
|
||||
|
||||
switch (named.Count)
|
||||
{
|
||||
case 0:
|
||||
PromptRefusal = PromptMatches.Count > 0
|
||||
? $"No saved host is called \"{typed}\". Press Tab for {PromptMatches[0].Label}, or type user@host."
|
||||
: $"No saved host is called \"{typed}\". Type user@host to reach a machine that is not saved.";
|
||||
return;
|
||||
|
||||
case > 1:
|
||||
PromptRefusal =
|
||||
$"More than one saved host is called \"{typed}\". Press Ctrl+K to choose between them.";
|
||||
return;
|
||||
|
||||
default:
|
||||
await TakePromptHostAsync(vault, named[0]).ConfigureAwait(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A click on one of the matches printed under the prompt.</summary>
|
||||
[RelayCommand(AllowConcurrentExecutions = true)]
|
||||
private async Task ChoosePromptMatchAsync(HostRowViewModel? host)
|
||||
{
|
||||
if (Vault is not { } vault || host is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PromptText = host.Label;
|
||||
await TakePromptHostAsync(vault, host).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A click on one of the recent connections printed under the prompt.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A row naming a host still in the keychain goes to that host, with whatever the keychain knows about
|
||||
/// it; one that names none — or a host deleted since — is the address that was dialled, and asks for its
|
||||
/// password the way a typed one does. The same split <see cref="ConnectToRecent"/> makes, and for its
|
||||
/// reason, except that here the answer is a line of the prompt rather than another screen.
|
||||
/// </remarks>
|
||||
[RelayCommand(AllowConcurrentExecutions = true)]
|
||||
private async Task ChoosePromptRecentAsync(ConnectionLogRowViewModel? row)
|
||||
{
|
||||
if (Vault is not { } vault || row is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (row.HostId is { } hostId
|
||||
&& vault.Hosts.FirstOrDefault(host => host.EntityId == hostId) is { } known
|
||||
&& vault.IsVaultShown(known.VaultId))
|
||||
{
|
||||
PromptText = known.Label;
|
||||
await TakePromptHostAsync(vault, known).ConfigureAwait(true);
|
||||
return;
|
||||
}
|
||||
|
||||
PromptText = row.Address;
|
||||
|
||||
if (VaultViewModel.TryParseManualTarget(row.Address, out var endpoint, out var reason))
|
||||
{
|
||||
AskForPromptPassword(endpoint);
|
||||
}
|
||||
else
|
||||
{
|
||||
PromptRefusal = reason;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escape at the prompt: off the password line back to the name, or clears the name.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private void CancelPrompt()
|
||||
{
|
||||
if (IsPromptAskingForPassword)
|
||||
{
|
||||
IsPromptAskingForPassword = false;
|
||||
PromptPassword = string.Empty;
|
||||
PromptRefusal = string.Empty;
|
||||
promptHost = null;
|
||||
promptEndpoint = null;
|
||||
return;
|
||||
}
|
||||
|
||||
PromptText = string.Empty;
|
||||
PromptRefusal = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Tab at the prompt: completes to the best match, if there is one.</summary>
|
||||
/// <returns>Whether anything was completed, so the key can move the focus as usual when it was not.</returns>
|
||||
internal bool CompletePrompt()
|
||||
{
|
||||
if (IsPromptAskingForPassword || PromptMatches.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
PromptText = PromptMatches[0].Label;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Connects to a saved host, or asks for its password first if it needs one.</summary>
|
||||
private async Task TakePromptHostAsync(VaultViewModel vault, HostRowViewModel host)
|
||||
{
|
||||
if (VaultViewModel.AsksForAPassword(host))
|
||||
{
|
||||
promptHost = host;
|
||||
promptEndpoint = null;
|
||||
PromptPasswordLabel = $"{host.Address}'s password:";
|
||||
PromptPassword = string.Empty;
|
||||
PromptRefusal = string.Empty;
|
||||
IsPromptAskingForPassword = true;
|
||||
return;
|
||||
}
|
||||
|
||||
ResetPrompt();
|
||||
|
||||
// No password to offer and nothing to remember: the binding supplies the credential. Any refusal —
|
||||
// a key deleted on another machine, a host with no username — lands on the status line, as it does
|
||||
// for the palette.
|
||||
await vault.ConnectToHostAsync(host, string.Empty, mayRememberPassword: false, CancellationToken.None)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Moves the prompt on to the password for a typed address.</summary>
|
||||
private void AskForPromptPassword(VaultViewModel.ManualEndpoint endpoint)
|
||||
{
|
||||
promptHost = null;
|
||||
promptEndpoint = endpoint;
|
||||
PromptPasswordLabel = $"{endpoint.Address}'s password:";
|
||||
PromptPassword = string.Empty;
|
||||
PromptRefusal = string.Empty;
|
||||
IsPromptAskingForPassword = true;
|
||||
}
|
||||
|
||||
/// <summary>The password line's Enter: dials whichever of the two the line is for.</summary>
|
||||
private async Task ConnectFromPromptAsync(VaultViewModel vault)
|
||||
{
|
||||
if (PromptPassword.Length == 0)
|
||||
{
|
||||
PromptRefusal = "Type the password, or press Esc to go back.";
|
||||
return;
|
||||
}
|
||||
|
||||
var password = PromptPassword;
|
||||
var host = promptHost;
|
||||
var endpoint = promptEndpoint;
|
||||
|
||||
// Before the dial, so the password does not wait in the box for the length of a handshake. The tab the
|
||||
// connection opens replaces the prompt anyway; this is what is there the next time nothing is open.
|
||||
ResetPrompt();
|
||||
|
||||
if (host is not null)
|
||||
{
|
||||
await vault.ConnectToHostAsync(host, password, mayRememberPassword: false, CancellationToken.None)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
else if (endpoint is not null)
|
||||
{
|
||||
await vault.ConnectToEndpointAsync(endpoint, password, CancellationToken.None).ConfigureAwait(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Puts the prompt back to an empty first line.</summary>
|
||||
private void ResetPrompt()
|
||||
{
|
||||
IsPromptAskingForPassword = false;
|
||||
PromptPassword = string.Empty;
|
||||
PromptText = string.Empty;
|
||||
PromptRefusal = string.Empty;
|
||||
promptHost = null;
|
||||
promptEndpoint = null;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A refusal is about what was typed, so typing again clears it; the matches follow every keystroke.
|
||||
/// </remarks>
|
||||
partial void OnPromptTextChanged(string value)
|
||||
{
|
||||
PromptRefusal = string.Empty;
|
||||
|
||||
PromptMatches.Clear();
|
||||
|
||||
var query = value.Trim();
|
||||
|
||||
if (Vault is { } vault && query.Length > 0 && !query.Contains('@', StringComparison.Ordinal))
|
||||
{
|
||||
foreach (var host in HostsMatching(vault, query).Take(5))
|
||||
{
|
||||
PromptMatches.Add(host);
|
||||
}
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(HasPromptMatches));
|
||||
OnPropertyChanged(nameof(ShowsPromptMatches));
|
||||
}
|
||||
|
||||
// ---- Quick connect ----
|
||||
|
||||
/// <summary>Whether the quick-connect palette is open over the window.</summary>
|
||||
@@ -2827,16 +3156,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
var query = SearchText.Trim();
|
||||
|
||||
var matches = query.Length == 0
|
||||
? vault.Hosts.AsEnumerable()
|
||||
: vault.Hosts
|
||||
.Select(host => (host, rank: Rank(host, query)))
|
||||
.Where(candidate => candidate.rank < int.MaxValue)
|
||||
.OrderBy(candidate => candidate.rank)
|
||||
.ThenBy(candidate => candidate.host.Label, StringComparer.CurrentCulture)
|
||||
.Select(candidate => candidate.host);
|
||||
|
||||
foreach (var host in matches.Take(8))
|
||||
foreach (var host in HostsMatching(vault, query).Take(8))
|
||||
{
|
||||
SearchResults.Add(host);
|
||||
}
|
||||
@@ -2866,6 +3186,21 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
OnPropertyChanged(nameof(HasNothingToOffer));
|
||||
}
|
||||
|
||||
/// <summary>The vault's hosts that match what was typed, best first; every host for an empty query.</summary>
|
||||
/// <remarks>
|
||||
/// Shared by the palette and the empty terminal's prompt, so a name typed into either finds the same
|
||||
/// machines in the same order.
|
||||
/// </remarks>
|
||||
private static IEnumerable<HostRowViewModel> HostsMatching(VaultViewModel vault, string query) =>
|
||||
query.Length == 0
|
||||
? vault.Hosts
|
||||
: vault.Hosts
|
||||
.Select(host => (host, rank: Rank(host, query)))
|
||||
.Where(candidate => candidate.rank < int.MaxValue)
|
||||
.OrderBy(candidate => candidate.rank)
|
||||
.ThenBy(candidate => candidate.host.Label, StringComparer.CurrentCulture)
|
||||
.Select(candidate => candidate.host);
|
||||
|
||||
/// <summary>Where one host places against what was typed, or <see cref="int.MaxValue"/> for no match.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -5906,6 +6241,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
OnPropertyChanged(nameof(IsTerminalShowing));
|
||||
OnPropertyChanged(nameof(IsConnectingShowing));
|
||||
OnPropertyChanged(nameof(IsHostKeyDecisionShowing));
|
||||
OnPropertyChanged(nameof(IsConnectPromptShowing));
|
||||
|
||||
// ShowsQuickAccessSidebar and the session shell's own facts read IsTerminalSurface too, so anything
|
||||
// that moves the terminal state has to repaint them — otherwise the sidebar could stay drawn over a
|
||||
|
||||
@@ -4378,9 +4378,21 @@ internal sealed partial class VaultViewModel(
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool SelectedHostAsksForAPassword =>
|
||||
SelectedHost is null
|
||||
|| (!SelectedHost.Host.SignsInThroughSpelunk
|
||||
&& SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword);
|
||||
SelectedHost is null || AsksForAPassword(SelectedHost);
|
||||
|
||||
/// <summary>Whether connecting to this host needs a password typed for it.</summary>
|
||||
/// <remarks>
|
||||
/// The per-row half of <see cref="SelectedHostAsksForAPassword"/>, and the same reading for the same
|
||||
/// reasons: the resolved binding rather than the host's own ids, and never for a host Spelunk signs in to.
|
||||
/// The desktop's terminal prompt asks it of a host named there, which is not the selected one.
|
||||
/// </remarks>
|
||||
internal static bool AsksForAPassword(HostRowViewModel row)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
|
||||
return !row.Host.SignsInThroughSpelunk
|
||||
&& row.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the terminal column says in place of the password box, or nothing when the box is showing.
|
||||
@@ -11460,11 +11472,41 @@ internal sealed partial class VaultViewModel(
|
||||
return;
|
||||
}
|
||||
|
||||
await ConnectToHostAsync(row, ConnectPassword, mayRememberPassword: true, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a terminal on a keychain host, with a password typed for it if it needs one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The body of <see cref="ConnectToSelectedHostAsync"/>, taking the host and the password as arguments so
|
||||
/// the desktop's terminal prompt can name a host without selecting it and type a password without writing
|
||||
/// it into <see cref="ConnectPassword"/> — which is the drawer's box, and would carry a remember-this
|
||||
/// choice the prompt never offered. Every refusal is still made here, once.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The password is ignored for a host whose binding supplies its own credential, as the drawer's box is.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="row">The host to connect to.</param>
|
||||
/// <param name="password">The password typed for it, or empty.</param>
|
||||
/// <param name="mayRememberPassword">See <see cref="ConnectionTarget.MayRememberPassword"/>.</param>
|
||||
/// <param name="cancellationToken">Whatever the caller's own lifetime is.</param>
|
||||
internal async Task ConnectToHostAsync(
|
||||
HostRowViewModel row,
|
||||
string password,
|
||||
bool mayRememberPassword,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
|
||||
// Refused rather than quietly falling back to the password box. A host set up for key-only access
|
||||
// that silently starts offering a password is the failure worth ruling out — the user asked for one
|
||||
// thing and got another, and the host is the last place that would say so.
|
||||
if (!TryBuildAuthentication(
|
||||
row.Host, row.Resolved, ConnectPassword, out var authentication, out var refusal))
|
||||
row.Host, row.Resolved, password, out var authentication, out var refusal))
|
||||
{
|
||||
Status = refusal;
|
||||
return;
|
||||
@@ -11477,7 +11519,12 @@ internal sealed partial class VaultViewModel(
|
||||
IsAskingForConnectPassword = false;
|
||||
|
||||
await ConnectToAsync(
|
||||
new ConnectionTarget(row.Label, row.Host.Hostname, row.Resolved.Port.Value, row),
|
||||
new ConnectionTarget(
|
||||
row.Label,
|
||||
row.Host.Hostname,
|
||||
row.Resolved.Port.Value,
|
||||
row,
|
||||
mayRememberPassword),
|
||||
authentication,
|
||||
cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
@@ -12331,7 +12378,7 @@ internal sealed partial class VaultViewModel(
|
||||
// Only for a target that came from a keychain host. A machine typed into the manual box has nothing
|
||||
// to bind a credential to and nothing to bind it *on* — that path saves nothing by design, and the
|
||||
// screen it is typed on says so.
|
||||
if (target.Row is { } row)
|
||||
if (target is { Row: { } row, MayRememberPassword: true })
|
||||
{
|
||||
await RememberTypedPasswordAsync(row, authentication, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
@@ -12618,6 +12665,12 @@ internal sealed partial class VaultViewModel(
|
||||
/// <param name="Row">
|
||||
/// The keychain host this came from, or null for a machine that is not in the keychain.
|
||||
/// </param>
|
||||
/// <param name="MayRememberPassword">
|
||||
/// Whether <see cref="RemembersConnectPassword"/> applies to this attempt. That tick belongs to the
|
||||
/// drawer's password box, so only an attempt whose password came out of that box may act on it — a
|
||||
/// password typed into the desktop's terminal prompt, which offers no such choice, must not be saved
|
||||
/// because the tick happened to be left on in a drawer that is not even open.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This exists so the connect path stops being shaped like <see cref="HostRowViewModel"/>. Everything
|
||||
@@ -12638,7 +12691,8 @@ internal sealed partial class VaultViewModel(
|
||||
string Label,
|
||||
string Hostname,
|
||||
int Port,
|
||||
HostRowViewModel? Row = null)
|
||||
HostRowViewModel? Row = null,
|
||||
bool MayRememberPassword = true)
|
||||
{
|
||||
/// <summary>The keychain item, or null for a machine that is not in it.</summary>
|
||||
internal Guid? HostId => Row?.EntityId;
|
||||
|
||||
@@ -139,20 +139,41 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
||||
IAsyncDisposable? lifetime,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var info = BuildConnectionInfo(request);
|
||||
|
||||
// Keep-alive here for the reason it is on the shell client: a transfer connection left open while
|
||||
// somebody browses is idle in exactly the same way, and a file listing against a machine that has
|
||||
// since rebooted would otherwise wait on a socket nothing will ever answer.
|
||||
var client = new SftpClient(BuildConnectionInfo(request))
|
||||
//
|
||||
// ◆ The operation timeout is the connect timeout for the handshake, and nothing afterwards. SSH.NET's
|
||||
// default is to wait for ever, and its ConnectAsync runs the SFTP handshake — the version exchange
|
||||
// and the home directory lookup — synchronously after the login, where the cancellation token is not
|
||||
// read. A host whose login prints anything into the channel (an echo in .bashrc is the classic) sends
|
||||
// text SSH.NET reads as the length of a packet a gigabyte long, and the file browser then said
|
||||
// "Connecting…" for ever. Reset once connected, so a slow write on a slow link is not cut off.
|
||||
var client = new SftpClient(info)
|
||||
{
|
||||
BufferSize = SftpBufferSize,
|
||||
KeepAliveInterval = KeepAliveInterval,
|
||||
OperationTimeout = info.Timeout,
|
||||
};
|
||||
|
||||
// No progress for the file-transfer path. The screen that waits on one is the file browser, which
|
||||
// reports itself, and a second connection opened behind an already-open shell has nothing the user
|
||||
// is watching a step list for.
|
||||
var gate = await ConnectThroughHostKeyGateAsync(client, request, progress: null, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
HostKeyGate gate;
|
||||
|
||||
try
|
||||
{
|
||||
gate = await ConnectThroughHostKeyGateAsync(client, request, progress: null, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (SshOperationTimeoutException exception) when (info.IsAuthenticated)
|
||||
{
|
||||
throw SftpHandshakeTimedOut(request, info, exception);
|
||||
}
|
||||
|
||||
client.OperationTimeout = Timeout.InfiniteTimeSpan;
|
||||
|
||||
// Read once, here, rather than per call. SftpClient.WorkingDirectory canonicalises against the server
|
||||
// on first read, so leaving it to the property would put a round trip behind something that reads
|
||||
@@ -172,6 +193,24 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
||||
return new SshNetSftpSession(client, gate.Presented!, home, lifetime);
|
||||
}
|
||||
|
||||
/// <summary>Says what a timeout after the login means for a file-transfer connection.</summary>
|
||||
/// <remarks>
|
||||
/// Only after the login: a timeout before it is the network's or the server's, and SSH.NET's own words
|
||||
/// for it are left alone. One after it is the SFTP handshake's — the host let this account in and then
|
||||
/// never answered as a file server — and SSH.NET's "Operation has timed out" would send somebody looking
|
||||
/// at the network for what is nearly always a line of text in a login script on the host.
|
||||
/// </remarks>
|
||||
private static TimeoutException SftpHandshakeTimedOut(
|
||||
SshConnectionRequest request,
|
||||
ConnectionInfo info,
|
||||
SshOperationTimeoutException exception) =>
|
||||
new(
|
||||
$"{request.Host} accepted the login, but its SFTP service did not answer within "
|
||||
+ $"{info.Timeout.TotalSeconds:0} seconds. This is usually a login script on the host that "
|
||||
+ "prints text (an echo in .bashrc, say): SFTP needs the remote shell to stay silent when "
|
||||
+ "it is not interactive.",
|
||||
exception);
|
||||
|
||||
/// <summary>
|
||||
/// Runs the handshake with host key trust attached, and translates a refusal this factory caused.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
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;
|
||||
using DodoSSH.Crypto;
|
||||
using NSubstitute;
|
||||
|
||||
namespace DodoSSH.Client.App.Layout.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// How the empty terminal's prompt answers a keyboard: where the caret goes, and what Enter, Tab and Escape do.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// What each key decides is <c>ShellFlowTests</c>' business; this is the half only a real control can show —
|
||||
/// that the caret is on the line that is asking, every time the line that is asking changes. A prompt whose
|
||||
/// password line appeared without the caret on it would take the password into the name it had just read.
|
||||
/// The fixture is the palette suite's, for the palette suite's reasons: a real shell over a real unlocked
|
||||
/// vault, and nothing that reaches a network.
|
||||
/// </remarks>
|
||||
public sealed class ConnectPromptTests : IAsyncLifetime
|
||||
{
|
||||
private const string Passphrase = "a sufficiently long passphrase";
|
||||
private const string ServerUrl = "https://dodossh.example";
|
||||
|
||||
/// <remarks>Far below the shipped profile: nothing here attacks a wrap.</remarks>
|
||||
private static readonly Argon2Profile CheapProfile =
|
||||
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
|
||||
|
||||
private readonly FakeAccountServer server = new();
|
||||
private readonly StubKeyBinding keyBinding = new();
|
||||
private readonly VaultKnownHostStore knownHosts = new();
|
||||
|
||||
private ClientCacheFactory caches = null!;
|
||||
private TerminalWorkspace workspace = null!;
|
||||
private VaultSession session = null!;
|
||||
private VaultViewModel vault = null!;
|
||||
private MainWindowViewModel shell = null!;
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
caches = ClientCacheFactory.ForMemory($"prompt-{Guid.CreateVersion7():N}");
|
||||
await caches.MigrateAsync(Token);
|
||||
|
||||
await new AccountProvisioner(server, keyBinding, caches, TimeProvider.System, CheapProfile)
|
||||
.EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
|
||||
|
||||
var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
|
||||
outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
|
||||
session = outcome.Session!;
|
||||
|
||||
workspace = new TerminalWorkspace(
|
||||
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
|
||||
Substitute.For<ISshConnectionFactory>(),
|
||||
TimeProvider.System);
|
||||
|
||||
await knownHosts.OpenAsync(session, Token);
|
||||
|
||||
vault = new VaultViewModel(session, workspace, knownHosts, static () => null);
|
||||
|
||||
await SeedAsync();
|
||||
|
||||
shell = new MainWindowViewModel(
|
||||
ClientPaths.Default,
|
||||
caches,
|
||||
workspace,
|
||||
knownHosts,
|
||||
Substitute.For<IDeviceKeyStore>(),
|
||||
(_, _) => throw new NotSupportedException("nothing here signs in"),
|
||||
TimeProvider.System,
|
||||
// Never asked for a session: the palette searches the host list and connects through the
|
||||
// vault's own command, and nothing on this screen transfers a file.
|
||||
Substitute.For<ISftpSessionFactory>(),
|
||||
CheapProfile)
|
||||
{
|
||||
// The state the palette is only ever open in. Assigned rather than reached through the unlock
|
||||
// path, which would be a second enrollment and a second Argon2 pass for no extra coverage.
|
||||
State = ShellState.Unlocked,
|
||||
Vault = vault,
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await shell.DisposeAsync();
|
||||
knownHosts.Close();
|
||||
await workspace.DisposeAsync();
|
||||
await session.DisposeAsync();
|
||||
caches.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThePromptTakesTheKeyboardWhenItAppears()
|
||||
{
|
||||
await OnThePromptAsync((prompt, _) => prompt.TargetBox.IsFocused.ShouldBeTrue());
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Enter on an address moves the question to the password, and the caret with it — typing straight on is
|
||||
/// what somebody does next, and it has to land in the password box rather than on the end of the address.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task EnterOnAnAddressPutsTheCaretOnThePassword()
|
||||
{
|
||||
await OnThePromptAsync((prompt, window) =>
|
||||
{
|
||||
shell.PromptText = "root@10.0.0.5";
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||
Relayout(window);
|
||||
|
||||
shell.IsPromptAskingForPassword.ShouldBeTrue();
|
||||
prompt.PasswordBox.IsEffectivelyVisible.ShouldBeTrue();
|
||||
prompt.PasswordBox.IsFocused.ShouldBeTrue("the caret follows the question");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>Escape off the password line puts the caret back on the name, which is still there.</remarks>
|
||||
[Fact]
|
||||
public async Task EscapeFromThePasswordPutsTheCaretBackOnTheName()
|
||||
{
|
||||
await OnThePromptAsync((prompt, window) =>
|
||||
{
|
||||
shell.PromptText = "root@10.0.0.5";
|
||||
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||
Relayout(window);
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
|
||||
Relayout(window);
|
||||
|
||||
shell.IsPromptAskingForPassword.ShouldBeFalse();
|
||||
prompt.PasswordBox.IsEffectivelyVisible.ShouldBeFalse();
|
||||
prompt.TargetBox.IsFocused.ShouldBeTrue();
|
||||
shell.PromptText.ShouldBe("root@10.0.0.5");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Tab completes rather than moving the focus away, when there is something to complete to — and leaves
|
||||
/// the caret at the end of what it wrote, so Enter is the next key rather than End.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TabCompletesTheNameAndKeepsTheCaretOnIt()
|
||||
{
|
||||
await OnThePromptAsync((prompt, window) =>
|
||||
{
|
||||
shell.PromptText = "host-3";
|
||||
shell.PromptMatches.ShouldNotBeEmpty();
|
||||
|
||||
shell.PromptText = "ost-3";
|
||||
window.KeyPressQwerty(PhysicalKey.Tab, RawInputModifiers.None);
|
||||
|
||||
shell.PromptText.ShouldBe("host-3");
|
||||
prompt.TargetBox.IsFocused.ShouldBeTrue("Tab completed rather than moving on");
|
||||
prompt.TargetBox.CaretIndex.ShouldBe("host-3".Length);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
/// <summary>Shows the prompt in a window the size of the terminal's rectangle, and runs one body against it.</summary>
|
||||
private Task OnThePromptAsync(Action<ConnectPrompt, Window> body) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
shell.ShowTerminalCommand.Execute(null);
|
||||
shell.IsConnectPromptShowing.ShouldBeTrue("every case here starts on an empty terminal");
|
||||
|
||||
var prompt = new ConnectPrompt { DataContext = shell };
|
||||
var window = new Window { Content = prompt };
|
||||
LayoutHarness.Settle(window, 900, 600);
|
||||
|
||||
// The posted focus the application's dispatcher would run once the prompt was laid out.
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
try
|
||||
{
|
||||
body(prompt, window);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
|
||||
/// <summary>Runs the layout pass, and the focus it posts, that the application's dispatcher would.</summary>
|
||||
private static void Relayout(Window window)
|
||||
{
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
window.UpdateLayout();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
}
|
||||
|
||||
/// <remarks>Enough hosts that a partial name has more than one to match.</remarks>
|
||||
private async Task SeedAsync()
|
||||
{
|
||||
for (var i = 0; i < 6; i++)
|
||||
{
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = $"host-{i}";
|
||||
vault.EditorHostname = $"host-{i}.internal";
|
||||
vault.EditorUsername = "deploy";
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1223,6 +1223,228 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
vault.FilterAddressRefusal.ShouldContain("between 1 and 65535");
|
||||
}
|
||||
|
||||
// ---- The empty terminal's prompt ----
|
||||
|
||||
/// <remarks>
|
||||
/// Pressing Terminal with nothing open shows the prompt, and a tab opening takes it away: the two share the
|
||||
/// renderer's rectangle, and the prompt is only allowed in it because they are exclusive.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePrompt_ShowsOnTheTerminalWithNothingOpen_AndGoesWhenATabOpens()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
shell.IsConnectPromptShowing.ShouldBeFalse("the hosts screen is showing, not the terminal");
|
||||
|
||||
shell.ShowTerminalCommand.Execute(null);
|
||||
shell.IsConnectPromptShowing.ShouldBeTrue();
|
||||
|
||||
shell.PromptText = "deploy@build.internal";
|
||||
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||
shell.PromptPassword = "hunter2";
|
||||
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||
|
||||
shell.HasTabs.ShouldBeTrue();
|
||||
shell.IsConnectPromptShowing.ShouldBeFalse("the session's tab has the rectangle now");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A saved host by name — case aside, as a person types it — whose binding wants a typed password: the
|
||||
/// prompt asks for it in ssh's own words and dials the host with it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePrompt_ConnectsToASavedHostByName_AskingForItsPasswordFirst()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
shell.ShowTerminalCommand.Execute(null);
|
||||
shell.PromptText = "PROD-DB";
|
||||
|
||||
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||
|
||||
ssh.Requests.ShouldBeEmpty("nothing is dialled before the password is typed");
|
||||
shell.IsPromptAskingForPassword.ShouldBeTrue();
|
||||
shell.PromptPasswordLabel.ShouldBe($"{vault.Hosts[0].Address}'s password:");
|
||||
|
||||
shell.PromptPassword = "s3cret";
|
||||
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||
|
||||
ssh.Requests.ShouldHaveSingleItem().Credential
|
||||
.ShouldBeOfType<SshPasswordCredential>().Password.ShouldBe("s3cret");
|
||||
shell.Tabs.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
|
||||
|
||||
shell.PromptText.ShouldBeEmpty();
|
||||
shell.PromptPassword.ShouldBeEmpty("the password does not wait in the box");
|
||||
shell.IsPromptAskingForPassword.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <b>The failure the prompt could most easily cause.</b> The drawer's remember-this tick is read when a
|
||||
/// connection succeeds, and the prompt goes through the same connect path — so without the attempt
|
||||
/// carrying its own answer, a tick left on in a drawer that is not even open would save a password typed
|
||||
/// at a prompt that never offered to save anything.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePrompt_NeverSavesAPassword_EvenWithTheDrawersTickLeftOn()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
vault.RemembersConnectPassword = true;
|
||||
|
||||
shell.ShowTerminalCommand.Execute(null);
|
||||
shell.PromptText = "prod-db";
|
||||
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||
shell.PromptPassword = "s3cret";
|
||||
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||
|
||||
ssh.Requests.ShouldHaveSingleItem();
|
||||
shell.HasTabs.ShouldBeTrue("the connection succeeded, which is when remembering would happen");
|
||||
|
||||
vault.Credentials.ShouldBeEmpty();
|
||||
vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// An address the keychain does not hold is dialled as typed, with the typed password, and the keychain
|
||||
/// is left exactly as it was.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePrompt_ConnectsToATypedAddress_AndSavesNothing()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
shell.ShowTerminalCommand.Execute(null);
|
||||
shell.PromptText = "deploy@build.internal:2222";
|
||||
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||
|
||||
shell.PromptPasswordLabel.ShouldBe("deploy@build.internal:2222's password:");
|
||||
|
||||
shell.PromptPassword = "hunter2";
|
||||
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||
|
||||
var request = ssh.Requests.ShouldHaveSingleItem();
|
||||
request.Host.ShouldBe("build.internal");
|
||||
request.Port.ShouldBe(2222);
|
||||
request.Username.ShouldBe("deploy");
|
||||
|
||||
vault.Hosts.ShouldHaveSingleItem("connecting without saving saves nothing");
|
||||
vault.Credentials.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Enter on a name is a decision rather than a search, so a partial name is refused — but the refusal
|
||||
/// names the nearest host, and Tab takes it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePrompt_RefusesAPartialName_AndTabCompletesIt()
|
||||
{
|
||||
await ReadyToConnectAsync();
|
||||
|
||||
shell.ShowTerminalCommand.Execute(null);
|
||||
shell.PromptText = "prod";
|
||||
|
||||
shell.PromptMatches.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
|
||||
|
||||
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||
|
||||
shell.PromptRefusal.ShouldContain("Tab");
|
||||
ssh.Requests.ShouldBeEmpty();
|
||||
|
||||
shell.CompletePrompt().ShouldBeTrue();
|
||||
shell.PromptText.ShouldBe("prod-db");
|
||||
shell.HasPromptRefusal.ShouldBeFalse("typing again clears a refusal about what was typed");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Two hosts in two vaults can share a name, and the prompt has no room to say which vault each is in, so
|
||||
/// it refuses rather than guessing and points at the palette, which does say.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePrompt_RefusesANameTwoHostsShare()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
|
||||
shell.ShowTerminalCommand.Execute(null);
|
||||
shell.PromptText = "prod-db";
|
||||
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||
|
||||
shell.PromptRefusal.ShouldContain("More than one");
|
||||
shell.IsPromptAskingForPassword.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Escape walks back one line at a time, as it does in the palette: off the password onto the name with
|
||||
/// the name intact, and then the name cleared.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task EscapeAtThePrompt_StepsBackALineAtATime()
|
||||
{
|
||||
await ReadyToConnectAsync();
|
||||
|
||||
shell.ShowTerminalCommand.Execute(null);
|
||||
shell.PromptText = "root@box";
|
||||
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||
shell.PromptPassword = "hunter2";
|
||||
|
||||
shell.CancelPromptCommand.Execute(null);
|
||||
|
||||
shell.IsPromptAskingForPassword.ShouldBeFalse();
|
||||
shell.PromptText.ShouldBe("root@box");
|
||||
shell.PromptPassword.ShouldBeEmpty();
|
||||
|
||||
shell.CancelPromptCommand.Execute(null);
|
||||
|
||||
shell.PromptText.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// An empty password is refused on the prompt rather than sent, for the reason the manual box gives: the
|
||||
/// server's "authentication failed" would be a sentence about the machine when the problem is the box.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePrompt_RefusesAnEmptyPassword()
|
||||
{
|
||||
await ReadyToConnectAsync();
|
||||
|
||||
shell.ShowTerminalCommand.Execute(null);
|
||||
shell.PromptText = "root@box";
|
||||
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||
|
||||
shell.PromptRefusal.ShouldContain("password");
|
||||
shell.IsPromptAskingForPassword.ShouldBeTrue();
|
||||
ssh.Requests.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A recent connection that names no keychain host is the address that was dialled, so choosing it asks
|
||||
/// for that address's password — the log never held the password, which is the point of the path.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ARecentAddressOnThePrompt_AsksForItsPassword()
|
||||
{
|
||||
await ReadyToConnectAsync();
|
||||
|
||||
shell.ShowTerminalCommand.Execute(null);
|
||||
|
||||
await shell.ChoosePromptRecentCommand.ExecuteAsync(
|
||||
Recent("deploy@build.internal", "deploy@build.internal:2222", hostId: null));
|
||||
|
||||
shell.PromptText.ShouldBe("deploy@build.internal:2222");
|
||||
shell.IsPromptAskingForPassword.ShouldBeTrue();
|
||||
shell.PromptPasswordLabel.ShouldBe("deploy@build.internal:2222's password:");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A password belongs to the machine it was typed for. Changing the address in the box has to take it
|
||||
/// away, or the next machine typed would be dialled with somebody else's password.
|
||||
|
||||
@@ -211,6 +211,31 @@ public sealed class SftpSessionTests(SshServerFixture fixture)
|
||||
refusal.Presentation.Fingerprint.ShouldStartWith("SHA256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AHostWhoseLoginPrintsText_FailsWithinTheConnectTimeout_AndSaysWhy()
|
||||
{
|
||||
var knownHosts = new InMemoryKnownHostStore();
|
||||
var factory = new SshNetConnectionFactory(knownHosts);
|
||||
|
||||
var request = Request() with { Port = fixture.ChattyPort, ConnectTimeout = TimeSpan.FromSeconds(3) };
|
||||
|
||||
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
||||
await factory.OpenSftpAsync(request, Token));
|
||||
await knownHosts.TrustAsync(unknown.Presentation, Token);
|
||||
|
||||
// The banner's first four bytes read as the length of a packet about a gigabyte long, and SSH.NET's
|
||||
// own default is to wait for the rest of it for ever — which the file browser drew as "Connecting…"
|
||||
// with no end. The outer bound is only there so that regression fails this test rather than hangs it,
|
||||
// and it is a cancellation rather than a timeout so that it cannot be mistaken for the answer.
|
||||
using var bound = CancellationTokenSource.CreateLinkedTokenSource(Token);
|
||||
bound.CancelAfter(TimeSpan.FromSeconds(30));
|
||||
|
||||
var failure = await Should.ThrowAsync<TimeoutException>(async () =>
|
||||
await factory.OpenSftpAsync(request, Token).WaitAsync(bound.Token));
|
||||
|
||||
failure.Message.ShouldContain("SFTP service did not answer within 3 seconds");
|
||||
}
|
||||
|
||||
private SshConnectionRequest Request() => new(
|
||||
fixture.Host,
|
||||
fixture.Port,
|
||||
|
||||
@@ -42,6 +42,17 @@ public sealed class SshServerFixture : IAsyncLifetime
|
||||
|
||||
private const int SshPort = 2222;
|
||||
|
||||
/// <summary>
|
||||
/// A second port on the same sshd, where every session starts with a line of text before SFTP.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// What a host with an <c>echo</c> in its login script looks like to a file-transfer client: the text
|
||||
/// arrives where the server's first SFTP packet should be. A port rather than an account because this
|
||||
/// image's sshd runs unprivileged and cannot log in as anybody but <see cref="Username"/>, and a port
|
||||
/// rather than a change to the main one so the shell and SFTP suites keep a server that behaves.
|
||||
/// </remarks>
|
||||
private const int ChattySshPort = 2223;
|
||||
|
||||
/// <summary>
|
||||
/// How many connections in a row the server has to answer before this fixture calls it ready.
|
||||
/// </summary>
|
||||
@@ -65,6 +76,9 @@ public sealed class SshServerFixture : IAsyncLifetime
|
||||
/// <summary>Host port the container's sshd is published on.</summary>
|
||||
public ushort Port => container!.GetMappedPublicPort(SshPort);
|
||||
|
||||
/// <summary>Host port of the same sshd whose sessions print a banner first; see <see cref="ChattySshPort"/>.</summary>
|
||||
public ushort ChattyPort => container!.GetMappedPublicPort(ChattySshPort);
|
||||
|
||||
/// <summary>Host the container is reachable at.</summary>
|
||||
public string Host => container!.Hostname;
|
||||
|
||||
@@ -83,6 +97,7 @@ public sealed class SshServerFixture : IAsyncLifetime
|
||||
.WithEnvironment("SUDO_ACCESS", "false")
|
||||
.WithEnvironment("PUBLIC_KEY", ExportOpenSshPublicKey(ClientKey))
|
||||
.WithPortBinding(SshPort, assignRandomHostPort: true)
|
||||
.WithPortBinding(ChattySshPort, assignRandomHostPort: true)
|
||||
|
||||
// The entrypoint generates host keys, rewrites sshd_config and installs the authorized
|
||||
// key before sshd is usable, so a published port is not readiness. Both conditions are
|
||||
@@ -176,6 +191,10 @@ public sealed class SshServerFixture : IAsyncLifetime
|
||||
/// been touched — the same trap as the wrong file, one layer up. Measured from the log, after a version
|
||||
/// of this fixture did exactly that and failed twenty-eight tests.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The <see cref="ChattySshPort"/> block goes last because a <c>Match</c> runs to the end of the file:
|
||||
/// anything appended after it would apply only to that port.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task ReconfigureAsync()
|
||||
{
|
||||
@@ -185,6 +204,10 @@ public sealed class SshServerFixture : IAsyncLifetime
|
||||
"sed -i 's/^AllowTcpForwarding no/AllowTcpForwarding yes/' /config/sshd/sshd_config"
|
||||
+ " && sed -i 's/^#*MaxStartups .*/MaxStartups 200/' /config/sshd/sshd_config"
|
||||
+ " && printf '\\nPerSourcePenalties no\\n' >> /config/sshd/sshd_config"
|
||||
+ $" && sed -i 's/^Port {SshPort}$/Port {SshPort}\\nPort {ChattySshPort}/' /config/sshd/sshd_config"
|
||||
+ $" && printf '\\nMatch LocalPort {ChattySshPort}\\n"
|
||||
+ " ForceCommand /bin/sh -c \"echo Welcome to the test server; exec /usr/lib/ssh/sftp-server\"\\n'"
|
||||
+ " >> /config/sshd/sshd_config"
|
||||
+ " && pkill -HUP sshd",
|
||||
]);
|
||||
|
||||
|
||||
Reference in new issue
Block a user