From cccae351692fec56f8abfeafffa579dd4d672cad Mon Sep 17 00:00:00 2001 From: Jure <44338+hoornet@users.noreply.github.com> Date: Tue, 10 Mar 2026 19:29:00 +0100 Subject: [PATCH] Add system tray / minimize to tray (roadmap #8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tauri "tray-icon" feature enabled - Tray icon created on startup using the app's default window icon - Tray menu: "Open Wrystr" (show + focus) and "Quit" (app.exit) - Left-click on tray icon: show + focus the main window - Right-click: context menu (platform default) - Window close button (X) now hides to tray instead of exiting — "Quit" in the tray menu is the exit point - Works on Windows (system tray), Linux (status bar tray), macOS (menu bar); DE-specific tray support varies on Linux Co-Authored-By: Claude Sonnet 4.6 --- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/lib.rs | 68 ++++++++++++++++++++++++++++++++++++-------- 3 files changed, 58 insertions(+), 14 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 964fdf5..8106618 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5066,7 +5066,7 @@ dependencies = [ [[package]] name = "wrystr" -version = "0.1.1" +version = "0.1.2" dependencies = [ "keyring", "rusqlite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cf67f70..20ea14f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = ["devtools"] } +tauri = { version = "2", features = ["devtools", "tray-icon"] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bf5598a..d39a6eb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,20 +1,22 @@ use keyring::Entry; use rusqlite::{params, Connection}; use std::sync::Mutex; -use tauri::Manager; +use tauri::{ + menu::{Menu, MenuItem}, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, + Manager, WindowEvent, +}; // ── OS keychain ───────────────────────────────────────────────────────────── const KEYRING_SERVICE: &str = "wrystr"; -/// Store an nsec in the OS keychain, keyed by pubkey (hex). #[tauri::command] fn store_nsec(pubkey: String, nsec: String) -> Result<(), String> { let entry = Entry::new(KEYRING_SERVICE, &pubkey).map_err(|e| e.to_string())?; entry.set_password(&nsec).map_err(|e| e.to_string()) } -/// Load a stored nsec from the OS keychain. Returns None if no entry exists. #[tauri::command] fn load_nsec(pubkey: String) -> Result, String> { let entry = Entry::new(KEYRING_SERVICE, &pubkey).map_err(|e| e.to_string())?; @@ -25,13 +27,12 @@ fn load_nsec(pubkey: String) -> Result, String> { } } -/// Delete a stored nsec from the OS keychain. #[tauri::command] fn delete_nsec(pubkey: String) -> Result<(), String> { let entry = Entry::new(KEYRING_SERVICE, &pubkey).map_err(|e| e.to_string())?; match entry.delete_credential() { Ok(()) => Ok(()), - Err(keyring::Error::NoEntry) => Ok(()), // already gone — that's fine + Err(keyring::Error::NoEntry) => Ok(()), Err(e) => Err(e.to_string()), } } @@ -63,8 +64,6 @@ fn open_db(data_dir: std::path::PathBuf) -> rusqlite::Result { Ok(conn) } -/// Upsert a batch of raw Nostr event JSON strings into the notes cache. -/// Prunes the kind-1 table to the most recent 500 entries after insert. #[tauri::command] fn db_save_notes(state: tauri::State, notes: Vec) -> Result<(), String> { let conn = state.0.lock().map_err(|e| e.to_string())?; @@ -80,7 +79,6 @@ fn db_save_notes(state: tauri::State, notes: Vec) -> Result<(), ) .map_err(|e| e.to_string())?; } - // Keep only the most recent 500 kind-1 notes conn.execute( "DELETE FROM notes WHERE kind=1 AND id NOT IN \ (SELECT id FROM notes WHERE kind=1 ORDER BY created_at DESC LIMIT 500)", @@ -90,7 +88,6 @@ fn db_save_notes(state: tauri::State, notes: Vec) -> Result<(), Ok(()) } -/// Return up to `limit` recent kind-1 note JSONs, newest first. #[tauri::command] fn db_load_feed(state: tauri::State, limit: u32) -> Result, String> { let conn = state.0.lock().map_err(|e| e.to_string())?; @@ -107,7 +104,6 @@ fn db_load_feed(state: tauri::State, limit: u32) -> Result, Ok(result) } -/// Cache a profile's JSON content (the NDKUserProfile object) keyed by pubkey. #[tauri::command] fn db_save_profile(state: tauri::State, pubkey: String, content: String) -> Result<(), String> { let conn = state.0.lock().map_err(|e| e.to_string())?; @@ -123,7 +119,6 @@ fn db_save_profile(state: tauri::State, pubkey: String, content: String Ok(()) } -/// Load a cached profile JSON for `pubkey`. Returns None if not cached. #[tauri::command] fn db_load_profile(state: tauri::State, pubkey: String) -> Result, String> { let conn = state.0.lock().map_err(|e| e.to_string())?; @@ -145,11 +140,60 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .setup(|app| { + // ── SQLite ─────────────────────────────────────────────────────── let data_dir = app.path().app_data_dir()?; - // Fall back to in-memory DB if the on-disk open fails (e.g. permissions). let conn = open_db(data_dir) .unwrap_or_else(|_| Connection::open_in_memory().expect("in-memory SQLite")); app.manage(DbState(Mutex::new(conn))); + + // ── System tray ────────────────────────────────────────────────── + let show_item = MenuItem::with_id(app, "show", "Open Wrystr", true, None::<&str>)?; + let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; + let menu = Menu::with_items(app, &[&show_item, &quit_item])?; + + let icon = app.default_window_icon().unwrap().clone(); + TrayIconBuilder::new() + .icon(icon) + .menu(&menu) + .show_menu_on_left_click(false) // left click → show window, right click → menu + .on_menu_event(|app, event| match event.id.as_ref() { + "quit" => app.exit(0), + "show" => { + if let Some(w) = app.get_webview_window("main") { + let _ = w.show(); + let _ = w.set_focus(); + } + } + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + let app = tray.app_handle(); + if let Some(w) = app.get_webview_window("main") { + let _ = w.show(); + let _ = w.set_focus(); + } + } + }) + .build(app)?; + + // ── Close → hide to tray ───────────────────────────────────────── + // Closing the window hides it instead of exiting. Use "Quit" in the + // tray menu (or ⌘Q / Alt-F4) to fully exit. + let window = app.get_webview_window("main").unwrap(); + let window_clone = window.clone(); + window.on_window_event(move |event| { + if let WindowEvent::CloseRequested { api, .. } = event { + api.prevent_close(); + let _ = window_clone.hide(); + } + }); + Ok(()) }) .invoke_handler(tauri::generate_handler![