diff --git a/README.md b/README.md index 7213026..375f4fa 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/README.zh-CN.md b/README.zh-CN.md index a4ae0cf..a11f5ac 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -43,7 +43,7 @@ Taskly 构建在 [Rivet](https://github.com/turinglambdaai/rivet) 之上:全 | Linux x64 | `taskly--linux-x64.tar.gz` | `taskly--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` 即可。 diff --git a/racket/taskly/updater.rkt b/racket/taskly/updater.rkt index 0657a8c..fc596c5 100644 --- a/racket/taskly/updater.rkt +++ b/racket/taskly/updater.rkt @@ -74,6 +74,7 @@ set-update-candidate! update-worker-thread perform-check! + update-install-kind start-download! rollout-bucket) @@ -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) @@ -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"))) @@ -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") @@ -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 () @@ -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: " "; 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) @@ -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") diff --git a/racket/tests/updater-test.rkt b/racket/tests/updater-test.rkt index fb86cea..1413c7f 100644 --- a/racket/tests/updater-test.rkt +++ b/racket/tests/updater-test.rkt @@ -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 @@ -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) diff --git a/scripts/make-update-manifest.sh b/scripts/make-update-manifest.sh index 5ddeb20..b11dce2 100755 --- a/scripts/make-update-manifest.sh +++ b/scripts/make-update-manifest.sh @@ -7,8 +7,10 @@ # directory containing the release artifacts, i.e. the names # the release pipeline produces: # taskly--macos-arm64.zip taskly--macos-x64.zip -# taskly--windows-x64.zip taskly--linux-x64.tar.gz -# taskly--linux-arm64.tar.gz +# taskly--windows-x64.zip taskly--windows-x64.msi +# taskly--linux-x64.tar.gz taskly--linux-arm64.tar.gz +# taskly--linux-x86_64.AppImage +# taskly--linux-aarch64.AppImage # Ed25519 private key PEM (default: $UPDATE_KEY_PATH, then # ~/.taskly/update-signing-key.pem) # @@ -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; } @@ -114,10 +119,16 @@ cat > "$SCRIPT" < "$SCRIPT" <", "size": 12345678, "installer": "zip", "arguments": [] }, { "platform": "macos", "architecture": "x64", "…": "…" }, - { "platform": "windows", "architecture": "x64", "…": "…" }, - { "platform": "linux", "architecture": "x64", "…": "…" } + { "platform": "windows", "architecture": "x64", "installer": "zip", "…": "…" }, + { "platform": "windows", "architecture": "x64", "installer": "msi", "…": "…" }, + { "platform": "linux", "architecture": "x64", "installer": "targz", "…": "…" }, + { "platform": "linux", "architecture": "x64", "installer": "appimage", "…": "…" } ] } ```