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..bf7580f7 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -18,6 +18,8 @@ "websocket:default", "window-state:default", "dialog: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..a88d26a2 --- /dev/null +++ b/desktop/src/features/settings/UpdateChecker.tsx @@ -0,0 +1,168 @@ +import { useState, useRef, useCallback } from "react"; +import { check, type Update } 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" } + | { 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" }); + } + } catch (err) { + setStatus({ + state: "error", + message: err instanceof Error ? err.message : String(err), + }); + } + } + + async function downloadAndInstall() { + try { + const update = updateRef.current; + if (!update) { + setStatus({ state: "up-to-date" }); + return; + } + + setStatus({ state: "downloading" }); + + await update.downloadAndInstall((event) => { + 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: {