Online updates are the release vehicle: every platform must be able to check, download, verify and install a new version without the user hunting for download pages. The behavior (triggers, UX, strings, failure handling) is identical on all platforms; the transport and install mechanism is native to each platform, like every other part of the Taskly architecture.
- Triggers: one silent check shortly after launch (throttled: at most
once per 4 hours, persisted as config key
last-update-check, unix epoch seconds, host-owned viaget_setting/set_setting), plus a manualSettings ▸ 检查更新… / Check for Updates…menu item that bypasses the throttle. - Silent mode: network failure / rate limit → silently ignore (never nag). Manual mode reports every outcome.
- Newer version found (semver compare, downgrades ignored) → dialog
with the new version:
立即更新 / Update(download + install) or取消 / Cancel. Up-to-date →当前已是最新版本 / Up to date. - Download: performed by the backend over the
check_updates/start_download/update_stateRPCs (rivet/distribution family pattern — Ed25519 manifest verify and sha256 artifact verify live in the backend; hosts never implement crypto). Hosts pollupdate_state(phase: idle | checking | downloading | downloaded | error) and show progress. - Install failure → non-crashing error with the platform message; the running version keeps working (same rule as notifications).
- CLI parity:
--jsonoutput and exit codes are version-stable; an update must never change golden outputs within a major version. - i18n keys are shared (
shared/i18n):updateCheckNow,updateChecking,updateUpToDate,updateAvailableTitle,updateAvailableBody,updateRestart,updateCheckFailed,updateDownloading,updateDownloadPercent,updateDownloaded,updateQuitAndInstall,updateInstallHint,updateOpenFolder,updateInstallFailed,updatePortable,updateNotInstalled,updateOpenReleases,updateReadyBody.
The release pipeline signs and uploads update-manifest.json (the
self-contained signed wrapper, below) for every release, so the feed
exists on all platforms from day one.
| Platform | Feed | Install | Integrity | Status |
|---|---|---|---|---|
| macOS Apple silicon | feed macos/arm64 zip entry |
zip → sha256 → ditto → atomic swap in place (.old copy kept until relaunch); the DMG is the human installer, not a feed artifact |
Ed25519 wrapper signature (CryptoKit, UpdateService.swift) + artifact sha256 |
wired since 1.1.1 |
| macOS Intel | feed macos/x64 zip entry |
same swap flow on the x64 build (hosts match their compile-time architecture) | same | wired since 1.3.0 |
| Windows x64 | feed windows/x64 zip entry |
portable zip → handoff update-install.cmd: wait for app exit → tar -xf extract → swap install dir in place → relaunch; failure marker (update-failed.txt) reported on next launch. MSI installs (host declares install-flavor=msi at startup): backend derives the sibling .msi asset from the feed zip URL, checksums it against the published .msi.sha256 sidecar, then handoff msi-install.cmd: wait for app exit → msiexec /passive /norestart /i (per-machine installs surface one UAC consent; msiexec log at %TEMP%\taskly-update\msiexec.log) → relaunch; failure marker on next launch |
portable zip: Ed25519 wrapper signature (backend, libcrypto) + artifact sha256; MSI: .msi.sha256 release sidecar over the same HTTPS origin |
portable wired since 1.2.0; MSI online upgrade since 0.1.2 (releases-page guidance 1.3.0–0.1.1) |
| Windows ARM64 | — | no native package exists (Racket ships no Windows-on-ARM runtime); run the x64 build under Windows on ARM's built-in x64 emulation | — | documented since 1.3.0 |
| Linux x64 | feed linux/x64 tar.gz entry |
three install shapes (host declares install-flavor at startup): AppImage (APPIMAGE env set) — backend derives the sibling .AppImage asset from the feed tar.gz URL (arch mapped: x64→x86_64, arm64→aarch64), checksums against the published .AppImage.sha256 sidecar, stages beside the running image and atomically renames it over $APPIMAGE with exec bits set; host asks for consent, then execls the replaced image (Tauri-2 style in-place update). deb/rpm (exe under /opt/taskly or /usr) — download kept, dialog points at the system package manager (updateUsePackageManager); no self-swap of a system-managed prefix. tarball — download + "open folder" manual extract, unchanged |
tar.gz: Ed25519 wrapper signature (backend, libcrypto) + artifact sha256; AppImage: .AppImage.sha256 release sidecar over the same HTTPS origin |
tarball wired since 1.2.0; AppImage in-place since 0.1.2; deb/rpm guidance since 0.1.2 |
(The frozen v1 line used Velopack on Windows and a tarball replacer on Linux; those mechanisms died with that line — the Rivet hosts start from the manifest feed above.)
Generated by scripts/make-update-manifest.sh from the release
artifacts and uploaded as a single release asset. It is the family
signed-wrapper format (rivet/distribution): the inner manifest travels
base64-encoded inside a JSON envelope, so the Ed25519 signature covers
the exact bytes every client verifies.
{
"schema": 1,
"payload": "<base64 of the inner manifest JSON>",
"signature": { "algorithm": "ed25519", "key_id": "taskly-2026-10",
"value": "<base64, 64 bytes>" }
}Inner manifest (decoded payload) — rivet's manifest schema:
{
"schema": 1,
"application_id": "app.taskly.Taskly",
"version": "1.2.0",
"build": 1,
"channel": "stable",
"published_at": "2026-10-09T12:00:00Z",
"minimum_version": "0.0.0",
"previous_version": null,
"rollback_allowed": true,
"rollout": 100,
"artifacts": [
{ "platform": "macos", "architecture": "arm64",
"url": "https://…/taskly-1.3.0-macos-arm64.zip",
"sha256": "<hex>", "size": 12345678, "installer": "zip", "arguments": [] },
{ "platform": "macos", "architecture": "x64", "…": "…" },
{ "platform": "windows", "architecture": "x64", "…": "…" },
{ "platform": "linux", "architecture": "x64", "…": "…" }
]
}versionmust equal the rootVERSIONfile (release preflight checks it, along withrivet.rktdand the updater module's embedded identity).- Clients fetch
https://github.com/turinglambdaai/taskly/releases/latest/download/update-manifest.json(the moving "latest" location; redirect-following is mandatory — release assets 302 to the CDN, rivet#153). Artifact URLs inside the manifest are pinned to the concrete tag. - A sticky per-install rollout bucket (config key
rollout-bucket, integer 0–99) gates staged rollouts against the manifest'srolloutpercentage.
- Key type: Ed25519. Private key lives ONLY in the GitHub secret
UPDATE_ED25519_PRIVATE_KEY(PEM) and in the release manager's local backup — never in the repository. - Generate / inspect:
scripts/update-keys.sh(openssl, one-time). - Sign: inside
make-update-manifest.sh, the PEM is converted to DER (openssl pkey -outform DER) and the wrapper is signed byrivet/distribution'swrite-signed-manifest(which also validates the manifest against the schema before signing). - Public key: embedded twice, same keypair — raw 32 bytes base64 in the
macOS host (
UpdateService.publicKeyBase64) and SPKI DER base64 in the backend updater (racket/taskly/updater.rkt).key_idistaskly-2026-10; clients pin it. Rotate = ship a build that trusts the next key before releases stop being signed with the old one. - Verification: wrapper → schema 1 → key id → Ed25519 over the exact payload bytes; then size + sha256 of the downloaded artifact against the signed manifest. Any mismatch aborts the update silently in silent mode, with an error in manual mode.
- Downgrades are never auto-installed; semver compare is numeric per segment.
- The updater must never delete or touch
~/.taskly/(DB + config) — it swaps application bundles/prefixes only. - A failed swap must leave the previous install runnable (macOS keeps a
.oldcopy until the next successful launch; Windows restores the renamed directory and writes a failure marker the next launch reports).