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
|
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));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user