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))
{
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;
}
///
/// 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();
}
}