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
@@ -65,6 +65,15 @@ internal sealed class ConnectionLogRowViewModel(VaultItem<ConnectionLogSecret> e
/// <summary>Whether this was a terminal or the file browser.</summary>
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;
/// <remarks>
@@ -220,11 +229,33 @@ internal sealed partial class LogsViewModel : ObservableObject
/// <summary>Reads both logs into the lists.</summary>
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)
.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)
.ConfigureAwait(true);
@@ -257,15 +288,7 @@ internal sealed partial class LogsViewModel : ObservableObject
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(HasActivity));
}
partial void OnSectionChanged(LogSection value)
@@ -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();
}
@@ -1992,6 +1992,44 @@ internal sealed partial class VaultViewModel(
[ObservableProperty]
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>
/// Whether the selected host will want something typed into the password box.
/// </summary>
@@ -4911,21 +4949,194 @@ internal sealed partial class VaultViewModel(
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;
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
// 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.
var attempt = new ConnectionAttemptEventArgs(
Guid.CreateVersion7(),
row.Label,
Dialled(row, authentication));
target.Label,
Dialled(target, authentication));
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>
@@ -4962,7 +5173,15 @@ internal sealed partial class VaultViewModel(
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
// 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>
private async Task OpenSessionAsync(
ConnectionAttemptEventArgs attempt,
HostRowViewModel row,
ConnectionTarget target,
HostAuthentication authentication,
CancellationToken cancellationToken)
{
try
{
await ConnectAndAnnounceAsync(attempt, row, authentication, cancellationToken)
await ConnectAndAnnounceAsync(attempt, target, authentication, cancellationToken)
.ConfigureAwait(true);
}
catch (TimeoutException)
@@ -5141,7 +5360,7 @@ internal sealed partial class VaultViewModel(
}
catch (SshHostKeyMismatchException exception)
{
RecordFailure(row, authentication, ConnectionOutcome.Refused);
RecordFailure(target, authentication, ConnectionOutcome.Refused);
HostKeyMismatch = exception.Message;
Answer(attempt, "The host key has changed. The connection was refused.");
@@ -5152,7 +5371,7 @@ internal sealed partial class VaultViewModel(
}
catch (Exception exception)
{
RecordFailure(row, authentication, ConnectionOutcome.Failed);
RecordFailure(target, authentication, ConnectionOutcome.Failed);
Abandon(attempt, exception.Message);
}
}
@@ -5197,15 +5416,15 @@ internal sealed partial class VaultViewModel(
/// </remarks>
private async Task ConnectAndAnnounceAsync(
ConnectionAttemptEventArgs attempt,
HostRowViewModel row,
ConnectionTarget target,
HostAuthentication authentication,
CancellationToken cancellationToken)
{
await workspace.WaitForRendererAsync(cancellationToken).ConfigureAwait(true);
var request = new SshConnectionRequest(
row.Host.Hostname,
Resolve(row.Host).Port.Value,
target.Hostname,
target.Port,
authentication.Username,
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
// 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
// the start time to now.
connectionLog?.Identify(sessionId, row.Label, row.EntityId);
// the start time to now. A target with no item still gets its name, and a null id: the entry is the
// 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
// 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(
attempt.AttemptId,
sessionId,
row.Label,
Dialled(row, authentication)));
target.Label,
Dialled(target, authentication)));
}
/// <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
/// naming a port nothing dialled is worse than no log.
/// </remarks>
private static string Dialled(HostRowViewModel row, HostAuthentication authentication) =>
private static string Dialled(ConnectionTarget target, HostAuthentication authentication) =>
string.Create(
CultureInfo.InvariantCulture,
$"{authentication.Username}@{row.Host.Hostname}:{row.Resolved.Port.Value}");
$"{authentication.Username}@{target.Hostname}:{target.Port}");
/// <summary>
/// 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.
/// </remarks>
private void RecordFailure(
HostRowViewModel row,
ConnectionTarget target,
HostAuthentication authentication,
ConnectionOutcome outcome)
{
var at = TimeProvider.System.GetUtcNow();
connectionLog?.Record(
Dialled(row, authentication),
row.Label,
row.EntityId,
Dialled(target, authentication),
target.Label,
target.HostId,
ConnectionKind.Terminal,
at,
at,
@@ -5327,6 +5547,22 @@ internal sealed partial class VaultViewModel(
/// </remarks>
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>
/// Works out how a host authenticates, or says why it cannot.
/// </summary>