Let the desktop client replace itself, and give the repository one version

Packaging for Windows, and the updater that only exists once something is
packaged. Velopack, win-x64, fed from the project's own forge — never from the
deployment a client signs in to, which is ADR 0011 rule 2 carried over
unchanged and is why the feed address is a constant in the code rather than a
setting. See docs/adr/0012-desktop-distribution-and-updates.md.

**Nothing is ever installed while somebody is using it.** A newer build is found
on a six-hourly pass, downloaded in the background, and then waits — for a
restart the user presses, or for the next launch they were going to do anyway.
That is a policy rather than caution: this application argues at length that
locking keeps shells running, because a lock that destroyed work would stop
being used, and a restart does not keep them. Having taught that, it owes the
user the choice at the one moment it stops being true, and the sentence saying
so counts the shells it would close.

**The version is now derived from the v* tag**, by MinVer, for everything. There
was no version before this — no property anywhere, so every assembly reported
the SDK's 1.0.0 and the API served that string as its serverVersion to every
client that asked. The tag was already the version of record for the container
image; this makes it the version of record full stop. MinVer's failure mode is
answering plausibly rather than failing, and here a wrong version is a client
that never updates, so it is guarded twice: fetch-depth 0 on every checkout, and
a step that fails a tag build when the tag and the computed version disagree.

**The pack id is DodoSSH.Desktop and not DodoSSH**, which is the one decision
here that would have destroyed data. Velopack installs to %LOCALAPPDATA%\<packId>
and removes that whole directory on uninstall, and %LOCALAPPDATA%\DodoSSH is
where ClientPaths keeps the encrypted cache, the outbox of changes not yet
pushed, and the device key. The obvious id would have had the uninstaller
silently delete work the server has never seen — the thing the application
refuses to do without a counted confirmation. Velopack's own advice to move user
data to roaming %APPDATA% is declined for the reason ClientPaths already gives.

**Releases are cut by a person, and CI gains no job that could.** The tempting
argument is that a forge write token is not a signing key. It does not survive
contact with what the token does: Velopack clients trust their feed and do not
verify a package signature when they apply one, so whoever can write a release
can ship an update every install runs. That is the capability ADR 0011 rule 1
puts on a machine which is not a runner, reached through a different door. The
mechanical objection — vpk needs Windows and the runners are Linux — is the
smaller of the two and is recorded beside it, because somebody will fix one and
believe they are done.

Unsigned for now, deliberately and with the cost stated where a user reads it:
SmartScreen warns once per person, on Setup.exe, because Mark-of-the-Web is
applied by the browser that downloaded it. In-app updates are fetched by the
application and applied from a local file, and never trip it.

The banner is a fourth row of the window rather than an overlay. Anything drawn
in the terminal's rectangle is sliced by the native child window that composites
above it — the defect this window has shipped once — and a sibling row is the
arrangement TitleBar and StatusBar already prove works.

----

Three defects surfaced on the way, none of them in the feature being built.

**A settings key absent from the file came back as the CLR default, not the
declared one.** The JSON source generator builds a record through a synthesised
parameterised constructor and assigns every property from its argument array, so
a property initializer runs and is then overwritten by a default for anything the
file did not contain. A settings.json of {} read back a font size of 0, clamped
up to the 8px floor rather than the 13px the renderer draws at. It could not bite
while there was one setting, because that setting was written on every save and
so was never absent; adding a second would have turned automatic update checks
off for every existing profile, silently, the opposite of the documented default.
Reflection-based deserialisation of the same JSON answers correctly, which is why
every way of checking it by hand agrees except the one that ships. The defaults
now live on the constructor parameters, which is the only place the generator
reads them from.

**Declaring a RuntimeIdentifier on the desktop head broke the server's image
build.** It is the obvious way to let a self-contained publish restore under
locked mode, and it writes a net10.0/win-x64 target into the lock file of every
project the head references transitively — including DodoSSH.Contracts and
DodoSSH.Crypto, which the API builds too. The Dockerfile restores those with no
RID and fails NU1004. Found by running docker build rather than by reading. The
RID stays out of the committed state; the two commands that need one ask for it
unlocked, and the release script puts the lock files back.

**A Docker ARG named VERSION silently sets MSBuild's Version.** An ARG is an
environment variable for the rest of the stage, MSBuild reads environment
variables as properties, and property names are case-insensitive. With the
workflow passing main-<short sha> on a main build the publish died with
NETSDK1018 pointing at DodoSSH.Contracts, a project nobody had touched. The build
stage's argument is ASSEMBLY_VERSION now, empty except on a tag build.

All three are in docs/platform-flags.md, which is where the next person will look.

----

Verified: the whole solution builds and restores locked; 289 shell, 93 layout and
54 session tests pass, including the regression test for the settings defect and
a measurement of the banner at the window's minimum width. vpk pack runs end to
end and reports "Verified VelopackApp.Run()" against Program.Main. The API image
builds correctly both as a main build and as a tag build, carrying 1.0.0 and
0.1.0 respectively.

Not verified, and it needs a published release to be: installing, updating and
uninstalling on a real machine. That is Phase 15 of docs/manual-checks.md, and
the pack id and the WebView2 profile fix are reasoned and commented but only
proved by walking it. Two things to watch at the first upload — the reverse
proxy's body-size limit for a 64 MB asset, and whether vpk upload gitea is happy
with Gitea 1.27.1.
This commit is contained in:
2026-08-04 17:04:41 +02:00
parent 176df67861
commit 6728a0a597
66 changed files with 3190 additions and 44 deletions
+124
View File
@@ -1465,3 +1465,127 @@ delivery survives the failure because it is held against the transfer rather tha
**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 — 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 — 15.6 onwards needs 15.1 to have happened.
Run `pwsh -File scripts/release-windows.ps1` first. It stops after packing, on purpose, so that everything
below happens before anything reaches a user.
### 15.1 The installer needs no administrator, and lands beside the vault rather than on it · **the one that would destroy data**
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 0012](adr/0012-desktop-distribution-and-updates.md) decision 2.
### 15.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.
### 15.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.
### 15.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.
### 15.5 The version on screen is the version that was built
Right-click `DodoSSH.exe` → Properties → Details, and open PREFERENCES → UPDATES.
**Pass:** File version reads the tag (`0.1.0.0`), product **DodoSSH**, company **DodoTech**, and the
preferences screen prints the same number.
**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.
### 15.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.
### 15.7 The update arrives, and the restart lands in it · **the whole point of the work**
With v0.1.0 installed and running, a vault unlocked, a host change made, and **a terminal open**, publish
v0.1.1 (`-Upload`). Then press CHECK NOW on PREFERENCES rather than waiting six hours.
**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 15.1's failure arriving late.
### 15.8 The first connect after an update is not a cold start
Immediately after 15.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.
### 15.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 0012
decision 2 exists to prevent, and it is why 15.1 checks the same thing from the other end.