From f80b3d435156c3473b404d4b82533cce8a0f3796 Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Wed, 29 Jul 2026 13:42:23 +0200 Subject: [PATCH] Harden the WebView collapse, and replace its evidence with a measurement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An adversarial review of 0500e43 did not refute the fix but closed the gap I had left open and found three hazards around it. A standalone spike — a 60-line Avalonia app with no DodoSSH code — reproduces the airspace bug on a 340,* grid, and a second harness mirroring the data plane's handshake measures what I had only reasoned about: with IsVisible=false set before the window is ever shown, the adapter is created, the page is fetched and the WebSocket 101 is sent, with frames arriving over the socket while hidden. A cold WebView2 profile behaves the same. Revealing recomputes bounds in about 7 ms. So the docs no longer cite "35 msedgewebview2 processes" as the confirmation that the renderer attaches. A process count cannot show that a socket was accepted — the same shape of mistake, one level down, as the one that entry was already correcting. It now cites the handshake, quotes Avalonia's maintainer on airspace being by design, and links the still-open upstream issue. Three changes to the fix itself: - terminal.js skips the fit below 40px in either axis. The vendored fit addon floors its proposal at 2 columns by 1 row rather than refusing, so a degenerate viewport reflows the *remote* pty through window-change and mangles wrapped scrollback unrecoverably. Reachable today by minimising, and by dragging a splitter to the edge once splits land — a guard where the sizes arrive, not a special case for one caller. - FallbackValue=False on the binding. A compiled binding with no DataContext yields UnsetValue, IsVisible falls back to true, and the occlusion returns silently. Not reachable at runtime; it is what the previewer does. - The comment now says why it must be IsVisible on this control: detaching destroys the native control and the whole WebView2 process tree, so conditional content would pay a cold start per unlock, and hoisting the binding to an ancestor is unverified because NativeWebView's own bounds-and-scaling re-push fires only for its own IsVisible. Also recorded, not fixed: hiding does not suspend the page (visibilityState stays "visible" and rAF keeps firing at ~115/s, which is *why* the handshake completes while hidden); the conflict log can squeeze the terminal row toward nothing; and nothing hands the terminal Win32 focus after Connect, so the first keystrokes go to the shell's UI rather than the remote shell. --- docs/platform-flags.md | 51 +++++++++++++++++-- src/DodoSSH.Client.App/Views/MainWindow.axaml | 14 ++++- src/DodoSSH.Client.App/WebAssets/terminal.js | 14 +++++ 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/docs/platform-flags.md b/docs/platform-flags.md index a891e02..e39db2a 100644 --- a/docs/platform-flags.md +++ b/docs/platform-flags.md @@ -39,9 +39,17 @@ Avalonia-drawn content, which is the axis on which it does not behave like an or next entry. **A native child window cannot be covered by Avalonia content, on any platform that hosts it windowed.** -`NativeWebView` attaches a real Win32 child HWND through `NativeControlHost`, and a child window paints -above everything its parent draws, whatever the visual tree's z-order says. Layering a screen over the -terminal therefore does nothing: the WebView's rectangle stays on top. In this shell that sliced the setup +`NativeWebView` attaches a real Win32 child HWND through `NativeControlHost` — on Windows the backend +creates a `WS_CHILD` holder window and `SetParent`s WebView2's HWND into it — and a child window paints +above everything its parent draws, whatever the visual tree's z-order says. This is by design and +acknowledged upstream: *"NativeControlHost places native controls over Avalonia content just like WPF one +does. So it suffers from the same airspace problem"* (Avalonia's maintainer, +[#6605](https://github.com/AvaloniaUI/Avalonia/issues/6605), still open). Reproduced in a 60-line standalone +app with no DodoSSH code: a `340,*` grid, a `NativeWebView` in column 1 and an opaque `Border` as a later +`Panel` sibling renders the overlay sliced dead on x=340. + +Layering a screen over the terminal therefore does nothing: the WebView's rectangle stays on top. In this +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 @@ -57,8 +65,21 @@ for a while: - `NativeWebView` stashes a `Source` assigned before its adapter exists and replays it once created, so navigation is never lost to ordering. The shell already depends on that replay. - So a collapsed WebView still starts WebView2, still loads the page and still lets the renderer attach its - socket. Confirmed on Windows: 35 `msedgewebview2` processes with the control collapsed behind the setup - screen. + socket. Measured on Windows in a harness mirroring the data plane's handshake, with `IsVisible=false` set + before the window was ever shown: adapter created, `GET /`, then **the WebSocket 101 sent** — the moment + `RendererAttached` fires — followed by frames arriving over the socket, all while hidden. A cold WebView2 + profile behaves the same. Revealing it recomputes bounds within about 7 ms, on one `ResizeObserver` + callback, over the same socket. + + It must be `IsVisible`, not removal from the tree. Detaching runs `DestroyNativeControl` and takes the + whole WebView2 process tree with it, so conditional content or a template swap would pay a cold start on + every unlock. Hiding merely does + `SetWindowPos(holder, …, SWP_HIDEWINDOW)`. Negative `Margin` also works as a runtime toggle; + `RenderTransform` does **not**, because `NativeControlHost` never watches it. + + Note the earlier version of this bullet cited "35 `msedgewebview2` processes" as the confirmation. A + process count cannot show that a socket was accepted — it is the same shape of mistake as the one + described below, one level down. The previous version of this entry claimed the reverse — that hiding it would mean never realising it — and cited the `msedgewebview2` connection as verification. That observation was made while the overlay was @@ -73,6 +94,26 @@ drops frames when no renderer is attached rather than queueing them. That await control's visibility is not. It currently has no timeout, so a WebView2 that fails to initialise hangs Connect with the busy flag stuck — worth fixing on its own merits. +**Hiding the WebView does not pause it.** With the holder window hidden, the page keeps +`visibilityState: "visible"` and `requestAnimationFrame` keeps firing at roughly 115/s — Chromium does not +treat a hidden child HWND as a hidden page. That is *why* the handshake completes while collapsed, so it is +load-bearing rather than merely wasteful, but it means a locked DodoSSH is still animating a full-size +off-screen page. Worth revisiting if idle power ever matters. + +**A degenerate pane size reaches the remote pty.** The fit addon floors its proposal at 2 columns by 1 row +rather than refusing, so any path that fits a terminal with almost no viewport sends `window-change` for a +2x1 window and permanently mangles the wrapped scrollback. Reachable today by minimising, and — once splits +land — by dragging a splitter to the edge. `terminal.js` now skips the fit below 40 px in either axis. +Related and not yet addressed: the conflict log above the terminal is an `ItemsControl` with no +`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. + **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 window for native control host"* — so the WebView, and therefore the terminal, does not start at all. diff --git a/src/DodoSSH.Client.App/Views/MainWindow.axaml b/src/DodoSSH.Client.App/Views/MainWindow.axaml index ebb6ec0..2b67440 100644 --- a/src/DodoSSH.Client.App/Views/MainWindow.axaml +++ b/src/DodoSSH.Client.App/Views/MainWindow.axaml @@ -220,8 +220,20 @@ IsVisible is load-bearing rather than cosmetic — see the note on the root Panel. Without it the native child window paints over the setup and unlock screens and swallows their input. + + It must stay IsVisible on this control specifically, and two nearby alternatives are wrong. + Removing the control from the tree instead — conditional content, a template swap — detaches it, + and detaching destroys the native control and the whole WebView2 process tree, so every unlock + would pay a cold start. Hoisting the binding to an ancestor looks tidier and is unverified: + NativeControlHost does watch ancestors, but NativeWebView's own bounds-and-scaling re-push fires + only for its own IsVisible. + + FallbackValue, because a compiled binding with no DataContext yields UnsetValue, IsVisible then + falls back to its default of true, and the occlusion comes back silently. Not reachable at + runtime — the DataContext is set before the window is shown — but it is what the previewer does. --> - + diff --git a/src/DodoSSH.Client.App/WebAssets/terminal.js b/src/DodoSSH.Client.App/WebAssets/terminal.js index 6081058..7eed365 100644 --- a/src/DodoSSH.Client.App/WebAssets/terminal.js +++ b/src/DodoSSH.Client.App/WebAssets/terminal.js @@ -135,7 +135,21 @@ function activate(sessionId) { } } +// Below this, a pane is not being looked at — it is minimised, dragged to nothing, or the host has +// hidden its window. Fitting anyway would be actively harmful rather than merely useless: the fit addon +// floors its proposal at 2 columns by 1 row, so a degenerate viewport reflows the *remote* pty to 2x1 +// through window-change, and the wrapped scrollback that produces cannot be recovered when the pane comes +// back. A guard rather than a fix for one caller, because several paths reach here — a minimised window, a +// splitter dragged to the edge, and a host that hides the WebView while the vault is locked. +const MINIMUM_FITTABLE_PIXELS = 40; + function resize(session, sessionId) { + const pane = session.pane; + + if (pane.clientWidth < MINIMUM_FITTABLE_PIXELS || pane.clientHeight < MINIMUM_FITTABLE_PIXELS) { + return; + } + // fit() throws if the pane has no layout yet, which happens on the very first frame. try { session.fit.fit();