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:
parent
a6233f285d
commit
ce5c996b35
10 changed files with 614 additions and 4 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
||||||
|
|
|
||||||
138
apps/desktop-wallet/src/components/UpdateBanner.tsx
Normal file
138
apps/desktop-wallet/src/components/UpdateBanner.tsx
Normal 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;
|
||||||
133
apps/desktop-wallet/src/hooks/useAutoUpdater.ts
Normal file
133
apps/desktop-wallet/src/hooks/useAutoUpdater.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
40
apps/desktop-wallet/src/hooks/useTrayEvents.ts
Normal file
40
apps/desktop-wallet/src/hooks/useTrayEvents.ts
Normal 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]);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue