Public Access
The first pass of the update loop waited two minutes. Every pass after it came six hours apart, which is the right interval for a product that ships rarely — but the delay in front of the first one quietly excluded a whole way of using this application. A client opened to reach one host and closed again is over before the two minutes are. Used that way, it never checks at all: not once, not slowly, never. That is precisely the machine ADR 0011 names as the real cost of distributing outside a store — quietly a year behind — and the galling part is that the mechanism to fix it was switched on the whole time and simply never reached. The delay's own argument is recorded in the diff it is being removed from, and it was not a bad one: nothing anybody does in their first two minutes depends on an update, and launch is already contending for the network with a schema migration, a resumed sign-in and a first sync, at the one moment somebody is watching the window. What it weighed was the cost of checking early against the benefit of checking early. It never weighed the cost of not checking at all. ◆ THE YIELD IS WHAT KEEPS THIS OFF THE LAUNCH PATH, AND IT IS NOT DECORATION. Start() is called from MainWindowViewModel.StartAsync ahead of the migration, so an inline first pass would run whatever the channel does before its own first await — Velopack reads the install layout from disk — between the user and their window. Yielding hands the rest of launch back and puts the check in a later turn, which is the same moment in every sense anybody can perceive and none of the cost. So the answer to the delay's argument is not that it was wrong; it is that a yield buys most of what two minutes bought. Task.Yield takes no token where Task.Delay did, so the loop body now observes cancellation at its head. Without that, an application closed during launch spends its last moment asking a release channel about a build it will not run. Two things deliberately not changed. The AUTOMATIC UPDATE CHECKS preference still gates the pass — "on start" means every start, not regardless of what the user asked for, and that setting is already on by default. And the data cost is unchanged rather than merely acceptable: a check is a few hundred bytes and the download only follows if something newer exists, so this moves the same traffic earlier without adding any. That matters most on the phone, where the same loop runs against AndroidUpdateChannel. TheFirstPassRunsAtStart_RatherThanOnADelay drives the real loop rather than CheckOnceAsync, which is the one thing that file otherwise avoids — and here it is the point, because the claim is about when the pass happens rather than what it does. It waits on the pass and not on a clock, so there is nothing to be flaky about: a regression that puts a delay back does not fail on a margin, it spins until the suite's own cancellation ends it. DisposingStopsTheLoop keeps its assertion and gains a note that it is now a race rather than a formality. §16.7 of the manual checks gains the sentence that reopening the application does what CHECK NOW does. It is the step somebody following that section would otherwise discover by accident. 447 App tests and 153 layout tests pass.
2718 lines
150 KiB
Markdown
2718 lines
150 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 is covered headlessly too:
|
||
`RegroupingHostsChosenAcrossTwoKeychains_IsRefusedBeforeThePickerOpens` ticks a host in each of two
|
||
keychains and asserts no picker opens and the sentence is on the status line. What is left for the eye is
|
||
7.9's wiring, as in 7.6 — and 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
|
||
|
||
Twenty-one checks, and the reason there are twenty-one 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.
|
||
|
||
### 8.18 QUICK ACCESS in the phone's host editor
|
||
|
||
Open a host's editor and scroll to QUICK ACCESS.
|
||
|
||
**Pass:** an empty list, an add field and an ADD button. Type `/var/www/app` and press ADD.
|
||
|
||
**Pass:** a row appears carrying that path and a ✕ at least 44dp on a side. Type the same path again and
|
||
press ADD.
|
||
|
||
**Pass:** nothing is added, and a sentence appears on the page saying the path is already pinned — this page
|
||
covers the whole screen while it is open, so that sentence is this page's own `Status` line rather than the
|
||
one the host list draws above HOSTS, which is off-screen right now. Clear the box and press ADD with nothing
|
||
typed.
|
||
|
||
**Pass:** a sentence saying a pinned path cannot be blank, in the same place.
|
||
|
||
Press the ✕ on the pinned row, then SAVE.
|
||
|
||
**Pass:** back on HOSTS with the pin gone. Open the editor on that host again.
|
||
|
||
**Pass:** QUICK ACCESS is empty — the removal was saved, not merely staged. Re-pin `/var/www/app`, SAVE, and
|
||
check the same host on the desktop.
|
||
|
||
**Pass:** the pin is there. `HostSecret.PinnedPaths` is shared and merged like every other field on a host,
|
||
so nothing about this page keeps its own copy.
|
||
|
||
**Failure means:** a refusal that changes nothing on screen is `AddEditorPinCommand` writing to `Status`
|
||
with nothing on this page bound to it — the honesty rule broken silently, since the command still behaves
|
||
correctly and only the telling of it is missing. A pin gone after SAVE-then-reopen but present on the
|
||
desktop is `BuildHost` not reading `EditorPinnedPaths`, or `EditSelectedHost` not loading it back in.
|
||
|
||
### 8.19 Pin chips on the Files screen
|
||
|
||
Pin a folder on a host, then connect to it on the files screen (SFTP), either directly or via **Connect via
|
||
SFTP**.
|
||
|
||
**Pass:** once connected, a row of chips appears between the breadcrumb and the listing, one per pin, each
|
||
at least 44dp tall. Tap one.
|
||
|
||
**Pass:** the listing navigates straight to that directory, the same as tapping a breadcrumb crumb does.
|
||
Disconnect, then connect to a host with nothing pinned.
|
||
|
||
**Pass:** no chip row at all — not an empty one. Connect to a bucket instead.
|
||
|
||
**Pass:** still no chip row, on any bucket. A bucket has no `HostSecret` underneath it and so nothing to
|
||
pin.
|
||
|
||
**Failure means:** chips that do not move the listing are the row's `GoRemoteCommand` binding pointed at the
|
||
wrong `DataContext` — see the `$parent[views:FilesScreen]` escape every other command in this file uses. A
|
||
chip row surviving a disconnect, or appearing under a bucket, is `TransfersViewModel.ConnectedPinnedPaths`
|
||
not being cleared in `CloseSessionAsync` or `OpenBucketAsync`. A chip row missing a pin added *after* this
|
||
connect is not a bug — see `ConnectedPinnedPaths`'s own remark on why this is a snapshot rather than a live
|
||
follow, and try disconnecting and reconnecting instead.
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
### 11.10a The accessory keys do not cost the terminal its *software* keyboard either
|
||
|
||
With a shell open and the software keyboard up, tap **esc**, **tab** or an arrow on the accessory row, then
|
||
keep typing on the software keyboard.
|
||
|
||
**Pass:** the keyboard settles back unchanged — same layout, same suggestion strip, same height — and
|
||
everything typed after the tap still reaches the terminal. The accessory row stays visible above the
|
||
keyboard throughout. A blink during the press itself is tolerable: the platform takes the focus on both
|
||
halves of every touch and the return is posted right behind each theft, so the connection can visibly flap
|
||
for the press's own duration — what it must never do is *stay* swapped after the finger lifts.
|
||
|
||
**Failure means:** Android's own view focus stayed on Avalonia's input view after the tap instead of being
|
||
handed back. This is the half `Focusable = false` cannot reach — the platform requests focus for its own
|
||
view after dispatching every handled touch — and the symptom chain is the keyboard swapping to its no-input
|
||
layout and the inset churn parking it over the very row that was tapped. The first fix for this failed by
|
||
timing alone: it handed focus back from inside the very dispatch the platform re-steals it after. See
|
||
`TerminalFocus` in the Android head's Platform folder for both the mechanism and the fix's shape.
|
||
|
||
### 11.11 Closing a connection and opening a new one both take you somewhere real
|
||
|
||
Open a shell, close its tab, then open a different one from HOSTS.
|
||
|
||
**Pass:** the new terminal renders and takes input straight away — no stuck "Connecting…" status, no blank
|
||
pane that never receives the prompt.
|
||
|
||
**Failure means:** `TerminalDataPlane` refused the page's reattach. The renderer's `WebSocket` does not
|
||
survive a tab going from one to zero and back to one on every device, and a host that answers a second valid
|
||
upgrade with `409 Conflict` instead of taking the socket over leaves every terminal after the first
|
||
permanently unreachable — see the correction in `docs/android-port.md`'s terminal section.
|
||
|
||
### 11.12 A backgrounded shell survives its renderer being killed · **needs several minutes, or developer tooling**
|
||
|
||
With a shell open and something worth reading in its scrollback, background the app (home button, not back)
|
||
for several minutes — long enough for Android to consider reclaiming it — then return. If the device exposes
|
||
it, forcing a stop of the WebView renderer process from Developer Options while backgrounded is the more
|
||
reliable way to trigger the same thing on demand rather than waiting on the OS's own judgement. Either way,
|
||
type something once you are back.
|
||
|
||
**Pass:** one of two honest outcomes, both good. Either the pane is exactly as it was — the renderer process
|
||
survived, so nothing needed to happen — or the pane is empty but for a dim line reading `── the view
|
||
reconnected; earlier output stayed on the host ──`, meaning the page reloaded and reattached. In both cases
|
||
what is typed now reaches the shell, and the shell is still the same one — not a new tab, not a reconnect
|
||
sheet, no "Connecting…" status stuck on screen.
|
||
|
||
**Failure means:** if the status stays stuck or nothing typed arrives, the renderer's socket did not retry
|
||
itself — see `terminal.js`'s `connect()` and its backoff. If the pane came back empty with **no** banner, a
|
||
session that survived a reload is being shown as though its scrollback had too, which is not true and is
|
||
worse than saying nothing: the banner exists so this is never silently wrong. If typing does nothing but the
|
||
banner is there, the session's credit window was not reset on reattach and the shell is frozen behind it —
|
||
see `TerminalWorkspace.ReplayAfterAttachAsync`.
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
Now, separately: open a shell to the host, press the home button (backgrounding rather than sleeping — the
|
||
distinction matters, because backgrounded is the state in which Android is free to kill a process no
|
||
foreground service is protecting), wait thirty seconds with the shell doing nothing, and return.
|
||
|
||
**Pass:** the notification stayed up the whole time, and the shell is exactly where it was — same scrollback,
|
||
same prompt — with typing reaching the host immediately. Exit the shell.
|
||
|
||
**Pass:** the notification goes with it, once nothing else is open. Open another shell and close it from the
|
||
shells strip's ✕ instead of exiting — the notification comes down for that route too, which is the route
|
||
that used to leave it up: a deliberate close announced nothing to the keep-alive at all, and a shell exiting
|
||
on its own was announced while the count still included it.
|
||
|
||
**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. A shell that has
|
||
disconnected on return is `MainWindowViewModel.TerminalSessionOpened` never reaching `SessionKeepAlive` — the
|
||
service only ever heard about a shell *ending*, so it never came up for one in the first place. A
|
||
notification still saying "1 shell connected" after the shell is gone — by either route — is
|
||
`TerminalWorkspace.SessionEnded` firing before the run completed, or a close not announcing; see
|
||
`AnnounceEndedAsync` and the event's own remark.
|
||
|
||
### 14.6a A Files connection with nothing moving still survives backgrounding
|
||
|
||
Connect to a host on the Files screen with no transfer queued — just browse to somewhere and stop. Note the
|
||
directory shown, then background the app, wait thirty seconds, and return.
|
||
|
||
**Pass:** the notification stayed up the whole time (check the shade if the return is too quick to see it
|
||
directly), and the pane is exactly where it was — the same listing, the same breadcrumb — with no reconnect
|
||
needed.
|
||
|
||
**Failure means:** `TransfersViewModel.HasLiveFileSession` not reaching `SessionKeepAlive`, so an idle but
|
||
still-open SFTP connection read as nothing running at all and the process was free to die under it.
|
||
|
||
### 14.6b The notification permission is asked for once, at the first thing worth showing · **needs Android 13+**
|
||
|
||
On a device running Android 13 or later, on a fresh install that has never connected to anything, open a
|
||
shell or the Files screen for the first time.
|
||
|
||
**Pass:** a system dialogue asking to allow notifications appears at that moment — not at launch, and not
|
||
before this first connect. Answer it either way; the connection completes regardless, and background the app
|
||
afterwards to confirm nothing else changed about it.
|
||
|
||
**Failure means:** the dialogue appearing at launch is asking before there is anything on screen to justify
|
||
it. Never appearing at all on API 33+ is the harder failure to notice, because nothing else surfaces it —
|
||
the service still starts and still holds the process open, only the receipt is invisible. See
|
||
`SessionForegroundService.RequestNotificationPermission`.
|
||
|
||
### 14.6c Refusing the permission costs the notification and nothing else
|
||
|
||
Continuing from 14.6b: choose **Don't allow** on the system dialogue. Queue a transfer, or open a shell, and
|
||
background the app.
|
||
|
||
**Pass:** no notification appears anywhere, but the transfer still finishes, or the shell is still there on
|
||
return, exactly as in 14.1–14.6a.
|
||
|
||
**Failure means:** anything disconnecting or failing here is the permission refusal being read as though it
|
||
had refused the service itself, rather than only the notification Android draws for it.
|
||
|
||
### 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. Closing and
|
||
reopening the application does the same thing without the button: the first pass of the loop runs at launch,
|
||
so a client started after a release finds it without anybody asking.
|
||
|
||
**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.
|
||
|
||
## Phase 18 — Installing the macOS client, and being updated by it
|
||
|
||
The macOS counterpart of phase 16, and it needs a Mac with a Secure Enclave — an Apple Silicon machine or
|
||
an Intel one with a T2. Every check here is structurally unreachable by a test for the reasons phase 16
|
||
gives, plus one this platform adds: **CI has no macOS runner at all**, so this phase is the only place the
|
||
suite and the application ever run on macOS. Anything `docs/platform-flags.md` marks as unverified on macOS
|
||
is verified here or nowhere.
|
||
|
||
Run `bash scripts/release-macos.sh` first. It stops after packing and notarizing, on purpose, so that
|
||
everything below happens before anything reaches a user. Phase 16.0 — the feed being readable without
|
||
credentials — applies unchanged and is not repeated.
|
||
|
||
### 18.1 Gatekeeper accepts it on a machine that did not build it · **do this one first**
|
||
|
||
The Mac that signed a package trusts it locally whatever happened, so the build machine cannot answer this
|
||
question about itself. Copy the `.pkg` to a second Mac — or at minimum download it through a browser, which
|
||
is what applies the quarantine attribute — and open it.
|
||
|
||
**Pass:** it installs with no warning beyond the ordinary installer prompts.
|
||
|
||
**Failure means:** "cannot be opened because Apple cannot check it for malicious software" is notarization
|
||
that did not happen or a ticket that did not staple. The script's `spctl --assess` and `xcrun stapler
|
||
validate` should have caught it before this point, so reaching here means one of those two checks was
|
||
removed or skipped. Do not distribute the package.
|
||
|
||
### 18.2 The Dock shows the product and not the pack id
|
||
|
||
Look at the installed application in `/Applications`, in the Dock, and in the menu bar while it runs.
|
||
|
||
**Pass:** the menu bar says **DodoSSH**. Finder shows **DodoSSH**. The bundle on disk is
|
||
`DodoSSH.Desktop.app` and that is expected — see the pack id note in `scripts/release-macos.sh`.
|
||
|
||
**Failure means:** "DodoSSH.Desktop" in the menu bar is `CFBundleName` not reaching the bundle, which means
|
||
the rendered `Info.plist` did not get used. Since vpk copies a custom plist verbatim and substitutes
|
||
nothing, check the same bundle's `CFBundleShortVersionString` — if it reads `@VERSION@`, the template was
|
||
passed through unrendered.
|
||
|
||
### 18.3 The icon is the mark, at every size
|
||
|
||
Look at it in the Dock, in Finder's icon view at a large size, and in `⌘I` Get Info.
|
||
|
||
**Pass:** the accent tile and the `>_` mark, crisp at 1024, with the same air around it that Finder and
|
||
Safari have.
|
||
|
||
**Failure means:** a generic application icon is `CFBundleIconFile` naming a file that is not in
|
||
`Contents/Resources`. An icon that fills its square edge to edge, larger than its neighbours, is
|
||
`New-MarkPng` having been called with the Windows tile fraction — see `dodossh-icon.ps1`.
|
||
|
||
### 18.4 Touch ID guards the device key, and the enclave enforces it
|
||
|
||
Register a device key from the security settings page, then lock the vault and unlock it again.
|
||
|
||
**Pass:** registering shows **no** prompt at all — sealing uses only the public half — and unlocking raises
|
||
the system Touch ID sheet saying DodoSSH is trying to *unlock your DodoSSH vault*. The vault opens on a
|
||
successful touch.
|
||
|
||
**Failure means:** a prompt at registration is not a failure of correctness but says the key was not created
|
||
in the enclave; check that `kSecAttrTokenID` reached the attributes. **No prompt at unlock, with the vault
|
||
opening anyway, is the serious one** — it means the key is a software key and the access control did nothing,
|
||
which is precisely the "a gate inside the process is not a gate" mistake `WindowsDeviceKeyStore` documents.
|
||
|
||
### 18.5 Declining the fingerprint falls back to the passphrase
|
||
|
||
Repeat 18.4 and cancel the Touch ID sheet.
|
||
|
||
**Pass:** the unlock screen asks for the passphrase, and it works.
|
||
|
||
**Failure means:** an error dialog, or a stuck screen, is `TryLoadAsync` throwing rather than answering
|
||
null. Every failure it can meet — cancelled, timed out, key invalidated by a password reset — is meant to
|
||
be indistinguishable and to land on the passphrase.
|
||
|
||
### 18.6 A development build offers no device key at all
|
||
|
||
Run the application with `dotnet run` rather than from the installed bundle, and open the security settings
|
||
page.
|
||
|
||
**Pass:** registering a device key is not offered.
|
||
|
||
**Failure means:** being offered it is `IsSupported` having inferred availability from the OS rather than
|
||
probing. An unsigned build cannot create an enclave key, so accepting the offer would put a wrap on the
|
||
server that nothing can ever open and list a capability this machine does not have.
|
||
|
||
### 18.7 The terminal works, which is the WKWebView question
|
||
|
||
Connect to a host and use the shell: type, run something that scrolls, resize the window.
|
||
|
||
**Pass:** the terminal attaches within a second or two and behaves as it does on Windows.
|
||
|
||
**Failure means:** a blank pane that reports a renderer timeout after fifteen seconds is the loopback
|
||
WebSocket not reaching WKWebView. This is the check that most needs walking, because the data plane has
|
||
never run against this backend — see `TerminalDataPlane`. If it fails, the App Sandbox is the first thing to
|
||
rule out: the entitlements deliberately do not enable it, and a sandboxed process cannot listen on loopback
|
||
without `com.apple.security.network.server`.
|
||
|
||
### 18.8 An update is offered, downloaded and applied
|
||
|
||
With the release installed, cut a second release with a higher version and publish it, then leave the first
|
||
running.
|
||
|
||
**Pass:** the banner appears, downloads, and on applying the application closes and reopens on the new
|
||
version. The vault's contents and the known hosts survive.
|
||
|
||
**Failure means:** an update that never arrives is usually the channel — `osx` here and `osx` in
|
||
`VelopackUpdateChannel.MacReleaseChannel`, with no error anywhere when they disagree. An update that
|
||
downloads and fails to apply, leaving the application unable to restart, is library validation: check that
|
||
`com.apple.security.cs.disable-library-validation` survived into the entitlements.
|
||
|
||
### 18.9 Uninstalling does not take the vault with it
|
||
|
||
Register a device, sync something, then remove the application.
|
||
|
||
**Pass:** `~/Library/Application Support/DodoSSH` still holds the cache and the outbox afterwards.
|
||
|
||
**Failure means:** an empty directory is the pack id having been changed to `DodoSSH`, which puts Velopack's
|
||
install root on top of `ClientPaths.DataDirectory` and makes an uninstall delete a user's un-synced work.
|
||
This is the single reason the bundle is named `DodoSSH.Desktop.app`.
|