diff --git a/docs/auto-update-plan.md b/docs/auto-update-plan.md index 6ffd73d..51169d3 100644 --- a/docs/auto-update-plan.md +++ b/docs/auto-update-plan.md @@ -1,143 +1,181 @@ -# Auto-update for haul — plan +# Auto-update — plan -Status: **planned, not started.** Blocked on four decisions (bottom of this -file). No code has been written. +Status: **planned, not started.** Blocked on decisions at the bottom of this +file. No code written. -The model is the self-updater in +Ben wants this as a **standalone, reusable Go module**, not something buried in +haul — so it can be dropped into dial, haul, and whatever comes next. This doc +lives in haul's repo because that's where the conversation started; the module +gets its own repo and its own README. + +The behaviour to copy is the self-updater in [ShippingTracker](https://gitea.apointless.space/bsncubed/ShippingTracker) -(`HelloWorld/Updater.vb`), which haul should behave like. +(`HelloWorld/Updater.vb`). ## Research already done — don't redo this -Verified 2026-07-29 against the live gitea instance: +Verified 2026-07-29/30 against the live gitea instance: -- `https://gitea.apointless.space/api/v1/repos/bsncubed/ShippingTracker/releases/latest` - returns **HTTP 200 anonymously**. No token needed. -- `.../repos/bsncubed/haul` also returns **200 anonymously**, and - `.../repos/bsncubed/haul/releases` returns `[]`. So haul's repo is public; - the 404 on `releases/latest` is simply "no releases exist yet", not a - permissions problem. The updater will work unauthenticated. -- Release JSON exposes `tag_name`, `draft`, `prerelease`, and an `assets` array - whose entries carry `name` and `browser_download_url`. ShippingTracker's - latest (`1.1.0.2`) attaches exactly two assets: `Shipping Tracker.exe` and - `checksums.txt`. +- The releases API is **readable anonymously** — both + `.../repos/bsncubed/ShippingTracker/releases/latest` and + `.../repos/bsncubed/haul` return 200 with no token. The updater needs no + credentials. +- `.../repos/bsncubed/haul/releases` returns `[]`. haul's repo is public; the + 404 on `releases/latest` is just "no releases yet", not permissions. +- **dial already publishes proper releases.** Tag `0.2.0`, `draft: false`, + `prerelease: false`, with six assets: `dial-darwin-amd64`, + `dial-darwin-arm64`, `dial-linux-amd64`, `dial-linux-arm64`, + `dial-windows-amd64.exe`, and `SHA256SUMS`. +- Release JSON exposes `tag_name`, `draft`, `prerelease`, and `assets[]` with + `name` and `browser_download_url`. +- Gitea serves `go-import` meta tags + (``), + so `go get gitea.apointless.space/bsncubed/` resolves. ## What ShippingTracker's updater does 1. GET `/api/v1/repos/{owner}/{repo}/releases/latest`; take `tag_name`, match assets by exact filename. 2. Parse the tag into a version — tolerates a leading `v`, pads missing - components with `0` (an unset `System.Version` component is `-1`, which - sorts wrong). + components with `0`. 3. **Silent vs. loud.** A startup check swallows every failure (offline, DNS, timeout, non-200, malformed JSON) with no UI at all. A user-clicked check reports "you're up to date" and surfaces errors. 4. Prompts with current vs. available before doing anything. 5. **Pre-flights write permission** next to the executable, so a read-only install directory fails early rather than half-applying an update. -6. Streams the download to `.new` behind a small progress window. -7. Verifies SHA256 against a `checksums.txt` asset in `sha256sum` format - (` `). If that asset is absent the update still installs, - unverified. +6. Streams the download to `.new` behind a progress window. +7. Verifies SHA256 against a checksum asset in `sha256sum` format + (` `). Absent checksum asset → installs unverified. 8. **Swaps by rename**: running exe → `.old`, `.new` → exe, rolling back if the - second rename fails. `.old` is deleted on the next launch. -9. On any failure, offers to open the releases page in a browser instead. - -## Ports over unchanged - -The API shape, the silent/loud split, tag parsing, checksum verification, the -rename-swap with `.old` cleanup, and the manual-download fallback. + second rename fails. `.old` deleted on next launch. +9. On any failure, offers to open the releases page in a browser. The rename dance exists because Windows won't delete a *running* binary but will rename one. Linux and macOS also allow renaming a running executable (the inode -stays live), so a single code path covers all three platforms. +stays live), so one code path covers all three. -## Has to change for haul +## The module -1. **haul has no version at all.** ShippingTracker compares against - `AssemblyVersion`; `cmd/haul` has no equivalent, which is why the Makefile - deliberately omits `-X main.version` (a `-X` against a non-existent symbol is - silently a no-op). Adding one is a prerequisite — without it every check - reports "up to date". -2. **One asset becomes many.** ShippingTracker is Windows-only, so its - `AssetName` is a single constant. haul must select an asset by - `runtime.GOOS`/`runtime.GOARCH` (`haul-linux-amd64`, `haul-darwin-arm64`, - `haul-windows-amd64.exe`). -3. **The executable bit.** On Unix the downloaded file needs `chmod 0755`. - Nothing in the VB version corresponds to this. -4. **Fyne threading.** `internal/ui` only touches widgets from the UI thread and - returns from I/O via `fyne.Do`. The updater must follow the same shape as - `session.transfer` and `pane.navigateKeepMarks`. +Its own repo — something like `gitea.apointless.space/bsncubed/selfupdate` +(name is an open decision). Separate module, separately versioned, imported by +each project. -## Blockers — read before starting +### The single most important constraint: no UI -**The release assets can't be built yet.** This gates everything. haul is cgo, -so `make dist` produces the native binary only; there is currently no way to -produce macOS or Windows assets from the Linux box. An updater with nothing to -download is pointless, so **the release pipeline must come first** -(`fyne-cross` over Docker, which is installed, or real machines). +ShippingTracker's updater calls `MessageBox.Show` directly. **A reusable module +cannot do that**, because the three consumers could not be more different: -**Linux binaries don't travel.** haul links dynamically against `libGL`, -`libX11`, `libc` and ten others. A binary built on Ubuntu 24.04 may not start on -a different or older distro — it would download, swap itself in, and then fail -to launch, which is the worst failure mode an updater has. ShippingTracker never -hit this (.NET Framework, stable Windows ABI). Options: build against the oldest -glibc worth supporting, ship Linux without auto-update, or accept it. +| Consumer | UI | +| --- | --- | +| ShippingTracker | WinForms message boxes | +| haul | Fyne GUI, and widgets may only be touched from the UI thread via `fyne.Do` | +| dial | Bubble Tea TUI, an Elm-style message loop | -**`checksums.txt` is not a signature.** It lives on the same server as the +So the module is **pure logic plus callbacks**: it decides, downloads, verifies +and swaps; the host application does every prompt, progress bar and error +dialog. That also makes it trivially testable against `httptest`. + +### Sketch + +```go +type Config struct { + BaseURL string // https://gitea.apointless.space + Owner, Repo string + Current Version + AssetName func(goos, goarch string) string // default: --[.exe] + ChecksumAsset string // "SHA256SUMS" (dial) or "checksums.txt" (ShippingTracker) + HTTPClient *http.Client + UserAgent string + AllowPrerelease bool +} + +func Check(ctx, cfg) (*Release, error) // fetch, parse tag, pick this platform's asset +func (r *Release) Newer(v Version) bool +func Download(ctx, r, dst string, onProgress func(done, total int64)) error +func Verify(path, checksumURL, assetName string) error +func Apply(newPath string) error // rename dance, rollback, chmod 0755 +func CleanupOld() error // delete .old; never errors +``` + +`onProgress` is the seam: haul marshals it through `fyne.Do`, dial turns it into +a Bubble Tea message, ShippingTracker-style apps call it directly. + +### Configurable because it genuinely varies + +- **Checksum asset name** — dial uses `SHA256SUMS`, ShippingTracker uses + `checksums.txt`. Proof this can't be a constant. +- **Asset naming** — a `func(goos, goarch)` hook, defaulting to dial's existing + `--[.exe]` convention, which haul should adopt too. +- **Forge** — gitea's release JSON is GitHub-shaped, so the same client covers + GitHub for free. Worth keeping the fetch behind a small interface. + +### Stays with each project + +Version stamping, the release pipeline, asset naming, and all UI. + +## Consumers, in order + +**dial first — it is not blocked.** Pure Go, cross-compiles trivially, and its +`make dist` already produces all five platform binaries and `SHA256SUMS`, which +release `0.2.0` already has attached. dial can ship auto-update as soon as the +module exists, which makes it the honest test of whether the API is actually +reusable. + +**haul second, and it needs work first:** + +1. **haul has no version at all.** `cmd/haul` has no `version` variable, which + is why the Makefile omits `-X main.version` (a `-X` against a non-existent + symbol is silently a no-op). Without it every check reports "up to date". +2. **haul cannot build its own release assets.** It is cgo, so `make dist` + produces the native binary only — no macOS or Windows assets exist to + download. Needs `fyne-cross` over Docker (installed) or real machines. +3. **Linux binaries don't travel.** haul links dynamically against `libGL`, + `libX11`, `libc` and ten others; a binary built on Ubuntu 24.04 may not start + on another distro. It would download, swap itself in, then fail to launch — + the worst failure mode an updater has. dial, being static, has no such + problem. Options: build against the oldest glibc worth supporting, ship Linux + without auto-update, or accept it. + +## Security note + +**A checksum is not a signature.** `SHA256SUMS` sits on the same server as the binary, so it catches corruption and truncation but not a compromised gitea — anyone who can replace one can replace the other. ShippingTracker accepts this. Closing the gap means a minisign/ed25519 signature with the public key compiled -into haul, roughly 40 extra lines. +into each consumer, roughly 40 extra lines in the module. -## Proposed shape +## Consuming the module -New package `internal/selfupdate`, UI-free so it can be tested against a fake -HTTP server: +Because the host is self-hosted, dependent projects will likely want: -| Piece | Responsibility | -| --- | --- | -| `Check(ctx) (*Release, error)` | Fetch latest, parse tag, pick this platform's asset | -| `Release.Newer(current)` | Version comparison | -| `Download(ctx, rel, progress)` | Stream to `.new`, report progress | -| `Verify(path, checksumURL)` | SHA256 against `checksums.txt` | -| `Apply(newPath)` | Rename dance, rollback, `chmod` | -| `CleanupOld()` | Delete `.old`; never errors | +```sh +go env -w GOPRIVATE=gitea.apointless.space/* +``` -Plus a thin `internal/ui/update.go` for dialogs, reusing `confirmDialog` from -`dialogs.go` and a progress dialog modelled on the existing transfer bar. - -Integration points, mirroring ShippingTracker's `Form1_Load` and `Form3`: - -- `CleanupOld()` and a silent check when the connect window opens - (`newConnectWindow`). -- A manual "Check for updates" button. The connect window is the natural home — - haul has no settings screen. - -Release process: a `make release VERSION=x.y.z` target that builds, writes -`checksums.txt`, and prints the tag/upload steps. Tag as `1.2.0` or `v1.2.0`, -and don't mark it pre-release — `/releases/latest` skips drafts and -pre-releases. +so the module is fetched straight from gitea rather than through +`proxy.golang.org` and the checksum database. Worth confirming in practice — +the repo is public, so the proxy path may work unaided. ## Phasing | Phase | Work | | --- | --- | -| 0 | Version stamping — `var version` in `cmd/haul`, `-X` in the Makefile, `haul --version` | -| 1 | **Release pipeline** — cross-builds via fyne-cross, checksums, first tagged release | -| 2 | `internal/selfupdate` with tests against a fake HTTP server | -| 3 | UI wiring — startup silent check, manual button, progress and error dialogs | -| 4 | README: how updating works, and how to publish a release | +| 0 | Create the module repo; `Check`/`Newer` plus version parsing, tested against `httptest` | +| 1 | `Download`/`Verify`/`Apply`/`CleanupOld`, tested in a temp dir | +| 2 | **Adopt in dial** — the real proof of reusability, and shippable immediately | +| 3 | haul phase A: version stamping (`var version`, `-X`, `haul --version`) | +| 4 | haul phase B: release pipeline via fyne-cross, first tagged release | +| 5 | haul phase C: wire the module in — silent check on the connect window, a manual "Check for updates" button, progress and error dialogs | +| 6 | READMEs: the module's own, plus a section in each consumer | -Phases 0 and 1 are worth doing regardless — there is currently no way to ship -haul to anyone at all. +## Open decisions -## Open decisions — needed before building - -1. **Auto-check on startup, or manual only?** ShippingTracker does both. Silent - startup checks mean haul contacts gitea on every launch. -2. **Which platforms get auto-update?** Suggestion: exclude Linux at first, for - the dynamic-linking reason above. -3. **Checksums only, or add signatures?** -4. **Do phase 1 first, or write the updater against ShippingTracker's existing - releases as a test fixture?** +1. **Module name and path** — `selfupdate`? `gitup`? `bsncubed/selfupdate`? +2. **Auto-check on startup, or manual only?** ShippingTracker does both. Silent + startup checks mean the app contacts gitea on every launch. +3. **Which platforms get auto-update?** Suggestion: exclude haul's Linux build + for the dynamic-linking reason above. dial is fine everywhere. +4. **Checksums only, or real signatures?** +5. **Confirm dial goes first** — it's unblocked and validates the API, whereas + haul can't produce release assets yet.