From 4ca4ea6c85f66a08940f31f88274f28404ac385d Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 23 Apr 2026 11:32:31 -0700 Subject: [PATCH 1/2] feat(desktop): restore Tauri auto-updater support The updater was removed in #360 ("Remove release pipeline from public repo") as collateral when the CI workflows were moved. This restores all client-side updater scaffolding: - Add tauri-plugin-updater dependency (Rust + npm) - Restore build.rs env var detection (SPROUT_UPDATER_PUBLIC_KEY, SPROUT_UPDATER_ENDPOINT) and cfg(sprout_updater_enabled) flag - Restore conditional plugin registration in lib.rs (release builds only, double-gated on compile-time cfg + debug_assertions) - Add plugins.updater config skeleton to tauri.conf.json - Add updater capability permissions - Restore UpdateChecker frontend component and wire it into Settings The updater endpoint and public key are injected at release build time by the external release pipeline. Local dev builds are unaffected. Co-Authored-By: Claude Opus 4.6 (1M context) --- desktop/package.json | 1 + desktop/pnpm-lock.yaml | 10 ++ desktop/src-tauri/Cargo.lock | 80 ++++++++- desktop/src-tauri/Cargo.toml | 1 + desktop/src-tauri/build.rs | 16 ++ desktop/src-tauri/capabilities/default.json | 3 + desktop/src-tauri/src/lib.rs | 13 ++ desktop/src-tauri/tauri.conf.json | 5 + .../src/features/settings/UpdateChecker.tsx | 161 ++++++++++++++++++ .../features/settings/ui/SettingsPanels.tsx | 10 ++ 10 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 desktop/src/features/settings/UpdateChecker.tsx diff --git a/desktop/package.json b/desktop/package.json index df1d4737..75068b46 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -41,6 +41,7 @@ "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2.3.1", + "@tauri-apps/plugin-updater": "^2.10.0", "@tiptap/core": "^3.22.3", "@tiptap/extension-link": "^3.22.3", "@tiptap/extension-placeholder": "^3.22.3", diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index 98a580b6..fc7a0326 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: '@tauri-apps/plugin-process': specifier: ^2.3.1 version: 2.3.1 + '@tauri-apps/plugin-updater': + specifier: ^2.10.0 + version: 2.10.1 '@tiptap/core': specifier: ^3.22.3 version: 3.22.3(@tiptap/pm@3.22.3) @@ -1359,6 +1362,9 @@ packages: '@tauri-apps/plugin-process@2.3.1': resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==} + '@tauri-apps/plugin-updater@2.10.1': + resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==} + '@tiptap/core@3.22.3': resolution: {integrity: sha512-Dv9MKK5BDWCF0N2l6/Pxv3JNCce2kwuWf2cKMBc2bEetx0Pn6o7zlFmSxMvYK4UtG1Tw9Yg/ZHi6QOFWK0Zm9Q==} peerDependencies: @@ -3731,6 +3737,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.10.1 + '@tauri-apps/plugin-updater@2.10.1': + dependencies: + '@tauri-apps/api': 2.10.1 + '@tiptap/core@3.22.3(@tiptap/pm@3.22.3)': dependencies: '@tiptap/pm': 3.22.3 diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index fd314c0d..d82dbb2a 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2937,6 +2937,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" @@ -3374,6 +3380,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" @@ -3529,6 +3547,20 @@ dependencies = [ "ureq 3.3.0", ] +[[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" @@ -5278,6 +5310,7 @@ dependencies = [ "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-process", + "tauri-plugin-updater", "tauri-plugin-websocket", "tauri-plugin-window-state", "tempfile", @@ -5288,7 +5321,7 @@ dependencies = [ "uuid", "windows-sys 0.61.2", "zeroize", - "zip", + "zip 2.4.2", ] [[package]] @@ -5931,6 +5964,39 @@ dependencies = [ "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", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest 0.13.2", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip 4.6.1", +] + [[package]] name = "tauri-plugin-websocket" version = "2.4.2" @@ -8052,6 +8118,18 @@ dependencies = [ "zstd", ] +[[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/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 0fe8ed2f..1374d45a 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -31,6 +31,7 @@ tauri-plugin-opener = "2" tauri-plugin-window-state = "2" tauri-plugin-websocket = "2" tauri-plugin-dialog = "2" +tauri-plugin-updater = "2" tauri-plugin-process = "2" infer = "0.19" hex = "0.4" diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index cb6618e1..e148451a 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -1,6 +1,9 @@ fn main() { println!("cargo:rerun-if-env-changed=SPROUT_RELAY_URL"); println!("cargo:rerun-if-env-changed=SPROUT_RELAY_HTTP"); + println!("cargo:rerun-if-env-changed=SPROUT_UPDATER_PUBLIC_KEY"); + println!("cargo:rerun-if-env-changed=SPROUT_UPDATER_ENDPOINT"); + println!("cargo:rustc-check-cfg=cfg(sprout_updater_enabled)"); if let Ok(relay_url) = std::env::var("SPROUT_RELAY_URL") { println!("cargo:rustc-env=SPROUT_DESKTOP_BUILD_RELAY_URL={relay_url}"); @@ -10,5 +13,18 @@ fn main() { println!("cargo:rustc-env=SPROUT_DESKTOP_BUILD_RELAY_HTTP={relay_http}"); } + let updater_public_key = std::env::var("SPROUT_UPDATER_PUBLIC_KEY") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let updater_endpoint = std::env::var("SPROUT_UPDATER_ENDPOINT") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + + if updater_public_key.is_some() && updater_endpoint.is_some() { + println!("cargo:rustc-cfg=sprout_updater_enabled"); + } + tauri_build::build() } diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index bd0565d1..fc4eae76 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -18,6 +18,9 @@ "websocket:default", "window-state:default", "dialog:default", + "updater:default", + "updater:allow-check", + "updater:allow-download-and-install", "process:allow-restart", "global-shortcut:allow-register", "global-shortcut:allow-unregister", diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index b43a3b86..1ecc9b29 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -252,6 +252,19 @@ pub fn run() { .build() }); + // Only register the updater in release builds that were compiled with a + // real updater configuration. Local unsigned builds omit that config and + // should still launch for debugging. + #[cfg(sprout_updater_enabled)] + let builder = if cfg!(debug_assertions) { + builder + } else { + builder.plugin(tauri_plugin_updater::Builder::new().build()) + }; + + #[cfg(not(sprout_updater_enabled))] + let builder = builder; + let shutdown_started = Arc::new(AtomicBool::new(false)); let restore_shutdown_started = Arc::clone(&shutdown_started); let app = builder diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index feafa248..ee42edac 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -33,6 +33,11 @@ "csp": null } }, + "plugins": { + "updater": { + "endpoints": [] + } + }, "bundle": { "active": true, "targets": "all", diff --git a/desktop/src/features/settings/UpdateChecker.tsx b/desktop/src/features/settings/UpdateChecker.tsx new file mode 100644 index 00000000..1ecfc4db --- /dev/null +++ b/desktop/src/features/settings/UpdateChecker.tsx @@ -0,0 +1,161 @@ +import { useState } from "react"; +import { check } from "@tauri-apps/plugin-updater"; +import { relaunch } from "@tauri-apps/plugin-process"; + +type UpdateStatus = + | { state: "idle" } + | { state: "checking" } + | { state: "up-to-date" } + | { state: "available"; version: string } + | { state: "downloading"; progress?: number } + | { state: "installing" } + | { state: "ready" } + | { state: "error"; message: string }; + +export function UpdateChecker() { + const [status, setStatus] = useState({ state: "idle" }); + + async function checkForUpdate() { + try { + setStatus({ state: "checking" }); + const update = await check(); + + if (update) { + setStatus({ state: "available", version: update.version }); + } else { + setStatus({ state: "up-to-date" }); + } + } catch (err) { + setStatus({ + state: "error", + message: err instanceof Error ? err.message : String(err), + }); + } + } + + async function downloadAndInstall() { + try { + setStatus({ state: "downloading" }); + const update = await check(); + if (!update) { + setStatus({ state: "up-to-date" }); + return; + } + + await update.downloadAndInstall((event) => { + if (event.event === "Started" && event.data.contentLength) { + setStatus({ state: "downloading", progress: 0 }); + } else if (event.event === "Progress") { + // Could track progress here + } else if (event.event === "Finished") { + setStatus({ state: "installing" }); + } + }); + + setStatus({ state: "ready" }); + } catch (err) { + setStatus({ + state: "error", + message: err instanceof Error ? err.message : String(err), + }); + } + } + + async function handleRelaunch() { + await relaunch(); + } + + return ( +
+

+ Software Updates +

+ + {status.state === "idle" && ( +
+

+ Check if a new version is available. +

+ +
+ )} + + {status.state === "checking" && ( +

Checking for updates...

+ )} + + {status.state === "up-to-date" && ( +
+

You're on the latest version.

+ +
+ )} + + {status.state === "available" && ( +
+

+ Version {status.version} is + available. +

+ +
+ )} + + {status.state === "downloading" && ( +

Downloading update...

+ )} + + {status.state === "installing" && ( +

Installing update...

+ )} + + {status.state === "ready" && ( +
+

+ Update installed. Restart to apply. +

+ +
+ )} + + {status.state === "error" && ( +
+

+ Update failed: {status.message} +

+ +
+ )} +
+ ); +} diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 05e68336..046c34b0 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -2,6 +2,7 @@ import { useState, useMemo, useRef } from "react"; import { BellRing, Check, + Download, Keyboard, KeyRound, MonitorCog, @@ -26,6 +27,7 @@ import { KeyboardShortcutsCard } from "./KeyboardShortcutsCard"; import { MobilePairingCard } from "./MobilePairingCard"; import { NotificationSettingsCard } from "./NotificationSettingsCard"; import { ProfileSettingsCard } from "./ProfileSettingsCard"; +import { UpdateChecker } from "../UpdateChecker"; export type SettingsSection = | "profile" @@ -34,6 +36,7 @@ export type SettingsSection = | "shortcuts" | "tokens" | "mobile" + | "updates" | "doctor"; export const DEFAULT_SETTINGS_SECTION: SettingsSection = "profile"; @@ -88,6 +91,11 @@ export const settingsSections: SettingsSectionDescriptor[] = [ label: "Mobile", icon: Smartphone, }, + { + value: "updates", + label: "Updates", + icon: Download, + }, { value: "doctor", label: "Doctor", @@ -248,6 +256,8 @@ export function renderSettingsSection( return ; case "mobile": return ; + case "updates": + return ; case "doctor": return ; default: { From 024a3ecb2f859a94bbeded96534a007eb289f218 Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 23 Apr 2026 11:42:13 -0700 Subject: [PATCH 2/2] =?UTF-8?q?fix(desktop):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20fix=20Update=20resource=20leak,=20trim=20capabiliti?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Store the Update handle in a ref and reuse it for download instead of calling check() twice (fixes resource leak and TOCTOU issue) - Close previous Update resource before re-checking - Remove redundant updater:default capability (allow-check and allow-download-and-install are sufficient) - Remove unused progress tracking field Co-Authored-By: Claude Opus 4.6 (1M context) --- desktop/src-tauri/capabilities/default.json | 1 - .../src/features/settings/UpdateChecker.tsx | 27 ++++++++++++------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index fc4eae76..bf7580f7 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -18,7 +18,6 @@ "websocket:default", "window-state:default", "dialog:default", - "updater:default", "updater:allow-check", "updater:allow-download-and-install", "process:allow-restart", diff --git a/desktop/src/features/settings/UpdateChecker.tsx b/desktop/src/features/settings/UpdateChecker.tsx index 1ecfc4db..a88d26a2 100644 --- a/desktop/src/features/settings/UpdateChecker.tsx +++ b/desktop/src/features/settings/UpdateChecker.tsx @@ -1,5 +1,5 @@ -import { useState } from "react"; -import { check } from "@tauri-apps/plugin-updater"; +import { useState, useRef, useCallback } from "react"; +import { check, type Update } from "@tauri-apps/plugin-updater"; import { relaunch } from "@tauri-apps/plugin-process"; type UpdateStatus = @@ -7,20 +7,30 @@ type UpdateStatus = | { state: "checking" } | { state: "up-to-date" } | { state: "available"; version: string } - | { state: "downloading"; progress?: number } + | { state: "downloading" } | { state: "installing" } | { state: "ready" } | { state: "error"; message: string }; export function UpdateChecker() { const [status, setStatus] = useState({ state: "idle" }); + const updateRef = useRef(null); + + const closeUpdate = useCallback(async () => { + if (updateRef.current) { + await updateRef.current.close(); + updateRef.current = null; + } + }, []); async function checkForUpdate() { try { + await closeUpdate(); setStatus({ state: "checking" }); const update = await check(); if (update) { + updateRef.current = update; setStatus({ state: "available", version: update.version }); } else { setStatus({ state: "up-to-date" }); @@ -35,19 +45,16 @@ export function UpdateChecker() { async function downloadAndInstall() { try { - setStatus({ state: "downloading" }); - const update = await check(); + const update = updateRef.current; if (!update) { setStatus({ state: "up-to-date" }); return; } + setStatus({ state: "downloading" }); + await update.downloadAndInstall((event) => { - if (event.event === "Started" && event.data.contentLength) { - setStatus({ state: "downloading", progress: 0 }); - } else if (event.event === "Progress") { - // Could track progress here - } else if (event.event === "Finished") { + if (event.event === "Finished") { setStatus({ state: "installing" }); } });