From ce5c996b35361052d72e69b4db485a861ef3e04d Mon Sep 17 00:00:00 2001 From: Gulshan Yadav Date: Sat, 10 Jan 2026 06:55:44 +0530 Subject: [PATCH] feat(desktop-wallet): add system tray and auto-updater - Add system tray with menu: Show, Hide, Lock, Check Updates, Quit - Integrate tauri-plugin-updater for seamless auto-updates - Add UpdateBanner component for update notifications - Add useAutoUpdater hook for update state management - Add useTrayEvents hook for tray event handling - Add Updates section to Settings page for manual update checks - Configure updater endpoints in tauri.conf.json - Exclude desktop-wallet from Cargo workspace (uses own Tauri deps) --- Cargo.toml | 1 + apps/desktop-wallet/package.json | 3 + apps/desktop-wallet/src-tauri/Cargo.toml | 7 +- apps/desktop-wallet/src-tauri/src/lib.rs | 198 +++++++++++++++++- apps/desktop-wallet/src-tauri/tauri.conf.json | 12 +- apps/desktop-wallet/src/App.tsx | 10 + .../src/components/UpdateBanner.tsx | 138 ++++++++++++ .../src/hooks/useAutoUpdater.ts | 133 ++++++++++++ .../desktop-wallet/src/hooks/useTrayEvents.ts | 40 ++++ apps/desktop-wallet/src/pages/Settings.tsx | 76 +++++++ 10 files changed, 614 insertions(+), 4 deletions(-) create mode 100644 apps/desktop-wallet/src/components/UpdateBanner.tsx create mode 100644 apps/desktop-wallet/src/hooks/useAutoUpdater.ts create mode 100644 apps/desktop-wallet/src/hooks/useTrayEvents.ts diff --git a/Cargo.toml b/Cargo.toml index 2b7ae70..c28f22b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ exclude = [ "contracts/dex", "contracts/staking", "crates/synor-crypto-wasm", + "apps/desktop-wallet/src-tauri", ] # WASM modules are not part of workspace as they target wasm32 diff --git a/apps/desktop-wallet/package.json b/apps/desktop-wallet/package.json index f988c93..16a1135 100644 --- a/apps/desktop-wallet/package.json +++ b/apps/desktop-wallet/package.json @@ -19,6 +19,9 @@ "@tauri-apps/plugin-shell": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.0.0", "@tauri-apps/plugin-clipboard-manager": "^2.0.0", + "@tauri-apps/plugin-updater": "^2.0.0", + "@tauri-apps/plugin-notification": "^2.0.0", + "@tauri-apps/plugin-process": "^2.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.21.0", diff --git a/apps/desktop-wallet/src-tauri/Cargo.toml b/apps/desktop-wallet/src-tauri/Cargo.toml index 5342ad8..1ddcc18 100644 --- a/apps/desktop-wallet/src-tauri/Cargo.toml +++ b/apps/desktop-wallet/src-tauri/Cargo.toml @@ -20,6 +20,9 @@ tauri-plugin-store = "2" tauri-plugin-shell = "2" tauri-plugin-dialog = "2" tauri-plugin-clipboard-manager = "2" +tauri-plugin-updater = "2" +tauri-plugin-notification = "2" +tauri-plugin-process = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } @@ -36,9 +39,9 @@ hex = "0.4" zeroize = { version = "1", features = ["derive"] } bech32 = "0.11" -# Local crates from the monorepo +# Local crates from the monorepo (optional - for direct integration with core) synor-crypto = { path = "../../../crates/synor-crypto", optional = true } -synor-primitives = { path = "../../../crates/synor-primitives", optional = true } +synor-types = { path = "../../../crates/synor-types", optional = true } synor-rpc = { path = "../../../crates/synor-rpc", optional = true } [features] diff --git a/apps/desktop-wallet/src-tauri/src/lib.rs b/apps/desktop-wallet/src-tauri/src/lib.rs index 9456c53..a3712ff 100644 --- a/apps/desktop-wallet/src-tauri/src/lib.rs +++ b/apps/desktop-wallet/src-tauri/src/lib.rs @@ -5,30 +5,137 @@ //! - Direct RPC communication with Synor nodes //! - Transaction signing with Dilithium3 post-quantum signatures //! - File system access for wallet backups +//! - System tray for background operation +//! - Auto-updates for seamless upgrades mod commands; mod crypto; mod error; mod wallet; -use tauri::Manager; +use tauri::{ + menu::{Menu, MenuItem}, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, + Emitter, Manager, Runtime, +}; pub use error::{Error, Result}; +/// Build the system tray menu +fn build_tray_menu(app: &tauri::AppHandle) -> tauri::Result> { + let show = MenuItem::with_id(app, "show", "Show Wallet", true, None::<&str>)?; + let hide = MenuItem::with_id(app, "hide", "Hide to Tray", true, None::<&str>)?; + let separator1 = MenuItem::with_id(app, "sep1", "─────────────", false, None::<&str>)?; + let lock = MenuItem::with_id(app, "lock", "Lock Wallet", true, None::<&str>)?; + let separator2 = MenuItem::with_id(app, "sep2", "─────────────", false, None::<&str>)?; + let check_updates = MenuItem::with_id(app, "check_updates", "Check for Updates", true, None::<&str>)?; + let separator3 = MenuItem::with_id(app, "sep3", "─────────────", false, None::<&str>)?; + let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; + + Menu::with_items( + app, + &[ + &show, + &hide, + &separator1, + &lock, + &separator2, + &check_updates, + &separator3, + &quit, + ], + ) +} + +/// Handle system tray menu events +fn handle_tray_event(app: &tauri::AppHandle, event: TrayIconEvent) { + match event { + TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } => { + // Left click: show and focus the window + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + } + _ => {} + } +} + +/// Handle tray menu item clicks +fn handle_menu_event(app: &tauri::AppHandle, event: tauri::menu::MenuEvent) { + match event.id().as_ref() { + "show" => { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + } + "hide" => { + if let Some(window) = app.get_webview_window("main") { + let _ = window.hide(); + } + } + "lock" => { + // Emit lock event to frontend + let _ = app.emit("wallet-lock", ()); + } + "check_updates" => { + // Emit update check event to frontend + let _ = app.emit("check-updates", ()); + } + "quit" => { + // Emit quit event to allow cleanup + let _ = app.emit("app-quit", ()); + std::process::exit(0); + } + _ => {} + } +} + /// Initialize the Tauri application with all plugins and commands #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + // Core plugins .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_store::Builder::default().build()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_clipboard_manager::init()) + // New plugins for desktop features + .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_notification::init()) + .plugin(tauri_plugin_process::init()) .setup(|app| { // Initialize wallet state let wallet_state = wallet::WalletState::new(); app.manage(wallet_state); + // Build and set up system tray + let menu = build_tray_menu(app.handle())?; + let _tray = TrayIconBuilder::new() + .icon(app.default_window_icon().unwrap().clone()) + .menu(&menu) + .menu_on_left_click(false) + .on_tray_icon_event(|tray, event| { + handle_tray_event(tray.app_handle(), event); + }) + .on_menu_event(|app, event| { + handle_menu_event(app, event); + }) + .tooltip("Synor Wallet") + .build(app)?; + + // Check for updates on startup (non-blocking) + let handle = app.handle().clone(); + tauri::async_runtime::spawn(async move { + check_for_updates(handle).await; + }); + #[cfg(debug_assertions)] { // Open devtools in development @@ -61,7 +168,96 @@ pub fn run() { commands::connect_node, commands::disconnect_node, commands::get_network_status, + // Updates + check_update, + install_update, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } + +/// Check for updates on startup +async fn check_for_updates(app: tauri::AppHandle) { + use tauri_plugin_updater::UpdaterExt; + + // Wait a few seconds before checking for updates + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + + let updater = match app.updater() { + Ok(u) => u, + Err(e) => { + eprintln!("Updater not available: {}", e); + return; + } + }; + + match updater.check().await { + Ok(Some(update)) => { + // Notify frontend about available update + let _ = app.emit("update-available", serde_json::json!({ + "version": update.version, + "body": update.body, + "date": update.date + })); + } + Ok(None) => { + // No update available + } + Err(e) => { + eprintln!("Failed to check for updates: {}", e); + } + } +} + +/// Tauri command: Check for updates +#[tauri::command] +async fn check_update(app: tauri::AppHandle) -> std::result::Result, String> { + use tauri_plugin_updater::UpdaterExt; + + let updater = app.updater().map_err(|e| format!("Updater not configured: {}", e))?; + + match updater.check().await { + Ok(Some(update)) => { + Ok(Some(serde_json::json!({ + "version": update.version, + "body": update.body, + "date": update.date + }))) + } + Ok(None) => Ok(None), + Err(e) => Err(format!("Update check failed: {}", e)), + } +} + +/// Tauri command: Install update +#[tauri::command] +async fn install_update(app: tauri::AppHandle) -> std::result::Result<(), String> { + use tauri_plugin_updater::UpdaterExt; + + let updater = app.updater().map_err(|e| format!("Updater not configured: {}", e))?; + + match updater.check().await { + Ok(Some(update)) => { + // Download and install with progress callbacks + let downloaded: u64 = 0; + let total_size: u64 = 0; + + update.download_and_install( + move |chunk_len, content_len| { + // Progress callback - could emit progress events here + let _ = (chunk_len, content_len, downloaded, total_size); + }, + || { + // Ready to install callback + }, + ).await.map_err(|e| format!("Update installation failed: {}", e))?; + + // Notify user to restart + let _ = app.emit("update-installed", ()); + + Ok(()) + } + Ok(None) => Err("No update available".to_string()), + Err(e) => Err(format!("Update check failed: {}", e)), + } +} diff --git a/apps/desktop-wallet/src-tauri/tauri.conf.json b/apps/desktop-wallet/src-tauri/tauri.conf.json index 483a78a..319b7ea 100644 --- a/apps/desktop-wallet/src-tauri/tauri.conf.json +++ b/apps/desktop-wallet/src-tauri/tauri.conf.json @@ -44,7 +44,17 @@ "open": true }, "dialog": {}, - "clipboard-manager": {} + "clipboard-manager": {}, + "updater": { + "endpoints": [ + "https://releases.synor.io/wallet/{{target}}/{{arch}}/{{current_version}}" + ], + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IFNZTk9SIFdBTExFVApSV1RiRE5QYVdvOHVqeTFGMVdXR0dZR3ROQ0Y4bno1dXl4SWJocjd1V3pvbnZ6Y0xSNjNyNjNUdgo=", + "windows": { + "installMode": "passive" + } + }, + "notification": {} }, "bundle": { "active": true, diff --git a/apps/desktop-wallet/src/App.tsx b/apps/desktop-wallet/src/App.tsx index b3cb32f..40343da 100644 --- a/apps/desktop-wallet/src/App.tsx +++ b/apps/desktop-wallet/src/App.tsx @@ -4,6 +4,10 @@ import { useWalletStore } from './store/wallet'; // Layout import Layout from './components/Layout'; import TitleBar from './components/TitleBar'; +import UpdateBanner from './components/UpdateBanner'; + +// Hooks +import { useTrayEvents } from './hooks/useTrayEvents'; // Pages import Welcome from './pages/Welcome'; @@ -19,8 +23,14 @@ import Settings from './pages/Settings'; function App() { const { isInitialized, isUnlocked } = useWalletStore(); + // Listen for system tray events + useTrayEvents(); + return (
+ {/* Update notification banner */} + + {/* Custom title bar for frameless window (optional) */} diff --git a/apps/desktop-wallet/src/components/UpdateBanner.tsx b/apps/desktop-wallet/src/components/UpdateBanner.tsx new file mode 100644 index 0000000..86ba5e2 --- /dev/null +++ b/apps/desktop-wallet/src/components/UpdateBanner.tsx @@ -0,0 +1,138 @@ +/** + * Update notification banner. + * + * Displays when a new version is available. + */ + +import { useAutoUpdater } from '../hooks/useAutoUpdater'; + +export function UpdateBanner() { + const { + available, + downloading, + installed, + updateInfo, + installUpdate, + dismissUpdate, + } = useAutoUpdater(); + + // Don't render if no update available + if (!available && !installed) { + return null; + } + + // Show restart prompt after install + if (installed) { + return ( +
+
+ + + + + Update installed! Restart to apply changes. + +
+ +
+ ); + } + + // Show update available banner + return ( +
+
+ + + + + {updateInfo?.version + ? `Version ${updateInfo.version} is available` + : 'A new update is available'} + + {updateInfo?.body && ( + + - {updateInfo.body.slice(0, 50)} + {updateInfo.body.length > 50 ? '...' : ''} + + )} +
+ +
+ {downloading ? ( +
+ + + + Downloading... +
+ ) : ( + <> + + + + )} +
+
+ ); +} + +export default UpdateBanner; diff --git a/apps/desktop-wallet/src/hooks/useAutoUpdater.ts b/apps/desktop-wallet/src/hooks/useAutoUpdater.ts new file mode 100644 index 0000000..0b1e53a --- /dev/null +++ b/apps/desktop-wallet/src/hooks/useAutoUpdater.ts @@ -0,0 +1,133 @@ +/** + * Auto-updater hook for desktop wallet. + * + * Listens to backend events and provides update functionality. + */ + +import { useEffect, useState, useCallback } from 'react'; +import { listen } from '@tauri-apps/api/event'; +import { invoke } from '@tauri-apps/api/core'; + +export interface UpdateInfo { + version: string; + body?: string; + date?: string; + downloadUrl?: string; +} + +export interface UpdateState { + checking: boolean; + available: boolean; + downloading: boolean; + installed: boolean; + error: string | null; + updateInfo: UpdateInfo | null; +} + +export function useAutoUpdater() { + const [state, setState] = useState({ + checking: false, + available: false, + downloading: false, + installed: false, + error: null, + updateInfo: null, + }); + + // Listen for backend events + useEffect(() => { + const unlisteners: (() => void)[] = []; + + // Update available notification from backend + listen('update-available', (event) => { + setState((prev) => ({ + ...prev, + available: true, + updateInfo: event.payload, + })); + }).then((unlisten) => unlisteners.push(unlisten)); + + // Update installed notification + listen('update-installed', () => { + setState((prev) => ({ + ...prev, + downloading: false, + installed: true, + })); + }).then((unlisten) => unlisteners.push(unlisten)); + + // Check updates trigger from tray + listen('check-updates', () => { + checkForUpdates(); + }).then((unlisten) => unlisteners.push(unlisten)); + + return () => { + unlisteners.forEach((unlisten) => unlisten()); + }; + }, []); + + // Check for updates manually + const checkForUpdates = useCallback(async () => { + setState((prev) => ({ ...prev, checking: true, error: null })); + + try { + const result = await invoke('check_update'); + + if (result) { + setState((prev) => ({ + ...prev, + checking: false, + available: true, + updateInfo: result, + })); + } else { + setState((prev) => ({ + ...prev, + checking: false, + available: false, + updateInfo: null, + })); + } + } catch (error) { + setState((prev) => ({ + ...prev, + checking: false, + error: error instanceof Error ? error.message : String(error), + })); + } + }, []); + + // Install available update + const installUpdate = useCallback(async () => { + if (!state.available) return; + + setState((prev) => ({ ...prev, downloading: true, error: null })); + + try { + await invoke('install_update'); + // The 'update-installed' event will set the installed state + } catch (error) { + setState((prev) => ({ + ...prev, + downloading: false, + error: error instanceof Error ? error.message : String(error), + })); + } + }, [state.available]); + + // Dismiss update notification + const dismissUpdate = useCallback(() => { + setState((prev) => ({ + ...prev, + available: false, + updateInfo: null, + })); + }, []); + + return { + ...state, + checkForUpdates, + installUpdate, + dismissUpdate, + }; +} diff --git a/apps/desktop-wallet/src/hooks/useTrayEvents.ts b/apps/desktop-wallet/src/hooks/useTrayEvents.ts new file mode 100644 index 0000000..dd1522b --- /dev/null +++ b/apps/desktop-wallet/src/hooks/useTrayEvents.ts @@ -0,0 +1,40 @@ +/** + * System tray event hook for desktop wallet. + * + * Handles events triggered from the system tray menu. + */ + +import { useEffect } from 'react'; +import { listen } from '@tauri-apps/api/event'; +import { useNavigate } from 'react-router-dom'; +import { useWalletStore } from '../store/wallet'; + +export function useTrayEvents() { + const navigate = useNavigate(); + const { lockWallet, isUnlocked } = useWalletStore(); + + useEffect(() => { + const unlisteners: (() => void)[] = []; + + // Handle wallet lock from tray + listen('wallet-lock', async () => { + if (isUnlocked) { + await lockWallet(); + navigate('/unlock'); + } + }).then((unlisten) => unlisteners.push(unlisten)); + + // Handle app quit from tray + listen('app-quit', async () => { + // Perform cleanup if wallet is unlocked + if (isUnlocked) { + await lockWallet(); + } + // The backend will handle the actual quit + }).then((unlisten) => unlisteners.push(unlisten)); + + return () => { + unlisteners.forEach((unlisten) => unlisten()); + }; + }, [isUnlocked, lockWallet, navigate]); +} diff --git a/apps/desktop-wallet/src/pages/Settings.tsx b/apps/desktop-wallet/src/pages/Settings.tsx index bf75034..f3bc17b 100644 --- a/apps/desktop-wallet/src/pages/Settings.tsx +++ b/apps/desktop-wallet/src/pages/Settings.tsx @@ -7,11 +7,22 @@ import { AlertTriangle, Check, RefreshCw, + Download, } from 'lucide-react'; import { useWalletStore } from '../store/wallet'; +import { useAutoUpdater } from '../hooks/useAutoUpdater'; export default function Settings() { const { networkStatus, connectNode } = useWalletStore(); + const { + checking, + available, + downloading, + error: updateError, + updateInfo, + checkForUpdates, + installUpdate, + } = useAutoUpdater(); const [rpcUrl, setRpcUrl] = useState( 'http://localhost:17110' @@ -247,6 +258,71 @@ export default function Settings() {
+ {/* Updates */} +
+
+ +

Updates

+
+ +
+ {available && updateInfo ? ( +
+
+
+

+ Version {updateInfo.version} available +

+ {updateInfo.body && ( +

+ {updateInfo.body} +

+ )} +
+ +
+
+ ) : ( +
+

+ {checking ? 'Checking for updates...' : 'No updates available'} +

+ +
+ )} + + {updateError && ( +

+ + {updateError} +

+ )} +
+
+ {/* Version info */}
Synor Wallet v0.1.0