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)
This commit is contained in:
Gulshan Yadav 2026-01-10 06:55:44 +05:30
parent a6233f285d
commit ce5c996b35
10 changed files with 614 additions and 4 deletions

View file

@ -25,6 +25,7 @@ exclude = [
"contracts/dex", "contracts/dex",
"contracts/staking", "contracts/staking",
"crates/synor-crypto-wasm", "crates/synor-crypto-wasm",
"apps/desktop-wallet/src-tauri",
] ]
# WASM modules are not part of workspace as they target wasm32 # WASM modules are not part of workspace as they target wasm32

View file

@ -19,6 +19,9 @@
"@tauri-apps/plugin-shell": "^2.0.0", "@tauri-apps/plugin-shell": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-clipboard-manager": "^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": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.21.0", "react-router-dom": "^6.21.0",

View file

@ -20,6 +20,9 @@ tauri-plugin-store = "2"
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-clipboard-manager = "2" tauri-plugin-clipboard-manager = "2"
tauri-plugin-updater = "2"
tauri-plugin-notification = "2"
tauri-plugin-process = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
@ -36,9 +39,9 @@ hex = "0.4"
zeroize = { version = "1", features = ["derive"] } zeroize = { version = "1", features = ["derive"] }
bech32 = "0.11" 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-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 } synor-rpc = { path = "../../../crates/synor-rpc", optional = true }
[features] [features]

View file

@ -5,30 +5,137 @@
//! - Direct RPC communication with Synor nodes //! - Direct RPC communication with Synor nodes
//! - Transaction signing with Dilithium3 post-quantum signatures //! - Transaction signing with Dilithium3 post-quantum signatures
//! - File system access for wallet backups //! - File system access for wallet backups
//! - System tray for background operation
//! - Auto-updates for seamless upgrades
mod commands; mod commands;
mod crypto; mod crypto;
mod error; mod error;
mod wallet; mod wallet;
use tauri::Manager; use tauri::{
menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
Emitter, Manager, Runtime,
};
pub use error::{Error, Result}; pub use error::{Error, Result};
/// Build the system tray menu
fn build_tray_menu<R: Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>> {
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<R: Runtime>(app: &tauri::AppHandle<R>, 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<R: Runtime>(app: &tauri::AppHandle<R>, 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 /// Initialize the Tauri application with all plugins and commands
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
// Core plugins
.plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_store::Builder::default().build()) .plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_clipboard_manager::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| { .setup(|app| {
// Initialize wallet state // Initialize wallet state
let wallet_state = wallet::WalletState::new(); let wallet_state = wallet::WalletState::new();
app.manage(wallet_state); 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)] #[cfg(debug_assertions)]
{ {
// Open devtools in development // Open devtools in development
@ -61,7 +168,96 @@ pub fn run() {
commands::connect_node, commands::connect_node,
commands::disconnect_node, commands::disconnect_node,
commands::get_network_status, commands::get_network_status,
// Updates
check_update,
install_update,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }
/// Check for updates on startup
async fn check_for_updates<R: Runtime>(app: tauri::AppHandle<R>) {
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<R: Runtime>(app: tauri::AppHandle<R>) -> std::result::Result<Option<serde_json::Value>, 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<R: Runtime>(app: tauri::AppHandle<R>) -> 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)),
}
}

View file

@ -44,7 +44,17 @@
"open": true "open": true
}, },
"dialog": {}, "dialog": {},
"clipboard-manager": {} "clipboard-manager": {},
"updater": {
"endpoints": [
"https://releases.synor.io/wallet/{{target}}/{{arch}}/{{current_version}}"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IFNZTk9SIFdBTExFVApSV1RiRE5QYVdvOHVqeTFGMVdXR0dZR3ROQ0Y4bno1dXl4SWJocjd1V3pvbnZ6Y0xSNjNyNjNUdgo=",
"windows": {
"installMode": "passive"
}
},
"notification": {}
}, },
"bundle": { "bundle": {
"active": true, "active": true,

View file

@ -4,6 +4,10 @@ import { useWalletStore } from './store/wallet';
// Layout // Layout
import Layout from './components/Layout'; import Layout from './components/Layout';
import TitleBar from './components/TitleBar'; import TitleBar from './components/TitleBar';
import UpdateBanner from './components/UpdateBanner';
// Hooks
import { useTrayEvents } from './hooks/useTrayEvents';
// Pages // Pages
import Welcome from './pages/Welcome'; import Welcome from './pages/Welcome';
@ -19,8 +23,14 @@ import Settings from './pages/Settings';
function App() { function App() {
const { isInitialized, isUnlocked } = useWalletStore(); const { isInitialized, isUnlocked } = useWalletStore();
// Listen for system tray events
useTrayEvents();
return ( return (
<div className="h-screen flex flex-col bg-gray-950"> <div className="h-screen flex flex-col bg-gray-950">
{/* Update notification banner */}
<UpdateBanner />
{/* Custom title bar for frameless window (optional) */} {/* Custom title bar for frameless window (optional) */}
<TitleBar /> <TitleBar />

View file

@ -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 (
<div className="bg-green-900/80 border-b border-green-700 px-4 py-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<svg
className="w-5 h-5 text-green-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
<span className="text-green-100 text-sm">
Update installed! Restart to apply changes.
</span>
</div>
<button
onClick={() => window.location.reload()}
className="px-3 py-1 text-sm bg-green-600 hover:bg-green-500 text-white rounded transition-colors"
>
Restart Now
</button>
</div>
);
}
// Show update available banner
return (
<div className="bg-blue-900/80 border-b border-blue-700 px-4 py-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<svg
className="w-5 h-5 text-blue-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
<span className="text-blue-100 text-sm">
{updateInfo?.version
? `Version ${updateInfo.version} is available`
: 'A new update is available'}
</span>
{updateInfo?.body && (
<span className="text-blue-300 text-xs hidden sm:inline">
- {updateInfo.body.slice(0, 50)}
{updateInfo.body.length > 50 ? '...' : ''}
</span>
)}
</div>
<div className="flex items-center gap-2">
{downloading ? (
<div className="flex items-center gap-2 text-blue-200 text-sm">
<svg
className="w-4 h-4 animate-spin"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Downloading...
</div>
) : (
<>
<button
onClick={installUpdate}
className="px-3 py-1 text-sm bg-blue-600 hover:bg-blue-500 text-white rounded transition-colors"
>
Install Update
</button>
<button
onClick={dismissUpdate}
className="p-1 text-blue-300 hover:text-blue-100 transition-colors"
aria-label="Dismiss"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</>
)}
</div>
</div>
);
}
export default UpdateBanner;

View file

@ -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<UpdateState>({
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<UpdateInfo>('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<UpdateInfo | null>('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,
};
}

View file

@ -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]);
}

View file

@ -7,11 +7,22 @@ import {
AlertTriangle, AlertTriangle,
Check, Check,
RefreshCw, RefreshCw,
Download,
} from 'lucide-react'; } from 'lucide-react';
import { useWalletStore } from '../store/wallet'; import { useWalletStore } from '../store/wallet';
import { useAutoUpdater } from '../hooks/useAutoUpdater';
export default function Settings() { export default function Settings() {
const { networkStatus, connectNode } = useWalletStore(); const { networkStatus, connectNode } = useWalletStore();
const {
checking,
available,
downloading,
error: updateError,
updateInfo,
checkForUpdates,
installUpdate,
} = useAutoUpdater();
const [rpcUrl, setRpcUrl] = useState( const [rpcUrl, setRpcUrl] = useState(
'http://localhost:17110' 'http://localhost:17110'
@ -247,6 +258,71 @@ export default function Settings() {
</div> </div>
</div> </div>
{/* Updates */}
<div className="card">
<div className="flex items-center gap-2 mb-4">
<Download size={20} className="text-gray-400" />
<h2 className="text-lg font-semibold text-white">Updates</h2>
</div>
<div className="space-y-4">
{available && updateInfo ? (
<div className="p-4 bg-blue-900/20 border border-blue-700/50 rounded-lg">
<div className="flex items-center justify-between">
<div>
<p className="text-blue-200 font-medium">
Version {updateInfo.version} available
</p>
{updateInfo.body && (
<p className="text-sm text-blue-300/70 mt-1">
{updateInfo.body}
</p>
)}
</div>
<button
onClick={installUpdate}
disabled={downloading}
className="btn btn-primary"
>
{downloading ? (
<>
<RefreshCw size={18} className="animate-spin" />
Downloading...
</>
) : (
'Install Update'
)}
</button>
</div>
</div>
) : (
<div className="flex items-center justify-between">
<p className="text-gray-400 text-sm">
{checking ? 'Checking for updates...' : 'No updates available'}
</p>
<button
onClick={checkForUpdates}
disabled={checking}
className="btn btn-secondary"
>
{checking ? (
<RefreshCw size={18} className="animate-spin" />
) : (
'Check for Updates'
)}
</button>
</div>
)}
{updateError && (
<p className="text-red-400 text-sm flex items-center gap-2">
<AlertTriangle size={16} />
{updateError}
</p>
)}
</div>
</div>
{/* Version info */} {/* Version info */}
<div className="text-center text-sm text-gray-600"> <div className="text-center text-sm text-gray-600">
Synor Wallet v0.1.0 Synor Wallet v0.1.0