Skip to content
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,9 @@ Grab your build from
| Linux ARM64 | full Linux artifact set on `ubuntu-24.04-arm` builds (`-linux-arm64`) | — |

Every release carries a `SHA256SUMS` manifest and a signed
`update-manifest.json` — the in-app updater's feed. Portable zip
installs update themselves in place; MSI installs get pointed at the
releases page.
`update-manifest.json` — the in-app updater's feed. Portable zip,
Windows MSI, and Linux AppImage installs update in place; every installed
artifact is selected from and verified against the signed manifest.

macOS builds are ad-hoc signed; if Gatekeeper complains on first launch,
run `xattr -cr /Applications/Taskly.app`.
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Taskly 构建在 [Rivet](https://github.com/turinglambdaai/rivet) 之上:全
| Linux x64 | `taskly-<version>-linux-x64.tar.gz` | `taskly-<version>-linux-x64.deb` / `.rpm` / `.AppImage` |
| Linux ARM64 | `ubuntu-24.04-arm` 构建全套 Linux 产物(`-linux-arm64`) | — |

每个版本都带 `SHA256SUMS` 清单和签名的 `update-manifest.json`(应用内更新的 feed)。便携 zip 安装原地自动更新;MSI 安装版会引导到发布页。
每个版本都带 `SHA256SUMS` 清单和签名的 `update-manifest.json`(应用内更新的 feed)。便携 zip、Windows MSI 和 Linux AppImage 都可原地更新;实际安装的产物都会直接从签名清单中选择并校验。

macOS 包为 ad-hoc 签名;首次启动如被 Gatekeeper 拦截,执行 `xattr -cr /Applications/Taskly.app` 即可。

Expand Down
143 changes: 30 additions & 113 deletions racket/taskly/updater.rkt
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
set-update-candidate!
update-worker-thread
perform-check!
update-install-kind
start-download!
rollout-bucket)

Expand Down Expand Up @@ -227,7 +228,11 @@
;; rivet #271: component #f = the plain whole-app
;; artifact (legacy manifests carry no component).
#f))
(define candidate (select-update config manifest))
(define kind (update-install-kind))
(define candidate
(if kind
(select-update config manifest #:install-kind kind)
(select-update config manifest)))
(cond
[candidate
(set-box! candidate-box candidate)
Expand Down Expand Up @@ -318,12 +323,10 @@
(rename-file-or-directory temporary destination #t)
destination))

;; ---------- MSI flavor (Windows installs under Program Files) ----------
;; The feed's windows entry is the portable zip; MSI installs update through
;; the sibling .msi release asset: same asset base with .zip → .msi, and the
;; checksum comes from the published .msi.sha256 sidecar. (The Ed25519
;; manifest still authenticates the zip entry that carried the version; the
;; sidecar is a plain checksum fetched over the same HTTPS origin.)
;; ---------- install flavor selection ----------
;; MSI and AppImage are first-class artifacts in the signed manifest. The
;; installed flavor selects the matching signed artifact during check-update,
;; so the bytes later executed/installed are covered by Ed25519 + SHA-256.

(define (install-flavor)
(string-downcase (or (config-ref (read-config) "install-flavor") "zip")))
Expand All @@ -332,18 +335,20 @@
(and (eq? (platform-symbol) 'windows)
(equal? (install-flavor) "msi")))

;; ---------- AppImage flavor (Linux installs run from an AppImage) ----------
;; The feed's linux entry is the tar.gz; AppImage installs update through the
;; sibling .AppImage release asset. Asset families disagree on arch names —
;; feed/deb/rpm use x64|arm64, AppImage builds use x86_64|aarch64 — so the
;; derivation maps the arch before appending. The download lands NEXT TO the
;; running image (same filesystem → atomic rename) and replaces it after the
;; sidecar checksum verifies; the host re-execs the replaced image on consent.
;; AppImage downloads land next to the running image (same filesystem →
;; atomic rename), verify against the signed manifest, then replace $APPIMAGE;
;; the host re-execs the replaced image on consent.

(define (appimage-flavor?)
(and (eq? (platform-symbol) 'linux)
(equal? (install-flavor) "appimage")))

(define (update-install-kind)
(cond
[(msi-flavor?) 'msi]
[(appimage-flavor?) 'appimage]
[else #f]))

;; Pure so tests can pin the derivation — the arch-name mapping is the trap.
(define (appimage-asset-name tar-gz-filename architecture)
(unless (string-suffix? tar-gz-filename ".tar.gz")
Expand Down Expand Up @@ -383,17 +388,21 @@
#f
(path-only self))))

(define (download-appimage-with-progress! candidate)
(define-values (appimage-url sidecar-url)
(appimage-artifact-urls candidate))
(define (download-appimage-with-progress! config candidate)
(define-values (self temporary) (appimage-download-target))
(define expected (fetch-sidecar-sha256! sidecar-url))
(with-handlers ([exn:fail?
(lambda (e)
(when (file-exists? temporary)
(delete-file temporary))
(raise e))])
(define-values (in total) (open-download-with-total! appimage-url))
(define artifact (update-candidate-artifact candidate))
(define total (update-artifact-size artifact))
(when (> total (updater-config-maximum-download-bytes config))
(error 'download-update "signed artifact size exceeds the download limit"))
(define in
(get-pure-port (string->url (update-artifact-url artifact))
'("User-Agent: Taskly-Updater/1")
#:redirections 10))
(dynamic-wind
void
(lambda ()
Expand All @@ -402,102 +411,12 @@
#:mode 'binary
(lambda (out) (copy-with-progress! in out total))))
(lambda () (close-input-port in)))
(define actual (string-downcase (sha256-file/hex temporary)))
(unless (string=? actual expected)
(raise-arguments-error 'start-download
"downloaded appimage does not match its checksum sidecar"
"expected" expected
"actual" actual))
;; The replaced image must stay executable: rename carries the temp
;; file's modes, so set them explicitly before the swap. The setter
;; takes integer bits (the vector form is a contract violation on
;; Windows, where POSIX modes do not exist) — and the swap itself is
;; only ever reached under APPIMAGE, i.e. never on Windows.
(verify-update-artifact! candidate temporary)
(unless (eq? (system-type 'os) 'windows)
(file-or-directory-permissions temporary #o755))
(rename-file-or-directory temporary self #t)
self))

(define (msi-artifact-urls candidate)
(define url (update-artifact-url (update-candidate-artifact candidate)))
(unless (string-suffix? url ".zip")
(error 'start-download "cannot derive an msi url from a non-zip feed entry"))
(define stem (substring url 0 (- (string-length url) 4)))
(values (string-append stem ".msi")
(string-append stem ".msi.sha256")))

(define (fetch-sidecar-sha256! url)
;; sha256sum format: "<hex> <filename>"; the first token is the digest.
(define in (get-pure-port (string->url url)
'("User-Agent: Taskly-Updater/1")
#:redirections 10))
(dynamic-wind
void
(lambda ()
(define digest
(let ([body (string-trim (port->string in))])
(if (string=? body "") "" (first (string-split body)))))
(unless (regexp-match? #px"^[0-9a-fA-F]{64}$" digest)
(error 'start-download "msi checksum sidecar has an unexpected shape"))
(string-downcase digest))
(lambda () (close-input-port in))))

;; MSI downloads come from the same CDN without a signed manifest, so the
;; response head is the only total available: open an impure port, parse
;; Content-Length for the progress percent (0 → indeterminate), and hand the
;; body port to the same copy loop. Connection: close keeps the copy ending
;; at EOF, exactly like the zip flow.
(define (open-download-with-total! url)
;; Shared by the msi and appimage flows: parse the response head so the
;; progress percent can use Content-Length (0 → indeterminate), then hand
;; the body port to the copy loop. Connection: close makes the copy end at
;; EOF like the manifest-verified zip flow. Redirections MUST follow:
;; release asset URLs 302 into the CDN, and without redirections this
;; downloads the empty 302 body — a 0-byte "installer" (the 1620
;; msiexec failure of 0.2.1's first Windows OTA; the same trap rivet#153
;; fixed for the zip flow).
(define in (get-impure-port (string->url url)
'("User-Agent: Taskly-Updater/1"
"Connection: close")
#:redirections 10))
(let loop ([total 0])
(define line (read-line in 'return-linefeed))
(cond
[(eof-object? line) (values in 0)]
[(non-empty-string? (string-trim line))
(define match
(regexp-match #px"(?i:^content-length:\\s*(\\d+))" line))
(loop (if match (string->number (second match)) total))]
[else (values in total)])))

(define (download-msi-with-progress! config candidate destination)
(define-values (msi-url sidecar-url) (msi-artifact-urls candidate))
(define expected (fetch-sidecar-sha256! sidecar-url))
(make-parent-directory* destination)
(define temporary (path-add-extension destination #".partial"))
(when (file-exists? temporary) (delete-file temporary))
(with-handlers ([exn:fail?
(lambda (e)
(when (file-exists? temporary) (delete-file temporary))
(raise e))])
(define-values (in total) (open-download-with-total! msi-url))
(dynamic-wind
void
(lambda ()
(call-with-output-file temporary
#:exists 'truncate/replace
#:mode 'binary
(lambda (out) (copy-with-progress! in out total))))
(lambda () (close-input-port in)))
(define actual (string-downcase (sha256-file/hex temporary)))
(unless (string=? actual expected)
(raise-arguments-error 'start-download
"downloaded msi does not match its checksum sidecar"
"expected" expected
"actual" actual))
(rename-file-or-directory temporary destination #t)
destination))

;; Runs on a backend worker thread; the host follows progress via the
;; update-state RPC. Never raises: failures surface through the phase.
(define (start-download! data-dir)
Expand Down Expand Up @@ -551,10 +470,8 @@
(exn-message e))))])
(define path
(cond
[(msi-flavor?)
(download-msi-with-progress! config candidate destination)]
[(appimage-flavor?)
(download-appimage-with-progress! candidate)]
(download-appimage-with-progress! config candidate)]
[else
(download-with-progress! config candidate destination)]))
(state-set! 'phase "downloaded")
Expand Down
38 changes: 38 additions & 0 deletions racket/tests/updater-test.rkt
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@
verify-signed-manifest
update-manifest
update-artifact
update-artifact-installer
update-candidate
update-candidate-artifact
updater-config
select-update
fetch-update-manifest
update-manifest-version
update-manifest-artifacts
Expand Down Expand Up @@ -146,6 +150,40 @@
(delete-file priv-path)
(delete-file artifact-file))

(test-case "signed manifest selects the requested installable artifact"
(define manifest
(update-manifest
app-identifier "9.9.9" 7 'stable
"2026-10-09T00:00:00Z" "0.0.0" #f #t 100
(list
(update-artifact 'windows 'x64
"https://dl.example/taskly/taskly-9.9.9-windows-x64.zip"
(make-string 64 #\a) 10 'zip '() #f)
(update-artifact 'windows 'x64
"https://dl.example/taskly/taskly-9.9.9-windows-x64.msi"
(make-string 64 #\b) 20 'msi '() #f)
(update-artifact 'linux 'x64
"https://dl.example/taskly/taskly-9.9.9-linux-x64.tar.gz"
(make-string 64 #\c) 30 'targz '() #f)
(update-artifact 'linux 'x64
"https://dl.example/taskly/taskly-9.9.9-linux-x86_64.AppImage"
(make-string 64 #\d) 40 'appimage '() #f))))
(define common
(updater-config app-identifier "0.0.1" 'stable
'windows 'x64 #f #f 0 (* 1024 1024) #f))
(check-equal?
(update-artifact-installer
(update-candidate-artifact
(select-update common manifest #:install-kind 'msi)))
'msi)
(check-equal?
(update-artifact-installer
(update-candidate-artifact
(select-update (struct-copy updater-config common [platform 'linux])
manifest
#:install-kind 'appimage)))
'appimage))

(test-case "embedded release identity is coherent"
(check-equal? app-identifier "app.taskly.Taskly")
(check-equal? app-channel 'stable)
Expand Down
21 changes: 16 additions & 5 deletions scripts/make-update-manifest.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
# <dist-dir> directory containing the release artifacts, i.e. the names
# the release pipeline produces:
# taskly-<ver>-macos-arm64.zip taskly-<ver>-macos-x64.zip
# taskly-<ver>-windows-x64.zip taskly-<ver>-linux-x64.tar.gz
# taskly-<ver>-linux-arm64.tar.gz
# taskly-<ver>-windows-x64.zip taskly-<ver>-windows-x64.msi
# taskly-<ver>-linux-x64.tar.gz taskly-<ver>-linux-arm64.tar.gz
# taskly-<ver>-linux-x86_64.AppImage
# taskly-<ver>-linux-aarch64.AppImage
# <key-path> Ed25519 private key PEM (default: $UPDATE_KEY_PATH, then
# ~/.taskly/update-signing-key.pem)
#
Expand All @@ -31,8 +33,11 @@ VERSION="${TAG#v}"
for artifact in "$DIST/taskly-$VERSION-macos-arm64.zip" \
"$DIST/taskly-$VERSION-macos-x64.zip" \
"$DIST/taskly-$VERSION-windows-x64.zip" \
"$DIST/taskly-$VERSION-windows-x64.msi" \
"$DIST/taskly-$VERSION-linux-x64.tar.gz" \
"$DIST/taskly-$VERSION-linux-arm64.tar.gz"; do
"$DIST/taskly-$VERSION-linux-arm64.tar.gz" \
"$DIST/taskly-$VERSION-linux-x86_64.AppImage" \
"$DIST/taskly-$VERSION-linux-aarch64.AppImage"; do
[[ -f "$artifact" ]] || { echo "error: missing $artifact" >&2; exit 1; }
done
[[ -f "$KEY" ]] || { echo "error: missing signing key $KEY (see scripts/update-keys.sh)" >&2; exit 1; }
Expand Down Expand Up @@ -114,10 +119,16 @@ cat > "$SCRIPT" <<RKT
(format "taskly-~a-macos-x64.zip" version) 'zip)
(artifact 'windows 'x64
(format "taskly-~a-windows-x64.zip" version) 'zip)
(artifact 'windows 'x64
(format "taskly-~a-windows-x64.msi" version) 'msi)
(artifact 'linux 'x64
(format "taskly-~a-linux-x64.tar.gz" version) 'targz)
(artifact 'linux 'x64
(format "taskly-~a-linux-x86_64.AppImage" version) 'appimage)
(artifact 'linux 'arm64
(format "taskly-~a-linux-arm64.tar.gz" version) 'targz)
(artifact 'linux 'arm64
(format "taskly-~a-linux-arm64.tar.gz" version) 'targz))))
(format "taskly-~a-linux-aarch64.AppImage" version) 'appimage))))

;; write-signed-manifest validates the struct against the manifest schema
;; before signing, so a malformed manifest fails the release instead of
Expand All @@ -130,7 +141,7 @@ cat > "$SCRIPT" <<RKT
key-id
out)
(newline out)))
(printf "manifest: ~a (5 artifacts, key-id ~a)\\n"
(printf "manifest: ~a (8 artifacts, key-id ~a)\\n"
(build-path dist "update-manifest.json") key-id)
RKT

Expand Down
Loading
Loading