Give the phone the rest of its screens, and a way in
ci / build and test (push) Failing after 2s
ci / android head (push) Failing after 1s

All seven screens of the design, plus the two it does not draw because it starts at an
enrolled phone: naming a server, and choosing a passphrase.

The five states docs/android-port.md worried about losing at 360dp are all here and none
of them softened. The changed-key refusal is a full-screen panel rather than a bottom
sheet, because a sheet is swipe-to-dismiss by convention and that screen must have no way
forward. The recovery code raises FLAG_SECURE for its own state and lowers it afterwards,
so the sentence about screenshots is true rather than decorative. The delete
confirmations keep their counts and replace the row in place.

Signing in works, and the seam it needed is worth more than the implementation:
IAuthorizationCallback now sits between OidcClient and the loopback listener, so the two
heads differ in where the response arrives and in nothing else. PKCE, the state check,
discovery, the token exchange and the key binding stay one implementation — a second OIDC
client would be a second place for a security bug to live. The phone registers a
private-use scheme with the system rather than binding a loopback port, which on a shared
device any other app can do first.

The accessory key row needed TerminalWorkspace.SendInputAsync: ordinary typing goes from
the renderer straight down the socket, and there was no way in for the keys a software
keyboard does not have. Ctrl latches, because one thumb cannot chord, and the latch is
drawn — a modifier that is on and does not look on is how somebody sends ^L to a database
prompt believing they typed an l.

597 client tests green, including two new ones for the input path and one for the
terminal surface command. Nothing has run on a device.
This commit is contained in:
2026-07-31 21:43:11 +02:00
parent 81e7e6d939
commit 7a3a521c59
51 changed files with 2144 additions and 134 deletions
@@ -562,6 +562,37 @@ public sealed class ShellFlowTests : IAsyncLifetime
shell.Screen.ShouldBe(ShellScreen.Preferences);
}
/// <remarks>
/// <para>
/// The phone's bottom bar names the terminal beside the pages, so the surface needs a command of its
/// own — the desktop only ever reaches it implicitly, by opening a session or clicking a tab.
/// </para>
/// <para>
/// Tested here rather than in the Android head because it is shared state-machine behaviour, and
/// because nothing in this repository can run a test on a phone.
/// </para>
/// </remarks>
[Fact]
public async Task ShowingTheTerminal_SwitchesSurfaceWithoutChangingTheScreen()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
shell.ShowScreenCommand.Execute(ShellScreen.Vault);
shell.IsTerminalShowing.ShouldBeFalse();
shell.ShowTerminalCommand.Execute(null);
shell.IsTerminalShowing.ShouldBeTrue();
shell.IsShowingPages.ShouldBeFalse();
// The page underneath is remembered, not reset. Going to the terminal and back is navigation, and
// navigation that forgets where you were is how a four-button bar becomes annoying.
shell.Screen.ShouldBe(ShellScreen.Vault);
}
/// <remarks>
/// A visible WebView with no pane in it reads as the application having broken, so this is the one
/// transition that moves the surface back on its own.
@@ -268,6 +268,56 @@ public sealed class TerminalWorkspaceTests
// ---- Helpers ----
/// <remarks>
/// <para>
/// Input that did not come from the keyboard. The Android head's accessory key row is what needs this —
/// a software keyboard has no Ctrl, Esc, Tab or arrows — and what it sends has to arrive at the remote
/// byte for byte, because an escape sequence that loses a byte is not a degraded arrow key, it is a
/// stray character in somebody's shell.
/// </para>
/// <para>
/// Ordinary typing does not come this way and is not what is being tested: that goes from the renderer
/// down the socket, which the pump's own tests cover.
/// </para>
/// </remarks>
[Fact]
public async Task SendingInput_ReachesTheRemoteUnchanged()
{
var connections = new FakeConnectionFactory();
await using var workspace = CreateWorkspace(connections);
var sessionId = await workspace.OpenSessionAsync(
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
// The up-arrow, as a PTY expects it. Three bytes, and all three matter.
byte[] upArrow = [0x1B, (byte)'[', (byte)'A'];
await workspace.SendInputAsync(sessionId, upArrow, TestContext.Current.CancellationToken);
await WaitUntilAsync(() =>
connections.Connections.SingleOrDefault()?.Shell?.Written.SequenceEqual(upArrow) == true);
}
/// <remarks>
/// A tab can close while a key is still in flight, which on a phone is one mis-tap rather than a rare
/// race — the close cross sits inside the tab and the accessory row is directly under it. Throwing
/// would turn that into a crash on a keystroke that no longer matters.
/// </remarks>
[Fact]
public async Task SendingInputToASessionThatIsGone_IsIgnored()
{
await using var workspace = CreateWorkspace(new FakeConnectionFactory());
var sessionId = await workspace.OpenSessionAsync(
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
await workspace.CloseSessionAsync(sessionId);
await Should.NotThrowAsync(async () =>
await workspace.SendInputAsync(sessionId, "x"u8.ToArray(), TestContext.Current.CancellationToken));
}
/// <param name="connections">
/// How connections are made. The renderer-gate tests never reach it — they stop at the gate — but they
/// take a fake anyway, because reaching a real host from here would make this a network test.