Compare commits

..
Author SHA1 Message Date
jaap-jan d71f057bde SFTP: stop waiting for ever on a host whose login prints text, and draw the file panes' icons whole
ci / build and test (pull_request) Successful in 2m40s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Successful in 7s
ci / deploy ssh.dodotech.cloud (pull_request) Skipped
ci / android head (pull_request) Successful in 3m43s
SSH.NET 2026.0.0 runs the SFTP handshake synchronously inside ConnectAsync, with an infinite
operation timeout and without reading the cancellation token. A host whose login script prints
anything (an echo in .bashrc) puts text where the first SFTP packet belongs; SSH.NET reads its first
four bytes as a length of about a gigabyte and waits for the rest, so the file browser said
"Connecting…" for ever. The SFTP client now uses the connect timeout as its operation timeout for
the handshake and resets it to infinite once connected, so transfers on slow links are unchanged. A
timeout after authentication becomes a TimeoutException naming the likely cause.

The SSH test server gains a second port whose sessions print a banner before sftp-server, and
SftpSessionTests proves the open fails within a 3 s connect timeout with that message. Without the
fix the test runs into its 30 s guard.

Button.paneicon is a fixed 26x26 but kept Fluent's 8,5,8,6 padding, which left a 10px-wide content
box, so every 15-16px Material icon on the SFTP panes was drawn with its sides cut off. Its padding
is now 0. Rename on the host pane used U+E9AD, an italic T (text formatting); it now uses U+E9A2,
the rename glyph the phone's files screen already uses. The host pane's "Up one directory" drew the
design's arrow_downward; both panes now use arrow_upward.

SSH tests 123/123, layout tests 203/203, app tests 744/749 with the 5 Linux TPM skips.
2026-10-16 15:09:46 +02:00
jaap-jan 3c8c0980c2 Terminal: ask what to connect to when Terminal is pressed with nothing open
ci / build and test (push) Successful in 4m13s
ci / android head (push) Successful in 4m24s
ci / api image (push) Successful in 13s
ci / deploy ssh.dodotech.cloud (push) Failing after 1s
ci / desktop nightly (push) Successful in 3m6s
The terminal surface with no session used to be an empty frame, with the tab row's "+" the only
thing on it that did anything. It now holds a prompt, in the order ssh asks: "connect to:" takes a
saved host's name, matched exactly, with the hosts that match so far listed under it for Tab or a
click, or user@host[:port] for a machine that is not saved. A password line follows only when one
is needed, worded as ssh words it. Escape steps back a line; recent connections are listed below.

It is drawn by Avalonia in the WebView's rectangle, which the occlusion rule allows because the two
are exclusive: the prompt shows only while there are no tabs, the WebView only for a tab with a
session.

Saved hosts connect through a new VaultViewModel.ConnectToHostAsync, which the drawer's own connect
now goes through too, so every refusal is still made once. Each attempt now carries whether it may
act on the drawer's remember-password tick, and the prompt's may not: otherwise a tick left on in
a closed drawer would save a password typed at a prompt that never offered to keep anything.

Covered by nine ShellFlowTests cases and four ConnectPromptTests; manual check 7.10b.
2026-10-16 12:17:34 +02:00
jaap-jan 5b2e3f3651 Android: type user@host into the hosts search to open a terminal on a machine without saving it
ci / android head (push) Successful in 4m22s
ci / build and test (push) Successful in 4m39s
ci / api image (push) Successful in 7s
ci / deploy ssh.dodotech.cloud (push) Failing after 1s
ci / desktop nightly (push) Successful in 3m20s
The phone's only unsaved way in was the box on the terminal screen, which is drawn only while nothing
is open, so a second machine could not be reached that way while a first one's session was up. The
search well at the top of HOSTS is always there and the design already calls it "Search or
connect...", so it now reads an address the way the desktop palette does: with an @ in it, a NOT
SAVED card above the list offers a password box and CONNECT.

It connects through VaultViewModel.ConnectToEndpointAsync, the same path as the manual box and the
desktop palette, so nothing is written to the keychain. The search box and the password are cleared
once it dials, the password goes whenever the address changes, and an address the manual parser
refuses gets its sentence under the box.

Covered by six new ShellFlowTests cases; manual check 8.20 covers the card on a device.
2026-10-14 17:27:57 +02:00
jaap-jan 988248fa57 Quick connect: type user@host into Ctrl+K to open a terminal on a machine without saving it
ci / build and test (push) Successful in 5m48s
ci / api image (push) Successful in 47s
ci / deploy ssh.dodotech.cloud (push) Failing after 2s
ci / desktop nightly (push) Successful in 3m26s
ci / android head (push) Successful in 10m2s
The desktop had no way to reach a machine that was not in the keychain; the phone has had a manual
box on its terminal screen all along. The palette's query now reads as an address when it has an @
in it: a "not saved" row offers to dial it, Enter asks for the password in place, and a second Enter
connects. Escape on the password step goes back to the address.

It goes through the phone's own path, now split out as VaultViewModel.ConnectToEndpointAsync, so the
tab, the host-key question and the connection-log entry are the same on both heads and nothing is
written to the keychain. Password only, as on the phone: a key is the reason to save the host.

The typed row takes the highlight unless a saved host dials exactly that address, so typing a new
address cannot land on a saved machine whose address merely starts the same way.

Covered by eleven new QuickConnectTests; manual check 7.10a covers the real dial.
2026-10-14 17:02:26 +02:00
18 changed files with 2381 additions and 146 deletions

No files matched your search

+1 -1
View File
@@ -510,7 +510,7 @@ field cannot be removed and stays as a permanently refused member; `SyncEndpoint
| Org chip `dodotech / platform` | contracts + server | An *organisation* above teams, which does not exist — `VaultSummary.TeamId` is no longer always null since M3, but a team is not an org and there is exactly one tenant per deployment. | The vault's own name, and the account this machine is enrolled as. Team names are on the VAULTS screen, where they are about something. |
| `SYNCED` dot, always green | client-app | Nothing — the design's claim is simply unconditional. | Green **only** when a connection is held, the last sync pass actually reached the server, and the outbox is empty; otherwise `UNREACHABLE`, the count of changes still waiting, or `OFFLINE`. Holding an `IVaultServer` proves a sign-in once succeeded and nothing more, so a laptop whose lid has been shut all afternoon still has one — reachability comes from the outcome of the last pass. A permanently green light is the same as no light. |
| `VAULT SYNCED 11:02` | client-session | `StoredSyncState.LastPulledAt`/`LastPushedAt` are persisted, but `VaultSession` exposes the store as `internal`. A property away. | Omitted. The one honest sync fact — the outbox depth — is in the titlebar and the status bar. |
| `⌘K` command palette running commands | client-domain | A snippet or saved-command item type (`SyncEntityType.Snippet = 8` is reserved). | Ctrl+K opens a real host search that connects on Enter. The box says "search hosts", not "search hosts · run command". |
| `⌘K` command palette running commands | client-domain | A snippet or saved-command item type (`SyncEntityType.Snippet = 8` is reserved). | Ctrl+K opens a real host search that connects on Enter, and reads a typed `user@host[:port]` as a machine to connect to with a password, without saving it. The box says "search hosts, or type user@host", not "search hosts · run command". |
| Status bar `· via bastion-eu` | client-ssh | Jump-host execution. See below. | Omitted. |
| Status bar port forwards | client-ssh | — | **Shipped** on the session status bar as a count of listening forwards; the ports themselves are in the session sidebar. |
| Status bar `sftp · 2 transfers` | client-app | Nothing now — file transfer is built. What is missing is the count reaching the status bar, which is a screen away from where the queue lives. | Omitted from the status bar. The queue itself is on the FILES screen, with a row per transfer. |
+51 -1
View File
@@ -1388,6 +1388,39 @@ Ctrl+K, then click a result with the mouse rather than pressing Enter.
**Pass:** the palette closes and the connection starts.
### 7.10a Connecting to a machine that is not saved
Ctrl+K, type `user@host` for a reachable machine that is **not** on HOSTS, press Enter, type its password and
press Enter again. Do it once against a machine whose host key this vault has never seen.
**Pass:** the palette asks for the password in place, with the caret already in the box. On the second Enter
it closes, a tab named `user@host` opens, and the fingerprint card asks about the key; approving it connects.
Afterwards HOSTS has nothing new on it, and the connection log has an entry for `user@host:22`. Escape from
the password box goes back to the address with what was typed still in it.
**Failure means:** a new row on HOSTS is the palette saving what it promised not to. A tab that fails with
"authentication failed" for a password known to be right is worth comparing against the phone's CONNECT TO A
MACHINE box, which takes the same path — `VaultViewModel.ConnectToEndpointAsync` — so a difference between
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
@@ -1421,7 +1454,7 @@ time)" — is the worse failure of the two: it rebinds a host as a side effect o
## Phase 8 — Adding and removing on the phone's host list
Twenty-one checks, and the reason there are twenty-one rather than none is worth stating: **the layout suite
Twenty-two checks, and the reason there are twenty-two rather than none is worth stating: **the layout suite
cannot see any of this and structurally never will.** `DodoSSH.Client.App.Layout.Tests` targets `net10.0` and
`DodoSSH.Client.Android` targets `net10.0-android`, so a project reference is impossible; Avalonia's
application, dispatcher and platform are one-shot process globals, so a second head cannot share the
@@ -1794,6 +1827,23 @@ follow, and try disconnecting and reconnecting instead.
---
### 8.20 Connecting from the search box to a machine that is not saved
With a terminal already open on some other machine, go back to HOSTS and type `user@host` for a reachable
machine that is **not** in the list into the search box at the top. Type its password into the card that
appears, and press CONNECT.
**Pass:** the card appears only once there is an `@` in the box, names the address with `:22` on the end, and
says NOT SAVED. CONNECT opens a second tab for `user@host` — the first is still there — and the fingerprint
card asks about an unknown key over the terminal. Back on HOSTS the search box is empty, the list is the whole
list again, and there is no new host in it. A port the parser refuses (`root@box:70000`) gives a sentence in
amber under the box rather than a card.
**Failure means:** the card sitting *over* the list rather than pushing it down is the panels' arrangement
having been lost — see the remark above `IsSendingChosenHostsToAVault` in `HostsScreen.axaml`. A password that
shows in the keyboard's suggestion strip is `Classes="secret"` missing from the box. Everything behind the
card is `ShellFlowTests`' hosts-search cases; this is the rectangles, and the software keyboard.
## Phase 9 — Tag chips and the picker
The desktop half is measured by the layout suite, which now seeds three tags and puts all of them on one
@@ -72,7 +72,7 @@
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" Classes="icon" Text="&#xE8B6;" Foreground="{StaticResource TextFaint}"
FontSize="20" Margin="12,0,0,0" />
<TextBox Grid.Column="1" Text="{Binding HostFilter}" PlaceholderText="Search hosts"
<TextBox Grid.Column="1" Text="{Binding HostFilter}" PlaceholderText="Search hosts, or type user@host"
Padding="9,0" VerticalContentAlignment="Center"
Background="Transparent" BorderThickness="0"
Foreground="{StaticResource Text}" FontSize="14" />
@@ -82,6 +82,42 @@
<!-- ============ sync + conflicts ============ -->
<StackPanel Grid.Row="1" Margin="16,4,16,0" Spacing="7">
<!--
◆ A MACHINE THAT IS NOT SAVED. When the search box holds user@host, this card offers to connect to it
with a password, and nothing is written to the keychain — the desktop palette's typed-address row, in
this screen's shape. The terminal screen's own box does the same but is drawn only while nothing is
open; this one is here whatever is running. See VaultViewModel.FilterAddress.
Above the list rather than over it, like the panels below: the hosts that also match what was typed
stay in view, and one of them may be the same machine with a key already bound to it.
Classes="field secret" on the password for the reason the terminal screen's box gives: the class is
what keeps the password out of the software keyboard's dictionary, not only off the screen.
-->
<Border IsVisible="{Binding HasFilterAddress}" Background="{StaticResource Panel}"
BorderBrush="{StaticResource BorderMid}" BorderThickness="1" CornerRadius="12" Padding="13,11">
<StackPanel Spacing="9">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding FilterAddressText}" FontSize="13"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="label" Text="NOT SAVED" Margin="10,0,0,0"
VerticalAlignment="Center" />
</Grid>
<TextBox Classes="field secret" Text="{Binding FilterAddressPassword}" PlaceholderText="password">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding ConnectToFilterAddressCommand}" />
</TextBox.KeyBindings>
</TextBox>
<Button Classes="primary" Height="44" Content="CONNECT"
Command="{Binding ConnectToFilterAddressCommand}" />
</StackPanel>
</Border>
<!-- Why what was typed is not an address yet, or why CONNECT refused — a sentence about the box. -->
<TextBlock Classes="detail" TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
Text="{Binding FilterAddressRefusal}" IsVisible="{Binding HasFilterAddressRefusal}" />
<TextBlock Classes="detail" Text="{Binding Status}" TextWrapping="Wrap" />
<!--
+40
View File
@@ -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}" />
@@ -1342,6 +1369,19 @@
<Style Selector="TextBlock.qcrow-subtext">
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
<!--
The palette's typed-address row, which sits above the list rather than in it and so has no ListBoxItem
to take :pointerover and :selected from. The same two states, on the row itself.
-->
<Style Selector="Border.qcrow.adhoc:pointerover">
<Setter Property="Background" Value="{StaticResource Hover}" />
</Style>
<Style Selector="Border.qcrow.adhoc.selected, Border.qcrow.adhoc.selected:pointerover">
<Setter Property="Background" Value="{StaticResource Accent}" />
</Style>
<Style Selector="Border.qcrow.adhoc.selected TextBlock.qcrow-text, Border.qcrow.adhoc.selected TextBlock.qcrow-subtext">
<Setter Property="Foreground" Value="{StaticResource AccentInk}" />
</Style>
<Style Selector="ListBoxItem:selected TextBlock.qcrow-text, ListBoxItem:selected TextBlock.qcrow-subtext">
<!--
AccentInk rather than a literal white: it is v5's name for "the colour text sits in on top of the
@@ -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;
}
}
}
+12 -3
View File
@@ -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.
+159 -80
View File
@@ -5,7 +5,8 @@
x:DataType="vm:MainWindowViewModel">
<!--
Ctrl+K: type a few characters of a host's name and press Enter to connect to it.
Ctrl+K: type a few characters of a host's name and press Enter to connect to it — or type user@host, press
Enter, and give a password, to connect to a machine that is not saved at all.
The design's box promises "search hosts · run command". Only the first half is here, because there is no
snippet or saved-command item type in the vault for the second half to run — see
@@ -35,93 +36,171 @@
BorderThickness="1" CornerRadius="14">
<StackPanel>
<!--
">_" rather than the bare ">" v4 drew: the mock's own prompt glyph, and the underscore is what
reads as a cursor waiting for the first keystroke rather than a stray angle bracket. There is
still no drawn caret in the field beside it, for the reason TextBox.address in this file gives —
it already has a real one.
-->
<Border Padding="16,14" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" Classes="mono" Text="&gt;_" FontSize="14" FontWeight="Bold"
Foreground="{StaticResource Accent}" VerticalAlignment="Center" />
<TextBox Grid.Column="1" x:Name="Query" Text="{Binding SearchText}"
PlaceholderText="search hosts" Margin="10,0,0,0"
FontFamily="{StaticResource MonoFont}" FontSize="14"
Background="Transparent" BorderThickness="0" />
</Grid>
</Border>
<StackPanel x:Name="QueryStep" IsVisible="{Binding IsAskingForSearchQuery}">
<!--
">_" rather than the bare ">" v4 drew: the mock's own prompt glyph, and the underscore is what
reads as a cursor waiting for the first keystroke rather than a stray angle bracket. There is
still no drawn caret in the field beside it, for the reason TextBox.address in this file gives —
it already has a real one.
-->
<Border Padding="16,14" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" Classes="mono" Text="&gt;_" FontSize="14" FontWeight="Bold"
Foreground="{StaticResource Accent}" VerticalAlignment="Center" />
<TextBox Grid.Column="1" x:Name="Query" Text="{Binding SearchText}"
PlaceholderText="search hosts, or type user@host" Margin="10,0,0,0"
FontFamily="{StaticResource MonoFont}" FontSize="14"
Background="Transparent" BorderThickness="0" />
</Grid>
</Border>
<!--
The typed address, above the saved hosts: what was typed was an address, so the row that dials it
is the one the query is most plainly about. It is lit by IsAdHocSelected rather than by the list's
own selection, because it is not a host and the list below holds hosts only — see the remark on
MainWindowViewModel.IsAdHocSelected. Classes="qcrow adhoc" shares the list rows' look; the
selected state is a class here because there is no ListBoxItem around it to carry :selected.
"not saved" in the corner, where a saved row says how it authenticates: that is this row's answer
to the same question, and the one thing worth knowing before choosing it.
-->
<Border x:Name="AdHocRow" Classes="qcrow adhoc" Classes.selected="{Binding IsAdHocSelected}"
Height="44" CornerRadius="9" Padding="10,0" Margin="8,6,8,2"
IsVisible="{Binding HasAdHocTarget}" Cursor="Hand">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="mono qcrow-text" Text="&gt;_" FontSize="12"
FontWeight="Bold" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
<TextBlock Classes="mono qcrow-text" Text="{Binding AdHocAddress}" FontSize="13"
FontWeight="Medium" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="mono qcrow-subtext" Text="connect with a password" FontSize="11.5" />
</StackPanel>
<TextBlock Grid.Column="2" Classes="mono qcrow-subtext" Text="not saved" FontSize="11"
Margin="10,0,0,0" VerticalAlignment="Center" />
</Grid>
</Border>
<!-- Why a query that looks like an address has no row to dial it — a missing port number, say. -->
<TextBlock Classes="hint" FontSize="12" Margin="18,12,18,4"
Foreground="{StaticResource WarnText}"
Text="{Binding AdHocRefusal}"
IsVisible="{Binding HasAdHocRefusal}" />
<!--
Arrow keys move the selection and Enter takes it; the code-behind owns both, because a ListBox that
took focus would take the arrow keys away from the box being typed into.
A click connects rather than merely selecting, which is what a list of hosts under a search box
looks like it does — the keyboard route already treats choosing a row and connecting to it as one
act, and a pointer that only highlighted would leave the palette open over a choice already made.
The gesture is wired in the code-behind, as the sidebar's double-click is.
Classes="tiles" clears the theme's full-bleed hover and selection wash the way the hosts grid's own
cards do — see the remark on Border.qcrow in App.axaml, which is what paints every state here
instead. Rows rather than cards, but the same reason applies: a square wash behind a rounded shape
shows its corners.
-->
<ListBox x:Name="Results" Classes="tiles" MaxHeight="320" Focusable="False"
ItemsSource="{Binding SearchResults}"
SelectedItem="{Binding SelectedSearchResult}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:HostRowViewModel">
<Border Classes="qcrow" Height="44" CornerRadius="9" Padding="10,0" Margin="8,2">
<Grid ColumnDefinitions="Auto,*,Auto">
<!--
Green when this host has a terminal open right now, grey otherwise — the same two-state
dot the hosts grid draws, and the same reason: nothing here pings a machine, so a third
colour would be a claim this application never checks. See Ellipse.dot in App.axaml.
-->
<Ellipse Grid.Column="0" Classes="dot" Classes.live="{Binding IsConnected}"
Width="8" Height="8" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
<TextBlock Classes="mono qcrow-text" Text="{Binding Label}" FontSize="13"
FontWeight="Medium" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="mono qcrow-subtext" Text="{Binding Address}" FontSize="11.5"
TextTrimming="CharacterEllipsis" />
</StackPanel>
<!--
Two readings stacked against the two on the left, and in the same order: what this row
IS above, how it is reached below.
◆ THE VAULT, and only when there is more than one to be in — HostRowViewModel.VaultBadge
is empty otherwise, which collapses the line and leaves the kind word centred as it was.
The palette searches every readable vault at once, so without it two hosts a team and a
person both call "prod-db" are two identical rows, and Enter takes whichever the ranking
happened to put first. Trimmed against a cap because the column is Auto-sized: a long
vault name would otherwise take its width out of the host name beside it.
credential / key / password — never the mock's SSH/SFTP kind column. Every palette
connect here is SSH, so printing that word would be a constant dressed up as a reading;
see hosts-v5-design-spec.md's deviations and HostRowViewModel.Authentication.
-->
<StackPanel Grid.Column="2" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
<TextBlock Classes="mono qcrow-subtext" Text="{Binding VaultBadge}" FontSize="10"
HorizontalAlignment="Right" MaxWidth="150"
TextTrimming="CharacterEllipsis" IsVisible="{Binding HasVaultBadge}" />
<TextBlock Classes="mono qcrow-subtext" Text="{Binding Authentication}"
FontSize="11" HorizontalAlignment="Right" />
</StackPanel>
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!--
Names the other thing the box takes, at the moment somebody is most likely to want it: a name that
matches nothing is often a machine that was never saved.
-->
<TextBlock Classes="hint" FontSize="12" Margin="18,16"
Text="No host matches that. Type user@host to connect without saving it."
IsVisible="{Binding HasNothingToOffer}" />
<Border Padding="16,10" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0">
<TextBlock Classes="mono" FontSize="10" Foreground="{StaticResource TextFaint}"
Text="↑ ↓ to choose · ENTER or click to connect · ESC to close" />
</Border>
</StackPanel>
<!--
Arrow keys move the selection and Enter takes it; the code-behind owns both, because a ListBox that
took focus would take the arrow keys away from the box being typed into.
============ the typed address's password ============
A click connects rather than merely selecting, which is what a list of hosts under a search box
looks like it does — the keyboard route already treats choosing a row and connecting to it as one
act, and a pointer that only highlighted would leave the palette open over a choice already made.
The gesture is wired in the code-behind, as the sidebar's double-click is.
Classes="tiles" clears the theme's full-bleed hover and selection wash the way the hosts grid's own
cards do — see the remark on Border.qcrow in App.axaml, which is what paints every state here
instead. Rows rather than cards, but the same reason applies: a square wash behind a rounded shape
shows its corners.
The second step, in the same card, so the keyboard never leaves the palette: Enter on the address
comes here, Enter here dials, Escape goes back to the address rather than closing. The box carries
PasswordChar and nothing is written anywhere — see MainWindowViewModel.IsAskingForAdHocPassword,
and VaultViewModel.ConnectManuallyAsync for why a typed password is the only way in.
-->
<ListBox x:Name="Results" Classes="tiles" MaxHeight="320" Focusable="False"
ItemsSource="{Binding SearchResults}"
SelectedItem="{Binding SelectedSearchResult}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:HostRowViewModel">
<Border Classes="qcrow" Height="44" CornerRadius="9" Padding="10,0" Margin="8,2">
<Grid ColumnDefinitions="Auto,*,Auto">
<!--
Green when this host has a terminal open right now, grey otherwise — the same two-state
dot the hosts grid draws, and the same reason: nothing here pings a machine, so a third
colour would be a claim this application never checks. See Ellipse.dot in App.axaml.
-->
<Ellipse Grid.Column="0" Classes="dot" Classes.live="{Binding IsConnected}"
Width="8" Height="8" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
<TextBlock Classes="mono qcrow-text" Text="{Binding Label}" FontSize="13"
FontWeight="Medium" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="mono qcrow-subtext" Text="{Binding Address}" FontSize="11.5"
TextTrimming="CharacterEllipsis" />
</StackPanel>
<!--
Two readings stacked against the two on the left, and in the same order: what this row
IS above, how it is reached below.
<StackPanel x:Name="PasswordStep" IsVisible="{Binding IsAskingForAdHocPassword}">
◆ THE VAULT, and only when there is more than one to be in — HostRowViewModel.VaultBadge
is empty otherwise, which collapses the line and leaves the kind word centred as it was.
The palette searches every readable vault at once, so without it two hosts a team and a
person both call "prod-db" are two identical rows, and Enter takes whichever the ranking
happened to put first. Trimmed against a cap because the column is Auto-sized: a long
vault name would otherwise take its width out of the host name beside it.
<Border Padding="16,14" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" Classes="mono" Text="&gt;_" FontSize="14" FontWeight="Bold"
Foreground="{StaticResource Accent}" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding AdHocAddress}" Margin="10,0,0,0"
FontSize="14" Foreground="{StaticResource Text}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
</Grid>
</Border>
credential / key / password — never the mock's SSH/SFTP kind column. Every palette
connect here is SSH, so printing that word would be a constant dressed up as a reading;
see hosts-v5-design-spec.md's deviations and HostRowViewModel.Authentication.
-->
<StackPanel Grid.Column="2" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
<TextBlock Classes="mono qcrow-subtext" Text="{Binding VaultBadge}" FontSize="10"
HorizontalAlignment="Right" MaxWidth="150"
TextTrimming="CharacterEllipsis" IsVisible="{Binding HasVaultBadge}" />
<TextBlock Classes="mono qcrow-subtext" Text="{Binding Authentication}"
FontSize="11" HorizontalAlignment="Right" />
</StackPanel>
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<StackPanel Margin="16,14" Spacing="8">
<TextBox x:Name="Password" Text="{Binding AdHocPassword}" PasswordChar="•"
PlaceholderText="password" FontSize="14" Height="38" />
<TextBlock Classes="hint" FontSize="12" Margin="18,16"
Text="No host matches that."
IsVisible="{Binding !HasSearchResults}" />
<TextBlock Classes="hint" FontSize="12" Foreground="{StaticResource WarnText}"
Text="{Binding AdHocRefusal}" IsVisible="{Binding HasAdHocRefusal}" />
<Border Padding="16,10" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0">
<TextBlock Classes="mono" FontSize="10" Foreground="{StaticResource TextFaint}"
Text="↑ ↓ to choose · ENTER or click to connect · ESC to close" />
</Border>
<TextBlock Classes="hint" FontSize="12"
Text="Nothing here is saved. A machine worth keeping belongs on HOSTS, where it can carry a key." />
</StackPanel>
<Border Padding="16,10" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0">
<TextBlock Classes="mono" FontSize="10" Foreground="{StaticResource TextFaint}"
Text="ENTER to connect · ESC to go back" />
</Border>
</StackPanel>
</StackPanel>
</Border>
@@ -39,11 +39,25 @@ internal sealed partial class QuickConnect : UserControl
// drag across the list — and the ListBox has moved its own selection by the time a tap completes,
// which is what makes the handler below a matter of reading the selection rather than hit-testing.
Results.Tapped += OnResultTapped;
AdHocRow.Tapped += OnAdHocRowTapped;
// The two steps swap in place, and whichever one has just appeared takes the caret: typing an address
// and pressing Enter should leave somebody typing a password, not looking for the box to click into.
// Watched on the steps themselves rather than on the view model, for the reason TakeKeyboard gives —
// the view model's change arrives ahead of the binding that reveals the step.
QueryStep.PropertyChanged += OnStepChanged;
PasswordStep.PropertyChanged += OnStepChanged;
}
/// <summary>The box, so the caret can be put in it the moment the palette opens.</summary>
internal TextBox QueryBox => Query;
/// <summary>The typed address's password box, which takes the caret when the palette asks for it.</summary>
internal TextBox PasswordBox => Password;
/// <summary>The row offering to dial what was typed, when it reads as an address.</summary>
internal Control AdHocTargetRow => AdHocRow;
private MainWindowViewModel? Shell => DataContext as MainWindowViewModel;
/// <summary>
@@ -63,6 +77,29 @@ internal sealed partial class QuickConnect : UserControl
return;
}
// The password step answers only Enter and Escape, and Escape there goes back to the address: a
// mistyped address is the likeliest reason to want out of it, and closing would throw that away too.
if (shell.IsAskingForAdHocPassword)
{
switch (e.Key)
{
case Key.Escape:
shell.BackToSearchQueryCommand.Execute(null);
e.Handled = true;
break;
case Key.Enter:
_ = shell.ConnectToAdHocTargetCommand.ExecuteAsync(null);
e.Handled = true;
break;
default:
break;
}
return;
}
switch (e.Key)
{
case Key.Escape:
@@ -76,12 +113,12 @@ internal sealed partial class QuickConnect : UserControl
break;
case Key.Down:
Move(shell, 1);
shell.MoveSearchSelection(1);
e.Handled = true;
break;
case Key.Up:
Move(shell, -1);
shell.MoveSearchSelection(-1);
e.Handled = true;
break;
@@ -129,23 +166,38 @@ internal sealed partial class QuickConnect : UserControl
NativeKeyboardFocus.ReturnTo(window);
}
Query.Focus();
if (PasswordStep.IsVisible)
{
Password.Focus();
}
else
{
Query.Focus();
}
}
/// <remarks>Clamped rather than wrapped: a list that jumps from the last row to the first loses people.</remarks>
private static void Move(MainWindowViewModel shell, int delta)
/// <summary>Hands the caret to whichever step has just been revealed.</summary>
/// <remarks>Posted, for the same reason the palette's own opening is: see <see cref="TakeKeyboard"/>.</remarks>
private void OnStepChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
{
if (shell.SearchResults.Count == 0)
if (e.Property == IsVisibleProperty && e.GetNewValue<bool>() && IsVisible)
{
Dispatcher.UIThread.Post(TakeKeyboard, DispatcherPriority.Loaded);
}
}
/// <summary>
/// Takes the typed address, for a click on its row: the pointer's version of Enter on it.
/// </summary>
private void OnAdHocRowTapped(object? sender, TappedEventArgs e)
{
if (Shell is not { IsSearching: true, HasAdHocTarget: true } shell)
{
return;
}
var current = shell.SelectedSearchResult is { } selected
? shell.SearchResults.IndexOf(selected)
: -1;
shell.SelectedSearchResult =
shell.SearchResults[Math.Clamp(current + delta, 0, shell.SearchResults.Count - 1)];
e.Handled = true;
shell.ChooseAdHocTargetCommand.Execute(null);
}
/// <inheritdoc />
@@ -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="&#xE5DB;" />
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="&#xE5D8;" />
</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="&#xE9AD;" />
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="&#xE9A2;" />
</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>
@@ -2471,6 +2800,102 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks>
internal bool HasSearchResults => SearchResults.Count > 0;
/// <summary>
/// The machine the palette's query names, when what was typed reads as <c>user@host[:port]</c>.
/// </summary>
/// <remarks>
/// <para>
/// <b>The palette's answer to a machine that is not in the keychain.</b> Every other row it offers is a
/// saved host, and a box somebody has just been given the address of had no way in on this head short of
/// saving it first — the phone has had a manual box on its terminal screen all along. The query already
/// is a box, and <c>user@host</c> is the grammar people type into one, so the palette reads it rather
/// than growing a second field.
/// </para>
/// <para>
/// Read with <see cref="VaultViewModel.TryParseManualTarget"/>, which is the phone's box's own parser, so
/// the two heads accept exactly the same addresses and refuse the rest with the same sentences.
/// </para>
/// <para>
/// Only a query with an <c>@</c> in it is read at all. Without one, everything typed is a search, and a
/// row offering to dial whatever three letters were typed so far would be noise on every keystroke.
/// </para>
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasAdHocTarget))]
[NotifyPropertyChangedFor(nameof(AdHocAddress))]
[NotifyPropertyChangedFor(nameof(HasNothingToOffer))]
private VaultViewModel.ManualEndpoint? adHocTarget;
/// <summary>Whether the palette is offering to connect to a typed address.</summary>
internal bool HasAdHocTarget => AdHocTarget is not null;
/// <summary>The typed address as it will be dialled, port and all.</summary>
internal string AdHocAddress => AdHocTarget?.Address ?? string.Empty;
/// <summary>
/// Why a query with an <c>@</c> in it is not an address yet, or empty.
/// </summary>
/// <remarks>
/// Said under the box rather than swallowed, because a query that looks like an address and produces no
/// row to connect to is otherwise a palette that silently ignored the one thing it was asked. It doubles
/// as the password step's own refusal line.
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasAdHocRefusal))]
[NotifyPropertyChangedFor(nameof(HasNothingToOffer))]
private string adHocRefusal = string.Empty;
/// <summary>Whether there is a refusal to show under the box.</summary>
internal bool HasAdHocRefusal => AdHocRefusal.Length > 0;
/// <summary>
/// Whether the highlight is on the typed address rather than on a saved host.
/// </summary>
/// <remarks>
/// A flag beside <see cref="SelectedSearchResult"/> rather than a row in <see cref="SearchResults"/>,
/// because that list is the vault's own host rows and every reader of it — the ranking, the clicks, the
/// tests — is entitled to find a host in each. At most one of the two is ever set; see
/// <see cref="OnIsAdHocSelectedChanged"/> and <see cref="OnSelectedSearchResultChanged"/>.
/// </remarks>
[ObservableProperty]
private bool isAdHocSelected;
/// <summary>
/// Whether the palette has moved on from the query to asking for the typed address's password.
/// </summary>
/// <remarks>
/// <para>
/// <b>A typed password and nothing else</b>, which is the manual path's own rule and for its reason: a
/// key is the reason to save the machine as a host, and offering the keychain's keys here would be a
/// second binding resolution beside the one saved hosts already go through.
/// </para>
/// <para>
/// A second step inside the palette rather than a dialog, so the whole thing stays on the keyboard:
/// type the address, Enter, type the password, Enter. Escape goes back to the query rather than closing,
/// because a mistyped address is the likeliest reason to want out of this step.
/// </para>
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsAskingForSearchQuery))]
private bool isAskingForAdHocPassword;
/// <summary>Whether the palette is showing its query box and results, rather than the password step.</summary>
internal bool IsAskingForSearchQuery => !IsAskingForAdHocPassword;
/// <summary>The password typed for <see cref="AdHocTarget"/>.</summary>
/// <remarks>
/// Cleared whenever the palette closes or goes back to the query, on the same terms the phone's manual box
/// clears its own: it is a secret nobody asked to keep, and the next address typed must not arrive with the
/// last one's password already in the box.
/// </remarks>
[ObservableProperty]
private string adHocPassword = string.Empty;
/// <summary>
/// Whether the palette has nothing to offer at all — no host, no typed address and no reason why not.
/// </summary>
internal bool HasNothingToOffer => !HasSearchResults && !HasAdHocTarget && !HasAdHocRefusal;
/// <summary>Opens the palette, or closes it if it is already open.</summary>
[RelayCommand]
private void ToggleSearch()
@@ -2496,11 +2921,174 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
private void CloseSearch()
{
IsSearching = false;
IsAskingForAdHocPassword = false;
AdHocPassword = string.Empty;
SearchText = string.Empty;
SearchResults.Clear();
SelectedSearchResult = null;
IsAdHocSelected = false;
AdHocTarget = null;
AdHocRefusal = string.Empty;
OnPropertyChanged(nameof(HasSearchResults));
OnPropertyChanged(nameof(HasNothingToOffer));
}
/// <summary>
/// Moves the palette's highlight by one row, the typed address first when there is one.
/// </summary>
/// <remarks>
/// Clamped rather than wrapped: a list that jumps from the last row to the first loses people. Here
/// rather than in the palette's code-behind because the typed address is a row that is not in
/// <see cref="SearchResults"/>, and which of the two highlights to set is the view model's to know.
/// </remarks>
internal void MoveSearchSelection(int delta)
{
var offset = HasAdHocTarget ? 1 : 0;
var count = offset + SearchResults.Count;
if (count == 0 || IsAskingForAdHocPassword)
{
return;
}
var current = IsAdHocSelected
? 0
: SelectedSearchResult is { } selected
? offset + SearchResults.IndexOf(selected)
: -1;
var next = Math.Clamp(current + delta, 0, count - 1);
if (offset == 1 && next == 0)
{
IsAdHocSelected = true;
}
else
{
SelectedSearchResult = SearchResults[next - offset];
}
}
/// <summary>
/// Takes the typed address, for a click on its row: the pointer's version of Enter.
/// </summary>
[RelayCommand]
private void ChooseAdHocTarget()
{
if (!HasAdHocTarget)
{
return;
}
IsAdHocSelected = true;
AskForAdHocPassword();
}
/// <summary>
/// Puts the palette back on its query, leaving what was typed there and dropping the password.
/// </summary>
[RelayCommand]
private void BackToSearchQuery()
{
IsAskingForAdHocPassword = false;
AdHocPassword = string.Empty;
AdHocRefusal = string.Empty;
}
/// <summary>
/// Connects to the typed address with the typed password, saving neither.
/// </summary>
/// <remarks>
/// <para>
/// Through <see cref="VaultViewModel.ConnectToEndpointAsync"/>, which is the phone's manual box's own
/// path, so a machine reached from here gets the same tab, the same host-key question and the same
/// connection-log entry. What is written, if the user answers the host-key question, is a pin — the trust
/// decision belongs to the endpoint rather than to a keychain item, and a machine reached this way is
/// exactly the one whose key nobody has seen before.
/// </para>
/// <para>
/// The palette closes before the dial rather than after it, as it does for a saved host: the tab the
/// connection opens is where its progress and its failure are drawn, and a palette left up for the length
/// of a handshake would sit over the very card that reports it.
/// </para>
/// <para>
/// Concurrent executions allowed, for the reason <c>VaultViewModel.ConnectAsync</c> gives: this command is
/// still running for as long as the handshake takes, and without the flag a second address typed into the
/// palette meanwhile would be refused as a silent no-op.
/// </para>
/// </remarks>
[RelayCommand(AllowConcurrentExecutions = true)]
private async Task ConnectToAdHocTargetAsync()
{
if (Vault is not { } vault || AdHocTarget is not { } endpoint)
{
return;
}
if (AdHocPassword.Length == 0)
{
AdHocRefusal = $"Type the password for {endpoint.Address}.";
return;
}
var password = AdHocPassword;
CloseSearch();
// No token, for the reason VaultViewModel.ConnectAsync gives at length: a connection is abandoned by
// closing its tab, not by anything here.
await vault.ConnectToEndpointAsync(endpoint, password, CancellationToken.None).ConfigureAwait(true);
}
/// <summary>Moves the palette on to asking for the typed address's password.</summary>
private void AskForAdHocPassword()
{
AdHocPassword = string.Empty;
AdHocRefusal = string.Empty;
IsAskingForAdHocPassword = true;
}
/// <summary>
/// Reads the query as an address, if it has an <c>@</c> in it, and says why not if it cannot be one.
/// </summary>
private void RefreshAdHocTarget(string query)
{
if (!query.Contains('@', StringComparison.Ordinal))
{
AdHocTarget = null;
AdHocRefusal = string.Empty;
return;
}
if (VaultViewModel.TryParseManualTarget(query, out var endpoint, out var reason))
{
AdHocTarget = endpoint;
AdHocRefusal = string.Empty;
}
else
{
AdHocTarget = null;
AdHocRefusal = reason;
}
}
/// <remarks>Clears the other half of the highlight, so at most one row is ever lit.</remarks>
partial void OnIsAdHocSelectedChanged(bool value)
{
if (value)
{
SelectedSearchResult = null;
}
}
/// <inheritdoc cref="OnIsAdHocSelectedChanged" />
partial void OnSelectedSearchResultChanged(HostRowViewModel? value)
{
if (value is not null)
{
IsAdHocSelected = false;
}
}
/// <summary>
@@ -2515,6 +3103,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[RelayCommand]
private async Task ConnectToSearchResultAsync()
{
// The typed address has no keychain item behind it, so nothing can be resolved for it: the password
// it needs has to be asked for first. See IsAskingForAdHocPassword.
if (IsAdHocSelected && HasAdHocTarget)
{
AskForAdHocPassword();
return;
}
if (Vault is not { } vault || SelectedSearchResult is not { } row)
{
return;
@@ -2552,13 +3148,52 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
if (Vault is not { } vault)
{
SelectedSearchResult = null;
IsAdHocSelected = false;
AdHocTarget = null;
AdHocRefusal = string.Empty;
return;
}
var query = SearchText.Trim();
var matches = query.Length == 0
? vault.Hosts.AsEnumerable()
foreach (var host in HostsMatching(vault, query).Take(8))
{
SearchResults.Add(host);
}
RefreshAdHocTarget(query);
// The typed address is highlighted when there is one, unless a saved host dials exactly that address:
// that host is the same machine with whatever the keychain knows about it — a key, a saved password —
// and Enter should reach it the way it was set up to be reached. The typed row is still one arrow
// away for somebody who wants the password prompt instead.
var saved = AdHocTarget is { } typed
? SearchResults.FirstOrDefault(host =>
string.Equals(host.Address, typed.Address, StringComparison.OrdinalIgnoreCase))
: null;
if (HasAdHocTarget && saved is null)
{
IsAdHocSelected = true;
}
else
{
IsAdHocSelected = false;
SelectedSearchResult = saved ?? SearchResults.FirstOrDefault();
}
OnPropertyChanged(nameof(HasSearchResults));
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)
@@ -2566,16 +3201,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
.ThenBy(candidate => candidate.host.Label, StringComparer.CurrentCulture)
.Select(candidate => candidate.host);
foreach (var host in matches.Take(8))
{
SearchResults.Add(host);
}
SelectedSearchResult = SearchResults.FirstOrDefault();
OnPropertyChanged(nameof(HasSearchResults));
}
/// <summary>Where one host places against what was typed, or <see cref="int.MaxValue"/> for no match.</summary>
/// <remarks>
/// <para>
@@ -5616,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
@@ -4278,6 +4278,54 @@ internal sealed partial class VaultViewModel(
[ObservableProperty]
private string manualStatus = string.Empty;
/// <summary>
/// The machine the hosts search box names, when what was typed into it reads as <c>user@host[:port]</c>.
/// </summary>
/// <remarks>
/// <para>
/// <b>The phone's version of the desktop palette's typed-address row.</b> The manual box on the terminal
/// screen is the other way in, and it is only drawn while nothing is open — a second machine reached while
/// a first one's session is up had no box at all. The search well at the top of HOSTS is always there, the
/// design already calls it "Search or connect…", and <c>user@host</c> is what people type into a box, so
/// it reads the address rather than the screen growing a second field.
/// </para>
/// <para>
/// Read with <see cref="TryParseManualTarget"/>, the manual box's own parser, and only for a filter with an
/// <c>@</c> in it: without one, everything typed is a search, and a card offering to dial three letters
/// would arrive on every keystroke.
/// </para>
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasFilterAddress))]
[NotifyPropertyChangedFor(nameof(FilterAddressText))]
private ManualEndpoint? filterAddress;
/// <summary>Whether the search box holds an address that can be connected to.</summary>
internal bool HasFilterAddress => FilterAddress is not null;
/// <summary>The typed address as it will be dialled, port and all.</summary>
internal string FilterAddressText => FilterAddress?.Address ?? string.Empty;
/// <summary>
/// Why a search with an <c>@</c> in it is not an address, or why its CONNECT was refused; empty otherwise.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasFilterAddressRefusal))]
private string filterAddressRefusal = string.Empty;
/// <summary>Whether there is a refusal to show under the search box.</summary>
internal bool HasFilterAddressRefusal => FilterAddressRefusal.Length > 0;
/// <summary>The password typed for <see cref="FilterAddress"/>.</summary>
/// <remarks>
/// Its own box rather than <see cref="ManualPassword"/>, for the reason that one is not
/// <see cref="ConnectPassword"/>: these are different screens, and a password typed on one is not a
/// password offered on the other. Cleared when the address changes, so the next machine typed never
/// arrives with the last one's password already in the box.
/// </remarks>
[ObservableProperty]
private string filterAddressPassword = string.Empty;
/// <summary>
/// The attempt an unanswered host-key question belongs to, so that trusting the key can replay it.
/// </summary>
@@ -4330,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.
@@ -11412,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;
@@ -11429,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);
}
@@ -11480,7 +11575,103 @@ internal sealed partial class VaultViewModel(
ManualStatus = string.Empty;
await ConnectToAsync(
await ConnectToEndpointAsync(endpoint, ManualPassword, cancellationToken).ConfigureAwait(true);
}
/// <summary>
/// Connects to the address typed into the hosts search box, with the password typed under it.
/// </summary>
/// <remarks>
/// <para>
/// Through <see cref="ConnectToEndpointAsync"/>, so the tab, the host-key question and the connection-log
/// entry are the ones the manual box and the desktop palette produce, and nothing is written to the
/// keychain.
/// </para>
/// <para>
/// The search box and the password are cleared before the dial, from copies taken first. The list left
/// filtered to an address would be a list of nothing to come back to, and the password is a secret nobody
/// asked to keep.
/// </para>
/// <para>
/// It takes no cancellation token and allows concurrent executions, for the two reasons
/// <see cref="ConnectAsync"/> carries.
/// </para>
/// </remarks>
[RelayCommand(AllowConcurrentExecutions = true)]
private async Task ConnectToFilterAddressAsync()
{
if (FilterAddress is not { } endpoint)
{
return;
}
if (FilterAddressPassword.Length == 0)
{
FilterAddressRefusal = $"Type the password for {endpoint.Address}.";
return;
}
var password = FilterAddressPassword;
// In this order: clearing the filter clears the address and the password with it — see
// OnHostFilterChanged — and the endpoint and password above are what is dialled.
HostFilter = string.Empty;
await ConnectToEndpointAsync(endpoint, password, CancellationToken.None).ConfigureAwait(true);
}
/// <summary>Reads the hosts search box as an address, if it has an <c>@</c> in it.</summary>
private void RefreshFilterAddress(string filter)
{
var previous = FilterAddress;
var trimmed = filter.Trim();
if (!trimmed.Contains('@', StringComparison.Ordinal))
{
FilterAddress = null;
FilterAddressRefusal = string.Empty;
}
else if (TryParseManualTarget(trimmed, out var endpoint, out var reason))
{
FilterAddress = endpoint;
FilterAddressRefusal = string.Empty;
}
else
{
FilterAddress = null;
FilterAddressRefusal = reason;
}
// A different machine, or none, is a different question: the password typed for the last one goes.
if (FilterAddress != previous)
{
FilterAddressPassword = string.Empty;
}
}
/// <summary>
/// Opens a terminal on a typed address with a typed password, saving neither.
/// </summary>
/// <remarks>
/// <para>
/// The second half of <see cref="ConnectManuallyAsync(CancellationToken)"/>, split out for the desktop's
/// quick-connect palette. That head has no manual box for <see cref="ManualTarget"/> to be typed into —
/// the palette's own query is the box — so it parses with <see cref="TryParseManualTarget"/> and hands
/// over what it read rather than writing into the phone's fields to have them read back here.
/// </para>
/// <para>
/// The caller has already refused an unreadable address and an empty password, which is why neither is
/// checked again: both refusals are sentences about a box, and only the caller knows which box.
/// </para>
/// </remarks>
internal Task ConnectToEndpointAsync(
ManualEndpoint endpoint,
string password,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(endpoint);
return ConnectToAsync(
// Labelled by what was typed rather than by the hostname alone. Two accounts on one box are two
// different connections, and a strip showing the same name twice would be the tab equivalent of
// the log entry this also names.
@@ -11488,8 +11679,8 @@ internal sealed partial class VaultViewModel(
$"{endpoint.Username}@{endpoint.Hostname}",
endpoint.Hostname,
endpoint.Port),
new HostAuthentication(endpoint.Username, new SshPasswordCredential(ManualPassword)),
cancellationToken).ConfigureAwait(true);
new HostAuthentication(endpoint.Username, new SshPasswordCredential(password)),
cancellationToken);
}
/// <summary>
@@ -11514,7 +11705,7 @@ internal sealed partial class VaultViewModel(
/// would make this the only field in the product with its own address grammar.
/// </para>
/// </remarks>
private static bool TryParseManualTarget(
internal static bool TryParseManualTarget(
string typed,
[NotNullWhen(true)] out ManualEndpoint? endpoint,
[NotNullWhen(false)] out string? reason)
@@ -11673,7 +11864,16 @@ internal sealed partial class VaultViewModel(
/// credential rather than from the host — so a username on the shared record would be a field that is
/// null for every connection but this one.
/// </remarks>
private sealed record ManualEndpoint(string Username, string Hostname, int Port);
internal sealed record ManualEndpoint(string Username, string Hostname, int Port)
{
/// <summary>The address as it will be dialled, in the grammar it was typed in.</summary>
/// <remarks>
/// The same shape as <see cref="HostRowViewModel.Address"/> and the connection log's own entries, so
/// the palette can compare what was typed against a saved host's address with a plain string match.
/// </remarks>
public string Address =>
string.Create(CultureInfo.InvariantCulture, $"{Username}@{Hostname}:{Port}");
}
/// <summary>Everything both connect paths share, from the tab appearing to the session opening.</summary>
/// <remarks>
@@ -12178,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);
}
@@ -12465,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
@@ -12485,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;
@@ -13470,6 +13677,7 @@ internal sealed partial class VaultViewModel(
/// </remarks>
partial void OnHostFilterChanged(string value)
{
RefreshFilterAddress(value);
RebuildVisibleHosts();
RebuildHostSections();
OnPropertyChanged(nameof(NoHostBoardMessage));
@@ -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);
}
}
@@ -321,6 +321,263 @@ public sealed class QuickConnectTests : IAsyncLifetime
Token);
}
// ---- A machine that is not saved ----
/// <remarks>
/// The palette's answer to an address somebody has just been given: the query reads as one, a row offers
/// to dial it, and that row is the one Enter takes — nothing saved answers an address nobody has saved.
/// </remarks>
[Fact]
public async Task TypingAnAddressOffersToConnectWithoutSavingIt()
{
await OnThePaletteAsync((palette, window) =>
{
shell.SearchText = "root@10.0.0.5:2222";
Relayout(window);
shell.HasAdHocTarget.ShouldBeTrue();
shell.IsAdHocSelected.ShouldBeTrue("the typed address is what the query is plainly about");
shell.SelectedSearchResult.ShouldBeNull("exactly one row is lit");
palette.AdHocTargetRow.IsEffectivelyVisible.ShouldBeTrue();
VisibleTexts(palette.AdHocTargetRow).ShouldContain("root@10.0.0.5:2222", StringComparer.Ordinal);
VisibleTexts(palette.AdHocTargetRow).ShouldContain("not saved", StringComparer.Ordinal);
});
}
/// <remarks>
/// The default port is printed rather than implied, because it is what will be dialled — and it is what
/// makes the row comparable with a saved host's address, which always carries one.
/// </remarks>
[Fact]
public async Task AnAddressWithNoPortIsOfferedOnTwentyTwo()
{
await OnThePaletteAsync((_, _) =>
{
shell.SearchText = "root@box";
shell.AdHocAddress.ShouldBe("root@box:22");
});
}
/// <remarks>
/// A query with no <c>@</c> is a search and nothing else, so a name that matches no host offers no row to
/// dial — but it does say what the box would take instead, because a name that matches nothing is often a
/// machine that was never saved.
/// </remarks>
[Fact]
public async Task ANameThatMatchesNothingSaysHowToReachAnUnsavedMachine()
{
await OnThePaletteAsync((palette, window) =>
{
shell.SearchText = "nothing-is-called-this";
Relayout(window);
shell.HasAdHocTarget.ShouldBeFalse();
palette.AdHocTargetRow.IsEffectivelyVisible.ShouldBeFalse();
VisibleTexts(palette).ShouldContain(
text => text.Contains("user@host", StringComparison.Ordinal),
"the hint names the other thing the box takes");
});
}
/// <remarks>
/// A query that looks like an address and cannot be one says why under the box, rather than producing no
/// row and no reason — which would read as a palette that ignored the one thing it was asked.
/// </remarks>
[Fact]
public async Task AnAddressThatCannotBeReadSaysWhy()
{
await OnThePaletteAsync((palette, window) =>
{
shell.SearchText = "root@box:99999";
Relayout(window);
shell.HasAdHocTarget.ShouldBeFalse();
VisibleTexts(palette).ShouldContain(
text => text.Contains("port", StringComparison.Ordinal),
"the refusal is the manual parser's own sentence");
});
}
/// <remarks>
/// The password is asked for in the palette itself, and the caret goes with the question: typing an
/// address and pressing Enter has to leave somebody typing a password, not hunting for the box.
/// </remarks>
[Fact]
public async Task EnterOnTheAddressAsksForItsPasswordInThePalette()
{
await OnThePaletteAsync((palette, window) =>
{
palette.QueryBox.Focus().ShouldBeTrue();
shell.SearchText = "root@10.0.0.5";
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
Relayout(window);
shell.IsSearching.ShouldBeTrue("nothing has been dialled yet");
shell.IsAskingForAdHocPassword.ShouldBeTrue();
palette.PasswordBox.IsEffectivelyVisible.ShouldBeTrue();
palette.QueryBox.IsEffectivelyVisible.ShouldBeFalse();
palette.PasswordBox.IsFocused.ShouldBeTrue("the caret follows the question");
});
}
/// <remarks>
/// Escape from the password goes back to the address with what was typed intact, because a mistyped
/// address is the likeliest reason to want out — and drops the password, which is a secret nobody asked
/// to keep.
/// </remarks>
[Fact]
public async Task EscapeFromThePasswordGoesBackToTheAddress()
{
await OnThePaletteAsync((palette, window) =>
{
palette.QueryBox.Focus().ShouldBeTrue();
shell.SearchText = "root@10.0.0.5";
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
Relayout(window);
shell.AdHocPassword = "hunter2";
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
Relayout(window);
shell.IsSearching.ShouldBeTrue("Escape here steps back rather than closing");
shell.IsAskingForAdHocPassword.ShouldBeFalse();
shell.SearchText.ShouldBe("root@10.0.0.5");
shell.AdHocPassword.ShouldBeEmpty();
palette.QueryBox.IsFocused.ShouldBeTrue("the caret goes back to the address");
});
}
/// <remarks>
/// Enter with no password typed is refused in the palette rather than dialled: an SSH server asked for a
/// password with an empty one answers "authentication failed", which is a sentence about the machine
/// when the problem is the box.
/// </remarks>
[Fact]
public async Task AnEmptyPasswordIsRefusedRatherThanDialled()
{
var started = new List<ConnectionAttemptEventArgs>();
vault.ConnectionStarting += (_, attempt) => started.Add(attempt);
await OnThePaletteAsync((palette, window) =>
{
palette.QueryBox.Focus().ShouldBeTrue();
shell.SearchText = "root@10.0.0.5";
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
Relayout(window);
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
shell.IsSearching.ShouldBeTrue();
shell.HasAdHocRefusal.ShouldBeTrue();
started.ShouldBeEmpty();
});
}
/// <remarks>
/// The whole path: an address, a password, and a tab for that address — with nothing added to the
/// keychain. The dial itself fails in this suite, as every connect here does, and inside the vault's own
/// handling; what the palette promises is the attempt and the closing.
/// </remarks>
[Fact]
public async Task ThePasswordConnectsToTheAddressAndSavesNothing()
{
var started = new List<ConnectionAttemptEventArgs>();
vault.ConnectionStarting += (_, attempt) => started.Add(attempt);
var hostsBefore = vault.Hosts.Count;
await OnThePaletteAsync((palette, window) =>
{
palette.QueryBox.Focus().ShouldBeTrue();
shell.SearchText = "root@10.0.0.5:2222";
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
Relayout(window);
shell.AdHocPassword = "hunter2";
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
shell.IsSearching.ShouldBeFalse("connecting closes the palette, as it does for a saved host");
shell.AdHocPassword.ShouldBeEmpty("the password does not outlive the palette");
var attempt = started.ShouldHaveSingleItem();
attempt.Address.ShouldBe("root@10.0.0.5:2222");
attempt.HostId.ShouldBeNull("nothing in the keychain stands behind this tab");
});
vault.Hosts.Count.ShouldBe(hostsBefore, "connecting without saving saves nothing");
}
/// <remarks>
/// The one time the typed address does not take the highlight: a saved host dials exactly that address,
/// so it is the same machine with whatever the keychain knows about it, and Enter should reach it the way
/// it was set up to be reached. The typed row is still offered, one arrow away.
/// </remarks>
[Fact]
public async Task AnAddressASavedHostDialsHighlightsThatHost()
{
await OnThePaletteAsync((_, _) =>
{
shell.SearchText = "deploy@host-3.internal";
shell.HasAdHocTarget.ShouldBeTrue();
shell.IsAdHocSelected.ShouldBeFalse();
shell.SelectedSearchResult.ShouldNotBeNull().Label.ShouldBe("host-3");
});
}
/// <remarks>
/// The typed row sits above the list and the arrows treat it as the list's first row, clamped as the rest
/// are — and the caret stays in the box throughout, for the reason the plain arrows test gives.
/// </remarks>
[Fact]
public async Task TheArrowsMoveBetweenTheAddressAndTheSavedHosts()
{
await OnThePaletteAsync((palette, window) =>
{
palette.QueryBox.Focus().ShouldBeTrue();
// Every seeded host's address starts with this, and none of them is exactly it.
shell.SearchText = "deploy@host";
shell.SearchResults.Count.ShouldBeGreaterThan(1, "an empty list would prove nothing");
shell.IsAdHocSelected.ShouldBeTrue();
window.KeyPressQwerty(PhysicalKey.ArrowDown, RawInputModifiers.None);
shell.IsAdHocSelected.ShouldBeFalse();
shell.SelectedSearchResult.ShouldBe(shell.SearchResults[0]);
window.KeyPressQwerty(PhysicalKey.ArrowUp, RawInputModifiers.None);
shell.IsAdHocSelected.ShouldBeTrue();
shell.SelectedSearchResult.ShouldBeNull();
window.KeyPressQwerty(PhysicalKey.ArrowUp, RawInputModifiers.None);
shell.IsAdHocSelected.ShouldBeTrue("clamped rather than wrapped");
palette.QueryBox.IsFocused.ShouldBeTrue();
});
}
/// <remarks>The pointer's version of Enter on the typed row: straight to its password.</remarks>
[Fact]
public async Task ClickingTheAddressAsksForItsPassword()
{
await OnThePaletteAsync((palette, window) =>
{
shell.SearchText = "root@10.0.0.5";
Relayout(window);
var row = palette.AdHocTargetRow;
window.MouseDown(Centre(row, window), MouseButton.Left);
window.MouseUp(Centre(row, window), MouseButton.Left);
shell.IsAskingForAdHocPassword.ShouldBeTrue();
shell.IsSearching.ShouldBeTrue();
});
}
// ---- Helpers ----
/// <summary>Opens the palette in a window the size the application's is, and runs one body against it.</summary>
@@ -1130,6 +1130,338 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
ssh.Requests.ShouldBeEmpty();
}
/// <remarks>
/// The phone's hosts search box, read as an address: the card that dials it appears for <c>user@host</c>
/// and for nothing else. A plain search must not grow one on every keystroke.
/// </remarks>
[Theory]
[InlineData("root@box", "root@box:22")]
[InlineData(" deploy@build.internal:2222 ", "deploy@build.internal:2222")]
public async Task AnAddressTypedIntoTheHostsSearch_IsOfferedAsAMachineToConnectTo(
string typed,
string offered)
{
var vault = await ReadyToConnectAsync();
vault.HostFilter = "prod";
vault.HasFilterAddress.ShouldBeFalse("a search without an @ is a search");
vault.HasFilterAddressRefusal.ShouldBeFalse();
vault.HostFilter = typed;
vault.HasFilterAddress.ShouldBeTrue();
vault.FilterAddressText.ShouldBe(offered);
}
/// <remarks>
/// The whole path from the search box: the address and the password typed there are what is dialled, a tab
/// opens for it, and the keychain is exactly as it was — that is the point of connecting this way. The box
/// and the password are both empty afterwards, so the list is not left filtered to a machine it does not
/// hold and the password does not wait in the box for the next one.
/// </remarks>
[Fact]
public async Task TheHostsSearchConnectsToATypedAddress_AndSavesNothing()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
var hostsBefore = vault.Hosts.Count;
vault.HostFilter = "deploy@build.internal:2222";
vault.FilterAddressPassword = "hunter2";
await vault.ConnectToFilterAddressCommand.ExecuteAsync(null);
var request = ssh.Requests.ShouldHaveSingleItem();
request.Host.ShouldBe("build.internal");
request.Port.ShouldBe(2222);
request.Username.ShouldBe("deploy");
request.Credential.ShouldBeOfType<SshPasswordCredential>().Password.ShouldBe("hunter2");
shell.Tabs.ShouldHaveSingleItem().Label.ShouldBe("deploy@build.internal");
vault.Hosts.Count.ShouldBe(hostsBefore, "connecting without saving saves nothing");
vault.HostFilter.ShouldBeEmpty();
vault.FilterAddressPassword.ShouldBeEmpty();
vault.HasFilterAddress.ShouldBeFalse();
}
/// <remarks>
/// An empty password is refused under the box rather than sent, as the manual box refuses one — and the
/// address stays where it was typed, so filling the password in is all that is left to do.
/// </remarks>
[Fact]
public async Task TheHostsSearchRefusesAnEmptyPassword_AndKeepsTheAddress()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.HostFilter = "root@box";
await vault.ConnectToFilterAddressCommand.ExecuteAsync(null);
vault.FilterAddressRefusal.ShouldContain("password");
vault.HostFilter.ShouldBe("root@box");
ssh.Requests.ShouldBeEmpty();
shell.Tabs.ShouldBeEmpty();
}
/// <remarks>
/// A search that looks like an address and cannot be one says why, in the manual parser's own words,
/// rather than offering nothing and leaving the box looking ignored.
/// </remarks>
[Fact]
public async Task AnAddressInTheHostsSearchThatCannotBeRead_SaysWhy()
{
var vault = await ReadyToConnectAsync();
vault.HostFilter = "root@box:70000";
vault.HasFilterAddress.ShouldBeFalse();
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.
/// </remarks>
[Fact]
public async Task ChangingTheAddressInTheHostsSearch_DropsThePasswordTypedForTheLastOne()
{
var vault = await ReadyToConnectAsync();
vault.HostFilter = "root@box";
vault.FilterAddressPassword = "hunter2";
vault.HostFilter = "root@other-box";
vault.FilterAddressPassword.ShouldBeEmpty();
}
/// <remarks>
/// <b>The retry used to re-run whichever host was selected.</b> That was right while a selected host was
/// the only way to connect; with a manual target it would answer "do you trust this key" by dialling a
@@ -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",
]);