Public Access
Ask about a host key where the connection was made, not on the host list
The trust prompt was two banners at the top of the desktop's hosts screen, so the shell navigated there before letting a handshake raise one: Screen = Hosts, Surface = Page, in OnVaultConnectionFailed and again in the palette's own connect. The reason was sound — a connection can be started from Ctrl+K on any screen, and a question behind whatever somebody is looking at is a question nobody can answer — and it was answered the wrong way round. Rather than making the decision reachable from where the user is, it moved the user to where the decision was, and charged every screen for it. It is worst for the one connection that has no host at all. A machine typed into the phone's connect box is deliberately in no keychain, so a first contact from there judged it on a list it does not appear on, after taking the box that dialled it away. So both heads now draw the decision over the surface. HostKeyCard is the desktop's, and is the counterpart of the phone's HostKeySheet: a scrim with no press handler, because a question with two named answers must not be answerable by missing; the unknown key offering TRUST AND CONNECT, because judging a fingerprint against what an operator published is a decision a person is entitled to make and the only moment they can make it; and the changed key offering no way forward at all, because a button beside that warning is "continue anyway" with two clicks instead of one. The phone needed no new markup — its sheet was already a shell-level overlay, so deleting the navigation is what puts it over the Connections screen. IsHostKeyDecisionShowing is on the shell rather than on a screen because the answer decides an occlusion. A second connection can be refused while a first one is open, so this card is routinely raised over a live terminal, and that rectangle is a native child window: layered over it the card would be sliced at its left edge with TRUST AND CONNECT taking no clicks, which for the most safety-critical question in the product is the worst place for that class of bug to land. IsTerminalShowing gives the rectangle up instead. The banners are gone rather than copied. One prompt in two markups is two copies of the most safety-critical wording here, and the second is the one that goes stale. TWO DEFECTS FOUND BEHIND IT. VaultViewModel.RejectHostKey cleared only the pending key and never the mismatch, so the changed-key refusal had no working exit. That was invisible for as long as it was a banner nothing was drawn over — nothing was trapped, and the next attempt cleared it — and it was already live on the phone, where that refusal is an opaque full-screen panel whose one button runs this command: pressing it left the panel up over every screen the user went to next, including the host editor the panel tells them to open. TransfersViewModel.RejectHostKey has always cleared both; the vault's was the outlier. Its button said BACK TO HOSTS, which was wrong twice over, and now says BACK. And an assertion written for this change could not fail: the palette test asserted the renderer was collapsed in a scenario whose only tab had just been removed, so it was collapsed for want of a session whatever the occlusion rule said. It is gone, with a note pointing at the test that can fail on it. VERIFIED. 1580 tests, build clean, no new warnings, format clean. Three mutations each seen to fail and then seen green again: dropping !IsHostKeyDecisionShowing from IsTerminalShowing, caught by AChangedHostKey_CollapsesTheTerminalItIsRefusedOver; reverting RejectHostKey to clear one flag, caught by RefusingAHostKeyDecision_TakesItOffTheScreen(false) and by that same test; and dropping the two host-key arms from OnVaultPropertyChanged, caught by TheHostKeyDecision_IsAnnouncedToTheWindowWhenItArrivesAndWhenItGoes. That last one is the first test in this repository to watch PropertyChanged, and it is worth being the first: every other assertion about the flag reads it directly, and a direct read passes with the subscription deleted — while the card would never go away. The two layout tests moved with the prompts, from the hosts screen to the card. manual-checks gains 7.4a for the occlusion, 7.4b for getting out of a refusal and 11.7a for the hand-typed case, none of which a test can see; 1.5 and 7.4 are corrected rather than left describing a window that no longer moves. ONE ROUGH EDGE, DELIBERATELY LEFT. On the desktop, refusing a first contact whose tab was the only one leaves the terminal surface with no tabs — a blank rectangle under the strip's "no terminals open · press + or Ctrl+K", which is the one sentence near that rectangle Avalonia can draw. The alternative was falling back to the page, and on the phone that means the host list, which is the bug this commit is about. A desktop connect page would close it properly.
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.HostKeyCard"
|
||||
x:DataType="vm:VaultViewModel">
|
||||
|
||||
<!--
|
||||
The two host-key decisions, over whatever the window happens to be showing. The desktop counterpart of
|
||||
the phone's HostKeySheet, and one control for both answers for the same reason: they are one decision
|
||||
point, the view models make them mutually exclusive, and the shell must not be able to show both.
|
||||
|
||||
── IT WAS TWO BANNERS AT THE TOP OF THE HOSTS SCREEN. ───────────────────────────────────────────────
|
||||
Which is why the shell used to navigate there before letting the vault raise the question: a connection
|
||||
can be started from the palette on any screen, and a prompt behind the screen somebody is looking at is a
|
||||
prompt nobody can answer. The navigation covered that, and paid for it everywhere else — it took the user
|
||||
off the screen they were working on, and for the one connection that has no host at all it judged a
|
||||
machine typed in by hand on the list it is deliberately not on. See MainWindowViewModel's own note on
|
||||
OnVaultConnectionFailed.
|
||||
|
||||
Drawn over the surface instead, so nothing has to move and the palette's case needs no special one.
|
||||
|
||||
── THE OCCLUSION RULE, AND THIS IS THE CONTROL THAT MOST NEEDS IT. ──────────────────────────────────
|
||||
A second connection can be made while a first one is open, so this card is routinely raised over a live
|
||||
terminal — and that rectangle is a native child window which composites above everything Avalonia draws.
|
||||
A card layered over it would be sliced at its left edge with TRUST AND CONNECT unreachable, which for the
|
||||
most safety-critical question in the product is the worst place for that class of bug to land. The shell
|
||||
collapses the WebView while this is up; see MainWindowViewModel.IsHostKeyDecisionShowing.
|
||||
|
||||
── ◆ WHAT IS DELIBERATELY DIFFERENT BETWEEN THE TWO HALVES. ─────────────────────────────────────────
|
||||
First contact offers TRUST AND CONNECT, because deciding whether a fingerprint is the one the operator
|
||||
published is a judgement a person is entitled to make and is the only moment they can make it.
|
||||
|
||||
A changed key offers no way forward at all: no continue, no "connect anyway", and nothing that turns
|
||||
dismissing it into a connection. The only way past it is FORGET HOST KEY in the host's own editor, which
|
||||
is a deliberate act performed somewhere else — a button next to this warning would be "continue anyway"
|
||||
with two clicks instead of one. The phone's sheet carries the same note, and calls presenting this as
|
||||
dismissible the one design mistake here that matters.
|
||||
-->
|
||||
|
||||
<!--
|
||||
A wash with no press handler, and the omission is the point: every other overlay in this window closes by
|
||||
clicking away from it, and a question with two named answers must not be answerable by missing. The
|
||||
palette's own backdrop is the control to compare with — see QuickConnect.
|
||||
|
||||
80% of Canvas, written out because a scrim is a brush with an alpha and the palette holds no alpha
|
||||
variant of a surface; the pre-multiplied ones there are accent washes.
|
||||
-->
|
||||
<Border Background="#CC0E1220">
|
||||
<Panel>
|
||||
|
||||
<!-- ============ UNKNOWN HOST KEY ============ -->
|
||||
<Border Classes="card" IsVisible="{Binding HasPendingHostKey}"
|
||||
BorderBrush="{StaticResource WarnSoft}">
|
||||
<StackPanel Spacing="10">
|
||||
|
||||
<TextBlock Classes="label" Text="UNKNOWN HOST KEY" Foreground="{StaticResource Warn}" />
|
||||
|
||||
<TextBlock Foreground="{StaticResource WarnText}" TextWrapping="Wrap">
|
||||
<Run Text="First contact with" />
|
||||
<Run Text="{Binding PendingHostKey.Host}" Foreground="{StaticResource Text}" />
|
||||
<Run Text="·" />
|
||||
<Run Text="{Binding PendingHostKey.Port}" />
|
||||
<Run Text=". Nothing in this keychain has approved this key." />
|
||||
</TextBlock>
|
||||
|
||||
<TextBlock Classes="label" FontSize="10" Text="{Binding PendingHostKey.Algorithm}" />
|
||||
|
||||
<!--
|
||||
In full, wrapping rather than trimmed, and selectable. A fingerprint exists to be compared
|
||||
character by character against one an operator published, and the two things that ruin that are
|
||||
an ellipsis in the middle and a line you cannot copy.
|
||||
-->
|
||||
<Border Background="{StaticResource Field}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="10,8">
|
||||
<SelectableTextBlock Classes="mono" FontSize="12" TextWrapping="Wrap"
|
||||
Foreground="{StaticResource Warn}"
|
||||
Text="{Binding PendingHostKey.Fingerprint}" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="12"
|
||||
Text="Compare it with the fingerprint the server's operator published before trusting it. Trusting pins it for this keychain, on every machine." />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="TRUST AND CONNECT"
|
||||
Command="{Binding TrustHostKeyCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding RejectHostKeyCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ============ CHANGED HOST KEY ============ -->
|
||||
<Border Classes="card" IsVisible="{Binding HasHostKeyMismatch}"
|
||||
BorderBrush="{StaticResource DangerSoft}">
|
||||
<StackPanel Spacing="10">
|
||||
|
||||
<TextBlock Classes="label" Text="HOST KEY CHANGED" Foreground="{StaticResource Danger}" />
|
||||
|
||||
<TextBlock Foreground="{StaticResource Danger}" FontWeight="SemiBold" TextWrapping="Wrap"
|
||||
Text="The host key changed and the connection was refused." />
|
||||
|
||||
<!--
|
||||
The whole explanation comes from the view model, which already composes it — which host, both
|
||||
fingerprints, and which of them this keychain has pinned. Rewriting it here would be a second
|
||||
copy of the most safety-critical sentence in the product, in a second place to forget.
|
||||
-->
|
||||
<Border Background="{StaticResource Field}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="10,8">
|
||||
<SelectableTextBlock Classes="mono" FontSize="12" TextWrapping="Wrap"
|
||||
Foreground="{StaticResource DangerText}"
|
||||
Text="{Binding HostKeyMismatch}" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="12" Foreground="{StaticResource WarnText}"
|
||||
Text="If this server was rebuilt on purpose, edit the host and choose FORGET HOST KEY, then connect again. There is deliberately no way to continue from here." />
|
||||
|
||||
<!--
|
||||
One button, and it goes nowhere. It puts the refusal away so the host's editor can be reached; it
|
||||
is not an answer, because this half of the control has none to give. Compare the accent button
|
||||
above, which connects.
|
||||
-->
|
||||
<Button Classes="ghost" Content="CLOSE" HorizontalAlignment="Left"
|
||||
Command="{Binding RejectHostKeyCommand}" />
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Panel>
|
||||
</Border>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,20 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The two host-key decisions, drawn over whatever the window is showing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Its data context is the <c>VaultViewModel</c>, so every binding in the markup is a property of the vault;
|
||||
/// which of the two halves draws is decided there and the two are mutually exclusive. Whether it is on screen
|
||||
/// at all is the shell's business — see <c>MainWindowViewModel.IsHostKeyDecisionShowing</c>, which is also
|
||||
/// what collapses the terminal's WebView underneath it.
|
||||
///
|
||||
/// In its own file rather than in the window, like every other card here, because nothing inside that window
|
||||
/// can be laid out by a test: WebView2's adapter refuses the headless session's thread.
|
||||
/// </remarks>
|
||||
internal sealed partial class HostKeyCard : UserControl
|
||||
{
|
||||
public HostKeyCard() => InitializeComponent();
|
||||
}
|
||||
@@ -89,39 +89,19 @@
|
||||
<StackPanel Grid.Row="2">
|
||||
|
||||
<!--
|
||||
Host key prompts. Unknown and changed look deliberately different: one is a decision, the other is
|
||||
a refusal. Presenting a changed key with a "continue" button is how users are taught to click
|
||||
through the one warning that matters.
|
||||
-->
|
||||
<Border Padding="16,10" Background="{StaticResource WarnWash}"
|
||||
BorderBrush="{StaticResource WarnSoft}" BorderThickness="0,0,0,1"
|
||||
IsVisible="{Binding HasPendingHostKey}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="This host has not been seen before. Check the fingerprint against what the server's operator published."
|
||||
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding PendingHostKey.Fingerprint}"
|
||||
Foreground="{StaticResource Warn}" TextWrapping="Wrap" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="accent" Content="TRUST AND CONNECT"
|
||||
Command="{Binding TrustHostKeyCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL"
|
||||
Command="{Binding RejectHostKeyCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
◆ THE TWO HOST KEY PROMPTS WERE HERE, and they are now drawn over the window instead.
|
||||
|
||||
<Border Padding="16,10" Background="{StaticResource DangerWash}"
|
||||
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
|
||||
IsVisible="{Binding HasHostKeyMismatch}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="The host key changed and the connection was refused."
|
||||
Foreground="{StaticResource Danger}" FontWeight="SemiBold" />
|
||||
<SelectableTextBlock Text="{Binding HostKeyMismatch}"
|
||||
Foreground="{StaticResource Danger}" TextWrapping="Wrap" />
|
||||
<TextBlock Text="If the server was legitimately rebuilt, edit the host and choose "Forget host key" first. There is deliberately no way to continue from here."
|
||||
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
They were banners on this screen, so the shell navigated to this screen before letting the vault
|
||||
raise one — because a connection can be started from the palette on any screen, and a question
|
||||
behind whatever somebody is looking at is a question nobody can answer. That covered the palette
|
||||
and charged everybody else for it: it took the user off the screen they were working on, and for a
|
||||
machine typed into the phone's connect box by hand it asked about it on the list it is deliberately
|
||||
not on. The decision is over the surface now, both heads, and nothing has to move. See
|
||||
HostKeyCard.axaml and MainWindowViewModel.OnVaultConnectionFailed.
|
||||
|
||||
Not copied into both places, deliberately. One prompt in two markups is two copies of the most
|
||||
safety-critical wording in the product, and the second is the one that goes stale.
|
||||
-->
|
||||
|
||||
<!--
|
||||
The conflict log. The merge is only allowed to pick a winner because the value it overrode is kept
|
||||
|
||||
@@ -348,6 +348,22 @@
|
||||
-->
|
||||
<views:QuickConnect x:Name="Palette" IsVisible="{Binding IsSearching}" />
|
||||
|
||||
<!--
|
||||
◆ Later still, and the order between these two is a ranking rather than an accident: the palette is
|
||||
something the user opened and can close, and a host-key decision is a connection waiting on them. It
|
||||
used to be two banners at the top of the hosts screen, which is why the shell navigated there before
|
||||
the question could be asked — see HostKeyCard.axaml, and MainWindowViewModel.OnVaultConnectionFailed
|
||||
for what that cost every other screen.
|
||||
|
||||
Wrapped, like every other child here whose data context is the vault: the visibility is the shell's
|
||||
business — it is the flag that also collapses the WebView — and IsHostKeyDecisionShowing does not
|
||||
exist on a VaultViewModel, which with compiled bindings is a build error rather than a card that
|
||||
silently never appears.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsHostKeyDecisionShowing, FallbackValue=False}">
|
||||
<views:HostKeyCard x:Name="HostKeyPane" DataContext="{Binding Vault}" />
|
||||
</Panel>
|
||||
|
||||
</Panel>
|
||||
|
||||
<!--
|
||||
|
||||
Reference in New Issue
Block a user