Commit Graph
11 Commits
Author SHA1 Message Date
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-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 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-janandClaude Opus 5 c00e5dbc5c Let the terminal's text be made bigger, and remember how big
ci / build and test (push) Successful in 1m12s
ci / android head (push) Failing after 4s
ci / api image (push) Successful in 24s
Taking pinch-zoom off the phone left nothing in its place, and there was nothing on the desktop
either. This is the replacement, and it is deliberately not the thing that was removed: zoom scales
what has already been drawn, so the remote goes on wrapping to a width that is no longer on screen.
Changing the font size refits the grid and reports the new column count, so the far end is told it
has fewer columns. That round trip is the feature.

The size is one number, owned by the shell. It has to be, for two reasons that pull the same way: it
must survive a relaunch, and it must be reachable from a phone that has no Ctrl key to press. So the
page asks and the host decides — a signed step over a new client opcode, answered with a size over a
new server opcode. The phone's buttons and the desktop's chords arrive at the same place, and a size
set by either is the size both remember.

Stored in settings.json beside the cache rather than in it, and that is not laziness about a
migration. The cache is encrypted and unreadable until a vault is unlocked, and the first terminal of
a locked launch needs the size already. Nothing secret may go in that file; ClientSettings says so
out loud, because the next person to add a preference is the one who needs to read it.

Where it is reachable from differs per head, and only here. The phone gets A− and A+ on the
connection line — not in the accessory row, which scrolls, and a control that fixes unreadable text
must never be the thing that is off-screen. The desktop gets the three chords every terminal
emulator has, answered by the page while a terminal has focus and by the window when it does not,
plus a row in preferences that shows the current value and names the chords rather than replacing
them. Someone whose terminal is too small to read is not in a position to go looking.

Clamped 8 to 32. Below eight a monospace grid stops being legible and becomes a texture, and every
column of it is still a column the remote is being told exists; above thirty-two a phone in portrait
has too few columns to hold a prompt. The buttons disable at the ends rather than accepting presses
that do nothing, which on a terminal reads as the application having stopped responding.

The preferences screen's header comment claimed none of the design's terminal settings could be
saved, and listed the three things that were missing to make one work. All three now exist, so it
says which one is real and why the other five still are not.

Verified with the protocol suite — including that the step byte round-trips signed, since read
unsigned a step down arrives as 255 and clamps to the largest font, making "smaller" do the most
dramatic available version of "larger" — a data-plane test that the chord is heard with no session
registered, and five shell tests: the default matches the renderer's, both clamps hold, reset works,
and a size chosen in one shell is there in a second one over the same profile directory. Layout
suite and both heads build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 13:15:11 +02:00
jaap-janandClaude Opus 5 5cb9ffaf9d Stop the phone's terminal being a page you can pinch
ci / build and test (push) Successful in 1m3s
ci / android head (push) Failing after 4s
ci / api image (push) Successful in 36s
The terminal on Android could be pinch-zoomed and double-tap zoomed, which a terminal must not
be: the grid is sized to the window by the fit addon and the column count is told to the remote,
so zooming makes the visible width disagree with what the far end is wrapping to, and puts the
cell under your thumb somewhere other than where you tapped.

Three things, and the first is the one that made it look worst. The page had no viewport meta tag
at all. An Android WebView with no viewport lays out at a notional 980 CSS pixels and scales the
result down to fit, so a terminal built to fill the window was drawn small and then offered as
something to zoom around in. width=device-width makes one CSS pixel one layout pixel, which is
what the fit addon has been assuming all along and what a desktop WebView gives without being
asked.

Second, the gestures. touch-action: pan-y keeps the only one a terminal wants — dragging the
scrollback — and refuses pinch-zoom and the double-tap zoom that fired on every attempt to place
a cursor. text-size-adjust stops Android's own font inflation, which resizes text it judges too
small without telling the page and leaves the characters no longer matching the grid that was
measured.

Third, the knob that actually disables zoom: BuiltInZoomControls on the WebView. user-scalable=no
is in the viewport tag for completeness and does nothing on its own — Blink has ignored it since
Chrome 48 and WebView follows Blink. That is worth knowing before somebody removes the C# and
trusts the meta tag.

The page and stylesheet are shared with the desktop head, deliberately, and none of it costs
anything there: a desktop WebView already lays out at device width, and a Windows touchscreen
should not be pinch-zooming a terminal either.

This does not settle whether the phone should keep a browser-based terminal at all, which is the
question actually asked. It does remove the reason it was asked, and every complaint about this
renderer so far has turned out to be configuration rather than the approach.

Verified by building the head in Debug and Release and by the desktop layout suite, which draws
the same assets. Not verified on a device — nothing in this head is; see docs/android-port.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:45:11 +02:00
jaap-janandClaude Opus 5 a0d53b0c9d Give the phone its screens back by not stealing a data context
Signing in on the phone reached an unlocked shell showing PREFERENCES over the middle of the
screen, with a bottom bar whose four buttons did nothing. The buttons were fine. Every one
of them changed the shell's screen exactly as asked, and nothing moved, because an opaque
panel was sitting on top of the whole page area and never came down.

PendingScreen set DataContext = this in its constructor, so that Heading and Detail could be
written as plain bindings inside its own XAML. That is not a private arrangement: a binding
the parent writes ON one of these — IsVisible="{Binding IsPreferencesShowing}" in
PhoneShell — resolves against this control's data context, which was no longer the shell.
The binding looked for a shell property on a PendingScreen, found nothing, and left
IsVisible at its default. Its default is true. So the panel that says "this screen is not
built yet" was permanently visible, last in the Panel and therefore on top of the host list
and the keychain both — and the terminal underneath them.

The two properties are read with $parent now and the control inherits its context like every
other screen. There is no x:DataType on it any more either, so a plain binding here is a
compile error rather than a silently missing screen.

The desktop head has the other half of this lesson written down already: MainWindow gives the
terminal's IsVisible a FallbackValue precisely because an unresolved visibility binding does
not hide anything, it shows everything. That note was about the previewer. This is what it
looks like at runtime.

Verified by reproducing the mechanism rather than by reasoning about it: a control that owns
its data context ignores a parent's IsVisible binding and stays visible; one that inherits
obeys it. The head builds in Debug and Release. Nothing has been run on a device, as ever —
see docs/android-port.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:35:04 +02:00
jaap-jan 4300d917a8 Stop making people wait for a handshake, and give the host list a pointer
ci / build and test (push) Failing after 3s
ci / android head (push) Failing after 2s
Connecting held the vault's busy gate, which meant a window that did nothing visible for
as long as a machine took to answer — and against one that is merely asleep, that is the
whole timeout. The gate is gone from that one command. A tab now appears in the strip in
the same turn as the click, carrying "connecting…" rather than a pane, and the terminal's
rectangle draws a card naming the host and the address being dialled. Every other screen
stays usable, and two connections can be in flight at once.

That splits the vault's one connection event into three, carrying an attempt id, because
"which tab is this about" can no longer be answered by "the most recent one". The id also
buys the two kinds of not-connecting their different endings: a refusal stays in the strip
as a tab holding its reason, since by then the user is quite likely three screens away and
a status line they are not looking at is not where a failure should end; a host key
question takes the tab away and puts the window back on HOSTS, because the prompt is drawn
there and a tab claiming failure would be competing with the thing about to resume it.

ConnectAsync takes no CancellationToken any more, and that is load-bearing rather than
tidying. A [RelayCommand] over a method that takes one generates a command that cancels
the previous execution's token on every invocation — so asking for a second machine
silently abandoned the first, measured as the first tab disappearing with "Cancelled." the
instant the second was asked for. Giving up on a connection is closing its tab, and a
session that lands after that is adopted rather than dropped: a shell running with nothing
naming it cannot be closed at all.

A tab is marked active on IsShowing rather than IsSelected. The selection survives
navigating away — that is what makes the strip a way back to a terminal instead of a way
to lose one — so a tab lit while preferences filled the window was a second "you are here"
mark pointing at something nobody could see. The nav rail's own entries have always made
this distinction.

The host list grows the two gestures it looked like it already had. A right click selects
the row under the pointer before opening a menu of Connect, Edit and Delete — the menu is
on the list rather than in the item template, so its entries are the vault's own commands
and not a row's, and it is cancelled outright over a group heading. Dragging a host onto a
heading files it there, onto a host files it beside that one, and onto UNGROUPED takes it
out of a group; the write is one field of one host through the same repository a save
uses, refused while the editor is open because a drop is a gesture on the list and not on
a half-typed form.

Clicking a result in the palette connects, which is what a list of hosts under a search
box looks like it does. It went through the shell's own command, so the pointer and Enter
take one path.

And the files screen's two pickers followed the vault's lists once, at unlock: a host or a
bucket created afterwards could not be picked until the keychain had been locked and
opened again, with nothing on screen explaining why the machine plainly in the host list
was missing. They follow the collections now, re-finding the selection by id across the
rebuild a sync pass causes every minute.

165 shell tests and 69 layout tests green, including the connecting tab, both failure
endings, two connections at once, a connection in flight across a lock, and the right
click acting on the row under the pointer rather than on the selection. The drag itself is
in docs/manual-checks.md with the rest of phase 7 — headless Avalonia has no platform
drag, and a test that claimed to have dropped something would pass while confirming
nothing.
2026-07-31 22:59:33 +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 81e7e6d939 Write down what the phone found, and stop it rotting
docs/android-port.md was an audit of work not started; it now says what is built. Three of
its statements needed correcting rather than extending, and they are marked where they sit:
the Android version question is settled and was never as open as it looked, because
Avalonia.Controls.WebView ships only a net10.0-android36.0 assembly and nothing lower can
resolve it; cleartext to loopback has to be permitted explicitly, which the audit missed
entirely; and the spike produced a structural change it did not anticipate, in
DodoSSH.Client.Shell.

A CI job of its own, because the head is deliberately not in DodoSSH.slnx and a project
outside the solution is a project nobody notices breaking. It packages as well as builds:
a native library with no Android ABI and an assembly that will not dex are both invisible
to a compile, and both are exactly what this head is exposed to.

The README says plainly that signing in is not built, that a fingerprint re-enrolment
destroys the device key, that a notification appears while a shell is open, and that none
of it has run on a device.
2026-07-31 21:09:42 +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