using System.ComponentModel; using Avalonia.Controls; using Avalonia.Input; using DodoSSH.Client.App.ViewModels; namespace DodoSSH.Client.App.Views; /// /// The shell window. /// /// /// 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 for why one direction is a plain /// Focus() call and the other is not. /// internal sealed partial class MainWindow : Window { /// /// The page's request to give the keyboard back to the application. /// /// /// It has to come from the page. Once the native child window holds Win32 focus, Avalonia sees no /// key events at all, so a KeyBinding on this window could never fire — the terminal is the /// only thing that can hear the shortcut and ask to be let go of. /// private const string ReleaseFocusMessage = "dodossh.release-focus"; private MainWindowViewModel? shell; private bool wasUnlocked; public MainWindow() { InitializeComponent(); DataContextChanged += (_, _) => Attach(DataContext as MainWindowViewModel); Terminal.WebMessageReceived += (_, e) => { // 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)) { // The column decides which of its lists the keyboard belongs to; this window only decides // that the keyboard should leave the terminal. ReleaseKeyboardTo(VaultPane.KeyboardTarget); } }; } 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; } /// /// A bare Focus() is the whole fix in this direction: NativeWebView.OnGotFocus 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. /// 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; } /// /// Both halves are needed. The Win32 call moves the keyboard off the native child window, and the /// Focus() 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. /// private void ReleaseKeyboardTo(IInputElement target) { NativeKeyboardFocus.ReturnTo(this); target.Focus(); } }