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:
@@ -297,7 +297,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
|||||||
> | The host header's OS label (`Ubuntu 24.04 LTS`) and latency reading (`12 ms`) | Neither. The client does not know the remote's OS and nothing measures round-trip time — the same two absences the terminal pane header already recorded before this pass, carried into the new header rather than reopened. |
|
> | The host header's OS label (`Ubuntu 24.04 LTS`) and latency reading (`12 ms`) | Neither. The client does not know the remote's OS and nothing measures round-trip time — the same two absences the terminal pane header already recorded before this pass, carried into the new header rather than reopened. |
|
||||||
> | A **Port forward** button on the host header | Omitted. The feature does not exist; see the FORWARDING rows earlier in this document. |
|
> | A **Port forward** button on the host header | Omitted. The feature does not exist; see the FORWARDING rows earlier in this document. |
|
||||||
> | The tab and status dots' third, amber state | ◆ **Still two states**, for the reason the hosts screen's own dot has stayed two states since v3: green means a shell is open (or a session is connected), grey means it is not, and nothing here pings a host to justify a third colour meaning "reachable but not connected". |
|
> | The tab and status dots' third, amber state | ◆ **Still two states**, for the reason the hosts screen's own dot has stayed two states since v3: green means a shell is open (or a session is connected), grey means it is not, and nothing here pings a host to justify a third colour meaning "reachable but not connected". |
|
||||||
> | The status bar's negotiated cipher, host-key algorithm and key/credential name | Omitted, and recorded rather than silently dropped. None of the three is reachable from this shell today: `SshNetConnection` holds the negotiated algorithms but nothing reads them off `ISshConnection`; `HostKeyPresentation` exists only for the trust questions a connection asks on the way in, not for a session already open; and a key's display name is never threaded past the connect call into anything the session keeps. What is real and bound is CONNECTED plus a dot, the session's own elapsed timer, and — terminal surface only — `UTF-8`, worded as a fact about this client's own renderer rather than a claim the remote agreed to. Candidates for future plumbing, not a defect closed here. |
|
> | The status bar's negotiated cipher, host-key algorithm and key/credential name | ◆ **Shipped, on both surfaces, with three honest deviations.** `ISshConnection` and `ISftpSession` now both carry `Cipher` — the server-to-client algorithm off SSH.NET's own `ConnectionInfo.CurrentServerEncryption`, captured once at construction because a rekey is not an event SSH.NET raises — and `TerminalWorkspace.GetSessionFacts` hands the cipher and the host key's algorithm back to the shell the moment a session opens; `VaultViewModel.TryBuildAuthentication` now threads the authenticating key's or credential's own `Label` into `HostAuthentication.IdentityLabel`, all the way to `MainWindowViewModel`'s surface-aware `SessionCipher`, `SessionHostKeyAlgorithm` and `SessionIdentityLabel`, composed into one `SessionIdentityText` run for the status bar. Three deviations from the mock, not omissions: the algorithm prints exactly as negotiated (`ssh-ed25519`), not the design's shortened `ed25519`, because trimming it would be an edit to a string this client did not choose; the run is plain text rather than the design's clickable element, because there is no pin-details modal for a session that is already open, and drawing a click target for a screen that does not exist would itself be a fabrication; and a typed-password session — nothing filed in the keychain to name — shows the host-key algorithm alone, with no `·` after it, because there is no item behind the dot. |
|
||||||
> | S3 dimmed in the design's own switcher | **Enabled.** The mock leaves S3 as future work; this application already has bucket browsing, so SSH, SFTP and S3 are a true three-way segment, wired to `IsSshShowing`, `IsTransfersShowing` and `IsBucketsShowing` exactly alike. |
|
> | S3 dimmed in the design's own switcher | **Enabled.** The mock leaves S3 as future work; this application already has bucket browsing, so SSH, SFTP and S3 are a true three-way segment, wired to `IsSshShowing`, `IsTransfersShowing` and `IsBucketsShowing` exactly alike. |
|
||||||
> | The S3/Buckets screen | **Did not get the session shell in v5b.** `TransfersScreen` serves both SFTP and S3 today and only the SFTP usage in `MainWindow.axaml` sat inside the new tab row/header/status bar/sidebar; the S3 usage was unchanged at the time. **v5c gives it the shell's own look without the machinery** — a 26-pixel padded, bordered, radius-12 container and nothing past that, since a bucket has no tab to close, no host to head a card with and no pin for a sidebar to show; see the v5c section, below. |
|
> | The S3/Buckets screen | **Did not get the session shell in v5b.** `TransfersScreen` serves both SFTP and S3 today and only the SFTP usage in `MainWindow.axaml` sat inside the new tab row/header/status bar/sidebar; the S3 usage was unchanged at the time. **v5c gives it the shell's own look without the machinery** — a 26-pixel padded, bordered, radius-12 container and nothing past that, since a bucket has no tab to close, no host to head a card with and no pin for a sidebar to show; see the v5c section, below. |
|
||||||
> | No pins destination in the design at all | **Kept anyway.** The rail still carries Pins — `KnownHostsScreen` — because the mock has no screen for approved host keys and this application's has to stay reachable. |
|
> | No pins destination in the design at all | **Kept anyway.** The rail still carries Pins — `KnownHostsScreen` — because the mock has no screen for approved host keys and this application's has to stay reachable. |
|
||||||
@@ -537,7 +537,7 @@ caption buttons and window title drawn on top of the application's own — two s
|
|||||||
| `SNIPPETS` panel, `↵` to run | client-domain | A snippet item type (`Snippet = 8`, reserved). | **Shipped**, as a screen rather than a panel. `↵` is per snippet and off by default: inserting types the command at the prompt and stops, because nothing here can tell whether the terminal is at a prompt at all. |
|
| `SNIPPETS` panel, `↵` to run | client-domain | A snippet item type (`Snippet = 8`, reserved). | **Shipped**, as a screen rather than a panel. `↵` is per snippet and off by default: inserting types the command at the prompt and stops, because nothing here can tell whether the terminal is at a prompt at all. |
|
||||||
| Broadcast to all panes (`⌥↵`) | client-ssh | Input is routed strictly by session id in `TerminalDataPlane.Dispatch`; there is no fan-out. Needs splits first. | Omitted. |
|
| Broadcast to all panes (`⌥↵`) | client-ssh | Input is routed strictly by session id in `TerminalDataPlane.Dispatch`; there is no fan-out. Needs splits first. | Omitted. |
|
||||||
| Pane header `24ms` | client-ssh | Round-trip measurement. SSH.NET offers no RTT API. | Omitted. |
|
| Pane header `24ms` | client-ssh | Round-trip measurement. SSH.NET offers no RTT API. | Omitted. |
|
||||||
| Pane header `aes256-gcm` | client-ssh | **The closest miss on this list.** `SshNetConnection` holds the `SshClient`, so `ConnectionInfo.CurrentServerEncryption` is right there — it just is not on `ISshConnection` or surfaced by `TerminalWorkspace`. | Omitted; the session shell's own host header shows the account and endpoint actually dialled (v3–v4: the tab strip did; v5b moved the fact to `SessionHeader`, off `MainWindowViewModel.SessionAddress`, when the strip was retired). |
|
| Pane header `aes256-gcm` | client-ssh | **No longer a miss — the plumbing this row used to lack now exists.** `ISshConnection.Cipher` and `TerminalWorkspace.GetSessionFacts` were built for the v5b session shell's status bar; see that row in the v5b section, above. | Still not on a pane header — this pre-v5b element does not exist as its own piece of chrome any more. The fact it wanted to show is drawn instead where v5b moved it: the status bar's `SessionCipher`, beside CONNECTED. The session shell's host header separately shows the account and endpoint actually dialled (v3–v4: the tab strip did; v5b moved that fact to `SessionHeader`, off `MainWindowViewModel.SessionAddress`, when the strip was retired). |
|
||||||
| Pane header showing the running command and `following` | client-ssh | The host moves opaque bytes and never parses terminal output. Would need shell integration (OSC 133) on the remote. | Omitted. |
|
| Pane header showing the running command and `following` | client-ssh | The host moves opaque bytes and never parses terminal output. Would need shell integration (OSC 133) on the remote. | Omitted. |
|
||||||
| A `local · zsh` tab | client-ssh | Every session here is an SSH channel. Needs ConPTY and a second session kind. | Omitted. |
|
| A `local · zsh` tab | client-ssh | Every session here is an SSH channel. Needs ConPTY and a second session kind. | Omitted. |
|
||||||
| Tab strip `+` button | ui | Not missing so much as redundant: the real operation is *select a host, press Connect*, which the hosts grid already is. | **Shipped**, as the palette rather than a menu: it opens what Ctrl+K opens, so the strip and the shortcut are one way of doing one thing. A `MenuFlyout` offering "SSH" and "local shell" is the nicer answer and is not verifiably safe above the terminal's native child window — and there is no local shell to offer. |
|
| Tab strip `+` button | ui | Not missing so much as redundant: the real operation is *select a host, press Connect*, which the hosts grid already is. | **Shipped**, as the palette rather than a menu: it opens what Ctrl+K opens, so the strip and the shortcut are one way of doing one thing. A `MenuFlyout` offering "SSH" and "local shell" is the nicer answer and is not verifiably safe above the terminal's native child window — and there is no local shell to offer. |
|
||||||
|
|||||||
@@ -8,26 +8,38 @@
|
|||||||
<!--
|
<!--
|
||||||
── v5b's session shell status bar ───────────────────────────────────────────────────────────────────────
|
── v5b's session shell status bar ───────────────────────────────────────────────────────────────────────
|
||||||
37px, DeepChrome, a 1px top border — the foot of the bordered container both the terminal and the SFTP
|
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
|
surface share. The design's own row also carries a negotiated cipher and a host-key-plus-identity run
|
||||||
key/credential name; none of the three is reachable from this shell today — see the wave B report this
|
(`ed25519 · acme-deploy-key`, clickable to a pin-details modal); both are now real and bound, plumbed all
|
||||||
control's history carries, and design-notes/v5b-fidelity-notes.md's own decided deviation — so none of
|
the way from SSH.NET through TerminalWorkspace.GetSessionFacts and TransfersViewModel to
|
||||||
them is drawn, omitted rather than faked, per this project's honesty rule.
|
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
|
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,
|
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
|
the same state the header answers with its own empty-state sentence; the cipher and the host-key/identity
|
||||||
SessionElapsedText, which is null and therefore absent whenever there is nothing timed; and, only on the
|
run, each collapsed rather than shown empty or fabricated when the fact behind it is not there — a bucket
|
||||||
terminal surface — see <see cref="ShowsEncoding"/> — "UTF-8", which is a true fact about this client's own
|
connection has neither, and a session whose facts could not be read back at the instant they were asked
|
||||||
renderer and write path (see TerminalWorkspace's terminal.js and SshShellSessionExtensions.WriteTextAsync)
|
for has neither either; the elapsed timer, off SessionElapsedText, which is null and therefore absent
|
||||||
rather than a negotiated session parameter, and is worded plainly rather than as a claim the remote agreed
|
whenever there is nothing timed; and, only on the terminal surface — see <see cref="ShowsEncoding"/> —
|
||||||
to it.
|
"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}"
|
<Border Height="37" Background="{StaticResource DeepChrome}"
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0">
|
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0">
|
||||||
<Grid ColumnDefinitions="*,Auto" Margin="24,0">
|
<Grid ColumnDefinitions="*,Auto" Margin="24,0">
|
||||||
|
|
||||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="7" 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}}">
|
IsVisible="{Binding SessionAddress, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||||
<Ellipse Classes="dot" Width="7" Height="7" Classes.live="{Binding IsSessionConnected}"
|
<Ellipse Classes="dot" Width="7" Height="7" Classes.live="{Binding IsSessionConnected}"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
@@ -40,6 +52,14 @@
|
|||||||
IsVisible="{Binding !IsSessionConnected}" />
|
IsVisible="{Binding !IsSessionConnected}" />
|
||||||
</StackPanel>
|
</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">
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="18" VerticalAlignment="Center">
|
||||||
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
|
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
|
||||||
VerticalAlignment="Center" Text="{Binding SessionElapsedText}"
|
VerticalAlignment="Center" Text="{Binding SessionElapsedText}"
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ using Avalonia.Controls;
|
|||||||
namespace DodoSSH.Client.App.Views;
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The v5b session shell's status bar: CONNECTED and its dot, the elapsed timer, and — only where it is true
|
/// The v5b session shell's status bar: CONNECTED and its dot, the negotiated cipher, the host key's algorithm
|
||||||
/// — UTF-8. See the remark at the top of SessionStatusBar.axaml.
|
/// 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>
|
/// </summary>
|
||||||
internal sealed partial class SessionStatusBar : UserControl
|
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
|
// 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:
|
// 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.
|
// 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);
|
AdoptTab(adopted);
|
||||||
RefreshConnectedHosts();
|
RefreshConnectedHosts();
|
||||||
return;
|
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
|
// 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.
|
// shell's elapsed timer is about a shell that is actually running.
|
||||||
tab.StartedAt = clock.GetUtcNow();
|
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 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.
|
// 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,
|
_ => 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>
|
/// <summary>Whether the session the header and status bar are describing is actually open.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Not the same question as <see cref="SessionAddress"/> being non-null on the terminal surface: a tab
|
/// 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(SessionAddress));
|
||||||
OnPropertyChanged(nameof(IsSessionConnected));
|
OnPropertyChanged(nameof(IsSessionConnected));
|
||||||
OnPropertyChanged(nameof(SessionElapsedText));
|
OnPropertyChanged(nameof(SessionElapsedText));
|
||||||
|
OnPropertyChanged(nameof(SessionCipher));
|
||||||
|
OnPropertyChanged(nameof(SessionHostKeyAlgorithm));
|
||||||
|
OnPropertyChanged(nameof(SessionIdentityLabel));
|
||||||
|
OnPropertyChanged(nameof(SessionIdentityText));
|
||||||
OnPropertyChanged(nameof(ShowsQuickAccessSidebar));
|
OnPropertyChanged(nameof(ShowsQuickAccessSidebar));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3996,7 +4072,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
if (e.PropertyName is nameof(TransfersViewModel.SelectedHost)
|
if (e.PropertyName is nameof(TransfersViewModel.SelectedHost)
|
||||||
or nameof(TransfersViewModel.IsConnected)
|
or nameof(TransfersViewModel.IsConnected)
|
||||||
or nameof(TransfersViewModel.Remote)
|
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();
|
RaiseSessionState();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,10 +28,11 @@ internal enum TerminalTabState
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// A tab is a session id and two strings. It holds no terminal and owns nothing: the pane, its scrollback
|
/// A tab is a session id and a handful of strings — the address, and now the three connection facts the
|
||||||
/// and the shell behind it all live in the renderer and in <c>TerminalWorkspace</c>, and selecting a tab is
|
/// status bar draws beside CONNECTED. It holds no terminal and owns nothing: the pane, its scrollback and the
|
||||||
/// one frame telling the page which pane to show. That is what makes tabs cheap here — the expensive object
|
/// shell behind it all live in the renderer and in <c>TerminalWorkspace</c>, and selecting a tab is one frame
|
||||||
/// is the WebView, and there is one of those however many tabs are open.
|
/// 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>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>A tab starts before its session does.</b> Connecting is a network round trip that can take as long as
|
/// <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>
|
/// </remarks>
|
||||||
internal DateTimeOffset? StartedAt { get; set; }
|
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" />
|
/// <inheritdoc cref="TerminalTabState" />
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private TerminalTabState state;
|
private TerminalTabState state;
|
||||||
|
|||||||
@@ -606,6 +606,37 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string? connectedTo;
|
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]
|
[ObservableProperty]
|
||||||
private HostKeyPresentation? pendingHostKey;
|
private HostKeyPresentation? pendingHostKey;
|
||||||
|
|
||||||
@@ -874,6 +905,12 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
ConnectedTo = string.Create(
|
ConnectedTo = string.Create(
|
||||||
CultureInfo.InvariantCulture, $"s3://{row.Store.Bucket}");
|
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());
|
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
|
||||||
|
|
||||||
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
|
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
|
||||||
@@ -891,7 +928,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
return;
|
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;
|
Status = refusal;
|
||||||
return;
|
return;
|
||||||
@@ -906,9 +944,11 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
{
|
{
|
||||||
await CloseSessionAsync().ConfigureAwait(true);
|
await CloseSessionAsync().ConfigureAwait(true);
|
||||||
|
|
||||||
|
ISftpSession opened;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
session = await sftp.OpenSftpAsync(request, cancellationToken).ConfigureAwait(true);
|
opened = await sftp.OpenSftpAsync(request, cancellationToken).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
catch (SshHostKeyUnknownException exception)
|
catch (SshHostKeyUnknownException exception)
|
||||||
{
|
{
|
||||||
@@ -926,17 +966,9 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
session = opened;
|
||||||
TypedPassword = string.Empty;
|
TypedPassword = string.Empty;
|
||||||
IsConnected = true;
|
MarkHostConnected(opened, request, row, identityLabel);
|
||||||
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());
|
|
||||||
|
|
||||||
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
|
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
@@ -944,6 +976,33 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
}).ConfigureAwait(true);
|
}).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>
|
/// <summary>
|
||||||
/// Closes the file-transfer session.
|
/// Closes the file-transfer session.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -1713,6 +1772,9 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
|
|
||||||
IsConnected = false;
|
IsConnected = false;
|
||||||
ConnectedTo = null;
|
ConnectedTo = null;
|
||||||
|
ConnectedCipher = null;
|
||||||
|
ConnectedHostKeyAlgorithm = null;
|
||||||
|
ConnectedIdentityLabel = null;
|
||||||
RemotePath = string.Empty;
|
RemotePath = string.Empty;
|
||||||
RemoteEntries.Clear();
|
RemoteEntries.Clear();
|
||||||
RemoteTrail.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="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="label">The host's name, as the vault has it.</param>
|
||||||
/// <param name="address">The account and endpoint actually dialled.</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(
|
internal sealed class TerminalSessionEventArgs(
|
||||||
Guid attemptId,
|
Guid attemptId,
|
||||||
uint sessionId,
|
uint sessionId,
|
||||||
string label,
|
string label,
|
||||||
string address) : EventArgs
|
string address,
|
||||||
|
string cipher,
|
||||||
|
string hostKeyAlgorithm,
|
||||||
|
string? identityLabel) : EventArgs
|
||||||
{
|
{
|
||||||
/// <summary>Which attempt this session came out of.</summary>
|
/// <summary>Which attempt this session came out of.</summary>
|
||||||
/// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" path="/remarks" />
|
/// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" path="/remarks" />
|
||||||
@@ -903,6 +911,12 @@ internal sealed class TerminalSessionEventArgs(
|
|||||||
internal string Label { get; } = label;
|
internal string Label { get; } = label;
|
||||||
|
|
||||||
internal string Address { get; } = address;
|
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>
|
/// <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}.";
|
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
|
// 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
|
||||||
// without this the first keystrokes of the session go to the shell's UI instead of the remote shell.
|
// 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,
|
attempt.AttemptId,
|
||||||
sessionId,
|
sessionId,
|
||||||
target.Label,
|
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
|
// 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.
|
// for must not wait on a vault write to appear.
|
||||||
@@ -11279,13 +11303,19 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="Username">The account to log in as, after any credential has had its say.</param>
|
/// <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="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>
|
/// <remarks>
|
||||||
/// The two travel together because a credential can change both. Returning only the secret and reading the
|
/// 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
|
/// 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
|
/// 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".
|
/// the server reports as "authentication failed".
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private sealed record HostAuthentication(string Username, SshCredential Credential);
|
private sealed record HostAuthentication(string Username, SshCredential Credential, string? IdentityLabel = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The machine a connection is being made to, however it was named.
|
/// The machine a connection is being made to, however it was named.
|
||||||
@@ -11359,6 +11389,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
HostSecret host,
|
HostSecret host,
|
||||||
string typedPassword,
|
string typedPassword,
|
||||||
[NotNullWhen(true)] out SshConnectionRequest? request,
|
[NotNullWhen(true)] out SshConnectionRequest? request,
|
||||||
|
out string? identityLabel,
|
||||||
[NotNullWhen(false)] out string? reason)
|
[NotNullWhen(false)] out string? reason)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(host);
|
ArgumentNullException.ThrowIfNull(host);
|
||||||
@@ -11368,6 +11399,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
if (!TryBuildAuthentication(host, resolved, typedPassword, out var authentication, out reason))
|
if (!TryBuildAuthentication(host, resolved, typedPassword, out var authentication, out reason))
|
||||||
{
|
{
|
||||||
request = null;
|
request = null;
|
||||||
|
identityLabel = null;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11377,6 +11409,8 @@ internal sealed partial class VaultViewModel(
|
|||||||
authentication.Username,
|
authentication.Username,
|
||||||
authentication.Credential);
|
authentication.Credential);
|
||||||
|
|
||||||
|
identityLabel = authentication.IdentityLabel;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11428,6 +11462,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
return Complete(
|
return Complete(
|
||||||
credential.Credential.Username ?? resolved.Username.Value,
|
credential.Credential.Username ?? resolved.Username.Value,
|
||||||
new SshPasswordCredential(credential.Credential.Password),
|
new SshPasswordCredential(credential.Credential.Password),
|
||||||
|
credential.Label,
|
||||||
out authentication,
|
out authentication,
|
||||||
out reason);
|
out reason);
|
||||||
}
|
}
|
||||||
@@ -11446,13 +11481,18 @@ internal sealed partial class VaultViewModel(
|
|||||||
resolved.Username.Value,
|
resolved.Username.Value,
|
||||||
new SshPrivateKeyCredential(
|
new SshPrivateKeyCredential(
|
||||||
Encoding.UTF8.GetBytes(key.Key.PrivateKeyPem), key.Key.Passphrase),
|
Encoding.UTF8.GetBytes(key.Key.PrivateKeyPem), key.Key.Passphrase),
|
||||||
|
key.Label,
|
||||||
out authentication,
|
out authentication,
|
||||||
out reason);
|
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(
|
return Complete(
|
||||||
resolved.Username.Value,
|
resolved.Username.Value,
|
||||||
new SshPasswordCredential(typedPassword),
|
new SshPasswordCredential(typedPassword),
|
||||||
|
identityLabel: null,
|
||||||
out authentication,
|
out authentication,
|
||||||
out reason);
|
out reason);
|
||||||
}
|
}
|
||||||
@@ -11476,6 +11516,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
private static bool Complete(
|
private static bool Complete(
|
||||||
string? username,
|
string? username,
|
||||||
SshCredential credential,
|
SshCredential credential,
|
||||||
|
string? identityLabel,
|
||||||
out HostAuthentication? authentication,
|
out HostAuthentication? authentication,
|
||||||
[NotNullWhen(false)] out string? reason)
|
[NotNullWhen(false)] out string? reason)
|
||||||
{
|
{
|
||||||
@@ -11487,7 +11528,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
out reason);
|
out reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
authentication = new HostAuthentication(username, credential);
|
authentication = new HostAuthentication(username, credential, identityLabel);
|
||||||
reason = null;
|
reason = null;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6221,7 +6221,9 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The status bar's facts, read off the selected terminal tab. <see cref="MainWindowViewModel.SessionElapsedText"/>
|
/// The status bar's facts, read off the selected terminal tab. <see cref="MainWindowViewModel.SessionElapsedText"/>
|
||||||
/// is real, not fabricated: <c>StartedAt</c> is set from the shell's own clock at the moment the session
|
/// is real, not fabricated: <c>StartedAt</c> is set from the shell's own clock at the moment the session
|
||||||
/// opens, and this reads it back through the same clock.
|
/// opens, and this reads it back through the same clock. The cipher and host-key algorithm are the
|
||||||
|
/// fake's own, and the identity text is the bound key's label — <c>ConnectedHostWithAPinAsync</c> binds
|
||||||
|
/// "deploy" — composed with the real (unshortened) algorithm string.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SessionFacts_ReflectTheSelectedTerminalTab()
|
public async Task SessionFacts_ReflectTheSelectedTerminalTab()
|
||||||
@@ -6231,6 +6233,9 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
shell.IsSessionConnected.ShouldBeTrue();
|
shell.IsSessionConnected.ShouldBeTrue();
|
||||||
shell.SessionAddress.ShouldBe(shell.Tabs[0].Address);
|
shell.SessionAddress.ShouldBe(shell.Tabs[0].Address);
|
||||||
shell.SessionElapsedText.ShouldNotBeNull().ShouldStartWith("session ");
|
shell.SessionElapsedText.ShouldNotBeNull().ShouldStartWith("session ");
|
||||||
|
shell.SessionCipher.ShouldBe("aes256-gcm@openssh.com");
|
||||||
|
shell.SessionHostKeyAlgorithm.ShouldBe("ssh-ed25519");
|
||||||
|
shell.SessionIdentityText.ShouldBe("ssh-ed25519 · deploy");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -6245,6 +6250,55 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
shell.IsSessionConnected.ShouldBeFalse();
|
shell.IsSessionConnected.ShouldBeFalse();
|
||||||
shell.SessionAddress.ShouldBeNull();
|
shell.SessionAddress.ShouldBeNull();
|
||||||
shell.SessionElapsedText.ShouldBeNull();
|
shell.SessionElapsedText.ShouldBeNull();
|
||||||
|
shell.SessionCipher.ShouldBeNull();
|
||||||
|
shell.SessionHostKeyAlgorithm.ShouldBeNull();
|
||||||
|
shell.SessionIdentityText.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A typed password has nothing filed in the keychain to name, so the status bar's identity run is the
|
||||||
|
/// host-key algorithm alone — no " · " and nothing after it, which is what
|
||||||
|
/// <see cref="MainWindowViewModel.SessionIdentityText"/>'s own remark promises rather than a placeholder
|
||||||
|
/// standing in for the item that was never there.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task SessionFacts_ATypedPasswordSession_ShowsTheHostKeyAlgorithmAloneWithNoIdentity()
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
|
||||||
|
shell.IsSessionConnected.ShouldBeTrue();
|
||||||
|
shell.SessionHostKeyAlgorithm.ShouldBe("ssh-ed25519");
|
||||||
|
shell.SessionIdentityText.ShouldBe("ssh-ed25519", "a typed password names no keychain item");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The same facts on the other surface, and one asymmetry worth pinning: the SFTP session is its own
|
||||||
|
/// login through the same resolution ladder, so the status bar names that session's cipher, host key and
|
||||||
|
/// identity — and stops naming them on disconnect, where a terminal tab keeps its facts for the
|
||||||
|
/// scrollback still on screen (see <c>TerminalTabViewModel.Cipher</c>'s remark).
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task SessionFacts_FollowTheSftpSurfacesOwnSession()
|
||||||
|
{
|
||||||
|
await ConnectedHostWithAPinAsync();
|
||||||
|
|
||||||
|
await shell.OpenPinnedPathCommand.ExecuteAsync("/var/www/app");
|
||||||
|
|
||||||
|
shell.IsTransfersShowing.ShouldBeTrue();
|
||||||
|
shell.SessionCipher.ShouldBe("aes256-gcm@openssh.com");
|
||||||
|
shell.SessionIdentityText.ShouldBe("ssh-ed25519 · deploy");
|
||||||
|
|
||||||
|
await shell.Transfers.DisconnectCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.SessionCipher.ShouldBeNull("the SFTP surface has no scrollback for a dead session's facts to describe");
|
||||||
|
shell.SessionIdentityText.ShouldBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user