Merge branch 'claude/trust-connect-popup-56abec'
ci / android head (push) Successful in 3m28s
ci / desktop nightly (push) Successful in 43s
ci / build and test (push) Successful in 2m6s
ci / api image (push) Successful in 25s

This commit is contained in:
2026-08-06 12:27:20 +02:00
11 changed files with 609 additions and 75 deletions
@@ -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
@@ -9960,12 +9960,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>