feat(launcher): implement verified atomic updates

This commit is contained in:
Erik 2026-08-14 22:09:34 +02:00
parent 2198a0cc8e
commit 2d2a5b5046
34 changed files with 6755 additions and 61 deletions

View file

@ -329,11 +329,21 @@ src/
adjacent
`.<pak>.acdream-bake.<guid:N>.tmp` files are
transaction-owned crash residue
Updates/ -> pinned GitHub manifest + strict SemVer/RID
authority, bounded verified streaming download,
hardened ZIP extraction, immutable
`app/<version>/` installs, atomic `current.json`
activation/rollback, and durable next-start
launcher self-update journal; one OS-handle
shared-session/exclusive-update barrier spans
every launcher process
-> references Platform only; no Avalonia or game-host dependency
AcDream.Launcher/ Avalonia 12 Windows/Linux desktop shell
ViewModels/ -> thin MVVM projection over Launcher.Core,
including the first-run DAT/bake wizard
including the first-run DAT/bake wizard and
nonfatal startup/manual update state, actions,
progress, cancellation, rollback, and errors
-> references Launcher.Core only (Platform transitively); it never owns
a second profile, process, status, or credential state graph
-> every per-RID publish composes the separately published self-contained

View file

@ -478,6 +478,131 @@ gate (user): clean-profile first-run against real DATs.
fixture; rollback test; refusal-while-running test; self-update staging test;
suites green. Connected gate (user): staged-manifest update swap end-to-end.
### Pinned updater contracts (v1, BINDING)
This section is the single source of truth for every LA10 feed and on-disk
shape. Readers use strict, case-sensitive `System.Text.Json` parsing, reject
unknown or duplicate properties, and reject unsupported schema versions
before doing network, extraction, or activation work.
The production feed is pinned to GitHub owner/repository
`eriknihlen/acdream`; the launcher reads
`https://github.com/eriknihlen/acdream/releases/latest/download/manifest.json`.
Tests may inject a loopback HTTP URI, but production artifacts and redirects
must use HTTPS. `manifest.json` is:
```json
{
"schemaVersion": 1,
"version": "1.2.3",
"minimumLauncherVersion": "1.1.0",
"clients": {
"win-x64": {
"url": "https://github.com/eriknihlen/acdream/releases/download/v1.2.3/acdream-client-win-x64.zip",
"sha256": "<64 hex characters>",
"size": 123
}
},
"launchers": {
"win-x64": {
"url": "https://github.com/eriknihlen/acdream/releases/download/v1.2.3/acdream-launcher-win-x64.zip",
"sha256": "<64 hex characters>",
"size": 123
}
}
}
```
`version` and `minimumLauncherVersion` are strict SemVer 2.0 strings. Build
metadata is ignored for precedence; numeric identifiers are compared without
fixed-width integer overflow. RID keys are exact lowercase portable RIDs.
Both dictionaries are required and the running RID must have a client and a
launcher row. Artifact sizes are positive and capped by the launcher's
download limit; SHA-256 is exactly 64 hex characters. ZIP URLs are absolute.
Client ZIPs have the two host executables at their root
(`AcDream.App[.exe]`, `acdream-headless[.exe]`); launcher ZIPs have
`acdream-launcher[.exe]` at their root. No implicit wrapper directory exists.
Every extracted client version has
`DataDirectory/app/<version>/install.json`:
```json
{
"schemaVersion": 1,
"version": "1.2.3",
"rid": "win-x64",
"archiveSha256": "<64 hex characters>",
"archiveSize": 123,
"files": [
{ "path": "AcDream.App.exe", "sha256": "<64 hex characters>", "size": 123, "unixMode": 0 }
]
}
```
Paths use `/`, are relative, normalized, unique under ordinal-ignore-case,
and sorted ordinally. `unixMode` contains only the portable permission bits
captured from the ZIP entry. Startup verifies every recorded regular file by
size/SHA, rejects unrecorded files/reparse points, and requires the two host
executables before admitting a version. Extraction uses a random sibling
directory under `DataDirectory/app/`; promotion to `<version>/` is one
same-volume directory rename.
`DataDirectory/app/current.json` is the only activation authority:
```json
{ "schemaVersion": 1, "currentVersion": "1.2.3", "previousVersion": "1.1.0" }
```
`previousVersion` is omitted for the first activation. Pointer writes are
write-through temporary-file + same-directory atomic rename. The last valid
pointer is also atomically preserved as `current.previous.json`; startup may
restore that exact backup only when `current.json` is missing/malformed and
the referenced version verifies. Orphan LA10 staging directories and pointer
temporaries are transaction-owned by exact names and are removed under the
update lease. A corrupt installed version is never silently selected; the
explicit one-step rollback swaps the two verified pointer versions.
`DataDirectory/app/.update-session.lock` is the cross-process barrier. Each
supervised launcher activity holds a shared OS handle from before executable
resolution until terminal process observation; an update/rollback holds the
exclusive handle for its entire recovery/download/extract/promote/pointer
transaction. Failure to acquire the exclusive handle is an immediate refusal,
not a wait behind a running session. The open handle, not lock-file contents,
owns the lease and therefore releases after process death.
Launcher self-update staging lives at
`DataDirectory/launcher-update/transactions/<transactionId>/` and the sole
durable authority is `DataDirectory/launcher-update/pending.json`:
```json
{
"schemaVersion": 1,
"transactionId": "0123456789abcdef0123456789abcdef",
"state": "staged",
"version": "1.2.3",
"rid": "win-x64",
"targetDirectory": "<absolute current launcher directory>",
"archiveSha256": "<64 hex characters>",
"archiveSize": 123,
"files": [
{ "path": "acdream-launcher.exe", "sha256": "<64 hex characters>", "size": 123, "unixMode": 0 }
],
"apply": null
}
```
Before mutation a next-start helper copied outside the target directory
atomically advances the plan to `applying` and fills `apply` with each path's
`hadOriginal` bit. It waits for the initiating launcher PID without invoking a
shell, moves originals into the transaction backup tree, then moves verified
staged files into place. It never opens a target with truncate/overwrite. On
success the plan becomes `awaitingConfirmation`; the new launcher confirms at
its first managed instruction, after which backup and plan cleanup is safe. An
`applying` plan is rolled back before retry, and failure to start/confirm the
new launcher restores every original (and removes every no-original target).
All plan paths are re-derived/contained under the pinned data root except the
target directory, which must equal the actual launcher base directory.
## LA11 — closeout
- One connected-gate script `docs/research/2026-XX-XX-campaign-la-test-script.md`

View file

@ -303,6 +303,18 @@ preview would be a deliberate divergence we are NOT taking.
- **Feed hosting:** GitHub Releases (user-confirmed). Manifest and zips
are release assets; the launcher pins the repo/owner in its config.
The exact v1 manifest, extracted-version record, `current.json` activation
pointer, shared-session/exclusive-update OS lease, and durable self-update
plan are pinned in
`docs/plans/2026-08-14-launcher-campaign.md` under **Pinned updater
contracts (v1, BINDING)**. That section is normative: implementations reject
unknown/duplicate fields and unsupported versions, use strict SemVer 2.0
precedence, verify bounded streamed downloads before safe ZIP extraction, and
derive all mutable staging/backup paths from the application data root. The
LA9 DAT/pak install record remains the sole content descriptor fed to session
configs; LA10 changes only which verified `app/current.json` client binaries
the process supervisor executes.
## 10. Testing
- **Launcher.Core unit tests** (new test project, registered in