Merge branch 'claude/sleepy-chebyshev-cda68d'

This commit is contained in:
2026-07-29 15:35:02 +02:00
10 changed files with 641 additions and 16 deletions
+41 -6
View File
@@ -53,7 +53,8 @@ shell that sliced the setup
and unlock cards at the terminal column's left edge, put every one of their buttons inside the WebView's
rectangle at the window's default width — so the flow could only be completed by keyboard — and handed
Win32 focus to WebView2 on any click in that region, which makes a text box stop accepting keystrokes with
no visible cause.
no visible cause. That last symptom is the focus asymmetry documented further down, not a separate fault:
focus crosses into the WebView readily and does not come back on its own.
The fix is to collapse the control, not to cover it: `IsVisible="{Binding IsUnlocked}"` on the
`NativeWebView`. That is safe, and this is the part worth recording, because the opposite was asserted here
@@ -108,11 +109,45 @@ Related and not yet addressed: the conflict log above the terminal is an `ItemsC
`ScrollViewer` and no `MaxHeight` on an `Auto` row, so enough conflicts squeeze the terminal row toward
nothing.
**Nothing hands the terminal keyboard focus after connecting.** The page calls `term.focus()`, which focuses
the textarea inside the document, but Avalonia's focus is still on the Connect button — so the first
keystrokes after a successful connect go to the shell's UI, not to the remote shell. Click inside the
terminal first. This is a focus-plumbing gap between Avalonia and the native child window, not a terminal
bug.
**Keyboard focus crosses into the WebView by itself and does not come back.** This is the asymmetry to
know; the connect-focus bug that led here was only its first symptom. Measured on Windows with a harness
that reports `GetFocus()`, the class name of the window holding it, and the page's own
`document.hasFocus()` at each step.
- **Into the page: nothing custom is needed.** `NativeWebView` overrides `Focusable` to true and its
`OnGotFocus` calls the adapter's `Focus()`, which on Windows is
`ICoreWebView2Controller::MoveFocus(PROGRAMMATIC)`. A plain Avalonia `Terminal.Focus()` therefore moves
real Win32 focus to the `Chrome_WidgetWin_1` child and the page reports `hasFocus: true`. No `SetFocus`
P/Invoke and no COM work — the package version of this entry that assumed otherwise was wrong. The
control also replays a `Focus()` that arrived before its adapter existed, and re-asserts itself: while
it holds Win32 focus its `GotFocus` handler pulls Avalonia's *logical* focus back onto the control. Worth
stating positively, because the reasonable guess before measuring — that crossing into a child HWND must
need `SetFocus` — is the wrong way round: it is the return trip that needs it.
- **Out of the page: the package does nothing at all.** `OnLostFocus` calls the adapter's `ResignFocus()`,
and on Windows that method is **empty**. So `someTextBox.Focus()` moves Avalonia's focused element while
Win32 focus stays on WebView2: a text box with a caret that silently receives nothing. `Window.Activate()`
and `Window.Focus()` were both measured and neither recovers it. The hand-back has to be
`SetFocus(topLevelHwnd)` — see `Views/NativeKeyboardFocus.cs`. A real mouse click *does* recover it,
because Avalonia's window sets focus on pointer input, which is exactly why this is invisible to anyone
who clicks before typing.
- **Collapsing the control does not release the keyboard.** With `IsVisible=false` the holder window is
hidden but Win32 focus stays on it — measured as focus held by a window reporting `visible=False`, with
Avalonia's focused element becoming `(none)`. So locking the vault after touching the terminal left the
unlock passphrase box eating keystrokes. The lock path now hands the keyboard back and focuses that box.
- **`Focus()` on a collapsed control is a no-op and is not replayed on reveal.** Order matters: reveal,
then focus. Focus does survive a lock/unlock cycle when done that way.
- **There is no Tab-out.** The package subscribes `ICoreWebView2Controller::add_MoveFocusRequested` and its
handler body is empty, so WebView2's request to move focus off itself is discarded; xterm eats Tab
anyway. The way out is `Ctrl+Shift+F6`, intercepted in `terminal.js` and sent to the host as a web
message — measured arriving verbatim in `WebMessageReceivedEventArgs.Body`. It has to be handled in the
page, because once the child window owns Win32 focus Avalonia sees no key events and no `KeyBinding`
could fire. Not Escape, and not a bare F6: both are keys a TUI legitimately binds, and Ctrl+Shift is the
range terminal emulators conventionally keep for themselves.
None of this is covered by a test, and cannot be here: headless Avalonia has no native window, so a
headless test renders and focuses correctly and would confirm the wrong belief. What the suite covers is
the plumbing that drives it — that connecting asks for focus once per session, that a failed connect does
not, and that locking stops the forwarding.
**The Windows app manifest must declare a `supportedOS` list.** Without it the process reports a
downlevel Windows version and Avalonia's native control host fails outright — *"Unable to create child
@@ -142,6 +142,17 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// <summary>Where the embedded browser should navigate.</summary>
internal Uri TerminalPageUrl => workspace.PageUrl;
/// <summary>
/// Raised when a terminal session opens, so the view can hand the terminal the keyboard.
/// </summary>
/// <remarks>
/// Forwarded from <see cref="VaultViewModel.SessionOpened"/> rather than exposed there directly,
/// because <see cref="Vault"/> is replaced on every unlock and the view would have to re-subscribe
/// each time. This shell is the window's data context for the life of the process, so one
/// subscription is enough.
/// </remarks>
internal event EventHandler? TerminalSessionOpened;
internal bool IsStarting => State == ShellState.Starting;
internal bool IsNeedingServer => State == ShellState.NeedsServer;
@@ -469,6 +480,26 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
return exception.Message;
}
/// <remarks>
/// One place for the subscription, so unlocking, locking and disposing all route through it rather
/// than each remembering to detach.
/// </remarks>
partial void OnVaultChanged(VaultViewModel? oldValue, VaultViewModel? newValue)
{
if (oldValue is not null)
{
oldValue.SessionOpened -= OnVaultSessionOpened;
}
if (newValue is not null)
{
newValue.SessionOpened += OnVaultSessionOpened;
}
}
private void OnVaultSessionOpened(object? sender, EventArgs e) =>
TerminalSessionOpened?.Invoke(this, e);
partial void OnStateChanged(ShellState value)
{
OnPropertyChanged(nameof(IsStarting));
@@ -179,6 +179,18 @@ internal sealed partial class VaultViewModel(
[ObservableProperty]
private string? hostKeyMismatch;
/// <summary>
/// Raised once a terminal session is open and its renderer has it.
/// </summary>
/// <remarks>
/// An event rather than a property because handing the terminal the keyboard is something that
/// happens, not something that is true: connecting a second host while one is already open has to
/// move focus again, and no state change describes that. Raised on the UI thread — every await on
/// the path from the command to here uses <c>ConfigureAwait(true)</c> — so a handler may touch
/// controls directly.
/// </remarks>
internal event EventHandler? SessionOpened;
internal bool HasPendingHostKey => PendingHostKey is not null;
internal bool HasHostKeyMismatch => HostKeyMismatch is not null;
@@ -611,6 +623,12 @@ internal sealed partial class VaultViewModel(
.ConfigureAwait(true);
Status = $"Connected to {row.Label}.";
// Only now, and only on success. The page's own term.focus() focuses the textarea inside
// the document, which does nothing while the window's keyboard focus is still on the
// Connect button — so without this the first keystrokes of the session go to the shell's
// UI instead of the remote shell.
SessionOpened?.Invoke(this, EventArgs.Empty);
}
catch (SshHostKeyUnknownException exception)
{
@@ -77,7 +77,8 @@
<Grid Grid.Row="1" Grid.Column="0" RowDefinitions="*,Auto,Auto"
Background="#131722" IsVisible="{Binding IsUnlocked}">
<ListBox Grid.Row="0" Margin="6"
<!-- Named because it is where keyboard focus lands when the user leaves the terminal. -->
<ListBox Grid.Row="0" x:Name="HostList" Margin="6"
ItemsSource="{Binding Vault.Hosts}"
SelectedItem="{Binding Vault.SelectedHost}"
Background="Transparent">
@@ -306,7 +307,13 @@
<StackPanel Spacing="12">
<TextBlock Classes="heading" Text="Unlock your vault" />
<TextBlock Text="{Binding AccountName}" Foreground="#bcd2ea" />
<TextBox Text="{Binding Passphrase}" PlaceholderText="vault passphrase" PasswordChar="•" />
<!--
Named because locking has to put the keyboard here explicitly. The terminal's native
child window keeps Win32 focus when it is collapsed, so without that this box would
show a caret and silently swallow the passphrase — see NativeKeyboardFocus.
-->
<TextBox x:Name="UnlockPassphrase" Text="{Binding Passphrase}"
PlaceholderText="vault passphrase" PasswordChar="•" />
<Button Content="Unlock" Command="{Binding UnlockCommand}"
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
@@ -1,23 +1,117 @@
using System.ComponentModel;
using Avalonia.Controls;
using Avalonia.Input;
using DodoSSH.Client.App.ViewModels;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// The shell window.
/// </summary>
/// <remarks>
/// Keyboard focus across the Avalonia/WebView boundary is handled here rather than in a view model,
/// because it is a property of the controls and not of the state. What the view models expose is the
/// two facts the policy needs — a session opened, and the vault is no longer unlocked — and both are
/// things they already know. See <see cref="NativeKeyboardFocus"/> for why one direction is a plain
/// <c>Focus()</c> call and the other is not.
/// </remarks>
internal sealed partial class MainWindow : Window
{
/// <summary>
/// The page's request to give the keyboard back to the application.
/// </summary>
/// <remarks>
/// It has to come from the page. Once the native child window holds Win32 focus, Avalonia sees no
/// key events at all, so a <c>KeyBinding</c> on this window could never fire — the terminal is the
/// only thing that can hear the shortcut and ask to be let go of.
/// </remarks>
private const string ReleaseFocusMessage = "dodossh.release-focus";
private MainWindowViewModel? shell;
private bool wasUnlocked;
public MainWindow()
{
InitializeComponent();
// Navigation happens once the data context is known, because the URL carries the port the
// loopback listener was assigned. Setting Source in XAML would need a constant port, and a
// fixed port is one that another process can already be holding.
DataContextChanged += (_, _) =>
DataContextChanged += (_, _) => Attach(DataContext as MainWindowViewModel);
Terminal.WebMessageReceived += (_, e) =>
{
if (DataContext is MainWindowViewModel viewModel)
// Compared against a constant rather than parsed: the page sends exactly one message and
// treating anything else as a command would be a wider door than this needs. A string
// posted by the page arrives in Body verbatim.
if (string.Equals(e.Body, ReleaseFocusMessage, StringComparison.Ordinal))
{
Terminal.Source = viewModel.TerminalPageUrl;
ReleaseKeyboardTo(HostList);
}
};
}
private void Attach(MainWindowViewModel? viewModel)
{
if (shell is { } previous)
{
previous.TerminalSessionOpened -= OnTerminalSessionOpened;
previous.PropertyChanged -= OnShellPropertyChanged;
}
shell = viewModel;
if (viewModel is null)
{
return;
}
// Navigation happens once the data context is known, because the URL carries the port the
// loopback listener was assigned. Setting Source in XAML would need a constant port, and a
// fixed port is one that another process can already be holding.
Terminal.Source = viewModel.TerminalPageUrl;
wasUnlocked = viewModel.IsUnlocked;
viewModel.TerminalSessionOpened += OnTerminalSessionOpened;
viewModel.PropertyChanged += OnShellPropertyChanged;
}
/// <remarks>
/// A bare <c>Focus()</c> is the whole fix in this direction: <c>NativeWebView.OnGotFocus</c> pushes
/// Win32 focus into WebView2 for us. It has to happen while the control is visible, which it is —
/// a session can only be opened from an unlocked vault, and the vault being unlocked is what
/// reveals the control. Focus() on a collapsed control is measurably a no-op and is not replayed
/// when it is revealed.
/// </remarks>
private void OnTerminalSessionOpened(object? sender, EventArgs e) => Terminal.Focus();
private void OnShellPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (!string.Equals(e.PropertyName, nameof(MainWindowViewModel.IsUnlocked), StringComparison.Ordinal)
|| shell is not { } viewModel)
{
return;
}
var unlocked = viewModel.IsUnlocked;
// Only the transition out of unlocked matters. IsUnlocked is re-raised for every shell state
// change, and reacting to all of them would move focus during setup and sign-in.
if (wasUnlocked && !unlocked)
{
ReleaseKeyboardTo(UnlockPassphrase);
}
wasUnlocked = unlocked;
}
/// <remarks>
/// Both halves are needed. The Win32 call moves the keyboard off the native child window, and the
/// <c>Focus()</c> gives it somewhere to go — collapsing the terminal leaves Avalonia with no
/// focused element, so the keystrokes would otherwise reach the window and stop there.
/// </remarks>
private void ReleaseKeyboardTo(IInputElement target)
{
NativeKeyboardFocus.ReturnTo(this);
target.Focus();
}
}
@@ -0,0 +1,74 @@
using System.Runtime.InteropServices;
using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// Moves the window's keyboard focus back out of the terminal's native child window.
/// </summary>
/// <remarks>
/// <para>
/// The two directions across this boundary are not symmetric, and only one of them needs anything
/// like this. <see cref="NativeWebView"/> is <c>Focusable</c> by default and its <c>OnGotFocus</c>
/// calls the adapter's <c>Focus()</c>, which on Windows is
/// <c>ICoreWebView2Controller::MoveFocus(PROGRAMMATIC)</c> — so an ordinary Avalonia
/// <c>Focus()</c> call on the control really does hand Win32 focus to WebView2. Measured: focus
/// lands on the <c>Chrome_WidgetWin_1</c> child and the page reports
/// <c>document.hasFocus() == true</c>. Nothing custom is needed to give the terminal the keyboard.
/// </para>
/// <para>
/// Coming back is where the package stops helping. <c>OnLostFocus</c> calls the adapter's
/// <c>ResignFocus()</c>, and on Windows that method is <b>empty</b> — so moving Avalonia's focus to
/// another control leaves Win32 focus on WebView2. Measured: after <c>textBox.Focus()</c> the
/// focused element is the <c>TextBox</c> while <c>GetFocus()</c> is still the WebView2 child and the
/// page still reports focus, which is a text box that shows a caret and silently receives nothing.
/// <c>Window.Activate()</c> and <c>Window.Focus()</c> were both measured and neither recovers it.
/// A real mouse click does, because Avalonia's window sets focus on pointer input — which is why the
/// symptom is invisible to anyone who clicks before typing.
/// </para>
/// <para>
/// So the hand-back has to be the Win32 call. This is safe to run at any time, including while the
/// application is in the background: both windows live on this thread's message queue, and
/// <c>SetFocus</c> confined to one queue changes which window receives keys without activating
/// anything or taking focus from another application.
/// </para>
/// </remarks>
internal static class NativeKeyboardFocus
{
/// <summary>
/// Returns keyboard focus to <paramref name="window"/>'s own window, so that Avalonia's focused
/// element receives keystrokes again.
/// </summary>
/// <remarks>
/// Focus an element afterwards. Collapsing the terminal leaves Avalonia with no focused element
/// at all, so handing the keyboard back without also choosing a target means keystrokes reach
/// the window and stop there.
/// </remarks>
internal static void ReturnTo(Window window)
{
// Only Windows hosts the WebView in a child window today. Elsewhere this is either
// unnecessary or wrong, and doing nothing is the honest option until a spike says otherwise
// — see docs/platform-flags.md on the unproven Linux backend.
if (!OperatingSystem.IsWindows())
{
return;
}
if (window.TryGetPlatformHandle()?.Handle is { } handle && handle != IntPtr.Zero)
{
SetFocus(handle);
}
}
/// <remarks>
/// <c>DllImport</c> rather than the source-generated <c>LibraryImport</c>, which requires
/// <c>AllowUnsafeBlocks</c> for the whole project. The signature is blittable, so there is no
/// marshalling stub for the generator to improve on, and turning unsafe code on across a client
/// that handles key material to save nothing would be a poor trade. Hence the SYSLIB1054
/// suppression rather than a fix.
/// </remarks>
#pragma warning disable SYSLIB1054
[DllImport("user32.dll")]
private static extern IntPtr SetFocus(IntPtr window);
#pragma warning restore SYSLIB1054
}
@@ -28,6 +28,19 @@ const CLIENT_RESIZE = 3;
const HEADER_LENGTH = 5;
const SCROLLBACK_LINES = 5000;
/*
The way out of the terminal, for someone using only a keyboard.
It has to be handled here rather than by the host: once this page's window owns Win32 focus, the
host's Avalonia window receives no key events at all, so nothing on that side could hear a shortcut.
Ctrl+Shift+F6 rather than Escape. F6 is the Windows convention for moving to the next pane, but a
bare F6 is a real terminal key that TUIs bind — as is Escape, which vim alone rules out. Ctrl+Shift
is the range terminal emulators conventionally keep for themselves and never forward to the remote,
so qualifying F6 with it keeps the convention without taking a key away from the remote shell.
*/
const RELEASE_FOCUS_MESSAGE = 'dodossh.release-focus';
const root = document.getElementById('root');
const statusBanner = document.getElementById('status');
@@ -80,6 +93,31 @@ function sendResize(sessionId, term, pane) {
send(CLIENT_RESIZE, sessionId, payload);
}
/**
* Asks the host to take keyboard focus back.
*
* Optional by design: the bridge only exists under a real embedded WebView, and this page is also
* openable in a plain browser for debugging, where there is no host to ask.
*/
function releaseFocusToHost() {
window.chrome?.webview?.postMessage(RELEASE_FOCUS_MESSAGE);
}
/**
* Swallows the release-focus shortcut so it never reaches the remote.
*
* Returning false stops xterm processing the event, which is what keeps the chord from being encoded
* and written to the pty.
*/
function handleKey(event) {
if (event.type === 'keydown' && event.ctrlKey && event.shiftKey && event.key === 'F6') {
releaseFocusToHost();
return false;
}
return true;
}
function createSession(sessionId) {
const pane = document.createElement('div');
pane.className = 'pane';
@@ -109,6 +147,8 @@ function createSession(sessionId) {
console.warn('WebGL renderer unavailable; falling back to canvas.', error);
}
term.attachCustomKeyEventHandler(handleKey);
term.onData((data) => {
send(CLIENT_INPUT, sessionId, new TextEncoder().encode(data));
});
@@ -0,0 +1,107 @@
using System.Globalization;
using System.Net.WebSockets;
using System.Text.RegularExpressions;
using DodoSSH.Client.Terminal;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// Stands in for the terminal page, so the connect path can be exercised without a WebView.
/// </summary>
/// <remarks>
/// <para>
/// It attaches the way the real renderer does rather than reaching for the workspace's internals:
/// fetch the served page, read the token and socket URL the host substituted into it, then open the
/// WebSocket with the same two subprotocols. Anything cheaper — handing it the token directly — would
/// stop testing the part of the handshake that has actually been got wrong before.
/// </para>
/// <para>
/// Attaching is what completes <c>TerminalWorkspace.WaitForRendererAsync</c>, and that await is the
/// real gate on a first connection: the data plane drops frames when nothing is attached rather than
/// queueing them, so a session opened before this exists would lose its <c>SessionOpened</c> frame.
/// </para>
/// </remarks>
internal sealed partial class FakeRenderer : IAsyncDisposable
{
private readonly ClientWebSocket socket;
private FakeRenderer(ClientWebSocket socket) => this.socket = socket;
/// <summary>Fetches the page and attaches a socket, as the real renderer would.</summary>
internal static async Task<FakeRenderer> AttachAsync(
TerminalWorkspace workspace,
CancellationToken cancellationToken)
{
using var http = new HttpClient();
var page = await http
.GetStringAsync(workspace.PageUrl, cancellationToken)
.ConfigureAwait(false);
var token = Attribute(page, "data-token");
var socketUrl = Attribute(page, "data-socket");
var attached = new ClientWebSocket();
attached.Options.AddSubProtocol(TerminalDataPlane.SubProtocol);
attached.Options.AddSubProtocol($"token.{token}");
// The listener requires the page's own origin, which is what makes a page in the user's
// browser unable to reach this socket.
attached.Options.SetRequestHeader(
"Origin",
string.Create(
CultureInfo.InvariantCulture,
$"{workspace.PageUrl.Scheme}://{workspace.PageUrl.Authority}"));
try
{
await attached.ConnectAsync(new Uri(socketUrl), cancellationToken).ConfigureAwait(false);
}
catch
{
attached.Dispose();
throw;
}
var renderer = new FakeRenderer(attached);
await workspace.WaitForRendererAsync().ConfigureAwait(false);
return renderer;
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (socket.State == WebSocketState.Open)
{
try
{
await socket
.CloseAsync(WebSocketCloseStatus.NormalClosure, null, CancellationToken.None)
.ConfigureAwait(false);
}
catch (WebSocketException)
{
// The host may have gone first; there is nothing to salvage either way.
}
}
socket.Dispose();
}
private static string Attribute(string page, string name)
{
var match = AttributeValue(name).Match(page);
return match.Success
? match.Groups[1].Value
: throw new InvalidOperationException(
$"The served page carried no {name}. The host substitutes it at serve time, so "
+ $"either the placeholder is missing from the test's page asset or substitution broke.");
}
private static Regex AttributeValue(string name) =>
new($"{Regex.Escape(name)}=\"([^\"]*)\"", RegexOptions.None, TimeSpan.FromSeconds(1));
}
+99
View File
@@ -0,0 +1,99 @@
using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// An SSH stack that connects to nothing.
/// </summary>
/// <remarks>
/// The shell suite is about what the view models do, and the real factory would need a reachable
/// sshd — which <c>DodoSSH.Client.Ssh.Tests</c> already covers against a container. What this makes
/// testable is everything the connect path does <em>around</em> the connection.
/// </remarks>
internal sealed class FakeSshConnectionFactory : ISshConnectionFactory
{
/// <summary>Thrown instead of connecting, when set. Used for the host-key paths.</summary>
internal Exception? Failure { get; set; }
/// <summary>Requests this factory was asked for, in order.</summary>
internal List<SshConnectionRequest> Requests { get; } = [];
/// <inheritdoc />
public Task<ISshConnection> ConnectAsync(
SshConnectionRequest request,
CancellationToken cancellationToken)
{
Requests.Add(request);
return Failure is { } failure
? Task.FromException<ISshConnection>(failure)
: Task.FromResult<ISshConnection>(new FakeSshConnection(request));
}
}
internal sealed class FakeSshConnection(SshConnectionRequest request) : ISshConnection
{
/// <inheritdoc />
public bool IsConnected { get; private set; } = true;
/// <inheritdoc />
public HostKeyPresentation HostKey { get; } =
new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake");
/// <inheritdoc />
public Task<ISshShellSession> OpenShellAsync(
TerminalSize size,
CancellationToken cancellationToken) =>
Task.FromResult<ISshShellSession>(new FakeSshShellSession());
/// <inheritdoc />
public ValueTask DisposeAsync()
{
IsConnected = false;
return ValueTask.CompletedTask;
}
}
/// <summary>A shell that is open, silent and never closes on its own.</summary>
/// <remarks>
/// <see cref="ReadAsync"/> blocks rather than returning 0. Returning 0 means the remote closed the
/// channel, which would end the session the moment it was opened and make the test assert against a
/// connection that had already gone.
/// </remarks>
internal sealed class FakeSshShellSession : ISshShellSession
{
private readonly CancellationTokenSource closed = new();
/// <inheritdoc />
public bool IsOpen { get; private set; } = true;
/// <inheritdoc />
public async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken)
{
using var linked = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, closed.Token);
await Task.Delay(Timeout.InfiniteTimeSpan, linked.Token).ConfigureAwait(false);
return 0;
}
/// <inheritdoc />
public ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken) =>
ValueTask.CompletedTask;
/// <inheritdoc />
public void Resize(TerminalSize size)
{
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
IsOpen = false;
await closed.CancelAsync().ConfigureAwait(false);
closed.Dispose();
}
}
@@ -29,6 +29,13 @@ public sealed class ShellFlowTests : IAsyncLifetime
private readonly FakeVaultServer server = new();
/// <remarks>
/// The real factory would need a reachable sshd, which <c>DodoSSH.Client.Ssh.Tests</c> covers against
/// a container. Nothing in this suite connected before, so substituting it costs no coverage and makes
/// the connect path reachable.
/// </remarks>
private readonly FakeSshConnectionFactory ssh = new();
private int signInAttempts;
private string directory = null!;
@@ -51,15 +58,26 @@ public sealed class ShellFlowTests : IAsyncLifetime
// In-memory assets rather than the application's Avalonia-resource provider, which reads the
// resource system at construction and needs an initialised toolkit. This is what
// ITerminalAssetProvider is for; nothing in this suite renders anything.
//
// The page carries the same two placeholders the real one does, because FakeRenderer attaches by
// reading them back out of the served page rather than by being handed the token.
workspace = new TerminalWorkspace(
new InMemoryTerminalAssetProvider(
new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)
{
["/terminal"] = new("text/html; charset=utf-8", "<!doctype html>"u8.ToArray()),
["/terminal"] = new(
"text/html; charset=utf-8",
System.Text.Encoding.UTF8.GetBytes(
$"<!doctype html><div data-token=\"{TerminalDataPlane.TokenPlaceholder}\" "
+ $"data-socket=\"{TerminalDataPlane.SocketUrlPlaceholder}\"></div>")),
}),
new SshNetConnectionFactory(knownHosts),
ssh,
TimeProvider.System);
// Started, as the application does immediately after composing it. Without the accept loop the
// page is never served, so nothing could attach a renderer.
workspace.Start();
shell = new MainWindowViewModel(
paths,
caches,
@@ -372,6 +390,95 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.Status.ShouldContain("bad day");
}
/// <remarks>
/// <para>
/// The page's own <c>term.focus()</c> focuses the textarea inside the document, which does nothing
/// while the window's keyboard focus is still on the Connect button — so the first keystrokes of a
/// session went to the shell's UI rather than the remote shell, and the terminal had to be clicked
/// first. The view hands the control focus when this fires; see <c>NativeKeyboardFocus</c> for why an
/// ordinary <c>Focus()</c> call is enough in that direction and not in the other.
/// </para>
/// <para>
/// What this covers is the plumbing that carries the fix: that the raise is on the success path and
/// happens once per session, and that the shell forwards it. Deleting the raise outright is already a
/// build error — the event would be unused, and warnings are errors — but moving it, which is the
/// likelier mistake, is not. It does <em>not</em> cover the focus call itself: that needs a native
/// window, and headless Avalonia has none, which is exactly why this class of defect has escaped
/// tests here before. Measured separately in a harness; see docs/platform-flags.md.
/// </para>
/// </remarks>
[Fact]
public async Task ConnectingAsksTheViewToFocusTheTerminal()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
var requests = 0;
shell.TerminalSessionOpened += (_, _) => requests++;
await vault.ConnectCommand.ExecuteAsync(null);
vault.Status.ShouldContain("Connected", Case.Insensitive);
requests.ShouldBe(1);
// Again, on a second session. This is why it is an event and not a bound flag: a boolean that was
// already true would not move focus to the terminal the user just opened.
await vault.ConnectCommand.ExecuteAsync(null);
requests.ShouldBe(2);
}
/// <remarks>
/// Focus must not be taken on a failure. A host-key prompt needs the keyboard on the prompt's own
/// buttons, and taking it into a terminal that has no session would strand the decision.
/// </remarks>
[Fact]
public async Task AFailedConnect_DoesNotAskForTheTerminalToBeFocused()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
ssh.Failure = new SshHostKeyUnknownException(
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:unknown"));
var requests = 0;
shell.TerminalSessionOpened += (_, _) => requests++;
await vault.ConnectCommand.ExecuteAsync(null);
vault.HasPendingHostKey.ShouldBeTrue();
requests.ShouldBe(0);
}
/// <remarks>
/// The shell stops forwarding once the vault is gone. Dropping the detach half of that would compile
/// and pass every other test, while leaving a discarded vault able to move focus in a locked window.
/// </remarks>
[Fact]
public async Task LockingStopsTheShellForwardingFocusRequests()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
var requests = 0;
shell.TerminalSessionOpened += (_, _) => requests++;
await vault.ConnectCommand.ExecuteAsync(null);
requests.ShouldBe(1);
await shell.LockCommand.ExecuteAsync(null);
shell.Vault.ShouldBeNull();
// The discarded vault is detached, so even a late raise from it reaches nobody.
await vault.ConnectCommand.ExecuteAsync(null);
requests.ShouldBe(1);
}
[Fact]
public async Task AnInvalidHost_IsRefusedWithAReason()
{
@@ -569,4 +676,17 @@ public sealed class ShellFlowTests : IAsyncLifetime
await vault.SaveHostCommand.ExecuteAsync(null);
}
/// <summary>An unlocked vault with one selected host and a renderer attached.</summary>
private async Task<VaultViewModel> ReadyToConnectAsync()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
vault.SelectedHost = vault.Hosts[0];
return vault;
}
}