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
@@ -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));
});