Merge branch 'claude/sleepy-chebyshev-cda68d'

This commit is contained in:
2026-07-29 15:35:02 +02:00
10 changed files with 641 additions and 16 deletions
+41 -6
View File
@@ -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 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
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 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 `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 `ScrollViewer` and no `MaxHeight` on an `Auto` row, so enough conflicts squeeze the terminal row toward
nothing. nothing.
**Nothing hands the terminal keyboard focus after connecting.** The page calls `term.focus()`, which focuses **Keyboard focus crosses into the WebView by itself and does not come back.** This is the asymmetry to
the textarea inside the document, but Avalonia's focus is still on the Connect button — so the first know; the connect-focus bug that led here was only its first symptom. Measured on Windows with a harness
keystrokes after a successful connect go to the shell's UI, not to the remote shell. Click inside the that reports `GetFocus()`, the class name of the window holding it, and the page's own
terminal first. This is a focus-plumbing gap between Avalonia and the native child window, not a terminal `document.hasFocus()` at each step.
bug.
- **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 **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
@@ -142,6 +142,17 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// <summary>Where the embedded browser should navigate.</summary> /// <summary>Where the embedded browser should navigate.</summary>
internal Uri TerminalPageUrl => workspace.PageUrl; 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 IsStarting => State == ShellState.Starting;
internal bool IsNeedingServer => State == ShellState.NeedsServer; internal bool IsNeedingServer => State == ShellState.NeedsServer;
@@ -469,6 +480,26 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
return exception.Message; 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) partial void OnStateChanged(ShellState value)
{ {
OnPropertyChanged(nameof(IsStarting)); OnPropertyChanged(nameof(IsStarting));
@@ -179,6 +179,18 @@ internal sealed partial class VaultViewModel(
[ObservableProperty] [ObservableProperty]
private string? hostKeyMismatch; 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 HasPendingHostKey => PendingHostKey is not null;
internal bool HasHostKeyMismatch => HostKeyMismatch is not null; internal bool HasHostKeyMismatch => HostKeyMismatch is not null;
@@ -611,6 +623,12 @@ internal sealed partial class VaultViewModel(
.ConfigureAwait(true); .ConfigureAwait(true);
Status = $"Connected to {row.Label}."; 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) catch (SshHostKeyUnknownException exception)
{ {
@@ -77,7 +77,8 @@
<Grid Grid.Row="1" Grid.Column="0" RowDefinitions="*,Auto,Auto" <Grid Grid.Row="1" Grid.Column="0" RowDefinitions="*,Auto,Auto"
Background="#131722" IsVisible="{Binding IsUnlocked}"> 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}" ItemsSource="{Binding Vault.Hosts}"
SelectedItem="{Binding Vault.SelectedHost}" SelectedItem="{Binding Vault.SelectedHost}"
Background="Transparent"> Background="Transparent">
@@ -306,7 +307,13 @@
<StackPanel Spacing="12"> <StackPanel Spacing="12">
<TextBlock Classes="heading" Text="Unlock your vault" /> <TextBlock Classes="heading" Text="Unlock your vault" />
<TextBlock Text="{Binding AccountName}" Foreground="#bcd2ea" /> <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}" <Button Content="Unlock" Command="{Binding UnlockCommand}"
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" /> IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
<TextBlock Classes="hint" Text="{Binding StatusMessage}" /> <TextBlock Classes="hint" Text="{Binding StatusMessage}" />
@@ -1,23 +1,117 @@
using System.ComponentModel;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input;
using DodoSSH.Client.App.ViewModels; using DodoSSH.Client.App.ViewModels;
namespace DodoSSH.Client.App.Views; 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 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() public MainWindow()
{ {
InitializeComponent(); InitializeComponent();
// Navigation happens once the data context is known, because the URL carries the port the DataContextChanged += (_, _) => Attach(DataContext as MainWindowViewModel);
// 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.WebMessageReceived += (_, e) =>
DataContextChanged += (_, _) =>
{ {
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 HEADER_LENGTH = 5;
const SCROLLBACK_LINES = 5000; 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 root = document.getElementById('root');
const statusBanner = document.getElementById('status'); const statusBanner = document.getElementById('status');
@@ -80,6 +93,31 @@ function sendResize(sessionId, term, pane) {
send(CLIENT_RESIZE, sessionId, payload); 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) { function createSession(sessionId) {
const pane = document.createElement('div'); const pane = document.createElement('div');
pane.className = 'pane'; pane.className = 'pane';
@@ -109,6 +147,8 @@ function createSession(sessionId) {
console.warn('WebGL renderer unavailable; falling back to canvas.', error); console.warn('WebGL renderer unavailable; falling back to canvas.', error);
} }
term.attachCustomKeyEventHandler(handleKey);
term.onData((data) => { term.onData((data) => {
send(CLIENT_INPUT, sessionId, new TextEncoder().encode(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));
}
+99
View File
@@ -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(); 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 int signInAttempts;
private string directory = null!; 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 // 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 // resource system at construction and needs an initialised toolkit. This is what
// ITerminalAssetProvider is for; nothing in this suite renders anything. // 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( workspace = new TerminalWorkspace(
new InMemoryTerminalAssetProvider( new InMemoryTerminalAssetProvider(
new Dictionary<string, TerminalAsset>(StringComparer.Ordinal) 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); 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( shell = new MainWindowViewModel(
paths, paths,
caches, caches,
@@ -372,6 +390,95 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.Status.ShouldContain("bad day"); 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] [Fact]
public async Task AnInvalidHost_IsRefusedWithAReason() public async Task AnInvalidHost_IsRefusedWithAReason()
{ {
@@ -569,4 +676,17 @@ public sealed class ShellFlowTests : IAsyncLifetime
await vault.SaveHostCommand.ExecuteAsync(null); 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;
}
} }