diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index f79337ff0..278ea13b3 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -4,11 +4,12 @@ version = 4 [[package]] name = "OpenHuman" -version = "0.53.3" +version = "0.53.4" dependencies = [ "anyhow", "async-trait", "base64 0.22.1", + "block2", "cef", "chrono", "directories", @@ -21,6 +22,8 @@ dependencies = [ "notify-rust", "objc2", "objc2-app-kit", + "objc2-foundation", + "objc2-user-notifications", "parking_lot", "reqwest 0.12.28", "rusqlite", @@ -2825,6 +2828,16 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-text" version = "0.3.2" @@ -2946,6 +2959,19 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-core-location", + "objc2-foundation", +] + [[package]] name = "objc2-web-kit" version = "0.3.2" diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index e1fef769e..fb36522ec 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -113,6 +113,9 @@ objc2-app-kit = "0.3.2" mac-notification-sys = "0.6" # iMessage scanner reads ~/Library/Messages/chat.db read-only on macOS. rusqlite = { version = "0.37", features = ["bundled"] } +objc2-user-notifications = "0.3.2" +block2 = "0.6.2" +objc2-foundation = "0.3.2" [target.'cfg(target_os = "linux")'.dependencies] notify-rust = { version = "4", default-features = false, features = ["dbus"] } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index cfff73ae4..6096aa0f2 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -637,8 +637,13 @@ fn show_native_notification( tag: Option, ) -> Result<(), String> { use tauri_plugin_notification::NotificationExt; + let permission_state = app + .notification() + .permission_state() + .map(|s| format!("{s:?}")) + .unwrap_or_else(|e| format!("err({e})")); log::debug!( - "[notify] show_native_notification title_chars={} body_chars={} tag={:?}", + "[notify] show_native_notification title_chars={} body_chars={} tag={:?} permission={permission_state}", title.len(), body.len(), tag @@ -647,11 +652,102 @@ fn show_native_notification( if !body.is_empty() { builder = builder.body(&body); } + #[cfg(target_os = "macos")] + { + builder = builder.sound("default"); + } builder .show() .map_err(|e| format!("notification show failed: {e}")) } +#[cfg(target_os = "macos")] +fn macos_notification_permission_state_inner() -> Result { + use std::ptr::NonNull; + use std::sync::mpsc; + + use block2::RcBlock; + use objc2_user_notifications::{ + UNAuthorizationStatus, UNNotificationSettings, UNUserNotificationCenter, + }; + + let center = UNUserNotificationCenter::currentNotificationCenter(); + let (tx, rx) = mpsc::channel::(); + let completion = RcBlock::new(move |settings: NonNull| { + let status = unsafe { settings.as_ref().authorizationStatus() }; + let state = if status == UNAuthorizationStatus::Authorized { + "granted" + } else if status == UNAuthorizationStatus::Denied { + "denied" + } else if status == UNAuthorizationStatus::NotDetermined { + "not_determined" + } else if status == UNAuthorizationStatus::Provisional { + "provisional" + } else if status == UNAuthorizationStatus::Ephemeral { + "ephemeral" + } else { + "unknown" + }; + let _ = tx.send(state.to_string()); + }); + center.getNotificationSettingsWithCompletionHandler(&completion); + rx.recv_timeout(std::time::Duration::from_secs(2)) + .map_err(|_| "timed out waiting for macOS notification settings".to_string()) +} + +#[cfg(target_os = "macos")] +fn macos_notification_permission_request_inner() -> Result { + use block2::RcBlock; + use objc2::runtime::Bool; + use objc2_foundation::NSError; + use objc2_user_notifications::{UNAuthorizationOptions, UNUserNotificationCenter}; + use std::sync::mpsc; + + let center = UNUserNotificationCenter::currentNotificationCenter(); + let (tx, rx) = mpsc::channel::(); + let options = UNAuthorizationOptions::Alert + | UNAuthorizationOptions::Badge + | UNAuthorizationOptions::Sound; + let completion = RcBlock::new(move |granted: Bool, _error: *mut NSError| { + let _ = tx.send(granted.as_bool()); + }); + center.requestAuthorizationWithOptions_completionHandler(options, &completion); + let granted = rx + .recv_timeout(std::time::Duration::from_secs(5)) + .map_err(|_| "timed out waiting for macOS permission prompt result".to_string())?; + if granted { + Ok("granted".to_string()) + } else { + // If the user denies or notifications are disabled for the app, + // macOS reports `false` here. + Ok("denied".to_string()) + } +} + +#[tauri::command] +fn notification_permission_state() -> Result { + #[cfg(target_os = "macos")] + { + return macos_notification_permission_state_inner(); + } + #[cfg(not(target_os = "macos"))] + { + Ok("granted".to_string()) + } +} + +#[tauri::command] +fn notification_permission_request() -> Result { + #[cfg(target_os = "macos")] + { + return macos_notification_permission_request_inner(); + } + #[cfg(not(target_os = "macos"))] + { + Ok("granted".to_string()) + } +} + fn show_main_window(app: &AppHandle) -> Result<(), String> { let window = app .get_webview_window("main") @@ -1383,6 +1479,8 @@ pub fn run() { gmail::gmail_trash, gmail::gmail_add_label, gmail::gmail_find_linkedin_profile_url, + notification_permission_state, + notification_permission_request, activate_main_window, show_native_notification ]) diff --git a/app/src-tauri/vendor/tauri-plugin-notification b/app/src-tauri/vendor/tauri-plugin-notification index 7db785afb..36c4004f3 160000 --- a/app/src-tauri/vendor/tauri-plugin-notification +++ b/app/src-tauri/vendor/tauri-plugin-notification @@ -1 +1 @@ -Subproject commit 7db785afbeb6e4807fccd71dc25ebbd39ec2733a +Subproject commit 36c4004f3d6cd23c6ee0574d29eea65504a8f3ff diff --git a/app/src/components/OpenhumanLinkModal.tsx b/app/src/components/OpenhumanLinkModal.tsx index f8386f5ec..c2b1e83de 100644 --- a/app/src/components/OpenhumanLinkModal.tsx +++ b/app/src/components/OpenhumanLinkModal.tsx @@ -10,10 +10,14 @@ * * Mounted once at AppShell root. */ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { isTauri as coreIsTauri } from '@tauri-apps/api/core'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useChannelDefinitions } from '../hooks/useChannelDefinitions'; -import { showNativeNotification } from '../lib/nativeNotifications/tauriBridge'; +import { + ensureNotificationPermission, + showNativeNotification, +} from '../lib/nativeNotifications/tauriBridge'; import { purgeWebviewAccount } from '../services/webviewAccountService'; import { addAccount, removeAccount } from '../store/accountsSlice'; import { useAppDispatch, useAppSelector } from '../store/hooks'; @@ -181,21 +185,53 @@ function renderBody(path: string, close: () => void) { // ── Notifications ──────────────────────────────────────────────────────── const NotificationsBody = ({ close }: { close: () => void }) => { - const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle'); + const [status, setStatus] = useState<'idle' | 'sending' | 'queued' | 'sent' | 'error'>('idle'); const [error, setError] = useState(null); + const TEST_NOTIFICATION_DELAY_MS = 3500; + const notificationTimeoutRef = useRef(null); + const cancelledRef = useRef(false); + + useEffect(() => { + return () => { + cancelledRef.current = true; + if (notificationTimeoutRef.current !== null) { + window.clearTimeout(notificationTimeoutRef.current); + notificationTimeoutRef.current = null; + } + }; + }, []); const handleAllow = async () => { setStatus('sending'); setError(null); try { - // First send triggers the OS permission prompt on macOS / Windows. - // Once granted the notification appears and subsequent calls - // succeed silently. + const granted = await ensureNotificationPermission(); + if (coreIsTauri() && !granted) { + setStatus('error'); + setError( + 'Notification permission was denied. Please enable notifications for OpenHuman in System Settings → Notifications.' + ); + return; + } + // Delay the test ping so users can move focus away from OpenHuman. + // macOS commonly suppresses banner popouts while the app is foreground. + setStatus('queued'); + await new Promise(resolve => { + notificationTimeoutRef.current = window.setTimeout(() => { + notificationTimeoutRef.current = null; + resolve(); + }, TEST_NOTIFICATION_DELAY_MS); + }); + if (cancelledRef.current) { + return; + } await showNativeNotification({ title: 'OpenHuman is good to go', body: 'You will get pings here when something needs your attention.', - tag: 'welcome-notification-test', }); + if (cancelledRef.current) { + return; + } setStatus('sent'); } catch (e) { setStatus('error'); @@ -213,10 +249,19 @@ const NotificationsBody = ({ close }: { close: () => void }) => { + {status === 'queued' && ( +

+ Timer started. Switch to another app for a moment so the banner can pop out. +

+ )} {status === 'sent' && (

Sent. If you saw a pop-up in the corner, you're all set. If your OS asked for permission, diff --git a/app/src/components/notifications/NotificationCard.tsx b/app/src/components/notifications/NotificationCard.tsx index 01a03d668..613681611 100644 --- a/app/src/components/notifications/NotificationCard.tsx +++ b/app/src/components/notifications/NotificationCard.tsx @@ -50,12 +50,21 @@ function scoreBadgeClass(score: number): string { interface Props { notification: IntegrationNotification; onMarkRead: (id: string) => void; + onNavigate?: (id: string) => void; onDismiss?: (id: string) => void; } -const NotificationCard = ({ notification: n, onMarkRead, onDismiss }: Props) => { +const NotificationCard = ({ notification: n, onMarkRead, onNavigate, onDismiss }: Props) => { const isUnread = n.status === 'unread'; + const handleBodyClick = () => { + if (onNavigate) { + onNavigate(n.id); + } else if (isUnread) { + onMarkRead(n.id); + } + }; + return (