Public Access
The drag onto a group card went with v5's flat sections; filing a set is the chosen-hosts menu's "Change group..." picker on both heads now. The two checks walk that route instead and say honestly what ShellFlowTests and ScreenLayoutTests already cover, what only a real popup can show, and that nothing automated raises the mixed-keychain refusal. The numbering preamble's example swaps to citations that still exist.
2438 lines
134 KiB
Markdown
2438 lines
134 KiB
Markdown
# Things a person still has to check
|
||
|
||
Automated tests cover what they can reach. This file is the rest: the checks that need a real window, a
|
||
real network, a real remote host, or a real Explorer — and the reasons each one is out of reach.
|
||
|
||
Three constraints put things on this list, and they are worth knowing before adding to it:
|
||
|
||
- **`MainWindow` cannot be laid out by a test.** WebView2's adapter refuses the headless dispatcher's MTA
|
||
thread — see `LayoutHarnessTests.WhyTheWindowItselfIsNeverShown`. Anything that has to be measured lives
|
||
on a `UserControl` instead, and what is left in the window is unmeasured by construction.
|
||
- **Headless Avalonia has no native window.** So nothing about Win32 focus, about the WebView collapsing,
|
||
or about a drag that crosses into another application can be asserted. A headless test of any of those
|
||
would pass and confirm the wrong belief.
|
||
- **No network, no container, no remote host** in the ordinary suite. The SSH suites that do use one
|
||
(`DodoSSH.Client.Ssh.Tests`, `DodoSSH.SystemTests`) need Docker and are the exception.
|
||
|
||
Each item says what to do, what a pass looks like, and what a failure would mean.
|
||
|
||
**On the numbering.** A check keeps its number for life, because code comments and other documents cite them
|
||
— `platform-flags.md` sends a reader to 3.6–3.8, ADR 0013 to 16.4. A check inserted later therefore
|
||
takes a letter rather than pushing its neighbours along: 3.2a and 3.2b sit between 3.2 and 3.3 and always
|
||
will. Add in the same way, and keep each one next to the check it belongs beside; a gap in the numbers means
|
||
a phase had nothing left for a person to do, which is the good outcome rather than an omission.
|
||
|
||
---
|
||
|
||
## Phase 1 — the shell and the nav rail
|
||
|
||
### 1.1 No screen is sliced at the WebView's left edge · **the important one**
|
||
|
||
Open two terminals, then visit every nav rail entry in turn — Hosts, Keys, Pins, Snips, Logs — and both of
|
||
the switcher's other two segments, SFTP and S3, at the rail's own head.
|
||
|
||
**Pass:** each screen draws whole, its buttons all clickable, and the nav rail stays up the left edge for
|
||
every one of them. Since v5b's chrome pass the rail is permanent furniture — it no longer collapses for
|
||
SFTP, S3 or an open terminal the way it did while it belonged to a Vaults tab; see
|
||
`MainWindowViewModel.IsVaultsTab`'s own remark for what changed and what stayed the same under that name.
|
||
|
||
**Failure means:** a screen is not collapsing while the terminal shows. The terminal is a native child
|
||
window and composites above everything Avalonia paints, so the symptom is a screen cut off at the WebView's
|
||
left edge with the rest unreachable. This window has shipped that defect once. The single wrapper `Panel`
|
||
bound to `IsShowingPages` in `MainWindow.axaml` is what should make it impossible.
|
||
|
||
### 1.2 A tab clicked from another screen takes the keyboard
|
||
|
||
Go to SFTP with a terminal open, then click the SSH segment at the rail's head to return to it. **Start
|
||
typing immediately, without clicking anything else.**
|
||
|
||
**Pass:** every character reaches the shell, including the first.
|
||
|
||
**Failure means:** `FocusTerminalWhenLaidOut` in `MainWindow.axaml.cs` is posting too early.
|
||
`NativeControlHost` re-pushes its bounds on the next layout pass, so focusing ahead of that pass races the
|
||
thing the focus depends on. The symptom is losing only the first keystroke or two, which is why this has to
|
||
be typed immediately rather than after a pause.
|
||
|
||
### 1.3 Leaving a terminal gives the keyboard back
|
||
|
||
With a terminal focused, click SFTP at the rail's head. Type into the filter box.
|
||
|
||
**Pass:** the characters appear in the box.
|
||
|
||
**Failure means:** `ReleaseKeyboardTo` is missing on that path. **Collapsing the WebView does not release
|
||
the keyboard** — the native child window goes on holding Win32 focus and Avalonia then sees no key events
|
||
at all, so the screen that just appeared silently swallows everything. This was a latent bug before the
|
||
strip rework and is now on the hot path. See `docs/platform-flags.md`.
|
||
|
||
### 1.4 The middle click closes tabs and only tabs
|
||
|
||
On the terminal's own in-screen tab row: middle-click a tab (closes it), the row's background to the right
|
||
of the last tab (closes nothing), and the `+` button (closes nothing, opens nothing).
|
||
|
||
**Pass:** as described. Covered by `SessionTabRowTests` headlessly, so this is a confirmation that headless
|
||
pointer input matches a real mouse rather than a first look.
|
||
|
||
### 1.5 Connecting from the palette while on another screen
|
||
|
||
Press Ctrl+K from the FILES screen and connect to a host whose key is not yet approved.
|
||
|
||
**Pass:** the fingerprint card comes up over the FILES screen, visible and answerable, and the screen
|
||
underneath does not move — the transfer that was on it is still there when the question is answered.
|
||
Approving connects; CANCEL leaves you on FILES.
|
||
|
||
**Failure means:** the prompt is behind the screen that asked for it, and the connection is blocked on a
|
||
question that cannot be reached — or the window has jumped to HOSTS, which is what it used to do and what
|
||
cost the palette its whole point.
|
||
|
||
### 1.6 The user-chip popover draws above the terminal's rectangle · **the one with a precedent**
|
||
|
||
With a terminal open and showing, press the user chip at the foot of the nav rail.
|
||
|
||
**Pass:** the popover opens without leaving the terminal, and is drawn whole over the screen underneath it —
|
||
no part of it clipped along the WebView's edge.
|
||
|
||
**Failure means:** the popup is being composited under the renderer's native child window. Unlike the v3–v5
|
||
tab strip's own vault menu, which sat directly above the terminal's own rectangle and had to select the
|
||
Vaults tab before opening for exactly that reason, the rail's popover should not need that guard at all: it
|
||
opens inside the rail's own 255-pixel column — see `NavRail.axaml`'s own remark — which the terminal's
|
||
native child window never occupies, so there should be no rectangle here for a popup to be composited
|
||
under. If this fails, that assumption is the thing to re-examine, not the ordering of two calls. No headless
|
||
test can see this either way — it has no native window, so it cannot see what is painted over what.
|
||
|
||
It is on this list because `docs/platform-flags.md` records what this project has already paid for treating
|
||
a rendering claim as settled without looking.
|
||
|
||
### 1.7 Switching a vault off does not switch it out
|
||
|
||
With a team vault holding at least one host: press the user chip at the foot of the nav rail, switch the
|
||
team vault off in the popover, and check the hosts screen, the keychain and the pins.
|
||
|
||
**Pass:** that vault's hosts, keys and pins are gone from all three; the vault is still in the "file this
|
||
into" picker on a host editor; the sync indicator still settles rather than stalling; and a host in another
|
||
vault that authenticates with a key filed in the switched-off one still connects.
|
||
|
||
**Failure means:** the filter has reached past the projections it is allowed to touch. See
|
||
`VaultViewModel.IsVaultShown` for the list, and `VaultVisibilityTests` for the same assertions made against
|
||
view models — this check is the version with a real connection on the end of it.
|
||
|
||
### 1.8 Entering and leaving settings mode
|
||
|
||
From HOSTS, press the user chip at the foot of the nav rail and choose **Settings**. Then repeat from a
|
||
fresh open, choosing **Vaults**, and again choosing **Preferences**.
|
||
|
||
**Pass:** each of the three swaps the whole window's chrome — the titlebar becomes the 53px "Back to
|
||
application" bar, the ordinary nav rail is replaced by the 340px SettingsNav, and the status bar and the
|
||
update banner (if either was showing) are both gone. **Settings** lands on General; **Vaults** and
|
||
**Preferences** land on their own pages, each lit in SettingsNav's own list — General/Vaults/Account under
|
||
SETTINGS, Security/Preferences/Groups/Tags under CUSTOMIZE, Logout pinned below both. Click every one of the
|
||
seven rows in turn: each swaps the content column without leaving settings mode or touching the titlebar.
|
||
|
||
Now open a terminal, leave it showing, and open Settings from the user chip again. Press **Back to
|
||
application** — or, without touching anything else, press Esc.
|
||
|
||
**Pass:** both return to exactly the screen that was showing before Settings was opened — the terminal,
|
||
still running, rather than HOSTS or anywhere else. Switching between settings pages first (General → Vaults
|
||
→ Preferences) does not change what "back" goes back to; only the screen open the moment Settings was
|
||
**first** entered does.
|
||
|
||
**Failure means:** a return to HOSTS regardless of where Settings was opened from is
|
||
`MainWindowViewModel.settingsReturnScreen` being recaptured on every `EnterSettings` call rather than only on
|
||
the way in from outside settings mode — see that field's own remark. A status bar or update banner still
|
||
visible in settings mode is `MainWindow.axaml`'s two bottom rows no longer reading `!IsSettingsMode`. Esc
|
||
doing nothing is `MainWindow.axaml.cs`'s `OnKeyDown` no longer checking `IsSettingsMode` after the palette's
|
||
own branch.
|
||
|
||
### 1.9 The importer's own home, and what still reaches through settings mode
|
||
|
||
From Settings → Preferences, press **OPEN IMPORTER**.
|
||
|
||
**Pass:** the titlebar's back button relabels itself "Back to preferences" — not "Back to application" — and
|
||
SettingsNav stays lit on **Preferences** the whole time; the importer is drawn over the Preferences page
|
||
rather than being a destination of its own. Press SCAN AGAIN and let it read your `~/.ssh/config`.
|
||
|
||
Press **Back to preferences** (or Esc). **Pass:** the Preferences page is back, still inside settings mode,
|
||
and the titlebar's label reverts. Press Esc a second time (or **Back to application**): now settings mode
|
||
itself closes, back to whatever screen was open before Settings was entered — the same two-deep
|
||
"closest thing first" order the titlebar's own two buttons and `MainWindowViewModel.OpenImport`/`CloseImport`
|
||
follow.
|
||
|
||
**Then, with the importer open, press Ctrl+K** and connect to a host from the palette.
|
||
|
||
**Pass:** it works exactly as it does anywhere else in the application — the palette opens over the
|
||
importer, and connecting leaves settings mode outright for the new terminal, the same way choosing any
|
||
ordinary nav rail destination does through `MainWindowViewModel.ShowScreen`. An unapproved host's key card
|
||
comes up over settings mode the same way it would over any other screen.
|
||
|
||
**Failure means:** a back button that never changes label is `SettingsTitleBar.axaml`'s two buttons both
|
||
bound to the same side of `IsImportOpen`. SettingsNav lighting a different row while the importer is up is
|
||
`MainWindowViewModel.IsSettingsPreferencesPage` reading `IsImportOpen` when it must not —
|
||
`Import.dc.html` draws the rail unmoved on purpose; see the remark on `IsImportOpen`. Ctrl+K doing nothing
|
||
over the importer would be a guard added to `OnKeyDown` for `IsSettingsMode` that the design never asked for
|
||
and this application's own quick-connect card was built to reach past.
|
||
|
||
---
|
||
|
||
## Phase 2 — Known Hosts as its own page
|
||
|
||
### 2.1 The fingerprint column is readable end to end
|
||
|
||
Connect to two or three hosts, approving each fingerprint. Go to Pins and narrow the window to its minimum
|
||
(1081px since v5b's titlebar and rail fidelity pass; it was 1016 with the v2 sidebar, and 880 while the rail
|
||
was 54 wide), then widen it to something ordinary.
|
||
|
||
**Pass:** the full `SHA256:…` is on screen at both sizes, never cut off and never ellipsised.
|
||
|
||
**Failure means:** the one thing this screen is for has been broken. A truncated fingerprint cannot be
|
||
compared against a published one — it can only be glanced at, which is the habit pinning exists to replace.
|
||
`TheHostKeysScreenFitsWithPinsAndOneSelected` measures this at the minimum width, so a failure here is a
|
||
size the harness does not cover.
|
||
|
||
### 2.2 The APPROVED date is plausible
|
||
|
||
**Pass:** it is roughly the day you first connected to that host.
|
||
|
||
**Failure means:** the version 7 identifier is being read in the wrong byte order — the symptom is dates
|
||
tens of thousands of years out, not an error. Covered by `Uuid7TimestampTests`, so this is a confirmation
|
||
that the ids reaching the screen really are the ones this client minted. A pin restored from an older
|
||
client or another implementation shows `—`, which is correct rather than a failure.
|
||
|
||
### 2.3 Forgetting a pin reaches the server
|
||
|
||
With two machines signed in to the same account: forget a pin on one, sync the other.
|
||
|
||
**Pass:** the pin is gone on both, and the second machine asks you to check the fingerprint again on the
|
||
next connection.
|
||
|
||
**Failure means:** the forward from the screen's command to the vault's has lost the push. Withdrawing
|
||
trust that stays withdrawn only locally is the failure mode that matters here — the machines still refusing
|
||
to reach a rebuilt server are the other ones.
|
||
|
||
---
|
||
|
||
## Phase 2 — Generating a key
|
||
|
||
Most of this one *is* covered: `KeyAuthenticationTests.AKeyThisClientGenerated_AuthenticatesAgainstARealServer`
|
||
installs a generated public line on a real OpenSSH server in a container and connects with the private half,
|
||
for both algorithms. That is the claim that mattered, and it is automated. What is left is the interface
|
||
around it.
|
||
|
||
### 2.4 COPY PUBLIC KEY actually reaches the clipboard
|
||
|
||
Generate a key, save it, select it, press COPY PUBLIC KEY, then paste somewhere.
|
||
|
||
**Pass:** one `ssh-ed25519 AAAA… comment` line.
|
||
|
||
**Failure means:** the clipboard closure in `App.axaml.cs` is not finding the window. No test can see this —
|
||
the view models take a delegate precisely so they never touch a visual, which means the one real
|
||
implementation of that delegate is exercised by nothing but a person. `CopyingAPublicKey_WithNoClipboard_SaysSo`
|
||
covers only the branch where there is none.
|
||
|
||
### 2.5 RSA-4096 does not freeze the window
|
||
|
||
Choose RSA 4096 and press GENERATE. While it runs, drag the window and click around.
|
||
|
||
**Pass:** the window keeps painting and the status line says it is working.
|
||
|
||
**Failure means:** the `Task.Run` is not actually taking the work off the UI thread. Ed25519 is instant and
|
||
will not show this, so it has to be tried with RSA.
|
||
|
||
### 2.6 The generated key works end to end, by hand
|
||
|
||
Generate a key, save it, copy the public line, add it to a real host's `~/.ssh/authorized_keys`, bind the
|
||
host to the key in its editor, and connect.
|
||
|
||
**Pass:** it connects without a password.
|
||
|
||
This duplicates the container test on purpose. The container runs one image; the thing worth knowing is
|
||
that it works against whatever you actually run.
|
||
|
||
---
|
||
|
||
## Phase 2 — Importing ssh_config
|
||
|
||
The parser has 22 cases over the shapes a real file contains, and the end-to-end path is covered by
|
||
`ImportingAnSshConfig_ShowsItFirstAndThenStoresWhatWasTicked`. What no test can do is read *your* file.
|
||
|
||
### 2.7 Scan your own `~/.ssh/config` and read the preview against the file
|
||
|
||
Settings → Preferences → OPEN IMPORTER, then SCAN AGAIN. Do not press Import N hosts yet.
|
||
|
||
**Pass:** every entry you would expect is listed, with the address and port you expect, and the warnings
|
||
above the table account for anything missing.
|
||
|
||
**What to look for specifically:**
|
||
- A `Host *` block's `User` should appear on hosts that set none, and **not** override hosts that set one.
|
||
- Entries whose name is a pattern (`*.internal`, `bastion-?`) should be *absent* from the table and named
|
||
in the warnings.
|
||
- `Match` blocks should be counted in the warnings and their settings should not have leaked onto any host.
|
||
- A `ProxyCommand` should be reported as dropped, not silently kept.
|
||
|
||
**Failure means:** the importer and `ssh` disagree about what your file means, which produces bookmarks
|
||
that nearly connect. That is worse than an import that refused, so it is worth reading the table properly
|
||
once.
|
||
|
||
### 2.8 Nothing is written until the button
|
||
|
||
Scan, then navigate away without importing.
|
||
|
||
**Pass:** the Hosts screen is unchanged.
|
||
|
||
### 2.9 Imported hosts are correct
|
||
|
||
Import a couple with the key tick **off**, then open one on the Hosts screen.
|
||
|
||
**Pass:** the address, port and username match the config, and the notes record any `IdentityFile` path and
|
||
any `ProxyJump` — with `ProxyJump` clearly stated as not routing. Connecting asks for a password even where
|
||
the config named a key, because with the tick off **no key material is read**; binding it to a key in the
|
||
keychain is a separate act.
|
||
|
||
### 2.10 The key tick reads `~/.ssh`, and only when it is ticked · **the one worth the most care**
|
||
|
||
Scan a config that names at least one `IdentityFile`.
|
||
|
||
**Pass:** below the list there is a tick reading *Also import the private keys these hosts point at*, and it
|
||
is **off**. On a config that names no `IdentityFile` anywhere there is no tick at all.
|
||
|
||
Turn it on without pressing anything else.
|
||
|
||
**Pass:** a warning appears naming what IMPORT will read, and the rows whose config named a key change from
|
||
*a key on disk* to *a key, imported*. Nothing has been read yet — turn it off again and navigate away, and
|
||
the point is that no private key was ever opened.
|
||
|
||
Turn it on and press IMPORT.
|
||
|
||
**Pass:** the keychain holds one key per **file**, not per host — a config pointing twelve entries at one
|
||
`~/.ssh/id_ed25519` produces one key, and all twelve hosts show `key` as their authentication. The key's
|
||
notes name the file it came from. A report appears under the button with one line per file.
|
||
|
||
**Pass, and this is the half that is easiest to lose:** for a key protected by a passphrase, the report says
|
||
so and names the file. The key is still imported; what it does not have is the passphrase, which is nowhere
|
||
on disk to be read. Add it on the keychain screen and the host connects.
|
||
|
||
Now import the same config again.
|
||
|
||
**Pass:** the hosts duplicate — a second bookmark for one machine is allowed and takes a click — and the
|
||
**key does not**. The report should be identical and the keychain should still hold one.
|
||
|
||
**Failure means:** a tick that is on by default, or one that reads keys during SCAN, is the failure that
|
||
matters here — this is the only control in the application that opens private key material out of a
|
||
directory the user did not point at file by file, and the whole of what makes it acceptable is that it took
|
||
a deliberate press. A second copy of a key on a re-import means the material comparison in
|
||
`VaultViewModel.ImportHostsAsync` is not finding what is already there, which turns "import my config"
|
||
into a way to fill a keychain with duplicates of one file.
|
||
|
||
### 2.11 A key file that is not on this machine leaves the host alone
|
||
|
||
Point an `IdentityFile` at a path that does not exist, tick the box and import.
|
||
|
||
**Pass:** the host is imported, unbound, exactly as it would have been with the tick off — and the report
|
||
names the file and says there is no such file. One bad path must not stop the other entries.
|
||
|
||
**Failure means:** an import that stopped, or a host that vanished, on a config carried from another
|
||
machine. That is the ordinary case rather than an error.
|
||
|
||
---
|
||
|
||
## Phase 2 — Drag and drop on the SFTP page
|
||
|
||
**This is the least-covered thing in the repository, and unavoidably so.** Headless Avalonia has no native
|
||
window and cannot synthesise a platform drag, so a test that claimed to drop a file from Explorer would
|
||
pass while confirming nothing. What is automated is the policy — `TransferQueueingTests` covers what may be
|
||
queued, what is skipped and what is said about it — and the wiring between a real drag and that policy is
|
||
covered by nothing at all.
|
||
|
||
Connect the SFTP page to a host first. All four of these should queue transfers.
|
||
|
||
### 2.12 Explorer → remote pane
|
||
|
||
Drag one file, then several, from Explorer onto the right-hand pane.
|
||
|
||
**Pass:** the pane outlines in accent colour while the pointer is over it, and the drop queues one transfer
|
||
per file into the directory showing.
|
||
|
||
### 2.13 Local pane → remote pane
|
||
|
||
**Pass:** as above. This uses the same platform file format as the Explorer drag, so a failure here with
|
||
2.12 passing points at the drag *source*, not the drop target.
|
||
|
||
### 2.14 Remote pane → local pane
|
||
|
||
**Pass:** the left pane outlines and the drop queues a download.
|
||
|
||
### 2.15 Local pane → Explorer
|
||
|
||
**Pass:** the file copies out.
|
||
|
||
### 2.16 The highlight clears · **the one most likely to be wrong**
|
||
|
||
Drag something over a pane and then out of it again without dropping.
|
||
|
||
**Pass:** the outline appears and then goes away.
|
||
|
||
**Failure means:** an overlay is participating in hit testing. It lays out identically either way — which is
|
||
why the layout test cannot catch it — but once visible it swallows the `DragOver` events underneath it, so
|
||
the pointer appears to leave immediately, the highlight sticks, and the drop lands nowhere. The fix is
|
||
`IsHitTestVisible="False"` on the highlight `Border` in `TransfersScreen.axaml`.
|
||
|
||
### 2.17 Dropping while disconnected
|
||
|
||
Disconnect, then drag a file over the remote pane.
|
||
|
||
**Pass:** the pane outlines in red and says "Connect to a host first." Nothing is queued on drop.
|
||
|
||
### 2.18 A click still selects a row
|
||
|
||
Click rows in both panes, and drag a row a few pixels without releasing.
|
||
|
||
**Pass:** a click selects; a small movement does not start a drag.
|
||
|
||
**Failure means:** the 4-pixel threshold in `TransfersScreen.axaml.cs` is not doing its job, and selecting a
|
||
row has become impossible.
|
||
|
||
### Not implemented: remote pane → Explorer
|
||
|
||
Dragging a *remote* file out to Explorer is deliberately absent. It needs the source to supply a virtual
|
||
file — on Windows, `CFSTR_FILEDESCRIPTORW` plus `CFSTR_FILECONTENTS` with delayed rendering — and Avalonia's
|
||
`IDataTransfer` marshals `DataFormat.File` only from a storage item that resolves to a real local path.
|
||
Pre-downloading to a temp file does not help: the shell demands the bytes during the drop. The only route is
|
||
a native COM `IDataObject` behind a platform interface, Windows-only and outside Avalonia's supported
|
||
surface. Use the ← button, or drag the file to the local pane first.
|
||
|
||
---
|
||
|
||
## Phase 3 — Host groups and snippets
|
||
|
||
Two synced item kinds, two ways of drawing a group — the desktop's grid of cards, one level at a time, and
|
||
the phone's flat list under headings — and one new frame between the host process and the renderer. The data
|
||
half of all of that is covered: the payloads round-trip, the server refuses the plaintext fields, both ways
|
||
of drawing a group and the snippet policy are in `ShellFlowTests`, and both new screens are measured. What is
|
||
left here is the part that only exists inside a WebView, plus the two-machine cases no single-process test
|
||
can reach.
|
||
|
||
### 3.1 A keychain with no groups looks exactly as it did
|
||
|
||
Open the hosts screen without creating any group.
|
||
|
||
**Pass:** the grid is the flat wrap of host cards it always was — no GROUPS section above it, no chip on any
|
||
card, nothing saying the hosts are unfiled.
|
||
|
||
**Failure means:** the "invisible until used" property is gone, and every existing user gets a section they
|
||
did not ask for. `HasGroups` is what hides the row of group cards; `HostRowViewModel.HasGroup` hides the chip.
|
||
|
||
### 3.2 Filing hosts, and the board drawing every group at once
|
||
|
||
Make two groups — the Group ▾ flyout's "New group…" — and file some hosts into each, once through a host's
|
||
own editor and its GROUP picker, and once by ticking several hosts and choosing **Change group…** from the
|
||
right-click menu. Leave at least one host unfiled.
|
||
|
||
**Pass:** every group is a heading on the board **at once** — in label order, "No group" first where it is
|
||
occupied — with its own hosts underneath. There is no double-click, no card to open a group into and no
|
||
breadcrumb trail: v3's one-level-at-a-time grid is gone outright, and every section is on screen from the
|
||
moment the board is. A host filed under `production` is drawn once, under that heading, and nowhere else on
|
||
the board.
|
||
|
||
**Then press the chevron beside one heading.** It folds — the heading stays, with its live count, and its
|
||
cards go — and pressing it again brings the cards back. **Then press "Collapse all"**, drawn on the first
|
||
heading only. It folds every section at once and its own label swaps to "Expand all"; opening any one heading
|
||
by hand swaps the label back to "Collapse all".
|
||
|
||
**Then type a filed host's name into the find box at the top, with its heading folded.** **Pass:** the
|
||
heading's own count narrows to match — the count is read off the set the find box has already narrowed,
|
||
before the fold is looked at — but the cards themselves stay hidden until that heading is reopened; open it
|
||
and the narrowed set is there. Clear the box and every count returns.
|
||
|
||
**Failure means:** a host drawn under a heading that is not its own, or under two at once, is
|
||
`VaultViewModel.AddFlatSection`'s membership test reading the wrong field for that section —
|
||
`host.Host.GroupId == group.EntityId` for a named heading, everything else for "No group". A chevron that
|
||
folds every section rather than the one it sits beside is `ToggleGroupCommand` being handed the board rather
|
||
than the single `SidebarGroupHeader` it was pressed on. A count that does not move with the find box is
|
||
`MatchesHostBoardFilters` not running before `HostSections` is rebuilt — see
|
||
`VaultViewModel.RebuildHostSections` and `OnHostFilterChanged`.
|
||
|
||
### 3.2a A group nested under another, and what a host filed under it inherits
|
||
|
||
Make an outer group with a default port and no default username, then make a second group whose editor's
|
||
PARENT picker points at the outer one, and give this inner group no default port of its own. File a host
|
||
under the inner group with nothing set on the host itself.
|
||
|
||
**Pass:** both groups are their own headings on the board, side by side — nesting is never drawn on the board
|
||
itself, only carried in the group editor's own PARENT field. The inner heading's count is only the hosts
|
||
filed directly under it, and the outer's is only the hosts filed directly under the outer one; the host filed
|
||
under the inner group is not counted on the outer heading. Connect the host anyway: it dials the **outer**
|
||
group's port. `HostInheritance.Chain` walks past the inner group's own silence on that field rather than
|
||
stopping at the nearest group regardless of whether it answered. Give the inner group its own default port
|
||
and reconnect: the host now dials that one instead, because the nearer group's answer wins once there is one.
|
||
|
||
**Then open the inner group's own editor and look at its PARENT picker.** The outer group is offered. **Then
|
||
open the outer group's own editor and look at its PARENT picker.** The inner group is **not** offered — a
|
||
group found by walking down from the outer one is refused as its own parent, which is what keeps this one
|
||
machine, acting alone, from building a cycle. (Two machines can still build one offline between them; that is
|
||
3.4a, below.)
|
||
|
||
**Failure means:** a host that dials 22 with the outer group's port set is the chain stopping at the first
|
||
group above the host rather than reading each field independently from the nearest group that states it — see
|
||
`HostInheritance.Resolve`'s own remarks. A parent picker offering a group's own descendant is
|
||
`VaultViewModel.BuildGroupParentChoices` walking the wrong direction: each candidate has to be walked
|
||
*upward*, through `HostInheritance.Chain`, to see whether it passes through the group being edited — a
|
||
downward index of children is what the alternative would need, and this view model keeps none.
|
||
|
||
### 3.2b Making a host or a group inherits nothing from where the board is scrolled
|
||
|
||
Scroll the board so one group's section fills the window, or fold every other section away, then press
|
||
**+ New host**. Afterwards, open the Group ▾ flyout and press **New group…**.
|
||
|
||
**Pass:** the host editor opens with its GROUP picker on **"No group"**, and the group editor opens with its
|
||
PARENT on **"No group"** too — neither reads anything from which heading happens to be on screen or scrolled
|
||
to. This is deliberate: the level-at-a-time grid these two commands used to inherit an "open group" from is
|
||
gone, and nothing on the flat board replaced that context. Pick a group by hand in either picker and it
|
||
stays picked — the host or the new group lands there once saved.
|
||
|
||
**Failure means:** an editor that opens already filed under whichever section happened to be on screen is
|
||
`VaultViewModel.GroupTarget` — the fallback both `NewHost` and `NewGroup` still read — having been wired to a
|
||
live selection again. Read that property's own remarks before treating this as a regression: it is an alias
|
||
for the single-select `GroupFilter`, which nothing in the current toolbar ever assigns any more — the Group ▾
|
||
flyout ticks a set, `checkedGroupFilterIds`, a different field entirely — so today the fallback is dead code
|
||
rather than a path either `+` button takes. Restoring it as live board context would make both commands
|
||
context-sensitive in a way nothing on this board signals before the fact.
|
||
|
||
### 3.3 Deleting a group with hosts in it
|
||
|
||
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, 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:** 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.3b Moving a key, and moving a host with its key · **needs a second vault**
|
||
|
||
In your personal vault: add an SSH key, then two hosts that both authenticate with it. On the Keychain
|
||
screen select the key and press **MOVE**, and read the panel before choosing the shared vault.
|
||
|
||
**Pass:** the panel says what uses the key — "Used by 2 hosts…" — before anything happens. Afterwards the key
|
||
carries the destination's badge under an id it did not have a moment ago, *and both hosts still say `key`
|
||
under their names* and still connect. The sentence names the vault and the two hosts that followed it.
|
||
|
||
Then the other direction: with a key back in your personal vault and a host bound to it, choose **Move to
|
||
another vault…** on the host and pick the shared vault. The tick box under the picker offers to bring the
|
||
key, unticked, with the count of what else uses it underneath. Leave it and press MOVE; then move the host
|
||
back, tick it, and press MOVE again.
|
||
|
||
**Pass:** unticked, the host lands in the shared vault and the status line says the key it authenticates with
|
||
is in another vault and will not resolve for anybody else there. Ticked, the key lands in the shared vault
|
||
too and the host still says `key`. Choosing a *different* vault in the picker re-asks the question, and the
|
||
box disappears when the destination is the vault the key is already in.
|
||
|
||
**Failure means:** a host that says `password` after either move is the re-aim not having happened — the item
|
||
takes a new id in the destination, so every host bound to it and every group lending it has to be rewritten
|
||
as it lands, and a host bound to something its vault no longer holds refuses to connect rather than falling
|
||
back to a typed password. A tick box that is ticked when the panel opens is worse than a bug: it moves a
|
||
private key into a shared vault on the strength of a decision nobody made.
|
||
|
||
### 3.4 A group deleted on another machine · **needs two machines**
|
||
|
||
Make a group on machine A, file a host into it, sync. On machine B, sync, then delete the group and sync
|
||
again. Back on A, sync.
|
||
|
||
**Pass:** A shows the host with no chip on its card, as though it were in no group. Open that host's editor:
|
||
the group picker shows "(a group that is no longer here)" and *keeps it selected*. Change the port and save.
|
||
|
||
**Failure means:** if the picker opened on "No group", saving has just unfiled the host — quietly, as a side
|
||
effect of an unrelated edit. That is the case `BuildGroupChoices` adds the placeholder for.
|
||
|
||
### 3.4a A parent that dangles, or a cycle · **needs two machines**
|
||
|
||
Rare, and the reason it is here is that the repair for both is inside the group's own editor, which is
|
||
opened from its card. On A, file group X under group Y and sync. On B, sync, delete Y, sync. Back on A, sync.
|
||
For the cycle: with both machines offline, file X under Y on A and Y under X on B, then sync both.
|
||
|
||
**Pass:** in both cases every group still has a card at the outermost level, so every one of them can be
|
||
opened and its parent cleared. Nothing is missing from the grid, and the trail never loops.
|
||
|
||
**Failure means:** a group drawn nowhere is a group that can never be repaired — the fix is locked inside the
|
||
state that needs fixing. See `VaultViewModel.EffectiveParents`.
|
||
|
||
### 3.5 A grouped host stays editable on an older build · **needs two builds**
|
||
|
||
Only worth doing before a release that ships alongside an older client. A host filed into a group is written
|
||
at payload schema 4; an older build must show it and refuse to edit it, rather than editing it and dropping
|
||
the group.
|
||
|
||
**Pass:** the older build says the host was written by a newer version. A host with *no* group still edits
|
||
normally there — that is what makes the version a maximum over the fields present rather than a stamp.
|
||
|
||
### 3.6 Inserting a snippet · **the one that cannot be tested here**
|
||
|
||
Open a terminal, go to SNIPS, select a snippet with `Press Enter after inserting this` **off**, and press the
|
||
insert button.
|
||
|
||
**Pass:** the terminal comes forward with the command sitting at the prompt, not run. The button named the
|
||
tab it was going to — check that it named the right one if several are open.
|
||
|
||
**Failure means:** if the command runs by itself, the flag byte or the JavaScript that reads it is wrong. If
|
||
nothing appears at all, the frame reached a pane the page does not have.
|
||
|
||
### 3.7 A multi-line snippet does not run line by line · **the reason the opcode exists**
|
||
|
||
Save a snippet whose command is three lines — `echo one`, `echo two`, `echo three` — with the run flag off,
|
||
and insert it into a **bash or zsh** session.
|
||
|
||
**Pass:** all three lines sit at the prompt as one pending command, and nothing runs until Enter. Modern
|
||
shells turn bracketed paste on, xterm.js sees that in the output stream, and `term.paste` wraps the text.
|
||
|
||
**Failure means:** if the first two lines execute and the third waits, the text went through as plain input
|
||
and the bracketing did not happen — which is the whole failure this frame was added to prevent.
|
||
|
||
**Then insert the same snippet into something with bracketed paste off** — a raw `sh`, or a session inside
|
||
`vi`. The lines *will* run there, and that is correct and unavoidable: without the mode there is no way to
|
||
distinguish pasted newlines from typed ones. It is why the screen says "types this into whatever is there"
|
||
rather than "runs this command".
|
||
|
||
### 3.8 RUN presses Enter
|
||
|
||
Select a snippet with the run flag on and press RUN.
|
||
|
||
**Pass:** the command runs.
|
||
|
||
**Failure means:** if the command appears and does not run, the `\r` is going through `paste` instead of
|
||
`input` — inside the bracketed wrapper it is literal text, so nothing executes.
|
||
|
||
### 3.9 Inserting into a tab whose remote has hung up
|
||
|
||
Open a terminal, `exit` it, leave the tab open, then insert a snippet at it.
|
||
|
||
**Pass:** the screen says that tab is no longer connected, and nothing is claimed to have been sent.
|
||
|
||
### 3.10 Both new kinds reach a second machine · **needs two machines**
|
||
|
||
Make a group and a snippet on A and sync; sync B.
|
||
|
||
**Pass:** both arrive, with their names and — for the snippet — its run flag intact. A snippet whose flag
|
||
arrives *set* when it was saved unset is the one failure here worth stopping for.
|
||
|
||
---
|
||
|
||
## Phase 4 — Logs
|
||
|
||
Both logs are synced item kinds with the whole pipeline covered: payloads round-trip, the server refuses
|
||
every plaintext field, retention is tested against a real vault, the activity hook is tested through the
|
||
repository every kind writes through, and the recursion guard has a test of its own. What is left here is
|
||
the part that only happens across a lock, across a process exit, or across two machines.
|
||
|
||
### 4.1 A connection is recorded when the tab closes, not before
|
||
|
||
Connect to a host, leave the tab open, and open LOGS.
|
||
|
||
**Pass:** the connection is at the top of CONNECTIONS with a green dot and the words **still open** — not a
|
||
dash, and not a duration. Close the tab and press REFRESH: the same connection now has a duration.
|
||
|
||
**Failure means:** a dash instead of "still open" reads as a recording that failed, which is the opposite of
|
||
what is happening. A duration before the tab closes means an entry is being written at open, which would
|
||
also mean it gets written twice.
|
||
|
||
### 4.2 The duration is plausible
|
||
|
||
Connect, wait a measured minute or two, disconnect.
|
||
|
||
**Pass:** the LASTED column agrees with the clock, rounded to whole units.
|
||
|
||
**Failure means:** a wildly wrong number points at the two timestamps coming from different clocks — both
|
||
should come from the workspace's own `TimeProvider`.
|
||
|
||
### 4.3 Closing the application records the tabs that were open · **the one most likely to be wrong**
|
||
|
||
Open two terminals and close the DodoSSH window without closing the tabs. Start it again, unlock, open LOGS.
|
||
|
||
**Pass:** both connections have entries, with durations running up to the moment you closed the window.
|
||
|
||
**Failure means:** they are the ordinary way a session ends, and the workspace's own close-outs happen while
|
||
it tears sessions down — *after* the vault they would be written into has gone. `ConnectionRecorder`'s
|
||
`DisposeAsync` closes the tickets itself, before the session is disposed, and waits up to two seconds for
|
||
the queue. If the entries are missing, that ordering has been broken; if closing the window became slow,
|
||
the bounded wait has.
|
||
|
||
### 4.4 A shell open across a lock still gets its entry
|
||
|
||
Connect to a host, lock the keychain from the titlebar, unlock again, then close the tab.
|
||
|
||
**Pass:** the connection is recorded, into the vault it was made in.
|
||
|
||
**Failure means:** the ticket keeps the repository it was opened against precisely so this works. A missing
|
||
entry means it is reading the current one instead, which would also mean an entry could be filed into the
|
||
wrong vault once shared vaults land.
|
||
|
||
### 4.5 A refused host key is recorded
|
||
|
||
Connect to a host, approve its key, then change the key on the remote (or edit the pin) so the next
|
||
connection is refused.
|
||
|
||
**Pass:** an entry appears with **host key refused** beside it. This is the row the connection log most
|
||
exists for — a changed host key is refused with no way past it, so the status line is otherwise its only
|
||
trace.
|
||
|
||
### 4.6 The SFTP session is recorded separately
|
||
|
||
Open the files screen and connect, then disconnect.
|
||
|
||
**Pass:** an entry with **files** in the KIND column, separate from any terminal entry.
|
||
|
||
**Failure means:** if it is missing, our log disagrees with the remote's own `auth.log`, which records the
|
||
second login. Anybody comparing the two would be right to believe the host.
|
||
|
||
### 4.7 The keychain log records names and never values
|
||
|
||
Edit a stored password: change both the password and the username. Open LOGS → KEYCHAIN.
|
||
|
||
**Pass:** one row saying **changed**, with `Password, Username` in the FIELDS column.
|
||
|
||
**Failure means:** if any part of the old or new password appears anywhere on that screen, stop — that is
|
||
the one thing this payload must never carry, and it would now be synced to every machine in the vault.
|
||
|
||
### 4.8 A pin trusted at the prompt is recorded
|
||
|
||
Connect to a host you have never reached and approve the fingerprint.
|
||
|
||
**Pass:** the keychain log shows a `KnownHostKey` **created**. This write never goes through a screen, so it
|
||
is exactly the one a hook placed in the view models would have missed.
|
||
|
||
### 4.9 The pending count and the status line stay honest
|
||
|
||
Save a host while online and watch the titlebar and the status line for a minute.
|
||
|
||
**Pass:** the pending count returns to zero and stays there, and the status line keeps saying what the save
|
||
said — it does not get overwritten a moment later by a sync report.
|
||
|
||
**Failure means:** every user action queues a log entry a moment afterwards. If the count sticks at 1 or the
|
||
status line flickers to "Synchronised: 1 out", the log entries have stopped being excluded from the two
|
||
numbers that are about the user's own work.
|
||
|
||
### 4.10 Both logs reach a second machine · **needs two machines**
|
||
|
||
Connect and edit something on A, sync; then sync B and open LOGS there.
|
||
|
||
**Pass:** both entries are on B, with A's device name on them. This is the claim the whole decision to sync
|
||
these rests on.
|
||
|
||
### 4.11 Retention actually prunes · **slow, or needs a clock**
|
||
|
||
Only checkable honestly by leaving a vault in use for months, or by temporarily lowering
|
||
`LogRetention.Default` in a debug build and watching a prune remove the excess and push the tombstones.
|
||
|
||
**Pass:** the count comes down, and the second machine's copy comes down too at its next sync.
|
||
|
||
**Failure means:** these entries sync, so a prune that does not push leaves every other machine holding
|
||
them — and this machine deleting them again on every pass.
|
||
|
||
---
|
||
|
||
## Phase 6 — S3 as a remote in the file browser
|
||
|
||
The parts that are this client's own reasoning are covered: the path-to-key translation, what a bucket must
|
||
have before it can be stored, the server's refusal of every plaintext field, and the item kind end to end.
|
||
What is left needs a real endpoint, and a fake would only assert our reading of the protocol back at us.
|
||
|
||
**Get a bucket first.** MinIO in Docker is the cheapest way and exercises the harder path — path-style
|
||
addressing, a custom endpoint, and a region that is ignored:
|
||
|
||
```bash
|
||
docker run -p 9000:9000 -e MINIO_ROOT_USER=dodossh -e MINIO_ROOT_PASSWORD=dodossh-secret minio/minio server /data
|
||
```
|
||
|
||
### 6.1 Adding a bucket
|
||
|
||
Keychain → BUCKETS → `+ BUCKET`. Endpoint `http://localhost:9000`, path-style **on**, any region.
|
||
|
||
**Pass:** it saves, appears in the list with the bucket and endpoint under its name, and syncs.
|
||
|
||
**Failure means:** if saving is refused, read the message — the validation exists so the reason names the
|
||
field rather than arriving later as an SDK error about resolving a URI.
|
||
|
||
### 6.2 Path-style addressing · **the one most likely to be wrong**
|
||
|
||
Save the same bucket with path-style **off** and open it.
|
||
|
||
**Pass:** it fails, and the failure is a name-resolution error mentioning `bucket.localhost`.
|
||
|
||
**Why it is worth doing deliberately:** that is exactly what a user gets when they leave the checkbox at its
|
||
default against a self-hosted service, and the message names neither buckets nor the setting. Seeing it once
|
||
is what makes the hint under the checkbox worth its space.
|
||
|
||
### 6.3 Browsing
|
||
|
||
Files → BUCKET → pick it → OPEN.
|
||
|
||
**Pass:** the right pane lists the bucket root. Prefixes appear as directories in the directory colour;
|
||
objects appear as files with sizes and dates. The PERMS column is empty — a bucket has no POSIX mode, and a
|
||
plausible `-rw-r--r--` would be invented.
|
||
|
||
**Also check** that the timestamps agree with the local pane's. Both columns are UTC; if the bucket's are out
|
||
by your machine's offset, the `DateTime.Kind` handling in `S3FileStore.Utc` has regressed.
|
||
|
||
### 6.4 Upload, and the pipe underneath it
|
||
|
||
Upload a file of a few hundred megabytes.
|
||
|
||
**Pass:** it completes, the object is in the bucket at the right key and the right size, and the machine's
|
||
memory does not grow with the file. The upload streams through a pipe into a multipart upload — nothing is
|
||
buffered to disk twice and nothing is held whole in memory.
|
||
|
||
**Failure means:** if it hangs at the end, the pipe's writer is not being completed on disposal. If memory
|
||
tracks the file size, the multipart path is not being taken.
|
||
|
||
### 6.5 A failed upload surfaces at the write
|
||
|
||
Start an upload and stop MinIO halfway.
|
||
|
||
**Pass:** the queue row fails with a message from the service, reasonably promptly.
|
||
|
||
**Failure means:** if it hangs instead, the background upload is failing without completing the pipe's
|
||
reader — and the copy is blocked on a pipe nobody is draining. That is the case `S3UploadStream` completes
|
||
the reader *with* the exception for.
|
||
|
||
### 6.6 Download, and resume
|
||
|
||
Download a large object, stop it partway, and resume.
|
||
|
||
**Pass:** it resumes from where it stopped, and the finished file matches the original. This direction is the
|
||
one where a bucket is better than SFTP — a ranged GET is part of the protocol.
|
||
|
||
### 6.7 Upload resume is refused, and says why
|
||
|
||
Start an upload, stop it partway, and press RESUME.
|
||
|
||
**Pass:** it fails with a message saying an object cannot be written from the middle, so an interrupted
|
||
upload starts again rather than resuming. RETRY from the start works.
|
||
|
||
**Why this is not a bug:** objects are immutable. Multipart could rebuild an interrupted transfer, but only
|
||
by persisting the upload id and every part's ETag across the interruption. Refusing is honest; silently
|
||
starting from zero would corrupt the file.
|
||
|
||
### 6.8 Delete refuses a prefix with anything under it
|
||
|
||
Try to delete a directory in the bucket that has objects in it.
|
||
|
||
**Pass:** refused, saying there are still objects under it. Deleting an empty one works.
|
||
|
||
### 6.9 The keys never appear anywhere they should not
|
||
|
||
After adding and editing a bucket, open LOGS → KEYCHAIN.
|
||
|
||
**Pass:** the entry says `Secret access key` in the FIELDS column and nowhere on that screen does any part of
|
||
the key itself appear.
|
||
|
||
### 6.10 Against real AWS · **needs an account**
|
||
|
||
Repeat 6.1 and 6.3 with the endpoint blank, a real region, and path-style **off**.
|
||
|
||
**Pass:** it lists. This is the path that exercises `RegionEndpoint.GetBySystemName` and virtual-host
|
||
addressing, neither of which MinIO covers.
|
||
|
||
---
|
||
|
||
## Phase 7 — Connecting without waiting, and the host list's pointer
|
||
|
||
Connecting no longer holds the vault while it happens: a tab appears at once and the handshake runs behind
|
||
it. The state machine around that is covered in `ShellFlowTests` — the tab, the card, the two kinds of
|
||
failure, two connections at once — and the card itself is measured. What is left needs a real window: a
|
||
native drag, a real menu popup, and a host that takes its time answering.
|
||
|
||
### 7.1 The window stays usable while a connection is being made · **the point of the change**
|
||
|
||
Connect to something that will take a while — an address that is routable but silent, so the handshake sits
|
||
there rather than failing at once (a firewalled IP is ideal; `10.255.255.1` will do). While it runs: click
|
||
around the host list, open KEYS, edit something, connect to a second host.
|
||
|
||
**Pass:** everything works. The strip shows a tab named for the host with `connecting…` beside it, and the
|
||
terminal area shows the host, the address it is dialling, and the same word.
|
||
|
||
**Failure means:** the connect has ended up back inside `RunAsync`'s busy gate, which is the one thing
|
||
`VaultViewModel.ConnectAsync` must never be put inside again.
|
||
|
||
### 7.2 Navigating away during a connection unlights the tab
|
||
|
||
While 7.1's connection is still running, click KEYS.
|
||
|
||
**Pass:** the tab stays in the strip and stops being marked as the active one. No accent bar along its top
|
||
edge while a page is showing.
|
||
|
||
**Failure means:** the tab is bound to `IsSelected` again rather than to `IsShowing`, and the strip is
|
||
claiming to be what the window is showing when it is not.
|
||
|
||
### 7.3 A refusal ends up in the tab, not only in the status line
|
||
|
||
Connect to a host with the wrong password, and navigate to PREFS before it answers.
|
||
|
||
**Pass:** the tab goes grey and carries the refusal; selecting it shows the reason in the middle of the
|
||
window with a CLOSE TAB button. Closing it returns to the page underneath.
|
||
|
||
**Failure means:** a connection that failed while the user was elsewhere has left no trace anywhere they
|
||
would look.
|
||
|
||
### 7.4 An unknown host key still wins the window
|
||
|
||
Connect to a host whose key is not yet approved.
|
||
|
||
**Pass:** no tab is left behind, and the fingerprint card is over whatever the window was showing. Approving
|
||
it connects and gives the new tab a pane. This is 1.5 with the tab rework underneath it.
|
||
|
||
### 7.4a A second connection's question, over a terminal that is open · **the occlusion one**
|
||
|
||
With one terminal open and showing, connect to a *different* host whose key is not yet approved.
|
||
|
||
**Pass:** the card is fully drawn and both its buttons take clicks. The terminal's rectangle is given up while
|
||
it is up — the WebView is a native child window, so a card merely layered over it would be sliced at its left
|
||
edge with TRUST AND CONNECT unreachable. Answering either way brings the pane back.
|
||
|
||
**Failure means:** `IsTerminalShowing` has stopped asking `IsHostKeyDecisionShowing`, and the most
|
||
safety-critical question in the product is half-drawn and unanswerable. No test can see this — headless
|
||
Avalonia has no native child window.
|
||
|
||
### 7.4b A changed host key can be got out of
|
||
|
||
Connect to a host whose pin no longer matches (approve a key, then change it on the remote).
|
||
|
||
**Pass:** the refusal is over the window with **no** way to continue — no "connect anyway", nothing that
|
||
dismisses into a connection — and one control that puts it away. Pressing it leaves the screen that was
|
||
underneath, and from there the host's editor and FORGET HOST KEY are reachable.
|
||
|
||
**Failure means:** the refusal is stuck on screen. It was, until `RejectHostKey` started clearing the
|
||
mismatch as well as the pending key: on the phone that left an opaque full-screen panel over every screen the
|
||
user went to next, including the editor it tells them to open.
|
||
|
||
### 7.5 Giving up on a connection, and what arrives afterwards
|
||
|
||
Start 7.1's slow connection and press GIVE UP (or the tab's cross) while it is still trying.
|
||
|
||
**Pass:** the tab goes at once. If the host does eventually answer, a tab appears for the session that
|
||
opened — it is a real shell, and one running with nothing naming it would be worse than one that comes
|
||
back.
|
||
|
||
### 7.6 Filing a ticked host through "Change group…" · **the drag's replacement**
|
||
|
||
The gesture this number used to describe — a host card dragged onto a group card — went with the group cards
|
||
themselves in v5's flat-sections rework: every group is a heading now, there is nothing on the board to drop
|
||
onto, and no gesture replaced the drag. Filing from the board is the chosen-hosts menu's **Change group…**,
|
||
the phone's route become both heads' — see `VaultViewModel.ConfirmRegroupChosenHostsAsync`'s own remarks.
|
||
(A single host can also still be filed through its own editor's GROUP picker; 3.2 walks that route.)
|
||
|
||
Make two groups and file a host into one. Ctrl-click that host so it takes its ✓, right-click it, and choose
|
||
**Change group…**.
|
||
|
||
**Pass:** the entry is on the menu only while something is ticked — right-click an unticked card first and
|
||
the menu is the ordinary four (Connect, Details…, Edit…, Delete…) with no filing entry among them. Choosing
|
||
it opens the CHANGE GROUP panel **above the board, not over it** — the ticked card stays in view, "1 chosen"
|
||
sits beside the panel's heading — and the ComboBox reads **"No group"**, not the group the host is already
|
||
in; drop it down and every group in this keychain is listed after it.
|
||
|
||
**Then pick the other group and press FILE.** **Pass:** the panel folds, the card is drawn under the other
|
||
heading and the headings' counts follow, the tick is off, and the status line says where it went — `Filed 1
|
||
host(s) under …`.
|
||
|
||
**Then take it back out:** tick it again, open **Change group…**, and press FILE with the ComboBox untouched.
|
||
**Pass:** the host is unfiled, back under the "No group" heading. The picker opening on "No group" is a
|
||
decision rather than an oversight — unfiling a run of machines is exactly as common as filing them, and the
|
||
box says what FILE will do before it is pressed — but it is also why the box has to be *read*: FILE never
|
||
means "keep things as they are". CANCEL, tried once, folds the panel and keeps the ticks.
|
||
|
||
**Failure means:** the write and its guards are all covered headlessly — the filing by
|
||
`ShellFlowTests.ChangingTheGroupOfTheChosenHosts_FilesThemAllAtOnce`, the refusal under an open editor by
|
||
`RegroupingTheChosenHosts_IsRefusedWhileTheEditorIsOpen`, the panels' one-at-a-time rule by
|
||
`TheActionBarsPanels_TakeEachOthersPlaceRatherThanStacking`, and the panel's fit by
|
||
`ScreenLayoutTests.TheHostsScreenFitsWithTheChosenHostsGroupPanelOpen`. What none of them can see is a real
|
||
popup: the menu entry and the ComboBox's dropdown both live in popups, reaching the vault through the same
|
||
`#Board` indirection 7.9 explains. A menu missing the entry, or a FILE that files nothing, is that wiring —
|
||
7.9's class of failure, on the one entry that opens a panel rather than acting at once.
|
||
|
||
### 7.6a Filing a whole set at once, and the one refusal · **the refusal needs a second vault**
|
||
|
||
Tick three hosts (7.7a is how), right-click one of the three, choose **Change group…**, pick a group, and
|
||
press FILE.
|
||
|
||
**Pass:** the same panel, its heading now saying "3 chosen", and the write moves **all three** — every card
|
||
under the picked heading, the status line counting them, and the ticks gone once it is done. A host that was
|
||
never ticked stays exactly where it was.
|
||
|
||
**Then, with a second vault:** tick one host from each keychain and choose **Change group…** again.
|
||
|
||
**Pass:** no panel opens, and the status line says the hosts are in more than one keychain and a group
|
||
belongs to one. The refusal is whole and it is early — raised when the picker is asked for, over the whole
|
||
set, rather than after a group was picked from a list that could only ever have been one keychain's. Filing
|
||
across that line would leave everyone else in the shared vault seeing a machine filed under nothing.
|
||
|
||
**Failure means:** the set's write is `ChangingTheGroupOfTheChosenHosts_FilesThemAllAtOnce` again — one
|
||
command reads the whole set, so "filed one of three" has no half-gesture to hide in the way the old drag's
|
||
payload did. The refusal, though, is asserted by **nothing automated at all**: `VaultViewModel.RegroupChosenHosts`
|
||
counts the distinct vaults and no test raises it, so this check is the only thing between that sentence and
|
||
silence. A panel that does open over a mixed set is the worse half — it would offer one keychain's groups
|
||
for another keychain's machines, which is the half-filed set the refusal exists to prevent.
|
||
|
||
### 7.7 A click still selects, and a double click still connects
|
||
|
||
Click host cards; drag one a few pixels without releasing; double-click one. Then double-click the space
|
||
around the cards.
|
||
|
||
**Pass:** a click selects, a small movement starts nothing, and a double click connects. Double-clicking the
|
||
space around the cards connects to nothing.
|
||
|
||
**Failure means:** the 5-pixel threshold in `HostsScreen.axaml.cs` is not doing its job — the same failure
|
||
as 2.18 on the other screen, and here it would make the grid unusable. Empty space that connects means the
|
||
double-tap handler has lost its check that the pointer was over a card.
|
||
|
||
**And the same two gestures on the group cards above**, where they mean select and open rather than select
|
||
and connect — the space around *those* cards must open nothing, for the same reason. See 3.2.
|
||
|
||
### 7.7a Choosing several cards with Ctrl, Shift and a band · **the desktop's half of the phone's set**
|
||
|
||
With four or more hosts in the grid: Ctrl-click two cards, Shift-click a third, then drag a rectangle out
|
||
over the cards starting from the empty space below them. Finally press Esc, and try Ctrl+A.
|
||
|
||
**Pass:** each ticked card takes a ✓ beside its status dot and a filled surface, and the strip beside the
|
||
HOSTS heading counts them. A Ctrl-click does **not** move the selection — whichever card was lit before
|
||
stays lit, and the drawer, if it is open, goes on showing the same machine. Shift ticks the whole run
|
||
between the anchor and the card, in the order the cards are laid out. The band draws an accent rectangle
|
||
that follows the pointer and ticks every card it *touches*, unticking them again as it is pulled back off.
|
||
Ctrl+A ticks every card being drawn — with something in the find box, that is the ones on screen and not the
|
||
ones it is hiding. Esc, CLEAR, a plain click on a card and a click on the empty space each drop the set.
|
||
|
||
**And the menu is about the set while one is up:** right-click a ticked card and the entries are Connect,
|
||
Browse files and Edit… only while exactly one is ticked, then Change group…, Move to another vault…, Copy to
|
||
another vault…, Duplicate and Delete… — the last naming the count. Right-clicking a card **outside** the set
|
||
drops the set and gives the ordinary four back, aimed at that card.
|
||
|
||
**Also check the band does not start** on the scrollbar, on a group card, or inside a panel above the grid,
|
||
and that dragging the scrollbar still scrolls.
|
||
|
||
**Failure means:** a Ctrl-click that also selects is the press not being handled on the way down, and it
|
||
puts the drawer on a machine somebody is removing from a set. A menu offering both halves at once is the
|
||
`IsVisible` bindings in `HostsScreen.axaml` having come apart, which is how the wrong machine gets deleted.
|
||
`HostGridTests` drives all of these headlessly; what it cannot see is the rectangle actually being painted
|
||
and the two marks being legible together, which is what this check is for.
|
||
|
||
### 7.8 The highlight clears after a drag that goes nowhere
|
||
|
||
Drag a host over a group card and release outside the grid, or press Escape mid-drag.
|
||
|
||
**Pass:** the wash goes away.
|
||
|
||
### 7.9 The right-click menu acts on the row under the pointer
|
||
|
||
With host A selected, right-click host B and choose Delete.
|
||
|
||
**Pass:** the question names **B**. Then right-click the space around the cards.
|
||
|
||
**Pass:** no menu opens at all, and the host selection has not moved.
|
||
|
||
**Failure means:** a menu acting on the selection rather than on the card under the pointer deletes the
|
||
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.
|
||
|
||
**Pass:** the palette closes and the connection starts.
|
||
|
||
### 7.11 A host or a bucket made now can be picked on FILES now
|
||
|
||
With the files screen's picker open on an empty or short list, go to HOSTS and add a host — or to KEYS and
|
||
add a bucket — then come back.
|
||
|
||
**Pass:** the new one is in the picker, without locking and unlocking.
|
||
|
||
**Failure means:** the screen has gone back to copying the vault's lists once at unlock. Covered by
|
||
`TheTransfersScreen_FollowsTheVaultsHostList`; this is the version of it with a real picker in front of it.
|
||
|
||
### 7.12 A key made now can be bound in the editor that is already open
|
||
|
||
The same check one screen over, and the ordinary way round: start editing a host, type a port into it, then go
|
||
to KEYS and add a key — or a password, or a tag — and come back to HOSTS. Repeat it with a group's editor open
|
||
instead of a host's.
|
||
|
||
**Pass:** the editor is still open with the port still in it, and the new key is in the AUTHENTICATION picker
|
||
straight away. Choosing it and saving binds the host. A new tag is a chip among the others, unworn.
|
||
|
||
Then the other half, which matters more: with a host that already authenticates with a key, open its editor
|
||
and add a *second* key. **Pass:** the picker still reads the first one, and saving leaves the host bound to it.
|
||
|
||
**Failure means:** an editor that has to be cancelled and reopened before it can see what was just made — the
|
||
pickers went back to being snapshots taken when the editor opened, which was defensible only while the two
|
||
screens refused each other. See `VaultViewModel.RefreshOpenEditors`, and
|
||
`AKeyAddedWithTheHostEditorOpen_AppearsInItsAuthenticationPicker` for the same thing asserted against view
|
||
models. A picker that *moves* instead — landing on the key that just arrived, or back on "Password (ask each
|
||
time)" — is the worse failure of the two: it rebinds a host as a side effect of somebody else's sync.
|
||
|
||
---
|
||
|
||
## Phase 8 — Adding and removing on the phone's host list
|
||
|
||
Thirteen checks, and the reason there are thirteen rather than none is worth stating: **the layout suite
|
||
cannot see any of this and structurally never will.** `DodoSSH.Client.App.Layout.Tests` targets `net10.0` and
|
||
`DodoSSH.Client.Android` targets `net10.0-android`, so a project reference is impossible; Avalonia's
|
||
application, dispatcher and platform are one-shot process globals, so a second head cannot share the
|
||
process either; and the phone's numbers only mean anything measured under the phone's own style graph.
|
||
Every property and command below is covered headlessly in `ShellFlowTests` — the flow is shared, and only
|
||
the rectangles are not. So what follows is the rectangles.
|
||
|
||
Run on a 360dp-wide device or emulator, unlocked, on HOSTS.
|
||
|
||
### 8.0 It launches at all · **thirty seconds, and it has caught a shipped crash**
|
||
|
||
Install the APK and open it.
|
||
|
||
**Pass:** the opening screen appears.
|
||
|
||
**Failure means:** nothing on this head is covered by a test — see the paragraph above — so a view that
|
||
throws while it is being built takes the launch with it and no build gate anywhere says so. The one that
|
||
shipped was a generated `x:Name` field used from a constructor, which is null in every view in this
|
||
repository; see `docs/platform-flags.md`. Get the stack with `adb logcat -d -b crash`, which names the view.
|
||
|
||
Every check below assumes this one passed, and none of them can be reached if it did not.
|
||
|
||
### 8.1 A wide surface draws the rail, and a narrow one draws the bar · **needs a tablet, a foldable or a resizable window**
|
||
|
||
Open the application on something at least 600dp wide — a tablet, an unfolded foldable, a phone in
|
||
landscape, or a freeform window dragged wide.
|
||
|
||
**Pass:** a rail down the left edge carrying Hosts, Connections, Keychain, Snippets, SFTP, S3, Logs, Vaults
|
||
and Preferences, with Preferences at the foot. **No bar across the bottom.** The header with the vault's
|
||
name, the sync light and LOCK is up, and stays up on every one of those screens rather than disappearing on
|
||
the keychain the way it does on a phone.
|
||
|
||
Now make it narrow again — fold it, rotate it, drag the window in.
|
||
|
||
**Pass:** the rail goes, the bottom bar comes back with its three entries, and the screens behind SETTINGS
|
||
have their back arrows again.
|
||
|
||
Go to SETTINGS on the narrow surface, then widen it without touching anything else.
|
||
|
||
**Pass:** it lands on Hosts rather than sitting on the hub. The hub is a list of the destinations the rail
|
||
now carries, so staying there would be a menu of things visible beside it.
|
||
|
||
On the wide surface, open the keychain and press the system back gesture.
|
||
|
||
**Pass:** Hosts. Not the hub — that screen is not drawn here, and backing into it would strand somebody on
|
||
a surface with no way off it but a second back.
|
||
|
||
**Failure means:** a rail *and* a bottom bar at once is `ShowsRail` and `ShowsBottomBar` no longer being
|
||
each other's opposite. A back arrow on a wide surface leads to a screen the layout removed. And a bar that
|
||
never becomes a rail is the resize handler having gone back to returning early when the keyboard is
|
||
closed — which is how a foldable would open to a phone layout until somebody typed something.
|
||
|
||
### 8.2 The + is reachable and is not covering anything
|
||
|
||
Look at the bottom right of the host list with no host selected.
|
||
|
||
**Pass:** a filled accent circle, 56×56, clear of the bottom edge and of the last row's text. Then press
|
||
and hold a host so the action bar rises, and open the ⋯ menu → **Change group**.
|
||
|
||
**Pass:** the + is gone the moment anything is ticked, and stays gone under the picker that panel raises.
|
||
It comes back when the cross at the left of the bar is pressed.
|
||
|
||
**Failure means:** a + still drawn over a selection is `ShowsAddButton` no longer reading `IsChoosingHosts`
|
||
— a control that does something, where the something is not what the screen is about. Nothing in the layout
|
||
harness would catch it if it could run: its only fit rule is containment inside the window, never overlap
|
||
between two controls.
|
||
|
||
### 8.3 The sheet says what each choice does
|
||
|
||
Tap the +.
|
||
|
||
**Pass:** a sheet rises from the bottom with a grab handle, two rows — **New host** and **New group** — each
|
||
with a sentence under it, and CANCEL. The scrim darkens the list.
|
||
|
||
**Note:** the four nav destinations underneath stay lit and tappable. That is deliberate — this is a menu,
|
||
not a decision, and navigating away is a fine way to answer it. Compare the changed-host-key screen, which
|
||
must offer no way out but BACK.
|
||
|
||
### 8.4 The sheet is dismissible three ways
|
||
|
||
Tap the scrim. Reopen, tap CANCEL. Reopen, use the system back gesture.
|
||
|
||
**Pass:** all three lower it, and none of them opens an editor.
|
||
|
||
**Failure means:** back is being handled by the screen switch rather than by the guard above it, and the
|
||
gesture has left HOSTS instead of closing the sheet.
|
||
|
||
### 8.5 An empty port box says what it will dial
|
||
|
||
Add a group with a default port of 2222, then tap + → New host and choose that group in the GROUP picker.
|
||
|
||
**Pass:** the port box is **empty**, and the greyed text inside it changes to `2222` the moment the group is
|
||
chosen. The username box behaves the same way.
|
||
|
||
**Failure means:** the editor loaded a resolved value into the box instead of a placeholder behind it —
|
||
saving would then pin 2222 onto the host, and changing the group later would leave that host behind.
|
||
|
||
### 8.6 A host created that way inherits rather than copies
|
||
|
||
Save that host, then edit the group's default port to 2200 and look at the host's row.
|
||
|
||
**Pass:** the address now reads `…:2200`. Connect: the session goes to 2200.
|
||
|
||
**Failure means:** the port was copied at creation and the group is a one-shot template.
|
||
|
||
### 8.7 Filing a host into a group does not silently pin its password
|
||
|
||
Add a group with a default SSH key. Add a host with no group and leave AUTHENTICATION on **Password (ask
|
||
each time)**. Save. Now edit it and choose the group.
|
||
|
||
**Pass:** the picker gains an **Inherit from group** entry and moves to it. Save, and the host row reads
|
||
`key`. Tap it: it connects without asking for anything, rather than raising the password sheet.
|
||
|
||
**Failure means:** the picker's "Password (ask each time)" was written as a decision when it was only the
|
||
absence of one, and this host will never use the fleet's key.
|
||
|
||
### 8.8 A host can still refuse its group's key
|
||
|
||
On that same host, set AUTHENTICATION back to **Password (ask each time)** and save.
|
||
|
||
**Pass:** tapping the row raises the password sheet again, and that survives a lock and unlock.
|
||
|
||
**Failure means:** the fourth state has no storage and the host is inheriting whatever the group says.
|
||
|
||
### 8.9 Back closes the topmost thing, one at a time
|
||
|
||
Tap + → New host, then use the back gesture.
|
||
|
||
**Pass:** the editor closes and the list is back, with the boxes cleared. Back again leaves the application
|
||
— HOSTS is the screen the app opens on and back from it should exit, as it always did.
|
||
|
||
Repeat with the group menu: tap the `⋯` on a group heading, then back.
|
||
|
||
**Pass:** the menu closes and nothing is armed behind it — no editor, no question, no move panel.
|
||
|
||
### 8.10 Every target is a thumb's size
|
||
|
||
Walk both sheets' rows, the editor's fields, both pickers, the checkbox and the four buttons.
|
||
|
||
**Pass:** nothing is under 44 effective pixels tall.
|
||
|
||
**Failure means:** a control took its size from its glyph. The desktop suite's only tap-target assertion has
|
||
a floor of 20 pixels — set for a mouse — so copying it here would certify a target no thumb can hit.
|
||
|
||
### 8.11 The group menu names its group, and the heading still fits
|
||
|
||
Needs a vault with at least one group holding a host. On HOSTS, look at the group's heading row first.
|
||
|
||
**Pass:** chevron, name, count and a `⋯` all on one line, with the name trimmed rather than pushing the
|
||
count off the right edge. On a shared session the vault badge is between the name and the count and the name
|
||
gives way before the badge does.
|
||
|
||
Tap the `⋯`.
|
||
|
||
**Pass:** a sheet rises carrying **GROUP** and the group's own name, then three rows — Edit, Move to another
|
||
vault, a rule, and Delete in the danger colour — each with a sentence under it, and CANCEL. Tapping the
|
||
scrim closes it.
|
||
|
||
**Failure means:** if the name is missing, the sheet is bound to the wrong thing; the whole point of naming
|
||
it is that the heading it was raised from may have scrolled away under the scrim.
|
||
|
||
### 8.12 Deleting a group asks the second question
|
||
|
||
`⋯` → Delete on a group with at least two hosts filed under it.
|
||
|
||
**Pass:** a question above the list, naming the group, saying how many hosts are filed under it and that
|
||
left alone they move to UNGROUPED — with an **unticked** box offering to delete them as well, below the
|
||
count and not above it. DELETE and KEEP under that.
|
||
|
||
Press KEEP, then raise the question again on a *different* group.
|
||
|
||
**Pass:** the box is unticked again. A tick carried over from the last question would delete a second
|
||
group's machines on the strength of a decision about the first.
|
||
|
||
Now tick it and press DELETE.
|
||
|
||
**Pass:** the group and its hosts are gone. Untick it on a third group and the hosts survive under UNGROUPED.
|
||
|
||
### 8.13 Moving a group names the shelf · **needs a second writable vault**
|
||
|
||
`⋯` → Move to another vault.
|
||
|
||
**Pass:** a panel above the list reading **MOVE GROUP** and the group's name, a vault picker that does not
|
||
offer the vault it is already in, the paragraph about what stays behind, and MOVE / CANCEL.
|
||
|
||
Press MOVE.
|
||
|
||
**Pass:** the group, the groups nested inside it and every host under any of them are in the other vault,
|
||
the group is at the top level, and the status line says so.
|
||
|
||
**Failure means:** if MOVE appears to do nothing at all, the command is reading the desktop's group
|
||
selection, which this head never sets. That is the exact failure the header-aimed commands exist to prevent
|
||
— see `VaultViewModel.MoveGroupFromHeading`.
|
||
|
||
With only one writable vault, the entry answers on the status line instead and no panel opens. That is the
|
||
intended answer, not a failure.
|
||
|
||
### 8.14 Remove is at the bottom of the menu, below a rule
|
||
|
||
Press and hold a host so the action bar rises, then open the ⋯.
|
||
|
||
**Pass:** Connect, Connect via SFTP, Move to vault, Copy to vault, Change group, Duplicate — then a rule,
|
||
then **Remove** in the danger colour. The sheet scrolls if it has to, and Remove is reachable without the
|
||
list underneath being tapped by accident.
|
||
|
||
Tick a second host and open the ⋯ again.
|
||
|
||
**Pass:** Connect and Connect via SFTP are gone. A terminal and a file session are each about one machine,
|
||
and there is no reading of "connect to these six". The other five are still there.
|
||
|
||
Press Remove.
|
||
|
||
**Pass:** a question above the list naming the count — "Delete these 2 hosts?" — and KEEP puts the list back
|
||
with both hosts and both ticks still there.
|
||
|
||
**Failure means:** one question per host is `DeletionTarget.ChosenHosts` not being used, and six copies of
|
||
"delete prod-db?" is not a confirmation anybody reads. A question that cleared the ticks on KEEP is a
|
||
selection thrown away for declining to destroy it.
|
||
|
||
### 8.14a The key question is asked for one host and for a move · **needs two writable vaults**
|
||
|
||
With a host bound to a key that is in the vault it is leaving, tick that host alone and choose **Move to
|
||
vault**.
|
||
|
||
**Pass:** under the picker, an unticked box offering to bring the key, with the count of what else uses it
|
||
underneath. Choosing a different destination re-asks it, and it disappears when the destination is the vault
|
||
the key is already in — the same behaviour as the desktop's, which 3.3 measures.
|
||
|
||
Now choose **Copy to vault** on the same host.
|
||
|
||
**Pass:** no box at all. A copy that took the key away would leave the original — still sitting in the vault
|
||
it started in — unable to connect, which is the one thing "copy" promises not to do.
|
||
|
||
Tick a second host and open **Move to vault** again.
|
||
|
||
**Pass:** still no box. Which key to carry is a fact about one machine, and a selection of six has six
|
||
answers.
|
||
|
||
**Failure means:** a box under a copy is `ChosenBindingToBring` no longer reading
|
||
`ChosenHostsAreBeingCopied`, and it moves a private key out from under a host that is still using it. A box
|
||
that is ticked when the panel opens is worse than a bug for the reason 3.3 gives.
|
||
|
||
### 8.15 A tap connects, and a long press chooses · **the one worth the most care**
|
||
|
||
On a host bound to a key or a stored credential — one that needs nothing typed — tap the row once.
|
||
|
||
**Pass:** it connects, and the terminal takes the screen. **Nothing rises over the list on the way past** —
|
||
no password box, no action bar. Come back to HOSTS: still nothing, and no row is marked.
|
||
|
||
Now press and hold the same row.
|
||
|
||
**Pass:** the header is replaced by a bar carrying ✕, the count `1`, a pencil and a ⋯, and the row itself is
|
||
filled with a tick at its right-hand end. Press and hold a second row: the count reads `2` and the pencil is
|
||
gone.
|
||
|
||
Tap one of the two.
|
||
|
||
**Pass:** it unticks rather than connecting. Tap the other: the count reaches zero, the bar goes and the
|
||
vault header comes back.
|
||
|
||
Now tap a host that authenticates with a **typed password**.
|
||
|
||
**Pass:** it does not dial. A sheet rises from the bottom naming that host, with a password box, the
|
||
remember tick, CONNECT and CANCEL. Type it and press CONNECT — or dismiss the sheet, type nothing, and tap
|
||
the row a second time — and it connects. Dismissing it empties the box.
|
||
|
||
**Failure means:** a tap that connects while rows are ticked is `IsChoosingHosts` not being read in
|
||
`HostsScreen.axaml.cs` — a terminal opened on top of a selection somebody was halfway through building. A
|
||
tap on a group *heading* that ticks a host is worse: it means the handler is reading the list's selection
|
||
rather than the element under the finger, so a machine nobody pointed at has gone into a set about to be
|
||
deleted. A tap that dials a password host with an empty box fails somewhere the user cannot see why. And a
|
||
long press that also connects means `held` is not suppressing the `Tapped` that follows a hold.
|
||
|
||
### 8.16 The pencil in the action bar opens a page, not a card
|
||
|
||
Tick exactly one host and press the pencil.
|
||
|
||
**Pass:** the editor takes the **whole screen** — no search box, no sync line, no shells strip, no bottom
|
||
bar — with its own header carrying ←, the title, and a ✓. The ticks are gone: the screen is about one host
|
||
now.
|
||
|
||
Fill in a field and press ← .
|
||
|
||
**Pass:** back on the list with nothing saved, and the boxes cleared for the next time. The system back
|
||
gesture does the same thing.
|
||
|
||
Tick two hosts.
|
||
|
||
**Pass:** no pencil. Editing two machines has no meaning, and the bar collapses the control rather than
|
||
greying it.
|
||
|
||
**Failure means:** chrome left around the editor is `RefreshChrome` no longer reading the vault's
|
||
`IsEditing` — see `PhoneShell.axaml.cs`, which subscribes to the vault as well as the shell precisely so
|
||
that it can. A pencil beside a count of two is `HasOneChosenHost` not being read.
|
||
|
||
### 8.17 Connect via SFTP crosses to the files screen with the host already chosen
|
||
|
||
Tick one host that needs nothing typed and choose **Connect via SFTP** from the ⋯.
|
||
|
||
**Pass:** the files screen, with that machine already chosen and connecting — not a picker asking which host
|
||
again. The ticks are gone.
|
||
|
||
Now do the same with a host that authenticates with a typed password.
|
||
|
||
**Pass:** the files screen with the picker open, that host chosen in it, its own password box beside it, and
|
||
a line saying the host asks for one. It does not dial with an empty box.
|
||
|
||
Open a bucket first, then try the entry again.
|
||
|
||
**Pass:** it refuses and says so, and the screen stays on the bucket. There is one file session behind both
|
||
destinations, so switching under a live one would show a screen titled S3 listing an SFTP host's files.
|
||
|
||
**Failure means:** a picker asking which host is `MainWindowViewModel.OnVaultFilesRequested` failing to find
|
||
the row — that screen keeps its own copy of the host list, so it has to be re-found there by entity id
|
||
rather than handed the vault's object.
|
||
|
||
---
|
||
|
||
## Phase 9 — Tag chips and the picker
|
||
|
||
The desktop half is measured by the layout suite, which now seeds three tags and puts all of them on one
|
||
host so the chip row that wraps is what gets laid out. Two things it still cannot answer, and the phone's
|
||
side of it remains unmeasurable for the reasons in phase 8.
|
||
|
||
### 9.1 The desktop host editor scrolls rather than losing its buttons
|
||
|
||
Open a host's editor with a keychain holding a dozen tags.
|
||
|
||
**Pass:** the drawer scrolls, and FORGET HOST KEY is reachable at the bottom of it.
|
||
|
||
**Failure means:** the drawer's ScrollViewer is gone. The layout harness skips anything inside one, so from
|
||
that commit on it certifies the drawer fits rather than the fields — it will tell you the drawer is fine
|
||
while the last button sits below the window. The editor used to carry a MaxHeight of its own because the
|
||
host list shared its column; the drawer is alone in its column now, so the height is the window's.
|
||
|
||
### 9.2 A chip toggles and reads as toggled
|
||
|
||
In the picker, tap a tag on and off.
|
||
|
||
**Pass:** worn is filled and accent-coloured, unworn is outlined. The difference is legible at a glance from
|
||
normal reading distance, not only side by side.
|
||
|
||
### 9.3 A tag renamed on one machine renames every chip on the other
|
||
|
||
Tag two hosts, sync, rename the tag on the other machine, sync back.
|
||
|
||
**Pass:** both rows show the new name, and neither host shows as having unsynced changes — renaming a tag
|
||
rewrites no host.
|
||
|
||
**Failure means:** the rename copied a name into the hosts, which is the thing making a tag an item was for.
|
||
|
||
### 9.4 A deleted tag drops the chip and nothing else
|
||
|
||
Delete a tag two hosts wear.
|
||
|
||
**Pass:** the confirmation says how many hosts wear it. After confirming, both rows lose the chip and
|
||
nothing else about them changes. Neither host is queued for push.
|
||
|
||
### 9.5 The phone's picker is usable with a thumb
|
||
|
||
On the phone's host editor with several tags: the chips are at least 36 tall, spaced enough that a miss
|
||
lands between them rather than on the wrong tag, and the new-tag box and ADD sit on one row without either
|
||
being squeezed to nothing.
|
||
|
||
---
|
||
|
||
## Phase 10 — The software keyboard and the boxes that take secrets
|
||
|
||
Every check here needs a real Android device or emulator, and there is no headless equivalent of any of
|
||
them: the software keyboard is an inset the platform reports, and a headless top level reports none.
|
||
Worth running on two devices if you have them — one on Android 14 or earlier and one on Android 15 or
|
||
later — because the interface is kept clear of the keyboard by a different mechanism on each. Before 15 the
|
||
activity's `AdjustResize` has the platform shorten the window; from 15 the window is not resized at all and
|
||
`PhoneShell` applies the reported inset itself. A build that only ever ran on one of the two will look
|
||
correct and be half broken.
|
||
|
||
### 10.1 The vault passphrase box is treated as a password by the keyboard
|
||
|
||
Launch to the lock screen, tap the passphrase box, type a few characters.
|
||
|
||
**Pass:** dots on screen, and **no suggestion strip above the keyboard** — no completions, no previously
|
||
typed words, no autocorrect. Then open any ordinary box on the phone (the host search, a snippet's name)
|
||
and confirm the suggestions come back there.
|
||
|
||
**Failure means:** `TextInputOptions.ContentType` is missing — most likely a box was given `PasswordChar`
|
||
directly instead of `Classes="... secret"`. `PasswordChar` is what the screen draws; the content type is
|
||
what the keyboard is told, and only the second one keeps a passphrase out of the IME's learning
|
||
dictionary. A box showing dots with a suggestion strip over it is the worst case, not a cosmetic one.
|
||
|
||
### 10.2 COPY CODE actually copies, on both heads
|
||
|
||
Enrol a fresh profile. On the recovery-code screen, press **COPY CODE**, then paste somewhere — another
|
||
app on the phone, a text editor on the desktop.
|
||
|
||
**Pass:** the whole code arrives, and the line under the button says to put it in a password manager now.
|
||
|
||
**Failure means:** if the phone says *this machine has no clipboard*, the delegate is not being passed to
|
||
`MainWindowViewModel` again — the phone shipped for a while with none, so every copy on that head said
|
||
exactly that on a device that plainly has one. If nothing is said at all, the button silently no-opped,
|
||
which on this screen is worse than refusing: somebody who believes the code is on their clipboard will not
|
||
write it down, and it is shown once.
|
||
|
||
Worth pasting somewhere you can see all of it. The code is the only thing standing between a forgotten
|
||
passphrase and an unrecoverable vault, and a truncated copy fails silently months later.
|
||
|
||
### 10.3 The keyboard does not cover the box being typed into
|
||
|
||
The same box: with the keyboard up, the passphrase box and the UNLOCK button under it are both visible.
|
||
Repeat on each of the five boxes that take a secret — lock screen, both enrollment boxes, the connect
|
||
password on HOSTS, and the connect password on FILES.
|
||
|
||
**Pass:** the box stays on screen when the keyboard opens, and the interface is shortened rather than slid
|
||
— the header stays where it is rather than scrolling off the top.
|
||
|
||
**Failure means:** on Android 15 or later, the inset is no longer reaching `PhoneShell`. On 14 or earlier,
|
||
`WindowSoftInputMode` has been dropped from the activity and the platform is panning the window instead of
|
||
resizing it — which, for a window whose whole content is one native view, pans by nothing useful.
|
||
|
||
### 10.4 Nothing is stranded when the keyboard closes
|
||
|
||
Dismiss the keyboard with back or the down-chevron from each of those screens.
|
||
|
||
**Pass:** the interface fills the screen again immediately, with no band of empty canvas left along the
|
||
bottom and no scroll position left part way down.
|
||
|
||
**Failure means:** the inset is being applied but not cleared — the closed state is not being read from the
|
||
event, or the margin is only ever added to.
|
||
|
||
### 10.5 Rotating with the keyboard up
|
||
|
||
Focus a passphrase box, then turn the phone sideways.
|
||
|
||
**Pass:** the box is still visible and still focused, and the shell is intact — the activity handles the
|
||
rotation rather than being recreated, and live shells survive it.
|
||
|
||
---
|
||
|
||
## Phase 11 — The phone's terminal surface
|
||
|
||
Every check here needs a real device for the reason Phase 10's do, plus one of its own: the interesting
|
||
question on this screen is whether a native `WebView` composites above what Avalonia draws over it, and no
|
||
headless surface has a native view to answer with. `docs/android-port.md` still records that as unverified;
|
||
11.2 is the check that settles it.
|
||
|
||
### 11.1 A shell gets the screen · **the important one for chrome**
|
||
|
||
Open a shell from HOSTS.
|
||
|
||
**Pass:** the vault header, the session strip and the three-entry bottom bar are all gone. What is left is
|
||
one bar — a back arrow, the session pills, a `+` — and then the terminal down to the accessory keys. Press
|
||
back: all three come back, the tab is still in the strip and its dot is still green.
|
||
|
||
**Failure means:** the strip is not bound on `IsShowingPages`, `RefreshChrome` is not reading `HasTabs`, or
|
||
the terminal is being reached by a route that leaves `Surface` on `Page`. A bar still there **with a shell
|
||
showing** is the one that matters: that is the third of the screen this arrangement exists to give back.
|
||
|
||
### 11.2 The connect menu is not drawn over the renderer · **the important one**
|
||
|
||
With a shell showing output, press `+`.
|
||
|
||
**Pass:** the terminal's rectangle goes to the canvas colour and the sheet sits over it whole — scrim, three
|
||
rows and CANCEL, every one of them tappable, none of them sliced down the left edge. Tap the scrim: the
|
||
terminal comes back with its scrollback intact and the shell still running.
|
||
|
||
**Failure means:** `IsTerminalShowing` is not being cleared by `IsConnectSheetOpen` — or, if the sheet is
|
||
sliced *despite* the rectangle going blank, something else in that Panel is still showing. A sheet that
|
||
draws over live terminal output is the Android answer to the compositing question, and it means every
|
||
future sheet on this surface has to collapse the renderer too.
|
||
|
||
### 11.3 Back lowers the menu before it leaves the terminal
|
||
|
||
With the connect menu open, use the system back gesture.
|
||
|
||
**Pass:** the menu closes and the terminal is still showing. A second back leaves the terminal for the
|
||
screen it was opened over.
|
||
|
||
**Failure means:** the guard in `PhoneShell.axaml.cs` is below the surface check rather than above it, and
|
||
one gesture is spending two levels.
|
||
|
||
### 11.4 The two end buttons cannot be pushed off the bar
|
||
|
||
Open six or more shells.
|
||
|
||
**Pass:** the pills scroll under a fixed back arrow and a fixed `+`; neither ever leaves the screen, and
|
||
scrolling the pills to either end does not move them.
|
||
|
||
**Failure means:** a control was put inside the `ScrollViewer` rather than beside it.
|
||
|
||
### 11.5 The text-size buttons are always reachable
|
||
|
||
With a shell open, scroll the accessory key row to the far left and the far right.
|
||
|
||
**Pass:** `A−` and `A+` stay pinned at the right-hand end throughout, separated from the keys by the
|
||
hairline, and both are at least 30 tall. At the smallest and largest sizes the one that can do nothing is
|
||
visibly disabled rather than silently inert.
|
||
|
||
**Failure means:** they have been folded into the scrolling row — which is the arrangement the connection
|
||
line existed to avoid, and the reason it could be removed at all.
|
||
|
||
### 11.6 The keyboard goes away with the connect menu
|
||
|
||
With a shell open, tap into the terminal so the software keyboard comes up, then press `+`.
|
||
|
||
**Pass:** the keyboard drops and the sheet sits on the bottom of the full screen, all four of its rows
|
||
reachable. Repeat on Android 14 or earlier and on 15 or later if you have both — the keyboard shortens this
|
||
head's interface by a different mechanism on each, and a sheet laid out into the strip left above a
|
||
keyboard is the failure this exists to catch.
|
||
|
||
**Failure means:** `SoftKeyboard.Hide` found no activity, no input-method manager, or the wrong window
|
||
token. Note that Avalonia cannot do this for us — `TopLevel.InputPane` reports the keyboard and cannot close
|
||
one, and the keyboard here belongs to the `WebView`, so clearing Avalonia's focus is not a fallback.
|
||
|
||
### 11.7 Connections, with nothing open
|
||
|
||
Close every tab, then press Connections in the bottom bar.
|
||
|
||
**Pass:** a box reading `user@host or user@host:port`, a password box under it, CONNECT, and — if this
|
||
keychain has ever connected to anything — a RECENT list underneath. The whole thing scrolls with the
|
||
keyboard up, and the password box shows dots with **no suggestion strip** above the keyboard.
|
||
|
||
**And the bottom bar is still there, with Connections lit.** Press Hosts: it goes straight there, without
|
||
the back gesture. Then open a shell — the bar goes, which is 11.1. Wide, the same holds for the rail.
|
||
|
||
**Failure means:** for the bar, `RefreshChrome` is back to collapsing the nav for the whole terminal
|
||
surface rather than for a shell — which strands anybody who reaches this screen by closing their last tab.
|
||
For an unlit Connections entry, `IsCurrent` lost its binding: on this screen it is the only true thing the
|
||
bar could say about where you are.
|
||
|
||
Type a machine you can reach and press CONNECT: a tab appears in the bar and the shell opens. Type
|
||
something malformed — no `user@`, or `:70000` — and the refusal appears under the boxes, in the warning
|
||
colour, with no tab and nothing dialled.
|
||
|
||
**Failure means:** for the suggestion strip, the box lost `Classes="secret"` — see 10.1, and it is the same
|
||
seriousness here, since this password is typed rather than stored and so is typed often.
|
||
|
||
### 11.7a A hand-typed machine's host key is judged on this screen · **the one the old arrangement got wrong**
|
||
|
||
From Connections with nothing open, type a machine this keychain has never reached and press CONNECT.
|
||
|
||
**Pass:** the UNKNOWN HOST KEY sheet rises **over this screen**. TRUST AND CONNECT opens the shell; CANCEL
|
||
lowers the sheet and the box is still there, with what was typed still in it.
|
||
|
||
**Failure means:** if the window is on HOSTS instead, the shell is navigating there before letting the
|
||
handshake ask — which is what it used to do, and it is at its worst precisely here: a machine reached from
|
||
this box is deliberately in no keychain, so it was being judged on a list it does not appear on, with the box
|
||
that dialled it taken away. See `MainWindowViewModel.OnVaultConnectionFailed`.
|
||
|
||
### 11.8 A recent machine leads to the right place
|
||
|
||
With at least one keychain host and one machine connected to by hand in the log, open Connections with
|
||
nothing running.
|
||
|
||
**Pass:** tapping the keychain one lands on HOSTS with that host **ticked and the action bar up**, ready
|
||
for CONNECT out of the ⋯. Tapping the hand-typed one puts its address back in the box on this screen and
|
||
leaves the password box **empty**. Neither one dials anything on its own.
|
||
|
||
**Failure means:** if the hand-typed one navigates to HOSTS, the log entry is carrying a host id it should
|
||
not have. If the password came back, something is storing it — it never was. And if the keychain one arrives
|
||
at a list with nothing marked, `ConnectToRecent` is setting the selection rather than going through
|
||
`AskAboutHost`: nothing on that list means "selected" any more, so that is a screen with nothing to press.
|
||
|
||
### 11.9 Settings holds the keychain, and back walks out of it
|
||
|
||
From HOSTS: Settings, then Keychain.
|
||
|
||
**Pass:** the bottom bar has three entries and the Settings one stays lit while the keychain is showing.
|
||
The keychain draws its own header with a back arrow — the vault name and sync light are not above it. Back,
|
||
by gesture or by the arrow, returns to Settings and not to HOSTS; a second back returns to HOSTS.
|
||
|
||
**Failure means:** `ShellScreen.Vault` is missing from `IsMoreSurface` or from the back gesture's first
|
||
case, and those two have to move together — the switch mirrors that property by construction.
|
||
|
||
### 11.10 The accessory keys do not cost the terminal its keyboard · **needs a hardware keyboard**
|
||
|
||
With a shell open and a Bluetooth or USB keyboard paired, type into the terminal and confirm it arrives.
|
||
Now tap **Tab** on the accessory row, or **A+**, and type again.
|
||
|
||
**Pass:** the byte the key sent arrives, and so does everything typed after it. The terminal still has the
|
||
keyboard.
|
||
|
||
**Failure means:** the buttons on that row have become focusable again. An ordinary Avalonia button takes
|
||
focus on tap, which takes it off the `NativeWebView` — and the package's own `OnLostFocus` then resigns the
|
||
page's focus. The row goes on working, because its keys are pressed rather than typed into, so the symptom
|
||
is a terminal that answers the buttons and ignores the keyboard: it reads as the session having died. See
|
||
`Focusable = false` in `TerminalScreen.axaml.cs` and the focus entry in `docs/platform-flags.md`.
|
||
|
||
Worth doing on the software keyboard too, where the same fault shows as the keyboard closing on the first
|
||
tap of an arrow key.
|
||
|
||
---
|
||
|
||
## Phase 12 — Shared vaults: the operations that span two accounts
|
||
|
||
The server's own rules are covered by the endpoint suite: teams are renamed, an archive is refused while a
|
||
vault is in the way, ownership changes hands, and an address with no account is refused with its own code.
|
||
What is left needs two real accounts, a real sign-in, and in one case a clock — because the half of sharing
|
||
this product is built around happens on a machine rather than on the server, and no server test can reach
|
||
it.
|
||
|
||
**Two accounts, and two profiles.** The dev realm ships `alice` and `bob`; a second DodoSSH profile means a
|
||
second machine, a second OS user, or the same machine after signing out. Whichever account plays the
|
||
newcomer **must not have signed in to this deployment before** — 12.1 is about exactly that boundary.
|
||
|
||
### 12.1 An address with no account is refused, and joins nothing when it later signs in · **the one worth the most care**
|
||
|
||
1. Sign in as `alice`, go to Settings → Vaults, make a vault there, and select it.
|
||
2. Add `bob@example.com` as a Member, with Bob having never signed in here.
|
||
3. **Pass:** it is refused. The status line names the address and says to ask them to sign in to this
|
||
server once and then add them. **Nothing on the screen should suggest anything is pending** — no
|
||
invited row, no "we will add them when they arrive", and the address stays in the box so it can be
|
||
used again in a moment.
|
||
4. Sign in as `bob` on the second profile and enroll.
|
||
5. **Pass, and this is the half that is easiest to lose:** Bob's vault list holds only his personal vault.
|
||
Alice's members table is unchanged. Signing in with an address somebody typed earlier must join nothing
|
||
at all.
|
||
6. Back on Alice's machine, add `bob@example.com` again.
|
||
7. **Pass:** he is added, and — because Alice's machine holds the vault key and Bob has now published one
|
||
— the key is wrapped in the same step. Have Bob sync; the vault opens.
|
||
|
||
**Failure means:** step 5 is the one to stop on. A vault appearing in Bob's list because he signed in with
|
||
an address is the deferred-membership path coming back, and it is the property this phase exists to check:
|
||
access is granted to an account somebody named, never to an address. See ADR 0009.
|
||
|
||
Step 7 opening the vault *without* Alice's machine having wrapped a key would be the more serious failure:
|
||
nothing on the server can wrap a vault key, so an item that decrypts after a membership change alone means
|
||
a key reached that machine by a route this architecture says does not exist.
|
||
|
||
### 12.2 Somebody who has signed in but not enrolled can still be added
|
||
|
||
Have a third account sign in and stop — no passphrase, no enrollment. Then add its address to a vault.
|
||
|
||
**Pass:** accepted. Their row appears saying they hold no key, and SHARE KEY does not offer to wrap one to
|
||
them. The directory returns nothing for that address, which is correct: it lists accounts that have
|
||
published a key, and this one has not.
|
||
|
||
**Failure means:** a refusal here is the old bug, and it is the reason ADD does not take the directory's
|
||
silence as an answer. Everybody passes through the window between a first sign-in and enrollment, and
|
||
being told they have no account while they are standing beside you is the worst moment to say it.
|
||
|
||
### 12.3 An address already in the team is refused, and says which
|
||
|
||
With Bob in the vault, add `bob@example.com` to it again.
|
||
|
||
**Pass:** refused, with a sentence saying the address already belongs to a member and to change their role
|
||
instead — distinct from the "no account here" refusal in 12.1, because the two lead somewhere different.
|
||
|
||
**Failure means:** the two refusals reading alike leaves somebody checking what they typed when the answer
|
||
is that the person is already in.
|
||
|
||
### 12.4 Somebody can be added from the phone, and the key goes with them · **needs a device and two accounts**
|
||
|
||
On the phone: Settings → Vaults, choose a shared vault this account administers.
|
||
|
||
**Pass:** under the members list there is an ADD SOMEBODY box, three role chips with MEMBER lit, an ADD
|
||
button, and a paragraph saying that adding lets the server serve them and does not let them read anything.
|
||
On a vault this account only belongs to, none of that is drawn at all — not greyed, absent.
|
||
|
||
Type a colleague's address and press ADD, with the phone unlocked.
|
||
|
||
**Pass:** they appear in the members list, and the status line says both halves — that they were added, and
|
||
what happened to the key. On an unlocked phone that holds the vault key it says it shared it; the colleague
|
||
syncs and the vault opens.
|
||
|
||
Now lock the phone's keychain and add somebody else.
|
||
|
||
**Pass:** it refuses to pretend. The membership is made and the line says the key could not be wrapped
|
||
because this keychain is locked — which is a state somebody can act on, unlike silence.
|
||
|
||
**Failure means:** a screen that adds somebody and says nothing about the key is implying the server can
|
||
hand out access, which is the one claim this product exists to refuse. ADD drawn for a plain member is a
|
||
button whose only outcome is a 403 from the server.
|
||
|
||
### 12.5 LAST ACTIVE is a real time, and a coarse one · **needs a couple of hours**
|
||
|
||
Use one account and leave the other idle for two or three hours, then read the members table.
|
||
|
||
**Pass:** the account being used carries a recent time, the idle one does not move, and neither moves more
|
||
than once an hour however much is done in it. It is shown as roughly-when, never to the minute.
|
||
|
||
**Failure means:** a value that tracks every click means the hourly gate is gone and every authenticated
|
||
request is writing to `user_account` — which carries the xmin concurrency token, so the next symptom is a
|
||
user's own overlapping requests failing on a version that moved under them. A value frozen at enrollment
|
||
means the refresh is not running on ordinary requests at all, which is the state that made this column
|
||
impossible to offer honestly before.
|
||
|
||
### 12.6 Ownership changes hands in one act
|
||
|
||
As the owner, transfer ownership to another active member, then read both rows.
|
||
|
||
**Pass:** they are Owner and you are **Admin** — not removed, not Member. Your vault key grant is intact
|
||
and the vault has not come back flagged for rekey. Then try to hand it to somebody who is not a member, and
|
||
to yourself.
|
||
|
||
**Pass:** both refused, and the message says which.
|
||
|
||
**Failure means:** two owners, or none, is the state this being a single transaction exists to prevent, and
|
||
either one leaves a vault that no client can administer back into shape. If your grant was revoked or the
|
||
vault is now flagged for rekey, the transfer is removing the outgoing owner rather than demoting them.
|
||
|
||
### 12.7 A vault cannot be deleted, and the screen says so rather than offering a button
|
||
|
||
Look for a way to remove a vault, on both heads.
|
||
|
||
**Pass:** there is none, and Settings → Vaults says why in a sentence: nothing in this product removes a
|
||
vault, and the server refuses to archive the membership list behind one while it exists. Archiving that
|
||
list is still reachable over the API, and the endpoint suite drives both its refusal and its success — what
|
||
is being checked here is that no button offers it.
|
||
|
||
**Failure means:** a delete that worked would take the vault out of the list of everybody holding a key —
|
||
including the person who pressed it, quietly, and with nothing in the product able to put them back. A
|
||
button that always refuses is the milder failure and is still worth removing.
|
||
|
||
### 12.8 Renaming a vault reaches every place its name is drawn · **needs two accounts**
|
||
|
||
Rename a shared vault from Settings → Vaults.
|
||
|
||
**Pass:** the new name is on the vault list, on the badge of every host card in that vault, in the keychain
|
||
screen's "new items file to" picker, in the host editor's vault picker, and in the nav rail's user-chip
|
||
popover — and on the second account after a refresh. Nothing in the vault needs re-encrypting and
|
||
everybody's key still opens it.
|
||
|
||
**Failure means:** a name that moved in one place and not another is the shape this rename is most likely to
|
||
fail in, because several screens read it separately from a cached vault row. A vault that stops opening
|
||
after a rename would be far worse, and means that cached row was replaced by the server's answer rather
|
||
than edited — that answer deliberately carries no wrapped key.
|
||
|
||
### 12.9 Somebody who may write to a vault may not rename it
|
||
|
||
As a plain Member of somebody else's vault, look for RENAME.
|
||
|
||
**Pass:** it is not drawn. Adding a host to that vault still works, which is what makes this a boundary
|
||
rather than a broken role.
|
||
|
||
**Failure means:** a name is what everybody in the vault sees it called, so a member renaming it out from
|
||
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
|
||
|
||
Every check here needs a real Android device or emulator with a screen lock and a fingerprint enrolled on
|
||
the phone itself, and none has a headless equivalent: the whole feature is a keystore key the platform will
|
||
not release without a gesture, and there is no gesture in a test process. What *is* covered automatically is
|
||
the shell's half — `ShellFlowTests` registers, relaunches, unlocks and withdraws against a fake keystore, so
|
||
what is left here is the platform half plus the one thing only a person can see, which is which dialogue
|
||
comes up.
|
||
|
||
### 13.1 The offer is on PREFERENCES, and only when there is something to offer
|
||
|
||
Unlock the keychain, go to SETTINGS → Preferences on a phone with a screen lock.
|
||
|
||
**Pass:** REGISTER THIS PHONE is there under THIS PHONE. On a phone with **no** screen lock at all, neither
|
||
button is drawn and the paragraph saying this phone has nowhere to keep a device key is.
|
||
|
||
**Failure means:** if the button is drawn on a phone with no screen lock, `AndroidDeviceKeyStore`
|
||
`IsAvailableAsync` is no longer asking the keyguard — and registering there would put a wrap on the account
|
||
that nothing can ever open, on a phone whose key no gesture can release.
|
||
|
||
### 13.2 Registering asks for the fingerprint, and says which phone it registered
|
||
|
||
Press REGISTER THIS PHONE while signed in.
|
||
|
||
**Pass:** the system's own biometric prompt appears, titled "Register this phone". Confirm it, and the status
|
||
line names **this phone** — the name from Android's Settings, or the model — rather than `localhost`. The
|
||
button is replaced by STOP UNLOCKING HERE. Cancel the prompt instead and nothing changes but the message.
|
||
|
||
**Failure means:** a status line reading `localhost` means the head is no longer passing
|
||
`PhoneEnvironment.DeviceName`, and the account's device list is about to fill with rows nobody can tell
|
||
apart. No prompt at all means the cipher is not being bound to it — see `BiometricGate`, where binding is
|
||
the entire point.
|
||
|
||
### 13.3 The lock screen then opens without the passphrase
|
||
|
||
Lock the keychain, close the app, and launch it again.
|
||
|
||
**Pass:** the prompt is raised on arrival, and confirming it opens the keychain with nothing typed. UNLOCK
|
||
WITH FINGERPRINT is on the screen behind it. Lock from inside the running app instead and **no** prompt is
|
||
raised — that rule is deliberate; see `PhoneShell.TryOfferDeviceUnlock`.
|
||
|
||
**Failure means:** a button that is absent after a successful registration is the wrap not reaching the local
|
||
cache. A prompt raised after an in-app lock trains the reflex of authenticating at a prompt nobody asked for.
|
||
|
||
### 13.4 Enrolling a new fingerprint on the phone destroys the key · **the security property**
|
||
|
||
With DodoSSH registered, add another fingerprint in Android's own Settings. Then launch DodoSSH.
|
||
|
||
**Pass:** no fingerprint button, and the passphrase opens the vault as it always did. Registering again from
|
||
PREFERENCES restores it.
|
||
|
||
**Failure means:** `setInvalidatedByBiometricEnrollment` has been dropped, and anybody who can add their own
|
||
fingerprint to an unlocked phone has inherited the vault. This is the check that says the phone's fast path
|
||
is not a downgrade of the passphrase.
|
||
|
||
### 13.5 Withdrawing stops this phone, and clears the account
|
||
|
||
Press STOP UNLOCKING HERE.
|
||
|
||
**Pass:** it goes back to offering REGISTER, a relaunch asks for the passphrase, and the device is gone from
|
||
the account — check from the desktop head, or by registering the same phone again and seeing one device
|
||
rather than two. There is no confirmation prompt, deliberately.
|
||
|
||
**Failure means:** a phone that still unlocks itself after this is the local half not happening, which is the
|
||
half that matters when the handset is the thing that was lost.
|
||
|
||
### 13.6 The phone says which build it is
|
||
|
||
Scroll the same screen to **THIS BUILD**.
|
||
|
||
**Pass:** a VERSION row carrying a real version rather than `1.0.0`, matching the tag the APK was built
|
||
from, with the paragraph under it saying this head does not replace itself and that no DodoSSH server will
|
||
ever offer you a newer one.
|
||
|
||
**Failure means:** `1.0.0` is MinVer answering from a checkout with no tags — see check 16.5 for the same
|
||
failure on the desktop. A missing paragraph means `Updates.IsUnsupported` came back false on a head that
|
||
has no updater at all, which would be the null channel not being the one composed.
|
||
|
||
---
|
||
|
||
## Phase 14 — Moving files to and from the phone's remote
|
||
|
||
Every check here needs a real Android device or emulator, a host with SFTP or a bucket, and at least one
|
||
document on the phone to send. What is automated is what can be: `TransferQueueingTests` says a staged
|
||
upload obeys the same rules as any other and that a delivered download refuses a directory before the
|
||
picker's damage is done. What cannot be automated is everything below — the two system pickers are another
|
||
application, and the staging copies, the delivery, the notification and the resume are all things only a
|
||
running phone does.
|
||
|
||
### 14.1 ADD FILES opens the system picker, and takes more than one
|
||
|
||
Connect to a host on SFTP, navigate somewhere writable, press **ADD FILES**, and choose two documents in one
|
||
go — long-press to multi-select in Android's picker.
|
||
|
||
**Pass:** two rows appear in the queue with the names the picker showed, and both land in the directory the
|
||
breadcrumb names. The pane's listing shows them after **↻**.
|
||
|
||
**Failure means:** one row from a two-document pick is `PickAsync` losing the rest, and two rows with one
|
||
name is the per-file staging directory having gone — that is the overwrite `DocumentStaging` documents, and
|
||
it silently uploads the same bytes twice.
|
||
|
||
### 14.2 The name that arrives is the name that was picked · **the one most likely to be wrong**
|
||
|
||
Pick a document whose display name has a space and a non-Latin character in it, and one from a cloud
|
||
provider — Drive, or the Downloads shortcut — rather than local storage.
|
||
|
||
**Pass:** the file on the host is called what the picker called it. A cloud document uploads too, or fails
|
||
with the provider's own message in the status line rather than a crash.
|
||
|
||
**Failure means:** a mangled name is `SafeName` over-reaching. A name that reaches the host with a `/` in it
|
||
is `SafeName` under-reaching, and that one writes to a path nobody chose. A cloud document that hangs is
|
||
the copy being made on the interface thread — the whole reason `CopyInAsync` leaves it.
|
||
|
||
### 14.3 The queue is bounded, and the buttons stay reachable
|
||
|
||
Queue five or six files at once, on a small phone if there is one.
|
||
|
||
**Pass:** the queue scrolls inside its own region and **ADD FILES**, **DELETE** and **CLOSE** are all still
|
||
on screen. Every button is a thumb's size.
|
||
|
||
**Failure means:** buttons pushed off the bottom is the `MaxHeight` gone from the queue's scroller, and it
|
||
makes the screen unusable exactly when somebody has queued the most work.
|
||
|
||
### 14.4 A stopped upload resumes rather than starting again
|
||
|
||
Start a large upload, press **STOP** part way, then press **RESUME**.
|
||
|
||
**Pass:** it carries on from roughly where it stopped rather than from zero — the progress text is the thing
|
||
to read.
|
||
|
||
**Failure means:** restarting from zero means the staged copy was deleted at the stop, which is precisely
|
||
what `QueueStagedUploads` does not do and why it does not. A failure saying the file cannot be found is the
|
||
same bug, one step further along.
|
||
|
||
### 14.5 The copies do not accumulate · **the one nothing else would catch**
|
||
|
||
Note the app's storage in Android Settings → Apps → DodoSSH → Storage. Upload a large file, let it finish,
|
||
and look again. Then stop an upload part way, leave it stopped, force-stop the app and relaunch it.
|
||
|
||
**Pass:** storage returns to about what it was after the successful upload — the copy is deleted the moment
|
||
the transfer completes. After the stopped one, the cache is bigger while the app stays open (the copy is
|
||
being kept for RESUME) and back to its old size after the relaunch, which is `DocumentStaging.Sweep`.
|
||
|
||
**Failure means:** growth after a successful upload is `ReleaseStaged` not firing, and every file sent
|
||
leaves a second copy on the phone until Android reclaims the cache. Growth that survives a relaunch is the
|
||
sweep not running.
|
||
|
||
### 14.6 The notification is up while it transfers, and gone afterwards
|
||
|
||
Queue several files in each direction, put the phone to sleep with the screen off, and wait.
|
||
|
||
**Pass:** the foreground notification is up, the transfers finish while the screen is off, and the
|
||
notification goes away when the last one does — with no shell open. With a shell open it stays, because that
|
||
is what it was already for.
|
||
|
||
**Failure means:** an upload that stalls with the screen off is the count not reaching
|
||
`SessionForegroundService`, and Android has stopped the process mid-transfer. A notification left up
|
||
afterwards is `ActivityChanged` not being subscribed — the other end of the same wire.
|
||
|
||
### 14.7 SAVE FILE writes where you pointed it, and the file opens
|
||
|
||
Select a file on the host — something with a viewer, an image or a PDF — press **SAVE FILE**, and put it
|
||
somewhere reachable: Downloads, or a folder in Drive. When the transfer finishes, open it from the phone's
|
||
own Files app.
|
||
|
||
**Pass:** the status line says it was saved, the file is where the picker was pointed under the name shown
|
||
there, and it opens with the right contents. The queue row says DONE.
|
||
|
||
**Failure means:** a row that says DONE with nothing at the destination is `DeliverAsync` never running —
|
||
the delivery is registered per transfer id, and losing it makes the download look like a success while the
|
||
bytes sit in a cache nobody can reach. A file that is there but empty or truncated is the copy out, not the
|
||
transfer: check the `SetLength(0)` and that the write stream is being disposed before the status is written.
|
||
|
||
### 14.8 The button is dead until a file is chosen, and refuses a directory
|
||
|
||
With nothing selected, look at **SAVE FILE**. Then select a directory row.
|
||
|
||
**Pass:** disabled in both cases — it needs a connected remote and a selected *file*, which is the desktop's
|
||
own `CanDownload`.
|
||
|
||
**Failure means:** an enabled button over a directory reaches `QueueDeliveredDownload`'s refusal, which is
|
||
the right answer arriving too late: the save picker has already created an empty file, so the person is left
|
||
with a file they did not want and a message saying nothing happened.
|
||
|
||
### 14.9 A download that fails leaves the empty file it warned about
|
||
|
||
Point SAVE FILE at a destination for a large file, then break the transfer — turn off Wi-Fi and mobile data
|
||
while it runs.
|
||
|
||
**Pass:** the row goes to FAILED with the reason, the status line does not claim it was saved, and there is
|
||
an empty file at the destination. Reconnect, press **RETRY**, and the same destination fills in — the
|
||
delivery survives the failure because it is held against the transfer rather than the attempt.
|
||
|
||
**Failure means:** a retry that succeeds but leaves the destination empty is the delivery having been
|
||
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 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
|
||
published minutes apart — so what is being checked is the shape of a release, which only exists once
|
||
somebody has cut one. What *is* automated is the machinery underneath: `UpdateFlowTests` drives every state
|
||
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 — 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.
|
||
|
||
### 16.0 The feed is readable without credentials · **do this one first, on either platform**
|
||
|
||
Nothing else in this phase or the next means anything until this passes. Run it from anywhere, signed in to
|
||
nothing:
|
||
|
||
```bash
|
||
curl -so /dev/null -w '%{http_code}\n' https://git.dodotech.cloud/api/v1/repos/DodoTech-Public/DodoSSH
|
||
```
|
||
|
||
**Pass:** `200`.
|
||
|
||
**Failure means:** `404` is a repository this caller may not see — Gitea does not distinguish "not there"
|
||
from "not yours" — and the clients read this address anonymously, because there is no token and deliberately
|
||
nowhere to put one. Every check on every machine will answer 404 and the feature cannot work. See
|
||
[ADR 0013 §4](adr/0013-desktop-distribution-and-updates.md).
|
||
|
||
**And check the organisation before the repository, because the organisation wins.** A Gitea org has its own
|
||
visibility, and a *public repository inside a Limited or Private org is invisible to anyone not signed in* —
|
||
so setting the repository public and stopping there changes nothing. This is what it looks like:
|
||
|
||
```bash
|
||
curl -so /dev/null -w '%{http_code}
|
||
' https://git.dodotech.cloud/api/v1/orgs/DodoTech
|
||
```
|
||
|
||
`404` there means the org is the gate; `200` means it is not and the repository itself is. Both must answer
|
||
`200`. One more, which tells the two apart from an instance that requires sign-in for everything:
|
||
`/explore/repos` answering `200` means anonymous browsing is on, so a repo that is still invisible is
|
||
invisible on purpose rather than by instance policy.
|
||
|
||
**Do not check `/api/v1/version` instead.** It answers `200` from a forge that is up regardless of what is
|
||
readable on it, and that is exactly what made this look fine while nothing worked.
|
||
|
||
### 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%`.
|
||
|
||
**Pass:** no UAC prompt; `%LOCALAPPDATA%\DodoSSH.Desktop\current\DodoSSH.exe` exists; a Start-menu entry
|
||
reading **DodoSSH**; and `%LOCALAPPDATA%\DodoSSH` either absent (a fresh machine) or **untouched**.
|
||
|
||
**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 0013](adr/0013-desktop-distribution-and-updates.md) decision 2.
|
||
|
||
### 16.2 The installed path is short, measured rather than assumed
|
||
|
||
```powershell
|
||
"$env:LOCALAPPDATA\DodoSSH.Desktop\current\DodoSSH.exe".Length
|
||
```
|
||
|
||
**Pass:** under 100. It is 64 on an ordinary profile.
|
||
|
||
**Failure means:** folder redirection, or a very long profile path. The terminal is about to fail with
|
||
`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.
|
||
|
||
### 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.
|
||
|
||
**Failure means:** `--icon` or `ApplicationIcon` did not survive packaging. Cosmetic, and the first thing
|
||
anybody notices.
|
||
|
||
### 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.
|
||
|
||
**Pass:** characters reach the remote and output comes back.
|
||
|
||
**Failure means:** if it hangs and then reports the WebView2 message after about fifteen seconds, the
|
||
renderer never attached — check whether packaging has given the process a package identity, which would put
|
||
WebView2 in an AppContainer where the loopback data plane cannot connect. That is the failure MSIX was ruled
|
||
out for, and this is the check that would find it in the Velopack path. `RendererTimeout` is where the
|
||
fifteen seconds comes from.
|
||
|
||
### 16.5 The version on screen is the version that was built
|
||
|
||
Right-click `DodoSSH.exe` → Properties → Details, and open Settings → General.
|
||
|
||
**Pass:** File version reads the tag (`0.1.0.0`), product **DodoSSH**, company **DodoTech**, and the
|
||
General page's UPDATES card prints the same number — the block moved there from Preferences in v5c, and
|
||
Preferences itself carries no version line any more.
|
||
|
||
**Failure means:** `0.0.0.0` is MinVer never seeing a tag — a shallow clone, or `fetch-depth` having been
|
||
dropped from a checkout. `1.0.0.0` is somebody having wired the app manifest's inert `assemblyIdentity`
|
||
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.
|
||
|
||
### 16.6 A second release produces a delta, not only a full package
|
||
|
||
Tag `v0.1.1` and run the script again.
|
||
|
||
**Pass:** `Releases\` holds both a `*-full.nupkg` and a `*-delta.nupkg`, and the delta is a small fraction
|
||
of the full.
|
||
|
||
**Failure means:** no delta at all is `vpk download gitea` having found nothing to build one against — the
|
||
previous release did not come down, so every user is about to fetch a ~60 MB full package for a one-line
|
||
change. The
|
||
script warns rather than failing when that is legitimate, which is the first release only.
|
||
|
||
### 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 Settings → General rather than waiting six hours.
|
||
|
||
**Pass:** the progress bar moves, the banner appears above the status bar, and — the part to actually watch
|
||
— the terminal **reflows cleanly rather than being sliced**, with the remote seeing the smaller row count.
|
||
Press **RESTART NOW**: the application closes and reopens as 0.1.1, still enrolled, with the host change
|
||
intact.
|
||
|
||
**Failure means:** no banner is a channel mismatch between `vpk pack --channel` and
|
||
`VelopackUpdateChannel.ReleaseChannel`, which fails silently by design — the check succeeds, finds nothing,
|
||
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 16.1's failure arriving late.
|
||
|
||
### 16.8 The first connect after an update is not a cold start
|
||
|
||
Immediately after 16.7, connect to a host.
|
||
|
||
**Pass:** the terminal appears about as quickly as it did before the update.
|
||
|
||
**Failure means:** the WebView2 user data folder is back inside `current\` and was destroyed by the update —
|
||
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.
|
||
|
||
### 16.9 Uninstalling removes the application and leaves the vault · **the data-loss check**
|
||
|
||
Settings → Apps → DodoSSH → Uninstall.
|
||
|
||
**Pass:** `%LOCALAPPDATA%\DodoSSH.Desktop` is gone, and `%LOCALAPPDATA%\DodoSSH` still holds `cache.db`,
|
||
`cache.db-wal` and `cache.db-shm` — all three, per the entry that says any routine touching only the first
|
||
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 0013
|
||
decision 2 exists to prevent, and it is why 16.1 checks the same thing from the other end.
|
||
|
||
### 16.10 The nightly installs beside the release build and not over it · **the one CI cannot check**
|
||
|
||
Needs both: a release build installed by walking 16.1, and `DodoSSH.Desktop.Nightly-win-nightly-Setup.exe`
|
||
from the `nightly-desktop` release. Install the nightly second, and then start **both**.
|
||
|
||
**Pass:** two entries in the Start menu, one *DodoSSH* and one *DodoSSH Nightly*; two directories,
|
||
`%LOCALAPPDATA%\DodoSSH.Desktop` and `%LOCALAPPDATA%\DodoSSH.Desktop.Nightly`; two profiles,
|
||
`%LOCALAPPDATA%\DodoSSH` and `%LOCALAPPDATA%\DodoSSH.Nightly`, each with its own `cache.db` and
|
||
`device.key`. The nightly's titlebar says **DodoSSH Nightly** and the release build's says **DodoSSH** —
|
||
which is the only difference visible while somebody is typing a passphrase into one of them. Signing in to
|
||
the nightly leaves the release build signed in and untouched, and the account shows a second device.
|
||
|
||
**Failure means:** if there is one profile directory, the two builds are sharing a cache and a device key,
|
||
and the next nightly carrying a schema migration will upgrade the database the release build opens. If
|
||
there is one install directory, the pack ids collide and the nightly has replaced the release build
|
||
outright — which is the thing ADR 0013 decision 9 is constructed to make impossible, so it means one of the
|
||
four separations has been undone.
|
||
|
||
**And the direction that matters most:** on the release build, Settings → General → CHECK NOW must not
|
||
offer a nightly, ever, however many have been published since. It reads a different index and refuses
|
||
prereleases; if a nightly version is ever offered there, stop and treat it as a release-channel incident
|
||
rather than as a bug in the nightly.
|
||
|
||
---
|
||
|
||
## Phase 17 — Installing and updating the phone
|
||
|
||
Every check here needs a real Android device or emulator. Nothing about this is automated and structurally
|
||
cannot be for the reason phase 8 gives, with one more on top: the install is another application's screen.
|
||
|
||
Two channels, and they are two applications — see [ADR 0014](adr/0014-android-updates.md). The nightly is
|
||
what CI publishes on every push to main and is signed with a key that is in this repository; the release
|
||
channel is cut by `scripts/release-android.ps1` on a machine holding the real key. Most of these checks can
|
||
be walked on the nightly, which is the point of having one.
|
||
|
||
### 17.1 The unknown-sources gate is asked for and not assumed
|
||
|
||
On a phone that has never installed a DodoSSH APK, download one from the release page and open it.
|
||
|
||
**Pass:** Android says the browser is not allowed to install unknown apps and offers the settings screen.
|
||
Granting it there and returning completes the install.
|
||
|
||
**Failure means:** nothing — this is the platform working, and it is the cost ADR 0011 names. It is a check
|
||
because the *next* one depends on somebody having been through it.
|
||
|
||
### 17.2 Both channels install side by side
|
||
|
||
Install a nightly on a phone that already has a release build, or the other way round.
|
||
|
||
**Pass:** two applications, two icons, both openable, each with its own keychain. Android's app info shows
|
||
`dev.dodotech.dodossh` for one and `dev.dodotech.dodossh.nightly` for the other.
|
||
|
||
**Failure means:** if the second install replaces the first, the two channels are sharing a package id and
|
||
the separation ADR 0014 rests on is not there. If it is *refused*, they share an id and differ in key,
|
||
which is the same fault seen from the other side.
|
||
|
||
Both icons say DodoSSH, which is a known defect rather than a surprise — see `docs/platform-flags.md`.
|
||
|
||
### 17.3 The version on screen is the version that was built
|
||
|
||
Settings → Preferences → UPDATES.
|
||
|
||
**Pass:** a version matching the tag it was built from, or on a nightly the full MinVer string with its
|
||
height. Never `1.0.0`.
|
||
|
||
**Failure means:** `1.0.0` is the `_AndroidVersionName` fix having come undone; see platform-flags.
|
||
|
||
### 17.4 A check that finds nothing says so, a check that fails says something else, and a timer says neither
|
||
|
||
**Walk 16.0 first.** This check is the one that used to pass while the whole feature was broken: with the
|
||
repository private, every request answered 404, the channel swallowed it, and CHECK NOW reported the running
|
||
build as the latest. It read exactly like success.
|
||
|
||
Press CHECK NOW on the newest build there is.
|
||
|
||
**Pass:** it answers — "DodoSSH x.y.z is the latest build."
|
||
|
||
Now turn the phone's network off and press CHECK NOW again.
|
||
|
||
**Pass:** it says it could not reach the forge, in whatever words the failure came with, and the section
|
||
shows the failed state rather than a version number. **It must not say the build is the latest.** Those two
|
||
answers being different is the whole of what makes the first one worth reading.
|
||
|
||
Turn the network back on, and leave the app open doing nothing for several minutes.
|
||
|
||
**Pass:** no message appears on its own, either way. A background pass that found nothing — or that could
|
||
not ask — is silent, which is what makes the feature tolerable.
|
||
|
||
**Failure means:** if the offline press reports the latest build, `AndroidUpdateChannel.CheckAsync` is
|
||
catching again. Every outage then looks like good news, which is how this went unnoticed from the day it
|
||
shipped.
|
||
|
||
### 17.5 An update is found, fetched without being asked, and installed only when asked · **the whole point**
|
||
|
||
With an older build installed, publish or wait for a newer one on the same channel, then press CHECK NOW.
|
||
|
||
**Pass:** the bar moves, then an INSTALL button and a line saying what it costs. **Nothing is installed
|
||
yet.** Leave it sitting there and confirm the application still works normally.
|
||
|
||
Press INSTALL.
|
||
|
||
**Pass:** Android's own installer appears naming the package. Agreeing replaces the app and it reopens as
|
||
the new version, still enrolled, with the vault and its hosts intact.
|
||
|
||
**Failure means:** an install that happens without INSTALL being pressed is the policy this feature is built
|
||
around being broken. An `INSTALL_FAILED_UPDATE_INCOMPATIBLE` means the two builds were signed by different
|
||
keys — on the nightly channel that means the committed keystore changed, and on the release channel it means
|
||
the wrong keystore was used.
|
||
|
||
◆ Two failures worth naming separately, because both look like "the button does nothing" and neither is a
|
||
signing problem. **The application closes when INSTALL is pressed** — that is an exception escaping the
|
||
command handler, and the message it should have shown is now the Failed line on this screen. **Nothing
|
||
happens at all, and the application carries on** — Android was asked to install and nobody started the
|
||
confirmation it handed back; see `InstallSessionReceiver` and the ◆ note in ADR 0014 rule 6. This check is
|
||
the only thing in the project that can catch either, which is the argument for running it on a real pair of
|
||
builds rather than reasoning about it.
|
||
|
||
### 17.6 Declining leaves a working session · **the one that would be missed**
|
||
|
||
Repeat 17.5 to the point where Android's installer is on screen, with a terminal open and the vault
|
||
unlocked. Press back or cancel.
|
||
|
||
**Pass:** DodoSSH is still running, still unlocked, and the shell is still connected.
|
||
|
||
**Failure means:** a locked keychain or a dead session is the shell having disposed itself before asking —
|
||
`IUpdateChannel.ApplyingEndsTheProcess` not being read, or answering true on this head. It punishes somebody
|
||
for declining an update, and nothing in an automated suite would notice.
|
||
|
||
### 17.7 A nightly cannot update a release build
|
||
|
||
With a release build installed, download the nightly APK and try to install it *over* it — rename it if the
|
||
package manager will not offer to.
|
||
|
||
**Pass:** refused, or installed as a second application. Never a replacement.
|
||
|
||
**Failure means:** the channels are not separate, and a public key is signing the application people keep
|
||
their credentials in.
|