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
@@ -775,20 +775,27 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
internal bool IsBucketsShowing => IsShowingPages && IsBucketsScreen;
/// <summary>
/// Whether the phone's MORE tab should light.
/// Whether the phone's SETTINGS tab should light.
/// </summary>
/// <remarks>
/// 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
/// place where "which tab" and "which screen" are deliberately not the same question — the other three
/// tabs are each exactly one screen, and this one is six.
/// 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 two
/// 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
/// 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>
internal bool IsMoreSurface =>
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>
/// Whether the terminal's WebView may be on screen at this instant.
@@ -899,7 +906,127 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </para>
/// </remarks>
[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>
/// 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);
// 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();
}