Compare commits

...
Author SHA1 Message Date
jaap-jan fa85ad1eff Connections: drop the manual box's view-model state now that both heads ask through the prompt
ci / build and test (pull_request) Successful in 2m32s
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 3m41s
The phone's Connections screen no longer draws the user@host + password box, and the desktop never
did, so nothing bound VaultViewModel.ManualTarget, ManualPassword, ManualStatus or
ConnectManuallyCommand any more, nor MainWindowViewModel.ConnectToRecentCommand, whose address branch
wrote into that box. They are gone, and so is VaultViewModel.AskAboutHost, whose one caller was
ConnectToRecent. TryParseManualTarget, ManualEndpoint and ConnectToEndpointAsync stay: the prompt, the
desktop's Ctrl+K palette and the phone's hosts search all use them. The reasoning the removed members
carried moved onto ConnectToEndpointAsync and ChoosePromptRecentAsync.

The tests whose subject is still live now go through the prompt instead of the box: a typed address
dialled exactly as typed, port 22 by default, the refusal sentences, the host-key retry replaying the
attempt that raised it, the host-key card staying over the surface it was typed on, the session facts
for a typed password, and no forwards for a typed address. The recent-row cases now drive
ChoosePromptRecentCommand. Two cases existing prompt tests already covered were deleted. Manual check
11.8 now describes recent rows landing on the prompt.
2026-10-16 14:21:10 +02:00
jaap-jan 0cecf1afc4 Android: ask what to connect to on Connections, the same prompt the desktop's empty terminal draws
ci / build and test (push) Successful in 4m14s
ci / api image (push) Successful in 10s
ci / deploy ssh.dodotech.cloud (push) Successful in 22s
ci / android head (push) Successful in 5m6s
ci / desktop nightly (push) Successful in 3m2s
The phone's Connections screen with nothing open had a box that took an address alone, which left a
saved machine several screens away. It now draws the desktop's prompt over the same view-model state:
"connect to:" takes a saved host's name or user@host[:port], and a password line follows only when
one is needed. Names, addresses and refusals are read exactly as on the desktop, and nothing typed
here is saved.

What a phone changes: the hosts matching so far are thumb-sized buttons that take a host outright,
since there is no Tab; CONNECT is always drawn, since the IME's return key may only close the
keyboard; BACK, and the system back gesture, step off the password line rather than leaving the
screen; and the password's label sits on its own line. The keyboard follows the question onto the
password line, but is not raised when the screen opens, where it would cover the recent list.

Recent rows now go onto the prompt too. Covered by a new ShellFlowTests case for a tapped match on
top of the shared prompt cases; manual check 11.14.
2026-10-16 14:07:13 +02:00
12 changed files with 368 additions and 360 deletions

No files matched your search

+26 -7
View File
@@ -2099,14 +2099,16 @@ that dialled it taken away. See `MainWindowViewModel.OnVaultConnectionFailed`.
With at least one keychain host and one machine connected to by hand in the log, open Connections with
nothing running.
**Pass:** tapping the keychain one lands on HOSTS with that host **ticked and the action bar up**, ready
for CONNECT out of the ⋯. Tapping the hand-typed one puts its address back in the box on this screen and
leaves the password box **empty**. Neither one dials anything on its own.
**Pass:** tapping the keychain one puts **its name** on the `connect to:` line and carries on as typing that
name would — its password line, or straight into a terminal for a host with a key or a saved password.
Tapping the hand-typed one puts its `user@host:port` on the line and asks for its password with the password
line **empty**. Neither one leaves this screen, and the hand-typed one dials nothing until a password is
given.
**Failure means:** if the hand-typed one navigates to HOSTS, the log entry is carrying a host id it should
not have. If the password came back, something is storing it — it never was. And if the keychain one arrives
at a list with nothing marked, `ConnectToRecent` is setting the selection rather than going through
`AskAboutHost`: nothing on that list means "selected" any more, so that is a screen with nothing to press.
**Failure means:** if the hand-typed one shows a host's name rather than its address, the log entry is
carrying a host id it should not have. If the password came back, something is storing it — it never was.
And if the keychain one shows the logged address rather than the host's name, the host id is not being
matched against the keychain — see `MainWindowViewModel.ChoosePromptRecentAsync`.
### 11.9 The keychain and snippets are the vault's, and back walks out of them
@@ -2335,6 +2337,23 @@ Android is not delivering the click as a `contextmenu` event, and the gesture ne
`WebView` instead of a page one. Neither costs this head anything it has today: a phone without a pointer
has no right button to miss.
### 11.14 Connections with nothing open asks what to connect to
Close every shell, then press **Connections**. Type part of a saved host's name into the `connect to:` line.
**Pass:** the matching hosts appear under the line as buttons a thumb can hit; tapping one asks for that
host's password on a line of its own under ssh's wording — or connects at once, for a host with a key or a
saved password. Then type `user@host` for a machine that is not saved and press CONNECT: the password line
appears with the keyboard already on it, and BACK — or the system back gesture — steps off it onto the
address rather than leaving the screen. A tap on a RECENT row lands on the prompt and asks whatever it needs
to before anything is dialled. Nothing new appears on HOSTS or in the keychain afterwards.
**Failure means:** a password line that appears with the keyboard still on the first line is the posted focus
in `ConnectPrompt.axaml.cs` arriving before the box has bounds. Back that leaves Connections from the password
line is `PhoneShell.OnBackRequested`'s prompt step having been reordered below the surface check. The
decisions themselves — which name matches, what is refused, what is saved — are `ShellFlowTests`' prompt
cases, shared with the desktop; this is the rectangles and the keyboard.
---
## Phase 12 — Shared vaults: the operations that span two accounts
@@ -843,6 +843,30 @@
<Setter Property="TextInputOptions.ContentType" Value="Password" />
</Style>
<!--
The Connections prompt's two lines, typed into the way a shell is: no fill and no border in any state, so
the text sits on the pane where its label leaves off and the caret is the only sign the line takes input.
44 tall all the same, which is this head's touch floor — the line has no box to aim at, so the whole
height of it has to be the target. See ConnectPrompt.axaml. Declared after .secret, which it combines with
on the password line and sets nothing in common with.
-->
<Style Selector="TextBox.prompt">
<Setter Property="MinHeight" Value="44" />
<Setter Property="Padding" Value="0" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="CaretBrush" Value="{StaticResource AccentText}" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="14" />
</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>
<!--
The live dot, and the class name is the same one the desktop sidebar uses so the two heads cannot
drift on what green means: a terminal is open on this host right now. Deliberately not reachability —
@@ -0,0 +1,98 @@
<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.Android.Views"
x:Class="DodoSSH.Client.Android.Views.ConnectPrompt"
x:DataType="vm:MainWindowViewModel">
<!--
The phone's half of the desktop's empty-terminal prompt, drawn on Connections while nothing is open: the
same two lines, over the same view-model state — MainWindowViewModel.PromptText and its siblings — so a
name or an address typed here is read, refused and dialled exactly as it is on the desktop. See the
desktop's ConnectPrompt.axaml for the reasoning; what is here is only what a phone changes.
THREE THINGS A PHONE CHANGES.
No Tab and no Escape. The matches under the first line are buttons sized for a thumb instead of a
completion list for a key, and a tap on one takes it outright — completing to a name and then pressing
CONNECT is two taps for what one already says. The way off the password line is BACK beside CONNECT,
and the system back gesture, which PhoneShell routes to the same command.
A CONNECT button, because the software keyboard's own return key may commit or may only close the
keyboard — that is the IME's choice, as the files screen's GO button already says. Enter is still bound,
for every keyboard that does send it.
The password's label on its own line. ssh's wording — "deploy@build.internal:2222's password:" — is a
third of a phone's width before anything is typed, so the box goes under it rather than beside it.
-->
<Border Background="{StaticResource Pane}" BorderBrush="{StaticResource Border}" BorderThickness="1"
CornerRadius="12" Padding="14,12">
<StackPanel Spacing="6">
<TextBlock Classes="mono" FontSize="12" Foreground="{StaticResource TextFaint}" TextWrapping="Wrap"
Text="Type a saved host's name, or user@host[:port] for a machine that is not saved." />
<!-- ============ what to connect to ============ -->
<Grid ColumnDefinitions="Auto,*" Margin="0,4,0,0">
<TextBlock Grid.Column="0" Classes="mono" FontSize="14" FontWeight="Bold"
Foreground="{StaticResource AccentText}" Text="connect to:" VerticalAlignment="Center" />
<TextBox Grid.Column="1" x:Name="Target" Classes="prompt" Margin="8,0,0,0"
AutomationProperties.Name="Connect to"
Text="{Binding PromptText}" IsReadOnly="{Binding IsPromptAskingForPassword}">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding SubmitPromptCommand}" />
</TextBox.KeyBindings>
</TextBox>
</Grid>
<!-- The saved hosts that match so far, best first. A tap takes one: it connects, or asks its password. -->
<ItemsControl ItemsSource="{Binding PromptMatches}" IsVisible="{Binding ShowsPromptMatches}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:HostRowViewModel">
<Button Classes="chiptoggle" MinHeight="44" Margin="0,0,6,6"
Command="{Binding $parent[views:ConnectPrompt].((vm:MainWindowViewModel)DataContext).ChoosePromptMatchCommand}"
CommandParameter="{Binding}">
<TextBlock Classes="mono" FontSize="13" Text="{Binding Label}" />
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- ============ the password, when one is needed ============ -->
<StackPanel x:Name="PasswordLine" Spacing="0" IsVisible="{Binding IsPromptAskingForPassword}">
<TextBlock Classes="mono" FontSize="13" Foreground="{StaticResource Text}" TextWrapping="Wrap"
Text="{Binding PromptPasswordLabel}" />
<!--
Classes="secret" for what it tells the software keyboard, not only for the dots: see Phone.axaml.
-->
<TextBox x:Name="Password" Classes="prompt secret" AutomationProperties.Name="Password"
Text="{Binding PromptPassword}">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding SubmitPromptCommand}" />
</TextBox.KeyBindings>
</TextBox>
</StackPanel>
<TextBlock Classes="mono" FontSize="12" TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
Text="{Binding PromptRefusal}" IsVisible="{Binding HasPromptRefusal}" />
<!--
CONNECT always; BACK only on the password line, where there is a line to go back from. Side by side,
CONNECT on the right where the thumb ends up, the order the hosts screen's own panels use.
-->
<Grid ColumnDefinitions="*,8,*" Margin="0,6,0,0">
<Button Grid.Column="0" Classes="secondary" Height="44" Content="BACK"
Command="{Binding CancelPromptCommand}" IsVisible="{Binding IsPromptAskingForPassword}" />
<Button Grid.Column="2" Classes="primary" Height="44" Content="CONNECT"
Command="{Binding SubmitPromptCommand}" />
</Grid>
</StackPanel>
</Border>
</UserControl>
@@ -0,0 +1,45 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
namespace DodoSSH.Client.Android.Views;
/// <summary>The phone's empty-terminal prompt: what to connect to, and its password when one is needed.</summary>
/// <remarks>
/// <para>
/// The one thing the code-behind does is move the keyboard onto the password line when it appears. Enter on a
/// name is answered with a second question, and a keyboard left on the first line — read-only by then — would
/// type nothing and look as though it should.
/// </para>
/// <para>
/// It does not take the keyboard when the screen opens, unlike the desktop's prompt. On a phone that would
/// raise the software keyboard over the recent connections underneath, which are the likelier tap.
/// </para>
/// <para>
/// <c>FindControl</c> rather than the generated <c>x:Name</c> fields, for the reason
/// <c>HostsScreen</c>'s constructor gives: no view on this head calls <c>InitializeComponent</c>, so those
/// fields are null at run time.
/// </para>
/// </remarks>
internal sealed partial class ConnectPrompt : UserControl
{
public ConnectPrompt()
{
AvaloniaXamlLoader.Load(this);
var line = this.FindControl<StackPanel>("PasswordLine")!;
var box = this.FindControl<TextBox>("Password")!;
line.PropertyChanged += (_, e) =>
{
if (e.Property == IsVisibleProperty && line.IsVisible)
{
// Posted at Loaded, as HostsScreen's own password sheet is: the box has no bounds on the turn
// its line becomes visible, and focusing a control with none raises no keyboard.
Dispatcher.UIThread.Post(() => box.Focus(NavigationMethod.Tab), DispatcherPriority.Loaded);
}
};
}
}
@@ -674,6 +674,16 @@ internal sealed partial class PhoneShell : UserControl
return;
}
// The Connections prompt's password line, which back steps off onto the name it asked about — the
// same thing its BACK button does, and the nearer of the two things back could mean here. Leaving the
// surface would throw the name away along with the question.
if (current is { IsConnectPromptShowing: true, IsPromptAskingForPassword: true })
{
current.CancelPromptCommand.Execute(null);
e.Handled = true;
return;
}
if (!current.IsShowingPages)
{
current.ShowScreenCommand.Execute(current.Screen);
@@ -345,9 +345,11 @@
running this surface is where a connection is made rather than a page apologising for having
nothing on it. Two things are offered, in the order somebody reaches for them.
A box, because the case this application had no answer for is the address somebody has just been
given. Everything else here starts from a keychain item, which is right for a machine used twice and
is several screens of work for one used once.
A prompt, because a terminal with nothing in it should do what a terminal does, which is ask — the
same one the desktop draws in its empty terminal, over the same view-model state. It takes a saved
host's name or user@host for a machine that is not saved, and asks for a password only when one is
needed. See ConnectPrompt.axaml. It replaced a box that took addresses alone, which left a saved
machine several screens away from here.
And the machines already connected to, under it, because that is the commoner half by far: the
thing most likely to be wanted is the thing that was wanted yesterday. They come out of the vault's
@@ -359,44 +361,7 @@
<TextBlock Classes="title" FontSize="13" Text="CONNECT TO A MACHINE" />
<!--
The manual target. One box in the grammar people already have — user@host, or user@host:port —
rather than three, because three boxes on a phone is three taps between three keyboards for
something that arrives as one string.
-->
<TextBox Classes="field" Text="{Binding Vault.ManualTarget}"
PlaceholderText="user@host or user@host:port">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding Vault.ConnectManuallyCommand}" />
</TextBox.KeyBindings>
</TextBox>
<!--
A typed password and nothing else. Offering the keychain's keys here would be a second binding
resolution beside the one the connect path already has, and a key is the reason to save the
machine as a host rather than dial it from a box.
Classes="secret" and not merely PasswordChar: the class is what also tells the software keyboard
this is not prose, which is the difference between dots on screen and dots on screen with the
password in the IME's dictionary. See Theme/Phone.axaml.
-->
<TextBox Classes="field secret" Text="{Binding Vault.ManualPassword}"
PlaceholderText="password">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding Vault.ConnectManuallyCommand}" />
</TextBox.KeyBindings>
</TextBox>
<Button Classes="primary" Content="CONNECT" Command="{Binding Vault.ConnectManuallyCommand}" />
<!--
Beside the box rather than on the shell's status line. What fails here is about what was typed —
a missing account, a port that is not a number — and a sentence about a text box belongs under
the text box.
-->
<TextBlock Classes="detail" TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
Text="{Binding Vault.ManualStatus}"
IsVisible="{Binding Vault.ManualStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<views:ConnectPrompt />
<TextBlock Classes="body"
Text="Nothing typed here is saved. A machine worth keeping belongs on HOSTS, where it can carry a key, a group's defaults and a name." />
@@ -410,14 +375,13 @@
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConnectionLogRowViewModel">
<!--
Tapping one does not connect, and that is deliberate rather than a missing step. A row
that names a keychain host goes to that host with its connect bar — which is where its
key, or its password box, or its refusal lives — and one that names only an address goes
back into the box above, without the password, which was never stored. Either way what
happens next is visible before anything is dialled. See ConnectToRecentCommand.
A tap goes onto the prompt above and carries on as if it had been typed: a keychain host
connects, or asks for its password there; an address asks for its password, which was
never stored. Nothing is dialled before the prompt has asked whatever it needs to. See
MainWindowViewModel.ChoosePromptRecentCommand.
-->
<Button Classes="row" MinHeight="52" Padding="12,8" CornerRadius="10"
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).ConnectToRecentCommand}"
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).ChoosePromptRecentCommand}"
CommandParameter="{Binding}">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2" VerticalAlignment="Center">
@@ -170,7 +170,7 @@
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.
and VaultViewModel.ConnectToEndpointAsync for why a typed password is the only way in.
-->
<StackPanel x:Name="PasswordStep" IsVisible="{Binding IsAskingForAdHocPassword}">
@@ -68,9 +68,9 @@ internal sealed class ConnectionLogRowViewModel(VaultItem<ConnectionLogSecret> e
/// <summary>The keychain host this was, if it was one.</summary>
/// <remarks>
/// Null for a connection made to a typed address, and that is a real distinction rather than missing
/// data — see <c>VaultViewModel.ConnectManuallyAsync</c>. It is what lets the Connections screen offer
/// the right thing when one of these rows is tapped: a keychain host has a connect bar with its own
/// authentication behind it, and an address has only the box it was typed into.
/// data — see <c>VaultViewModel.ConnectToEndpointAsync</c>. It is what lets the connect prompt offer the
/// right thing when one of these rows is tapped: a keychain host has its own authentication behind it, and
/// an address has only the password typed for it — see <c>MainWindowViewModel.ChoosePromptRecentAsync</c>.
/// </remarks>
internal Guid? HostId => entry.Secret.HostId;
@@ -1857,9 +1857,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// button needs a command.
/// </para>
/// <para>
/// 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.
/// Not gated on there being a tab. With nothing open the surface is where a connection is made — both
/// heads draw the connect prompt there; see <see cref="IsConnectPromptShowing"/> — which is worth being
/// able to reach deliberately.
/// </para>
/// </remarks>
[RelayCommand]
@@ -1946,57 +1946,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(HasRecentConnections));
}
/// <summary>Goes back to a machine that has been connected to before.</summary>
/// <remarks>
/// <para>
/// <b>Two destinations, because a recent row is one of two different things.</b> One that names a
/// keychain host goes to that host on the hosts screen, with whatever that head uses to ask about one
/// machine raised over it — the desktop's drawer, the phone's action bar — carrying whatever
/// authentication the keychain resolves for it. Connecting from here instead would be a third connect
/// path that had to answer all of that again.
/// </para>
/// <para>
/// â—† <b>It raises that rather than merely selecting the row, and on the phone it has to.</b> Nothing on
/// that list means "selected" any more — a tap connects and a long press ticks — so arriving with the
/// host selected and nothing else would be arriving at a screen with nothing to press. Asking to go back
/// to a machine is exactly the deliberate act those flags exist to distinguish from browsing. See
/// <c>VaultViewModel.AskAboutHost</c>, which is where the two heads' answers are raised together.
/// </para>
/// <para>
/// One that names no item was typed into the manual box, and the log stored exactly what was dialled —
/// <c>user@host:port</c>, which is the grammar that box takes. So it goes back into the box, and what
/// is deliberately not restored is the password: it was never stored, which is the whole point of the
/// manual path, and a field that filled itself in would be claiming otherwise.
/// </para>
/// <para>
/// A host deleted since it was connected to falls through to the address, which is the honest answer:
/// the machine is still there and the keychain no longer knows about it. So does a host in a vault the
/// user has switched off, and for the same reason rather than by accident: selecting it would point the
/// hosts screen at a row that screen is not drawing, and the grid would null the selection straight back
/// out — arriving at the hosts screen with nothing selected and no explanation.
/// </para>
/// </remarks>
[RelayCommand]
private void ConnectToRecent(ConnectionLogRowViewModel row)
{
if (row is null || Vault is not { } vault)
{
return;
}
if (row.HostId is { } hostId
&& vault.Hosts.FirstOrDefault(host => host.EntityId == hostId) is { } known
&& vault.IsVaultShown(known.VaultId))
{
vault.AskAboutHostCommand.Execute(known);
ShowScreen(ShellScreen.Hosts);
return;
}
vault.ManualTarget = row.Address;
vault.ManualStatus = string.Empty;
}
// ---- The rail's own page grouping ----
/// <summary>
@@ -2602,10 +2551,20 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// 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.
/// <para>
/// <b>Two answers, because a recent row is one of two different things.</b> A row naming a host still in
/// the keychain goes to that host, with whatever the keychain knows about it — its key, its saved
/// password, its refusals — exactly as typing its name would. One that names none was typed in as an
/// address, and the log stored exactly what was dialled, <c>user@host:port</c>, which is the grammar the
/// prompt takes. So it goes back onto the prompt and asks for its password the way a typed one does; the
/// password was never stored, which is the whole point of that path.
/// </para>
/// <para>
/// A host deleted since it was connected to falls through to the address, which is the honest answer:
/// the machine is still there and the keychain no longer knows about it. So does a host in a vault the
/// user has switched off, and for the same reason rather than by accident: taking it would reach a host
/// the rest of the application is deliberately not showing.
/// </para>
/// </remarks>
[RelayCommand(AllowConcurrentExecutions = true)]
private async Task ChoosePromptRecentAsync(ConnectionLogRowViewModel? row)
@@ -2807,13 +2766,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// <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.
/// saving it first. 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.
/// Read with <see cref="VaultViewModel.TryParseManualTarget"/>, which is the connect prompt's parser and
/// the phone's hosts search's too, so every box accepts exactly the same addresses and refuses 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
@@ -2884,7 +2843,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// <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
/// Cleared whenever the palette closes or goes back to the query, on the same terms the connect prompt
/// 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>
@@ -3001,8 +2960,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </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
/// Through <see cref="VaultViewModel.ConnectToEndpointAsync"/>, which is the connect prompt'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.
@@ -4251,48 +4251,21 @@ internal sealed partial class VaultViewModel(
[NotifyPropertyChangedFor(nameof(ShowsAddButton))]
private bool isAskingForConnectPassword;
/// <summary>What was typed into the manual connect box, as <c>user@host</c> or <c>user@host:port</c>.</summary>
/// <remarks>
/// One box rather than four, because this is the form of an address people already have: it is what a
/// colleague pastes into a chat window and what `ssh` itself takes. Splitting it into user, host and port
/// would make the ordinary case three taps between three keyboards on a phone.
/// </remarks>
[ObservableProperty]
private string manualTarget = string.Empty;
/// <inheritdoc cref="ConnectPassword" />
/// <remarks>
/// Its own box rather than <see cref="ConnectPassword"/>, for the reason <c>TryBuildConnectionRequest</c>
/// takes the typed password as a parameter: these are different screens, and a password typed on one is
/// not a password offered on the other.
/// </remarks>
[ObservableProperty]
private string manualPassword = string.Empty;
/// <summary>Why the manual box refused, if it did.</summary>
/// <remarks>
/// Beside that box rather than only on <see cref="Status"/>. The refusals here are about what was typed
/// — a missing account, a port that is not a number — and a sentence about a text box belongs next to
/// the text box, not on a status line that also carries what the sync engine is doing.
/// </remarks>
[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.
/// <b>The phone's version of the desktop palette's typed-address row.</b> The connect prompt 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.
/// Read with <see cref="TryParseManualTarget"/>, the prompt's and the palette's parser too, 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]
@@ -4318,10 +4291,9 @@ internal sealed partial class VaultViewModel(
/// <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.
/// Its own box rather than <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;
@@ -7820,40 +7792,6 @@ internal sealed partial class VaultViewModel(
IsHostPaneOpen = true;
}
/// <summary>
/// Raises whatever the head in front of the user uses to ask about one host.
/// </summary>
/// <param name="row">The machine, which the caller is holding.</param>
/// <remarks>
/// <para>
/// â—† <b>One command, two pieces of furniture, and it exists because the phone's has changed.</b> The
/// desktop's answer is the drawer beside the grid and the phone's used to be the connect bar — one flag
/// served both. The bar is gone: the phone's answer is now a tick and the action bar across the top, and
/// that is a different piece of state, so a caller that only opened the pane would leave this head
/// arriving at a list with nothing on it to press.
/// </para>
/// <para>
/// Both are set rather than branching on which head is running, because neither view model knows and
/// neither should have to. The one the head does not draw is inert: the desktop draws no ticks, and the
/// phone has no drawer.
/// </para>
/// <para>
/// The one caller is <c>MainWindowViewModel.ConnectToRecent</c>, and what it is doing is the deliberate
/// act both flags exist to tell apart from browsing.
/// </para>
/// </remarks>
[RelayCommand]
private void AskAboutHost(HostRowViewModel? row)
{
if (row is null)
{
return;
}
OpenHostPane(row);
ChooseHost(row);
}
/// <summary>
/// Puts the drawer away, whichever of the three panels is in it.
/// </summary>
@@ -11529,62 +11467,13 @@ internal sealed partial class VaultViewModel(
cancellationToken).ConfigureAwait(true);
}
/// <summary>
/// Opens a terminal on somewhere that is not in the keychain.
/// </summary>
/// <remarks>
/// <para>
/// <b>The one connection this application makes to a machine it has never been told about.</b> Everything
/// else starts from a keychain item, and that is still the way a host anybody uses twice should be
/// reached — it is the only way to get a key, a group's defaults, a saved username or a password that is
/// not typed again. This is for the other case, which is real and had no answer: a box somebody has just
/// been given the address of.
/// </para>
/// <para>
/// <b>A typed password and nothing else.</b> Offering the keychain's keys here would be a second binding
/// resolution beside <see cref="TryBuildAuthentication"/>, and the argument against a second one is
/// written there at length. A key is a reason to save the host.
/// </para>
/// <para>
/// Nothing is written to the keychain, deliberately. What <em>is</em> written, if the user answers the
/// question, is a host-key pin — the trust decision belongs to the endpoint rather than to the item, and
/// a machine reached this way is exactly the one whose key nobody has seen before.
/// </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 Task ConnectManuallyAsync() => ConnectManuallyAsync(CancellationToken.None);
/// <inheritdoc cref="ConnectManuallyAsync()" />
private async Task ConnectManuallyAsync(CancellationToken cancellationToken)
{
if (!TryParseManualTarget(ManualTarget, out var endpoint, out var refusal))
{
ManualStatus = refusal;
return;
}
if (ManualPassword.Length == 0)
{
ManualStatus = "A password is needed. Save this machine as a host to reach it with a key.";
return;
}
ManualStatus = string.Empty;
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
/// entry are the ones the connect prompt and the desktop palette produce, and nothing is written to the
/// keychain.
/// </para>
/// <para>
@@ -11654,14 +11543,27 @@ internal sealed partial class VaultViewModel(
/// </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.
/// <b>The one connection this application makes to a machine it has never been told about.</b> Everything
/// else starts from a keychain item, and that is still the way a host anybody uses twice should be
/// reached — it is the only way to get a key, a group's defaults, a saved username or a password that is
/// not typed again. This is for the other case, which is real and had no answer: a box somebody has just
/// been given the address of.
/// </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.
/// <b>A typed password and nothing else.</b> Offering the keychain's keys here would be a second binding
/// resolution beside <see cref="TryBuildAuthentication"/>, and the argument against a second one is
/// written there at length. A key is a reason to save the host.
/// </para>
/// <para>
/// Nothing is written to the keychain, deliberately. What <em>is</em> written, if the user answers the
/// question, is a host-key pin — the trust decision belongs to the endpoint rather than to the item, and
/// a machine reached this way is exactly the one whose key nobody has seen before.
/// </para>
/// <para>
/// Every box an address is typed into — the connect prompt, the desktop's palette, the phone's hosts
/// search — parses it with <see cref="TryParseManualTarget"/> and hands over what it read. 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(
@@ -12375,7 +12277,7 @@ internal sealed partial class VaultViewModel(
// Last, and after the tab exists: keeping the password is a favour, and the session the user asked
// for must not wait on a vault write to appear.
//
// Only for a target that came from a keychain host. A machine typed into the manual box has nothing
// Only for a target that came from a keychain host. A machine typed in as an address 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 is { Row: { } row, MayRememberPassword: true })
@@ -12702,7 +12604,7 @@ internal sealed partial class VaultViewModel(
/// </summary>
/// <remarks>
/// Only a keychain host can be: the relay dials the address the server holds for an item, Spelunk is
/// reached with a key the item names, and a machine typed into the manual box has neither.
/// reached with a key the item names, and a machine typed in as an address has neither.
/// </remarks>
internal bool Routed => Row?.Host is { } host && (host.RelayEnabled || host.Spelunk is not null);
}
@@ -152,13 +152,11 @@ public sealed partial class ShellFlowTests
[Fact]
public async Task ATypedInAddress_StartsNoForwards()
{
var vault = await ReadyToConnectAsync();
await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.ManualTarget = "deploy@10.0.0.9";
vault.ManualPassword = "hunter2";
await vault.ConnectManuallyCommand.ExecuteAsync(null);
await ConnectAtThePromptAsync("deploy@10.0.0.9", "hunter2");
shell.Tabs.ShouldHaveSingleItem().Forwards.ShouldBeEmpty();
ssh.Forwards.ShouldBeEmpty();
+90 -101
View File
@@ -1040,19 +1040,17 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
/// <remarks>
/// The one connection this application makes to a machine that is not in the keychain. What is worth
/// pinning is that it is dialled exactly as typed and nothing is inferred — the account, the address and
/// the port all come out of the one box.
/// the port all come out of the one line, and the spaces a paste brings with it are not part of any of
/// them.
/// </remarks>
[Fact]
public async Task AManualTarget_IsDialledExactlyAsItWasTyped()
public async Task ThePrompt_DialsATypedAddressExactlyAsItWasTyped()
{
var vault = await ReadyToConnectAsync();
await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.ManualTarget = " deploy@build.internal:2222 ";
vault.ManualPassword = "hunter2";
await vault.ConnectManuallyCommand.ExecuteAsync(null);
await ConnectAtThePromptAsync(" deploy@build.internal:2222 ", "hunter2");
var request = ssh.Requests.ShouldHaveSingleItem();
request.Host.ShouldBe("build.internal");
@@ -1065,71 +1063,55 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
}
[Fact]
public async Task AManualTargetWithNoPort_TakesTwentyTwo()
public async Task ThePrompt_DialsAnAddressWithNoPortOnTwentyTwo()
{
var vault = await ReadyToConnectAsync();
await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.ManualTarget = "root@box";
vault.ManualPassword = "hunter2";
await vault.ConnectManuallyCommand.ExecuteAsync(null);
await ConnectAtThePromptAsync("root@box", "hunter2");
ssh.Requests.ShouldHaveSingleItem().Port.ShouldBe(22);
}
/// <remarks>
/// <para>
/// <c>ssh</c> would fall back to this machine's own account name. A phone's is the Android user, which
/// is never a login on anything, so the guess would fail at the remote as "authentication failed"
/// rather than here as a sentence about the box that was typed into.
/// rather than here as a sentence about the line that was typed into.
/// </para>
/// <para>
/// The first two have no <c>@</c>, so the prompt reads them as the name of a saved host rather than as an
/// address — which is why their sentences are the prompt's own and not the address parser's.
/// </para>
/// </remarks>
[Theory]
[InlineData("", "Type a machine")]
[InlineData("build.internal", "Say who to log in as")]
[InlineData("", "Type the name of a saved host")]
[InlineData("build.internal", "No saved host is called")]
[InlineData("deploy@", "Say who to log in as")]
[InlineData("@build.internal", "Say who to log in as")]
[InlineData("deploy@build.internal:70000", "between 1 and 65535")]
[InlineData("deploy@build.internal:ssh", "between 1 and 65535")]
[InlineData("deploy@[fe80::1]", "bracketed IPv6")]
public async Task AManualTargetThatCannotBeRead_IsRefusedBeforeAnythingIsDialled(
public async Task ThePrompt_RefusesWhatCannotBeRead_BeforeAnythingIsDialled(
string typed,
string because)
{
var vault = await ReadyToConnectAsync();
await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.ManualTarget = typed;
vault.ManualPassword = "hunter2";
shell.ShowTerminalCommand.Execute(null);
shell.PromptText = typed;
await vault.ConnectManuallyCommand.ExecuteAsync(null);
await shell.SubmitPromptCommand.ExecuteAsync(null);
vault.ManualStatus.ShouldContain(because);
shell.PromptRefusal.ShouldContain(because);
shell.IsPromptAskingForPassword.ShouldBeFalse("a line that cannot be read has no password to ask for");
ssh.Requests.ShouldBeEmpty();
shell.Tabs.ShouldBeEmpty("a refusal is not an attempt, so there is no tab to explain it");
}
/// <remarks>
/// A password is the only thing this path can authenticate with, so an empty one is refused here rather
/// than sent. Offering the keychain's keys would be a second binding resolution beside the connect
/// path's own, which is the thing <c>TryBuildAuthentication</c> exists to be the only copy of.
/// </remarks>
[Fact]
public async Task AManualTargetWithNoPassword_SaysSoRatherThanDiallingWithoutOne()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.ManualTarget = "root@box";
await vault.ConnectManuallyCommand.ExecuteAsync(null);
vault.ManualStatus.ShouldContain("password");
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.
@@ -1188,8 +1170,8 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
}
/// <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.
/// An empty password is refused under the box rather than sent, as the connect prompt 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()
@@ -1363,6 +1345,27 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
shell.HasPromptRefusal.ShouldBeFalse("typing again clears a refusal about what was typed");
}
/// <remarks>
/// A tap on a match takes that host outright — the phone's way of completing a name, where there is no
/// Tab — and carries on as Enter would: here, to the host's password, with nothing dialled yet.
/// </remarks>
[Fact]
public async Task ATappedMatchOnThePrompt_TakesThatHost()
{
var vault = await ReadyToConnectAsync();
shell.ShowTerminalCommand.Execute(null);
shell.PromptText = "prod";
await shell.ChoosePromptMatchCommand.ExecuteAsync(shell.PromptMatches.ShouldHaveSingleItem());
shell.PromptText.ShouldBe("prod-db");
shell.IsPromptAskingForPassword.ShouldBeTrue();
shell.ShowsPromptMatches.ShouldBeFalse("the matches are for the name, not drawn under its password");
shell.PromptPasswordLabel.ShouldBe($"{vault.Hosts[0].Address}'s password:");
ssh.Requests.ShouldBeEmpty();
}
/// <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.
@@ -1408,8 +1411,11 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
}
/// <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.
/// An empty password is refused on the prompt rather than sent: the server's "authentication failed" would
/// be a sentence about the machine when the problem is the box. A password is the only thing a typed
/// address can authenticate with — offering the keychain's keys would be a second binding resolution
/// beside the connect path's own, which is the thing <c>TryBuildAuthentication</c> exists to be the only
/// copy of.
/// </remarks>
[Fact]
public async Task ThePrompt_RefusesAnEmptyPassword()
@@ -1443,6 +1449,8 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
shell.PromptText.ShouldBe("deploy@build.internal:2222");
shell.IsPromptAskingForPassword.ShouldBeTrue();
shell.PromptPasswordLabel.ShouldBe("deploy@build.internal:2222's password:");
shell.PromptPassword.ShouldBeEmpty("the log never held one");
ssh.Requests.ShouldBeEmpty();
}
/// <remarks>
@@ -1464,7 +1472,7 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
/// <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
/// the only way to connect; with a typed address it would answer "do you trust this key" by dialling a
/// different machine — or by refusing with "choose a host first" over a key the user has just agreed to
/// trust. A host is deliberately selected here, so a retry that ignored the attempt would connect and
/// the assertion would still catch it.
@@ -1481,10 +1489,7 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
ssh.Failure = new SshHostKeyUnknownException(
new HostKeyPresentation("build.internal", 2222, "ssh-ed25519", "SHA256:unknown"));
vault.ManualTarget = "deploy@build.internal:2222";
vault.ManualPassword = "hunter2";
await vault.ConnectManuallyCommand.ExecuteAsync(null);
await ConnectAtThePromptAsync("deploy@build.internal:2222", "hunter2");
vault.HasPendingHostKey.ShouldBeTrue();
@@ -1499,46 +1504,25 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
}
/// <remarks>
/// A recent row is one of two different things, and tapping it has to lead to whichever one it is. The
/// keychain half goes to the host's own connect bar rather than connecting from here, because that bar
/// is where its key, its password box and its refusals already live.
/// A recent row is one of two different things, and choosing it has to lead to whichever one it is. The
/// keychain half takes the host as typing its name would, so its key, its saved password and its
/// refusals are the keychain's — here, a host with nothing saved, so the prompt asks for its password
/// with nothing dialled yet.
/// </remarks>
[Fact]
public async Task ARecentConnectionNamingAKeychainHost_OpensThatHostOnTheHostsScreen()
public async Task ARecentHostOnThePrompt_TakesThatHost()
{
var vault = await ReadyToConnectAsync();
var host = vault.Hosts[0];
vault.SelectedHost = null;
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
shell.ConnectToRecentCommand.Execute(Recent("prod-db", "root@prod-db:22", host.EntityId));
shell.IsHostsShowing.ShouldBeTrue();
vault.SelectedHost.ShouldBe(host);
vault.ManualTarget.ShouldBeEmpty("a keychain host is not dialled out of the manual box");
}
/// <remarks>
/// The other half. The log stored what was actually dialled, which is the grammar the manual box takes,
/// so it goes straight back in — without the password, which was never stored and whose absence is the
/// point of that path rather than a gap in it.
/// </remarks>
[Fact]
public async Task ARecentConnectionWithNoKeychainItem_GoesBackIntoTheManualBox()
{
var vault = await ReadyToConnectAsync();
// Deliberately somewhere else first, so "it did not navigate" is an assertion rather than the
// screen the application happens to open on.
shell.ShowTerminalCommand.Execute(null);
shell.ConnectToRecentCommand.Execute(
Recent("deploy@build.internal", "deploy@build.internal:2222", hostId: null));
await shell.ChoosePromptRecentCommand.ExecuteAsync(Recent("prod-db", "root@prod-db:22", host.EntityId));
vault.ManualTarget.ShouldBe("deploy@build.internal:2222");
vault.ManualPassword.ShouldBeEmpty();
shell.IsTerminalSurface.ShouldBeTrue("the box being filled in is on this surface");
shell.PromptText.ShouldBe("prod-db", "a keychain host is taken by name, not as the address the log kept");
shell.IsPromptAskingForPassword.ShouldBeTrue();
shell.PromptPasswordLabel.ShouldBe($"{host.Address}'s password:");
ssh.Requests.ShouldBeEmpty();
}
/// <remarks>
@@ -1546,17 +1530,18 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
/// about it, so the address is the honest answer rather than a tap that does nothing.
/// </remarks>
[Fact]
public async Task ARecentConnectionNamingAHostThatHasGone_FallsBackToTheAddress()
public async Task ARecentHostThatHasGone_FallsBackToItsAddressOnThePrompt()
{
var vault = await ReadyToConnectAsync();
await ReadyToConnectAsync();
shell.ShowTerminalCommand.Execute(null);
shell.ConnectToRecentCommand.Execute(
await shell.ChoosePromptRecentCommand.ExecuteAsync(
Recent("prod-db", "root@prod-db:22", Guid.CreateVersion7()));
vault.ManualTarget.ShouldBe("root@prod-db:22");
shell.IsTerminalSurface.ShouldBeTrue();
shell.PromptText.ShouldBe("root@prod-db:22");
shell.PromptPasswordLabel.ShouldBe("root@prod-db:22's password:");
ssh.Requests.ShouldBeEmpty();
}
/// <summary>One row of the connection log, built by hand.</summary>
@@ -3578,8 +3563,8 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
/// <remarks>
/// The connection with no host, which is the case the old navigation was worst for: it took the box that
/// was typed into away and asked about the machine on a list the machine is deliberately not on. The
/// phone's connect box is on the terminal surface, so staying there is what keeps it behind the sheet —
/// and what the user comes back to whichever way they answer.
/// connect prompt is on the terminal surface, so staying there is what keeps it behind the sheet — and
/// what the user comes back to whichever way they answer.
/// </remarks>
[Fact]
public async Task ConnectingByHand_AsksAboutTheHostKeyOverTheBoxItWasTypedInto()
@@ -3588,19 +3573,14 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
shell.ShowTerminalCommand.Execute(null);
ssh.Failure = new SshHostKeyUnknownException(
new HostKeyPresentation("build.internal", 2222, "ssh-ed25519", "SHA256:unknown"));
vault.ManualTarget = "deploy@build.internal:2222";
vault.ManualPassword = "hunter2";
await vault.ConnectManuallyCommand.ExecuteAsync(null);
await ConnectAtThePromptAsync("deploy@build.internal:2222", "hunter2");
vault.HasPendingHostKey.ShouldBeTrue();
shell.IsTerminalSurface.ShouldBeTrue("the connect box is on this surface and has not been taken away");
shell.IsTerminalSurface.ShouldBeTrue("the connect prompt is on this surface and has not been taken away");
shell.IsHostKeyDecisionShowing.ShouldBeTrue();
}
@@ -8571,14 +8551,11 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
[Fact]
public async Task SessionFacts_ATypedPasswordSession_ShowsTheHostKeyAlgorithmAloneWithNoIdentity()
{
var vault = await ReadyToConnectAsync();
await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.ManualTarget = "deploy@build.internal:2222";
vault.ManualPassword = "hunter2";
await vault.ConnectManuallyCommand.ExecuteAsync(null);
await ConnectAtThePromptAsync("deploy@build.internal:2222", "hunter2");
shell.IsSessionConnected.ShouldBeTrue();
shell.SessionHostKeyAlgorithm.ShouldBe("ssh-ed25519");
@@ -11378,6 +11355,18 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
return vault;
}
/// <summary>Types an address at the connect prompt, then its password, pressing Enter after each.</summary>
private async Task ConnectAtThePromptAsync(string typed, string password)
{
shell.ShowTerminalCommand.Execute(null);
shell.PromptText = typed;
await shell.SubmitPromptCommand.ExecuteAsync(null);
shell.PromptPassword = password;
await shell.SubmitPromptCommand.ExecuteAsync(null);
}
/// <summary>A transfers screen connected to the fixture's host, and the session it opened.</summary>
private async Task<FakeSftpSession> ConnectedTransfersAsync()
{