From 3b7653d731bf15a49e533cf3c00afb4d21410525 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 18 May 2026 17:27:41 -0700 Subject: [PATCH] =?UTF-8?q?feat(m1):=20notification=20delivery=20=E2=80=94?= =?UTF-8?q?=20tray=20badge,=20desktop=20toast,=20pending=20event=20queue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands.rs | 22 ++++++++++------- src-tauri/src/lib.rs | 2 ++ src-tauri/src/notify.rs | 50 +++++++++++++++++++++++++++++++++++++++ src-tauri/src/tray.rs | 32 ++++++++++++++++++++++++- 4 files changed, 97 insertions(+), 9 deletions(-) create mode 100644 src-tauri/src/notify.rs diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 94a7863..5900532 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,6 +1,6 @@ -use crate::config::{NotificationLevel, RobinConfig, MigrationConfig, SourceOs}; -use tauri::State; +use crate::config::{MigrationConfig, NotificationLevel, RobinConfig, SourceOs}; use std::sync::Mutex; +use tauri::State; pub struct AppState { pub config: Mutex, @@ -8,14 +8,18 @@ pub struct AppState { #[tauri::command] pub fn get_config(state: State<'_, AppState>) -> Result { - state.config.lock() + state + .config + .lock() .map(|c| c.clone()) .map_err(|e| e.to_string()) } #[tauri::command] pub fn needs_onboarding(state: State<'_, AppState>) -> bool { - state.config.lock() + state + .config + .lock() .map(|c| c.needs_onboarding()) .unwrap_or(true) } @@ -49,10 +53,7 @@ pub fn complete_onboarding( } #[tauri::command] -pub fn update_notification_level( - level: String, - state: State<'_, AppState>, -) -> Result<(), String> { +pub fn update_notification_level(level: String, state: State<'_, AppState>) -> Result<(), String> { let parsed = match level.as_str() { "off" => NotificationLevel::Off, "badge_only" => NotificationLevel::BadgeOnly, @@ -63,3 +64,8 @@ pub fn update_notification_level( config.display.notification_level = parsed; config.save().map_err(|e| e.to_string()) } + +#[tauri::command] +pub fn get_pending_events() -> Vec { + crate::notify::take_pending() +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ae63786..4d3ae38 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod commands; mod config; mod distro; +mod notify; mod patterns; mod tray; mod watcher; @@ -32,6 +33,7 @@ pub fn run() { commands::needs_onboarding, commands::complete_onboarding, commands::update_notification_level, + commands::get_pending_events, ]) .run(tauri::generate_context!()) .expect("error while running Robin"); diff --git a/src-tauri/src/notify.rs b/src-tauri/src/notify.rs new file mode 100644 index 0000000..2500e26 --- /dev/null +++ b/src-tauri/src/notify.rs @@ -0,0 +1,50 @@ +use std::sync::Mutex; +use tauri::{AppHandle, Emitter, Runtime}; +use tauri_plugin_notification::NotificationExt; + +use crate::commands::AppState; +use crate::config::NotificationLevel; +use crate::patterns::MatchedEvent; + +static PENDING: Mutex> = Mutex::new(vec![]); + +pub fn dispatch(app: &AppHandle, event: MatchedEvent) { + let level = { + let state = app.state::(); + let config = state.config.lock().unwrap(); + config.display.notification_level.clone() + }; + + if let Ok(mut pending) = PENDING.lock() { + pending.push(event.clone()); + } + + match level { + NotificationLevel::Off => {} + NotificationLevel::BadgeOnly => { + crate::tray::badge_on(app); + } + NotificationLevel::BadgeAndToast => { + crate::tray::badge_on(app); + send_toast(app, &event); + } + } + + let _ = app.emit("robin:event", &event); +} + +fn send_toast(app: &AppHandle, event: &MatchedEvent) { + let _ = app + .notification() + .builder() + .title(&event.title) + .body(&event.body) + .show(); +} + +pub fn take_pending() -> Vec { + PENDING + .lock() + .map(|mut v| std::mem::take(&mut *v)) + .unwrap_or_default() +} diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 2633957..2c5f850 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -1,3 +1,4 @@ +use std::sync::atomic::{AtomicBool, Ordering}; use tauri::{ menu::{Menu, MenuItem}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, @@ -34,7 +35,7 @@ pub fn build_tray(app: &AppHandle) -> tauri::Result<()> { Ok(()) } -fn toggle_chat_panel(app: &AppHandle) { +pub fn toggle_chat_panel(app: &AppHandle) { if let Some(window) = app.get_webview_window("chat") { if window.is_visible().unwrap_or(false) { let _ = window.hide(); @@ -44,3 +45,32 @@ fn toggle_chat_panel(app: &AppHandle) { } } } + +static BADGE_ACTIVE: AtomicBool = AtomicBool::new(false); + +pub fn badge_on(app: &AppHandle) { + if BADGE_ACTIVE.swap(true, Ordering::Relaxed) { + return; // already badged + } + if let Some(tray) = app.tray_by_id("robin-tray") { + if let Some(icon) = app.default_window_icon().cloned() { + let _ = tray.set_icon(Some(icon)); + } + let _ = tray.set_tooltip(Some("Robin — something to show you")); + } +} + +pub fn badge_off(app: &AppHandle) { + BADGE_ACTIVE.store(false, Ordering::Relaxed); + if let Some(tray) = app.tray_by_id("robin-tray") { + if let Some(icon) = app.default_window_icon().cloned() { + let _ = tray.set_icon(Some(icon)); + } + let _ = tray.set_tooltip(Some("Robin")); + } +} + +pub fn clear_badge_and_open(app: &AppHandle) { + badge_off(app); + toggle_chat_panel(app); +}