Commit Graph
9 Commits
Author SHA1 Message Date
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 5cbda59a34 Merge branch 'main' into claude/host-management-ui-plan-7f20ab
Seven files needed a hand. Most were two branches adding something in the same
place, but three were one branch changing what the other had moved or renamed,
and those are the ones worth reading.

The shell keeps both new fields and both constructor lines: the connection
recorder this branch built and the teams view model main did. Where main put a
teams load inside OnScreenChanged, it now sits beside the logs refresh rather
than inside RaiseSurfaceState — this branch extracted that notification block
and it is called from two properties, so a screen-specific side effect in there
would fire on every terminal switch as well.

Main gave four row types a vault id and a vault name, and this branch had moved
one of them — KnownHostRowViewModel — into its own file when the pinned keys
became a screen. Git resolved that as "deleted here, modified there" and took
the delete, which compiles as long as nobody looks: the moved copy still had
the two-argument constructor and the call site had grown to four. Carried over
by hand, along with the ordering the pins list now does on them.

The status line's quiet rule was the subtle one. Main extracted it into
IsWorthReporting; this branch had changed the same condition to read item
counts rather than raw ones, because every user action queues a log entry a
moment later and this machine reads its own entries back on the next pull. Take
main's structure and the merge builds, passes, and silently restores a bug this
branch existed partly to fix — every save's message overwritten a second after
it appears. The method now reads PulledItems and PushedItems, with the reason
in its remarks.

Two conflicts were prose that had gone stale rather than code. The keychain
screen's comment said team vaults are refused by the server's access service,
which was true when it was written and is not now; main's replacement stands,
in this branch's vocabulary. The design-gaps row for groups was claimed by both
— real host groups here, per-vault headings there — and they are different
things, so both rows stay and the difference is stated: a group is a shelf the
user chose, a vault is who can read the item.

One defect the tests found and the compiler could not. Generating a key opens
the same editor as pasting one, but not through NewKey — so it never set the
target vault main added, and a generated key was filed into whatever vault was
edited last, or none. Both key-generation tests failed on it. Fixed where the
editor opens, with the reason recorded there.

One gap is left deliberately and is written down rather than half-built. Hosts,
keys, credentials and pins are read across every vault this session holds a key
for; groups are read from the active vault alone, so a host a teammate filed
shows under UNGROUPED. Nothing is lost or misfiled — it is what the sidebar
already shows for a group that has been deleted — but closing it needs a vault
id on every group row for rename and delete, and a way to tell two vaults'
identically-named groups apart under a layout with one heading per group. Both
are worth doing and neither is a merge's business. It is in the remarks on
ReloadGroupsAsync and in docs/design-import-gaps.md.

dotnet build, dotnet test and dotnet format --verify-no-changes are all clean:
1282 tests, including the end-to-end suite against real containers.
2026-07-31 20:44:39 +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
jaap-jan 23eca3a21b Merge branch 'main' into claude/m3-implementation-57f9d7
ci / build and test (push) Failing after 2s
Three files conflicted, and two of the resolutions are more than a choice of
side.

QuickConnectTests had both branches fixing the same build break — main's M2
merge left the shell's constructor with an ISftpSessionFactory nobody passed.
Main's version wins because it carries a comment saying why the palette never
needs a session.

VaultSession's conflict is adjacent edits: main added the remembered sign-in
members and this branch changed SyncAsync's summary from "the active vault" to
"one vault". Both kept.

VaultViewModel is the one that matters. Main taught the background pass to
report a sync that had to start over, on the grounds that a machine which
silently re-read a whole vault has had something happen to it; this branch
turned a pass into one report per readable vault. Taking either side alone
would have lost the other, so ResyncedFromStart is now one of the conditions
IsWorthReporting checks, per vault.

Merging also broke something neither branch could have caught alone, and the
build would not have said a word. SyncOnceAsync cleared LastSyncFailed
unconditionally, which was right while a pass was one vault and a failure was
an exception that never reached that line. A failure is now a report — one
unreachable team vault must not stop the others syncing — so the flag was being
cleared over a vault that had just failed, lighting the titlebar SYNCED. It is
computed from the report instead, in the one place both callers go through, so
the manual command gets it as well as the loop. The background pass still
swallows the message and keeps the fact, which is what
AnAutomaticPassThatFails_LeavesTheStatusAlone is there to hold it to.

Two comments the auto-merge left describing a world with one vault in it: the
SCOPES rail's, which said team vaults are refused by the access service, and
the host sidebar's "One heading, for one vault".
2026-07-31 12:26:59 +02:00
jaap-jan 95816de0c5 Share a vault with a team, without the server holding a key
M3's teams, sharing and ACLs. Teams with roles, a public-key directory, the
append-only key log served for clients to check it against, team-owned vaults,
and vault key grants wrapped by a client and stored opaquely by the server.
VaultAccessService resolves team membership to PermissionFlags, so a viewer may
pull and may not push; the desktop client reads and syncs every vault it holds
a key for, and a real TEAMS screen replaces the one that said it did not exist.
No migration: team, team_membership, vault.team_id and vault_key_grant have all
been there since the first one, which is what carrying two unused tables bought.

Membership is authorisation. A grant is access. The obvious model is one
concept — "access", with a role attached, handed out by the server — and this
architecture cannot implement it: a vault key is sealed to each member's X25519
key, and only a client holding the plaintext can seal it for somebody else. So
"give Bob access" decomposes into a database write and a wrap, which happen on
different machines. Adding a member makes the server serve them the vault; it
cannot make it readable. VaultSummary.WrappedVaultKey is null in the meantime
and the vault appears in their list saying it is waiting for a key, because
hiding it until a grant existed would have been tidier and would have implied
the server was the thing granting access. The screen says the same thing after
every add, in the status line. ADR 0009 records the whole decision.

Sharing verifies or refuses. A directory lookup is a claim by the server about
a third party's public key, and wrapping to an unverified claim hands the vault
to whoever made it — no amount of transport security helps, because the server
is inside the threat model. KeyLogAudit reads the whole log, recomputes every
entry's hash from its own contents, checks the chain from genesis, and refuses
unless the offered key appears in it unchanged. There is no override flag: one
that exists gets used on the day the log is briefly unreachable, and the
resulting grant is indistinguishable from a correct one afterwards. What it
still cannot promise is that the key is the right person's, so the fingerprint
comes back for an out-of-band comparison and the success message says so every
time. A test corrupts the fake server's log by one byte and watches the client
refuse rather than warn.

The roles are only the ones that are enforceable. There is no ConnectOnly,
despite the design asking for one and TeamRole having room: SSH terminates on
the client, so a session needs the credential's plaintext on that machine, and
"may connect but may not read the key" cannot be enforced here. Shipping it as
an option in a dropdown would have been a lie. Connect rides along with Read
and is documented as an interface hint. Removal is named for what it does — it
revokes grants and flags the vault for rekey, and claims nothing about what is
already on somebody's laptop.

Three things are deliberately absent, and each is a refusal rather than an
omission. The rekey itself, because re-wrapping every item's data key under a
new vault key needs a client holding the current one; the server records that a
rotation is owed and the interface reports it, which is more honest than a
button that only appears to do it. Ownership transfer, because allowing an
owner to be removed without one leaves a team nobody can administer. And
cross-vault host key trust: a pin in a team vault is listed but not consulted
at connect time, because any member with Write could otherwise pre-approve a
fingerprint another member's client then trusts silently for a host in their
own vault. Scoping trust properly needs a scope on the SSH connect path, which
IKnownHostStore has not got; until then the narrow direction is the safe one
and the cost is in the README rather than hidden.

Reading now spans vaults and writing still does not. Every list on the vault
and hosts screens covers each vault the keyring opened, rows carry the vault
they came from, and an edit goes back to that vault rather than to the active
one — writing it to the active vault would fork the item and only show up when
a colleague wondered why their change never arrived. A new item goes wherever a
picker says, defaulting to the personal vault and never moving on its own,
because an item filed into a team's vault is visible to that team and moving it
back means deleting and retyping. The sidebar heading stops naming one vault
once there are two, and each row names its own.

The server checks what it can and nothing it cannot. It will not record a grant
for a key its recipient no longer holds, for a superseded generation, or for
somebody who is not in the team — each of those would otherwise surface days
later at the far end as a tag failure indistinguishable from corruption. It
does not verify the wrap or the signature, and the grant service says so: that
would be a convenience and never the boundary, and would put an asymmetric
implementation on a machine that is supposed to hold no keys.

Two bugs the tests found. TeamsViewModel's busy gate blocked its own reload, so
a team created a moment earlier was missing from the list it had just been
added to. And syncing every vault turned a failure from an exception into a
report, which made a background pass announce an unreachable vault once a
minute — the exact behaviour AnAutomaticPassThatFails_LeavesTheStatusAlone
exists to prevent. The fact is recorded and the message swallowed, as it was
before; pressing Sync still names the vault and the reason.

Also fixes a build break this branch started with: QuickConnectTests was never
updated when M2 added ISftpSessionFactory to the shell's constructor, so
nothing built at all.
2026-07-31 12:18:28 +02:00
jaap-jan 240aadb746 Merge branch 'main' into claude/vault-unlock-logout-autosync-a84c35
ci / build and test (push) Failing after 3s
Four files needed a hand, and all four were two branches adding something in
the same place rather than either changing what the other did.

The shell's constructor now takes both new parameters: main's SFTP session
factory, which it must have because it builds the transfers view model, and
this branch's optional resume handler, which stays last so every existing test
that constructs a shell without one still gets a shell that can only be online
because somebody signed in during this run. App.axaml.cs, ShellFlowTests and
QuickConnectTests pass the pair; the layout suite keeps both of its new fields.

Signing out now detaches the transfers screen exactly as locking does, and the
confirmation says that an open transfer session survives it. That is the same
policy both sides already argue for their own case: signing out destroys this
machine's copy of the vault, not work that authenticated before it.

QuickConnectTests did not compile on main — the SFTP commit added a constructor
parameter and the quick-connect suite, merged from a parallel branch just
before it, was still calling the old one. Fixed here rather than worked around,
since the merged tree has to build.

dotnet build, dotnet test and dotnet format --verify-no-changes are all clean:
980 tests, including the end-to-end suite against real containers.
2026-07-31 11:16:49 +02:00
jaap-jan 0b261c4d39 Stay signed in, come back online by itself, and let a machine be given up
Three things a machine that has been set up could not do. Unlock now takes
Enter, which is the gesture everybody makes after typing a password and which
did nothing until they found the button.

Signing in survives a relaunch. The refresh token is kept in the local cache,
sealed under the vault's own cache key, so a later launch resumes the session
through the refresh grant with no browser and nobody present — and because it
is sealed under that key, only an unlocked vault can resume it. A locked
client therefore cannot reach the server at all, which is a consequence worth
stating rather than working around; docs/crypto.md §3.2 records it. Every sync
pass asks the shell for a connection rather than reading one captured at
unlock, so a laptop that unlocked on a train is online within a minute of
finding a network, with nothing pressed. Unlocking itself still never waits on
a socket.

Signing out empties this machine: the profile, the cached items, the outbox
and this machine's device key, with the account's row withdrawn when the
server can be reached. It asks first and says what it costs — the outbox count
when the vault is open, an admission that it cannot be counted when it is not,
and the shells that keep running either way. The vault is on the server and is
untouched, which is what makes the same button the only honest answer to a
forgotten passphrase, so it is on the unlock screen as well as in preferences.
It cannot end the session at the identity provider, and says so.

Two defects surfaced on the way. The synchronisation pass that runs when the
vault opens never ran at all: the loop is started from inside the unlock
command, so the busy flag it yields to was raised by that command — the first
sync was a minute late on every launch. And signing in from preferences while
unlocked threw an unlock screen over an open vault whose keys were still in
memory.

The unlock card and the new confirmation live in their own controls because
MainWindow cannot be laid out headless, so markup left inside it is markup no
test can measure; both are now measured at the window's minimum size in the
shapes that grow. What is still unverified is the composed window itself.
2026-07-31 11:07:36 +02:00
jaap-jan 04faef6597 Move files to and from a host over SFTP
M2's file transfer, built bottom-up: an SFTP session on the SSH layer, a
transfer queue in a project of its own, and the two-pane browser the design
asked for replacing the screen that said it did not exist. Remote listings
carry names, sizes, modification times and a real drwxr-xr-x — nothing in this
repository could render a POSIX mode before — and the queue moves one file at a
time with progress, throughput and resume.

The design import assumed this would be an SFTP subsystem channel on
ISshConnection, beside the shell on a transport that is already up. SSH.NET
does not offer that: SftpClient derives from BaseClient and owns its own
transport, and there is no supported way to hand it an SshClient's session. So
file transfer opens a second authenticated connection, and it is named for
that rather than dressed up as a channel — OpenSftpAsync is on
ISftpSessionFactory, not on a connection. The difference is visible to a user:
the host records a second login, and a host whose password is typed each time
asks for it again on this screen. It goes through the same host key gate, the
same pin and the same two refusals a shell does, so a fingerprint approved for
a terminal is approved here and one approved here reaches the other machines
with the next sync. docs/design-import-gaps.md is corrected, and marked as the
one row where what shipped differs from what it predicted.

Nothing is written at its final name until it is complete. Every transfer goes
to a .dodossh-part file beside its destination and is renamed into place at the
end, so an interrupted transfer can never be mistaken for a finished one —
which matters most for what this screen is actually for, which is copying a
build artefact onto a server and then running it. A destination that already
exists is refused outright rather than overwritten: the queue has no way to
ask, and silently replacing a file somebody's process is serving is the worse
of the two failures. The remote pane has DELETE and MKDIR so that refusal is
not a dead end. A test against the container pins the assumption underneath all
of this — that SFTP's rename does not clobber.

Resume works within a run of the application and not across a restart, and the
limit is deliberate rather than unfinished. Nothing records which source wrote
a part file, and resuming one on the strength of its name matching is how a
corrupt artefact gets delivered with nothing reporting a failure; a part file
found at startup is started over. Making it survive a restart needs the
preferences store this client still has not got. The offset a resume starts at
is the part file's own length rather than the transfer's recorded progress: a
cancellation can land between a write completing and the counter moving, and
only one of those two is a fact about the bytes that are there.

The queue and its connection outlive a lock, as shells do. LockAsync already
argues that locking must not destroy work in flight — it is what somebody does
when they walk away from the machine, which is exactly when a long transfer is
most likely to be running — so TransfersViewModel is created once and the vault
is attached on unlock and detached on lock. What locking takes is the host
list, and it has to: those rows carry decrypted secrets.

DodoSSH.Client.Transfer is a new project rather than more of Client.Ssh. The
two answer different questions — one is about reaching a host, the other about
moving bytes and what to do when moving them stops halfway — and this is the
only client project that deliberately touches the local filesystem.

Three defects the tests found, none of which review would have. SftpPath.Name
answered an empty string for the root. NavigateRemoteAsync wrapped itself in
the busy guard, so navigating from inside another command did nothing at all
and the remote pane simply stayed empty after connecting, with no failure
anywhere to explain it. And opening an SFTP session per test made two
handshakes per test — this client learns a host key by being refused — which
pushed the SSH assembly past sshd's MaxStartups and failed a different few
unrelated tests each run; the session is shared through the fixture now, with
the reason written where the next person will hit it.

1004 tests green across 18 projects, 24 of them new: the SFTP subsystem against
the OpenSSH container, the queue against a real temporary directory and a fake
host, and three more layout measurements because a screen this window has never
laid out is a screen never checked.

Not verified: the screen has not been looked at running. The layout harness
measures it at the window's minimum in three shapes, which is the class of
defect that has shipped here before, but reaching it in the application needs
the compose stack, the migrations, the API and a browser sign-in. What is still
absent — the status bar's transfer count, dragging between the panes,
transferring a directory, and sftp over a bastion — is in
docs/design-import-gaps.md.
2026-07-31 11:07:29 +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