e923b12b7f30ae27b77cdb63b5376bf7ea6b2934
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e923b12b7f | Merge branch 'claude/desktop-auto-updater-0264a3' | ||
|
|
0b6059d4a8 |
Stop a late progress report from putting the download bar back
`Progress<T>` does not invoke its callback inline — it posts to the captured synchronisation context — so a report can be delivered after the download it belongs to has already returned. The unguarded handler then wrote a stale smaller number over the 100 set on completion, and nothing reports again, so it stayed there: a finished download showing 50% next to a banner offering the restart. Caught by running the suite a second time on another checkout, which is the only reason it was caught at all. The assertion that failed is the one that says a found update is fetched without being asked about, and it had passed on every previous run — the ordering it depends on is real and simply usually goes the other way. Guarded rather than made synchronous. The posting is wanted: in the application these callbacks arrive on whichever thread Velopack downloads on, and setting an observable property off the UI thread is a binding exception rather than a stale number. Monotonic is what the bar should have been anyway, since progress that goes backwards is not progress. Ran five times over to confirm it is settled rather than merely rearranged. |
||
|
|
f37d7ee12e | Merge branch 'claude/desktop-auto-updater-0264a3' | ||
|
|
3ead865f01 |
Merge branch 'main' into the desktop updater, and give way on two numbers
Main landed a realtime push feature while this branch was building the updater, and the two collided in three places. Every one of them resolves the same way: main got there first, so this branch moves. **Two ADRs were both numbered 0012.** Main's is realtime push; this one is now [ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md). Git did not call this a conflict — the filenames differ — so it would have merged quietly and left the directory with two 0012s and every cross-reference ambiguous. Renumbered here along with the nine places that point at it. **Two manual-check phases were both numbered 15**, and that one git did catch. Main's "Changes that arrive without a timer" keeps 15; installing and updating the desktop client becomes Phase 16, with its checks and every reference to them renumbered. The file's own rule is that a number is for life, which is exactly why the one that had not been pushed is the one that gives way. **The merge rewrote several files with CRLF**, and `.editorconfig` asks for LF on everything except `*.ps1`. That is not cosmetic here: IDE0055 is an error and `EnforceCodeStyleInBuild` is on, so it failed the build on three lines of App.axaml.cs whose only change in this branch was an ADR number in a comment. Forty-six files normalised back to LF; the release script keeps CRLF, which is what `.gitattributes` and `.editorconfig` both already say for a PowerShell file. Nothing else conflicted. The updater does not touch the sync loop or the event stream, and the one file both sides edited heavily — MainWindowViewModel — merged without a hunk in common. Verified after merging: the solution restores locked and builds clean, and 304 shell, 100 layout, 54 session, 28 client-api and 25 contracts tests pass. The first two counts are higher than before the merge because main's own tests came with it and pass alongside these. |
||
|
|
5a8731ca8c | Merge branch 'claude/group-vault-migration-deletion-4af5f4' | ||
|
|
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. |
||
|
|
6728a0a597 |
Let the desktop client replace itself, and give the repository one version
Packaging for Windows, and the updater that only exists once something is
packaged. Velopack, win-x64, fed from the project's own forge — never from the
deployment a client signs in to, which is ADR 0011 rule 2 carried over
unchanged and is why the feed address is a constant in the code rather than a
setting. See docs/adr/0012-desktop-distribution-and-updates.md.
**Nothing is ever installed while somebody is using it.** A newer build is found
on a six-hourly pass, downloaded in the background, and then waits — for a
restart the user presses, or for the next launch they were going to do anyway.
That is a policy rather than caution: this application argues at length that
locking keeps shells running, because a lock that destroyed work would stop
being used, and a restart does not keep them. Having taught that, it owes the
user the choice at the one moment it stops being true, and the sentence saying
so counts the shells it would close.
**The version is now derived from the v* tag**, by MinVer, for everything. There
was no version before this — no property anywhere, so every assembly reported
the SDK's 1.0.0 and the API served that string as its serverVersion to every
client that asked. The tag was already the version of record for the container
image; this makes it the version of record full stop. MinVer's failure mode is
answering plausibly rather than failing, and here a wrong version is a client
that never updates, so it is guarded twice: fetch-depth 0 on every checkout, and
a step that fails a tag build when the tag and the computed version disagree.
**The pack id is DodoSSH.Desktop and not DodoSSH**, which is the one decision
here that would have destroyed data. Velopack installs to %LOCALAPPDATA%\<packId>
and removes that whole directory on uninstall, and %LOCALAPPDATA%\DodoSSH is
where ClientPaths keeps the encrypted cache, the outbox of changes not yet
pushed, and the device key. The obvious id would have had the uninstaller
silently delete work the server has never seen — the thing the application
refuses to do without a counted confirmation. Velopack's own advice to move user
data to roaming %APPDATA% is declined for the reason ClientPaths already gives.
**Releases are cut by a person, and CI gains no job that could.** The tempting
argument is that a forge write token is not a signing key. It does not survive
contact with what the token does: Velopack clients trust their feed and do not
verify a package signature when they apply one, so whoever can write a release
can ship an update every install runs. That is the capability ADR 0011 rule 1
puts on a machine which is not a runner, reached through a different door. The
mechanical objection — vpk needs Windows and the runners are Linux — is the
smaller of the two and is recorded beside it, because somebody will fix one and
believe they are done.
Unsigned for now, deliberately and with the cost stated where a user reads it:
SmartScreen warns once per person, on Setup.exe, because Mark-of-the-Web is
applied by the browser that downloaded it. In-app updates are fetched by the
application and applied from a local file, and never trip it.
The banner is a fourth row of the window rather than an overlay. Anything drawn
in the terminal's rectangle is sliced by the native child window that composites
above it — the defect this window has shipped once — and a sibling row is the
arrangement TitleBar and StatusBar already prove works.
----
Three defects surfaced on the way, none of them in the feature being built.
**A settings key absent from the file came back as the CLR default, not the
declared one.** The JSON source generator builds a record through a synthesised
parameterised constructor and assigns every property from its argument array, so
a property initializer runs and is then overwritten by a default for anything the
file did not contain. A settings.json of {} read back a font size of 0, clamped
up to the 8px floor rather than the 13px the renderer draws at. It could not bite
while there was one setting, because that setting was written on every save and
so was never absent; adding a second would have turned automatic update checks
off for every existing profile, silently, the opposite of the documented default.
Reflection-based deserialisation of the same JSON answers correctly, which is why
every way of checking it by hand agrees except the one that ships. The defaults
now live on the constructor parameters, which is the only place the generator
reads them from.
**Declaring a RuntimeIdentifier on the desktop head broke the server's image
build.** It is the obvious way to let a self-contained publish restore under
locked mode, and it writes a net10.0/win-x64 target into the lock file of every
project the head references transitively — including DodoSSH.Contracts and
DodoSSH.Crypto, which the API builds too. The Dockerfile restores those with no
RID and fails NU1004. Found by running docker build rather than by reading. The
RID stays out of the committed state; the two commands that need one ask for it
unlocked, and the release script puts the lock files back.
**A Docker ARG named VERSION silently sets MSBuild's Version.** An ARG is an
environment variable for the rest of the stage, MSBuild reads environment
variables as properties, and property names are case-insensitive. With the
workflow passing main-<short sha> on a main build the publish died with
NETSDK1018 pointing at DodoSSH.Contracts, a project nobody had touched. The build
stage's argument is ASSEMBLY_VERSION now, empty except on a tag build.
All three are in docs/platform-flags.md, which is where the next person will look.
----
Verified: the whole solution builds and restores locked; 289 shell, 93 layout and
54 session tests pass, including the regression test for the settings defect and
a measurement of the banner at the window's minimum width. vpk pack runs end to
end and reports "Verified VelopackApp.Run()" against Program.Main. The API image
builds correctly both as a main build and as a tag build, carrying 1.0.0 and
0.1.0 respectively.
Not verified, and it needs a published release to be: installing, updating and
uninstalling on a real machine. That is Phase 15 of docs/manual-checks.md, and
the pack id and the WebView2 profile fix are reasoned and commented but only
proved by walking it. Two things to watch at the first upload — the reverse
proxy's body-size limit for a 64 MB asset, and whether vpk upload gitea is happy
with Gitea 1.27.1.
|
||
|
|
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. |
||
|
|
6fad84c484 | Merge branch 'claude/remove-group-edit-delete-buttons-3d38ec' | ||
|
|
d02d103569 |
Take the group's EDIT and DELETE off the heading row
A pair of buttons sat at the end of the GROUPS heading, and the card's own right-click menu arrived later offering the same two things. Two controls for one job, and the buttons were the harder of the two to read: a button beside a heading has no card under a pointer to mean, so it had to work its subject out — the selected card, or failing that the group the trail ends with, which once a group is open is not a card on screen at all. The menu never has that problem, because opening it is what aims it. The menu is the whole of Edit and Delete on the desktop now. ShowsGroupActions went with the buttons, since hiding them was all it did. GroupTarget stays: the menu's two entries read it after the code-behind has selected whatever was right-clicked, and its fallback to the open group is what makes + NEW HOST open on the group somebody is standing in rather than on none. One case changes shape. Opening a group with nothing inside it folds the card grid away, so from in there nothing can be right-clicked — renaming that group means pressing the trail back one level, to where it has a card of its own. The buttons used to cover it through the fallback. The trail was already the way out of an empty group, and 3.2a says so now. The test that pressed EDIT through its binding is replaced by one holding that no button on the screen commands either of them, which is the failure worth catching: a button coming back is not a compile error, and it would draw itself in place, aimed at the group the trail ends with. What that test covered — a command that has to accept an empty parameter, and act on the card the pointer was on — the two menu tests beside it already do. |
||
|
|
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. |
||
|
|
742f65c204 | Merge branch 'claude/card-selection-state-sharing-f9348c' | ||
|
|
0258ec3029 |
Merge branch 'claude/groups-vault-sharing-e4b154'
# Conflicts: # src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs |
||
|
|
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. |
||
|
|
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. |
||
|
|
be012585b3 |
Let a group be right-clicked, as a host already can
The host cards have had a menu since the grid replaced the sidebar; the group cards above them had a double-click and two buttons beside the heading, and nothing that named the card under the pointer. Open, Edit and Delete are on them now, drawn and aimed the same way. On the list rather than in the item template, for the reason the host grid's is: the three commands are the vault's, and a ContextMenu inside a DataTemplate has the row for its data context, so every binding in it would silently resolve to nothing. The code-behind selects whatever was right-clicked before the menu opens, and that is what makes one menu act on the card under the pointer rather than on whichever was selected before. **Cancelled over the space around the cards, and here that guard is doing more than the host grid's.** GroupTarget falls back to the group whose contents are on screen when no card is selected — the right answer for a pair of buttons beside the heading, which would otherwise have no subject the moment a group with nothing inside it is opened, and the wrong one for a menu that opened on a card. Without the guard, right-clicking the gap beside the cards would offer to delete the group the trail ends with: a question about something the user is not pointing at, in the one menu where the answer is a deletion. Only Open takes a parameter, and it has to. OpenGroupCommand's null is a real argument rather than a missing one — it is the trail's first crumb, ALL HOSTS — so an entry with no parameter would not open the card, it would leave the group the user right-clicked and go back to the top level. Two tests beside the two the host menu already had. What they hold that a build cannot is the CommandParameter binding: a path that resolves to nothing compiles and draws, and the entry would then quietly do the opposite of what it says. The popup itself is still the platform's, so manual-checks 7.9 gained the group half of the same check. |
||
|
|
7f5b871c47 | Merge remote-tracking branch 'origin/main' into claude/vault-share-s3-delete-a84c35 | ||
|
|
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. |
||
|
|
176df67861 | Merge branch 'claude/vault-share-s3-delete-a84c35' | ||
|
|
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.
|
||
|
|
6f960e5674 | Merge branch 'claude/main-page-group-hierarchy-3a3210' | ||
|
|
a0568d4c35 |
Merge branch 'main' into the vaults screen, and let it rotate keys too
Main built vault key rotation while this branch was reshaping the screen that would drive it, so the two met in the same three files. Every other conflict was textual and resolved by taking both; these are the ones where a decision had to be made. **The view model.** Main taught TeamsViewModel three things and this branch had renamed and rewritten it into VaultsViewModel. All three are ported rather than dropped, because each is a behaviour rather than wording: adding somebody now wraps the vault to them on the spot instead of leaving SHARE KEY to be pressed, removing somebody rotates the vault and hands the new key to whoever is left, and a share reports how many generations were wrapped. The session calls they reach — ShareTeamVaultsAsync and RekeyTeamVaultsAsync — are scoped to a membership list rather than to one vault, and they are called that way here rather than narrowed: adding somebody is a change to the list, so every vault the list carries is one they can now fetch. This screen makes lists that carry one vault, so the sentences name one; where a list carries several, naming them all is the honest report, and the members section already says the list is shared. AddMemberAsync ran two lines over the length limit once the sharing was in it, so the calls behind it moved to AddOrInviteAsync and the three-way refusal to WhyNobodyCanBeAdded — the command reads as its guards now, which is what it was before the sharing arrived. **The tests.** Main's four new cases are ported to the vault-first API, including the one that matters most: the tampered key log is corrupted *before* the add, because the add is now a route to a wrap and a test that corrupted it afterwards would be asserting about the manual route only. SelectingAVault_ListsWhoHoldsAKey now expects two holders rather than one — main's fake records the creator's own self-grant, and a key-holder list that omitted it would show the one person who can certainly open a new vault as somebody who cannot. **The README.** The limits list is six rather than four or five: main's rotation entries and this branch's "a vault cannot be deleted" describe different things and both are true. "The rekey is flagged, never performed" is gone, since it is now performed, and M3 reads *Done* rather than *Done, except rekey*. One thing worth writing down that neither side had. An invitation claimed at sign-in still leaves the key owed, where an add does not: at the moment an invitation is issued there is no account and no published key to wrap to, and the claim happens on the invitee's machine, which holds nothing. Manual check 12.1 says so, because a reader who knows adding shares would otherwise read that step as stale. 1561 tests pass. |
||
|
|
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.
|
||
|
|
8d2f4c8ffb | Merge branch 'claude/main-page-group-hierarchy-3a3210' | ||
|
|
6ae1912c34 |
Give the two logs and the buckets a resource type, so a conflict can be written
AadResourceTypes.For maps a syncable type onto the AAD resource type its cache records bind to, and it had no arm for ConnectionLogEntry, ActivityLogEntry or ObjectStore. All three are on both enums, in the reconciler registry and in the cipher pinning; only this switch was missed, and it throws rather than falling back — so a merge conflict on a connection log, an activity log or a bucket raised ArgumentOutOfRangeException on the path that records what the merge discarded. The conflict log is the whole reason the merge is allowed to pick a winner, so the one item kind whose conflicts could not be recorded was a bucket: an editable item two machines can genuinely disagree about. Worth writing down why it lasted two phases. Of the three callers, ItemStore and OutboxStore reach the mapping only when an item carries plaintext fields, and none of these three kinds does — so they never touched the gap. ConflictStore calls it unconditionally, but a test only reaches that by causing a real merge conflict, and every existing one raised its conflict against a Host. Three arms missing, and no path in the suite crossed any of them. So the tests are the point of this commit as much as the arms are. The guard is AadResourceTypeTests.EverySyncableType_HasAnArmInTheStorageMapping: it walks the whole wire enum, and for each type asserts both that there is an arm and that the arm returns the same-named resource type, which is the mistake the file's cipher half already guards against on the server side. Written over the full enum rather than over ItemKinds.SyncedTypes, because that is the stronger claim and the one the switch really makes — the two reserved association types have arms too. Beside it, CacheStoreTests.AConflict_CanBeRecordedForEveryKindOfItem records a conflict per kind and reads the detail back, since an arm returning the wrong resource type seals under one AAD and opens under another, which surfaces as an empty detail rather than as a throw. Both were confirmed to fail with the arms removed: the theory fails on exactly ConnectionLogEntry, ActivityLogEntry and ObjectStore and passes on the other three, and the guard names those three and no others. The note in docs/adding-hosts-on-the-phone.md that recorded this as out of scope is marked fixed, with what let it survive, since that is the part worth knowing next time an item kind is added. 1529 tests pass, seven of them new. |
||
|
|
805d81dbcc |
Merge branch 'claude/team-key-share-rotate-4b2619'
Two conflicts, and the second is worth recording. main's M4 bullet gained the Android signing decision while this branch rewrote the M5 line either side of it; both are kept. The other is an ADR number collision: two sessions each took 0010, one for vault key rotation and one for Android distribution, and both are now on main. ADR numbers are identifiers — "see ADR 0010" appears in code comments as well as in prose — so leaving two would make every such reference ambiguous. The rotation ADR landed first and is referenced from crypto.md, the gaps document, ADR 0009 and the sync code; the Android one is referenced from README and android-port.md. So the later and cheaper one moves: 0010-android-distribution.md is now ADR 0011, with its title and both references updated. Nothing about either decision changes. |
||
|
|
5d447da532 |
Take a rotated vault's contents onto the new key as well
Rotating a vault re-keyed the vault and not its contents, which was the deal struck last time: everything already stored stayed sealed under the generation it was written with, every remaining member kept the older keys, and the guarantee was narrowed to "nothing written from now on". That left one gap worth closing — somebody who walked off with the old key could still open old ciphertext they later got hold of — and the reason it was safe to defer is the reason it was cheap to add. A vault at mixed generations reads perfectly well, so the pass that moves items across can stop half way and be run again. VaultResealer walks the vault and rewrites each item as an ordinary upsert against the version the server holds. It never decodes the plaintext: an item is opened and the same bytes are sealed again under a fresh data key, so an item written by a newer client crosses a rotation untouched rather than being re-encoded through this build's codec and quietly losing the fields this build has no concept of. It also means nothing in the pass knows what an item is, which is why one loop covers every type including the ones added after it. A conflict is counted and skipped rather than merged — there is nothing to merge, since no content changes — and the next pass picks the item up at the version the other client left. The half that a pass over stored items cannot see is a change queued before the rotation and pushed after it, which would put a brand-new item into the vault under the key the person who just left still holds. So the push path re-seals a stale payload as it dispatches it, writing the revision back to the outbox first so that a retry sends the same bytes rather than a fresh envelope. Between the two, nothing reaches the server under a superseded generation at all. Queued items are therefore deliberately left alone by the pass: rewriting one there would overwrite the user's unpushed work with the version the server holds, which is the one thing a re-keying pass must never do. Removal runs it last, after a sync — a mirror that is behind produces a batch of conflicts instead of a re-sealed vault — and the status line distinguishes the two guarantees, because they are not the same: a vault fully re-sealed is closed to the person who left, and one with items outstanding is closed only to what happens next. Six tests, and three mutations run against them: making the re-seal return the payload unchanged fails five of the six, making the push path skip re-sealing fails the queued-edit test and only that one, and counting conflicts as applied fails the write-elsewhere test. One of the six was wrong before it was right — it modelled a third-party write by re-pushing an existing payload at a bumped version, which no real client would do, and it took reading the AAD to see that the test was lying rather than the code. |
||
|
|
27bb1deb5d |
Say what the docs describe, in the order they describe it
A tidy across docs/, and every change is either something that had drifted from the code or something a reader would trip over. Nothing here is a rewrite: the audit documents keep their audit-time text, because that is what the scope was decided against, and corrections are marked in place the way each file already marks them. The one that matters is crypto.md. Section 4.3's resourceType table stopped at 13 while CryptoSpec.AadResourceType has carried ConnectionLogEntry = 14, ActivityLogEntry = 15 and ObjectStore = 16 since the logs and the buckets shipped — and items have been sealed under all three. That file is normative, so a table three item kinds behind the code is the sort of gap somebody reimplementing this would build against. Nothing on the wire or on disk changes; the list now says what the code has been doing, and it notes the thing that makes it worth reading carefully — 14, 15 and 16 are the first members that are not a fixed offset from their SyncEntityType counterparts, because 12 and 13 closed a hole and pushed the two enums apart. manual-checks.md had Phase 3 in an order nobody could follow: 3.2b, then 3.2a, then 3.4a, then 3.3. The letters are not the problem — they exist so a check can keep its number for life, which is what lets HostGridTests cite 7.6 and platform-flags.md cite 3.6-3.8 — so the sections are reordered and the numbers are untouched, and the intro now says the convention out loud rather than leaving the next person to infer it from two orphaned letters. Phases 8 and 9 got the horizontal rule every other phase has. The Phase 3 preamble still said "a sidebar that now draws headings", which has been two different interfaces for a while: the desktop's grid of cards and the phone's flat list. 7.6 cited MovingAHostToAGroup_FilesItAndLeavesItSelected, which was renamed in the commit before this one. Every other Type.Member citation across all six files was checked against src/ and tests/ the same way; those two were the only ones pointing at nothing. design-import-gaps.md's v3 section described the grid of cards without saying it holds one level, and its Groups row still said the sidebar emits one heading per group — true of the phone and not of the desktop. The team-groups row said a host a teammate filed "shows under UNGROUPED", which is now only half the story and the interesting half is why: the chip resolves through groupsById, which is every readable vault, while the level resolves through Groups, which is the active vault alone — so the card sits at the outermost level with its group's name still on it. Verified against both call sites rather than reasoned about. android-port.md's destination inventory is the desktop as it stood when the audit was taken and is deliberately left that way, with a note at its head saying so and two inline corrections where a reader would otherwise be misled: the rail lost FILES and S3 to fixed tabs, and the hosts sidebar became a grid — which is exactly why the headings this head draws are the phone's alone now. adding-hosts-on-the-phone.md is a plan marked built, so its two open questions are answered in place: GroupId did get into the merge test's with block, and the server test that asserts a plaintext ParentId is refused survived under a name that says what it means. Its "found on the way" note is marked still open, because it is: LocalCacheProtector.For has no arm for ConnectionLogEntry, ActivityLogEntry or ObjectStore, so ConflictStore.Record still throws for all three. That outlived the phases that shipped them, which is the drift a note like that exists to prevent. No code changed, so the suite is untouched at 1522 passing. |
||
|
|
cba6f435e9 |
Merge branch 'claude/vault-creation-sharing-62c0b6'
# Conflicts: # README.md |
||
|
|
89f1f07992 | Merge branch 'claude/main-page-group-hierarchy-3a3210' | ||
|
|
ebb88c8ae4 |
Give the phone both pickers, and settle who signs the APK
The files screen could browse a remote and delete on it, and that was all: there is no browsable local filesystem on Android for a second pane to show, so the gesture the desktop is built around — choose on the left, press the arrow — has nothing to stand on. What replaces it is the platform's own two pickers. ADD FILES is ACTION_OPEN_DOCUMENT, so a document is pointed at wherever it lives and goes to the directory showing; SAVE FILE is ACTION_CREATE_DOCUMENT for the selected row. Both stage through the application's cache, and that copy is a requirement rather than a shortcut. android-port.md predicted a picked document would be a third IRemoteFileStore beside SFTP and S3; it cannot be. FileTransferQueue seeks, because an upload resumes from the byte the last attempt reached, and a content:// URI has no path behind it, no length worth trusting, no promised seek and no grant that survives the document being edited underneath it. Copying first costs one class in the head and nothing at all in the shared layers, where the alternative was every resume rule rewritten around a stream that cannot rewind. The copy is deleted when the transfer completes, kept while it is stopped so RESUME still has something to read, and swept at the next launch — which is the one moment emptying that directory is provably safe, since nothing has queued anything yet. Coming out had a decision going in did not: when to ask where it goes. The save picker is raised before the transfer, so the download runs into the same staging directory and hands its bytes to a callback the head supplied, held against the transfer id so a RETRY still lands where the person pointed. Asking afterwards would put the picker minutes from the button that caused it and, on a phone, usually while the application is backgrounded and Android will not show one at all. The cost is that the picker creates its file when it is dismissed, so a download that then fails leaves an empty one there; that is said on the screen, in the README and in the manual checks rather than left to be discovered. A delivery that fails keeps the staged bytes for the sweep instead of throwing away the one copy of something just fetched over somebody's network. The foreground service counts transfers now, which is the half of it that matters most here: a shell survives backgrounding because somebody is looking at it, and an upload has to survive precisely when nobody is. Queued counts as active, so putting five files in and locking the phone moves five files. The seam was built for this and wired to () => 0 because nothing could fill the queue. Alongside it, ADR 0010 answers the second question android-port.md left open, and it had to be answered before the first release rather than at upload time: a new Play app must use App Bundles and therefore Play App Signing, and an installed app can only be updated by a package signed with the same key, so the first release picks an identity for good. The project holds the key, offline and never in CI — the workflow's package step now says so where somebody would break it — and a DodoSSH deployment never serves the client, because a download link on your own server hands the binary that holds the plaintext to the party the whole threat model is about. The README's M1 gap note was stale in both halves and is replaced by what is actually true: credentials have an editor and a REMEMBER tick, and the device key registers into the TPM under a CNG policy that makes the consent dialog a condition of using it. What is left is the floor rather than a gap — no TPM, or no Windows, means the passphrase on every launch. |
||
|
|
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. |
||
|
|
c86c4dc6ec | Merge branch 'claude/team-key-share-rotate-4b2619' | ||
|
|
d5b1a73182 |
Move the keys when a membership changes, not just the flag
Adding somebody to a team granted them nothing readable and removing them
rotated nothing. Both were honest — the interface said so in as many words — and
both left the actual work to a button somebody had to remember to press, on a
machine that happened to hold the key. Adding now wraps every team vault this
machine can open to the new member, and removing revokes their grants and moves
each of those vaults to a fresh key that goes to whoever is left.
The rotation is where the design had to be decided rather than written. A vault
key is per generation and an item carries the generation it was sealed under, so
advancing the vault and withdrawing the old grants would make everything already
stored unreadable to everybody, including whoever pressed the button. So earlier
grants are kept: a member holds one per generation, /me serves them as
PriorKeyWraps, and VaultKeyring holds a key per generation — the newest for
writing, the item's own for reading, chosen per item on every read path. Sharing
issues one grant per generation held, because a recipient handed only the current
key would open the vault to find most of it undecryptable; revocation takes every
generation, because leaving the history behind leaves them able to read
everything written before the rotation.
The bump itself is one server transaction. POST /vaults/{id}/rekey must name
exactly current + 1 and the vault's xmin token makes that binding, so two admins
rotating at once do not both walk away believing they succeeded — the second is
refused and told to read the vault again. The server contributes the moment and
no cryptography: it cannot generate the key, cannot tell that the one it is
handed differs from the old one, and checks that the caller held the old one the
only way it can, by requiring a live grant at the current generation.
What this does not do is re-encrypt what is already stored, and the product says
so rather than the reassuring version: everything written from the rotation
onwards is unreadable to the person who left, and nothing about the past changes.
That half is deferred and is safe to add incrementally precisely because a vault
at mixed generations stays readable. ADR 0010 records the alternatives — revoking
the old grants, chaining each key under its successor, re-sealing every item in
one request against a server that caps a push at 500 operations — and why each
was rejected.
Two things fell out of the change rather than being asked for. The grant listing
would have shown a member once per generation, so it now returns one row per
holder carrying the best key they hold, which is what makes a row below the
vault's generation mean "still owed the new key". And MarkUnreadable gives up the
write target as well as reporting: a client whose vault was rotated elsewhere
would otherwise have gone on sealing items under its superseded key — readable to
its author, unreadable to everybody else, with nothing to show for it.
|
||
|
|
e82a25c912 | Merge branch 'claude/vault-creation-sharing-62c0b6' | ||
|
|
7b7fd7b2ef |
Make a vault the thing you create, and let a window set one aside
Everything a shared vault needs was already here and arranged the wrong way round. A vault has to belong to a team, so creating one meant going to the teams screen, founding an organisation, and only then adding a vault to it — which the NEW VAULT button named after the team, so a team with three of them held three vaults called the same thing and nothing told them apart. Somebody who wants to share four servers with two colleagues is not asking to found anything. So the form asks for a name and nothing else. The team is derived from it, slug included, and created with this account as its owner; the vault goes inside; and the members, roles, invitations and key holders that hang off a team are all on screen the moment it exists. The tab strip's New vault entry lands there with the new vault selected, which is where the next thing anybody wants to do already is. That is two calls, and the first can succeed alone. When it does the team is kept: the id is minted once into pendingVaultTeamId, so pressing CREATE again resends the identical create — which the server treats as the same team — and retries the vault, and the message says all of that rather than "creating the vault failed". Archiving the orphan instead would be a client deleting something on the user's behalf because a later step failed, which is the kind of tidying that eventually archives a team somebody has just been added to. A slug taken by somebody else is retried once with a disambiguated one and never in a loop; a name with no a-z or 0-9 anywhere in it falls back to the team's own id rather than to a refusal pointing at a field nobody was shown. The other half is the caret beside Vaults. Being in four teams means four teams' machines in front of you all day, and the answer is a switch per vault rather than four sign-ins. Switching one off takes its hosts, groups, keys and pins off the screens that list them and does nothing else: it still syncs, its key stays in the keyring, it stays choosable as somewhere to file a new item, and a shown host that authenticates with a key filed in it still connects. That last one is what shaped the design. TryBuildAuthentication resolves a binding out of the keychain's typed list and a cross-vault binding is legal, so filtering the reload loops — the obvious implementation — would have turned a preference about reading into an outage. Only the projections a person reads consult IsVaultShown; every Reload*Async stays whole, including the dialled-endpoint set that decides which pins are described as unused, because that is a hint which invites deleting trust. Snippets, logs and buckets needed no code and the comment says so out loud: all three read ActiveVaultId alone, and the personal vault is drawn in the menu ticked and cannot be switched off — it is the active vault, the group and tag editors' target, and the save picker's fallback, so hiding it would empty half the application rather than filter it. The preference is a column on the cache's vault row, which is what makes it survive both a relaunch and the /me refresh that runs every minute: Apply does not touch it, deliberately, because the server has never been told which vaults this machine is showing. It is in the encrypted cache rather than settings.json because it is a list of vault ids and that file's own doc comment says what may go in it. VaultSession cannot see the type at all — ReadableVaults is what the sync loop walks, and a filter reaching it would be a vault that quietly stopped syncing, found out weeks later from a host that was never there. The strip's note refusing a MenuFlyout stands and is unchanged. This flyout sidesteps the question rather than answering it: the handler selects the Vaults tab first, which collapses the renderer, so nothing native is under the popup by the time it opens — the move QuickConnect already makes. A headless test asserts that ordering, which is as far as headless can go with no native window, and manual check 1.6 is the other half. The phone is out of scope on purpose: it has no tab strip and its teams screen's vault section is read-only. The plumbing is in Client.Shell, so it can adopt this later; until then nothing there is ever hidden, which is today's behaviour. 1514 tests pass. Fifteen are new in VaultVisibilityTests, and the ones worth naming are the guards: a hidden vault still syncs, still holds keys that authenticate hosts on screen, still appears in the save picker, and still counts towards which pins nothing dials. Not fixed, and noted here because it is next door: VaultGrantService's team-vault create refuses a taken vault id rather than returning the existing vault, while VaultSharing's own remark claims a create whose response was lost is safe to resend. A lost 200 therefore leaves a vault whose key the client's catch already zeroed, openable by nobody. |
||
|
|
aa06868b1e |
Add the member the directory cannot see, rather than inviting them
ADD MEMBER quietly issued an invitation instead of adding anybody, for everyone who had signed in here and not yet enrolled. The screen told them that address had no account, the members list did not change, and the person only actually joined on the next hourly sweep. The client decided whether an address had an account by asking the public-key directory, and the directory answers a narrower question than that. It drops every account with no current key — deliberately, because an entry exists to be wrapped to and one carrying no key is a check a caller forgets exactly once. An account exists from its owner's first authenticated request and publishes nothing until they choose a passphrase on their own machine, so every account is missing from the directory for that whole window and some indefinitely. A miss there is not an absent account, and reading it as one was the bug. The server would have taken the add. TeamService.AddMemberAsync only requires the account row, and TeamMemberSummary.IsEnrolled exists precisely so a member with no key can be listed — added on Monday, enrolled on Tuesday. The client never asked. So the directory is still asked first and the miss is retried as an add by address, and only a server saying there is no such account reaches the invitation. AddTeamMemberRequest gained an Email used when UserId is empty. The lookup-first ordering is kept because it is load-bearing for sharing and not for this: the key verified before a vault key is wrapped is the one the lookup returned, and nothing is wrapped by adding somebody. That is why resolving the address server-side is safe here and would not be there. NoSuchAccount is its own code rather than folded into InvalidTeam, because it is the one add failure the caller can act on unprompted — there is nobody to add, so invite them — and a code shared with a rejected role would leave them guessing which had happened. It does answer whether an address has an account here, which CreateTeamInvitationRequest deliberately does not. That is the property traded for the fix; the exposure is bounded by the admin check the add already needed, and it is the same fact the member list shows a moment later. Adding by a user id that does not exist now answers 404 no-such-account rather than 400 invalid-team, and nothing depended on the old pairing. The two silent returns are gone. Offline and no-team-selected set nothing and returned, so those failures were visible only as a flicker of the busy flag — which reads as a button that does nothing at all. The success line reads the enrollment flag too, because pointing an unenrolled member at SHARE KEY is pointing at a button that will refuse; their row already says it holds no key. Why nothing caught it. FakeVaultServer had one list, so it could not tell an account that does not exist from one that exists and has not enrolled — the distinction this whole path turns on — and every account it knew was enrolled by construction. It grows an accounts list beside the directory and reports IsEnrolled from whether the directory has them, rather than hardcoding true. The regression test asserts Invitations is empty, which is what fails against the old behaviour. Four tests: that pair in the shell suite, and in the API suite an unenrolled account added by address after its own directory lookup comes back empty, and an unknown address refused under the new code. 1495 tests pass. |
||
|
|
1f2607cc9a | Merge branch 'claude/group-double-click-breadcrumb-7bf3f8' | ||
|
|
01d54ba4fa |
Put the trail above the GROUPS label rather than under it
It was drawn as the transfers screen's is, under the heading of the list it belongs to. That is right there and wrong here: over there a path describes the one pane beneath it, and this one describes two. Opening a group narrows the host grid as well as the row of cards, so a trail sitting under GROUPS reads as one more fact about the groups while it is also the reason the grid at the bottom of the screen is showing eleven machines instead of forty. So it is the first thing in the section, and both headings below — GROUPS and HOSTS — belong to it. Markup only: the trail moved, the note on it says why it is not placed like its sibling, and the group buttons' note now points up rather than down. 85 layout tests pass, including the one that presses a rendered crumb and would have caught the $parent binding failing to resolve from its new place. |
||
|
|
68600dea07 | Merge branch 'claude/fingerprint-enrollment-support-a6c2f3' | ||
|
|
fd6c4f5155 | Merge branch 'claude/host-detail-pane-design-e98621' | ||
|
|
a2c56de1f2 |
Read the selected team once per reload, so its owner is listed once
Creating a team drew its owner twice. ReloadAsync rebuilds the team list and then reselects, and the selection handler answers that assignment by starting its own read of the members, invitations and vaults — while ReloadAsync is awaiting a read of exactly the same thing. Both clear the collections up front and both append when their round trip answers, so everything below the team list was drawn twice. On a team nobody has been added to yet, whose only member is its owner, that read as the owner being in the team twice. The rows are records, so the reselect only raises a change when something about the team has actually moved — which is every reload that follows a change: creating a team, adding a member, renaming one. A plain refresh looked fine, and the screen doubled precisely after the acts people come to this screen to perform. isReselecting suppresses the handler for the length of the assignment rather than deduplicating rows afterwards, because only one of the two reads is awaited. A command that reloads and then reads Members has to be looking at the reload's own read and not at a fire-and-forget one that may not have answered. The generation counter is the other half, and it is a different bug with the same cause: selecting a second team before the first has answered leaves two reads in flight against the same collections and nothing decides which wins, so team A's members could land in the list under team B's name. A superseded read now drops its answer instead of appending it. Why nothing caught this. The fake server answers from memory, so every read completes before the next begins and the appends can never interleave — the duplicate needs a round trip to hold two reads open at once. FakeVaultServer grows a MemberReadGate for that, and the new test holds a read open and counts the reads in flight, which is the only moment a second one is distinguishable from the first. It fails against the old behaviour with two. 237 tests pass in the app suite and 83 in the layout suite. |
||
|
|
e3dfe5c371 |
Make the host pane something you ask for, and draw it as cards
THE DRAWER USED TO ARRIVE WITH THE SELECTION. IsDrawerOpen read "a host is selected", so touching any card took 304 pixels off the grid — including every card arrowed past on the way to the one somebody wanted. Choosing among forty machines was charged the price of the pane for one of them. A pencil now appears on the card under the pointer and on the selected card, and that is what opens it; IsHostPaneOpen is the flag, and the grid's context menu gained Details… so the pane is reachable without a pointer, which a hover-only control is not. Once open it follows the selection rather than pinning the host it was opened on: a pane about one host beside a grid marking a different one is two answers to the same question. Losing the selection closes it and clears the flag, or a filter matching nothing would leave the pane armed to spring open again on the next card merely selected — which is the behaviour the pencil exists to remove. The pencil is drawn over the card rather than in a column of its own. A column would have cost the name 30 pixels of a 232-pixel tile, permanently, for a control that is only there while the pointer is; the dot and the pencil stack in the two corners of that edge instead. IsVisible and not opacity, because a button at zero opacity still takes the click and the card underneath does not. A HEADER, A BODY THAT SCROLLS, AND A FOOTER, which is the one structural change in the pane. The header names what the drawer is about and carries the two things true of every panel; the footer carries the one thing each panel is for — CONNECT, or SAVE, or the question about deleting. Only the middle scrolls, so the button somebody came here to press can no longer be below the fold, which CONNECT could be on a host with fifteen tags. That also widens what the layout harness certifies: it skips anything inside a ScrollViewer, and the control each panel exists to offer is now outside one. THE SAME THREE CARDS TWICE. Address, General, Connection — first as rows stating what the host is, then as boxes for changing it. The detail pane's rows are buttons that open the editor: the design draws every fact as a filled box, and rather than draw an input that refuses the pointer, pressing one leads to the same card with a real box in it. Nothing here saves as you type, and that is not timidity — saving validates the key-or-credential exclusion and writes one encrypted payload, so a box committing per keystroke would be a save per character and a half-typed hostname on the wire. Every value the pane prints is the resolved one, and says "inherited" beside it where a group supplied it. The number is the same either way and the edit is not: clearing a group's default moves every host that never overrode it. A HOST CARD IS TWO LINES AND NO CHIPS. The subtitle is now "ssh, root, pci, eu-west-1" — the transport, the resolved account, then every tag — replacing both the user@host:port line and the wrapped row of tag chips under it. The address went to the card's tooltip rather than nowhere: a card is read while scanning forty machines, where the name and the kind of machine are what is being looked for, and an address is what you read once you have found it. "ssh" is a constant today and is printed anyway, which is the one thing here that argues with this codebase's own rule about constants dressed as readings. It is the first item of a list whose other items vary, and a list beginning with the account on one card and a tag on the next has no shape to scan. The remark on HostRowViewModel.Summary says so rather than leaving it to be discovered. WHAT THE DESIGN DRAWS AND THIS PANE HAS NOT GOT: Share this host, Add Telnet, "SSH ID, Certificate, FIDO2", the backspace-key mapping row, the vault picker's chevron and Show more. Sharing is per vault and not per item, every session here is an SSH channel, there are no identity or certificate item types, nothing carries a terminal setting to the renderer, and an item cannot be moved between vaults at all. Six controls with nothing behind them, listed in docs/design-import-gaps.md with what ships instead, and none drawn disabled. The credentials row is marked with ◆ rather than the ⚿ the nav rail uses for the keychain. U+26BF is outside both faces this application substitutes for the design's fonts, so it lands on whatever the platform's fallback has; every other glyph in the pane is from Geometric Shapes, which both carry. |
||
|
|
ca6f69de34 | Merge branch 'claude/group-double-click-breadcrumb-7bf3f8' | ||
|
|
0c0ac94312 |
Open a group by double-clicking it, and say where you are
ONE PRESS WAS DOING TWO JOBS. A group card was the only place a group could be selected — it is what EDIT and DELETE aim at — and it was also the control that narrowed the grid to that group. So there was no way to name a group in order to rename it without every host outside it leaving the screen at the same moment, and no way back except a SHOW ALL button that appeared beside the heading. Two gestures instead. A click selects and does nothing else; a double-click opens, which is what the host cards below already do to get a shell and what the transfers screen's directories already do to go inside one. The grid now has one vocabulary rather than one per list. The gesture is wired in the code-behind beside the host one, and guarded the same way: a double-click on the space around the cards must not open whichever group happened to be selected. THE CARDS ARE ONE LEVEL NOW, not every group in the keychain. Groups nest, and drawing all of them flat was the only honest thing to do while a card was a filter — a filter nobody can see is a filter nobody can turn off. Once opening one became navigation the cards became its contents, and VisibleGroups is that level beside Groups the way VisibleHosts sits beside Hosts. Groups itself is untouched: it is what every lookup reads and what the phone's headings are built from, and the phone binds none of the new members. Which is what the trail is for. A level with no name and no way out is a grid that has quietly hidden things, so a breadcrumb sits above the cards — drawn exactly as the transfers screen draws a directory path, same flat crumbs and same separator, because it is the same control answering the same question and a window with two breadcrumbs that look different has two ideas of what a path is. The first crumb is always there and always goes back to every host, which is what SHOW ALL was; that button went with it, because a control that only says "stop" beside a trail that says where you are is a second control for one job, and this one also gets you back one level rather than all the way. EDIT AND DELETE AIM AT GroupTarget: the selected card, or the open group when no card is selected. Without the fallback a group with nothing inside it could be opened and then never edited, because opening a group is exactly what takes its own card off the screen. It is also what a file manager does — act on the selection, and on the current folder when there is none — and the pair is now hidden with nothing to act on rather than shown doing nothing. A DANGLING PARENT AND A CYCLE BOTH HAD TO END UP SOMEWHERE REACHABLE. Neither is prevented: a parent id may point at a group deleted on another machine, and two clients can each re-parent A under B and B under A while offline, which no merge can see because the pointer is inside the payload. EffectiveParents promotes both to the outermost level, which is the same degradation the resolver's visited set produces for inheritance. The repair for either is the group's own editor and the editor is opened from its card, so a group drawn nowhere would be a broken state with the fix locked inside it. Three tests in HostGridTests: the split rule through the properties the cards bind, the pointer gesture itself in two windows so that "one press still only selects" is asserted separately from the pair, and a nested group opened, emptied of cards and walked back out of one level. The last presses the trail as it is actually rendered rather than calling the command, because a crumb reaches the vault through a $parent binding — a string that compiles whether or not it resolves, and would otherwise leave a row of buttons that do nothing. 85 layout tests and 234 shell-flow tests pass. Manual-checks 3.2, 7.6 and 7.7 follow the new gestures, and 3.2a and 3.4a are new: nesting, and the two states above, both of which need two machines and neither of which headless Avalonia can reach. |
||
|
|
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`. |
||
|
|
1b7df47537 |
Merge main into the desktop redesign branch
Four conflicts. Three were two people adding to the same spot, and one was a real collision: main gave the connect bar a REMEMBER tick in the same pass that this branch took the connect bar off the hosts screen. REMEMBER is now in the drawer, beside the password box it qualifies. Nothing about the feature changed — RemembersConnectPassword, its refusal to fire until the remote has accepted the password, and the six tests over it are main's untouched — only where it is drawn. The move improves it slightly and it is worth saying why rather than claiming a merge was neutral: the bar had one row and had to fit the box, the tick, the authentication note and CONNECT along it, which is why the tick was a bare "REMEMBER" in tracked capitals. A column has room to put it under the box as a sentence, where it reads as a property of the password rather than as a fourth control in a row. MainWindowViewModel: both sides added members after ShowTerminal — the desktop's three fixed tabs here, the phone's connect menu on main. They do not interact, so both are kept, each under its own heading. TeamsScreen: main added the team's own RENAME, HAND OVER and ARCHIVE, a member's LastActive, the role a new member arrives as, and the KEY HOLDERS list. This branch had only bumped the file's font sizes a point. Resolved by taking main's file whole and re-running the bump over it, so the new controls join the scale rather than sitting a point below everything around them. README: both sides described a different head's third pass in the same paragraph. Both kept. Two things checked rather than assumed, because this branch moved the furniture the merged commits sit on. The chrome heights main's terminal work touched are the phone's, not the desktop's — 44, 42 and 24 are unchanged, so the layout harness's budget still describes the window. And main's keychain DELETE did not reach VaultScreen.axaml, whose header this branch rearranged, so the five buttons that overflowed at the larger type are still five. 2415 tests pass, up from 2369 by the 46 main brought. |
||
|
|
208443b932 |
Move SFTP and S3 into the tab strip, and the host list into a card grid
Four asks in one pass over the desktop head, and two of them are furniture moving rather than anything new. THE STRIP IS THE WINDOW'S NOW, not the terminal's. Vaults, SFTP and S3 sit at its head and cannot be closed, and open terminals follow them. SFTP and S3 left the nav rail to get there, which is the one semantic change: they are the two destinations you stay in while something runs, and a rail entry is for somewhere you go and come back from. So the rail belongs to the Vaults tab and collapses with it, which also means SFTP, S3 and a terminal each get the full 1016 rather than the 826 a page gets. The tab is expressed as "a page, and not one of the two the strip took" rather than as a fourth ShellSurface. Both are still ShellScreen members and have to be — that is what they are on the phone, where they are two rows in a hub — so a surface each would have been a second way to say a thing Screen already says. IsTransfersShowing and IsBucketsShowing light the other two tabs unchanged. What is new is one field: the page Vaults comes back to, because it is the one tab with sub-navigation and therefore the one that can return to the wrong place. That is not the hidden field ShellSurface argues against — that one would be a second copy of "which page"; this is a tab remembering its own. THE HOSTS SCREEN IS A GRID, and the 268-pixel sidebar is gone. That column was choosing among forty machines and editing one of them at two-thirds width, and it was narrow so the editor beneath it could be a column at all. Cards took the first job at full width; a 304-pixel drawer took the second and collapses when nothing is selected. Pressing a group card narrows the grid; SHOW ALL is the way back. The group editor moved into the drawer as well, which finally makes IsEditingGroup mean the same thing on both heads — it was the phone's alone, because the desktop's editor was a bar that was always on screen. AreHostsExpanded and ToggleHosts went with the control that used them. They folded the whole list away under one heading, an affordance that existed because the column was narrow. Folding a single group is a different thing and is still here. THE TYPE SCALE IS A POINT LARGER and the text ramp is white. The base size was never stated anywhere — a bare TextBlock took TextElement's default of 12 — so raising the scale meant naming it, on Window and on UserControl. The second selector is not redundancy: the layout harness hosts a UserControl in a window it builds itself, and without it the suite would measure every screen a point smaller than it ships, silently. A selector on TextBlock would have been the obvious way and is wrong, because a style setter beats an inherited value and would collapse every deliberate step back to one number. #E3E7F4 is a blue-tinted white on blue-black surfaces, which costs contrast twice — once for being darker than white and once for sharing a hue with what it is drawn on. Pure white is 18.3:1 against the canvas where that was 15.5:1. Every step below moved with the top, so the intervals the design chose are kept and TextDim clears 9:1 against 6.4:1. The palette is shared, so the phone has both changes too. TWO DEFECTS THE HARNESS STRUCTURALLY CANNOT SEE, found by rendering the screen rather than by measuring it, and both now covered. The tile was 232 and was first written as 248, from arithmetic that left out the scrolling stack's own margins. Every layout test passed — the harness asks whether a control is inside the window, never how many fit on a line — so the grid quietly became one column wide at exactly the minimum this application guarantees, which is the shape cards exist to avoid. TheHostsGridKeepsTwoColumnsAtTheMinimumWithTheDrawerOpen counts columns instead, and fails at 248. And a card's text ran past its own border, because a horizontal StackPanel measures children with infinite width: a TextBlock inside one never learns it is short of room, so TextTrimming never fires. Both card rows are grids with a star column that gives way and an Auto column that does not — a hostname with its tail cut is still the machine you were looking for, where a badge or the word naming an auth method is not. The keychain header changed shape for the same class of reason. It was Auto,Auto,*,Auto with the buttons last, so the slack column was the only thing absorbing a change of width and five buttons fell off the right edge the moment the type grew. That is how GENERATE lost the word KEY once already. The summary sits in the star column and trims now, so the buttons always get their width. HostSidebarTests became HostGridTests and moved to the grid with the gestures it drives. docs/design-import-gaps.md gains a v3 section naming the five toolbar controls in the design with nothing behind them — a view-mode switch, a tag filter, a calendar, a share control and Serial — and manual-checks.md and the README follow the controls that moved. |
||
|
|
52f7226ac2 | Merge branch 'claude/connection-host-default-text-9e501d' | ||
|
|
9801a744ba |
Merge branch 'main' into claude/angry-cray-f3d496
# Conflicts: # README.md |
||
|
|
7343c717da |
Bring the last three outlined marks over to the filled tile
ServerScreen, LockedScreen and PendingScreen were the only places left drawing v1: a 44px square outlined in the accent with >_ in the accent inside it. Every other place the mark appears — both headers, the desktop titlebar, the launcher icon and the .ico — draws it filled. The radius is derived rather than chosen. The mark is 6 at 20 and 8 at 26, a third of a unit per unit of tile, which puts 44 at exactly 14 — and 14 is already on Phone.axaml's radius ladder as the one for a block of monospaced output, which is a fair description of a box with >_ in it. PendingScreen takes the shape and not the colour. It is the screen that says a surface is not built yet, and the accent is the shell's one "do this" colour; a tile in Raised with the glyph left at TextFaint keeps the mark recognisable while the screen goes on looking as inert as it is. |
||
|
|
dbfe3a5a37 | Merge branch 'claude/host-connection-top-bar-25d04e' | ||
|
|
99d3ab386f | Merge branch 'claude/connection-host-default-text-9e501d' | ||
|
|
5800a4bbd0 |
Give the keychain the DELETE its confirmation was already written for
The counted confirmation has been on this screen since the phone got its screens, and nothing could raise it. PendingDeletion is armed by DeleteSelectedItemCommand and its siblings, and the only Delete bindings anywhere on this head were the files screen's remote delete and the snippets one — so the card, and the header comment calling it structural, described a question the phone could not ask. The button is what was missing rather than anything behind it: the command, the counting and the wording were all already shared with the desktop. It sits in row 2 beside that confirmation rather than in a bar at the foot of the screen, which is where HOSTS and FILES put what to do with the chosen thing. ShowsItemActions is SelectedItemIsEditable and not-already-asking, so sharing the row makes the question take the place of the button that asked it, and DELETE cannot be pressed a second time underneath its own confirmation. A button at the bottom and its question at the top would be one exchange drawn twice. DELETE alone, without the EDIT the desktop's detail pane offers beside it, because there is nothing on this head for EDIT to open. That is also why the bar names the item rather than trusting the highlight: it is the only thing on screen saying which row is about to go. And the highlight had to be made to exist. HOSTS and FILES fill the selected ContentPresenter, which works because their rows are bare Borders; a keychain row is Border.card, and the card paints an opaque Sidebar over it. The selector reaches the card itself instead, in the same Active the chosen host wears, so the row a button now acts on looks different from the ones it does not. The TAGS comment said renaming and deleting happened here. Half of that is true as of this commit and the other half is not — a tag is still renamed on the desktop, because no kind of item opens an editor on this screen. The README's paragraph about what the phone is missing said there was no host editor, which stopped being true when the floating + and both editors landed. It now names what is actually absent: no local pane for file transfer, no keychain editor, and no screen at all for pins, teams or import. |
||
|
|
a2f0d4813a |
Take a third off both of the terminal's bars, and centre the cross in a tab
The bar the last commit put above a shell opened at 52 and the accessory row under it at 50, both inherited from the arrangement they replaced rather than measured against the one they are in. Neither is carrying a title or a sentence any more — the top one holds two icons and a row of pills, the bottom one a line of keys — so a third comes off each: 35 and 33. Every height inside them came down too. The pills go 44 to 30, the icon squares 34 rather than 44, the keys 38 to 30. A bar that shrank around contents that did not would not have saved anything; it would have moved the clipping somewhere harder to see. Two of those numbers had arguments written against them and both arguments change rather than disappear. The pill was 44 because it contains the one control on this head that is destructive with neither confirmation nor undo, and that is now carried by width — the cross keeps its full 44-pixel column, and what it gave up is vertical slack in a row where nothing sits above or below it to be hit by mistake. The keys were 38 for the same kind of reason, and the 44 that mattered there was always the width: ten keys flexed across 360dp is 32 pixels each, which is what the horizontal minimum exists to refuse. Both comments say what replaced the reasoning rather than quietly showing a smaller number. The close cross was not vertically centred, and it was not a rounding error. `Button.row` sets `HorizontalContentAlignment` and says nothing about the other axis, so the glyph sat against the top of its own column while the label beside it was centred by the stack panel it lives in. On the control that ends a session that reads as a misprint. Both alignments are now stated, on the button and on the text. The `+` loses the accent and becomes the same `Button.icon` as the arrow across from it. The two are a matched pair at either end of one bar — one leaves this surface, one adds to it — and an accented one ranked itself above the way out. The accent fill belongs to the floating `+` on HOSTS, which is the only action on its screen; this one is not. Five pixels between the renderer and the keys, as a margin rather than a border. The renderer is a native child view and nothing Avalonia draws can sit on top of it, so a hairline there would have to be a row of its own — and a terminal whose last line of output is flush against a row of grey keys reads as one surface that has gone wrong rather than as two that are different things. The four places that named the old bar height are corrected, including manual check 11.5, which asserted a number that would now fail. |
||
|
|
d6cd8825fc |
Let the launcher icon catch up with the mark v2 already drew everywhere else
TitleBar and PhoneShell have drawn >_ knocked out of a solid accent tile since v2, and both say in a comment that the launcher icon draws the same thing so the three cannot drift. The launcher was still v1: an outlined square in the accent. The comments are true now. On Android the tile is the background layer rather than a rounded rectangle in the foreground, because the rounding a launcher applies is its mask. Drawing the tile would put a second rounded shape inside the mask, clipped at the corners on any device whose mask is not the one it was drawn for; leaving it to the mask gets a squircle on one device and a circle on another for free. Windows has no mask, so dodossh.ico still draws its own corners. The monochrome layer keeps only the glyph. A themed icon drops the background, which is where the tile now lives, and a layer filled to the edges to stand in for it would tint to a featureless square. The glyph is half the width of the safe zone, taken from the two headers rather than invented -- both set >_ a little under half the tile across. It is also markedly more legible small than the outline was: dark on accent at 16px is a mark, where a sub-pixel hairline was a smudge. |
||
|
|
0d239bcd6c | Merge branch 'claude/adding-hosts-phone-docs-e472b0' | ||
|
|
84b198bda0 | Merge branch 'claude/host-password-persistence-2c2c1f' | ||
|
|
718c6b11bd | Merge branch 'claude/connection-host-default-text-9e501d' | ||
|
|
7e4e068aab |
Merge main into the teams branch
Two conflicts, and both were two people counting the same things differently rather than disagreeing about what the code should do. PhoneShell's header comment. The branch made "the five hub screens" numberless, because TEAMS made it six and a number in that sentence had already gone stale once. Main corrected "three destinations" to "two" in the same sentence, because giving a shell the whole phone took the terminal out of the set the header is drawn on. Both are right and neither noticed the other: the header now stays on the hub's screens and on the two top-level destinations, which is Hosts and Keychain. The manual checks. Both sides appended a Phase 10 — main added the software keyboard and the phone's terminal surface as 10 and 11, the branch added Teams. Nothing about them overlaps, so the resolution is to keep all three in the order they were written and renumber Teams to Phase 12, its subsections and the one cross-reference inside 12.1 with it. Main's two phases keep the numbers they already carry in its history, since renumbering those would move headings somebody may already have linked to. Everything else merged without a conflict, and the two places worth checking afterwards both held: IsMoreSurface and the first case of PhoneShell.OnBackRequested each kept ShellScreen.Team alongside main's edits. Those two are one fact in two places, so a merge that dropped Team from either would have trapped the user on the teams screen with the MORE tab dark. Verified after resolving: solution builds with no errors and no new warnings, the Android head builds, and every suite passes — App 214, Layout 73, Api 162, Infrastructure 34, Contracts 25, Session 54. App gained the three shell-flow tests main brought with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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. |
||
|
|
c7c844f7bd |
Give the desktop head the mark the phone has carried all along
The window had no icon and the executable had no icon, so Windows drew its generic one in the taskbar, in alt-tab and in Explorer, next to an application that draws its own titlebar precisely so that nothing about it looks borrowed. The geometry is the launcher vector's, mapped so the middle 72 of its 108-unit viewport fills the canvas: that 72 is what an adaptive icon actually shows, and rendering the full 108 would draw the mark at 39% of the width. Nine sizes, because Windows asks for several and downsamples unkindly when one is missing, and a floor of one pixel on the strokes so 16px is a mark rather than a smudge. Windows takes an .ico and nothing else, so the raster the phone does not need exists here. dodossh-icon.ps1 sits beside it and draws it from those same numbers, so the two heads still have one geometry between them. |
||
|
|
8da58db5ce | Merge branch 'claude/host-connection-top-bar-25d04e' | ||
|
|
80ae586fc4 |
Give a shell the whole phone, and one bar to leave it by
A connected phone was drawing five rows of chrome around the thing the user opened it for. The vault header at 56, the terminal's own tab strip at 52, a connection line at 36, the shells strip at 46 and the four-entry bottom bar at 64: at 360dp that is about a third of the display, and every row of it was about somewhere the user was not. What replaces them is one 52-pixel bar drawn by the surface itself — back on the left, the session pills, and a `+` across from them — and then the terminal. Three of those rows belong to `PhoneShell` and each is now bound on `IsShowingPages`. That is the same question asked once rather than three conditions that could drift: the surface is either a page or a terminal, and these are the chrome a page has. The header needed a wrapper because Avalonia's bindings have no "and" and it already had a condition of its own; the strip needed one for the same reason. The bottom bar had none and is bound directly. The back arrow goes to the page the terminal was opened over rather than to Hosts by name, because the system back gesture already picks that and an arrow landing somewhere else would be the second of two answers to one question. The bar's `+` raises a sheet offering the three connections this application can make — a shell, a host's files over SFTP, a bucket — since SFTP and S3 used to be two taps through the bottom bar's MORE and the bar is not on screen here. A control that replaced it and led to one of the three would have quietly removed the other two. Two things moved rather than being dropped. The text-size buttons are pinned at the right-hand end of the accessory key row, outside its scroller: the connection line existed to keep them from scrolling out of reach, and being outside the scroller answers that argument rather than abandoning it. The dialled address moved onto the connecting card, which is the moment it is worth reading — what is being connected to, before anything has answered — and after that the shell's own prompt says it more accurately than a header derived from the keychain ever did. The sheet collapses the renderer rather than covering it. Whether Android's `WebView` composites above Avalonia content the way Win32's child window does is still unverified — `docs/android-port.md` has said so since the port — so this follows the desktop's palette and gives up the rectangle outright, which is correct under either answer. It collapses `IsTerminalShowing` and not `IsTerminalSurface`, because the bar the sheet was raised from is part of that surface and dropping it would take the bar, the tabs and the whole arrangement with it, leaving the sheet floating over the page underneath. `OnSurfaceChanged` is the one place the flag is lowered, and that is the load- bearing half. Every way out of a terminal ends there — a destination, the files screen, the palette connecting to a host, closing the last tab, a lock — and each of them would otherwise leave a sheet set over a page. Not merely untidy: the flag holds the renderer blank, so the next return to the terminal would draw the menu again over a rectangle kept blank by it. Opening is refused off the terminal surface for the same reason from the other direction. The back gesture gains a guard above the switch, in the shape of the editor guard that arrived with the phone's `+`. It is nearer than any of them: with no header and no bottom bar, while the menu is up that gesture is the only way off it other than the scrim and CANCEL. The bottom bar's Terminal entry lost its `IsCurrent` binding. The bar is collapsed on that surface, so the binding could only ever be read as false, and a rule about a state the control cannot be in is a claim that it can. Three tests in `ShellFlowTests`, which is where shared state-machine behaviour for this head goes: the collapse and its recovery, the refusal to open over a page, and the sheet lowering both by a menu entry and by a route it was never wired to. Everything visual needs a device, so it is phase 11 of `docs/manual-checks.md` — and 11.2 is the check that would finally settle the compositing question this head has carried as unverified since the port. |
||
|
|
a43286ece8 |
Let a team change hands, and be joined by somebody with no account yet
M3 built teams and stopped short of the two operations that decide who controls one. Both were written down as refusals rather than omissions: ADR 0009 listed ownership transfer under "deliberately not built", and design-import-gaps said an invitation needed "a token with a lifetime and an outbound mail path". One of those reasons had expired and the other never applied — an invitation does not need a token if it is not a thing anybody presents. Handing a team over is one write. The member you name becomes owner and you become an admin, in a single transaction, because ownership is sole: promoting first leaves the team owned twice, demoting first leaves it owned by nobody, and there is nobody left with the authority to finish a transfer that stopped in the middle. That is also why it is not two calls to the role endpoint, which refuses Owner outright. The outgoing owner is demoted rather than removed — removing them would revoke their vault key grants and flag every team vault for rekey, which is a far larger act than the one asked for, and somebody handing over a team is usually staying in it. It unblocks the thing that was impossible before: an owner can now leave, by handing the team on first. An invitation is a standing instruction rather than a message. This server has no outbound mail path, so nothing is sent and there is nothing for the invitee to present. The row says the next account signing in with that address joins this team at this role, and telling them to sign in is the caller's job over a channel this server does not carry. A link nobody can deliver would be worse than none. It lives in its own table rather than becoming a membership with MembershipStatus.Invited, and that member stays unwritten for the reason it always was: team_membership.user_id is not nullable and carries a foreign key, so somebody who has never signed in has nothing for that row to point at. Widening it would make the unique index on (team, user) meaningless, because PostgreSQL counts every NULL as distinct. Verification is the security boundary, and nothing in this server read it before. A claim requires the access token to assert email_verified. An invitation decides what the server will serve, so one claimable by anybody able to obtain a token carrying somebody else's address is a way into a team — which is precisely the attack OidcOptions.AllowEmailLinking exists to refuse, and it would have been reintroduced by the back door. There is deliberately no setting that relaxes it: a flag that exists is one somebody turns on for the afternoon their provider is misconfigured. Absence is refused rather than trusted, and logged, because a provider that never sends the claim otherwise leaves every invitation pending with nothing anywhere saying why. Claiming happens at just-in-time provisioning and again on an hourly sweep. The sweep is what makes it recoverable rather than one-shot — an invitation issued between an account being created and that person next signing in would otherwise be stranded for ever — and it shares its rate with the last-seen write because both are housekeeping nobody is waiting on. Archiving is refused while a team owns a vault, and that refusal is the end of the road rather than a step on it. A team vault is readable because of membership, so archiving one that still owned vaults would take them away from everybody holding a key, including the caller, quietly and all at once. Nothing in this product deletes a vault, so no order of operations gets past it today — which is stated with a count of what is in the way, for the reason the SFTP layer refuses a recursive delete: a refusal is visible and a quiet removal is not. It is owner-only, as handing over is; renaming is not, because a rename is visible to everybody and reversible by anybody who can do it. The slug is not renameable at all: it is unique only among live teams, so a rename could take one an archived team is still holding, and that team could then never be restored. LAST ACTIVE is real and coarse on purpose. UserAccount.LastSeenAtUtc is refreshed on ordinary authenticated requests, at most once per account per hour, through ExecuteUpdateAsync — user_account carries the xmin concurrency token, so a read-then-write on the hot path would start losing races between one user's own overlapping requests. An hour is the granularity the question is actually asked at, and the interface draws it to the day rather than the minute so it does not read as a precision that is not there. The remarks in Contracts and in the view model that argued at length for the column's absence are rewritten rather than extended; both had become false. Two endpoints already existed and nothing called them. ChangeTeamMemberRole and ListVaultGrants have been reachable since M3. The role picker refuses Owner itself rather than letting the server do it, since the interface already knew the rule; the key-holder list sits under the vault rather than beside the member, because a grant is per vault and a count on a member row would imply per-item sharing, which is M5. It lists withdrawn and stale grants and says which they are — a list that dropped them would show a departed colleague as merely absent rather than as somebody whose key was taken away — and staleness is decided by comparing generations, since a grant can be Active and still open nothing. ADD MEMBER stopped being a dead end. An address the directory did not know used to end at a sentence telling the user their colleague had to sign in first. It invites them instead, from the same button, because which of the two applies is a fact about the server's account table rather than about what the user is doing; which one happened is reported afterwards, because that decides what they do next. An address that merely has an account is invited rather than refused: refusing would have made the endpoint an oracle for which addresses have accounts here, answerable by anybody willing to create a team first. The phone has a TEAMS screen, behind MORE, and it is the reverse of every other row in design-import-gaps: a shipped screen the design had no slot for. It is there because an invitation is claimed by signing in, so somebody told they are now in a team is at least as likely to be holding a phone — and a membership visible only on a head they never installed is one they cannot see. It draws SHARE KEY and nothing that takes something away: wrapping a key is the one act on that screen a server cannot perform at all, and the desktop guards its revocations with a tooltip, which is a control a touch screen cannot show. Two defects were found by an adversarial pass and both were green against the whole suite at the time. The owner-only check on archiving and handing over had been weakened to the admin check while their messages and comments still said owner — and since nothing behind the archive endpoint re-checks it, an admin the owner had promoted could have archived the team out from under them. And the rename endpoint built its response with a hardcoded Owner role, so an admin who renamed a team was handed a summary claiming they owned it, and a client trusting that instead of re-listing would have offered them the two owner-only buttons the server then refuses. The new table gets its constraints tested rather than merely migrated: live uniqueness per (team, address), the citext proof that an address typed by a person matches one cased by a provider, and reissue after both revocation and acceptance. The teams screen gets its first entries in the layout suite, at the minimum window with every list populated and with each of the two states that cover half of it — it had none, and it just grew four sections and a second line in the member row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6fe2b21781 |
Point every build at the hosted server, not just the ones that ship
The default was split on DEBUG so a clone would offer localhost and only an installed build would offer ssh.dodotech.cloud. That protected development launches from enrolling a device against production. It also meant the address in the box depended on how the binary was built, which is not what was wanted. Working against a local API now means typing http://localhost:5233 by hand. |
||
|
|
416f233657 |
Let the lock screen wear the theme instead of its own copy of it
Four controls on this screen carried a full set of metrics each, and three of them were a shared class rewritten by hand — UNLOCK is Button.primary down to the font weight, the fingerprint button is Button.secondary, the passphrase box is TextBox.field. Copies drift, and every difference between these and the originals was drift rather than intent: corner radii of 6 from the first design where v2's ladder says 9 for a button and 11 for a field, a label half a point small, and the missing content alignment that put UNLOCK's word against the top of its box and started this. Two things fall out that are worth naming, because neither was the reported fault. UNLOCK now has the pressed and disabled states the class carries, and before it had neither — pressing it ran Fluent's default, which replaces the accent fill with a theme grey, so the one button this application most wants to look deliberate flashed the wrong colour under a thumb. And the passphrase box is 44 rather than 48, which is what every other box on the phone is; the four pixels bought it nothing that the button under it does not already give the thumb. RESET keeps two properties of its own against Button.danger, and they are the two that carry meaning. That class is a DELETE beside a KEEP, where the red outline says which of two offered actions costs something. Here there is no pair, and a bordered red button under UNLOCK reads as the next thing to try rather than as the last resort the screen means it to be. The live-sessions disclosure is not a class anywhere — warn cards are plain borders on all three screens that draw one — but its radius was 6 against the 12 the other two use, so it joins the ladder without joining a class. |
||
|
|
6fb82a111e |
Sit UNLOCK's word in the middle of the button it is drawn on
A button's content alignment defaults to Stretch and the Fluent template binds it straight through, so a text block filling a 48-pixel button draws its line along the top edge of it rather than across the middle. That is why all seven button classes in Phone.axaml set VerticalContentAlignment, and it is why this one did not: the lock screen is the only place on the phone whose buttons carry their own metrics rather than a class, and it was written with the horizontal half of the pair and not the vertical. Its two neighbours are unaffected and not for the same reason, which is worth knowing before this is read as a pattern: RESET has no fixed height and a symmetric vertical padding, and the fingerprint button's content is a panel that stretches and centres its own children. Only the one with a bare string in a fixed-height box shows it. Left as its own set of metrics rather than folded into Button.primary, which it otherwise duplicates down to the font weight. The two differ in corner radius — 6 here against the 9 the v2 ladder gives a button — and changing that is a design decision rather than a fix to the thing that was reported. |
||
|
|
ce86a4ff72 |
Look the panel up, because the field the generator declares is never filled
The last commit crashed the phone before its first frame. `PhoneShell` took the `Body` field that Avalonia's name generator declares for `x:Name`, and that field is assigned in the generated `InitializeComponent` — which no view on this head calls, all of them loading their XAML directly instead. It compiles, it is null at run time, and this control dereferences it in its constructor. `TerminalScreen` has been looking its two named controls up with `FindControl` since it was written, for exactly this reason. This does the same, and the remark on the field now says why so the next person reaching for the shorter spelling finds the answer before the device does. Confirmed rather than guessed: the generator's output for this control is one method, and the assignment is inside it. |
||
|
|
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> |
||
|
|
b7335743d9 |
Make a host take what its group lends it, everywhere it is read
Step 4 of docs/adding-hosts-on-the-phone.md. The domain could resolve a host against its groups; nothing asked it to. This is the wiring, and it is mostly one change repeated: read the resolved host, not the stored one. TryBuildAuthentication and TryBuildConnectionRequest now take the resolved value beside the stored one, which is where group context was being lost. It is the only authentication resolution in the product — both heads and both transports come through it — so a host inheriting its binding would otherwise have been offered a password prompt on every screen at once. The credential-username fallback becomes three levels, and Complete still refuses an empty username, but now only after the chain has been walked; refusing before it would refuse exactly the hosts inheritance exists to serve. HostRowViewModel carries its ResolvedHost, resolved once when the list is built. Address, Authentication and Dialled read it, so a row cannot disagree with itself about what it dials — and MainWindowViewModel.Rank searches Address, so a host inheriting 2222 that displayed 22 would have been unfindable by the port it actually answers on. HostsBoundTo counts over the resolved binding, which is the difference between a warning and a silence: a key bound once on a group and inherited by twenty hosts named nobody, would have been deleted, and would then have refused all twenty at connect time. HostFields.From is answered by a refusal rather than by threading a group list through the sync engine. A relay host may not inherit its port. The reason is stronger than the convenience: a plaintext column is a derived duplicate the client supplies when it pushes *this* host, so an inherited port would make it depend on another item — editing a group would change what the relay dials for every host beneath it, except that nothing re-pushes those hosts, so the server would keep dialling the old port until each was next touched for some unrelated reason. A stale wire on the relay path connects the user to the wrong service. The editor distinguishes unset from explicit in both directions. An empty port box means "take the group's" and shows what that will be as a placeholder, following the group picker as it moves — a pre-filled 2222 would have been indistinguishable from one the user typed, and saving would have pinned it. The authentication picker gains a fourth entry, offered only to a host in a group, because for an ungrouped host it would behave exactly like the first. Which found a real defect while the tests were being written. Filing an ungrouped host into a group silently pinned it to a typed password: the picker had no "Inherit" entry when it opened, so it sat on "Password (ask each time)", and saving wrote that as a decision — the host would have been pinned to a prompt nobody asked for and the group's key would never have reached it. Two guards now: the picker is rebuilt when the group changes, and BuildHost writes AsksForPassword only for a host that had the alternative on offer. The group editor is here too, and the plan never assigned it a step. Without it no group can carry a default, so every line above would have been unreachable. It grows a parent picker that leaves out the group itself and everything beneath it — a courtesy rather than the guarantee, since a cycle assembled from two offline re-parents was never offered that list — and three defaults beside the name, each of which may be left empty because "lend nothing" is an answer. Tags are stored and not editable. TagIds merges, encodes and resolves; no screen can set one yet, and the editor carries the set through a save untouched so a client that can set them does not lose them to somebody editing a port. Eight new tests, and they dial. That is the point of them: a resolved value that never reaches SshConnectionRequest is a label, and every one of these failures would be silent — a host connecting to the wrong port, or being asked for a password it does not need, with nothing on screen admitting it. Verified by the whole suite: 1390 tests over nineteen projects, none failing. Both heads build. Nothing on the phone has changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8c04ba60b0 |
Build the three things the phone's + needs, before the + exists
Steps 1 to 3 of docs/adding-hosts-on-the-phone.md: the domain half. Nothing on either head has changed, which is deliberate — the plan orders these first because everything the editors will bind to has to exist and be merge-safe before a screen can offer it. HostGroupSecret gains a parent and four defaults, and the codec gains the version rule it never had. It stamped CurrentSchemaVersion unconditionally, which was harmless with one field and one version and stops being harmless here: upgrading one machine and renaming any group would have made that group uneditable on every machine still on the old build. It now emits the lowest version that loses nothing, so a flat group with no defaults still encodes at version 1, byte for byte, pinned against a literal. Tags become a real item over the reserved slot. Secret, codec, merge, cipher, repository, both registries, the EF entity and a generated AddTagItem migration. TagCipher names AadResourceType.Tag as a constant rather than casting the wire type, because Tag is 5 on the wire and 8 in the crypto enum and 5 there is Credential — a cast would seal every tag under the resource type for a password, encrypt and decrypt perfectly on the machine that wrote it, and only fail when another implementation refused the item, by which time the AAD is frozen into stored ciphertext. HostTag stays reserved and unused: the one thing the join buys over a set on the host is bought instead by merging TagIds per id. HostSecret grows TagIds and Port goes nullable, which is the change with the widest blast radius and the only one that loses an item rather than locking one. A host with no port of its own omits the property, an older build reads int Port as 0, and TryValidate refuses it — unreadable rather than read-only. That cost is confined to hosts which actually inherit, because the version is a maximum over the fields present; the alternative, writing 22 into every host, is the lie inheritance exists to stop telling. One decision the plan did not specify. "Three states where there were two" is four — key, credential, typed password, or the group's answer — and two nullable ids carry three. Naming neither id now means inherit, so AsksForPassword says "a typed password even under a group that lends a key" out loud. Only true is ever written and a decoded false folds back to null, so a host that never touched it encodes as it always did. Nothing already stored changed meaning: no group could lend a binding before this build, so every existing host resolves exactly as it did. HostInheritance is the resolver, and its visited set is load-bearing rather than defensive. Two clients can each re-parent A under B and B under A while offline; the merge sees one item against one item and the server sees ciphertext, so nothing upstream can refuse the pair. With inheritance the chain is walked at connect time, so an unguarded cycle is not an undrawable sidebar — it is a shell that never opens. Stopping at the first repeat degrades it to a group that reads as a root, and clearing the parent is the repair. A tag set turns out to be the one field on a host that can never ask the user anything. TagSet.ToIdMap keys by the value, so no key can hold two values, so the both-sides-moved-differently branch of the keyed merge is unreachable — asserted over the whole eight-row matrix. The conflict loop is kept anyway, because that proof is one edit from ceasing to hold and what it would cause is a discarded tag nothing records. Three guard tests failed by design and were fixed rather than relaxed: the ordered pull filter, the AAD pinning table, and the server's refusal of a plaintext parent — that last one survives with its reason rewritten, because the refusal now means "the parent is not the server's to hold" rather than "there is no such thing as a parent". The prose that said groups are flat is rewritten in all four places it appeared, not deleted. The five view-model sites that read Port directly now go through the resolver, which is a down payment on step 4 rather than the whole of it. HostFields.From still emits the stored port, and that is the one remaining place where an unresolved read would be a wrong wire rather than a wrong label. Verified by the whole suite: 1382 tests over nineteen projects, none failing. Both heads build. Nothing seen on a display, because nothing on a display has changed yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c9eca96ce7 |
Merge pull request 'Write down how the phone will add hosts, before it adds any' (#2) from add-hosts-on-the-phone into main
Reviewed-on: DodoTech/DodoSSH#2 |
||
|
|
9629b7d938 |
Write down how the phone will add hosts, before it adds any
The + button the design has asked for twice needs three things that do not exist: a tag, a group's parent, and a group's defaults. Two of them are refused on the record — HostGroupSecret argues groups are flat, and the theme argues an unused style is a claim the control exists. So the plan goes in first, with the decisions and the reasons. Tags become a real item over the reserved slot, a host names them, and membership merges through ThreeWayMerge.Map rather than as a whole value, so two people tagging one host both keep theirs — which is what HostTag was going to buy. Group defaults inherit rather than copy, shown as the field's placeholder, which is what makes editing a group afterwards mean anything. A parent arrives with a visited-set walk, because with inheritance a cycle is no longer an undrawable sidebar — it is a shell that never opens. Also written down: that Port has to go nullable and everything that touches, the byte pin it will trip, and the schema-version branch whose absence would make an inheriting host unreadable rather than read-only on every client that has not been upgraded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
359087f1ce |
Give the tabs their hover back, and the plus the shape its comment claims
Two things v2 broke in the last commit, both found by reading resolved brushes rather than markup, and both the same mistake: Avalonia has no specificity, so the later declaration wins, and a rule that restated the base instead of excepting from it went in below the rules it was supposed to be underneath. Making a tab a pill gave it a Background of its own, which the flat tab it replaced never had. That one detail moved where the hover has to live. `Button.flat:pointerover` is declared far above and had been supplying it; the moment `Button.tab`'s template rule set a Background, it won, and every tab in the strip stopped answering the pointer. Silently — a tab that no longer lights is not a crash and not a layout change, and nothing in a suite that measures heights and reachability can see it. The `+` lost more than that. It sits below as an exception — no outline, because it is not one of the things being chosen between — and a `.tab` rule declared after it was overriding the exception itself. It drew as a filled, outlined pill identical to a tab, contradicting the comment directly above it. So the base pill and its hover come first now and the exceptions follow, which is the order the rest of this file already uses and the order the Border.rowmark note further down was written about. The `+` clears the fill as well as the border, because an exception to a rule that sets both has to say both. The titlebar's search box had the same shape of error in geometry rather than colour. The design draws it at exactly 380 and centred, and stating that as a Width on the inner Border is what made it wrong: the button around it is free to shrink when the account name or the vault chip beside it is long, and a Border that will not shrink with it arranges outside its own parent — over the name on one side and over the window buttons on the other. MaxWidth on a stretching button gives the same 380 whenever there is room and gives way when there is not. The regression test is the point of this commit rather than an afterthought. It is the only test in that suite that reads a brush, and the gap it fills is exactly the one these two went through: everything else measures rectangles. It hovers a tab through the real input path and asserts the fill changes, then asserts the `+` is neither filled like a tab nor outlined like one. Checked against the broken ordering before being kept — it fails there and passes here, which is the only thing that makes a regression test worth committing. Verified by the whole suite: 1310 tests over nineteen projects, none failing, the layout suite now 70 cases. Both heads build. Still nothing seen on a display. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AZE3u99BNt6LzgTC5jhbz2 |
||
|
|
3627021420 |
Give the desktop the second design too, and the window the size it now needs
The desktop v2 design is the other half of the one the phone took last commit, and this is its chrome: a 190-pixel labelled sidebar where the 54-pixel icon rail was, a titlebar with the search box centred in it, and session tabs drawn as pills. The palette was already here — it is shared, and moved when the phone's did — so what this changes is shape rather than colour. **The window's minimum grew, and by exactly what v2 added.** The sidebar is 136 wider and the chrome 14 taller, so 880x560 became 1016x574. That is not a round number somebody liked: it leaves every screen the same 826x464 it was designed against, which is the arithmetic the layout suite is built on. Four of the tables stop fitting at 690 wide, so widening the sidebar and leaving the window alone would have broken them somewhere no test was looking. LayoutHarness carries the new constants and the suite still passes at the minimum, which is the whole reason it exists. The rail's five-character abbreviations are gone with the width that caused them — PINS and SNIPS are Pins and Snippets again — and each row gains a glyph and a count. A count is drawn only where one is real, so SFTP, Logs and Preferences show nothing rather than a zero: a transfer queue's depth is not how many files a screen holds, and a log has no total until it is read. The count beside Pins is the vault's own, not the Pins screen's VisiblePins, which is the filtered list and would have made the sidebar count whatever somebody had just typed into a filter box on another screen. Teams has no count for a related reason: they are read from the server when that screen is opened rather than on unlock, so a number there would read 0 until somebody had already been to look. One colour moved with it, finishing what the repalette started: the live-session summaries on the unlock and sign-out cards were Info, so the two heads disagreed about a fact the phone paints green. They match again. **Buckets became a destination rather than a mode**, which is what the design draws and what the phone already does. The HOST / BUCKET pair inside the files screen is gone; ShellScreen.Buckets draws the same TransfersScreen with the other picker, and the sidebar entry is what sets it. That also settles an old disagreement rather than merely moving it: TotalItemCount is keys plus passwords and excludes buckets, so the number beside the keychain used to disagree with the list under it, and now counts what that screen shows. There is one session behind both file destinations, so asking for the other kind while something is open is refused rather than obeyed — and refusing means staying put. An earlier turn of this had it move anyway and only decline to switch the picker, which put the S3 entry in the sidebar over a screen still listing an SFTP host: two pieces of chrome disagreeing about where you are, which is worse than the navigation simply not happening. The message that says so goes to Transfers.Status, which turned out to be drawn in the same grid cell as the connected chip — survivable while it was mostly read before connecting, and not once a refusal reports itself there. It has its own column now. The design has nine entries' worth of screens and draws five. Pins, Teams, Import and Preferences are built, working screens, so they keep their entries — the sidebar is labelled now and has the room, and dropping an entry would have stranded a screen rather than simplified anything. The Team vault card the design pins to the foot is not drawn: it is a second route to a screen already in the list, carrying a seat count nothing here produces. **The status bar survives the design that deletes it**, cut down to one thing. Two of the three facts it carried moved into the titlebar with v2 — the sync word is beside its dot and the shortcut hint is inside the box that uses it — so those are gone from it rather than printed twice. The third is Vault.Status, the only channel this application has for saying a save failed or a merge picked a winner. The design is a mock-up of an afternoon that goes well and has nowhere to put a sentence like that; dropping the bar would have meant dropping the sentence or repeating it on nine screens. What v2 draws and this does not is in docs/design-import-gaps.md, and it is the same list as the phone's for the same reasons: the forwarding screen and both its chips, the host detail's fingerprint, tags and last-session cards, the keychain's rotate button, the logs' FOLLOW pill and severity filters, and the session footer's latency. The terminal is not inset behind a rounded frame either — it is a native child window that composites above everything Avalonia paints, so the frame would clip nothing, which is the same answer the phone gave. **The light theme is not built.** Its accent is #6D5AE6, a different hue rather than a tint of the dark one, so it needs every colour doubled, a variant to switch on, the renderer's own page switching with it, and contrast checked twice. That is a piece of work rather than a setting, and it is separable from the layout — which is why this commit is the layout. The screens themselves are restyled through the shared vocabulary rather than rebuilt: corner radii, chips, cards and the accent's ink, all in App.axaml, so every screen moves at once. Their layouts are left alone deliberately. The design draws read-only detail panes and these screens carry the editors and forms it has no equivalent of, so replacing a layout with the mock-up's would have lost the half that is actually used. Verified by the whole suite: 1309 tests over nineteen projects, none failing, including the 68 layout cases that stand up real Avalonia and measure every screen at the new minimum. Both heads build. Not run on a machine with a display — see docs/manual-checks.md for what wants looking at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AZE3u99BNt6LzgTC5jhbz2 |
||
|
|
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 |
||
|
|
c00e5dbc5c |
Let the terminal's text be made bigger, and remember how big
Taking pinch-zoom off the phone left nothing in its place, and there was nothing on the desktop either. This is the replacement, and it is deliberately not the thing that was removed: zoom scales what has already been drawn, so the remote goes on wrapping to a width that is no longer on screen. Changing the font size refits the grid and reports the new column count, so the far end is told it has fewer columns. That round trip is the feature. The size is one number, owned by the shell. It has to be, for two reasons that pull the same way: it must survive a relaunch, and it must be reachable from a phone that has no Ctrl key to press. So the page asks and the host decides — a signed step over a new client opcode, answered with a size over a new server opcode. The phone's buttons and the desktop's chords arrive at the same place, and a size set by either is the size both remember. Stored in settings.json beside the cache rather than in it, and that is not laziness about a migration. The cache is encrypted and unreadable until a vault is unlocked, and the first terminal of a locked launch needs the size already. Nothing secret may go in that file; ClientSettings says so out loud, because the next person to add a preference is the one who needs to read it. Where it is reachable from differs per head, and only here. The phone gets A− and A+ on the connection line — not in the accessory row, which scrolls, and a control that fixes unreadable text must never be the thing that is off-screen. The desktop gets the three chords every terminal emulator has, answered by the page while a terminal has focus and by the window when it does not, plus a row in preferences that shows the current value and names the chords rather than replacing them. Someone whose terminal is too small to read is not in a position to go looking. Clamped 8 to 32. Below eight a monospace grid stops being legible and becomes a texture, and every column of it is still a column the remote is being told exists; above thirty-two a phone in portrait has too few columns to hold a prompt. The buttons disable at the ends rather than accepting presses that do nothing, which on a terminal reads as the application having stopped responding. The preferences screen's header comment claimed none of the design's terminal settings could be saved, and listed the three things that were missing to make one work. All three now exist, so it says which one is real and why the other five still are not. Verified with the protocol suite — including that the step byte round-trips signed, since read unsigned a step down arrives as 255 and clamps to the largest font, making "smaller" do the most dramatic available version of "larger" — a data-plane test that the chord is heard with no session registered, and five shell tests: the default matches the renderer's, both clamps hold, reset works, and a size chosen in one shell is there in a second one over the same profile directory. Layout suite and both heads build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5cb9ffaf9d |
Stop the phone's terminal being a page you can pinch
The terminal on Android could be pinch-zoomed and double-tap zoomed, which a terminal must not be: the grid is sized to the window by the fit addon and the column count is told to the remote, so zooming makes the visible width disagree with what the far end is wrapping to, and puts the cell under your thumb somewhere other than where you tapped. Three things, and the first is the one that made it look worst. The page had no viewport meta tag at all. An Android WebView with no viewport lays out at a notional 980 CSS pixels and scales the result down to fit, so a terminal built to fill the window was drawn small and then offered as something to zoom around in. width=device-width makes one CSS pixel one layout pixel, which is what the fit addon has been assuming all along and what a desktop WebView gives without being asked. Second, the gestures. touch-action: pan-y keeps the only one a terminal wants — dragging the scrollback — and refuses pinch-zoom and the double-tap zoom that fired on every attempt to place a cursor. text-size-adjust stops Android's own font inflation, which resizes text it judges too small without telling the page and leaves the characters no longer matching the grid that was measured. Third, the knob that actually disables zoom: BuiltInZoomControls on the WebView. user-scalable=no is in the viewport tag for completeness and does nothing on its own — Blink has ignored it since Chrome 48 and WebView follows Blink. That is worth knowing before somebody removes the C# and trusts the meta tag. The page and stylesheet are shared with the desktop head, deliberately, and none of it costs anything there: a desktop WebView already lays out at device width, and a Windows touchscreen should not be pinch-zooming a terminal either. This does not settle whether the phone should keep a browser-based terminal at all, which is the question actually asked. It does remove the reason it was asked, and every complaint about this renderer so far has turned out to be configuration rather than the approach. Verified by building the head in Debug and Release and by the desktop layout suite, which draws the same assets. Not verified on a device — nothing in this head is; see docs/android-port.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
52596aac76 |
Ask the Linux WebView for the one mode it can draw in this window
The terminal renders nothing on Linux, and does it in the most misleading way available: the page loads, the scripts run, the renderer connects, InvokeScript answers. Everything works except the pixels, so it reads as a broken terminal rather than as a host with nowhere to paint. Measured on Fedora 44 with Avalonia.Controls.WebView 12.0.1, by a spike that hosts a NativeWebView and reads AdapterInfo. The backend is WebKitGTK 2.52.5 — not the WPE one this repository's platform notes predicted, and Fedora packages no WPE WebKit at all, so that path was never going to be the answer here. In its default mode the adapter reports SupportedScenarios = NativeDialog: a window of its own, and nothing that can be hosted in place. Identical under X11 and Wayland, so it is the adapter's answer rather than a session problem. Setting ExperimentalOffscreen on the GTK environment arguments changes the same adapter's answer to OffscreenRenderer — the compositor-drawn mode, which is what the NativeWebViewCompositorHost mentioned in those same notes exists to host. MainWindow now sets it as the environment is settled. Windows and macOS are untouched by construction rather than by an OS check: the argument is a GTK type there and the handler does nothing. The platform notes carried this as "unproven, and still the largest risk in the plan". They carry the measurement now, including the part that is still unproven and the reason the spike could not settle it. What is NOT verified is that it now paints. An XWayland root capture is black under a Wayland compositor and RenderTargetBitmap does not capture a compositor surface, so both ways of looking at it from here failed. It needs eyes on a running client, and if the terminal is still blank the next question is whether it takes input at all — that separates "not drawing" from "not hosted". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a0d53b0c9d |
Give the phone its screens back by not stealing a data context
Signing in on the phone reached an unlocked shell showing PREFERENCES over the middle of the
screen, with a bottom bar whose four buttons did nothing. The buttons were fine. Every one
of them changed the shell's screen exactly as asked, and nothing moved, because an opaque
panel was sitting on top of the whole page area and never came down.
PendingScreen set DataContext = this in its constructor, so that Heading and Detail could be
written as plain bindings inside its own XAML. That is not a private arrangement: a binding
the parent writes ON one of these — IsVisible="{Binding IsPreferencesShowing}" in
PhoneShell — resolves against this control's data context, which was no longer the shell.
The binding looked for a shell property on a PendingScreen, found nothing, and left
IsVisible at its default. Its default is true. So the panel that says "this screen is not
built yet" was permanently visible, last in the Panel and therefore on top of the host list
and the keychain both — and the terminal underneath them.
The two properties are read with $parent now and the control inherits its context like every
other screen. There is no x:DataType on it any more either, so a plain binding here is a
compile error rather than a silently missing screen.
The desktop head has the other half of this lesson written down already: MainWindow gives the
terminal's IsVisible a FallbackValue precisely because an unresolved visibility binding does
not hide anything, it shows everything. That note was about the previewer. This is what it
looks like at runtime.
Verified by reproducing the mechanism rather than by reasoning about it: a control that owns
its data context ignores a parent's IsVisible binding and stays visible; one that inherits
obeys it. The head builds in Debug and Release. Nothing has been run on a device, as ever —
see docs/android-port.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
ffab2be22a |
Drop the formatting step, which spent minutes agreeing with the build
`dotnet format --verify-no-changes` re-analysed the whole solution before the build did, to reach a verdict the build reaches on its own: IDE0055 is an error in .editorconfig, EnforceCodeStyleInBuild is on and warnings are errors, so a misformatted file fails the build step. What the separate step bought was hearing about it a few minutes earlier, and it charged those minutes on every run. Checked rather than assumed, because the whole justification rests on it: appending a badly-spaced member to a source file produces three `error IDE0055` lines and a failed build with no format step in sight. Three places said the old arrangement out loud and would now be wrong on their own — the comment on the IDE0055 line, the conventions list in the README, and a note in platform-flags telling people to run dotnet format before pushing or CI would fail them. They say the build enforces it now. dotnet format is still how to fix what the build complains about; it just no longer gates anything. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ddf0dd6a2b |
Stop the transfer tests depending on which thread ran them first
Every test in TransferQueueingTests failed on the Linux runner with "The calling thread cannot access this object because a different thread owns it", and none of them had anything to do with the commits in that run. The class drained its rows through Dispatcher.UIThread.RunJobs(). That dispatcher is process-wide and belongs to whichever thread touched it first, and xunit runs each test class as its own parallel collection — so the moment a runner scheduled another class onto that thread ahead of this one, all nine died inside DispatcherOperation.Execute having asserted nothing about transfers at all. It passes locally and fails on a machine that schedules differently, which is the whole of why this took a CI run to find. TransfersViewModel now takes the poster it marshals through, defaulting to Dispatcher.UIThread.Post — the seam VaultViewModel's clipboard already is, for the same reason: a view model that reaches a process-wide UI object directly makes every test of it depend on a thread it does not choose. No head passes the parameter, so nothing about the running application changes. The test supplies a queue of its own and drains it, which is the same shape the dispatcher gave it. A poster that ran the action inline was tried first and is wrong: the transfer queue raises Changed from its pump thread as well as from the call that enqueued, so inline execution has a background thread adding rows to an ObservableCollection while the test reads it — it passed once and then failed a different test on the next run. Draining keeps every mutation on the thread doing the asserting, which is the one thing the dispatcher was providing that was worth keeping. One test added for the property that broke: queueing is reachable from any thread and must not care which. The class as a whole guards the seam — remove it and nothing drains, so every assertion about a row fails. Verified by reproducing the failure first: a throwaway probe that touched the dispatcher on one thread and posted and drained on another produced exactly the CI message. Then six consecutive Release runs of the app suite, all green, plus the layout suite, which builds a TransfersViewModel of its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
093f3904c1 |
Apply pending migrations at startup instead of asking for a second command
The API deliberately never migrated: it failed readiness while a migration was pending and named it, and a separate step applied them. That is the right split for a deployment with a release pipeline and the wrong one for a self-hosted server, where it means an image that boots, refuses traffic, and waits for somebody to know that dotnet ef exists. The schema and the code that expects it ship in the same image, so the image is where the two are reconciled now. Before RunAsync rather than in the background. A migration racing the first requests would let them through against a half-applied schema, and the first authenticated request is the one that provisions accounts. Failing to migrate therefore fails to start, which is the loudest signal available and the one an orchestrator already acts on. Concurrent starts take a Postgres advisory lock first. Without it two replicas rolled out together read the same empty history table, both apply the same migration, and the second dies on an object that already exists — a crash loop on the day of a schema change, which is the worst day to have one. The lock is held on a connection of its own because EF opens and closes one per command, and a session lock belongs to the connection that took it. The exception is a database that does not exist yet: there is nothing to hold a lock in, so that path migrates without one and says so. Two instances creating it at once still converges — one wins, the other restarts into the ordinary locked path — and refusing to start would leave a fresh deployment stuck on the step this removes. Database:AutoMigrate turns it off for the deployments that own their schema: a migrator job, a rollout where new code must run against the old schema first, or a database user denied DDL. With it off the behaviour is exactly what it was, and the health check now explains which of the two situations a pending migration means. Verified against a throwaway PostgreSQL container: an empty database gets all seven migrations applied before the port opens, the tables land in the dodo schema, and a second start logs the schema up to date and serves. The API suite passes, which exercises the startup path once per assembly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
73c7e2a1e3 |
Point a release build at the hosted server, and a debug build at the clone
The shipped default was http://localhost:5233, which is the address the API serves under dotnet run and a machine an installed application is not running. Somebody who installs a release and accepts the field unread is signing in to nothing. Two defaults now, because the two audiences never overlap. A release build offers https://ssh.dodotech.cloud, so a first launch needs no address typed at all. A debug build keeps the loopback address, and that half matters as much: shipping the hosted address into a clone would point every development launch at production, and sign-in is the call that provisions an account there. The remark carries the reason the schemes differ, since the pair now looks like an oversight rather than the deliberate thing it is — the API's first launch profile is plaintext on 5233, and an HTTPS client meeting a plaintext port reports a TLS failure that reads like a certificate problem. The test spells out both branches rather than asserting the constant, which would pass however it were edited. What it is really guarding is that a release never ships a developer's loopback address and a debug build never points a clone at production, and it can only guard those by naming them. Verified by running the shell suite in Debug and in Release. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
39b7e5620f |
Say which half of signing in is happening, and which half failed
A sign-in against a production Keycloak was reported as the shell hanging on "Opening your browser to sign in…" and then, some time later, saying "The server returned 500". Both halves of that are the message's fault. The browser flow had already succeeded — the provider authenticated the user, the code came back, the tokens were exchanged — and what was actually happening was a round trip to the DodoSSH server for the account. The screen went on describing a browser nobody was waiting for. The status now moves when the browser half ends, so the wait that follows is attributed to the server being asked rather than to the browser that has already answered. The failure gets the same treatment. "The server returned 500" is the API client's phrase for any server it talks to, and read underneath a sign-in button it is naturally taken as the sign-in having failed — which sends somebody to their identity provider's logs to find out why a thing that worked did not work. It now says signing in succeeded, names the host that failed afterwards, and says the reason is in that server's logs, because this side cannot know more than that. Nothing here fixes the 500. It changes which of the two servers the next person goes and looks at, which was the actual cost of the old message. Verified against the shell suite, including the case that asserts a failed command leaves the window enabled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
1db8bed872 |
Let a cancelled sign-in end the sign-in rather than the timeout
Backing out of the login page on Android left the shell showing "Opening your browser to sign in…" with the button disabled for five minutes. Nothing was wrong except that nobody told it: the redirect callback only ever completed when an intent arrived, so a user who pressed back was waiting on OidcClient's browser timeout to expire before the flow failed and the button came back. There is no cancel event to subscribe to on this platform. Pressing back, dismissing the browser and closing a provider's error page are indistinguishable from here — the browser goes away and this application is foreground again with nothing delivered — so being resumed while a sign-in is still waiting is the signal, and the only one there is. The launcher records that a browser took the intent, OnResume fails the wait, and the guard means the resumes that have nothing to do with signing in (a launch, recents, the keystore's fingerprint prompt) go through untouched. An exception rather than a cancellation, because OidcClient reads a cancelled wait as its own timeout expiring and would report five minutes passing to somebody who waited two seconds. It cannot steal a successful sign-in either: Android delivers the redirect to OnNewIntent before resuming the activity, so the completion is already settled and the attempt does nothing. The enrollment key-binding trip through the browser is covered by the same change, since it waits on the same callback. The OnNewIntent remark had been sitting above OnResume, describing a method two below it. Moved back, since the new remark wanted the space and the old one was wrong where it was. Verified by building the head in Debug and Release. The behaviour itself is unverified for the reason docs/android-port.md gives about this whole head: nothing has been run on a device. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
08a820adcf |
Stop the login step interpolating a comment I wrote in it
The secrets were never the problem. The log shows both arriving masked, which is what a runner does with a value it holds — so the repository secrets were configured correctly the whole time, and the guidance about Actions Variables was wrong. What broke was the guard added to diagnose them. Its comment contained an expression delimiter written out literally to explain what an unset secret renders as, and a shell comment is not a comment yet at that point: the runner substitutes the whole script before any shell sees it, so it tried to evaluate an empty expression and failed the step with a parse error carrying no line number. The step never ran, and push then reached the registry with nothing to authenticate as — "no basic auth credentials", which looks precisely like the missing-secret problem the guard was added to rule out. The comment now describes the delimiter instead of containing one, and warns the next person, since the failure is invisible to review and to every local check: the file is valid YAML and the script is valid shell. Verified with a scan for empty expressions across every run block in the file — one before, none after — and by running the step's script with credentials set, which passes the guard and gets a 401 from the real registry. That is the right answer for an invented password, and it means the endpoint is reachable and the path through this step is sound. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2bc0d4d89f |
Say which credential is missing instead of letting docker guess
The registry secrets are reportedly not arriving, and the job could not have told anybody
which one or why. An unset secret is not an error anywhere upstream: ${{ }} renders a
missing value as an empty string, so docker gets --username "" and replies with something
about credentials — which reads as the registry rejecting a login rather than as a value
that never left the settings page.
Checked before use now, and reported by length rather than by value. Gitea masks known
secret values in logs, but a mask is only as good as the runner's bookkeeping, and a length
answers the only question actually being asked: did anything arrive at all. The message
names the page to look at, and names the neighbouring one too, since Actions Variables and
Actions Secrets sit next to each other and only one of them is readable through the secrets
context.
This does not fix the credentials. It converts a confusing failure into a specific one, so
the next run distinguishes "the secret is empty here" from "the registry refused these" —
two problems with nothing in common that currently look identical.
Verified by running the step's script with both variables set empty, which is the reported
symptom: it names both, points at the settings page and exits 1 before docker is called.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
a515a35804 |
Build the image with BuildKit rather than the builder Docker is retiring
"DEPRECATED: The legacy builder is deprecated and will be removed in a future release." Not a failure — the image was built and the job carried on — but a countdown, and one the last commit walked straight into: Alpine's docker-cli package does not carry buildx, so giving the job a working client left it building the old way. Two packages instead of one now. With the plugin present `docker build` routes through BuildKit on its own, which also stops the Dockerfile's independent stages being serialised, so this is slightly faster as well as not deprecated. buildx is wanted rather than required, and the difference is deliberate. Missing it costs a warning and a slower build; the image is still correct. So each install branch ends in `|| true` and the check afterwards reports instead of exiting — a distribution with no package for it should not be able to turn a release into a red build over a plugin. The comment above the build step said this job needed "no buildx plugin", which was true when the build was the only thing being weighed and is not true now. It says what is actually wanted, and what is still not: no QEMU, no builder instance to create and tear down, no third-party action to re-pin. Verified in Alpine containers with the socket mounted, in all three states this can be in: nothing installed, the client present and buildx missing — which is exactly what produced the warning — and buildx unavailable with no package manager to fix it, which warns and exits 0. The API image builds through BuildKit with no deprecation notice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5ddbca49d3 |
Give the image job a docker client to go with the daemon it already had
Exit 127, `docker: command not found`, from the build step of the image job. The daemon was never the problem and never missing: Testcontainers speaks to /var/run/docker.sock from a .NET library, so every integration suite in the build job had been starting PostgreSQL, Keycloak and an sshd on this runner while `docker` was not a command on it at all. Having a socket and having a client are two different things to have, and this runner had one. It failed late for the same reason it was easy to miss. Node, git, the SDK and the tags all came up fine, so the job looked healthy right until the line that actually needed the binary. The client only. There is a daemon answering on that socket already — installing an engine would start a second one beside the one in use, which is a worse outcome than the error. Verified by running this step's own script in an Alpine container with the socket mounted: it installs docker-cli, the client then reports server 29.6.2 across the socket, and the API image builds to completion from inside that container with the repository as its context. Which is as close to the runner as this can be checked without being it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |