Public Access
Merge branch 'claude/sleepy-chebyshev-cda68d'
This commit is contained in:
@@ -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));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user