Public Access
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:
@@ -1,3 +1,4 @@
|
||||
using System.Security.Authentication;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Auth;
|
||||
@@ -105,8 +106,18 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
[ObservableProperty]
|
||||
private bool isBusy;
|
||||
|
||||
/// <remarks>
|
||||
/// The address <c>dotnet run --project src/DodoSSH.Api</c> actually serves, so the first launch after
|
||||
/// a clone works without the user having to know a port. This was <c>https://localhost:7217</c>, which
|
||||
/// is the API's <em>second</em> launch profile: the first is HTTP on 5233 and is the one both the
|
||||
/// README and a plain <c>dotnet run</c> select, so nothing was listening on 7217. Pointing an HTTPS
|
||||
/// client at a plaintext port fails as "The SSL connection could not be established", which sends
|
||||
/// people looking for a certificate problem — see <see cref="ExplainSignInFailure" />. A real
|
||||
/// deployment is HTTPS behind a proxy and its address is typed over this one; the placeholder in the
|
||||
/// setup card shows that shape.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string serverUrl = "https://localhost:7217";
|
||||
private string serverUrl = "http://localhost:5233";
|
||||
|
||||
[ObservableProperty]
|
||||
private string passphrase = string.Empty;
|
||||
@@ -192,9 +203,19 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
return;
|
||||
}
|
||||
|
||||
// Checked separately from parsing, because "localhost:5233" parses perfectly well as an absolute
|
||||
// URI whose scheme is "localhost" — and then fails much later with something unrelated to the
|
||||
// actual mistake.
|
||||
if (url.Scheme is not ("http" or "https"))
|
||||
{
|
||||
StatusMessage = $"A server URL has to start with http:// or https://, not {url.Scheme}:.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(
|
||||
"Opening your browser to sign in…",
|
||||
async () =>
|
||||
explain: exception => ExplainSignInFailure(exception, url),
|
||||
work: async () =>
|
||||
{
|
||||
connection?.Dispose();
|
||||
connection = null;
|
||||
@@ -383,7 +404,17 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// Every command funnels through here so the busy flag and the failure message are handled once. A
|
||||
/// command that forgot either would leave the window permanently disabled or silently doing nothing.
|
||||
/// </remarks>
|
||||
private async Task RunAsync(string busyMessage, Func<Task> work)
|
||||
/// <param name="busyMessage">Shown while the work runs.</param>
|
||||
/// <param name="work">The work.</param>
|
||||
/// <param name="explain">
|
||||
/// Turns a failure into something a user can act on. Optional, because most failures here already
|
||||
/// carry their own explanation; the ones that do not are the ones crossing into another process's
|
||||
/// vocabulary, where the exception describes a symptom and not the mistake.
|
||||
/// </param>
|
||||
private async Task RunAsync(
|
||||
string busyMessage,
|
||||
Func<Task> work,
|
||||
Func<Exception, string>? explain = null)
|
||||
{
|
||||
if (IsBusy)
|
||||
{
|
||||
@@ -403,7 +434,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
StatusMessage = exception.Message;
|
||||
StatusMessage = explain?.Invoke(exception) ?? exception.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -411,6 +442,28 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// One case earns a translation rather than the exception's own words: pointing an HTTPS client at a
|
||||
/// plaintext port reports "The SSL connection could not be established", which sends people looking
|
||||
/// for a certificate problem. The scheme is the mistake, and the development stack serves HTTP, so
|
||||
/// this is the first thing a new user will hit.
|
||||
/// </remarks>
|
||||
private static string ExplainSignInFailure(Exception exception, Uri server)
|
||||
{
|
||||
var secureChannelFailed = exception is HttpRequestException
|
||||
&& exception.GetBaseException() is AuthenticationException;
|
||||
|
||||
if (secureChannelFailed && server.Scheme is "https")
|
||||
{
|
||||
var plain = new UriBuilder(server) { Scheme = "http" }.Uri;
|
||||
|
||||
return $"{exception.Message} {server.Host} answered, but not with TLS. If this is a "
|
||||
+ $"development server it probably serves plain HTTP — try {plain.GetLeftPart(UriPartial.Authority)}.";
|
||||
}
|
||||
|
||||
return exception.Message;
|
||||
}
|
||||
|
||||
partial void OnStateChanged(ShellState value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsStarting));
|
||||
|
||||
@@ -31,9 +31,21 @@
|
||||
</Window.Styles>
|
||||
|
||||
<!--
|
||||
The terminal's WebView stays in the visual tree at all times and is covered by the setup and unlock
|
||||
screens rather than being collapsed. A NativeWebView hosts a real child window, and hiding it means
|
||||
never realising it — which would leave the terminal blank on the first connection after unlocking.
|
||||
The terminal's WebView is collapsed whenever the vault is not unlocked, and that is not a style
|
||||
choice. NativeWebView hosts a real Win32 child window through NativeControlHost, and a child window
|
||||
composites above everything the parent paints — so no sibling in this visual tree can cover it,
|
||||
whatever the z-order says. Layering the setup screens over it left them sliced at the WebView's left
|
||||
edge, with their buttons unreachable at the window's default width.
|
||||
|
||||
Collapsing is safe, which the earlier version of this comment denied: NativeControlHost creates the
|
||||
native control when the control is attached to the visual tree, not when it is laid out or shown, and
|
||||
an assigned Source is replayed once the adapter exists. IsVisible=false therefore still starts
|
||||
WebView2, still loads the page and still lets the renderer attach its socket; it only swaps
|
||||
ShowInBounds for HideWithSize. Flipping it back re-pushes the bounds.
|
||||
|
||||
What the first connection after unlocking actually depends on is the await in
|
||||
VaultViewModel.ConnectAsync — the data plane drops frames when no renderer is attached, so the gate
|
||||
is that await, never this control's visibility.
|
||||
-->
|
||||
<Panel>
|
||||
|
||||
@@ -205,14 +217,20 @@
|
||||
<!--
|
||||
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate browser process
|
||||
tree, so twenty tabs would cost twenty of them.
|
||||
|
||||
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.
|
||||
-->
|
||||
<NativeWebView Grid.Row="2" x:Name="Terminal" />
|
||||
<NativeWebView Grid.Row="2" x:Name="Terminal" IsVisible="{Binding IsUnlocked}" />
|
||||
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- Setup and unlock, over the top. -->
|
||||
<!--
|
||||
Setup and unlock. Last in the Panel, so it is above the app content in Avalonia's z-order — which
|
||||
covers Avalonia-drawn content and nothing else. The terminal is collapsed rather than covered.
|
||||
-->
|
||||
<Border Background="#10131a" IsVisible="{Binding !IsUnlocked}">
|
||||
|
||||
<Panel>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
<!--
|
||||
The terminal data plane. Avalonia-free and WebView-free on purpose: the throughput and
|
||||
backpressure behaviour is the part most likely to be wrong, and it has to be testable
|
||||
without a UI toolkit or a browser engine. ITerminalHost is the seam the app plugs into.
|
||||
without a UI toolkit or a browser engine. What the app plugs into is TerminalWorkspace;
|
||||
ITerminalHost is a declared shape with no implementation yet, and says so.
|
||||
-->
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -5,16 +5,24 @@ namespace DodoSSH.Client.Terminal;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Deliberately tiny. Everything the renderer needs — its files, its connection token, its socket
|
||||
/// URL — arrives over the loopback HTTP server, so the only thing the host has to do is navigate.
|
||||
/// That is what keeps three WebView backends interchangeable: the official
|
||||
/// <c>Avalonia.Controls.WebView</c>, the community <c>NativeWebView</c> whose Linux backend is the
|
||||
/// more widely installed WebKitGTK, and CEF as the heavyweight escape hatch.
|
||||
/// <b>Declared, not yet wired.</b> Nothing in the application implements this today: the view assigns
|
||||
/// <c>NativeWebView.Source</c> directly in <c>MainWindow.axaml.cs</c>, and the headless shell tests
|
||||
/// substitute <see cref="ITerminalAssetProvider" /> instead — they never need a browser, because the view
|
||||
/// models do not own one. So swapping WebView backends currently means editing the XAML and its code-behind.
|
||||
/// This interface records the shape that swap should take; it is not a seam that exists yet, and it should
|
||||
/// not be cited as one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It is also what makes the terminal testable headlessly. Avalonia's headless platform has no
|
||||
/// WebView at all, so a stub implementing this interface stands in — and because the interface is one
|
||||
/// method, the stub cannot drift from the real thing.
|
||||
/// Deliberately tiny, and that part is worth keeping. Everything the renderer needs — its files, its
|
||||
/// connection token, its socket URL — arrives over the loopback HTTP server, so the only thing a host has to
|
||||
/// do is navigate. A backend swap is therefore one method wide however it is eventually wired.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The candidates are two, not three: <c>Avalonia.Controls.WebView</c> is the package and
|
||||
/// <c>NativeWebView</c> is the control it ships, so they are one option — whose Linux backend is WPE WebKit,
|
||||
/// with WebKitGTK the more widely installed library it is not using — and CEF is the heavyweight escape
|
||||
/// hatch. An earlier version of this remark counted the package and the control separately and had the
|
||||
/// Linux backend the wrong way round, which made the interchangeability argument rest on a miscount.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface ITerminalHost
|
||||
|
||||
Reference in New Issue
Block a user