From f53098106db04ef87fd4843085f61d175c5a1bc9 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Tue, 26 Aug 2025 20:28:25 -0700 Subject: [PATCH 1/4] wip --- Cargo.lock | 1 + crates/notification/src/lib.rs | 1 + plugins/notification/Cargo.toml | 1 + plugins/notification/src/detect.rs | 57 +++++++++++++++++++ .../notification/src/{worker.rs => event.rs} | 1 + plugins/notification/src/ext.rs | 36 ++---------- plugins/notification/src/lib.rs | 35 ++++++------ 7 files changed, 84 insertions(+), 48 deletions(-) create mode 100644 plugins/notification/src/detect.rs rename plugins/notification/src/{worker.rs => event.rs} (98%) diff --git a/Cargo.lock b/Cargo.lock index e8c71a571..59ad46217 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14819,6 +14819,7 @@ dependencies = [ "tauri-plugin-db", "tauri-plugin-store", "tauri-plugin-store2", + "tauri-plugin-windows", "tauri-specta", "thiserror 2.0.12", "tokio", diff --git a/crates/notification/src/lib.rs b/crates/notification/src/lib.rs index 2dfa5971e..a5fe4042b 100644 --- a/crates/notification/src/lib.rs +++ b/crates/notification/src/lib.rs @@ -5,6 +5,7 @@ use std::time::{Duration, Instant}; pub use hypr_notification_interface::*; static RECENT_NOTIFICATIONS: OnceLock>> = OnceLock::new(); + const DEDUPE_WINDOW: Duration = Duration::from_secs(60 * 5); #[cfg(target_os = "macos")] diff --git a/plugins/notification/Cargo.toml b/plugins/notification/Cargo.toml index feb5d42f1..b3b2a424b 100644 --- a/plugins/notification/Cargo.toml +++ b/plugins/notification/Cargo.toml @@ -21,6 +21,7 @@ hypr-notification = { workspace = true } tauri-plugin-db = { workspace = true } tauri-plugin-store2 = { workspace = true } +tauri-plugin-windows = { workspace = true } serde = { workspace = true } specta = { workspace = true } diff --git a/plugins/notification/src/detect.rs b/plugins/notification/src/detect.rs new file mode 100644 index 000000000..6479a9d4c --- /dev/null +++ b/plugins/notification/src/detect.rs @@ -0,0 +1,57 @@ +pub struct DetectState { + tx: std::sync::mpsc::Sender, + handle: Option>, + detector: hypr_detect::Detector, +} + +impl DetectState { + pub fn new(app_handle: tauri::AppHandle) -> Self { + let (tx, rx) = std::sync::mpsc::channel::(); + + let handle = std::thread::spawn(move || { + while let Ok(event) = rx.recv() { + match event { + hypr_detect::DetectEvent::MicStarted => { + let visible = { + use tauri_plugin_windows::{HyprWindow, WindowsPluginExt}; + app_handle + .window_is_visible(HyprWindow::Main) + .unwrap_or(false) + }; + + if !visible { + hypr_notification::show( + &hypr_notification::Notification::builder() + .title("Meeting detected") + .message("Click here to start writing a note") + .url("hypr://hyprnote.com/notification") + .timeout(std::time::Duration::from_secs(10)) + .build(), + ); + } + } + _ => {} + } + } + }); + + Self { + tx, + handle: Some(handle), + detector: hypr_detect::Detector::default(), + } + } + + pub fn start(&mut self) { + let tx = self.tx.clone(); + self.detector.start(hypr_detect::new_callback(move |e| { + let _ = tx.send(e); + })); + } + + pub fn stop(&mut self) { + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } +} diff --git a/plugins/notification/src/worker.rs b/plugins/notification/src/event.rs similarity index 98% rename from plugins/notification/src/worker.rs rename to plugins/notification/src/event.rs index 344b869c9..c065e97a9 100644 --- a/plugins/notification/src/worker.rs +++ b/plugins/notification/src/event.rs @@ -44,6 +44,7 @@ pub async fn perform_event_notification(_job: Job, ctx: Data) -> Re if let Err(e) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { hypr_notification::show( &hypr_notification::Notification::builder() + .key(&event.id) .title("Meeting starting in 5 minutes") .message(event.name.clone()) .url(format!( diff --git a/plugins/notification/src/ext.rs b/plugins/notification/src/ext.rs index 5914a6ecc..9238947ea 100644 --- a/plugins/notification/src/ext.rs +++ b/plugins/notification/src/ext.rs @@ -81,7 +81,7 @@ impl> NotificationPluginExt for T { let mut s = state.lock().unwrap(); s.worker_handle = Some(tokio::runtime::Handle::current().spawn(async move { - let _ = crate::worker::monitor(crate::worker::WorkerState { db, user_id }).await; + let _ = crate::event::monitor(crate::event::WorkerState { db, user_id }).await; })); Ok(()) @@ -101,43 +101,17 @@ impl> NotificationPluginExt for T { #[tracing::instrument(skip(self))] fn start_detect_notification(&self) -> Result<(), Error> { - let cb = hypr_detect::new_callback(|event| match event { - hypr_detect::DetectEvent::MicStarted => { - let notif = hypr_notification::Notification::builder() - .title("Meeting detected") - .message("Click here to start writing a note") - .url("hypr://hyprnote.com/notification") - .timeout(std::time::Duration::from_secs(10)) - .build(); - hypr_notification::show(¬if); - } - hypr_detect::DetectEvent::MicStopped => { - let notif = hypr_notification::Notification::builder() - .title("Meeting stopped") - .message("Click here to start writing a note") - .url("hypr://hyprnote.com/notification") - .timeout(std::time::Duration::from_secs(10)) - .build(); - hypr_notification::show(¬if); - } - _ => {} + let (tx, rx) = std::sync::mpsc::channel::(); + + let cb = hypr_detect::new_callback(move |event| { + let _ = tx.send(event); // Send event through channel }); - let state = self.state::(); - { - let mut guard = state.lock().unwrap(); - guard.detector.start(cb); - } Ok(()) } #[tracing::instrument(skip(self))] fn stop_detect_notification(&self) -> Result<(), Error> { - let state = self.state::(); - { - let mut guard = state.lock().unwrap(); - guard.detector.stop(); - } Ok(()) } } diff --git a/plugins/notification/src/lib.rs b/plugins/notification/src/lib.rs index fc373ab05..ddae956ed 100644 --- a/plugins/notification/src/lib.rs +++ b/plugins/notification/src/lib.rs @@ -2,10 +2,11 @@ use std::sync::Mutex; use tauri::Manager; mod commands; +mod detect; mod error; +mod event; mod ext; mod store; -mod worker; pub use error::*; pub use ext::*; @@ -15,10 +16,18 @@ const PLUGIN_NAME: &str = "notification"; pub type SharedState = Mutex; -#[derive(Default)] pub struct State { worker_handle: Option>, - detector: hypr_detect::Detector, + detect_state: detect::DetectState, +} + +impl State { + pub fn new(app_handle: tauri::AppHandle) -> Self { + Self { + worker_handle: None, + detect_state: detect::DetectState::new(app_handle), + } + } } fn make_specta_builder() -> tauri_specta::Builder { @@ -38,20 +47,17 @@ fn make_specta_builder() -> tauri_specta::Builder { .error_handling(tauri_specta::ErrorHandlingMode::Throw) } -pub fn init() -> tauri::plugin::TauriPlugin { +pub fn init() -> tauri::plugin::TauriPlugin { let specta_builder = make_specta_builder(); tauri::plugin::Builder::new(PLUGIN_NAME) .invoke_handler(specta_builder.invoke_handler()) .setup(|app, _api| { - let state = SharedState::default(); - app.manage(state); + let state = State::new(app.clone()); + app.manage(Mutex::new(state)); - if app.get_detect_notification().unwrap_or(false) { - if let Err(e) = app.start_detect_notification() { - tracing::error!("start_detect_notification_failed: {:?}", e); - } - } + // TODO: we cannot start event_notic here. maybe in `.event()`callback? + // detector can though Ok(()) }) @@ -75,16 +81,11 @@ mod test { .unwrap() } - fn create_app(builder: tauri::Builder) -> tauri::App { + fn create_app(builder: tauri::Builder) -> tauri::App { builder .plugin(tauri_plugin_store::Builder::default().build()) .plugin(init()) .build(tauri::test::mock_context(tauri::test::noop_assets())) .unwrap() } - - #[tokio::test] - async fn test_notification() { - let _app = create_app(tauri::test::mock_builder()); - } } From bccd016e4a6d5a378a6fbeb2ce58717f08914b95 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Tue, 26 Aug 2025 22:10:16 -0700 Subject: [PATCH 2/4] done --- apps/desktop/src-tauri/src/lib.rs | 10 --- .../settings/views/notifications.tsx | 24 +++---- .../examples/test_notification.rs | 14 ++-- .../swift-lib/src/lib.swift | 61 +++++++---------- plugins/notification/src/detect.rs | 67 ++++++++++++++----- plugins/notification/src/event.rs | 4 +- plugins/notification/src/ext.rs | 18 ++--- plugins/notification/src/lib.rs | 33 +++++---- 8 files changed, 127 insertions(+), 104 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 5ef1a464b..226daf742 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -262,16 +262,6 @@ pub async fn main() { }); } } - - // Start event notification after DB is initialized - { - use tauri_plugin_notification::NotificationPluginExt; - if app_clone.get_event_notification().unwrap_or(false) { - if let Err(e) = app_clone.start_event_notification().await { - tracing::error!("start_event_notification_failed: {:?}", e); - } - } - } }); Ok(()) diff --git a/apps/desktop/src/components/settings/views/notifications.tsx b/apps/desktop/src/components/settings/views/notifications.tsx index 9e2005d7f..893453ce6 100644 --- a/apps/desktop/src/components/settings/views/notifications.tsx +++ b/apps/desktop/src/components/settings/views/notifications.tsx @@ -39,12 +39,6 @@ export default function NotificationsComponent() { mutationFn: async (v: Schema) => { if (v.event) { notificationCommands.setEventNotification(true); - notificationCommands.showNotification({ - title: "Test", - message: "Test", - url: "https://hypr.ai", - timeout: { secs: 5, nanos: 0 }, - }); } else { notificationCommands.setEventNotification(false); } @@ -54,6 +48,12 @@ export default function NotificationsComponent() { eventNotification.refetch(); if (active) { notificationCommands.startEventNotification(); + notificationCommands.showNotification({ + title: "You're all set!", + message: "This is how notifications look.", + timeout: { secs: 10, nanos: 0 }, + url: null, + }); } else { notificationCommands.stopEventNotification(); } @@ -64,12 +64,6 @@ export default function NotificationsComponent() { mutationFn: async (v: Schema) => { if (v.detect) { notificationCommands.setDetectNotification(true); - notificationCommands.showNotification({ - title: "Test", - message: "Test", - url: "https://hypr.ai", - timeout: { secs: 5, nanos: 0 }, - }); } else { notificationCommands.setDetectNotification(false); } @@ -79,6 +73,12 @@ export default function NotificationsComponent() { detectNotification.refetch(); if (active) { notificationCommands.startDetectNotification(); + notificationCommands.showNotification({ + title: "You're all set!", + message: "This is how notifications look.", + timeout: { secs: 10, nanos: 0 }, + url: null, + }); } else { notificationCommands.stopDetectNotification(); } diff --git a/crates/notification-macos/examples/test_notification.rs b/crates/notification-macos/examples/test_notification.rs index 7b883889b..582f0fba6 100644 --- a/crates/notification-macos/examples/test_notification.rs +++ b/crates/notification-macos/examples/test_notification.rs @@ -5,8 +5,10 @@ use std::time::Duration; use objc2::rc::Retained; use objc2::runtime::ProtocolObject; use objc2::{define_class, msg_send, MainThreadOnly}; -use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy, NSApplicationDelegate}; -use objc2_foundation::{MainThreadMarker, NSObject, NSObjectProtocol}; +use objc2_app_kit::{ + NSAppearance, NSApplication, NSApplicationActivationPolicy, NSApplicationDelegate, +}; +use objc2_foundation::{ns_string, MainThreadMarker, NSObject, NSObjectProtocol}; #[derive(Debug, Default)] struct AppDelegateIvars {} @@ -35,6 +37,10 @@ fn main() { let app = NSApplication::sharedApplication(mtm); app.setActivationPolicy(NSApplicationActivationPolicy::Regular); + if let Some(appearance) = NSAppearance::appearanceNamed(ns_string!("NSAppearanceNameAqua")) { + app.setAppearance(Some(&appearance)); + } + let delegate = AppDelegate::new(mtm); app.setDelegate(Some(&ProtocolObject::from_ref(&*delegate))); @@ -46,11 +52,11 @@ fn main() { .title("Test Notification") .message("Hover/click should now react") .url("https://example.com") - .timeout(Duration::from_secs(20)) + .timeout(Duration::from_secs(30)) .build(); show(¬ification); - std::thread::sleep(Duration::from_secs(5)); + std::thread::sleep(Duration::from_secs(30)); std::process::exit(0); }); diff --git a/crates/notification-macos/swift-lib/src/lib.swift b/crates/notification-macos/swift-lib/src/lib.swift index 2ccd74d41..2dbeab76e 100644 --- a/crates/notification-macos/swift-lib/src/lib.swift +++ b/crates/notification-macos/swift-lib/src/lib.swift @@ -239,14 +239,20 @@ class ActionButton: NSButton { font = NSFont.systemFont(ofSize: 14, weight: .semibold) focusRingType = .none - contentTintColor = NSColor.white + contentTintColor = NSColor(calibratedWhite: 0.1, alpha: 1.0) if #available(macOS 11.0, *) { - bezelColor = NSColor.white.withAlphaComponent(0.16) + bezelColor = NSColor(calibratedWhite: 0.9, alpha: 1.0) } + layer?.cornerRadius = 10 - layer?.backgroundColor = NSColor.white.withAlphaComponent(0.16).cgColor - layer?.borderColor = NSColor.white.withAlphaComponent(0.22).cgColor + layer?.backgroundColor = NSColor(calibratedWhite: 0.95, alpha: 0.9).cgColor + layer?.borderColor = NSColor(calibratedWhite: 0.7, alpha: 0.5).cgColor layer?.borderWidth = 0.5 + + layer?.shadowColor = NSColor(calibratedWhite: 0.0, alpha: 0.5).cgColor + layer?.shadowOpacity = 0.3 + layer?.shadowRadius = 3 + layer?.shadowOffset = CGSize(width: 0, height: 1) } override var intrinsicContentSize: NSSize { @@ -461,14 +467,12 @@ class NotificationManager { url: String?, notification: NotificationInstance ) { - let descriptionText = makeDescription(from: url) let hasUrl = (url != nil && !url!.isEmpty) let contentView = createNotificationView( - description: descriptionText, title: title, body: message, - buttonTitle: hasUrl ? "Open" : nil, + buttonTitle: hasUrl ? "Take Notes" : nil, notification: notification ) contentView.translatesAutoresizingMaskIntoConstraints = false @@ -476,7 +480,7 @@ class NotificationManager { NSLayoutConstraint.activate([ contentView.leadingAnchor.constraint(equalTo: effectView.leadingAnchor, constant: 12), - contentView.trailingAnchor.constraint(equalTo: effectView.trailingAnchor, constant: -10), // nudge left a bit + contentView.trailingAnchor.constraint(equalTo: effectView.trailingAnchor, constant: -10), contentView.topAnchor.constraint(equalTo: effectView.topAnchor, constant: 9), contentView.bottomAnchor.constraint(equalTo: effectView.bottomAnchor, constant: -9), ]) @@ -486,7 +490,6 @@ class NotificationManager { } private func createNotificationView( - description: String, title: String, body: String, buttonTitle: String? = nil, @@ -502,58 +505,50 @@ class NotificationManager { iconContainer.wantsLayer = true iconContainer.layer?.cornerRadius = 9 iconContainer.translatesAutoresizingMaskIntoConstraints = false - iconContainer.widthAnchor.constraint(equalToConstant: 36).isActive = true - iconContainer.heightAnchor.constraint(equalToConstant: 36).isActive = true + iconContainer.widthAnchor.constraint(equalToConstant: 42).isActive = true + iconContainer.heightAnchor.constraint(equalToConstant: 42).isActive = true let iconImageView = createAppIconView() iconContainer.addSubview(iconImageView) NSLayoutConstraint.activate([ iconImageView.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor), iconImageView.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor), - iconImageView.widthAnchor.constraint(equalToConstant: 24), - iconImageView.heightAnchor.constraint(equalToConstant: 24), + iconImageView.widthAnchor.constraint(equalToConstant: 32), + iconImageView.heightAnchor.constraint(equalToConstant: 32), ]) // Middle: text stack let textStack = NSStackView() textStack.orientation = .vertical - textStack.spacing = 3 + textStack.spacing = 4 textStack.alignment = .leading textStack.setContentHuggingPriority(.defaultLow, for: .horizontal) textStack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - let descriptionLabel = NSTextField(labelWithString: description) - descriptionLabel.font = NSFont.systemFont(ofSize: 11) - descriptionLabel.textColor = NSColor.secondaryLabelColor - descriptionLabel.lineBreakMode = .byTruncatingTail - descriptionLabel.maximumNumberOfLines = 1 - + // Title let titleLabel = NSTextField(labelWithString: title) - titleLabel.font = NSFont.systemFont(ofSize: 14, weight: .semibold) + titleLabel.font = NSFont.systemFont(ofSize: 16, weight: .semibold) titleLabel.textColor = NSColor.labelColor titleLabel.lineBreakMode = .byTruncatingTail titleLabel.maximumNumberOfLines = 1 titleLabel.allowsDefaultTighteningForTruncation = true + // Body let bodyLabel = NSTextField(labelWithString: body) - bodyLabel.font = NSFont.systemFont(ofSize: 12) + bodyLabel.font = NSFont.systemFont(ofSize: 12, weight: .light) bodyLabel.textColor = NSColor.secondaryLabelColor - bodyLabel.lineBreakMode = .byWordWrapping - bodyLabel.maximumNumberOfLines = 2 + bodyLabel.maximumNumberOfLines = 1 bodyLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - textStack.addArrangedSubview(descriptionLabel) textStack.addArrangedSubview(titleLabel) textStack.addArrangedSubview(bodyLabel) - textStack.setCustomSpacing(4, after: descriptionLabel) // Assemble so far container.addArrangedSubview(iconContainer) container.addArrangedSubview(textStack) - // Right: larger pill action button with a fixed spacer to avoid setCustomSpacing crash + // Right: action button if let buttonTitle { - // Small fixed spacer between text and button (adjust width to move button left/right) let gap = NSView() gap.translatesAutoresizingMaskIntoConstraints = false gap.widthAnchor.constraint(equalToConstant: 8).isActive = true @@ -583,16 +578,6 @@ class NotificationManager { notification.dismiss() } - private func makeDescription(from urlString: String?) -> String { - if let urlString, let url = URL(string: urlString), let host = url.host, !host.isEmpty { - return host - } - if let appName = NSRunningApplication.current.localizedName { - return appName - } - return "Notification" - } - private func createAppIconView() -> NSImageView { let imageView = NSImageView() if let appIcon = NSApp.applicationIconImage { diff --git a/plugins/notification/src/detect.rs b/plugins/notification/src/detect.rs index 6479a9d4c..e40faa4a2 100644 --- a/plugins/notification/src/detect.rs +++ b/plugins/notification/src/detect.rs @@ -1,13 +1,28 @@ +use crate::error::Error; + pub struct DetectState { - tx: std::sync::mpsc::Sender, + tx: Option>, handle: Option>, - detector: hypr_detect::Detector, + detector: Option, + app_handle: tauri::AppHandle, } impl DetectState { pub fn new(app_handle: tauri::AppHandle) -> Self { + Self { + tx: None, + handle: None, + detector: None, + app_handle, + } + } + + pub fn start(&mut self) -> Result<(), Error> { + self.stop()?; + let (tx, rx) = std::sync::mpsc::channel::(); + let app_handle = self.app_handle.clone(); let handle = std::thread::spawn(move || { while let Ok(event) = rx.recv() { match event { @@ -23,35 +38,53 @@ impl DetectState { hypr_notification::show( &hypr_notification::Notification::builder() .title("Meeting detected") - .message("Click here to start writing a note") - .url("hypr://hyprnote.com/notification") - .timeout(std::time::Duration::from_secs(10)) + .message("Based on your microphone activity") + .url("hypr://hyprnote.com/app/new?record=true") + .timeout(std::time::Duration::from_secs(30)) .build(), ); } } + hypr_detect::DetectEvent::MicStopped => {} _ => {} } } }); - Self { - tx, - handle: Some(handle), - detector: hypr_detect::Detector::default(), - } - } - - pub fn start(&mut self) { - let tx = self.tx.clone(); - self.detector.start(hypr_detect::new_callback(move |e| { - let _ = tx.send(e); + let mut detector = hypr_detect::Detector::default(); + let tx_clone = tx.clone(); + detector.start(hypr_detect::new_callback(move |e| { + let _ = tx_clone.send(e); })); + + self.tx = Some(tx); + self.handle = Some(handle); + self.detector = Some(detector); + + Ok(()) } - pub fn stop(&mut self) { + pub fn stop(&mut self) -> Result<(), Error> { + if let Some(mut detector) = self.detector.take() { + detector.stop(); + } + + self.tx = None; + if let Some(handle) = self.handle.take() { let _ = handle.join(); } + + Ok(()) + } + + pub fn _is_running(&self) -> bool { + self.detector.is_some() && self.handle.is_some() + } +} + +impl Drop for DetectState { + fn drop(&mut self) { + let _ = self.stop(); } } diff --git a/plugins/notification/src/event.rs b/plugins/notification/src/event.rs index c065e97a9..6f1b4e7b8 100644 --- a/plugins/notification/src/event.rs +++ b/plugins/notification/src/event.rs @@ -45,8 +45,8 @@ pub async fn perform_event_notification(_job: Job, ctx: Data) -> Re hypr_notification::show( &hypr_notification::Notification::builder() .key(&event.id) - .title("Meeting starting in 5 minutes") - .message(event.name.clone()) + .title(event.name.clone()) + .message("Meeting starting in 5 minutes") .url(format!( "hypr://hyprnote.com/notification?event_id={}", event.id diff --git a/plugins/notification/src/ext.rs b/plugins/notification/src/ext.rs index 9238947ea..09ca7a984 100644 --- a/plugins/notification/src/ext.rs +++ b/plugins/notification/src/ext.rs @@ -38,7 +38,7 @@ impl> NotificationPluginExt for T { store .get(crate::StoreKey::EventNotification) .map_err(Error::Store) - .map(|v| v.unwrap_or(true)) + .map(|v| v.unwrap_or(false)) } #[tracing::instrument(skip(self))] @@ -55,7 +55,7 @@ impl> NotificationPluginExt for T { store .get(crate::StoreKey::DetectNotification) .map_err(Error::Store) - .map(|v| v.unwrap_or(true)) + .map(|v| v.unwrap_or(false)) } #[tracing::instrument(skip(self))] @@ -101,17 +101,17 @@ impl> NotificationPluginExt for T { #[tracing::instrument(skip(self))] fn start_detect_notification(&self) -> Result<(), Error> { - let (tx, rx) = std::sync::mpsc::channel::(); - - let cb = hypr_detect::new_callback(move |event| { - let _ = tx.send(event); // Send event through channel - }); + let state = self.state::(); + let mut guard = state.lock().unwrap(); - Ok(()) + guard.detect_state.start() } #[tracing::instrument(skip(self))] fn stop_detect_notification(&self) -> Result<(), Error> { - Ok(()) + let state = self.state::(); + let mut guard = state.lock().unwrap(); + + guard.detect_state.stop() } } diff --git a/plugins/notification/src/lib.rs b/plugins/notification/src/lib.rs index ddae956ed..4e75ea431 100644 --- a/plugins/notification/src/lib.rs +++ b/plugins/notification/src/lib.rs @@ -55,12 +55,29 @@ pub fn init() -> tauri::plugin::TauriPlugin { .setup(|app, _api| { let state = State::new(app.clone()); app.manage(Mutex::new(state)); - - // TODO: we cannot start event_notic here. maybe in `.event()`callback? - // detector can though - Ok(()) }) + .on_event(|app, event| match event { + tauri::RunEvent::Ready => { + if app.get_detect_notification().unwrap_or(false) { + match app.start_detect_notification() { + Ok(_) => tracing::info!("detect_notification_start_success"), + Err(_) => tracing::error!("detect_notification_start_failed"), + } + } + + if app.get_event_notification().unwrap_or(false) { + let app_clone = app.clone(); + tokio::spawn(async move { + match app_clone.start_event_notification().await { + Ok(_) => tracing::info!("event_notification_start_success"), + Err(_) => tracing::error!("event_notification_start_failed"), + } + }); + } + } + _ => {} + }) .build() } @@ -80,12 +97,4 @@ mod test { ) .unwrap() } - - fn create_app(builder: tauri::Builder) -> tauri::App { - builder - .plugin(tauri_plugin_store::Builder::default().build()) - .plugin(init()) - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .unwrap() - } } From b462a9e34fc5d227666a85714a51f826ce6ed1cf Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Tue, 26 Aug 2025 23:15:25 -0700 Subject: [PATCH 3/4] wip --- Cargo.lock | 1 + apps/desktop/src/locales/en/messages.po | 8 +- apps/desktop/src/locales/ko/messages.po | 8 +- crates/detect/src/lib.rs | 2 +- crates/whisper-local/src/model.rs | 1 - plugins/notification/Cargo.toml | 1 + plugins/notification/src/detect.rs | 75 ++++--------- plugins/notification/src/event.rs | 43 ++++---- plugins/notification/src/ext.rs | 24 +++-- plugins/notification/src/handler.rs | 138 ++++++++++++++++++++++++ plugins/notification/src/lib.rs | 8 +- 11 files changed, 213 insertions(+), 96 deletions(-) create mode 100644 plugins/notification/src/handler.rs diff --git a/Cargo.lock b/Cargo.lock index 59ad46217..1f7a64b77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14817,6 +14817,7 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-plugin-db", + "tauri-plugin-listener", "tauri-plugin-store", "tauri-plugin-store2", "tauri-plugin-windows", diff --git a/apps/desktop/src/locales/en/messages.po b/apps/desktop/src/locales/en/messages.po index 6eb122baa..04473cc04 100644 --- a/apps/desktop/src/locales/en/messages.po +++ b/apps/desktop/src/locales/en/messages.po @@ -248,11 +248,11 @@ msgstr "{weeks} weeks later" #~ msgid "in {hours} hours" #~ msgstr "in {hours} hours" -#: src/components/settings/views/notifications.tsx:105 +#: src/components/settings/views/notifications.tsx:113 msgid "(Beta) Detect meetings automatically" msgstr "(Beta) Detect meetings automatically" -#: src/components/settings/views/notifications.tsx:132 +#: src/components/settings/views/notifications.tsx:140 msgid "(Beta) Upcoming meeting notifications" msgstr "(Beta) Upcoming meeting notifications" @@ -1428,11 +1428,11 @@ msgstr "Share usage data" msgid "Shorten summary" msgstr "Shorten summary" -#: src/components/settings/views/notifications.tsx:135 +#: src/components/settings/views/notifications.tsx:143 msgid "Show notifications when you have meetings starting soon in your calendar." msgstr "Show notifications when you have meetings starting soon in your calendar." -#: src/components/settings/views/notifications.tsx:108 +#: src/components/settings/views/notifications.tsx:116 msgid "Show notifications when you join a meeting." msgstr "Show notifications when you join a meeting." diff --git a/apps/desktop/src/locales/ko/messages.po b/apps/desktop/src/locales/ko/messages.po index 777c8b1b2..7013b2231 100644 --- a/apps/desktop/src/locales/ko/messages.po +++ b/apps/desktop/src/locales/ko/messages.po @@ -248,11 +248,11 @@ msgstr "" #~ msgid "in {hours} hours" #~ msgstr "" -#: src/components/settings/views/notifications.tsx:105 +#: src/components/settings/views/notifications.tsx:113 msgid "(Beta) Detect meetings automatically" msgstr "" -#: src/components/settings/views/notifications.tsx:132 +#: src/components/settings/views/notifications.tsx:140 msgid "(Beta) Upcoming meeting notifications" msgstr "" @@ -1428,11 +1428,11 @@ msgstr "" msgid "Shorten summary" msgstr "" -#: src/components/settings/views/notifications.tsx:135 +#: src/components/settings/views/notifications.tsx:143 msgid "Show notifications when you have meetings starting soon in your calendar." msgstr "" -#: src/components/settings/views/notifications.tsx:108 +#: src/components/settings/views/notifications.tsx:116 msgid "Show notifications when you join a meeting." msgstr "" diff --git a/crates/detect/src/lib.rs b/crates/detect/src/lib.rs index 3db9cb480..c1708a7a9 100644 --- a/crates/detect/src/lib.rs +++ b/crates/detect/src/lib.rs @@ -7,7 +7,7 @@ pub use mic::*; use utils::*; -#[derive(Debug)] +#[derive(Debug, Clone)] pub enum DetectEvent { MicStarted, MicStopped, diff --git a/crates/whisper-local/src/model.rs b/crates/whisper-local/src/model.rs index 96c65cd82..4f8a0c5b6 100644 --- a/crates/whisper-local/src/model.rs +++ b/crates/whisper-local/src/model.rs @@ -304,7 +304,6 @@ impl Segment { #[cfg(test)] mod tests { use super::*; - use futures_util::StreamExt; #[test] fn test_whisper() { diff --git a/plugins/notification/Cargo.toml b/plugins/notification/Cargo.toml index b3b2a424b..fbb012327 100644 --- a/plugins/notification/Cargo.toml +++ b/plugins/notification/Cargo.toml @@ -20,6 +20,7 @@ hypr-detect = { workspace = true } hypr-notification = { workspace = true } tauri-plugin-db = { workspace = true } +tauri-plugin-listener = { workspace = true } tauri-plugin-store2 = { workspace = true } tauri-plugin-windows = { workspace = true } diff --git a/plugins/notification/src/detect.rs b/plugins/notification/src/detect.rs index e40faa4a2..934b5d043 100644 --- a/plugins/notification/src/detect.rs +++ b/plugins/notification/src/detect.rs @@ -1,65 +1,38 @@ -use crate::error::Error; +use crate::{ + handler::{NotificationHandler, NotificationTrigger, NotificationTriggerDetect}, + Error, +}; pub struct DetectState { - tx: Option>, - handle: Option>, detector: Option, - app_handle: tauri::AppHandle, + notification_tx: Option>, } impl DetectState { - pub fn new(app_handle: tauri::AppHandle) -> Self { + pub fn new(notification_handler: &NotificationHandler) -> Self { Self { - tx: None, - handle: None, detector: None, - app_handle, + notification_tx: notification_handler.sender(), } } pub fn start(&mut self) -> Result<(), Error> { self.stop()?; - let (tx, rx) = std::sync::mpsc::channel::(); - - let app_handle = self.app_handle.clone(); - let handle = std::thread::spawn(move || { - while let Ok(event) = rx.recv() { - match event { - hypr_detect::DetectEvent::MicStarted => { - let visible = { - use tauri_plugin_windows::{HyprWindow, WindowsPluginExt}; - app_handle - .window_is_visible(HyprWindow::Main) - .unwrap_or(false) - }; - - if !visible { - hypr_notification::show( - &hypr_notification::Notification::builder() - .title("Meeting detected") - .message("Based on your microphone activity") - .url("hypr://hyprnote.com/app/new?record=true") - .timeout(std::time::Duration::from_secs(30)) - .build(), - ); - } - } - hypr_detect::DetectEvent::MicStopped => {} - _ => {} + { + let notification_tx = self.notification_tx.as_ref().unwrap().clone(); + let mut detector = hypr_detect::Detector::default(); + detector.start(hypr_detect::new_callback(move |event| { + if let Err(e) = + notification_tx.send(NotificationTrigger::Detect(NotificationTriggerDetect { + event, + })) + { + tracing::error!("{}", e); } - } - }); - - let mut detector = hypr_detect::Detector::default(); - let tx_clone = tx.clone(); - detector.start(hypr_detect::new_callback(move |e| { - let _ = tx_clone.send(e); - })); - - self.tx = Some(tx); - self.handle = Some(handle); - self.detector = Some(detector); + })); + self.detector = Some(detector); + } Ok(()) } @@ -69,17 +42,11 @@ impl DetectState { detector.stop(); } - self.tx = None; - - if let Some(handle) = self.handle.take() { - let _ = handle.join(); - } - Ok(()) } pub fn _is_running(&self) -> bool { - self.detector.is_some() && self.handle.is_some() + self.detector.is_some() } } diff --git a/plugins/notification/src/event.rs b/plugins/notification/src/event.rs index 6f1b4e7b8..835d26178 100644 --- a/plugins/notification/src/event.rs +++ b/plugins/notification/src/event.rs @@ -2,6 +2,8 @@ use apalis::prelude::{Data, Error, WorkerBuilder, WorkerFactoryFn}; use chrono::{DateTime, Duration, Utc}; use hypr_db_user::{ListEventFilter, ListEventFilterCommon, ListEventFilterSpecific}; +use crate::handler::{NotificationTrigger, NotificationTriggerEvent}; + #[allow(unused)] #[derive(Default, Debug, Clone)] pub struct Job(DateTime); @@ -10,6 +12,7 @@ pub struct Job(DateTime); pub struct WorkerState { pub db: hypr_db_user::UserDatabase, pub user_id: String, + pub notification_tx: std::sync::mpsc::Sender, } impl From> for Job { @@ -39,32 +42,22 @@ pub async fn perform_event_notification(_job: Job, ctx: Data) -> Re .map_err(|e| crate::Error::Db(e).as_worker_error())?; if let Some(event) = latest_event.first() { - tracing::info!("Found upcoming event - showing notification"); + tracing::info!("found_upcoming_event: {}", event.name); + + let minutes_until_start = event + .start_date + .signed_duration_since(Utc::now()) + .num_minutes(); - if let Err(e) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - hypr_notification::show( - &hypr_notification::Notification::builder() - .key(&event.id) - .title(event.name.clone()) - .message("Meeting starting in 5 minutes") - .url(format!( - "hypr://hyprnote.com/notification?event_id={}", - event.id - )) - .timeout(std::time::Duration::from_secs(10)) - .build(), - ); - })) { - let panic_msg = if let Some(s) = e.downcast_ref::<&str>() { - s.to_string() - } else if let Some(s) = e.downcast_ref::() { - s.clone() - } else { - "Unknown panic".to_string() - }; - tracing::error!("Notification panic: {}", panic_msg); - } else { - tracing::info!("Notification shown"); + if let Err(e) = + ctx.notification_tx + .send(NotificationTrigger::Event(NotificationTriggerEvent { + event_id: event.id.clone(), + event_name: event.name.clone(), + minutes_until_start, + })) + { + tracing::error!("{}", e); } } diff --git a/plugins/notification/src/ext.rs b/plugins/notification/src/ext.rs index 09ca7a984..02a6affb9 100644 --- a/plugins/notification/src/ext.rs +++ b/plugins/notification/src/ext.rs @@ -77,12 +77,24 @@ impl> NotificationPluginExt for T { ) }; - let state = self.state::(); - let mut s = state.lock().unwrap(); - - s.worker_handle = Some(tokio::runtime::Handle::current().spawn(async move { - let _ = crate::event::monitor(crate::event::WorkerState { db, user_id }).await; - })); + { + let state = self.state::(); + let mut s = state.lock().unwrap(); + + let notification_tx = s.notification_handler.sender().unwrap(); + + if let Some(h) = s.worker_handle.take() { + h.abort(); + } + s.worker_handle = Some(tokio::runtime::Handle::current().spawn(async move { + let _ = crate::event::monitor(crate::event::WorkerState { + db, + user_id, + notification_tx, + }) + .await; + })); + } Ok(()) } diff --git a/plugins/notification/src/handler.rs b/plugins/notification/src/handler.rs new file mode 100644 index 000000000..d9464f48e --- /dev/null +++ b/plugins/notification/src/handler.rs @@ -0,0 +1,138 @@ +use std::sync::mpsc::{Receiver, Sender}; +use std::thread::JoinHandle; +use tauri::AppHandle; +use tauri_plugin_windows::{HyprWindow, WindowsPluginExt}; + +#[derive(Debug, Clone)] +pub enum NotificationTrigger { + Detect(NotificationTriggerDetect), + Event(NotificationTriggerEvent), +} + +#[derive(Debug, Clone)] +pub struct NotificationTriggerDetect { + pub event: hypr_detect::DetectEvent, +} + +#[derive(Debug, Clone)] +pub struct NotificationTriggerEvent { + pub event_id: String, + pub event_name: String, + pub minutes_until_start: i64, +} + +pub struct NotificationHandler { + tx: Option>, + handle: Option>, +} + +impl NotificationHandler { + pub fn new(app_handle: AppHandle) -> Self { + let (tx, rx) = std::sync::mpsc::channel::(); + + let handle = std::thread::spawn(move || { + Self::worker_loop(rx, app_handle); + }); + + Self { + tx: Some(tx), + handle: Some(handle), + } + } + + pub fn sender(&self) -> Option> { + self.tx.clone() + } + + fn worker_loop(rx: Receiver, app_handle: AppHandle) { + while let Ok(trigger) = rx.recv() { + match trigger { + NotificationTrigger::Detect(t) => { + Self::handle_detect_event(&app_handle, t); + } + NotificationTrigger::Event(e) => { + Self::handle_calendar_event(&app_handle, e); + } + } + } + } + + fn handle_detect_event(app_handle: &AppHandle, trigger: NotificationTriggerDetect) { + let window_visible = app_handle + .window_is_visible(HyprWindow::Main) + .unwrap_or(false); + + match trigger.event { + hypr_detect::DetectEvent::MicStarted => { + if !window_visible { + hypr_notification::show( + &hypr_notification::Notification::builder() + .title("Meeting detected") + .message("Based on your microphone activity") + .url("hypr://hyprnote.com/app/new?record=true") + .timeout(std::time::Duration::from_secs(60)) + .build(), + ); + } + } + hypr_detect::DetectEvent::MicStopped => { + use tauri_plugin_listener::ListenerPluginExt; + app_handle.pause_session(); + } + _ => {} + } + } + + fn handle_calendar_event( + app_handle: &AppHandle, + trigger: NotificationTriggerEvent, + ) { + let window_visible = app_handle + .window_is_visible(HyprWindow::Main) + .unwrap_or(false); + + if !window_visible || trigger.minutes_until_start < 3 { + if let Err(e) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + hypr_notification::show( + &hypr_notification::Notification::builder() + .key(&format!( + "event_{}_{}", + trigger.event_id, + trigger.minutes_until_start < 3 + )) + .title(trigger.event_name.clone()) + .message(format!( + "Meeting starting in {} minutes", + if trigger.minutes_until_start < 3 { + 1 + } else { + trigger.minutes_until_start + } + )) + .url(format!( + "hypr://hyprnote.com/app/new?calendar_event_id={}", + trigger.event_id + )) + .timeout(std::time::Duration::from_secs(10)) + .build(), + ); + })) { + tracing::error!("{:?}", e); + } + } + } + + pub fn stop(&mut self) { + self.tx = None; + + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } +} + +impl Drop for NotificationHandler { + fn drop(&mut self) { + self.stop(); + } +} diff --git a/plugins/notification/src/lib.rs b/plugins/notification/src/lib.rs index 4e75ea431..01b1a4656 100644 --- a/plugins/notification/src/lib.rs +++ b/plugins/notification/src/lib.rs @@ -6,6 +6,7 @@ mod detect; mod error; mod event; mod ext; +mod handler; mod store; pub use error::*; @@ -19,13 +20,18 @@ pub type SharedState = Mutex; pub struct State { worker_handle: Option>, detect_state: detect::DetectState, + notification_handler: handler::NotificationHandler, } impl State { pub fn new(app_handle: tauri::AppHandle) -> Self { + let notification_handler = handler::NotificationHandler::new(app_handle.clone()); + let detect_state = detect::DetectState::new(¬ification_handler); + Self { worker_handle: None, - detect_state: detect::DetectState::new(app_handle), + detect_state, + notification_handler, } } } From 1cb0c1df674e1fd2315343c1b35e4eb5f76f48b2 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Tue, 26 Aug 2025 23:21:31 -0700 Subject: [PATCH 4/4] fix --- plugins/notification/src/handler.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/notification/src/handler.rs b/plugins/notification/src/handler.rs index d9464f48e..56e64fa1f 100644 --- a/plugins/notification/src/handler.rs +++ b/plugins/notification/src/handler.rs @@ -77,7 +77,9 @@ impl NotificationHandler { } hypr_detect::DetectEvent::MicStopped => { use tauri_plugin_listener::ListenerPluginExt; - app_handle.pause_session(); + tokio::spawn(async move { + app_handle.pause_session(); + }); } _ => {} }