From e45c56fd185a466d588f087b2e0c97023306701e Mon Sep 17 00:00:00 2001 From: pbean Date: Thu, 18 Jun 2026 14:16:34 -0700 Subject: [PATCH] feat: add auto-updater with signed GitHub release artifacts Integrate Tauri updater + process plugins so the app can check for, download, and install signed updates from GitHub releases. - Frontend: src/features/updates/ (store, checkForUpdates, UpdateBanner component + tests) - Wire UpdateBanner into App.tsx - Add @tauri-apps/plugin-updater and plugin-process - Configure updater pubkey + GitHub releases endpoint in tauri.conf.json - Enable createUpdaterArtifacts in bundle config - Grant updater/process capabilities in default.json - Register plugins in lib.rs - CI: produce and sign updater artifacts in release.yml - Docs: installation + CONTRIBUTING + CHANGELOG updates Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 35 +- CHANGELOG.md | 11 +- CONTRIBUTING.md | 35 ++ docs/installation.md | 34 +- package-lock.json | 25 +- package.json | 2 + src-tauri/Cargo.lock | 331 ++++++++++++++++++ src-tauri/Cargo.toml | 4 + src-tauri/capabilities/default.json | 2 + src-tauri/src/lib.rs | 5 + src-tauri/tauri.conf.json | 9 + src/App.tsx | 7 + src/features/updates/checkForUpdates.ts | 51 +++ .../updates/components/UpdateBanner.test.tsx | 66 ++++ .../updates/components/UpdateBanner.tsx | 110 ++++++ src/features/updates/store.test.ts | 45 +++ src/features/updates/store.ts | 54 +++ src/test-utils/setup.ts | 2 + 18 files changed, 822 insertions(+), 6 deletions(-) create mode 100644 src/features/updates/checkForUpdates.ts create mode 100644 src/features/updates/components/UpdateBanner.test.tsx create mode 100644 src/features/updates/components/UpdateBanner.tsx create mode 100644 src/features/updates/store.test.ts create mode 100644 src/features/updates/store.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b1e9a88..be23d41 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -88,10 +88,43 @@ jobs: uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Updater artifact signing (NOT OS code-signing). Signs each bundle and + # assembles latest.json so the in-app auto-updater trusts the release. + # Create ONE repo secret: TAURI_SIGNING_PRIVATE_KEY = contents of the + # private key from `tauri signer generate`. The key has no passphrase, so + # do NOT create the PASSWORD secret (GitHub rejects empty secret values) — + # the reference below resolves to an empty string when the secret is + # absent, which is exactly "no password". Without the key the build still + # runs but produces no .sig/latest.json and auto-update will not work. + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + # + # --- OS code-signing (intentionally OFF; unsigned v1) ---------------- + # Releases are unsigned today; users follow the "open anyway" flow (see + # docs/installation.md). To enable later, add the secrets below — no + # other workflow change is needed (Tauri auto-signs when they exist): + # + # macOS (Developer ID, $99/yr — Tauri auto-signs AND notarizes): + # APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + # APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + # APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + # APPLE_ID: ${{ secrets.APPLE_ID }} + # APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} # app-specific pw + # APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + # + # Windows (Azure Trusted Signing — also add a bundle.windows.signCommand + # using trusted-signing-cli in tauri.conf.json): + # AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + # AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + # AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} with: tagName: ${{ github.event_name == 'workflow_dispatch' && inputs.releaseTag || github.ref_name }} releaseName: Notey ${{ github.event_name == 'workflow_dispatch' && inputs.releaseTag || github.ref_name }} releaseBody: 'See the assets below to download and install this version.' - releaseDraft: true # artifacts are unsigned (v1) — a human publishes after review + # Draft so a human reviews unsigned artifacts before publishing. NOTE: + # the updater endpoint (releases/latest/download/latest.json) only + # resolves once the draft is PUBLISHED and marked "latest" — auto-update + # goes live at publish time, not at build time. + releaseDraft: true prerelease: false args: ${{ matrix.args }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 67c52da..9e34107 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -_Nothing yet._ +### Added +- In-app auto-updater: on startup Notey checks the GitHub Releases endpoint and + shows a banner to install the new signed build and restart + (`tauri-plugin-updater`). + +### Changed +- Release workflow now signs updater artifacts and emits `latest.json`; it is also + wired (commented) for later macOS/Windows OS code-signing. Release builds remain + unsigned for now — see [docs/installation.md](docs/installation.md) for the + per-OS "open anyway" steps. ## [0.1.0] - 2026-06-17 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2c2c9a8..f0136ad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,6 +46,41 @@ npx tauri build --debug --no-bundle # debug binary at src-tauri/target/debug/not npx tauri build # release bundles for your platform ``` +## Releasing + +Releases are built by `.github/workflows/release.yml`, which runs +`tauri-apps/tauri-action` across five targets (macOS arm64/x64, Linux x64/arm64, +Windows x64) and uploads the bundles to a **draft** GitHub Release. + +One-time setup (maintainers): create **one** repo secret under **Settings → +Secrets and variables → Actions** so the in-app updater artifacts get signed — +`TAURI_SIGNING_PRIVATE_KEY` (contents of the private key from +`npm run tauri signer generate`). The key has no passphrase, so do **not** create +a `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` secret — GitHub rejects empty secret +values, and the workflow already resolves the missing reference to an empty +string (i.e. "no password"). The matching public key lives in +`src-tauri/tauri.conf.json` under `plugins.updater.pubkey`. + +To cut a release: + +1. Bump the version in **all three** files, kept in lockstep: `package.json`, + `src-tauri/Cargo.toml`, and `src-tauri/tauri.conf.json`. +2. Update `CHANGELOG.md` (move **Unreleased** items under the new version). +3. Tag and push: + + ```sh + git tag vX.Y.Z + git push origin vX.Y.Z # triggers the Release workflow + ``` + +4. When the workflow finishes, review the **draft** release and **publish** it + (mark it as "latest"). Publishing is what makes the updater endpoint + (`releases/latest/download/latest.json`) resolve — **in-app auto-update only + goes live once the draft is published.** + +Release artifacts are currently unsigned (OS code-signing is wired but disabled; +see the comment block in `release.yml` to enable Apple/Azure signing later). + ## Testing ```sh diff --git a/docs/installation.md b/docs/installation.md index 76ec1d2..1885c1f 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -16,8 +16,38 @@ Download the artifact for your platform and install it the usual way for your OS (open the `.dmg` on macOS, the `.AppImage`/`.deb` on Linux, or the installer on Windows). -> Release artifacts are currently unsigned. Your OS may warn you on first launch; -> allow the app to run via your platform's standard "open anyway" flow. +### Opening an unsigned build + +Release artifacts are currently **unsigned**, so your OS warns you the first time +you launch. This is expected — follow your platform's "open anyway" flow once and +the app runs normally thereafter: + +- **macOS** — Gatekeeper shows *"Notey can't be opened because it is from an + unidentified developer."* Either **right-click (or Control-click) the app → + Open → Open**, or clear the quarantine flag from a terminal: + + ```sh + xattr -dr com.apple.quarantine /Applications/Notey.app + ``` + +- **Windows** — SmartScreen shows *"Windows protected your PC."* Click + **More info → Run anyway**. + +- **Linux** — make the AppImage executable, then run it: + + ```sh + chmod +x Notey_*.AppImage + ./Notey_*.AppImage + ``` + + Or install the Debian package: `sudo dpkg -i Notey_*.deb` (or + `sudo apt install ./Notey_*.deb` to pull in dependencies). + +### Updates + +Once installed, Notey checks for new releases on startup and shows an **in-app +banner** offering to install and restart when a newer version is published — no +manual re-download needed. ## First launch diff --git a/package-lock.json b/package-lock.json index 3936029..3425ccd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { - "name": "tauri-app", + "name": "notey", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "tauri-app", + "name": "notey", "version": "0.1.0", + "license": "MIT", "dependencies": { "@base-ui/react": "^1.3.0", "@codemirror/commands": "^6.8.1", @@ -20,6 +21,8 @@ "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-global-shortcut": "^2.3.1", "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-process": "^2.3.1", + "@tauri-apps/plugin-updater": "^2.10.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -3356,6 +3359,24 @@ "@tauri-apps/api": "^2.11.0" } }, + "node_modules/@tauri-apps/plugin-process": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz", + "integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@tauri-apps/plugin-updater": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.1.tgz", + "integrity": "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.10.1" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/package.json b/package.json index b827a25..8a153e5 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,8 @@ "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-global-shortcut": "^2.3.1", "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-process": "^2.3.1", + "@tauri-apps/plugin-updater": "^2.10.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 03cebc2..aa39a22 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -53,6 +53,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -704,6 +713,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -1060,6 +1080,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1700,6 +1730,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -2253,6 +2298,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2352,6 +2403,8 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-global-shortcut", "tauri-plugin-opener", + "tauri-plugin-process", + "tauri-plugin-updater", "tauri-specta", "tempfile", "thiserror 1.0.69", @@ -2535,6 +2588,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2610,6 +2675,12 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -2626,6 +2697,20 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "pango" version = "0.18.3" @@ -3272,15 +3357,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3316,6 +3406,20 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rusqlite" version = "0.34.0" @@ -3368,6 +3472,79 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3383,6 +3560,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3440,6 +3626,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.24.0" @@ -3883,6 +4092,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -4000,6 +4215,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -4230,6 +4456,49 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-process" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" +dependencies = [ + "tauri", + "tauri-plugin", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" +dependencies = [ + "base64 0.22.1", + "dirs 6.0.0", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + [[package]] name = "tauri-runtime" version = "2.11.2" @@ -4492,6 +4761,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4811,6 +5090,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -5115,6 +5400,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -5360,6 +5654,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -5870,6 +6173,16 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "xkeysym" version = "0.2.1" @@ -6001,6 +6314,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.4" @@ -6034,6 +6353,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.13.1", + "memchr", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 92f8ae0..c4e5205 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -32,6 +32,10 @@ tauri-plugin-opener = "2" tauri-plugin-global-shortcut = "2" tauri-plugin-dialog = "2" tauri-plugin-autostart = "2" +# >= 2.10 so latest.json supports the {os}-{arch}-{installer} keys (multiple +# installer formats per platform). Drives the in-app auto-updater. +tauri-plugin-updater = "2.10" +tauri-plugin-process = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" rusqlite = { version = "0.34", features = ["bundled", "modern_sqlite"] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 16c61eb..efead3e 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -16,6 +16,8 @@ "autostart:allow-enable", "autostart:allow-disable", "autostart:allow-is-enabled", + "updater:default", + "process:allow-restart", "allow-set-autostart", "allow-get-autostart", "allow-create-note", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e93b18f..0bd55e0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -167,6 +167,11 @@ pub fn run() { tauri_plugin_autostart::MacosLauncher::LaunchAgent, Some(vec![]), )) + // In-app auto-updater: checks the GitHub Releases `latest.json` endpoint + // (configured in tauri.conf.json) and installs signed update artifacts. + // `process` provides `relaunch()` so the app restarts onto the new build. + .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_process::init()) .invoke_handler(builder.invoke_handler()) .setup(move |app| { // Register typed tauri-specta events (e.g. `note-created`) into the diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 240aaaa..91be4fb 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -30,6 +30,7 @@ "bundle": { "active": true, "targets": "all", + "createUpdaterArtifacts": true, "icon": [ "icons/32x32.png", "icons/128x128.png", @@ -37,5 +38,13 @@ "icons/icon.icns", "icons/icon.ico" ] + }, + "plugins": { + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhDRjg0MjcyOENERjE3MjYKUldRbUY5K01ja0w0alBOdzFrWm01ZVQrRnBXSk1HeGtmZ3lacE9vTmZkNnBPSzBjOStWZnNBMlgK", + "endpoints": [ + "https://github.com/pbean/notey/releases/latest/download/latest.json" + ] + } } } diff --git a/src/App.tsx b/src/App.tsx index 3aeaca7..f69a0d5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,8 @@ import { restoreSession, startSessionAutoSave } from './features/session/persist import { startNoteCreatedSync } from './features/note-list/realtimeSync'; import { initOnboarding } from './features/onboarding/bootstrap'; import { startHotkeyUnavailableNotice } from './features/hotkey/unavailableNotice'; +import { checkForUpdates } from './features/updates/checkForUpdates'; +import { UpdateBanner } from './features/updates/components/UpdateBanner'; /** Application root — renders the main CaptureWindow and the toast overlay. */ function App() { @@ -25,6 +27,10 @@ function App() { // Best-effort and independent of the chain below. void startHotkeyUnavailableNotice(); + // Probe for a newer release in the background. No-op outside Tauri and fully + // best-effort — surfaces the UpdateBanner only when an install is available. + void checkForUpdates(); + // Attempt workspace init first, then restore the saved session. Auto-save // still starts if either step fails so session persistence keeps working. void (async () => { @@ -75,6 +81,7 @@ function App() { return ( <> + diff --git a/src/features/updates/checkForUpdates.ts b/src/features/updates/checkForUpdates.ts new file mode 100644 index 0000000..7504502 --- /dev/null +++ b/src/features/updates/checkForUpdates.ts @@ -0,0 +1,51 @@ +import { check } from "@tauri-apps/plugin-updater"; +import { useUpdateStore } from "./store"; + +/** + * True when running inside the Tauri webview (as opposed to jsdom unit tests or + * a plain browser). The updater plugin's `check()` reaches the OS over IPC, so + * the startup probe must be skipped anywhere `__TAURI_INTERNALS__` is absent. + */ +function isTauri(): boolean { + return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; +} + +/** + * Guards against re-running the one-shot startup check. React StrictMode mounts + * the root effect twice in development; the update probe should fire at most + * once per session. + */ +let started = false; + +/** + * Probe the configured GitHub Releases `latest.json` endpoint on startup and, + * when a newer signed build exists, record it so {@link UpdateBanner} offers an + * in-app install. No-op outside Tauri. + * + * Best-effort and idempotent: a network error, a missing/draft release (404), + * or "already up to date" must never block startup or surface an error — the + * banner only appears when there is genuinely something to install. The check + * is deliberately not awaited by the caller. + */ +export async function checkForUpdates(): Promise { + if (started || !isTauri()) return; + started = true; + + try { + const update = await check(); + if (update?.available) { + useUpdateStore.getState().setAvailable(update); + } + } catch (e) { + // Swallow: no endpoint yet, offline, or up to date — none are user-facing. + console.warn("update check failed:", e); + } +} + +/** + * Reset the one-shot guard. Test-only — production code calls + * {@link checkForUpdates} exactly once at startup. + */ +export function resetUpdateCheck(): void { + started = false; +} diff --git a/src/features/updates/components/UpdateBanner.test.tsx b/src/features/updates/components/UpdateBanner.test.tsx new file mode 100644 index 0000000..435f78b --- /dev/null +++ b/src/features/updates/components/UpdateBanner.test.tsx @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import type { Update } from "@tauri-apps/plugin-updater"; +import { UpdateBanner } from "./UpdateBanner"; +import { useUpdateStore } from "../store"; + +const relaunch = vi.fn(); +vi.mock("@tauri-apps/plugin-process", () => ({ + relaunch: () => relaunch(), +})); + +/** Build a fake Update whose downloadAndInstall is controllable per test. */ +function fakeUpdate(downloadAndInstall = vi.fn().mockResolvedValue(undefined)): Update { + return { available: true, version: "0.2.0", downloadAndInstall } as unknown as Update; +} + +beforeEach(() => { + relaunch.mockReset().mockResolvedValue(undefined); +}); + +describe("UpdateBanner", () => { + it("renders nothing while idle", () => { + render(); + expect(screen.queryByTestId("update-banner")).toBeNull(); + }); + + it("shows the available version once an update is recorded", () => { + useUpdateStore.getState().setAvailable(fakeUpdate()); + render(); + expect(screen.getByTestId("update-banner")).toHaveTextContent("Notey 0.2.0 is available"); + }); + + it("installs then relaunches when the user confirms", async () => { + const downloadAndInstall = vi.fn().mockResolvedValue(undefined); + useUpdateStore.getState().setAvailable(fakeUpdate(downloadAndInstall)); + render(); + + fireEvent.click(screen.getByTestId("update-install")); + + await waitFor(() => expect(downloadAndInstall).toHaveBeenCalledOnce()); + await waitFor(() => expect(relaunch).toHaveBeenCalledOnce()); + }); + + it("surfaces an inline error and offers Retry when install fails", async () => { + const downloadAndInstall = vi.fn().mockRejectedValue(new Error("network down")); + useUpdateStore.getState().setAvailable(fakeUpdate(downloadAndInstall)); + render(); + + fireEvent.click(screen.getByTestId("update-install")); + + await waitFor(() => + expect(screen.getByTestId("update-banner")).toHaveTextContent("Update failed: network down"), + ); + expect(relaunch).not.toHaveBeenCalled(); + expect(screen.getByTestId("update-install")).toHaveTextContent("Retry"); + }); + + it("dismiss hides the banner", () => { + useUpdateStore.getState().setAvailable(fakeUpdate()); + render(); + + fireEvent.click(screen.getByTestId("update-dismiss")); + + expect(screen.queryByTestId("update-banner")).toBeNull(); + }); +}); diff --git a/src/features/updates/components/UpdateBanner.tsx b/src/features/updates/components/UpdateBanner.tsx new file mode 100644 index 0000000..03c872d --- /dev/null +++ b/src/features/updates/components/UpdateBanner.tsx @@ -0,0 +1,110 @@ +import { relaunch } from "@tauri-apps/plugin-process"; +import { useUpdateStore } from "../store"; + +/** + * Top-of-window banner offering to install a pending update. Renders nothing + * until {@link checkForUpdates} finds an available build, so it is inert in the + * common case. Non-modal: the user can dismiss it and keep capturing notes. + * + * "Install & restart" downloads + applies the signed artifact, then relaunches + * onto the new build via `@tauri-apps/plugin-process`. A failure is shown inline + * and leaves the app running on the current version. + */ +export function UpdateBanner() { + const update = useUpdateStore((s) => s.update); + const version = useUpdateStore((s) => s.version); + const phase = useUpdateStore((s) => s.phase); + const error = useUpdateStore((s) => s.error); + const setInstalling = useUpdateStore((s) => s.setInstalling); + const setError = useUpdateStore((s) => s.setError); + const dismiss = useUpdateStore((s) => s.dismiss); + + if (phase === "idle" || !update) return null; + + const installing = phase === "installing"; + + async function install() { + if (!update) return; + setInstalling(); + try { + await update.downloadAndInstall(); + // Restarts the app onto the freshly installed version; nothing after this + // line runs in the current process. + await relaunch(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + } + + return ( +
+ + {phase === "error" + ? `Update failed: ${error}` + : installing + ? `Installing Notey ${version}…` + : `Notey ${version} is available.`} + + + +
+ ); +} diff --git a/src/features/updates/store.test.ts b/src/features/updates/store.test.ts new file mode 100644 index 0000000..8d017a3 --- /dev/null +++ b/src/features/updates/store.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from "vitest"; +import type { Update } from "@tauri-apps/plugin-updater"; +import { useUpdateStore } from "./store"; + +/** Minimal stand-in for the plugin's Update handle (only fields the store reads). */ +function fakeUpdate(version: string): Update { + return { available: true, version } as unknown as Update; +} + +describe("useUpdateStore", () => { + it("starts idle with no update", () => { + const s = useUpdateStore.getState(); + expect(s.phase).toBe("idle"); + expect(s.update).toBeNull(); + expect(s.version).toBeNull(); + }); + + it("setAvailable records the update and mirrors its version", () => { + useUpdateStore.getState().setAvailable(fakeUpdate("0.2.0")); + const s = useUpdateStore.getState(); + expect(s.phase).toBe("available"); + expect(s.version).toBe("0.2.0"); + expect(s.update).not.toBeNull(); + }); + + it("setInstalling and setError transition phase, error clears on install", () => { + useUpdateStore.getState().setAvailable(fakeUpdate("0.2.0")); + useUpdateStore.getState().setError("boom"); + expect(useUpdateStore.getState().phase).toBe("error"); + expect(useUpdateStore.getState().error).toBe("boom"); + + useUpdateStore.getState().setInstalling(); + expect(useUpdateStore.getState().phase).toBe("installing"); + expect(useUpdateStore.getState().error).toBeNull(); + }); + + it("dismiss resets back to idle", () => { + useUpdateStore.getState().setAvailable(fakeUpdate("0.2.0")); + useUpdateStore.getState().dismiss(); + const s = useUpdateStore.getState(); + expect(s.phase).toBe("idle"); + expect(s.update).toBeNull(); + expect(s.version).toBeNull(); + }); +}); diff --git a/src/features/updates/store.ts b/src/features/updates/store.ts new file mode 100644 index 0000000..d09748d --- /dev/null +++ b/src/features/updates/store.ts @@ -0,0 +1,54 @@ +import { create } from "zustand"; +import type { Update } from "@tauri-apps/plugin-updater"; + +/** + * Phase of the in-app update flow. `idle` covers both "no update" and "the user + * dismissed the banner"; the banner only renders for `available`/`installing`/`error`. + */ +export type UpdatePhase = "idle" | "available" | "installing" | "error"; + +interface UpdateState { + /** The pending update handle from `check()`, or null when none is available. */ + update: Update | null; + /** Target version string (e.g. "0.2.0") for display, mirrored from `update`. */ + version: string | null; + phase: UpdatePhase; + /** Human-readable error shown in the banner when install fails. */ + error: string | null; +} + +interface UpdateActions { + /** Record an available update so the banner offers to install it. */ + setAvailable: (update: Update) => void; + /** Enter the installing phase (download + apply in progress). */ + setInstalling: () => void; + /** Record an install failure with a user-facing message. */ + setError: (message: string) => void; + /** Dismiss the banner / clear update state back to idle. */ + dismiss: () => void; + /** Reset all state to initial values (test cleanup only). */ + reset: () => void; +} + +const initial: UpdateState = { + update: null, + version: null, + phase: "idle", + error: null, +}; + +/** Per-feature Zustand store backing the in-app auto-update banner. */ +export const useUpdateStore = create((set) => ({ + ...initial, + + setAvailable: (update) => + set({ update, version: update.version, phase: "available", error: null }), + + setInstalling: () => set({ phase: "installing", error: null }), + + setError: (message) => set({ phase: "error", error: message }), + + dismiss: () => set({ ...initial }), + + reset: () => set({ ...initial }), +})); diff --git a/src/test-utils/setup.ts b/src/test-utils/setup.ts index 7a62069..18061a1 100644 --- a/src/test-utils/setup.ts +++ b/src/test-utils/setup.ts @@ -10,6 +10,7 @@ import { useToastStore } from '../features/toast/store'; import { useTrashStore } from '../features/trash/store'; import { useSettingsStore } from '../features/settings/store'; import { useOnboardingStore } from '../features/onboarding/store'; +import { useUpdateStore } from '../features/updates/store'; import { resetToggleTracking } from '../features/command-palette/actions'; import { resetSingleflight } from '../lib/singleflight'; @@ -45,6 +46,7 @@ afterEach(() => { useTrashStore.getState().resetTrash(); useSettingsStore.getState().resetSettings(); useOnboardingStore.getState().reset(); + useUpdateStore.getState().reset(); // Clear shared in-flight (singleflight) dedup keys and the sticky per-session // theme/layout toggle markers (module-level, not a store)