Public Access
Merge branch 'claude/sleepy-chebyshev-cda68d'
This commit is contained in:
+41
-6
@@ -53,7 +53,8 @@ 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.
|
||||
no visible cause. That last symptom is the focus asymmetry documented further down, not a separate fault:
|
||||
focus crosses into the WebView readily and does not come back on its own.
|
||||
|
||||
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
|
||||
@@ -108,11 +109,45 @@ Related and not yet addressed: the conflict log above the terminal is an `ItemsC
|
||||
`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.
|
||||
**Keyboard focus crosses into the WebView by itself and does not come back.** This is the asymmetry to
|
||||
know; the connect-focus bug that led here was only its first symptom. Measured on Windows with a harness
|
||||
that reports `GetFocus()`, the class name of the window holding it, and the page's own
|
||||
`document.hasFocus()` at each step.
|
||||
|
||||
- **Into the page: nothing custom is needed.** `NativeWebView` overrides `Focusable` to true and its
|
||||
`OnGotFocus` calls the adapter's `Focus()`, which on Windows is
|
||||
`ICoreWebView2Controller::MoveFocus(PROGRAMMATIC)`. A plain Avalonia `Terminal.Focus()` therefore moves
|
||||
real Win32 focus to the `Chrome_WidgetWin_1` child and the page reports `hasFocus: true`. No `SetFocus`
|
||||
P/Invoke and no COM work — the package version of this entry that assumed otherwise was wrong. The
|
||||
control also replays a `Focus()` that arrived before its adapter existed, and re-asserts itself: while
|
||||
it holds Win32 focus its `GotFocus` handler pulls Avalonia's *logical* focus back onto the control. Worth
|
||||
stating positively, because the reasonable guess before measuring — that crossing into a child HWND must
|
||||
need `SetFocus` — is the wrong way round: it is the return trip that needs it.
|
||||
- **Out of the page: the package does nothing at all.** `OnLostFocus` calls the adapter's `ResignFocus()`,
|
||||
and on Windows that method is **empty**. So `someTextBox.Focus()` moves Avalonia's focused element while
|
||||
Win32 focus stays on WebView2: a text box with a caret that silently receives nothing. `Window.Activate()`
|
||||
and `Window.Focus()` were both measured and neither recovers it. The hand-back has to be
|
||||
`SetFocus(topLevelHwnd)` — see `Views/NativeKeyboardFocus.cs`. A real mouse click *does* recover it,
|
||||
because Avalonia's window sets focus on pointer input, which is exactly why this is invisible to anyone
|
||||
who clicks before typing.
|
||||
- **Collapsing the control does not release the keyboard.** With `IsVisible=false` the holder window is
|
||||
hidden but Win32 focus stays on it — measured as focus held by a window reporting `visible=False`, with
|
||||
Avalonia's focused element becoming `(none)`. So locking the vault after touching the terminal left the
|
||||
unlock passphrase box eating keystrokes. The lock path now hands the keyboard back and focuses that box.
|
||||
- **`Focus()` on a collapsed control is a no-op and is not replayed on reveal.** Order matters: reveal,
|
||||
then focus. Focus does survive a lock/unlock cycle when done that way.
|
||||
- **There is no Tab-out.** The package subscribes `ICoreWebView2Controller::add_MoveFocusRequested` and its
|
||||
handler body is empty, so WebView2's request to move focus off itself is discarded; xterm eats Tab
|
||||
anyway. The way out is `Ctrl+Shift+F6`, intercepted in `terminal.js` and sent to the host as a web
|
||||
message — measured arriving verbatim in `WebMessageReceivedEventArgs.Body`. It has to be handled in the
|
||||
page, because once the child window owns Win32 focus Avalonia sees no key events and no `KeyBinding`
|
||||
could fire. Not Escape, and not a bare F6: both are keys a TUI legitimately binds, and Ctrl+Shift is the
|
||||
range terminal emulators conventionally keep for themselves.
|
||||
|
||||
None of this is covered by a test, and cannot be here: headless Avalonia has no native window, so a
|
||||
headless test renders and focuses correctly and would confirm the wrong belief. What the suite covers is
|
||||
the plumbing that drives it — that connecting asks for focus once per session, that a failed connect does
|
||||
not, and that locking stops the forwarding.
|
||||
|
||||
**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
|
||||
|
||||
@@ -142,6 +142,17 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// <summary>Where the embedded browser should navigate.</summary>
|
||||
internal Uri TerminalPageUrl => workspace.PageUrl;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when a terminal session opens, so the view can hand the terminal the keyboard.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Forwarded from <see cref="VaultViewModel.SessionOpened"/> rather than exposed there directly,
|
||||
/// because <see cref="Vault"/> is replaced on every unlock and the view would have to re-subscribe
|
||||
/// each time. This shell is the window's data context for the life of the process, so one
|
||||
/// subscription is enough.
|
||||
/// </remarks>
|
||||
internal event EventHandler? TerminalSessionOpened;
|
||||
|
||||
internal bool IsStarting => State == ShellState.Starting;
|
||||
|
||||
internal bool IsNeedingServer => State == ShellState.NeedsServer;
|
||||
@@ -469,6 +480,26 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
return exception.Message;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// One place for the subscription, so unlocking, locking and disposing all route through it rather
|
||||
/// than each remembering to detach.
|
||||
/// </remarks>
|
||||
partial void OnVaultChanged(VaultViewModel? oldValue, VaultViewModel? newValue)
|
||||
{
|
||||
if (oldValue is not null)
|
||||
{
|
||||
oldValue.SessionOpened -= OnVaultSessionOpened;
|
||||
}
|
||||
|
||||
if (newValue is not null)
|
||||
{
|
||||
newValue.SessionOpened += OnVaultSessionOpened;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnVaultSessionOpened(object? sender, EventArgs e) =>
|
||||
TerminalSessionOpened?.Invoke(this, e);
|
||||
|
||||
partial void OnStateChanged(ShellState value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsStarting));
|
||||
|
||||
@@ -179,6 +179,18 @@ internal sealed partial class VaultViewModel(
|
||||
[ObservableProperty]
|
||||
private string? hostKeyMismatch;
|
||||
|
||||
/// <summary>
|
||||
/// Raised once a terminal session is open and its renderer has it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An event rather than a property because handing the terminal the keyboard is something that
|
||||
/// happens, not something that is true: connecting a second host while one is already open has to
|
||||
/// move focus again, and no state change describes that. Raised on the UI thread — every await on
|
||||
/// the path from the command to here uses <c>ConfigureAwait(true)</c> — so a handler may touch
|
||||
/// controls directly.
|
||||
/// </remarks>
|
||||
internal event EventHandler? SessionOpened;
|
||||
|
||||
internal bool HasPendingHostKey => PendingHostKey is not null;
|
||||
|
||||
internal bool HasHostKeyMismatch => HostKeyMismatch is not null;
|
||||
@@ -611,6 +623,12 @@ internal sealed partial class VaultViewModel(
|
||||
.ConfigureAwait(true);
|
||||
|
||||
Status = $"Connected to {row.Label}.";
|
||||
|
||||
// Only now, and only on success. The page's own term.focus() focuses the textarea inside
|
||||
// the document, which does nothing while the window's keyboard focus is still on the
|
||||
// Connect button — so without this the first keystrokes of the session go to the shell's
|
||||
// UI instead of the remote shell.
|
||||
SessionOpened?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
catch (SshHostKeyUnknownException exception)
|
||||
{
|
||||
|
||||
@@ -77,7 +77,8 @@
|
||||
<Grid Grid.Row="1" Grid.Column="0" RowDefinitions="*,Auto,Auto"
|
||||
Background="#131722" IsVisible="{Binding IsUnlocked}">
|
||||
|
||||
<ListBox Grid.Row="0" Margin="6"
|
||||
<!-- Named because it is where keyboard focus lands when the user leaves the terminal. -->
|
||||
<ListBox Grid.Row="0" x:Name="HostList" Margin="6"
|
||||
ItemsSource="{Binding Vault.Hosts}"
|
||||
SelectedItem="{Binding Vault.SelectedHost}"
|
||||
Background="Transparent">
|
||||
@@ -306,7 +307,13 @@
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="heading" Text="Unlock your vault" />
|
||||
<TextBlock Text="{Binding AccountName}" Foreground="#bcd2ea" />
|
||||
<TextBox Text="{Binding Passphrase}" PlaceholderText="vault passphrase" PasswordChar="•" />
|
||||
<!--
|
||||
Named because locking has to put the keyboard here explicitly. The terminal's native
|
||||
child window keeps Win32 focus when it is collapsed, so without that this box would
|
||||
show a caret and silently swallow the passphrase — see NativeKeyboardFocus.
|
||||
-->
|
||||
<TextBox x:Name="UnlockPassphrase" Text="{Binding Passphrase}"
|
||||
PlaceholderText="vault passphrase" PasswordChar="•" />
|
||||
<Button Content="Unlock" Command="{Binding UnlockCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
||||
|
||||
@@ -1,23 +1,117 @@
|
||||
using System.ComponentModel;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using DodoSSH.Client.App.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The shell window.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Keyboard focus across the Avalonia/WebView boundary is handled here rather than in a view model,
|
||||
/// because it is a property of the controls and not of the state. What the view models expose is the
|
||||
/// two facts the policy needs — a session opened, and the vault is no longer unlocked — and both are
|
||||
/// things they already know. See <see cref="NativeKeyboardFocus"/> for why one direction is a plain
|
||||
/// <c>Focus()</c> call and the other is not.
|
||||
/// </remarks>
|
||||
internal sealed partial class MainWindow : Window
|
||||
{
|
||||
/// <summary>
|
||||
/// The page's request to give the keyboard back to the application.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// It has to come from the page. Once the native child window holds Win32 focus, Avalonia sees no
|
||||
/// key events at all, so a <c>KeyBinding</c> on this window could never fire — the terminal is the
|
||||
/// only thing that can hear the shortcut and ask to be let go of.
|
||||
/// </remarks>
|
||||
private const string ReleaseFocusMessage = "dodossh.release-focus";
|
||||
|
||||
private MainWindowViewModel? shell;
|
||||
|
||||
private bool wasUnlocked;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
// Navigation happens once the data context is known, because the URL carries the port the
|
||||
// loopback listener was assigned. Setting Source in XAML would need a constant port, and a
|
||||
// fixed port is one that another process can already be holding.
|
||||
DataContextChanged += (_, _) =>
|
||||
DataContextChanged += (_, _) => Attach(DataContext as MainWindowViewModel);
|
||||
|
||||
Terminal.WebMessageReceived += (_, e) =>
|
||||
{
|
||||
if (DataContext is MainWindowViewModel viewModel)
|
||||
// Compared against a constant rather than parsed: the page sends exactly one message and
|
||||
// treating anything else as a command would be a wider door than this needs. A string
|
||||
// posted by the page arrives in Body verbatim.
|
||||
if (string.Equals(e.Body, ReleaseFocusMessage, StringComparison.Ordinal))
|
||||
{
|
||||
Terminal.Source = viewModel.TerminalPageUrl;
|
||||
ReleaseKeyboardTo(HostList);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void Attach(MainWindowViewModel? viewModel)
|
||||
{
|
||||
if (shell is { } previous)
|
||||
{
|
||||
previous.TerminalSessionOpened -= OnTerminalSessionOpened;
|
||||
previous.PropertyChanged -= OnShellPropertyChanged;
|
||||
}
|
||||
|
||||
shell = viewModel;
|
||||
|
||||
if (viewModel is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigation happens once the data context is known, because the URL carries the port the
|
||||
// loopback listener was assigned. Setting Source in XAML would need a constant port, and a
|
||||
// fixed port is one that another process can already be holding.
|
||||
Terminal.Source = viewModel.TerminalPageUrl;
|
||||
|
||||
wasUnlocked = viewModel.IsUnlocked;
|
||||
|
||||
viewModel.TerminalSessionOpened += OnTerminalSessionOpened;
|
||||
viewModel.PropertyChanged += OnShellPropertyChanged;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A bare <c>Focus()</c> is the whole fix in this direction: <c>NativeWebView.OnGotFocus</c> pushes
|
||||
/// Win32 focus into WebView2 for us. It has to happen while the control is visible, which it is —
|
||||
/// a session can only be opened from an unlocked vault, and the vault being unlocked is what
|
||||
/// reveals the control. Focus() on a collapsed control is measurably a no-op and is not replayed
|
||||
/// when it is revealed.
|
||||
/// </remarks>
|
||||
private void OnTerminalSessionOpened(object? sender, EventArgs e) => Terminal.Focus();
|
||||
|
||||
private void OnShellPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (!string.Equals(e.PropertyName, nameof(MainWindowViewModel.IsUnlocked), StringComparison.Ordinal)
|
||||
|| shell is not { } viewModel)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var unlocked = viewModel.IsUnlocked;
|
||||
|
||||
// Only the transition out of unlocked matters. IsUnlocked is re-raised for every shell state
|
||||
// change, and reacting to all of them would move focus during setup and sign-in.
|
||||
if (wasUnlocked && !unlocked)
|
||||
{
|
||||
ReleaseKeyboardTo(UnlockPassphrase);
|
||||
}
|
||||
|
||||
wasUnlocked = unlocked;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Both halves are needed. The Win32 call moves the keyboard off the native child window, and the
|
||||
/// <c>Focus()</c> gives it somewhere to go — collapsing the terminal leaves Avalonia with no
|
||||
/// focused element, so the keystrokes would otherwise reach the window and stop there.
|
||||
/// </remarks>
|
||||
private void ReleaseKeyboardTo(IInputElement target)
|
||||
{
|
||||
NativeKeyboardFocus.ReturnTo(this);
|
||||
target.Focus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Moves the window's keyboard focus back out of the terminal's native child window.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The two directions across this boundary are not symmetric, and only one of them needs anything
|
||||
/// like this. <see cref="NativeWebView"/> is <c>Focusable</c> by default and its <c>OnGotFocus</c>
|
||||
/// calls the adapter's <c>Focus()</c>, which on Windows is
|
||||
/// <c>ICoreWebView2Controller::MoveFocus(PROGRAMMATIC)</c> — so an ordinary Avalonia
|
||||
/// <c>Focus()</c> call on the control really does hand Win32 focus to WebView2. Measured: focus
|
||||
/// lands on the <c>Chrome_WidgetWin_1</c> child and the page reports
|
||||
/// <c>document.hasFocus() == true</c>. Nothing custom is needed to give the terminal the keyboard.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Coming back is where the package stops helping. <c>OnLostFocus</c> calls the adapter's
|
||||
/// <c>ResignFocus()</c>, and on Windows that method is <b>empty</b> — so moving Avalonia's focus to
|
||||
/// another control leaves Win32 focus on WebView2. Measured: after <c>textBox.Focus()</c> the
|
||||
/// focused element is the <c>TextBox</c> while <c>GetFocus()</c> is still the WebView2 child and the
|
||||
/// page still reports focus, which is a text box that shows a caret and silently receives nothing.
|
||||
/// <c>Window.Activate()</c> and <c>Window.Focus()</c> were both measured and neither recovers it.
|
||||
/// A real mouse click does, because Avalonia's window sets focus on pointer input — which is why the
|
||||
/// symptom is invisible to anyone who clicks before typing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So the hand-back has to be the Win32 call. This is safe to run at any time, including while the
|
||||
/// application is in the background: both windows live on this thread's message queue, and
|
||||
/// <c>SetFocus</c> confined to one queue changes which window receives keys without activating
|
||||
/// anything or taking focus from another application.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class NativeKeyboardFocus
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns keyboard focus to <paramref name="window"/>'s own window, so that Avalonia's focused
|
||||
/// element receives keystrokes again.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Focus an element afterwards. Collapsing the terminal leaves Avalonia with no focused element
|
||||
/// at all, so handing the keyboard back without also choosing a target means keystrokes reach
|
||||
/// the window and stop there.
|
||||
/// </remarks>
|
||||
internal static void ReturnTo(Window window)
|
||||
{
|
||||
// Only Windows hosts the WebView in a child window today. Elsewhere this is either
|
||||
// unnecessary or wrong, and doing nothing is the honest option until a spike says otherwise
|
||||
// — see docs/platform-flags.md on the unproven Linux backend.
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.TryGetPlatformHandle()?.Handle is { } handle && handle != IntPtr.Zero)
|
||||
{
|
||||
SetFocus(handle);
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <c>DllImport</c> rather than the source-generated <c>LibraryImport</c>, which requires
|
||||
/// <c>AllowUnsafeBlocks</c> for the whole project. The signature is blittable, so there is no
|
||||
/// marshalling stub for the generator to improve on, and turning unsafe code on across a client
|
||||
/// that handles key material to save nothing would be a poor trade. Hence the SYSLIB1054
|
||||
/// suppression rather than a fix.
|
||||
/// </remarks>
|
||||
#pragma warning disable SYSLIB1054
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr SetFocus(IntPtr window);
|
||||
#pragma warning restore SYSLIB1054
|
||||
}
|
||||
@@ -28,6 +28,19 @@ const CLIENT_RESIZE = 3;
|
||||
const HEADER_LENGTH = 5;
|
||||
const SCROLLBACK_LINES = 5000;
|
||||
|
||||
/*
|
||||
The way out of the terminal, for someone using only a keyboard.
|
||||
|
||||
It has to be handled here rather than by the host: once this page's window owns Win32 focus, the
|
||||
host's Avalonia window receives no key events at all, so nothing on that side could hear a shortcut.
|
||||
|
||||
Ctrl+Shift+F6 rather than Escape. F6 is the Windows convention for moving to the next pane, but a
|
||||
bare F6 is a real terminal key that TUIs bind — as is Escape, which vim alone rules out. Ctrl+Shift
|
||||
is the range terminal emulators conventionally keep for themselves and never forward to the remote,
|
||||
so qualifying F6 with it keeps the convention without taking a key away from the remote shell.
|
||||
*/
|
||||
const RELEASE_FOCUS_MESSAGE = 'dodossh.release-focus';
|
||||
|
||||
const root = document.getElementById('root');
|
||||
const statusBanner = document.getElementById('status');
|
||||
|
||||
@@ -80,6 +93,31 @@ function sendResize(sessionId, term, pane) {
|
||||
send(CLIENT_RESIZE, sessionId, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the host to take keyboard focus back.
|
||||
*
|
||||
* Optional by design: the bridge only exists under a real embedded WebView, and this page is also
|
||||
* openable in a plain browser for debugging, where there is no host to ask.
|
||||
*/
|
||||
function releaseFocusToHost() {
|
||||
window.chrome?.webview?.postMessage(RELEASE_FOCUS_MESSAGE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Swallows the release-focus shortcut so it never reaches the remote.
|
||||
*
|
||||
* Returning false stops xterm processing the event, which is what keeps the chord from being encoded
|
||||
* and written to the pty.
|
||||
*/
|
||||
function handleKey(event) {
|
||||
if (event.type === 'keydown' && event.ctrlKey && event.shiftKey && event.key === 'F6') {
|
||||
releaseFocusToHost();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function createSession(sessionId) {
|
||||
const pane = document.createElement('div');
|
||||
pane.className = 'pane';
|
||||
@@ -109,6 +147,8 @@ function createSession(sessionId) {
|
||||
console.warn('WebGL renderer unavailable; falling back to canvas.', error);
|
||||
}
|
||||
|
||||
term.attachCustomKeyEventHandler(handleKey);
|
||||
|
||||
term.onData((data) => {
|
||||
send(CLIENT_INPUT, sessionId, new TextEncoder().encode(data));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
using System.Globalization;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text.RegularExpressions;
|
||||
using DodoSSH.Client.Terminal;
|
||||
|
||||
namespace DodoSSH.Client.App.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Stands in for the terminal page, so the connect path can be exercised without a WebView.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// It attaches the way the real renderer does rather than reaching for the workspace's internals:
|
||||
/// fetch the served page, read the token and socket URL the host substituted into it, then open the
|
||||
/// WebSocket with the same two subprotocols. Anything cheaper — handing it the token directly — would
|
||||
/// stop testing the part of the handshake that has actually been got wrong before.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Attaching is what completes <c>TerminalWorkspace.WaitForRendererAsync</c>, and that await is the
|
||||
/// real gate on a first connection: the data plane drops frames when nothing is attached rather than
|
||||
/// queueing them, so a session opened before this exists would lose its <c>SessionOpened</c> frame.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class FakeRenderer : IAsyncDisposable
|
||||
{
|
||||
private readonly ClientWebSocket socket;
|
||||
|
||||
private FakeRenderer(ClientWebSocket socket) => this.socket = socket;
|
||||
|
||||
/// <summary>Fetches the page and attaches a socket, as the real renderer would.</summary>
|
||||
internal static async Task<FakeRenderer> AttachAsync(
|
||||
TerminalWorkspace workspace,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var http = new HttpClient();
|
||||
|
||||
var page = await http
|
||||
.GetStringAsync(workspace.PageUrl, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var token = Attribute(page, "data-token");
|
||||
var socketUrl = Attribute(page, "data-socket");
|
||||
|
||||
var attached = new ClientWebSocket();
|
||||
|
||||
attached.Options.AddSubProtocol(TerminalDataPlane.SubProtocol);
|
||||
attached.Options.AddSubProtocol($"token.{token}");
|
||||
|
||||
// The listener requires the page's own origin, which is what makes a page in the user's
|
||||
// browser unable to reach this socket.
|
||||
attached.Options.SetRequestHeader(
|
||||
"Origin",
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{workspace.PageUrl.Scheme}://{workspace.PageUrl.Authority}"));
|
||||
|
||||
try
|
||||
{
|
||||
await attached.ConnectAsync(new Uri(socketUrl), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
attached.Dispose();
|
||||
throw;
|
||||
}
|
||||
|
||||
var renderer = new FakeRenderer(attached);
|
||||
|
||||
await workspace.WaitForRendererAsync().ConfigureAwait(false);
|
||||
|
||||
return renderer;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (socket.State == WebSocketState.Open)
|
||||
{
|
||||
try
|
||||
{
|
||||
await socket
|
||||
.CloseAsync(WebSocketCloseStatus.NormalClosure, null, CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (WebSocketException)
|
||||
{
|
||||
// The host may have gone first; there is nothing to salvage either way.
|
||||
}
|
||||
}
|
||||
|
||||
socket.Dispose();
|
||||
}
|
||||
|
||||
private static string Attribute(string page, string name)
|
||||
{
|
||||
var match = AttributeValue(name).Match(page);
|
||||
|
||||
return match.Success
|
||||
? match.Groups[1].Value
|
||||
: throw new InvalidOperationException(
|
||||
$"The served page carried no {name}. The host substitutes it at serve time, so "
|
||||
+ $"either the placeholder is missing from the test's page asset or substitution broke.");
|
||||
}
|
||||
|
||||
private static Regex AttributeValue(string name) =>
|
||||
new($"{Regex.Escape(name)}=\"([^\"]*)\"", RegexOptions.None, TimeSpan.FromSeconds(1));
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using DodoSSH.Client.Ssh;
|
||||
|
||||
namespace DodoSSH.Client.App.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// An SSH stack that connects to nothing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The shell suite is about what the view models do, and the real factory would need a reachable
|
||||
/// sshd — which <c>DodoSSH.Client.Ssh.Tests</c> already covers against a container. What this makes
|
||||
/// testable is everything the connect path does <em>around</em> the connection.
|
||||
/// </remarks>
|
||||
internal sealed class FakeSshConnectionFactory : ISshConnectionFactory
|
||||
{
|
||||
/// <summary>Thrown instead of connecting, when set. Used for the host-key paths.</summary>
|
||||
internal Exception? Failure { get; set; }
|
||||
|
||||
/// <summary>Requests this factory was asked for, in order.</summary>
|
||||
internal List<SshConnectionRequest> Requests { get; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ISshConnection> ConnectAsync(
|
||||
SshConnectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Requests.Add(request);
|
||||
|
||||
return Failure is { } failure
|
||||
? Task.FromException<ISshConnection>(failure)
|
||||
: Task.FromResult<ISshConnection>(new FakeSshConnection(request));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeSshConnection(SshConnectionRequest request) : ISshConnection
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public bool IsConnected { get; private set; } = true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public HostKeyPresentation HostKey { get; } =
|
||||
new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake");
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ISshShellSession> OpenShellAsync(
|
||||
TerminalSize size,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult<ISshShellSession>(new FakeSshShellSession());
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
IsConnected = false;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A shell that is open, silent and never closes on its own.</summary>
|
||||
/// <remarks>
|
||||
/// <see cref="ReadAsync"/> blocks rather than returning 0. Returning 0 means the remote closed the
|
||||
/// channel, which would end the session the moment it was opened and make the test assert against a
|
||||
/// connection that had already gone.
|
||||
/// </remarks>
|
||||
internal sealed class FakeSshShellSession : ISshShellSession
|
||||
{
|
||||
private readonly CancellationTokenSource closed = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsOpen { get; private set; } = true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken)
|
||||
{
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
cancellationToken, closed.Token);
|
||||
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, linked.Token).ConfigureAwait(false);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken) =>
|
||||
ValueTask.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Resize(TerminalSize size)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
IsOpen = false;
|
||||
|
||||
await closed.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
closed.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,13 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
|
||||
private readonly FakeVaultServer server = new();
|
||||
|
||||
/// <remarks>
|
||||
/// The real factory would need a reachable sshd, which <c>DodoSSH.Client.Ssh.Tests</c> covers against
|
||||
/// a container. Nothing in this suite connected before, so substituting it costs no coverage and makes
|
||||
/// the connect path reachable.
|
||||
/// </remarks>
|
||||
private readonly FakeSshConnectionFactory ssh = new();
|
||||
|
||||
private int signInAttempts;
|
||||
|
||||
private string directory = null!;
|
||||
@@ -51,15 +58,26 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
// In-memory assets rather than the application's Avalonia-resource provider, which reads the
|
||||
// resource system at construction and needs an initialised toolkit. This is what
|
||||
// ITerminalAssetProvider is for; nothing in this suite renders anything.
|
||||
//
|
||||
// The page carries the same two placeholders the real one does, because FakeRenderer attaches by
|
||||
// reading them back out of the served page rather than by being handed the token.
|
||||
workspace = new TerminalWorkspace(
|
||||
new InMemoryTerminalAssetProvider(
|
||||
new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)
|
||||
{
|
||||
["/terminal"] = new("text/html; charset=utf-8", "<!doctype html>"u8.ToArray()),
|
||||
["/terminal"] = new(
|
||||
"text/html; charset=utf-8",
|
||||
System.Text.Encoding.UTF8.GetBytes(
|
||||
$"<!doctype html><div data-token=\"{TerminalDataPlane.TokenPlaceholder}\" "
|
||||
+ $"data-socket=\"{TerminalDataPlane.SocketUrlPlaceholder}\"></div>")),
|
||||
}),
|
||||
new SshNetConnectionFactory(knownHosts),
|
||||
ssh,
|
||||
TimeProvider.System);
|
||||
|
||||
// Started, as the application does immediately after composing it. Without the accept loop the
|
||||
// page is never served, so nothing could attach a renderer.
|
||||
workspace.Start();
|
||||
|
||||
shell = new MainWindowViewModel(
|
||||
paths,
|
||||
caches,
|
||||
@@ -372,6 +390,95 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
vault.Status.ShouldContain("bad day");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The page's own <c>term.focus()</c> focuses the textarea inside the document, which does nothing
|
||||
/// while the window's keyboard focus is still on the Connect button — so the first keystrokes of a
|
||||
/// session went to the shell's UI rather than the remote shell, and the terminal had to be clicked
|
||||
/// first. The view hands the control focus when this fires; see <c>NativeKeyboardFocus</c> for why an
|
||||
/// ordinary <c>Focus()</c> call is enough in that direction and not in the other.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// What this covers is the plumbing that carries the fix: that the raise is on the success path and
|
||||
/// happens once per session, and that the shell forwards it. Deleting the raise outright is already a
|
||||
/// build error — the event would be unused, and warnings are errors — but moving it, which is the
|
||||
/// likelier mistake, is not. It does <em>not</em> cover the focus call itself: that needs a native
|
||||
/// window, and headless Avalonia has none, which is exactly why this class of defect has escaped
|
||||
/// tests here before. Measured separately in a harness; see docs/platform-flags.md.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ConnectingAsksTheViewToFocusTheTerminal()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
var requests = 0;
|
||||
shell.TerminalSessionOpened += (_, _) => requests++;
|
||||
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Status.ShouldContain("Connected", Case.Insensitive);
|
||||
requests.ShouldBe(1);
|
||||
|
||||
// Again, on a second session. This is why it is an event and not a bound flag: a boolean that was
|
||||
// already true would not move focus to the terminal the user just opened.
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
requests.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Focus must not be taken on a failure. A host-key prompt needs the keyboard on the prompt's own
|
||||
/// buttons, and taking it into a terminal that has no session would strand the decision.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AFailedConnect_DoesNotAskForTheTerminalToBeFocused()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
ssh.Failure = new SshHostKeyUnknownException(
|
||||
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:unknown"));
|
||||
|
||||
var requests = 0;
|
||||
shell.TerminalSessionOpened += (_, _) => requests++;
|
||||
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
vault.HasPendingHostKey.ShouldBeTrue();
|
||||
requests.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The shell stops forwarding once the vault is gone. Dropping the detach half of that would compile
|
||||
/// and pass every other test, while leaving a discarded vault able to move focus in a locked window.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task LockingStopsTheShellForwardingFocusRequests()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
var requests = 0;
|
||||
shell.TerminalSessionOpened += (_, _) => requests++;
|
||||
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
requests.ShouldBe(1);
|
||||
|
||||
await shell.LockCommand.ExecuteAsync(null);
|
||||
|
||||
shell.Vault.ShouldBeNull();
|
||||
|
||||
// The discarded vault is detached, so even a late raise from it reaches nobody.
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
requests.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnInvalidHost_IsRefusedWithAReason()
|
||||
{
|
||||
@@ -569,4 +676,17 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
/// <summary>An unlocked vault with one selected host and a renderer attached.</summary>
|
||||
private async Task<VaultViewModel> ReadyToConnectAsync()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
|
||||
return vault;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user