Public Access
Draw what authenticated, and over what, on the session status bar
The v5b design's own row: the negotiated cipher, then the host key's algorithm and the name of the key or credential that authenticated, as one mono run beside CONNECTED — on both surfaces, off MainWindowViewModel's surface-aware SessionCipher and SessionIdentityText, the same shape SessionAddress set. The identity's name comes out of TryBuildAuthentication, the one resolution point that always had it in scope and always threw it away; it rides HostAuthentication to the tab and to the SFTP connect alike. Three deviations, recorded in the gaps doc: the algorithm prints as negotiated rather than shortened, the run is plain text because no pin-details modal exists for an open session, and a typed password shows the algorithm alone — there is no item behind the dot. A dead terminal tab keeps its facts for the scrollback still on screen; an SFTP disconnect, with no scrollback, clears them.
This commit is contained in:
@@ -8,36 +8,56 @@
|
||||
<!--
|
||||
── v5b's session shell status bar ───────────────────────────────────────────────────────────────────────
|
||||
37px, DeepChrome, a 1px top border — the foot of the bordered container both the terminal and the SFTP
|
||||
surface share. The design's own row also carries a negotiated cipher, a host-key algorithm and a
|
||||
key/credential name; none of the three is reachable from this shell today — see the wave B report this
|
||||
control's history carries, and design-notes/v5b-fidelity-notes.md's own decided deviation — so none of
|
||||
them is drawn, omitted rather than faked, per this project's honesty rule.
|
||||
surface share. The design's own row also carries a negotiated cipher and a host-key-plus-identity run
|
||||
(`ed25519 · acme-deploy-key`, clickable to a pin-details modal); both are now real and bound, plumbed all
|
||||
the way from SSH.NET through TerminalWorkspace.GetSessionFacts and TransfersViewModel to
|
||||
MainWindowViewModel's own surface-aware properties — see SessionCipher, SessionHostKeyAlgorithm,
|
||||
SessionIdentityLabel and the composed SessionIdentityText.
|
||||
|
||||
Three honest deviations from the mock. The algorithm prints exactly as negotiated — "ssh-ed25519", not
|
||||
the design's shortened "ed25519" — because trimming it would be a cosmetic edit to a string this client
|
||||
did not choose. The run is plain text, not a button: this application has no pin-details modal for a
|
||||
session that is already open, and drawing a click target for a screen that does not exist would be the
|
||||
fabrication this project's honesty rule forbids, not an honest omission of one. And a typed-password
|
||||
session — nothing filed in the keychain to name — shows the algorithm alone, with no " · " after it,
|
||||
because there is no item behind the dot.
|
||||
|
||||
What is real and bound: the CONNECTED word and dot, off IsSessionConnected, shown only while there is a
|
||||
session/host context to report on at all — SessionAddress null means nothing here has anything to say,
|
||||
the same state the header answers with its own empty-state sentence; the elapsed timer, off
|
||||
SessionElapsedText, which is null and therefore absent whenever there is nothing timed; and, only on the
|
||||
terminal surface — see <see cref="ShowsEncoding"/> — "UTF-8", which is a true fact about this client's own
|
||||
renderer and write path (see TerminalWorkspace's terminal.js and SshShellSessionExtensions.WriteTextAsync)
|
||||
rather than a negotiated session parameter, and is worded plainly rather than as a claim the remote agreed
|
||||
to it.
|
||||
the same state the header answers with its own empty-state sentence; the cipher and the host-key/identity
|
||||
run, each collapsed rather than shown empty or fabricated when the fact behind it is not there — a bucket
|
||||
connection has neither, and a session whose facts could not be read back at the instant they were asked
|
||||
for has neither either; the elapsed timer, off SessionElapsedText, which is null and therefore absent
|
||||
whenever there is nothing timed; and, only on the terminal surface — see <see cref="ShowsEncoding"/> —
|
||||
"UTF-8", which is a true fact about this client's own renderer and write path (see TerminalWorkspace's
|
||||
terminal.js and SshShellSessionExtensions.WriteTextAsync) rather than a negotiated session parameter, and
|
||||
is worded plainly rather than as a claim the remote agreed to it.
|
||||
-->
|
||||
|
||||
<Border Height="37" Background="{StaticResource DeepChrome}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0">
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="24,0">
|
||||
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="7" VerticalAlignment="Center"
|
||||
IsVisible="{Binding SessionAddress, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<Ellipse Classes="dot" Width="7" Height="7" Classes.live="{Binding IsSessionConnected}"
|
||||
VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="18" VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center"
|
||||
IsVisible="{Binding SessionAddress, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<Ellipse Classes="dot" Width="7" Height="7" Classes.live="{Binding IsSessionConnected}"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
|
||||
VerticalAlignment="Center" Text="CONNECTED" Foreground="{StaticResource Live}"
|
||||
IsVisible="{Binding IsSessionConnected}" />
|
||||
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
|
||||
VerticalAlignment="Center" Text="NOT CONNECTED" Foreground="{StaticResource TextFaint}"
|
||||
IsVisible="{Binding !IsSessionConnected}" />
|
||||
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
|
||||
VerticalAlignment="Center" Text="CONNECTED" Foreground="{StaticResource Live}"
|
||||
IsVisible="{Binding IsSessionConnected}" />
|
||||
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
|
||||
VerticalAlignment="Center" Text="NOT CONNECTED" Foreground="{StaticResource TextFaint}"
|
||||
IsVisible="{Binding !IsSessionConnected}" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
|
||||
VerticalAlignment="Center" Text="{Binding SessionCipher}"
|
||||
IsVisible="{Binding SessionCipher, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
|
||||
VerticalAlignment="Center" Text="{Binding SessionIdentityText}"
|
||||
IsVisible="{Binding SessionIdentityText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="18" VerticalAlignment="Center">
|
||||
|
||||
@@ -4,8 +4,9 @@ using Avalonia.Controls;
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The v5b session shell's status bar: CONNECTED and its dot, the elapsed timer, and — only where it is true
|
||||
/// — UTF-8. See the remark at the top of SessionStatusBar.axaml.
|
||||
/// The v5b session shell's status bar: CONNECTED and its dot, the negotiated cipher, the host key's algorithm
|
||||
/// and — where one authenticated — the identity that did, the elapsed timer, and — only where it is true —
|
||||
/// UTF-8. See the remark at the top of SessionStatusBar.axaml.
|
||||
/// </summary>
|
||||
internal sealed partial class SessionStatusBar : UserControl
|
||||
{
|
||||
|
||||
@@ -3400,7 +3400,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
// No tab was opened for this attempt, which means the user closed the connecting tab while the
|
||||
// handshake was still running. The session is real and has to be adopted rather than dropped:
|
||||
// dropping it would leave a shell running with nothing in the window naming it.
|
||||
var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address) { StartedAt = clock.GetUtcNow() };
|
||||
var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address)
|
||||
{
|
||||
StartedAt = clock.GetUtcNow(),
|
||||
Cipher = NullIfEmpty(e.Cipher),
|
||||
HostKeyAlgorithm = NullIfEmpty(e.HostKeyAlgorithm),
|
||||
IdentityLabel = e.IdentityLabel,
|
||||
};
|
||||
AdoptTab(adopted);
|
||||
RefreshConnectedHosts();
|
||||
return;
|
||||
@@ -3411,6 +3417,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
// From this moment, not from when the tab first appeared — connecting is not open, and the session
|
||||
// shell's elapsed timer is about a shell that is actually running.
|
||||
tab.StartedAt = clock.GetUtcNow();
|
||||
tab.Cipher = NullIfEmpty(e.Cipher);
|
||||
tab.HostKeyAlgorithm = NullIfEmpty(e.HostKeyAlgorithm);
|
||||
tab.IdentityLabel = e.IdentityLabel;
|
||||
|
||||
// The pane exists from this moment, so what the rectangle should hold has changed — the card goes and
|
||||
// the WebView comes back. Only for the tab being looked at, which is what these flags already ask.
|
||||
@@ -3914,6 +3923,69 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The negotiated server-to-client cipher the status bar shows beside CONNECTED, or null when neither
|
||||
/// surface has a session — or when the one it has has nothing to report.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One property reading whichever surface is showing, for the same reason <see cref="SessionAddress"/>
|
||||
/// is: <c>SessionStatusBar.axaml</c> is the same markup on both surfaces. The terminal's is
|
||||
/// <see cref="TerminalTabViewModel.Cipher"/>; SFTP's is <see cref="TransfersViewModel.ConnectedCipher"/>.
|
||||
/// </remarks>
|
||||
internal string? SessionCipher => Surface switch
|
||||
{
|
||||
ShellSurface.Terminal => NullIfEmpty(SelectedTab?.Cipher),
|
||||
_ when IsTransfersShowing => NullIfEmpty(Transfers.ConnectedCipher),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>The accepted host key's algorithm, e.g. <c>ssh-ed25519</c> — printed as negotiated, not shortened.</summary>
|
||||
/// <remarks>See <see cref="SessionCipher"/>; resolved the same way, off <see cref="TerminalTabViewModel.HostKeyAlgorithm"/> or <see cref="TransfersViewModel.ConnectedHostKeyAlgorithm"/>.</remarks>
|
||||
internal string? SessionHostKeyAlgorithm => Surface switch
|
||||
{
|
||||
ShellSurface.Terminal => NullIfEmpty(SelectedTab?.HostKeyAlgorithm),
|
||||
_ when IsTransfersShowing => NullIfEmpty(Transfers.ConnectedHostKeyAlgorithm),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The display name of the key or credential that authenticated, or null when a typed password did or
|
||||
/// nothing is connected.
|
||||
/// </summary>
|
||||
/// <remarks>See <see cref="SessionCipher"/>; resolved the same way, off <see cref="TerminalTabViewModel.IdentityLabel"/> or <see cref="TransfersViewModel.ConnectedIdentityLabel"/>.</remarks>
|
||||
internal string? SessionIdentityLabel => Surface switch
|
||||
{
|
||||
ShellSurface.Terminal => SelectedTab?.IdentityLabel,
|
||||
_ when IsTransfersShowing => Transfers.ConnectedIdentityLabel,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The status bar's one run of text for the host key and the identity that authenticated — the design's
|
||||
/// <c>ed25519 · acme-deploy-key</c> — or null while <see cref="SessionHostKeyAlgorithm"/> is.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Composed here rather than in the view, so <c>SessionStatusBar.axaml</c> binds one <c>TextBlock</c> to
|
||||
/// one string instead of assembling a separator between two bindings that can each be absent on their own.
|
||||
/// The algorithm prints exactly as negotiated — <c>ssh-ed25519</c>, not the design's shortened
|
||||
/// <c>ed25519</c> — because trimming it would be a cosmetic claim about a string this shell has no
|
||||
/// business editing. A typed-password session has no item to name, so it shows the algorithm alone with
|
||||
/// no <c>·</c> — there being nothing after the dot would be a punctuation mark standing in for the fact
|
||||
/// that was never real.
|
||||
/// </remarks>
|
||||
internal string? SessionIdentityText => SessionHostKeyAlgorithm is { } algorithm
|
||||
? SessionIdentityLabel is { } label ? $"{algorithm} · {label}" : algorithm
|
||||
: null;
|
||||
|
||||
/// <summary>Null for an empty string, unchanged otherwise.</summary>
|
||||
/// <remarks>
|
||||
/// <see cref="ISshConnection.Cipher"/> and <see cref="HostKeyPresentation.Algorithm"/> are non-nullable
|
||||
/// strings that this shell nonetheless treats as absent when empty — a session whose facts genuinely
|
||||
/// could not be read back (see <c>TerminalWorkspace.GetSessionFacts</c>) hands over <see cref="string.Empty"/>
|
||||
/// rather than null, and the status bar's collapse bindings only know how to ask about null.
|
||||
/// </remarks>
|
||||
private static string? NullIfEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value;
|
||||
|
||||
/// <summary>Whether the session the header and status bar are describing is actually open.</summary>
|
||||
/// <remarks>
|
||||
/// Not the same question as <see cref="SessionAddress"/> being non-null on the terminal surface: a tab
|
||||
@@ -3981,6 +4053,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
OnPropertyChanged(nameof(SessionAddress));
|
||||
OnPropertyChanged(nameof(IsSessionConnected));
|
||||
OnPropertyChanged(nameof(SessionElapsedText));
|
||||
OnPropertyChanged(nameof(SessionCipher));
|
||||
OnPropertyChanged(nameof(SessionHostKeyAlgorithm));
|
||||
OnPropertyChanged(nameof(SessionIdentityLabel));
|
||||
OnPropertyChanged(nameof(SessionIdentityText));
|
||||
OnPropertyChanged(nameof(ShowsQuickAccessSidebar));
|
||||
}
|
||||
|
||||
@@ -3996,7 +4072,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
if (e.PropertyName is nameof(TransfersViewModel.SelectedHost)
|
||||
or nameof(TransfersViewModel.IsConnected)
|
||||
or nameof(TransfersViewModel.Remote)
|
||||
or nameof(TransfersViewModel.ConnectedTo))
|
||||
or nameof(TransfersViewModel.ConnectedTo)
|
||||
or nameof(TransfersViewModel.ConnectedCipher)
|
||||
or nameof(TransfersViewModel.ConnectedHostKeyAlgorithm)
|
||||
or nameof(TransfersViewModel.ConnectedIdentityLabel))
|
||||
{
|
||||
RaiseSessionState();
|
||||
}
|
||||
|
||||
@@ -28,10 +28,11 @@ internal enum TerminalTabState
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A tab is a session id and two strings. It holds no terminal and owns nothing: the pane, its scrollback
|
||||
/// and the shell behind it all live in the renderer and in <c>TerminalWorkspace</c>, and selecting a tab is
|
||||
/// one frame telling the page which pane to show. That is what makes tabs cheap here — the expensive object
|
||||
/// is the WebView, and there is one of those however many tabs are open.
|
||||
/// A tab is a session id and a handful of strings — the address, and now the three connection facts the
|
||||
/// status bar draws beside CONNECTED. It holds no terminal and owns nothing: the pane, its scrollback and the
|
||||
/// shell behind it all live in the renderer and in <c>TerminalWorkspace</c>, and selecting a tab is one frame
|
||||
/// telling the page which pane to show. That is what makes tabs cheap here — the expensive object is the
|
||||
/// WebView, and there is one of those however many tabs are open.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A tab starts before its session does.</b> Connecting is a network round trip that can take as long as
|
||||
@@ -102,6 +103,31 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
||||
/// </remarks>
|
||||
internal DateTimeOffset? StartedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The negotiated server-to-client cipher, for the status bar — or null while there is no session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Set alongside <see cref="StartedAt"/>, from the same event, and kept for the same reason: a dead tab's
|
||||
/// pane still holds the scrollback of a session that really did negotiate this cipher, and clearing the
|
||||
/// fact when the shell ends would not make it less true of what is on screen.
|
||||
/// </remarks>
|
||||
internal string? Cipher { get; set; }
|
||||
|
||||
/// <summary>The accepted host key's algorithm, e.g. <c>ssh-ed25519</c> — or null while there is no session.</summary>
|
||||
/// <remarks>See <see cref="Cipher"/>; set and kept the same way, for the same reason.</remarks>
|
||||
internal string? HostKeyAlgorithm { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The display name of the key or credential that authenticated, or null when a typed password did, or
|
||||
/// null while there is no session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Three different reasons collapse to the same null, and that is deliberate: nothing downstream needs to
|
||||
/// tell a session with no identity to name apart from a tab that has not opened one yet — both mean the
|
||||
/// status bar shows the host-key algorithm alone, with no ` · name` after it.
|
||||
/// </remarks>
|
||||
internal string? IdentityLabel { get; set; }
|
||||
|
||||
/// <inheritdoc cref="TerminalTabState" />
|
||||
[ObservableProperty]
|
||||
private TerminalTabState state;
|
||||
|
||||
@@ -606,6 +606,37 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
[ObservableProperty]
|
||||
private string? connectedTo;
|
||||
|
||||
/// <summary>
|
||||
/// The negotiated server-to-client cipher for the open SFTP session, for the v5b status bar — or null
|
||||
/// while nothing is connected, or while what is connected is a bucket rather than a host.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A bucket is <c>IRemoteFileStore</c> with no SSH underneath it at all, so it has no cipher, no host key
|
||||
/// and no identity to name — <see cref="OpenBucketAsync"/> leaves all three null rather than each reading
|
||||
/// as "not yet known", which is the meaning null already carries for a host that has not connected yet.
|
||||
/// Read off the concrete <c>ISftpSession</c> at connect time, the same moment <see cref="ConnectedTo"/> is
|
||||
/// set, because <see cref="session"/> itself is typed as <c>IRemoteFileStore</c> and does not carry it.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string? connectedCipher;
|
||||
|
||||
/// <summary>The accepted host key's algorithm, e.g. <c>ssh-ed25519</c>. See <see cref="ConnectedCipher"/>.</summary>
|
||||
[ObservableProperty]
|
||||
private string? connectedHostKeyAlgorithm;
|
||||
|
||||
/// <summary>
|
||||
/// The display name of the key or credential that authenticated, or null when a typed password did, or
|
||||
/// null while nothing is connected.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Threaded from <see cref="VaultViewModel.TryBuildConnectionRequest"/>'s own out parameter rather than
|
||||
/// re-resolved here: the label names a keychain item this screen has no authentication ladder of its own
|
||||
/// to climb, and a second lookup would be a second place for a stale binding to answer differently than
|
||||
/// the one that actually authenticated.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string? connectedIdentityLabel;
|
||||
|
||||
[ObservableProperty]
|
||||
private HostKeyPresentation? pendingHostKey;
|
||||
|
||||
@@ -874,6 +905,12 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
ConnectedTo = string.Create(
|
||||
CultureInfo.InvariantCulture, $"s3://{row.Store.Bucket}");
|
||||
|
||||
// No SSH underneath a bucket, so none of the three has an honest value — see
|
||||
// ConnectedCipher's own remark.
|
||||
ConnectedCipher = null;
|
||||
ConnectedHostKeyAlgorithm = null;
|
||||
ConnectedIdentityLabel = null;
|
||||
|
||||
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
|
||||
|
||||
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
|
||||
@@ -891,7 +928,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
return;
|
||||
}
|
||||
|
||||
if (!open.TryBuildConnectionRequest(row.Host, TypedPassword, out var request, out var refusal))
|
||||
if (!open.TryBuildConnectionRequest(
|
||||
row.Host, TypedPassword, out var request, out var identityLabel, out var refusal))
|
||||
{
|
||||
Status = refusal;
|
||||
return;
|
||||
@@ -906,9 +944,11 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
{
|
||||
await CloseSessionAsync().ConfigureAwait(true);
|
||||
|
||||
ISftpSession opened;
|
||||
|
||||
try
|
||||
{
|
||||
session = await sftp.OpenSftpAsync(request, cancellationToken).ConfigureAwait(true);
|
||||
opened = await sftp.OpenSftpAsync(request, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
catch (SshHostKeyUnknownException exception)
|
||||
{
|
||||
@@ -926,17 +966,9 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
return;
|
||||
}
|
||||
|
||||
session = opened;
|
||||
TypedPassword = string.Empty;
|
||||
IsConnected = true;
|
||||
ConnectedTo = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{request.Username}@{request.Host}:{request.Port}");
|
||||
|
||||
// Recorded, and not hidden because it is "only" the file browser. Opening this is a second
|
||||
// login as far as the remote's own auth.log is concerned, so a log of ours that omitted it
|
||||
// would disagree with the host's — and anybody comparing the two would be right to believe
|
||||
// the host.
|
||||
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
|
||||
MarkHostConnected(opened, request, row, identityLabel);
|
||||
|
||||
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
@@ -944,6 +976,33 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records that a host connection just succeeded: the address, the negotiated facts, and the ticket the
|
||||
/// disconnect log closes out later.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Split out of <see cref="ConnectToHostAsync"/> for length rather than for reuse — <see cref="OpenBucketAsync"/>
|
||||
/// sets the same four properties its own way, with no SSH underneath to read the last three off. Reads
|
||||
/// the cipher and host key off <paramref name="opened"/> rather than <see cref="session"/>, which is typed
|
||||
/// as <c>IRemoteFileStore</c> and does not carry either.
|
||||
/// </remarks>
|
||||
private void MarkHostConnected(
|
||||
ISftpSession opened, SshConnectionRequest request, HostRowViewModel row, string? identityLabel)
|
||||
{
|
||||
IsConnected = true;
|
||||
ConnectedTo = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{request.Username}@{request.Host}:{request.Port}");
|
||||
ConnectedCipher = opened.Cipher;
|
||||
ConnectedHostKeyAlgorithm = opened.HostKey.Algorithm;
|
||||
ConnectedIdentityLabel = identityLabel;
|
||||
|
||||
// Recorded, and not hidden because it is "only" the file browser. Opening this is a second login as
|
||||
// far as the remote's own auth.log is concerned, so a log of ours that omitted it would disagree with
|
||||
// the host's — and anybody comparing the two would be right to believe the host.
|
||||
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes the file-transfer session.
|
||||
/// </summary>
|
||||
@@ -1713,6 +1772,9 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
|
||||
IsConnected = false;
|
||||
ConnectedTo = null;
|
||||
ConnectedCipher = null;
|
||||
ConnectedHostKeyAlgorithm = null;
|
||||
ConnectedIdentityLabel = null;
|
||||
RemotePath = string.Empty;
|
||||
RemoteEntries.Clear();
|
||||
RemoteTrail.Clear();
|
||||
|
||||
@@ -888,11 +888,19 @@ internal static class ItemBadge
|
||||
/// <param name="sessionId">Identifies the session to the renderer and to the workspace.</param>
|
||||
/// <param name="label">The host's name, as the vault has it.</param>
|
||||
/// <param name="address">The account and endpoint actually dialled.</param>
|
||||
/// <param name="cipher">The negotiated server-to-client cipher; see <see cref="ISshConnection.Cipher"/>.</param>
|
||||
/// <param name="hostKeyAlgorithm">The accepted host key's algorithm, e.g. <c>ssh-ed25519</c>.</param>
|
||||
/// <param name="identityLabel">
|
||||
/// The display name of the key or credential that authenticated, or null when a typed password did.
|
||||
/// </param>
|
||||
internal sealed class TerminalSessionEventArgs(
|
||||
Guid attemptId,
|
||||
uint sessionId,
|
||||
string label,
|
||||
string address) : EventArgs
|
||||
string address,
|
||||
string cipher,
|
||||
string hostKeyAlgorithm,
|
||||
string? identityLabel) : EventArgs
|
||||
{
|
||||
/// <summary>Which attempt this session came out of.</summary>
|
||||
/// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" path="/remarks" />
|
||||
@@ -903,6 +911,12 @@ internal sealed class TerminalSessionEventArgs(
|
||||
internal string Label { get; } = label;
|
||||
|
||||
internal string Address { get; } = address;
|
||||
|
||||
internal string Cipher { get; } = cipher;
|
||||
|
||||
internal string HostKeyAlgorithm { get; } = hostKeyAlgorithm;
|
||||
|
||||
internal string? IdentityLabel { get; } = identityLabel;
|
||||
}
|
||||
|
||||
/// <summary>A connection that has been asked for, and has not answered yet.</summary>
|
||||
@@ -11085,6 +11099,13 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
Status = $"Connected to {target.Label}.";
|
||||
|
||||
// The session the workspace just opened is the only place the negotiated cipher and host-key
|
||||
// algorithm live — an SshConnectionRequest asks for neither and gets no say in either — so they are
|
||||
// read back here rather than carried forward from somewhere upstream that never had them. Absent only
|
||||
// for a session that has already ended in the instant between opening it and asking, which the empty
|
||||
// string collapses to on the status bar exactly as a session with nothing to report would.
|
||||
var facts = workspace.GetSessionFacts(sessionId);
|
||||
|
||||
// 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
|
||||
// without this the first keystrokes of the session go to the shell's UI instead of the remote shell.
|
||||
@@ -11094,7 +11115,10 @@ internal sealed partial class VaultViewModel(
|
||||
attempt.AttemptId,
|
||||
sessionId,
|
||||
target.Label,
|
||||
Dialled(target, authentication)));
|
||||
Dialled(target, authentication),
|
||||
facts?.Cipher ?? string.Empty,
|
||||
facts?.HostKeyAlgorithm ?? string.Empty,
|
||||
authentication.IdentityLabel));
|
||||
|
||||
// Last, and after the tab exists: keeping the password is a favour, and the session the user asked
|
||||
// for must not wait on a vault write to appear.
|
||||
@@ -11279,13 +11303,19 @@ internal sealed partial class VaultViewModel(
|
||||
/// </summary>
|
||||
/// <param name="Username">The account to log in as, after any credential has had its say.</param>
|
||||
/// <param name="Credential">What proves it.</param>
|
||||
/// <param name="IdentityLabel">
|
||||
/// The display name of the keychain item that authenticates — a key's or a credential's own
|
||||
/// <c>Label</c> — or null when the password was typed rather than filed. Carried alongside the credential
|
||||
/// so the session shell's status bar can name what actually authenticated without a second lookup back
|
||||
/// into <see cref="Keys"/> or <see cref="Credentials"/> once the connection has moved on.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// The two travel together because a credential can change both. Returning only the secret and reading the
|
||||
/// username off the host separately is what the connect path used to do, and it would have sent a stored
|
||||
/// credential's password under the host's username — which is the one combination that is wrong in a way
|
||||
/// the server reports as "authentication failed".
|
||||
/// </remarks>
|
||||
private sealed record HostAuthentication(string Username, SshCredential Credential);
|
||||
private sealed record HostAuthentication(string Username, SshCredential Credential, string? IdentityLabel = null);
|
||||
|
||||
/// <summary>
|
||||
/// The machine a connection is being made to, however it was named.
|
||||
@@ -11359,6 +11389,7 @@ internal sealed partial class VaultViewModel(
|
||||
HostSecret host,
|
||||
string typedPassword,
|
||||
[NotNullWhen(true)] out SshConnectionRequest? request,
|
||||
out string? identityLabel,
|
||||
[NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(host);
|
||||
@@ -11368,6 +11399,7 @@ internal sealed partial class VaultViewModel(
|
||||
if (!TryBuildAuthentication(host, resolved, typedPassword, out var authentication, out reason))
|
||||
{
|
||||
request = null;
|
||||
identityLabel = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -11377,6 +11409,8 @@ internal sealed partial class VaultViewModel(
|
||||
authentication.Username,
|
||||
authentication.Credential);
|
||||
|
||||
identityLabel = authentication.IdentityLabel;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -11428,6 +11462,7 @@ internal sealed partial class VaultViewModel(
|
||||
return Complete(
|
||||
credential.Credential.Username ?? resolved.Username.Value,
|
||||
new SshPasswordCredential(credential.Credential.Password),
|
||||
credential.Label,
|
||||
out authentication,
|
||||
out reason);
|
||||
}
|
||||
@@ -11446,13 +11481,18 @@ internal sealed partial class VaultViewModel(
|
||||
resolved.Username.Value,
|
||||
new SshPrivateKeyCredential(
|
||||
Encoding.UTF8.GetBytes(key.Key.PrivateKeyPem), key.Key.Passphrase),
|
||||
key.Label,
|
||||
out authentication,
|
||||
out reason);
|
||||
}
|
||||
|
||||
// No item to name: a typed password has nothing filed in the keychain, and IdentityLabel stays null —
|
||||
// the default Complete's own signature gives it — rather than the placeholder text a display-only
|
||||
// fallback would tempt someone to invent.
|
||||
return Complete(
|
||||
resolved.Username.Value,
|
||||
new SshPasswordCredential(typedPassword),
|
||||
identityLabel: null,
|
||||
out authentication,
|
||||
out reason);
|
||||
}
|
||||
@@ -11476,6 +11516,7 @@ internal sealed partial class VaultViewModel(
|
||||
private static bool Complete(
|
||||
string? username,
|
||||
SshCredential credential,
|
||||
string? identityLabel,
|
||||
out HostAuthentication? authentication,
|
||||
[NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
@@ -11487,7 +11528,7 @@ internal sealed partial class VaultViewModel(
|
||||
out reason);
|
||||
}
|
||||
|
||||
authentication = new HostAuthentication(username, credential);
|
||||
authentication = new HostAuthentication(username, credential, identityLabel);
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user