Commit Graph
55 Commits
Author SHA1 Message Date
jaap-jan 94e11f5e38 update packages
ci / build and test (ubuntu) (push) Canceled after 0s
ci / build (windows) (push) Canceled after 0s
2026-07-31 10:12:05 +02:00
jaap-jan 9a76eced14 Give hosts and terminals their own screen, and the rest of the vault another
ci / build and test (ubuntu) (pull_request) Canceled after 0s
ci / build (windows) (pull_request) Canceled after 0s
Rebuilds the client's shell from an imported design: a titlebar and nav rail
it draws itself, real multi-session tabs over the one WebView, a Ctrl+K host
search, and a vault screen that merges keys, passwords and pinned host keys
into one table. Hosts left the vault column for their own screen beside the
terminal, which is what the design asks for and turned out to be the better
split anyway.

Two screens the design shows have nothing behind them yet — file transfer
and teams — and say so plainly rather than rendering invented data; every
other gap between the design and this build is recorded in
docs/design-import-gaps.md.
2026-07-31 08:39:37 +02:00
jaap-jan d162271a45 Show the host keys this vault has approved
ci / build and test (ubuntu) (push) Canceled after 0s
ci / build (windows) (push) Canceled after 0s
Trust was created by the connect prompt and withdrawn from one host's editor, so
a pin for a host that had since been deleted or re-addressed was unreachable
from the interface entirely. It went on refusing connections and nothing in the
application would admit it was there. Two of the four recorded debts were really
this one: leftover pins, and no list to see them in.

A fourth section in the vault column, and the first that adding one has been
cheap for — three edits and two layout tests, which is what #8 and #9 were for.

No editor and no Add, which makes it the only section with neither. A pin is not
something anybody writes: it appears when somebody approves a fingerprint at the
moment of connecting, which is the one place a person can actually check it
against what the operator published. A form for typing one in would be a form
for pasting whatever a man in the middle just offered. So the section exists to
show and to withdraw, which is exactly what was missing.

The fingerprint is shown in full, wrapped, in a monospace line. The only thing
anybody does with one is compare it against a fingerprint an operator published,
and half of one cannot be compared — it can only be glanced at, which is the
habit pinning exists to replace. Nothing here is secret; a host key fingerprint
is published on purpose.

A pin no host in this vault dials is badged rather than hidden or deleted. That
is the leftover the debt was about, and keeping it is still right: the address
may be reached by something without a bookmark, and trust is about the endpoint
rather than the bookmark. The badge is a hint and not a verdict, which is why
nothing acts on it. Matched case-insensitively, because a host name is, and
because a list that called DB.internal unused next to a host saved as
db.internal would be inviting somebody to delete trust they rely on.

Forgetting goes through the same ForgetAsync as the host editor's button, which
withdraws every pin for the address rather than the selected row. Deliberate:
somebody who has stopped trusting a machine has not decided to keep trusting one
of its keys, and a second pin under another algorithm would go on being offered
at the next handshake — which reads as a withdrawal that did not work. The
status line says how many went, and the change is pushed immediately, because
the other machines are the ones still refusing to connect to a rebuilt server.

The list is read through the repository rather than through VaultKnownHostStore,
whose snapshot is shaped for the SSH handshake: one pin per endpoint,
deduplicated, no entity ids. This list has to show duplicates, because a
duplicate is one of the things worth seeing.

Two mutations, both caught: calling every pin dialled (3 tests), and defaulting
the selection to the first row (1) — the same hazard as the credential list,
since Forget acts on the selection.

The selector now holds four buttons in 340 pixels, and TheSelectorIsBigEnoughToClick
measures how much of that they use rather than leaving a fifth section to
discover it as "a button falls outside the window".

936 tests green across 16 projects, 6 of them new. Zero warnings, format clean.

Not verified: how the section looks. It joins the list in outstanding item #7.
2026-07-30 17:44:33 +02:00
jaap-jan da7462e41f Show one kind of vault item at a time, and let the vault hold passwords
Outstanding items #8 and #9, in one commit rather than two. They are separable
as work and were built in that order, but not as a diff: the section enum has
three members, the one-editor guard has three arms, and the picker offers keys
and credentials from the same list. Reconstructing an #8-only state would mean
hand-writing an intermediate version of VaultViewModel that never existed and
that no test has ever run. One honest commit beats two invented ones.

--- #8, the type selector ---

The column showed two lists and two editors stacked in 340 pixels, and only
just: the key list needed a MaxHeight and had to hide itself whenever its editor
opened, both to stop the host list above it pushing the buttons off the bottom
edge. Credentials would not have fitted at all. It now shows one kind at a time,
chosen by a selector at the top, and both workarounds are gone because a section
owns the whole column.

Three departures from the plan, each with a reason found while building it.

The selector is plain Buttons and a parameterised command, not a TabControl, a
TabStrip or a ListBox. All three of those hold the selection themselves, so a
click moves the highlight before the view model can refuse it — and this column
does refuse, while an editor is open. A selector lit on a section the column is
not showing is worse than the refusal it would be hiding. Buttons carry no state
and cannot disagree with the vault.

The one-editor-at-a-time rule survives with its justification replaced. That
rule was a workaround for the sizing problem above, and sections dissolved it:
the editors are in different sections and only one section is ever laid out.
BothEditorsAtOnce_DoNotFit_WhichIsWhyTheRuleExists is now
BothEditorsOpen_NowFit_BecauseOnlyOneSectionIsLaidOut — the same test, inverted,
because its own comment said that if it ever started passing the rule had become
unnecessary. It has. The rule stays for a better reason: an open key editor
holds a pasted private key in a bound string, and letting the column move on
would leave key material in a form nobody can see, with nothing on screen to say
it is there. A sizing hack became a rule about not hiding a secret from the
person holding it. KeyEditorIsInTheWay and HostEditorIsInTheWay are one
AnEditorIsInTheWay, called by the section switch and by every editor-opening
command.

And releasing the keyboard from the terminal has never worked. MainWindow takes
Win32 focus off the WebView's child window and then calls Focus() on
VaultColumn.KeyboardTarget — and a ListBox is not focusable by default in
Avalonia, which leaves focus to its items. So the call returned false, the
window ended up with nothing focused, and the keystrokes went nowhere: exactly
the state that method's own comment says its second half exists to prevent.
Found by writing the test to assert focus was taken rather than that the right
control was named — the cheap assertion was already passing. Fixed with
Focusable="True" on every list.

--- #9, credentials ---

Credentials have synced since they were added and could not be created. They can
now, and the sync layer needed no change at all: fourth item type, same result,
which is the item-kind seam working as intended.

One picker for all three ways a host authenticates, which is what makes the
illegal combination unrepresentable rather than merely invalid. SshKeyChoice
became AuthenticationChoice carrying an AuthenticationKind, and BuildHost reads
both SshKeyId and CredentialId off that single selection, so a host naming a key
and a credential — which HostSecret.TryValidate refuses — cannot be expressed.
Two pickers would have expressed it and then rejected it at save time. The kind
travels with the id in three places and none is padding: Missing takes it, the
placeholder lookup matches on kind as well as id, and Bound(kind) returns null
unless the selection is that kind. Drop any one and a dangling credential comes
back as a dangling key, which saves as a key binding to an id no key has.

A credential's username had to reach the SSH request, not just its password.
TryBuildCredential returned only the secret and the connect path read the
username off the host, so a stored credential would have gone out under the
wrong account — wrong in a way a server only reports as "authentication failed".
It is now TryBuildAuthentication returning a (Username, Credential) pair.

The no-username refusal moved, and had to. It ran before anything looked at the
binding, which made a credential's username unreachable in the one case it is
most useful: a host somebody never filled a username in for. It is now the last
thing every branch agrees on, so such a host is perfectly usable through a
credential that carries one, and a host with neither still refuses and now says
where to put one.

--- What the measurements cost ---

Ten mutations, all caught. Two are worth naming.

Removing a section's IsVisible is caught by OnlyOneSectionIsOnScreenAtOnce and
by nothing else: two visible sections overlap in the row they share rather than
clip, so every fit test still passes while the column shows one list through
another.

Defaulting the credential selection to the first row is caught by
ReloadingKeepsACredentialSelectionButNeverInventsOne, and the property is a
safety one rather than tidiness — Delete acts on the selection, so a list that
picked a row on every background sync would aim a one-click password deletion at
something nobody chose. The key list has the same property, and its comment
cited a method that has not existed for some time; both now name the delete
command they actually protect.

One test of mine could not fail, and the mutation pass is what found it.
AHostBoundToACredential_SendsItsPasswordAndItsUsername gave the credential and
the host the same username, so it passed whichever one the code read. An
override is only tested when the two values differ.

Two shipped statements went false and were corrected rather than left: the class
remark saying passwords were "not yet" in the vault, and the terminal column's
"Keys are in the vault; passwords are not yet."

That column's hint is now a tooltip on the password box rather than a sentence
in the row, which was measured the hard way — by looking. At the window's 820px
minimum the column gets 480, and a 220px box plus Connect plus any sentence does
not fit; the row has shipped clipped for as long as it has had a hint in it.
That strip is the one part of the window nothing can measure, because MainWindow
cannot be laid out headlessly at all. Extracting it into its own control, as the
vault column was extracted for exactly this reason, is what would fix that, and
is not done here.

911 tests green, 30 of them new. Zero warnings, dotnet format clean.

Seen by a person, which is how the two defects above were found. Still open from
that pass: unlocking with the device key raises its consent dialog and then
never returns, while registering one works — the difference is which thread the
CNG call lands on, and diagnosing it properly is its own change.
2026-07-30 17:08:59 +02:00
jaap-jan c5dec2d68e Measure the vault column instead of arguing about it
Nothing in this repository loaded a .axaml, so the one class of defect this
window has actually shipped — a control arranged past the edge of its container,
where it cannot be clicked — was the one class nothing could catch. The setup
screens rendered sliced once, with their buttons unreachable. The vault column is
the next candidate: 340 pixels wide, two lists and two editors, and the only
thing keeping it from clipping its own Save button at the window's 520-pixel
minimum is a state rule that one editor may be open at a time.

That rule was added on the strength of an argument. This adds an
Avalonia.Headless project that lays real XAML out at a real size and reports
what a user could not reach, and the argument is now a number: with both editors
open the column overflows, so the rule is load-bearing rather than defensive.
BothEditorsAtOnce_DoNotFit_WhichIsWhyTheRuleExists is the test, and it says what
to do if it ever starts passing — the column has room, so delete the rule, not
the test.

Two findings arrived by measuring rather than by reasoning, and the first one
changed the design.

MainWindow cannot be shown headlessly at all. Showing it attaches the terminal's
NativeWebView, whose Win32 adapter initialises WebView2 on attach, and WebView2
refuses a non-STA thread — which is exactly why Program.Main carries [STAThread]
and is written down in that comment. A HeadlessUnitTestSession owns its
dispatcher thread and offers no apartment choice, so the whole window is out of
reach at any size. That is pinned as a test asserting RPC_E_CHANGED_MODE by
HResult rather than by message, so a future Avalonia that makes the adapter lazy
will fail it and the harness can be widened.

So the column had to become its own control to be measurable, which is the
extraction the type-selector rework wanted anyway. Keyboard release moved with
it: MainWindow used to call Focus() on HostList by name, and now asks
VaultColumn.KeyboardTarget. The window decides that the keyboard should leave the
terminal and the column decides where it lands — which is the seam the rework
needs, because once the column shows one list at a time, "which list owns the
keyboard" is a question only the column can answer.

The second finding is the way this kind of test lies quietly. The hint class
lived in MainWindow.Styles and carries TextWrapping. A Window's styles reach its
whole tree, so nothing about the application depended on where it lived — but a
control laid out on its own loses them, and every hint paragraph would have
measured as a single line. The harness would have passed while measuring heights
that were all too small. The three shared classes now live in App.axaml, which
changes no rendering and makes the measurement honest.

The detector is calibrated in both directions, because a clipping detector that
never fires reads as a guarantee: a deliberately clipped Save button is caught by
name, and a list longer than its viewport is exempt. Scrolling is how a list is
supposed to handle more rows than fit, and without that exemption the host list
would fail the moment it had content. It also mis-fired once and the rule is
narrower for it — an empty ListBox is zero pixels tall and correct, so "arranged
with no size" now applies only to controls the theme gives a height to.

Skia rather than the headless drawing stub, deliberately. The stub's font manager
invents glyph metrics, and text height is an input to every stacked panel in this
column, so measuring against it would produce numbers that are self-consistent
and unrelated to the application.

A separate test project rather than more tests in DodoSSH.Client.App.Tests.
Avalonia's application, dispatcher and platform are process-global singletons
initialised once, and that project's identity is the shell's state machine
without Avalonia — the whole reason sign-in is a delegate. The fakes needed to
reach a real unlocked vault are shared from DodoSSH.Client.Session.Tests by
source link: a project reference would make one test project a library of
another, and a copy would be a third implementation of the same decision table
drifting from the other two.

855 tests green, 10 of them new. Zero warnings, dotnet format clean.

Not done, and this is groundwork rather than the item itself: the type selector.
The column still holds both lists at once, so a third item type would still
recreate the defect the one-editor rule works around. What is different is that
the rework can now be checked instead of eyeballed — including the claim it is
being made for, that one editor at a time stops being a runtime rule and becomes
a fact about what is in the visual tree.

What this harness will never catch is the terminal's native child window
compositing over Avalonia content. That is a Win32 property of a real window, no
headless surface reproduces it, and it is the reason the WebView is collapsed
rather than covered.
2026-07-30 11:34:09 +02:00