From e1942237edfa97b2071466366eaa89ec9c9bf9e5 Mon Sep 17 00:00:00 2001 From: Emircan Sahin Date: Thu, 3 Sep 2026 23:08:24 +0300 Subject: [PATCH 1/5] Sign the macOS dev bundle with the real bundle identifier The linker's ad-hoc signature carries a monocode- identifier while Info.plist says com.monocode.desktop. UNUserNotificationCenter refuses authorization without prompting when the two disagree, so tauri dev could never show a notification. Copy the binary instead of hard-linking it, since re-signing rewrites the file the running process was linked to. --- src-tauri/src/macos.rs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index 8eb0248..456799f 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -497,15 +497,28 @@ fn relaunch_from_dev_bundle() -> Result<(), String> { let bundled = macos_dir.join("monocode"); let _ = std::fs::remove_file(&bundled); - if std::fs::hard_link(&exe, &bundled).is_err() { - std::fs::copy(&exe, &bundled).map_err(|e| e.to_string())?; - } + // A copy, not a hard link: re-signing below rewrites the file, and the + // linked original is the executable running this code. + std::fs::copy(&exe, &bundled).map_err(|e| e.to_string())?; let mut perms = std::fs::metadata(&bundled) .map_err(|e| e.to_string())? .permissions(); perms.set_mode(0o755); std::fs::set_permissions(&bundled, perms).map_err(|e| e.to_string())?; + // The linker's ad-hoc signature carries a `monocode-` identifier. + // UNUserNotificationCenter refuses authorization, without prompting, + // unless the signing identifier matches CFBundleIdentifier. + let signed = Command::new("/usr/bin/codesign") + .args(["--force", "--sign", "-", "--identifier", DEV_BUNDLE_ID]) + .arg(&app) + .status() + .map(|status| status.success()) + .unwrap_or(false); + if !signed { + eprintln!("monocode: macos dev bundle: codesign failed; notifications stay off"); + } + let err = Command::new(&bundled) .args(std::env::args_os().skip(1)) .exec(); @@ -525,6 +538,9 @@ fn write_dev_bundle_icons(app: &std::path::Path) -> Result<(), String> { Ok(()) } +/// Must match `CFBundleIdentifier` in `DEV_BUNDLE_PLIST` and tauri.conf.json. +#[cfg(debug_assertions)] +const DEV_BUNDLE_ID: &str = "com.monocode.desktop"; #[cfg(debug_assertions)] const DEV_ICNS: &[u8] = include_bytes!("../icons/icon.icns"); #[cfg(debug_assertions)] From dee768ffc64d7abbee6d405331254b21ddc7e4c1 Mon Sep 17 00:00:00 2001 From: Emircan Sahin Date: Thu, 3 Sep 2026 23:08:24 +0300 Subject: [PATCH 2/5] Add opt-in desktop notifications for finished and waiting sessions Off by default. When on, a system notification appears when a turn ends or an agent waits on an approval or question in a session that is not on screen, whether MonoCode is in the background or another session is open. Clicking it, or its Show button, focuses the window and opens the session. macOS goes through UNUserNotificationCenter directly: the app already links it for the Dock badge, it reports the real authorization state, and the Settings row offers a System Settings link when alerts are blocked. Linux uses notify-rust over the freedesktop bus. The Sounds setting decides whether the notification carries a sound; the in-app cue is skipped when a banner fires so nothing chimes twice. --- CHANGELOG.md | 1 + Cargo.lock | 40 ++++ src-tauri/Cargo.toml | 5 +- src-tauri/src/lib.rs | 6 + src-tauri/src/notifications.rs | 399 +++++++++++++++++++++++++++++++++ src/App.tsx | 46 +++- src/lib/notifications.test.ts | 197 ++++++++++++++++ src/lib/notifications.ts | 187 +++++++++++++++ src/surfaces/SettingsView.tsx | 71 ++++++ 9 files changed, 950 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/notifications.rs create mode 100644 src/lib/notifications.test.ts create mode 100644 src/lib/notifications.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ab4d09..2b3a915 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The composer hides its internal scrollbar, and a disabled attachment button names the active harness that does not support attachments. - Pull request CI cancels superseded runs while main-branch and other non-PR runs remain independent. In #57 by @tcmarkfeld. - The README uses a higher-resolution application screenshot. +- Settings: Notifications, off by default. With it on, a system notification appears when a turn finishes or an agent waits on an approval or question in a session that is not on screen, whether MonoCode is in the background or another session is open; clicking it jumps to that session. Turning it on asks macOS for permission, and a blocked state links to System Settings. The Sounds setting decides whether the notification plays a sound, and the in-app cue is skipped when the banner fires. ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 3552111..99d5026 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2110,6 +2110,20 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2", + "objc2-foundation", + "time", + "uuid", +] + [[package]] name = "markup5ever" version = "0.38.0" @@ -2176,6 +2190,7 @@ dependencies = [ "base64 0.22.1", "block2", "libc", + "notify-rust", "objc2", "objc2-app-kit", "objc2-foundation", @@ -2245,6 +2260,20 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "notify-rust" +version = "4.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -4107,6 +4136,17 @@ dependencies = [ "toml 1.1.4+spec-1.1.0", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" +dependencies = [ + "thiserror 2.0.20", + "windows", + "windows-version", +] + [[package]] name = "tempfile" version = "3.27.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 05a00aa..c0ffee8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -34,9 +34,12 @@ block2 = "0.6.2" objc2 = "0.6" objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSButton", "NSColor", "NSControl", "NSDockTile", "NSImage", "NSLayoutAnchor", "NSLayoutConstraint", "NSMenu", "NSMenuItem", "NSResponder", "NSView", "NSWindow", "objc2-core-foundation"] } objc2-foundation = { version = "0.3", features = ["NSGeometry", "NSError", "NSString"] } -objc2-user-notifications = { version = "0.3.2", features = ["UNUserNotificationCenter", "block2"] } +objc2-user-notifications = { version = "0.3.2", features = ["UNUserNotificationCenter", "UNNotification", "UNNotificationAction", "UNNotificationCategory", "UNNotificationContent", "UNNotificationRequest", "UNNotificationResponse", "UNNotificationSettings", "UNNotificationSound", "block2"] } raw-window-handle = "0.6" +[target.'cfg(target_os = "linux")'.dependencies] +notify-rust = "4.18" + [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-updater = "2" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5c66bbd..fba0a86 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,6 +10,7 @@ mod linear; mod macos; mod menu; mod notes; +mod notifications; mod project_logo; mod pty; mod rate_limits; @@ -165,6 +166,10 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ default_cwd, home_dir, + notifications::notification_permission, + notifications::request_notification_permission, + notifications::show_notification, + notifications::open_notification_settings, fs::list_dir, fs::list_project_files, fs::git_diff_stats, @@ -300,6 +305,7 @@ pub fn run() { #[cfg(target_os = "macos")] { macos::request_badge_authorization(); + notifications::install_delegate(handle); #[cfg(debug_assertions)] macos::prefer_bundle_dock_icon(); } diff --git a/src-tauri/src/notifications.rs b/src-tauri/src/notifications.rs new file mode 100644 index 0000000..f5d3962 --- /dev/null +++ b/src-tauri/src/notifications.rs @@ -0,0 +1,399 @@ +//! Desktop notifications for turns that end or stall while the window is in +//! the background. +//! +//! macOS goes through `UNUserNotificationCenter` directly: the app already +//! links it for the Dock badge, it reports the real authorization state, and +//! a delegate turns a click into a jump back to the session. Linux uses the +//! freedesktop notification bus, which has no permission model. + +use serde::Serialize; +use tauri::AppHandle; + +/// Emitted to every window when the user clicks a notification. Payload is +/// the session id; the window that owns that session handles it. +pub const CLICK_EVENT: &str = "monocode:notification-click"; + +#[cfg(target_os = "macos")] +pub use platform::install_delegate; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Permission { + /// Never asked, or not yet answered. + Prompt, + Granted, + /// Declined at the prompt, or alerts switched off in System Settings. + Denied, + /// No notification backend on this platform. + #[allow(dead_code)] + Unsupported, +} + +#[tauri::command] +pub async fn notification_permission() -> Permission { + platform::permission().await +} + +#[tauri::command] +pub async fn request_notification_permission() -> Permission { + platform::request_permission().await +} + +#[tauri::command] +pub fn show_notification( + app: AppHandle, + session_id: String, + title: String, + subtitle: String, + body: String, + sound: bool, +) -> Result<(), String> { + platform::show(&app, &session_id, &title, &subtitle, &body, sound) +} + +/// Opens the app's page in the OS notification settings, where the user can +/// re-enable alerts after declining the prompt. +#[tauri::command] +pub fn open_notification_settings(app: AppHandle) -> Result<(), String> { + platform::open_settings(&app) +} + +#[cfg(target_os = "macos")] +mod platform { + use std::cell::RefCell; + use std::ptr::NonNull; + use std::sync::mpsc; + + use block2::RcBlock; + use objc2::rc::Retained; + use objc2::runtime::{Bool, NSObject, NSObjectProtocol, ProtocolObject}; + use objc2::{define_class, AnyThread, DefinedClass, MainThreadMarker}; + use objc2_foundation::{NSArray, NSError, NSSet, NSString}; + use objc2_user_notifications::{ + UNAuthorizationOptions, UNAuthorizationStatus, UNMutableNotificationContent, + UNNotification, UNNotificationAction, UNNotificationActionOptions, UNNotificationCategory, + UNNotificationCategoryOptions, UNNotificationPresentationOptions, UNNotificationRequest, + UNNotificationResponse, UNNotificationSetting, UNNotificationSettings, UNNotificationSound, + UNUserNotificationCenter, UNUserNotificationCenterDelegate, + }; + use tauri::{AppHandle, Emitter}; + + use super::{Permission, CLICK_EVENT}; + + /// Request identifiers carry the session so a click can find it without + /// touching `userInfo`. Each request gets a fresh suffix: reusing one + /// replaces the previous banner, and macOS drops replacements that land + /// while the app is frontmost. + const ID_PREFIX: &str = "session:"; + + fn request_identifier(session_id: &str) -> String { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("{ID_PREFIX}{session_id}/{nanos}") + } + + fn session_from_identifier(identifier: &str) -> Option<&str> { + let rest = identifier.strip_prefix(ID_PREFIX)?; + Some(rest.split('/').next().unwrap_or(rest)) + } + + /// Category with a single "Show" button, so the banner offers the jump + /// explicitly instead of relying on a click on the body. + const CATEGORY: &str = "monocode.session"; + const SHOW_ACTION: &str = "monocode.session.show"; + + fn options() -> UNAuthorizationOptions { + UNAuthorizationOptions::Alert + | UNAuthorizationOptions::Sound + | UNAuthorizationOptions::Badge + } + + fn map_settings(settings: &UNNotificationSettings) -> Permission { + match settings.authorizationStatus() { + UNAuthorizationStatus::NotDetermined => Permission::Prompt, + UNAuthorizationStatus::Denied => Permission::Denied, + // Authorized for badges only still leaves alerts off. + _ if settings.alertSetting() == UNNotificationSetting::Disabled => Permission::Denied, + _ => Permission::Granted, + } + } + + /// Completion handlers run on a UN background queue. The ObjC objects + /// are released before any `.await` so the command future stays `Send`; + /// only the channel crosses into the async runtime. + fn query_permission() -> mpsc::Receiver { + let (tx, rx) = mpsc::channel(); + let handler = RcBlock::new(move |settings: NonNull| { + let settings = unsafe { settings.as_ref() }; + let _ = tx.send(map_settings(settings)); + }); + UNUserNotificationCenter::currentNotificationCenter() + .getNotificationSettingsWithCompletionHandler(&handler); + rx + } + + fn start_request() -> mpsc::Receiver<()> { + let (tx, rx) = mpsc::channel(); + let handler = RcBlock::new(move |_granted: Bool, _error: *mut NSError| { + let _ = tx.send(()); + }); + UNUserNotificationCenter::currentNotificationCenter() + .requestAuthorizationWithOptions_completionHandler(options(), &handler); + rx + } + + async fn wait(rx: mpsc::Receiver) -> Option { + tauri::async_runtime::spawn_blocking(move || rx.recv().ok()) + .await + .ok() + .flatten() + } + + pub(super) async fn permission() -> Permission { + wait(query_permission()).await.unwrap_or(Permission::Denied) + } + + pub(super) async fn request_permission() -> Permission { + wait(start_request()).await; + permission().await + } + + pub(super) fn show( + _app: &AppHandle, + session_id: &str, + title: &str, + subtitle: &str, + body: &str, + sound: bool, + ) -> Result<(), String> { + let content = UNMutableNotificationContent::new(); + content.setTitle(&NSString::from_str(title)); + content.setSubtitle(&NSString::from_str(subtitle)); + content.setBody(&NSString::from_str(body)); + content.setCategoryIdentifier(&NSString::from_str(CATEGORY)); + if sound { + content.setSound(Some(&UNNotificationSound::defaultSound())); + } + let identifier = NSString::from_str(&request_identifier(session_id)); + let request = UNNotificationRequest::requestWithIdentifier_content_trigger( + &identifier, + &content, + None, + ); + let on_done = RcBlock::new(|error: *mut NSError| { + if !error.is_null() { + let error = unsafe { &*error }; + eprintln!("monocode: notification rejected: {error}"); + } + }); + // Adding while authorization is still undetermined fails with + // UNErrorDomain 1, so ask first; the call is a no-op once decided. + let on_authorized = RcBlock::new(move |granted: Bool, _error: *mut NSError| { + if !granted.as_bool() { + eprintln!("monocode: notifications not authorized; skipping banner"); + return; + } + UNUserNotificationCenter::currentNotificationCenter() + .addNotificationRequest_withCompletionHandler(&request, Some(&on_done)); + }); + UNUserNotificationCenter::currentNotificationCenter() + .requestAuthorizationWithOptions_completionHandler(options(), &on_authorized); + Ok(()) + } + + pub(super) fn open_settings(app: &AppHandle) -> Result<(), String> { + let url = format!( + "x-apple.systempreferences:com.apple.Notifications-Settings.extension?id={}", + app.config().identifier + ); + std::process::Command::new("open") + .arg(url) + .spawn() + .map(|_| ()) + .map_err(|err| err.to_string()) + } + + struct DelegateIvars { + app: AppHandle, + } + + define_class!( + #[unsafe(super(NSObject))] + #[name = "MonoCodeNotificationDelegate"] + #[ivars = DelegateIvars] + struct Delegate; + + unsafe impl NSObjectProtocol for Delegate {} + + unsafe impl UNUserNotificationCenterDelegate for Delegate { + /// Without this macOS drops banners while the app is frontmost, + /// and a finished background session deserves one either way. + #[unsafe(method(userNotificationCenter:willPresentNotification:withCompletionHandler:))] + fn will_present( + &self, + _center: &UNUserNotificationCenter, + _notification: &UNNotification, + completion: &block2::DynBlock, + ) { + completion.call((UNNotificationPresentationOptions::Banner + | UNNotificationPresentationOptions::List + | UNNotificationPresentationOptions::Sound,)); + } + + #[unsafe(method(userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:))] + fn did_receive( + &self, + _center: &UNUserNotificationCenter, + response: &UNNotificationResponse, + completion: &block2::DynBlock, + ) { + let identifier = response.notification().request().identifier().to_string(); + if let Some(session_id) = session_from_identifier(&identifier) { + let _ = self.ivars().app.emit(CLICK_EVENT, session_id); + } + completion.call(()); + } + } + ); + + #[cfg(test)] + mod tests { + use super::*; + use objc2::runtime::AnyProtocol; + use objc2::{sel, ClassType}; + + #[test] + fn identifier_round_trips_the_session() { + let id = request_identifier("549ae7ac"); + assert_eq!(session_from_identifier(&id), Some("549ae7ac")); + assert_eq!(session_from_identifier("other"), None); + } + + #[test] + fn delegate_registers_protocol_methods() { + let cls = Delegate::class(); + let proto = + AnyProtocol::get(c"UNUserNotificationCenterDelegate").expect("protocol loaded"); + assert!(cls.conforms_to(proto)); + assert!(cls.responds_to(sel!( + userNotificationCenter:willPresentNotification:withCompletionHandler: + ))); + assert!(cls.responds_to(sel!( + userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler: + ))); + } + } + + thread_local! { + static DELEGATE: RefCell>> = const { RefCell::new(None) }; + } + + /// Must run on the main thread once the app is ready; the center keeps a + /// weak reference, so the delegate is retained here for the app lifetime. + pub fn install_delegate(app: &AppHandle) { + if MainThreadMarker::new().is_none() { + return; + } + let delegate = Delegate::alloc().set_ivars(DelegateIvars { app: app.clone() }); + let delegate: Retained = unsafe { objc2::msg_send![super(delegate), init] }; + let center = UNUserNotificationCenter::currentNotificationCenter(); + center.setDelegate(Some(ProtocolObject::from_ref(&*delegate))); + DELEGATE.with(|slot| *slot.borrow_mut() = Some(delegate)); + + let show = UNNotificationAction::actionWithIdentifier_title_options( + &NSString::from_str(SHOW_ACTION), + &NSString::from_str("Show"), + UNNotificationActionOptions::Foreground, + ); + let category = + UNNotificationCategory::categoryWithIdentifier_actions_intentIdentifiers_options( + &NSString::from_str(CATEGORY), + &NSArray::from_retained_slice(&[show]), + &NSArray::new(), + UNNotificationCategoryOptions::empty(), + ); + center.setNotificationCategories(&NSSet::from_retained_slice(&[category])); + } +} + +#[cfg(target_os = "linux")] +mod platform { + use tauri::{AppHandle, Emitter}; + + use super::{Permission, CLICK_EVENT}; + + pub(super) async fn permission() -> Permission { + Permission::Granted + } + + pub(super) async fn request_permission() -> Permission { + Permission::Granted + } + + pub(super) fn show( + app: &AppHandle, + session_id: &str, + title: &str, + subtitle: &str, + body: &str, + sound: bool, + ) -> Result<(), String> { + let mut notification = notify_rust::Notification::new(); + notification + .appname("MonoCode") + .summary(&format!("{title}: {subtitle}")) + .body(body) + .icon("monocode"); + if sound { + notification.sound_name("message-new-instant"); + } + let handle = notification.show().map_err(|err| err.to_string())?; + let app = app.clone(); + let session_id = session_id.to_string(); + // `wait_for_action` blocks until the notification closes. + std::thread::spawn(move || { + handle.wait_for_action(|action| { + if action == "default" { + let _ = app.emit(CLICK_EVENT, session_id.as_str()); + } + }); + }); + Ok(()) + } + + pub(super) fn open_settings(_app: &AppHandle) -> Result<(), String> { + Err("no notification settings page on this platform".into()) + } +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +mod platform { + use tauri::AppHandle; + + use super::Permission; + + pub(super) async fn permission() -> Permission { + Permission::Unsupported + } + + pub(super) async fn request_permission() -> Permission { + Permission::Unsupported + } + + pub(super) fn show( + _app: &AppHandle, + _session_id: &str, + _title: &str, + _subtitle: &str, + _body: &str, + _sound: bool, + ) -> Result<(), String> { + Err("notifications are not supported on this platform".into()) + } + + pub(super) fn open_settings(_app: &AppHandle) -> Result<(), String> { + Err("notifications are not supported on this platform".into()) + } +} diff --git a/src/App.tsx b/src/App.tsx index 6ebf994..41a074d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -257,6 +257,13 @@ import { syncDockBadge } from "./lib/dockBadge"; import { liveAgentsFromSessions } from "./lib/liveAgents"; import { hiddenApprovalNotices } from "./lib/approvalToast"; import { nextUnseenFinishedSessions } from "./lib/sessionDone"; +import { + loadNotificationsEnabled, + NOTIFICATION_CLICK_EVENT, + notifySession, + probeNotificationPermission, + setWindowFocused, +} from "./lib/notifications"; import { playCue } from "./lib/sounds"; import { deferUnhandledEscape, @@ -901,6 +908,26 @@ export default function App({ const approvalSessionIds = approvalSessionIdsRef.current; const activeSessionId = active?.id; + const activeSessionIdRef = useRef(activeSessionId); + activeSessionIdRef.current = activeSessionId; + + const notifiedApprovalIdsRef = useRef>(new Set()); + useEffect(() => { + const previous = notifiedApprovalIdsRef.current; + notifiedApprovalIdsRef.current = approvalSessionIds; + for (const id of approvalSessionIds) { + if (previous.has(id)) continue; + const session = sessionsRef.current.find((s) => s.id === id); + if (session) { + notifySession(session, "needsInput", id === activeSessionIdRef.current); + } + } + }, [approvalSessionIds]); + + // Cache the OS decision so a turn ending later can skip a denied banner. + useEffect(() => { + if (loadNotificationsEnabled()) void probeNotificationPermission(); + }, []); const busyForDoneRef = useRef(busySessionIds); const focusedForDoneRef = useRef(activeSessionId); const unseenFinishedRef = useRef>(new Set()); @@ -940,6 +967,7 @@ export default function App({ let unlisten: (() => void) | undefined; void getCurrentWindow() .onFocusChanged(({ payload: focused }) => { + setWindowFocused(focused); if (focused) { flushHarnessEvents(); syncDockBadge(sessionsRef.current); @@ -3571,7 +3599,15 @@ export default function App({ : finalized; }), ); - playCue("turnFinished"); + // Next tick: the flush above has rendered by then, so the banner + // quotes the reply's final text rather than the previous batch. + window.setTimeout(() => { + const finished = sessionsRef.current.find((s) => s.id === sessionId); + const visible = sessionId === activeSessionIdRef.current; + if (!finished || !notifySession(finished, "finished", visible)) { + playCue("turnFinished"); + } + }, 0); notifyReviewChanged(sessionId); notifyGitChanged(); nudgeWorkspace(workCwd); @@ -4353,6 +4389,7 @@ export default function App({ onNewTerminalTab, onToggleProjectTerminal, openSettings, + onOpenApprovalSession, }); actions.current = { onNew, @@ -4376,6 +4413,7 @@ export default function App({ onNewTerminalTab, onToggleProjectTerminal, openSettings, + onOpenApprovalSession, }; const debounce = useRef({ name: "", at: 0 }); @@ -4553,6 +4591,12 @@ export default function App({ listen("open_model_picker", () => { window.dispatchEvent(new Event("open_model_picker")); }), + // Every window hears the click; only the one holding the session acts. + listen(NOTIFICATION_CLICK_EVENT, ({ payload: sessionId }) => { + if (!sessionsRef.current.some((s) => s.id === sessionId)) return; + void getCurrentWindow().setFocus(); + actions.current.onOpenApprovalSession(sessionId); + }), ]; return () => { void Promise.all(unlisten).then((fns) => fns.forEach((fn) => fn())); diff --git a/src/lib/notifications.test.ts b/src/lib/notifications.test.ts new file mode 100644 index 0000000..8626e12 --- /dev/null +++ b/src/lib/notifications.test.ts @@ -0,0 +1,197 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + loadNotificationsEnabled, + NOTIFICATIONS_DEFAULT, + notificationText, + saveNotificationsEnabled, + shouldNotify, +} from "./notifications"; +import { newSession, type Session } from "./session"; + +const KEY = "monocode.notifications"; + +function chat(patch: Partial = {}): Session { + const session = newSession("claude", "/tmp/a"); + session.title = "claude · Fix the sidebar"; + session.blocks = [{ id: "u1", role: "user", text: "hello" }]; + return { ...session, ...patch, blocks: patch.blocks ?? session.blocks }; +} + +describe("notifications setting", () => { + beforeEach(mockLocalStorage); + afterEach(() => localStorage.removeItem(KEY)); + + it("is off until the user opts in", () => { + expect(NOTIFICATIONS_DEFAULT).toBe(false); + expect(loadNotificationsEnabled()).toBe(false); + }); + + it("round-trips", () => { + saveNotificationsEnabled(true); + expect(loadNotificationsEnabled()).toBe(true); + saveNotificationsEnabled(false); + expect(loadNotificationsEnabled()).toBe(false); + }); +}); + +describe("shouldNotify", () => { + it("stays quiet while the session is on screen in a focused window", () => { + expect( + shouldNotify({ + enabled: true, + permission: "granted", + windowFocused: true, + sessionVisible: true, + }), + ).toBe(false); + }); + + it("fires for a session that is not on screen even when focused", () => { + expect( + shouldNotify({ + enabled: true, + permission: "granted", + windowFocused: true, + sessionVisible: false, + }), + ).toBe(true); + }); + + it("respects the toggle and the OS decision", () => { + expect( + shouldNotify({ + enabled: false, + permission: "granted", + windowFocused: false, + sessionVisible: true, + }), + ).toBe(false); + expect( + shouldNotify({ + enabled: true, + permission: "denied", + windowFocused: false, + sessionVisible: true, + }), + ).toBe(false); + expect( + shouldNotify({ + enabled: true, + permission: "unsupported", + windowFocused: false, + sessionVisible: true, + }), + ).toBe(false); + }); + + it("fires when unfocused and allowed, or still undecided", () => { + expect( + shouldNotify({ + enabled: true, + permission: "granted", + windowFocused: false, + sessionVisible: true, + }), + ).toBe(true); + expect( + shouldNotify({ + enabled: true, + permission: "prompt", + windowFocused: false, + sessionVisible: true, + }), + ).toBe(true); + }); +}); + +describe("notificationText", () => { + it("leads with the app, then the session title, then the reply", () => { + const session = chat({ + blocks: [ + { id: "u1", role: "user", text: "hello" }, + { + id: "a1", + role: "assistant", + text: "\n\nDone. Sidebar\nfixed.\n\nDetails below.", + }, + ], + }); + expect(notificationText(session, "finished")).toEqual({ + title: "MonoCode", + subtitle: "Fix the sidebar", + body: "Done. Sidebar fixed.", + }); + }); + + it("falls back to a generic body without a reply", () => { + expect(notificationText(chat(), "finished").body).toBe( + "Claude Code finished", + ); + }); + + it("clips long bodies", () => { + const session = chat({ + blocks: [{ id: "a1", role: "assistant", text: "x".repeat(400) }], + }); + const body = notificationText(session, "finished").body; + expect(body.length).toBe(240); + expect(body.endsWith("…")).toBe(true); + }); + + it("names the pending approval", () => { + const session = chat({ + blocks: [ + { + id: "p1", + role: "approval", + text: "Run npm test", + tool: { title: "Run npm test" }, + approval: { requestId: 1 }, + }, + ], + }); + expect(notificationText(session, "needsInput")).toEqual({ + title: "MonoCode", + subtitle: "Fix the sidebar", + body: "Approve: Run npm test", + }); + }); + + it("prefers the question prompt over an approval", () => { + const session = chat({ + pendingQuestion: { + requestId: 2, + questions: [ + { + id: "q", + prompt: "Which database?", + multiSelect: false, + allowCustom: false, + options: [], + }, + ], + }, + }); + expect(notificationText(session, "needsInput")).toEqual({ + title: "MonoCode", + subtitle: "Fix the sidebar", + body: "Which database?", + }); + }); +}); + +function mockLocalStorage() { + const data = new Map(); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => data.get(key) ?? null, + setItem: (key: string, value: string) => { + data.set(key, value); + }, + removeItem: (key: string) => { + data.delete(key); + }, + }, + }); +} diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts new file mode 100644 index 0000000..42189df --- /dev/null +++ b/src/lib/notifications.ts @@ -0,0 +1,187 @@ +import { invoke } from "@tauri-apps/api/core"; +import { HARNESS_TITLE, sessionDisplayTitle, type Session } from "./session"; +import { loadSoundsEnabled } from "./sounds"; + +const KEY = "monocode.notifications"; + +/** Off until the user opts in; enabling asks the OS for permission. */ +export const NOTIFICATIONS_DEFAULT = false; + +export const NOTIFICATIONS_CHANGE_EVENT = "monocode:notifications-change"; + +/** Rust emits this with the session id when a notification is clicked. */ +export const NOTIFICATION_CLICK_EVENT = "monocode:notification-click"; + +export type NotificationPermission = + | "prompt" + | "granted" + | "denied" + | "unsupported"; + +export function loadNotificationsEnabled(): boolean { + try { + const raw = localStorage.getItem(KEY); + if (raw == null) return NOTIFICATIONS_DEFAULT; + return raw === "1" || raw === "true"; + } catch { + return NOTIFICATIONS_DEFAULT; + } +} + +export function saveNotificationsEnabled(value: boolean) { + try { + localStorage.setItem(KEY, value ? "1" : "0"); + } catch { + // private mode / quota + } + if (typeof window === "undefined") return; + window.dispatchEvent( + new CustomEvent(NOTIFICATIONS_CHANGE_EVENT, { detail: value }), + ); +} + +let permission: NotificationPermission = "prompt"; + +/** Last permission the OS reported; refreshed by the probes below. */ +export function cachedNotificationPermission(): NotificationPermission { + return permission; +} + +export async function probeNotificationPermission(): Promise { + try { + permission = await invoke("notification_permission"); + } catch { + permission = "unsupported"; + } + return permission; +} + +/** Shows the OS prompt when undecided; otherwise reports the current state. */ +export async function requestNotificationPermission(): Promise { + try { + permission = await invoke( + "request_notification_permission", + ); + } catch { + permission = "unsupported"; + } + return permission; +} + +export function openNotificationSettings(): Promise { + return invoke("open_notification_settings"); +} + +/** + * Tracked from Tauri's focus event rather than `document.hasFocus()`, which + * WKWebView keeps reporting true after the window drops to the background. + */ +let windowFocused = + typeof document !== "undefined" ? document.hasFocus() : true; + +export function setWindowFocused(focused: boolean) { + windowFocused = focused; +} + +/** + * A banner only earns its place while the user is looking elsewhere: another + * app, or another session. The transcript already shows the change on the + * session that is on screen. + */ +export function shouldNotify({ + enabled, + permission, + windowFocused, + sessionVisible, +}: { + enabled: boolean; + permission: NotificationPermission; + windowFocused: boolean; + sessionVisible: boolean; +}): boolean { + if (!enabled || (windowFocused && sessionVisible)) return false; + return permission === "granted" || permission === "prompt"; +} + +export type NotificationEvent = "finished" | "needsInput"; + +/** App name, then the session title, then the reply itself. */ +export type NotificationText = { title: string; subtitle: string; body: string }; + +const BODY_MAX = 240; + +export function notificationText( + session: Session, + event: NotificationEvent, +): NotificationText { + const title = "MonoCode"; + const subtitle = sessionDisplayTitle(session.title, session.harness); + const harness = HARNESS_TITLE[session.harness]; + if (event === "needsInput") { + const question = session.pendingQuestion; + if (question) { + const prompt = question.title || question.questions[0]?.prompt; + return { + title, + subtitle, + body: clip(prompt || `${harness} has a question for you`), + }; + } + const pending = [...session.blocks] + .reverse() + .find((block) => block.approval && !block.approval.decided); + const what = pending?.tool?.title || pending?.text; + return { + title, + subtitle, + body: clip(what ? `Approve: ${what}` : `${harness} needs your approval`), + }; + } + const reply = [...session.blocks] + .reverse() + .find((block) => block.role === "assistant" && block.text.trim()); + return { + title, + subtitle, + body: clip(reply?.text || `${harness} finished`), + }; +} + +/** First paragraph, whitespace collapsed; macOS wraps and truncates the rest. */ +function clip(text: string): string { + const paragraph = + text + .split(/\n\s*\n/) + .map((part) => part.replace(/\s+/g, " ").trim()) + .find((part) => part.length > 0) ?? ""; + return paragraph.length > BODY_MAX + ? `${paragraph.slice(0, BODY_MAX - 1)}…` + : paragraph; +} + +/** + * Sends the banner when policy allows. Returns true when one was dispatched + * so callers can skip the in-app cue: the OS sound stands in for it. + */ +export function notifySession( + session: Session, + event: NotificationEvent, + sessionVisible: boolean, +): boolean { + const decision = shouldNotify({ + enabled: loadNotificationsEnabled(), + permission, + windowFocused, + sessionVisible, + }); + if (!decision) return false; + const { title, subtitle, body } = notificationText(session, event); + void invoke("show_notification", { + sessionId: session.id, + title, + subtitle, + body, + sound: loadSoundsEnabled(), + }).catch(() => {}); + return true; +} diff --git a/src/surfaces/SettingsView.tsx b/src/surfaces/SettingsView.tsx index 1fd326e..411d3a1 100644 --- a/src/surfaces/SettingsView.tsx +++ b/src/surfaces/SettingsView.tsx @@ -136,6 +136,15 @@ import { type SettingsSectionId, } from "../lib/settings"; import { loadSoundsEnabled, playCue, saveSoundsEnabled } from "../lib/sounds"; +import { + cachedNotificationPermission, + loadNotificationsEnabled, + openNotificationSettings, + probeNotificationPermission, + requestNotificationPermission, + saveNotificationsEnabled, + type NotificationPermission, +} from "../lib/notifications"; import { installPendingUpdate, readAppVersion, @@ -276,8 +285,25 @@ function GeneralPage({ loadLiveAgentsEnabled, ); const [soundsEnabled, setSoundsEnabled] = useState(loadSoundsEnabled); + const [notificationsEnabled, setNotificationsEnabled] = useState( + loadNotificationsEnabled, + ); + const [notificationPermission, setNotificationPermission] = + useState(cachedNotificationPermission); const [claudeHooks, setClaudeHooks] = useState(loadClaudeHooks); + // The user may flip the switch in System Settings and come back: re-read + // the OS state whenever the window regains focus while the toggle is on. + useEffect(() => { + if (!notificationsEnabled) return; + const refresh = () => { + void probeNotificationPermission().then(setNotificationPermission); + }; + refresh(); + window.addEventListener("focus", refresh); + return () => window.removeEventListener("focus", refresh); + }, [notificationsEnabled]); + useEffect(() => { const onAnchor = (event: Event) => { setTranscriptAnchor((event as CustomEvent).detail === true); @@ -333,6 +359,13 @@ function GeneralPage({ setSoundsEnabled(next); }; + const onNotificationsEnabled = (next: boolean) => { + saveNotificationsEnabled(next); + setNotificationsEnabled(next); + if (!next) return; + void requestNotificationPermission().then(setNotificationPermission); + }; + const onClaudeHooks = (next: boolean) => { saveClaudeHooks(next); setClaudeHooks(next); @@ -434,6 +467,24 @@ function GeneralPage({ > + + {notificationsEnabled && notificationPermission === "denied" ? ( + + ) : null} + {notificationsEnabled && notificationPermission === "unsupported" ? ( + + Not available on this platform + + ) : null} + + + Permission needed + {IS_MAC ? ( + + ) : null} + + ); +} + function Toggle({ label, on, From bb9d923babb2da4a27e17b25cfbca6718af47b80 Mon Sep 17 00:00:00 2001 From: Emircan Sahin Date: Thu, 3 Sep 2026 23:52:19 +0300 Subject: [PATCH 3/5] Silence dead-code lint on notification permission variants for Linux --- src-tauri/src/notifications.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/notifications.rs b/src-tauri/src/notifications.rs index f5d3962..14f92bb 100644 --- a/src-tauri/src/notifications.rs +++ b/src-tauri/src/notifications.rs @@ -16,6 +16,9 @@ pub const CLICK_EVENT: &str = "monocode:notification-click"; #[cfg(target_os = "macos")] pub use platform::install_delegate; +/// Each platform constructs only the variants it can reach, so the lint is +/// silenced for the whole enum rather than per target. +#[allow(dead_code)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] pub enum Permission { @@ -25,7 +28,6 @@ pub enum Permission { /// Declined at the prompt, or alerts switched off in System Settings. Denied, /// No notification backend on this platform. - #[allow(dead_code)] Unsupported, } From aa2039553359691c055206df88e1ab15cf2fd882 Mon Sep 17 00:00:00 2001 From: Emircan Sahin Date: Fri, 4 Sep 2026 19:20:25 +0300 Subject: [PATCH 4/5] Address review: defer macOS prompt, fix Linux click action and markup, keep cue on failed dispatch - macOS no longer requests badge-only authorization at startup while the prompt is undecided; the Notifications toggle owns the one-time dialog. - Linux registers the "default" action so servers report the click, and escapes notification bodies since agent output is rendered as markup. - The in-app turn-finished cue is skipped only after the OS accepted the notification, not on dispatch. --- CHANGELOG.md | 2 +- src-tauri/src/macos.rs | 32 +++++++++++++++++++++++++------- src-tauri/src/notifications.rs | 34 ++++++++++++++++++++++++++++++++-- src/App.tsx | 15 +++++++++++---- src/lib/notifications.ts | 29 +++++++++++++++++------------ 5 files changed, 86 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b3a915..04467c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Diff reviews can be annotated line by line in both Unified and Editor views. Use the comment action on a changed line to write a note and add its file, line number, and code context to the active composer; collect multiple comments and send them to the agent in one prompt. +- Settings: Notifications, off by default. With it on, a system notification appears when a turn finishes or an agent waits on an approval or question in a session that is not on screen, whether MonoCode is in the background or another session is open; clicking it jumps to that session. Turning it on asks macOS for permission, and a blocked state links to System Settings. The Sounds setting decides whether the notification plays a sound, and the in-app cue is skipped when the banner fires. ## [0.1.32] - 2026-09-04 @@ -29,7 +30,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The composer hides its internal scrollbar, and a disabled attachment button names the active harness that does not support attachments. - Pull request CI cancels superseded runs while main-branch and other non-PR runs remain independent. In #57 by @tcmarkfeld. - The README uses a higher-resolution application screenshot. -- Settings: Notifications, off by default. With it on, a system notification appears when a turn finishes or an agent waits on an approval or question in a session that is not on screen, whether MonoCode is in the background or another session is open; clicking it jumps to that session. Turning it on asks macOS for permission, and a blocked state links to System Settings. The Sounds setting decides whether the notification plays a sound, and the in-app cue is skipped when the banner fires. ### Fixed diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index 456799f..73e2312 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -378,21 +378,39 @@ thread_local! { /// Since macOS 12, `NSDockTile` badge updates are ignored unless the app has /// requested `UNUserNotificationCenter` authorization with the badge option. /// Must run on the main thread after launch (`RunEvent::Ready`), not in setup. +/// +/// Only re-requests once the user has already answered the prompt: the +/// one-time system dialog is reserved for the Notifications toggle, so a +/// badge-only request at startup must not consume it. Until then the badge +/// stays off. pub(crate) fn request_badge_authorization() { - let Some(mtm) = MainThreadMarker::new() else { + if MainThreadMarker::new().is_none() { return; - }; + } use block2::RcBlock; use objc2::runtime::Bool; use objc2_foundation::NSError; - use objc2_user_notifications::{UNAuthorizationOptions, UNUserNotificationCenter}; + use objc2_user_notifications::{ + UNAuthorizationOptions, UNAuthorizationStatus, UNNotificationSettings, + UNUserNotificationCenter, + }; + use std::ptr::NonNull; let center = UNUserNotificationCenter::currentNotificationCenter(); - let options = UNAuthorizationOptions::Badge; - let handler = RcBlock::new(|_granted: Bool, _error: *mut NSError| {}); - center.requestAuthorizationWithOptions_completionHandler(options, &handler); - let _ = mtm; + let handler = RcBlock::new(|settings: NonNull| { + let settings = unsafe { settings.as_ref() }; + if settings.authorizationStatus() == UNAuthorizationStatus::NotDetermined { + return; + } + let done = RcBlock::new(|_granted: Bool, _error: *mut NSError| {}); + UNUserNotificationCenter::currentNotificationCenter() + .requestAuthorizationWithOptions_completionHandler( + UNAuthorizationOptions::Badge, + &done, + ); + }); + center.getNotificationSettingsWithCompletionHandler(&handler); } pub(crate) fn install_dock_menu(app: &AppHandle) { diff --git a/src-tauri/src/notifications.rs b/src-tauri/src/notifications.rs index 14f92bb..378993f 100644 --- a/src-tauri/src/notifications.rs +++ b/src-tauri/src/notifications.rs @@ -346,8 +346,11 @@ mod platform { notification .appname("MonoCode") .summary(&format!("{title}: {subtitle}")) - .body(body) - .icon("monocode"); + // The body is agent output; servers render it as markup. + .body(&escape_markup(body)) + .icon("monocode") + // Servers only report the click when a "default" action exists. + .action("default", "Show"); if sound { notification.sound_name("message-new-instant"); } @@ -365,6 +368,33 @@ mod platform { Ok(()) } + /// The freedesktop spec parses the body as a subset of HTML. + fn escape_markup(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for ch in text.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + _ => out.push(ch), + } + } + out + } + + #[cfg(test)] + mod tests { + use super::escape_markup; + + #[test] + fn escapes_markup_in_bodies() { + assert_eq!( + escape_markup("x & y"), + "<b>x</b> & y" + ); + } + } + pub(super) fn open_settings(_app: &AppHandle) -> Result<(), String> { Err("no notification settings page on this platform".into()) } diff --git a/src/App.tsx b/src/App.tsx index 41a074d..70fa9a9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -919,7 +919,11 @@ export default function App({ if (previous.has(id)) continue; const session = sessionsRef.current.find((s) => s.id === id); if (session) { - notifySession(session, "needsInput", id === activeSessionIdRef.current); + void notifySession( + session, + "needsInput", + id === activeSessionIdRef.current, + ); } } }, [approvalSessionIds]); @@ -3604,9 +3608,12 @@ export default function App({ window.setTimeout(() => { const finished = sessionsRef.current.find((s) => s.id === sessionId); const visible = sessionId === activeSessionIdRef.current; - if (!finished || !notifySession(finished, "finished", visible)) { - playCue("turnFinished"); - } + const sent = finished + ? notifySession(finished, "finished", visible) + : Promise.resolve(false); + void sent.then((ok) => { + if (!ok) playCue("turnFinished"); + }); }, 0); notifyReviewChanged(sessionId); notifyGitChanged(); diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index 42189df..b4e347d 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -160,14 +160,15 @@ function clip(text: string): string { } /** - * Sends the banner when policy allows. Returns true when one was dispatched - * so callers can skip the in-app cue: the OS sound stands in for it. + * Sends the banner when policy allows. Resolves true once the OS accepted it + * so callers can skip the in-app cue: the OS sound stands in for it. A + * rejected dispatch resolves false so the cue still plays. */ -export function notifySession( +export async function notifySession( session: Session, event: NotificationEvent, sessionVisible: boolean, -): boolean { +): Promise { const decision = shouldNotify({ enabled: loadNotificationsEnabled(), permission, @@ -176,12 +177,16 @@ export function notifySession( }); if (!decision) return false; const { title, subtitle, body } = notificationText(session, event); - void invoke("show_notification", { - sessionId: session.id, - title, - subtitle, - body, - sound: loadSoundsEnabled(), - }).catch(() => {}); - return true; + try { + await invoke("show_notification", { + sessionId: session.id, + title, + subtitle, + body, + sound: loadSoundsEnabled(), + }); + return true; + } catch { + return false; + } } From 444deb2b2ac33f2633f6f8b1e2ec461bfda970d2 Mon Sep 17 00:00:00 2001 From: Emircan Sahin Date: Fri, 4 Sep 2026 19:48:29 +0300 Subject: [PATCH 5/5] Move notification test modules after the items they cover Clippy's items_after_test_module lint fails CI on Linux with -D warnings. --- src-tauri/src/notifications.rs | 64 +++++++++++++++++----------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/src-tauri/src/notifications.rs b/src-tauri/src/notifications.rs index 378993f..b94ac84 100644 --- a/src-tauri/src/notifications.rs +++ b/src-tauri/src/notifications.rs @@ -260,34 +260,6 @@ mod platform { } ); - #[cfg(test)] - mod tests { - use super::*; - use objc2::runtime::AnyProtocol; - use objc2::{sel, ClassType}; - - #[test] - fn identifier_round_trips_the_session() { - let id = request_identifier("549ae7ac"); - assert_eq!(session_from_identifier(&id), Some("549ae7ac")); - assert_eq!(session_from_identifier("other"), None); - } - - #[test] - fn delegate_registers_protocol_methods() { - let cls = Delegate::class(); - let proto = - AnyProtocol::get(c"UNUserNotificationCenterDelegate").expect("protocol loaded"); - assert!(cls.conforms_to(proto)); - assert!(cls.responds_to(sel!( - userNotificationCenter:willPresentNotification:withCompletionHandler: - ))); - assert!(cls.responds_to(sel!( - userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler: - ))); - } - } - thread_local! { static DELEGATE: RefCell>> = const { RefCell::new(None) }; } @@ -318,6 +290,34 @@ mod platform { ); center.setNotificationCategories(&NSSet::from_retained_slice(&[category])); } + + #[cfg(test)] + mod tests { + use super::*; + use objc2::runtime::AnyProtocol; + use objc2::{sel, ClassType}; + + #[test] + fn identifier_round_trips_the_session() { + let id = request_identifier("549ae7ac"); + assert_eq!(session_from_identifier(&id), Some("549ae7ac")); + assert_eq!(session_from_identifier("other"), None); + } + + #[test] + fn delegate_registers_protocol_methods() { + let cls = Delegate::class(); + let proto = + AnyProtocol::get(c"UNUserNotificationCenterDelegate").expect("protocol loaded"); + assert!(cls.conforms_to(proto)); + assert!(cls.responds_to(sel!( + userNotificationCenter:willPresentNotification:withCompletionHandler: + ))); + assert!(cls.responds_to(sel!( + userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler: + ))); + } + } } #[cfg(target_os = "linux")] @@ -382,6 +382,10 @@ mod platform { out } + pub(super) fn open_settings(_app: &AppHandle) -> Result<(), String> { + Err("no notification settings page on this platform".into()) + } + #[cfg(test)] mod tests { use super::escape_markup; @@ -394,10 +398,6 @@ mod platform { ); } } - - pub(super) fn open_settings(_app: &AppHandle) -> Result<(), String> { - Err("no notification settings page on this platform".into()) - } } #[cfg(not(any(target_os = "macos", target_os = "linux")))]