The first pass of the update loop waited two minutes. Every pass after it came
six hours apart, which is the right interval for a product that ships rarely —
but the delay in front of the first one quietly excluded a whole way of using
this application.
A client opened to reach one host and closed again is over before the two
minutes are. Used that way, it never checks at all: not once, not slowly, never.
That is precisely the machine ADR 0011 names as the real cost of distributing
outside a store — quietly a year behind — and the galling part is that the
mechanism to fix it was switched on the whole time and simply never reached.
The delay's own argument is recorded in the diff it is being removed from, and
it was not a bad one: nothing anybody does in their first two minutes depends on
an update, and launch is already contending for the network with a schema
migration, a resumed sign-in and a first sync, at the one moment somebody is
watching the window. What it weighed was the cost of checking early against the
benefit of checking early. It never weighed the cost of not checking at all.
◆ THE YIELD IS WHAT KEEPS THIS OFF THE LAUNCH PATH, AND IT IS NOT DECORATION.
Start() is called from MainWindowViewModel.StartAsync ahead of the migration, so
an inline first pass would run whatever the channel does before its own first
await — Velopack reads the install layout from disk — between the user and their
window. Yielding hands the rest of launch back and puts the check in a later
turn, which is the same moment in every sense anybody can perceive and none of
the cost. So the answer to the delay's argument is not that it was wrong; it is
that a yield buys most of what two minutes bought.
Task.Yield takes no token where Task.Delay did, so the loop body now observes
cancellation at its head. Without that, an application closed during launch
spends its last moment asking a release channel about a build it will not run.
Two things deliberately not changed. The AUTOMATIC UPDATE CHECKS preference
still gates the pass — "on start" means every start, not regardless of what the
user asked for, and that setting is already on by default. And the data cost is
unchanged rather than merely acceptable: a check is a few hundred bytes and the
download only follows if something newer exists, so this moves the same traffic
earlier without adding any. That matters most on the phone, where the same loop
runs against AndroidUpdateChannel.
TheFirstPassRunsAtStart_RatherThanOnADelay drives the real loop rather than
CheckOnceAsync, which is the one thing that file otherwise avoids — and here it
is the point, because the claim is about when the pass happens rather than what
it does. It waits on the pass and not on a clock, so there is nothing to be
flaky about: a regression that puts a delay back does not fail on a margin, it
spins until the suite's own cancellation ends it. DisposingStopsTheLoop keeps
its assertion and gains a note that it is now a race rather than a formality.
§16.7 of the manual checks gains the sentence that reopening the application
does what CHECK NOW does. It is the step somebody following that section would
otherwise discover by accident.
447 App tests and 153 layout tests pass.
CI went red across the whole repository — main's run 125 and every open pull
request at once — on a restore that never reached a compiler:
error NU1004: The package reference Microsoft.NET.ILLink.Tasks version has
changed from [10.0.10, ) to [10.0.11, ). The packages lock file is
inconsistent with the project dependencies so restore can't be run in
locked mode.
Nothing in any of those commits touched a package. .NET had shipped SDK 10.0.400.
◆ THE VERSION IN THE LOCK FILES WAS NEVER THIS REPOSITORY'S TO DECIDE.
Microsoft.NET.ILLink.Tasks is referenced by nothing here. The SDK adds it to any
project setting IsTrimmable or IsAotCompatible — DodoSSH.Contracts and
DodoSSH.Crypto do, and the Android head gets it from trimming being on by
default — and it supplies the version itself, from the KnownILLinkPack item in
its own Microsoft.NETCoreSdk.BundledVersions.props. 10.0.302 says 10.0.10;
10.0.400 says 10.0.11.
packages.lock.json records that as a Direct reference with a requested range, so
what the committed file actually means is "whichever SDK last ran a restore".
global.json says rollForward: latestMinor, so setup-dotnet installs the newest
10.x SDK that exists on the morning it runs. The gate did its job — an unreviewed
dependency change is exactly what it is there to stop — but the change it caught
was not one anybody could have reviewed, and it will recur on every servicing
release.
Regenerating the lock files alone would have been the worse repair, and not only
because it holds until the next release. It cannot be done from this machine at
all: every SDK installed here tops out at 10.0.302, which writes 10.0.10 straight
back and re-breaks CI. The recorded version would flip according to who restored
last — the precise state locking exists to prevent.
So the version is pinned in Directory.Build.targets and the three lock files are
regenerated against the pin. It is an Update on the SDK's item rather than a
PackageVersion in Directory.Packages.props because the reference is implicit:
the SDK supplies a version, so central package management is never consulted. It
sits in a target because the conditioning is on %(TargetFramework) — all the
KnownILLinkPack items share one identity and only that metadata separates
net10.0's from net8.0's — and item batching in a condition is legal inside a
target and MSB4191 during evaluation.
Pinned forward to 10.0.11 rather than back to 10.0.10, which would have been a
one-line change with no lock file churn. Holding the trimmer a release behind the
framework it analyses to dodge an error is how a missed trim warning happens, and
taking the newer one makes the bump a reviewed diff, which is what the gate was
asking for.
Verified against the SDK that broke it rather than only the one here:
- sdk:10.0-alpine, 10.0.400, `dotnet restore DodoSSH.slnx --locked-mode` —
exit 0. That is ci.yml's line, on CI's SDK.
- the android workload on sdk:10.0-noble, 10.0.400, locked-mode restore of
DodoSSH.Client.Android — exit 0. That is scripts/ci-android.sh's line.
- locally on 10.0.302, the same locked-mode restore of the solution — exit 0.
One set of lock files satisfying both SDKs is the whole point of the pin, and the
third check is the one that demonstrates it.
Release build clean: 0 errors, and 0 IL-prefixed diagnostics from the newer
analyser on the two trimmable projects. 1,869 tests over 19 suites, none failing.
A caution for the next person, learned the hard way here: `--force-evaluate` on
Windows rewrites every lock file it touches with CRLF, and 23 of the 26 had no
content change at all. Only the three that really moved are in this commit.
A shell that ended printed "The remote closed the session." into the status
banner at the foot of the terminal. Switch to a tab whose shell was still very
much alive and the sentence was still there, sitting under a live prompt and
describing a terminal that was no longer on screen.
There is one #status element for the whole page, because there is one page for
every terminal — the panes are stacked in the same box and all but the active one
are hidden — and SESSION_CLOSED wrote its reason straight into it. The other half
of the same mistake ran the other way: SESSION_OPENED and SESSION_REMOVED both
cleared the element outright, so opening or closing any tab wiped a message that
belonged to a different one. Whichever tab spoke last owned the banner.
The fix is to separate the two things that were being put in one place by who
they are actually true of. A session's last words are a fact about one terminal
and are now held on the session record, drawn only while that session's pane is
the one showing; activate() re-renders, so the banner follows the tab and a dead
tab still says what became of it when you come back to it. The socket's own state
— "Connecting…", "Reconnecting the terminal view…" — stays page-wide, because
there is a single socket behind every pane, and it wins when both have something
to say: a page whose socket is down is not showing live output on any pane.
A SESSION_CLOSED for a session this page has no pane for is now dropped rather
than printed. There is nothing to attach it to, and putting it in the banner
anyway is precisely the bug in miniature.
Verified by driving the real handleFrame through a stub DOM under node, which is
as close as this repo gets — there is no JS test harness and CI runs dotnet only,
so nothing here is a standing test. Twelve checks over open, close, switch,
reopen, remove and a socket drop pass against this file; the same script run
against the previous one reproduces the report exactly, epitaph under a live tab
included. Not seen in a running app: no C# changed, and the page is unreachable
without one.
The phone's terminal was blank whenever it was connected. Not slow, not
mis-sized, not disconnected: a live session accepting keystrokes, acknowledging
output and drawing nothing at all.
◆ THE WEBGL ADDON DOES NOT RECOVER FROM A LOST CONTEXT AND DOES NOT FAIL LOUDLY.
It stays loaded over a dead context and renders an empty rectangle, which is
xterm's documented behaviour and the reason its guidance is to subscribe to
onContextLoss and dispose. This page never did, and until there was a phone
there was no reason to notice.
Losing the context is ordinary on Android and nearly unheard of on Windows,
which is what made this a one-head bug in shared code. Collapsing the renderer
sets the native view to GONE — Avalonia's AndroidNativeControlHostImpl.HideWithSize,
read out of the assembly rather than guessed at — and a WebView with no surface
has no GL context. The shell collapses it every time a tab starts connecting,
every time the connect sheet opens and every time the app is backgrounded. Worse,
the ordering guarantees it for the first session on every launch: OpenSessionAsync
sends SESSION_OPENED before the tab reports a session, so IsTerminalShowing is
still false and the pane, the terminal and its GL context are all built inside a
collapsed WebView. WebView2 hides a child HWND and keeps rendering throughout,
which docs/platform-flags.md measured at length.
The addon is not reloaded after a loss. A pane that lost the context once is on a
surface that will do it again, and thrashing between renderers is worse than being
slow — the DOM renderer is what the existing fallback comment already argues for,
because a blank pane is not usable and a slow one is.
The comment above MINIMUM_FITTABLE_PIXELS was wrong for this head and is corrected
with it. It asserted that collapsing the WebView leaves this page's viewport alone,
so no observer fires and the guard protects nothing — true of a hidden child HWND,
false of a GONE view, which its parent's layout skips outright. On the phone the
guard is the only thing standing between a lock, a connect sheet or a trip to the
background and a remote pty reflowed to 2x1.
Also on the way past: the renderer-timeout message told phone users to install the
Microsoft Edge WebView2 runtime. That is the other blank-terminal failure mode's
message, and naming a runtime that cannot exist on the device is worse than saying
nothing at the one moment somebody is trying to work out what went wrong. It now
names Android's own WebView on that head, as a runtime check for the reason
MainWindowViewModel.GestureWait records beside its own.
Not verified on a device — there is no handset or emulator here, and no test
covers this page. The diagnosis is the decompiled hide path plus xterm's own
requirement, not an observation. 523 tests over the shell and the terminal pass,
and both heads build.
Seven things reported from a day's use of v5b's session shell, and they are one
commit because five of them are the same complaint from different angles: the
window spends too much of itself on chrome describing the session, and the parts
that are not chrome do not behave.
◆ THE HOST HEADER IS GONE, and that is a deliberate departure from the design.
Terminal.dc.html and SFTP.dc.html both draw a 60-pixel row above the pane: the
address on the left, a cross-surface button on the right. Both facts are worth
having and the strip they sat in was not — the tab already names the host, and a
full-width bar repeating it was the cheapest 60 pixels in the layout to give
back. The address is now the first line of the sidebar and the button is
stretched across the column under it, so nothing is lost and the pane is taller.
Which word that button carries and which command it runs used to be handed in
from the two usage sites in MainWindow.axaml, because the control was drawn
twice. One sidebar cannot do that, so SessionCrossSurfaceLabel and
OpenOtherSurfaceCommand resolve it in the shell — the same place SessionAddress
already decides which surface's fact to read. The two directions underneath are
untouched: SelectFilesHostAsync for a tab's host, OpenTerminalForFilesHostAsync
for a fresh terminal at whatever SFTP has open.
SessionHeader.axaml is deleted rather than left unused, and LayoutHarness stops
subtracting its 60 pixels from every session screen's budget — the same
treatment the retired window-wide tab strip got, and for the same reason: a
constant for chrome nobody draws is a suite quietly measuring the wrong
rectangle.
◆ AND THE SIDEBAR CLOSES, which the design has no state for at all.
300 pixels of an 1081-pixel minimum is a great deal to spend on a list that is
often two rows long. The column now folds to a 34-pixel rail carrying the
chevron that brings it back — a rail rather than nothing, because a panel that
vanishes leaving no trace is one people report as lost rather than as closed.
Both states live in the one control and swap on IsSessionSidebarOpen, so
MainWindow's own "Auto" column takes whichever width is showing without knowing
the state exists.
Written through to ClientSettings.SessionSidebarOpen rather than held for the
session. It is a decision about how much of the window a terminal gets, and one
that had to be made again on every launch would not really be on offer.
---- THE FOUR SMALLER ONES ----
A SNIP LANDED IN A TERMINAL NOBODY COULD TYPE AT, and looked selected when it
got there. Two causes with nothing in common. The click moved Win32 focus onto
the sidebar row, and term.focus() in the page cannot take it back — only the
host can, so the shell raises TerminalFocusRequested and the window answers with
the same posted focus every other path here uses. The highlight was bash: xterm
wraps a paste in bracketed-paste markers, readline marks what arrives inside
them as an active region, and it stays in reverse video until the next
keystroke. Right for a clipboard paste, wrong for a snippet picked off a
sidebar. Single-line snips are typed rather than pasted now, which needs no
markers; multi-line still pastes, because "runs three commands unasked" is the
worse of the two failures and the markers are the whole of what prevents it.
A BLACK BAR UNDER THE TERMINAL, on Windows. xterm.css paints its scrolling
viewport #000 — its own comment explains why, and it is a macOS scrollbar
concern. Everywhere else that black is covered by the rows, except along the
bottom: the fit addon floors the row count, so the remainder below the last
whole row is bare viewport, up to a line tall, against this page's #171a26. The
light square at its right-hand end is where WebView2's classic scrollbar corner
lands. The viewport is repainted in the page's own background, and the scrollbar
with it — thin and in these colours rather than a grey Windows channel down the
side of a near-black terminal, and kept rather than hidden, because a surface
that scrolls with no sign that it does is worse than a quiet bar.
THE PINS ROW DREW A TOFU BOX. U+E946 is not in the embedded Material Icons face
at all — that file is the 2019 build and its cmap skips E944 and E946 — so the
rail's Pins row and the hosts screen's own pin badge have both been drawing a
missing-glyph rectangle since v5b picked the codepoint. push_pin in that vintage
is U+F10D, verified against the file rather than against a codepoints table for
a later release of the font. Every other icon codepoint in the repository was
audited the same way; this was the only miss.
THE KBD CHIP CUT THE CHORD IN HALF. 34 pixels is the design's width for a chip
reading ⌘K, and this build substitutes CTRL K — six characters and a space,
wider than 34 at 10.5 mono. MinWidth and padding instead, so the design's
footprint survives for the day this face has a ⌘ to draw.
---- AND THE POPOVER UNDER THE USER CHIP ----
Reported as not matching the design, and it was not: Button.poprow set a corner
radius and a padding and never touched the Background, so every row wore the
Fluent theme's own #33FFFFFF button fill. Six raised pills stacked in a menu the
design draws as six lines of text — and the hover rule underneath was already
correct and simply invisible against a fill that never went away. Set on the
ContentPresenter as well as on the Button, the same as Button.flat, because the
theme binds its brush there and a Background set only on the control loses to
it. The panel itself gets this window's own radius-12 card treatment through a
FlyoutPresenter class rather than by widening the shared context-menu rule, and
Vaults and Preferences stop being drawn one step dimmer than Settings and
Logout, which read as two disabled entries in a menu of five live ones.
---- WHAT PROVES IT ----
Three tests in the layout suite, two of them checked against the defect they
describe: the popover row's resting fill (fails with #33ffffff without the
style), and the kbd chip against the natural width of its own text, measured on
a detached copy because a TextBlock's DesiredSize is already clipped to what it
was given and reports 34 inside a 34-pixel chip either way. SessionSidebarTests
is new — the sidebar has never been laid out by a test, and it now holds a
string of unbounded length beside a button that has to stay clickable. In the
shell suite: the cross-surface row in both directions, the closed state
surviving to disk, and the focus request being made when a snip lands and not
made when it does not.
The connecting card set its status string once, when the tab was created, and
never touched it again. Every connection therefore looked identical from the
outside: one three seconds into a key exchange, one waiting out a fifteen-second
timeout against a machine that is asleep, and one that had hung all drew the
same "connecting…". The card now draws the five steps of getting there, each lit
at the moment the handshake reports reaching it, over an amber track that fills
as they finish.
◆ NOTHING ON THE LIST IS INVENTED. Every row changes state because a layer below
it said so, at the instant the thing it names actually began.
That is the whole reason it is worth showing, and it is why most of this commit
is plumbing rather than XAML: there was no progress reporting anywhere in the
stack to hook a step list onto, and a card animating plausible progress would
have been indistinguishable from one that had stopped receiving any.
SshConnectionPhase names four phases and deliberately not more. SSH.NET runs the
entire handshake inside one ConnectAsync and raises exactly one event from the
middle of it — HostKeyReceived, once the key exchange has produced a key to show
— so that event is the only interior moment there is to report. Everything
before it is Reaching and everything after it is Authenticating. A fifth phase
in that assembly would have to be a timer, so there is not one. OpeningShell is
reported by TerminalWorkspace instead, because that is where it happens: the
factory's work ends with an authenticated connection, and asking for a
pseudo-terminal on one is a separate round trip. The SFTP path passes null — a
second connection opened behind an already-open shell has nobody watching a step
list for it.
The card's fifth step, "Starting the terminal", is the renderer wait and lives
in the shell rather than in the SSH assembly, which has never heard of a
renderer. On the first connection after a cold start it is a real wait with a
real failure mode of its own — a missing WebView2 runtime — so a list that began
at "reaching the host" would leave the one wait most likely to hang unnamed.
Amber for the step in flight, and that follows the palette's rule rather than
bending it. Green is what is true and purple is what you can press; a step still
happening is neither, and it is exactly the caveat-worth-reading that amber
exists for. Steps behind it go green as they become true. Nothing animates,
which is the argument TransfersScreen.axaml already makes for its own track,
reaching a screen with far more reason to want a spinner: a spinner is furniture
invented to fill a state nobody measured, and these states are measured, so the
track fills to what has finished and then waits there.
A refusal keeps the step it stopped on, in red, with the ones behind it still
green. That is the half a progress bar could not do, and it is the difference
between "that host is not there" and "that host is there and would not have me"
— a question the reason sentence alone frequently does not settle.
The strip's dot goes amber while a tab is connecting, on both heads. It was
grey, and so is a tab whose shell has exited: the two states in that strip with
the least in common, one worth waiting for and one over. PhoneShell's own
comment already recorded half of this — the dot stopped being green before
anything had answered — and this is the other half.
Progress is raised inline rather than through System.Progress<T>, which captures
whatever synchronisation context it was constructed on and posts to it. That
reads like a convenience and is really a second place the marshalling decision
gets made: silently, differently under a test with no context, and out of order
with respect to the failure that follows a phase. The shell marshals once, in
one handler, through a new optional post parameter on MainWindowViewModel — the
same seam TransfersViewModel already uses, and for the reason its own remark
gives. The three Dispatcher.UIThread.Post calls that predate it are the ones
this suite's comments record as out of reach; they are left alone rather than
swept in here.
Both heads draw the list. They differ in one place: Phone.axaml's mono class
sets a colour and a size along with the family, so the caption rule names its
own family instead of composing the two and asking two rules for one Foreground.
The desktop's mono sets the family alone, which is why ConnectingCard does
compose them. Each head also gains SHOW LOGS beside the button that gives up —
the step list is this attempt and the log is every other one, which is what a
connection taking too long actually raises.
Seven tests, and the two that matter most run against the container rather than
a fake: a real handshake reports its phases in order, and a host-key refusal
never claims to have authenticated. A fake asserting what it was written to
assert would have established nothing about either. The rest cover the tab
advancing while the connection is gated, the step a refusal stops on, and a
phase reported after the user has given up on the tab. 1,861 tests, none
failing.
The Android head's layout is not verified by anything. It compiles, and
compiled bindings mean every new binding path resolves, but that project is not
in DodoSSH.slnx, there is no test project for it and no device here — so unlike
the desktop card, whose shapes the layout harness measures, these rows have not
been drawn. Vertical fit is reasoned, not observed.
The authentication picker has listed saved credentials since they existed, but
making one meant leaving a half-typed host for the keychain screen and coming
back to find it gone. On the phone it was worse than a detour: that head has no
credential editor at all, so it could bind a host to a credential and never
produce one. + NEW CREDENTIAL opens a card under the picker — name, optional
username, password, notes — and ADD writes it and binds the host in one step.
A button beside the picker rather than an entry inside it. Every row of that
list is a binding a host can have, and "make a new one" is an action: as an
entry it would sit in the box afterwards describing a state no host can be in,
and cancelling the form would leave the picker showing it.
It carries its own five fields rather than reusing the keychain editor's, and
that is the load-bearing part. IsEditingCredential is what AVaultEditorIsInTheWay
asks about, so sharing it would have made the whole Vault screen refuse to open
an editor while this card sat open on the Hosts screen, with a status line
naming a form the user cannot see on a screen they are not looking at — the
exact failure that guard was split in two to end. A test pins it.
It writes to the keychain immediately, unlike every other field in this editor,
because a credential is a shared item with an id and a host can only name an id
that exists. The consequence is honest rather than hidden and the hint says so:
a credential added this way outlives a cancelled host edit. What was still being
typed does not — every path that closes the host editor clears the form, and one
of those fields is a password.
The binding is written before the reload rather than after it. RefreshOpenEditors
rebuilds this picker and then restores it from the editor's own selection, so
setting it first is what survives the pass, and by the time it is read
ReloadCredentialsAsync has put the matching entry in the list to land on.
A name already taken is duplicated, not reused, and that is a deliberate parting
from the new-tag box six lines further down which offers the existing tag
instead. Two tags called "staging" are one intention spelled twice; two
credentials called "root" are two different passwords, and quietly binding the
host to whichever was there already would authenticate it as an account nobody
chose. A duplicate label in the picker is the smaller problem.
Into editingHostVaultId, so the credential lands wherever the host is being
sealed and everybody who can read the host can read what it authenticates with.
Stricter than the tag path — which files into the active vault and is recorded
as a gap in docs/design-import-gaps.md — and it can be, because this picker
lists credentials from every readable vault rather than one.
Five flow tests cover the bind-through-reload path, the cancel semantics on both
the saved credential and the abandoned one, the cross-screen guard, the
duplicate name and the empty-password refusal. The layout test is separate and
necessary: the card is collapsed until somebody presses the button, so a harness
driven by the default state draws none of it, and TheHostDrawerFitsWithTheHostEditorOpen
would have gone on passing over a card that blew the column. 1,854 tests, none
failing.
The same application, the same Velopack and the same two-phase person-run
release as Windows, with four things forced to differ. Signing is a
precondition rather than an improvement: Gatekeeper refuses an
un-notarized download outright instead of warning about it, so there was
never the "unsigned for now" that ADR 0013 decision 8 argues for on
Windows, and release-macos.sh refuses to start without the identities.
The packaging split is narrower than it first looked, and the old claim
at the foot of ci.yml is why it was worth checking rather than assuming.
vpk cross-compiles when told to: 'vpk [osx] bundle' builds a real .app on
any platform, and CI now publishes osx-arm64 and bundles it on every main
and tag build, which is what catches a restore graph with no macOS native
asset. There is no '[osx] pack' off a Mac, and that part is correct — pack
drives codesign, notarytool and stapler, which exist nowhere else.
The dylib signing loop in the script looks redundant beside vpk's own
pass and is not. vpk signs with 'codesign --deep', which is the shape
Apple documents as wrong for nested code, and platform-flags has recorded
a notarization rejection that names no file since before any of this
existed. Signing each native binary inside-out first leaves that pass
nothing to get wrong.
MacDeviceKeyStore reaches ADR 0007's conclusion through different
hardware: a P-256 key in the Secure Enclave under an access control
requiring user presence, so the platform enforces the gate rather than
this process — which is the whole point of that ADR's amendment. The
enclave holds no other kind of key, hence ECIES where Windows uses
RSA-OAEP, and the shape that falls out is better than the Windows one:
sealing needs only the public half and is silent, so only unlock prompts.
IsSupported probes rather than infers, because three ordinary Macs answer
no — an Intel machine without a T2, one with no login password, and every
unsigned development build, since enclave keys need a signing identity.
Two decisions worth stating because they are reversible. arm64 only: a
second channel is small work and nobody here has an Intel Mac to walk
Phase 18 on, and an x64 package would be the only artefact in this
repository reaching users unverified. And the pack id stays
DodoSSH.Desktop even though vpk names the bundle after it, so
/Applications holds DodoSSH.Desktop.app: decision 2's reasoning binds
harder here, because a pack id of DodoSSH would put Velopack's install
root on top of ClientPaths.DataDirectory and let an uninstall take the
user's un-synced outbox with it. CFBundleDisplayName puts the product
name back in front of a person.
Measured rather than assumed, since none of it is obvious: the publish
and the bundle were both run, LSMinimumSystemVersion is 12.0 because that
is the minos in the apphost's own LC_BUILD_VERSION, and vpk copies a
custom Info.plist verbatim with no substitution at all — which is why the
plist is a template the script renders and not a committed file.
What is not done is the half that needs the hardware. There is no macOS
runner, so nothing past "it bundles" has ever run. Phase 18 is the whole
of the verification, and the two checks most likely to fail are the
terminal against WKWebView and the enclave interop, neither of which has
executed once.
Button.row sets the height a thumb needs and left the caption's placement to
Avalonia's Stretch default, so the content presenter stretched the caption to
the whole row and a TextBlock draws its line at the top of what it is given —
the same omission the desktop head's ghost/accent/danger rule had.
Most of the thirty-three rows never showed it, which is what made the four that
did look like four unrelated mistakes rather than one rule: a row whose content
is a StackPanel or a Grid of already-centred children is centred whatever this
property says. The four that are a bare TextBlock are FilesScreen's breadcrumb
crumb, its up-one-directory chip and its pinned-path chip, and TerminalScreen's
CLOSE THIS TAB — 36 or 44 tall with no vertical padding, so measured at those
numbers the caption sat flush against the top edge with 21 to 33 pixels of
nothing under it, eleven to seventeen pixels off centre in a control barely
twice that tall.
Nothing is excluded here, unlike the desktop's own sweep: no row's content
depends on being stretched — there is no full-height strip inside any of the
thirty-three, the thing that keeps flat and cat out of the equivalent rule over
there — and the Grids that stop filling hold only children that already centre
themselves, so they land where they always did.
The other two phone classes that do not set it are both fine and neither should
get it. RadioButton.chip declares its own ControlTemplate whose presenter reads
VerticalAlignment="Center" outright, so it centres regardless and the property
would not be read; Button.scrim is the full-screen dimmer behind a sheet and
has no caption at all.
Not covered by a test, and it cannot be from here: there is no Android layout
suite, the desktop harness cannot instantiate net10.0-android views, and
AvaloniaRuntimeXamlLoader — which would let it load Phone.axaml on its own —
lives in a package this repo does not reference. What is verified is that the
head builds, so the Avalonia XAML compiler has accepted the setter, and that
the desktop's own 147 layout tests are unmoved.
The sweep the ghost/accent/danger fix implied: navuser, poprow, panechip,
chiptoggle and choice each set VerticalContentAlignment now, because each set
everything else about how its content sits and left that one to Avalonia's
Stretch default.
None of them was misbehaving. Every one is content-sized everywhere it is used
today, so Stretch and Center agreed and this moves nothing — 113 buttons across
29 screens and cards measured byte-identical before and after, the strips that
have no height of their own included. What it buys is that the day one of them
is given a height, it is already right rather than quietly drawing its label in
the top third.
flat and cat are deliberately NOT swept in, and the reasoning that would sweep
them is exactly the trap. flat carries the titlebar's search pill, a
Border.searchpill with no height of its own that is meant to fill all 35 pixels
of its button — the usage states HorizontalContentAlignment="Stretch" and takes
the vertical default to match. cat carries the keychain rail's accent strip, a
Border.rowmark whose style sets Width="2" and no height at all, "at full row
height" by its own remark. Centring either from the style shrinks a pill and a
strip that are correct today. AStretchingShapeStillFillsItsButton pins both, and
fails when flat is centred.
ButtonCaptionTests covers the five new shapes on the existing rule. Its
stretch-fill assertion reads the content slot off the presenter rather than
recomputing it from the button's Padding: the shapes differ in whether their
presenter also draws a border, and a hand-rolled sum was two pixels out on
Button.cat for that reason.
The first fix handed Android's focus back from inside the keys' Click
handlers — which fire inside the UP event's dispatch, and Avalonia's
own view requests focus for itself after every handled touch dispatch
returns (AvaloniaView.DispatchTouchEvent, decompiled from 12.1.1). So
the platform's request ran after ours and undid it microseconds later,
which is exactly what the phone showed: the terminal still lost focus.
The return is now posted onto the main looper, landing one message
after the dispatch that stole, and it is wired at the row for both
halves of a press — DOWN steals too, and Click only exists for UP, so
a keyboard detached at DOWN would otherwise stay detached for the whole
length of the press. Check 11.10a now also says what a tolerable blink
looks like against a failure that stays.
The phone's notification kept saying '1 shell connected' after the
shell was gone, and both close routes were at fault. A shell exiting on
its own raised SessionEnded from inside its run's finally block — where
the run task is by definition not yet complete, so the LiveSessionCount
the keep-alive reads still counted the dead shell, and nothing fired
later to correct it. A tab closed by hand announced nothing at all, by
a recorded decision that assumed every subscriber was the closer; the
keep-alive is not, and a close it never heard about left the
notification claiming a shell over nothing.
The end is now announced from a continuation after the run completes,
and CloseSessionAsync announces after its own drain — every subscriber
was already a reconcile-to-reality handler, so the echo the old remark
feared costs nothing. Shutdown stays silent: it is dismantling the
subscribers along with the sessions.
Focusable=false was only ever half the fix, and its remark now says so:
Avalonia's focus stays on the NativeWebView, but the touch that presses
a key still hands Android's native focus to Avalonia's input view — the
platform moves it before Avalonia decides anything. The WebView's input
connection dies with it, the keyboard swaps to its no-input layout, and
the inset churn parks it over the very row that was tapped.
Each key now returns that focus once its byte is on the wire, through a
sibling of SoftKeyboard that walks the decor view to the one WebView
this application has. Free when nothing moved. Check 11.10a is the
phone-in-hand proof.
Brings the Android keep-alive corrections and the terminal renderer
reattach: the foreground service now actually comes up for shells and
an idle Files session, survives refreshes from the background, and the
terminal's data plane lets a reloaded WebView page take its socket back
over instead of freezing every session behind a dead one.
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 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.
App.axaml's Button.ghost, Button.accent, Button.danger rule set
VerticalAlignment and never VerticalContentAlignment. The first places the
button in its parent; the second places the caption in the button, and its
default is Stretch — so on any of these given a fixed Height the content
presenter stretched the caption TextBlock to the whole content box, and a
TextBlock draws its line at the top of whatever it is given. Measured on the
hosts toolbar, whose three buttons are 40 pixels: nine above the ink and
twenty below it. Every box was the height it declared, which is why this read
as one of them being the wrong height — nothing was mis-sized, the labels sat
in the top third.
Center rather than a hand-tuned Padding, because the gap is the difference
between the line box and the content box and moves with the font size: these
carry 11.5 by default and the primary action overrides it to 13.5. It is the
three shapes that were missed rather than a new idiom — navseg, sesstab,
headerghost, sidebarrow, fieldrow and paneicon all state it already, as does
every one of the phone head's own button classes. 134 buttons carry these
three classes; the ones that show it are those with an explicit Height, which
is both toolbars, the drawer's Save/Cancel pair, and the import screen. A
button sized to its own caption was already right and is untouched.
HorizontalContentAlignment is deliberately left alone: it is Stretch too and
invisible on a self-sized button, and the flyout rows that are stretched wide
ask for Left themselves.
ButtonCaptionTests measures a bare Button, since Application.Styles is global
and a screen-level test would pin one toolbar and leave the rest to the same
defect. It measures the laid-out line rather than the TextBlock's arranged
bounds, and that distinction is the test: under Stretch those bounds fill the
content box and so are symmetrical whether or not the ink in them is. The
first draft asserted on them and passed against the defect; the calibration
test caught it, and against the old markup all three shapes now fail naming
their own gap.
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 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.
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.