Compare commits

...
Author SHA1 Message Date
laurensandClaude Opus 5.5 88bd924d83 Merge claude/linux-update-check: offer an RPM only to a copy rpm owns, and never read an unknown version as older
ci / build and test (push) Successful in 3m43s
ci / api image (push) Successful in 9s
ci / deploy ssh.dodotech.cloud (push) Successful in 12s
ci / android head (push) Successful in 4m15s
ci / desktop nightly (push) Successful in 2m10s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:47:01 +02:00
laurensandClaude Opus 5.5 e0509c6679 Updates: offer an RPM only to a copy rpm owns, and never read an unknown version as older
On a distribution without rpm, a locally built package that mirrors the spec puts the nightly tree
at /opt/DodoSSH.Nightly, and that path was the whole test for "the RPM installed this". The channel
then asked rpm for the installed version, got nothing because there is no rpm, read the silence as
"0", and offered every nightly as newer, fetching a 56 MB RPM into the profile directory that the
machine could never install, every six hours.

UpdateChannels.LinuxChannel still checks the path first, then asks rpm through the new
LinuxUpdateChannel.ForInstall. A copy rpm has no record of gets an UnavailableUpdateChannel that
says it was installed outside the dodossh-nightly (or dodossh) RPM and should be updated with the
package manager that installed it. UnavailableUpdateChannel gains an optional Reason for that,
UpdateViewModel exposes it as UnsupportedReason, and the desktop preferences page shows it in place
of the sentence about checkouts and unzipped copies. That costs one rpm launch at start, and only
where the path already matched.

CheckAsync no longer falls back to "0". If rpm stops answering after composition, the check throws
instead, so CHECK NOW reports it and the timer stays quiet, and nothing is offered or fetched. The
comment that called the fallback a deliberate choice is replaced with the reason it was wrong.

LinuxUpdateChannelTests: an unknown installed version throws and downloads nothing (replacing the
test that asserted the old behaviour), a copy rpm does not own is unavailable and says why, and a
copy rpm owns gets the channel. The newer, equal and older comparisons were already covered.
UpdateFlowTests covers the reason reaching the view model. Manual check 19.13.

App tests 744/752 with 8 skips, layout tests 202/203 with 1 skip; the full solution passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:39:17 +02:00
job f7354aa7e8 Merge pull request 'SFTP: stop waiting for ever on a host whose login prints text, and draw the file panes' icons whole' (#25) from claude/sftp-connection-icons-5ad752 into main
ci / build and test (push) Successful in 3m27s
ci / api image (push) Successful in 7s
ci / deploy ssh.dodotech.cloud (push) Successful in 13s
ci / android head (push) Successful in 3m54s
ci / desktop nightly (push) Successful in 2m18s
Reviewed-on: #25
2026-10-19 09:15:24 +00:00
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 36b6ded63e Merge pull request 'Connections: drop the manual box's view-model state now that both heads ask through the prompt' (#24) from claude/charming-cerf-ae57b6 into main
ci / build and test (push) Successful in 3m46s
ci / api image (push) Successful in 10s
ci / deploy ssh.dodotech.cloud (push) Successful in 12s
ci / android head (push) Successful in 4m28s
ci / desktop nightly (push) Successful in 3m27s
Reviewed-on: #24
2026-10-16 12:32:14 +00:00
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
jaap-jan 089de72834 Merge pull request 'Tests: wait for a dropped session's forwards to stop, not just for the tab to go dead' (#23) from claude/elastic-mccarthy-6b4ae7 into main
ci / android head (push) Successful in 3m50s
ci / desktop nightly (push) Canceled after 0s
ci / api image (push) Canceled after 0s
ci / deploy ssh.dodotech.cloud (push) Canceled after 0s
ci / build and test (push) Canceled after 4m25s
Reviewed-on: #23
2026-10-16 12:04:41 +00: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
28 changed files with 1852 additions and 424 deletions

No files matched your search

+56 -7
View File
@@ -1404,6 +1404,23 @@ MACHINE box, which takes the same path — `VaultViewModel.ConnectToEndpointAsyn
the two is in how the palette hands the password over, not in the connection. The headless half of this is
`QuickConnectTests`; what it cannot do is dial.
### 7.10b Pressing Terminal with nothing open asks what to connect to
Close every terminal tab, then press **Terminal** on the rail.
**Pass:** the terminal's frame holds a prompt — `connect to:` with the caret already on it, so typing works
without a click. Type part of a saved host's name: the matching hosts appear under the line, Tab completes to
the first, and Enter connects to it — asking `user@host:port's password:` first only if that host has no key
or saved password. Type `user@host` for a machine that is not saved instead: Enter asks for its password, and
the second Enter opens a tab for it. Escape steps back from the password to the name. Afterwards HOSTS has
nothing new on it, and no credential was saved — even with "remember" ticked in a host's drawer earlier.
**Failure means:** a prompt that needs a click before it takes typing is the terminal's WebView still holding
the native keyboard focus — see `NativeKeyboardFocus`, which `ConnectPrompt.TakeKeyboard` calls. Anything drawn
*under* a blank rectangle is the occlusion rule broken: the prompt may only show while no tab exists. A new
credential in KEYS after connecting is the prompt's attempt acting on the drawer's tick, which
`ShellFlowTests.ThePrompt_NeverSavesAPassword_EvenWithTheDrawersTickLeftOn` exists to stop.
### 7.11 A host or a bucket made now can be picked on FILES now
With the files screen's picker open on an empty or short list, go to HOSTS and add a host — or to KEYS and
@@ -2082,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
@@ -2318,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
@@ -3502,3 +3538,16 @@ Register a device, sync something, then `sudo dnf remove dodossh`.
make an uninstall delete a user's un-synced work — the Linux spelling of the mistake ADR 0013 decision 2
exists to prevent. Files left behind under `/opt/DodoSSH` are files the package did not own, which means
something wrote into the install directory at runtime and would survive a reinstall.
### 19.13 A copy at the same path that rpm does not own is offered nothing
On a distribution without rpm — Arch, say, with a locally built package that puts the nightly tree at
`/opt/DodoSSH.Nightly` — open PREFERENCES → UPDATES.
**Pass:** **CHECK NOW** is disabled and the row says this copy was installed outside the `dodossh-nightly`
RPM and should be updated with the package manager that installed it. `~/.local/share/dodossh.nightly/updates/`
stays empty however long it runs.
**Failure means:** a banner offering a nightly is the path being taken as proof the RPM is installed, or an
rpm that cannot answer being read as an old version — the fault this check was written for, which offered
every nightly as newer and fetched a 56 MB package the machine could never install, every six hours.
@@ -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">
+27
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}" />
@@ -127,17 +127,61 @@ internal sealed class LinuxUpdateChannel : IUpdateChannel
this.installedVersion = installedVersion;
this.handToInstaller = handToInstaller;
packageName = string.Equals(channel, "nightly", StringComparison.Ordinal)
? "dodossh-nightly"
: "dodossh";
packageName = PackageFor(channel);
}
/// <summary>The channel for a copy under the RPM's install root, if rpm says the package is there.</summary>
internal static IUpdateChannel ForInstall(string channel) =>
ForInstall(channel, InstalledRpmVersion, () => new LinuxUpdateChannel(channel));
/// <summary>
/// The channel for a copy under the RPM's install root, with rpm supplied, which is how the tests
/// reach it.
/// </summary>
/// <remarks>
/// <para>
/// <b>The path says where this copy is and only rpm says who put it there.</b>
/// <c>UpdateChannels.LinuxChannel</c> has already established the first, and on an RPM distribution
/// that is the whole story. It is not on any other: a locally built pacman or dpkg package that mirrors
/// the spec puts the same tree at the same path, and this channel then offered every nightly as newer
/// than a version nobody could read, fetched a package that machine has no way to install, and did it
/// again every six hours.
/// </para>
/// <para>
/// Asked once, at composition, for the reason the rest of this decision is: who installed this copy
/// does not change while it runs. It costs one <c>rpm</c> launch at start on a machine whose path
/// already matched, and nothing at all anywhere else.
/// </para>
/// </remarks>
/// <param name="channel">The build's channel, which names the package.</param>
/// <param name="installedVersion">What rpm says is installed under a package name, or null.</param>
/// <param name="whenOwned">The channel to use when rpm does own this copy.</param>
internal static IUpdateChannel ForInstall(
string channel,
Func<string, string?> installedVersion,
Func<IUpdateChannel> whenOwned)
{
ArgumentException.ThrowIfNullOrEmpty(channel);
ArgumentNullException.ThrowIfNull(installedVersion);
ArgumentNullException.ThrowIfNull(whenOwned);
var package = PackageFor(channel);
return installedVersion(package) is null
? new UnavailableUpdateChannel(
$"This copy of DodoSSH was installed outside the {package} RPM — rpm has no record of that "
+ "package here — and an RPM is the only kind of update it can fetch. Update it with the "
+ "package manager that installed it.")
: whenOwned();
}
/// <inheritdoc />
/// <remarks>
/// True unconditionally, because whether this copy is one that can be updated was already decided by
/// <see cref="UpdateChannels.ForThisMachine"/> — a build run from a checkout never reaches this class
/// at all. The same split Velopack's side uses, where <c>IsInstalled</c> is checked once at
/// composition rather than at every call site.
/// <see cref="UpdateChannels.ForThisMachine"/> and <see cref="ForInstall(string)"/> — a build run from
/// a checkout, or installed by something other than rpm, never reaches this class at all. The same
/// split Velopack's side uses, where <c>IsInstalled</c> is checked once at composition rather than at
/// every call site.
/// </remarks>
public bool IsSupported => true;
@@ -184,9 +228,20 @@ internal sealed class LinuxUpdateChannel : IUpdateChannel
$"The {channel} release's {manifestName} could not be read as a manifest.");
}
// â—† No answer from rpm is a failure, never a version. ForInstall only built this class because
// rpm answered, so silence now is a package removed under a running process or an rpm that
// stopped working — and any stand-in number would make every published build look newer and
// fetch a package for a machine that cannot install it, which is what this once did.
if (installedVersion(packageName) is not { } installed)
{
throw new InvalidOperationException(
$"rpm has no record of {packageName}, so there is no installed version to compare the "
+ $"{channel} release against.");
}
// The one place null is returned, and it means what null is documented to mean: this build is
// current. Compared the way rpm compares, because rpm is what accepts or refuses the install.
if (RpmVersion.Compare(manifest.RpmVersion, installedVersion(packageName) ?? "0") <= 0)
if (RpmVersion.Compare(manifest.RpmVersion, installed) <= 0)
{
return null;
}
@@ -324,6 +379,12 @@ internal sealed class LinuxUpdateChannel : IUpdateChannel
? $"{RepositoryApi}/releases/tags/nightly-desktop"
: $"{RepositoryApi}/releases/latest";
/// <summary>The package a channel's builds are published as.</summary>
private static string PackageFor(string channel) =>
string.Equals(channel, "nightly", StringComparison.Ordinal)
? "dodossh-nightly"
: "dodossh";
private static string? Asset(LinuxForgeRelease? release, string name) =>
release?.Assets?.FirstOrDefault(
asset => string.Equals(asset.Name, name, StringComparison.Ordinal))?.DownloadUrl;
@@ -365,9 +426,11 @@ internal sealed class LinuxUpdateChannel : IUpdateChannel
/// <c>scripts/ci-linux-rpm.sh</c> performs, and two copies of one rule is a pair that can disagree.
/// </para>
/// <para>
/// A machine that cannot answer gets null, which the caller reads as <c>0</c> and makes every
/// published build look newer. That is the wrong way to fail, chosen deliberately: the failure is then
/// an install PackageKit declines rather than a security fix that never arrives.
/// A machine that cannot answer gets null — no <c>rpm</c> at all, or one with no such package — and
/// null means "rpm does not own this copy", never a version. It used to be read as <c>0</c>, on the
/// argument that the worst case was an install PackageKit declined. That holds only where PackageKit
/// installs RPMs: on any other distribution it made every published build look newer, forever, and
/// fetched a package that machine could not install. See <see cref="ForInstall(string)"/>.
/// </para>
/// </remarks>
private static string? InstalledRpmVersion(string packageName)
@@ -66,21 +66,23 @@ internal static class UpdateChannels
}
}
/// <summary>The Linux channel, if this copy is one a package manager put here.</summary>
/// <summary>The Linux channel, if this copy is one the RPM put here.</summary>
/// <remarks>
/// <para>
/// <b>The question Velopack answers with <c>IsInstalled</c>, answered by looking at where this process
/// lives.</b> The RPM owns <c>/opt/DodoSSH</c> or <c>/opt/DodoSSH.Nightly</c> — the spec puts it there
/// and nothing else writes to <c>/opt</c> under those names — so a build running from anywhere else is
/// a checkout somebody started, an extracted archive, or a tree copied by hand. None of those can be
/// updated by installing a package over them, and offering it would be an offer that cannot be carried
/// out.
/// lives and then asking rpm.</b> The RPM owns <c>/opt/DodoSSH</c> or <c>/opt/DodoSSH.Nightly</c> — the
/// spec puts it there — so a build running from anywhere else is a checkout somebody started, an
/// extracted archive, or a tree copied by hand. None of those can be updated by installing a package
/// over them, and offering it would be an offer that cannot be carried out.
/// </para>
/// <para>
/// Checked by path rather than by asking rpm which package owns the executable, which would be the
/// more thorough answer and costs a process launch on every start for a distinction nothing acts on
/// differently. The install root is a constant in two places — here and the packaging script — and
/// they are named in each other's comments.
/// <b>The path is necessary and not sufficient.</b> It used to be the whole check, on the argument
/// that asking rpm cost a process launch for a distinction nothing acted on differently. Something
/// does: a locally built pacman package that mirrors the spec puts the same tree at the same path,
/// and that copy was offered every nightly as an RPM it could never install. So a matching path is
/// then put to rpm — see <see cref="LinuxUpdateChannel.ForInstall(string)"/> — and the launch is paid
/// only where the path already matched. The install root is a constant in two places — here and the
/// packaging script — and they are named in each other's comments.
/// </para>
/// </remarks>
[SupportedOSPlatform("linux")]
@@ -91,7 +93,7 @@ internal static class UpdateChannels
var here = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar);
return string.Equals(here, root, StringComparison.Ordinal)
? new LinuxUpdateChannel(DesktopChannel.Name)
? LinuxUpdateChannel.ForInstall(DesktopChannel.Name)
: new UnavailableUpdateChannel();
}
}
@@ -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.
@@ -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}">
@@ -84,7 +84,10 @@
IsVisible="{Binding Updates.Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<TextBlock Classes="settingsrowcaption"
Text="This copy of DodoSSH cannot replace itself, so nothing above does anything. That is what a build run from a source checkout looks like, and also what a copy somebody unzipped by hand looks like — it is the installer that registers the update path."
IsVisible="{Binding Updates.IsUnsupported}" />
IsVisible="{Binding Updates.IsUnsupportedForTheUsualReason}" />
<TextBlock Classes="settingsrowcaption"
Text="{Binding Updates.UnsupportedReason}"
IsVisible="{Binding Updates.UnsupportedReason, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
</Border>
@@ -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">
@@ -141,9 +141,32 @@ public interface IUpdateChannel
/// <see cref="UnavailableDeviceKeyStore"/>: the caller then has one shape to write against, and "no
/// updater here" is a thing the interface can express rather than a case every call site has to remember.
/// </para>
/// <para>
/// <b>One situation outside those four carries a <see cref="Reason"/>.</b> A Linux copy sitting exactly
/// where the RPM puts it that no RPM owns — built locally and installed by another distribution's package
/// manager. It looks installed, so the sentence about checkouts and unzipped copies would describe the
/// wrong machine, and the person reading it needs telling which updater is theirs.
/// </para>
/// </remarks>
public sealed class UnavailableUpdateChannel : IUpdateChannel
{
/// <summary>Creates one for the situations that need no telling apart.</summary>
public UnavailableUpdateChannel()
{
}
/// <summary>Creates one whose situation is worth a sentence of its own.</summary>
/// <param name="reason">What to tell the user, as a sentence they can act on.</param>
public UnavailableUpdateChannel(string reason)
{
ArgumentException.ThrowIfNullOrWhiteSpace(reason);
Reason = reason;
}
/// <summary>Why this copy cannot replace itself, where that is not the usual why.</summary>
public string? Reason { get; }
/// <inheritdoc />
public bool IsSupported => false;
@@ -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;
@@ -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 — both
/// heads draw the connect prompt there; see <see cref="IsConnectPromptShowing"/> — which is worth being
/// able to reach deliberately.
/// </para>
/// </remarks>
[RelayCommand]
@@ -1948,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>
@@ -2365,7 +2312,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 +2387,343 @@ 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>
/// <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)
{
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>
@@ -2478,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
@@ -2555,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>
@@ -2672,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.
@@ -2827,16 +3115,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
var query = SearchText.Trim();
var matches = query.Length == 0
? vault.Hosts.AsEnumerable()
: vault.Hosts
.Select(host => (host, rank: Rank(host, query)))
.Where(candidate => candidate.rank < int.MaxValue)
.OrderBy(candidate => candidate.rank)
.ThenBy(candidate => candidate.host.Label, StringComparer.CurrentCulture)
.Select(candidate => candidate.host);
foreach (var host in matches.Take(8))
foreach (var host in HostsMatching(vault, query).Take(8))
{
SearchResults.Add(host);
}
@@ -2866,6 +3145,21 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(HasNothingToOffer));
}
/// <summary>The vault's hosts that match what was typed, best first; every host for an empty query.</summary>
/// <remarks>
/// Shared by the palette and the empty terminal's prompt, so a name typed into either finds the same
/// machines in the same order.
/// </remarks>
private static IEnumerable<HostRowViewModel> HostsMatching(VaultViewModel vault, string query) =>
query.Length == 0
? vault.Hosts
: vault.Hosts
.Select(host => (host, rank: Rank(host, query)))
.Where(candidate => candidate.rank < int.MaxValue)
.OrderBy(candidate => candidate.rank)
.ThenBy(candidate => candidate.host.Label, StringComparer.CurrentCulture)
.Select(candidate => candidate.host);
/// <summary>Where one host places against what was typed, or <see cref="int.MaxValue"/> for no match.</summary>
/// <remarks>
/// <para>
@@ -5906,6 +6200,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
@@ -126,6 +126,7 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
this.restart = restart;
CurrentVersion = updates.CurrentVersion;
UnsupportedReason = (updates as UnavailableUpdateChannel)?.Reason;
isAutomatic = settings.Read().AutomaticUpdateChecks;
state = updates.IsSupported ? UpdateState.Idle : UpdateState.Unsupported;
}
@@ -133,6 +134,16 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
/// <summary>What this build calls itself.</summary>
internal string CurrentVersion { get; }
/// <summary>Why this copy cannot replace itself, when the channel had something particular to say.</summary>
/// <remarks>
/// Null in the ordinary cases, where the preferences screen's own sentence about checkouts and unzipped
/// copies is the right one. See <see cref="UnavailableUpdateChannel"/> for the case where it is not.
/// </remarks>
internal string? UnsupportedReason { get; }
/// <summary>Unsupported, with nothing more particular to say than the screen's own sentence.</summary>
internal bool IsUnsupportedForTheUsualReason => IsUnsupported && UnsupportedReason is null;
[ObservableProperty]
private UpdateState state;
@@ -248,6 +259,7 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
{
OnPropertyChanged(nameof(IsSupported));
OnPropertyChanged(nameof(IsUnsupported));
OnPropertyChanged(nameof(IsUnsupportedForTheUsualReason));
OnPropertyChanged(nameof(IsChecking));
OnPropertyChanged(nameof(IsDownloading));
OnPropertyChanged(nameof(IsReady));
@@ -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;
@@ -4378,9 +4350,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.
@@ -7808,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>
@@ -11460,11 +11410,41 @@ internal sealed partial class VaultViewModel(
return;
}
await ConnectToHostAsync(row, ConnectPassword, mayRememberPassword: true, cancellationToken)
.ConfigureAwait(true);
}
/// <summary>
/// Opens a terminal on a keychain host, with a password typed for it if it needs one.
/// </summary>
/// <remarks>
/// <para>
/// The body of <see cref="ConnectToSelectedHostAsync"/>, taking the host and the password as arguments so
/// the desktop's terminal prompt can name a host without selecting it and type a password without writing
/// it into <see cref="ConnectPassword"/> — which is the drawer's box, and would carry a remember-this
/// choice the prompt never offered. Every refusal is still made here, once.
/// </para>
/// <para>
/// The password is ignored for a host whose binding supplies its own credential, as the drawer's box is.
/// </para>
/// </remarks>
/// <param name="row">The host to connect to.</param>
/// <param name="password">The password typed for it, or empty.</param>
/// <param name="mayRememberPassword">See <see cref="ConnectionTarget.MayRememberPassword"/>.</param>
/// <param name="cancellationToken">Whatever the caller's own lifetime is.</param>
internal async Task ConnectToHostAsync(
HostRowViewModel row,
string password,
bool mayRememberPassword,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(row);
// Refused rather than quietly falling back to the password box. A host set up for key-only access
// that silently starts offering a password is the failure worth ruling out — the user asked for one
// thing and got another, and the host is the last place that would say so.
if (!TryBuildAuthentication(
row.Host, row.Resolved, ConnectPassword, out var authentication, out var refusal))
row.Host, row.Resolved, password, out var authentication, out var refusal))
{
Status = refusal;
return;
@@ -11477,67 +11457,23 @@ 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);
}
/// <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>
@@ -11607,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(
@@ -12328,10 +12277,10 @@ 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.Row is { } row)
if (target is { Row: { } row, MayRememberPassword: true })
{
await RememberTypedPasswordAsync(row, authentication, cancellationToken).ConfigureAwait(true);
}
@@ -12618,6 +12567,12 @@ internal sealed partial class VaultViewModel(
/// <param name="Row">
/// The keychain host this came from, or null for a machine that is not in the keychain.
/// </param>
/// <param name="MayRememberPassword">
/// Whether <see cref="RemembersConnectPassword"/> applies to this attempt. That tick belongs to the
/// drawer's password box, so only an attempt whose password came out of that box may act on it — a
/// password typed into the desktop's terminal prompt, which offers no such choice, must not be saved
/// because the tick happened to be left on in a drawer that is not even open.
/// </param>
/// <remarks>
/// <para>
/// This exists so the connect path stops being shaped like <see cref="HostRowViewModel"/>. Everything
@@ -12638,7 +12593,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;
@@ -12648,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);
}
@@ -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);
}
}
@@ -86,19 +86,86 @@ public sealed class LinuxUpdateChannelTests : IDisposable
}
/// <summary>
/// A machine that cannot say what it has treats everything as newer.
/// A machine that cannot say what it has is told so, and is offered nothing.
/// </summary>
/// <remarks>
/// The wrong way to fail, chosen deliberately and for the reason the android channel gives: the cost is
/// an install the package manager declines, and the cost of the other direction is a security fix that
/// never arrives.
/// This used to read the silence as <c>0</c> and offer everything, on the argument that the cost was
/// an install the package manager declined. On a machine with no rpm at all — a pacman package of the
/// same tree at the same path — that was every nightly, every six hours, as a 56 MB download nothing
/// could install. Unknown is not older.
/// </remarks>
[Fact]
public async Task AnUnknownInstalledVersionTreatsEverythingAsNewer()
public async Task AnUnknownInstalledVersionThrowsRatherThanOfferingAnything()
{
var channel = Channel(RandomNumberGenerator.GetBytes(16), published: "0.0.1~alpha.0.1-1", installed: null);
var handed = new List<string>();
var channel = Channel(
RandomNumberGenerator.GetBytes(16),
published: "0.0.1~alpha.0.200-1",
installed: null,
handToInstaller: handed.Add);
(await channel.CheckAsync(Token)).ShouldNotBeNull();
await Should.ThrowAsync<InvalidOperationException>(async () => await channel.CheckAsync(Token));
await Should.ThrowAsync<InvalidOperationException>(
async () => await channel.DownloadAsync(
new DodoSSH.Client.Session.AvailableUpdate("0.0.1-alpha.0.200"),
new Progress<int>(),
Token));
handed.ShouldBeEmpty();
Directory.Exists(Path.Combine(directory, "updates")).ShouldBeFalse();
}
/// <summary>
/// A copy rpm does not own gets no channel at all, and a sentence saying whose it is to update.
/// </summary>
/// <remarks>
/// The case that matters: the right path and no rpm — a locally built pacman package that mirrors the
/// spec. No binary and no such package both arrive here as null.
/// </remarks>
[Fact]
public void ACopyRpmDoesNotOwn_IsUnavailableAndSaysWhy()
{
var asked = new List<string>();
var updates = LinuxUpdateChannel.ForInstall(
"nightly",
package =>
{
asked.Add(package);
return null;
},
() => throw new InvalidOperationException("Built a channel for a copy rpm does not own."));
asked.ShouldBe(["dodossh-nightly"]);
updates.IsSupported.ShouldBeFalse();
var reason = updates.ShouldBeOfType<DodoSSH.Client.Session.UnavailableUpdateChannel>().Reason;
reason.ShouldNotBeNull();
reason.ShouldContain("outside the dodossh-nightly RPM");
reason.ShouldContain("package manager that installed it");
}
[Fact]
public void ACopyRpmOwns_GetsTheChannel()
{
var asked = new List<string>();
var owned = new FakeUpdateChannel();
var updates = LinuxUpdateChannel.ForInstall(
"release",
package =>
{
asked.Add(package);
return "0.0.1-1";
},
() => owned);
asked.ShouldBe(["dodossh"]);
updates.ShouldBeSameAs(owned);
}
[Fact]
@@ -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();
+310 -99
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()
@@ -1223,6 +1205,254 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
vault.FilterAddressRefusal.ShouldContain("between 1 and 65535");
}
// ---- The empty terminal's prompt ----
/// <remarks>
/// Pressing Terminal with nothing open shows the prompt, and a tab opening takes it away: the two share the
/// renderer's rectangle, and the prompt is only allowed in it because they are exclusive.
/// </remarks>
[Fact]
public async Task ThePrompt_ShowsOnTheTerminalWithNothingOpen_AndGoesWhenATabOpens()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
shell.IsConnectPromptShowing.ShouldBeFalse("the hosts screen is showing, not the terminal");
shell.ShowTerminalCommand.Execute(null);
shell.IsConnectPromptShowing.ShouldBeTrue();
shell.PromptText = "deploy@build.internal";
await shell.SubmitPromptCommand.ExecuteAsync(null);
shell.PromptPassword = "hunter2";
await shell.SubmitPromptCommand.ExecuteAsync(null);
shell.HasTabs.ShouldBeTrue();
shell.IsConnectPromptShowing.ShouldBeFalse("the session's tab has the rectangle now");
}
/// <remarks>
/// A saved host by name — case aside, as a person types it — whose binding wants a typed password: the
/// prompt asks for it in ssh's own words and dials the host with it.
/// </remarks>
[Fact]
public async Task ThePrompt_ConnectsToASavedHostByName_AskingForItsPasswordFirst()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
shell.ShowTerminalCommand.Execute(null);
shell.PromptText = "PROD-DB";
await shell.SubmitPromptCommand.ExecuteAsync(null);
ssh.Requests.ShouldBeEmpty("nothing is dialled before the password is typed");
shell.IsPromptAskingForPassword.ShouldBeTrue();
shell.PromptPasswordLabel.ShouldBe($"{vault.Hosts[0].Address}'s password:");
shell.PromptPassword = "s3cret";
await shell.SubmitPromptCommand.ExecuteAsync(null);
ssh.Requests.ShouldHaveSingleItem().Credential
.ShouldBeOfType<SshPasswordCredential>().Password.ShouldBe("s3cret");
shell.Tabs.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
shell.PromptText.ShouldBeEmpty();
shell.PromptPassword.ShouldBeEmpty("the password does not wait in the box");
shell.IsPromptAskingForPassword.ShouldBeFalse();
}
/// <remarks>
/// <b>The failure the prompt could most easily cause.</b> The drawer's remember-this tick is read when a
/// connection succeeds, and the prompt goes through the same connect path — so without the attempt
/// carrying its own answer, a tick left on in a drawer that is not even open would save a password typed
/// at a prompt that never offered to save anything.
/// </remarks>
[Fact]
public async Task ThePrompt_NeverSavesAPassword_EvenWithTheDrawersTickLeftOn()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.RemembersConnectPassword = true;
shell.ShowTerminalCommand.Execute(null);
shell.PromptText = "prod-db";
await shell.SubmitPromptCommand.ExecuteAsync(null);
shell.PromptPassword = "s3cret";
await shell.SubmitPromptCommand.ExecuteAsync(null);
ssh.Requests.ShouldHaveSingleItem();
shell.HasTabs.ShouldBeTrue("the connection succeeded, which is when remembering would happen");
vault.Credentials.ShouldBeEmpty();
vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBeNull();
}
/// <remarks>
/// An address the keychain does not hold is dialled as typed, with the typed password, and the keychain
/// is left exactly as it was.
/// </remarks>
[Fact]
public async Task ThePrompt_ConnectsToATypedAddress_AndSavesNothing()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
shell.ShowTerminalCommand.Execute(null);
shell.PromptText = "deploy@build.internal:2222";
await shell.SubmitPromptCommand.ExecuteAsync(null);
shell.PromptPasswordLabel.ShouldBe("deploy@build.internal:2222's password:");
shell.PromptPassword = "hunter2";
await shell.SubmitPromptCommand.ExecuteAsync(null);
var request = ssh.Requests.ShouldHaveSingleItem();
request.Host.ShouldBe("build.internal");
request.Port.ShouldBe(2222);
request.Username.ShouldBe("deploy");
vault.Hosts.ShouldHaveSingleItem("connecting without saving saves nothing");
vault.Credentials.ShouldBeEmpty();
}
/// <remarks>
/// Enter on a name is a decision rather than a search, so a partial name is refused — but the refusal
/// names the nearest host, and Tab takes it.
/// </remarks>
[Fact]
public async Task ThePrompt_RefusesAPartialName_AndTabCompletesIt()
{
await ReadyToConnectAsync();
shell.ShowTerminalCommand.Execute(null);
shell.PromptText = "prod";
shell.PromptMatches.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
await shell.SubmitPromptCommand.ExecuteAsync(null);
shell.PromptRefusal.ShouldContain("Tab");
ssh.Requests.ShouldBeEmpty();
shell.CompletePrompt().ShouldBeTrue();
shell.PromptText.ShouldBe("prod-db");
shell.HasPromptRefusal.ShouldBeFalse("typing again clears a refusal about what was typed");
}
/// <remarks>
/// 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.
/// </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: 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()
{
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:");
shell.PromptPassword.ShouldBeEmpty("the log never held one");
ssh.Requests.ShouldBeEmpty();
}
/// <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.
@@ -1242,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.
@@ -1259,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();
@@ -1277,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>
@@ -1324,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>
@@ -3356,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()
@@ -3366,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();
}
@@ -8349,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");
@@ -11156,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()
{
@@ -80,6 +80,32 @@ public sealed class UpdateFlowTests : IDisposable
Build().CurrentVersion.ShouldBe("0.4.2");
}
[Fact]
public void ABuildThatCannotReplaceItself_ForAReasonOfItsOwn_SaysThatInstead()
{
var updates = new UpdateViewModel(
new UnavailableUpdateChannel("Update it with the package manager that installed it."),
new ClientSettingsStore(Paths),
TimeProvider.System,
() => 0,
restart: _ => Task.CompletedTask);
updates.IsUnsupported.ShouldBeTrue();
updates.UnsupportedReason.ShouldBe("Update it with the package manager that installed it.");
updates.IsUnsupportedForTheUsualReason.ShouldBeFalse();
}
[Fact]
public void ABuildThatCannotReplaceItself_ForTheUsualReasons_LeavesTheScreenItsOwnSentence()
{
channel.IsSupported = false;
var updates = Build();
updates.UnsupportedReason.ShouldBeNull();
updates.IsUnsupportedForTheUsualReason.ShouldBeTrue();
}
[Fact]
public async Task ABackgroundPassThatFindsNothing_SaysNothing()
{
@@ -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",
]);