Public Access
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.
This commit is contained in:
@@ -58,7 +58,11 @@ the Npgsql connection string** — the default; do not enable multiplexing.
|
||||
- One place enforces revision, change-log and ACL invariants. That halves both the endpoint
|
||||
count and the authorization surface, which is the main reason for the single write path.
|
||||
- Delta pull makes frequent polling cheap, so multi-device feels live; push notification over
|
||||
SSE or the existing WebSocket can layer on with polling as the fallback.
|
||||
SSE or the existing WebSocket can layer on with polling as the fallback. **That has since been
|
||||
built — see [ADR 0012](0012-realtime-push.md)** — and nothing in this ADR changed to accommodate
|
||||
it. The socket carries a notice naming a vault and a sequence, whose answer is the delta pull
|
||||
above, so there is still exactly one path that applies a change; and polling is still what
|
||||
guarantees a pass rather than a legacy route kept for old clients.
|
||||
- Conflict resolution is entirely client-side. The client retains a `BaseCiphertext` common
|
||||
ancestor and performs a field-level three-way merge for structured items, or creates a
|
||||
visible conflicted copy for opaque ones. **It must never silently drop a key or a host.**
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
# ADR 0012 — A WebSocket that carries notices, not data
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-08-04
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0003](0003-sync-protocol.md) built a delta pull that is cheap enough to run on a timer, and
|
||||
the client does: one pass a minute. That is the difference between a colleague's change appearing
|
||||
"soon" and appearing *now*, and it shows up in three places that are not equally forgivable.
|
||||
|
||||
- **A vault shared with you** arrives on the next pass. `AdmitNewVaultsAsync` says so in its own
|
||||
remarks — "the recipient is handed nothing — there is no push channel" — and the README repeats
|
||||
it. Sharing works and looks broken.
|
||||
- **Two people editing one keychain** see each other up to a minute late, which is long enough to
|
||||
make the same edit twice and produce a conflict that nobody needed to have.
|
||||
- **A revoked grant** keeps serving a client that has not noticed yet, for up to a pass.
|
||||
|
||||
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 does not converge on *immediate* — it converges
|
||||
on a busier server that is still late.
|
||||
|
||||
There is also a second thing coming that this decision has to not preclude. The intended feature is
|
||||
a **shared terminal session** — one person's shell, watched or driven by another, TeamViewer-shaped.
|
||||
That is bidirectional, continuous, and latency-sensitive in a way a keychain notice is not.
|
||||
|
||||
## Decision
|
||||
|
||||
### One WebSocket per signed-in client, at `GET /api/v1/events`
|
||||
|
||||
Subprotocol `dodossh.events.v1`. The client opens it after unlock and keeps it open; the server
|
||||
sends a notice whenever something the client can read has changed.
|
||||
|
||||
**Not SSE.** Server-sent events would carry today's notices perfectly well and would be less code.
|
||||
It is one-directional, so the shared-session feature would need a second mechanism next to it, and
|
||||
then two transports would need reconnection, authorization and lifetime rules that agree. The cost
|
||||
of a WebSocket over SSE is small; the cost of two transports is not.
|
||||
|
||||
**Not SignalR.** It brings hub protocol negotiation, its own serialisation and transport fallbacks,
|
||||
none of which are wanted here: `DodoSSH.Contracts` and its source-generated serialiser are "the
|
||||
actual contract between the two sides", and a second wire format alongside it is exactly the silent
|
||||
drift `Setup/Json.cs` records having already cost this project once.
|
||||
|
||||
### The notice carries no ciphertext
|
||||
|
||||
A `vault.changed` frame is `{ kind, vaultId, sequence }` and nothing else. The client's answer to it
|
||||
is the pull it would have done on the timer anyway.
|
||||
|
||||
This is the load-bearing decision, and it is worth being explicit about why the tempting alternative
|
||||
is refused. Pushing the changed items themselves would save a round trip and would fork the code
|
||||
path that applies a change into two — one that arrives by pull and one that arrives by socket — with
|
||||
the cursor, the merge and the tombstone rules duplicated across both. ADR 0003 put every mutation
|
||||
through one write path for exactly that reason; this keeps every *read* on one path for the same
|
||||
one. The socket decides *when* to sync. It never decides *what* a vault contains.
|
||||
|
||||
It also means a dropped notice is harmless, which is what lets everything below be simple.
|
||||
|
||||
### Polling stays, and is the fallback rather than a legacy path
|
||||
|
||||
The one-minute pass is unchanged. The socket makes it *early*; it does not make it *necessary*. A
|
||||
client on a network that eats WebSockets, an older client, a server that has the feature off, a
|
||||
notice dropped under backpressure, a second API replica that did not see the write — every one of
|
||||
those degrades to what the product does today, which is correct and up to a minute late.
|
||||
|
||||
Nothing may be reachable only by socket. That is a rule about future features, not an observation
|
||||
about this one.
|
||||
|
||||
### Authorization: the bearer token on the upgrade, not a ticket
|
||||
|
||||
[ADR 0004](0004-relay-authorization.md) gives the relay a two-step ticket so its WebSocket carries
|
||||
no API authority. This one goes the other way and takes the ordinary bearer JWT on the upgrade
|
||||
request, which is an ordinary authenticated HTTP request. The difference is not inconsistency:
|
||||
|
||||
- The relay's socket is a **byte pipe to a third party**, and its whole authorization decision —
|
||||
which host, which IPs, which port — is made *before* the socket opens and never revisited. It is
|
||||
also the extraction seam for a standalone relay process that must not hold ACL code.
|
||||
- This socket is a **view of the caller's own vault list**, and it has to keep answering "what may
|
||||
this account read" for as long as it is open. It needs the full ACL context, in-process, for the
|
||||
life of the connection. A ticket would carry that context in a token instead, and it would be
|
||||
wrong the moment the account's access changed.
|
||||
|
||||
A long-lived connection authorised by a short-lived token is the problem this creates, and it is met
|
||||
head-on rather than ignored:
|
||||
|
||||
1. **The socket does not outlive the token.** The `exp` claim is read at accept, and the connection
|
||||
is closed with `4401` when it passes. The client reconnects with a fresh token; that is a
|
||||
sub-second gap in a channel whose failure mode is already "poll instead".
|
||||
2. **The vault set is re-resolved periodically** (`Events:AccessRefreshInterval`, default five
|
||||
minutes) as well as on the changes that are known to affect it. A withdrawn grant therefore stops
|
||||
producing notices within that window at the latest, and immediately in the ordinary case.
|
||||
|
||||
Both are bounds on **metadata** — the fact that a vault changed and roughly when — because that is
|
||||
all a notice contains. Nobody's ciphertext is behind this socket, and a client that stayed subscribed
|
||||
one interval too long could still not read a byte of it: reading requires a vault key grant, which
|
||||
this server has never held.
|
||||
|
||||
### The frames
|
||||
|
||||
Text frames, JSON, `DodoSshJsonContext`. Server to client:
|
||||
|
||||
| kind | meaning |
|
||||
| --- | --- |
|
||||
| `hello` | accepted; carries the heartbeat interval and the vault count subscribed |
|
||||
| `vault.changed` | `vaultId` moved to `sequence`; pull it |
|
||||
| `vaults.changed` | the set of vaults this account can reach is different; re-read it |
|
||||
| `ping` | heartbeat; the client answers `pong` |
|
||||
|
||||
Client to server: `ping`, answered with `pong`. Nothing else — subscription is decided by the server
|
||||
from the caller's access, not asked for by the client, because a client that could ask to subscribe
|
||||
to a vault id is a client that can probe for vault ids.
|
||||
|
||||
`kind` is a **string**, not an enum, and that is deliberate. `UseStringEnumConverter` throws on a
|
||||
value it does not know, so a newer server sending a kind an older client has never heard of would
|
||||
not add an unknown frame — it would break that client's socket entirely. A string is ignored
|
||||
instead, which is what makes the table above extensible. `ProblemCodes` is the same shape for the
|
||||
same reason.
|
||||
|
||||
### Where the shared session will attach
|
||||
|
||||
The socket is the seam, and one thing about it is chosen now so that it need not be renegotiated
|
||||
later: **session data will be binary frames on this same connection, not JSON on the table above.**
|
||||
Terminal output base64'd into a JSON envelope would cost a third of the bandwidth for nothing, on
|
||||
the one payload here that is continuous rather than occasional. Control — offer, accept, resize,
|
||||
end — is JSON like everything else.
|
||||
|
||||
That is as far as this ADR goes. Two questions are open and are not being answered by implication:
|
||||
whether a shared session's bytes go through the API at all or peer-to-peer past it, and what
|
||||
end-to-end encryption means when the second party is watching a stream rather than holding a key.
|
||||
Both are ADR 0001 questions and deserve their own decision. What this one buys is that they will not
|
||||
also be transport questions.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Fan-out is in-process, and the deployment is therefore single-node for this feature.** Every
|
||||
connection is held by the node that accepted it; a write handled by another node produces no
|
||||
notice on this one. `IVaultEventPublisher` is the seam a backplane implements — PostgreSQL
|
||||
`LISTEN`/`NOTIFY` needs no infrastructure this stack does not already run — and it is deliberately
|
||||
**not implemented**, because an untested backplane is worse than a documented gap. Multiple API
|
||||
replicas do not break: they degrade to polling, which is the state before this ADR. `/api/v1/meta`
|
||||
advertises `events` so a client knows which it is getting.
|
||||
- **Per-connection queues are bounded and drop the oldest.** A notice is "pull vault X, which is at
|
||||
least at sequence N", so the newest is strictly more useful than the one it displaces and the
|
||||
client's answer is identical either way. A slow reader costs itself latency, never the publisher's
|
||||
progress — the publish path never blocks and never awaits a socket.
|
||||
- **The publish happens after the transaction commits**, outside the advisory lock ADR 0003 takes.
|
||||
A notice sent from inside it would name a sequence a reader cannot yet see, and would hold the
|
||||
per-vault write lock across a socket write.
|
||||
- **A client is notified of its own writes.** It pushed, so it already pulled; the extra pass finds
|
||||
nothing. The client coalesces notices over a short window rather than the server suppressing an
|
||||
echo, because suppressing it correctly needs a per-*device* identity on the socket and the same
|
||||
user's other machines must still be told.
|
||||
- **Connections are capped** per user and per node (`Events:MaxConnectionsPerUser`,
|
||||
`Events:MaxConnectionsTotal`). A socket is cheap but not free, and an unbounded count of them is a
|
||||
denial of service that authenticates first.
|
||||
- The feature can be turned off entirely (`Events:Enabled`). A deployment behind a proxy that will
|
||||
not upgrade should say so rather than have every client discover it by failing.
|
||||
|
||||
### Rejected
|
||||
|
||||
- **Shorter polling.** Cheaper to build, converges on a busier server that is still late.
|
||||
- **Long polling.** No new transport and genuinely immediate, but it holds a request thread and a
|
||||
connection per client for the same money as a WebSocket while offering none of the bidirectionality
|
||||
the shared session needs.
|
||||
- **Pushing the changed items down the socket.** Saves a round trip; forks the apply path in two. See
|
||||
above.
|
||||
- **Client-chosen subscriptions.** A `subscribe(vaultId)` frame is an existence oracle for vault ids,
|
||||
which is the disclosure `SyncPullEndpoint` answers 404 rather than 403 to avoid.
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
# ADR 0012 — Distributing the desktop client, and letting it replace itself
|
||||
# ADR 0013 — Distributing the desktop client, and letting it replace itself
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-08-04
|
||||
@@ -43,7 +43,7 @@ not degrade the product, it would remove the terminal.
|
||||
|
||||
Velopack's path was checked against that and reintroduces nothing: `Setup.exe` is an ordinary Win32
|
||||
executable that unpacks a directory and creates shortcuts, there is no package manifest and no package
|
||||
identity, and the process therefore stays an ordinary desktop process. Manual check 15.4 is what would
|
||||
identity, and the process therefore stays an ordinary desktop process. Manual check 16.4 is what would
|
||||
notice if that ever changed, because it connects a real shell from the installed build.
|
||||
|
||||
### 2. The pack id is `DodoSSH.Desktop`, and it is irreversible
|
||||
@@ -157,7 +157,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
||||
> | **Add Telnet**, and **Serial** in the toolbar | Omitted. `ISshConnection` is the only transport there is. This is also why the card subtitle's `ssh` is a constant today rather than a reading — it is stated in `HostRowViewModel.Summary`, which is the one place in this interface where a constant is printed on purpose. |
|
||||
> | **+ SSH ID, Certificate, FIDO2** | Omitted. `IDENTITIES` and `CERTIFICATES` have been on this document's list since the first import — neither is even a reserved `SyncEntityType` — and there is no security-key path anywhere in the SSH layer. One control offering three item types that do not exist. |
|
||||
> | The **Backspace / Default** row | Omitted. It is a terminal setting, and the client has no preferences store and no frame to carry one to the renderer — see the Preferences section. It would be a control whose value could not survive the window closing. |
|
||||
> | The **chevron beside the vault name** | The name alone, on the pane about an existing host: an item cannot be moved between vaults, because the two are encrypted under different keys and moving one is a delete and a retype. The half of the question that *does* have an answer — where a new host goes — is asked in the host editor, as a picker beside the name; keys, passwords and buckets take theirs from the keychain screen's standing picker instead. |
|
||||
> | The **chevron beside the vault name** | The name alone, and the move behind the pane's ⋯ menu instead. A host *can* now be moved between vaults, so the gap is no longer that there is nothing to offer — it is that a chevron on a subtitle implies an edit, and this is not one: the two vaults are encrypted under different keys, so it is a re-seal into one and a tombstone in the other, the host takes a new id, and its group and tags stay behind. A control that implied "just change this field" would be describing something else. Where a *new* host goes is still asked in the host editor, as a picker beside the name. A group moves too, from its card's right-click menu, and takes its nested groups and every host filed under them; keys, passwords and buckets take theirs from the keychain screen's standing picker and cannot be moved yet. |
|
||||
> | **Show more ⌄** | Not drawn as a disclosure. What it would hide — notes, the relay switch, forgetting the host key — is in the editor, one press away, and a second fold inside a pane that already scrolls is a second place for a field to be missing from. |
|
||||
> | **Port Forwarding** in the sidebar | Nothing, for the third time in this document. |
|
||||
> | The host grid's toolbar avatar, share and tag-filter controls | Omitted, as in v3 and for the same reasons. |
|
||||
@@ -307,7 +307,7 @@ caption buttons and window title drawn on top of the application's own — two s
|
||||
| Tag chips (`nginx`, `eu`, `pg16`) | client-domain | A tag item type, and a way to put one on a host. | **Shipped.** `Tag = 5` is a live item kind and `HostSecret.TagIds` names them, merged per tag so two people tagging one host both keep theirs — which is what `HostTag = 6` was going to buy, so it stays reserved and unused. Chips are drawn on host rows on both heads and toggled in the host editor, which also creates one inline; renaming and deleting are a TAGS category on the keychain screen. The filter box still searches name, address and notes only — a chip is read rather than typed. |
|
||||
| Groups `PRODUCTION` / `STAGING` / `PERSONAL` | client-domain | A host-group item type (`HostGroup = 4`, reserved) or a group field on `HostSecret`. | **Shipped**, as both: `VaultHostGroup` is a synced item kind and `HostSecret.GroupId` names one. A group carries a `ParentId` and the defaults its hosts inherit, and the two heads draw the nesting differently on purpose. The desktop is a grid of cards holding one level at a time, the way a directory pane holds one directory; the phone has no room for a row of cards, so it flattens the whole tree into one heading per group in label order with no indentation. A keychain with no groups renders exactly as it did before on both — one flat list, no headings and no cards. |
|
||||
| Group badge `TEAM·PLATFORM` | server | **Built in M3.** | The vault's name on each row, and the personal vault ordered first. Not the team's name: two of a team's vaults would then carry the same badge and the badge would be naming the wrong thing. Distinct from the groups above, and deliberately so — a group is a shelf the user chose, a vault is who can read the item. |
|
||||
| Groups on a **team's** hosts | client-domain | A vault id on each group row for rename and delete, and a way to tell two vaults' identically-named groups apart in a list with one heading per group. | **Half shipped, and the half that shipped had to.** Every readable vault's groups are now read into the resolution map, because a group lends a port, a username and a binding — so a host whose group went unread would silently dial 22 as nobody while the machine is on 2222 as `deploy`. A missing heading is cosmetic; a missing port is a connection to the wrong place. The editable list is still the active vault's alone, so a group a teammate made has no card and no heading and cannot be renamed from here. A host filed into one is drawn at the outermost level of the desktop's grid and under UNGROUPED on the phone — reachable either way, which is the point — with the chip on its card still naming the group, because the label is resolved through the wide map while the level is decided from the narrow list. |
|
||||
| Groups on a **team's** hosts | client-domain | A vault id on each group row for rename and delete, and a way to tell two vaults' identically-named groups apart in a list with one heading per group. | **Shipped, and it is what makes a shared vault an arrangement rather than a heap.** Both halves are paid for: the row carries the vault it came out of, so rename and delete go back to it, and the vault's name is drawn beside the group's on the desktop's cards and the phone's headings wherever the session holds more than one — which is what lets two `production` groups sit side by side. The group editor asks which vault a new group goes into, on the terms the host editor's picker set: while adding only, hidden at one writable vault, and never offered afterwards, because the two are encrypted under different keys. The parent picker is that vault's alone, for the reason the host editor's group picker is — a parent in another vault is a level half the key holders cannot resolve. Dragging a host card onto a group card in another vault is refused with the reason, rather than filing it under an id nobody in its own vault can read. The resolution map stays wider than the list and still spans hidden vaults: a group lends a port, a username and a binding, so a host whose group went unread would silently dial 22 as nobody while the machine is on 2222 as `deploy`. |
|
||||
| Per-host status dot, three colours | client-ssh | The amber state would mean "reachable but not connected", and nothing here ever probes a host. | Two states, both real: green when a terminal is open on that host, grey when not. |
|
||||
| `· ⤷ bastion-eu` in the host subtitle | client-ssh | **Jump hosts are data-only.** `HostSecret.JumpHostIds` is a `JumpChain` that is stored, encrypted, synced and three-way merged — and nothing reads it at connect time. `SshConnectionRequest` carries one host. | Omitted. The stored chain is preserved untouched by every edit. |
|
||||
| `SPLIT ⌘D` and side-by-side panes | client-ssh + ui | The renderer stacks panes and shows one (`terminal.css`: `.pane { position:absolute; inset:0; display:none }`). Tiling needs a real pane geometry and a splitter. | Omitted. Tabs ship instead, over the same one-WebView multiplexing. |
|
||||
|
||||
+163
-25
@@ -324,7 +324,8 @@ phone's, whose list has no room for a row of group cards and draws the whole tre
|
||||
host filed, the grid says so in a sentence rather than sitting empty.
|
||||
|
||||
**Then press a group card once.** It is marked as chosen and **nothing else happens** — the grid is still the
|
||||
level it was, and EDIT and DELETE now aim at that group. **Then double-press it.** The group opens: its hosts
|
||||
level it was, and no buttons appear beside the GROUPS heading: editing and deleting a group are on the card's
|
||||
own right-click menu, which is 7.9. **Then double-press it.** The group opens: its hosts
|
||||
are the grid, the trail above the cards reads `ALL HOSTS › <name> ›`, each card carrying the group's name as
|
||||
an accent chip, and the card grid shows what is *inside* that group rather than every group in the keychain.
|
||||
Pressing ALL HOSTS goes back to the outermost level.
|
||||
@@ -347,9 +348,9 @@ Make two groups and file one under the other with the parent picker in the group
|
||||
|
||||
**Pass:** only the outer group has a card to start with. Double-press it and the inner one is the only card
|
||||
shown, with the trail reading `ALL HOSTS › <outer> ›`. Double-press that, and the cards disappear entirely —
|
||||
it has nothing inside it — while the trail, EDIT and DELETE stay: with no card selected the two buttons act
|
||||
on the group the trail ends with, so a group with nothing in it can still be renamed after being opened.
|
||||
Pressing the **middle** crumb goes back one level rather than all the way out.
|
||||
it has nothing inside it — while the trail stays. Pressing the **middle** crumb goes back one level rather
|
||||
than all the way out, which is also how a group with nothing inside it is renamed: back out to the level
|
||||
where it has a card, and right-click that.
|
||||
|
||||
**Failure means:** cards for groups that are not at this level is `VisibleGroups` having been bound past —
|
||||
the flat `Groups` is the phone's and the lookups'. A group that cannot be reached at all is worse and is the
|
||||
@@ -370,13 +371,35 @@ takes only the group that is open.
|
||||
|
||||
### 3.3 Deleting a group with hosts in it
|
||||
|
||||
Select a group with hosts and press DELETE.
|
||||
Right-click a group with hosts in it and choose **Delete…**. Do it twice: once leaving the tick alone, and
|
||||
once — on another group — ticking it.
|
||||
|
||||
**Pass:** the question names how many hosts are filed under it and says they stay. Agreeing removes the
|
||||
group; the hosts lose their chip and are otherwise unchanged.
|
||||
**Pass:** the question names how many hosts are filed under it, says they stay and move to UNGROUPED, and
|
||||
offers a tick that would delete them as well. The tick starts clear, and it starts clear again on the next
|
||||
group even if it was set on the last one. Left clear, agreeing removes the group and the hosts stay, without
|
||||
a chip and otherwise unchanged. Ticked, the hosts go with it — and only the hosts that were filed under that
|
||||
group. A group with nothing under it is asked no second question and shows no tick.
|
||||
|
||||
**Failure means:** if the hosts vanish, the delete is rewriting host payloads, which it must not — see
|
||||
`HostGroupRepository`.
|
||||
**Failure means:** a tick that carries from one question to the next is the reset in `OnPendingDeletionChanged`
|
||||
having gone, and it deletes machines on the strength of a decision about a different group. Hosts that keep
|
||||
the chip after an unticked delete are the unfiling not happening: they still name a group that is gone, which
|
||||
is what this used to do on purpose and no longer should.
|
||||
|
||||
### 3.3a Moving a group to another vault · **needs a second vault**
|
||||
|
||||
Build `outer › inner` with a host in `inner`, all in your personal vault, then right-click **outer** and
|
||||
choose **Move to another vault…**. Pick the shared vault and press MOVE.
|
||||
|
||||
**Pass:** the panel says what travels and what does not before you press anything. Afterwards all three items
|
||||
carry the destination's badge, `inner` is still inside `outer` and the host is still inside `inner` — every
|
||||
one of them under an id it did not have a moment ago. The sentence names the vault, the counts, and the fact
|
||||
that the group now sits at the top level if it was nested. Nothing is left behind in the vault it came from.
|
||||
|
||||
**Failure means:** a host under UNGROUPED in the destination is the group id having been carried across
|
||||
rather than remapped — the ids are the destination's making, so every reference has to be rewritten as its
|
||||
target lands. Anything still in the source vault is a partial move, which is survivable by design but should
|
||||
not happen with the network up: the groups are written top-down and the hosts last, so an interruption leaves
|
||||
hosts behind and never a shelf with nothing on it.
|
||||
|
||||
### 3.4 A group deleted on another machine · **needs two machines**
|
||||
|
||||
@@ -795,6 +818,17 @@ With host A selected, right-click host B and choose Delete.
|
||||
wrong machine. `HostGridTests` covers both halves headlessly, so this is a confirmation that a real popup
|
||||
behaves as the headless one did.
|
||||
|
||||
**And the same on the group cards above.** Open a group, then right-click a card inside it and choose
|
||||
Delete.
|
||||
|
||||
**Pass:** the question names the **card**, not the group that is open — and Open on that menu goes into the
|
||||
card, rather than back out to ALL HOSTS. Right-clicking the space around the group cards opens no menu.
|
||||
|
||||
**Failure means:** the menu is reading `GroupTarget`'s fallback, which is the group whose contents are on
|
||||
screen rather than the card the pointer is on. This menu is the only way to edit or delete a group on the
|
||||
desktop — there are no buttons beside the GROUPS heading any more — so a menu aimed wrongly is the whole of
|
||||
the mistake.
|
||||
|
||||
### 7.10 Clicking a host in the palette connects
|
||||
|
||||
Ctrl+K, then click a result with the mouse rather than pressing Enter.
|
||||
@@ -1279,6 +1313,31 @@ rather than a broken role.
|
||||
under the people who share it is an administrative act reached without the role for it. The server refuses
|
||||
it too — this is the interface not offering what the server would turn down.
|
||||
|
||||
### 12.10 A group made in a shared vault arrives as a group, not as a heap · **needs two accounts**
|
||||
|
||||
1. As Alice, on HOSTS, press + NEW GROUP, choose the shared vault in the editor's VAULT picker, name it
|
||||
`production`, and give it a default port and username.
|
||||
2. Add two hosts to the same shared vault and file them under it.
|
||||
3. Make a second group called `production` in the **personal** vault.
|
||||
4. Sync, then look at Bob's machine after his own sync.
|
||||
|
||||
**Pass on Alice's:** the two cards are told apart by the vault name printed under each — same name, two
|
||||
folders — and on the phone the two headings carry the same badge. Opening either shows only its own hosts.
|
||||
Dragging one of the shared vault's host cards onto the personal `production` card is **refused with a
|
||||
sentence naming both vaults**, and the host stays where it was.
|
||||
|
||||
**Pass on Bob's:** the group is a card and a heading on his machine too, with the hosts inside it, and the
|
||||
port and username they dial are the ones Alice typed into the group rather than 22 and his own account. He
|
||||
can rename it, and the rename comes back to Alice rather than arriving as a second group in his personal
|
||||
vault.
|
||||
|
||||
**Failure means:** a group that reaches Bob as UNGROUPED hosts is the resolution map having gone narrow
|
||||
again — cosmetic on its own, except that the port and the username go with it, so his terminal dials the
|
||||
wrong place. A rename of his that turns up as a new group in his own vault is the editor writing to the
|
||||
active vault rather than to the row's, which forks the shelf and leaves Alice's untouched. Two identical
|
||||
cards with no vault under them means one of them is a folder somebody outside the team can read, and
|
||||
nothing on screen says which.
|
||||
|
||||
---
|
||||
|
||||
## Phase 13 — Unlocking the phone with a fingerprint
|
||||
@@ -1466,9 +1525,88 @@ delivery survives the failure because it is held against the transfer rather tha
|
||||
dropped on the failure. An error saying the staged file is missing is the copy having been deleted at the
|
||||
stop, which is what `QueueDeliveredDownload` documents it does not do.
|
||||
|
||||
## Phase 15 — Changes that arrive without a timer
|
||||
|
||||
The socket is covered by tests on both sides: the endpoint suite opens a real one against a real
|
||||
`TestServer` and proves a push produces a notice, that another account's push does not, and that a frame
|
||||
carries no ciphertext; the shell suite proves a notice wakes the synchronisation loop long before the
|
||||
minute. What none of that can reach is **the network in between**, and that is where this feature is most
|
||||
likely to fail: a reverse proxy that will not upgrade, one that drops an idle socket without telling either
|
||||
end, a corporate middlebox, a phone moving between Wi-Fi and mobile data. Every one of those looks the same
|
||||
from inside a test host, which has no proxy and no radio.
|
||||
|
||||
The pass condition throughout is *two* things, and the second matters as much as the first: it arrives
|
||||
quickly, **and** it still arrives when the socket is gone. A build where the timer had stopped working would
|
||||
pass every "it was fast" check here and fail nobody until somebody's proxy changed.
|
||||
|
||||
### 15.1 A colleague's edit appears while you are looking at it
|
||||
|
||||
Two accounts sharing a vault, both unlocked, both on the Hosts screen. On the first machine, rename a host
|
||||
in the shared vault and save.
|
||||
|
||||
**Pass:** the second machine's list shows the new name within a second or two, with nothing pressed and no
|
||||
screen flicker — the row updates, the selection does not move, and the status line is not repainted with a
|
||||
sync report.
|
||||
|
||||
**Failure means:** nothing within a minute, then the new name, is the socket not being established at all —
|
||||
that is the timer doing its job, which is the correct fallback and not the feature. Check `/api/v1/meta`
|
||||
lists `events`, then whether the proxy in front of the API forwards `Upgrade` and `Connection`. A list that
|
||||
never updates at all is a synchronisation failure and has nothing to do with this phase.
|
||||
|
||||
### 15.2 A vault shared with you turns up as it is shared
|
||||
|
||||
The second account signed in and unlocked, sitting on the VAULTS screen. From the first, add them to a team
|
||||
and press SHARE KEY.
|
||||
|
||||
**Pass:** the vault appears in their list within a second or two of the key being wrapped, and reads as
|
||||
waiting for a key until the share, then as readable.
|
||||
|
||||
**Failure means:** the vault appearing only on the minute is the `vaults.changed` notice not being published
|
||||
or not being followed. Both the membership add and the grant publish one; if the membership arrives promptly
|
||||
and the key does not, the grant path is the one to look at.
|
||||
|
||||
### 15.3 It still works with the socket taken away
|
||||
|
||||
On the second machine, block the WebSocket — the simplest way is a proxy rule rejecting the upgrade, or
|
||||
setting `Events:Enabled` to `false` on the server and restarting it.
|
||||
|
||||
**Pass:** everything above still happens, within the minute rather than within seconds. Nothing on the
|
||||
screen says anything is wrong, because nothing is: no error, no OFFLINE badge, no repeated status message.
|
||||
The Sync button still works and still reports.
|
||||
|
||||
**Failure means:** an error message, a titlebar claiming to be offline, or a status line that repaints with
|
||||
a socket failure is the client treating an absent push channel as a fault. It is not one — the timer is the
|
||||
guarantee and the socket is the optimisation, and a user with a strict proxy must never be told their
|
||||
keychain is broken.
|
||||
|
||||
### 15.4 A laptop that slept comes back on its own
|
||||
|
||||
With the second machine idle and connected, close the lid for a few minutes — or disable Wi-Fi for two
|
||||
minutes and re-enable it. Then make a change on the first machine.
|
||||
|
||||
**Pass:** the change arrives quickly again, without the vault having been locked or the application
|
||||
restarted. The reconnection is invisible.
|
||||
|
||||
**Failure means:** changes that arrive only on the timer from then on are the stream having given up after
|
||||
its socket died — the reconnection loop is what should make that impossible, and a client that reconnects
|
||||
once and not twice is the specific defect its tests exist to catch. Changes that never arrive again, timer
|
||||
included, are a different and worse bug in the synchronisation loop rather than in the socket.
|
||||
|
||||
### 15.5 An expiring token does not end the push
|
||||
|
||||
This one needs a short access-token lifetime in the identity provider — the dev realm's Keycloak client can
|
||||
be set to a couple of minutes. Leave a machine unlocked and idle for longer than that, then make a change
|
||||
elsewhere.
|
||||
|
||||
**Pass:** the change still arrives quickly. The socket is closed by the server at the token's expiry and the
|
||||
client reconnects with a fresh one, which should be invisible.
|
||||
|
||||
**Failure means:** notices stopping at roughly the token's lifetime is the reconnection not asking for a new
|
||||
token — it would be dialling with the spent one and being closed again immediately. A burst of reconnection
|
||||
attempts in the server log is the same defect seen from the other end.
|
||||
---
|
||||
|
||||
## Phase 15 — Installing the desktop client, and being updated by it
|
||||
## Phase 16 — Installing the desktop client, and being updated by it
|
||||
|
||||
Nothing in this phase is reachable by a test, and not for the usual reason. There is no installed
|
||||
application in CI, no `%LOCALAPPDATA%` worth inspecting, and the update path only exists across two builds
|
||||
@@ -1478,12 +1616,12 @@ of the view model against a fake channel, and pins the one promise that matters
|
||||
(`AReadyUpdate_IsNeverAppliedOnItsOwn`); `UpdateBannerTests` measures the banner at the window's minimum
|
||||
width. Neither can install anything.
|
||||
|
||||
Walk it once per release, and in order — 15.6 onwards needs 15.1 to have happened.
|
||||
Walk it once per release, and in order — 16.6 onwards needs 16.1 to have happened.
|
||||
|
||||
Run `pwsh -File scripts/release-windows.ps1` first. It stops after packing, on purpose, so that everything
|
||||
below happens before anything reaches a user.
|
||||
|
||||
### 15.1 The installer needs no administrator, and lands beside the vault rather than on it · **the one that would destroy data**
|
||||
### 16.1 The installer needs no administrator, and lands beside the vault rather than on it · **the one that would destroy data**
|
||||
|
||||
Run `Releases\DodoSSH.Desktop-win-Setup.exe` from an ordinary account. Then look at `%LOCALAPPDATA%`.
|
||||
|
||||
@@ -1493,9 +1631,9 @@ reading **DodoSSH**; and `%LOCALAPPDATA%\DodoSSH` either absent (a fresh machine
|
||||
**Failure means:** a UAC prompt is a per-machine install, which is not what was designed. Anything written
|
||||
into `%LOCALAPPDATA%\DodoSSH` is the pack id having drifted back to `DodoSSH`, and that is the serious one —
|
||||
the uninstaller removes its whole install root, so it would take the vault cache and the outbox with it.
|
||||
See [ADR 0012](adr/0012-desktop-distribution-and-updates.md) decision 2.
|
||||
See [ADR 0013](adr/0013-desktop-distribution-and-updates.md) decision 2.
|
||||
|
||||
### 15.2 The installed path is short, measured rather than assumed
|
||||
### 16.2 The installed path is short, measured rather than assumed
|
||||
|
||||
```powershell
|
||||
"$env:LOCALAPPDATA\DodoSSH.Desktop\current\DodoSSH.exe".Length
|
||||
@@ -1507,7 +1645,7 @@ See [ADR 0012](adr/0012-desktop-distribution-and-updates.md) decision 2.
|
||||
`CO_E_SERVER_EXEC_FAILURE` and name nothing — see the long-path entry in `platform-flags.md`, which is the
|
||||
reason this check is a number rather than a shrug.
|
||||
|
||||
### 15.3 The window opens and carries its own icon
|
||||
### 16.3 The window opens and carries its own icon
|
||||
|
||||
**Pass:** the Start-menu shortcut launches it, and the taskbar and Alt-Tab show the dodo mark rather than a
|
||||
generic icon.
|
||||
@@ -1515,7 +1653,7 @@ generic icon.
|
||||
**Failure means:** `--icon` or `ApplicationIcon` did not survive packaging. Cosmetic, and the first thing
|
||||
anybody notices.
|
||||
|
||||
### 15.4 A terminal connects from the installed build · **the one that would catch an AppContainer**
|
||||
### 16.4 A terminal connects from the installed build · **the one that would catch an AppContainer**
|
||||
|
||||
Sign in, unlock, open a shell against a real host, and type.
|
||||
|
||||
@@ -1527,7 +1665,7 @@ WebView2 in an AppContainer where the loopback data plane cannot connect. That i
|
||||
out for, and this is the check that would find it in the Velopack path. `RendererTimeout` is where the
|
||||
fifteen seconds comes from.
|
||||
|
||||
### 15.5 The version on screen is the version that was built
|
||||
### 16.5 The version on screen is the version that was built
|
||||
|
||||
Right-click `DodoSSH.exe` → Properties → Details, and open PREFERENCES → UPDATES.
|
||||
|
||||
@@ -1539,7 +1677,7 @@ dropped from a checkout. `1.0.0.0` is somebody having wired the app manifest's i
|
||||
version to the real one. A version on screen that differs from the file properties means the two are being
|
||||
read from different places, which is the thing having one number was for.
|
||||
|
||||
### 15.6 A second release produces a delta, not only a full package
|
||||
### 16.6 A second release produces a delta, not only a full package
|
||||
|
||||
Tag `v0.1.1` and run the script again.
|
||||
|
||||
@@ -1551,7 +1689,7 @@ previous release did not come down, so every user is about to fetch a ~60 MB ful
|
||||
change. The
|
||||
script warns rather than failing when that is legitimate, which is the first release only.
|
||||
|
||||
### 15.7 The update arrives, and the restart lands in it · **the whole point of the work**
|
||||
### 16.7 The update arrives, and the restart lands in it · **the whole point of the work**
|
||||
|
||||
With v0.1.0 installed and running, a vault unlocked, a host change made, and **a terminal open**, publish
|
||||
v0.1.1 (`-Upload`). Then press CHECK NOW on PREFERENCES rather than waiting six hours.
|
||||
@@ -1566,11 +1704,11 @@ intact.
|
||||
and reports the client up to date forever. A banner sliced at the terminal's left edge is the occlusion rule
|
||||
having been broken, and the fallback is to move the offer into the titlebar instead. Coming back as 0.1.0 is
|
||||
the swap having been blocked, usually by a process still holding a file under `current\`. Being asked to
|
||||
enrol again means the profile directory did not survive, which is 15.1's failure arriving late.
|
||||
enrol again means the profile directory did not survive, which is 16.1's failure arriving late.
|
||||
|
||||
### 15.8 The first connect after an update is not a cold start
|
||||
### 16.8 The first connect after an update is not a cold start
|
||||
|
||||
Immediately after 15.7, connect to a host.
|
||||
Immediately after 16.7, connect to a host.
|
||||
|
||||
**Pass:** the terminal appears about as quickly as it did before the update.
|
||||
|
||||
@@ -1578,7 +1716,7 @@ Immediately after 15.7, connect to a host.
|
||||
see the entry in `platform-flags.md`. Slow but working, so it gets dismissed as a fluke unless somebody is
|
||||
looking for it, which is why it is a numbered check rather than a note.
|
||||
|
||||
### 15.9 Uninstalling removes the application and leaves the vault · **the data-loss check**
|
||||
### 16.9 Uninstalling removes the application and leaves the vault · **the data-loss check**
|
||||
|
||||
Settings → Apps → DodoSSH → Uninstall.
|
||||
|
||||
@@ -1587,5 +1725,5 @@ Settings → Apps → DodoSSH → Uninstall.
|
||||
is wrong. Reinstalling then asks for the passphrase rather than for a server.
|
||||
|
||||
**Failure means:** the cache going with the application is the pack-id collision, and whoever ran this has
|
||||
lost their offline unlock and any change that was still in the outbox. That is the failure ADR 0012
|
||||
decision 2 exists to prevent, and it is why 15.1 checks the same thing from the other end.
|
||||
lost their offline unlock and any change that was still in the outbox. That is the failure ADR 0013
|
||||
decision 2 exists to prevent, and it is why 16.1 checks the same thing from the other end.
|
||||
|
||||
@@ -225,7 +225,7 @@ suspect.
|
||||
40-character corporate username adds 35 of them back. The shipped installer is not at risk. Two things
|
||||
would reopen it and neither is in the plan: a self-extracting single-file publish, whose native libraries
|
||||
land under a hashed temp path, and `%LOCALAPPDATA%` folder-redirected to a deep UNC path in a domain.
|
||||
Manual check 15.2 measures it on the real machine rather than trusting this paragraph.
|
||||
Manual check 16.2 measures it on the real machine rather than trusting this paragraph.
|
||||
|
||||
**WebView2's user data folder must be kept out of the install directory.** It defaults to a directory
|
||||
beside the host executable, which under Velopack is inside `current\` — and `current\` is *replaced* by
|
||||
@@ -233,7 +233,7 @@ every update. Left alone, the browser profile would be destroyed on each one, so
|
||||
every update would pay a cold WebView2 start: a fresh user-data directory and a new process tree, which is
|
||||
the slow path `RendererTimeout`'s fifteen seconds was sized for, arriving at the exact moment somebody is
|
||||
most ready to believe the update broke the terminal. `Program.Main` sets `WEBVIEW2_USER_DATA_FOLDER` to
|
||||
`%LOCALAPPDATA%\DodoSSH\WebView2` — under the profile directory, which Velopack never touches. Check 15.8
|
||||
`%LOCALAPPDATA%\DodoSSH\WebView2` — under the profile directory, which Velopack never touches. Check 16.8
|
||||
is what would notice it regressing, and it is worth having because the symptom is "slow but working", which
|
||||
gets dismissed as a fluke.
|
||||
|
||||
@@ -243,7 +243,7 @@ gets dismissed as a fluke.
|
||||
choice — would have made the uninstaller delete the vault cache and the outbox of changes not yet pushed,
|
||||
silently, which is the thing the application will not do without a counted confirmation. The pack id is
|
||||
`DodoSSH.Desktop` for that reason and no other; `--packTitle` supplies the name people see, so nothing is
|
||||
lost. Do not "tidy" it. See [ADR 0012](adr/0012-desktop-distribution-and-updates.md) and check 15.9.
|
||||
lost. Do not "tidy" it. See [ADR 0013](adr/0013-desktop-distribution-and-updates.md) and check 16.9.
|
||||
|
||||
**The Windows app manifest must declare a `supportedOS` list.** Without it the process reports a
|
||||
downlevel Windows version and Avalonia's native control host fails outright — *"Unable to create child
|
||||
@@ -358,7 +358,7 @@ for Windows/macOS/AppImage; Flatpak and deb/rpm defer updates to the package man
|
||||
reintroduce the thing MSIX was ruled out for. `Setup.exe` is an ordinary Win32 executable that unpacks a
|
||||
directory under `%LOCALAPPDATA%` and creates shortcuts — there is no `AppxManifest`, no package identity,
|
||||
no `runFullTrust`, no elevation and no execution alias, so the process stays an ordinary desktop process
|
||||
and WebView2 stays out of an AppContainer. That is reasoning, not measurement; manual check 15.4 is the
|
||||
and WebView2 stays out of an AppContainer. That is reasoning, not measurement; manual check 16.4 is the
|
||||
measurement, because if a package identity ever did appear the symptom would be the terminal hanging and
|
||||
then reporting the WebView2 message after fifteen seconds, which reads like a broken runtime rather than
|
||||
like packaging.
|
||||
|
||||
Reference in New Issue
Block a user