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();