Commit Graph
35 Commits
Author SHA1 Message Date
jaap-jan 8a77b7ca68 Say how far a connection has got while it is still being made
ci / build and test (pull_request) Failing after 2m34s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Skipped
ci / android head (pull_request) Successful in 3m28s
The connecting card set its status string once, when the tab was created, and
never touched it again. Every connection therefore looked identical from the
outside: one three seconds into a key exchange, one waiting out a fifteen-second
timeout against a machine that is asleep, and one that had hung all drew the
same "connecting…". The card now draws the five steps of getting there, each lit
at the moment the handshake reports reaching it, over an amber track that fills
as they finish.

◆ NOTHING ON THE LIST IS INVENTED. Every row changes state because a layer below
it said so, at the instant the thing it names actually began.

That is the whole reason it is worth showing, and it is why most of this commit
is plumbing rather than XAML: there was no progress reporting anywhere in the
stack to hook a step list onto, and a card animating plausible progress would
have been indistinguishable from one that had stopped receiving any.

SshConnectionPhase names four phases and deliberately not more. SSH.NET runs the
entire handshake inside one ConnectAsync and raises exactly one event from the
middle of it — HostKeyReceived, once the key exchange has produced a key to show
— so that event is the only interior moment there is to report. Everything
before it is Reaching and everything after it is Authenticating. A fifth phase
in that assembly would have to be a timer, so there is not one. OpeningShell is
reported by TerminalWorkspace instead, because that is where it happens: the
factory's work ends with an authenticated connection, and asking for a
pseudo-terminal on one is a separate round trip. The SFTP path passes null — a
second connection opened behind an already-open shell has nobody watching a step
list for it.

The card's fifth step, "Starting the terminal", is the renderer wait and lives
in the shell rather than in the SSH assembly, which has never heard of a
renderer. On the first connection after a cold start it is a real wait with a
real failure mode of its own — a missing WebView2 runtime — so a list that began
at "reaching the host" would leave the one wait most likely to hang unnamed.

Amber for the step in flight, and that follows the palette's rule rather than
bending it. Green is what is true and purple is what you can press; a step still
happening is neither, and it is exactly the caveat-worth-reading that amber
exists for. Steps behind it go green as they become true. Nothing animates,
which is the argument TransfersScreen.axaml already makes for its own track,
reaching a screen with far more reason to want a spinner: a spinner is furniture
invented to fill a state nobody measured, and these states are measured, so the
track fills to what has finished and then waits there.

A refusal keeps the step it stopped on, in red, with the ones behind it still
green. That is the half a progress bar could not do, and it is the difference
between "that host is not there" and "that host is there and would not have me"
— a question the reason sentence alone frequently does not settle.

The strip's dot goes amber while a tab is connecting, on both heads. It was
grey, and so is a tab whose shell has exited: the two states in that strip with
the least in common, one worth waiting for and one over. PhoneShell's own
comment already recorded half of this — the dot stopped being green before
anything had answered — and this is the other half.

Progress is raised inline rather than through System.Progress<T>, which captures
whatever synchronisation context it was constructed on and posts to it. That
reads like a convenience and is really a second place the marshalling decision
gets made: silently, differently under a test with no context, and out of order
with respect to the failure that follows a phase. The shell marshals once, in
one handler, through a new optional post parameter on MainWindowViewModel — the
same seam TransfersViewModel already uses, and for the reason its own remark
gives. The three Dispatcher.UIThread.Post calls that predate it are the ones
this suite's comments record as out of reach; they are left alone rather than
swept in here.

Both heads draw the list. They differ in one place: Phone.axaml's mono class
sets a colour and a size along with the family, so the caption rule names its
own family instead of composing the two and asking two rules for one Foreground.
The desktop's mono sets the family alone, which is why ConnectingCard does
compose them. Each head also gains SHOW LOGS beside the button that gives up —
the step list is this attempt and the log is every other one, which is what a
connection taking too long actually raises.

Seven tests, and the two that matter most run against the container rather than
a fake: a real handshake reports its phases in order, and a host-key refusal
never claims to have authenticated. A fake asserting what it was written to
assert would have established nothing about either. The rest cover the tab
advancing while the connection is gated, the step a refusal stops on, and a
phase reported after the user has given up on the tab. 1,861 tests, none
failing.

The Android head's layout is not verified by anything. It compiles, and
compiled bindings mean every new binding path resolves, but that project is not
in DodoSSH.slnx, there is no test project for it and no device here — so unlike
the desktop card, whose shapes the layout harness measures, these rows have not
been drawn. Vertical fit is reasoned, not observed.
2026-08-10 15:47:45 +02:00
jaap-jan e936ab4646 Announce a session's end when it is actually over, and for closes too
ci / android head (push) Successful in 3m17s
ci / desktop nightly (push) Successful in 41s
ci / build and test (push) Successful in 2m27s
ci / api image (push) Successful in 28s
The phone's notification kept saying '1 shell connected' after the
shell was gone, and both close routes were at fault. A shell exiting on
its own raised SessionEnded from inside its run's finally block — where
the run task is by definition not yet complete, so the LiveSessionCount
the keep-alive reads still counted the dead shell, and nothing fired
later to correct it. A tab closed by hand announced nothing at all, by
a recorded decision that assumed every subscriber was the closer; the
keep-alive is not, and a close it never heard about left the
notification claiming a shell over nothing.

The end is now announced from a continuation after the run completes,
and CloseSessionAsync announces after its own drain — every subscriber
was already a reconcile-to-reality handler, so the echo the old remark
feared costs nothing. Shutdown stays silent: it is dismantling the
subscribers along with the sessions.
2026-08-09 13:01:14 +02:00
jaap-jan aaff81272a Teach the page and the shell to put a reattached view back together
The page's socket now retries itself forever with backoff — a dropped
socket is an ordinary event on a phone, not the end of the terminal's
life — and createSession is idempotent, so a replay landing on a pane
that survived changes nothing. A replay creating a pane that did not
survive writes one dim line saying the earlier output stayed on the
host, because that is the truth about a reloaded page's scrollback.

The shell answers RendererReattached with the two things only it owns:
the font size, and which tab is active.
2026-08-09 10:54:38 +02:00
jaap-jan 9d5ff9f23a Draw what authenticated, and over what, on the session status bar
ci / build and test (push) Failing after 2m20s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 3m27s
The v5b design's own row: the negotiated cipher, then the host key's algorithm
and the name of the key or credential that authenticated, as one mono run
beside CONNECTED — on both surfaces, off MainWindowViewModel's surface-aware
SessionCipher and SessionIdentityText, the same shape SessionAddress set.

The identity's name comes out of TryBuildAuthentication, the one resolution
point that always had it in scope and always threw it away; it rides
HostAuthentication to the tab and to the SFTP connect alike. Three deviations,
recorded in the gaps doc: the algorithm prints as negotiated rather than
shortened, the run is plain text because no pin-details modal exists for an
open session, and a typed password shows the algorithm alone — there is no
item behind the dot. A dead terminal tab keeps its facts for the scrollback
still on screen; an SFTP disconnect, with no scrollback, clears them.
2026-08-08 20:54:56 +02:00
jaap-jan c8507b44fe Give the application a settings area built from what really exists 2026-08-08 14:17:19 +02:00
jaap-jan 43c939b697 Give the window its v5b chrome and each session surface its own shell 2026-08-08 00:49:59 +02:00
jaap-jan 2ba7c14e35 Restyle the drawer, pin folders on a host, and say when it was last connected 2026-08-07 18:12:31 +02:00
jaap-jan 4f9faa2fe3 Ask about a host key where the connection was made, not on the host list
The trust prompt was two banners at the top of the desktop's hosts screen, so the shell navigated there
before letting a handshake raise one: Screen = Hosts, Surface = Page, in OnVaultConnectionFailed and again in
the palette's own connect. The reason was sound — a connection can be started from Ctrl+K on any screen, and
a question behind whatever somebody is looking at is a question nobody can answer — and it was answered the
wrong way round. Rather than making the decision reachable from where the user is, it moved the user to where
the decision was, and charged every screen for it.

It is worst for the one connection that has no host at all. A machine typed into the phone's connect box is
deliberately in no keychain, so a first contact from there judged it on a list it does not appear on, after
taking the box that dialled it away.

So both heads now draw the decision over the surface. HostKeyCard is the desktop's, and is the counterpart of
the phone's HostKeySheet: a scrim with no press handler, because a question with two named answers must not be
answerable by missing; the unknown key offering TRUST AND CONNECT, because judging a fingerprint against what
an operator published is a decision a person is entitled to make and the only moment they can make it; and the
changed key offering no way forward at all, because a button beside that warning is "continue anyway" with two
clicks instead of one. The phone needed no new markup — its sheet was already a shell-level overlay, so
deleting the navigation is what puts it over the Connections screen.

IsHostKeyDecisionShowing is on the shell rather than on a screen because the answer decides an occlusion. A
second connection can be refused while a first one is open, so this card is routinely raised over a live
terminal, and that rectangle is a native child window: layered over it the card would be sliced at its left
edge with TRUST AND CONNECT taking no clicks, which for the most safety-critical question in the product is
the worst place for that class of bug to land. IsTerminalShowing gives the rectangle up instead.

The banners are gone rather than copied. One prompt in two markups is two copies of the most safety-critical
wording here, and the second is the one that goes stale.

TWO DEFECTS FOUND BEHIND IT.

VaultViewModel.RejectHostKey cleared only the pending key and never the mismatch, so the changed-key refusal
had no working exit. That was invisible for as long as it was a banner nothing was drawn over — nothing was
trapped, and the next attempt cleared it — and it was already live on the phone, where that refusal is an
opaque full-screen panel whose one button runs this command: pressing it left the panel up over every screen
the user went to next, including the host editor the panel tells them to open. TransfersViewModel.RejectHostKey
has always cleared both; the vault's was the outlier. Its button said BACK TO HOSTS, which was wrong twice
over, and now says BACK.

And an assertion written for this change could not fail: the palette test asserted the renderer was collapsed
in a scenario whose only tab had just been removed, so it was collapsed for want of a session whatever the
occlusion rule said. It is gone, with a note pointing at the test that can fail on it.

VERIFIED. 1580 tests, build clean, no new warnings, format clean. Three mutations each seen to fail and then
seen green again: dropping !IsHostKeyDecisionShowing from IsTerminalShowing, caught by
AChangedHostKey_CollapsesTheTerminalItIsRefusedOver; reverting RejectHostKey to clear one flag, caught by
RefusingAHostKeyDecision_TakesItOffTheScreen(false) and by that same test; and dropping the two host-key arms
from OnVaultPropertyChanged, caught by TheHostKeyDecision_IsAnnouncedToTheWindowWhenItArrivesAndWhenItGoes.

That last one is the first test in this repository to watch PropertyChanged, and it is worth being the first:
every other assertion about the flag reads it directly, and a direct read passes with the subscription
deleted — while the card would never go away.

The two layout tests moved with the prompts, from the hosts screen to the card. manual-checks gains 7.4a for
the occlusion, 7.4b for getting out of a refusal and 11.7a for the hand-typed case, none of which a test can
see; 1.5 and 7.4 are corrected rather than left describing a window that no longer moves.

ONE ROUGH EDGE, DELIBERATELY LEFT. On the desktop, refusing a first contact whose tab was the only one leaves
the terminal surface with no tabs — a blank rectangle under the strip's "no terminals open · press + or
Ctrl+K", which is the one sentence near that rectangle Avalonia can draw. The alternative was falling back to
the page, and on the phone that means the host list, which is the bug this commit is about. A desktop connect
page would close it properly.
2026-08-06 12:25:37 +02:00
jaap-jan c882fa0cd3 Give the phone a selection instead of a card under the list
A long press on a host raised a connect card over the bottom of the list: a
password box, CONNECT, EDIT, MOVE and DELETE. It was the right idea in the wrong
place. It covered rows, it had room for five things and never a sixth, and every
one of them was about exactly one machine — so filing eleven imported hosts under
a group was eleven trips through a form, and there was nowhere to put a sixth
action if anybody wanted one.

A long press now chooses the host it landed on, and the actions move into a bar
across the top of the screen, in the vault header's place rather than beside it.
That is where Android has put them since contextual action bars existed, and it
is the one strip a list can never grow into — but the real reason for it is that
while it is up the screen is unambiguously about the ticked hosts and nothing
else, which is what lets the count in the middle of it mean something. Left to
right: the cross that leaves the mode, the count, the pencil, and a ⋯ holding
Connect, Connect via SFTP, Move to vault, Copy to vault, Change group, Duplicate
and Remove.

A tap still connects and still raises nothing. Once anything is ticked it ticks
and unticks instead, which is what every Android list does and is not merely a
convention worth following: a tap that connected while five machines sat ticked
would open a terminal on top of a selection somebody was halfway through
building. Unticking the last host leaves the mode, so there are two ways out of
it and the cross is only one of them.

Both gestures now read the row from the element under the finger rather than from
the list's selection, and that is a correctness change rather than tidying. A tap
on a group heading moves the selection and the view model bounces it straight back
to whichever host was chosen before — which answered "a host, or nothing" for free
while a tap only ever connected. It stops answering it the moment a tap can tick
one: the heading would tick a machine the user was not pointing at, into a set
they are about to delete.

Three of the seven entries are about one machine and are drawn only for one. A
terminal, a file-transfer session and a form each have no reading over six, so
they are collapsed rather than refused. The other four read better for a count
than without one — it is the reason the set exists — and each of them says
afterwards how many hosts it wrote and how many it left alone. Skipping beats
refusing the whole run: a selection of eleven with one read-only row would
otherwise do nothing at all and then report about the wrong ten.

Copy to vault and Duplicate are new, and the difference between them is what each
can safely carry. A copy crosses a key boundary, so it drops the group and the
tags exactly as a move does — both are items of the vault being left, and a host
arriving with either would point at something the destination does not contain,
resolvable on the machine that sent it and dangling for everybody else. A
duplicate stays in the same keychain, so everything it points at is still there
and it keeps both. Change group is the write dragging a card onto a group already
makes on the desktop, run over a selection; it refuses one spanning two keychains
rather than half-filing it, which is the refusal a drop across that boundary
already makes one host at a time.

Connect via SFTP is the one action that leaves the vault. Which machine is a
decrypted item and so is this object's business; the screen it leads to and the
transfers view model behind it are the shell's — so it is an event, on the same
division SessionOpened already draws for a shell. The host is re-found in that
screen's own copy of the list, because the picker binds to rows in that copy and
handing it the vault's object would select nothing.

What is left of the card is the password box, and only because it had nowhere
else to go: a host that authenticates with a typed password cannot be reached by
a tap alone. That tap now raises a sheet rather than the bar, and the difference
is that a sheet is up only while a question is on screen — the bar was raised by
a long press and stayed, so it was a password box sitting over the list whether or
not anything was being asked. Dismissing it empties the box, which is not tidiness
either: a secret left behind would satisfy the emptiness check that decides
whether to raise the sheet at all, so the next tap would dial with somebody else's
password.

The pencil moving into that bar takes the host editor with it. It was a card in
the list's own row, under the search box and the sync line — twenty controls
sharing a screen with two rows of chrome about the list it had replaced. It is a
page now, and PhoneShell stands all four of its rows down for it, which is what
"opens with all the options" means at 360dp. That needed a second subscription in
that control: two of its flags are questions about the vault rather than about the
shell, and the shell does not forward the vault's notifications.

The ticks are held as entity ids rather than as rows, and written back onto the
rows after every reload. Every row object in the list is replaced on every filter
keystroke and every synchronisation pass, so a set of rows would empty itself once
a minute under somebody choosing what to do with eleven machines. Ids that no
longer resolve are dropped, so a colleague's deletion arriving mid-selection
leaves a count that matches what is on screen.

One caller had to change with it. ConnectToRecent opened the pane about a host,
which was the desktop's drawer and the phone's card; the phone's answer is now a
tick, and nothing on that list means "selected" any more — so arriving with the
host merely selected would be arriving at a screen with nothing to press. Both are
raised together, and the one the head in front of the user does not draw is inert.
2026-08-06 09:15:37 +02:00
jaap-jan dc1ebf6afa Let the recovery code be copied, and give the phone a clipboard to copy to
ci / build and test (push) Canceled after 0s
ci / android head (push) Canceled after 0s
ci / api image (push) Canceled after 0s
Both screens had made the code selectable and both said why: a person who cannot
get it out of the box photographs the screen, and a screenshot is a worse home
for it than a clipboard. This finishes that argument. Selecting 64 characters of
letter-spaced monospace with a thumb is the version of "possible" people give up
on halfway — and on the phone the screen blocks screenshots, so the honest
remaining options were retyping it or losing it.

It is the one secret this application deliberately offers to a clipboard, and the
contrast with the keychain's copy is the whole argument rather than an
inconsistency. There, copying the private half is refused outright, because
installing a key means pasting the public one and the private one has no business
leaving the vault. Here there is no better route: the code exists for one screen,
is stored nowhere, and has to reach a password manager. The clipboard is the
intended destination rather than a way round the design.

The sentence afterwards matters as much as the copy, and is asserted: a clipboard
is a staging post, this screen is the only place the code exists, and the next
thing copied replaces it. Somebody who copies and does nothing has not saved it.

The phone had no clipboard delegate at all — the desktop passed one and this head
passed null — so COPY PUBLIC KEY on the keychain answered "this machine has no
clipboard" on a device that plainly has one. Nothing about that was platform
shaped: Android has a clipboard and Avalonia surfaces it through the same
TopLevel. Wiring it fixes that copy too.

The test fixture built its shell without a clipboard, which modelled the bug
rather than the product, so it has one now and the public-key test asserts what
lands there instead of the refusal. The refusal keeps its own test, on a shell
built without one, because the view model reads the delegate's absence rather
than an empty result — and because a button that silently does nothing on this
screen is worse than one that refuses.
2026-08-05 18:14:34 +02:00
jaap-jan 746711da9d Let a tap on the phone's host list mean connect
Choosing a machine raised the connect bar over the bottom of the list: a
password box, CONNECT, EDIT, MOVE and DELETE. Five controls in the way of the
one thing a tap on a machine's name obviously means.

So the gestures split. A tap connects. A long press raises the bar, with all
five. The pencil in the phone's header — its only persistent chrome — edits
whichever host is chosen, which is the one of the five common enough to be worth
a control that is always in the same place.

The flag doing it is the desktop's own IsHostPaneOpen rather than a second one.
That head made exactly this move when a selection stopped opening its drawer, and
the question both are asking is "has somebody asked about this host" — answering
it twice is how two heads come to disagree about what a selection means.

One tap cannot finish: a host that authenticates with a typed password has
nowhere on a list to be given one. That tap raises the bar with the box in it and
says so, and a second tap with the box filled in connects. The branch is in the
view model rather than in the head, because "can this machine be reached without
asking for anything" is the same question the bar's own password box answers, and
a copy of it in a view would be a second reading of a binding chain that has one.

Two mechanics worth knowing. Avalonia raises Tapped on release whatever the press
lasted, so a long press would open the bar and then connect — one touch firing
both gestures — which is why HostsScreen tracks the hold and swallows the tap it
precedes. And Holding only fires once IsHoldingEnabled is set, so that and the
handler are attached together rather than one in markup and one in code.

ConnectToRecent now opens the pane rather than selecting the row. On the phone it
has to: a selection alone raises nothing now, so going back to a recent machine
would land on a screen with nothing to press.
2026-08-05 08:43:33 +02:00
jaap-jan b4a6c19ac1 Let the phone replace itself, and give CI a channel it may sign
ci / build and test (push) Successful in 1m53s
ci / android head (push) Failing after 32s
ci / api image (push) Successful in 28s
The Android head had no updater and no release path, and the two are one problem:
Android refuses an update signed by a different key, and CI generates a fresh debug
key in every container. An APK released from a workflow could be installed once and
never updated again — each new one an uninstall, which on this product means losing
the cache, the outbox and the device key.

So there are two channels, and they are two applications because the platform gives
no third option. dev.dodotech.dodossh is cut from a v* tag by a person running
scripts/release-android.ps1 with the key ADR 0011 rule 1 keeps off runners.
dev.dodotech.dodossh.nightly is cut from main by CI and signed with a keystore
committed here in the open — a key everybody has cannot be stolen and grants nothing
by being held, which is why putting it in CI does not touch the rule. Neither can
update the other, by construction. See ADR 0014.

The android job assumed an image with a JDK and an Android SDK on it, which is what
a GitHub runner is and what this project's is not. It now installs a JDK, fetches
Google's command-line tools, accepts the licences and installs API 36 — each a no-op
where it is already satisfied, and each cached by the persistent runner's own disk
rather than by an action that would move a quarter of a gigabyte to rebuild a
directory that never left.

The client reads a small JSON manifest beside the APK, the counterpart of
releases.win.json, and compares Android's versionCode rather than a version name:
that integer is what the platform itself uses to accept or refuse an install, so
comparing anything else would offer updates the phone then rejects. It fetches, and
then asks Android to ask — the system draws its own confirmation, and from API 26
will not draw even that until unknown sources is on for this application.

IUpdateChannel gained ApplyingEndsTheProcess. On Windows applying replaces the files
and restarts, so the shell disposes the vault first and that is what zeroes the keys.
On the phone the install is a request and the answer may be no, so disposing first
would answer "not now" with a locked keychain and every shell closed — a punishment
for declining an update.

Two measured bugs found on the way, both older than this work and both invisible to
a -getProperty check. ApplicationDisplayVersion is read by the Android targets in a
top-level PropertyGroup, so the target setting it from MinVer ran after the only
thing that reads it: every APK ever built here said versionName 1.0.0. And nothing
found so far varies the launcher name per channel — four mechanisms tried, all of
them recorded in platform-flags, none of them reaching the label the launcher shows.
The two channels share an icon name for now and are told apart by package name,
version, and what the preferences screen says.
2026-08-04 21:46:01 +02:00
jaap-jan 6728a0a597 Let the desktop client replace itself, and give the repository one version
Packaging for Windows, and the updater that only exists once something is
packaged. Velopack, win-x64, fed from the project's own forge — never from the
deployment a client signs in to, which is ADR 0011 rule 2 carried over
unchanged and is why the feed address is a constant in the code rather than a
setting. See docs/adr/0012-desktop-distribution-and-updates.md.

**Nothing is ever installed while somebody is using it.** A newer build is found
on a six-hourly pass, downloaded in the background, and then waits — for a
restart the user presses, or for the next launch they were going to do anyway.
That is a policy rather than caution: this application argues at length that
locking keeps shells running, because a lock that destroyed work would stop
being used, and a restart does not keep them. Having taught that, it owes the
user the choice at the one moment it stops being true, and the sentence saying
so counts the shells it would close.

**The version is now derived from the v* tag**, by MinVer, for everything. There
was no version before this — no property anywhere, so every assembly reported
the SDK's 1.0.0 and the API served that string as its serverVersion to every
client that asked. The tag was already the version of record for the container
image; this makes it the version of record full stop. MinVer's failure mode is
answering plausibly rather than failing, and here a wrong version is a client
that never updates, so it is guarded twice: fetch-depth 0 on every checkout, and
a step that fails a tag build when the tag and the computed version disagree.

**The pack id is DodoSSH.Desktop and not DodoSSH**, which is the one decision
here that would have destroyed data. Velopack installs to %LOCALAPPDATA%\<packId>
and removes that whole directory on uninstall, and %LOCALAPPDATA%\DodoSSH is
where ClientPaths keeps the encrypted cache, the outbox of changes not yet
pushed, and the device key. The obvious id would have had the uninstaller
silently delete work the server has never seen — the thing the application
refuses to do without a counted confirmation. Velopack's own advice to move user
data to roaming %APPDATA% is declined for the reason ClientPaths already gives.

**Releases are cut by a person, and CI gains no job that could.** The tempting
argument is that a forge write token is not a signing key. It does not survive
contact with what the token does: Velopack clients trust their feed and do not
verify a package signature when they apply one, so whoever can write a release
can ship an update every install runs. That is the capability ADR 0011 rule 1
puts on a machine which is not a runner, reached through a different door. The
mechanical objection — vpk needs Windows and the runners are Linux — is the
smaller of the two and is recorded beside it, because somebody will fix one and
believe they are done.

Unsigned for now, deliberately and with the cost stated where a user reads it:
SmartScreen warns once per person, on Setup.exe, because Mark-of-the-Web is
applied by the browser that downloaded it. In-app updates are fetched by the
application and applied from a local file, and never trip it.

The banner is a fourth row of the window rather than an overlay. Anything drawn
in the terminal's rectangle is sliced by the native child window that composites
above it — the defect this window has shipped once — and a sibling row is the
arrangement TitleBar and StatusBar already prove works.

----

Three defects surfaced on the way, none of them in the feature being built.

**A settings key absent from the file came back as the CLR default, not the
declared one.** The JSON source generator builds a record through a synthesised
parameterised constructor and assigns every property from its argument array, so
a property initializer runs and is then overwritten by a default for anything the
file did not contain. A settings.json of {} read back a font size of 0, clamped
up to the 8px floor rather than the 13px the renderer draws at. It could not bite
while there was one setting, because that setting was written on every save and
so was never absent; adding a second would have turned automatic update checks
off for every existing profile, silently, the opposite of the documented default.
Reflection-based deserialisation of the same JSON answers correctly, which is why
every way of checking it by hand agrees except the one that ships. The defaults
now live on the constructor parameters, which is the only place the generator
reads them from.

**Declaring a RuntimeIdentifier on the desktop head broke the server's image
build.** It is the obvious way to let a self-contained publish restore under
locked mode, and it writes a net10.0/win-x64 target into the lock file of every
project the head references transitively — including DodoSSH.Contracts and
DodoSSH.Crypto, which the API builds too. The Dockerfile restores those with no
RID and fails NU1004. Found by running docker build rather than by reading. The
RID stays out of the committed state; the two commands that need one ask for it
unlocked, and the release script puts the lock files back.

**A Docker ARG named VERSION silently sets MSBuild's Version.** An ARG is an
environment variable for the rest of the stage, MSBuild reads environment
variables as properties, and property names are case-insensitive. With the
workflow passing main-<short sha> on a main build the publish died with
NETSDK1018 pointing at DodoSSH.Contracts, a project nobody had touched. The build
stage's argument is ASSEMBLY_VERSION now, empty except on a tag build.

All three are in docs/platform-flags.md, which is where the next person will look.

----

Verified: the whole solution builds and restores locked; 289 shell, 93 layout and
54 session tests pass, including the regression test for the settings defect and
a measurement of the banner at the window's minimum width. vpk pack runs end to
end and reports "Verified VelopackApp.Run()" against Program.Main. The API image
builds correctly both as a main build and as a tag build, carrying 1.0.0 and
0.1.0 respectively.

Not verified, and it needs a published release to be: installing, updating and
uninstalling on a real machine. That is Phase 15 of docs/manual-checks.md, and
the pack id and the WebView2 profile fix are reasoned and commented but only
proved by walking it. Two things to watch at the first upload — the reverse
proxy's body-size limit for a 64 MB asset, and whether vpk upload gitea is happy
with Gitea 1.27.1.
2026-08-04 17:04:41 +02:00
jaap-jan e9cea2ccbc Let a shared vault arrive, a bucket be found, and a vault be deleted
Three things a user reported, one of which was a real bug and one of which was
not the bug it looked like.

**A vault shared with somebody never reached their machine.** The grant was
correct at both ends: the sharing client verified the recipient's key against the
key log and wrapped every generation to it, the server stored it, and /me would
have returned it. Nothing asked. VaultSession.RefreshVaultsAsync — the method
whose own summary says it is "called after a share and on a periodic pass" — had
no caller anywhere in the application, so the vault list was whatever the last
browser sign-in cached. A restart did not help: an offline unlock reads that same
cache. The vault appeared only if the recipient happened to sign in through the
browser again, which is why this looked like sharing being broken rather than
like a list that was never re-read.

So every synchronisation pass now re-reads it, before it syncs. SyncOnceAsync
takes the whole server rather than its sync half for that reason, and the order
matters: a vault admitted by the refresh is one that same pass then pulls, where
the other order would show a newly shared vault as an empty one until the minute
after. The shell is told only when the set actually changed — it rebuilds the tab
strip's vault menu from the session's list, and doing that on every quiet pass
would rebuild a menu once a minute for nothing.

The test needed the fake server to be able to do something no test here had
needed before: hand this account a vault it did not make. ShareVaultWithMe wraps
a real key to the encryption key this account enrolled, so the keyring opens it
exactly as it opens a real colleague's — a helper that filled the field with
bytes would let a vault appear in the list and never prove it could be read.

**Adding an S3 bucket on the desktop works, and could not be found.** The report
was that it is not possible; driving the real XAML headlessly says otherwise —
Keychain, + BUCKET, and the editor saves. What is true is that S3 is where
somebody goes looking, and from there SELECT BUCKET opened a combo box with
nothing in it and no sentence anywhere saying that a bucket is a keychain item.
From where the user was standing that is indistinguishable from an application
with no way to add one.

The empty state now says what a bucket is and offers a button that lands on the
keychain with the editor already open — navigating to the screen and leaving
+ BUCKET to be found among five buttons would be most of the same problem. The
phone gets the sentence and no button: its keychain screen reads and deletes and
edits nothing, so there is no editor to send anybody to, and naming the machine
that has one beats an empty control that reads as a screen still loading.

The keychain screen's layout test grew the two categories it never covered.
Tags and buckets arrived after it was written, and the header strip it measures
is one that has overflowed twice before.

**A vault can now be deleted.** DELETE /api/v1/vaults/{id}, gated on Admin —
the line the rename already drew, for a stronger version of its reason, since
this takes the vault from everybody in it at once. The row is soft-deleted and
every grant to it withdrawn in one write; VaultAccessService filters on the stamp
at both ends, so from that moment the vault is absent from every member's /me and
every call naming it answers 404. Their clients notice on the pass described
above.

The team behind it is archived when it owned nothing else, which is the mirror of
renaming it: a vault made from the vaults screen gets a team named after it that
nobody was ever shown, and leaving that behind would leave a membership list no
screen has a row for. That is a second call rather than one transaction —
archiving is TeamService's, it refuses while a team owns vaults, and it can only
tell that this one no longer does once the deletion is committed. A crash between
the two leaves an empty team: invisible, archivable afterwards, harmless, and a
better failure than a vault that could not be deleted because tidying up after it
did not work.

Two refusals worth stating. The personal vault cannot be deleted at either end:
it is created by enrollment, everything filed nowhere else lives in it, and no
call would make another. And the items are kept — ciphertext behind a vault
nothing will resolve, so deleting them buys no confidentiality while destroying
what an operator undoing a mistake would need.

The client drops the key from the keyring and the row from the cache rather than
waiting for a refresh, so the list is right immediately; the items stay, as they
stay for a vault whose grant was withdrawn, because a copy is on every other
member's machine too and removing these rows would be the client pretending to a
reach it does not have. The confirmation says that out loud before it is
answered. It is the one sentence this screen must not leave implied: deletion is
no more retroactive than revocation is. See ADR 0001.

Desktop only, deliberately. The Android vaults screen offers no rename and no
hand-over either, so adding delete alone there would be the one destructive vault
operation on a screen with no other.

Three places asserted that a vault can never be deleted — TeamService's refusal
message, the TeamNotEmpty problem code, and ADR 0009 — and each now names the
route instead.
2026-08-04 15:34:40 +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 7b7fd7b2ef Make a vault the thing you create, and let a window set one aside
Everything a shared vault needs was already here and arranged the wrong way
round. A vault has to belong to a team, so creating one meant going to the teams
screen, founding an organisation, and only then adding a vault to it — which the
NEW VAULT button named after the team, so a team with three of them held three
vaults called the same thing and nothing told them apart. Somebody who wants to
share four servers with two colleagues is not asking to found anything.

So the form asks for a name and nothing else. The team is derived from it, slug
included, and created with this account as its owner; the vault goes inside; and
the members, roles, invitations and key holders that hang off a team are all on
screen the moment it exists. The tab strip's New vault entry lands there with the
new vault selected, which is where the next thing anybody wants to do already is.

That is two calls, and the first can succeed alone. When it does the team is
kept: the id is minted once into pendingVaultTeamId, so pressing CREATE again
resends the identical create — which the server treats as the same team — and
retries the vault, and the message says all of that rather than "creating the
vault failed". Archiving the orphan instead would be a client deleting something
on the user's behalf because a later step failed, which is the kind of tidying
that eventually archives a team somebody has just been added to. A slug taken by
somebody else is retried once with a disambiguated one and never in a loop; a
name with no a-z or 0-9 anywhere in it falls back to the team's own id rather
than to a refusal pointing at a field nobody was shown.

The other half is the caret beside Vaults. Being in four teams means four teams'
machines in front of you all day, and the answer is a switch per vault rather
than four sign-ins. Switching one off takes its hosts, groups, keys and pins off
the screens that list them and does nothing else: it still syncs, its key stays
in the keyring, it stays choosable as somewhere to file a new item, and a shown
host that authenticates with a key filed in it still connects. That last one is
what shaped the design. TryBuildAuthentication resolves a binding out of the
keychain's typed list and a cross-vault binding is legal, so filtering the reload
loops — the obvious implementation — would have turned a preference about reading
into an outage. Only the projections a person reads consult IsVaultShown; every
Reload*Async stays whole, including the dialled-endpoint set that decides which
pins are described as unused, because that is a hint which invites deleting
trust.

Snippets, logs and buckets needed no code and the comment says so out loud: all
three read ActiveVaultId alone, and the personal vault is drawn in the menu
ticked and cannot be switched off — it is the active vault, the group and tag
editors' target, and the save picker's fallback, so hiding it would empty half
the application rather than filter it.

The preference is a column on the cache's vault row, which is what makes it
survive both a relaunch and the /me refresh that runs every minute: Apply does
not touch it, deliberately, because the server has never been told which vaults
this machine is showing. It is in the encrypted cache rather than settings.json
because it is a list of vault ids and that file's own doc comment says what may
go in it. VaultSession cannot see the type at all — ReadableVaults is what the
sync loop walks, and a filter reaching it would be a vault that quietly stopped
syncing, found out weeks later from a host that was never there.

The strip's note refusing a MenuFlyout stands and is unchanged. This flyout
sidesteps the question rather than answering it: the handler selects the Vaults
tab first, which collapses the renderer, so nothing native is under the popup by
the time it opens — the move QuickConnect already makes. A headless test asserts
that ordering, which is as far as headless can go with no native window, and
manual check 1.6 is the other half.

The phone is out of scope on purpose: it has no tab strip and its teams screen's
vault section is read-only. The plumbing is in Client.Shell, so it can adopt this
later; until then nothing there is ever hidden, which is today's behaviour.

1514 tests pass. Fifteen are new in VaultVisibilityTests, and the ones worth
naming are the guards: a hidden vault still syncs, still holds keys that
authenticate hosts on screen, still appears in the save picker, and still counts
towards which pins nothing dials.

Not fixed, and noted here because it is next door: VaultGrantService's team-vault
create refuses a taken vault id rather than returning the existing vault, while
VaultSharing's own remark claims a create whose response was lost is safe to
resend. A lost 200 therefore leaves a vault whose key the client's catch already
zeroed, openable by nobody.
2026-08-03 21:52:27 +02:00
jaap-jan 38d8706784 Give the phone a way to enrol the fingerprint it already unlocks with
ci / android head (push) Failing after 4s
ci / api image (push) Successful in 35s
ci / build and test (push) Successful in 1m36s
The Android device key store, the biometric gate and the lock screen's UNLOCK
WITH FINGERPRINT button have all shipped since this head was written, and none
of them could ever run: that button appears only when a device key exists, and
nothing on the phone could create one. `CanUnlockWithDevice` was false on every
launch of every phone. This is the missing half.

**The offer is on PREFERENCES**, which held a PendingScreen until it had a
setting on it. It is there rather than beside the button it turns on because
registering needs an unlocked keychain and a reachable server — the vault has to
be open to seal the bundle, and the wrap has to reach the account or a phone
somebody has lost could never be revoked. Neither is true on the lock screen.

One card, and exactly one of its three blocks is ever drawn: the offer, the
withdrawal, or the sentence saying this phone has nowhere to keep a key. That is
`CanRegisterDevice` / `CanForgetDevice` / `HasNoDeviceKeyOption`, which are two
flags and not one and its negation for the reason written where they are set —
a phone with no screen lock and a phone already registered are both "cannot
register", and only the second has anything to take back. The withdrawal has no
confirmation, deliberately, and the sentence above it carries what the desktop
puts in a tooltip this head has no room for. `StatusMessage` is on the screen
because it is the only feedback this head has once the system's own dialogue has
gone.

**Two things would have been wrong in the feature the moment it worked.**

`Environment.MachineName` answers `localhost` on Android, and registering names
the device — so every phone would have arrived in the account's device list as
another identical row, on the very screen a lost handset is revoked from.
`PhoneEnvironment.DeviceName` was already written and never called; the shell
now takes it as an optional constructor argument that the desktop does not pass,
and it reaches enrollment, registration and every connection log entry. That was
gap §7 of docs/android-port.md, and it is now closed.

And the status line said "Waiting for Windows…" over an Android biometric
prompt. `GestureWait` picks the sentence from the platform rather than from a
head, unlike the device name beside it: a device name is a fact about one
handset only the head can read, and which dialogue appears is a fact about the
operating system this assembly is running on.

Two tests cover the seam — the injected name reaching the account, and the
default still being this machine's own name — and `FakeVaultServer` records what
each device called itself, because the name is the only part of a registration a
person ever reads. The gesture itself is unreachable from any test process, so
Phase 13 of docs/manual-checks.md carries five checks, including that enrolling
a new fingerprint in Android's own Settings destroys the key. That one is the
property that makes this a fast path rather than a weakening of the passphrase.
2026-08-03 16:12:50 +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 1b7df47537 Merge main into the desktop redesign branch
ci / build and test (push) Successful in 1m26s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 20s
Four conflicts. Three were two people adding to the same spot, and one was a
real collision: main gave the connect bar a REMEMBER tick in the same pass that
this branch took the connect bar off the hosts screen.

REMEMBER is now in the drawer, beside the password box it qualifies. Nothing
about the feature changed — RemembersConnectPassword, its refusal to fire until
the remote has accepted the password, and the six tests over it are main's
untouched — only where it is drawn. The move improves it slightly and it is
worth saying why rather than claiming a merge was neutral: the bar had one row
and had to fit the box, the tick, the authentication note and CONNECT along it,
which is why the tick was a bare "REMEMBER" in tracked capitals. A column has
room to put it under the box as a sentence, where it reads as a property of the
password rather than as a fourth control in a row.

MainWindowViewModel: both sides added members after ShowTerminal — the desktop's
three fixed tabs here, the phone's connect menu on main. They do not interact,
so both are kept, each under its own heading.

TeamsScreen: main added the team's own RENAME, HAND OVER and ARCHIVE, a member's
LastActive, the role a new member arrives as, and the KEY HOLDERS list. This
branch had only bumped the file's font sizes a point. Resolved by taking main's
file whole and re-running the bump over it, so the new controls join the scale
rather than sitting a point below everything around them.

README: both sides described a different head's third pass in the same
paragraph. Both kept.

Two things checked rather than assumed, because this branch moved the furniture
the merged commits sit on. The chrome heights main's terminal work touched are
the phone's, not the desktop's — 44, 42 and 24 are unchanged, so the layout
harness's budget still describes the window. And main's keychain DELETE did not
reach VaultScreen.axaml, whose header this branch rearranged, so the five
buttons that overflowed at the larger type are still five.

2415 tests pass, up from 2369 by the 46 main brought.
2026-08-03 15:17:55 +02:00
jaap-jan 208443b932 Move SFTP and S3 into the tab strip, and the host list into a card grid
Four asks in one pass over the desktop head, and two of them are furniture
moving rather than anything new.

THE STRIP IS THE WINDOW'S NOW, not the terminal's. Vaults, SFTP and S3 sit at
its head and cannot be closed, and open terminals follow them. SFTP and S3 left
the nav rail to get there, which is the one semantic change: they are the two
destinations you stay in while something runs, and a rail entry is for
somewhere you go and come back from. So the rail belongs to the Vaults tab and
collapses with it, which also means SFTP, S3 and a terminal each get the full
1016 rather than the 826 a page gets.

The tab is expressed as "a page, and not one of the two the strip took" rather
than as a fourth ShellSurface. Both are still ShellScreen members and have to
be — that is what they are on the phone, where they are two rows in a hub — so
a surface each would have been a second way to say a thing Screen already says.
IsTransfersShowing and IsBucketsShowing light the other two tabs unchanged.
What is new is one field: the page Vaults comes back to, because it is the one
tab with sub-navigation and therefore the one that can return to the wrong
place. That is not the hidden field ShellSurface argues against — that one
would be a second copy of "which page"; this is a tab remembering its own.

THE HOSTS SCREEN IS A GRID, and the 268-pixel sidebar is gone. That column was
choosing among forty machines and editing one of them at two-thirds width, and
it was narrow so the editor beneath it could be a column at all. Cards took the
first job at full width; a 304-pixel drawer took the second and collapses when
nothing is selected. Pressing a group card narrows the grid; SHOW ALL is the
way back. The group editor moved into the drawer as well, which finally makes
IsEditingGroup mean the same thing on both heads — it was the phone's alone,
because the desktop's editor was a bar that was always on screen.

AreHostsExpanded and ToggleHosts went with the control that used them. They
folded the whole list away under one heading, an affordance that existed
because the column was narrow. Folding a single group is a different thing and
is still here.

THE TYPE SCALE IS A POINT LARGER and the text ramp is white. The base size was
never stated anywhere — a bare TextBlock took TextElement's default of 12 — so
raising the scale meant naming it, on Window and on UserControl. The second
selector is not redundancy: the layout harness hosts a UserControl in a window
it builds itself, and without it the suite would measure every screen a point
smaller than it ships, silently. A selector on TextBlock would have been the
obvious way and is wrong, because a style setter beats an inherited value and
would collapse every deliberate step back to one number.

#E3E7F4 is a blue-tinted white on blue-black surfaces, which costs contrast
twice — once for being darker than white and once for sharing a hue with what
it is drawn on. Pure white is 18.3:1 against the canvas where that was 15.5:1.
Every step below moved with the top, so the intervals the design chose are
kept and TextDim clears 9:1 against 6.4:1. The palette is shared, so the phone
has both changes too.

TWO DEFECTS THE HARNESS STRUCTURALLY CANNOT SEE, found by rendering the screen
rather than by measuring it, and both now covered.

The tile was 232 and was first written as 248, from arithmetic that left out
the scrolling stack's own margins. Every layout test passed — the harness asks
whether a control is inside the window, never how many fit on a line — so the
grid quietly became one column wide at exactly the minimum this application
guarantees, which is the shape cards exist to avoid.
TheHostsGridKeepsTwoColumnsAtTheMinimumWithTheDrawerOpen counts columns
instead, and fails at 248.

And a card's text ran past its own border, because a horizontal StackPanel
measures children with infinite width: a TextBlock inside one never learns it
is short of room, so TextTrimming never fires. Both card rows are grids with a
star column that gives way and an Auto column that does not — a hostname with
its tail cut is still the machine you were looking for, where a badge or the
word naming an auth method is not.

The keychain header changed shape for the same class of reason. It was
Auto,Auto,*,Auto with the buttons last, so the slack column was the only thing
absorbing a change of width and five buttons fell off the right edge the moment
the type grew. That is how GENERATE lost the word KEY once already. The summary
sits in the star column and trims now, so the buttons always get their width.

HostSidebarTests became HostGridTests and moved to the grid with the gestures
it drives. docs/design-import-gaps.md gains a v3 section naming the five
toolbar controls in the design with nothing behind them — a view-mode switch, a
tag filter, a calendar, a share control and Serial — and manual-checks.md and
the README follow the controls that moved.
2026-08-03 15:08:48 +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 8da58db5ce Merge branch 'claude/host-connection-top-bar-25d04e'
ci / build and test (push) Successful in 1m23s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 23s
2026-08-03 14:33:52 +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 6fe2b21781 Point every build at the hosted server, not just the ones that ship
ci / api image (push) Canceled after 0s
ci / build and test (push) Canceled after 16s
ci / android head (push) Canceled after 0s
The default was split on DEBUG so a clone would offer localhost and only an
installed build would offer ssh.dodotech.cloud. That protected development
launches from enrolling a device against production. It also meant the address
in the box depended on how the binary was built, which is not what was wanted.

Working against a local API now means typing http://localhost:5233 by hand.
2026-08-03 14:27:01 +02:00
jaap-janandClaude Opus 5 3627021420 Give the desktop the second design too, and the window the size it now needs
The desktop v2 design is the other half of the one the phone took last commit, and this is its chrome: a
190-pixel labelled sidebar where the 54-pixel icon rail was, a titlebar with the search box centred in it,
and session tabs drawn as pills. The palette was already here — it is shared, and moved when the phone's
did — so what this changes is shape rather than colour.

**The window's minimum grew, and by exactly what v2 added.** The sidebar is 136 wider and the chrome 14
taller, so 880x560 became 1016x574. That is not a round number somebody liked: it leaves every screen the
same 826x464 it was designed against, which is the arithmetic the layout suite is built on. Four of the
tables stop fitting at 690 wide, so widening the sidebar and leaving the window alone would have broken
them somewhere no test was looking. LayoutHarness carries the new constants and the suite still passes at
the minimum, which is the whole reason it exists.

The rail's five-character abbreviations are gone with the width that caused them — PINS and SNIPS are Pins
and Snippets again — and each row gains a glyph and a count. A count is drawn only where one is real, so
SFTP, Logs and Preferences show nothing rather than a zero: a transfer queue's depth is not how many files
a screen holds, and a log has no total until it is read. The count beside Pins is the vault's own, not the
Pins screen's VisiblePins, which is the filtered list and would have made the sidebar count whatever
somebody had just typed into a filter box on another screen. Teams has no count for a related reason: they
are read from the server when that screen is opened rather than on unlock, so a number there would read 0
until somebody had already been to look.

One colour moved with it, finishing what the repalette started: the live-session summaries on the unlock and
sign-out cards were Info, so the two heads disagreed about a fact the phone paints green. They match again.

**Buckets became a destination rather than a mode**, which is what the design draws and what the phone
already does. The HOST / BUCKET pair inside the files screen is gone; ShellScreen.Buckets draws the same
TransfersScreen with the other picker, and the sidebar entry is what sets it. That also settles an old
disagreement rather than merely moving it: TotalItemCount is keys plus passwords and excludes buckets, so
the number beside the keychain used to disagree with the list under it, and now counts what that screen
shows.

There is one session behind both file destinations, so asking for the other kind while something is open is
refused rather than obeyed — and refusing means staying put. An earlier turn of this had it move anyway and
only decline to switch the picker, which put the S3 entry in the sidebar over a screen still listing an
SFTP host: two pieces of chrome disagreeing about where you are, which is worse than the navigation simply
not happening. The message that says so goes to Transfers.Status, which turned out to be drawn in the same
grid cell as the connected chip — survivable while it was mostly read before connecting, and not once a
refusal reports itself there. It has its own column now.

The design has nine entries' worth of screens and draws five. Pins, Teams, Import and Preferences are
built, working screens, so they keep their entries — the sidebar is labelled now and has the room, and
dropping an entry would have stranded a screen rather than simplified anything. The Team vault card the
design pins to the foot is not drawn: it is a second route to a screen already in the list, carrying a seat
count nothing here produces.

**The status bar survives the design that deletes it**, cut down to one thing. Two of the three facts it
carried moved into the titlebar with v2 — the sync word is beside its dot and the shortcut hint is inside
the box that uses it — so those are gone from it rather than printed twice. The third is Vault.Status, the
only channel this application has for saying a save failed or a merge picked a winner. The design is a
mock-up of an afternoon that goes well and has nowhere to put a sentence like that; dropping the bar would
have meant dropping the sentence or repeating it on nine screens.

What v2 draws and this does not is in docs/design-import-gaps.md, and it is the same list as the phone's
for the same reasons: the forwarding screen and both its chips, the host detail's fingerprint, tags and
last-session cards, the keychain's rotate button, the logs' FOLLOW pill and severity filters, and the
session footer's latency. The terminal is not inset behind a rounded frame either — it is a native child
window that composites above everything Avalonia paints, so the frame would clip nothing, which is the same
answer the phone gave.

**The light theme is not built.** Its accent is #6D5AE6, a different hue rather than a tint of the dark
one, so it needs every colour doubled, a variant to switch on, the renderer's own page switching with it,
and contrast checked twice. That is a piece of work rather than a setting, and it is separable from the
layout — which is why this commit is the layout.

The screens themselves are restyled through the shared vocabulary rather than rebuilt: corner radii,
chips, cards and the accent's ink, all in App.axaml, so every screen moves at once. Their layouts are left
alone deliberately. The design draws read-only detail panes and these screens carry the editors and forms
it has no equivalent of, so replacing a layout with the mock-up's would have lost the half that is
actually used.

Verified by the whole suite: 1309 tests over nineteen projects, none failing, including the 68 layout cases
that stand up real Avalonia and measure every screen at the new minimum. Both heads build. Not run on a
machine with a display — see docs/manual-checks.md for what wants looking at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZE3u99BNt6LzgTC5jhbz2
2026-08-02 19:29:21 +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 73c7e2a1e3 Point a release build at the hosted server, and a debug build at the clone
The shipped default was http://localhost:5233, which is the address the API serves under
dotnet run and a machine an installed application is not running. Somebody who installs a
release and accepts the field unread is signing in to nothing.

Two defaults now, because the two audiences never overlap. A release build offers
https://ssh.dodotech.cloud, so a first launch needs no address typed at all. A debug build
keeps the loopback address, and that half matters as much: shipping the hosted address into
a clone would point every development launch at production, and sign-in is the call that
provisions an account there.

The remark carries the reason the schemes differ, since the pair now looks like an
oversight rather than the deliberate thing it is — the API's first launch profile is
plaintext on 5233, and an HTTPS client meeting a plaintext port reports a TLS failure that
reads like a certificate problem.

The test spells out both branches rather than asserting the constant, which would pass
however it were edited. What it is really guarding is that a release never ships a
developer's loopback address and a debug build never points a clone at production, and it
can only guard those by naming them.

Verified by running the shell suite in Debug and in Release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 21:12:00 +02:00
jaap-janandClaude Opus 5 39b7e5620f Say which half of signing in is happening, and which half failed
A sign-in against a production Keycloak was reported as the shell hanging on "Opening your
browser to sign in…" and then, some time later, saying "The server returned 500". Both
halves of that are the message's fault. The browser flow had already succeeded — the
provider authenticated the user, the code came back, the tokens were exchanged — and what
was actually happening was a round trip to the DodoSSH server for the account. The screen
went on describing a browser nobody was waiting for.

The status now moves when the browser half ends, so the wait that follows is attributed to
the server being asked rather than to the browser that has already answered.

The failure gets the same treatment. "The server returned 500" is the API client's phrase
for any server it talks to, and read underneath a sign-in button it is naturally taken as
the sign-in having failed — which sends somebody to their identity provider's logs to find
out why a thing that worked did not work. It now says signing in succeeded, names the host
that failed afterwards, and says the reason is in that server's logs, because this side
cannot know more than that.

Nothing here fixes the 500. It changes which of the two servers the next person goes and
looks at, which was the actual cost of the old message.

Verified against the shell suite, including the case that asserts a failed command leaves
the window enabled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 21:11:38 +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 2caedd93ff Merge branch 'main' into the Android head
Main grew the screens the host-management plan called for — hosts, pins, snippets, logs,
import, teams — plus the ObjectStore and Import projects behind two of them, and moved
WindowsDeviceKeyStore into the desktop head's Platform folder.

Five of those view models landed in a directory this branch had already moved, so they
join the rest in DodoSSH.Client.Shell: git spotted the rename and put them there, and the
namespaces followed. Shell picks up ObjectStore and Import as a result, which the Android
head then gets transitively and will use neither of at first — scoped storage means there
is no ~/.ssh/config to import, and file transfer is out of its first scope.

Desktop suites green at 155 and 64.
2026-07-31 21:03:22 +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