diff --git a/docs/platform-flags.md b/docs/platform-flags.md index e715211..cdd00d3 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 655c5df..53a19df 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; @@ -469,6 +480,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 35aee2c..39136dc 100644 --- a/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs +++ b/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs @@ -179,6 +179,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; @@ -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) { 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 @@ - + +