Stop the terminal's WebView painting over the setup screens

The shell layered its setup and unlock screens over the terminal, which does
not work: NativeWebView attaches a real Win32 child HWND through
NativeControlHost, and a child window composites above everything its parent
paints regardless of visual-tree z-order. The cards rendered sliced at the
terminal column's left edge; at the window's default width every one of their
buttons fell inside the WebView's rectangle, so the flow could only be
completed by keyboard, and a click in that region handed Win32 focus to
WebView2 so the text boxes silently stopped accepting keystrokes.

The WebView is now collapsed while the vault is not unlocked. The comment
that previously forbade this — hiding it means never realising it — was
wrong: NativeControlHost creates the native attachment on attach to the
visual tree, never consulting layout or visibility, and NativeWebView replays
a Source assigned before its adapter exists. A collapsed WebView still starts
WebView2, loads the page and lets the renderer attach. Confirmed: 35
msedgewebview2 processes with the control collapsed. What the first
connection after unlocking actually depends on is the existing await on
WaitForRendererAsync, since the data plane drops frames when no renderer is
attached.

Also fixes the second visible defect: the default server URL was
https://localhost:7217, the API's *second* launch profile, while the README,
its appsettings and a plain `dotnet run` all use http://localhost:5233 — so
nothing was listening, and an HTTPS client against a plaintext port reports
"The SSL connection could not be established", which reads as a certificate
problem. The default now matches, a missing scheme is rejected by name
instead of parsing as scheme "localhost", and that specific TLS failure now
suggests http://. Both new tests fail when the fixes are reverted.

Corrections to claims I made earlier and should not have:

- docs/platform-flags.md asserted the opposite of the mechanism above and
  cited an established msedgewebview2 connection as verification. That
  observation was taken while the overlay was showing but, because of this
  very bug, the WebView was uncovered and in plain view — so it confirmed
  only that a visible WebView is realised. A process-level check cannot
  verify a rendering claim. The entry was also filed under "Local cache".
- ITerminalHost was documented as the live seam the app plugs into, with a
  stub standing in for headless tests. It has no implementation anywhere and
  no test uses it; the view navigates the control directly. It also counted
  Avalonia.Controls.WebView and NativeWebView as two interchangeable
  backends when they are one component, with the Linux backend backwards.
- The README claimed the shell's whole path was covered by tests. Its state
  machine is; its layout is covered by nothing, and a headless test could
  not have caught this — headless has no native window, so it would have
  rendered correctly and confirmed the wrong belief.

Verified by screenshotting the running app: the card renders complete and
centred at the default size, with the button clickable.
This commit is contained in:
2026-07-29 13:26:30 +02:00
parent b9e7c258ae
commit 0500e43e02
7 changed files with 206 additions and 35 deletions
+55 -12
View File
@@ -28,10 +28,50 @@ or notarization fails with an error that does not name the offending file.
## Desktop client
**The WebView works on Windows.** `Avalonia.Controls.WebView` 12.0.1 (MIT, no licence key) hosts the
terminal page successfully: WebView2 launches, navigates to the loopback page, runs its JavaScript and
completes the WebSocket handshake. Verified by observing an established TCP connection from
`msedgewebview2` to the data plane port.
**The WebView runs on Windows.** `Avalonia.Controls.WebView` 12.0.1 (MIT, no licence key) hosts the
terminal page: WebView2 launches, navigates to the loopback page, runs its JavaScript and completes the
WebSocket handshake. Verified by observing an established TCP connection from `msedgewebview2` to the data
plane port.
Note precisely what that evidence covers, because it was once stretched to cover more: every clause above
is about the process and the socket. It says nothing about how the control **composites** with
Avalonia-drawn content, which is the axis on which it does not behave like an ordinary control — see the
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
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.
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
for a while:
- `NativeControlHost` creates the native attachment from **attach to the visual tree**, not from layout and
not from visibility. Its `UpdateHost` never reads `IsEffectivelyVisible`; only
`TryUpdateNativeControlPosition` does, choosing `HideWithSize` over `ShowInBounds`.
- `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.
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
showing but, because of the airspace behaviour above, the WebView was in fact uncovered and in plain view.
It confirmed only that a *visible* WebView is realised, which nobody disputed, and could not discriminate
the case it was attached to. A process-level check cannot verify a rendering claim; that needs a
screenshot, and this defect shipped because one was never taken.
**What the first connection after unlocking actually depends on** is the `await
workspace.WaitForRendererAsync()` in `VaultViewModel.ConnectAsync`, because `TerminalDataPlane.SendAsync`
drops frames when no renderer is attached rather than queueing them. That await is the invariant; the
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.
**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
@@ -48,8 +88,17 @@ package's own release notes say `NativeWebView` gained Linux support via a **WPE
(`libwpewebkit-2.0`), which is much less widely installed than WebKitGTK — and it ships a separate
`NativeWebDialog` described as *"particularly useful for platforms like Linux where embedded WebView
controls might not be available"*, which is the vendor confirming the concern. *Unverified:* a spike
must cover Ubuntu on both Wayland and X11, Fedora KDE, and macOS 15. This is why the terminal sits
behind `ITerminalHost`; that seam should not be collapsed away for convenience.
must cover Ubuntu on both Wayland and X11, Fedora KDE, and macOS 15.
`ITerminalHost` was supposed to be the seam that keeps a backend swap cheap, and it is **declared but not
implemented** — nothing in the application uses it, and the view navigates `NativeWebView.Source` directly.
Swapping backends today means editing `MainWindow.axaml` and its code-behind. That is a small job, but do
not plan around a seam that is currently only a file.
One more reason the Linux picture may be better than this entry assumes: the package also ships
`NativeWebViewCompositorHost`, a non-windowed host drawn through Avalonia's compositor. A compositor host
would not have the airspace problem described below at all. Whether it can be selected deliberately is
unknown and worth establishing during the spike, because it would change how overlays can be built.
**`Avalonia.Diagnostics` has no 12.x release** (latest is 11.3.18), so the developer tools overlay is
unavailable on Avalonia 12. Development-only, so nothing ships differently — but debugging a layout
@@ -180,12 +229,6 @@ licence obligation — and `bundle_e_sqlcipher` was deprecated in SQLitePCLRaw 3
be honest about: the cache offers no protection against another process running as the same user. See
`LocalCacheProtector` for what it does and does not defend against.
**A `NativeWebView` that is never laid out is never realised.** The shell covers the terminal with its
setup and unlock screens rather than collapsing it with `IsVisible`, because the control hosts a real
child window and hiding it would leave the terminal blank on the first connection after unlocking.
Verified on Windows: with the unlock overlay showing, `msedgewebview2` still had an established
connection to the data plane port, so the page had loaded and completed its WebSocket handshake.
## Build and CI
**Integration tests need a Docker daemon** (Testcontainers). They run on `ubuntu-latest` in CI.