Public Access
Harden the WebView collapse, and replace its evidence with a measurement
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.
This commit is contained in:
+46
-5
@@ -39,9 +39,17 @@ Avalonia-drawn content, which is the axis on which it does not behave like an or
|
|||||||
next entry.
|
next entry.
|
||||||
|
|
||||||
**A native child window cannot be covered by Avalonia content, on any platform that hosts it windowed.**
|
**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
|
`NativeWebView` attaches a real Win32 child HWND through `NativeControlHost` — on Windows the backend
|
||||||
above everything its parent draws, whatever the visual tree's z-order says. Layering a screen over the
|
creates a `WS_CHILD` holder window and `SetParent`s WebView2's HWND into it — and a child window paints
|
||||||
terminal therefore does nothing: the WebView's rectangle stays on top. In this shell that sliced the setup
|
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
|
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
|
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
|
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
|
- `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.
|
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
|
- 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
|
socket. Measured on Windows in a harness mirroring the data plane's handshake, with `IsVisible=false` set
|
||||||
screen.
|
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
|
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
|
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
|
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.
|
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
|
**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
|
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.
|
window for native control host"* — so the WebView, and therefore the terminal, does not start at all.
|
||||||
|
|||||||
@@ -220,8 +220,20 @@
|
|||||||
|
|
||||||
IsVisible is load-bearing rather than cosmetic — see the note on the root Panel. Without it the
|
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.
|
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.
|
||||||
-->
|
-->
|
||||||
<NativeWebView Grid.Row="2" x:Name="Terminal" IsVisible="{Binding IsUnlocked}" />
|
<NativeWebView Grid.Row="2" x:Name="Terminal"
|
||||||
|
IsVisible="{Binding IsUnlocked, FallbackValue=False}" />
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
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.
|
// fit() throws if the pane has no layout yet, which happens on the very first frame.
|
||||||
try {
|
try {
|
||||||
session.fit.fit();
|
session.fit.fit();
|
||||||
|
|||||||
Reference in New Issue
Block a user