diff --git a/docs/platform-flags.md b/docs/platform-flags.md
index e39db2a..e98c91a 100644
--- a/docs/platform-flags.md
+++ b/docs/platform-flags.md
@@ -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
diff --git a/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs b/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs
index 3cebb3f..f60236f 100644
--- a/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs
+++ b/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs
@@ -142,6 +142,17 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// Where the embedded browser should navigate.
internal Uri TerminalPageUrl => workspace.PageUrl;
+ ///
+ /// Raised when a terminal session opens, so the view can hand the terminal the keyboard.
+ ///
+ ///
+ /// Forwarded from rather than exposed there directly,
+ /// because 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.
+ ///
+ internal event EventHandler? TerminalSessionOpened;
+
internal bool IsStarting => State == ShellState.Starting;
internal bool IsNeedingServer => State == ShellState.NeedsServer;
@@ -464,6 +475,26 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
return exception.Message;
}
+ ///
+ /// One place for the subscription, so unlocking, locking and disposing all route through it rather
+ /// than each remembering to detach.
+ ///
+ 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));
diff --git a/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs b/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs
index c3f5591..5ad913b 100644
--- a/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs
+++ b/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs
@@ -159,6 +159,18 @@ internal sealed partial class VaultViewModel(
[ObservableProperty]
private string? hostKeyMismatch;
+ ///
+ /// Raised once a terminal session is open and its renderer has it.
+ ///
+ ///
+ /// 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 ConfigureAwait(true) — so a handler may touch
+ /// controls directly.
+ ///
+ internal event EventHandler? SessionOpened;
+
internal bool HasPendingHostKey => PendingHostKey is not null;
internal bool HasHostKeyMismatch => HostKeyMismatch is not null;
@@ -431,6 +443,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)
{
diff --git a/src/DodoSSH.Client.App/Views/MainWindow.axaml b/src/DodoSSH.Client.App/Views/MainWindow.axaml
index 2b67440..8664cc4 100644
--- a/src/DodoSSH.Client.App/Views/MainWindow.axaml
+++ b/src/DodoSSH.Client.App/Views/MainWindow.axaml
@@ -77,7 +77,8 @@
-
+
@@ -306,7 +307,13 @@
-
+
+
diff --git a/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs b/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs
index df7155b..9deca5d 100644
--- a/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs
+++ b/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs
@@ -1,23 +1,117 @@
+using System.ComponentModel;
using Avalonia.Controls;
+using Avalonia.Input;
using DodoSSH.Client.App.ViewModels;
namespace DodoSSH.Client.App.Views;
+///
+/// The shell window.
+///
+///
+/// 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 for why one direction is a plain
+/// Focus() call and the other is not.
+///
internal sealed partial class MainWindow : Window
{
+ ///
+ /// The page's request to give the keyboard back to the application.
+ ///
+ ///
+ /// It has to come from the page. Once the native child window holds Win32 focus, Avalonia sees no
+ /// key events at all, so a KeyBinding on this window could never fire — the terminal is the
+ /// only thing that can hear the shortcut and ask to be let go of.
+ ///
+ 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;
+ }
+
+ ///
+ /// A bare Focus() is the whole fix in this direction: NativeWebView.OnGotFocus 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.
+ ///
+ 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;
+ }
+
+ ///
+ /// Both halves are needed. The Win32 call moves the keyboard off the native child window, and the
+ /// Focus() 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.
+ ///
+ private void ReleaseKeyboardTo(IInputElement target)
+ {
+ NativeKeyboardFocus.ReturnTo(this);
+ target.Focus();
+ }
}
diff --git a/src/DodoSSH.Client.App/Views/NativeKeyboardFocus.cs b/src/DodoSSH.Client.App/Views/NativeKeyboardFocus.cs
new file mode 100644
index 0000000..a79607b
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/NativeKeyboardFocus.cs
@@ -0,0 +1,74 @@
+using System.Runtime.InteropServices;
+using Avalonia.Controls;
+
+namespace DodoSSH.Client.App.Views;
+
+///
+/// Moves the window's keyboard focus back out of the terminal's native child window.
+///
+///
+///
+/// The two directions across this boundary are not symmetric, and only one of them needs anything
+/// like this. is Focusable by default and its OnGotFocus
+/// calls the adapter's Focus(), which on Windows is
+/// ICoreWebView2Controller::MoveFocus(PROGRAMMATIC) — so an ordinary Avalonia
+/// Focus() call on the control really does hand Win32 focus to WebView2. Measured: focus
+/// lands on the Chrome_WidgetWin_1 child and the page reports
+/// document.hasFocus() == true. Nothing custom is needed to give the terminal the keyboard.
+///
+///
+/// Coming back is where the package stops helping. OnLostFocus calls the adapter's
+/// ResignFocus(), and on Windows that method is empty — so moving Avalonia's focus to
+/// another control leaves Win32 focus on WebView2. Measured: after textBox.Focus() the
+/// focused element is the TextBox while GetFocus() is still the WebView2 child and the
+/// page still reports focus, which is a text box that shows a caret and silently receives nothing.
+/// Window.Activate() and Window.Focus() 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.
+///
+///
+/// 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
+/// SetFocus confined to one queue changes which window receives keys without activating
+/// anything or taking focus from another application.
+///
+///
+internal static class NativeKeyboardFocus
+{
+ ///
+ /// Returns keyboard focus to 's own window, so that Avalonia's focused
+ /// element receives keystrokes again.
+ ///
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+ }
+
+ ///
+ /// DllImport rather than the source-generated LibraryImport, which requires
+ /// AllowUnsafeBlocks 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.
+ ///
+#pragma warning disable SYSLIB1054
+ [DllImport("user32.dll")]
+ private static extern IntPtr SetFocus(IntPtr window);
+#pragma warning restore SYSLIB1054
+}
diff --git a/src/DodoSSH.Client.App/WebAssets/terminal.js b/src/DodoSSH.Client.App/WebAssets/terminal.js
index 7eed365..ef28a1b 100644
--- a/src/DodoSSH.Client.App/WebAssets/terminal.js
+++ b/src/DodoSSH.Client.App/WebAssets/terminal.js
@@ -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));
});
diff --git a/tests/DodoSSH.Client.App.Tests/FakeRenderer.cs b/tests/DodoSSH.Client.App.Tests/FakeRenderer.cs
new file mode 100644
index 0000000..5f2b05d
--- /dev/null
+++ b/tests/DodoSSH.Client.App.Tests/FakeRenderer.cs
@@ -0,0 +1,107 @@
+using System.Globalization;
+using System.Net.WebSockets;
+using System.Text.RegularExpressions;
+using DodoSSH.Client.Terminal;
+
+namespace DodoSSH.Client.App.Tests;
+
+///
+/// Stands in for the terminal page, so the connect path can be exercised without a WebView.
+///
+///
+///
+/// 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.
+///
+///
+/// Attaching is what completes TerminalWorkspace.WaitForRendererAsync, 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 SessionOpened frame.
+///
+///
+internal sealed partial class FakeRenderer : IAsyncDisposable
+{
+ private readonly ClientWebSocket socket;
+
+ private FakeRenderer(ClientWebSocket socket) => this.socket = socket;
+
+ /// Fetches the page and attaches a socket, as the real renderer would.
+ internal static async Task 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;
+ }
+
+ ///
+ 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));
+}
diff --git a/tests/DodoSSH.Client.App.Tests/FakeSsh.cs b/tests/DodoSSH.Client.App.Tests/FakeSsh.cs
new file mode 100644
index 0000000..20c4ec9
--- /dev/null
+++ b/tests/DodoSSH.Client.App.Tests/FakeSsh.cs
@@ -0,0 +1,99 @@
+using DodoSSH.Client.Ssh;
+
+namespace DodoSSH.Client.App.Tests;
+
+///
+/// An SSH stack that connects to nothing.
+///
+///
+/// The shell suite is about what the view models do, and the real factory would need a reachable
+/// sshd — which DodoSSH.Client.Ssh.Tests already covers against a container. What this makes
+/// testable is everything the connect path does around the connection.
+///
+internal sealed class FakeSshConnectionFactory : ISshConnectionFactory
+{
+ /// Thrown instead of connecting, when set. Used for the host-key paths.
+ internal Exception? Failure { get; set; }
+
+ /// Requests this factory was asked for, in order.
+ internal List Requests { get; } = [];
+
+ ///
+ public Task ConnectAsync(
+ SshConnectionRequest request,
+ CancellationToken cancellationToken)
+ {
+ Requests.Add(request);
+
+ return Failure is { } failure
+ ? Task.FromException(failure)
+ : Task.FromResult(new FakeSshConnection(request));
+ }
+}
+
+internal sealed class FakeSshConnection(SshConnectionRequest request) : ISshConnection
+{
+ ///
+ public bool IsConnected { get; private set; } = true;
+
+ ///
+ public HostKeyPresentation HostKey { get; } =
+ new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake");
+
+ ///
+ public Task OpenShellAsync(
+ TerminalSize size,
+ CancellationToken cancellationToken) =>
+ Task.FromResult(new FakeSshShellSession());
+
+ ///
+ public ValueTask DisposeAsync()
+ {
+ IsConnected = false;
+ return ValueTask.CompletedTask;
+ }
+}
+
+/// A shell that is open, silent and never closes on its own.
+///
+/// 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.
+///
+internal sealed class FakeSshShellSession : ISshShellSession
+{
+ private readonly CancellationTokenSource closed = new();
+
+ ///
+ public bool IsOpen { get; private set; } = true;
+
+ ///
+ public async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken)
+ {
+ using var linked = CancellationTokenSource.CreateLinkedTokenSource(
+ cancellationToken, closed.Token);
+
+ await Task.Delay(Timeout.InfiniteTimeSpan, linked.Token).ConfigureAwait(false);
+
+ return 0;
+ }
+
+ ///
+ public ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken) =>
+ ValueTask.CompletedTask;
+
+ ///
+ public void Resize(TerminalSize size)
+ {
+ }
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ IsOpen = false;
+
+ await closed.CancelAsync().ConfigureAwait(false);
+
+ closed.Dispose();
+ }
+}
diff --git a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
index 5324310..5ffc77f 100644
--- a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
+++ b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
@@ -29,6 +29,13 @@ public sealed class ShellFlowTests : IAsyncLifetime
private readonly FakeVaultServer server = new();
+ ///
+ /// The real factory would need a reachable sshd, which DodoSSH.Client.Ssh.Tests covers against
+ /// a container. Nothing in this suite connected before, so substituting it costs no coverage and makes
+ /// the connect path reachable.
+ ///
+ 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(StringComparer.Ordinal)
{
- ["/terminal"] = new("text/html; charset=utf-8", ""u8.ToArray()),
+ ["/terminal"] = new(
+ "text/html; charset=utf-8",
+ System.Text.Encoding.UTF8.GetBytes(
+ $"")),
}),
- 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,
@@ -294,6 +312,95 @@ public sealed class ShellFlowTests : IAsyncLifetime
server.LiveRowCount.ShouldBe(0, "nothing should have been pushed yet");
}
+ ///
+ ///
+ /// 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 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 NativeKeyboardFocus for why an
+ /// ordinary Focus() call is enough in that direction and not in the other.
+ ///
+ ///
+ /// 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 not 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.
+ ///
+ ///
+ [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);
+ }
+
+ ///
+ /// 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.
+ ///
+ [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);
+ }
+
+ ///
+ /// 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.
+ ///
+ [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()
{
@@ -478,4 +585,17 @@ public sealed class ShellFlowTests : IAsyncLifetime
await vault.SaveHostCommand.ExecuteAsync(null);
}
+
+ /// An unlocked vault with one selected host and a renderer attached.
+ private async Task ReadyToConnectAsync()
+ {
+ await UnlockedAsync();
+
+ var vault = shell.Vault!;
+
+ await AddHostAsync(vault, "prod-db");
+ vault.SelectedHost = vault.Hosts[0];
+
+ return vault;
+ }
}