Commit Graph
13 Commits
Author SHA1 Message Date
jaap-jan 50fa6fba38 Let the phone delete a host, and move or remove a group
The desktop gained three things the phone did not follow: moving a group to
another vault, the second question asking whether a group's deletion takes its
hosts with it, and — since long before either — deleting a host at all. What
that left was a head whose v3 + can fill a keychain and whose editors can
correct one, with no way to empty either.

The commands could not simply be bound. DeleteGroup and MoveGroup aim at
GroupTarget, which is the selected card or the open group, and the phone has
neither: its list draws headings, and a heading's selection deliberately bounces
back to the host. Called bare on that head they would have returned having done
nothing — a DELETE that appears to have been pressed and has not. Both now take
the row and fall back to GroupTarget for the desktop's menu, and
ConfirmMoveGroupAsync resolves from the panel's own movingGroupId rather than
from the selection, which is also the honester answer on the desktop: what moves
is the shelf the panel was opened on.

The heading's pencil became a menu. Three icons after a chevron, a name, a vault
badge and a count is what would be left of the name at 360dp, so the ⋯ raises
the add sheet's shape carrying Edit, Move to another vault, a rule, and Delete —
the desktop's card menu, in the one idiom this screen already has. It does not
carry Open: the desktop's grid holds one level of the group tree and this list
holds all of it flattened, so there is nowhere to open a group into.

DELETE under a host sits on a row of its own beneath EDIT and MOVE rather than
beside them. A phone has no hover and no tooltip, so where a thumb lands is the
only thing separating a destructive control from an ordinary one. Both questions
take the controls that asked them — ShowsConnectControls, which is the phone's
half of the rule ShowsHostPaneActions already carries for the desktop's drawer —
so DELETE cannot be pressed a second time underneath its own confirmation.

Preferences gained the running version, and the sentence saying this head does
not replace itself and that no DodoSSH server will ever offer one. It reads
Updates.CurrentVersion off the same view model the desktop's UPDATES section
does, over the null channel that reports itself unsupported.

Nothing was needed for the realtime push: it is composed in ServerConnection,
which both heads use.

Seven tests, all phone-shaped — a group acted on with nothing selected, the menu
waved away leaving nothing armed, the ungrouped heading raising none, and the
bar's three states. The rectangles remain unmeasurable for the reason phase 8
gives; the checks for them are 8.10 to 8.13 and 13.6.
2026-08-04 19:58:10 +02:00
jaap-jan 8707629a6c Make the vault the thing you share, and ask a host which one it lives in
The teams screen listed teams that owned vaults, so sharing four servers with two
colleagues meant creating a team, then a vault inside it, then wrapping a key.
Two of those three steps are about a concept nobody arrives wanting. The screen
now lists vaults: naming one creates the membership list that carries it, named
after the vault and owned by you, and members, invitations, roles, hand-over and
key holders all hang off the vault they apply to.

Nothing on the server moved. VaultAccessService still resolves a shared vault
through team_membership and every membership call still names a team id — what
went is the requirement that anybody make one. The split the whole design rests
on is untouched and is still what the screen is built around: adding somebody
authorises the server to serve them, and only a machine holding the key can make
the vault readable. ADR 0009 keeps its decision and gains an addendum recording
which half of it a person is now asked about.

The one place the team resurfaces is a membership list carrying several vaults,
which this screen cannot produce and does not hide: the members section says so,
because "adding somebody here adds them there" is precisely the fact a
vault-shaped screen is in a position to conceal.

Two things left the interface and one arrived. Creating a team is gone, and so is
archiving one — it was only ever possible for a team owning no vaults, and a
screen whose rows are vaults has no row for one, so the button would have been
unreachable or always refused. The endpoint is unchanged and the screen states
the limit instead, since a vault cannot be deleted at all. The exception is a
create whose second call failed: cancelling that form archives the membership
list it left behind, which is a deliberate departure from this client's rule
against tidying up on the user's behalf, made because nothing else can reach it.

What arrived is PUT /api/v1/vaults/{id}. Without it the screen loses its only
editing action, since renaming the team behind a vault is invisible to everybody
who was never shown the team. It is gated on PermissionFlags.Admin — the line
UpdateTeamEndpoint already draws, because a name is what everybody in the vault
sees it called rather than part of its contents — and it renames the owning team
with it when that team carries nothing else, so the row an operator reads and the
name a user says cannot drift apart. The slug never moves, for the reason it does
not move on a team rename. The session edits its cached vault row rather than
replacing it with the response, which deliberately carries no wrapped key.

The host editor now asks which vault a host goes into, beside the name, while
adding and only where there is more than one vault to write to. It is a second
picker rather than the keychain screen's reused, and the two selections are
separate on purpose: that one is a standing preference about where new items go,
this is a field of the host in front of you, and binding both to one selection
would mean a click on the other screen could move a half-typed host. An existing
host is not offered it at all rather than offered it disabled — the two vaults
are encrypted under different keys, so moving an item is a delete and a retype.

That forced a fix worth naming. The group picker was built from the active
vault's groups whatever vault the host was being filed into, so a host put in a
shared vault could be filed under a group only its author can resolve — a
colleague would see it filed under nothing, which is the quietest kind of wrong.
Groups are now kept per vault and the picker follows the vault choice.

Two renames, because the pair they would otherwise have made is a bug farm:
ShellScreen.Vault became Keychain and VaultScreen became KeychainScreen, which is
what the rail has always labelled that screen, leaving Vault for one vault's
contents and Vaults for the vaults themselves. The enum values are unchanged;
NavRail.axaml writes them as x:Static literals.

1536 tests pass, seven more than before. Five are new on the server — the rename
endpoint's success, the team it does and does not take with it, the two refusals
and the empty name — and the client suite gains six and folds four together,
having lost the two about archiving a team.
2026-08-04 12:22:29 +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-janandClaude Opus 5 7e4e068aab Merge main into the teams branch
Two conflicts, and both were two people counting the same things differently
rather than disagreeing about what the code should do.

PhoneShell's header comment. The branch made "the five hub screens"
numberless, because TEAMS made it six and a number in that sentence had
already gone stale once. Main corrected "three destinations" to "two" in the
same sentence, because giving a shell the whole phone took the terminal out of
the set the header is drawn on. Both are right and neither noticed the other:
the header now stays on the hub's screens and on the two top-level
destinations, which is Hosts and Keychain.

The manual checks. Both sides appended a Phase 10 — main added the software
keyboard and the phone's terminal surface as 10 and 11, the branch added
Teams. Nothing about them overlaps, so the resolution is to keep all three in
the order they were written and renumber Teams to Phase 12, its subsections
and the one cross-reference inside 12.1 with it. Main's two phases keep the
numbers they already carry in its history, since renumbering those would move
headings somebody may already have linked to.

Everything else merged without a conflict, and the two places worth checking
afterwards both held: IsMoreSurface and the first case of
PhoneShell.OnBackRequested each kept ShellScreen.Team alongside main's edits.
Those two are one fact in two places, so a merge that dropped Team from either
would have trapped the user on the teams screen with the MORE tab dark.

Verified after resolving: solution builds with no errors and no new warnings,
the Android head builds, and every suite passes — App 214, Layout 73, Api 162,
Infrastructure 34, Contracts 25, Session 54. App gained the three shell-flow
tests main brought with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:40:27 +02:00
jaap-jan 80ae586fc4 Give a shell the whole phone, and one bar to leave it by
A connected phone was drawing five rows of chrome around the thing the user
opened it for. The vault header at 56, the terminal's own tab strip at 52, a
connection line at 36, the shells strip at 46 and the four-entry bottom bar at
64: at 360dp that is about a third of the display, and every row of it was
about somewhere the user was not. What replaces them is one 52-pixel bar drawn
by the surface itself — back on the left, the session pills, and a `+` across
from them — and then the terminal.

Three of those rows belong to `PhoneShell` and each is now bound on
`IsShowingPages`. That is the same question asked once rather than three
conditions that could drift: the surface is either a page or a terminal, and
these are the chrome a page has. The header needed a wrapper because Avalonia's
bindings have no "and" and it already had a condition of its own; the strip
needed one for the same reason. The bottom bar had none and is bound directly.

The back arrow goes to the page the terminal was opened over rather than to
Hosts by name, because the system back gesture already picks that and an arrow
landing somewhere else would be the second of two answers to one question. The
bar's `+` raises a sheet offering the three connections this application can
make — a shell, a host's files over SFTP, a bucket — since SFTP and S3 used to
be two taps through the bottom bar's MORE and the bar is not on screen here. A
control that replaced it and led to one of the three would have quietly removed
the other two.

Two things moved rather than being dropped. The text-size buttons are pinned at
the right-hand end of the accessory key row, outside its scroller: the
connection line existed to keep them from scrolling out of reach, and being
outside the scroller answers that argument rather than abandoning it. The
dialled address moved onto the connecting card, which is the moment it is worth
reading — what is being connected to, before anything has answered — and after
that the shell's own prompt says it more accurately than a header derived from
the keychain ever did.

The sheet collapses the renderer rather than covering it. Whether Android's
`WebView` composites above Avalonia content the way Win32's child window does is
still unverified — `docs/android-port.md` has said so since the port — so this
follows the desktop's palette and gives up the rectangle outright, which is
correct under either answer. It collapses `IsTerminalShowing` and not
`IsTerminalSurface`, because the bar the sheet was raised from is part of that
surface and dropping it would take the bar, the tabs and the whole arrangement
with it, leaving the sheet floating over the page underneath.

`OnSurfaceChanged` is the one place the flag is lowered, and that is the load-
bearing half. Every way out of a terminal ends there — a destination, the files
screen, the palette connecting to a host, closing the last tab, a lock — and
each of them would otherwise leave a sheet set over a page. Not merely untidy:
the flag holds the renderer blank, so the next return to the terminal would
draw the menu again over a rectangle kept blank by it. Opening is refused off
the terminal surface for the same reason from the other direction.

The back gesture gains a guard above the switch, in the shape of the editor
guard that arrived with the phone's `+`. It is nearer than any of them: with no
header and no bottom bar, while the menu is up that gesture is the only way off
it other than the scrim and CANCEL.

The bottom bar's Terminal entry lost its `IsCurrent` binding. The bar is
collapsed on that surface, so the binding could only ever be read as false, and
a rule about a state the control cannot be in is a claim that it can.

Three tests in `ShellFlowTests`, which is where shared state-machine behaviour
for this head goes: the collapse and its recovery, the refusal to open over a
page, and the sheet lowering both by a menu entry and by a route it was never
wired to. Everything visual needs a device, so it is phase 11 of
`docs/manual-checks.md` — and 11.2 is the check that would finally settle the
compositing question this head has carried as unverified since the port.
2026-08-03 14:33:16 +02:00
jaap-janandClaude Opus 5 a43286ece8 Let a team change hands, and be joined by somebody with no account yet
M3 built teams and stopped short of the two operations that decide who
controls one. Both were written down as refusals rather than omissions:
ADR 0009 listed ownership transfer under "deliberately not built", and
design-import-gaps said an invitation needed "a token with a lifetime and an
outbound mail path". One of those reasons had expired and the other never
applied — an invitation does not need a token if it is not a thing anybody
presents.

Handing a team over is one write. The member you name becomes owner and you
become an admin, in a single transaction, because ownership is sole: promoting
first leaves the team owned twice, demoting first leaves it owned by nobody,
and there is nobody left with the authority to finish a transfer that stopped
in the middle. That is also why it is not two calls to the role endpoint, which
refuses Owner outright. The outgoing owner is demoted rather than removed —
removing them would revoke their vault key grants and flag every team vault for
rekey, which is a far larger act than the one asked for, and somebody handing
over a team is usually staying in it. It unblocks the thing that was impossible
before: an owner can now leave, by handing the team on first.

An invitation is a standing instruction rather than a message. This server has
no outbound mail path, so nothing is sent and there is nothing for the invitee
to present. The row says the next account signing in with that address joins
this team at this role, and telling them to sign in is the caller's job over a
channel this server does not carry. A link nobody can deliver would be worse
than none. It lives in its own table rather than becoming a membership with
MembershipStatus.Invited, and that member stays unwritten for the reason it
always was: team_membership.user_id is not nullable and carries a foreign key,
so somebody who has never signed in has nothing for that row to point at.
Widening it would make the unique index on (team, user) meaningless, because
PostgreSQL counts every NULL as distinct.

Verification is the security boundary, and nothing in this server read it
before. A claim requires the access token to assert email_verified. An
invitation decides what the server will serve, so one claimable by anybody able
to obtain a token carrying somebody else's address is a way into a team — which
is precisely the attack OidcOptions.AllowEmailLinking exists to refuse, and it
would have been reintroduced by the back door. There is deliberately no setting
that relaxes it: a flag that exists is one somebody turns on for the afternoon
their provider is misconfigured. Absence is refused rather than trusted, and
logged, because a provider that never sends the claim otherwise leaves every
invitation pending with nothing anywhere saying why.

Claiming happens at just-in-time provisioning and again on an hourly sweep. The
sweep is what makes it recoverable rather than one-shot — an invitation issued
between an account being created and that person next signing in would
otherwise be stranded for ever — and it shares its rate with the last-seen
write because both are housekeeping nobody is waiting on.

Archiving is refused while a team owns a vault, and that refusal is the end of
the road rather than a step on it. A team vault is readable because of
membership, so archiving one that still owned vaults would take them away from
everybody holding a key, including the caller, quietly and all at once. Nothing
in this product deletes a vault, so no order of operations gets past it today —
which is stated with a count of what is in the way, for the reason the SFTP
layer refuses a recursive delete: a refusal is visible and a quiet removal is
not. It is owner-only, as handing over is; renaming is not, because a rename is
visible to everybody and reversible by anybody who can do it. The slug is not
renameable at all: it is unique only among live teams, so a rename could take
one an archived team is still holding, and that team could then never be
restored.

LAST ACTIVE is real and coarse on purpose. UserAccount.LastSeenAtUtc is
refreshed on ordinary authenticated requests, at most once per account per
hour, through ExecuteUpdateAsync — user_account carries the xmin concurrency
token, so a read-then-write on the hot path would start losing races between
one user's own overlapping requests. An hour is the granularity the question is
actually asked at, and the interface draws it to the day rather than the minute
so it does not read as a precision that is not there. The remarks in Contracts
and in the view model that argued at length for the column's absence are
rewritten rather than extended; both had become false.

Two endpoints already existed and nothing called them. ChangeTeamMemberRole and
ListVaultGrants have been reachable since M3. The role picker refuses Owner
itself rather than letting the server do it, since the interface already knew
the rule; the key-holder list sits under the vault rather than beside the
member, because a grant is per vault and a count on a member row would imply
per-item sharing, which is M5. It lists withdrawn and stale grants and says
which they are — a list that dropped them would show a departed colleague as
merely absent rather than as somebody whose key was taken away — and staleness
is decided by comparing generations, since a grant can be Active and still open
nothing.

ADD MEMBER stopped being a dead end. An address the directory did not know used
to end at a sentence telling the user their colleague had to sign in first. It
invites them instead, from the same button, because which of the two applies is
a fact about the server's account table rather than about what the user is
doing; which one happened is reported afterwards, because that decides what
they do next. An address that merely has an account is invited rather than
refused: refusing would have made the endpoint an oracle for which addresses
have accounts here, answerable by anybody willing to create a team first.

The phone has a TEAMS screen, behind MORE, and it is the reverse of every other
row in design-import-gaps: a shipped screen the design had no slot for. It is
there because an invitation is claimed by signing in, so somebody told they are
now in a team is at least as likely to be holding a phone — and a membership
visible only on a head they never installed is one they cannot see. It draws
SHARE KEY and nothing that takes something away: wrapping a key is the one act
on that screen a server cannot perform at all, and the desktop guards its
revocations with a tooltip, which is a control a touch screen cannot show.

Two defects were found by an adversarial pass and both were green against the
whole suite at the time. The owner-only check on archiving and handing over had
been weakened to the admin check while their messages and comments still said
owner — and since nothing behind the archive endpoint re-checks it, an admin
the owner had promoted could have archived the team out from under them. And
the rename endpoint built its response with a hardcoded Owner role, so an admin
who renamed a team was handed a summary claiming they owned it, and a client
trusting that instead of re-listing would have offered them the two owner-only
buttons the server then refuses.

The new table gets its constraints tested rather than merely migrated: live
uniqueness per (team, address), the citext proof that an address typed by a
person matches one cased by a provider, and reissue after both revocation and
acceptance. The teams screen gets its first entries in the layout suite, at the
minimum window with every list populated and with each of the two states that
cover half of it — it had none, and it just grew four sections and a second
line in the member row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:31:43 +02:00
jaap-jan ce86a4ff72 Look the panel up, because the field the generator declares is never filled
ci / api image (push) Successful in 34s
ci / build and test (push) Successful in 1m17s
ci / android head (push) Failing after 5s
The last commit crashed the phone before its first frame. `PhoneShell` took the
`Body` field that Avalonia's name generator declares for `x:Name`, and that
field is assigned in the generated `InitializeComponent` — which no view on
this head calls, all of them loading their XAML directly instead. It compiles,
it is null at run time, and this control dereferences it in its constructor.

`TerminalScreen` has been looking its two named controls up with `FindControl`
since it was written, for exactly this reason. This does the same, and the
remark on the field now says why so the next person reaching for the shorter
spelling finds the answer before the device does.

Confirmed rather than guessed: the generator's output for this control is one
method, and the assignment is inside it.
2026-08-03 14:01:07 +02:00
jaap-jan 35387b1c9d Tell the phone's keyboard these are secrets, and get it off the box
ci / api image (push) Successful in 21s
ci / build and test (push) Successful in 1m22s
ci / android head (push) Failing after 5s
Five boxes on this head take a secret and every one of them was drawing dots
and saying nothing. `PasswordChar` is a screen property: Windows has no opinion
about what is being typed into a text box, so the desktop head needs nothing
more. Android's software keyboard has an opinion, and left at its default it
read a vault passphrase as prose — completions offered in the suggestion strip
above the box, and the passphrase itself learned into the IME's dictionary.
Dots on screen with a word bar over them is the worst of both: hidden from the
person typing it and offered to the room. `TextInputOptions.ContentType` is the
property the Android backend maps onto `InputType`, and it is what turns both
off. Both attributes now live in one `TextBox.secret` class rather than being
repeated per box, because they are two halves of one fact and the next box
added would have got one of them.

The keyboard also went on covering whichever box had raised it. That is in
`PhoneShell` rather than on each screen: everything the phone draws is inside
its one root panel, so a bottom margin shortens all eleven screens at once, and
a screen added later cannot forget to handle something it never had to know
about.

Two mechanisms, and it matters that neither is a backstop for the other. Before
Android 15 the activity now declares `AdjustResize` and the platform shortens
the window itself; left unspecified Android chooses, and what it chooses for a
window whose entire content is one native view — which is what an Avalonia
surface is — is to pan, sliding the window by however much it thinks the
focused native view needs and leaving the box exactly where it was. That was
the bug. From Android 15 the attribute is ignored, edge-to-edge being enforced
and the window no longer resized for the keyboard at all, and the reported
inset is what there is. Each is dead where the other applies — where the window
resizes, the inset arrives already consumed and measures zero — which is why
the margin comes from the inset alone. Both added together would strand the
interface an entire keyboard above the keyboard.

Scrolling the box back into view keys off the size change rather than off
either mechanism. `ScrollViewer` already brings a newly focused child into
view; what it cannot know is that the visible region shrank after the focus,
and both ways of losing that region end in the same resize.

None of it is reachable by a test. The software keyboard is an inset the
platform reports and a headless top level reports none, so phase 10 of
`docs/manual-checks.md` is the whole of the verification — including the note
to run it on one device each side of Android 15, since a build exercised on
only one of the two will look correct and be half broken.
2026-08-03 13:55:08 +02:00
jaap-janandClaude Opus 5 4925dcf179 Give the phone the + it was promised, and the editors behind it
Steps 5 and 6 of docs/adding-hosts-on-the-phone.md, which finishes it. The
phone can now put things in a keychain rather than only read one.

The floating + arrives three designs after it was first asked for. It was
refused twice on the honest grounds that it would open an editor this head had
not got — and that stopped being true when steps 1 to 4 landed. It raises a
sheet rather than a form, because "add" on this screen has been two operations
since groups existed: a host, or a group to file hosts under.

Both editors are cards in the list's own row, swapped for the list rather than
stacked over it, following SnippetsScreen and for the reason written there: a
form on top of the list hides what it is about. There is still no dialog and no
editor screen anywhere on this head. The port and username boxes may be left
empty, and the greyed text inside each is what the host will actually use —
following the group picker as it moves, so the form says what leaving a box
blank will do rather than making it a guess.

The back gesture gains a guard above the switch rather than a case inside it.
An editor is not a screen and has no entry there, and it is strictly nearer:
the sheet sits over the editor's own screen, so back lowers whatever is topmost
before it considers moving between screens.

A group editor needs a way in, and the phone draws no groups panel — so the
heading in the host list gains a pencil. A + that adds groups with no way to
correct one is the same strange thing to ship as one that adds hosts with no
way to correct one, and worse: a group's defaults are what every host beneath
it falls back to.

Scouting step 6 turned up a bug worth more than the prose it was looking for.
ReloadGroupsAsync read the active vault alone, which was a cosmetic limitation
while a group carried only a name — a teammate's host showed under UNGROUPED
and nothing else was lost. Since a group began lending a port, a username and a
binding, the same omission silently dropped all three: that host would dial 22
as nobody while the machine is on 2222 as deploy, with nothing on screen saying
why. The resolution map now spans every readable vault. The editable list stays
narrow, because a row shown across vaults needs a vault id for rename and
delete and two vaults may hold groups with one name — but the map needs none of
that, since it is only ever asked what an id says.

An adversarial review of this change found something I had written a comment
denying. The + was a child of the outer Panel rather than of the list's row, so
it anchored to the bottom of the screen and sat squarely over the new EDIT
button and the lower edge of CONNECT — and because a Panel hit-tests its
last-declared child first, tapping the right end of EDIT would have raised the
add sheet. The comment beside it claimed it was in the list's row precisely so
that could not happen, and manual-checks 8.1 names that exact failure. It is in
Grid.Row 2 now. The same pass caught a connect bar that was disabled rather
than hidden while its own comment said hidden, a scrim that would have flashed
its default pressed chrome across the screen, a fab comment claiming an accent
fill no other button had when Button.primary has it, and a back-gesture remark
naming two view-model properties that do not exist anywhere in the repo.

That last one came from this plan, which invented IsVaultsSurface and
IsConnectionsSurface, and pointed step 6 at a ConnectionsScreen.axaml that has
never existed. Both are corrected in the plan rather than quietly worked
around, along with a fourth "groups are flat" site in README.md that step 1
missed.

The phone's rectangles are not measurable and structurally never will be: the
layout suite is net10.0, the Android head is net10.0-android, and Avalonia's
application is a one-shot process global — so a second head cannot share the
process even if the reference were possible. Everything the sheet and both
editors bind to is shared and is tested headlessly here; the pixels go to
docs/manual-checks.md phase 8, nine checks, which is where this project already
sends what it cannot assert.

Tags remain the one thing asked for that did not ship. Tag is a full item kind
and TagIds merges, encodes and resolves, but no screen draws a chip or offers
to add one, so the tags a client can store are ones nothing here can see. Both
editors carry the set through a save untouched so a client that can set them
does not lose them. Recorded as half-shipped in docs/design-import-gaps.md
rather than quietly left out.

Verified by the whole suite: 1402 tests over nineteen projects, none failing.
Both heads build. Nothing seen on a display — phase 8 is what that costs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 11:30:00 +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
jaap-jan fe9d7fc289 Give DodoSSH a phone, and a shared shell for both heads to drive
The Android head from docs/android-port.md, taken as far as its step 6.

Step 3, the spike, is answered and its throwaway screen is gone: libsodium.so and
libe_sqlite3.so are both in the arm64 APK, so NSec resolves its native half on Android
despite shipping no Android build, and the local cache opens. Two findings the audit
could not have had: Avalonia.Controls.WebView only ships net10.0-android36.0, which
settles the open "which Android versions" question at targetSdk 36; and Android has
blocked cleartext HTTP since API 28, so the terminal renderer needs a network security
config scoped to 127.0.0.1 or the WebView loads nothing.

DodoSSH.Client.Shell is new and is why the phone can exist: the view models, the terminal
renderer files and the palette moved there so both heads drive one state machine and draw
from one set of tokens. The desktop head is otherwise untouched and its 144 tests still
pass.

The platform pieces behind interfaces that already existed: the profile directory from
filesDir, a device key wrapped by a StrongBox-backed key that a fingerprint releases, and
a foreground service so a shell outliving a vault lock stays true on a platform that
stops backgrounded processes.

Sign-in is deliberately absent rather than approximated. It needs an app link, because
reusing the desktop loopback listener is the attack RFC 8252 section 8.3 names.
2026-07-31 20:58:48 +02:00