diff --git a/docs/AUTO_UPDATE_PLAN.md b/docs/AUTO_UPDATE_PLAN.md new file mode 100644 index 0000000..e1b6899 --- /dev/null +++ b/docs/AUTO_UPDATE_PLAN.md @@ -0,0 +1,252 @@ +# Auto-update implementation plan + +Working document for in-app updates from GitHub Releases. Retire this file and +record the shipped behaviour in [CHANGELOG.md](CHANGELOG.md), +[ARCHITECTURE.md](ARCHITECTURE.md), and [STANDARDS.md](STANDARDS.md) when all +phases are done. + +**Approach:** design-first — build and agree the Settings UI on a stub before +any network or install code. Backend work follows the UI contract. + +--- + +## 1. Goal and non-goals + +**Goal:** Let the user update the portable build (zip/tar.gz produced by +[`.github/workflows/release.yml`](../.github/workflows/release.yml)) to the +latest GitHub Release in one confirmed action, with an application restart. + +**Non-goals:** + +- macOS (stub returns a clear error; no self-replace path in v1). +- Installers, package managers, or system-wide installs outside the app folder. +- Downgrades, pre-release channels, delta updates, or silent install without + confirmation. + +--- + +## 2. Current state + +Facts the design depends on: + +| Topic | Detail | +| --- | --- | +| Version | `app.Version` in [src/app/version.go](../src/app/version.go); release builds override via `-ldflags -X` in [scripts/ci-build-release.sh](../scripts/ci-build-release.sh). | +| Artifacts | `gosentry--linux-{amd64,arm64}.tar.gz`, `gosentry--windows-amd64.zip`. Executable at archive root: `gosentry` / `gosentry.exe`. | +| Portable layout | Config and jobs live beside the executable ([src/storage/paths.go](../src/storage/paths.go)). Autostart shortcuts keep working after replace. | +| Single instance | `127.0.0.1:37653` ([src/ui/singleinstance.go](../src/ui/singleinstance.go)). A second process that starts before the port is free sends `"show"` and exits — the main restart risk. | +| Hosting | `origin` is Gitea (`gitea.mixdep.ru/mix/gosentry`). GitHub [`mixeme/gosentry`](https://github.com/mixeme/gosentry) is a **pruning push mirror** (git refs only). Release **assets** are attached by GitHub Actions, not mirrored. **Update source:** GitHub Releases API only. | +| Repository URL in app | Today `projectRepositoryURL` lives in `ui`; move release coordinates to `app` (constant or build-time `-X`). | + +**Mirror release ordering** ([DEVELOPMENT.md](DEVELOPMENT.md) §Releasing through the +GitHub push mirror): + +1. A release can appear on GitHub **before** the workflow attaches archives and + checksum files (build takes several minutes). If `releases/latest` has no + matching asset or no `SHA256SUMS` / `.sig`, treat as **not yet available**: + no UI error, no “up to date”; retry on the next check. +2. Deleting or moving a tag in Gitea removes or repoints the GitHub release. + Re-fetch release metadata before download; HTTP 404 → treat as no update. + +--- + +## 3. UI design (built first) + +Implement the full user-facing surface on a **stub** (no network, no install). +Capture screenshots for every state; **Phase 2 does not start until the owner +signs off** on layout and copy in this document. + +### 3.1 Elements + +| Location | Control | +| --- | --- | +| Settings → Application | Checkbox **Check for updates** (saved with **Save**, like other toggles). | +| Settings → About | Row **Updates**: status label, one primary button (label depends on state), **Release notes** hyperlink, progress bar while downloading. | +| Modal | **Install and restart** confirmation: target version, download size, text that GoSentry will restart and scheduled runs pause briefly. | +| Modal | Refusal when a job is running (same policy as changing the jobs file). | +| Modal | Error for manual check or install failure (`dialog.ShowError`). | +| Tray menu | **Update to X…** when a newer version is known (tray enabled). | +| History | Session line after successful restart: `Updated from X to Y`. | + +### 3.2 States (screenshot each) + +1. Auto-check off, never checked. +2. **Checking** — primary button disabled. +3. **Up to date** — includes last-check time. +4. **Available** — **Install and restart** + Release notes. +5. **Downloading** — progress + **Cancel**. +6. **Restarting** — short transitional copy. +7. **Failed** — status + **Check now** (or equivalent). +8. **Available**, app directory not writable — Release notes link only (no Install). +9. **Unsupported** — dev build (`0.0.0-dev` / unparseable version) or macOS. + +A release that exists but has **no assets yet** does **not** get its own state; +the UI matches **Up to date** until artifacts are present. + +### 3.3 View model + +- `updateView` in `ui`: status text, primary button label/enabled/visible, release + URL, progress 0–1, whether tray item shows. +- Widgets render **only** from `updateView`. +- Phase 2 adds `updateViewFor(domain.UpdateState) updateView` — the UI defines + the contract the backend must satisfy. + +### 3.4 Preview build + +- `src/ui/update_preview.go` with build tag `updatepreview`: cycle states on a + timer and via buttons for screenshot work. +- **Production builds:** no Updates row until Phase 2 wires real state (avoids + shipping a non-functional row). + +### 3.5 STANDARDS constraints + +- Add new captions to `settingsCaptions` for measured column width. +- Widget updates from background goroutines via `fyne.Do`. + +### 3.6 Design gate + +Insert screenshots under §3.2 in this file (or `docs/screenshots/update_*.PNG`). +Owner approves copy and layout → proceed to Phase 2. + +--- + +## 4. Architecture + +```mermaid +flowchart LR + settingsUI["ui Settings About/Updates"] -->|"CheckForUpdates, InstallUpdate"| svc["app.Service UpdateState"] + svc -->|"updater seam"| upd["src/update API download verify extract"] + svc -->|"ReplaceExecutable, Relaunch"| plat["platform/selfupdate"] + upd -->|"HTTPS"| gh["api.github.com"] + svc -->|"UpdateStateChanged"| settingsUI +``` + +| Layer | Responsibility | +| --- | --- | +| `domain` | `UpdateState` enum + data; `CompareVersions` (semver, pre-release rules). Dev/unparseable current version → never offer update. | +| `src/update` | stdlib only: `LatestRelease`, `SelectAsset(goos, goarch)`, `Download`, `Verify` (SHA256 + ed25519), `ExtractBinary` (zip-slip safe, single binary entry). | +| `src/platform/selfupdate` | `ReplaceExecutable`, `Relaunch`, `CleanupPrevious` — `_windows.go`, `_linux.go`, `_other.go` like `autostart`. | +| `app.Service` | `CheckForUpdates(manual)`, `InstallUpdate`, `UpdateState()`, emit `UpdateStateChanged`. Network behind `updater` interface (tests use fake). No network I/O under `mu`. | + +**Windows replace:** rename running `gosentry.exe` → `gosentry.exe.old`, write new +exe via temp + rename; rollback rename on failure. + +**Linux replace:** temp file in app dir, `chmod 0755`, `rename` over executable. + +**Relaunch:** new process with `--updated-from=` and `--start-in-tray` +when the window was hidden. New instance retries single-instance bind ~10s, +deletes `.old`, records History. + +--- + +## 5. Update flow (install and restart) + +1. User clicks **Install and restart** → confirmation dialog. +2. If any job is running → error dialog (same rule as `UpdateSettings` jobs-file switch). +3. Download, verify signature and hash, extract binary, replace executable. +4. `svc.Stop()`, close single-instance listener, `Relaunch`, `a.Quit()`. +5. New process: bind port (retry), `CleanupPrevious()`, History event. +6. If `Relaunch` fails → restore from `.old`, `dialog.ShowError`. + +--- + +## 6. Integrity and security + +- CI produces `SHA256SUMS` and `SHA256SUMS.sig` (ed25519). Public key embedded + in the binary; private key in GitHub Actions secret. No new Go dependencies. +- Install **refuses** without valid signature and matching hash for the selected + archive (checksum alone does not defend a compromised release upload). +- HTTPS only; download size cap; allow redirects only to + `objects.githubusercontent.com` (and same release asset URLs from the API). + +--- + +## 7. Check behaviour and config + +- `Config.CheckForUpdates bool` — `json:"check_for_updates,omitempty"`. Zero = + off (legacy configs unchanged). Default in `DefaultConfig()`: see §12 (recommend off). +- When enabled: check after startup (delayed, non-blocking) and every 24h; cache + in Service. Automatic check errors are silent; manual check/install errors use + `dialog.ShowError`. +- Writable app directory probe in Service → `UpdateState` for “link only” UI. + +--- + +## 8. Release pipeline changes + +- [scripts/ci-build-release.sh](../scripts/ci-build-release.sh): emit `SHA256SUMS`; + sign with `openssl pkeyutl` or small `cmd/signsums` helper. +- [.github/workflows/release.yml](../.github/workflows/release.yml): secret key; + attach `SHA256SUMS` and `SHA256SUMS.sig` in `action-gh-release` `files:` (same + job as build so assets and signature land together). +- [DEVELOPMENT.md](DEVELOPMENT.md): key generation, rotation, post-release + checklist — five files per release: three archives + `SHA256SUMS` + `.sig`. + +--- + +## 9. Phases (design-first order) + +| Phase | Deliverable | Shippable? | +| --- | --- | --- | +| **1 — UI design** | §3: `updateView`, Updates row, checkbox, dialogs, tray item, `updatepreview`, UI tests, screenshots, **design gate** | Preview tag only | +| **2 — Detection** | `domain.UpdateState`, `CompareVersions`, `update.LatestRelease`, config field, Service + event, `updateViewFor`; Updates row in release builds; **Available** opens release page until Phase 4 | Yes (ROADMAP notification scope) | +| **3 — Signed artifacts** | CI checksums + signature on every GitHub release | Must ship **before** Phase 4 | +| **4 — Download and install** | Full `update` + `selfupdate`, handoff, `--updated-from`, `.old` rollback; **Install and restart** live | Yes | +| **5 — Landing** | README screenshot, ARCHITECTURE, STANDARDS intentional behaviour; remove `updatepreview` if unused; delete this plan from ROADMAP | Yes | + +If Phase 2–4 needs a new UI state or field not in the signed-off §3, update +screenshots and §3 first, then code. + +--- + +## 10. Testing + +Per [TESTS.md](TESTS.md): + +| Phase | Package | Coverage | +| --- | --- | --- | +| 1 | `ui` | Each `updateView` state via Fyne test driver; assert no Updates row in default build | +| 2 | `ui` | Table tests for `updateViewFor` | +| 2+ | `domain` | `CompareVersions` (`0.12.0` > `0.9.0`, pre-releases, garbage) | +| 3–4 | `update` | `httptest`: draft/prerelease, missing asset, 403, bad hash/sig, size limit, zip-slip | +| 4 | `platform/selfupdate` | Temp-dir replace; Windows rename + rollback | +| 4 | `app` | Fake updater; install blocked when job running; events; config compat trio (STANDARDS) | + +**Manual:** N−1 → N on Windows and Linux; tray vs visible window; autostart enabled. + +--- + +## 11. Docs to update when feature lands + +- **STANDARDS** §Intentional: no install while job running; brief scheduler gap + on restart; opt-in silent auto-check. +- **ARCHITECTURE:** package map, platform table, update flow diagram. +- **ROADMAP:** remove auto-update item. +- **CHANGELOG:** user-visible release notes. +- Delete **this file** after Phase 5. + +--- + +## 12. Open questions + +1. `CheckForUpdates` default in `DefaultConfig()`: **off** vs first-run prompt. +2. Signature format: raw ed25519 (stdlib) vs minisign-compatible. +3. Optional pre-release channel later? + +--- + +## Screenshot placeholders (Phase 1) + +_Add images here after the design gate._ + +| State | File | +| --- | --- | +| Auto-check off | `docs/screenshots/update_idle.PNG` | +| Checking | `docs/screenshots/update_checking.PNG` | +| Up to date | `docs/screenshots/update_uptodate.PNG` | +| Available | `docs/screenshots/update_available.PNG` | +| Downloading | `docs/screenshots/update_downloading.PNG` | +| Restarting | `docs/screenshots/update_restarting.PNG` | +| Failed | `docs/screenshots/update_failed.PNG` | +| Not writable | `docs/screenshots/update_nowrite.PNG` | +| Unsupported | `docs/screenshots/update_unsupported.PNG` | diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1567ddd..7ecf594 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -16,6 +16,9 @@ restructured around what actually blocks each item.** **Documentation:** +- **`AUTO_UPDATE_PLAN.md`** — design-first plan for GitHub Release checks, + signed portable installs, and in-app restart. **`ROADMAP.md`** retitled the + feature to auto-update and points at the plan instead of detection-only scope. - **`ROADMAP.md`** — reorganized into Features, Platform, Fyne/UI, and Maintenance. The over-the-guideline file-split item is closed; each deferred UI item now states whether it is blocked on a missing Fyne API, on UX diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b55a2f0..ff562a4 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -7,37 +7,20 @@ Completed work is recorded in [CHANGELOG.md](CHANGELOG.md), not here. User-facing functionality that is not blocked on a framework or platform gap. -### Update check from GitHub releases +### Auto-update from GitHub releases -Releases are published as GitHub Releases (tags like `v0.12.0`, built by +Releases are published as GitHub Releases (tags like `v1.0.4`, built by `.github/workflows/release.yml`), but the app never tells the user a newer -version exists — they have to check the releases page by hand. +version exists — they have to open the releases page by hand. -Add an update check that queries the GitHub Releases API -(`GET /repos/mixeme/gosentry/releases/latest`) for the latest published tag, -strips the leading `v`, and compares it against `app.Version`. When a newer -version is available, surface it non-intrusively — an "Update available" -line in Settings (next to the existing version/build info) with a hyperlink -to the release page, not a modal on launch. +Ship opt-in update checks and one-click install for the portable archives: +semver compare against `app.Version`, signed downloads, replace the running +executable, and restart. Settings shows status in the About section; no modal +on launch. -Design notes / open questions: - -- *Opt-in and offline-safe.* The check makes a network request, so it must be - off by default (or clearly consented) and never block startup. Failures - (offline, rate-limited, API change) should be silent — no error dialogs for a - best-effort convenience feature. -- *Version comparison.* Compare semantic versions, not strings, so `0.12.0` - reads as newer than `0.9.0`. A tiny semver comparator in `app` (or a small - dependency) avoids lexical bugs. -- *Where the check lives.* Keep it in the `app` layer behind the Service so the - UI only renders the result, and cache the last check so opening Settings - repeatedly does not spam the API (unauthenticated GitHub allows 60 req/h). -- *Repo coordinates.* The primary remote is Gitea; the GitHub repo used for - releases is [`mixeme/gosentry`](https://github.com/mixeme/gosentry) and must - be wired in explicitly (constant or build-time value) rather than derived from - `origin`. -- *No auto-download.* Scope is detection and notification only; installing the - update stays a manual click-through to the release page. +Implementation is **design-first** (UI on a stub, then backend). Phases, +security, and platform details live in +[AUTO_UPDATE_PLAN.md](AUTO_UPDATE_PLAN.md). ### Import/export jobs as a cron table diff --git a/docs/STANDARDS.md b/docs/STANDARDS.md index 210bd3a..e03990d 100644 --- a/docs/STANDARDS.md +++ b/docs/STANDARDS.md @@ -140,6 +140,6 @@ change to their shape has to stay compatible on its own. ## Out of scope -Larger or blocked work is tracked in [ROADMAP.md](ROADMAP.md) (update check from +Larger or blocked work is tracked in [ROADMAP.md](ROADMAP.md) (auto-update from GitHub releases, cron-table import/export, window size persistence, History column filters).