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:
@@ -947,7 +947,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// same rectangle is drawn underneath it and its buttons cannot be clicked. Anything that covers the
|
||||
/// terminal's area has to collapse the terminal instead, and that is every one of the conditions here: a
|
||||
/// locked vault (the unlock card), the page area (every screen uses the full width), the quick-connect
|
||||
/// palette, and the phone's connect sheet.
|
||||
/// palette, the phone's connect sheet, and the host-key decision.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The host-key decision is the newest of them, and the one that made it a rule rather than a list.</b>
|
||||
/// It is drawn over whatever is on screen at the moment a handshake asks the question — including an open
|
||||
/// terminal, because a second connection can be being made while a first one is being typed into. Without
|
||||
/// this condition the two buttons that answer the most safety-critical question in the product would be
|
||||
/// sliced at the WebView's left edge and take no clicks. See <see cref="IsHostKeyDecisionShowing"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The sheet is here rather than in <see cref="IsTerminalSurface"/>, and the palette is not.</b> The
|
||||
@@ -984,7 +991,36 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool IsTerminalShowing =>
|
||||
IsTerminalSurface && !IsConnectSheetOpen && SelectedTab is { HasSession: true };
|
||||
IsTerminalSurface
|
||||
&& !IsConnectSheetOpen
|
||||
&& !IsHostKeyDecisionShowing
|
||||
&& SelectedTab is { HasSession: true };
|
||||
|
||||
/// <summary>
|
||||
/// Whether a host key is waiting to be judged, over whatever the user is looking at.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>It is asked of the shell rather than of a screen because the answer decides an occlusion, and
|
||||
/// because the question is no longer tied to a screen.</b> The prompt used to be a banner at the top of
|
||||
/// the hosts screen, and the shell navigated there before letting the vault raise it — which is how a
|
||||
/// machine typed into the phone's own connect box came to be judged on a list it is deliberately not on.
|
||||
/// Both heads now draw the decision over whichever surface was showing when the handshake stopped, so
|
||||
/// there is nowhere it can be asked from that it cannot be answered on, and nothing has to move.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Two states, one flag, and the views keep them apart: an unknown key is a decision with two buttons and
|
||||
/// a changed one is a refusal with no way forward. What they share is that both cover the rectangle the
|
||||
/// terminal would be in. See <c>HostKeyCard.axaml</c> on the desktop and <c>HostKeySheet.axaml</c> on the
|
||||
/// phone.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The transfers screen's own copy of this question is not here, and that is not an omission: file
|
||||
/// transfer's prompt is drawn inside that screen, which is a page, so it occludes nothing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool IsHostKeyDecisionShowing =>
|
||||
IsUnlocked && Vault is { HasPendingHostKey: true } or { HasHostKeyMismatch: true };
|
||||
|
||||
/// <summary>
|
||||
/// Whether the terminal half of the window is the half being shown, pane or no pane.
|
||||
@@ -1623,13 +1659,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
CloseSearch();
|
||||
|
||||
// The hosts page, because that is where this connection's questions get asked. An unknown or changed
|
||||
// host key is answered by a prompt drawn on that page and the palette opens from any screen, so
|
||||
// connecting from the files screen without this would leave the question behind the screen that asked
|
||||
// it. The surface does not stay here — the tab that appears for the attempt takes it — and it does not
|
||||
// need to: a refusal that needs an answer puts the page back, which is where this leaves the screen.
|
||||
Screen = ShellScreen.Hosts;
|
||||
Surface = ShellSurface.Page;
|
||||
// ◆ IT USED TO GO TO THE HOSTS PAGE FIRST, and the reason it did no longer exists. An unknown or
|
||||
// changed host key was answered by a prompt drawn on that page, and the palette opens from any screen,
|
||||
// so connecting from the files screen without the jump would have left the question behind the screen
|
||||
// that asked it. Both heads draw the decision over the surface now — see IsHostKeyDecisionShowing —
|
||||
// and the other thing this connection can say before it dials, a refusal on Vault.Status, is in the
|
||||
// status bar, which is a row of the window rather than part of a screen.
|
||||
//
|
||||
// So the palette leaves the user where they were, which is the whole point of a palette: Ctrl+K over a
|
||||
// transfer that is still running should not cost the transfer's screen.
|
||||
vault.SelectedHost = vault.Hosts.FirstOrDefault(host => host.EntityId == row.EntityId);
|
||||
|
||||
// Null, not the token. A [RelayCommand] over a method whose only parameter is a CancellationToken
|
||||
@@ -2815,12 +2853,27 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
OnPropertyChanged(nameof(HasRecentConnections));
|
||||
|
||||
RaiseSyncState();
|
||||
|
||||
// The host-key decision is a property of the vault, so swapping the vault out is one of the ways it
|
||||
// stops being on screen — locking with a question still up is the case, and it is reachable: a
|
||||
// handshake that stopped on an unknown key does not hold the window, so the lock button is live
|
||||
// behind the card asking about it.
|
||||
RaiseTerminalState();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// One property is watched rather than all of them: the titlebar's sync state is the vault's outbox
|
||||
/// depth, which lives on the vault, and re-raising the shell's two derived properties on every
|
||||
/// notification a busy vault produces would repaint the titlebar on every keystroke in an editor.
|
||||
/// <para>
|
||||
/// Named properties are watched rather than all of them: the titlebar's sync state is the vault's outbox
|
||||
/// depth, which lives on the vault, and re-raising the shell's derived properties on every notification a
|
||||
/// busy vault produces would repaint the titlebar on every keystroke in an editor.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The two host-key flags are here because the decision the vault raises collapses this shell's WebView —
|
||||
/// see <see cref="IsHostKeyDecisionShowing"/>. It is a property rather than an event because it is a
|
||||
/// state the vault is in and not a moment: it is entered by a refused handshake, left by either answer,
|
||||
/// and cleared outright by forgetting a pin from a host's editor, which is a third caller that has
|
||||
/// nothing to do with connecting.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnVaultPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
@@ -2829,6 +2882,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
{
|
||||
RaiseSyncState();
|
||||
}
|
||||
|
||||
if (string.Equals(
|
||||
e.PropertyName, nameof(VaultViewModel.HasPendingHostKey), StringComparison.Ordinal)
|
||||
|| string.Equals(
|
||||
e.PropertyName, nameof(VaultViewModel.HasHostKeyMismatch), StringComparison.Ordinal))
|
||||
{
|
||||
RaiseTerminalState();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnVaultHostsChanged(object? sender, NotifyCollectionChangedEventArgs e) =>
|
||||
@@ -2913,11 +2974,27 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// Answers a connection that did not become a session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Two outcomes, because there are two kinds of not-connecting. A refusal stays in the strip as a tab
|
||||
/// carrying its reason — connecting no longer holds the window, so the user may be three screens away by
|
||||
/// now, and the status line they are not looking at is not where a failure should end. A host key
|
||||
/// question is not a refusal: it is a prompt on the hosts screen, so the tab goes and the window is put
|
||||
/// back where the question is being asked.
|
||||
/// now, and the status line they are not looking at is not where a failure should end. A host key question
|
||||
/// is not a refusal: nothing has gone wrong and there is nothing to report, so the tab goes and the
|
||||
/// decision takes its place until it is answered.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It used to move the window, and that was the wrong half of the problem to solve.</b> The prompt was
|
||||
/// a banner on the hosts screen, so this navigated there — <c>Screen = Hosts</c>, <c>Surface = Page</c> —
|
||||
/// on the reasoning that a connection can be started from the palette on any screen and a question behind
|
||||
/// the screen somebody is looking at is a question nobody can answer. True, and answered the wrong way
|
||||
/// round: what it did to the one connection that has no host at all was to judge a machine typed into the
|
||||
/// phone's connect box on a list it is deliberately not on, after taking the box away.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Both heads now draw the decision over whatever is showing, so nothing has to move and the palette's
|
||||
/// case is covered without a special one. What is left here is the tab, and the surface it was on stays as
|
||||
/// it is — for the phone that surface <em>is</em> the connect box, and it is what the user comes back to
|
||||
/// whichever way they answer.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnVaultConnectionFailed(object? sender, ConnectionFailedEventArgs e)
|
||||
{
|
||||
@@ -2945,11 +3022,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
: Tabs[Math.Clamp(index - 1, 0, Tabs.Count - 1)];
|
||||
}
|
||||
|
||||
// The screen the question is drawn on, and the page rather than a terminal. A connection can be
|
||||
// started from the palette on any screen, so without this the prompt would be behind whatever the
|
||||
// user was looking at, with the connection waiting on an answer they cannot reach.
|
||||
Screen = ShellScreen.Hosts;
|
||||
Surface = ShellSurface.Page;
|
||||
// Nothing else. The decision is drawn over the surface the user is on rather than on a screen they
|
||||
// have to be taken to — see the remark — and the neighbour selected above is what that surface shows
|
||||
// once the question is answered. Its WebView is collapsed while the card is up, which is the one
|
||||
// thing a card in that rectangle cannot do for itself; see IsHostKeyDecisionShowing.
|
||||
RaiseTerminalState();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -3344,6 +3421,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
OnPropertyChanged(nameof(IsTerminalSurface));
|
||||
OnPropertyChanged(nameof(IsTerminalShowing));
|
||||
OnPropertyChanged(nameof(IsConnectingShowing));
|
||||
OnPropertyChanged(nameof(IsHostKeyDecisionShowing));
|
||||
|
||||
// The tabs themselves, and not only the window's own flags. A tab that stayed lit after the user
|
||||
// navigated to preferences would be a second "you are here" mark pointing at a terminal that is not
|
||||
|
||||
@@ -9849,12 +9849,37 @@ internal sealed partial class VaultViewModel(
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Dismisses the trust prompt without pinning anything.</summary>
|
||||
/// <summary>
|
||||
/// Dismisses whichever host-key decision is showing, without pinning or forgetting anything.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One command for both, because both are dismissals and the two states are mutually exclusive — which is
|
||||
/// what <c>TransfersViewModel.RejectHostKey</c> has always done for the same pair of cards. The mismatch
|
||||
/// has nothing else it may offer: withdrawing a pin is a deliberate act performed in the host's editor,
|
||||
/// away from the moment of connecting, and a button here would be "continue anyway" with two clicks
|
||||
/// instead of one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It used to clear only the pending key, which left the mismatch with no working exit.</b> That was
|
||||
/// invisible for as long as the refusal was a banner at the top of a screen — nothing was drawn over, so
|
||||
/// nothing was trapped, and the next connection attempt cleared it. It is not invisible now: both heads
|
||||
/// draw the refusal over the surface, and the phone's is an opaque full-screen panel whose one button runs
|
||||
/// this command. Leaving <see cref="HostKeyMismatch"/> set would have kept that panel up over every screen
|
||||
/// the user went to afterwards, including the host editor they were being sent to.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void RejectHostKey()
|
||||
{
|
||||
var wasOffered = PendingHostKey is not null;
|
||||
|
||||
PendingHostKey = null;
|
||||
Status = "The host key was not trusted, so nothing was connected.";
|
||||
HostKeyMismatch = null;
|
||||
|
||||
Status = wasOffered
|
||||
? "The host key was not trusted, so nothing was connected."
|
||||
: "Nothing was connected.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user