diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d5a937..c6c4a53 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,6 +48,20 @@ jobs: - name: Assert the tag matches all five version files run: bash scripts/check-version-lockstep.sh "${GITHUB_REF_NAME#v}" + # X6 — an unsigned update artifact is worse than no release: it uploads + # fine, publishes fine, and then every installed client silently refuses + # it. Catch the missing secret here rather than an hour of build minutes + # later. Only presence is checked; the value never reaches the log. + - name: Assert the updater signing key is configured + env: + KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + run: | + set -euo pipefail + if [[ -z "${KEY}" ]]; then + echo "::error::TAURI_SIGNING_PRIVATE_KEY is not set. Update artifacts would ship unsigned and every client would reject them. Add the secret (see PLAN §8) and re-run." + exit 1 + fi + - name: Extract this version's CHANGELOG section for the release body id: notes run: | @@ -62,7 +76,10 @@ jobs: macOS users right-click the app and choose Open the first time; Windows users click "More info" → "Run anyway" on the SmartScreen warning. - StudyVis does not auto-update. Download a new version here when one drops.' + If you are already on StudyVis 1.5.0 or newer, you do not need to download + anything — the app picks this up on its own and offers to restart into it. + Auto-update reads `latest.json` from the LATEST published release, so this + draft has to be published before anyone receives it.' { echo "body< **This is the last version you install by hand.** From here on StudyVis +> updates itself. **Windows:** this release switched installer format — +> uninstall your existing StudyVis from Settings → Apps _before_ running the +> new `-setup.exe`, or Windows will list two copies. Your identity, friends, +> and history are untouched. **macOS:** after a self-update, macOS may ask +> for camera / microphone / screen-recording permission again (the app isn't +> notarized yet); granting it is safe. + +### Added + +- **StudyVis updates itself.** It checks for new releases in the + background, downloads them, and shows a "StudyVis X.Y.Z is ready" + banner with a Restart now button — the restart is a couple of seconds + because the download already happened. Nothing checks, downloads, or + interrupts during a session. Dismissing with "Later" leaves the update + waiting in Settings → About. + + Each update is signature-verified before it is installed, so a tampered + download is rejected. This does not require the code-signing + certificates StudyVis still lacks — the updater carries its own key. - **Remember window size and position** (Settings → Appearance → Window, on by default): the window reopens where you left it — size, position, @@ -28,6 +47,21 @@ shipped it.) there's no resize flash. Geometry saved on an unplugged monitor falls back to centering instead of opening off-screen. A **Reset** row returns the window to the default 1280 × 800, centered. + +### Changed + +- **Automatic updates are ON by default** (Settings → About). This widens + the privacy stance: previously the only outbound request beyond P2P was + an opt-in, OFF-by-default version check. The requests are still + anonymous fetches of a public file with no identifiers and no payload, + and turning the toggle off restores zero outbound. If you had + deliberately turned the old version check off, that choice carries over + and auto-update stays off. +- **The Windows installer is now `-setup.exe` (NSIS), not `.msi`.** + Applying an MSI update needs an administrator prompt every single time, + which defeats the point. **Upgrading from 1.4.0 or earlier: uninstall + the old StudyVis from Settings → Apps first**, then run the new + installer — otherwise Windows lists two copies. Your data is untouched. - Settings nav rework: the eleven categories are grouped (You / Study / App / System) with lucide icons and an accent edge on the active item; the rail is now fluid (`clamp(224px, 22vw, 280px)`) so narrow windows @@ -49,6 +83,13 @@ shipped it.) About pane's copyright line sits beside the version it belongs to; the two stats charts share one left plot edge. +### Known issue + +- **macOS may re-ask for camera / microphone / screen-recording + permission after an update.** macOS ties those grants to a signed app + identity, and StudyVis is not yet notarized. Granting again is safe. + A Developer ID certificate would remove this. + ## 1.4.0 — 2026-07-19 — multi-friend sessions, faster AI, and the verified backlog Everything merged since v1.3.1: a production-readiness audit, a settings diff --git a/CLAUDE.md b/CLAUDE.md index bcf69c5..76e58bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ Auto-loaded by Claude Code at the start of every session in this repo. It summarizes how to work here and points to the canonical docs. Read the relevant ones in full at the start of any non-trivial session — they are the source of truth, not training data. -StudyVis is **shipped and feature-complete**: a peer-to-peer desktop study app for friends (body-doubling video + optional on-device AI focus detection), released friends-only and unsigned for macOS + Windows. The current line is **v1.x** (see `CHANGELOG.md`). Work is now **maintenance and new features**, not a from-scratch build. There are real installed builds but no auto-update and no public users — friends pull releases manually. +StudyVis is **shipped and feature-complete**: a peer-to-peer desktop study app for friends (body-doubling video + optional on-device AI focus detection), released friends-only and unsigned for macOS + Windows. The current line is **v1.x** (see `CHANGELOG.md`). Work is now **maintenance and new features**, not a from-scratch build. There are real installed builds and no public users. As of **v1.5.0** the app self-updates in-app (tauri-plugin-updater, tag X6): background download + signature-verified restart, defaults ON — only the *first* install is manual. Builds remain unsigned for OS code-signing purposes, so friends still clear the Gatekeeper/SmartScreen warning on that first install. ## Canonical documents diff --git a/INSTALL.md b/INSTALL.md index 6940b43..cda0b51 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -2,7 +2,7 @@ StudyVis ships unsigned installers for a friends-only audience. Each OS will warn the first time you run the app — the steps below explain how to clear those warnings. After the first launch, the OS remembers your decision and stops asking. -> StudyVis does **not** auto-update. When a new version is available, download the latest installer from the [GitHub Releases page](https://github.com/scotej/studyvis/releases) and re-run the install steps for your OS. +> **You only have to do this once.** From v1.5.0 on, StudyVis updates itself — see [Updating](#updating) below. ## macOS (Apple Silicon) @@ -16,27 +16,44 @@ StudyVis ships unsigned installers for a friends-only audience. Each OS will war ## Windows 10 / 11 -1. From the [Releases page](https://github.com/scotej/studyvis/releases), download `StudyVis__x64_en-US.msi`. -2. Double-click the `.msi`. **SmartScreen** intercepts: _"Windows protected your PC"_. Click **More info**, then **Run anyway**. +1. From the [Releases page](https://github.com/scotej/studyvis/releases), download `StudyVis__x64-setup.exe`. +2. Double-click the installer. **SmartScreen** intercepts: _"Windows protected your PC"_. Click **More info**, then **Run anyway**. 3. Step through the installer (defaults are fine). StudyVis lands in your Start menu and Programs list. 4. The first time you join a session, Windows asks for camera and microphone permission via WebView2. Allow both. +> **Coming from StudyVis 1.4.0 or earlier?** Those shipped as an `.msi`. Uninstall the old StudyVis from Settings → Apps first, then run this installer — otherwise Windows lists two copies. Your identity, friends, and history are untouched by the uninstall; they live in your user data directory, not the program folder. + ## Linux Linux installers are not available yet. WebKitGTK's `getDisplayMedia` support was not validated during the V0 sanity check; Linux returns once that path is verified. If you want to try the development build today, clone the repo and run `npm run tauri dev`. ## Updating -StudyVis never updates itself, but it can tell you when an update -exists: Settings → About has an optional, off-by-default new-version -check that compares against the GitHub Releases page (an anonymous -lookup — no identifiers sent). Downloading and installing stays -manual. To upgrade: +StudyVis updates itself. It checks GitHub for new releases shortly after +launch and every few hours after that, downloads one in the background when +it finds it, and then shows a **"StudyVis X.Y.Z is ready"** banner with a +**Restart now** button. Clicking it takes a couple of seconds — the download +already happened. + +It will not interrupt you: no check, no download, and no banner while you are +in a session. Dismissing the banner with **Later** keeps the update waiting; +it stays available in Settings → About until you restart. + +Nothing about you is sent in any of this — the requests are anonymous +fetches of a public file. Each update is signature-checked before it is +installed, so a tampered download is rejected. To opt out entirely, turn +**Automatic updates** off in Settings → About; StudyVis then makes no +outbound requests at all beyond connecting you to friends. + +**If you ever need to install by hand** — you're on a build older than +v1.5.0, or an update failed: - **macOS:** download the new `.dmg` and drag StudyVis to Applications, replacing the existing app. -- **Windows:** download the new `.msi` and run it; it upgrades the existing install in place. +- **Windows:** download the new `-setup.exe` and run it; it upgrades the existing install in place. + +Your identity, friends list, and local session history live in your OS data directory — they are preserved across updates and reinstalls. -Your identity, friends list, and local session history live in your OS data directory — they are preserved across reinstalls. +> **macOS permission re-prompts.** Because the app is not yet signed with an Apple Developer ID, macOS may treat an updated StudyVis as a new app and ask for camera / microphone / screen-recording permission again after an update. Granting it again is safe; this goes away if the app is ever properly signed. ## Troubleshooting diff --git a/PLAN.md b/PLAN.md index 3d3244b..a832c97 100644 --- a/PLAN.md +++ b/PLAN.md @@ -24,7 +24,8 @@ Surfaced explicitly because the design implies a footprint the user should conse - **Network footprint**: a handful of long-lived WebSockets to public signaling infrastructure while idle (a few KB/hour): a small curated set of public Nostr relays, plus — since the dual-strategy line — a few public MQTT brokers raced as a second transport for the same inbox/presence/invite traffic (trystero-layer encrypted either way; see ARCHITECTURE §4). The inbox + presence rooms pin the full endpoint lists and stay open. During sessions: full-mesh WebRTC (peer-to-peer) for audio/video. Approximately 15% of network configurations require a TURN relay to connect; no public TURN ships today (the old free public endpoints are dead — see §7 and ARCHITECTURE §4), so those sessions can fail until the user adds their own TURN server in Settings → Network. - **Disk footprint**: app + design assets <50 MB. AI vision model GGUFs (V2+) range 1–8 GB depending on the user's choice. - **Camera, screen, microphone**: requested only when needed — camera + mic when joining a session, screen capture only after the user opts in to AI features (V2+). -- **Outbound data beyond P2P + Nostr signaling**: zero, with one explicit, opt-in carve-out — when the user enables the new-version check (OFF by default), the app makes an unauthenticated GET to the public GitHub Releases API to compare release tags. The request carries no identifiers, no query parameters, and no payload; failures are silent. No telemetry, no crash auto-uploads. Crash logs stay local with a manual "Share Log" button. +- **Outbound data beyond P2P + Nostr signaling**: zero, with one explicit carve-out — **auto-update** (X6, ON by default, Settings → About). While it is on, the app fetches `latest.json` from the public GitHub Releases page on launch and every 6 hours, and downloads the installer when a newer version exists. Every request is unauthenticated and carries no identifiers, no query parameters, and no payload; nothing about the user, their friends, or their sessions is transmitted, and background failures are silent. Turning the toggle off restores literal zero outbound — no check is scheduled and none is made. No telemetry, no crash auto-uploads. Crash logs stay local with a manual "Share Log" button. + - *This widened in v1.5.0.* Before it, the carve-out was an opt-in, OFF-by-default tag comparison the user had to visit Settings to trigger. The exchange is deliberate: friends installing by hand meant security fixes landed only when someone remembered to check. The privacy properties that mattered — no identifiers, no payload, user-disableable — are unchanged. ## 4. Principles @@ -62,7 +63,7 @@ A complete, polished video-study app for friends. Zero AI code present. The app - System tray + autostart-at-login (opt-in) so the user is reachable for invites. - Onboarding — welcome → permissions → identity setup (with BIP39 backup) → add first friend (or skip) → tutorial. - Settings — friends management, identity export/import, autostart toggle, PTT keybindings (fixed defaults; rebinding lands in V3), theme (dark / light / auto), notification preferences. -- Per-OS installers for the friends-only V1 audience: macOS + Windows only — Linux is deferred to V3 pending V0 re-run on Linux (WebKitGTK `getDisplayMedia` was the open question). macOS `.dmg` (Tauri ad-hoc signing only — friends right-click → Open the first time to bypass Gatekeeper); Windows `.msi` (unsigned — friends click through SmartScreen "Run anyway"). No auto-update — friends pull new releases manually from GitHub Releases. Apple notarization, Windows code-signing, and the in-app updater plugin are deferred to a later phase if and when signing credentials become available. +- Per-OS installers for the friends-only V1 audience: macOS + Windows only — Linux is deferred to V3 pending V0 re-run on Linux (WebKitGTK `getDisplayMedia` was the open question). macOS `.dmg` (Tauri ad-hoc signing only — friends right-click → Open the first time to bypass Gatekeeper); Windows `-setup.exe` (NSIS, unsigned — friends click through SmartScreen "Run anyway"). **Auto-update ships as of v1.5.0** (X6): only the *first* install is manual. Apple notarization and Windows code-signing remain deferred pending credentials — they govern the Gatekeeper/SmartScreen warnings on that first install, not update integrity, which rides on the updater's own minisign keypair. **Non-goals (V1):** - Any AI inference, model picker, model download, vision processing, focus scoring. @@ -160,7 +161,9 @@ These are not promises — they are scoped backlog items, parked until a named t 1. Re-run the V0 smoke test under current WebKitGTK — `getUserMedia` + `getDisplayMedia` + a trystero rendezvous between two machines. 2. If `getDisplayMedia` passes: add the libsecret / Secret-Service feature to `keyring` under `cfg(target_os = "linux")` (today `keyring` is gated to macOS + Windows only) and add an `.AppImage` job to `release.yml`. Confirm the battery fallback (`system_battery` already returns a safe `on_battery: false` default when UPower is absent). 3. If `getDisplayMedia` still fails: ship **AI-off Linux** rather than blocking the whole platform — body-doubling needs only camera + mic; screen capture is exclusively the AI loop's, so the no-AI study experience is fully available. -- **Signing / notarization / auto-update** — *trigger: a Developer ID or EV cert is acquired.* One credential-gated roadmap item, not three quick wins; auto-update can't be verified without signed artifacts. When certs land, re-enable in lockstep: re-add the `tauri-plugin-updater` dependency (removed in this line — it was dormant), set the updater pubkey + endpoints in `tauri.conf.json`, flip `includeUpdaterJson` on in `release.yml`, wire the signing secrets, and drop the right-click-to-Open / SmartScreen "Run anyway" language from `INSTALL.md`. The cheap half — the opt-in, OFF-by-default new-version notification (§3) — already shipped; auto-download rides on signing and stays deferred. +- **Signing / notarization** — *trigger: a Developer ID or EV cert is acquired.* What these buy is a clean **first install**: no right-click-to-Open on macOS, no SmartScreen "Run anyway" on Windows. When certs land, wire the `APPLE_*` / `WINDOWS_CERTIFICATE` secrets into `release.yml`, set `macOS.signingIdentity` + `hardenedRuntime` in `tauri.conf.json`, and drop that language from `INSTALL.md`. + - **This is *not* a prerequisite for auto-update, and a previous revision of this document was wrong to bundle them.** Tauri's updater has its own integrity chain: release artifacts are signed with a minisign keypair (`npx tauri signer generate`) and verified in-app against `plugins.updater.pubkey` before anything is unpacked. That is independent of OS code signing, so auto-update shipped in v1.5.0 (X6) on ad-hoc-signed builds. The private key lives outside the repo; CI reads it from `TAURI_SIGNING_PRIVATE_KEY`. + - **Known caveat while unsigned (macOS).** An ad-hoc signature has no stable identity, so the app's code hash changes with every build. macOS keys camera / microphone / screen-recording grants to that hash, which means an auto-update can silently drop TCC permissions and re-prompt on the next session. This is no worse than the manual reinstall it replaces, and a Developer ID would fix it properly. ## 9. Document map diff --git a/README.md b/README.md index df214a4..8a17d24 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,11 @@ A few honest disclosures, in the spirit of "no surprises": System Settings → Privacy & Security → Screen Recording; StudyVis can open that pane for you when needed. - **Zero outbound data beyond the above.** No telemetry, no crash - uploads, no analytics. One opt-in exception: the OFF-by-default - new-version check (Settings → About) makes an unauthenticated GET to - the public GitHub Releases API, carrying no identifiers. If something + uploads, no analytics. One exception: auto-update (Settings → About, + ON by default) fetches the release manifest from GitHub and downloads + new versions. Those requests are unauthenticated and carry no + identifiers and no payload — nothing about you, your friends, or your + sessions. Turn the toggle off for literal zero outbound. If something goes wrong, share the log file manually (Settings → Advanced → Open data folder). @@ -64,18 +66,24 @@ Applications. **Right-click** the app icon and choose **Open** the first time; macOS asks once, then remembers. The right-click is load-bearing — double-clicking will refuse. -**Windows 10 / 11** — download the `.msi` from +**Windows 10 / 11** — download `StudyVis__x64-setup.exe` from [Releases](https://github.com/scotej/studyvis/releases). Double-click to install. SmartScreen will warn ("Windows protected your PC") — click **More info** → **Run anyway**. StudyVis lands in your Start -menu. +menu. **Upgrading from v1.4.0 or earlier?** Those shipped as an +`.msi`; uninstall the old StudyVis from Settings → Apps first, or +Windows lists two copies. Your data is untouched. (This is a one-time +step — from v1.5.0 on, updates are automatic.) **Linux** — not in 1.0. WebKitGTK's `getDisplayMedia` support was not validated; Linux returns when the V0 sanity pass is re-run on it. If you want to try the dev build today, clone the repo and run `npm run tauri dev`. -There is no auto-update. Re-run the install for a new version. +StudyVis keeps itself up to date. It checks for new releases in the +background, downloads them, and offers a "Restart now" button when one is +ready — never during a session. Only your first install is manual. You can +turn this off in Settings → About. ## First run @@ -209,11 +217,15 @@ where you'd see it surface. that pass runs again. (PLAN §5, V3.) Includes the Linux keyring `sync-secret-service` feature and any Linux-side `identity_box_decrypt` hardening. -- **Signed installers + auto-update.** No code-signing credentials. - macOS notarization, Windows code-signing, and the - `tauri-plugin-updater` re-enable all wait for a Developer ID and an - EV cert. Today, friends right-click → Open on macOS and click - through SmartScreen on Windows. +- **Signed installers.** No code-signing credentials. macOS + notarization and Windows code-signing wait for a Developer ID and an + EV cert, so friends still right-click → Open on macOS and click + through SmartScreen on Windows _on the first install_. Auto-update + itself shipped in v1.5.0 — it has its own signature check and doesn't + need those certs. One consequence of staying unsigned on macOS: an + update can reset the camera / mic / screen-recording permissions, + because macOS ties them to a code identity an ad-hoc build doesn't + have. - **Sepia / high-contrast theme variants.** Dark + light + auto are the V3 set. Additional themes are a small-but-not-zero token-pair - contrast effort that didn't make 1.0. @@ -364,10 +376,11 @@ backup, custom keybindings, multi-monitor capture, light + auto themes, opt-in custom window chrome, the accessibility and reduced-motion pass, and the cohesion + copy pass). **v1.1.0** added the pairing QR redesign; **v1.2.x** brought a maintenance + feature -wave and more reliable pairing discovery; **v1.3.1** is the current -release (offline friend codes — see `CHANGELOG.md` for the full -history). The version number lives in (and must stay consistent -across): +wave and more reliable pairing discovery; **v1.3.1** brought offline +friend codes; **v1.4.0** added multi-friend sessions, faster AI, and +the verified backlog; **v1.5.0** is the current release, adding in-app +auto-update (see `CHANGELOG.md` for the full history). The version +number lives in (and must stay consistent across): - `package.json` — npm root - `package-lock.json` — npm lockfile (two spots: top-level + the diff --git a/package-lock.json b/package-lock.json index 084bb4e..8989853 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "studyvis", - "version": "1.4.0", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "studyvis", - "version": "1.4.0", + "version": "1.5.0", "dependencies": { "@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8", @@ -20,6 +20,7 @@ "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-store": "^2.4.3", + "@tauri-apps/plugin-updater": "^2.10.1", "@trystero-p2p/mqtt": "^0.24.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -6420,6 +6421,15 @@ "@tauri-apps/api": "^2.11.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 85ab77b..88f1672 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "studyvis", "private": true, - "version": "1.4.0", + "version": "1.5.0", "type": "module", "scripts": { "dev": "vite", @@ -35,6 +35,7 @@ "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-store": "^2.4.3", + "@tauri-apps/plugin-updater": "^2.10.1", "@trystero-p2p/mqtt": "^0.24.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 291ceee..91e8f38 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -57,6 +57,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" @@ -864,6 +873,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 = "2.1.1" @@ -1229,6 +1249,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" @@ -2159,6 +2189,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -2413,6 +2473,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" @@ -2719,6 +2785,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.1", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2800,6 +2878,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" @@ -2836,6 +2920,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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" @@ -3484,15 +3582,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", @@ -3619,6 +3722,18 @@ dependencies = [ "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 3.7.0", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -3629,6 +3744,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys 0.8.7", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework 3.7.0", + "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" @@ -3670,6 +3812,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" @@ -4035,6 +4186,22 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.3" @@ -4171,7 +4338,7 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "studyvis" -version = "1.4.0" +version = "1.5.0" dependencies = [ "base64 0.22.1", "battery", @@ -4198,6 +4365,7 @@ dependencies = [ "tauri-plugin-shell", "tauri-plugin-single-instance", "tauri-plugin-store", + "tauri-plugin-updater", ] [[package]] @@ -4289,7 +4457,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -4322,6 +4490,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" @@ -4346,7 +4525,7 @@ dependencies = [ "heck 0.5.0", "http", "image", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -4644,6 +4823,39 @@ dependencies = [ "tracing", ] +[[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 0.13.3", + "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.0" @@ -4654,7 +4866,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -4677,7 +4889,7 @@ checksum = "2cadb13dad0c681e1e0a2c49ae488f0e2906ded3d57e7a0017f4aaf46e387117" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -5586,6 +5798,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "1.0.7" @@ -6237,7 +6458,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -6301,6 +6522,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" @@ -6471,6 +6702,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.14.0", + "memchr", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c0d18aa..1a05e54 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "studyvis" -version = "1.4.0" +version = "1.5.0" description = "Peer-to-peer study app for friends" authors = ["StudyVis"] license = "" @@ -38,6 +38,7 @@ futures-util = "0.3" tauri-plugin-global-shortcut = "2" tauri-plugin-autostart = "2" tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } +tauri-plugin-updater = "2" battery = "0.7" [target.'cfg(target_os = "macos")'.dependencies] diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 00c07cc..c33d057 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,7 +1,7 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "main-window permissions (ARCHITECTURE.md §12: scoped to main). V3-P6 adds the four window-control IPC bindings (start-dragging / minimize / toggle-maximize / close) used by the opt-in custom titlebar; the set-size / center / unmaximize trio backs the Settings → Appearance → Window reset action. Gated to the main window so the AI dialog stays on least-privilege.", + "description": "main-window permissions (ARCHITECTURE.md §12: scoped to main). V3-P6 adds the four window-control IPC bindings (start-dragging / minimize / toggle-maximize / close) used by the opt-in custom titlebar; the set-size / center / unmaximize trio backs the Settings → Appearance → Window reset action. X6 adds updater:default — check/download/install are driven only from the main window's updater feature, so the AI dialog can't reach them. Gated to the main window so the AI dialog stays on least-privilege.", "windows": ["main"], "permissions": [ "core:default", @@ -9,6 +9,7 @@ "store:default", "dialog:default", "deep-link:default", + "updater:default", "core:window:allow-start-dragging", "core:window:allow-minimize", "core:window:allow-toggle-maximize", diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs index 41def69..bbf62ef 100644 --- a/src-tauri/src/commands/system.rs +++ b/src-tauri/src/commands/system.rs @@ -4,9 +4,10 @@ //! `AiFeaturesFlag`, `SessionActiveFlag`, `ShortcutBindings`) that `lib.rs` //! `manage()`s at setup and consults in the window/run-event handlers, plus //! assorted commands: autostart, global-shortcut rebinding, quit/relaunch, -//! text-file export, opening OS settings panes (macOS-only deep links), the -//! battery probe, and the opt-in GitHub release check. All flags use -//! `Ordering::Relaxed` deliberately — last-write-wins between the JS setters +//! text-file export, opening OS settings panes (macOS-only deep links), and +//! the battery probe. (X6 retired the hand-rolled GitHub tag check that used +//! to live here — tauri-plugin-updater owns update discovery now.) All flags +//! use `Ordering::Relaxed` deliberately — last-write-wins between the JS setters //! and the event handlers is fine, and no flag guards memory another thread //! publishes. //! @@ -360,50 +361,6 @@ pub fn system_open_releases(app: AppHandle) -> Result<(), String> .map_err(|e| e.to_string()) } -// X4 — opt-in version check, the one sanctioned outbound request beyond P2P + -// Nostr signaling (PLAN §3 carve-out). A bare unauthenticated GET of the -// public GitHub Releases API: no identifiers, no query params, and a static -// User-Agent only because GitHub rejects UA-less requests. Failures return -// Err for the frontend to silently ignore. The owner/repo pair is derived -// from RELEASES_URL so the two release-facing commands can't drift apart. -fn latest_release_api_url() -> String { - let repo = RELEASES_URL - .trim_start_matches("https://github.com/") - .trim_end_matches("/releases"); - format!("https://api.github.com/repos/{repo}/releases/latest") -} - -#[tauri::command] -pub async fn system_fetch_latest_version() -> Result { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .user_agent("studyvis") - .build() - .map_err(|e| format!("build http client: {e}"))?; - let resp = client - .get(latest_release_api_url()) - .send() - .await - .map_err(|e| format!("GET releases/latest: {e}"))?; - if !resp.status().is_success() { - return Err(format!( - "GitHub API returned HTTP {}", - resp.status().as_u16() - )); - } - let bytes = resp - .bytes() - .await - .map_err(|e| format!("read response: {e}"))?; - let body: serde_json::Value = - serde_json::from_slice(&bytes).map_err(|e| format!("parse response: {e}"))?; - let tag = body - .get("tag_name") - .and_then(|v| v.as_str()) - .ok_or("response missing tag_name")?; - Ok(tag.strip_prefix('v').unwrap_or(tag).to_string()) -} - // V2-P5 battery awareness for the AI sample loop. ARCHITECTURE.md §8: "if // user_on_battery and battery_pct < 20: pause AI". Returned shape matches // the `RawBattery` interface in `src/features/ai/battery.ts`. @@ -556,13 +513,14 @@ pub fn system_open_microphone_settings(app: AppHandle) -> Result< #[cfg(test)] mod tests { - use super::latest_release_api_url; + use super::RELEASES_URL; + // X6 replaced the X4 tag check with tauri-plugin-updater, whose endpoint + // is derived from this same repo in tauri.conf.json. Pin the constant so + // an edit here can't silently point the About card's "Releases" button at + // a different repo than the one the updater pulls from. #[test] - fn latest_release_api_url_derives_owner_repo_from_releases_url() { - assert_eq!( - latest_release_api_url(), - "https://api.github.com/repos/scotej/studyvis/releases/latest" - ); + fn releases_url_points_at_the_studyvis_repo() { + assert_eq!(RELEASES_URL, "https://github.com/scotej/studyvis/releases"); } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fbf7353..0590e00 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -55,12 +55,11 @@ use commands::sidecar::{ #[cfg(desktop)] use commands::system::{ app_quit, autostart_is_enabled, autostart_set_enabled, session_set_active, - system_ai_features_set_enabled, system_battery, system_fetch_latest_version, - system_minimize_to_tray_set_enabled, system_open_camera_settings, system_open_data_folder, - system_open_microphone_settings, system_open_notification_settings, system_open_releases, - system_open_screen_capture_settings, system_relaunch_app, system_set_global_shortcut, - system_write_text_file, AiFeaturesFlag, MinimizeToTrayFlag, QuitFlag, SessionActiveFlag, - ShortcutBindings, + system_ai_features_set_enabled, system_battery, system_minimize_to_tray_set_enabled, + system_open_camera_settings, system_open_data_folder, system_open_microphone_settings, + system_open_notification_settings, system_open_releases, system_open_screen_capture_settings, + system_relaunch_app, system_set_global_shortcut, system_write_text_file, AiFeaturesFlag, + MinimizeToTrayFlag, QuitFlag, SessionActiveFlag, ShortcutBindings, }; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -84,6 +83,19 @@ pub fn run() { .plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_opener::init()); + // X6 — auto-update. Registering the plugin does NOT itself make a + // request: every check is driven from JS (`features/updater`), which + // stays silent while the `auto_update_enabled` setting is off. That's + // what keeps PLAN §3's "zero unsolicited outbound" claim honest. + // + // Update integrity rides on the minisign keypair in + // `plugins.updater.pubkey` (tauri.conf.json), which is independent of + // Apple/Windows code signing — an ad-hoc-signed bundle still can't be + // handed a tampered update, because the payload signature is checked + // against the baked-in public key before anything is unpacked. + #[cfg(desktop)] + let builder = builder.plugin(tauri_plugin_updater::Builder::new().build()); + // `tauri::generate_handler!` accepts outer attributes on individual // entries (each becomes a match arm in the generated dispatcher), so a // single invocation with `#[cfg(...)]` gates per-command replaces the @@ -137,8 +149,6 @@ pub fn run() { #[cfg(desktop)] system_open_releases, #[cfg(desktop)] - system_fetch_latest_version, - #[cfg(desktop)] system_open_screen_capture_settings, #[cfg(desktop)] system_open_camera_settings, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 13979f5..5462696 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "StudyVis", - "version": "1.4.0", + "version": "1.5.0", "identifier": "com.studyvis.app", "build": { "frontendDist": "../dist", @@ -32,11 +32,21 @@ "desktop": { "schemes": ["studyvis"] } + }, + "updater": { + "endpoints": [ + "https://github.com/scotej/studyvis/releases/latest/download/latest.json" + ], + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDY1MURFQkUxOUMyRUNBODAKUldTQXlpNmM0ZXNkWlNpdVRVNHlCbnd5RUxrNmlsMFFCZnVNMExCQjRScmpGWUEzRGp4YUdMOEYK", + "windows": { + "installMode": "passive" + } } }, "bundle": { "active": true, - "targets": ["app", "dmg", "msi"], + "createUpdaterArtifacts": true, + "targets": ["app", "dmg", "nsis"], "icon": [ "icons/32x32.png", "icons/128x128.png", @@ -50,6 +60,11 @@ "signingIdentity": "-", "hardenedRuntime": false }, + "windows": { + "nsis": { + "installMode": "currentUser" + } + }, "android": { "debugApplicationIdSuffix": ".debug" } diff --git a/src/App.tsx b/src/App.tsx index cc9161a..cb0d56b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ import { QuitConfirmListener, WindowLayoutListener, } from '@/features/system' +import { UpdaterBoot } from '@/features/updater' import { Home } from '@/routes/Home' import { StyleGuide } from '@/routes/StyleGuide' import { readWindowStyleBootCache } from '@/stores/settingsStore' @@ -52,6 +53,7 @@ function App() { + diff --git a/src/components/UpdateReadyBanner.tsx b/src/components/UpdateReadyBanner.tsx new file mode 100644 index 0000000..8bd7bb4 --- /dev/null +++ b/src/components/UpdateReadyBanner.tsx @@ -0,0 +1,109 @@ +import { DownloadIcon, RotateCcwIcon } from 'lucide-react' +import { toast } from 'sonner' + +import { Button } from '@/components/ui/button' +import { tokens } from '@/design/tokens' +import { useUpdaterStore } from '@/features/updater' +import { cn } from '@/lib/utils' +import { useSessionStore } from '@/stores/sessionStore' +import { strings } from '@/strings' + +export type UpdateReadyBannerViewProps = { + version: string + installing: boolean + onRestart: () => void + onDismiss: () => void + className?: string +} + +// X6 — the one place an update is allowed to interrupt. It appears only once +// the new version is downloaded and its signature verified, so "Restart now" +// is a couple of seconds and never a surprise download. +// +// Deliberately quiet: `aria-live="polite"`, no modal, no toast. +export function UpdateReadyBannerView({ + version, + installing, + onRestart, + onDismiss, + className, +}: UpdateReadyBannerViewProps) { + const copy = strings.updater.banner + + return ( +
+
+ +
+
+ + {copy.title(version)} + + {copy.body} +
+
+ + +
+
+
+
+ ) +} + +// Container. Dismissing hides the banner for this process only — the staged +// update stays reachable from Settings → About, so dismissing can't strand +// someone on an old build. +export function UpdateReadyBanner({ className }: { className?: string }) { + const status = useUpdaterStore((s) => s.status) + const version = useUpdaterStore((s) => s.version) + const dismissed = useUpdaterStore((s) => s.dismissed) + const installing = useUpdaterStore((s) => s.installing) + const installAndRestart = useUpdaterStore((s) => s.installAndRestart) + const dismiss = useUpdaterStore((s) => s.dismiss) + const sessionActive = useSessionStore((s) => s.status === 'active') + + const handleRestart = async () => { + const ok = await installAndRestart() + // Only reachable when the swap failed and we're still running — the + // success path has already relaunched or been taken over by the installer. + if (!ok) toast.error(strings.updater.errors.installFailed) + } + + // Never mid-session: the download is already suppressed there (UpdaterBoot), + // but an update staged before the session started could otherwise surface + // over a live video grid and offer to restart out of it. + if (status !== 'ready' || !version || dismissed || sessionActive) return null + + return ( + void handleRestart()} + onDismiss={dismiss} + className={className} + /> + ) +} diff --git a/src/features/settings/categories/AboutCategory.tsx b/src/features/settings/categories/AboutCategory.tsx index aef267a..fed8691 100644 --- a/src/features/settings/categories/AboutCategory.tsx +++ b/src/features/settings/categories/AboutCategory.tsx @@ -1,18 +1,20 @@ // Settings → About: version (from Vite's __APP_VERSION__ define), a link to -// the GitHub releases page, and the X4 opt-in new-version check. The check is -// OFF by default and this component is the zero-outbound guarantee: while the -// toggle is off, `system_fetch_latest_version` is never invoked — the one -// sanctioned outbound request beyond P2P/Nostr (PLAN §3) only fires when on. +// the GitHub releases page, and the X6 auto-update controls. +// +// The toggle is the zero-outbound guarantee: while it's off, `UpdaterBoot` +// never schedules a check and nothing here calls one either. X4's opt-in tag +// check used to live in this file; the updater subsumed it, so there is one +// update mechanism instead of two overlapping ones. -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useState } from 'react' import { invoke } from '@tauri-apps/api/core' -import { ExternalLinkIcon } from 'lucide-react' +import { ExternalLinkIcon, RotateCcwIcon, SearchIcon } from 'lucide-react' import { toast } from 'sonner' import { SettingsRow, SettingsSection } from '@/components/SettingsRow' import { Button } from '@/components/ui/button' import { Switch } from '@/components/ui/switch' -import { isNewerVersion } from '@/lib/version' +import { useUpdaterStore } from '@/features/updater' import { useSettingsStore } from '@/stores/settingsStore' import { strings } from '@/strings' @@ -22,16 +24,24 @@ const COPYRIGHT_LINE = strings.settings.about.copyright.line( export function AboutCategory() { const [opening, setOpening] = useState(false) - // X4 — the latest release tag when a NEWER version is found, else null. - // Stays null on every failure (silent) and while the toggle is off. - const [latestNewer, setLatestNewer] = useState(null) - const versionCheckEnabled = useSettingsStore( - (s) => s.values.versionCheckEnabled - ) - const setVersionCheckEnabled = useSettingsStore( - (s) => s.setVersionCheckEnabled - ) + const autoUpdateEnabled = useSettingsStore((s) => s.values.autoUpdateEnabled) + const setAutoUpdateEnabled = useSettingsStore((s) => s.setAutoUpdateEnabled) + + const status = useUpdaterStore((s) => s.status) + const pendingVersion = useUpdaterStore((s) => s.version) + const percent = useUpdaterStore((s) => s.percent) + const installing = useUpdaterStore((s) => s.installing) + const errorKind = useUpdaterStore((s) => s.errorKind) + const checkNow = useUpdaterStore((s) => s.checkNow) + const installAndRestart = useUpdaterStore((s) => s.installAndRestart) + const copy = strings.settings.about + const updaterCopy = strings.updater.settings + + const handleRestart = useCallback(async () => { + const ok = await installAndRestart() + if (!ok) toast.error(strings.updater.errors.installFailed) + }, [installAndRestart]) const handleOpenReleases = useCallback(async () => { setOpening(true) @@ -60,33 +70,75 @@ export function AboutCategory() { ) - // X4 — opt-in version check. ZERO outbound while the toggle is off: the - // effect bails before any invoke. When on, it runs once per mount (the - // simpler honest option than a daily timer — the user has to open this - // screen anyway, and it's the natural place to see the result). Silent on - // every failure path. The latest tag is compared semver-ishly to the - // baked-in __APP_VERSION__; only a strictly-newer tag surfaces a row. - useEffect(() => { - // ZERO outbound while off: bail before any invoke. A stale `latestNewer` - // from a prior on-session is harmless — the row's render is gated on - // `versionCheckEnabled` too, so nothing shows while off. - if (!versionCheckEnabled) return - let cancelled = false - void (async () => { - try { - const latest = await invoke('system_fetch_latest_version') - if (cancelled) return - setLatestNewer(isNewerVersion(__APP_VERSION__, latest) ? latest : null) - } catch { - // Best-effort: a network failure, blocked request, or unparseable - // tag all leave the row hidden. No toast, no log surfaced to the user. - if (!cancelled) setLatestNewer(null) - } - })() - return () => { - cancelled = true + // One row that changes shape with the updater's state, rather than four + // rows that are empty most of the time. + const updateStatusRow = () => { + // The "ready" row is shown regardless of the toggle: once bytes are + // downloaded and signature-verified they're local and installable, and + // hiding the restart here while the Home banner still offers it would let + // the two surfaces disagree. The toggle governs *future* checks, not an + // update already staged. (Turning it off mid-download is covered too: + // that download finishes and lands here as "ready".) + if (status === 'ready' && pendingVersion) { + return ( + void handleRestart()} + disabled={installing} + > + {updaterCopy.restartCta} + + } + /> + ) } - }, [versionCheckEnabled]) + + // Below here is live-check status. With auto-update off there's nothing to + // report and offering "Check now" would contradict the toggle above it. + if (!autoUpdateEnabled) return null + + if (status === 'downloading' && pendingVersion) { + return ( + + ) + } + + const help = + status === 'checking' + ? updaterCopy.checkingHelp + : errorKind === 'check' + ? strings.updater.errors.checkFailed + : errorKind === 'download' + ? strings.updater.errors.downloadFailed + : updaterCopy.upToDateHelp(__APP_VERSION__) + + return ( + void checkNow({ userInitiated: true })} + disabled={status === 'checking'} + > + {updaterCopy.checkCta} + + } + /> + ) + } return ( @@ -109,34 +161,24 @@ export function AboutCategory() { } /> - void setVersionCheckEnabled(Boolean(checked)) + void setAutoUpdateEnabled(Boolean(checked)) } - aria-label={copy.versionCheck.ariaLabel} + aria-label={copy.autoUpdate.ariaLabel} /> } /> - {/* One row, one CTA: both rows open the same Releases page, so the - update-available variant replaces the generic one instead of - stacking a second identical button under it. */} - {versionCheckEnabled && latestNewer ? ( - - ) : ( - - )} + {updateStatusRow()} + ) } diff --git a/src/features/updater/UpdaterBoot.tsx b/src/features/updater/UpdaterBoot.tsx new file mode 100644 index 0000000..50f7a51 --- /dev/null +++ b/src/features/updater/UpdaterBoot.tsx @@ -0,0 +1,62 @@ +// X6 — owns *when* the updater runs. The store owns what it does. +// +// Two rules shape the schedule: +// +// 1. Nothing outbound while `autoUpdateEnabled` is off. The effect bails +// before the first check, which is what keeps the PLAN §3 promise the +// toggle's help text makes. +// 2. Nothing at all during a session. A check is a trivial request, but the +// download that follows it is not — pulling an installer while three +// people are on a WebRTC mesh is exactly the bandwidth you don't have to +// spare. Sessions are finite; the update waits. +// +// The first check is delayed rather than fired at mount so it doesn't race +// P2P discovery for the network on a cold launch. + +import { useEffect } from 'react' + +import { useSessionStore } from '@/stores/sessionStore' +import { useSettingsStore } from '@/stores/settingsStore' + +import { useUpdaterStore } from './updaterStore' + +// Long enough to stay out of the way of boot: identity load, relay +// connection, and presence all land well inside it. +const FIRST_CHECK_DELAY_MS = 20_000 +// Friends ship releases in bursts, not continuously. Six hours catches a +// same-day release without meaningful traffic. +const RECHECK_INTERVAL_MS = 6 * 60 * 60 * 1000 + +export function UpdaterBoot() { + const autoUpdateEnabled = useSettingsStore((s) => s.values.autoUpdateEnabled) + const settingsStatus = useSettingsStore((s) => s.status) + const sessionStatus = useSessionStore((s) => s.status) + const checkNow = useUpdaterStore((s) => s.checkNow) + + const sessionActive = sessionStatus === 'active' + + useEffect(() => { + // Wait for the persisted value: hydrating later would let one check slip + // out on the `true` default before we learn the user turned it off. + if (settingsStatus !== 'ready' || !autoUpdateEnabled || sessionActive) + return + + let cancelled = false + const run = () => { + if (cancelled) return + void checkNow() + } + + const first = setTimeout(run, FIRST_CHECK_DELAY_MS) + const repeat = setInterval(run, RECHECK_INTERVAL_MS) + return () => { + cancelled = true + clearTimeout(first) + clearInterval(repeat) + } + // Re-running on `sessionActive` is the point: the timers are torn down + // when a session starts and re-armed (delay included) when it ends. + }, [settingsStatus, autoUpdateEnabled, sessionActive, checkNow]) + + return null +} diff --git a/src/features/updater/index.ts b/src/features/updater/index.ts new file mode 100644 index 0000000..0d505e3 --- /dev/null +++ b/src/features/updater/index.ts @@ -0,0 +1,9 @@ +export { UpdaterBoot } from './UpdaterBoot' +export { + defaultUpdaterDeps, + resetUpdaterDeps, + setUpdaterDeps, + useUpdaterStore, + type UpdaterDeps, + type UpdaterStatus, +} from './updaterStore' diff --git a/src/features/updater/updaterStore.ts b/src/features/updater/updaterStore.ts new file mode 100644 index 0000000..b64b180 --- /dev/null +++ b/src/features/updater/updaterStore.ts @@ -0,0 +1,231 @@ +// X6 — auto-update state machine. +// +// The shape of the flow, and why: +// idle → checking → (up-to-date | available) → downloading → ready → … +// +// The download runs unattended the moment a release is found; only the +// install waits for a person. That split is deliberate — restarting is the +// only part with a cost (you lose your place), and by the time the banner +// appears the bytes are already on disk and signature-verified, so "Restart +// now" is a couple of seconds rather than a download you have to sit through. +// +// Integrity: tauri-plugin-updater verifies the artifact's minisign signature +// against the pubkey baked into tauri.conf.json BEFORE unpacking. That check +// is independent of Apple/Windows code signing, which is why this ships on +// ad-hoc-signed builds — an attacker who controls the release page still +// can't hand this app a payload it will install. +// +// Session safety is split. `UpdaterBoot` owns *scheduling* — it doesn't +// start a check while a session is active. But a check is network-bound, so a +// session can begin between UpdaterBoot's decision to check and the download +// actually starting; the timer teardown can't reach into an already-running +// `checkNow`. So the check→download boundary re-reads session state here (via +// the `isSessionActive` dep) and defers the download if a session slipped in. +// The one residual gap the plugin makes unfixable: a download already in +// flight when a session starts cannot be aborted — this plugin version's +// `download()` takes no AbortSignal. That window is narrow (only on a launch +// where a release is newly found, mid-download) and self-limiting. + +import { invoke } from '@tauri-apps/api/core' +import { check, type Update } from '@tauri-apps/plugin-updater' +import { create } from 'zustand' + +import { useSessionStore } from '@/stores/sessionStore' + +export type UpdaterStatus = + | 'idle' + | 'checking' + | 'downloading' + | 'ready' + | 'upToDate' + | 'error' + +export type UpdaterDeps = { + check: () => Promise + // Split out so tests can drive the flow without a Tauri host. All three are + // thin passthroughs in production. + stopSidecar: () => Promise + relaunch: () => Promise + // Read at the check→download boundary so a session that started *during* + // the (network-bound) check aborts before any installer bytes move. See + // the note in checkNow. + isSessionActive: () => boolean +} + +export const defaultUpdaterDeps: UpdaterDeps = { + check: () => check(), + stopSidecar: () => invoke('sidecar_stop'), + relaunch: () => invoke('system_relaunch_app'), + isSessionActive: () => useSessionStore.getState().status === 'active', +} + +let deps: UpdaterDeps = defaultUpdaterDeps + +export function setUpdaterDeps(next: Partial) { + deps = { ...deps, ...next } +} + +export function resetUpdaterDeps() { + deps = defaultUpdaterDeps +} + +type UpdaterState = { + status: UpdaterStatus + // Version of the pending update; null whenever there isn't one. + version: string | null + // Release notes as GitHub rendered them into latest.json. May be empty. + notes: string | null + // 0..100, meaningful only while `status === 'downloading'`. Stays at 100 + // when the server sends no Content-Length (progress is indeterminate). + percent: number + // Which step failed, for picking copy. Set only by user-initiated actions + // (and by install, which is always user-initiated); background failures + // leave it null so a flaky network never produces UI. + errorKind: 'check' | 'download' | 'install' | null + // Banner suppression for the rest of this process. The update stays staged + // and Settings → About still offers it. + dismissed: boolean + installing: boolean + // Held between download and install: the Update handle owns the staged + // bytes on the Rust side, so dropping it would strand them. + pending: Update | null + + checkNow: (opts?: { userInitiated?: boolean }) => Promise + // Resolves `false` when the install failed and the app is still running. + // A successful install never resolves on Windows (the installer takes the + // process over) and relaunches on macOS, so `true` is mostly theoretical. + installAndRestart: () => Promise + dismiss: () => void +} + +export const useUpdaterStore = create((set, get) => ({ + status: 'idle', + version: null, + notes: null, + percent: 0, + errorKind: null, + dismissed: false, + installing: false, + pending: null, + + checkNow: async ({ userInitiated = false } = {}) => { + const { status } = get() + // A check already in flight, or an update already staged, wins — a second + // pass would re-download bytes we have. + if (status === 'checking' || status === 'downloading' || status === 'ready') + return + + set({ status: 'checking', errorKind: null }) + let update: Update | null + try { + update = await deps.check() + } catch (err) { + // Silent in the background path: no network is the common case on a + // laptop that just woke up, and it isn't the user's problem. + console.error('[updater] check failed:', err) + set({ status: 'error', errorKind: userInitiated ? 'check' : null }) + return + } + + if (!update) { + set({ status: 'upToDate', version: null, notes: null }) + return + } + + // A session may have started during the check above. Don't pull an + // installer onto a live WebRTC mesh — reset to idle (nothing staged) and + // let UpdaterBoot's post-session check re-find and download it. A + // user-initiated check is exempt: the user asked for it, and the Settings + // screen isn't reachable to press "Check now" mid-session anyway. + if (!userInitiated && deps.isSessionActive()) { + set({ status: 'idle' }) + return + } + + set({ + status: 'downloading', + version: update.version, + notes: update.body ?? null, + percent: 0, + // A fresh version supersedes an earlier dismissal — the user dismissed + // the old one, not this one. + dismissed: false, + }) + + try { + let total = 0 + let received = 0 + await update.download((event) => { + switch (event.event) { + case 'Started': + total = event.data.contentLength ?? 0 + break + case 'Progress': + received += event.data.chunkLength + set({ + percent: + total > 0 ? Math.min(100, (received / total) * 100) : 100, + }) + break + case 'Finished': + set({ percent: 100 }) + break + } + }) + } catch (err) { + // Leave nothing staged: the next check re-downloads from scratch rather + // than trying to resume a half-written artifact. + console.error('[updater] download failed:', err) + set({ + status: 'error', + pending: null, + version: null, + errorKind: userInitiated ? 'download' : null, + }) + return + } + + set({ status: 'ready', percent: 100, pending: update }) + }, + + installAndRestart: async () => { + const { pending, installing } = get() + if (!pending || installing) return false + set({ installing: true, errorKind: null }) + + // The sidecar has to die before the bundle is swapped. On Windows the + // NSIS installer cannot overwrite a running llama-server.exe; on macOS an + // orphan would survive the relaunch holding its port and model file. + // `sidecar_stop` is idempotent, so a never-started sidecar is a no-op. + try { + await deps.stopSidecar() + } catch { + // Best-effort. A sidecar we failed to stop is a worse install, not a + // reason to refuse the update. + } + + try { + await pending.install() + } catch (err) { + // Most often macOS refusing to overwrite a bundle the user doesn't own + // (a root-installed /Applications, or still running from the .dmg). + console.error('[updater] install failed:', err) + set({ installing: false, errorKind: 'install' }) + return false + } + + // Windows: the NSIS installer has already taken over and will restart the + // app, so this line is usually never reached. macOS: the bundle has been + // swapped in place and we relaunch ourselves. + try { + await deps.relaunch() + } catch (err) { + console.error('[updater] relaunch failed:', err) + set({ installing: false, errorKind: 'install' }) + return false + } + return true + }, + + dismiss: () => set({ dismissed: true }), +})) diff --git a/src/routes/Home.tsx b/src/routes/Home.tsx index d59348f..c880989 100644 --- a/src/routes/Home.tsx +++ b/src/routes/Home.tsx @@ -19,6 +19,7 @@ import { Link } from 'react-router' import { Settings2Icon } from 'lucide-react' import { toast } from 'sonner' +import { UpdateReadyBanner } from '@/components/UpdateReadyBanner' import { Button } from '@/components/ui/button' import { tokens } from '@/design/tokens' import { @@ -451,6 +452,10 @@ export function Home() { {strings.settings.heading} + {/* X6 — the staged-update banner lives only on the dashboard: it's + the one view where restarting costs nothing. The component + self-hides mid-session and while nothing is staged. */} + {/* #47 B1 — pending incoming invites persist here for their full 5-minute validity; accepting funnels through the same topic-gated path as the toast. */} diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index 4703d19..77d1a0d 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -93,10 +93,12 @@ export type SettingsValues = { // N3 — OS notification when a friend flips offline→online. Opt-IN: OFF by // default. Read by InboxBoot's presence detector; ~60s presence latency. friendOnlineNotificationEnabled: boolean - // X4 — opt-in version check, OFF by default. The one sanctioned outbound - // request beyond P2P + Nostr (PLAN §3 carve-out). When OFF, AboutCategory - // never calls system_fetch_latest_version — zero outbound. - versionCheckEnabled: boolean + // X6 — auto-update, ON by default. Widens PLAN §3's carve-out from X4's + // opt-in tag check to a recurring check + background download against + // GitHub Releases. Still no identifiers, no payload, no telemetry. When + // OFF, `features/updater` never calls check() — zero outbound, the same + // guarantee the X4 toggle carried. + autoUpdateEnabled: boolean minimizeToTrayOnClose: boolean debugLogEnabled: boolean turnPreference: TurnPreference @@ -175,7 +177,11 @@ export const SETTINGS_KEY_POMODORO_NOTIFY = 'pomodoro_notification_enabled' export const SETTINGS_KEY_POMODORO_SOUND = 'pomodoro_sound_enabled' export const SETTINGS_KEY_FRIEND_ONLINE_NOTIFY = 'friend_online_notification_enabled' +// X4's opt-in tag check. X6 superseded it with the real updater; the key is +// still READ (never written) so a friend who deliberately turned the old +// check OFF doesn't get outbound traffic switched back on by the upgrade. export const SETTINGS_KEY_VERSION_CHECK = 'version_check_enabled' +export const SETTINGS_KEY_AUTO_UPDATE = 'auto_update_enabled' export const SETTINGS_KEY_MINIMIZE_TRAY = 'minimize_to_tray_on_close' export const SETTINGS_KEY_DEBUG_LOG = 'debug_log_enabled' export const SETTINGS_KEY_TURN_PREF = 'turn_preference' @@ -204,11 +210,11 @@ export const DEFAULT_SETTINGS: SettingsValues = { theme: 'dark', reduceMotion: false, incomingInviteNotificationEnabled: true, - // N2 opt-out (on), N6 opt-in (off), N3 opt-in (off), X4 opt-in (off). + // N2 opt-out (on), N6 opt-in (off), N3 opt-in (off), X6 opt-out (on). pomodoroNotificationEnabled: true, pomodoroSoundEnabled: false, friendOnlineNotificationEnabled: false, - versionCheckEnabled: false, + autoUpdateEnabled: true, minimizeToTrayOnClose: true, debugLogEnabled: false, turnPreference: 'auto', @@ -254,7 +260,7 @@ type SettingsState = { setPomodoroNotificationEnabled: (enabled: boolean) => Promise setPomodoroSoundEnabled: (enabled: boolean) => Promise setFriendOnlineNotificationEnabled: (enabled: boolean) => Promise - setVersionCheckEnabled: (enabled: boolean) => Promise + setAutoUpdateEnabled: (enabled: boolean) => Promise setMinimizeToTrayOnClose: (enabled: boolean) => Promise setDebugLogEnabled: (enabled: boolean) => Promise setTurnPreference: (pref: TurnPreference) => Promise @@ -649,6 +655,7 @@ export async function hydrateValuesFromStore( pomodoroSound: await store.get(SETTINGS_KEY_POMODORO_SOUND), friendOnline: await store.get(SETTINGS_KEY_FRIEND_ONLINE_NOTIFY), versionCheck: await store.get(SETTINGS_KEY_VERSION_CHECK), + autoUpdate: await store.get(SETTINGS_KEY_AUTO_UPDATE), tray: await store.get(SETTINGS_KEY_MINIMIZE_TRAY), debug: await store.get(SETTINGS_KEY_DEBUG_LOG), turn: await store.get(SETTINGS_KEY_TURN_PREF), @@ -718,9 +725,15 @@ export async function hydrateValuesFromStore( stored.friendOnline, DEFAULT_SETTINGS.friendOnlineNotificationEnabled ), - versionCheckEnabled: readBool( - stored.versionCheck, - DEFAULT_SETTINGS.versionCheckEnabled + // X6 one-way migration, evaluated newest-key-first: + // auto_update_enabled set → honor it; + // only the X4 version_check key → honor that (an explicit OFF stays + // OFF — we don't switch outbound + // traffic back on behind their back); + // neither → default ON. + autoUpdateEnabled: readBool( + stored.autoUpdate, + readBool(stored.versionCheck, DEFAULT_SETTINGS.autoUpdateEnabled) ), minimizeToTrayOnClose: readBool( stored.tray, @@ -938,9 +951,9 @@ export const useSettingsStore = create((set, get) => ({ await writeKey(set, SETTINGS_KEY_FRIEND_ONLINE_NOTIFY, enabled) }, - setVersionCheckEnabled: async (enabled) => { - set((s) => ({ values: { ...s.values, versionCheckEnabled: enabled } })) - await writeKey(set, SETTINGS_KEY_VERSION_CHECK, enabled) + setAutoUpdateEnabled: async (enabled) => { + set((s) => ({ values: { ...s.values, autoUpdateEnabled: enabled } })) + await writeKey(set, SETTINGS_KEY_AUTO_UPDATE, enabled) }, setMinimizeToTrayOnClose: async (enabled) => { diff --git a/src/stories/UpdateReadyBanner.stories.tsx b/src/stories/UpdateReadyBanner.stories.tsx new file mode 100644 index 0000000..502b17c --- /dev/null +++ b/src/stories/UpdateReadyBanner.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' + +import { UpdateReadyBannerView } from '@/components/UpdateReadyBanner' + +const meta = { + title: 'Components/UpdateReadyBanner', + component: UpdateReadyBannerView, + parameters: { layout: 'padded' }, + args: { + version: '1.5.0', + installing: false, + onRestart: () => {}, + onDismiss: () => {}, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Ready: Story = {} + +// Both buttons go disabled the moment the bundle swap starts — the restart is +// not cancellable once the installer has the files. +export const Installing: Story = { + args: { installing: true }, +} + +export const LongVersionString: Story = { + args: { version: '1.5.0-rc.1' }, +} diff --git a/src/strings.ts b/src/strings.ts index c3174d6..6d22ea5 100644 --- a/src/strings.ts +++ b/src/strings.ts @@ -1272,22 +1272,17 @@ export const strings = { }, releases: { label: 'Releases', - help: "StudyVis doesn't auto-update. Check here when a new version drops.", + help: 'Release notes for every version, and installers if you ever need one by hand.', openCta: 'Open', errorFallback: "Couldn't open the Releases page.", }, - // X4 — opt-in version check, OFF by default. The toggle is the one - // sanctioned outbound request (PLAN §3 carve-out); off means zero calls. - versionCheck: { - label: 'Check for new versions', - help: 'Off by default. When on, StudyVis asks GitHub once on this screen whether a newer release exists. It sends no data about you.', - ariaLabel: 'Check for new versions', - }, - // X4 — quiet "newer version available" row, shown only when the check - // succeeds and finds a newer tag. - updateAvailable: { - label: 'Update available', - help: (latest: string) => `Version ${latest} is available.`, + // X6 — auto-update replaces X4's opt-in tag check. ON by default (the + // widened PLAN §3 carve-out); off means zero outbound, same guarantee + // the X4 toggle used to carry. + autoUpdate: { + label: 'Automatic updates', + help: 'StudyVis checks GitHub for new releases, downloads them in the background, and installs on restart. It sends no data about you. Turn this off and nothing goes out.', + ariaLabel: 'Automatic updates', }, }, }, @@ -1606,6 +1601,48 @@ export const strings = { }, }, + // X6 — auto-update. The banner is the only place an update ever interrupts + // you, and it only appears once the new version is already downloaded and + // verified, so "Restart now" is instant. Nothing here fires mid-session. + updater: { + banner: { + ariaLabel: 'Update ready', + title: (version: string) => `StudyVis ${version} is ready`, + body: 'Downloaded and verified. Restart to finish — it takes a couple of seconds.', + restartCta: 'Restart now', + laterCta: 'Later', + dismissAriaLabel: 'Dismiss until next launch', + notesCta: 'Release notes', + installing: 'Installing…', + }, + settings: { + // Mirrors the banner in Settings → About so the update is reachable + // after the banner is dismissed. + readyLabel: 'Update ready', + readyHelp: (version: string) => + `Version ${version} is downloaded and waiting. Restart to finish.`, + downloadingLabel: 'Downloading update', + downloadingHelp: (version: string, percent: number) => + `Version ${version} — ${percent}%`, + checkingHelp: 'Checking for updates…', + upToDateHelp: (version: string) => `You're on ${version}, the latest.`, + checkCta: 'Check now', + restartCta: 'Restart now', + }, + errors: { + // Surfaced only on a user-initiated check / restart. Background failures + // stay silent — a flaky network shouldn't nag anyone mid-study. + checkFailed: "Couldn't reach GitHub to check for updates.", + downloadFailed: + "Couldn't download the update. StudyVis will retry later.", + // macOS: the app bundle has to be writable to swap itself out. Living in + // a root-owned /Applications, or still running from the .dmg, both land + // here. + installFailed: + "Couldn't install the update. Download the installer from the Releases page instead.", + }, + }, + errors: { leaveSessionFirst: 'Leave the current session before joining another.', }, diff --git a/tests/unit/updater-store.test.ts b/tests/unit/updater-store.test.ts new file mode 100644 index 0000000..358aff7 --- /dev/null +++ b/tests/unit/updater-store.test.ts @@ -0,0 +1,289 @@ +// X6 — updaterStore: the check → download → install state machine. +// +// The behaviors worth pinning are the ones that are quiet in production and +// therefore easy to regress: background failures must not produce UI, a +// re-entrant check must not re-download staged bytes, and the sidecar must be +// stopped before the bundle is swapped. + +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { + resetUpdaterDeps, + setUpdaterDeps, + useUpdaterStore, +} from '@/features/updater' + +type DownloadEvent = + | { event: 'Started'; data: { contentLength?: number } } + | { event: 'Progress'; data: { chunkLength: number } } + | { event: 'Finished' } + +// Minimal stand-in for the plugin's Update handle. Only the two methods the +// store calls are modelled. +function fakeUpdate( + overrides: { + version?: string + body?: string + onDownload?: (emit: (e: DownloadEvent) => void) => Promise + install?: () => Promise + } = {} +) { + return { + version: overrides.version ?? '2.0.0', + body: overrides.body, + download: vi.fn(async (onEvent: (e: DownloadEvent) => void) => { + if (overrides.onDownload) return overrides.onDownload(onEvent) + onEvent({ event: 'Started', data: { contentLength: 100 } }) + onEvent({ event: 'Progress', data: { chunkLength: 100 } }) + onEvent({ event: 'Finished' }) + }), + install: vi.fn(overrides.install ?? (async () => {})), + } +} + +function resetStore() { + useUpdaterStore.setState({ + status: 'idle', + version: null, + notes: null, + percent: 0, + errorKind: null, + dismissed: false, + installing: false, + pending: null, + }) +} + +beforeEach(() => { + resetStore() + resetUpdaterDeps() +}) + +describe('checkNow', () => { + test('no update available settles on upToDate', async () => { + setUpdaterDeps({ check: async () => null }) + await useUpdaterStore.getState().checkNow() + expect(useUpdaterStore.getState().status).toBe('upToDate') + expect(useUpdaterStore.getState().version).toBeNull() + }) + + test('an available update downloads immediately and stages itself', async () => { + const update = fakeUpdate({ version: '2.1.0', body: 'notes here' }) + setUpdaterDeps({ check: async () => update as never }) + + await useUpdaterStore.getState().checkNow() + + const s = useUpdaterStore.getState() + expect(update.download).toHaveBeenCalledOnce() + expect(s.status).toBe('ready') + expect(s.version).toBe('2.1.0') + expect(s.notes).toBe('notes here') + expect(s.percent).toBe(100) + expect(s.pending).toBe(update) + }) + + test('download progress is reported as a percentage of content length', async () => { + const seen: number[] = [] + const unsubscribe = useUpdaterStore.subscribe((s) => { + if (s.status === 'downloading') seen.push(s.percent) + }) + const update = fakeUpdate({ + onDownload: async (emit) => { + emit({ event: 'Started', data: { contentLength: 200 } }) + emit({ event: 'Progress', data: { chunkLength: 50 } }) + emit({ event: 'Progress', data: { chunkLength: 150 } }) + }, + }) + setUpdaterDeps({ check: async () => update as never }) + + await useUpdaterStore.getState().checkNow() + unsubscribe() + + expect(seen).toContain(25) + expect(seen).toContain(100) + }) + + test('a missing content length leaves progress indeterminate rather than dividing by zero', async () => { + const update = fakeUpdate({ + onDownload: async (emit) => { + emit({ event: 'Started', data: {} }) + emit({ event: 'Progress', data: { chunkLength: 10 } }) + }, + }) + setUpdaterDeps({ check: async () => update as never }) + + await useUpdaterStore.getState().checkNow() + + expect(useUpdaterStore.getState().percent).toBe(100) + expect(useUpdaterStore.getState().status).toBe('ready') + }) + + test('a background check failure stays silent — no errorKind for the UI to render', async () => { + setUpdaterDeps({ + check: async () => { + throw new Error('offline') + }, + }) + + await useUpdaterStore.getState().checkNow() + + expect(useUpdaterStore.getState().status).toBe('error') + expect(useUpdaterStore.getState().errorKind).toBeNull() + }) + + test('a user-initiated check failure is attributed so the UI can explain it', async () => { + setUpdaterDeps({ + check: async () => { + throw new Error('offline') + }, + }) + + await useUpdaterStore.getState().checkNow({ userInitiated: true }) + + expect(useUpdaterStore.getState().errorKind).toBe('check') + }) + + test('a failed download stages nothing, so the next check starts clean', async () => { + const update = fakeUpdate({ + onDownload: async () => { + throw new Error('connection reset') + }, + }) + setUpdaterDeps({ check: async () => update as never }) + + await useUpdaterStore.getState().checkNow({ userInitiated: true }) + + const s = useUpdaterStore.getState() + expect(s.status).toBe('error') + expect(s.errorKind).toBe('download') + expect(s.pending).toBeNull() + expect(s.version).toBeNull() + }) + + test('a check while an update is already staged is a no-op — staged bytes are not re-fetched', async () => { + const first = fakeUpdate({ version: '2.0.0' }) + setUpdaterDeps({ check: async () => first as never }) + await useUpdaterStore.getState().checkNow() + expect(useUpdaterStore.getState().status).toBe('ready') + + const check = vi.fn(async () => fakeUpdate({ version: '3.0.0' }) as never) + setUpdaterDeps({ check }) + await useUpdaterStore.getState().checkNow() + + expect(check).not.toHaveBeenCalled() + expect(useUpdaterStore.getState().version).toBe('2.0.0') + }) + + test('a newly found version clears a prior dismissal', async () => { + useUpdaterStore.setState({ dismissed: true }) + setUpdaterDeps({ check: async () => fakeUpdate() as never }) + + await useUpdaterStore.getState().checkNow() + + expect(useUpdaterStore.getState().dismissed).toBe(false) + }) + + test('a session that started during the check defers the download', async () => { + const update = fakeUpdate({ version: '2.2.0' }) + setUpdaterDeps({ + check: async () => update as never, + // Models a session that began while check() was in flight. + isSessionActive: () => true, + }) + + await useUpdaterStore.getState().checkNow() + + // No bytes moved, nothing staged — back to idle for the next post-session + // check to re-find and download. + expect(update.download).not.toHaveBeenCalled() + expect(useUpdaterStore.getState().status).toBe('idle') + expect(useUpdaterStore.getState().pending).toBeNull() + }) + + test('a user-initiated check downloads even during a session', async () => { + const update = fakeUpdate({ version: '2.3.0' }) + setUpdaterDeps({ + check: async () => update as never, + isSessionActive: () => true, + }) + + await useUpdaterStore.getState().checkNow({ userInitiated: true }) + + expect(update.download).toHaveBeenCalledOnce() + expect(useUpdaterStore.getState().status).toBe('ready') + }) +}) + +describe('installAndRestart', () => { + test('stops the sidecar before swapping the bundle', async () => { + const order: string[] = [] + const update = fakeUpdate({ + install: async () => { + order.push('install') + }, + }) + setUpdaterDeps({ + check: async () => update as never, + stopSidecar: async () => { + order.push('stopSidecar') + }, + relaunch: async () => { + order.push('relaunch') + }, + }) + + await useUpdaterStore.getState().checkNow() + await useUpdaterStore.getState().installAndRestart() + + expect(order).toEqual(['stopSidecar', 'install', 'relaunch']) + }) + + test('a sidecar that refuses to stop does not block the update', async () => { + const update = fakeUpdate() + setUpdaterDeps({ + check: async () => update as never, + stopSidecar: async () => { + throw new Error('no sidecar running') + }, + relaunch: async () => {}, + }) + + await useUpdaterStore.getState().checkNow() + const ok = await useUpdaterStore.getState().installAndRestart() + + expect(ok).toBe(true) + expect(update.install).toHaveBeenCalledOnce() + }) + + test('a failed install reports back so the caller can point at the Releases page', async () => { + const update = fakeUpdate({ + install: async () => { + throw new Error('read-only bundle') + }, + }) + setUpdaterDeps({ + check: async () => update as never, + stopSidecar: async () => {}, + relaunch: async () => {}, + }) + + await useUpdaterStore.getState().checkNow() + const ok = await useUpdaterStore.getState().installAndRestart() + + const s = useUpdaterStore.getState() + expect(ok).toBe(false) + expect(s.errorKind).toBe('install') + // Cleared so the button is live again for a retry. + expect(s.installing).toBe(false) + }) + + test('does nothing when no update is staged', async () => { + const stopSidecar = vi.fn(async () => {}) + setUpdaterDeps({ stopSidecar }) + + const ok = await useUpdaterStore.getState().installAndRestart() + + expect(ok).toBe(false) + expect(stopSidecar).not.toHaveBeenCalled() + }) +})