The port notes carry the third correction of this round: the data
plane assumed a renderer that attaches once and lives forever, which no
foreground service can make true of Android's separate WebView renderer
process. Phase 11 gains the two checks a phone can run — close and
reopen a connection, and a backgrounded shell surviving its renderer
being killed, banner and all.
The page's socket now retries itself forever with backoff — a dropped
socket is an ordinary event on a phone, not the end of the terminal's
life — and createSession is idempotent, so a replay landing on a pane
that survived changes nothing. A replay creating a pane that did not
survive writes one dim line saying the earlier output stayed on the
host, because that is the truth about a reloaded page's scrollback.
The shell answers RendererReattached with the two things only it owns:
the font size, and which tab is active.
Each live session gets its credit window reset — the unacknowledged
bytes died with the old page, and their acknowledgement is never
coming — and its SessionOpened frame again, flagged as a replay so the
page can tell a reattach from a genuinely new session. A session whose
shell already ended gets nothing: its scrollback lived only in the page
that is gone, and a frame implying otherwise would lie.
RendererReattached is the seam for what the workspace has no business
owning: the font size and the selected tab live in the shell, which
re-pushes them from its own subscription.
One attach per process was WebView2's truth, not Android's: the phone
kills the WebView's renderer independently of the app process, the page
reloads, and its fresh socket was answered 409 by a guard that never
reset — with no way back short of restarting the app. Only our own page
knows the token, so a second valid upgrade is that page returning; it
now displaces the old socket, which may never notice it is dead on its
own, since a killed renderer sends no FIN.
A send into the dead socket also no longer escapes as a fault. It used
to unwind the pump's flush loop, after which nothing drained the credit
window and the still-live shell froze behind it for good — including
the BCL quirk where such a send surfaces as an OperationCanceledException
nobody's token asked for.
The port doc's backgrounding decision now carries the four corrections
rather than describing a wiring that was not true, and Phase 14 gains
the checks a phone can actually run: a backgrounded shell surviving, an
idle Files connection surviving, the permission ask arriving at the
first thing worth showing, and a refusal costing the notification and
nothing else.
The foreground service existed, and four defects in its wiring meant it
mostly did not run. A shell opening was never announced to it — only the
ending was — so the service never came up for a shell at all. An idle
connected Files session counted as nothing. Every refresh restarted the
service, which Android 12+ answers with a crash the moment the app is
backgrounded — a transfer finishing in the pocket took the remaining
connections with it. And POST_NOTIFICATIONS was declared but never
requested, so on Android 13+ the receipt was silently invisible.
Updates while backgrounded now go through the notification manager; a
foregrounded refresh still prefers a real start, so a stop still in
flight cannot leave an orphan receipt over an unprotected process.
HasLiveFileSession answers the phone's foreground-service question — is
there a connection here that dying with the process would sever — and a
bucket answers no, because HTTP holds nothing open. ActivityChanged now
also fires at the end of MarkHostConnected and CloseSessionAsync, where
both facts it reads are finally true together.
Also makes the bucket pins test actually open a bucket: it never set
Remote, so CONNECT dialled the auto-selected host, and its assertions
passed only because that host had no pins either.
The data was never the gap — HostSecret.PinnedPaths syncs and merges on both
heads, and the desktop's drawer has staged it since v5 — the phone just had
nowhere to add, remove or use a pin. Now it has both halves.
The host editor page gains a QUICK ACCESS section over the same shared
staging the drawer binds (EditorPinnedPaths, AddEditorPin, RemoveEditorPin),
with the remove target at this head's 44dp touch floor rather than the
desktop's 22-pixel close box, and no folder glyph because this head embeds no
icon font for one. The page also gains a Status line of its own: the add
command's five refusals speak through Status, and this page covers the screen
that normally draws it — a refusal nothing shows is no refusal at all.
The Files screen draws the connected host's pins as chips between the
breadcrumb and the listing, each running GoRemoteCommand exactly as a crumb
does. They are captured at connect, like ConnectedTo and the session facts
before them; a bucket gets none, having no HostSecret to pin anything on.
Covered headlessly in ShellFlowTests — connect populates, disconnect clears,
a bucket stays empty — and by manual checks 8.18 and 8.19, whose phase
preamble also stops claiming thirteen checks when it lists twenty-one.
The chip derivation was TrimEnd(separator), which is a name only for the
Windows drives it was written against: on Unix it made the / chip an empty
pill and the home chip the entire home path, drawn at full width in a header
column nothing bounds. A home directory deep enough — CI's per-job HOME is
forty-six characters — had that one chip walk the header's own buttons out of
the window at the session shell's 472-pixel budget, which is the half of the
runner's red suite the star-column fix before this one did not reach.
A chip says C:, /, ~, or a mount's last segment now; the full path stays on
its command parameter, where length costs nothing. RootChipNameTests pins the
derivation with fixed strings, so it no longer takes a machine with a deep
profile path to ask the question.
TextTrimming only acts when measure hands the block a finite width, and a
horizontal StackPanel never does — it measures every child at infinity and an
Auto grid column passes the full answer on. So both SFTP pane headers grew
with their path, and a directory deep enough pushed the header's own icon
buttons past the window's edge at the session shell's 472-pixel budget.
The layout suite has said so on every CI run since v5b landed, and nowhere
else: the runner's per-job HOME is a 46-character path, which is what the
local pane opens on, and every developer machine's short profile path left
the same test green. The path sits alone in the star column now — bounded
width, working ellipsis — and the narrowest-budget test pins both panes to
sixty-character paths so the question is asked on every machine alike;
against the old markup that test fails on Windows too.
The two-keychain branch of VaultViewModel.RegroupChosenHosts had no test:
7.6a's manual walk was the only thing asserting that a mixed set gets the
sentence instead of the picker. A ShellFlowTests case now ticks a host in
each of two vaults, reads the refusal off the status line, and shows the
same command opening the picker once the set is one keychain's again.
Check 7.6a cites the test and keeps only the popup wiring for the eye.
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.
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.
ISshConnection and ISftpSession both carry Cipher now — the server-to-client
algorithm off SSH.NET's own ConnectionInfo, captured once because a rekey is
not an event that library raises — and TerminalWorkspace.GetSessionFacts hands
that plus the host key's algorithm back per session, without ever handing over
the connection itself. Nothing reads either yet; the status bar that will is
the next commit.
Five scrim hardcodes still dimmed through the old canvas #0E1220; they
now sit on #05050A. Each keeps its alpha except QuickConnect's backdrop,
which the v5 spec pins at rgba(5,5,10,0.6) — its essay drops from 80% to
60% of Canvas to match.
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.
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.
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.
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.
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.
The banner has never worked. It went into MainWindow's fourth row with no data
context of its own, so it inherited the shell's — and it is the one control in
that file typed to a screen's view model rather than to MainWindowViewModel,
because it is the only one with a layout suite that hosts it over UpdateViewModel
alone. Compiled bindings type-check against x:DataType at runtime, so every
binding inside it resolved against the wrong object and failed the way a compiled
binding does: quietly. No headline, and DismissBannerCommand and RestartNowCommand
both null.
A button with a null command is enabled, hovers, depresses and does nothing, which
is why this looked like a hit-testing problem and why the WebView was the first
suspect. It is not one. The strip is a sibling row for the reason the occlusion
rule gives and that arrangement is correct — the terminal's rectangle is never
covered, only shortened. What was actually on offer was an announcement that an
update had been downloaded, with two buttons that refused to install it and no
way to make it go away either. The preferences screen's RESTART NOW worked
throughout, because it binds Updates.RestartNowCommand from the shell's own
context, which is the contrast that pins the cause.
The context is set on the banner itself and IsVisible loses its Updates. prefix
with it, because a data context on an element resolves that element's other
bindings too — the rule the page area's wrappers upstairs exist to work around.
Those wrappers are needed because IsHostsScreen and its siblings belong to the
shell; IsBannerShowing belongs to the banner's own view model, so there is nothing
to wrap here.
Neither existing suite could have caught it. A layout test supplies the data
context it is measuring, which is exactly the assumption that was wrong, and the
shell suite has no visual tree — its project file already says it does not cover
whether the XAML binds to the right names. So the new test asserts the wiring
rather than the layout: a real shell over the ready-update fake, MainWindow
constructed and never shown, and the banner asked what context it got, whether it
is visible and whether RESTART NOW carries a command. Checked failing with the one
attribute removed. Constructing the window is safe where showing it is not, and
nothing here needs it shown: a data context propagates when it is set, not when
the tree is measured.
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.
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.
+ NEW HOST decided two defaults separately and let them contradict each other.
The group came from the screen — the selected card, or failing that the group
whose contents are showing — and the vault came from the keychain screen's
standing "new items go to" preference. Inside a group belonging to any other
vault the two disagreed, and the group is what lost: GroupInEditingVault drops a
group the editor's vault has not got, on the sound reasoning that a host filed
under an id its readers cannot resolve looks unfiled to everybody but the person
who wrote it. So pressing the button while standing inside a team's PLATFORM
opened a form filed under nothing, bound for the personal vault, with no sentence
anywhere saying either thing had happened.
The vault now follows the group. A group lives in exactly one vault, so a host
that is to land in that group has to be sealed in that vault too — which is the
rule + NEW GROUP has followed for a parent since the cards became a tree, and the
comment there claiming this as a deliberate difference from the host's editor is
the one the code has now caught up with.
The filter stays, because there is one case left for it: the group's vault may be
one this session can read and not write, a team vault this account is a viewer of.
TargetVaults is the readable-and-writable set and is what decides here, so a
viewer keeps the standing preference and loses the group with it, rather than
opening an editor aimed at a save that cannot happen.
Both directions are tested, since one alone would not say which default wins:
standing in a shared vault's group, the editor opens on that vault with the group
selected and the host saves there; and with the preference pointed at the shared
vault while a personal-vault group is open, the group beats the picker somebody
set once.
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.