diff --git a/src/DodoSSH.Client.App/Views/MainWindow.axaml b/src/DodoSSH.Client.App/Views/MainWindow.axaml
index f5f38f0..115f189 100644
--- a/src/DodoSSH.Client.App/Views/MainWindow.axaml
+++ b/src/DodoSSH.Client.App/Views/MainWindow.axaml
@@ -383,7 +383,12 @@
not replayed. See MainWindow.axaml.cs.
-->
+ PlaceholderText="https://dodossh.example">
+
+
+
+
+
@@ -395,8 +400,14 @@
-
-
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs b/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs
index cdc73c1..be0a8e4 100644
--- a/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs
+++ b/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs
@@ -33,6 +33,9 @@ internal sealed partial class MainWindow : Window
private bool wasUnlocked;
+ /// Which closed-vault card the keyboard was last sent to, so it is sent once per card.
+ private ShellState? closedState;
+
public MainWindow()
{
InitializeComponent();
@@ -184,6 +187,7 @@ internal sealed partial class MainWindow : Window
{
ShellState.Locked => UnlockPane.PassphraseBox,
ShellState.NeedsServer => ServerUrlBox,
+ ShellState.NeedsEnrollment => NewPassphraseBox,
_ => this,
};
@@ -307,6 +311,14 @@ internal sealed partial class MainWindow : Window
Terminal.Source = viewModel.TerminalPageUrl;
wasUnlocked = viewModel.IsUnlocked;
+ closedState = null;
+
+ // The shell may have reached its card before this window was given it, and then no change would
+ // ever say so.
+ if (!viewModel.IsUnlocked)
+ {
+ FocusClosedVaultCard(viewModel);
+ }
viewModel.TerminalSessionOpened += OnTerminalSessionOpened;
viewModel.TerminalFocusRequested += OnTerminalFocusRequested;
@@ -380,10 +392,32 @@ internal sealed partial class MainWindow : Window
{
ReleaseKeyboardTo(ClosedVaultKeyboardHome);
}
+ else if (!unlocked && viewModel.State != closedState)
+ {
+ FocusClosedVaultCard(viewModel);
+ }
+ closedState = unlocked ? null : viewModel.State;
wasUnlocked = unlocked;
}
+ /// Hands the keyboard to the box on whichever card a closed vault is showing.
+ ///
+ /// For arriving at a card other than by locking — launch landing on the unlock card, a sign-in finishing
+ /// on the enrolment one — which used to leave the passphrase somebody started typing going nowhere. Once
+ /// per card, which is what keeps it from moving the keyboard while somebody is mid-sign-in: IsUnlocked is
+ /// raised for every state change, and the state does not change while they type. Posted, because the card
+ /// is revealed by a binding the same change has not reached yet.
+ ///
+ private void FocusClosedVaultCard(MainWindowViewModel viewModel)
+ {
+ closedState = viewModel.State;
+
+ var home = ClosedVaultKeyboardHome;
+
+ Dispatcher.UIThread.Post(() => home.Focus(), DispatcherPriority.Loaded);
+ }
+
///
/// Closing only. Opening also has to move the keyboard — the palette is a text box somebody is expected
/// to start typing into immediately — but the palette does that for itself when it becomes visible,
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/ClosedVaultFocusTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/ClosedVaultFocusTests.cs
new file mode 100644
index 0000000..b774ec4
--- /dev/null
+++ b/tests/DodoSSH.Client.App.Layout.Tests/ClosedVaultFocusTests.cs
@@ -0,0 +1,127 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Threading;
+using DodoSSH.Client.App.Views;
+using DodoSSH.Client.Session;
+using DodoSSH.Client.Session.Tests;
+using DodoSSH.Client.Shell.ViewModels;
+using DodoSSH.Client.Ssh;
+using DodoSSH.Client.Storage;
+using DodoSSH.Client.Terminal;
+using DodoSSH.Client.Transfer;
+using DodoSSH.Crypto;
+using NSubstitute;
+
+namespace DodoSSH.Client.App.Layout.Tests;
+
+///
+/// The keyboard on the cards a closed vault shows: where it is when the card appears, and what Enter does.
+///
+///
+///
+/// Launch used to leave the keyboard nowhere. Focus only moved on the way out of an unlocked vault, so the
+/// passphrase somebody started typing the moment the window appeared went nowhere until they clicked the box,
+/// and Enter in the server address did nothing at all.
+///
+///
+/// The whole window, which says cannot be shown
+/// here: the terminal's WebView refuses this thread's apartment when it is attached. It is taken out of the
+/// tree before the window is shown, which is everything that test is about and nothing these are.
+///
+///
+public sealed class ClosedVaultFocusTests
+{
+ private const string Passphrase = "a sufficiently long passphrase";
+
+ private static readonly Argon2Profile CheapProfile =
+ Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
+
+ private static CancellationToken Token => TestContext.Current.CancellationToken;
+
+ [Fact]
+ public async Task AFreshMachine_PutsTheKeyboardInTheServerAddressAndEnterSignsIn()
+ {
+ using var caches = ClientCacheFactory.ForMemory($"focus-{Guid.CreateVersion7():N}");
+ await caches.MigrateAsync(Token);
+
+ await using var workspace = Workspace();
+ await using var shell = Shell(caches, workspace);
+
+ await shell.StartAsync(Token);
+ shell.State.ShouldBe(ShellState.NeedsServer);
+
+ await LayoutHarness.OnTheUiThreadAsync(
+ () => WithWindow(shell, window =>
+ {
+ var box = window.FindControl("ServerUrlBox").ShouldNotBeNull();
+
+ window.FocusManager!.GetFocusedElement().ShouldBeSameAs(box);
+
+ box.KeyBindings.ShouldContain(binding =>
+ binding.Gesture == new KeyGesture(Key.Enter) && binding.Command == shell.SignInCommand);
+ }),
+ Token);
+ }
+
+ [Fact]
+ public async Task AnEnrolledMachine_PutsTheKeyboardInThePassphraseBox()
+ {
+ using var caches = ClientCacheFactory.ForMemory($"focus-{Guid.CreateVersion7():N}");
+ await caches.MigrateAsync(Token);
+
+ await new AccountProvisioner(new FakeAccountServer(), new StubKeyBinding(), caches, TimeProvider.System, CheapProfile)
+ .EnrollAsync("https://dodossh.example", Passphrase, "laptop", "Personal", Token);
+
+ await using var workspace = Workspace();
+ await using var shell = Shell(caches, workspace);
+
+ await shell.StartAsync(Token);
+ shell.State.ShouldBe(ShellState.Locked);
+
+ await LayoutHarness.OnTheUiThreadAsync(
+ () => WithWindow(shell, window =>
+ window.FocusManager!.GetFocusedElement()
+ .ShouldBeSameAs(window.FindControl("UnlockPane").ShouldNotBeNull().PassphraseBox)),
+ Token);
+ }
+
+ private static TerminalWorkspace Workspace() =>
+ new(
+ new InMemoryTerminalAssetProvider(new Dictionary(StringComparer.Ordinal)),
+ Substitute.For(),
+ TimeProvider.System);
+
+ private static MainWindowViewModel Shell(ClientCacheFactory caches, TerminalWorkspace workspace) =>
+ new(
+ new ClientPaths(Path.Combine(Path.GetTempPath(), $"dodossh-focus-{Guid.CreateVersion7():N}")),
+ caches,
+ workspace,
+ new VaultKnownHostStore(),
+ new UnavailableDeviceKeyStore(),
+ static (_, _) => throw new InvalidOperationException("A layout test has no network."),
+ TimeProvider.System,
+ Substitute.For(),
+ CheapProfile);
+
+ private static void WithWindow(MainWindowViewModel shell, Action assert)
+ {
+ var window = new MainWindow { DataContext = shell };
+
+ if (window.FindControl("Terminal") is { Parent: Panel panel } web)
+ {
+ panel.Children.Remove(web);
+ }
+
+ try
+ {
+ LayoutHarness.Settle(window, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
+ Dispatcher.UIThread.RunJobs();
+
+ assert(window);
+ }
+ finally
+ {
+ window.Close();
+ }
+ }
+}