Commit Graph
9 Commits
Author SHA1 Message Date
jaap-jan aff776cb42 Rebuild the terminal's keypad, and fix what a device run showed
ci / build and test (push) Failing after 3m0s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 3m56s
Six defects, all found by running this head on an emulator against a local API rather than by reading it.
They are in one commit because two of the files carry several of them and splitting those would mean
hand-editing patches; each is argued at its own site.

── the keypad, which is most of this ───────────────────────────────────────────────────────────────────
It was eleven 30-by-44 keys in a horizontal ScrollViewer, and it was wrong twice over. A pan and a press
begin with the same few pixels of travel, so a thumb coming down at an angle — which is how a thumb comes
down — was read as the start of a pan and the press was never delivered; the row slid slightly instead.
And 30 tall is well under the 48 Android asks for, so the presses that did arrive were hard to aim. The
arrows, the keys pressed most and in sequence, were the ones parked past the right-hand edge.

Termux, Termius, JuiceSSH, ConnectBot and Blink all ship this row and not one of them scrolls it: they
divide the width evenly and add a second row when the set does not fit. That is not convention for its own
sake — a key whose position is fixed can be hit without looking, which is the whole point of a modifier row
used while reading the output above it.

So: two UniformGrid rows, every key an equal share of the width, nothing scrollable, 40 tall. Every key
kept. Arrows on the lower row where the thumb rests, reordered to ← ↓ ↑ → so the pairs that undo each other
are neighbours. It costs about 51 pixels of terminal, four or five lines, and that is the right trade: the
row that was cheaper did not work.

The keys also gain a pressed state, which they had none of. They are Focusable="False" so that using one
does not take the keyboard off the terminal, and a button that never takes focus never shows the adorner
Fluent would otherwise give it — leaving no feedback at all on a control whose only job is to be pressed.
Their looks move from assignments in the code-behind to Button.hotkey, which is what lets a style say
:pressed, and the Ctrl latch becomes a class rather than two brushes written by hand.

── and five smaller ones ────────────────────────────────────────────────────────────────────────────────
The keyboard's inset was never cleared when the app was resumed without the keyboard it was applied for.
Android takes the keyboard away as the activity stops and Avalonia's input pane raises no StateChanged,
the pane going with the window rather than closing in front of one — so the interface came back holding a
whole keyboard's height of empty canvas. 838 of 2340 pixels on a Pixel 5, on every screen, until something
opened and closed the keyboard. MainActivity now raises PhoneEnvironment.Resumed and PhoneShell re-asks the
pane. It asks rather than assuming zero: a permission prompt or the biometric sheet can hand the activity
back with the keyboard genuinely still up, and flattening the inset there would strand the focused box
under a real one.

TextBlock.detail never wrapped, where .body beside it always has. A block that neither wraps nor trims lays
out at its full single-line width and the parent clips it, with no ellipsis to say anything is missing — so
the add sheet read "A machine to connect to, filed under a group if you" and stopped. Which of the fifty-odd
.detail blocks overflows is a function of width and font scale rather than of the string, so this is a style
rather than an attribute on the handful visibly wrong on one phone. The nine that genuinely want one line
now say NoWrap beside their TextTrimming, which is the pairing that was always meant.

The two port boxes are NumericUpDowns and were the only unthemed controls on this head — Fluent's border and
fill, next to a TextBox.field sharing their row. App.axaml has styled NumericUpDown for the desktop all
along; this head never had one.

Three buttons classed "icon accent" were painting Fluent's own Button.accent, #6275AC, a blue in no palette
here. The desktop overrides that style and this head did not, so the same class read correctly there and not
here.

The host key sheet said "First contact with 10.0.2.2 · 2222 . Nothing in this keychain…" — Avalonia puts a
space between adjacent Runs and the last one began with the full stop. A gap before a full stop, on the one
dialogue whose whole job is to be read carefully before it is answered.

── checks ──────────────────────────────────────────────────────────────────────────────────────────────
manual-checks gains 10.4a and 11.10f. 10.4 already covered dismissing the keyboard with back or the
chevron but not leaving the app with it up, which is the path the inset bug shipped through; 10.4a covers
both directions, since a fix that flattened the inset on every resume would pass one half and fail the
other. 11.10f guards the keypad against a ScrollViewer being put back around it.
2026-08-26 13:28:59 +02:00
jaap-jan 185790fb14 Let a key move to another vault, and ask whether it goes with the host
Keys sync and keys are shared: SshKey is in the sync registry on both sides, the
material rides in the sealed payload, and every generation of the vault key is
wrapped to a new member. What was missing was the way in. Hosts and groups could
move between vaults and keychain items could not, so a key typed into a personal
vault before the team existed stayed there for good — and moving a host into the
team's vault left it authenticating with something nobody else in that vault can
read. The code said so and could do nothing about it: "the answer is usually to
put a copy of that key in the destination vault", which meant pasting the private
half into a second item and deleting the first. A private key on a clipboard, and
two items nobody can tell apart afterwards.

MoveAsync already existed on the generic repository and is now exposed for keys
and passwords as it is for hosts and groups. What had to be built around it is the
re-aim. An item re-sealed under another vault's key lands with an id of that
vault's making, so every host bound to the old one and every group lending it as a
default is left naming a tombstone — and a host bound to something its vault no
longer holds refuses to connect rather than falling back to a typed password. A
move without the re-aim would look like a success and break every machine on that
key. It runs over every vault this session can write to, because a binding
resolves across all of them, and it counts what it could not rewrite: an item from
a newer client, or one in a vault this account may only read. Those are said in
the sentence afterwards rather than swallowed.

The host's move asks the question rather than deciding it. A binding resolves
across vaults, so the moved host goes on working for the person who moved it
whichever way this is answered; it is the colleagues they have just joined who
hold one vault's key and cannot connect with a host whose key stayed behind.
Unticked, and it stays that way on purpose: moving a key into a team's vault hands
it to everybody holding that key, and this design does not default anybody into a
disclosure. Under the box is the count of everything else that authenticates with
that key, because a key twenty machines use is a different decision from one
nothing else touches, and neither number is visible from the panel otherwise. The
question is answered against the vault in the picker, so choosing a different
destination re-asks it and a key already in the destination offers nothing.

One thing fixed on the way. A host that inherited its key from its group arrived
in the destination naming nothing at all — the group belongs to the vault it left
— so a machine that connected before the move refused after it, with no sentence
anywhere saying why. The resolved binding is now written onto the host as it
crosses, and the stranded-binding warning reads the resolved binding too, which is
the case where somebody is least likely to know a key is involved.

MOVE is on both heads, for keys and passwords only: a tag, a bucket and a pin are
read from the active vault alone, so "another vault" is not a question any of them
has. Four tests cover the move and its re-aim, the host's move with the key
brought and without it, and the inherited binding.
2026-08-06 07:39:15 +02:00
jaap-jan 5cb361ea13 Lay the phone out like the desktop when the surface is not a phone
ci / build and test (push) Successful in 2m54s
ci / android head (push) Failing after 3m15s
ci / api image (push) Successful in 46s
Three destinations in a bar and everything else behind SETTINGS is the right
shape at 360dp, where a fourth entry costs the width of the three that are there.
On a tablet, an unfolded foldable or a landscape phone it is the wrong one: there
is room for every destination at once, and the hub becomes an extra tap between
somebody and a screen they can already see space for.

So at 600dp — Android's own boundary between a compact window and a medium one,
in the density-independent units Avalonia lays out in — the bar stands down and
PhoneRail takes the left edge with all nine on it. It is the desktop's NavRail
arrangement rather than its file: the two heads cannot share a view, and this one
draws the phone's destination set with the phone's palette and touch targets.

The flags are computed in code rather than assembled in the markup because none
of them is a single question any more, and Avalonia's bindings have no "and" —
and the header's condition is an "or", which not even a wrapper can express. That
header is the one worth reading twice: narrow it stands down behind SETTINGS, so
the hub's screens can draw their own; wide there is no hub to be behind, so it
stays up everywhere. Losing it on the keychain would be losing the only LOCK
button on the surface.

Removing the hub means removing the routes into it, and there were four kinds.
The rail has no SETTINGS entry, because that screen is a menu of the rail. The
five back arrows in the screens under it are hidden, since an arrow to a screen
the layout removed is the one control on a header that leads nowhere. The system
back gesture goes to Hosts instead. And unfolding while sitting on the hub moves
to Hosts, rather than leaving somebody on a list of things now visible beside it.

One bug fixed on the way: OnBodyResized returned early unless the keyboard was
open, so a foldable would have opened to a phone layout until somebody typed
something. The chrome is refreshed first and unconditionally; the early return
belongs to the older job below it.

What this does not do is use the width *inside* a screen — the host list is one
column at any size. Two columns needs the row model to change, because that list
is headings and hosts in one sequence and a heading has to span, and that model
is shared with the desktop. Check 8.1 walks the rail; nothing here is covered by
a test, for the reason 8.0 exists.
2026-08-05 20:50:18 +02:00
jaap-jan 562fb444a8 Merge main into the phone connections branch
ci / build and test (push) Canceled after 46s
ci / android head (push) Canceled after 0s
ci / api image (push) Canceled after 0s
Main had already taken this branch's first two commits, so what merged is the
Connections work against three things that landed beside it. Four of the six
conflicts were prose about arrangements both sides changed; two were real.

**The phone hub gained a Teams row while this branch was moving the keychain
onto it.** Both are additions to `IsMoreSurface` and both belong: teams because
the desktop reaches them from its rail and the phone through the hub, the
keychain because a bottom bar is for the places a session moves between. The
membership test, the back gesture's first case and the hub's own arithmetic all
take the union. The distinction is now written down rather than implied — teams
is the design's count plus one, and the keychain is the only rearrangement of
it: the bar lost a slot to gain that row.

**`ConnectAndAnnounceAsync` was the real one.** Main gave it
`RememberTypedPasswordAsync`, which binds the password that just worked to the
host it worked on; this branch had replaced the `HostRowViewModel` that method
needs with a four-field `ConnectionTarget`. Keeping both meant deciding what a
manual connection does with a password that succeeded, and the answer was
already written on the screen it is typed into: nothing. There is no item to
bind a credential to and none to bind it on, and that path saves nothing by
design.

So `ConnectionTarget` carries the row again — as a nullable, in place of the
host id it had, with `HostId` derived from it. Two things read it and both are
things that can only be done to a keychain item rather than to an address:
naming the log entry, and keeping the password. Null is not missing data there;
it is the whole of what makes the manual path different, and having one field
rather than two keeps "was this a keychain host" a question with one answer.

The desktop's rail lost SFTP and S3 to the tab strip on main, so the README's
"a rail with nine slots has room" was true when it was written this afternoon
and is not now. It says the room rather than the number.

Phase 11's four new device checks and main's Phase 12 on teams were the same
conflict twice — two appends to the end of one file — and both are kept.

Verified after resolving: the solution builds, the Android head builds clean,
and 837 tests pass across the seven client suites, including main's own additions
(233 shell, 79 layout, 240 domain, 118 sync, 54 session, 74 terminal, 39
storage).
2026-08-03 15:35:49 +02:00
jaap-jan f5ffd1983d Make Connections the place a connection is made, and put the keychain away
Four changes to the phone, and the last one needed the connect path taking
apart.

**The bottom bar is three entries.** The keychain moved onto the hub, which is
now SETTINGS with a gear rather than MORE with a hamburger. A bottom bar is for
the places a session moves between, and keys, credentials and tags are managed
occasionally and then left alone — which is the shape of everything already
behind that hub. With the keychain on it, "more" stopped being a description of
what is there. `ShellScreen.Vault` joining `IsMoreSurface` is the whole of the
change: the tab that lights, the header that stands down and the back gesture's
first case all read that one property, which is why the switch mirrors it by
construction rather than by a second list. The keychain screen grew the header
every hub screen has, because the shell's own is not above it any more and
without one there would be no back arrow and nothing saying what the list is.

The desktop keeps its Keychain rail entry. A rail with nine slots has room, so
this is the second thing the two heads arrange deliberately differently, after
the hub itself.

**Terminal became Connections**, and the word does more work than a rename
usually does — see below. The enum member stays `ShellSurface.Terminal`, for
the reason the tab was never called Vault: the surface is a terminal, and the
word a user reads is the product's.

**The + puts the software keyboard away.** It sits above a terminal somebody is
typing into, so the sheet it raises was arriving underneath a keyboard covering
the half of the screen the sheet is on — and worse, laid out into the strip
left above it, since the keyboard's inset shortens everything this head draws.
Avalonia cannot do this and it is worth knowing why: `TopLevel.InputPane`
reports the keyboard and offers nothing that closes one, because the framework's
model is that it belongs to whatever has focus — and this keyboard was raised by
the `WebView`'s own text input, by a native view Avalonia's focus manager never
owned. Clearing Avalonia's focus leaves it exactly where it is. So
`Platform/SoftKeyboard.cs` asks `InputMethodManager`, off the decor view's
window token, and every step of it is allowed to be absent.

**With nothing open, Connections is a connect screen rather than an empty
state.** A box taking `user@host` or `user@host:port`, a password, and the
machines most recently connected to underneath. The box is the only path in this
product to a machine the keychain has never heard of, which is a real case it
had no answer for: an address somebody was handed five minutes ago. A typed
password and nothing else — offering the keychain's keys would be a second
binding resolution beside `TryBuildAuthentication`, and the argument against a
second one is written there at length. Nothing typed is saved, and the screen
says so: a machine worth keeping belongs on HOSTS, where it can carry a key, a
group's defaults and a name.

The recents come out of the vault's own connection log rather than a list kept
in this process, so they survive a restart and arrive on a new phone with the
keychain. Deduplicated by address, because this is a list of places and not of
events, and capped at six so the box stays above the keyboard. Emptied when the
vault is — they are decrypted entries naming where somebody works, and a lock
that left them on screen would be a list still readable after every key that
decrypted it was zeroed. Tapping one leads to whichever of two things it is: a
keychain host goes to that host's connect bar, where its key, its password box
and its refusals already live, and an address goes back into the box, without
the password, whose absence is the point of that path rather than a gap in it.

**The connect path was shaped like `HostRowViewModel` all the way down.** The
log entry, the identification, the failure record and the retry all took a row.
They take a four-field `ConnectionTarget` now, so a connection to an address
shares the ladder of refusals, the host-key question and the tab's lifecycle
rather than growing a second copy of them. `ConnectionRecorder.Record` and
`Identify` have always taken a nullable host id, so the log could already hold a
connection with no item behind it.

One behavioural change falls out of that and it is the one to know about:
**trusting a host key now retries the attempt that raised the question** instead
of re-running whichever host is selected. That was correct while a selected host
was the only way to connect; with a manual target it would dial a different
machine, or refuse with "choose a host first" over a key the user has just
agreed to trust. The test selects a host first, so a regression cannot pass by
connecting to the wrong thing successfully.

`LogsViewModel.ReloadAsync` split so the connections half can be read alone.
Reading the keychain's activity for a screen that offers neither would double
the decryption on the list that was already the expensive one.

Twelve tests: the parse grammar as a theory over seven refusals, the dialled
request, the retry, and both branches of tapping a recent row. The recents rows
are built by hand rather than connected-and-closed — what those tests are about
is which branch a row takes, and driving it through the recorder's queue would
test the recorder, which `DodoSSH.Client.Session.Tests` already does. What needs
a device is phases 11.6 to 11.9 of `docs/manual-checks.md`.
2026-08-03 15:26:47 +02:00
jaap-jan 5800a4bbd0 Give the keychain the DELETE its confirmation was already written for
The counted confirmation has been on this screen since the phone got its
screens, and nothing could raise it. PendingDeletion is armed by
DeleteSelectedItemCommand and its siblings, and the only Delete bindings
anywhere on this head were the files screen's remote delete and the snippets
one — so the card, and the header comment calling it structural, described a
question the phone could not ask. The button is what was missing rather than
anything behind it: the command, the counting and the wording were all already
shared with the desktop.

It sits in row 2 beside that confirmation rather than in a bar at the foot of
the screen, which is where HOSTS and FILES put what to do with the chosen thing.
ShowsItemActions is SelectedItemIsEditable and not-already-asking, so sharing
the row makes the question take the place of the button that asked it, and
DELETE cannot be pressed a second time underneath its own confirmation. A button
at the bottom and its question at the top would be one exchange drawn twice.

DELETE alone, without the EDIT the desktop's detail pane offers beside it,
because there is nothing on this head for EDIT to open. That is also why the
bar names the item rather than trusting the highlight: it is the only thing on
screen saying which row is about to go.

And the highlight had to be made to exist. HOSTS and FILES fill the selected
ContentPresenter, which works because their rows are bare Borders; a keychain
row is Border.card, and the card paints an opaque Sidebar over it. The selector
reaches the card itself instead, in the same Active the chosen host wears, so
the row a button now acts on looks different from the ones it does not.

The TAGS comment said renaming and deleting happened here. Half of that is true
as of this commit and the other half is not — a tag is still renamed on the
desktop, because no kind of item opens an editor on this screen.

The README's paragraph about what the phone is missing said there was no host
editor, which stopped being true when the floating + and both editors landed. It
now names what is actually absent: no local pane for file transfer, no keychain
editor, and no screen at all for pins, teams or import.
2026-08-03 14:54:19 +02:00
jaap-janandClaude Opus 5 16e0051e89 Draw the tags that have been storable and invisible since the domain landed
ci / build and test (push) Successful in 1m22s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 52s
`Tag` has been a full item kind for three commits — a table, a migration, a
codec, a merge, a cipher — and `HostSecret.TagIds` has merged per tag so two
people tagging one host both keep theirs. Nothing drew a chip. The tags a
client could store were ones nothing here could see.

Chips on host rows, both heads, from names resolved through the tag list rather
than ids: a tag that does not resolve is left out rather than drawn, because it
means the tag was deleted elsewhere or belongs to a vault this session cannot
read, and a host with one chip fewer is the honest answer where a host wearing
a GUID is not. The id stays on the host, so the chip comes back if the tag does.

The picker is chips that toggle, matching the chips on the row behind it. A
list of names to tick would make the user match an entry to a chip they can see
two inches away. The box under it creates a tag and puts it on straight away,
because that is when a tag is usually wanted — while tagging a host and finding
it does not exist yet. Unlike every other field in that editor it writes to the
keychain immediately, since a host can only name an id that exists; cancelling
therefore leaves the tag behind, which is honest rather than hidden. A name
that already exists is used rather than repeated: two tags called "staging" are
storable and must stay storable, because two people creating one offline is how
it happens, but typing it into a box beside a chip of the same name is a slip.

Renaming and deleting needed a home, or the picker fills with names nobody uses
and never empties. That home is a TAGS category on the keychain screen, where
every other item kind is managed — and renaming is the whole reason a tag is an
item rather than a string repeated inside twenty payloads: it is one write, and
no host is touched. The delete confirmation counts the hosts wearing it, which
is the difference between a tidy-up and losing a filter somebody relies on.

The desktop host editor now scrolls, and that is not a tidy-up. A picker's
height is a chip per tag in the keychain, wrapped, so somebody with fifteen
tags has an editor half again as tall as somebody with three; no fixed height
holds that, and trimming other fields to buy room only moves the failure to
whoever has sixteen. The layout suite caught it the moment its seeder grew tags
— which is why the seeder now creates ten rather than three, enough to drive
the pane onto its cap so the capped shape is what gets measured rather than one
no real keychain produces. The cost is named where it is paid: the harness
skips anything inside a ScrollViewer, so from here it certifies that pane fits
the column rather than that every field in it does.

Two smaller things fell out. Five buttons overflowed the keychain header by a
few pixels, so GENERATE lost the word KEY — its tooltip carries what the word
did. And TotalItemCount had been counting keys and credentials while ALL showed
four kinds; it counts all five now, because a number under a chip that
disagrees with the rows it opens is worse than no number.

An adversarial review of this change found two defects it had introduced, both
green against the full suite. NewTag filed into the "new items go to" picker
while the tag list only ever holds the active vault's — so with a team vault
selected a tag would be created, queued for push, reported as added, and then
invisible, with no row, no count, no picker entry and nothing able to rename or
delete it, because there is no active-vault switcher to go and find it with.
The comment on the host editor's own create path states that exact rule; this
was the one place that broke it, and NewObjectStore, whose list is likewise
active-vault-only, already ignored the picker. And the tag editor was the only
one of five that did not disarm a pending deletion when it opened, so arming a
key's deletion and then pressing + TAG left a live DELETE for an item the user
was no longer looking at, directly above the boxes they were typing into. Both
are fixed, both have a test, and the first was checked against the broken
version before being kept.

The same review caught a doc comment that had been inserted between
SnippetRowViewModel's summary and its declaration, silently taking it over.

Verified by the whole suite on a clean build: 1413 tests over nineteen
projects, none failing. Both heads build. The rectangles the layout suite
cannot reach are phase 9 of docs/manual-checks.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 12:42:43 +02:00
jaap-janandClaude Opus 5 5593f337b6 Give the phone the second design, and both heads the palette it arrives with
The Android v2 design is what this head draws now: four destinations in a bottom bar — Hosts,
Terminal, Keychain, More — with snippets, SFTP, S3, logs and preferences one tap deeper behind the
last. The first design's four had nothing behind them, which is what made a hub worth building.

The palette moved from green-black to blue-black, and it moved in the shared project because that is
where it lives and the desktop v2 specifies the same seventeen tokens. One colour changed meaning
rather than value, and it is the only semantic change in the file. Green used to *be* the accent, so
Ellipse.dot.live filled with Accent and "the thing to press" and "a shell is open on this host" were
the same colour by construction. v2 makes the accent blue and keeps a green for status alone, which
finally separates them: Live is that green and nothing merely interactive may use it. The accent is
also two colours now — Accent fills, AccentText writes — because a row of chips in the fill colour is
a row of things that all look like the primary action.

A palette is not one file, which is the part worth knowing before the next one. Nine hex literals
lived outside it: the nav bar's own label colours, the accessory keys and their Ctrl-latched state,
two scrims, the window background Android paints before Avalonia has a frame, and the launcher
vector. The two C# sites now resolve from the dictionary by name rather than restating it. The
renderer's page cannot — it is served to a WebView over a loopback socket — so terminal.css and
terminal.js keep hand-copied values and say so at both sites.

ShellScreen gained More and Buckets, appended rather than slotted in. SFTP and S3 are one screen over
one TransfersViewModel differing only in which picker they offer, and the kind is set by the button
that navigates rather than on arrival — doing it in OnScreenChanged made every arrival at Transfers
force the picker back to hosts, including the desktop's own rail arriving at a screen with a bucket
already open. It refuses to change kind while a session is live, because there is one session behind
both destinations and switching under it would title a screen S3 while it listed an SFTP host.

What the design draws and this does not, on the usual grounds. The FORWARDING screen: nothing here
forwards anything, so every toggle would be a control with no effect — it is a paragraph on the hub
naming the absence, for the reason the desktop keeps TEAMS in its rail. The terminal's `23 ms · fwd
5432`. An ED25519 badge and a SHA256 line on keychain cards, which need an algorithm field and a
fingerprint the item type does not have. An `agent` chip, for an agent that does not exist. Snippet
run history and exit codes. The Logs FOLLOW pill, which claims a live tail over records that are
written once at close and read when the screen opens, and the severity filter, which has nothing to
count — that chip row is spent on the real choice, which of the two logs. S3 bucket totals and
lifecycle. And the + on HOSTS, which would open a host editor this head has not got.

SFTP is browse, open and delete. Both transfer commands work, and what they work against is the local
pane: QueueDownloads writes to Path.Combine(LocalPath, name), and LocalPath starts at
SpecialFolder.UserProfile, which on Android is the application's own private directory. A download
would have reported success and left the file where the person who asked for it cannot open it, which
is worse than not offering it — a refusal is visible and a file in /data/user/0/ is not. The queue is
not drawn either, since nothing here can put anything in it. Both return with the document picker.
The foreground service still counts zero transfers, and the reason moved rather than went away.

Four defects worth naming, because three of them are the kind that compile. A Button as a ListBox
ItemTemplate swallows the pointer press before the list sees it, so the files listing selected
nothing and every command reading the selection did nothing — the row is a Border now and the
phone-only single-tap-to-open is a Tapped handler, which also keeps a desktop single click from
walking into directories. Avalonia type selectors are exact, so TextBlock.fingerprint never matched
SelectableTextBlock and every fingerprint on this head rendered proportional and unwrapped: that was
breaking the never-truncated rule on the host-key sheet already. The new two-level hierarchy had no
handler for the system back gesture, so back left the application from a log screen. And the tab's
close cross had shrunk to a 30x32 target flush against the select target, which is the one control
here that ends a shell with no confirmation and no undo.

Fingerprint unlock is raised on arriving at the lock screen rather than waiting for its button, which
is still there. Only at launch: a lock the user asked for is not answered with an immediate request
to unlock, which makes LOCK look inert and trains the reflex of authenticating at a prompt nobody
asked for. And once, because a declined gesture leaves the passphrase box exactly where it was and a
prompt that came back after being dismissed would be a modal you cannot get out of to type into it.

Two fixes fall on the desktop. Its file listing coloured directories with Info and executables with
Accent, which was blue against green and is now two steps of one blue; an executable is Live now.
And a bucket's folders were drawn with a 0001-01-01 timestamp, because a prefix has no modification
time — blank now, for the reason a directory's size is blank.

Verified by the whole suite: 1309 tests over nineteen projects, none failing, including the layout
suite that stands up real Avalonia and parses every desktop screen. Both heads build. Not verified on
a device — nothing in this head ever has been; see docs/android-port.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZE3u99BNt6LzgTC5jhbz2
2026-08-02 18:23:53 +02:00
jaap-jan 7a3a521c59 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.
2026-07-31 21:43:11 +02:00