From dbddbcd7110e7d6a7c289823bc05cc0c19a43f23 Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Wed, 29 Jul 2026 14:31:09 +0200 Subject: [PATCH] Hand the terminal the keyboard on connect, and take it back on lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a successful connect the first keystrokes went to the shell's UI rather than the remote shell. 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 terminal had to be clicked before it would accept anything. The obvious guess about the fix — that reaching a native child window needs SetFocus through P/Invoke — is backwards, and measuring it first is what kept this small. NativeWebView overrides Focusable to true and its OnGotFocus calls the adapter's Focus(), which on Windows is ICoreWebView2Controller::MoveFocus(PROGRAMMATIC). So a plain Avalonia Terminal.Focus() really does move Win32 focus into WebView2. Measured in a standalone harness with no DodoSSH code, on the same 340,* grid as the shell, reporting GetFocus() and the page's own document.hasFocus() at each step: focus lands on the Chrome_WidgetWin_1 child and the page reports hasFocus: true. It is the return trip the package does not implement. OnLostFocus calls the adapter's ResignFocus(), and on Windows that method body is empty, so Avalonia's focus and Win32's diverge: after textBox.Focus() the focused element is the text box while the keyboard is still on WebView2 — a caret that silently receives nothing. Window.Activate() and Window.Focus() were both measured and neither recovers it, so the hand-back is a SetFocus on the top-level, in Views/NativeKeyboardFocus.cs. A real mouse click does recover it, because Avalonia's window sets focus on pointer input, which is why this is invisible to anyone who clicks before typing. That turned up a worse defect than the one being fixed, and it shipped in 0500e43. Collapsing the WebView does not release the keyboard: focus stays on the hidden holder — measured held by a window reporting visible=False — while Avalonia's focused element becomes (none). So a user who had clicked the terminal and then pressed Lock got an unlock screen that swallowed the passphrase. Locking now hands the keyboard back and focuses that box. Ctrl+Shift+F6 is the way out for someone using only a keyboard. It has to be handled in terminal.js and posted to the host as a web message, because once the child window owns Win32 focus Avalonia receives no key events and no KeyBinding could fire; the package also subscribes MoveFocusRequested and discards it, so there is no Tab-out to lean on. Not Escape, which vim alone rules out, and not a bare F6, which TUIs bind — Ctrl+Shift is the range terminal emulators conventionally keep for themselves and never forward to the remote. Verified rather than assumed: the posted string arrives verbatim in Body, and the chord reaches the page as F6 with both modifiers. Order matters and is now recorded. Focus() on a collapsed control is a measured no-op and is not replayed when it is revealed, so focus survives a lock/unlock cycle only because a session can be opened solely from an unlocked vault, which is what reveals the control in the first place. The view models still reference no view. VaultViewModel raises SessionOpened on the success path only, the shell forwards it as TerminalSessionOpened through the generated OnVaultChanged hook so unlock, lock and dispose all attach and detach in one place, and the view holds the whole focus policy. An event rather than a bound flag because connecting a second host while one is open has to move focus again, and no state change describes that. Three tests, and what they do not cover is the point. They cover the plumbing: focus is asked for once per session, a failed connect does not ask at all — a host-key prompt needs the keyboard on its own buttons — and locking stops the forwarding. They cannot cover the focus call, because headless Avalonia has no native window, so a headless test would focus correctly and confirm the wrong belief; that is measured in the harness and written down in docs instead. Dropping the forwarding fails two of them and dropping the detach fails one; deleting the raise outright does not compile, since the event would be unused. Reaching the connect path at all needed two new fakes. FakeRenderer attaches the way the real page does — fetch the served page, read back the token and socket URL the host substituted into it, then open the socket with both subprotocols — rather than being handed the token, so the part of the handshake that has been got wrong before stays under test. FakeSsh replaces a factory that would need a reachable sshd, which DodoSSH.Client.Ssh.Tests already covers against a container. The suite also never called workspace.Start(), so nothing served the page and no renderer could have attached. DllImport rather than the source-generated LibraryImport, which requires AllowUnsafeBlocks for the whole project. The signature is blittable so there is no marshalling stub to improve on, and turning unsafe code on across a client that handles key material to gain nothing is a poor trade. Correcting an earlier entry: docs/platform-flags.md described this as a focus-plumbing gap and offered "click inside the terminal first" as the workaround. Both true, and both stop short of the half that matters — focus crosses into the WebView readily and never comes back on its own, which is the same mechanism as the text boxes that mysteriously stopped accepting keystrokes in the airspace entry above it, not a separate fault. --- docs/platform-flags.md | 47 ++++++- .../ViewModels/MainWindowViewModel.cs | 31 +++++ .../ViewModels/VaultViewModel.cs | 18 +++ src/DodoSSH.Client.App/Views/MainWindow.axaml | 11 +- .../Views/MainWindow.axaml.cs | 106 ++++++++++++++- .../Views/NativeKeyboardFocus.cs | 74 +++++++++++ src/DodoSSH.Client.App/WebAssets/terminal.js | 40 ++++++ .../DodoSSH.Client.App.Tests/FakeRenderer.cs | 107 +++++++++++++++ tests/DodoSSH.Client.App.Tests/FakeSsh.cs | 99 ++++++++++++++ .../ShellFlowTests.cs | 124 +++++++++++++++++- 10 files changed, 641 insertions(+), 16 deletions(-) create mode 100644 src/DodoSSH.Client.App/Views/NativeKeyboardFocus.cs create mode 100644 tests/DodoSSH.Client.App.Tests/FakeRenderer.cs create mode 100644 tests/DodoSSH.Client.App.Tests/FakeSsh.cs 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 @@ - + +