Commit Graph
6 Commits
Author SHA1 Message Date
jaap-janandClaude Opus 5 4925dcf179 Give the phone the + it was promised, and the editors behind it
Steps 5 and 6 of docs/adding-hosts-on-the-phone.md, which finishes it. The
phone can now put things in a keychain rather than only read one.

The floating + arrives three designs after it was first asked for. It was
refused twice on the honest grounds that it would open an editor this head had
not got — and that stopped being true when steps 1 to 4 landed. It raises a
sheet rather than a form, because "add" on this screen has been two operations
since groups existed: a host, or a group to file hosts under.

Both editors are cards in the list's own row, swapped for the list rather than
stacked over it, following SnippetsScreen and for the reason written there: a
form on top of the list hides what it is about. There is still no dialog and no
editor screen anywhere on this head. The port and username boxes may be left
empty, and the greyed text inside each is what the host will actually use —
following the group picker as it moves, so the form says what leaving a box
blank will do rather than making it a guess.

The back gesture gains a guard above the switch rather than a case inside it.
An editor is not a screen and has no entry there, and it is strictly nearer:
the sheet sits over the editor's own screen, so back lowers whatever is topmost
before it considers moving between screens.

A group editor needs a way in, and the phone draws no groups panel — so the
heading in the host list gains a pencil. A + that adds groups with no way to
correct one is the same strange thing to ship as one that adds hosts with no
way to correct one, and worse: a group's defaults are what every host beneath
it falls back to.

Scouting step 6 turned up a bug worth more than the prose it was looking for.
ReloadGroupsAsync read the active vault alone, which was a cosmetic limitation
while a group carried only a name — a teammate's host showed under UNGROUPED
and nothing else was lost. Since a group began lending a port, a username and a
binding, the same omission silently dropped all three: that host would dial 22
as nobody while the machine is on 2222 as deploy, with nothing on screen saying
why. The resolution map now spans every readable vault. The editable list stays
narrow, because a row shown across vaults needs a vault id for rename and
delete and two vaults may hold groups with one name — but the map needs none of
that, since it is only ever asked what an id says.

An adversarial review of this change found something I had written a comment
denying. The + was a child of the outer Panel rather than of the list's row, so
it anchored to the bottom of the screen and sat squarely over the new EDIT
button and the lower edge of CONNECT — and because a Panel hit-tests its
last-declared child first, tapping the right end of EDIT would have raised the
add sheet. The comment beside it claimed it was in the list's row precisely so
that could not happen, and manual-checks 8.1 names that exact failure. It is in
Grid.Row 2 now. The same pass caught a connect bar that was disabled rather
than hidden while its own comment said hidden, a scrim that would have flashed
its default pressed chrome across the screen, a fab comment claiming an accent
fill no other button had when Button.primary has it, and a back-gesture remark
naming two view-model properties that do not exist anywhere in the repo.

That last one came from this plan, which invented IsVaultsSurface and
IsConnectionsSurface, and pointed step 6 at a ConnectionsScreen.axaml that has
never existed. Both are corrected in the plan rather than quietly worked
around, along with a fourth "groups are flat" site in README.md that step 1
missed.

The phone's rectangles are not measurable and structurally never will be: the
layout suite is net10.0, the Android head is net10.0-android, and Avalonia's
application is a one-shot process global — so a second head cannot share the
process even if the reference were possible. Everything the sheet and both
editors bind to is shared and is tested headlessly here; the pixels go to
docs/manual-checks.md phase 8, nine checks, which is where this project already
sends what it cannot assert.

Tags remain the one thing asked for that did not ship. Tag is a full item kind
and TagIds merges, encodes and resolves, but no screen draws a chip or offers
to add one, so the tags a client can store are ones nothing here can see. Both
editors carry the set through a save untouched so a client that can set them
does not lose them. Recorded as half-shipped in docs/design-import-gaps.md
rather than quietly left out.

Verified by the whole suite: 1402 tests over nineteen projects, none failing.
Both heads build. Nothing seen on a display — phase 8 is what that costs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 11:30:00 +02:00
jaap-janandClaude Opus 5 3627021420 Give the desktop the second design too, and the window the size it now needs
The desktop v2 design is the other half of the one the phone took last commit, and this is its chrome: a
190-pixel labelled sidebar where the 54-pixel icon rail was, a titlebar with the search box centred in it,
and session tabs drawn as pills. The palette was already here — it is shared, and moved when the phone's
did — so what this changes is shape rather than colour.

**The window's minimum grew, and by exactly what v2 added.** The sidebar is 136 wider and the chrome 14
taller, so 880x560 became 1016x574. That is not a round number somebody liked: it leaves every screen the
same 826x464 it was designed against, which is the arithmetic the layout suite is built on. Four of the
tables stop fitting at 690 wide, so widening the sidebar and leaving the window alone would have broken
them somewhere no test was looking. LayoutHarness carries the new constants and the suite still passes at
the minimum, which is the whole reason it exists.

The rail's five-character abbreviations are gone with the width that caused them — PINS and SNIPS are Pins
and Snippets again — and each row gains a glyph and a count. A count is drawn only where one is real, so
SFTP, Logs and Preferences show nothing rather than a zero: a transfer queue's depth is not how many files
a screen holds, and a log has no total until it is read. The count beside Pins is the vault's own, not the
Pins screen's VisiblePins, which is the filtered list and would have made the sidebar count whatever
somebody had just typed into a filter box on another screen. Teams has no count for a related reason: they
are read from the server when that screen is opened rather than on unlock, so a number there would read 0
until somebody had already been to look.

One colour moved with it, finishing what the repalette started: the live-session summaries on the unlock and
sign-out cards were Info, so the two heads disagreed about a fact the phone paints green. They match again.

**Buckets became a destination rather than a mode**, which is what the design draws and what the phone
already does. The HOST / BUCKET pair inside the files screen is gone; ShellScreen.Buckets draws the same
TransfersScreen with the other picker, and the sidebar entry is what sets it. That also settles an old
disagreement rather than merely moving it: TotalItemCount is keys plus passwords and excludes buckets, so
the number beside the keychain used to disagree with the list under it, and now counts what that screen
shows.

There is one session behind both file destinations, so asking for the other kind while something is open is
refused rather than obeyed — and refusing means staying put. An earlier turn of this had it move anyway and
only decline to switch the picker, which put the S3 entry in the sidebar over a screen still listing an
SFTP host: two pieces of chrome disagreeing about where you are, which is worse than the navigation simply
not happening. The message that says so goes to Transfers.Status, which turned out to be drawn in the same
grid cell as the connected chip — survivable while it was mostly read before connecting, and not once a
refusal reports itself there. It has its own column now.

The design has nine entries' worth of screens and draws five. Pins, Teams, Import and Preferences are
built, working screens, so they keep their entries — the sidebar is labelled now and has the room, and
dropping an entry would have stranded a screen rather than simplified anything. The Team vault card the
design pins to the foot is not drawn: it is a second route to a screen already in the list, carrying a seat
count nothing here produces.

**The status bar survives the design that deletes it**, cut down to one thing. Two of the three facts it
carried moved into the titlebar with v2 — the sync word is beside its dot and the shortcut hint is inside
the box that uses it — so those are gone from it rather than printed twice. The third is Vault.Status, the
only channel this application has for saying a save failed or a merge picked a winner. The design is a
mock-up of an afternoon that goes well and has nowhere to put a sentence like that; dropping the bar would
have meant dropping the sentence or repeating it on nine screens.

What v2 draws and this does not is in docs/design-import-gaps.md, and it is the same list as the phone's
for the same reasons: the forwarding screen and both its chips, the host detail's fingerprint, tags and
last-session cards, the keychain's rotate button, the logs' FOLLOW pill and severity filters, and the
session footer's latency. The terminal is not inset behind a rounded frame either — it is a native child
window that composites above everything Avalonia paints, so the frame would clip nothing, which is the same
answer the phone gave.

**The light theme is not built.** Its accent is #6D5AE6, a different hue rather than a tint of the dark
one, so it needs every colour doubled, a variant to switch on, the renderer's own page switching with it,
and contrast checked twice. That is a piece of work rather than a setting, and it is separable from the
layout — which is why this commit is the layout.

The screens themselves are restyled through the shared vocabulary rather than rebuilt: corner radii,
chips, cards and the accent's ink, all in App.axaml, so every screen moves at once. Their layouts are left
alone deliberately. The design draws read-only detail panes and these screens carry the editors and forms
it has no equivalent of, so replacing a layout with the mock-up's would have lost the half that is
actually used.

Verified by the whole suite: 1309 tests over nineteen projects, none failing, including the 68 layout cases
that stand up real Avalonia and measure every screen at the new minimum. Both heads build. Not run on a
machine with a display — see docs/manual-checks.md for what wants looking at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZE3u99BNt6LzgTC5jhbz2
2026-08-02 19:29:21 +02:00
jaap-janandClaude Opus 5 5593f337b6 Give the phone the second design, and both heads the palette it arrives with
The Android v2 design is what this head draws now: four destinations in a bottom bar — Hosts,
Terminal, Keychain, More — with snippets, SFTP, S3, logs and preferences one tap deeper behind the
last. The first design's four had nothing behind them, which is what made a hub worth building.

The palette moved from green-black to blue-black, and it moved in the shared project because that is
where it lives and the desktop v2 specifies the same seventeen tokens. One colour changed meaning
rather than value, and it is the only semantic change in the file. Green used to *be* the accent, so
Ellipse.dot.live filled with Accent and "the thing to press" and "a shell is open on this host" were
the same colour by construction. v2 makes the accent blue and keeps a green for status alone, which
finally separates them: Live is that green and nothing merely interactive may use it. The accent is
also two colours now — Accent fills, AccentText writes — because a row of chips in the fill colour is
a row of things that all look like the primary action.

A palette is not one file, which is the part worth knowing before the next one. Nine hex literals
lived outside it: the nav bar's own label colours, the accessory keys and their Ctrl-latched state,
two scrims, the window background Android paints before Avalonia has a frame, and the launcher
vector. The two C# sites now resolve from the dictionary by name rather than restating it. The
renderer's page cannot — it is served to a WebView over a loopback socket — so terminal.css and
terminal.js keep hand-copied values and say so at both sites.

ShellScreen gained More and Buckets, appended rather than slotted in. SFTP and S3 are one screen over
one TransfersViewModel differing only in which picker they offer, and the kind is set by the button
that navigates rather than on arrival — doing it in OnScreenChanged made every arrival at Transfers
force the picker back to hosts, including the desktop's own rail arriving at a screen with a bucket
already open. It refuses to change kind while a session is live, because there is one session behind
both destinations and switching under it would title a screen S3 while it listed an SFTP host.

What the design draws and this does not, on the usual grounds. The FORWARDING screen: nothing here
forwards anything, so every toggle would be a control with no effect — it is a paragraph on the hub
naming the absence, for the reason the desktop keeps TEAMS in its rail. The terminal's `23 ms · fwd
5432`. An ED25519 badge and a SHA256 line on keychain cards, which need an algorithm field and a
fingerprint the item type does not have. An `agent` chip, for an agent that does not exist. Snippet
run history and exit codes. The Logs FOLLOW pill, which claims a live tail over records that are
written once at close and read when the screen opens, and the severity filter, which has nothing to
count — that chip row is spent on the real choice, which of the two logs. S3 bucket totals and
lifecycle. And the + on HOSTS, which would open a host editor this head has not got.

SFTP is browse, open and delete. Both transfer commands work, and what they work against is the local
pane: QueueDownloads writes to Path.Combine(LocalPath, name), and LocalPath starts at
SpecialFolder.UserProfile, which on Android is the application's own private directory. A download
would have reported success and left the file where the person who asked for it cannot open it, which
is worse than not offering it — a refusal is visible and a file in /data/user/0/ is not. The queue is
not drawn either, since nothing here can put anything in it. Both return with the document picker.
The foreground service still counts zero transfers, and the reason moved rather than went away.

Four defects worth naming, because three of them are the kind that compile. A Button as a ListBox
ItemTemplate swallows the pointer press before the list sees it, so the files listing selected
nothing and every command reading the selection did nothing — the row is a Border now and the
phone-only single-tap-to-open is a Tapped handler, which also keeps a desktop single click from
walking into directories. Avalonia type selectors are exact, so TextBlock.fingerprint never matched
SelectableTextBlock and every fingerprint on this head rendered proportional and unwrapped: that was
breaking the never-truncated rule on the host-key sheet already. The new two-level hierarchy had no
handler for the system back gesture, so back left the application from a log screen. And the tab's
close cross had shrunk to a 30x32 target flush against the select target, which is the one control
here that ends a shell with no confirmation and no undo.

Fingerprint unlock is raised on arriving at the lock screen rather than waiting for its button, which
is still there. Only at launch: a lock the user asked for is not answered with an immediate request
to unlock, which makes LOCK look inert and trains the reflex of authenticating at a prompt nobody
asked for. And once, because a declined gesture leaves the passphrase box exactly where it was and a
prompt that came back after being dismissed would be a modal you cannot get out of to type into it.

Two fixes fall on the desktop. Its file listing coloured directories with Info and executables with
Accent, which was blue against green and is now two steps of one blue; an executable is Live now.
And a bucket's folders were drawn with a 0001-01-01 timestamp, because a prefix has no modification
time — blank now, for the reason a directory's size is blank.

Verified by the whole suite: 1309 tests over nineteen projects, none failing, including the layout
suite that stands up real Avalonia and parses every desktop screen. Both heads build. Not verified on
a device — nothing in this head ever has been; see docs/android-port.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZE3u99BNt6LzgTC5jhbz2
2026-08-02 18:23:53 +02:00
jaap-jan 7a3a521c59 Give the phone the rest of its screens, and a way in
ci / build and test (push) Failing after 2s
ci / android head (push) Failing after 1s
All seven screens of the design, plus the two it does not draw because it starts at an
enrolled phone: naming a server, and choosing a passphrase.

The five states docs/android-port.md worried about losing at 360dp are all here and none
of them softened. The changed-key refusal is a full-screen panel rather than a bottom
sheet, because a sheet is swipe-to-dismiss by convention and that screen must have no way
forward. The recovery code raises FLAG_SECURE for its own state and lowers it afterwards,
so the sentence about screenshots is true rather than decorative. The delete
confirmations keep their counts and replace the row in place.

Signing in works, and the seam it needed is worth more than the implementation:
IAuthorizationCallback now sits between OidcClient and the loopback listener, so the two
heads differ in where the response arrives and in nothing else. PKCE, the state check,
discovery, the token exchange and the key binding stay one implementation — a second OIDC
client would be a second place for a security bug to live. The phone registers a
private-use scheme with the system rather than binding a loopback port, which on a shared
device any other app can do first.

The accessory key row needed TerminalWorkspace.SendInputAsync: ordinary typing goes from
the renderer straight down the socket, and there was no way in for the keys a software
keyboard does not have. Ctrl latches, because one thumb cannot chord, and the latch is
drawn — a modifier that is on and does not look on is how somebody sends ^L to a database
prompt believing they typed an l.

597 client tests green, including two new ones for the input path and one for the
terminal surface command. Nothing has run on a device.
2026-07-31 21:43:11 +02:00
jaap-jan 81e7e6d939 Write down what the phone found, and stop it rotting
docs/android-port.md was an audit of work not started; it now says what is built. Three of
its statements needed correcting rather than extending, and they are marked where they sit:
the Android version question is settled and was never as open as it looked, because
Avalonia.Controls.WebView ships only a net10.0-android36.0 assembly and nothing lower can
resolve it; cleartext to loopback has to be permitted explicitly, which the audit missed
entirely; and the spike produced a structural change it did not anticipate, in
DodoSSH.Client.Shell.

A CI job of its own, because the head is deliberately not in DodoSSH.slnx and a project
outside the solution is a project nobody notices breaking. It packages as well as builds:
a native library with no Android ABI and an assembly that will not dex are both invisible
to a compile, and both are exactly what this head is exposed to.

The README says plainly that signing in is not built, that a fingerprint re-enrolment
destroys the device key, that a notification appears while a shell is open, and that none
of it has run on a device.
2026-07-31 21:09:42 +02:00
jaap-jan d07b336868 Free the terminal from the Hosts screen, and fill the room it left
The WebView sat inside the Hosts grid, so navigating to Files or the keychain
hid every open terminal and the strip that named them. A connection you had
opened was invisible from four of the five screens. The window now has two
surfaces rather than one: a nav rail that says which page you are on, and a
terminal strip that is always there and switches the whole content area to a
shell. Screen keeps meaning "which page" and never becomes a sixth kind of
page, which is why this is two properties instead of one enum with a terminal
member in it.

Every screen lives inside one wrapper panel that collapses when a terminal is
showing. That is not tidiness — the WebView hosts a Win32 child window that
composites above everything Avalonia draws, so a screen left visible over its
rectangle is a screen sliced in half, and this window has shipped that defect
once already. One decision point, IsTerminalShowing, and a nested panel rather
than five compound bindings nobody would remember to extend.

The focus choreography is the part no test in this repo can see. Every reveal
path now focuses in the same turn the WebView appeared, so all three of them
post at DispatcherPriority.Loaded and let the native control re-push its bounds
first. Going the other way had a real bug: the screen-changed branch called a
bare Focus() where it had to release the keyboard from the native child, so
switching from a terminal to Files silently ate the first keystrokes. Rare
before this commit and the primary gesture after it.

The tab strip grew a cross inside each tab, a plus that opens the quick-connect
palette, and middle-click close. Nested buttons are correct here: Avalonia
handles a left press on the cross and deliberately does not handle other
buttons, which is exactly what lets middle-click bubble up from the cross as
well as the tab. The test is PointerUpdateKind rather than
IsMiddleButtonPressed, because the latter reports button state and is also true
for a left press made while the middle button happens to be held. The handler
is on the tab and not the strip, so the background closes nothing by
construction. Plus opens the palette rather than a flyout, since a menu
dropping into the WebView's rectangle may or may not composite above a child
HWND and this repo does not make rendering claims it has not photographed.

Everything a user reads now says keychain. The wire, the database and the
cryptographic spec still say vault, deliberately: renaming those is a migration
and a protocol change for a word. That split is written down rather than left
to be rediscovered as an inconsistency.

Four things that were squeezed into the keychain's category rail, or into
nothing at all, now have screens. Pinned host keys get one, with fingerprints
never truncated and a filter that matches them, because comparing what you have
against what the operator published is the whole workflow; the approved date is
read out of the item's UUIDv7 rather than added as a column, and says so, since
it means first approval and not last use. Keys can be generated in the client,
which needed the openssh-key-v1 container written by hand — there is no BCL or
NSec helper, and the PKCS#8 route is unverified in the SSH library this uses.
The armour carries no passphrase: encrypting it needs bcrypt_pbkdf, which is
Blowfish with a swizzle, in a project whose crypto is otherwise entirely
libsodium, for a protection the key's own remarks argue is redundant inside a
vault. Generation fills the existing editor and stops, so SAVE stays the one
thing that writes. ~/.ssh/config can be imported behind a preview that is
ticked per row and writes nothing until the button; IdentityFile records the
path and imports the key material only on an explicit opt-in, because reading
somebody's private key into a vault is precisely the act this product exists to
make deliberate. Match blocks and ProxyJump are reported rather than obeyed —
one cannot be evaluated statically and the other has nothing behind it to route
with, and a preview that implied otherwise would be worse than one that admits
it.

Files can be dragged in all four directions that are honestly available. Remote
to Explorer does not ship and is not pretended to: the shell wants the bytes
during the drop, which needs a virtual file and a native COM data object,
outside what Avalonia offers. Note for the next person that Avalonia 12
replaced the drag model outright — DataObject and DataFormats are no-op stubs
and IDataObject is not in the reference assembly, so every tutorial written for
11 does not compile here.

Hosts can be grouped, flat and never nested. A parent id merged as a scalar
lets two offline clients each re-parent A under B and B under A, producing a
cycle inside an encrypted payload that no server can police and every reader
would have to detect for ever. Membership lives in that payload rather than in
the one plaintext concession ADR 0001 allows, whose test is that the relay
cannot function without it — nothing on the server reads a group, so what
plaintext would hand over is a clustering of the estate for nothing. The
plaintext column reserved for it is dropped, provably always null, and the
server now refuses a client that sends one; it was never populated, was copied
on apply, and was not cleared on delete, so a group id would have outlived the
host it described.

Snippets insert through xterm rather than through the pump, because xterm is
the only thing that knows whether the remote has bracketed paste on, and that
is what makes a shell treat embedded newlines as text instead of as execute.
The host process moves opaque bytes and never parses output, so it would have
to guess, and guessing wrong runs every line. Running is off by default and the
copy says the text goes into whatever is there — the terminal has no notion of
being at a prompt, and may be in vi or at a password prompt with echo off, so
the Enter the user presses themselves is the entire safety property.

Connections and keychain changes are recorded as synced encrypted items, which
is what makes them auditable by a team later and costs the server knowledge of
connection rate and timing from row counts alone. ADR 0001 already concedes it
cannot hide that class of metadata; the trade is now written into it rather
than left implicit. A connection entry is written once, at close, which is what
makes a synced log tractable: nothing to merge, one outbox row, no chance of
colliding with itself. Live sessions come from memory, not from the log. The
write is void by contract and posts to a bounded channel, because putting an
encrypt-and-write on the teardown path of every session is how closing the
application comes to take four seconds. A ticket opened before a lock still
closes afterwards, since a shell outlives the vault. The activity log hooks the
one generic repository every kind writes through, so it cannot miss a caller —
which is also why the log kinds themselves declare they are not audited, or the
first entry would write an entry about writing an entry. It records the names
of the fields that changed and never their values; a log with an old password
in it would be a plaintext credential store with no vault around it. Retention
is 90 days or 5,000 entries, whichever bites first, pruned on the sync loop
rather than on a second timer.

That log traffic then broke the status line, which is worth recording because
the fix is a shape and not a patch: background sync counted its own log rows as
pushed items, so the quiet rule stopped being quiet and every action's message
was overwritten a second later by a sync report. The report now separates log
rows from user items and the rule reads the latter.

S3 buckets appear as a remote in the file browser, behind the same interface an
SFTP session implements, so the queue and both panes did not have to learn what
they are talking to. Uploads go through a pipe, because the queue wants to
write and the SDK wants to read; memory is then bounded by the part size
instead of buffering a file to disk twice.

Finally, the Windows device key store moved out of the session project, which
was the one thing keeping it from being portable — everything else in it is
platform-neutral, and a Windows CNG dependency in the middle of the vault code
meant a second head could not reference it without dragging Windows along. The
seam that made the move free was already there. docs/android-port.md is the
audit behind that: what ports, what does not, in order of cost, the four
decisions taken, and an inventory of every screen and state the interface has
to carry, written so a design can be made from it directly.

dotnet build, dotnet test and dotnet format --verify-no-changes are all clean:
1240 tests at zero warnings, including the end-to-end suite against real
containers. The manual checks that headless Avalonia cannot make — the drag
from Explorer, a generated key against a real host, twelve tabs at the minimum
window width — are listed in docs/manual-checks.md and are still outstanding.
2026-07-31 20:30:05 +02:00