Make Connections the place a connection is made, and put the keychain away

Four changes to the phone, and the last one needed the connect path taking
apart.

**The bottom bar is three entries.** The keychain moved onto the hub, which is
now SETTINGS with a gear rather than MORE with a hamburger. A bottom bar is for
the places a session moves between, and keys, credentials and tags are managed
occasionally and then left alone — which is the shape of everything already
behind that hub. With the keychain on it, "more" stopped being a description of
what is there. `ShellScreen.Vault` joining `IsMoreSurface` is the whole of the
change: the tab that lights, the header that stands down and the back gesture's
first case all read that one property, which is why the switch mirrors it by
construction rather than by a second list. The keychain screen grew the header
every hub screen has, because the shell's own is not above it any more and
without one there would be no back arrow and nothing saying what the list is.

The desktop keeps its Keychain rail entry. A rail with nine slots has room, so
this is the second thing the two heads arrange deliberately differently, after
the hub itself.

**Terminal became Connections**, and the word does more work than a rename
usually does — see below. The enum member stays `ShellSurface.Terminal`, for
the reason the tab was never called Vault: the surface is a terminal, and the
word a user reads is the product's.

**The + puts the software keyboard away.** It sits above a terminal somebody is
typing into, so the sheet it raises was arriving underneath a keyboard covering
the half of the screen the sheet is on — and worse, laid out into the strip
left above it, since the keyboard's inset shortens everything this head draws.
Avalonia cannot do this and it is worth knowing why: `TopLevel.InputPane`
reports the keyboard and offers nothing that closes one, because the framework's
model is that it belongs to whatever has focus — and this keyboard was raised by
the `WebView`'s own text input, by a native view Avalonia's focus manager never
owned. Clearing Avalonia's focus leaves it exactly where it is. So
`Platform/SoftKeyboard.cs` asks `InputMethodManager`, off the decor view's
window token, and every step of it is allowed to be absent.

**With nothing open, Connections is a connect screen rather than an empty
state.** A box taking `user@host` or `user@host:port`, a password, and the
machines most recently connected to underneath. The box is the only path in this
product to a machine the keychain has never heard of, which is a real case it
had no answer for: an address somebody was handed five minutes ago. A typed
password and nothing else — offering the keychain's keys would be a second
binding resolution beside `TryBuildAuthentication`, and the argument against a
second one is written there at length. Nothing typed is saved, and the screen
says so: a machine worth keeping belongs on HOSTS, where it can carry a key, a
group's defaults and a name.

The recents come out of the vault's own connection log rather than a list kept
in this process, so they survive a restart and arrive on a new phone with the
keychain. Deduplicated by address, because this is a list of places and not of
events, and capped at six so the box stays above the keyboard. Emptied when the
vault is — they are decrypted entries naming where somebody works, and a lock
that left them on screen would be a list still readable after every key that
decrypted it was zeroed. Tapping one leads to whichever of two things it is: a
keychain host goes to that host's connect bar, where its key, its password box
and its refusals already live, and an address goes back into the box, without
the password, whose absence is the point of that path rather than a gap in it.

**The connect path was shaped like `HostRowViewModel` all the way down.** The
log entry, the identification, the failure record and the retry all took a row.
They take a four-field `ConnectionTarget` now, so a connection to an address
shares the ladder of refusals, the host-key question and the tab's lifecycle
rather than growing a second copy of them. `ConnectionRecorder.Record` and
`Identify` have always taken a nullable host id, so the log could already hold a
connection with no item behind it.

One behavioural change falls out of that and it is the one to know about:
**trusting a host key now retries the attempt that raised the question** instead
of re-running whichever host is selected. That was correct while a selected host
was the only way to connect; with a manual target it would dial a different
machine, or refuse with "choose a host first" over a key the user has just
agreed to trust. The test selects a host first, so a regression cannot pass by
connecting to the wrong thing successfully.

`LogsViewModel.ReloadAsync` split so the connections half can be read alone.
Reading the keychain's activity for a screen that offers neither would double
the decryption on the list that was already the expensive one.

Twelve tests: the parse grammar as a theory over seven refusals, the dialled
request, the retry, and both branches of tapping a recent row. The recents rows
are built by hand rather than connected-and-closed — what those tests are about
is which branch a row takes, and driving it through the recorder's queue would
test the recorder, which `DodoSSH.Client.Session.Tests` already does. What needs
a device is phases 11.6 to 11.9 of `docs/manual-checks.md`.
This commit is contained in:
2026-08-03 15:26:47 +02:00
parent a2f0d4813a
commit f5ffd1983d
15 changed files with 1028 additions and 94 deletions
+14 -5
View File
@@ -268,11 +268,20 @@ the reasoning behind it. Sign in, unlock, browse hosts, open a shell, and read t
host-key decisions and the counted delete confirmations are there too, and none of them were softened to host-key decisions and the counted delete confirmations are there too, and none of them were softened to
fit 360dp. fit 360dp.
Its interface is the **v2 design**: four destinations in a bottom bar — Hosts, Terminal, Keychain and More Its interface is the **v2 design**: destinations in a bottom bar, with the rest one tap deeper behind the
— with snippets, SFTP, S3 buckets, logs and preferences one tap deeper behind the last. Both heads are on last. The bar is three — **Hosts**, **Connections** and **Settings** — with the keychain, snippets, SFTP,
that design now; the desktop's own v2 is a 190-pixel labelled sidebar in place of the icon rail, a centred S3 buckets, logs and preferences behind Settings. A bottom bar is for the places a session moves between,
search box in the titlebar, and session tabs as pills. Its light theme is not built — see and managing keys is not one of those. Both heads are on that design now; the desktop's own v2 is a
[`docs/design-import-gaps.md`](docs/design-import-gaps.md) — so the application is dark on both. 190-pixel labelled sidebar in place of the icon rail, a centred search box in the titlebar, and session tabs
as pills, and it keeps its Keychain entry — a rail with nine slots has room. Its light theme is not built —
see [`docs/design-import-gaps.md`](docs/design-import-gaps.md) — so the application is dark on both.
**Connections is where a connection is made, not only where one is shown.** With nothing open it offers a
box taking `user@host` or `user@host:port` and a password, and lists the machines most recently connected
to underneath. That box is the one path in the product to a machine the keychain has never heard of — the
case somebody has just been handed an address — and nothing typed into it is saved: a machine worth keeping
belongs on Hosts, where it can carry a key, a group's defaults and a name. Tapping a recent machine goes to
its host if it has one and back into the box if it does not.
**A connected phone shows one bar and then the terminal.** The header, the session strip and the bottom **A connected phone shows one bar and then the terminal.** The header, the session strip and the bottom
bar are collapsed while a shell is up, and a single 35-pixel row replaces them: back on the left, the bar are collapsed while a shell is up, and a single 35-pixel row replaces them: back on the left, the
+28 -3
View File
@@ -493,11 +493,18 @@ go at 360dp:
**Superseded by v2.** A second design — *DodoSSH Android v2* — is what this head now draws, and it took **Superseded by v2.** A second design — *DodoSSH Android v2* — is what this head now draws, and it took
the "additive rather than structural" claim at its word: snippets, logs, SFTP and S3 are built, over the the "additive rather than structural" claim at its word: snippets, logs, SFTP and S3 are built, over the
view models that were already shared, behind a MORE hub that holds the five destinations the bottom bar view models that were already shared, behind a hub that holds the destinations the bottom bar has no room
has no room for. `ShellScreen` gained `More` and `Buckets`. The palette went blue and the two heads moved for. `ShellScreen` gained `More` and `Buckets`. The palette went blue and the two heads moved together,
together, because it is shared. What v2 asked for and did not get is in because it is shared. What v2 asked for and did not get is in
[`docs/design-import-gaps.md`](design-import-gaps.md) — the short version being that port forwarding is [`docs/design-import-gaps.md`](design-import-gaps.md) — the short version being that port forwarding is
named as absent rather than drawn, because nothing in the SSH layer forwards anything. named as absent rather than drawn, because nothing in the SSH layer forwards anything.
**The bar has since gone from four entries to three.** The keychain joined the hub, which is now called
SETTINGS rather than MORE and wears a gear: a bottom bar is for the places a session moves between, and
managing keys is not one of them. `IsMoreSurface` gained `ShellScreen.Vault`, which is the whole of it —
the membership test, the tab that lights and the back gesture's first case all read that one property.
The desktop keeps its Keychain rail entry, so this is the second thing the two heads arrange
deliberately differently, after the hub itself.
8. ~~**The terminal.**~~ **Done** — one `NativeWebView` for every session, as on the desktop and for the 8. ~~**The terminal.**~~ **Done** — one `NativeWebView` for every session, as on the desktop and for the
same reason, with the tab strip scrolling horizontally rather than wrapping so that opening a tab never same reason, with the tab strip scrolling horizontally rather than wrapping so that opening a tab never
reflows a terminal while output is arriving. reflows a terminal while output is arriving.
@@ -516,6 +523,24 @@ go at 360dp:
WebView actually composites above Avalonia content is still the unverified question recorded below, and WebView actually composites above Avalonia content is still the unverified question recorded below, and
collapsing is correct under either answer. collapsing is correct under either answer.
**The + also puts the software keyboard away, and that needed the platform.** `TopLevel.InputPane`
reports the keyboard and cannot close one — Avalonia's model is that it belongs to whatever has focus, so
the supported dismissal is to move focus — and the keyboard over a terminal was raised by the `WebView`'s
own text input, by a native view Avalonia's focus manager never owned. Clearing Avalonia's focus does
nothing to it. `Platform/SoftKeyboard.cs` asks `InputMethodManager` directly, off the decor view's window
token, and every step of it is allowed to be absent. Without it the sheet arrives under a keyboard
covering the half of the screen the sheet is on — and worse, laid out into what is left, since the
keyboard's inset shortens everything this head draws.
**With nothing open the surface is a connect screen rather than an empty state**, which is why the bar
calls it Connections. It offers a `user@host[:port]` box with a password and the machines most recently
connected to, out of the vault's connection log. The connect path in `VaultViewModel` was shaped like
`HostRowViewModel` all the way down; it now hangs off a four-field `ConnectionTarget`, so the ladder of
refusals, the host-key question, the log entry and the tab's lifecycle are shared rather than copied. The
host-key retry replays the attempt that raised the question instead of re-running whichever host was
selected — which was correct while a selected host was the only way to connect, and would now dial the
wrong machine.
All eight are done for the decided scope, and v2 has since gone past it — see the note under step 7. All eight are done for the decided scope, and v2 has since gone past it — see the note under step 7.
What is left, in the order it matters: What is left, in the order it matters:
+10 -1
View File
@@ -6,10 +6,18 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> ## The second import — v2, phone > ## The second import — v2, phone
> >
> A later design in the same project, **DodoSSH Android v2**, is what the phone head now draws: eight > A later design in the same project, **DodoSSH Android v2**, is what the phone head now draws: eight
> screens, a bottom bar of four with the rest behind MORE, and a new palette. It has been imported, and > screens, a bottom bar with the rest behind a hub, and a new palette. It has been imported, and
> this section is the same accounting for it. The desktop's own v2 has **not** been imported — only its > this section is the same accounting for it. The desktop's own v2 has **not** been imported — only its
> palette, because the palette is shared and the two heads may not disagree about a colour. > palette, because the palette is shared and the two heads may not disagree about a colour.
> >
> **The bar is three rather than the design's four, and the hub is SETTINGS rather than MORE.** The design
> puts Hosts, Terminal, Keychain and More along the bottom. The keychain moved behind the hub: a bottom bar
> is for the places a session moves between, and keys, credentials and tags are managed occasionally and
> then left alone, which is the shape of everything already behind it. With the keychain on it the hub is no
> longer "more" of anything — it is where everything that is not connecting to a machine lives — so it takes
> the name and the glyph that has meant exactly that on a phone since before this application existed.
> Terminal became **Connections** in the same pass, for a reason below.
>
> **The palette went from green to blue, and one colour changed meaning rather than value.** Green used to > **The palette went from green to blue, and one colour changed meaning rather than value.** Green used to
> be the accent, so `Ellipse.dot.live` filled with `Accent` and "the thing to press" and "a shell is open > be the accent, so `Ellipse.dot.live` filled with `Accent` and "the thing to press" and "a shell is open
> here" were the same colour by construction. v2 makes the accent `#5B8CFF` and keeps a green, `#3DDC97`, > here" were the same colour by construction. v2 makes the accent `#5B8CFF` and keeps a green, `#3DDC97`,
@@ -37,6 +45,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> | A 14px rounded inset around the terminal | Not drawn. The renderer is a native child view composited above everything Avalonia draws, so a rounded frame behind it clips nothing. What the design was after comes from the page's own background, which is the design's `#171A26`. | > | A 14px rounded inset around the terminal | Not drawn. The renderer is a native child view composited above everything Avalonia draws, so a rounded frame behind it clips nothing. What the design was after comes from the page's own background, which is the design's `#171A26`. |
> | Instrument Sans / Fira Code | Inter and the system monospace stack, as before and for the same reason — see the note on `MonoFont` in `Palette.axaml`. | > | Instrument Sans / Fira Code | Inter and the system monospace stack, as before and for the same reason — see the note on `MonoFont` in `Palette.axaml`. |
> | A theme toggle (`☀`) in the header, and the desktop v2's light theme | Omitted. There is one theme by decision, and a switch with one position is a control that does nothing. | > | A theme toggle (`☀`) in the header, and the desktop v2's light theme | Omitted. There is one theme by decision, and a switch with one position is a control that does nothing. |
> | TERMINAL's empty state: a sentence saying nothing is open | ◆ **More than the design asked for, which is rare enough on this list to say out loud.** The tab is called **Connections** and with nothing running the screen is where one is made: a box taking `user@host` or `user@host:port` with a password, and the machines most recently connected to underneath it, read from the vault's own connection log. The box is the only path in the product to a machine that is not in the keychain — the design assumes every connection starts from a saved host, and the case it has no answer for is an address somebody was handed five minutes ago. Nothing typed there is saved, and the screen says so. |
> >
> ## The desktop's v2 > ## The desktop's v2
> >
+51
View File
@@ -952,3 +952,54 @@ visibly disabled rather than silently inert.
**Failure means:** they have been folded into the scrolling row — which is the arrangement the connection **Failure means:** they have been folded into the scrolling row — which is the arrangement the connection
line existed to avoid, and the reason it could be removed at all. line existed to avoid, and the reason it could be removed at all.
### 11.6 The keyboard goes away with the connect menu
With a shell open, tap into the terminal so the software keyboard comes up, then press `+`.
**Pass:** the keyboard drops and the sheet sits on the bottom of the full screen, all four of its rows
reachable. Repeat on Android 14 or earlier and on 15 or later if you have both — the keyboard shortens this
head's interface by a different mechanism on each, and a sheet laid out into the strip left above a
keyboard is the failure this exists to catch.
**Failure means:** `SoftKeyboard.Hide` found no activity, no input-method manager, or the wrong window
token. Note that Avalonia cannot do this for us — `TopLevel.InputPane` reports the keyboard and cannot close
one, and the keyboard here belongs to the `WebView`, so clearing Avalonia's focus is not a fallback.
### 11.7 Connections, with nothing open
Close every tab, then press Connections in the bottom bar.
**Pass:** a box reading `user@host or user@host:port`, a password box under it, CONNECT, and — if this
keychain has ever connected to anything — a RECENT list underneath. The whole thing scrolls with the
keyboard up, and the password box shows dots with **no suggestion strip** above the keyboard.
Type a machine you can reach and press CONNECT: a tab appears in the bar and the shell opens. Type
something malformed — no `user@`, or `:70000` — and the refusal appears under the boxes, in the warning
colour, with no tab and nothing dialled.
**Failure means:** for the suggestion strip, the box lost `Classes="secret"` — see 10.1, and it is the same
seriousness here, since this password is typed rather than stored and so is typed often.
### 11.8 A recent machine leads to the right place
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 selected and its connect bar up. 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.
**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.
### 11.9 Settings holds the keychain, and back walks out of it
From HOSTS: Settings, then Keychain.
**Pass:** the bottom bar has three entries and the Settings one stays lit while the keychain is showing.
The keychain draws its own header with a back arrow — the vault name and sync light are not above it. Back,
by gesture or by the arrow, returns to Settings and not to HOSTS; a second back returns to HOSTS.
**Failure means:** `ShellScreen.Vault` is missing from `IsMoreSurface` or from the back gesture's first
case, and those two have to move together — the switch mirrors that property by construction.
@@ -0,0 +1,50 @@
using global::Android.Content;
using global::Android.Views.InputMethods;
namespace DodoSSH.Client.Android.Platform;
/// <summary>
/// Puts the software keyboard away.
/// </summary>
/// <remarks>
/// <para>
/// <b>There is no Avalonia way to do this, and it is worth knowing why rather than assuming.</b>
/// <c>TopLevel.InputPane</c> reports the keyboard — its state and the rectangle it occludes, which is what
/// <c>PhoneShell</c> keeps the interface clear of — and offers nothing that closes it. The framework's model
/// is that the keyboard belongs to whatever has focus, so the supported way to dismiss one is to move focus
/// off the text box that raised it.
/// </para>
/// <para>
/// <b>That model does not reach the case this exists for.</b> The keyboard over a terminal was raised by the
/// <c>WebView</c>'s own text input, inside the page, by a native view Avalonia's focus manager does not own.
/// Clearing Avalonia's focus leaves it exactly where it is, because Avalonia never had it. So the request
/// goes to the platform that does own it.
/// </para>
/// <para>
/// <c>HideSoftInputFromWindow</c> needs a window token, and any attached view's will do — they all belong to
/// the same window. The decor view is the one guaranteed to exist for as long as the activity does.
/// </para>
/// <para>
/// Every step is allowed to be absent and none of them is an error: there is no activity while the app is
/// backgrounded, no input-method manager on a system image without one, and no keyboard up most of the time.
/// A method whose whole contract is "if a keyboard is showing, stop showing it" has nothing to report when
/// one is not.
/// </para>
/// </remarks>
internal static class SoftKeyboard
{
public static void Hide()
{
if (PhoneEnvironment.CurrentActivity?.Window?.DecorView is not { } view)
{
return;
}
if (view.Context?.GetSystemService(Context.InputMethodService) is not InputMethodManager manager)
{
return;
}
manager.HideSoftInputFromWindow(view.WindowToken, HideSoftInputFlags.None);
}
}
@@ -1,6 +1,7 @@
<UserControl xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels" xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.Android.Views"
x:Class="DodoSSH.Client.Android.Views.KeychainScreen" x:Class="DodoSSH.Client.Android.Views.KeychainScreen"
x:DataType="vm:VaultViewModel" x:DataType="vm:VaultViewModel"
Background="{StaticResource Canvas}"> Background="{StaticResource Canvas}">
@@ -26,12 +27,30 @@
Mode=OneWay on the chips is load-bearing: a two-way selection binding moves the chip before the command Mode=OneWay on the chips is load-bearing: a two-way selection binding moves the chip before the command
beside it has had the chance to refuse, which would leave the row lit for a section the list is not on. beside it has had the chance to refuse, which would leave the row lit for a section the list is not on.
── v3 ────────────────────────────────────────────────────────────────────────────────────────────────
This screen moved behind SETTINGS, so it draws its own header — the one every hub screen has. It is not
a decoration: the shell's header stands down behind the hub, and without a row of its own there would be
no back arrow and nothing on screen saying what this list is.
--> -->
<Grid RowDefinitions="Auto,Auto,Auto,*"> <Grid RowDefinitions="Auto,Auto,Auto,Auto,*">
<!-- ============ header ============ -->
<!--
The same shape as the one over PREFERENCES in PhoneShell, and pointed at the same place. Back goes to
the hub rather than to HOSTS, because the hub is where this screen is reached from and back that
skipped it would strand the four rows underneath it.
-->
<Grid Grid.Row="0" ColumnDefinitions="Auto,*" Height="56" Margin="8,0">
<Button Grid.Column="0" Classes="icon" Content="←"
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.More}" />
<TextBlock Grid.Column="1" Classes="heading" Text="Keychain" Margin="4,0" />
</Grid>
<!-- ============ categories ============ --> <!-- ============ categories ============ -->
<ScrollViewer Grid.Row="0" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled" <ScrollViewer Grid.Row="1" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
Margin="0,12,0,4"> Margin="0,12,0,4">
<StackPanel Orientation="Horizontal" Spacing="6" Margin="16,0"> <StackPanel Orientation="Horizontal" Spacing="6" Margin="16,0">
@@ -91,7 +110,7 @@
them are still waiting to be pushed — which is the honest version of the design's unconditional them are still waiting to be pushed — which is the honest version of the design's unconditional
"synced". "synced".
--> -->
<TextBlock Grid.Row="1" Classes="detail" Margin="18,6,18,2" Text="{Binding SectionSummary}" /> <TextBlock Grid.Row="2" Classes="detail" Margin="18,6,18,2" Text="{Binding SectionSummary}" />
<!-- ============ ◆ the counted confirmation ============ --> <!-- ============ ◆ the counted confirmation ============ -->
<!-- <!--
@@ -99,7 +118,7 @@
"three hosts authenticate with this key and will refuse to connect" — and the count is the whole "three hosts authenticate with this key and will refuse to connect" — and the count is the whole
difference between a sentence somebody reads and one they click past. difference between a sentence somebody reads and one they click past.
--> -->
<Border Grid.Row="2" IsVisible="{Binding IsConfirmingDeletion}" Margin="12,4" <Border Grid.Row="3" IsVisible="{Binding IsConfirmingDeletion}" Margin="12,4"
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}" Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
BorderThickness="1" CornerRadius="12" Padding="14,12"> BorderThickness="1" CornerRadius="12" Padding="14,12">
<StackPanel Spacing="5"> <StackPanel Spacing="5">
@@ -120,7 +139,7 @@
</Border> </Border>
<!-- ============ the items ============ --> <!-- ============ the items ============ -->
<Panel Grid.Row="3"> <Panel Grid.Row="4">
<!-- Written per section by the view model, and it says what the section is for. --> <!-- Written per section by the view model, and it says what the section is for. -->
<TextBlock Classes="body" IsVisible="{Binding !HasVaultItems}" Margin="24,20" <TextBlock Classes="body" IsVisible="{Binding !HasVaultItems}" Margin="24,20"
@@ -6,26 +6,60 @@
Background="{StaticResource Canvas}"> Background="{StaticResource Canvas}">
<!-- <!--
Design v2 — MORE: the hub for everything the bottom bar has no room for. Design v2 — SETTINGS: the hub for everything the bottom bar has no room for.
Four slots and nine destinations is the arithmetic the design solves by putting five of them one tap Three slots and nine destinations is the arithmetic the design solves by putting the rest one tap
deeper. This screen is that tap. It takes the shell as its data context rather than the vault, because deeper. This screen is that tap. It takes the shell as its data context rather than the vault, because
every row on it is a navigation command and nothing here reads an item. every row on it is a navigation command and nothing here reads an item.
The rows are the design's list rows rather than cards: a card is one thing you act on, and a destination The rows are the design's list rows rather than cards: a card is one thing you act on, and a destination
is not a thing — it is a place. Each carries a sentence saying what is behind it, because a hub whose is not a thing — it is a place. Each carries a sentence saying what is behind it, because a hub whose
entries are one word each is a menu you have to open to read. entries are one word each is a menu you have to open to read.
── from MORE to SETTINGS ──────────────────────────────────────────────────────────────────────────────
The hub was called More because that was the honest name for five things that did not fit. With the
keychain on it as well it is the place everything that is not connecting to a machine lives, which has a
name of its own and a glyph everyone already knows.
The keychain is first, and above the rule, because it is the only row here that holds the user's own
material rather than a screen about the application. Everything below it is somewhere to look at what
has happened or how this phone behaves.
--> -->
<Grid RowDefinitions="Auto,*"> <Grid RowDefinitions="Auto,*">
<Border Grid.Row="0" Height="56" Padding="16,0"> <Border Grid.Row="0" Height="56" Padding="16,0">
<TextBlock Classes="heading" Text="More" /> <TextBlock Classes="heading" Text="Settings" />
</Border> </Border>
<ScrollViewer Grid.Row="1"> <ScrollViewer Grid.Row="1">
<StackPanel Margin="10,0,10,20" Spacing="2"> <StackPanel Margin="10,0,10,20" Spacing="2">
<!--
"Keychain", although the design's tab says Vault and the enum member is Vault. The split is a
standing rule rather than an inconsistency: every sentence a user reads calls the encrypted store a
keychain, and everything under the surface calls it a vault, because renaming the wire route, the
tables and the CLR types would put an AAD resource type inside the blast radius of a
find-and-replace. See docs/design-import-gaps.md. A row reading Vault would be the one place the
product surface leaked the implementation's word.
-->
<Button Classes="row" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Vault}">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="⚿" Foreground="{StaticResource AccentText}" FontSize="14"
Width="22" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Keychain" />
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}"
Text="Keys, passwords, buckets and tags — everything in the vault that is not a host." />
</StackPanel>
<TextBlock Grid.Column="2" Text="" Foreground="{StaticResource TextGhost}" FontSize="15"
VerticalAlignment="Center" />
</Grid>
</Button>
<Border Height="1" Background="{StaticResource Border}" Margin="16,8" />
<Button Classes="row" Command="{Binding ShowScreenCommand}" <Button Classes="row" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Snippets}"> CommandParameter="{x:Static vm:ShellScreen.Snippets}">
<Grid ColumnDefinitions="Auto,*,Auto"> <Grid ColumnDefinitions="Auto,*,Auto">
@@ -106,10 +140,10 @@
<!-- <!--
Locking, here as well as in the header. Locking, here as well as in the header.
The header carries it on the three top-level destinations and stands down behind MORE, so without The header carries it on HOSTS and stands down behind SETTINGS, so without this row every screen
this row the five screens under here would each be two taps from locking the keychain. That is the under here would be two taps from locking the keychain. That is the wrong direction for the one
wrong direction for the one action whose whole value is being quick, and it is why this is a row action whose whole value is being quick, and it is why this is a row on the hub rather than
on the hub rather than something you go back for. something you go back for.
--> -->
<Button Classes="row" Margin="0,10,0,0" Command="{Binding LockCommand}"> <Button Classes="row" Margin="0,10,0,0" Command="{Binding LockCommand}">
<Grid ColumnDefinitions="Auto,*"> <Grid ColumnDefinitions="Auto,*">
@@ -56,11 +56,11 @@
<!-- <!--
The header: which vault, and whether it is synced. The header: which vault, and whether it is synced.
Hidden behind MORE, and that is the design's arrangement rather than a saving. v2 gives every screen Hidden behind SETTINGS, and that is the design's arrangement rather than a saving. v2 gives every
one header carrying that screen's own name and its own actions — a back arrow, an add, a refresh — screen one header carrying that screen's own name and its own actions — a back arrow, an add, a
so the five hub screens draw their own and this one stands down rather than stacking a second row of refresh — so the hub screens draw their own and this one stands down rather than stacking a second
chrome above theirs. It stays on the two destinations that are the product's top level, where the row of chrome above theirs. What is left is HOSTS, which is the screen the application opens on and
vault's name and the sync light are the most useful thing a header could say. the one where the vault's name and the sync light are the most useful thing a header could say.
Wrapped rather than given a second condition, because Avalonia's bindings have no "and": the wrapper Wrapped rather than given a second condition, because Avalonia's bindings have no "and": the wrapper
collapses it over a terminal, where the surface draws its own bar and the vault's name is not what collapses it over a terminal, where the surface draws its own bar and the vault's name is not what
@@ -241,17 +241,23 @@
<!-- ============ navigation ============ --> <!-- ============ navigation ============ -->
<!-- <!--
Gone while a terminal is showing, which is the whole of that surface's arrangement: the bar's four Gone while a terminal is showing, which is the whole of that surface's arrangement: the bar's three
destinations are replaced by a back arrow and a + that leads to three of them, both in the terminal's destinations are replaced by a back arrow and a + that leads to all of them, both in the terminal's
own bar. See TerminalScreen.axaml. own bar. See TerminalScreen.axaml.
This one is bound directly rather than wrapped — its visibility is a single question and it has no This one is bound directly rather than wrapped — its visibility is a single question and it has no
second condition of its own to keep separate. second condition of its own to keep separate.
── three rather than four ─────────────────────────────────────────────────────────────────────────
The keychain moved behind SETTINGS. A bottom bar is for the places a session moves between, and keys,
credentials and tags are not that — they are managed occasionally and then left alone, which is the
shape of everything else already behind the hub. What is left is the two halves of using this
application, and the drawer holding the rest.
--> -->
<Border Grid.Row="3" IsVisible="{Binding IsShowingPages}" <Border Grid.Row="3" IsVisible="{Binding IsShowingPages}"
Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}" Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,1,0,0" Height="64"> BorderThickness="0,1,0,0" Height="64">
<Grid ColumnDefinitions="*,*,*,*"> <Grid ColumnDefinitions="*,*,*">
<views:NavButton Grid.Column="0" Label="Hosts" Glyph="▤" IsCurrent="{Binding IsHostsShowing}" <views:NavButton Grid.Column="0" Label="Hosts" Glyph="▤" IsCurrent="{Binding IsHostsShowing}"
Command="{Binding ShowScreenCommand}" Command="{Binding ShowScreenCommand}"
@@ -261,31 +267,30 @@
The terminal is a surface rather than a page — see ShellSurface — so this one does not go The terminal is a surface rather than a page — see ShellSurface — so this one does not go
through ShowScreen. Its own command is on the shell. through ShowScreen. Its own command is on the shell.
"Connections" rather than "Terminal", and the word does more work than a rename usually does:
with nothing open this surface is where a connection is *made* — a machine typed in by hand, and
the ones connected to before — so a tab labelled Terminal would name the thing behind the door
rather than the door. The enum member stays ShellSurface.Terminal, for the reason the keychain's
tab is not called Vault: the surface is a terminal, and the word a user reads is the product's.
The only entry here that never lights, and deliberately no longer tries: this bar is collapsed The only entry here that never lights, and deliberately no longer tries: this bar is collapsed
while the terminal is showing, so IsCurrent could only ever be read as false. Binding it anyway while the terminal is showing, so IsCurrent could only ever be read as false. Binding it anyway
would be a rule about a state this control cannot be in. What marks the terminal as current is would be a rule about a state this control cannot be in. What marks it as current is the surface
the surface filling the screen. filling the screen.
--> -->
<views:NavButton Grid.Column="1" Label="Terminal" Glyph="⌗" <views:NavButton Grid.Column="1" Label="Connections" Glyph="⌗"
Command="{Binding ShowTerminalCommand}" /> Command="{Binding ShowTerminalCommand}" />
<!-- <!--
"Keychain", although the design's tab says Vault and the enum member is Vault. The split is a IsMoreSurface rather than IsMoreShowing: this tab stands for seven screens, and a bar that went
standing rule rather than an inconsistency: every sentence a user reads calls the encrypted dark the moment you opened one of them would only ever light two of its three entries.
store a keychain, and everything under the surface calls it a vault, because renaming the wire
route, the tables and the CLR types would put an AAD resource type inside the blast radius of a
find-and-replace. See docs/design-import-gaps.md. A tab reading Vault would be the one place the
product surface leaked the implementation's word.
-->
<views:NavButton Grid.Column="2" Label="Keychain" Glyph="⚿" IsCurrent="{Binding IsVaultShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Vault}" />
<!-- "Settings" and a gear, where it was "More" and a hamburger. The hub held five destinations and a
IsMoreSurface rather than IsMoreShowing: this tab stands for six screens, and a bar that went paragraph and could not be called anything better than More; with the keychain and preferences
dark the moment you opened one of them would only ever light three of its four entries. both on it, it is the place everything that is not connecting to something lives — which is what
a gear has meant on a phone since before this application existed.
--> -->
<views:NavButton Grid.Column="3" Label="More" Glyph="" IsCurrent="{Binding IsMoreSurface}" <views:NavButton Grid.Column="2" Label="Settings" Glyph="" IsCurrent="{Binding IsMoreSurface}"
Command="{Binding ShowScreenCommand}" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.More}" /> CommandParameter="{x:Static vm:ShellScreen.More}" />
@@ -347,12 +347,12 @@ internal sealed partial class PhoneShell : UserControl
switch (current.Screen) switch (current.Screen)
{ {
case ShellScreen.Snippets or ShellScreen.Logs or ShellScreen.Transfers case ShellScreen.Snippets or ShellScreen.Logs or ShellScreen.Transfers
or ShellScreen.Buckets or ShellScreen.Preferences: or ShellScreen.Buckets or ShellScreen.Preferences or ShellScreen.Vault:
current.ShowScreenCommand.Execute(ShellScreen.More); current.ShowScreenCommand.Execute(ShellScreen.More);
e.Handled = true; e.Handled = true;
break; break;
case ShellScreen.More or ShellScreen.Vault: case ShellScreen.More:
current.ShowScreenCommand.Execute(ShellScreen.Hosts); current.ShowScreenCommand.Execute(ShellScreen.Hosts);
e.Handled = true; e.Handled = true;
break; break;
@@ -144,8 +144,12 @@
are a matched pair at either end of one bar — one leaves this surface, one adds to it — and an are a matched pair at either end of one bar — one leaves this surface, one adds to it — and an
accented + would rank itself above the way out. The accent fill belongs to the floating + on accented + would rank itself above the way out. The accent fill belongs to the floating + on
HOSTS, which is the only action on its screen; this one is not. HOSTS, which is the only action on its screen; this one is not.
Click as well as Command, and the handler does one thing the command cannot: put the software
keyboard away. See OnConnectMenuRequested.
--> -->
<Button Grid.Column="2" Classes="icon" Content="+" Height="34" Margin="0,0,4,0" <Button Grid.Column="2" Classes="icon" Content="+" Height="34" Margin="0,0,4,0"
Click="OnConnectMenuRequested"
Command="{Binding OpenConnectSheetCommand}" /> Command="{Binding OpenConnectSheetCommand}" />
</Grid> </Grid>
</Border> </Border>
@@ -154,14 +158,107 @@
<Panel Grid.Row="1"> <Panel Grid.Row="1">
<!-- <!--
The empty state, and it says what the surface is for rather than that it is empty. A phone opens ============ nothing open ============
here on a cold start, so this is the first thing a new user reads.
Not an empty state any more, and that is what the bottom bar's word change is about: with no shell
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.
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
own connection log — see MainWindowViewModel.RecentConnections — so they survive a restart and
arrive on a new phone with the keychain, which a list kept in this process would not.
--> -->
<StackPanel IsVisible="{Binding !HasTabs}" VerticalAlignment="Center" Margin="24" Spacing="10"> <ScrollViewer IsVisible="{Binding !HasTabs}">
<TextBlock Classes="title" FontSize="13" Text="NO SHELL OPEN" /> <StackPanel Margin="20,18,20,24" Spacing="10">
<TextBlock Classes="body"
Text="Press + above, or choose a host and press CONNECT. A shell opened here keeps running while the app is in the background, and keeps running after the keychain is locked — a notification says so for as long as one is alive." /> <TextBlock Classes="title" FontSize="13" Text="CONNECT TO A MACHINE" />
</StackPanel>
<!--
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}}" />
<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." />
<!-- ============ where you have been ============ -->
<StackPanel Spacing="2" Margin="0,14,0,0" IsVisible="{Binding HasRecentConnections}">
<TextBlock Classes="label" Text="RECENT" Margin="0,0,0,4" />
<ItemsControl ItemsSource="{Binding RecentConnections}">
<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.
-->
<Button Classes="row" MinHeight="52" Padding="12,8" CornerRadius="10"
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).ConnectToRecentCommand}"
CommandParameter="{Binding}">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13" FontWeight="SemiBold"
Text="{Binding HostLabel}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Address}"
TextTrimming="CharacterEllipsis" />
</StackPanel>
<TextBlock Grid.Column="1" Classes="detail" FontSize="10"
VerticalAlignment="Center" Text="{Binding Started}" />
</Grid>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<TextBlock Classes="body" Margin="0,10,0,0"
Text="A shell opened here keeps running while the app is in the background, and keeps running after the keychain is locked — a notification says so for as long as one is alive." />
</StackPanel>
</ScrollViewer>
<!-- <!--
A tab with no pane behind it: one still connecting, or one that was refused. The desktop head draws A tab with no pane behind it: one still connecting, or one that was refused. The desktop head draws
@@ -2,11 +2,13 @@ using System.Text;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Layout; using Avalonia.Layout;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Platform; using Avalonia.Platform;
using DodoSSH.Client.Android.Platform;
using DodoSSH.Client.Shell.ViewModels; using DodoSSH.Client.Shell.ViewModels;
// Avalonia's Button, not Android.Widget's. .NET for Android puts Android.Widget in this project's // Avalonia's Button, not Android.Widget's. .NET for Android puts Android.Widget in this project's
@@ -85,6 +87,30 @@ internal sealed partial class TerminalScreen : UserControl
}; };
} }
/// <summary>
/// Puts the keyboard away on the way to the connect menu.
/// </summary>
/// <remarks>
/// <para>
/// A handler beside the button's command rather than something the command does, because the command is
/// on the shared view model and this is a platform call: the desktop head has no software keyboard to
/// put away, and a shell view model that knew about Android's input-method manager would be the first
/// thing in that layer that did.
/// </para>
/// <para>
/// <b>The keyboard is up in the ordinary case.</b> This button sits above a terminal somebody is typing
/// into, so the sheet it raises would otherwise arrive underneath a keyboard covering half the display —
/// and the three rows on it are the bottom half. Worse, the keyboard's inset shortens everything the
/// phone draws (see <c>PhoneShell.ApplyKeyboardInset</c>), so the sheet would be laid out into the
/// remaining strip rather than merely hidden behind it.
/// </para>
/// <para>
/// Click and Command both fire for one press and their order is not something to rely on. Neither reads
/// what the other writes: this one talks to the platform and the command sets a flag, so they commute.
/// </para>
/// </remarks>
private void OnConnectMenuRequested(object? sender, RoutedEventArgs e) => SoftKeyboard.Hide();
/// <summary> /// <summary>
/// Takes the browser gestures back off a surface that is not a web page. /// Takes the browser gestures back off a surface that is not a web page.
/// </summary> /// </summary>
@@ -65,6 +65,15 @@ internal sealed class ConnectionLogRowViewModel(VaultItem<ConnectionLogSecret> e
/// <summary>Whether this was a terminal or the file browser.</summary> /// <summary>Whether this was a terminal or the file browser.</summary>
internal string Kind => entry.Secret.Kind is ConnectionKind.Sftp ? "files" : "terminal"; internal string Kind => entry.Secret.Kind is ConnectionKind.Sftp ? "files" : "terminal";
/// <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.
/// </remarks>
internal Guid? HostId => entry.Secret.HostId;
internal string DeviceName => entry.Secret.DeviceName; internal string DeviceName => entry.Secret.DeviceName;
/// <remarks> /// <remarks>
@@ -220,11 +229,33 @@ internal sealed partial class LogsViewModel : ObservableObject
/// <summary>Reads both logs into the lists.</summary> /// <summary>Reads both logs into the lists.</summary>
internal async Task ReloadAsync(CancellationToken cancellationToken) internal async Task ReloadAsync(CancellationToken cancellationToken)
{ {
var connections = await session.ConnectionLog await ReloadConnectionsAsync(cancellationToken).ConfigureAwait(true);
var activity = await session.ActivityLog
.ListAsync(session.ActiveVaultId, cancellationToken) .ListAsync(session.ActiveVaultId, cancellationToken)
.ConfigureAwait(true); .ConfigureAwait(true);
var activity = await session.ActivityLog Activity.Clear();
foreach (var entry in activity.Items.OrderByDescending(item => item.Secret.At))
{
Activity.Add(new ActivityLogRowViewModel(entry));
}
OnPropertyChanged(nameof(HasActivity));
}
/// <summary>Reads the connection log alone.</summary>
/// <remarks>
/// Split out for the Connections screen, which offers the most recent of these as a way back to a
/// machine and has no use at all for the keychain's activity. Reading both there would double the
/// decryption for a list nobody on that screen is looking at — and this list is already the expensive
/// one, which is why the whole thing is read on demand rather than kept in step. See the remark on the
/// type.
/// </remarks>
internal async Task ReloadConnectionsAsync(CancellationToken cancellationToken)
{
var connections = await session.ConnectionLog
.ListAsync(session.ActiveVaultId, cancellationToken) .ListAsync(session.ActiveVaultId, cancellationToken)
.ConfigureAwait(true); .ConfigureAwait(true);
@@ -257,15 +288,7 @@ internal sealed partial class LogsViewModel : ObservableObject
Connections.Add(new ConnectionLogRowViewModel(entry, isLive: false)); Connections.Add(new ConnectionLogRowViewModel(entry, isLive: false));
} }
Activity.Clear();
foreach (var entry in activity.Items.OrderByDescending(item => item.Secret.At))
{
Activity.Add(new ActivityLogRowViewModel(entry));
}
OnPropertyChanged(nameof(HasConnections)); OnPropertyChanged(nameof(HasConnections));
OnPropertyChanged(nameof(HasActivity));
} }
partial void OnSectionChanged(LogSection value) partial void OnSectionChanged(LogSection value)
@@ -775,20 +775,27 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
internal bool IsBucketsShowing => IsShowingPages && IsBucketsScreen; internal bool IsBucketsShowing => IsShowingPages && IsBucketsScreen;
/// <summary> /// <summary>
/// Whether the phone's MORE tab should light. /// Whether the phone's SETTINGS tab should light.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The hub and everything behind it, because a bottom bar that went dark the moment you opened one of /// The hub and everything behind it, because a bottom bar that went dark the moment you opened one of
/// its destinations would be a bar that only ever lights three of its four entries. This is the one /// its destinations would be a bar that only ever lights two of its three entries. This is the one
/// place where "which tab" and "which screen" are deliberately not the same question — the other three /// place where "which tab" and "which screen" are deliberately not the same question — the other two
/// tabs are each exactly one screen, and this one is six. /// tabs are each exactly one thing, and this one is seven.
/// ///
/// Preferences is in the list because the phone reaches it through the hub. The desktop reaches it from /// Preferences is in the list because the phone reaches it through the hub. The desktop reaches it from
/// the rail and never asks this. /// the rail and never asks this.
///
/// <b>The keychain joined it, and that is why the bar went from four entries to three.</b> A phone's
/// bottom bar is for the places a session moves between, and the keychain is not one of those: hosts
/// and connections are what somebody opens the application to do, and keys, credentials and tags are
/// what they go and manage occasionally. The desktop keeps its rail entry — it has room for nine — so
/// this is the second thing the two heads deliberately arrange differently, after the hub itself.
/// </remarks> /// </remarks>
internal bool IsMoreSurface => internal bool IsMoreSurface =>
IsShowingPages && Screen is ShellScreen.More or ShellScreen.Snippets or ShellScreen.Logs IsShowingPages && Screen is ShellScreen.More or ShellScreen.Snippets or ShellScreen.Logs
or ShellScreen.Transfers or ShellScreen.Buckets or ShellScreen.Preferences; or ShellScreen.Transfers or ShellScreen.Buckets or ShellScreen.Preferences
or ShellScreen.Vault;
/// <summary> /// <summary>
/// Whether the terminal's WebView may be on screen at this instant. /// Whether the terminal's WebView may be on screen at this instant.
@@ -899,7 +906,127 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </para> /// </para>
/// </remarks> /// </remarks>
[RelayCommand] [RelayCommand]
private void ShowTerminal() => Surface = ShellSurface.Terminal; private void ShowTerminal()
{
Surface = ShellSurface.Terminal;
// Only with nothing open, because that is the only state in which they are drawn — the surface shows
// the sessions otherwise. Not awaited, for the reason the logs screen's own load is not: navigating
// must not block on a read, and the list appears under the box the moment it arrives.
if (!HasTabs)
{
_ = RefreshRecentConnectionsAsync();
}
}
/// <summary>
/// The machines most recently connected to, for the Connections screen to offer when nothing is open.
/// </summary>
/// <remarks>
/// <para>
/// Deduplicated by address, because this is a list of places rather than of events: connecting to one
/// box nine times in a morning is nine entries in the log and one thing worth offering here. The log
/// screen shows every one of them; that is what a log is for and this is not one.
/// </para>
/// <para>
/// Capped, and the cap is not about memory. What makes this list useful is that the machine somebody
/// wants is visible without scrolling, above a keyboard, under the box they would otherwise be typing
/// into. Twenty rows would push the box off the screen and be a worse version of the log.
/// </para>
/// </remarks>
internal ObservableCollection<ConnectionLogRowViewModel> RecentConnections { get; } = [];
internal bool HasRecentConnections => RecentConnections.Count > 0;
/// <summary>How many machines the Connections screen offers.</summary>
private const int RecentConnectionLimit = 6;
/// <summary>Re-reads the connection log and takes the most recent distinct machines from it.</summary>
/// <remarks>
/// Failures are swallowed, and that is the same call the logs screen makes for the same reason: this is
/// a convenience under a box that works without it. A screen whose whole purpose is to let somebody
/// connect should not lead with a decryption error about a list of things they connected to yesterday.
/// </remarks>
private async Task RefreshRecentConnectionsAsync()
{
if (LogsScreen is not { } logs)
{
return;
}
try
{
await logs.ReloadConnectionsAsync(CancellationToken.None).ConfigureAwait(true);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
return;
}
RecentConnections.Clear();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var row in logs.Connections)
{
// The live ones are skipped rather than filtered later: a connection that is open right now has
// a tab, and a tab means this list is not on screen at all. Leaving them in would only matter in
// the one state where it cannot be seen, which is a rule that would be wrong the moment that
// stopped being true.
if (row.IsLive || !seen.Add(row.Address))
{
continue;
}
RecentConnections.Add(row);
if (RecentConnections.Count == RecentConnectionLimit)
{
break;
}
}
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, selected, on the hosts screen — which is where its connect bar is,
/// with whatever authentication the keychain resolves for it and a password box only if it needs one.
/// Connecting from here instead would be a third connect path that had to answer all of that again.
/// </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.
/// </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.SelectedHost = known;
ShowScreen(ShellScreen.Hosts);
return;
}
vault.ManualTarget = row.Address;
vault.ManualStatus = string.Empty;
}
/// <summary> /// <summary>
/// Whether the phone's connect menu is open over the terminal. /// Whether the phone's connect menu is open over the terminal.
@@ -2201,6 +2328,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
LogsScreen = newValue is null ? null : new LogsViewModel(newValue.Session, LiveConnections); LogsScreen = newValue is null ? null : new LogsViewModel(newValue.Session, LiveConnections);
// Emptied with the vault it was read out of. These rows are decrypted log entries — a host's name
// and the account and endpoint dialled — and a lock that left them on the shell would be a list of
// where somebody works, still on screen and still readable, after the thing that decrypted it was
// disposed and every key it held was zeroed.
RecentConnections.Clear();
OnPropertyChanged(nameof(HasRecentConnections));
RaiseSyncState(); RaiseSyncState();
} }
@@ -1992,6 +1992,44 @@ internal sealed partial class VaultViewModel(
[ObservableProperty] [ObservableProperty]
private string connectPassword = string.Empty; private string connectPassword = string.Empty;
/// <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 attempt an unanswered host-key question belongs to, so that trusting the key can replay it.
/// </summary>
/// <remarks>
/// Set on every attempt rather than only on the ones that stop, because whether a question is coming is
/// not knowable until the handshake has run. It is never cleared: a stale pair costs nothing, since the
/// only thing that reads it is a trust decision, and one of those can only exist for the attempt that
/// raised it.
/// </remarks>
private (ConnectionTarget Target, HostAuthentication Authentication)? pendingRetry;
/// <summary> /// <summary>
/// Whether the selected host will want something typed into the password box. /// Whether the selected host will want something typed into the password box.
/// </summary> /// </summary>
@@ -4911,21 +4949,194 @@ internal sealed partial class VaultViewModel(
return; return;
} }
await ConnectToAsync(
new ConnectionTarget(row.Label, row.EntityId, row.Host.Hostname, row.Resolved.Port.Value),
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 ConnectToAsync(
// Labelled by what was typed rather than by the hostname alone. Two accounts on one box are two
// different connections, and a strip showing the same name twice would be the tab equivalent of
// the log entry this also names.
new ConnectionTarget(
$"{endpoint.Username}@{endpoint.Hostname}",
HostId: null,
endpoint.Hostname,
endpoint.Port),
new HostAuthentication(endpoint.Username, new SshPasswordCredential(ManualPassword)),
cancellationToken).ConfigureAwait(true);
}
/// <summary>
/// Reads <c>user@host</c>, with an optional <c>:port</c>, or says why it cannot.
/// </summary>
/// <remarks>
/// <para>
/// The username is required rather than defaulted to this device's account name, which is what
/// <c>ssh</c> itself would do. A phone has no account name worth borrowing — the value there is the
/// Android user, which is never a login on anything — so a default would be a guess that fails at the
/// remote with "authentication failed" rather than here with a sentence.
/// </para>
/// <para>
/// The port defaults to 22 and refuses anything outside 165535, which is the range
/// <c>HostSecret.TryValidate</c> already enforces for a saved host. A target that cannot be stored is
/// not one this path should be able to dial either.
/// </para>
/// <para>
/// IPv6 in brackets is not accepted, and the refusal says so rather than silently reading
/// <c>::1</c>'s last colon as a port separator. Nothing else in this application accepts a bracketed
/// address — <c>HostSecret.Hostname</c> is a bare string dialled as it stands — so accepting one here
/// would make this the only field in the product with its own address grammar.
/// </para>
/// </remarks>
private static bool TryParseManualTarget(
string typed,
[NotNullWhen(true)] out ManualEndpoint? endpoint,
[NotNullWhen(false)] out string? reason)
{
endpoint = null;
var trimmed = typed.Trim();
if (trimmed.Length == 0)
{
reason = "Type a machine to connect to, as user@host.";
return false;
}
if (trimmed.Contains('[', StringComparison.Ordinal))
{
reason = "A bracketed IPv6 address is not accepted here. Save it as a host instead.";
return false;
}
var at = trimmed.LastIndexOf('@');
if (at <= 0 || at == trimmed.Length - 1)
{
reason = "Say who to log in as: user@host, or user@host:port.";
return false;
}
var username = trimmed[..at];
var host = trimmed[(at + 1)..];
var port = 22;
if (host.LastIndexOf(':') is var colon && colon >= 0)
{
if (!int.TryParse(
host[(colon + 1)..],
NumberStyles.None,
CultureInfo.InvariantCulture,
out port)
|| port is < 1 or > 65535)
{
reason = "The port has to be a number between 1 and 65535.";
return false;
}
host = host[..colon];
}
if (host.Length == 0)
{
reason = "Say which machine: user@host, or user@host:port.";
return false;
}
endpoint = new ManualEndpoint(username, host, port);
reason = null;
return true;
}
/// <summary>What a manual target reads as, once it has been taken apart.</summary>
/// <remarks>
/// Separate from <see cref="ConnectionTarget"/> because a keychain host has no username of its own at
/// this level — its account comes out of <see cref="TryBuildAuthentication"/>, possibly from a bound
/// credential rather than from the host — so a username on the shared record would be a field that is
/// null for every connection but this one.
/// </remarks>
private sealed record ManualEndpoint(string Username, string Hostname, int Port);
/// <summary>Everything both connect paths share, from the tab appearing to the session opening.</summary>
/// <remarks>
/// One method rather than two, and it is the same argument <see cref="TryBuildConnectionRequest"/> makes
/// about there being one authentication resolution: the ladder of refusals below this, the host-key
/// question, the log entry and the tab's own lifecycle are the parts nobody should be able to get
/// subtly different for one kind of connection.
/// </remarks>
private async Task ConnectToAsync(
ConnectionTarget target,
HostAuthentication authentication,
CancellationToken cancellationToken)
{
PendingHostKey = null; PendingHostKey = null;
HostKeyMismatch = null; HostKeyMismatch = null;
// Remembered so that answering the host-key question retries *this* attempt. It used to re-run the
// selected host unconditionally, which was right while that was the only way to connect and would
// now dial the wrong machine — or refuse, with nothing selected — for a manual one.
pendingRetry = (target, authentication);
// Before the first await, so the tab is in the strip in the same turn the user asked for it. The // Before the first await, so the tab is in the strip in the same turn the user asked for it. The
// address is the one that will actually be dialled — a bound credential can supply the username — // address is the one that will actually be dialled — a bound credential can supply the username —
// rather than the host's own fields, so the tab does not rename itself on connecting. // rather than the host's own fields, so the tab does not rename itself on connecting.
var attempt = new ConnectionAttemptEventArgs( var attempt = new ConnectionAttemptEventArgs(
Guid.CreateVersion7(), Guid.CreateVersion7(),
row.Label, target.Label,
Dialled(row, authentication)); Dialled(target, authentication));
ConnectionStarting?.Invoke(this, attempt); ConnectionStarting?.Invoke(this, attempt);
Status = $"Connecting to {row.Label}…"; Status = $"Connecting to {target.Label}…";
await OpenSessionAsync(attempt, row, authentication, cancellationToken).ConfigureAwait(true); await OpenSessionAsync(attempt, target, authentication, cancellationToken).ConfigureAwait(true);
} }
/// <summary> /// <summary>
@@ -4962,7 +5173,15 @@ internal sealed partial class VaultViewModel(
PendingHostKey = null; PendingHostKey = null;
await ConnectToSelectedHostAsync(cancellationToken).ConfigureAwait(true); // The attempt that raised the question, replayed as it stood. Re-running the selected host was
// right while that was the only way to connect; with a manual target it would dial whichever host
// happens to be selected, or refuse with "choose a host first" over a machine the user has just
// agreed to trust.
if (pendingRetry is { } retry)
{
await ConnectToAsync(retry.Target, retry.Authentication, cancellationToken)
.ConfigureAwait(true);
}
// After connecting, not before. A pin is worth pushing straight away — the same host on another // After connecting, not before. A pin is worth pushing straight away — the same host on another
// machine should not ask again — but not at the cost of delaying the connection the user asked for. // machine should not ask again — but not at the cost of delaying the connection the user asked for.
@@ -5118,13 +5337,13 @@ internal sealed partial class VaultViewModel(
/// </remarks> /// </remarks>
private async Task OpenSessionAsync( private async Task OpenSessionAsync(
ConnectionAttemptEventArgs attempt, ConnectionAttemptEventArgs attempt,
HostRowViewModel row, ConnectionTarget target,
HostAuthentication authentication, HostAuthentication authentication,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
try try
{ {
await ConnectAndAnnounceAsync(attempt, row, authentication, cancellationToken) await ConnectAndAnnounceAsync(attempt, target, authentication, cancellationToken)
.ConfigureAwait(true); .ConfigureAwait(true);
} }
catch (TimeoutException) catch (TimeoutException)
@@ -5141,7 +5360,7 @@ internal sealed partial class VaultViewModel(
} }
catch (SshHostKeyMismatchException exception) catch (SshHostKeyMismatchException exception)
{ {
RecordFailure(row, authentication, ConnectionOutcome.Refused); RecordFailure(target, authentication, ConnectionOutcome.Refused);
HostKeyMismatch = exception.Message; HostKeyMismatch = exception.Message;
Answer(attempt, "The host key has changed. The connection was refused."); Answer(attempt, "The host key has changed. The connection was refused.");
@@ -5152,7 +5371,7 @@ internal sealed partial class VaultViewModel(
} }
catch (Exception exception) catch (Exception exception)
{ {
RecordFailure(row, authentication, ConnectionOutcome.Failed); RecordFailure(target, authentication, ConnectionOutcome.Failed);
Abandon(attempt, exception.Message); Abandon(attempt, exception.Message);
} }
} }
@@ -5197,15 +5416,15 @@ internal sealed partial class VaultViewModel(
/// </remarks> /// </remarks>
private async Task ConnectAndAnnounceAsync( private async Task ConnectAndAnnounceAsync(
ConnectionAttemptEventArgs attempt, ConnectionAttemptEventArgs attempt,
HostRowViewModel row, ConnectionTarget target,
HostAuthentication authentication, HostAuthentication authentication,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await workspace.WaitForRendererAsync(cancellationToken).ConfigureAwait(true); await workspace.WaitForRendererAsync(cancellationToken).ConfigureAwait(true);
var request = new SshConnectionRequest( var request = new SshConnectionRequest(
row.Host.Hostname, target.Hostname,
Resolve(row.Host).Port.Value, target.Port,
authentication.Username, authentication.Username,
authentication.Credential); authentication.Credential);
@@ -5216,10 +5435,11 @@ internal sealed partial class VaultViewModel(
// The workspace has already opened a ticket for this session, with the address and the moment it // The workspace has already opened a ticket for this session, with the address and the moment it
// connected. What it could not know is which keychain item this was — an SshConnectionRequest has no // connected. What it could not know is which keychain item this was — an SshConnectionRequest has no
// notion of one — so the name is added here rather than the ticket being replaced, which would move // notion of one — so the name is added here rather than the ticket being replaced, which would move
// the start time to now. // the start time to now. A target with no item still gets its name, and a null id: the entry is the
connectionLog?.Identify(sessionId, row.Label, row.EntityId); // only record that machine was reached at all.
connectionLog?.Identify(sessionId, target.Label, target.HostId);
Status = $"Connected to {row.Label}."; Status = $"Connected to {target.Label}.";
// Only now, and only on success. The page's own term.focus() focuses the textarea inside the // Only now, and only on success. The page's own term.focus() focuses the textarea inside the
// document, which does nothing while the window's keyboard focus is still on the Connect button — so // document, which does nothing while the window's keyboard focus is still on the Connect button — so
@@ -5229,8 +5449,8 @@ internal sealed partial class VaultViewModel(
new TerminalSessionEventArgs( new TerminalSessionEventArgs(
attempt.AttemptId, attempt.AttemptId,
sessionId, sessionId,
row.Label, target.Label,
Dialled(row, authentication))); Dialled(target, authentication)));
} }
/// <summary>The address as actually dialled.</summary> /// <summary>The address as actually dialled.</summary>
@@ -5241,10 +5461,10 @@ internal sealed partial class VaultViewModel(
/// remote saw. This string is what the terminal tab and the connection log are labelled with, and a log /// remote saw. This string is what the terminal tab and the connection log are labelled with, and a log
/// naming a port nothing dialled is worse than no log. /// naming a port nothing dialled is worse than no log.
/// </remarks> /// </remarks>
private static string Dialled(HostRowViewModel row, HostAuthentication authentication) => private static string Dialled(ConnectionTarget target, HostAuthentication authentication) =>
string.Create( string.Create(
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
$"{authentication.Username}@{row.Host.Hostname}:{row.Resolved.Port.Value}"); $"{authentication.Username}@{target.Hostname}:{target.Port}");
/// <summary> /// <summary>
/// Removes log entries this vault has agreed to stop keeping, at most once every few hours. /// Removes log entries this vault has agreed to stop keeping, at most once every few hours.
@@ -5298,16 +5518,16 @@ internal sealed partial class VaultViewModel(
/// the duration is zero and the outcome carries the meaning. /// the duration is zero and the outcome carries the meaning.
/// </remarks> /// </remarks>
private void RecordFailure( private void RecordFailure(
HostRowViewModel row, ConnectionTarget target,
HostAuthentication authentication, HostAuthentication authentication,
ConnectionOutcome outcome) ConnectionOutcome outcome)
{ {
var at = TimeProvider.System.GetUtcNow(); var at = TimeProvider.System.GetUtcNow();
connectionLog?.Record( connectionLog?.Record(
Dialled(row, authentication), Dialled(target, authentication),
row.Label, target.Label,
row.EntityId, target.HostId,
ConnectionKind.Terminal, ConnectionKind.Terminal,
at, at,
at, at,
@@ -5327,6 +5547,22 @@ internal sealed partial class VaultViewModel(
/// </remarks> /// </remarks>
private sealed record HostAuthentication(string Username, SshCredential Credential); private sealed record HostAuthentication(string Username, SshCredential Credential);
/// <summary>
/// The machine a connection is being made to, however it was named.
/// </summary>
/// <param name="Label">What to call it — a keychain host's alias, or what was typed.</param>
/// <param name="HostId">The keychain item, or null for somewhere that is not in it.</param>
/// <param name="Hostname">The address to dial.</param>
/// <param name="Port">The port to dial, already resolved through any group.</param>
/// <remarks>
/// This exists so the connect path stops being shaped like <see cref="HostRowViewModel"/>. Everything
/// below the resolution needs four facts and a row carries dozens; taking the four is what let a
/// connection to an address that has no keychain item share the ladder rather than grow a second one.
/// <see cref="ConnectionRecorder.Record"/> and <c>Identify</c> both take a nullable id already, so the
/// log has always been able to hold a connection with no item behind it.
/// </remarks>
private sealed record ConnectionTarget(string Label, Guid? HostId, string Hostname, int Port);
/// <summary> /// <summary>
/// Works out how a host authenticates, or says why it cannot. /// Works out how a host authenticates, or says why it cannot.
/// </summary> /// </summary>
@@ -1,4 +1,5 @@
using DodoSSH.Client.Auth; using DodoSSH.Client.Auth;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Import; using DodoSSH.Client.Import;
using DodoSSH.Client.Session; using DodoSSH.Client.Session;
// FakeDeviceKeyStore is compiled into this assembly from a source link and keeps its original namespace; // FakeDeviceKeyStore is compiled into this assembly from a source link and keeps its original namespace;
@@ -7,6 +8,7 @@ using DodoSSH.Client.Session.Tests;
using DodoSSH.Client.Shell.ViewModels; using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh; using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage; using DodoSSH.Client.Storage;
using DodoSSH.Client.Sync;
using DodoSSH.Client.Terminal; using DodoSSH.Client.Terminal;
using DodoSSH.Crypto; using DodoSSH.Crypto;
@@ -687,6 +689,220 @@ public sealed class ShellFlowTests : IAsyncLifetime
shell.Screen.ShouldBe(ShellScreen.Vault); shell.Screen.ShouldBe(ShellScreen.Vault);
} }
/// <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.
/// </remarks>
[Fact]
public async Task AManualTarget_IsDialledExactlyAsItWasTyped()
{
var vault = 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);
var request = ssh.Requests.ShouldHaveSingleItem();
request.Host.ShouldBe("build.internal");
request.Port.ShouldBe(2222);
request.Username.ShouldBe("deploy");
request.Credential.ShouldBeOfType<SshPasswordCredential>().Password.ShouldBe("hunter2");
shell.IsTerminalShowing.ShouldBeTrue();
shell.Tabs.ShouldHaveSingleItem().Label.ShouldBe("deploy@build.internal");
}
[Fact]
public async Task AManualTargetWithNoPort_TakesTwentyTwo()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.ManualTarget = "root@box";
vault.ManualPassword = "hunter2";
await vault.ConnectManuallyCommand.ExecuteAsync(null);
ssh.Requests.ShouldHaveSingleItem().Port.ShouldBe(22);
}
/// <remarks>
/// <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.
/// </remarks>
[Theory]
[InlineData("", "Type a machine")]
[InlineData("build.internal", "Say who to log in as")]
[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(
string typed,
string because)
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.ManualTarget = typed;
vault.ManualPassword = "hunter2";
await vault.ConnectManuallyCommand.ExecuteAsync(null);
vault.ManualStatus.ShouldContain(because);
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>
/// <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
/// 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.
/// </remarks>
[Fact]
public async Task TrustingAHostKey_RetriesTheAttemptThatRaisedItRatherThanTheSelectedHost()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.SelectedHost.ShouldNotBeNull();
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);
vault.HasPendingHostKey.ShouldBeTrue();
ssh.Failure = null;
await vault.TrustHostKeyCommand.ExecuteAsync(null);
ssh.Requests.Count.ShouldBe(2);
ssh.Requests[1].Host.ShouldBe("build.internal", "the retry is the attempt that asked the question");
ssh.Requests[1].Port.ShouldBe(2222);
ssh.Requests[1].Username.ShouldBe("deploy");
}
/// <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.
/// </remarks>
[Fact]
public async Task ARecentConnectionNamingAKeychainHost_OpensThatHostOnTheHostsScreen()
{
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));
vault.ManualTarget.ShouldBe("deploy@build.internal:2222");
vault.ManualPassword.ShouldBeEmpty();
shell.IsTerminalSurface.ShouldBeTrue("the box being filled in is on this surface");
}
/// <remarks>
/// A host deleted since it was connected to. The machine is still there and the keychain no longer knows
/// about it, so the address is the honest answer rather than a tap that does nothing.
/// </remarks>
[Fact]
public async Task ARecentConnectionNamingAHostThatHasGone_FallsBackToTheAddress()
{
var vault = await ReadyToConnectAsync();
shell.ShowTerminalCommand.Execute(null);
shell.ConnectToRecentCommand.Execute(
Recent("prod-db", "root@prod-db:22", Guid.CreateVersion7()));
vault.ManualTarget.ShouldBe("root@prod-db:22");
shell.IsTerminalSurface.ShouldBeTrue();
}
/// <summary>One row of the connection log, built by hand.</summary>
/// <remarks>
/// Built rather than connected-and-closed, because what these three tests are about is which of the two
/// branches a row takes — and driving that through a real connection, a real close and the recorder's own
/// queue would test the recorder instead, which <c>DodoSSH.Client.Session.Tests</c> already does.
/// </remarks>
private static ConnectionLogRowViewModel Recent(string label, string address, Guid? hostId) =>
new(
new VaultItem<ConnectionLogSecret>(
Guid.CreateVersion7(),
new ConnectionLogSecret
{
HostLabel = label,
Address = address,
HostId = hostId,
StartedAt = DateTimeOffset.UnixEpoch,
DeviceName = "a phone",
},
Version: 1,
HasUnsyncedChanges: false,
IsBlocked: false,
IsReadOnly: false),
isLive: false);
/// <remarks> /// <remarks>
/// The phone's connect menu is drawn over the terminal's own rectangle, so it obeys the rule the palette /// The phone's connect menu is drawn over the terminal's own rectangle, so it obeys the rule the palette
/// does: whatever covers the renderer collapses it instead. The surface stays, because the bar the menu /// does: whatever covers the renderer collapses it instead. The surface stays, because the bar the menu