Commit Graph
136 Commits
Author SHA1 Message Date
jaap-jan d32f5609e3 Rewrite checks 7.6/7.6a for the picker that replaced the drag
ci / build and test (push) Failing after 2m9s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 3m29s
The drag onto a group card went with v5's flat sections; filing a set
is the chosen-hosts menu's "Change group..." picker on both heads now.
The two checks walk that route instead and say honestly what
ShellFlowTests and ScreenLayoutTests already cover, what only a real
popup can show, and that nothing automated raises the mixed-keychain
refusal. The numbering preamble's example swaps to citations that
still exist.
2026-08-08 22:12:44 +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 8915650a0d Record the phone catching up in the design-import log
ci / build and test (push) Failing after 2m17s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 3m25s
2026-08-08 15:22:04 +02:00
jaap-jan c59b517fdf Record v5c in the design-import log, and true up the manual checks
ci / build and test (push) Failing after 2m14s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 3m27s
2026-08-08 14:17:20 +02:00
jaap-jan 422d5ca10e Record v5b in the design-import log, and true up the manual checks
ci / build and test (push) Failing after 2m12s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 3m25s
2026-08-08 00:49:59 +02:00
jaap-jan 7d64027972 Record v5 in the design-import log, and let the working notes go
ci / build and test (push) Successful in 2m15s
ci / android head (push) Successful in 3m28s
ci / desktop nightly (push) Successful in 44s
ci / api image (push) Successful in 24s
2026-08-07 18:14:59 +02:00
jaap-jan 281e828e25 Sweep out the group-card navigation nothing reaches any more 2026-08-07 18:14:54 +02:00
jaap-jan 82966af37b Let a connection be reached through a proxy on this machine's loopback
ci / build and test (push) Successful in 2m8s
ci / android head (push) Successful in 3m20s
ci / desktop nightly (push) Successful in 46s
ci / api image (push) Successful in 23s
Step 1 of docs/reaching-a-host-you-cannot-dial.md, and it is not the step that document said it was.

SshConnectionRequest carries an optional SshLoopbackProxy and BuildConnectionInfo hands SSH.NET its proxy
ConnectionInfo when there is one. Nothing passes one yet: the callers are jump hosts and the relay, which
are steps 2 and 3.

◆ THE BRIDGE WAS THE WRONG FIRST STEP, AND BUILDING IT WOULD HAVE BEEN THE MISTAKE THIS DOCUMENT IS ABOUT.
ADR 0004 says the relay's loopback bridge "also provides ProxyJump via a SOCKS5 dynamic forward — one
mechanism, two features", and the plan took that to mean the bridge was the shared foundation. It is not:
ForwardedPortDynamic *is* the listener for a jump host — SSH.NET accepts on it, speaks SOCKS5 on it and
tunnels through the bastion — so nothing is left for a bridge of ours to do on that path. The relay is the
case with no SshClient to hang a forward off, so it is the bridge's only consumer, and the bridge belongs in
the commit that uses it. What the two actually share is one level down and a tenth of the size: being told
to reach a target through a loopback proxy while staying about the target. That is what this is.

Three properties, one test each.

A port and nothing else, so a proxy anywhere but loopback cannot be expressed. The failure that shape rules
out is an open SOCKS proxy on the user's network for the life of a shell, which nothing would report — so it
is made unrepresentable rather than validated, on the same grounds AuthenticationChoice carries a kind.

SOCKS5 rather than a dumb pipe, which is what keeps host key pinning honest. The target's own name and port
stay in the request, travel to the proxy in the CONNECT, and are what the gate pins — so a machine reached
through a bastion is pinned under its own name instead of under 127.0.0.1 on whatever ephemeral port that
day's forward got, which is not an identity at all. A pipe would have meant handing SSH.NET a stand-in and
remembering everywhere else that it was one.

And a proxy that is not listening fails as a connection error rather than as an unknown host key. The gate
turns "no host key seen" into a fingerprint prompt, and a connection that never reached a server has seen
none either; the prompt would offer to fix the wrong thing, with no fingerprint to show.

TWO THINGS THE TESTS MEASURED RATHER THAN ASSUMED, both found by the first run failing.

The target is resolved at the *bastion*, not here — a SOCKS CONNECT names it and the far end looks it up. So
the test asks for localhost:2222, the address inside the container, and the published port this host would
use means nothing there. That is not a quirk of the fixture; it is what ProxyJump means, and it is why an
ssh_config writes the target's internal address beside its jump host. Getting it wrong is a SOCKS "general
failure" that names neither end.

And the test server refuses forwarding. linuxserver/openssh-server ships AllowTcpForwarding no, which a
dynamic forward does not notice — opening one asks the server nothing — so every connection through it is
refused at channel-open and reported as the same general failure. The fixture patches it and HUPs sshd.
There are two sshd_config files in that image and the running server uses /config/sshd/sshd_config; the
first attempt patched /etc/ssh/sshd_config, which is the one a search finds first, changed the text and
nothing else, and left the failure exactly where it was.

VERIFIED. Build clean with no new warnings, 85 tests in Client.Ssh.Tests against the real sshd, and the
solution builds. The proxy test was seen to fail — proxy.Port + 1 in BuildConnectionInfo — and seen green
again. An earlier mutation attempt did not compile, and the log said 85 passing because the run never
started and the previous log was still on disk; the second attempt deletes the log first, which is worth
copying whenever a mutation "passes".

dotnet format reports one pre-existing IDE1006 in DodoSSH.Api/Features/Events/EventsEndpoint.cs, in a
project nothing here touches. Left alone.
2026-08-07 13:48:15 +02:00
jaap-jan 575a9a9f5e Stop the relay checkbox promising a connection this client cannot make
ci / build and test (push) Successful in 2m7s
ci / android head (push) Successful in 3m15s
ci / desktop nightly (push) Successful in 47s
ci / api image (push) Successful in 25s
Ticking "Connect through the server relay" moved the host's address and port out of the encrypted payload
into plaintext columns on the server — the single deliberate privacy concession in the design, per ADR 0004
— and then the client dialled the address directly, exactly as it does with the box clear. VaultViewModel
builds SshConnectionRequest(hostname, port, username, credential) and nothing on this side reads
RelayEnabled at all. The connection failed the way it always had, for a machine the laptop could not reach,
with nothing saying the box had done nothing.

The server half is built and shipped: tickets, the WebSocket, the deny list, the CHECK constraint that
enforces a non-null address for a relay-enabled host. What does not exist is the client's path to it, so
this is an unfinished feature rather than a broken one — but the control in front of it was collecting the
cost of the finished version.

Both heads now say so, in the label and in the first sentence of the paragraph under it. Not disabled, and
that is the one decision here worth stating: a host somebody has already ticked has to be able to lose the
flag, and a control greyed out with the concession switched on would trap it there. Tickable and honest
beats untickable and stuck.

This is step 0 of docs/reaching-a-host-you-cannot-dial.md, and the only step of it that should ship alone —
the sentence is written to be deleted when the bridge lands.

VERIFIED. Build clean, 112 layout tests. The drawer's paragraph is longer than it was and the host editor is
measured with the drawer open at the window's minimum, so the wrap is held inside the column rather than
assumed to fit.
2026-08-07 08:47:02 +02:00
jaap-jan 6185d74800 Write down the two things a user is promised and does not get
Two plans, both for the same class of defect: a control or a code that a user is told to rely on, backed by
storage and by nothing else. Neither is started; what follows is the reasoning, so that starting is not
where it gets thought about.

── UNLOCKING WITHOUT THE PASSPHRASE ─────────────────────────────────────────────────────────────────────
Every account is issued a recovery code at enrollment. The client generates it, wraps the identity bundle
under KEK_rc, the server stores that wrap as UserKeyWrapKind.Recovery, and both heads work to make sure the
user writes it down — the phone raises FLAG_SECURE for that screen alone and will not let anybody past it.
Nothing can use it. SessionOpener has UnlockAsync and UnlockWithDeviceAsync, and there is no third.

Walk the failure through: forget the passphrase, and the bundle cannot be unwrapped, so no vault key opens
and every item is unreadable. Signing out and back in returns the same passphrase wrap. The device key
would be the other door, and sign-out withdraws it — which is the advice the unlock screen gives for
exactly this situation. The loss is total and permanent, and the thing built to prevent it is inert.

The docs already disagree with each other about this, which is how it surfaced. manual-checks §10.2 calls
the code "the only thing standing between a forgotten passphrase and an unrecoverable vault"; android-port
says losing it *along with* the passphrase is what makes a vault unrecoverable; README says signing out is
the only answer and nothing can recover one. The third is the true one today.

More than half the work is already done and one piece of it was done on purpose: LocalCacheKey derives from
the identity bundle rather than from MK — crypto.md §3.2, changed 2026-07-30 — specifically so an unlock
that never computes MK can still read the cache it wrote. What is missing is an endpoint to serve the wrap,
an unlock path, and a way to set a new passphrase afterwards, without which the account unlocks with a
one-time code forever. That last step is the same re-wrap a change-passphrase feature needs, so it delivers
both.

Two traps are recorded because both would produce a code that verifies nowhere. The derivation uses the
displayed string *including its dashes*, so the unlock must canonicalise to the printed form rather than
strip it; and the recovery wrap uses a different Argon2 profile to the passphrase one (64 MiB against 256),
so it must derive from the parameters served with the wrap rather than from a profile constant.

── REACHING A HOST YOU CANNOT DIAL ──────────────────────────────────────────────────────────────────────
This started as "delete the dead jump-host field" and inverted twice.

HostSecret.JumpHostIds is stored, validated, encoded and three-way merged, and nothing reads it or writes
it — the ssh_config importer looks like the writer and is not; it records ProxyJump as an option and a note
saying DodoSSH cannot honour it. The first draft recommended deleting it. That was wrong twice over. ADR
0004's last consequence had already designed the implementation — a loopback TCP bridge for the relay, and
"the same bridge provides ProxyJump via a SOCKS5 dynamic forward", one mechanism and two features — which
the pinned SSH.NET 2025.1.0 supports through ForwardedPortDynamic and ProxyTypes.Socks5, checked in
Renci.SshNet.xml rather than remembered. And the stored shape is right: an ordered list of host ids is what
a chain is, the merge arm is correct, and the missing schema version is a line to add.

◆ Looking properly found the same shape one field over, where it costs something. RelayEnabled is also
stored, merged and never read by the connect path — but it is user-settable, and both heads draw a checkbox
promising it. Ticking it moves the host's address and port out of the encrypted payload into plaintext
columns, which ADR 0004 calls the single deliberate concession in the design, and then the client dials
directly anyway. The privacy is spent and the feature is not delivered. That is a defect rather than a gap,
and it is step 0.

The comparison the plan turns on: the relay reaches what the *deployment* can reach and the jump host
reaches what a *machine in the keychain* can reach, so they are not substitutes. On a self-hosted box
outside the target's network the relay reaches nothing the laptop could not. And the privacy ordering is
the opposite way round from the ADR's framing — the relay costs a plaintext address, the jump host costs
nothing, because the operator is not in it.

Both documents carry a section on what their own earlier reasoning got wrong, which for the second one is
the load-bearing part: "nothing reads this field" was read as evidence of a mistake when it was evidence of
an unfinished feature — and the same sentence one field over would have found the checkbox that is lying.
2026-08-07 08:44:21 +02:00
jaap-jan 88809f0d66 Stop the docs claiming absences that have since been built
An audit of README.md, the seven docs and the fourteen ADRs against the code, looking for what is described
as absent or planned. Most of it held. What did not is here, and it clusters: every stale claim but one is
downstream of the settings file arriving without this document noticing.

design-import-gaps said the client has no preferences store and writes exactly two files. It writes three —
ClientSettings is in settings.json beside the cache — and two preferences are saved through it. From that
one error followed four more: the terminal font size row said "fixed at the renderer's 13px" when it has
been 8 to 32 from a screen and three chords for some time; the transfer-resume row and the per-host last
directory row both blamed a store that now exists, when what they actually want is a table and a scalar file
is the wrong shape for one; and the Preferences table asserted no preference could be saved at all.

It also said TerminalServerOpcode has four values and none carries an option. It has eight, and one of them
is FontSize — which is the interesting part rather than a counting error, because that opcode is the proof
that the frame these rows say is missing can be built. The rows now say what each one would actually take,
which for three of the four is a setting, an opcode and a control, and for the Backspace row is a reason:
which byte backspace sends is a fact about the remote's stty, so a client-side switch fixes a mismatch by
hiding it.

THE SAME TWO ERRORS WERE SHIPPED IN THE INTERFACE. The preferences screen carries a NOT BUILT YET list, so
that what the screen does not do is as legible as what it does — and it said terminal size was hard-coded a
hundred lines below a working size control, and said there is one release channel a month after the nightly
shipped. A list of absences is only worth having if it is true, and a screen contradicting itself in the
same scroll is worse than no list. Both lines are corrected rather than removed: the first now says which
three of the four are genuinely hard-coded, and the second says what is actually missing, which is a way to
change channel from inside the application rather than by installing the other build.

docs/adding-hosts-on-the-phone.md is deleted. It was a work plan whose own header says "Status: built. All
six steps." — nothing links to it, and the decisions it records are in the code it produced, including the
one it is proudest of: HostSecret.AsksForPassword carries its own remark on why naming neither binding had
to stop meaning "ask me". What was left was step ordering and per-test instructions for work that shipped.
Git keeps it.

crypto.md is deliberately untouched. It is normative and frozen, and its claims are about the DSH1 format
rather than about this build — including the one that reads oddly next to the code, that a passphrase is
one of four ways to open a vault. Under the spec it is. What is missing is a statement about what this
build can open, and that belongs beside the spec rather than inside it; see
docs/unlocking-without-the-passphrase.md.

VERIFIED. Build clean, 112 layout tests, 354 app tests. The preferences screen is measured by the layout
suite, so the longer copy is held inside the window at the minimum size rather than assumed to fit.
2026-08-07 08:43:49 +02:00
jaap-jan 507cd9ff88 Choose more than one host card on the desktop, the way the phone already can
The chosen-hosts set has been in VaultViewModel since the phone's connect card became a contextual action
bar: a set of entity ids, a tick on the row, and seven things that can be done to it. Only one head could
fill it. The desktop's grid answered a press with one selection — the card the drawer, CONNECT and the
context menu are about — so filing eleven imported machines under a heading was eleven drags, and clearing
out a vault was eleven rounds of the deletion question.

So the pointer gets three ways into the same set. Ctrl-clicks a card to tick it, Shift-clicks to tick the run
between the anchor and the card, and drags a band out over the space between and below the cards to tick
everything it touches. Esc, CLEAR, a plain click on a card and a click on the empty space each drop it, and
Ctrl+A takes every card being drawn — VisibleHosts, so with something in the find box that is the ones on
screen and not the ones it is hiding, which is the version of that shortcut whose result can be checked
before Delete is pressed.

TWO SELECTIONS ON ONE SCREEN, AND KEEPING THEM FROM DISAGREEING IS MOST OF THE CHANGE. Ctrl and Shift are
answered on the tunnel and marked handled, so the ListBox never moves its own mark onto the card: a
Ctrl-click that also selected would light the card it had just unticked and open the drawer on a machine
somebody is removing from a set. A plain press drops the set unless it lands on a ticked card, and that case
is deferred to the release, because the press may be the start of a drag of all of it. After any ordinary
click exactly one card is in play, which is what makes every command on the screen unambiguous again.

The context menu is where the seven live, and it is one markup with two halves gated on IsChoosingHosts.
Connect, Browse files and Edit… are drawn only for a single ticked host, as the phone's sheet collapses them
and for the same reason; the other four read better for a count. A right click on a card outside the set
drops the set first, so a Delete… about the card under the pointer can never be offered while six sit ticked
behind the menu — the same rule OnContextRequested has always enforced for the selection, reached from the
other direction. No bar of buttons: the phone raises one because it has no other way to hold seven entries,
and a strip repeating a menu that already exists would be a second home for the wording that matters most.
What the desktop gains instead is a count beside the HOSTS heading, CLEAR, and a sentence saying where the
actions are.

A drag that starts on a ticked card carries every ticked card. The payload is a list rather than a row now,
and a drop of more than one goes through FileChosenHostsUnder, which makes the refusals once — an open
editor, and a group belonging to one keychain — and reports a count instead of forty status lines. Moving
whichever card the pointer happened to be holding and leaving the other five where they are is a gesture
that quietly does a fraction of what it looks like it does, and the five left behind look filed.

The three panels the set's actions raise had never been drawn in a window: the vault picker with its key
question, the group picker, and the deletion question. All three sit above the grid rather than over it,
which is the arrangement the GROUPS section and the phone's list already use and for the reason written
there — the ticked cards are the information the question exists to give, so the grid shortens instead.

A DEFECT FOUND BEHIND IT, AND IT WAS ALREADY LIVE ON THE PHONE. The deletion question names a count and the
run that answers it reads the set again, and nothing kept the two the same set: the panel is deliberately
above a live list, so one more tick between "Delete these 6 hosts?" and pressing DELETE deleted seven, with
the seventh named in nothing the user had read. It needed a deliberate act on a phone and a second's work
with a band, which is what turned it up. VaultViewModel now remembers which hosts the question was asked
about and drops the question when the set stops being them — the question rather than the set, because what
somebody has just chosen is what they meant. It also covers the case nobody performs: a colleague's deletion
arriving mid-question and shrinking the set under it.

VERIFIED. 354 tests in App.Tests and 111 in App.Layout.Tests, build clean, no new warnings. Six gesture tests
drive real pointer and key input through the headless window — the modifier click and what it must not do to
the selection, the run and its re-measurement from the anchor, the band and the click that drops the set,
Ctrl+A under a filter, and the menu's two halves — plus a DragOver carrying two hosts. Four layout tests
measure the strip and the three panels at the window's minimum; the vault panel binds a key to its host
first, or it would measure the short shape and certify the tall one. Two flow tests cover the multi-drop's
write and its refusal, and the deletion question dropping itself.

manual-checks gains 7.6a for dragging a set, which no test can see for the reason 7.6 gives, and 7.7a for the
gestures — the rectangle actually being painted and the tick and the fill being legible together are the two
things the harness cannot look at.
2026-08-06 14:11:48 +02:00
jaap-jan 1e8a1f2e83 Merge branch 'claude/trust-connect-popup-56abec'
ci / android head (push) Successful in 3m28s
ci / desktop nightly (push) Successful in 43s
ci / build and test (push) Successful in 2m6s
ci / api image (push) Successful in 25s
2026-08-06 12:27:20 +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 6d6edb02c1 Keep an open editor's pickers in step with the vault
The host editor's four pickers were snapshots taken when it opened, and the
comment on EditorAuthenticationChoices said why: a picker whose contents move
under somebody halfway through a form is worse than a list a minute stale, and
only one editor could be open at a time anyway, so the only way to add a key was
to close this one. The second half of that stopped being true when
AHostEditorIsInTheWay was split from AVaultEditorIsInTheWay. The host editor is
the Hosts screen's business and the keychain's editors are the Vault screen's;
neither refuses the other now, which was the right split — it stopped three
quarters of a screen going inert over an editor the user was not looking at — but
it left the assumption those snapshots rested on false and nothing to notice.

So the ordinary way of using the feature was the broken one. Somebody starts
editing a host, finds there is no key to bind it to, goes to KEYS, makes one, and
comes back to a picker that does not have it — with the fix being to throw the
form away and start again. The same for a password, a tag, a group, and for a
whole vault made on the Teams screen because the host being typed belongs to the
team rather than to the person typing it: the vault they had just made for it was
the one place they could not file it.

RefreshOpenEditors refills whichever editor is open, and it hangs off ReloadAsync
rather than off the twenty-odd commands that write to the vault. That is the
choice worth stating, because it is what makes a sync count as well as a save: a
key pulled from another machine reaches the open editor by the same path a key
typed here does, and a place that wrote to the vault without refreshing the editor
would be a bug nobody would find for months.

What the old comment was protecting against is real, so every picker is put back
onto what it was already showing, by id, and not one typed field is touched. An
editor that reset its own bindings because a background sync landed would be a
worse bug than the stale list this fixes — it would rebind a host as a side effect
of somebody else's work. The placeholder entries go back too, which is the case
3.4 measures: a group deleted on another machine mid-edit still cannot unfile the
host when the form is saved. The group editor gets the same treatment for the same
reasons; it shares the drawer, and its default binding is lent to every host under
it.

The snippet editor's vault picker was the same copy of the same list and went
stale the same way. It watches TargetVaults rather than the reload, because that
screen has always been a wrapper over the vault's collections and has no reload of
its own to hang off — which is how it already follows Snippets.

The move panels are deliberately left alone. A vault arriving from a sync while
one is open still will not appear in it, but a move panel is opened by the act that
fills it and its picker resets its selection to the first entry on every rebuild,
so refreshing it would move a destination somebody had chosen. Same class of bug,
different answer, and not this change.

Five tests, and four of them were checked failing with the RefreshOpenEditors call
commented out: a key reaching the open host editor and binding when chosen, an
item arriving without moving a selection that was already made, a tag arriving as
an unworn chip, a key reaching the group editor, and a vault reaching the host and
snippet editors without moving either. Manual check 7.12 sits beside 7.11, which
is this same bug on the files screen's picker, and says what the worse failure
would look like: a picker that moves rather than one that does not notice.
2026-08-06 12:08:25 +02:00
jaap-jan f1d6499bb5 Merge branch 'main'
ci / build and test (push) Successful in 2m3s
ci / android head (push) Successful in 3m21s
ci / desktop nightly (push) Successful in 45s
ci / api image (push) Successful in 33s
Two of main's changes land in files this branch rewrote, and both needed carrying
across by hand rather than by the merge.

The phone's nav staying up on Connections with nothing running is a fourth input
to RefreshChrome, which this branch had already given two more — whether hosts are
ticked and whether the host editor is filling the screen. They compose: the rail
and the bottom bar now ask (pages || connectPage) && !editing, so a page-shaped
terminal surface keeps its way off the screen and the editor still takes the whole
display.

The key question under the host's move panel is the harder one, because this
branch deleted the panel it was added to. The connect card is gone and the phone's
only route to a move is the action bar, so leaving the merge to take this side
would have removed a capability main had just shipped — silently, since nothing
would fail to build. It is asked in the action bar's own picker instead, in two
shapes fewer than the desktop's: one host, because which key to carry is a fact
about one machine and a selection of six has six answers, and a move rather than a
copy, because taking the key out from under an original that is staying put would
leave that original unable to connect. BindingOfTheMovingHost splits into
MovableBindingOf so both heads answer it the same way from different panels.

Main also fixed a real trap in the same commit — a host that only inherited its
key from its group arrived in the destination naming nothing at all, because the
group stays behind — and the batch move had the same bug for the same reason. It
goes through Detached now, which is where that fix lives.

The carried host is written as the carry left it rather than being detached again,
which is the one thing worth measuring: the key takes a new id over there, so a
run that rebuilt the payload from the row would send the machine across naming a
tombstone. Both directions are pinned, along with the rule about which shapes the
question is asked in at all.
2026-08-06 09:30:00 +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 69858f82d1 Merge branch 'claude/vault-key-sync-sharing-d098aa'
ci / build and test (push) Successful in 2m0s
ci / android head (push) Successful in 3m21s
ci / desktop nightly (push) Successful in 41s
ci / api image (push) Successful in 33s
2026-08-06 07:39:32 +02:00
jaap-jan 185790fb14 Let a key move to another vault, and ask whether it goes with the host
Keys sync and keys are shared: SshKey is in the sync registry on both sides, the
material rides in the sealed payload, and every generation of the vault key is
wrapped to a new member. What was missing was the way in. Hosts and groups could
move between vaults and keychain items could not, so a key typed into a personal
vault before the team existed stayed there for good — and moving a host into the
team's vault left it authenticating with something nobody else in that vault can
read. The code said so and could do nothing about it: "the answer is usually to
put a copy of that key in the destination vault", which meant pasting the private
half into a second item and deleting the first. A private key on a clipboard, and
two items nobody can tell apart afterwards.

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

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

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

MOVE is on both heads, for keys and passwords only: a tag, a bucket and a pin are
read from the active vault alone, so "another vault" is not a question any of them
has. Four tests cover the move and its re-aim, the host's move with the key
brought and without it, and the inherited binding.
2026-08-06 07:39:15 +02:00
jaap-jan cddfeb1f55 Keep the phone's nav under Connections when nothing is running
The chrome stands down for a shell, and it was standing down for the whole
terminal surface. Those parted company when that surface gained a connect page:
with no tabs open it draws a box, a CONNECT button and the machines connected to
before, which is a page in everything but which enum it is in. A third of the
display is worth giving to a shell and is not worth giving to that. Worse, it is
the one screen somebody arrives at by closing their last tab — so the state the
collapsed bar was most likely to be seen in was the state where it left the
system back gesture as the only route to Hosts or Settings.

So RefreshChrome reads one more question. IsTerminalSurface with no tabs joins
the pages in both flags, which keeps the rail and the bar in step: above 600dp
the rail is the bar, and fixing only the narrow layout would leave an unfolded
device on the same screen with the same nothing. The vault header is deliberately
not part of it. The surface draws its own bar with back and the +, and a header
above that is the second row of chrome this head exists to avoid.

The Connections entry lights for the first time, on IsTerminalSurface. It was
left unbound on the argument that the bar was never drawn while that surface was
up, so a lit state was unreachable — that argument is now false, and the flag is
unambiguous on a control that is only drawn in two situations: false on every
page, true on the connect page, and never read while a shell is showing. A bar
sitting under a screen it does not point at is the entry looking broken instead.

Nothing here is testable on this head — the phone's rectangles have no coverage,
for the reasons Phase 8 of manual-checks records — so 11.7 gains the check that
the bar is there with Connections lit, and 11.1 keeps the one that it is gone
with a shell up, which is the half that pays for the arrangement.
2026-08-06 07:38:31 +02:00
jaap-jan 174ef7c420 Merge branch 'claude/android-release'
ci / build and test (push) Successful in 1m57s
ci / android head (push) Successful in 3m13s
ci / desktop nightly (push) Successful in 41s
ci / api image (push) Successful in 23s
2026-08-05 22:49:51 +02:00
jaap-jan af0e29a98b Give the desktop a nightly channel, the way the phone has one
ADR 0014 gave the phone a nightly and ADR 0013 rule 3 gave the desktop none, so
the two heads had different answers to the same question — how does somebody try
what is on main? — for no reason except the order the work happened in. This is
the desktop's answer: CI publishes a build from main on every push, and it
installs beside the release one rather than over it.

The phone gets its separation from the platform. Android refuses an update signed
by a different key, so its two channels cannot replace one another whatever
anybody does. Nothing refuses anything here: Velopack applies what its feed serves
and verifies no signature. So all of it is construction, and there are four
separations because each closes a different door.

A pack id each, so the two install in different directories and neither feed's
package can be applied to the other's install. A Velopack channel each —
win and win-nightly — so neither build ever reads the other's release index; the
name reaches the wire as releases.win-nightly.json, which is why the constant in
VelopackUpdateChannel and the argument in ci.yml have to agree or the channel
answers nothing forever with no error. A prerelease flag, so the release channel
cannot see the nightly even by accident. And a profile directory each, which is
the one that is easy to skip and would hurt most: the cache schema is migrated on
every launch, before unlock, so a shared profile means a nightly quietly upgrading
a database the release build then opens. Both are installed at once by design, so
that is an ordinary Tuesday rather than a corner case.

The prerelease flag turns out to be load-bearing across heads as well. The phone's
release channel reads releases/latest, which skips prereleases — so a desktop
nightly published as a stable release would become the newest release in this
repository and every phone on the release channel would start failing its check
against a release carrying no Android manifest.

Which build this is arrives as assembly metadata, the same mechanism and the same
reasoning as the Android head: the updater needs the string rather than a branch,
and a value baked into the assembly is one a crash report can be asked for. Three
things read it — the feed, the prerelease flag, and the profile — and one more
shows it: the titlebar says DodoSSH Nightly. Everything else that distinguishes
the two is somewhere nobody is looking while typing a passphrase into one of them.

The version needed a floor and it is applied to the whole build rather than to the
packaging. MinVer answers 0.0.0-alpha.0.N until the first v* tag and vpk refuses
anything below 0.0.1, so the job lifts the patch digit and keeps the height —
through MinVerVersionOverride, so the assemblies carry the same number the
installer does. Packing a version the assembly disagreed with would put one string
on the preferences screen and another in the feed, which is the screen somebody
reads when asked which nightly they are on.

Two things found by running it rather than reading it. -t:MinVer needs a restore
first, because the target arrives with the package and MSB4057 on a clean checkout
reads like a typo in the workflow rather than a missing restore; the release
script had the same gap and now restores before it reads. And vpk rejects an empty
--packVersion loudly, which is how a broken version handoff announces itself
rather than shipping a package called 1.0.0.

Rule 3 is untouched. The release channel still has no job, no token and no runner,
and the two channels cannot see each other. What a nightly costs is written where
somebody reads it before installing one: whoever can write a release here can put
a build on every nightly machine, which is fine for a build being tried and is not
fine for a build holding somebody's infrastructure credentials.

Verified by running the job's own steps against a clone in a Linux container:
DodoSSH.Desktop.Nightly-win-nightly-Setup.exe, and an index naming pack id
DodoSSH.Desktop.Nightly at 0.0.1-alpha.0.144. The upload itself is the one step
not exercised — it needs a real forge and a write token, and check 16.10 is what
walks the half no runner can.
2026-08-05 22:35:51 +02:00
jaap-jan 8591035170 Merge branch 'claude/pipeline-curl-not-found-97e70f'
ci / build and test (push) Successful in 1m56s
ci / android head (push) Successful in 3m18s
ci / api image (push) Successful in 25s
2026-08-05 22:31:42 +02:00
jaap-jan ca7fee2358 Start the confirmation Android hands back, so an update can install
Pressing INSTALL closed the application, installed nothing and said nothing. That
is two independent faults in one method, either of which breaks it on its own,
and they hid each other: the first kills the process before the second can be
observed, and the second is silent by construction.

The pending intent handed to commit was implicit — an action string with no
component behind it. A mutable pending intent may not wrap one of those from API
34, and this head targets 36, so every current phone threw IllegalArgumentException
before commit was reached. Nothing caught it, so it left the command handler,
passed the dispatcher and took the process with it. That is the closing.

Below 34, where it did not throw, it still installed nothing. An application
holding REQUEST_INSTALL_PACKAGES rather than the privileged INSTALL_PACKAGES gets
no verdict back from a commit: what the platform answers first is
STATUS_PENDING_USER_ACTION, carrying the activity that draws the dialogue in
EXTRA_INTENT for the application to start. Android does not draw it on its own.
The comment here asserted the opposite — that a pending intent is required whether
or not anything listens, and that nothing needed to — so no receiver was ever
written, and the session was written, committed and left staged forever.

So there is a receiver now, not exported because the only sender is this
application's own commit, and the intent naming it is explicit, which is the same
change that stops the throw. Sessions are abandoned when anything fails, since one
created and neither committed nor abandoned stays staged against a per-application
cap — a repeating fault would have started failing at CreateSession instead, which
is the same bug wearing a completely unrelated face.

The reporting is the part worth keeping even after the cause is gone. Where
applying ends the process an exception has nowhere to go; where it does not, which
is this head's whole shape, it goes out through the dispatcher. RestartNowAsync now
answers the way CheckNowAsync already did, and the regression test asserts the
absence of a throw rather than the presence of one.

ADR 0014 rule 6 gets the correction in place: "asks Android to ask" is one step
longer than it reads. Check 17.5 needed no rewording — it asks for the installer
appearing by name, which is exactly the thing that never happened — so what it
gets instead is the two symptoms named, because both present as a dead button. It
is the only thing in the project that can catch either, and it plainly was never
run against a real pair of builds.

Note for whoever takes the next nightly: a broken updater cannot install its own
fix. The phone is running the code this commit replaces, so the first build
carrying it has to be sideloaded by hand; the ones after that install normally.

Compile-verified and manifest-verified — the receiver reaches the generated
manifest — and 321 tests pass. Not run on a device, which is what 17.5 is for.
2026-08-05 22:31:33 +02:00
jaap-jan 3d3d0bc95f Package the Windows client in CI, on the runner that could not
The build job published a win-x64 tree and stopped there, so the half of a
release that fails in ways a compile cannot see was proved by nobody until a
person was midway through cutting one. It now packs as well: vpk opens the
published binaries and verifies the main executable really calls
VelopackApp.Build().Run(), which is the check worth having — a refactor that
drops that call compiles, tests green, and produces an application that silently
never updates itself.

The file said this was impossible on Linux, and also said it was fine, in
comments forty lines apart. The claim that vpk needs Windows tooling to stamp the
Setup.exe stub is the one that was wrong: vpk cross-compiles when told to, and
the telling is a bracketed directive before the verb rather than a flag. Plain
`vpk pack --runtime win-x64` on a Linux host refuses outright and says so in the
message that names the fix. `[win]` must be quoted, or the shell reads it as a
glob matching any one of w, i and n. Only signing needs Windows, and nothing here
is signed yet.

Fixing that does not move ADR 0013 rule 3 an inch, which is why the two reasons
were recorded separately in the first place. What may not live on a runner is the
token, not the build: Velopack clients apply what their feed serves without
verifying a signature, so whoever can write a release can ship an update every
install runs. The packages go to RUNNER_TEMP and die with the job. They are not
offered as workflow artefacts either — an installer nobody has run should not sit
somewhere that invites passing it on.

Written out as shell rather than by calling scripts/release-windows.ps1. That
script is a person's procedure and holds things a runner must not have and must
not skip: it refuses a dirty tree, insists HEAD is tagged, downloads the previous
release for deltas, and asks for the forge token. Calling it would mean either
weakening it with CI switches or having CI satisfy conditions that only make
sense at a desk. The constants the two now share — pack id, title, authors,
channel, icon — are a contract with VelopackUpdateChannel and with every
installed client, and both sides say so.

Two things fell out of running the steps rather than reading them, and both were
in code nothing had ever executed:

dotnet msbuild -getProperty:Version answers 1.0.0. Without a target named it
evaluates the project and runs nothing, and MinVer computes inside a target — so
the read comes back as the SDK default on a full checkout with every tag present.
That line is the tag check in this file, which is `if:` a tag ref, and there are
no tags yet: the first release ever cut would have been refused by its own guard,
which would then have blamed fetch-depth. release-windows.ps1 had the same line
and would have demanded HEAD be tagged v1.0.0. Both now pass -t:MinVer.

And MinVer answers 0.0.0-alpha.0.N until that first tag exists, which vpk rejects
outright as below 0.0.1 — so packing the true version could not have worked on
any build made today. The patch digit is lifted for the throwaway package only.
The release script gets no such floor and must not: its version is the one users
compare against, and there the refusal is the right outcome.

Verified by extracting both steps from this file and running them against a real
clone in a dotnet SDK container: Setup.exe, the portable zip, the .nupkg and
releases.win.json, from a machine that is not Windows.
2026-08-05 21:56:45 +02:00
jaap-jan 5cb361ea13 Lay the phone out like the desktop when the surface is not a phone
ci / build and test (push) Successful in 2m54s
ci / android head (push) Failing after 3m15s
ci / api image (push) Successful in 46s
Three destinations in a bar and everything else behind SETTINGS is the right
shape at 360dp, where a fourth entry costs the width of the three that are there.
On a tablet, an unfolded foldable or a landscape phone it is the wrong one: there
is room for every destination at once, and the hub becomes an extra tap between
somebody and a screen they can already see space for.

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

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

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

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

What this does not do is use the width *inside* a screen — the host list is one
column at any size. Two columns needs the row model to change, because that list
is headings and hosts in one sequence and a heading has to span, and that model
is shared with the desktop. Check 8.1 walks the rail; nothing here is covered by
a test, for the reason 8.0 exists.
2026-08-05 20:50:18 +02:00
jaap-jan 0c61ea3a97 Let a vault be shared from the phone, not only read there
ci / build and test (push) Canceled after 0s
ci / android head (push) Canceled after 0s
ci / api image (push) Canceled after 0s
This screen's own comment argued ADD out: an address typed into a box, a
directory lookup, a role picker and a paragraph saying what adding somebody did
not do, for an act a colleague at a desktop is already performing.

That was a cost argument and it was wrong about who is holding what. The person
who needs to let somebody into a vault is often the one away from their desk, and
answering them with "go and find a desktop" is the thing this head exists to stop
doing. Making a vault was already here on exactly that reasoning.

Nothing shared changed — AddMemberCommand, the role and the chips are the same
members the desktop binds — so what this is, is markup and the argument it
reverses. Four rows under the members list: the box, three role chips rather than
a picker because the answer is one of three short words, ADD, and the paragraph.
Gated on being able to administer the vault, so a plain member sees nothing
rather than a button whose only outcome is a 403.

The paragraph is not the optional part. Adding somebody changes what the server
will serve and nothing else; the key is still wrapped by a machine that holds one
— which on an unlocked phone is this one, in the same press. A screen that
offered the first and stayed quiet about the second would imply the server can
hand out access, which is the single claim this product is built to refuse.

What the phone still does not draw is anything that takes access away. REMOVE and
WITHDRAW KEY act on the first press, and an irreversible revocation under a thumb
with its explanation in a tooltip no touch screen can show is the wrong trade —
which is the line this file already drew and this does not move.

Check 12.4 walks it, including the locked-keychain case: the membership is made
and the line says the key could not be wrapped, which is a state somebody can act
on rather than silence.
2026-08-05 19:10:25 +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 253c72d2b7 Name the organisation the repository actually lives in
ci / build and test (push) Canceled after 0s
ci / android head (push) Canceled after 0s
ci / api image (push) Canceled after 0s
It moved to DodoTech-Public, and every address in the product still said
DodoTech. That looked like it worked, which is the part worth writing down:
Gitea leaves a 301 at the old path and HttpClient follows a redirect on a GET, so
both update channels would have kept polling through it.

What a 301 does not survive is a POST. `vpk upload gitea` publishes the desktop
release by POSTing to that URL, so the stale address would have failed at the one
step the whole feature depends on — and a redirect is a thing an operator can
delete, which turns "works today" into the same silent outage this session has
already spent two commits on.

So both channel constants, both release scripts, the workflow's REPO, the image's
source label and the curl in phase 16 all name the live path. The local remote
too, which had been printing a redirect warning on every push.

Measured after the move: the org, the repo and the nightly release all answer 200
anonymously, and that release now carries both assets — the manifest and a 54 MB
APK. The upload going through also answers the open question about the reverse
proxy's body-size limit, which nothing local could test.
2026-08-05 12:51:19 +02:00
jaap-jan 23f1db9dc8 Stop the terminal's accessory keys taking the keyboard off it
ci / build and test (push) Canceled after 0s
ci / android head (push) Canceled after 0s
ci / api image (push) Canceled after 0s
Ctrl, Esc, Tab, the arrows and the two text-size keys were ordinary Avalonia
buttons sitting over a NativeWebView. An ordinary button takes focus on tap,
which takes it off the WebView — and the package's own OnLostFocus then calls the
adapter's ResignFocus(). So pressing Tab handed the terminal one byte and took
the keyboard away from it, and everything typed afterwards went nowhere.

What makes it worth more than a one-line fix is the symptom. The row goes on
working, because its keys are pressed rather than typed into, so what you see is
a terminal that answers the buttons and ignores the keyboard — which reads as the
session having died rather than as anything to do with focus.

Focusable = false is what a toolbar button is: these keys are an extension of the
keyboard, not a place it should go. The focused element then never changes, so
nothing resigns and nothing has to be handed back — which matters, because the
hand-back is the direction platform-flags already records as the hard one.

The flags file gains the phone's half of that entry, and check 11.10 is the
measurement: this needs a paired hardware keyboard and there is no test on this
head that could stand in for one.
2026-08-05 12:45:17 +02:00
jaap-jan 33d4c3ff48 Check the organisation as well, because in Gitea the organisation wins
ci / android head (push) Successful in 5m51s
ci / build and test (push) Successful in 1m39s
ci / api image (push) Successful in 25s
Setting the repository public changed nothing: /api/v1/orgs/DodoTech answers 404,
and a Gitea org's own visibility gates everything under it — a public repository
inside a Limited or Private org is invisible to anyone not signed in.

So 16.0 now checks both, and says which answer means which. It also names the
signal that tells this apart from an instance requiring sign-in for everything:
/explore/repos answering 200, which this one does, so what is hidden is hidden on
purpose rather than by policy.
2026-08-05 12:06:09 +02:00
jaap-jan e0655dbb31 Look the host list up by name, rather than off a field that is never assigned
ci / build and test (push) Successful in 1m51s
ci / api image (push) Canceled after 0s
ci / android head (push) Canceled after 3m51s
Nightly 0.0.0-alpha.0.133 died before its first frame. The long press I added
attached itself in HostsScreen's constructor through the field the Avalonia name
generator declares for `x:Name` — and that field is assigned by the generated
InitializeComponent, which no view in this repository calls. Every one of them
loads its XAML directly. So the field compiles, resolves in the editor, and is
null at run time; PhoneShell builds this control on the way up, so the
NullReferenceException took the launch rather than the hosts screen.

PhoneShell and TerminalScreen both use FindControl, and PhoneShell carries a
<remarks> saying exactly this and naming exactly this consequence. I read neither
and wrote the field.

So the rule is in docs/platform-flags.md now as well. A comment on the control
that already got it right is not where somebody writing a new one is looking,
which is the whole of why two correct examples and one warning were not enough.

And phase 8 opens with "it launches at all". Nothing on this head is covered by a
test — no test project, no headless surface — so a view that throws while being
built takes the launch with it and no gate anywhere says so. Thirty seconds, and
it would have caught this one before it was published.
2026-08-05 12:00:20 +02:00
jaap-jan 9a7e3bbd5c Let a failed update check say so, instead of reporting good news
The phone reported every build as current because the release repository is
private. Gitea answers 404 rather than 403 for a repo you cannot see, the client
reads that address anonymously, and AndroidUpdateChannel caught the failure and
returned null — which IUpdateChannel documented as meaning "this build is the
latest". The check had never once succeeded on any phone and nothing anywhere
said so.

Two faults, and the second is why the first lasted.

The seam said null was the honest answer for an unreachable channel, on the
reasoning that the caller does the same thing either way. That is true of the
six-hourly pass and false of CHECK NOW. UpdateViewModel already draws the line
correctly — silent on the timer, the exception's message on the button — and it
could only ever draw the first half, because nothing was ever thrown at it. The
desktop's channel does not catch, so the interface described neither
implementation.

So CheckAsync throws now, and null means one thing. A release that is reachable
but missing its manifest or the APK it names throws too: "you are up to date"
about a half-published feed is the same lie in a smaller costume, and the
self-healing that argument protected is untouched, since the timer still swallows
everything.

The precondition is written down where somebody would look, rather than left as a
sentence about where a token could live. ADR 0013 §4 already said a private
release repository was incompatible with this design; nobody checked which side
of it this repository was on. It is one curl, and manual-checks phase 16 now
opens with it — pointedly not against /api/v1/version, which answers 200 from a
forge that is up whatever is readable on it, and which is what made this look
like nothing was wrong.

Phone check 17.4 was the one that passed all along. It now presses CHECK NOW with
the network off as well as on, because two different answers are the whole of
what makes the first one worth reading.
2026-08-05 11:17:44 +02:00
jaap-jan ca07d63585 Offer to bring the keys an ssh_config points at
ci / build and test (push) Successful in 2m0s
ci / android head (push) Successful in 6m5s
ci / api image (push) Successful in 42s
An import that recorded a key path and left every host asking for a password was
an import whose result did not connect. The answer to that was a manual paste per
key, which is the sort of thing people do once and then stop importing.

So there is a tick, and it starts off. With it off nothing changes: an
IdentityFile becomes a note and the host asks for a password. With it on, IMPORT
reads each host's first IdentityFile out of ~/.ssh, stores it in the vault
encrypted like any other key, and binds the host to it.

Three things about how it is drawn are load-bearing rather than tidy. It is a
default nobody arrives at by accident. The sentence beside it names the directory
rather than saying "your keys", because that is what somebody is agreeing to. And
nothing is read during SCAN — tick it, read what it says, untick it, and no
private key has been opened. This is the only place the application opens key
material out of a directory the user did not point at file by file, and the whole
of what makes that acceptable is that it took a deliberate press.

One vault key per file, however many entries named it: an ssh_config pointing
twelve hosts at one id_ed25519 is the ordinary shape, and twelve copies would be
twelve things to rotate and eleven to forget. A file whose material is already in
the keychain is bound to rather than stored again, which is what makes running
the import twice harmless.

What cannot be read off a disk is a passphrase, so a protected key arrives
without one — and the report under the button names those files rather than
leaving a host to fail at connect time with a message about a malformed key.
Telling them apart means decoding for OpenSSH's own container, whose cipher name
is the first field inside the base64 rather than anything in the armour, and that
is the format ssh-keygen has written by default for years. The 88 base64
characters it decodes need 66 bytes, not 64: with the smaller span every
protected key came back unprotected, which the tests now pin.

A path that is not on this machine leaves its host imported and unbound, exactly
as it would have been with the tick off, and is named in the same report. A
config carried from another machine is the ordinary case, not an error.
2026-08-05 08:58:28 +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 69bc9e270b Let a team be joined only by somebody who is already here
An invitation decided access from an assertion about an address. Everything else
in this model decides it from something a person did — an admin naming an
account, a key holder wrapping a vault key to a key they verified — and this was
the one place a token's email claim was the thing that let somebody in.

It was guarded as tightly as that can be guarded: the claim was refused outright
on an unverified or absent `email_verified`, with no setting to relax it. But the
guard and the risk were the same shape. The whole defence was one boolean sent by
a system the deployment does not control.

So `POST /teams/{id}/members` is the only way in, and an address with no account
is refused with `no-such-account` — which is now the end of the road rather than
the signal to invite. Both clients say the remedy: that person signs in here
once, which is what creates the account, and then they can be added. The desktop
leaves the address in the box, because a message telling you to come back later
is one you act on later.

Gone with it: the `team_invitation` table, the claim hook in the sign-in path,
and `Oidc:EmailVerifiedClaim`, which that hook was the only reader of. Nothing in
the server now reads the email claim to decide anything.

Pending invitations are dropped rather than converted. Converting one would mean
creating a membership because an address matched, which is the property being
removed — and an invitation to an address that did have an account here had
already been claimed by the hourly sweep, so what is left is offers to people who
never arrived.

Two tests carry the property rather than the feature: the endpoint inventory
asserts the three routes are absent, and the API suite adds an address that has
no account, watches the refusal, then signs that address in and checks it joined
nothing. Without the second half, a server that merely renamed the deferred path
would pass.
2026-08-05 08:28:57 +02:00
jaap-jan a18ca56fde Copy the checkout into the container, because a socket is not a shared filesystem
ci / build and test (push) Successful in 1m51s
ci / android head (push) Failing after 6m24s
ci / api image (push) Successful in 23s
The container started and could not see the repository:

  MSBUILD : error MSB1009: Project file does not exist.
  Switch: src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj

`-v "$PWD:/build"` cannot work here. This runner is itself a container holding the host's Docker
socket, so the workspace path it reports — /root/.cache/act/<hash>/… — exists in the runner and not on
the daemon's host, which is where Docker resolves a bind source. It finds nothing, creates an empty
directory, and mounts that. Nothing about the failure says so.

Nothing earlier in this workflow would have caught it either, and that is the part worth keeping: the
image job's `docker build` sends its context over the API and Testcontainers mounts nothing, so neither
of the two places this repository already used Docker proves a bind mount would work. I read a working
daemon as a shared filesystem, and they are not the same claim.

`docker cp` goes over the same API and so does not care where the daemon lives. In with the whole
checkout, .git included, since MinVer and the versionCode both read it — the repository is well under a
megabyte packed. Out with the staged package. The NuGet cache becomes a named volume for the same
reason: it lives on the daemon and needs no path either side has to agree on.

The two container steps collapse into one, since with a copy in and a copy out there is nothing to be
gained by paying for both twice, and scripts/ci-android.sh is now what runs inside — a file that can be
read and executed on its own rather than a heredoc inside a workflow.

Exercised locally against a real clone, every step as the job runs it: create, cp in, start --attach,
cp out, parse the manifest on the outside.

  package: name='dev.dodotech.dodossh.nightly' versionCode='196' versionName='0.0.0-alpha.0.129'

The second run took 2m25s against the first run's 8m, which is the named volume doing its job.
2026-08-05 07:29:53 +02:00
jaap-jan 30a3edb1d4 Build the phone in a container, because this runner cannot build it at all
ci / build and test (push) Successful in 1m54s
ci / android head (push) Failing after 3m54s
ci / api image (push) Successful in 44s
The runner is Alpine, and .NET for Android does not work on musl. Not "needs setting up" — the SDK's
own MSBuild tasks pull glibc shared objects out of the workload pack into the build process, and a
musl-linked dotnet will not load one:

  error XARLP7000: Error relocating .../libZipSharpNative-3-3.so: __snprintf_chk: symbol not found

That is a glibc fortify symbol musl does not implement, reached through a DllImport rather than an
exec, so gcompat is no help: it gets a glibc *executable* started, which is a different problem. There
is no musl variant of the pack.

Everything this job did on the host to make Android work was therefore treatment of symptoms, mine
included. The missing aapt2 was present. The "unsupported version" was of a binary that had never run.
Both were this one sentence in a different accent, and the loader was the accent, not the sentence.

So the toolchain moves into build/android-build.Dockerfile — Microsoft's own sdk:10.0-noble plus a JDK,
the Android SDK and the workload — and the job keeps on the host only what the host is good at:
checkout, git, publishing. The daemon needed no arranging, since the image job already builds with it
and every Testcontainers suite reaches it over the socket. The image is tagged by the digest of the
Dockerfile that made it, so on a persistent runner every run after the first is a cache hit, and a
change to the toolchain is the only thing that buys a new one.

Built rather than pulled: a community image with the Android SDK already in it would put a stranger in
the path of a package this project signs and publishes. Eleven lines of apt and sdkmanager is the
cheaper trade.

Verified end to end in that image against a real clone rather than reasoned about, which after three
rounds of reasoning seemed the least I could do. Restore under locked mode, Release build, then
SignAndroidPackage:

  package: name='dev.dodotech.dodossh.nightly' versionCode='195' versionName='0.0.0-alpha.0.128'
  Signer #1 certificate SHA-256 digest: a9f067877724ddb0fdc04b637fbd5bfb97df753976616f100b48b522e132ba22

which is the keystore in build/. The versionName carries MinVer's height, so the csproj's target fires
in the container too, and the manifest the feed publishes parses back on the host.

Staging moves from RUNNER_TEMP to artifacts/, which is forced rather than preferred: the package is
made inside a container and read outside one, so it has to land under the bind-mounted checkout.
2026-08-04 23:28:47 +02:00
jaap-jan 65256fa337 Take the phone's aapt2 from the SDK the job installs rather than the workload's
ci / build and test (push) Successful in 1m49s
ci / android head (push) Failing after 7s
ci / api image (push) Successful in 30s
The android job got past restore and died in the .NET Android SDK's own tooling resolution:

  warning : An error occurred trying to start process
            '.../packs/Microsoft.Android.Sdk.Linux/36.1.69/tools/Linux/aapt2' … No such file or directory
  error XA0111: Unsupported version of AAPT2 found at path '.../tools/Linux'

The error names the wrong problem. Nothing was found, so nothing had a version, and XA0111 points at
an Aapt2ToolPath in the project file that has never been set. The warning above it is the real message
and it is only a warning.

The pack was incomplete, and on this runner it would have stayed that way: act's host executor keeps
/usr/share/dotnet between runs, and `dotnet workload install` reads the installed-workload records and
does nothing when android is listed, whatever is on disk. So two changes, each of which stands alone.

The build and the packaging step are now given -p:Aapt2ToolPath pointing at build-tools, which this job
installs itself and can therefore vouch for. That is already the aapt2 the packaging step shells out to
for `dump badging`, so this makes one tool of what were two, and the manifest the feed publishes is now
read by the binary that wrote it. Checked rather than assumed: build-tools 36.0.0 answers aapt2 2.20 and
.NET for Android 36.1.43 builds and packages this head against it with no complaint. The version is
named once, in the step's env, because three things now depend on it agreeing with itself.

And the workload step probes for the pack file that went missing and repairs the workload when it is
absent. The probe is a witness rather than the point — the build no longer touches that binary — but a
5 MB file near the end of a 130 MB package is what a truncated extraction loses first, and r8.jar and
manifestmerger.jar are what it loses next.
2026-08-04 22:32:52 +02:00
jaap-jan 7e0a1b2af8 Bring the phone's lock file back to the graph it actually restores
ci / build and test (push) Successful in 1m54s
ci / android head (push) Failing after 14s
ci / api image (push) Successful in 27s
The android job's restore has never been reachable — the runner had no JDK and no SDK, so it failed
before it got there — and DodoSSH.Client.Android is deliberately outside DodoSSH.slnx, so the solution
restore that keeps the other fourteen lock files honest has never seen this one either. It went stale
for a whole release and nothing could say so.

Two things had drifted by the time the repaired job reached the step: MinVer, added to
Directory.Build.props for the desktop updater, and the ABI set, which grew when the -r android-arm64
pin came off the packaging step so that the nightly is installable on more than an arm64 handset.

--force-evaluate on this project alone, and the fourteen it references come back byte-identical: an
android-* RID is not a graph any of them has a package for. Checked rather than assumed, because this
is the same mechanism that once put win-x64 into the server's lock files and broke its image build.

docs/platform-flags.md records the lasting half, which is not this fix: any change to a shared props
file touches a lock file this repository cannot verify from a machine without the Android workload.
2026-08-04 21:58:02 +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 50fa6fba38 Let the phone delete a host, and move or remove a group
The desktop gained three things the phone did not follow: moving a group to
another vault, the second question asking whether a group's deletion takes its
hosts with it, and — since long before either — deleting a host at all. What
that left was a head whose v3 + can fill a keychain and whose editors can
correct one, with no way to empty either.

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

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

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

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

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

Seven tests, all phone-shaped — a group acted on with nothing selected, the menu
waved away leaving nothing armed, the ungrouped heading raising none, and the
bar's three states. The rectangles remain unmeasurable for the reason phase 8
gives; the checks for them are 8.10 to 8.13 and 13.6.
2026-08-04 19:58:10 +02:00
jaap-jan 3ead865f01 Merge branch 'main' into the desktop updater, and give way on two numbers
Main landed a realtime push feature while this branch was building the updater,
and the two collided in three places. Every one of them resolves the same way:
main got there first, so this branch moves.

**Two ADRs were both numbered 0012.** Main's is realtime push; this one is now
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md). Git did not call
this a conflict — the filenames differ — so it would have merged quietly and left
the directory with two 0012s and every cross-reference ambiguous. Renumbered here
along with the nine places that point at it.

**Two manual-check phases were both numbered 15**, and that one git did catch.
Main's "Changes that arrive without a timer" keeps 15; installing and updating
the desktop client becomes Phase 16, with its checks and every reference to them
renumbered. The file's own rule is that a number is for life, which is exactly
why the one that had not been pushed is the one that gives way.

**The merge rewrote several files with CRLF**, and `.editorconfig` asks for LF on
everything except `*.ps1`. That is not cosmetic here: IDE0055 is an error and
`EnforceCodeStyleInBuild` is on, so it failed the build on three lines of
App.axaml.cs whose only change in this branch was an ADR number in a comment.
Forty-six files normalised back to LF; the release script keeps CRLF, which is
what `.gitattributes` and `.editorconfig` both already say for a PowerShell file.

Nothing else conflicted. The updater does not touch the sync loop or the event
stream, and the one file both sides edited heavily — MainWindowViewModel — merged
without a hunk in common.

Verified after merging: the solution restores locked and builds clean, and 304
shell, 100 layout, 54 session, 28 client-api and 25 contracts tests pass. The
first two counts are higher than before the merge because main's own tests came
with it and pass alongside these.
2026-08-04 17:52:57 +02:00
jaap-jan 780f4bf892 Merge branch 'main' into the group's move and its deletion question
Main took the group's EDIT and DELETE off the GROUPS heading while this branch
was adding a MOVE beside them, so the conflict was about the same six pixels
from both directions. Main's answer wins outright, and it is the better one for
the reason its own message gives: a button beside a heading has no card under a
pointer to mean, and had to work its subject out from the selection or from the
trail. Moving a group had that problem worst of all — the thing it takes with it
is everything on the shelf, and "which shelf" is not a question a button there
could answer plainly.

So the MOVE button is gone and the menu entry it was drawn beside is the whole
of it. That entry was already in this branch, above the separator DELETE sits
below, and it needed no change: the card menu selects whatever was right-clicked
before it runs anything, which is exactly the aiming a group move wants.

Three things went with the button. ShowsGroupActions, which main deleted because
hiding buttons was all it did, and which this branch had extended to hide them
for the move panel as well. CanMoveGroupTarget, which existed to answer whether
that button was worth drawing — CanMoveSelectedHost stays, because the phone
really does leave the host's MOVE out rather than offer a refusal, and a menu
whose entries came and went would be a menu whose items move. And the two test
assertions that read them, which were describing the button rather than the
behaviour; what they were guarding is that the two panels never share the
moment, and IsConfirmingGroupDeletion says that directly.

The move panel and the deletion question both keep their place under the
heading, which is where the buttons were and is now simply where that section
puts things. They still exclude each other, by disarming rather than by a
visibility flag: MoveGroup clears a pending deletion and DeleteGroup folds the
move panel away.

Manual checks 3.3 was rewritten by main for the menu and by this branch for the
tick, and now says both; 3.3a is new and walks a two-level shelf across a vault
boundary, which is the half of this feature no headless test can watch land.
2026-08-04 17:11:06 +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 a86731ee08 Move the shelf as well as what is on it, and ask what a deletion takes
Two things about a group, and they turn out to be the same argument twice.

**A group can be moved to another vault, and it takes everything on it.** The
host's move shipped last week and stopped one level too low: moving twenty
machines into a shared vault meant twenty trips through a menu, and each one
arrived stripped of the group it had been filed under, so the shelf had to be
rebuilt by hand on the other side. Moving the shelf is what people were
attempting. MOVE sits between EDIT and DELETE over the group cards, and on the
card's own menu above the separator DELETE is below — the same place, and the
same reasoning, as the host pane's ⋯ entry.

**The whole subtree goes, and taking less was never coherent.** A group's
children are items of the vault it is leaving, so a parent moved alone leaves
them naming a tombstone and they surface as roots in the vault the user has just
emptied: half a shelf here and half there, from one gesture that said "move
this". The hosts are the same argument and are the half the request was about.

The groups go first, top down, and the hosts last. Each item is re-sealed under
the destination's key and takes a new id — VaultItemRepository.MoveAsync, which
HostGroupRepository now exposes — so nothing pointing at a group can be written
until that group has landed and its new id is known, and a child's parent must
already be over there. What an interruption leaves is therefore hosts still in
the vault they started in, under UNGROUPED: visible, and re-movable. The reverse
order would leave hosts in the destination filed under nothing.

The parent stays behind and the tags are dropped, which is the host move's rule
one level up: both are items of the vault being left, so a reference carried
across would resolve on the machine that moved it and dangle for everybody else
in the destination. The moved group arrives at the top level, and the panel says
so before the press rather than the status line saying it after. Keys and
passwords are kept — those genuinely resolve across vaults, and clearing them
would take a working host and make one that cannot connect — and any now outside
the destination is named, because that is precisely what the other members of it
will not be able to resolve.

Refused as a whole where anything under the group was written by a newer client,
rather than skipped item by item: a move that left behind what it could not
re-encode would file some of the shelf in one vault and the rest in the other,
which is the state this exists to prevent. Refused with a host editor open, as
the drop gesture is, because it rewrites hosts. And the walk carries a visited
set, for the reason every walk over this tree does: a group that is its own
parent — which two offline clients can build and no editor was ever shown —
would otherwise be appended to the move list for as long as there was memory.

**Deleting a group now asks what should become of the hosts under it, and that
reverses a decision this repository had written down.** The deletion did not
touch them: the reference was left dangling, the list resolved it to nothing,
and the machines turned up under UNGROUPED. That was right for one of the two
things people delete a group for and wrong for the other — a heading being tidied
away should leave its machines alone, and a project that has been decommissioned
is a shelf and everything on it — and nothing in the code can tell which of the
two it is looking at. So it is asked.

A tick rather than a pair of options, because the two answers are not equally
weighted: keeping the hosts is recoverable and deleting them is not, so the safe
answer is the one that needs no decision. It is off on every question, including
the one that disarms it, or a tick left standing would destroy the next group's
machines on the strength of a decision about the last one's.

Once the deletion knows which hosts it means, leaving them naming something that
has gone is a state kept for no reason, so the unticked answer writes too: N
hosts with the reference cleared, where the ticked one writes N tombstones. That
is the N writes HostGroupRepository refuses to hide behind a DeleteAsync
overload, made where somebody asked for them and where the count is on screen
first. The nested groups take the deleted group's place in the tree rather than
being orphaned to the top level. A read-only host is skipped, counted and named,
because unfiling it would re-encode a payload this build cannot represent — and
the cost of skipping is a dangling id, which every reader here already survives.

**Both are the desktop's alone**, and that is not an omission. The phone draws
groups as headings in the host list and has never had a way to delete or move
one; the two panels take the row of buttons over the group cards, and there is
no such row on a 360dp screen to take.

One test had to change its premise rather than its assertion.
EditingAHostWhoseGroupIsGone built its dangling reference by deleting the group,
which now unfiles instead — so it imports a host naming an id nothing resolves,
which is what a group deleted on another machine actually looks like and is the
only way that state still arises. The picker's placeholder is still needed and
still covered.

Four places said an item could not be moved between vaults. Two were about a
group and were true when written; the other two were left stale by the host's
move. All four now say what is true, including the design gaps document, where
the chevron beside the vault name stays undrawn for the reason it already had.
2026-08-04 17:04:06 +02:00
jaap-jan 6fad84c484 Merge branch 'claude/remove-group-edit-delete-buttons-3d38ec'
ci / build and test (push) Successful in 1m31s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 27s
2026-08-04 16:40:56 +02:00
jaap-jan d02d103569 Take the group's EDIT and DELETE off the heading row
A pair of buttons sat at the end of the GROUPS heading, and the card's own
right-click menu arrived later offering the same two things. Two controls for one
job, and the buttons were the harder of the two to read: a button beside a
heading has no card under a pointer to mean, so it had to work its subject out —
the selected card, or failing that the group the trail ends with, which once a
group is open is not a card on screen at all. The menu never has that problem,
because opening it is what aims it.

The menu is the whole of Edit and Delete on the desktop now. ShowsGroupActions
went with the buttons, since hiding them was all it did. GroupTarget stays: the
menu's two entries read it after the code-behind has selected whatever was
right-clicked, and its fallback to the open group is what makes + NEW HOST open
on the group somebody is standing in rather than on none.

One case changes shape. Opening a group with nothing inside it folds the card
grid away, so from in there nothing can be right-clicked — renaming that group
means pressing the trail back one level, to where it has a card of its own. The
buttons used to cover it through the fallback. The trail was already the way out
of an empty group, and 3.2a says so now.

The test that pressed EDIT through its binding is replaced by one holding that
no button on the screen commands either of them, which is the failure worth
catching: a button coming back is not a compile error, and it would draw itself
in place, aimed at the group the trail ends with. What that test covered — a
command that has to accept an empty parameter, and act on the card the pointer
was on — the two menu tests beside it already do.
2026-08-04 16:39:14 +02:00
jaap-jan 589300253d Merge branch 'claude/vault-realtime-push-d64c61'
ci / build and test (push) Successful in 1m33s
ci / android head (push) Failing after 5s
ci / api image (push) Canceled after 36s
2026-08-04 16:38:42 +02:00
jaap-jan 4b706bc3c3 Say when a vault has moved, so nobody waits out the minute
The delta pull was cheap enough to run on a timer and the client did, once a
minute. That is fine for a machine and wrong for two people: an edit a colleague
makes is up to a minute stale, which is long enough for both of them to make it
and produce a conflict neither needed to have. Shortening the interval is the
obvious answer and the wrong one — it costs a request per client per interval
whether or not anything happened, and it converges on a busier server that is
still late.

So the server now says so. A client holds a WebSocket open at GET /api/v1/events,
subprotocol dodossh.events.v1, and gets a line down it when something it can read
has changed. ADR 0012 has the reasoning; three parts of it are worth repeating
here, because they are what everything else rests on.

**What crosses the socket is a notice, never data.** A frame names a vault and
how far its change log has got. No item, no ciphertext, not even which item it
was. The client's answer is the delta pull it would have run anyway, so there is
still exactly one code path that applies a change to a keychain, and it is not
this one. Pushing the items themselves would save a round trip and fork that path
in two, with the cursor, the merge and the tombstone rules duplicated across both
— ADR 0003 put every mutation through one write path for that reason, and this
keeps every read on one for the same one. It also makes a dropped notice
harmless, which is what lets the fan-out below be as simple as it is.

**Polling stays, and is what guarantees a pass.** The minute timer is unchanged.
A network that eats WebSockets, a server with Events:Enabled off, an older
server, a proxy that will not upgrade, a notice dropped under backpressure —
every one of those leaves a client behaving exactly as it did before this commit.
Nothing is reachable only over the socket and nothing is meant to become so;
VaultViewModel's AutoSyncInterval remark now says that where somebody changing it
will read it.

**The bearer token authorises the upgrade, unlike the relay's ticket.** Not an
inconsistency with ADR 0004: the relay's socket is a byte pipe whose whole
authorization decision — which host, which IPs, which port — is made before it
opens and never revisited, and it is the extraction seam for a process that must
hold no ACL code. This one is a view of the caller's own vault list and has to
keep answering "what may this account read" for as long as it is held. A ticket
would carry that answer in a token and be wrong the moment the account's access
changed. The two bounds that arrangement needs are met rather than waved at: the
socket is closed at the token's exp with close code 4401 and the client comes
straight back with a fresh one, and the vault set is re-resolved every few
minutes as well as on the changes known to affect it. Both bound *metadata*,
because a notice contains nothing else and reading a vault still needs a key this
server has never held.

**The fan-out.** VaultEventHub is a singleton holding the sockets this node
accepted; publishing walks them and asks each whether it cares, rather than
keeping a vault-to-subscriber index that every re-subscription would have to move
entries between under a lock publishing also takes. At a few hundred sockets per
node and an event rate bounded by how often people edit keychains, the walk is
not measurable and its races are obvious. Per-connection queues are bounded and
drop the *oldest*: a notice means "pull vault X, which is at least at sequence
N", so the newest subsumes what it displaces and the client's answer is identical
either way — which is what lets the publish path be void, never block, and never
fail.

Announced from the endpoint rather than from SyncService, and that placement is
the point: by then the push has committed and released the per-vault advisory
lock. From inside it would name a sequence no reader can see yet and would hold
the lock that serialises writers across a socket write. Only the highest
*applied* sequence, so a batch of pure conflicts announces nothing, and a
duplicate — already announced when it first landed — announces nothing either.

Grants and membership publish too, and those take the *recipient* rather than the
actor. This is what AdmitNewVaultsAsync has been apologising for since sharing
shipped — "the recipient is handed nothing, there is no push channel" — and the
README with it. A vault shared with somebody now turns up as it is shared. The
comment and the README paragraph both say what is true now, and both keep saying
that the pass is what *discovers* the vault, because a client with no socket has
to arrive at the same place.

**On the client**, VaultEventStream is really a reconnection policy wrapped round
a ClientWebSocket: a dropped socket is the ordinary case here — laptops sleep,
proxies time out, tokens expire, servers are redeployed — so nothing in it treats
a failure as exceptional, and every path ends in "wait, then dial again". A
connection that lived long enough to say hello resets the backoff, so a laptop
that woke, worked, and lost its network an hour later does not inherit a
minute-long wait it has already proved it need not take. A 4401 close skips the
backoff entirely and asks the token provider again, which is the whole reason
that close code is distinct. A server that does not advertise the events feature
gets IdleVaultEventStream, which never delivers — so IVaultServer.Events is never
null and every caller stays on one shape, because the correct behaviour without a
socket is the behaviour with a silent one.

The shell's background loop now selects between the timer and a notice, and both
waits are held across iterations. That is load-bearing rather than tidy:
PeriodicTimer permits one outstanding WaitForNextTickAsync and throws on a
second, and an abandoned channel read stays registered and consumes the next
notice written. Either defect leaves the first notice working and every one after
it silently lost, which is why NoticesKeepWakingTheLoop_NotJustTheFirst pushes
three and not one. Notices are coalesced over a quarter of a second, so one
person's save — a host and its log entry are two items — and a colleague clearing
a folder each cost one pass rather than a dozen.

**The kind is a string, not an enum**, and that is a compatibility decision.
UseStringEnumConverter throws on a value it does not know, so a newer server
sending a kind an older client had never heard of would not add an unreadable
frame — it would break that client's socket outright. A string is ignored
instead. ProblemCodes is the same shape for the same reason.

**Tested on both sides, through the real pipeline.** The endpoint suite opens a
genuine socket against TestServer and proves a push produces a notice, that
another account's push does not reach it, that a ping is answered, and that a
frame this server cannot parse does not end the connection. Two of those assert
on *ordering* rather than on absence within a timeout — the stranger's write goes
first, so a socket that leaked would have announced it before the one the test
waits for — because "nothing arrived in two seconds" is a test that passes on a
slow machine for the wrong reason. And ANoticeCarriesNoCiphertext asserts on the
bytes that crossed the wire rather than on the record's fields, since the latter
would only prove that this type has no payload member, which is a tautology; the
former is what catches a field added later without anybody thinking about
disclosure.

The client suite drives VaultEventStream through an injected connector, because
the one thing a test cannot do to a real network is make it fail on cue — and
failure is the entire subject. The shell suite proves a notice produces a pull
inside ten seconds against a sixty-second timer, so the timer cannot be what
caused it.

**Two limits, stated rather than left to be discovered.** Fan-out is in-process,
so a deployment running more than one API replica only pushes for writes its own
replica handled and the rest arrive on the timer. IVaultEventPublisher is the
seam a PostgreSQL LISTEN/NOTIFY backplane implements and it is deliberately not
implemented: an untested backplane is worse than a documented gap, and multiple
replicas degrade to the behaviour before this commit rather than breaking. And a
client is notified of its own writes; it pushed, so it already pulled, and the
extra pass finds nothing. Suppressing that echo correctly needs a per-device
identity on the socket, and the same user's other machines must still be told.

Manual checks phase 15 covers what no test here can reach, which is the network
in between: a proxy that will not upgrade, one that drops an idle socket without
telling either end, a laptop lid, a token expiring. Every one of those is
invisible inside a test host, and every check there passes only if the change
arrives quickly *and* still arrives with the socket taken away.

ADR 0012 also fixes one thing about the shared terminal session this is the
transport for, so it need not be renegotiated later: session data will be binary
frames on this same socket, because base64 in a JSON envelope is the wrong shape
for the one payload here that is continuous rather than occasional. Two questions
it explicitly does not answer by implication — whether those bytes go through the
API at all, and what end-to-end encryption means when the second party watches a
stream rather than holding a key — are ADR 0001 questions and get their own
decision.

1512 tests pass. DodoSSH.SystemTests was not run — it needs the whole compose
stack — so the end-to-end path is unverified for this change beyond what the
manual checks describe.
2026-08-04 16:37:41 +02:00