c3ef4bd8b46ce5e791bded472bd59adb2e882b9a
59
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d1700f5a34 |
Merge branch 'claude/m2-file-transfer-1b9951'
ci / build and test (push) Failing after 3s
|
||
|
|
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. |
||
|
|
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. |
||
|
|
312d766c30 |
Let the quick-connect palette answer for itself
Clicking outside the palette did nothing, because nothing was listening: the wash took no pointer input at all, so the only ways out were a key and the button that opened it. It now closes on a press whose source is the wash itself, which is what separates outside from inside — a press on the card bubbles through the same handler on its way to the window, and closing on those would make the palette impossible to click into. The caret never reached the query box either. The window focused it from the view model's PropertyChanged, and that handler runs before the binding which reveals the control — measured, with the same wiring, in a replica window. So it focused a control that was still collapsed, which Avalonia treats as a no-op and does not replay when the control is revealed, and the keyboard stayed wherever the click that opened the palette had left it. Becoming visible is now what triggers it, posted rather than called: a control that has never been laid out has no visual children, and at the instant IsVisible turns true the box still reports IsAttachedToVisualTree() == false. Escape, Enter and the arrows move to the palette as a tunnelled handler. Answering them only on the window was fragile in the way that matters here: anything on the route that took a key first would silence them, and with the focus never landing in the palette the key was being pressed at whatever the opening click had focused — a focused Button eats Enter. The window keeps Ctrl+K, which has to work when the palette is not showing, and forwards the rest as the net for a press that arrives from outside the palette. Which is also why this moved out of MainWindow rather than being fixed there. Showing MainWindow initialises WebView2 on a thread it refuses, so nothing on that window can be tested — the palette shipped with no test of any kind. As a UserControl it hosts in a bare window and takes real key and pointer input, and there are now six: press on the wash closes, press on the card does not, Escape closes, the arrows move the selection without taking the caret out of the box, Enter takes the highlighted host, and the palette takes the keyboard when it appears. |
||
|
|
94e11f5e38 | update packages | ||
|
|
9a76eced14 |
Give hosts and terminals their own screen, and the rest of the vault another
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. |
||
|
|
d162271a45 |
Show the host keys this vault has approved
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. |
||
|
|
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. |
||
|
|
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. |