ef12e8cc99151b6f7ade9f654a654420cce1b073
19
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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. |
||
|
|
b931a06998 | Repaint the phone's chrome, radii and accent to the v5 vocabulary | ||
|
|
575a9a9f5e |
Stop the relay checkbox promising a connection this client cannot make
Ticking "Connect through the server relay" moved the host's address and port out of the encrypted payload into plaintext columns on the server — the single deliberate privacy concession in the design, per ADR 0004 — and then the client dialled the address directly, exactly as it does with the box clear. VaultViewModel builds SshConnectionRequest(hostname, port, username, credential) and nothing on this side reads RelayEnabled at all. The connection failed the way it always had, for a machine the laptop could not reach, with nothing saying the box had done nothing. The server half is built and shipped: tickets, the WebSocket, the deny list, the CHECK constraint that enforces a non-null address for a relay-enabled host. What does not exist is the client's path to it, so this is an unfinished feature rather than a broken one — but the control in front of it was collecting the cost of the finished version. Both heads now say so, in the label and in the first sentence of the paragraph under it. Not disabled, and that is the one decision here worth stating: a host somebody has already ticked has to be able to lose the flag, and a control greyed out with the concession switched on would trap it there. Tickable and honest beats untickable and stuck. This is step 0 of docs/reaching-a-host-you-cannot-dial.md, and the only step of it that should ship alone — the sentence is written to be deleted when the bridge lands. VERIFIED. Build clean, 112 layout tests. The drawer's paragraph is longer than it was and the host editor is measured with the drawer open at the window's minimum, so the wrap is held inside the column rather than assumed to fit. |
||
|
|
f1d6499bb5 |
Merge branch 'main'
Two of main's changes land in files this branch rewrote, and both needed carrying across by hand rather than by the merge. The phone's nav staying up on Connections with nothing running is a fourth input to RefreshChrome, which this branch had already given two more — whether hosts are ticked and whether the host editor is filling the screen. They compose: the rail and the bottom bar now ask (pages || connectPage) && !editing, so a page-shaped terminal surface keeps its way off the screen and the editor still takes the whole display. The key question under the host's move panel is the harder one, because this branch deleted the panel it was added to. The connect card is gone and the phone's only route to a move is the action bar, so leaving the merge to take this side would have removed a capability main had just shipped — silently, since nothing would fail to build. It is asked in the action bar's own picker instead, in two shapes fewer than the desktop's: one host, because which key to carry is a fact about one machine and a selection of six has six answers, and a move rather than a copy, because taking the key out from under an original that is staying put would leave that original unable to connect. BindingOfTheMovingHost splits into MovableBindingOf so both heads answer it the same way from different panels. Main also fixed a real trap in the same commit — a host that only inherited its key from its group arrived in the destination naming nothing at all, because the group stays behind — and the batch move had the same bug for the same reason. It goes through Detached now, which is where that fix lives. The carried host is written as the carry left it rather than being detached again, which is the one thing worth measuring: the key takes a new id over there, so a run that rebuilt the payload from the row would send the machine across naming a tombstone. Both directions are pinned, along with the rule about which shapes the question is asked in at all. |
||
|
|
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. |
||
|
|
185790fb14 |
Let a key move to another vault, and ask whether it goes with the host
Keys sync and keys are shared: SshKey is in the sync registry on both sides, the material rides in the sealed payload, and every generation of the vault key is wrapped to a new member. What was missing was the way in. Hosts and groups could move between vaults and keychain items could not, so a key typed into a personal vault before the team existed stayed there for good — and moving a host into the team's vault left it authenticating with something nobody else in that vault can read. The code said so and could do nothing about it: "the answer is usually to put a copy of that key in the destination vault", which meant pasting the private half into a second item and deleting the first. A private key on a clipboard, and two items nobody can tell apart afterwards. MoveAsync already existed on the generic repository and is now exposed for keys and passwords as it is for hosts and groups. What had to be built around it is the re-aim. An item re-sealed under another vault's key lands with an id of that vault's making, so every host bound to the old one and every group lending it as a default is left naming a tombstone — and a host bound to something its vault no longer holds refuses to connect rather than falling back to a typed password. A move without the re-aim would look like a success and break every machine on that key. It runs over every vault this session can write to, because a binding resolves across all of them, and it counts what it could not rewrite: an item from a newer client, or one in a vault this account may only read. Those are said in the sentence afterwards rather than swallowed. The host's move asks the question rather than deciding it. A binding resolves across vaults, so the moved host goes on working for the person who moved it whichever way this is answered; it is the colleagues they have just joined who hold one vault's key and cannot connect with a host whose key stayed behind. Unticked, and it stays that way on purpose: moving a key into a team's vault hands it to everybody holding that key, and this design does not default anybody into a disclosure. Under the box is the count of everything else that authenticates with that key, because a key twenty machines use is a different decision from one nothing else touches, and neither number is visible from the panel otherwise. The question is answered against the vault in the picker, so choosing a different destination re-asks it and a key already in the destination offers nothing. One thing fixed on the way. A host that inherited its key from its group arrived in the destination naming nothing at all — the group belongs to the vault it left — so a machine that connected before the move refused after it, with no sentence anywhere saying why. The resolved binding is now written onto the host as it crosses, and the stranded-binding warning reads the resolved binding too, which is the case where somebody is least likely to know a key is involved. MOVE is on both heads, for keys and passwords only: a tag, a bucket and a pin are read from the active vault alone, so "another vault" is not a question any of them has. Four tests cover the move and its re-aim, the host's move with the key brought and without it, and the inherited binding. |
||
|
|
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. |
||
|
|
0258ec3029 |
Merge branch 'claude/groups-vault-sharing-e4b154'
# Conflicts: # src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs |
||
|
|
c39df3f51e |
Share the shelf as well as what is on it, and ask a group which vault
A group is where hosts are filed and what lends them a port, a username and a key, and until now it could only ever be made in the vault this machine files new items into. So sharing a vault shared the machines and not the arrangement: a colleague opened four hosts filed under a group they could read the name of and nothing else, and the group a teammate made had no card, no heading and no way to be corrected from the screen looking straight at the hosts inside it. Recorded as half shipped in docs/design-import-gaps.md, and this is the other half. The list stopped being the active vault's. It was narrow for two stated reasons — a row shown across vaults has to carry which vault it lives in, because rename and delete both need it, and two vaults may hold a "production" each, which a layout with one heading per group cannot tell apart — and both are now paid for rather than avoided. Every row carries its vault, the badge beside the name says which, and the two cards sit side by side saying what they are. The three shapes of the group read are now deliberately different sizes. The list is what a person looks at, so a hidden vault's groups leave it: a card that cannot be opened onto anything is worse than no card. The per-vault lists are what a picker offers, because a picker is always asking about one vault. The map is what a host's GroupId resolves through, and it stays widest of all — including over hidden vaults, since a group lends a port and hiding a vault must never change what one of its hosts dials. RebuildGroups is the one place hiding is applied, which is what keeps those answers apart. The editor asks which vault on the terms the host editor's picker set: while adding only, hidden where there is one writable vault, and never offered afterwards, because the two are encrypted under different keys and moving an item is a delete and a retype. Its parent picker is that vault's alone, for the reason the host editor's group picker is one level down — a parent in another vault is a level half the key holders cannot resolve, and their hosts would inherit from nothing. + NEW GROUP inside an open group departs from NewHost and takes that group's vault rather than the standing preference: a group made inside another is in its parent's vault by construction, and answering "inside PLATFORM" with a group elsewhere and no parent would drop the one thing the button said. Two smaller things follow from the cards spanning vaults. Dragging a host onto a group card in another vault is refused with both names, because the write it would make is exactly the id-nobody-can-resolve the host editor's picker was fixed to prevent, and treating it as "no group" would unfile a host somebody was plainly filing. And a group being renamed says its vault in the drawer's header, since the picker is not drawn for an existing one and renaming a colleague's shelf without being told whose it is is the edit most worth naming. The save target is a nullable field behind a property that falls back to the standing preference. The group name box is bound whether or not anything raised an editor over it — that is what the desktop's group bar was, and typing a name into it and pressing ADD is still a way to make a group, which would otherwise have written to no vault at all. 1575 tests pass, five more than before: a group filed into a shared vault is listed and renamed there, the editor's picker does not move the keychain screen's, the parent picker offers only its own vault, a cross-vault drop is refused, and hiding a vault takes the cards without changing what its hosts dial. |
||
|
|
bee6202949 |
Let a host be moved to another vault
The one thing the host editor's vault picker has always been unable to offer, and the comment beside it said so: an existing host's vault was not a field because the two vaults are encrypted under different keys. That is still true. What changed is that it is no longer a reason to have nothing. **A move is a copy and a tombstone, and it cannot be anything else.** A payload is sealed under its vault's key and its AAD binds the vault, the entity id and the item version, so no edit moves one and no server call could — the server holds ciphertext it cannot read. What crosses is the plaintext, in this process, between an unwrap under one key and a seal under another. VaultItemRepository gained MoveAsync for it, so the three decisions below live in one place with their reasons rather than being re-derived at each call site. The item takes a new id. Keeping it would put one entity id in two vaults, and the item table is keyed on the type and the id rather than on the vault — so the destination's row and the source's tombstone would be the same row, and the move would delete what it had just written. The write comes first and the tombstone second, which decides what an interruption leaves: a copy in both vaults, visible and deletable, rather than a tombstone with nothing on the other side. Both are queued rather than sent, so the window is a crash between two local writes; it is still worth being on the survivable side of. Two activity lines rather than one, because that is what the two vaults actually record. A single "moved" line would have to be written to one of them and would be missing from the other's history. **The group and the tags stay behind, and that is the half that makes this honest.** Both are items of the vault the host is leaving: the editor's group picker offers one vault's groups and the chips are drawn from one vault's tags. A host carrying either across would resolve it on the machine that moved it — groups and tags are resolved over every readable vault — and dangle for everybody else in the destination. The mover and their colleagues would be looking at two different hosts. Cleared and reported beats carried and invisible. The key or password binding is kept, and the difference is not inconsistency. Those genuinely resolve across vaults — one key on twenty hosts in three vaults is the arrangement they exist for — so clearing them would take a working host and make one that cannot connect. What the message does instead is name a binding that is now outside the destination, because that is precisely what the other members of it will not be able to resolve. **It is not in the editor**, on either head: the desktop puts it in the detail pane's ⋯ menu above the separator Delete sits below, and the phone beside EDIT. A picker inside the form would move a machine as a side effect of correcting a port, which is the bug the editor's own vault picker was fenced off to prevent in the first place. The panel takes the footer as the deletion question does, and says what will be left behind before the tap rather than after it — on a phone, where the status line afterwards is one line on a screen somebody has already navigated away from, that is the only place it reliably gets read. The phone hides the button where there is nowhere to go rather than offering one that answers with a refusal; the desktop keeps its menu entry either way, because a menu that grew and shrank would be a menu whose items move. One thing found while writing the test and deliberately not changed. The pass that follows every write on this screen reports what it moved and supersedes the confirmation — for a save and a delete as much as for a move — so the move's own sentence is what somebody sees offline. The test asserts it in that state and says why. Making confirmations survive their own sync pass is a question about the whole screen rather than about this. Four places said an item could never be moved, two of them sentences on screen in both heads. All four now say what is true, including the design gaps document, where the chevron beside the vault name stays undrawn for a different reason: a chevron on a subtitle implies an edit, and this is a re-seal, a new id and two references left behind. |
||
|
|
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.
|
||
|
|
b86ff6158b |
Keep the password that just worked, so the host stops asking for it
The vault has held credentials and host bindings since they landed, and the only route from a typed password into one ran through two screens: add a password under Keychain, open the host, bind it. The password box's own tooltip instructed people to do exactly that by hand — which means typing the secret a second time, into a screen that has no idea which host it is for, while the screen that does have the password is the one being left behind. A tick beside the box does it in one step. What it produces is an ordinary CredentialSecret, named after the host, bound through CredentialId with AsksForPassword cleared beside it. That is the whole reason nothing else here had to change: it syncs, it merges, it appears in the keychain, it can be renamed and deleted, and it can be bound to the other nineteen machines that share the account. A password field on HostSecret would have been a payload schema bump, a fourth place a secret lives, and a copy per host to rotate and forget. It waits for a handshake the remote accepted, and that is not caution for its own sake. Binding on the keystroke would store whatever was in the box — including the typo about to be refused — and the host would then stop asking, leaving a machine nobody can connect to until they work out that the keychain is where the wrong password now lives. For the same reason the password is read from the credential as dialled rather than from the box, which stays typeable throughout a handshake. Off by default, because the typed box exists precisely for the passwords that should not be in a synchronised vault — a one-off on a machine somebody will never open again. The credential is written into the host's own vault rather than the active one: in the personal vault, bound to a team's host, it would be a binding every other member can see and none of them can resolve. One thing is given up knowingly. The confirmation naming where the password went is replaced a moment later by the auto-sync's own count, exactly as SaveHostAsync's "Saved 'x'" is; this follows that rather than becoming the one write that suppresses a sync line. The feedback that lasts is the row answering "credential" and the box disappearing — which is also the only way to store a password on the phone at all, since that head lists credentials but has never had an editor to create one in. |
||
|
|
35387b1c9d |
Tell the phone's keyboard these are secrets, and get it off the box
Five boxes on this head take a secret and every one of them was drawing dots and saying nothing. `PasswordChar` is a screen property: Windows has no opinion about what is being typed into a text box, so the desktop head needs nothing more. Android's software keyboard has an opinion, and left at its default it read a vault passphrase as prose — completions offered in the suggestion strip above the box, and the passphrase itself learned into the IME's dictionary. Dots on screen with a word bar over them is the worst of both: hidden from the person typing it and offered to the room. `TextInputOptions.ContentType` is the property the Android backend maps onto `InputType`, and it is what turns both off. Both attributes now live in one `TextBox.secret` class rather than being repeated per box, because they are two halves of one fact and the next box added would have got one of them. The keyboard also went on covering whichever box had raised it. That is in `PhoneShell` rather than on each screen: everything the phone draws is inside its one root panel, so a bottom margin shortens all eleven screens at once, and a screen added later cannot forget to handle something it never had to know about. Two mechanisms, and it matters that neither is a backstop for the other. Before Android 15 the activity now declares `AdjustResize` and the platform shortens the window itself; left unspecified Android chooses, and what it chooses for a window whose entire content is one native view — which is what an Avalonia surface is — is to pan, sliding the window by however much it thinks the focused native view needs and leaving the box exactly where it was. That was the bug. From Android 15 the attribute is ignored, edge-to-edge being enforced and the window no longer resized for the keyboard at all, and the reported inset is what there is. Each is dead where the other applies — where the window resizes, the inset arrives already consumed and measures zero — which is why the margin comes from the inset alone. Both added together would strand the interface an entire keyboard above the keyboard. Scrolling the box back into view keys off the size change rather than off either mechanism. `ScrollViewer` already brings a newly focused child into view; what it cannot know is that the visible region shrank after the focus, and both ways of losing that region end in the same resize. None of it is reachable by a test. The software keyboard is an inset the platform reports and a headless top level reports none, so phase 10 of `docs/manual-checks.md` is the whole of the verification — including the note to run it on one device each side of Android 15, since a build exercised on only one of the two will look correct and be half broken. |
||
|
|
16e0051e89 |
Draw the tags that have been storable and invisible since the domain landed
`Tag` has been a full item kind for three commits — a table, a migration, a codec, a merge, a cipher — and `HostSecret.TagIds` has merged per tag so two people tagging one host both keep theirs. Nothing drew a chip. The tags a client could store were ones nothing here could see. Chips on host rows, both heads, from names resolved through the tag list rather than ids: a tag that does not resolve is left out rather than drawn, because it means the tag was deleted elsewhere or belongs to a vault this session cannot read, and a host with one chip fewer is the honest answer where a host wearing a GUID is not. The id stays on the host, so the chip comes back if the tag does. The picker is chips that toggle, matching the chips on the row behind it. A list of names to tick would make the user match an entry to a chip they can see two inches away. The box under it creates a tag and puts it on straight away, because that is when a tag is usually wanted — while tagging a host and finding it does not exist yet. Unlike every other field in that editor it writes to the keychain immediately, since a host can only name an id that exists; cancelling therefore leaves the tag behind, which is honest rather than hidden. A name that already exists is used rather than repeated: two tags called "staging" are storable and must stay storable, because two people creating one offline is how it happens, but typing it into a box beside a chip of the same name is a slip. Renaming and deleting needed a home, or the picker fills with names nobody uses and never empties. That home is a TAGS category on the keychain screen, where every other item kind is managed — and renaming is the whole reason a tag is an item rather than a string repeated inside twenty payloads: it is one write, and no host is touched. The delete confirmation counts the hosts wearing it, which is the difference between a tidy-up and losing a filter somebody relies on. The desktop host editor now scrolls, and that is not a tidy-up. A picker's height is a chip per tag in the keychain, wrapped, so somebody with fifteen tags has an editor half again as tall as somebody with three; no fixed height holds that, and trimming other fields to buy room only moves the failure to whoever has sixteen. The layout suite caught it the moment its seeder grew tags — which is why the seeder now creates ten rather than three, enough to drive the pane onto its cap so the capped shape is what gets measured rather than one no real keychain produces. The cost is named where it is paid: the harness skips anything inside a ScrollViewer, so from here it certifies that pane fits the column rather than that every field in it does. Two smaller things fell out. Five buttons overflowed the keychain header by a few pixels, so GENERATE lost the word KEY — its tooltip carries what the word did. And TotalItemCount had been counting keys and credentials while ALL showed four kinds; it counts all five now, because a number under a chip that disagrees with the rows it opens is worse than no number. An adversarial review of this change found two defects it had introduced, both green against the full suite. NewTag filed into the "new items go to" picker while the tag list only ever holds the active vault's — so with a team vault selected a tag would be created, queued for push, reported as added, and then invisible, with no row, no count, no picker entry and nothing able to rename or delete it, because there is no active-vault switcher to go and find it with. The comment on the host editor's own create path states that exact rule; this was the one place that broke it, and NewObjectStore, whose list is likewise active-vault-only, already ignored the picker. And the tag editor was the only one of five that did not disarm a pending deletion when it opened, so arming a key's deletion and then pressing + TAG left a live DELETE for an item the user was no longer looking at, directly above the boxes they were typing into. Both are fixed, both have a test, and the first was checked against the broken version before being kept. The same review caught a doc comment that had been inserted between SnippetRowViewModel's summary and its declaration, silently taking it over. Verified by the whole suite on a clean build: 1413 tests over nineteen projects, none failing. Both heads build. The rectangles the layout suite cannot reach are phase 9 of docs/manual-checks.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4925dcf179 |
Give the phone the + it was promised, and the editors behind it
Steps 5 and 6 of docs/adding-hosts-on-the-phone.md, which finishes it. The phone can now put things in a keychain rather than only read one. The floating + arrives three designs after it was first asked for. It was refused twice on the honest grounds that it would open an editor this head had not got — and that stopped being true when steps 1 to 4 landed. It raises a sheet rather than a form, because "add" on this screen has been two operations since groups existed: a host, or a group to file hosts under. Both editors are cards in the list's own row, swapped for the list rather than stacked over it, following SnippetsScreen and for the reason written there: a form on top of the list hides what it is about. There is still no dialog and no editor screen anywhere on this head. The port and username boxes may be left empty, and the greyed text inside each is what the host will actually use — following the group picker as it moves, so the form says what leaving a box blank will do rather than making it a guess. The back gesture gains a guard above the switch rather than a case inside it. An editor is not a screen and has no entry there, and it is strictly nearer: the sheet sits over the editor's own screen, so back lowers whatever is topmost before it considers moving between screens. A group editor needs a way in, and the phone draws no groups panel — so the heading in the host list gains a pencil. A + that adds groups with no way to correct one is the same strange thing to ship as one that adds hosts with no way to correct one, and worse: a group's defaults are what every host beneath it falls back to. Scouting step 6 turned up a bug worth more than the prose it was looking for. ReloadGroupsAsync read the active vault alone, which was a cosmetic limitation while a group carried only a name — a teammate's host showed under UNGROUPED and nothing else was lost. Since a group began lending a port, a username and a binding, the same omission silently dropped all three: that host would dial 22 as nobody while the machine is on 2222 as deploy, with nothing on screen saying why. The resolution map now spans every readable vault. The editable list stays narrow, because a row shown across vaults needs a vault id for rename and delete and two vaults may hold groups with one name — but the map needs none of that, since it is only ever asked what an id says. An adversarial review of this change found something I had written a comment denying. The + was a child of the outer Panel rather than of the list's row, so it anchored to the bottom of the screen and sat squarely over the new EDIT button and the lower edge of CONNECT — and because a Panel hit-tests its last-declared child first, tapping the right end of EDIT would have raised the add sheet. The comment beside it claimed it was in the list's row precisely so that could not happen, and manual-checks 8.1 names that exact failure. It is in Grid.Row 2 now. The same pass caught a connect bar that was disabled rather than hidden while its own comment said hidden, a scrim that would have flashed its default pressed chrome across the screen, a fab comment claiming an accent fill no other button had when Button.primary has it, and a back-gesture remark naming two view-model properties that do not exist anywhere in the repo. That last one came from this plan, which invented IsVaultsSurface and IsConnectionsSurface, and pointed step 6 at a ConnectionsScreen.axaml that has never existed. Both are corrected in the plan rather than quietly worked around, along with a fourth "groups are flat" site in README.md that step 1 missed. The phone's rectangles are not measurable and structurally never will be: the layout suite is net10.0, the Android head is net10.0-android, and Avalonia's application is a one-shot process global — so a second head cannot share the process even if the reference were possible. Everything the sheet and both editors bind to is shared and is tested headlessly here; the pixels go to docs/manual-checks.md phase 8, nine checks, which is where this project already sends what it cannot assert. Tags remain the one thing asked for that did not ship. Tag is a full item kind and TagIds merges, encodes and resolves, but no screen draws a chip or offers to add one, so the tags a client can store are ones nothing here can see. Both editors carry the set through a save untouched so a client that can set them does not lose them. Recorded as half-shipped in docs/design-import-gaps.md rather than quietly left out. Verified by the whole suite: 1402 tests over nineteen projects, none failing. Both heads build. Nothing seen on a display — phase 8 is what that costs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5593f337b6 |
Give the phone the second design, and both heads the palette it arrives with
The Android v2 design is what this head draws now: four destinations in a bottom bar — Hosts, Terminal, Keychain, More — with snippets, SFTP, S3, logs and preferences one tap deeper behind the last. The first design's four had nothing behind them, which is what made a hub worth building. The palette moved from green-black to blue-black, and it moved in the shared project because that is where it lives and the desktop v2 specifies the same seventeen tokens. One colour changed meaning rather than value, and it is the only semantic change in the file. Green used to *be* the accent, so Ellipse.dot.live filled with Accent and "the thing to press" and "a shell is open on this host" were the same colour by construction. v2 makes the accent blue and keeps a green for status alone, which finally separates them: Live is that green and nothing merely interactive may use it. The accent is also two colours now — Accent fills, AccentText writes — because a row of chips in the fill colour is a row of things that all look like the primary action. A palette is not one file, which is the part worth knowing before the next one. Nine hex literals lived outside it: the nav bar's own label colours, the accessory keys and their Ctrl-latched state, two scrims, the window background Android paints before Avalonia has a frame, and the launcher vector. The two C# sites now resolve from the dictionary by name rather than restating it. The renderer's page cannot — it is served to a WebView over a loopback socket — so terminal.css and terminal.js keep hand-copied values and say so at both sites. ShellScreen gained More and Buckets, appended rather than slotted in. SFTP and S3 are one screen over one TransfersViewModel differing only in which picker they offer, and the kind is set by the button that navigates rather than on arrival — doing it in OnScreenChanged made every arrival at Transfers force the picker back to hosts, including the desktop's own rail arriving at a screen with a bucket already open. It refuses to change kind while a session is live, because there is one session behind both destinations and switching under it would title a screen S3 while it listed an SFTP host. What the design draws and this does not, on the usual grounds. The FORWARDING screen: nothing here forwards anything, so every toggle would be a control with no effect — it is a paragraph on the hub naming the absence, for the reason the desktop keeps TEAMS in its rail. The terminal's `23 ms · fwd 5432`. An ED25519 badge and a SHA256 line on keychain cards, which need an algorithm field and a fingerprint the item type does not have. An `agent` chip, for an agent that does not exist. Snippet run history and exit codes. The Logs FOLLOW pill, which claims a live tail over records that are written once at close and read when the screen opens, and the severity filter, which has nothing to count — that chip row is spent on the real choice, which of the two logs. S3 bucket totals and lifecycle. And the + on HOSTS, which would open a host editor this head has not got. SFTP is browse, open and delete. Both transfer commands work, and what they work against is the local pane: QueueDownloads writes to Path.Combine(LocalPath, name), and LocalPath starts at SpecialFolder.UserProfile, which on Android is the application's own private directory. A download would have reported success and left the file where the person who asked for it cannot open it, which is worse than not offering it — a refusal is visible and a file in /data/user/0/ is not. The queue is not drawn either, since nothing here can put anything in it. Both return with the document picker. The foreground service still counts zero transfers, and the reason moved rather than went away. Four defects worth naming, because three of them are the kind that compile. A Button as a ListBox ItemTemplate swallows the pointer press before the list sees it, so the files listing selected nothing and every command reading the selection did nothing — the row is a Border now and the phone-only single-tap-to-open is a Tapped handler, which also keeps a desktop single click from walking into directories. Avalonia type selectors are exact, so TextBlock.fingerprint never matched SelectableTextBlock and every fingerprint on this head rendered proportional and unwrapped: that was breaking the never-truncated rule on the host-key sheet already. The new two-level hierarchy had no handler for the system back gesture, so back left the application from a log screen. And the tab's close cross had shrunk to a 30x32 target flush against the select target, which is the one control here that ends a shell with no confirmation and no undo. Fingerprint unlock is raised on arriving at the lock screen rather than waiting for its button, which is still there. Only at launch: a lock the user asked for is not answered with an immediate request to unlock, which makes LOCK look inert and trains the reflex of authenticating at a prompt nobody asked for. And once, because a declined gesture leaves the passphrase box exactly where it was and a prompt that came back after being dismissed would be a modal you cannot get out of to type into it. Two fixes fall on the desktop. Its file listing coloured directories with Info and executables with Accent, which was blue against green and is now two steps of one blue; an executable is Live now. And a bucket's folders were drawn with a 0001-01-01 timestamp, because a prefix has no modification time — blank now, for the reason a directory's size is blank. Verified by the whole suite: 1309 tests over nineteen projects, none failing, including the layout suite that stands up real Avalonia and parses every desktop screen. Both heads build. Not verified on a device — nothing in this head ever has been; see docs/android-port.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AZE3u99BNt6LzgTC5jhbz2 |
||
|
|
7a3a521c59 |
Give the phone the rest of its screens, and a way in
All seven screens of the design, plus the two it does not draw because it starts at an enrolled phone: naming a server, and choosing a passphrase. The five states docs/android-port.md worried about losing at 360dp are all here and none of them softened. The changed-key refusal is a full-screen panel rather than a bottom sheet, because a sheet is swipe-to-dismiss by convention and that screen must have no way forward. The recovery code raises FLAG_SECURE for its own state and lowers it afterwards, so the sentence about screenshots is true rather than decorative. The delete confirmations keep their counts and replace the row in place. Signing in works, and the seam it needed is worth more than the implementation: IAuthorizationCallback now sits between OidcClient and the loopback listener, so the two heads differ in where the response arrives and in nothing else. PKCE, the state check, discovery, the token exchange and the key binding stay one implementation — a second OIDC client would be a second place for a security bug to live. The phone registers a private-use scheme with the system rather than binding a loopback port, which on a shared device any other app can do first. The accessory key row needed TerminalWorkspace.SendInputAsync: ordinary typing goes from the renderer straight down the socket, and there was no way in for the keys a software keyboard does not have. Ctrl latches, because one thumb cannot chord, and the latch is drawn — a modifier that is on and does not look on is how somebody sends ^L to a database prompt believing they typed an l. 597 client tests green, including two new ones for the input path and one for the terminal surface command. Nothing has run on a device. |