Public Access
509937c4b81f87a35af377ab5c9c963e4d50341e
86
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
509937c4b8 | Sync: keep the remembered sign-in when the provider is merely down, and say why SYNC NOW found the machine offline | ||
|
|
7b072bcded | Desktop: swap the host lists in whole instead of announcing every row to listeners that redo all their work per row | ||
|
|
079a273d46 | Desktop: let a lock pressed during an unlock win, instead of the unlock finishing behind it | ||
|
|
441709f1e9 | Desktop: stop decrypting the whole keychain on a sync pass that changed nothing | ||
|
|
22b4d82ba8 | Desktop: stop the status bar's session timer when the shell ends | ||
|
|
a4b81da597 |
Desktop: show the right vault's people, leave settings on lock, say when an import fell short, and stop host names being cut to four letters
- Choosing a vault while the screen was busy went through a gate that did nothing, so the members
panel kept showing the previous vault's people under the new name. The read now starts regardless;
the selection generation discards answers for an earlier choice.
- Locking or signing out from Settings left the window in settings mode under the lock card, with no
title bar to drag, minimise or close, and unlock returned to Settings. Leaving the unlocked state
leaves Settings and closes the members panel; Escape closes that panel before leaving Settings.
- Both importers reported success when the keychain was busy and skipped the batch, or failed partway.
ImportOutcome now carries refused hosts and why a batch stopped, and the screens say so.
- Host cards: the sync badge took 70 of 214 pixels from the name ("bastion" drew as "bast…", two k8s
workers both as "k8s…"); it now sits on the address line. Tag chips wrap and trim inside the card.
Keychain names trim inside their column instead of printing over TYPE, and the Security page's
caption wraps inside its card.
|
||
|
|
6e39807cc0 |
Reach a Spelunk-managed server through Spelunk, and open one from a Spelunk link
A server Spelunk manages runs Spelunk's agent, which keeps a connection open
to Spelunk. A host can now be marked "Connect through Spelunk": a connect
asks Spelunk for a ticket, opens a WebSocket, and the agent connects to the
SSH server on the machine's own loopback, so the server needs no open SSH
port. Shells and file transfers both go this way.
Client, in Client.Session:
- SpelunkTunnel implements Spelunk's contract: POST
{api}/api/servers/{id}/tunnel-tickets with the API key as a bearer token
and {"port":N}, then a WebSocket to the API's own origin at the path
Spelunk returns, offering spelunk.tunnel.v1 and ticket.<ticket>. After the
upgrade the relay's RelayBridge carries the bytes unchanged. Every refusal
is a sentence: 401 key, 403 role (with Spelunk's detail), 404 server, 503
agent not connected, 401/503 on the upgrade, and close codes 4502 (agent
could not reach the port, or is too old: "Unknown stream kind") and 4503
(agent disconnected). Only a path is taken from the ticket response, so it
cannot send the ticket to another host.
- IConnectionRoute is what RelayRoute and SpelunkTunnelRoute have in common,
so the connect path has one shape for both.
Host model: HostSecret.Spelunk is a SpelunkRoute (normalised API base,
server id, and the id of the credential holding the API key), at payload
schema version 8. It is written last and only when present, so every other
host keeps its bytes and its version; an older build reads a Spelunk host
as read-only rather than dropping the route. A half-written route makes the
item unreadable instead of dialled. It merges as one value and is exclusive
with RelayEnabled. The API address must be https, or http to loopback.
The API key is an ordinary keychain credential whose password is the key,
referenced by id, so it is encrypted, synced, merged and rotated like every
other secret, one credential serves every server in the organisation, and
the used-by list and deletion warning count it.
Routing: OpenRouteAsync is the one decision for both transports. A Spelunk
host is never dialled directly, never probed after a failure, and a route
that hung up with a reason replaces SSH.NET's transport error.
UI: a "Connect through Spelunk" card beside the relay card in the desktop
drawer and the phone's editor; ticking one unticks the other.
dodossh://spelunk/connect?api=&server=&name=&host= links:
- SpelunkLink parses them strictly and never reads a credential from one.
- Program reads a link from the arguments; a launch that finds DodoSSH
running passes it down the doorbell as "dodossh.open-link <link>", which
the running instance parses again, bounded, and otherwise ignores whole.
The plain come-forward ring is unchanged.
- A saved host routed to that server on that Spelunk is connected to;
otherwise the editor opens on a new, unsaved Spelunk host, with the key
pre-chosen only when another host already sends it to that Spelunk. A link
that arrives while locked waits for the unlock.
- Windows registers the scheme under HKCU from Velopack's install and update
hooks and removes it on uninstall only if it still points at this
executable; the macOS plist template declares CFBundleURLTypes and the app
takes the URL from Avalonia's activation event; the Linux desktop entry
gets %u and MimeType=x-scheme-handler/dodossh.
|
||
|
|
2d95366552 |
Stop a reconnect dialling a different machine of the same name
A tab remembered what its host was called and nothing else, and the reconnect under a stopped tab looked the host up by that name: HostFor took the first row in the keychain whose label matched. Nothing in this application stops two hosts being called the same thing — a team's vault beside a personal one makes it ordinary, and "prod-db" is what the database server gets called in both — so the button dialled whichever namesake sat earliest in the list. A dropped session on one machine came back as a live shell on another, with the tab, the address and the status bar all still naming the first. There was nothing on screen to read the substitution off. The name match was not an oversight; it was an argument, written into the remark on HostFor and repeated on RefreshConnectedHosts: a tab outlives the vault that opened it, so it "cannot hold an entity id that would still mean anything after a lock". That is not so. EntityId is the item's own sync identity rather than a reference to a row object, so a vault that is locked and opened again rebuilds every row around the same ids. The one handle that was rejected as too fragile to survive a lock is the handle that survives it. So the id now travels with the connection. ConnectionAttemptEventArgs and TerminalSessionEventArgs carry it, taken from ConnectionTarget.HostId, which the connect path has had all along for the log; TerminalTabViewModel keeps it; and HostFor matches on it. A host renamed since the tab opened is found too, which a name match by definition cannot. Null is not missing data — it is the whole of what makes a typed-in address different, and it is why such a tab is refused a reconnect rather than offered one that would dial a stranger sharing its name. Three other lookups had grown their own copy of the same match and so had the same fault, where it is read rather than dialled: the green connected dot, which lit on every machine answering to the name and claimed a shell on hosts nobody had connected to; the pinned paths QUICK ACCESS offers, which come from whichever row that same match found; and the sidebar's "Pin folder" and the SFTP tab row, which would have edited and browsed the wrong host. All four now go through HostFor, so "which machine is this tab" has one answer. Both new tests assert on the address actually dialled rather than on anything the tab says about itself. The two rows agree on every string a tab carries, which is exactly why this went unnoticed: a test written against the label would have passed on the day the wrong machine was being opened. |
||
|
|
fd92b80d4f |
Find out why a connection did not reach, instead of only saying it timed out
Measured against SSH.NET 2026.0.0 before anything was written, because the
premise needed checking. Four ways of failing to reach a host, and what this
client puts in front of the user for each:
server accepts TCP, never sends a banner -> "Connection has timed out."
packets black-holed, nothing comes back -> "Connection has timed out."
nothing listening on the port -> "...actively refused it."
name does not resolve -> "No such host is known."
The first two are the same five words and are not the same problem. One is a
network, a firewall or a wrong address; the other is a machine that is up and
well and an sshd that is wedged, or a port that is not SSH at all. Nothing in
the exception tells them apart, and that sentence was the whole of what this
product could say about the failure people hit most.
◆ A SOCKET ANSWERS IT, IN ABOUT AS LONG AS IT TAKES TO OPEN ONE.
SshReachabilityProbe resolves the name and opens a bare TCP connection, and the
four answers it can come back with are the four somebody would do something
different about — unresolved, refused, silent, open. Every one of them is what
a socket actually did, measured at the moment it was asked, rather than
anything inferred from the exception that started it.
Deliberately nothing else. No ping: ICMP is filtered on most of the networks
this is pointed at, so an unanswered one would be evidence of nothing. No scan
of neighbouring ports — the question is what happened to the connection the
user asked for, and probing ports nobody asked about is how a client gets
flagged by an intrusion detector on the very network somebody is trying to
work on. No second SSH handshake against a server that has already failed one.
The budget is six seconds and the floor under it is not a guess: a refusal is
not immediate on Windows, and a closed loopback port took ~2s to answer when
this was measured, because the stack retries the SYN first. At two seconds the
probe calls a machine that is up and refusing a firewall. The test for that
case runs at the real budget on purpose, so lowering the number fails it.
◆ ONLY WHILE REACHING, AND THE WINDOW IS CLOSED AT BOTH ENDS.
Past SshConnectionPhase.Reaching the machine has offered a host key, so it has
demonstrably been reached and a refused password is not a reachability
question. Before it, nothing has been dialled at all — a private key that will
not parse throws out of BuildConnectionInfo before the factory reports
anything — and probing there finds a healthy port and appends "the SSH
handshake is what did not finish" to a message about an unreadable key. That
is a confident wrong answer replacing an accurate one, so both ends are shut.
PhaseReporter keeps the furthest phase to decide it.
The finding is added to what SSH.NET said rather than replacing it: the
exception is the fact, and this is a reading of a second measurement.
Every shell in ShellFlowTests now gets a scripted probe. Without one a failed
connection in any test in that file would do a live DNS lookup for db.internal
and open a real socket — slow, network-dependent, and worse than either, since
db.internal resolves on any machine whose resolver has a search domain that
makes it, which would make the asserted sentence depend on whose network the
suite ran from.
◆ WHAT THIS DOES NOT FIX, FOUND WHILE READING THE SAME PATH.
- SshConnectionRequest.ConnectTimeout is documented, plumbed, and passed by
nothing. Every connection this client makes is hard-wired to the 15-second
default, and there is no per-host field and no setting that can change it.
Fifteen seconds bounds the handshake waits as well as the connect — a
server that accepts TCP and never sends a banner fails at exactly the
timeout, measured — where OpenSSH's own ConnectTimeout covers only the
connect. If timeouts are frequent on slow links, this is the first
suspect, and it needs a product decision rather than a patch.
- The known-host lookup in HostKeyGate.OnHostKeyReceived blocks SSH.NET's
message thread inside that same 15-second budget. The blocking is
acknowledged in the code; that it competes with the key exchange's own
timeout is not. On a cold or locked vault a slow read would surface as a
connection timeout, which is exactly the symptom above.
- ConnectThroughHostKeyGateAsync never unsubscribes HostKeyReceived on
success, so the gate outlives the handshake holding the connect-time
CancellationToken. If SSH.NET raises that event again on rekey it would
re-enter the blocking store read with a token whose source is long gone.
Not verified either way — it needs a real sshd and a forced rekey.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
c859705b83 |
Give the hosts board its vaults, and the Hetzner import a vault and a key
Six things, all of them answers to a question a screen was deciding on somebody's behalf. The board draws a heading per vault and sorts each vault's hosts by that vault's own groups. A group is an item of one vault, so drawing every group of every vault in one run flattened away containment that already exists: two vaults each holding a "production" were two headings with the same word on them, told apart by a small grey name beside each. Single- vault sessions are unchanged. There is now one "No group" heading per vault, so the folded set is keyed on SidebarGroupHeader.CollapseKey rather than on GroupId ?? Guid.Empty, which had one vault's unfiled hosts folding every other vault's away with them. A Ctrl-click starts its set with the card the board has lit. The board always has a selection — a fresh unlock lights the first host so CONNECT has something under it — so "one card lit, nothing ticked" is the state this screen opens in, and ticking a second card there produced a set of one: the machine the user was looking at was left out of the count and out of everything Delete, Move and Change group then ran over. The card tooltip says the host's name as well as its address. 214 pixels of card trims the name, and the tooltip was the one place it was not said in full. The card's floor drops from 120 to 104. A card with no tags measures about 87, so the mock's number was thirty pixels of air under every card on a board that is scrolled. "Move to another vault…" joins the board's context menu for one host. Moving one machine meant opening its pane and finding the menu inside it, while moving six was a right click away — the one-host case was the harder of the two. And the Hetzner import asks which vault and which key. A project belongs to a team more often than to a person, so forty servers landing wherever the session happens to be standing is the wrong default; ImportHostsAsync takes the target vault, and the ssh_config importer passes null and is unchanged. The key is the half Hetzner cannot supply and the keychain usually already holds — the public half went to Hetzner when the servers were made — so the import points every host at it rather than bringing one. It defaults to no key rather than guessing, and says so when the key it was given lives in a different vault from the hosts. |
||
|
|
c270f3fe39 |
Import a Hetzner Cloud project's servers, and forget the token that read it
An estate on Hetzner is already described somewhere: the project lists every server, its addresses, its plan and where it runs. Getting that into a keychain meant copying forty addresses out of a browser by hand, which is the same work the ssh_config importer exists to remove for the machines described in a file. ◆ A WIZARD RATHER THAN A SCREEN, AND THE DIFFERENCE IS WHAT EACH STEP COSTS. ImportScreen reads a file that is already on this machine, so it scans on open and shows a list. This cannot: it needs a credential that has to be made in another company's console first, and then it makes a request over the internet. Those are two moments rather than one, and a screen that mixed the token box in with the list would ask for the credential again every time somebody came back to look at the list. So three steps — token, choose, done — and the first two write nothing. Step two carries one address choice, one login user and one port for the whole import, because an estate is reached the same way throughout and a per-server dropdown would be forty answers to one question. ◆ THE TOKEN IS USED ONCE AND CLEARED, INCLUDING WHEN IT WORKED. The half worth stating is the success path. Clearing only on failure would leave a credential capable of listing somebody's whole estate sitting in a text box for as long as they spend reading forty rows, which is the longest anybody stands on this screen. Nothing writes it to the vault, to settings or to a log, and reopening the wizard restarts at step one rather than resuming — a resumed step two would be a list nothing on screen has the means to refresh, going on looking current for as long as the application stays open. Read is the only permission it needs and the note above the box says so: a Read & Write token would work identically here and would be a credential capable of deleting the estate, pasted in for no gain. The note also says a token belongs to one project — there is no API call that will say which, and none that lists the others — so an account with four projects runs this four times. Without that sentence the wizard looks like it lost most of the estate. ◆ THE ADDRESS IS A CHOICE, AND TWO OF THE THREE FALL BACK WHERE THE THIRD MUST NOT. Public IPv4 and public IPv6 fall back to each other: the same machine over the same internet, so the worst case is a route nobody picked, and the row says which. Private does not fall back at all. A server on no private network is simply not importable under that choice — its tickbox is disabled, and neither direction of the header's tick-all can reach it — because falling back would put an address on the open internet into a bookmark that was asked to stay off it. IPv6 needs saying twice over. Hetzner routes a whole /64 and the API reports the network, which nothing can dial; the address a stock image answers on is the first one in it. That is a convention of Hetzner's own images rather than a fact the API stated, so every use of it carries a warning on the row and in the stored note, and it is computed by setting the low byte rather than by string surgery on the prefix. It is not an edge case either — IPv4 is billed separately now and a project built since then can be entirely IPv6-only. ◆ NOTHING IMPORTED HERE AUTHENTICATES, AND THAT IS SAID BEFORE THE BUTTON. Hetzner has never held the private half of an SSH key. The API will name a project's keys and hand over their public halves; there is no private half to bring. So every host arrives bound to nothing and asks for a password until somebody points it at a key already in the keychain — the same shape an ssh_config import has with its key tick off, and stated above the button on the same reasoning: somebody who is not told will conclude the import was broken. What the note keeps is what does not go stale — the id, the plan, the location, the image, the labels. The power state is a column and never a note: "running" about a machine somebody switched off last March is worse than no note. ◆ WHERE IT LIVES: TWO FLAGS OVER ONE CONTENT COLUMN. Drawn over the Preferences page inside settings mode exactly as ImportScreen is, with SettingsNav unmoved and the titlebar offering "Back to preferences". IsHetznerImportOpen is a second flag rather than a "which importer" enum, because they are two independent facts about the content column and a single value would need a "neither" member IsImportOpen already expresses; what keeps them from both being true is that every path that opens one closes the other. The titlebar and the Preferences page ask IsImporterOpen, which is written against both. Preferences grows a second importer row beside the first rather than one row that asks "from where?", which would put a question in front of the far more common answer. ◆ WHAT IS NOT HERE. Hetzner's Robot — dedicated servers and Storage Boxes — is a different API behind a different credential, and nothing in this touches it. Said on the Preferences row and on the token step rather than left to be discovered by an account that has both. And there is no phone layout. This one would work on Android, unlike the ssh_config importer beside it: it is a token and a network request, the view model is shared already, and scoped storage has nothing to do with it. What is missing is a screen — the desktop draws a five-column table, and the phone version of that is a different layout rather than the same one narrower. Recorded in android-port.md and on the phone's own Preferences screen, because the note there previously read as though no import of any kind could work. ◆ TESTS, AND WHAT IS LEFT FOR A PERSON. HetznerCloudClientTests is 16 cases over the shapes the API actually returns, against a stubbed HttpMessageHandler: paging followed, the token in a header and never a query string, a refused token distinguished from a fault at Hetzner's end, a token with a line break refused before anything is sent, the /64, the private-network asymmetry, and a server the API described without a name. ShellFlowTests.Hetzner.cs is 11 more against this fixture's real vault, real cache and real server — split into a file of its own, as FakeVaultServer keeps its team endpoints. What they establish beyond the reader's own suite is the half it cannot reach: that reading stores nothing, that importing goes through the ordinary create-and-push path and lands on the server with nothing pressing Sync, that a server the keychain already has arrives unticked, and that the token does not outlive the request. Two layout cases measure both steps against the same content budget every other settings page gets, which is also what proves the markup binds to names that exist. Manual checks 2.11a–2.11d are what no test in this repository can do, because there is no account, no token and no network in the ordinary suite: a real project listed against the real console, a wrong token, the private choice, and what an imported host does when you connect to it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1908189100 |
Make the terminal's right button copy and paste, and never open a menu
Selecting output and right-clicking it opened the WebView's own context menu with Copy on it, which is correct behaviour for a web page and wrong for a terminal. What a terminal's right button has meant since the Windows console got QuickEdit, and since PuTTY shipped, is this: with something selected it copies and drops the selection, with nothing selected it pastes. No menu, no second click. The gesture can only live in the page. The selection is drawn into a canvas the host never sees, and once a terminal has focus the host's window receives no input events at all — the same reason the Ctrl+plus chords are handled there. ◆ THE CLIPBOARD IS THE HOST'S IN BOTH DIRECTIONS, AND THE READING IS WHY. navigator.clipboard.readText() is gated behind the clipboard-read permission in every Chromium, and an embedded WebView with no PermissionRequested handler answers a permission request by putting a prompt on the screen. Trading a context menu for a permission dialog is not this change. So the page asks and the host answers, exactly as requestFontSizeStep does. Writing does not go the other way either, although navigator.clipboard.writeText would have worked from the page on Windows: a clipboard that is the host's in one direction and the page's in the other is two mechanisms to keep working across WebView2, WebKitGTK and Android's WebView. The host has owned a clipboard delegate since the keychain grew its COPY buttons, so each App.axaml.cs grows a ClipboardReader beside its ClipboardWriter and MainWindowViewModel takes it. Three opcodes: client ClipboardCopy and ClipboardPaste, server Clipboard. ◆ Clipboard IS NOT Paste, AND THE DIFFERENCE IS THE BRACKETING. Paste carries a snippet off the sidebar, and the page deliberately *types* a single-line one rather than bracketing it, because bash marks a bracketed paste as an active region and a snippet should read as though it had been typed. A clipboard paste is the case those markers are for — readline uses them to decide it has been pasted into — so Clipboard is always bracketed and never followed by an Enter. One opcode for both would have needed a flag meaning "which of the two kinds of paste is this", on a payload whose existing flag byte means something else. Both client frames are dispatched before the session lookup, which is what keeps the gesture working in a pane whose shell has just ended: squinting at the message a remote left behind is exactly when somebody copies the line. Dispatch was past MA0051's length once it grew a second such branch, so the frames no pump needs to see moved into Announce() and the font-size chord went with them. ◆ CAPTURED AND STOPPED, RATHER THAN MERELY PREVENTED. preventDefault alone suppresses the menu and still lets xterm's own contextmenu handler run, which moves its helper textarea under the pointer at z-index 1000 and leaves the selection sitting in it, selected. Both of those exist to serve the native menu. Taking the event in the capture phase on the pane and stopping it there is what keeps the gesture wholly the page's. rightClickSelectsWord is now false, which on macOS is a change of default rather than a restatement of it: xterm sets it true there alone, so a right-click would have selected the word under the pointer and copied that instead of pasting — wrong on one platform only, which is the worst way for it to be wrong. The renderer harness grows the pane it has been inviting since RendererReconnectionTests: a fake Terminal, the two addons, and a real UTF-8 codec, because both directions run through the page's own TextEncoder and TextDecoder and a Latin-1 stub would pass a test with an em dash in the selection. Its warning about declaring names terminal.js declares now covers the half that does not SyntaxError — a harness frame() was silently replaced by the page's frame() and failed six tests on a type error a long way from the cause. Manual checks 1.11 and 11.13 are what is left for a person: that a real contextmenu event reaches a capture listener, that no native menu is drawn once it is prevented, and that xterm's real paste() brackets. 11.13 also records what is unverified — whether Android's WebView delivers a pointer's secondary click as a contextmenu event at all. ClipboardReader is wired on that head regardless, because the alternative is a head where the paste half silently does nothing and nobody finds out; that head is not compiled here either, this machine having no android workload. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cfc2e3ae0c |
Notice when a file session stops, and give the file browser the four things it was short of
The commit before this one taught a terminal to notice its machine going away.
The file browser had the same defect and three of the same symptoms, and it is
also the screen people leave soonest — so the disconnect work and four things it
was missing arrive together, because they are the same complaint: this screen
knows less about the host than the host does.
◆ THE DISCONNECT HALF, WHICH IS THE OTHER SURFACE OF THE LAST COMMIT.
IsConnected was set at connect and cleared only by the DISCONNECT button. A host
that went away therefore left this screen saying CONNECTED over a session that
was gone, and every action after it failing with the same server message and
nothing saying why they all did.
IRemoteFileStore now carries a latched TransportFailure and a ConnectionLost
event. The latch is the shell's own — read ISshShellSession.TransportFailure for
the argument, which is unchanged. The event is not, and it is the part this
surface needs: a file browser is idle between operations, so without something
raised at the moment the transport fails, nobody finds out until they click. A
bucket implements both and raises neither, which is honest rather than a gap:
S3 is request-per-operation with no connection to lose.
- THE LISTING STAYS ON SCREEN, and that is the half that makes the rest worth
having. SftpEntry is a record of what the server said rather than a handle,
so the pane can go on showing where the user was — which is what RECONNECT
is offering to put them back into. That needed a new ShowsRemoteContents on
both heads: every "is there anything here" binding read IsConnected, which
was right while the only way to stop being connected was to press a button
that empties the pane, and wrong the moment a session could be lost.
- RECONNECT re-dials through the ordinary connect path — same host key gate,
same refusals, same log entry — and then walks back to the directory the
pane was showing. The path is read off RemotePath rather than captured,
because the pane already keeps it up to date and a copy is a second thing to
get wrong. A host wanting a typed password is asked for one rather than
dialled with an empty box.
- SessionEndedBar is now one control on both surfaces, with the shell
resolving which it is drawn over: a terminal has two endings to tell apart
and a keychain entry to check, a file session has one ending and already
captured what it would dial. The phone's Files screen has its own copy,
bound to TransfersViewModel directly, because its DataContext is that rather
than the shell.
◆ PERMISSIONS: THE PERMS COLUMN STOPS BEING READ-ONLY.
It has been a true fact about a file with nothing to be done about it since it
arrived, and the commonest next act after using this screen was opening a
terminal — a whole second authenticated connection — to chmod what had just been
uploaded.
Nine checkboxes rather than a box to type 755 into, because the mode is already
drawn as -rwxr-xr-x in the column the form was opened from: a who-can-do-what
grid is the same representation the reader is holding, where an octal field asks
them to translate something they can see into something they cannot check. The
octal is printed underneath as it changes, so the two readings check each other.
The EXEC column is headed "enter" over a directory, which is what that bit means
there and the commonest misreading of a POSIX mode. The listing is re-read after
APPLY, because a umask, an ACL or a filesystem mounted without permissions can
all land somewhere other than what was asked for.
Beside it, mode and mtime carried across a transfer — which is what makes an
uploaded deploy.sh arrive executable instead of with the server's umask on it.
THE MODE IS ONLY SENT FROM A PLATFORM THAT HAS ONE. A file on Windows has no
POSIX mode; this screen already says so by leaving the local pane's PERMS column
empty rather than filling it with a plausible -rw-r--r--, and inventing 0644, or
0755 for anything called .sh, would be this client deciding a fact about
somebody's file that nothing on the machine it came from ever said. The
timestamp goes either way, because every platform has one. A store with neither
refuses in its own words and is never asked.
◆ MULTI-SELECT: A TICK IS NOT THE LIST'S SELECTION.
The two answer different questions and the split is the whole design. The
selection is "the row I am looking at", which rename, move and permissions act
on — each takes a form naming one path and is one-at-a-time by construction. The
tick is "the rows I have gathered up", which download, upload and delete take,
and it survives the highlight moving.
With nothing ticked the batch falls back to the selection, which is what keeps
the commonest use of this screen from growing a step — the usual way a
multi-select makes a tool worse. Ticks are released once a set is queued, or a
second press sends everything again.
Deleting several names every path rather than a count, because agreeing to "3
items" is agreeing to a number, and this is the one act on this screen that
nothing walks back. Each goes on its own and a refusal does not stop the rest:
the host turns down a directory that still has something in it, and in a set of
five that must not silently cancel the four it would have removed — nor hide
which ones it did not.
A drawn checkbox rather than ctrl-click, and not for taste: the phone shares
this view model and has neither ctrl nor shift, so a multi-selection has to be a
tap target there whatever the desktop does. One shape on both heads, and a state
somebody can see.
◆ RECURSIVE FOLDERS: THE TREE BECOMES FILES BEFORE THE QUEUE SEES IT.
A directory used to be counted and skipped. It is now walked, and the queue
still knows nothing about trees — which is what keeps every property it has:
each file resumable on its own, each written to a part file and renamed into
place, none able to overwrite anything already there.
The plan is collected whole and then queued, because directories have to exist
before the files that go in them and shortest-first is the only order that
guarantees a parent before its child. It also means a walk refused halfway has
queued nothing yet, so the sentence it produces describes one outcome rather
than an outcome and a queue that half happened.
Links are counted, not followed, in both directions. One pointing at an ancestor
is a walk with no end, and on the remote side there is a second reason: an lstat
listing says a name is a link and not what it leads to, so descending would be a
round trip per entry to find out. Locally that is AttributesToSkip on the one
enumeration that decides what to descend into. A 5000-file ceiling is a backstop
for the accidental drag of a home directory, and it says so rather than
truncating quietly.
The status line grew four more ways to be partly what was asked for, and states
each: gone, refused, left alone, not a file.
◆ EDITING IN PLACE: FETCH, OPEN, WATCH, PUSH BACK.
The round trip this replaces is download, find it, open it, edit, come back,
delete the old one because nothing here overwrites, upload, check the mode
survived.
DELIBERATELY NOT THROUGH THE TRANSFER QUEUE, in either direction. The queue
refuses to overwrite, which is the right rule for a file somebody dragged and
exactly the wrong one for a file they are editing, where replacing the original
is the entire intent. The write-back deletes first: OpenWriteAsync at offset
zero writes over the beginning and leaves any tail that is longer — its own
contract says so — which for a config that got shorter means a file ending in
the back half of its previous version. Deleting is the only truncation this
interface offers, and the mode goes back on afterwards because deleting took it.
A stat before every save, compared against what the server said when the copy
was taken, catches somebody else writing while the file is open — which is how a
config gets silently reverted. There is no merge and no button pretending
otherwise; the three answers offered are the three that exist. Length and
timestamp rather than a digest, because a digest is a second full read of a file
there is otherwise no reason to fetch.
Both platform halves are injected: what opens a .conf is a registry
association, a desktop entry or a Launch Services binding depending on the
desktop, and watching a file is a platform object with its own thread. The
watcher watches the directory and filters the name, because a great many editors
write a temporary file and rename it over the top — which to a watcher pointed
at an inode is the file disappearing. The phone gets neither delegate, so the
button is not live there rather than there and disappointing.
◆ TWO THINGS FOUND ON THE WAY, BOTH WORTH STATING.
The shell suite's inline `post` was not serialising the way a dispatcher does.
Routing TransfersViewModel through it exposed a check-then-add in
OnTransferChanged that two threads turn into two rows for one transfer. Harmless
in production, where the UI thread serialises — the fixture was under-specifying
what it stood in for, and now posts under a lock.
And the edit session replaced its own record with `with` on each save, which
left the watcher holding an instance that was no longer the current one: every
save after the first quietly did nothing. The stamp moves in place now, and the
test saves twice.
◆ WHAT IS VERIFIED.
901 across the five suites this touches, 19 of them new. The layout suite earned
its keep three times over: the batch deletion card's path list overflowed the
204-pixel pane at 120, at 72 and at 56 before 32 held, and the edit strip
overflowed by 131 until the whole strip — rather than the log inside it — was
bounded. Each number in those files is measured and says which failure it came
from, because the next person to add a line to either card will move them again.
What no suite covers is the platform halves of editing: Process.Start with
UseShellExecute and a live FileSystemWatcher are the operating system, and a test
waiting on one would be measuring it. The shell's own half — when a save is
written back, when it is refused, what stopping releases — is covered against a
watcher that fires on demand.
|
||
|
|
e2733e5cbc |
Notice when a shell ends, say which way it ended, and offer the way back
A terminal whose machine rebooted stayed green for ever. Not "went stale and
recovered late" — for ever: the tab claimed CONNECTED, the elapsed timer went
on counting, and the only thing that ever corrected it was closing the
application. That is the defect this fixes, and it turned out to be two
separate failures wearing one symptom.
◆ THE HANG IS IN SSH.NET, AND KEEP-ALIVE ALONE WOULD NOT HAVE FIXED IT.
ShellStream ends a blocked read from exactly two places: its channel being
closed, and the session raising Disconnected. A shell that exits gives the
first. But Session raises Disconnected from one method only — OnDisconnectReceived,
for a server that politely says goodbye — and a socket that simply fails goes
down a different path entirely: RaiseError fires the error event, sets the
disconnecting flag and disposes the socket, without ever raising Disconnected.
So the stream was never disposed, its Monitor.Wait was never pulsed, and the
read blocked until the process ended. The transport was gone, the client knew
the transport was gone, and nothing woke the one thread that was waiting.
That is why the first attempt at a test for this sat at thirty seconds and
failed. Both halves are needed and neither is sufficient:
- Keep-alive makes the failure HAPPEN. An idle SSH session is a socket
nobody writes to, and TCP has no way to discover that the far end has gone
without writing to it — nothing was asked, so nothing could fail. Thirty
seconds, matching OpenSSH's own ServerAliveInterval examples. It is the
write that matters and not a reply: SSH_MSG_IGNORE need not be answered,
so this is traffic rather than a heartbeat with a deadline, and there is
no timeout invented here to be wrong about a slow link.
- Disposing the stream from the error handler makes it ARRIVE. Queued to the
thread pool and not done inline, which is not tidiness: this runs on
SSH.NET's message-listener thread, and closing a channel waits for the
server's close — processed by that same thread — so an inline dispose is
the listener thread waiting on itself.
SFTP gets the keep-alive too. Its operations are request/response and bounded
by ConnectionInfo.Timeout already, so it needs no equivalent wake-up.
◆ exit AND reboot ARE TOLD APART BY A LATCH, BECAUSE THE OBVIOUS WAY IS A RACE.
Wait for the read to end and then ask whether the transport is still up, and
against a real server the answer is wrong about half the time: this was the
only channel on the connection, so sshd hangs up a millisecond after closing
it. By the time anything could ask, the transport is gone either way — and a
poll would report every ordinary `exit` as a dropped connection.
So ISshShellSession.TransportFailure is set at the moment SSH.NET raises the
failure, which its own ordering puts strictly before the disconnect that wakes
the read. A read that has just returned zero consults a settled value: set if
the transport failed, still null if the remote merely closed the channel. The
pump turns that into a TerminalSessionOutcome — RemoteClosed, ConnectionLost,
ClosedLocally — carried on SessionEnded, which used to hand over a session id
and nothing else. A subscriber cannot work this out afterwards; by the time it
hears, there is no transport left to interrogate.
Deliberately no fourth value for "the remote rebooted". Nothing on the wire
distinguishes a reboot from a cable being pulled, and a name this client
cannot check would be a guess dressed as a fact.
◆ WHAT IS DRAWN, AND WHY IT IS NOT DRAWN OVER THE PANE.
The status bar says DISCONNECTED or SHELL EXITED where it used to say NOT
CONNECTED for both — a word that is honest about a tab still dialling and
useless about one whose shell has stopped. Red for the first and grey for the
second: one is a machine that stopped answering and the other is work
finished, and crying wolf on the ordinary end of every session is its own bug.
The tab strip's dot gains the same fourth state, on both heads.
Under the terminal, a strip carrying the reason and two buttons. It takes its
own Auto grid row rather than overlaying the renderer, and that is forced
rather than chosen: the WebView is a native child view composited above
everything Avalonia draws, so the alternative is collapsing the terminal — and
the scrollback is precisely what the user wants to read, because the last
thing the remote said is usually why it went. terminal.js already writes the
same sentence into the pane; this is the copy that does not scroll away.
RECONNECT reuses the tab in place, which is what the word means to somebody
who pressed it: the terminal they were working in comes back, where it was.
It dials through the vault's ordinary connect path, so every refusal — a key
that has left the keychain, an unknown host key, a group default that has
changed since — is decided in the one place that already decides them; what
this adds is which tab the attempt belongs to. The old session is closed as
part of it, because a dead shell still holds an authenticated connection this
process has stopped reading, and its pane still holds an xterm, a scrollback
and a WebGL context, of which a browser hands out about sixteen.
Two refusals it makes rather than pretending: a host reached by typing an
address has no keychain entry to redial, and one that wants a password typed
goes to the keychain with the sheet raised rather than being dialled with an
empty box. Both say so on the strip where the button was pressed, not only in
the vault's status on a screen that surface is not.
TRY AGAIN appears on a refused connection for the same reason, and it is worth
as much: the commonest refusal is a host that was not up yet.
◆ WHAT IS ACTUALLY VERIFIED.
Two tests against a real sshd in a container. `exit` is reported as the remote
closing the session — the one a polling implementation fails, and a fake shell
cannot show it because a fake has no server behind it deciding to hang up. And
a live shell losing its transport is reported as a lost connection, produced
by dialling through a plain TCP relay the test owns and then cutting it; the
container is shared by the collection, so killing sshd was never an option.
The first draft of that second test used LoopbackProxyTests' SOCKS forward and
timed out: disconnecting the carrier of a ForwardedPortDynamic does not close
the loopback socket it accepted. Recorded in the test, because it is a
property of SSH.NET's forwarder rather than of anything here, and it is not
what a dying machine looks like.
What no test reproduces is the waiting. A relay that closes sends a FIN, so
the socket reports itself at once; a machine switched off sends nothing, and
finding that out is the keep-alive's business and the OS's retransmit budget's
— neither of which a test can hurry along without a clock this code does not
own. A reboot is prompt in practice because the machine comes back and RSTs.
Beside those: three pump outcomes, three workspace announcements, and seven
shell-flow tests covering the drop, the clean exit, reuse of the tab, retry
after a refusal, and both ways a reconnect can honestly come to nothing.
Also fixed in passing: SshNetShellSession.IsOpen read ShellStream.CanRead,
which is documented as always true — the stream stays readable after disposal
so buffered output can be drained — so it had answered "yes" for every shell
that ever existed. It reads CanWrite now. And MainWindowViewModel's session-
ended handler moved from Dispatcher.UIThread.Post to the injected post
delegate, which is what makes any of the shell half testable at all; the
remark listing the three unreachable posts is one shorter.
|
||
|
|
882f01c515 |
Type a path on the SFTP pane, and put a file right without deleting it
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Successful in 23s
ci / android head (push) Successful in 3m27s
ci / desktop nightly (push) Successful in 53s
ci / build and test (pull_request) Successful in 2m30s
ci / android head (pull_request) Successful in 7m21s
ci / build and test (push) Successful in 2m28s
ci / api image (push) Successful in 36s
Three things the file browser could not do. Somewhere to go had to be walked to, a file that landed under the wrong name could only be removed and sent again, and the listing went on showing the directory as it was before this application put something in it. ◆ THE PATH IS A BOX NOW, not a TextBlock printing where the pane was. A breadcrumb only reaches what is under the directory already listed, so crossing a host's filesystem cost a click and a round trip a level, and a path somebody already knew could not be entered at all. Enter goes; Escape puts back what the pane is really showing. What is typed may be absolute, relative to the directory showing, or start at ~ — the four shapes a shell takes, because somebody typing into a box is typing what they would type into a shell. SftpPath.Resolve turns all of them into one absolute path, and it resolves them here rather than handing a relative path to the server: the server would resolve it against the session's own working directory, which is not necessarily the directory the breadcrumb above the box is showing. `..` is collapsed textually, which differs from cd through a symbolic link; the alternative is a realpath round trip before anything lists. The box holds its own value rather than RemotePath itself. Binding it straight to the path would navigate on every keystroke — `/`, then `/v`, then `/va` — a round trip and a failed listing per character. Every other way of moving writes the real path back into it; a refused listing deliberately does not, because the path that did not resolve is the one worth having in front of you. ◆ RENAME AND MOVE, and DELETE moves to join them. It had been sitting in the pane header beside UP and REFRESH — two things that move the pane and one that destroys a file, in one row — and the three that act on the selected row are now together in a row of their own, under the name of the row they are about. One SFTP rename underneath both new operations and two separate questions on the screen, because the answer is a different kind of thing each time: a rename takes a name and must not take a path, a move takes a directory and must not take a name. One box asking for a destination path would be fewer controls and a worse question — the two mistakes it invites are exactly the ones the split makes impossible. Each form opens on the answer that changes nothing, so what gets edited is the half somebody came to change. Three refusals are made here, and they are the ones a server's message would not explain: a separator in a name (no such file, about a directory nobody meant to name), a destination equal to the source (success, and a file that did not move), and a directory into itself (EINVAL, true and useless). Everything else is the server's — a name already taken, a path that is not there, a mount that cannot be crossed — and arrives in the status line in its own words. Neither is painted in the deletion card's red: a rename is undone by renaming back, and spending that red twice makes it stop meaning anything. ◆ THE LISTING REFRESHES ITSELF when a transfer lands in it. Both panes, since an upload lands on the host and a download lands here and either leaves the pane that is showing that directory a step behind. Only the directory actually on screen: a queue moving a hundred files elsewhere must not be a round trip per file for a listing nobody is looking at. It waits rather than interrupting. Another operation running would have two listings racing for one collection, and re-listing under an open question is how the row somebody answered about stops being the row that gets acted on — so the staleness is remembered and settled at the end of the operation or when the question is dismissed. It says nothing either way: nobody asked for it, and the status line at that moment is carrying the news about the transfer. The selection survives, re-found by path, which is what makes an unasked-for refresh safe to run under somebody's pointer at all. ◆ THE LAYOUT SUITE FOUND A REAL FAULT and the fix is where the height goes. The rename form did not fit the 204 pixels the session shell's own sidebar leaves this pane — MKDIR and the listing were pushed off the bottom. The form is shorter now (the placeholder says what the box wants, so there is no field label above it), and the item row and the breadcrumb step aside while any of the three questions is up, which is what the phone head already did with its own buttons. Measuring also settled something the markup had been claiming and is not true: at that width the listing collapses to nothing under any of the three cards, the deletion card included, and has done since the sidebar arrived. So "the row it is about is still on screen" is retired from the deletion card's remark, and the full path each card carries is what identifies the row instead. Recorded where it is drawn rather than left to be rediscovered. TESTS. SftpPath.Resolve gets the four shapes, ~ with and without a home to expand it to, a blank box, and climbing past the root. ShellFlowTests gets nine against a session that records what it was asked to do — which path was renamed, which directories were listed — rather than against the sentence this code writes about itself: the path box absolute and relative, rename in place, the separator refusal, a move by relative path, the no-op refusal, the three questions displacing each other, and the three rules the automatic refresh follows. The local half is in TransferQueueingTests, where a real folder answers. Two new shapes are measured, and the deletion card's own measurement moves to the connected state — the only state it can be asked in, and the narrow one. What the tests cannot reach is in docs/manual-checks.md as phase 2b: a real server's cross-device and name-taken refusals, and a soft keyboard, which is why the phone's path box has a GO button rather than trusting whatever the IME decided to draw on its return key. |
||
|
|
fdea3911c1 |
Name a quick-connect result's vault, and search on it
Ctrl+K reaches every vault the session holds a key for — that is deliberate, and it is what makes the palette worth opening from anywhere. What it did not do was say where a result came from. A team and a person who both call a machine prod-db got two identical rows, and Enter took whichever the ranking happened to put first: the same three characters, two different machines, depending on nothing anybody could see. Nothing new is computed for this. HostRowViewModel.VaultBadge has been filled in since the host list learned to span vaults, and it is already drawn on the hosts board, the keychain, the snippets list and the known-hosts pane. The palette was the one list reaching across every vault that did not print it. ◆ THE VAULT IS THE LAST OF THE FOUR RANKS, AND THAT IS THE POINT. A vault name is the widest reading of the three the palette had: one word matches every host in that vault at once, where a name or an address matches one machine. So it sits behind name-starts-with, name-contains and address — otherwise typing a machine's name would bury it under everybody else's. It matches on HasVaultBadge rather than on VaultName, so the search only ever matches what the row actually shows. A session holding one vault prints no vault on any row, and matching it there would answer "personal" with the entire keychain, ranked behind nothing and explained by nothing on screen. In the row, the right-hand column becomes two lines against the two on the left, in the same order: what this is above, how it is reached below. With one vault the badge is empty, the line collapses, and the kind word stays centred exactly where it was. The name is capped and ellipsised because that column is Auto-sized — a long vault name would otherwise take its width out of the host name beside it. Three tests, one per claim. VaultSharingTests puts platform-gateway in the personal vault and prod-db in "Platform secrets" and types "platform": both come back, the gateway first. ShellFlowTests types the personal vault's own name into a one-vault session and gets nothing. QuickConnectTests is the markup's half — the shared vault's row draws PLATFORM SECRETS, and a one-vault row draws no vault at all — with the shared vault put into the session through the layout suite's own StubTeamServer, as the settings pages' suite does it. 449 App tests and 155 layout tests pass. Both suites were run with -p:NuGetAudit=false: SSH.NET 2025.1.0 has picked up GHSA-q939-rpr3-3284 and NU1903 fails restore repo-wide, which predates this branch and is nothing to do with it. No package or lock file is touched here. |
||
|
|
ccaf7a8e72 |
Give the terminal back the width and the keyboard the session shell took
Seven things reported from a day's use of v5b's session shell, and they are one commit because five of them are the same complaint from different angles: the window spends too much of itself on chrome describing the session, and the parts that are not chrome do not behave. ◆ THE HOST HEADER IS GONE, and that is a deliberate departure from the design. Terminal.dc.html and SFTP.dc.html both draw a 60-pixel row above the pane: the address on the left, a cross-surface button on the right. Both facts are worth having and the strip they sat in was not — the tab already names the host, and a full-width bar repeating it was the cheapest 60 pixels in the layout to give back. The address is now the first line of the sidebar and the button is stretched across the column under it, so nothing is lost and the pane is taller. Which word that button carries and which command it runs used to be handed in from the two usage sites in MainWindow.axaml, because the control was drawn twice. One sidebar cannot do that, so SessionCrossSurfaceLabel and OpenOtherSurfaceCommand resolve it in the shell — the same place SessionAddress already decides which surface's fact to read. The two directions underneath are untouched: SelectFilesHostAsync for a tab's host, OpenTerminalForFilesHostAsync for a fresh terminal at whatever SFTP has open. SessionHeader.axaml is deleted rather than left unused, and LayoutHarness stops subtracting its 60 pixels from every session screen's budget — the same treatment the retired window-wide tab strip got, and for the same reason: a constant for chrome nobody draws is a suite quietly measuring the wrong rectangle. ◆ AND THE SIDEBAR CLOSES, which the design has no state for at all. 300 pixels of an 1081-pixel minimum is a great deal to spend on a list that is often two rows long. The column now folds to a 34-pixel rail carrying the chevron that brings it back — a rail rather than nothing, because a panel that vanishes leaving no trace is one people report as lost rather than as closed. Both states live in the one control and swap on IsSessionSidebarOpen, so MainWindow's own "Auto" column takes whichever width is showing without knowing the state exists. Written through to ClientSettings.SessionSidebarOpen rather than held for the session. It is a decision about how much of the window a terminal gets, and one that had to be made again on every launch would not really be on offer. ---- THE FOUR SMALLER ONES ---- A SNIP LANDED IN A TERMINAL NOBODY COULD TYPE AT, and looked selected when it got there. Two causes with nothing in common. The click moved Win32 focus onto the sidebar row, and term.focus() in the page cannot take it back — only the host can, so the shell raises TerminalFocusRequested and the window answers with the same posted focus every other path here uses. The highlight was bash: xterm wraps a paste in bracketed-paste markers, readline marks what arrives inside them as an active region, and it stays in reverse video until the next keystroke. Right for a clipboard paste, wrong for a snippet picked off a sidebar. Single-line snips are typed rather than pasted now, which needs no markers; multi-line still pastes, because "runs three commands unasked" is the worse of the two failures and the markers are the whole of what prevents it. A BLACK BAR UNDER THE TERMINAL, on Windows. xterm.css paints its scrolling viewport #000 — its own comment explains why, and it is a macOS scrollbar concern. Everywhere else that black is covered by the rows, except along the bottom: the fit addon floors the row count, so the remainder below the last whole row is bare viewport, up to a line tall, against this page's #171a26. The light square at its right-hand end is where WebView2's classic scrollbar corner lands. The viewport is repainted in the page's own background, and the scrollbar with it — thin and in these colours rather than a grey Windows channel down the side of a near-black terminal, and kept rather than hidden, because a surface that scrolls with no sign that it does is worse than a quiet bar. THE PINS ROW DREW A TOFU BOX. U+E946 is not in the embedded Material Icons face at all — that file is the 2019 build and its cmap skips E944 and E946 — so the rail's Pins row and the hosts screen's own pin badge have both been drawing a missing-glyph rectangle since v5b picked the codepoint. push_pin in that vintage is U+F10D, verified against the file rather than against a codepoints table for a later release of the font. Every other icon codepoint in the repository was audited the same way; this was the only miss. THE KBD CHIP CUT THE CHORD IN HALF. 34 pixels is the design's width for a chip reading ⌘K, and this build substitutes CTRL K — six characters and a space, wider than 34 at 10.5 mono. MinWidth and padding instead, so the design's footprint survives for the day this face has a ⌘ to draw. ---- AND THE POPOVER UNDER THE USER CHIP ---- Reported as not matching the design, and it was not: Button.poprow set a corner radius and a padding and never touched the Background, so every row wore the Fluent theme's own #33FFFFFF button fill. Six raised pills stacked in a menu the design draws as six lines of text — and the hover rule underneath was already correct and simply invisible against a fill that never went away. Set on the ContentPresenter as well as on the Button, the same as Button.flat, because the theme binds its brush there and a Background set only on the control loses to it. The panel itself gets this window's own radius-12 card treatment through a FlyoutPresenter class rather than by widening the shared context-menu rule, and Vaults and Preferences stop being drawn one step dimmer than Settings and Logout, which read as two disabled entries in a menu of five live ones. ---- WHAT PROVES IT ---- Three tests in the layout suite, two of them checked against the defect they describe: the popover row's resting fill (fails with #33ffffff without the style), and the kbd chip against the natural width of its own text, measured on a detached copy because a TextBlock's DesiredSize is already clipped to what it was given and reports 34 inside a 34-pixel chip either way. SessionSidebarTests is new — the sidebar has never been laid out by a test, and it now holds a string of unbounded length beside a button that has to stay clickable. In the shell suite: the cross-surface row in both directions, the closed state surviving to disk, and the focus request being made when a snip lands and not made when it does not. |
||
|
|
8a77b7ca68 |
Say how far a connection has got while it is still being made
The connecting card set its status string once, when the tab was created, and never touched it again. Every connection therefore looked identical from the outside: one three seconds into a key exchange, one waiting out a fifteen-second timeout against a machine that is asleep, and one that had hung all drew the same "connecting…". The card now draws the five steps of getting there, each lit at the moment the handshake reports reaching it, over an amber track that fills as they finish. ◆ NOTHING ON THE LIST IS INVENTED. Every row changes state because a layer below it said so, at the instant the thing it names actually began. That is the whole reason it is worth showing, and it is why most of this commit is plumbing rather than XAML: there was no progress reporting anywhere in the stack to hook a step list onto, and a card animating plausible progress would have been indistinguishable from one that had stopped receiving any. SshConnectionPhase names four phases and deliberately not more. SSH.NET runs the entire handshake inside one ConnectAsync and raises exactly one event from the middle of it — HostKeyReceived, once the key exchange has produced a key to show — so that event is the only interior moment there is to report. Everything before it is Reaching and everything after it is Authenticating. A fifth phase in that assembly would have to be a timer, so there is not one. OpeningShell is reported by TerminalWorkspace instead, because that is where it happens: the factory's work ends with an authenticated connection, and asking for a pseudo-terminal on one is a separate round trip. The SFTP path passes null — a second connection opened behind an already-open shell has nobody watching a step list for it. The card's fifth step, "Starting the terminal", is the renderer wait and lives in the shell rather than in the SSH assembly, which has never heard of a renderer. On the first connection after a cold start it is a real wait with a real failure mode of its own — a missing WebView2 runtime — so a list that began at "reaching the host" would leave the one wait most likely to hang unnamed. Amber for the step in flight, and that follows the palette's rule rather than bending it. Green is what is true and purple is what you can press; a step still happening is neither, and it is exactly the caveat-worth-reading that amber exists for. Steps behind it go green as they become true. Nothing animates, which is the argument TransfersScreen.axaml already makes for its own track, reaching a screen with far more reason to want a spinner: a spinner is furniture invented to fill a state nobody measured, and these states are measured, so the track fills to what has finished and then waits there. A refusal keeps the step it stopped on, in red, with the ones behind it still green. That is the half a progress bar could not do, and it is the difference between "that host is not there" and "that host is there and would not have me" — a question the reason sentence alone frequently does not settle. The strip's dot goes amber while a tab is connecting, on both heads. It was grey, and so is a tab whose shell has exited: the two states in that strip with the least in common, one worth waiting for and one over. PhoneShell's own comment already recorded half of this — the dot stopped being green before anything had answered — and this is the other half. Progress is raised inline rather than through System.Progress<T>, which captures whatever synchronisation context it was constructed on and posts to it. That reads like a convenience and is really a second place the marshalling decision gets made: silently, differently under a test with no context, and out of order with respect to the failure that follows a phase. The shell marshals once, in one handler, through a new optional post parameter on MainWindowViewModel — the same seam TransfersViewModel already uses, and for the reason its own remark gives. The three Dispatcher.UIThread.Post calls that predate it are the ones this suite's comments record as out of reach; they are left alone rather than swept in here. Both heads draw the list. They differ in one place: Phone.axaml's mono class sets a colour and a size along with the family, so the caption rule names its own family instead of composing the two and asking two rules for one Foreground. The desktop's mono sets the family alone, which is why ConnectingCard does compose them. Each head also gains SHOW LOGS beside the button that gives up — the step list is this attempt and the log is every other one, which is what a connection taking too long actually raises. Seven tests, and the two that matter most run against the container rather than a fake: a real handshake reports its phases in order, and a host-key refusal never claims to have authenticated. A fake asserting what it was written to assert would have established nothing about either. The rest cover the tab advancing while the connection is gated, the step a refusal stops on, and a phase reported after the user has given up on the tab. 1,861 tests, none failing. The Android head's layout is not verified by anything. It compiles, and compiled bindings mean every new binding path resolves, but that project is not in DodoSSH.slnx, there is no test project for it and no device here — so unlike the desktop card, whose shapes the layout harness measures, these rows have not been drawn. Vertical fit is reasoned, not observed. |
||
|
|
e96d01aab9 |
Let the host editor make the credential it is about to bind
The authentication picker has listed saved credentials since they existed, but making one meant leaving a half-typed host for the keychain screen and coming back to find it gone. On the phone it was worse than a detour: that head has no credential editor at all, so it could bind a host to a credential and never produce one. + NEW CREDENTIAL opens a card under the picker — name, optional username, password, notes — and ADD writes it and binds the host in one step. A button beside the picker rather than an entry inside it. Every row of that list is a binding a host can have, and "make a new one" is an action: as an entry it would sit in the box afterwards describing a state no host can be in, and cancelling the form would leave the picker showing it. It carries its own five fields rather than reusing the keychain editor's, and that is the load-bearing part. IsEditingCredential is what AVaultEditorIsInTheWay asks about, so sharing it would have made the whole Vault screen refuse to open an editor while this card sat open on the Hosts screen, with a status line naming a form the user cannot see on a screen they are not looking at — the exact failure that guard was split in two to end. A test pins it. It writes to the keychain immediately, unlike every other field in this editor, because a credential is a shared item with an id and a host can only name an id that exists. The consequence is honest rather than hidden and the hint says so: a credential added this way outlives a cancelled host edit. What was still being typed does not — every path that closes the host editor clears the form, and one of those fields is a password. The binding is written before the reload rather than after it. RefreshOpenEditors rebuilds this picker and then restores it from the editor's own selection, so setting it first is what survives the pass, and by the time it is read ReloadCredentialsAsync has put the matching entry in the list to land on. A name already taken is duplicated, not reused, and that is a deliberate parting from the new-tag box six lines further down which offers the existing tag instead. Two tags called "staging" are one intention spelled twice; two credentials called "root" are two different passwords, and quietly binding the host to whichever was there already would authenticate it as an account nobody chose. A duplicate label in the picker is the smaller problem. Into editingHostVaultId, so the credential lands wherever the host is being sealed and everybody who can read the host can read what it authenticates with. Stricter than the tag path — which files into the active vault and is recorded as a gap in docs/design-import-gaps.md — and it can be, because this picker lists credentials from every readable vault rather than one. Five flow tests cover the bind-through-reload path, the cancel semantics on both the saved credential and the abandoned one, the cross-screen guard, the duplicate name and the empty-password refusal. The layout test is separate and necessary: the card is collapsed until somebody presses the button, so a harness driven by the default state draws none of it, and TheHostDrawerFitsWithTheHostEditorOpen would have gone on passing over a card that blew the column. 1,854 tests, none failing. |
||
|
|
810bc48d3f |
Tell the keep-alive wire when the Files session opens and closes
HasLiveFileSession answers the phone's foreground-service question — is there a connection here that dying with the process would sever — and a bucket answers no, because HTTP holds nothing open. ActivityChanged now also fires at the end of MarkHostConnected and CloseSessionAsync, where both facts it reads are finally true together. Also makes the bucket pins test actually open a bucket: it never set Remote, so CONNECT dialled the auto-selected host, and its assertions passed only because that host had no pins either. |
||
|
|
dbf6ce1bcf |
Give the phone its pins: an editor section and chips on Files
The data was never the gap — HostSecret.PinnedPaths syncs and merges on both heads, and the desktop's drawer has staged it since v5 — the phone just had nowhere to add, remove or use a pin. Now it has both halves. The host editor page gains a QUICK ACCESS section over the same shared staging the drawer binds (EditorPinnedPaths, AddEditorPin, RemoveEditorPin), with the remove target at this head's 44dp touch floor rather than the desktop's 22-pixel close box, and no folder glyph because this head embeds no icon font for one. The page also gains a Status line of its own: the add command's five refusals speak through Status, and this page covers the screen that normally draws it — a refusal nothing shows is no refusal at all. The Files screen draws the connected host's pins as chips between the breadcrumb and the listing, each running GoRemoteCommand exactly as a crumb does. They are captured at connect, like ConnectedTo and the session facts before them; a bucket gets none, having no HostSecret to pin anything on. Covered headlessly in ShellFlowTests — connect populates, disconnect clears, a bucket stays empty — and by manual checks 8.18 and 8.19, whose phase preamble also stops claiming thirteen checks when it lists twenty-one. |
||
|
|
009b35e069 |
Cover the mixed-keychain regroup refusal headlessly
The two-keychain branch of VaultViewModel.RegroupChosenHosts had no test: 7.6a's manual walk was the only thing asserting that a mixed set gets the sentence instead of the picker. A ShellFlowTests case now ticks a host in each of two vaults, reads the refusal off the status line, and shows the same command opening the picker once the set is one keychain's again. Check 7.6a cites the test and keeps only the popup wiring for the eye. |
||
|
|
9d5ff9f23a |
Draw what authenticated, and over what, on the session status bar
The v5b design's own row: the negotiated cipher, then the host key's algorithm and the name of the key or credential that authenticated, as one mono run beside CONNECTED — on both surfaces, off MainWindowViewModel's surface-aware SessionCipher and SessionIdentityText, the same shape SessionAddress set. The identity's name comes out of TryBuildAuthentication, the one resolution point that always had it in scope and always threw it away; it rides HostAuthentication to the tab and to the SFTP connect alike. Three deviations, recorded in the gaps doc: the algorithm prints as negotiated rather than shortened, the run is plain text because no pin-details modal exists for an open session, and a typed password shows the algorithm alone — there is no item behind the dot. A dead terminal tab keeps its facts for the scrollback still on screen; an SFTP disconnect, with no scrollback, clears them. |
||
|
|
c8507b44fe | Give the application a settings area built from what really exists | ||
|
|
43c939b697 | Give the window its v5b chrome and each session surface its own shell | ||
|
|
2ba7c14e35 | Restyle the drawer, pin folders on a host, and say when it was last connected | ||
|
|
507cd9ff88 |
Choose more than one host card on the desktop, the way the phone already can
The chosen-hosts set has been in VaultViewModel since the phone's connect card became a contextual action bar: a set of entity ids, a tick on the row, and seven things that can be done to it. Only one head could fill it. The desktop's grid answered a press with one selection — the card the drawer, CONNECT and the context menu are about — so filing eleven imported machines under a heading was eleven drags, and clearing out a vault was eleven rounds of the deletion question. So the pointer gets three ways into the same set. Ctrl-clicks a card to tick it, Shift-clicks to tick the run between the anchor and the card, and drags a band out over the space between and below the cards to tick everything it touches. Esc, CLEAR, a plain click on a card and a click on the empty space each drop it, and Ctrl+A takes every card being drawn — VisibleHosts, so with something in the find box that is the ones on screen and not the ones it is hiding, which is the version of that shortcut whose result can be checked before Delete is pressed. TWO SELECTIONS ON ONE SCREEN, AND KEEPING THEM FROM DISAGREEING IS MOST OF THE CHANGE. Ctrl and Shift are answered on the tunnel and marked handled, so the ListBox never moves its own mark onto the card: a Ctrl-click that also selected would light the card it had just unticked and open the drawer on a machine somebody is removing from a set. A plain press drops the set unless it lands on a ticked card, and that case is deferred to the release, because the press may be the start of a drag of all of it. After any ordinary click exactly one card is in play, which is what makes every command on the screen unambiguous again. The context menu is where the seven live, and it is one markup with two halves gated on IsChoosingHosts. Connect, Browse files and Edit… are drawn only for a single ticked host, as the phone's sheet collapses them and for the same reason; the other four read better for a count. A right click on a card outside the set drops the set first, so a Delete… about the card under the pointer can never be offered while six sit ticked behind the menu — the same rule OnContextRequested has always enforced for the selection, reached from the other direction. No bar of buttons: the phone raises one because it has no other way to hold seven entries, and a strip repeating a menu that already exists would be a second home for the wording that matters most. What the desktop gains instead is a count beside the HOSTS heading, CLEAR, and a sentence saying where the actions are. A drag that starts on a ticked card carries every ticked card. The payload is a list rather than a row now, and a drop of more than one goes through FileChosenHostsUnder, which makes the refusals once — an open editor, and a group belonging to one keychain — and reports a count instead of forty status lines. Moving whichever card the pointer happened to be holding and leaving the other five where they are is a gesture that quietly does a fraction of what it looks like it does, and the five left behind look filed. The three panels the set's actions raise had never been drawn in a window: the vault picker with its key question, the group picker, and the deletion question. All three sit above the grid rather than over it, which is the arrangement the GROUPS section and the phone's list already use and for the reason written there — the ticked cards are the information the question exists to give, so the grid shortens instead. A DEFECT FOUND BEHIND IT, AND IT WAS ALREADY LIVE ON THE PHONE. The deletion question names a count and the run that answers it reads the set again, and nothing kept the two the same set: the panel is deliberately above a live list, so one more tick between "Delete these 6 hosts?" and pressing DELETE deleted seven, with the seventh named in nothing the user had read. It needed a deliberate act on a phone and a second's work with a band, which is what turned it up. VaultViewModel now remembers which hosts the question was asked about and drops the question when the set stops being them — the question rather than the set, because what somebody has just chosen is what they meant. It also covers the case nobody performs: a colleague's deletion arriving mid-question and shrinking the set under it. VERIFIED. 354 tests in App.Tests and 111 in App.Layout.Tests, build clean, no new warnings. Six gesture tests drive real pointer and key input through the headless window — the modifier click and what it must not do to the selection, the run and its re-measurement from the anchor, the band and the click that drops the set, Ctrl+A under a filter, and the menu's two halves — plus a DragOver carrying two hosts. Four layout tests measure the strip and the three panels at the window's minimum; the vault panel binds a key to its host first, or it would measure the short shape and certify the tall one. Two flow tests cover the multi-drop's write and its refusal, and the deletion question dropping itself. manual-checks gains 7.6a for dragging a set, which no test can see for the reason 7.6 gives, and 7.7a for the gestures — the rectangle actually being painted and the tick and the fill being legible together are the two things the harness cannot look at. |
||
|
|
1e8a1f2e83 | Merge branch 'claude/trust-connect-popup-56abec' | ||
|
|
4f9faa2fe3 |
Ask about a host key where the connection was made, not on the host list
The trust prompt was two banners at the top of the desktop's hosts screen, so the shell navigated there before letting a handshake raise one: Screen = Hosts, Surface = Page, in OnVaultConnectionFailed and again in the palette's own connect. The reason was sound — a connection can be started from Ctrl+K on any screen, and a question behind whatever somebody is looking at is a question nobody can answer — and it was answered the wrong way round. Rather than making the decision reachable from where the user is, it moved the user to where the decision was, and charged every screen for it. It is worst for the one connection that has no host at all. A machine typed into the phone's connect box is deliberately in no keychain, so a first contact from there judged it on a list it does not appear on, after taking the box that dialled it away. So both heads now draw the decision over the surface. HostKeyCard is the desktop's, and is the counterpart of the phone's HostKeySheet: a scrim with no press handler, because a question with two named answers must not be answerable by missing; the unknown key offering TRUST AND CONNECT, because judging a fingerprint against what an operator published is a decision a person is entitled to make and the only moment they can make it; and the changed key offering no way forward at all, because a button beside that warning is "continue anyway" with two clicks instead of one. The phone needed no new markup — its sheet was already a shell-level overlay, so deleting the navigation is what puts it over the Connections screen. IsHostKeyDecisionShowing is on the shell rather than on a screen because the answer decides an occlusion. A second connection can be refused while a first one is open, so this card is routinely raised over a live terminal, and that rectangle is a native child window: layered over it the card would be sliced at its left edge with TRUST AND CONNECT taking no clicks, which for the most safety-critical question in the product is the worst place for that class of bug to land. IsTerminalShowing gives the rectangle up instead. The banners are gone rather than copied. One prompt in two markups is two copies of the most safety-critical wording here, and the second is the one that goes stale. TWO DEFECTS FOUND BEHIND IT. VaultViewModel.RejectHostKey cleared only the pending key and never the mismatch, so the changed-key refusal had no working exit. That was invisible for as long as it was a banner nothing was drawn over — nothing was trapped, and the next attempt cleared it — and it was already live on the phone, where that refusal is an opaque full-screen panel whose one button runs this command: pressing it left the panel up over every screen the user went to next, including the host editor the panel tells them to open. TransfersViewModel.RejectHostKey has always cleared both; the vault's was the outlier. Its button said BACK TO HOSTS, which was wrong twice over, and now says BACK. And an assertion written for this change could not fail: the palette test asserted the renderer was collapsed in a scenario whose only tab had just been removed, so it was collapsed for want of a session whatever the occlusion rule said. It is gone, with a note pointing at the test that can fail on it. VERIFIED. 1580 tests, build clean, no new warnings, format clean. Three mutations each seen to fail and then seen green again: dropping !IsHostKeyDecisionShowing from IsTerminalShowing, caught by AChangedHostKey_CollapsesTheTerminalItIsRefusedOver; reverting RejectHostKey to clear one flag, caught by RefusingAHostKeyDecision_TakesItOffTheScreen(false) and by that same test; and dropping the two host-key arms from OnVaultPropertyChanged, caught by TheHostKeyDecision_IsAnnouncedToTheWindowWhenItArrivesAndWhenItGoes. That last one is the first test in this repository to watch PropertyChanged, and it is worth being the first: every other assertion about the flag reads it directly, and a direct read passes with the subscription deleted — while the card would never go away. The two layout tests moved with the prompts, from the hosts screen to the card. manual-checks gains 7.4a for the occlusion, 7.4b for getting out of a refusal and 11.7a for the hand-typed case, none of which a test can see; 1.5 and 7.4 are corrected rather than left describing a window that no longer moves. ONE ROUGH EDGE, DELIBERATELY LEFT. On the desktop, refusing a first contact whose tab was the only one leaves the terminal surface with no tabs — a blank rectangle under the strip's "no terminals open · press + or Ctrl+K", which is the one sentence near that rectangle Avalonia can draw. The alternative was falling back to the page, and on the phone that means the host list, which is the bug this commit is about. A desktop connect page would close it properly. |
||
|
|
6d6edb02c1 |
Keep an open editor's pickers in step with the vault
The host editor's four pickers were snapshots taken when it opened, and the comment on EditorAuthenticationChoices said why: a picker whose contents move under somebody halfway through a form is worse than a list a minute stale, and only one editor could be open at a time anyway, so the only way to add a key was to close this one. The second half of that stopped being true when AHostEditorIsInTheWay was split from AVaultEditorIsInTheWay. The host editor is the Hosts screen's business and the keychain's editors are the Vault screen's; neither refuses the other now, which was the right split — it stopped three quarters of a screen going inert over an editor the user was not looking at — but it left the assumption those snapshots rested on false and nothing to notice. So the ordinary way of using the feature was the broken one. Somebody starts editing a host, finds there is no key to bind it to, goes to KEYS, makes one, and comes back to a picker that does not have it — with the fix being to throw the form away and start again. The same for a password, a tag, a group, and for a whole vault made on the Teams screen because the host being typed belongs to the team rather than to the person typing it: the vault they had just made for it was the one place they could not file it. RefreshOpenEditors refills whichever editor is open, and it hangs off ReloadAsync rather than off the twenty-odd commands that write to the vault. That is the choice worth stating, because it is what makes a sync count as well as a save: a key pulled from another machine reaches the open editor by the same path a key typed here does, and a place that wrote to the vault without refreshing the editor would be a bug nobody would find for months. What the old comment was protecting against is real, so every picker is put back onto what it was already showing, by id, and not one typed field is touched. An editor that reset its own bindings because a background sync landed would be a worse bug than the stale list this fixes — it would rebind a host as a side effect of somebody else's work. The placeholder entries go back too, which is the case 3.4 measures: a group deleted on another machine mid-edit still cannot unfile the host when the form is saved. The group editor gets the same treatment for the same reasons; it shares the drawer, and its default binding is lent to every host under it. The snippet editor's vault picker was the same copy of the same list and went stale the same way. It watches TargetVaults rather than the reload, because that screen has always been a wrapper over the vault's collections and has no reload of its own to hang off — which is how it already follows Snippets. The move panels are deliberately left alone. A vault arriving from a sync while one is open still will not appear in it, but a move panel is opened by the act that fills it and its picker resets its selection to the first entry on every rebuild, so refreshing it would move a destination somebody had chosen. Same class of bug, different answer, and not this change. Five tests, and four of them were checked failing with the RefreshOpenEditors call commented out: a key reaching the open host editor and binding when chosen, an item arriving without moving a selection that was already made, a tag arriving as an unworn chip, a key reaching the group editor, and a vault reaching the host and snippet editors without moving either. Manual check 7.12 sits beside 7.11, which is this same bug on the files screen's picker, and says what the worse failure would look like: a picker that moves rather than one that does not notice. |
||
|
|
c882fa0cd3 |
Give the phone a selection instead of a card under the list
A long press on a host raised a connect card over the bottom of the list: a password box, CONNECT, EDIT, MOVE and DELETE. It was the right idea in the wrong place. It covered rows, it had room for five things and never a sixth, and every one of them was about exactly one machine — so filing eleven imported hosts under a group was eleven trips through a form, and there was nowhere to put a sixth action if anybody wanted one. A long press now chooses the host it landed on, and the actions move into a bar across the top of the screen, in the vault header's place rather than beside it. That is where Android has put them since contextual action bars existed, and it is the one strip a list can never grow into — but the real reason for it is that while it is up the screen is unambiguously about the ticked hosts and nothing else, which is what lets the count in the middle of it mean something. Left to right: the cross that leaves the mode, the count, the pencil, and a ⋯ holding Connect, Connect via SFTP, Move to vault, Copy to vault, Change group, Duplicate and Remove. A tap still connects and still raises nothing. Once anything is ticked it ticks and unticks instead, which is what every Android list does and is not merely a convention worth following: a tap that connected while five machines sat ticked would open a terminal on top of a selection somebody was halfway through building. Unticking the last host leaves the mode, so there are two ways out of it and the cross is only one of them. Both gestures now read the row from the element under the finger rather than from the list's selection, and that is a correctness change rather than tidying. A tap on a group heading moves the selection and the view model bounces it straight back to whichever host was chosen before — which answered "a host, or nothing" for free while a tap only ever connected. It stops answering it the moment a tap can tick one: the heading would tick a machine the user was not pointing at, into a set they are about to delete. Three of the seven entries are about one machine and are drawn only for one. A terminal, a file-transfer session and a form each have no reading over six, so they are collapsed rather than refused. The other four read better for a count than without one — it is the reason the set exists — and each of them says afterwards how many hosts it wrote and how many it left alone. Skipping beats refusing the whole run: a selection of eleven with one read-only row would otherwise do nothing at all and then report about the wrong ten. Copy to vault and Duplicate are new, and the difference between them is what each can safely carry. A copy crosses a key boundary, so it drops the group and the tags exactly as a move does — both are items of the vault being left, and a host arriving with either would point at something the destination does not contain, resolvable on the machine that sent it and dangling for everybody else. A duplicate stays in the same keychain, so everything it points at is still there and it keeps both. Change group is the write dragging a card onto a group already makes on the desktop, run over a selection; it refuses one spanning two keychains rather than half-filing it, which is the refusal a drop across that boundary already makes one host at a time. Connect via SFTP is the one action that leaves the vault. Which machine is a decrypted item and so is this object's business; the screen it leads to and the transfers view model behind it are the shell's — so it is an event, on the same division SessionOpened already draws for a shell. The host is re-found in that screen's own copy of the list, because the picker binds to rows in that copy and handing it the vault's object would select nothing. What is left of the card is the password box, and only because it had nowhere else to go: a host that authenticates with a typed password cannot be reached by a tap alone. That tap now raises a sheet rather than the bar, and the difference is that a sheet is up only while a question is on screen — the bar was raised by a long press and stayed, so it was a password box sitting over the list whether or not anything was being asked. Dismissing it empties the box, which is not tidiness either: a secret left behind would satisfy the emptiness check that decides whether to raise the sheet at all, so the next tap would dial with somebody else's password. The pencil moving into that bar takes the host editor with it. It was a card in the list's own row, under the search box and the sync line — twenty controls sharing a screen with two rows of chrome about the list it had replaced. It is a page now, and PhoneShell stands all four of its rows down for it, which is what "opens with all the options" means at 360dp. That needed a second subscription in that control: two of its flags are questions about the vault rather than about the shell, and the shell does not forward the vault's notifications. The ticks are held as entity ids rather than as rows, and written back onto the rows after every reload. Every row object in the list is replaced on every filter keystroke and every synchronisation pass, so a set of rows would empty itself once a minute under somebody choosing what to do with eleven machines. Ids that no longer resolve are dropped, so a colleague's deletion arriving mid-selection leaves a count that matches what is on screen. One caller had to change with it. ConnectToRecent opened the pane about a host, which was the desktop's drawer and the phone's card; the phone's answer is now a tick, and nothing on that list means "selected" any more — so arriving with the host merely selected would be arriving at a screen with nothing to press. Both are raised together, and the one the head in front of the user does not draw is inert. |
||
|
|
dc1ebf6afa |
Let the recovery code be copied, and give the phone a clipboard to copy to
Both screens had made the code selectable and both said why: a person who cannot get it out of the box photographs the screen, and a screenshot is a worse home for it than a clipboard. This finishes that argument. Selecting 64 characters of letter-spaced monospace with a thumb is the version of "possible" people give up on halfway — and on the phone the screen blocks screenshots, so the honest remaining options were retyping it or losing it. It is the one secret this application deliberately offers to a clipboard, and the contrast with the keychain's copy is the whole argument rather than an inconsistency. There, copying the private half is refused outright, because installing a key means pasting the public one and the private one has no business leaving the vault. Here there is no better route: the code exists for one screen, is stored nowhere, and has to reach a password manager. The clipboard is the intended destination rather than a way round the design. The sentence afterwards matters as much as the copy, and is asserted: a clipboard is a staging post, this screen is the only place the code exists, and the next thing copied replaces it. Somebody who copies and does nothing has not saved it. The phone had no clipboard delegate at all — the desktop passed one and this head passed null — so COPY PUBLIC KEY on the keychain answered "this machine has no clipboard" on a device that plainly has one. Nothing about that was platform shaped: Android has a clipboard and Avalonia surfaces it through the same TopLevel. Wiring it fixes that copy too. The test fixture built its shell without a clipboard, which modelled the bug rather than the product, so it has one now and the public-key test asserts what lands there instead of the refusal. The refusal keeps its own test, on a shell built without one, because the view model reads the delegate's absence rather than an empty result — and because a button that silently does nothing on this screen is worse than one that refuses. |
||
|
|
ca07d63585 |
Offer to bring the keys an ssh_config points at
An import that recorded a key path and left every host asking for a password was an import whose result did not connect. The answer to that was a manual paste per key, which is the sort of thing people do once and then stop importing. So there is a tick, and it starts off. With it off nothing changes: an IdentityFile becomes a note and the host asks for a password. With it on, IMPORT reads each host's first IdentityFile out of ~/.ssh, stores it in the vault encrypted like any other key, and binds the host to it. Three things about how it is drawn are load-bearing rather than tidy. It is a default nobody arrives at by accident. The sentence beside it names the directory rather than saying "your keys", because that is what somebody is agreeing to. And nothing is read during SCAN — tick it, read what it says, untick it, and no private key has been opened. This is the only place the application opens key material out of a directory the user did not point at file by file, and the whole of what makes that acceptable is that it took a deliberate press. One vault key per file, however many entries named it: an ssh_config pointing twelve hosts at one id_ed25519 is the ordinary shape, and twelve copies would be twelve things to rotate and eleven to forget. A file whose material is already in the keychain is bound to rather than stored again, which is what makes running the import twice harmless. What cannot be read off a disk is a passphrase, so a protected key arrives without one — and the report under the button names those files rather than leaving a host to fail at connect time with a message about a malformed key. Telling them apart means decoding for OpenSSH's own container, whose cipher name is the first field inside the base64 rather than anything in the armour, and that is the format ssh-keygen has written by default for years. The 88 base64 characters it decodes need 66 bytes, not 64: with the smaller span every protected key came back unprotected, which the tests now pin. A path that is not on this machine leaves its host imported and unbound, exactly as it would have been with the tick off, and is named in the same report. A config carried from another machine is the ordinary case, not an error. |
||
|
|
746711da9d |
Let a tap on the phone's host list mean connect
Choosing a machine raised the connect bar over the bottom of the list: a password box, CONNECT, EDIT, MOVE and DELETE. Five controls in the way of the one thing a tap on a machine's name obviously means. So the gestures split. A tap connects. A long press raises the bar, with all five. The pencil in the phone's header — its only persistent chrome — edits whichever host is chosen, which is the one of the five common enough to be worth a control that is always in the same place. The flag doing it is the desktop's own IsHostPaneOpen rather than a second one. That head made exactly this move when a selection stopped opening its drawer, and the question both are asking is "has somebody asked about this host" — answering it twice is how two heads come to disagree about what a selection means. One tap cannot finish: a host that authenticates with a typed password has nowhere on a list to be given one. That tap raises the bar with the box in it and says so, and a second tap with the box filled in connects. The branch is in the view model rather than in the head, because "can this machine be reached without asking for anything" is the same question the bar's own password box answers, and a copy of it in a view would be a second reading of a binding chain that has one. Two mechanics worth knowing. Avalonia raises Tapped on release whatever the press lasted, so a long press would open the bar and then connect — one touch firing both gestures — which is why HostsScreen tracks the hold and swallows the tap it precedes. And Holding only fires once IsHoldingEnabled is set, so that and the handler are attached together rather than one in markup and one in code. ConnectToRecent now opens the pane rather than selecting the row. On the phone it has to: a selection alone raises nothing now, so going back to a recent machine would land on a screen with nothing to press. |
||
|
|
50fa6fba38 |
Let the phone delete a host, and move or remove a group
The desktop gained three things the phone did not follow: moving a group to another vault, the second question asking whether a group's deletion takes its hosts with it, and — since long before either — deleting a host at all. What that left was a head whose v3 + can fill a keychain and whose editors can correct one, with no way to empty either. The commands could not simply be bound. DeleteGroup and MoveGroup aim at GroupTarget, which is the selected card or the open group, and the phone has neither: its list draws headings, and a heading's selection deliberately bounces back to the host. Called bare on that head they would have returned having done nothing — a DELETE that appears to have been pressed and has not. Both now take the row and fall back to GroupTarget for the desktop's menu, and ConfirmMoveGroupAsync resolves from the panel's own movingGroupId rather than from the selection, which is also the honester answer on the desktop: what moves is the shelf the panel was opened on. The heading's pencil became a menu. Three icons after a chevron, a name, a vault badge and a count is what would be left of the name at 360dp, so the ⋯ raises the add sheet's shape carrying Edit, Move to another vault, a rule, and Delete — the desktop's card menu, in the one idiom this screen already has. It does not carry Open: the desktop's grid holds one level of the group tree and this list holds all of it flattened, so there is nowhere to open a group into. DELETE under a host sits on a row of its own beneath EDIT and MOVE rather than beside them. A phone has no hover and no tooltip, so where a thumb lands is the only thing separating a destructive control from an ordinary one. Both questions take the controls that asked them — ShowsConnectControls, which is the phone's half of the rule ShowsHostPaneActions already carries for the desktop's drawer — so DELETE cannot be pressed a second time underneath its own confirmation. Preferences gained the running version, and the sentence saying this head does not replace itself and that no DodoSSH server will ever offer one. It reads Updates.CurrentVersion off the same view model the desktop's UPDATES section does, over the null channel that reports itself unsupported. Nothing was needed for the realtime push: it is composed in ServerConnection, which both heads use. Seven tests, all phone-shaped — a group acted on with nothing selected, the menu waved away leaving nothing armed, the ungrouped heading raising none, and the bar's three states. The rectangles remain unmeasurable for the reason phase 8 gives; the checks for them are 8.10 to 8.13 and 13.6. |
||
|
|
780f4bf892 |
Merge branch 'main' into the group's move and its deletion question
Main took the group's EDIT and DELETE off the GROUPS heading while this branch was adding a MOVE beside them, so the conflict was about the same six pixels from both directions. Main's answer wins outright, and it is the better one for the reason its own message gives: a button beside a heading has no card under a pointer to mean, and had to work its subject out from the selection or from the trail. Moving a group had that problem worst of all — the thing it takes with it is everything on the shelf, and "which shelf" is not a question a button there could answer plainly. So the MOVE button is gone and the menu entry it was drawn beside is the whole of it. That entry was already in this branch, above the separator DELETE sits below, and it needed no change: the card menu selects whatever was right-clicked before it runs anything, which is exactly the aiming a group move wants. Three things went with the button. ShowsGroupActions, which main deleted because hiding buttons was all it did, and which this branch had extended to hide them for the move panel as well. CanMoveGroupTarget, which existed to answer whether that button was worth drawing — CanMoveSelectedHost stays, because the phone really does leave the host's MOVE out rather than offer a refusal, and a menu whose entries came and went would be a menu whose items move. And the two test assertions that read them, which were describing the button rather than the behaviour; what they were guarding is that the two panels never share the moment, and IsConfirmingGroupDeletion says that directly. The move panel and the deletion question both keep their place under the heading, which is where the buttons were and is now simply where that section puts things. They still exclude each other, by disarming rather than by a visibility flag: MoveGroup clears a pending deletion and DeleteGroup folds the move panel away. Manual checks 3.3 was rewritten by main for the menu and by this branch for the tick, and now says both; 3.3a is new and walks a two-level shelf across a vault boundary, which is the half of this feature no headless test can watch land. |
||
|
|
a86731ee08 |
Move the shelf as well as what is on it, and ask what a deletion takes
Two things about a group, and they turn out to be the same argument twice. **A group can be moved to another vault, and it takes everything on it.** The host's move shipped last week and stopped one level too low: moving twenty machines into a shared vault meant twenty trips through a menu, and each one arrived stripped of the group it had been filed under, so the shelf had to be rebuilt by hand on the other side. Moving the shelf is what people were attempting. MOVE sits between EDIT and DELETE over the group cards, and on the card's own menu above the separator DELETE is below — the same place, and the same reasoning, as the host pane's ⋯ entry. **The whole subtree goes, and taking less was never coherent.** A group's children are items of the vault it is leaving, so a parent moved alone leaves them naming a tombstone and they surface as roots in the vault the user has just emptied: half a shelf here and half there, from one gesture that said "move this". The hosts are the same argument and are the half the request was about. The groups go first, top down, and the hosts last. Each item is re-sealed under the destination's key and takes a new id — VaultItemRepository.MoveAsync, which HostGroupRepository now exposes — so nothing pointing at a group can be written until that group has landed and its new id is known, and a child's parent must already be over there. What an interruption leaves is therefore hosts still in the vault they started in, under UNGROUPED: visible, and re-movable. The reverse order would leave hosts in the destination filed under nothing. The parent stays behind and the tags are dropped, which is the host move's rule one level up: both are items of the vault being left, so a reference carried across would resolve on the machine that moved it and dangle for everybody else in the destination. The moved group arrives at the top level, and the panel says so before the press rather than the status line saying it after. Keys and passwords are kept — those genuinely resolve across vaults, and clearing them would take a working host and make one that cannot connect — and any now outside the destination is named, because that is precisely what the other members of it will not be able to resolve. Refused as a whole where anything under the group was written by a newer client, rather than skipped item by item: a move that left behind what it could not re-encode would file some of the shelf in one vault and the rest in the other, which is the state this exists to prevent. Refused with a host editor open, as the drop gesture is, because it rewrites hosts. And the walk carries a visited set, for the reason every walk over this tree does: a group that is its own parent — which two offline clients can build and no editor was ever shown — would otherwise be appended to the move list for as long as there was memory. **Deleting a group now asks what should become of the hosts under it, and that reverses a decision this repository had written down.** The deletion did not touch them: the reference was left dangling, the list resolved it to nothing, and the machines turned up under UNGROUPED. That was right for one of the two things people delete a group for and wrong for the other — a heading being tidied away should leave its machines alone, and a project that has been decommissioned is a shelf and everything on it — and nothing in the code can tell which of the two it is looking at. So it is asked. A tick rather than a pair of options, because the two answers are not equally weighted: keeping the hosts is recoverable and deleting them is not, so the safe answer is the one that needs no decision. It is off on every question, including the one that disarms it, or a tick left standing would destroy the next group's machines on the strength of a decision about the last one's. Once the deletion knows which hosts it means, leaving them naming something that has gone is a state kept for no reason, so the unticked answer writes too: N hosts with the reference cleared, where the ticked one writes N tombstones. That is the N writes HostGroupRepository refuses to hide behind a DeleteAsync overload, made where somebody asked for them and where the count is on screen first. The nested groups take the deleted group's place in the tree rather than being orphaned to the top level. A read-only host is skipped, counted and named, because unfiling it would re-encode a payload this build cannot represent — and the cost of skipping is a dangling id, which every reader here already survives. **Both are the desktop's alone**, and that is not an omission. The phone draws groups as headings in the host list and has never had a way to delete or move one; the two panels take the row of buttons over the group cards, and there is no such row on a 360dp screen to take. One test had to change its premise rather than its assertion. EditingAHostWhoseGroupIsGone built its dangling reference by deleting the group, which now unfiles instead — so it imports a host naming an id nothing resolves, which is what a group deleted on another machine actually looks like and is the only way that state still arises. The picker's placeholder is still needed and still covered. Four places said an item could not be moved between vaults. Two were about a group and were true when written; the other two were left stale by the host's move. All four now say what is true, including the design gaps document, where the chevron beside the vault name stays undrawn for the reason it already had. |
||
|
|
589300253d | Merge branch 'claude/vault-realtime-push-d64c61' | ||
|
|
4b706bc3c3 |
Say when a vault has moved, so nobody waits out the minute
The delta pull was cheap enough to run on a timer and the client did, once a minute. That is fine for a machine and wrong for two people: an edit a colleague makes is up to a minute stale, which is long enough for both of them to make it and produce a conflict neither needed to have. Shortening the interval is the obvious answer and the wrong one — it costs a request per client per interval whether or not anything happened, and it converges on a busier server that is still late. So the server now says so. A client holds a WebSocket open at GET /api/v1/events, subprotocol dodossh.events.v1, and gets a line down it when something it can read has changed. ADR 0012 has the reasoning; three parts of it are worth repeating here, because they are what everything else rests on. **What crosses the socket is a notice, never data.** A frame names a vault and how far its change log has got. No item, no ciphertext, not even which item it was. The client's answer is the delta pull it would have run anyway, so there is still exactly one code path that applies a change to a keychain, and it is not this one. Pushing the items themselves would save a round trip and fork that path in two, with the cursor, the merge and the tombstone rules duplicated across both — ADR 0003 put every mutation through one write path for that reason, and this keeps every read on one for the same one. It also makes a dropped notice harmless, which is what lets the fan-out below be as simple as it is. **Polling stays, and is what guarantees a pass.** The minute timer is unchanged. A network that eats WebSockets, a server with Events:Enabled off, an older server, a proxy that will not upgrade, a notice dropped under backpressure — every one of those leaves a client behaving exactly as it did before this commit. Nothing is reachable only over the socket and nothing is meant to become so; VaultViewModel's AutoSyncInterval remark now says that where somebody changing it will read it. **The bearer token authorises the upgrade, unlike the relay's ticket.** Not an inconsistency with ADR 0004: the relay's socket is a byte pipe whose whole authorization decision — which host, which IPs, which port — is made before it opens and never revisited, and it is the extraction seam for a process that must hold no ACL code. This one is a view of the caller's own vault list and has to keep answering "what may this account read" for as long as it is held. A ticket would carry that answer in a token and be wrong the moment the account's access changed. The two bounds that arrangement needs are met rather than waved at: the socket is closed at the token's exp with close code 4401 and the client comes straight back with a fresh one, and the vault set is re-resolved every few minutes as well as on the changes known to affect it. Both bound *metadata*, because a notice contains nothing else and reading a vault still needs a key this server has never held. **The fan-out.** VaultEventHub is a singleton holding the sockets this node accepted; publishing walks them and asks each whether it cares, rather than keeping a vault-to-subscriber index that every re-subscription would have to move entries between under a lock publishing also takes. At a few hundred sockets per node and an event rate bounded by how often people edit keychains, the walk is not measurable and its races are obvious. Per-connection queues are bounded and drop the *oldest*: a notice means "pull vault X, which is at least at sequence N", so the newest subsumes what it displaces and the client's answer is identical either way — which is what lets the publish path be void, never block, and never fail. Announced from the endpoint rather than from SyncService, and that placement is the point: by then the push has committed and released the per-vault advisory lock. From inside it would name a sequence no reader can see yet and would hold the lock that serialises writers across a socket write. Only the highest *applied* sequence, so a batch of pure conflicts announces nothing, and a duplicate — already announced when it first landed — announces nothing either. Grants and membership publish too, and those take the *recipient* rather than the actor. This is what AdmitNewVaultsAsync has been apologising for since sharing shipped — "the recipient is handed nothing, there is no push channel" — and the README with it. A vault shared with somebody now turns up as it is shared. The comment and the README paragraph both say what is true now, and both keep saying that the pass is what *discovers* the vault, because a client with no socket has to arrive at the same place. **On the client**, VaultEventStream is really a reconnection policy wrapped round a ClientWebSocket: a dropped socket is the ordinary case here — laptops sleep, proxies time out, tokens expire, servers are redeployed — so nothing in it treats a failure as exceptional, and every path ends in "wait, then dial again". A connection that lived long enough to say hello resets the backoff, so a laptop that woke, worked, and lost its network an hour later does not inherit a minute-long wait it has already proved it need not take. A 4401 close skips the backoff entirely and asks the token provider again, which is the whole reason that close code is distinct. A server that does not advertise the events feature gets IdleVaultEventStream, which never delivers — so IVaultServer.Events is never null and every caller stays on one shape, because the correct behaviour without a socket is the behaviour with a silent one. The shell's background loop now selects between the timer and a notice, and both waits are held across iterations. That is load-bearing rather than tidy: PeriodicTimer permits one outstanding WaitForNextTickAsync and throws on a second, and an abandoned channel read stays registered and consumes the next notice written. Either defect leaves the first notice working and every one after it silently lost, which is why NoticesKeepWakingTheLoop_NotJustTheFirst pushes three and not one. Notices are coalesced over a quarter of a second, so one person's save — a host and its log entry are two items — and a colleague clearing a folder each cost one pass rather than a dozen. **The kind is a string, not an enum**, and that is a compatibility decision. UseStringEnumConverter throws on a value it does not know, so a newer server sending a kind an older client had never heard of would not add an unreadable frame — it would break that client's socket outright. A string is ignored instead. ProblemCodes is the same shape for the same reason. **Tested on both sides, through the real pipeline.** The endpoint suite opens a genuine socket against TestServer and proves a push produces a notice, that another account's push does not reach it, that a ping is answered, and that a frame this server cannot parse does not end the connection. Two of those assert on *ordering* rather than on absence within a timeout — the stranger's write goes first, so a socket that leaked would have announced it before the one the test waits for — because "nothing arrived in two seconds" is a test that passes on a slow machine for the wrong reason. And ANoticeCarriesNoCiphertext asserts on the bytes that crossed the wire rather than on the record's fields, since the latter would only prove that this type has no payload member, which is a tautology; the former is what catches a field added later without anybody thinking about disclosure. The client suite drives VaultEventStream through an injected connector, because the one thing a test cannot do to a real network is make it fail on cue — and failure is the entire subject. The shell suite proves a notice produces a pull inside ten seconds against a sixty-second timer, so the timer cannot be what caused it. **Two limits, stated rather than left to be discovered.** Fan-out is in-process, so a deployment running more than one API replica only pushes for writes its own replica handled and the rest arrive on the timer. IVaultEventPublisher is the seam a PostgreSQL LISTEN/NOTIFY backplane implements and it is deliberately not implemented: an untested backplane is worse than a documented gap, and multiple replicas degrade to the behaviour before this commit rather than breaking. And a client is notified of its own writes; it pushed, so it already pulled, and the extra pass finds nothing. Suppressing that echo correctly needs a per-device identity on the socket, and the same user's other machines must still be told. Manual checks phase 15 covers what no test here can reach, which is the network in between: a proxy that will not upgrade, one that drops an idle socket without telling either end, a laptop lid, a token expiring. Every one of those is invisible inside a test host, and every check there passes only if the change arrives quickly *and* still arrives with the socket taken away. ADR 0012 also fixes one thing about the shared terminal session this is the transport for, so it need not be renegotiated later: session data will be binary frames on this same socket, because base64 in a JSON envelope is the wrong shape for the one payload here that is continuous rather than occasional. Two questions it explicitly does not answer by implication — whether those bytes go through the API at all, and what end-to-end encryption means when the second party watches a stream rather than holding a key — are ADR 0001 questions and get their own decision. 1512 tests pass. DodoSSH.SystemTests was not run — it needs the whole compose stack — so the end-to-end path is unverified for this change beyond what the manual checks describe. |
||
|
|
3f419cb19b |
Let one card be selected at a time
The hosts screen draws two grids, one above the other, and each is a ListBox with a selection of its own. Nothing joined them, so a group card and a host card could be lit at the same moment — two chosen things, under two pairs of buttons of which only one would act on whichever the eye had settled on. Both grids mark a selection the same way, so there was nothing on screen to say which of the two the next press belonged to. They share one mark now. Selecting a host clears the group and selecting a group clears the host, and that second one takes the detail pane down with it: a pane about one machine cannot go on standing beside a marked group, because nothing on it would be about what is selected. Losing a selection deliberately clears nothing. A null arrives whenever either list is rebuilt — every keystroke in the filter box and every background sync — and treating that as somebody deselecting would take the mark off a group card because a search emptied the grid beneath it. The reload needed the same guard for the same reason. It falls back to the first host when nothing is selected, which is what puts a target under CONNECT on a fresh unlock; with one mark between the two grids that fallback would have unselected a group nobody had touched, once a minute. It is skipped while a group holds the selection, and it moved into a method of its own because the comment saying why pushed ReloadHostsAsync past sixty lines. One consequence needed handling rather than accepting. The phone's only route into the group editor is a button on a heading in the host list, and it worked by selecting the group first — which under this rule takes the highlight off the machine somebody was about to connect to, on a screen that draws no group cards to say where it has gone. EditGroup takes the group as an argument now: the heading passes its own row, and the desktop's button beside the cards passes nothing and still means "the card that is selected". What the tests hold is the half that lives in the controls. Clearing the property has to reach the list that is drawing the card, and a selection nulled in the view model while the card stays highlighted is the exact failure this is about — so the rule is driven on the real screen, in both directions, against the ListBoxes' own SelectedItem. The desktop's EDIT is pressed through its binding for the same kind of reason: a command refusing the button's empty parameter would be a button that never fires, and nothing about the markup would say so. |
||
|
|
e9cea2ccbc |
Let a shared vault arrive, a bucket be found, and a vault be deleted
Three things a user reported, one of which was a real bug and one of which was
not the bug it looked like.
**A vault shared with somebody never reached their machine.** The grant was
correct at both ends: the sharing client verified the recipient's key against the
key log and wrapped every generation to it, the server stored it, and /me would
have returned it. Nothing asked. VaultSession.RefreshVaultsAsync — the method
whose own summary says it is "called after a share and on a periodic pass" — had
no caller anywhere in the application, so the vault list was whatever the last
browser sign-in cached. A restart did not help: an offline unlock reads that same
cache. The vault appeared only if the recipient happened to sign in through the
browser again, which is why this looked like sharing being broken rather than
like a list that was never re-read.
So every synchronisation pass now re-reads it, before it syncs. SyncOnceAsync
takes the whole server rather than its sync half for that reason, and the order
matters: a vault admitted by the refresh is one that same pass then pulls, where
the other order would show a newly shared vault as an empty one until the minute
after. The shell is told only when the set actually changed — it rebuilds the tab
strip's vault menu from the session's list, and doing that on every quiet pass
would rebuild a menu once a minute for nothing.
The test needed the fake server to be able to do something no test here had
needed before: hand this account a vault it did not make. ShareVaultWithMe wraps
a real key to the encryption key this account enrolled, so the keyring opens it
exactly as it opens a real colleague's — a helper that filled the field with
bytes would let a vault appear in the list and never prove it could be read.
**Adding an S3 bucket on the desktop works, and could not be found.** The report
was that it is not possible; driving the real XAML headlessly says otherwise —
Keychain, + BUCKET, and the editor saves. What is true is that S3 is where
somebody goes looking, and from there SELECT BUCKET opened a combo box with
nothing in it and no sentence anywhere saying that a bucket is a keychain item.
From where the user was standing that is indistinguishable from an application
with no way to add one.
The empty state now says what a bucket is and offers a button that lands on the
keychain with the editor already open — navigating to the screen and leaving
+ BUCKET to be found among five buttons would be most of the same problem. The
phone gets the sentence and no button: its keychain screen reads and deletes and
edits nothing, so there is no editor to send anybody to, and naming the machine
that has one beats an empty control that reads as a screen still loading.
The keychain screen's layout test grew the two categories it never covered.
Tags and buckets arrived after it was written, and the header strip it measures
is one that has overflowed twice before.
**A vault can now be deleted.** DELETE /api/v1/vaults/{id}, gated on Admin —
the line the rename already drew, for a stronger version of its reason, since
this takes the vault from everybody in it at once. The row is soft-deleted and
every grant to it withdrawn in one write; VaultAccessService filters on the stamp
at both ends, so from that moment the vault is absent from every member's /me and
every call naming it answers 404. Their clients notice on the pass described
above.
The team behind it is archived when it owned nothing else, which is the mirror of
renaming it: a vault made from the vaults screen gets a team named after it that
nobody was ever shown, and leaving that behind would leave a membership list no
screen has a row for. That is a second call rather than one transaction —
archiving is TeamService's, it refuses while a team owns vaults, and it can only
tell that this one no longer does once the deletion is committed. A crash between
the two leaves an empty team: invisible, archivable afterwards, harmless, and a
better failure than a vault that could not be deleted because tidying up after it
did not work.
Two refusals worth stating. The personal vault cannot be deleted at either end:
it is created by enrollment, everything filed nowhere else lives in it, and no
call would make another. And the items are kept — ciphertext behind a vault
nothing will resolve, so deleting them buys no confidentiality while destroying
what an operator undoing a mistake would need.
The client drops the key from the keyring and the row from the cache rather than
waiting for a refresh, so the list is right immediately; the items stay, as they
stay for a vault whose grant was withdrawn, because a copy is on every other
member's machine too and removing these rows would be the client pretending to a
reach it does not have. The confirmation says that out loud before it is
answered. It is the one sentence this screen must not leave implied: deletion is
no more retroactive than revocation is. See ADR 0001.
Desktop only, deliberately. The Android vaults screen offers no rename and no
hand-over either, so adding delete alone there would be the one destructive vault
operation on a screen with no other.
Three places asserted that a vault can never be deleted — TeamService's refusal
message, the TeamNotEmpty problem code, and ADR 0009 — and each now names the
route instead.
|
||
|
|
8707629a6c |
Make the vault the thing you share, and ask a host which one it lives in
The teams screen listed teams that owned vaults, so sharing four servers with two
colleagues meant creating a team, then a vault inside it, then wrapping a key.
Two of those three steps are about a concept nobody arrives wanting. The screen
now lists vaults: naming one creates the membership list that carries it, named
after the vault and owned by you, and members, invitations, roles, hand-over and
key holders all hang off the vault they apply to.
Nothing on the server moved. VaultAccessService still resolves a shared vault
through team_membership and every membership call still names a team id — what
went is the requirement that anybody make one. The split the whole design rests
on is untouched and is still what the screen is built around: adding somebody
authorises the server to serve them, and only a machine holding the key can make
the vault readable. ADR 0009 keeps its decision and gains an addendum recording
which half of it a person is now asked about.
The one place the team resurfaces is a membership list carrying several vaults,
which this screen cannot produce and does not hide: the members section says so,
because "adding somebody here adds them there" is precisely the fact a
vault-shaped screen is in a position to conceal.
Two things left the interface and one arrived. Creating a team is gone, and so is
archiving one — it was only ever possible for a team owning no vaults, and a
screen whose rows are vaults has no row for one, so the button would have been
unreachable or always refused. The endpoint is unchanged and the screen states
the limit instead, since a vault cannot be deleted at all. The exception is a
create whose second call failed: cancelling that form archives the membership
list it left behind, which is a deliberate departure from this client's rule
against tidying up on the user's behalf, made because nothing else can reach it.
What arrived is PUT /api/v1/vaults/{id}. Without it the screen loses its only
editing action, since renaming the team behind a vault is invisible to everybody
who was never shown the team. It is gated on PermissionFlags.Admin — the line
UpdateTeamEndpoint already draws, because a name is what everybody in the vault
sees it called rather than part of its contents — and it renames the owning team
with it when that team carries nothing else, so the row an operator reads and the
name a user says cannot drift apart. The slug never moves, for the reason it does
not move on a team rename. The session edits its cached vault row rather than
replacing it with the response, which deliberately carries no wrapped key.
The host editor now asks which vault a host goes into, beside the name, while
adding and only where there is more than one vault to write to. It is a second
picker rather than the keychain screen's reused, and the two selections are
separate on purpose: that one is a standing preference about where new items go,
this is a field of the host in front of you, and binding both to one selection
would mean a click on the other screen could move a half-typed host. An existing
host is not offered it at all rather than offered it disabled — the two vaults
are encrypted under different keys, so moving an item is a delete and a retype.
That forced a fix worth naming. The group picker was built from the active
vault's groups whatever vault the host was being filed into, so a host put in a
shared vault could be filed under a group only its author can resolve — a
colleague would see it filed under nothing, which is the quietest kind of wrong.
Groups are now kept per vault and the picker follows the vault choice.
Two renames, because the pair they would otherwise have made is a bug farm:
ShellScreen.Vault became Keychain and VaultScreen became KeychainScreen, which is
what the rail has always labelled that screen, leaving Vault for one vault's
contents and Vaults for the vaults themselves. The enum values are unchanged;
NavRail.axaml writes them as x:Static literals.
1536 tests pass, seven more than before. Five are new on the server — the rename
endpoint's success, the team it does and does not take with it, the two refusals
and the empty name — and the client suite gains six and folds four together,
having lost the two about archiving a team.
|
||
|
|
2a56ae3efb |
Put a host inside the group it is filed under, rather than beside it
The group cards were headings with a navigation gesture bolted on. Opening one narrowed the grid to its hosts, but the level above already held every host in the keychain — so a card could only ever subtract, filing something changed nothing but a chip, and a keychain with forty machines was forty cards however carefully anybody had arranged them. The trail said ALL HOSTS and meant it. So the grid holds one level of the tree, the way a directory pane holds one directory. A host filed under a group is inside that group and is not also on the screen the group's card sits on; the outermost level is what nothing has been filed into. A group is a place now, and the cards, the trail and the drop target were already the vocabulary for saying so. The find box is the exception and had to be one. Typed into, it searches the open group and everything under it, which from the outermost level is every machine in the keychain. A box scoped to the level it was typed on would answer "no host matches that" about a host this keychain has got, and finding a machine without first remembering where it was filed is most of what the box is for. The accent chip on a card is what tells a searched-up host from one that lives at this level. Two things came free with the change and are handled rather than left. The phone would have broken. Its list binds SidebarRows, which was a projection over VisibleHosts, and it has no group cards and nowhere to open one into — so level-scoping would have left it drawing only the hosts nobody had filed. RebuildSidebarRows takes its own pass over the hosts now, narrowed by the vault switches and the box and by nothing else, which is the whole tree flattened under headings: exactly what it drew before. And anything created inside a group disappeared the moment it was saved. The host editor opens on the group the screen is about rather than only on a selected card, and + NEW GROUP defaults its parent to the group that is open. Deliberately not the selected card there: a highlighted card is what EDIT and DELETE are aimed at, and reading it as "and the next group goes inside it" would nest one because somebody had clicked something. The host editor takes both because it always took the selection, and its picker shows the answer before anything is written. Dropping a host on a group card now takes the card off the grid, and the selection goes with it — Connect, Edit and Delete all read that property and none of them should be aimed at a card that has left the screen. The status line is what says where it went, which is why the manual check now asks for it to be read. Coming back out through the editor lands the host on this level again, and there the selection survives. An empty grid has two more things it can say: that every host is filed away, which the level-at-a-time grid made reachable and which is not the same sentence as "there are none", and that nothing under this group matches what was typed — with ALL HOSTS named as the way to widen it. Not changed, and next door: a group card counts the hosts filed directly under it, so a group holding only subgroups reads "0 hosts". That was already true and is more visible now that its subgroups' hosts are not spilled onto the level above. 1522 tests pass. Four are new — the level rule and the phone's flat list asserted together, the box reaching two levels down, the all-filed sentence, and where a host and a group made inside a group end up. MovingAHostToAGroup was asserting the old outcome and is rewritten rather than adjusted: it held that the host stayed selected, and what it holds now is that the host leaves the level it came from. |
||
|
|
38d8706784 |
Give the phone a way to enrol the fingerprint it already unlocks with
The Android device key store, the biometric gate and the lock screen's UNLOCK WITH FINGERPRINT button have all shipped since this head was written, and none of them could ever run: that button appears only when a device key exists, and nothing on the phone could create one. `CanUnlockWithDevice` was false on every launch of every phone. This is the missing half. **The offer is on PREFERENCES**, which held a PendingScreen until it had a setting on it. It is there rather than beside the button it turns on because registering needs an unlocked keychain and a reachable server — the vault has to be open to seal the bundle, and the wrap has to reach the account or a phone somebody has lost could never be revoked. Neither is true on the lock screen. One card, and exactly one of its three blocks is ever drawn: the offer, the withdrawal, or the sentence saying this phone has nowhere to keep a key. That is `CanRegisterDevice` / `CanForgetDevice` / `HasNoDeviceKeyOption`, which are two flags and not one and its negation for the reason written where they are set — a phone with no screen lock and a phone already registered are both "cannot register", and only the second has anything to take back. The withdrawal has no confirmation, deliberately, and the sentence above it carries what the desktop puts in a tooltip this head has no room for. `StatusMessage` is on the screen because it is the only feedback this head has once the system's own dialogue has gone. **Two things would have been wrong in the feature the moment it worked.** `Environment.MachineName` answers `localhost` on Android, and registering names the device — so every phone would have arrived in the account's device list as another identical row, on the very screen a lost handset is revoked from. `PhoneEnvironment.DeviceName` was already written and never called; the shell now takes it as an optional constructor argument that the desktop does not pass, and it reaches enrollment, registration and every connection log entry. That was gap §7 of docs/android-port.md, and it is now closed. And the status line said "Waiting for Windows…" over an Android biometric prompt. `GestureWait` picks the sentence from the platform rather than from a head, unlike the device name beside it: a device name is a fact about one handset only the head can read, and which dialogue appears is a fact about the operating system this assembly is running on. Two tests cover the seam — the injected name reaching the account, and the default still being this machine's own name — and `FakeVaultServer` records what each device called itself, because the name is the only part of a registration a person ever reads. The gesture itself is unreachable from any test process, so Phase 13 of docs/manual-checks.md carries five checks, including that enrolling a new fingerprint in Android's own Settings destroys the key. That one is the property that makes this a fast path rather than a weakening of the passphrase. |
||
|
|
fd8497bb76 | Merge branch 'claude/sftp-s3-connection-ui-b0730f' | ||
|
|
61139bd469 |
Move the SFTP and S3 connection into the right-hand pane
THE CONNECT BAR IS GONE. It was a 44-pixel strip across the top of the file screen holding a heading, a picker, a password box and a button — chrome spanning both panes in order to configure one of them, drawn at full width whether or not anything was ever going to be connected. Underneath it sat a pane that was empty for exactly the same reason, saying so in a sentence nobody had to be told twice. So the pane says it instead. Disconnected, the right-hand half is an invitation where the listing would be: what the screen is for, what pressing the button costs, and one thing to press. SELECT HOST opens the picker in place — the same combo, the same password box, the same CONNECT — and the sentence and the button go away with it, because by then they have been read or they have not. Two steps rather than a picker sitting open, and that is not decoration. The reason the pane is empty is the question, and a combo box in the middle of it is a form with no question above it. The two steps also keep the panel inside its budget, which is the pane's height less whatever the queue has taken — 268 pixels with three transfers on it. Neither shape reaches the floor, and the layout suite measures both. IsChoosingRemote is cleared by everything that changes what the picker would be picking: connecting, disconnecting, moving between the SFTP and S3 tabs, and losing the vault. The last two are the ones that would rot quietly — a picker surviving a hop to the other tab offers hosts on a screen showing buckets, and one surviving a lock offers a list that has just been emptied because its rows carried decrypted secrets. CANCEL takes the typed password with it, which is a secret nobody asked to keep. WHERE THE TWO CONNECTED FACTS WENT. The address chip and DISCONNECT are a strip of their own inside the remote pane, above the listing — not three more cells in the header beside UP, REFRESH and DELETE. That pane is 381 pixels wide at the window's minimum and a fourth control in that row would have pushed one of the three off the edge; the number is written into the markup so the next thing added to either row is measured against it rather than tried. The status line did not fit there either. What is left after a 170-pixel address and a DISCONNECT is about eighty pixels, which turns every sentence into its first word and an ellipsis, so while a session is open it is in the queue's own strip at the foot of the screen, which spans the window. The other half of the time it is inside the invitation, beside the button that provoked it. One home in each state rather than two homes in one and none in the other. The header label reads HOST or BUCKET now, which is the only thing on the screen naming the kind — the bar that printed SFTP or S3 is gone and the tab in the strip says it either way, whether or not this screen is showing. The opening status text was "Choose a host and connect to browse its files", which the invitation now says in a heading, a sentence and a button. It is "Nothing is open yet.": a state rather than an instruction. That string is shared with the phone, where it still reads correctly under the picker card that head shows directly. Desktop only, and the phone is unchanged rather than merely untouched. Its FilesScreen is one pane at a time, so the picker *is* what it shows before a connection exists; it binds none of the new members. Four tests. Three in the layout suite — the picker open over a full queue, which is the tall shape and the one that has to be measured with the panes at their least; the bucket picker, which is a row shorter because an object store has nothing to type; and a session open with a long enough address to prove the chip gives way before DISCONNECT does. The fourth is the picker's lifecycle in ShellFlowTests, over all four things that put it away. 302 tests pass across the two suites. |
||
|
|
0c4bfea2a8 | Merge branch 'claude/group-creation-hosts-dragdrop-56c6a3' | ||
|
|
f9d08b738c |
Take the group headings out of the host grid, and drop onto a group card
A first group turned the wrap of host cards into an accordion: SidebarRows interleaves a full-width fold-away heading — chevron, name, count — between the cards, and in a grid that reads as a dropdown somebody left open. The desktop grid binds VisibleHosts now. The headings and the fold stay for the phone, whose list has no room for the row of group cards the desktop draws above the grid. What a heading said, the card says: HostRowViewModel.GroupLabel, resolved once per reload like the tag names, drawn as an accent chip and absent from a host in no group — or in one that has been deleted, which is the same thing to look at. What a heading also was is the drop target, and that moves to the group cards. Two things go with it. A host dropped onto another host card used to be filed beside it, which was legible while a heading named the band of cards it landed in and is guesswork now; it is refused. And UNGROUPED was how a host was dragged back out of a group; the way out is the picker in its own editor, which is the one place "no group" can be said in words. A drag held at either edge of the grid scrolls it. Without that the gesture only works for whoever can see both ends of it: the group cards are the first thing in the scroller, the host may be the fortieth card down, and a drag cannot use the wheel. A step per drag event rather than a timer, so it follows the pointer and stops when it stops. The two heading-shaped tests are replaced. TheHostsGridHoldsCardsAndNoGroupHeadings asserts the grid's contents rather than only measuring them, because a heading that came back would lay out perfectly cleanly. TheGroupCardsAreWhatAcceptsADroppedHost raises a real DragOver over both kinds of card and checks the effects and the mark — the nearest a headless test gets to a gesture no headless test can synthesise. manual-checks 3.1-3.2 and 7.6-7.9 follow. |
||
|
|
562fb444a8 |
Merge main into the phone connections branch
Main had already taken this branch's first two commits, so what merged is the Connections work against three things that landed beside it. Four of the six conflicts were prose about arrangements both sides changed; two were real. **The phone hub gained a Teams row while this branch was moving the keychain onto it.** Both are additions to `IsMoreSurface` and both belong: teams because the desktop reaches them from its rail and the phone through the hub, the keychain because a bottom bar is for the places a session moves between. The membership test, the back gesture's first case and the hub's own arithmetic all take the union. The distinction is now written down rather than implied — teams is the design's count plus one, and the keychain is the only rearrangement of it: the bar lost a slot to gain that row. **`ConnectAndAnnounceAsync` was the real one.** Main gave it `RememberTypedPasswordAsync`, which binds the password that just worked to the host it worked on; this branch had replaced the `HostRowViewModel` that method needs with a four-field `ConnectionTarget`. Keeping both meant deciding what a manual connection does with a password that succeeded, and the answer was already written on the screen it is typed into: nothing. There is no item to bind a credential to and none to bind it on, and that path saves nothing by design. So `ConnectionTarget` carries the row again — as a nullable, in place of the host id it had, with `HostId` derived from it. Two things read it and both are things that can only be done to a keychain item rather than to an address: naming the log entry, and keeping the password. Null is not missing data there; it is the whole of what makes the manual path different, and having one field rather than two keeps "was this a keychain host" a question with one answer. The desktop's rail lost SFTP and S3 to the tab strip on main, so the README's "a rail with nine slots has room" was true when it was written this afternoon and is not now. It says the room rather than the number. Phase 11's four new device checks and main's Phase 12 on teams were the same conflict twice — two appends to the end of one file — and both are kept. Verified after resolving: the solution builds, the Android head builds clean, and 837 tests pass across the seven client suites, including main's own additions (233 shell, 79 layout, 240 domain, 118 sync, 54 session, 74 terminal, 39 storage). |
||
|
|
f5ffd1983d |
Make Connections the place a connection is made, and put the keychain away
Four changes to the phone, and the last one needed the connect path taking apart. **The bottom bar is three entries.** The keychain moved onto the hub, which is now SETTINGS with a gear rather than MORE with a hamburger. A bottom bar is for the places a session moves between, and keys, credentials and tags are managed occasionally and then left alone — which is the shape of everything already behind that hub. With the keychain on it, "more" stopped being a description of what is there. `ShellScreen.Vault` joining `IsMoreSurface` is the whole of the change: the tab that lights, the header that stands down and the back gesture's first case all read that one property, which is why the switch mirrors it by construction rather than by a second list. The keychain screen grew the header every hub screen has, because the shell's own is not above it any more and without one there would be no back arrow and nothing saying what the list is. The desktop keeps its Keychain rail entry. A rail with nine slots has room, so this is the second thing the two heads arrange deliberately differently, after the hub itself. **Terminal became Connections**, and the word does more work than a rename usually does — see below. The enum member stays `ShellSurface.Terminal`, for the reason the tab was never called Vault: the surface is a terminal, and the word a user reads is the product's. **The + puts the software keyboard away.** It sits above a terminal somebody is typing into, so the sheet it raises was arriving underneath a keyboard covering the half of the screen the sheet is on — and worse, laid out into the strip left above it, since the keyboard's inset shortens everything this head draws. Avalonia cannot do this and it is worth knowing why: `TopLevel.InputPane` reports the keyboard and offers nothing that closes one, because the framework's model is that it belongs to whatever has focus — and this keyboard was raised by the `WebView`'s own text input, by a native view Avalonia's focus manager never owned. Clearing Avalonia's focus leaves it exactly where it is. So `Platform/SoftKeyboard.cs` asks `InputMethodManager`, off the decor view's window token, and every step of it is allowed to be absent. **With nothing open, Connections is a connect screen rather than an empty state.** A box taking `user@host` or `user@host:port`, a password, and the machines most recently connected to underneath. The box is the only path in this product to a machine the keychain has never heard of, which is a real case it had no answer for: an address somebody was handed five minutes ago. A typed password and nothing else — offering the keychain's keys would be a second binding resolution beside `TryBuildAuthentication`, and the argument against a second one is written there at length. Nothing typed is saved, and the screen says so: a machine worth keeping belongs on HOSTS, where it can carry a key, a group's defaults and a name. The recents come out of the vault's own connection log rather than a list kept in this process, so they survive a restart and arrive on a new phone with the keychain. Deduplicated by address, because this is a list of places and not of events, and capped at six so the box stays above the keyboard. Emptied when the vault is — they are decrypted entries naming where somebody works, and a lock that left them on screen would be a list still readable after every key that decrypted it was zeroed. Tapping one leads to whichever of two things it is: a keychain host goes to that host's connect bar, where its key, its password box and its refusals already live, and an address goes back into the box, without the password, whose absence is the point of that path rather than a gap in it. **The connect path was shaped like `HostRowViewModel` all the way down.** The log entry, the identification, the failure record and the retry all took a row. They take a four-field `ConnectionTarget` now, so a connection to an address shares the ladder of refusals, the host-key question and the tab's lifecycle rather than growing a second copy of them. `ConnectionRecorder.Record` and `Identify` have always taken a nullable host id, so the log could already hold a connection with no item behind it. One behavioural change falls out of that and it is the one to know about: **trusting a host key now retries the attempt that raised the question** instead of re-running whichever host is selected. That was correct while a selected host was the only way to connect; with a manual target it would dial a different machine, or refuse with "choose a host first" over a key the user has just agreed to trust. The test selects a host first, so a regression cannot pass by connecting to the wrong thing successfully. `LogsViewModel.ReloadAsync` split so the connections half can be read alone. Reading the keychain's activity for a screen that offers neither would double the decryption on the list that was already the expensive one. Twelve tests: the parse grammar as a theory over seven refusals, the dialled request, the retry, and both branches of tapping a recent row. The recents rows are built by hand rather than connected-and-closed — what those tests are about is which branch a row takes, and driving it through the recorder's queue would test the recorder, which `DodoSSH.Client.Session.Tests` already does. What needs a device is phases 11.6 to 11.9 of `docs/manual-checks.md`. |