Public Access
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cecf1afc4 | ||
|
|
089de72834 | ||
|
|
f43436218a |
No files matched your search
@@ -2335,6 +2335,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">
|
||||
|
||||
@@ -1223,14 +1223,10 @@
|
||||
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}" />
|
||||
|
||||
@@ -209,9 +209,10 @@
|
||||
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 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.
|
||||
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.
|
||||
-->
|
||||
<!--
|
||||
â—† THE PATH SITS IN THE STAR COLUMN, ALONE, AND THAT PLACEMENT IS LOAD-BEARING.
|
||||
@@ -397,8 +398,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_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.
|
||||
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.
|
||||
-->
|
||||
<!--
|
||||
â—† THE PATH IS TYPED HERE, and that is what the star column is now holding.
|
||||
@@ -445,7 +446,7 @@
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4" Margin="8,0,0,0">
|
||||
<Button Classes="paneicon" Command="{Binding RemoteUpCommand}"
|
||||
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Up one directory">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="" />
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="" />
|
||||
</Button>
|
||||
<Button Classes="paneicon" Command="{Binding RefreshRemoteCommand}"
|
||||
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Refresh">
|
||||
@@ -545,7 +546,7 @@
|
||||
</Button>
|
||||
<Button Classes="paneicon" Command="{Binding RenameRemoteCommand}"
|
||||
IsEnabled="{Binding CanRenameRemote}" ToolTip.Tip="Rename on the host">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="" />
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="" />
|
||||
</Button>
|
||||
<Button Classes="paneicon" Command="{Binding MoveRemoteCommand}"
|
||||
IsEnabled="{Binding CanMoveRemote}" ToolTip.Tip="Move to another directory">
|
||||
|
||||
@@ -139,41 +139,20 @@ 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.
|
||||
//
|
||||
// â—† 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)
|
||||
var client = new SftpClient(BuildConnectionInfo(request))
|
||||
{
|
||||
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.
|
||||
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;
|
||||
var gate = await ConnectThroughHostKeyGateAsync(client, request, progress: null, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// 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
|
||||
@@ -193,24 +172,6 @@ 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>
|
||||
|
||||
@@ -114,6 +114,13 @@ public sealed partial class ShellFlowTests
|
||||
|
||||
await EventuallyAsync(() => !tab.IsLive, "the dropped shell should have ended the session");
|
||||
|
||||
// Waited for in its own right rather than taken as read from IsLive. Ended clears IsLive and then
|
||||
// ends the forwards in the same dispatcher turn, which the app's UI thread never splits — but this
|
||||
// harness's post runs the ending on the fake shell's thread, so a poll from here can land between.
|
||||
await EventuallyAsync(
|
||||
() => tab.Forwards.All(row => row.Detail == "Stopped with the session."),
|
||||
"the session's end should have reached its forwards");
|
||||
|
||||
tab.Forwards.ShouldAllBe(row => row.State == PortForwardState.Stopped);
|
||||
tab.Forwards.ShouldAllBe(row => row.Detail == "Stopped with the session.");
|
||||
tab.Forwards.ShouldAllBe(row => !row.CanToggle);
|
||||
|
||||
@@ -1363,6 +1363,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.
|
||||
|
||||
@@ -211,31 +211,6 @@ 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,17 +42,6 @@ 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>
|
||||
@@ -76,9 +65,6 @@ 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;
|
||||
|
||||
@@ -97,7 +83,6 @@ 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
|
||||
@@ -191,10 +176,6 @@ 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()
|
||||
{
|
||||
@@ -204,10 +185,6 @@ public sealed class SshServerFixture : IAsyncLifetime
|
||||
"sed -i 's/^AllowTcpForwarding no/AllowTcpForwarding yes/' /config/sshd/sshd_config"
|
||||
+ " && sed -i 's/^#*MaxStartups .*/MaxStartups 200/' /config/sshd/sshd_config"
|
||||
+ " && printf '\\nPerSourcePenalties no\\n' >> /config/sshd/sshd_config"
|
||||
+ $" && sed -i 's/^Port {SshPort}$/Port {SshPort}\\nPort {ChattySshPort}/' /config/sshd/sshd_config"
|
||||
+ $" && printf '\\nMatch LocalPort {ChattySshPort}\\n"
|
||||
+ " ForceCommand /bin/sh -c \"echo Welcome to the test server; exec /usr/lib/ssh/sftp-server\"\\n'"
|
||||
+ " >> /config/sshd/sshd_config"
|
||||
+ " && pkill -HUP sshd",
|
||||
]);
|
||||
|
||||
|
||||
Reference in new issue
Block a user