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 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.
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 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.