From b8e98bc48cefbe94eeb506e726bcc22440fd9d00 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Tue, 26 Aug 2025 11:23:06 -0700 Subject: [PATCH 1/6] wip --- Cargo.lock | 282 ++---------------- Cargo.toml | 2 +- .../settings/views/notifications.tsx | 10 +- crates/notification-macos/Cargo.toml | 10 + crates/notification-macos/build.rs | 13 + crates/notification-macos/src/lib.rs | 25 ++ .../notification-macos/swift-lib/.gitignore | 7 + .../swift-lib/Package.resolved | 13 + .../swift-lib/Package.swift | 28 ++ .../swift-lib/src/lib.swift | 272 +++++++++++++++++ crates/notification/Cargo.toml | 6 +- crates/notification/src/lib.rs | 2 +- crates/notification/src/macos.rs | 216 -------------- crates/notification2/Cargo.toml | 15 - crates/notification2/src/lib.rs | 47 --- crates/notification2/src/macos.rs | 56 ---- plugins/notification/Cargo.toml | 2 +- plugins/notification/build.rs | 4 +- plugins/notification/js/bindings.gen.ts | 14 +- .../commands/show_notification.toml | 13 + .../permissions/autogenerated/reference.md | 27 ++ plugins/notification/permissions/default.toml | 1 + .../permissions/schemas/schema.json | 16 +- plugins/notification/src/commands.rs | 37 +-- plugins/notification/src/ext.rs | 62 +--- plugins/notification/src/lib.rs | 4 +- plugins/notification/src/worker.rs | 19 +- 27 files changed, 490 insertions(+), 713 deletions(-) create mode 100644 crates/notification-macos/Cargo.toml create mode 100644 crates/notification-macos/build.rs create mode 100644 crates/notification-macos/src/lib.rs create mode 100644 crates/notification-macos/swift-lib/.gitignore create mode 100644 crates/notification-macos/swift-lib/Package.resolved create mode 100644 crates/notification-macos/swift-lib/Package.swift create mode 100644 crates/notification-macos/swift-lib/src/lib.swift delete mode 100644 crates/notification/src/macos.rs delete mode 100644 crates/notification2/Cargo.toml delete mode 100644 crates/notification2/src/lib.rs delete mode 100644 crates/notification2/src/macos.rs create mode 100644 plugins/notification/permissions/autogenerated/commands/show_notification.toml diff --git a/Cargo.lock b/Cargo.lock index 537ec582d..bfc24ce02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -429,7 +429,7 @@ dependencies = [ "wayland-backend", "wayland-client", "wayland-protocols", - "zbus 5.9.0", + "zbus", ] [[package]] @@ -543,17 +543,6 @@ dependencies = [ "slab", ] -[[package]] -name = "async-fs" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f7e37c0ed80b2a977691c47dae8625cfb21e205827106c64f7c588766b2e50" -dependencies = [ - "async-lock", - "blocking", - "futures-lite 2.6.1", -] - [[package]] name = "async-io" version = "2.5.0" @@ -9209,19 +9198,6 @@ dependencies = [ "memoffset", ] -[[package]] -name = "nix" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" -dependencies = [ - "bitflags 2.9.1", - "cfg-if", - "cfg_aliases 0.2.1", - "libc", - "memoffset", -] - [[package]] name = "nix" version = "0.30.1" @@ -9255,25 +9231,15 @@ dependencies = [ name = "notification" version = "0.1.0" dependencies = [ - "block2 0.6.1", - "objc2 0.6.1", - "objc2-app-kit 0.3.1", - "objc2-foundation 0.3.1", - "objc2-user-notifications", + "notification-macos", "serde", ] [[package]] -name = "notification2" +name = "notification-macos" version = "0.1.0" dependencies = [ - "block2 0.6.1", - "objc2 0.6.1", - "objc2-foundation 0.3.1", - "objc2-user-notifications", - "serde", - "specta", - "wezterm-toast-notification", + "swift-rs 1.0.7 (git+https://github.com/Brendonovich/swift-rs?rev=01980f9)", ] [[package]] @@ -9711,16 +9677,6 @@ dependencies = [ "objc2-foundation 0.2.2", ] -[[package]] -name = "objc2-core-location" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac0f75792558aa9d618443bbb5db7426a7a0b6fddf96903f86ef9ad02e135740" -dependencies = [ - "objc2 0.6.1", - "objc2-foundation 0.3.1", -] - [[package]] name = "objc2-core-services" version = "0.3.1" @@ -9749,7 +9705,7 @@ dependencies = [ "block2 0.5.1", "objc2 0.5.2", "objc2-app-kit 0.2.2", - "objc2-core-location 0.2.2", + "objc2-core-location", "objc2-foundation 0.2.2", "objc2-map-kit", ] @@ -9830,7 +9786,7 @@ dependencies = [ "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-contacts", - "objc2-core-location 0.2.2", + "objc2-core-location", "objc2-foundation 0.2.2", ] @@ -9905,19 +9861,6 @@ dependencies = [ "objc2-foundation 0.3.1", ] -[[package]] -name = "objc2-user-notifications" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a3f5ec77a81d9e0c5a0b32159b0cb143d7086165e79708351e02bf37dfc65cd" -dependencies = [ - "bitflags 2.9.1", - "block2 0.6.1", - "objc2 0.6.1", - "objc2-core-location 0.3.1", - "objc2-foundation 0.3.1", -] - [[package]] name = "objc2-web-kit" version = "0.3.1" @@ -14852,7 +14795,7 @@ dependencies = [ "chrono", "db-user", "detect", - "notification2", + "notification", "serde", "specta", "specta-typescript", @@ -14920,7 +14863,7 @@ dependencies = [ "thiserror 2.0.12", "url", "windows 0.61.3", - "zbus 5.9.0", + "zbus", ] [[package]] @@ -15034,7 +14977,7 @@ dependencies = [ "thiserror 2.0.12", "tracing", "windows-sys 0.60.2", - "zbus 5.9.0", + "zbus", ] [[package]] @@ -17503,35 +17446,6 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" -[[package]] -name = "wezterm-open-url" -version = "0.1.0" -source = "git+https://github.com/yujonglee/wezterm?rev=b26ac3c#b26ac3c7a3a6e8cba5384d65a3e84266c83dc4c5" -dependencies = [ - "winapi", -] - -[[package]] -name = "wezterm-toast-notification" -version = "0.1.0" -source = "git+https://github.com/yujonglee/wezterm?rev=b26ac3c#b26ac3c7a3a6e8cba5384d65a3e84266c83dc4c5" -dependencies = [ - "async-io", - "block2 0.6.1", - "futures-util", - "log", - "objc2 0.6.1", - "objc2-foundation 0.3.1", - "objc2-user-notifications", - "serde", - "uuid", - "wezterm-open-url", - "windows 0.33.0", - "xml-rs", - "zbus 4.4.0", - "zvariant 4.2.0", -] - [[package]] name = "which" version = "4.4.2" @@ -17688,19 +17602,6 @@ dependencies = [ "windows-version", ] -[[package]] -name = "windows" -version = "0.33.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0128fa8e65e0616e45033d68dc0b7fbd521080b7844e5cad3a4a4d201c4b2bd2" -dependencies = [ - "windows_aarch64_msvc 0.33.0", - "windows_i686_gnu 0.33.0", - "windows_i686_msvc 0.33.0", - "windows_x86_64_gnu 0.33.0", - "windows_x86_64_msvc 0.33.0", -] - [[package]] name = "windows" version = "0.54.0" @@ -18081,12 +17982,6 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" -[[package]] -name = "windows_aarch64_msvc" -version = "0.33.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd761fd3eb9ab8cc1ed81e56e567f02dd82c4c837e48ac3b2181b9ffc5060807" - [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -18111,12 +18006,6 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" -[[package]] -name = "windows_i686_gnu" -version = "0.33.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab0cf703a96bab2dc0c02c0fa748491294bf9b7feb27e1f4f96340f208ada0e" - [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -18153,12 +18042,6 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" -[[package]] -name = "windows_i686_msvc" -version = "0.33.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cfdbe89cc9ad7ce618ba34abc34bbb6c36d99e96cae2245b7943cd75ee773d0" - [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -18183,12 +18066,6 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" -[[package]] -name = "windows_x86_64_gnu" -version = "0.33.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4dd9b0c0e9ece7bb22e84d70d01b71c6d6248b81a3c60d11869451b4cb24784" - [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -18237,12 +18114,6 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" -[[package]] -name = "windows_x86_64_msvc" -version = "0.33.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff1e4aa646495048ec7f3ffddc411e1d829c026a2ec62b39da15c1055e406eaa" - [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -18539,28 +18410,12 @@ dependencies = [ "rustix 1.0.8", ] -[[package]] -name = "xdg-home" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - [[package]] name = "xkeysym" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" -[[package]] -name = "xml-rs" -version = "0.8.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fd8403733700263c6eb89f192880191f1b83e332f7a20371ddcf421c4a337c7" - [[package]] name = "xmlparser" version = "0.13.6" @@ -18668,44 +18523,6 @@ dependencies = [ "url", ] -[[package]] -name = "zbus" -version = "4.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" -dependencies = [ - "async-broadcast", - "async-executor", - "async-fs", - "async-io", - "async-lock", - "async-process", - "async-recursion", - "async-task", - "async-trait", - "blocking", - "enumflags2", - "event-listener 5.4.1", - "futures-core", - "futures-sink", - "futures-util", - "hex", - "nix 0.29.0", - "ordered-stream", - "rand 0.8.5", - "serde", - "serde_repr", - "sha1", - "static_assertions", - "tracing", - "uds_windows", - "windows-sys 0.52.0", - "xdg-home", - "zbus_macros 4.4.0", - "zbus_names 3.0.0", - "zvariant 4.2.0", -] - [[package]] name = "zbus" version = "5.9.0" @@ -18735,22 +18552,9 @@ dependencies = [ "uds_windows", "windows-sys 0.59.0", "winnow 0.7.12", - "zbus_macros 5.9.0", - "zbus_names 4.2.0", - "zvariant 5.6.0", -] - -[[package]] -name = "zbus_macros" -version = "4.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" -dependencies = [ - "proc-macro-crate 3.3.0", - "proc-macro2", - "quote", - "syn 2.0.104", - "zvariant_utils 2.1.0", + "zbus_macros", + "zbus_names", + "zvariant", ] [[package]] @@ -18763,20 +18567,9 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.104", - "zbus_names 4.2.0", - "zvariant 5.6.0", - "zvariant_utils 3.2.0", -] - -[[package]] -name = "zbus_names" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" -dependencies = [ - "serde", - "static_assertions", - "zvariant 4.2.0", + "zbus_names", + "zvariant", + "zvariant_utils", ] [[package]] @@ -18788,7 +18581,7 @@ dependencies = [ "serde", "static_assertions", "winnow 0.7.12", - "zvariant 5.6.0", + "zvariant", ] [[package]] @@ -18919,19 +18712,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "zvariant" -version = "4.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" -dependencies = [ - "endi", - "enumflags2", - "serde", - "static_assertions", - "zvariant_derive 4.2.0", -] - [[package]] name = "zvariant" version = "5.6.0" @@ -18943,21 +18723,8 @@ dependencies = [ "serde", "url", "winnow 0.7.12", - "zvariant_derive 5.6.0", - "zvariant_utils 3.2.0", -] - -[[package]] -name = "zvariant_derive" -version = "4.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" -dependencies = [ - "proc-macro-crate 3.3.0", - "proc-macro2", - "quote", - "syn 2.0.104", - "zvariant_utils 2.1.0", + "zvariant_derive", + "zvariant_utils", ] [[package]] @@ -18970,18 +18737,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.104", - "zvariant_utils 3.2.0", -] - -[[package]] -name = "zvariant_utils" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", + "zvariant_utils", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 84e74dc61..f043a7e54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,7 +52,7 @@ hypr-moonshine = { path = "crates/moonshine", package = "moonshine" } hypr-nango = { path = "crates/nango", package = "nango" } hypr-network = { path = "crates/network", package = "network" } hypr-notification = { path = "crates/notification", package = "notification" } -hypr-notification2 = { path = "crates/notification2", package = "notification2" } +hypr-notification-macos = { path = "crates/notification-macos", package = "notification-macos" } hypr-notion = { path = "crates/notion", package = "notion" } hypr-onnx = { path = "crates/onnx", package = "onnx" } hypr-openai = { path = "crates/openai", package = "openai" } diff --git a/apps/desktop/src/components/settings/views/notifications.tsx b/apps/desktop/src/components/settings/views/notifications.tsx index 7e65ba6e9..bea85873b 100644 --- a/apps/desktop/src/components/settings/views/notifications.tsx +++ b/apps/desktop/src/components/settings/views/notifications.tsx @@ -38,9 +38,8 @@ export default function NotificationsComponent() { const eventMutation = useMutation({ mutationFn: async (v: Schema) => { if (v.event) { - notificationCommands.requestNotificationPermission().then(() => { - notificationCommands.setEventNotification(true); - }); + notificationCommands.setEventNotification(true); + notificationCommands.showNotification(); } else { notificationCommands.setEventNotification(false); } @@ -59,9 +58,8 @@ export default function NotificationsComponent() { const detectMutation = useMutation({ mutationFn: async (v: Schema) => { if (v.detect) { - notificationCommands.requestNotificationPermission().then(() => { - notificationCommands.setDetectNotification(true); - }); + notificationCommands.setDetectNotification(true); + notificationCommands.showNotification(); } else { notificationCommands.setDetectNotification(false); } diff --git a/crates/notification-macos/Cargo.toml b/crates/notification-macos/Cargo.toml new file mode 100644 index 000000000..29c393eda --- /dev/null +++ b/crates/notification-macos/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "notification-macos" +version = "0.1.0" +edition = "2021" + +[target.'cfg(target_os = "macos")'.build-dependencies] +swift-rs = { workspace = true, features = ["build"] } + +[target.'cfg(target_os = "macos")'.dependencies] +swift-rs = { workspace = true } diff --git a/crates/notification-macos/build.rs b/crates/notification-macos/build.rs new file mode 100644 index 000000000..1d4cdfed3 --- /dev/null +++ b/crates/notification-macos/build.rs @@ -0,0 +1,13 @@ +fn main() { + #[cfg(target_os = "macos")] + { + swift_rs::SwiftLinker::new("14.2") + .with_package("swift-lib", "./swift-lib/") + .link(); + } + + #[cfg(not(target_os = "macos"))] + { + println!("cargo:warning=Swift linking is only available on macOS"); + } +} diff --git a/crates/notification-macos/src/lib.rs b/crates/notification-macos/src/lib.rs new file mode 100644 index 000000000..90df8e172 --- /dev/null +++ b/crates/notification-macos/src/lib.rs @@ -0,0 +1,25 @@ +#[cfg(target_os = "macos")] +use swift_rs::{swift, Bool}; + +#[cfg(target_os = "macos")] +swift!(fn _show() -> Bool); + +#[cfg(target_os = "macos")] +pub fn show() { + unsafe { + _show(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_nspanel() { + println!("show1"); + show(); + println!("show2"); + std::thread::sleep(std::time::Duration::from_secs(10)); + } +} diff --git a/crates/notification-macos/swift-lib/.gitignore b/crates/notification-macos/swift-lib/.gitignore new file mode 100644 index 000000000..bb460e7be --- /dev/null +++ b/crates/notification-macos/swift-lib/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata diff --git a/crates/notification-macos/swift-lib/Package.resolved b/crates/notification-macos/swift-lib/Package.resolved new file mode 100644 index 000000000..13f1d81df --- /dev/null +++ b/crates/notification-macos/swift-lib/Package.resolved @@ -0,0 +1,13 @@ +{ + "pins" : [ + { + "identity" : "swift-rs", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Brendonovich/swift-rs", + "state" : { + "revision" : "01980f981bc642a6da382cc0788f18fdd4cde6df" + } + } + ], + "version" : 2 +} diff --git a/crates/notification-macos/swift-lib/Package.swift b/crates/notification-macos/swift-lib/Package.swift new file mode 100644 index 000000000..e51b486d2 --- /dev/null +++ b/crates/notification-macos/swift-lib/Package.swift @@ -0,0 +1,28 @@ +// swift-tools-version:5.9 + +import PackageDescription + +let package = Package( + name: "swift-lib", + platforms: [.macOS("14.2")], + products: [ + .library( + name: "swift-lib", + type: .static, + targets: ["swift-lib"]) + ], + dependencies: [ + .package( + url: "https://github.com/Brendonovich/swift-rs", + revision: "01980f981bc642a6da382cc0788f18fdd4cde6df") + ], + targets: [ + .target( + name: "swift-lib", + dependencies: [ + .product(name: "SwiftRs", package: "swift-rs") + ], + path: "src" + ) + ] +) diff --git a/crates/notification-macos/swift-lib/src/lib.swift b/crates/notification-macos/swift-lib/src/lib.swift new file mode 100644 index 000000000..ef1f30f9e --- /dev/null +++ b/crates/notification-macos/swift-lib/src/lib.swift @@ -0,0 +1,272 @@ +import SwiftRs +import Cocoa +import AVFoundation + +// NOTIFICATION FEATURES: +// - Audio: Plays system notification sound ("Ping") when shown +// - Clickable: Click anywhere on the notification to dismiss immediately +// - Auto-dismiss timeout: Automatically closes after 5 seconds if not clicked +// - Animation: Slides in from right with fade-in, slides out to right with fade-out +// - Visual: Native macOS appearance with blur effect and rounded corners + +// Store panel reference to prevent deallocation +private var sharedPanel: NSPanel? + +// Custom view that handles clicks to dismiss notification +class ClickableView: NSView { + var trackingArea: NSTrackingArea? + var isHovering = false + + override func updateTrackingAreas() { + super.updateTrackingAreas() + + if let existingArea = trackingArea { + removeTrackingArea(existingArea) + } + + let options: NSTrackingArea.Options = [ + .activeAlways, + .mouseEnteredAndExited, + .inVisibleRect + ] + + trackingArea = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil) + if let area = trackingArea { + addTrackingArea(area) + } + } + + override func mouseEntered(with event: NSEvent) { + isHovering = true + NSCursor.pointingHand.set() + } + + override func mouseExited(with event: NSEvent) { + isHovering = false + NSCursor.arrow.set() + } + + override func mouseDown(with event: NSEvent) { + if let panel = sharedPanel { + // Slide out to the right with fade + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.25 + context.timingFunction = CAMediaTimingFunction(name: .easeIn) + + let currentFrame = panel.frame + let targetFrame = NSRect( + x: currentFrame.origin.x + currentFrame.width, + y: currentFrame.origin.y, + width: currentFrame.width, + height: currentFrame.height + ) + + panel.animator().setFrame(targetFrame, display: true) + panel.animator().alphaValue = 0 + }) { + panel.close() + sharedPanel = nil + } + } + } +} + +// Play notification sound +private func playNotificationSound() { + // Use system notification sound + NSSound(named: .init("Ping"))?.play() + // Alternative sounds available: + // "Basso", "Blow", "Bottle", "Frog", "Funk", "Glass", "Hero", + // "Morse", "Ping", "Pop", "Purr", "Sosumi", "Submarine", "Tink" +} + +@_cdecl("_show") +public func _show() -> Bool { + // Initialize NSApplication if not already initialized + let app = NSApplication.shared + + // Use async to avoid potential deadlocks + DispatchQueue.main.async { + // Initialize the app if needed + if app.delegate == nil { + app.setActivationPolicy(.regular) + } + + // Get screen dimensions + guard let screen = NSScreen.main else { return } + let screenRect = screen.visibleFrame + + // Notification dimensions (like native macOS notifications) + let notificationWidth: CGFloat = 360 + let notificationHeight: CGFloat = 80 + let rightMargin: CGFloat = 12 + let topMargin: CGFloat = 12 + + // Calculate final position (top-right corner) + let finalXPos = screenRect.maxX - notificationWidth - rightMargin + let finalYPos = screenRect.maxY - notificationHeight - topMargin + + // Start position (off-screen to the right) + let startXPos = screenRect.maxX + 10 + + // Create NSPanel with borderless style (no title bar) + let panel = NSPanel( + contentRect: NSRect(x: startXPos, y: finalYPos, width: notificationWidth, height: notificationHeight), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + // Configure panel appearance + panel.level = .floating // Use floating level for notifications + panel.isFloatingPanel = true + panel.hidesOnDeactivate = false + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = true + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient, .ignoresCycle] + panel.isMovableByWindowBackground = false + panel.alphaValue = 0 // Start invisible for fade-in + + // Create custom content view with rounded corners and background + let contentView = NSView(frame: NSRect(x: 0, y: 0, width: notificationWidth, height: notificationHeight)) + contentView.wantsLayer = true + contentView.layer?.cornerRadius = 10 + contentView.layer?.masksToBounds = true + + // Shadow configuration for depth + contentView.layer?.shadowColor = NSColor.black.cgColor + contentView.layer?.shadowOpacity = 0.2 + contentView.layer?.shadowOffset = CGSize(width: 0, height: 2) + contentView.layer?.shadowRadius = 8 + + // Use visual effect view for native blur background + let visualEffectView = NSVisualEffectView(frame: contentView.bounds) + visualEffectView.material = .popover // More native-like material + visualEffectView.state = .active + visualEffectView.blendingMode = .behindWindow + visualEffectView.wantsLayer = true + visualEffectView.layer?.cornerRadius = 10 + contentView.addSubview(visualEffectView) + + // Create horizontal stack view for icon and text + let horizontalStack = NSStackView() + horizontalStack.orientation = .horizontal + horizontalStack.alignment = .centerY + horizontalStack.spacing = 12 + horizontalStack.edgeInsets = NSEdgeInsets(top: 15, left: 15, bottom: 15, right: 15) + horizontalStack.translatesAutoresizingMaskIntoConstraints = false + + // Create app icon (bell emoji as placeholder) + let iconView = NSTextField(labelWithString: "🔔") + iconView.font = NSFont.systemFont(ofSize: 28) + iconView.alignment = .center + iconView.backgroundColor = .clear + iconView.isBezeled = false + iconView.isEditable = false + iconView.widthAnchor.constraint(equalToConstant: 40).isActive = true + horizontalStack.addArrangedSubview(iconView) + + // Create vertical stack for title and message + let textStack = NSStackView() + textStack.orientation = .vertical + textStack.alignment = .leading + textStack.spacing = 2 + + // Create title label + let titleLabel = NSTextField(labelWithString: "Hyprnote") + titleLabel.font = NSFont.systemFont(ofSize: 13, weight: .semibold) + titleLabel.textColor = .labelColor + titleLabel.backgroundColor = .clear + titleLabel.isBezeled = false + titleLabel.isEditable = false + textStack.addArrangedSubview(titleLabel) + + // Create message label + let messageLabel = NSTextField(labelWithString: "Your notification is ready") + messageLabel.font = NSFont.systemFont(ofSize: 12) + messageLabel.textColor = .secondaryLabelColor + messageLabel.backgroundColor = .clear + messageLabel.isBezeled = false + messageLabel.isEditable = false + messageLabel.lineBreakMode = .byTruncatingTail + messageLabel.maximumNumberOfLines = 2 + textStack.addArrangedSubview(messageLabel) + + horizontalStack.addArrangedSubview(textStack) + + // Add stack to visual effect view + visualEffectView.addSubview(horizontalStack) + + // Set up constraints + NSLayoutConstraint.activate([ + horizontalStack.leadingAnchor.constraint(equalTo: visualEffectView.leadingAnchor), + horizontalStack.trailingAnchor.constraint(equalTo: visualEffectView.trailingAnchor), + horizontalStack.topAnchor.constraint(equalTo: visualEffectView.topAnchor), + horizontalStack.bottomAnchor.constraint(equalTo: visualEffectView.bottomAnchor) + ]) + + // Create clickable content view + let clickableContentView = ClickableView(frame: NSRect(x: 0, y: 0, width: notificationWidth, height: notificationHeight)) + clickableContentView.wantsLayer = true + clickableContentView.layer?.cornerRadius = 10 + clickableContentView.layer?.masksToBounds = true + + // Move visual effect view to clickable content view + contentView.removeFromSuperview() + clickableContentView.addSubview(visualEffectView) + + // Set the clickable content view as panel's content + panel.contentView = clickableContentView + + // Store panel reference to prevent deallocation + sharedPanel = panel + + // Show the panel (starts off-screen to the right) + panel.makeKeyAndOrderFront(nil) + + // Play notification sound + playNotificationSound() + + // Animate slide-in from right with fade-in + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.35 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + panel.animator().setFrame( + NSRect(x: finalXPos, y: finalYPos, width: notificationWidth, height: notificationHeight), + display: true + ) + panel.animator().alphaValue = 1.0 + }) { + // Auto-dismiss after 5 seconds if not clicked + DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { + if let currentPanel = sharedPanel { + // Animate slide-out to right with fade + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.3 + context.timingFunction = CAMediaTimingFunction(name: .easeIn) + + let exitFrame = NSRect( + x: screenRect.maxX + 10, + y: finalYPos, + width: notificationWidth, + height: notificationHeight + ) + + currentPanel.animator().setFrame(exitFrame, display: true) + currentPanel.animator().alphaValue = 0 + }) { + currentPanel.close() + sharedPanel = nil + } + } + } + } + + } + + // Give some time for the async block to execute + Thread.sleep(forTimeInterval: 0.1) + + return true +} diff --git a/crates/notification/Cargo.toml b/crates/notification/Cargo.toml index 221e180ba..c8c3aa630 100644 --- a/crates/notification/Cargo.toml +++ b/crates/notification/Cargo.toml @@ -7,8 +7,4 @@ edition = "2021" serde = { workspace = true, features = ["derive"] } [target.'cfg(target_os = "macos")'.dependencies] -block2 = { workspace = true } -objc2 = { workspace = true } -objc2-foundation = { workspace = true } -objc2-user-notifications = { workspace = true } -objc2-app-kit = { version = "0.3.0", features = ["NSWorkspace"] } +hypr-notification-macos = { workspace = true } diff --git a/crates/notification/src/lib.rs b/crates/notification/src/lib.rs index ed50e7d47..e7773373d 100644 --- a/crates/notification/src/lib.rs +++ b/crates/notification/src/lib.rs @@ -1,2 +1,2 @@ #[cfg(target_os = "macos")] -pub mod macos; +pub use hypr_notification_macos::*; diff --git a/crates/notification/src/macos.rs b/crates/notification/src/macos.rs deleted file mode 100644 index 7707db47f..000000000 --- a/crates/notification/src/macos.rs +++ /dev/null @@ -1,216 +0,0 @@ -// https://developer.apple.com/documentation/appkit/nsworkspace?language=objc -// https://developer.apple.com/documentation/foundation/nsdistributednotificationcenter/1414151-addobserver -// https://docs.rs/objc2-foundation/0.2.2/objc2_foundation/struct.NSNotificationCenter.html#method.addObserver_selector_name_object - -// https://github.com/search?q=addObserver_forKeyPath_options_context+language:Rust+&type=code -// https://github.com/search?q=addObserver_selector_name_object+language:Rust+&type=code - -use std::sync::{Arc, Mutex}; - -use block2::{Block, RcBlock}; -use objc2::runtime::{Bool, ProtocolObject}; -use objc2::{define_class, DefinedClass}; -use objc2::{msg_send, MainThreadOnly}; -use objc2_app_kit::NSWorkspace; -use objc2_foundation::{ - MainThreadMarker, NSArray, NSError, NSKeyValueObservingOptions, NSNotificationCenter, NSObject, - NSObjectNSKeyValueObserverRegistration, NSObjectProtocol, NSString, -}; -use objc2_user_notifications::{ - UNAuthorizationOptions, UNMutableNotificationContent, UNNotification, UNNotificationAction, - UNNotificationActionOptions, UNNotificationPresentationOptions, UNNotificationRequest, - UNNotificationResponse, UNTimeIntervalNotificationTrigger, UNUserNotificationCenter, - UNUserNotificationCenterDelegate, -}; - -#[derive(Default)] -struct NotificationDelegateIvars { - action_handler: Option>>, -} - -define_class!( - #[unsafe(super = NSObject)] - #[thread_kind = MainThreadOnly] - #[name = "NotificationDelegate"] - #[ivars = NotificationDelegateIvars] - struct NotificationDelegate; - - unsafe impl NSObjectProtocol for NotificationDelegate {} - - unsafe impl UNUserNotificationCenterDelegate for NotificationDelegate { - #[unsafe(method(userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:))] - fn user_notification_center_did_receive_notification_response_with_completion_handler( - &self, - _center: &UNUserNotificationCenter, - response: &UNNotificationResponse, - completion_handler: &Block, - ) { - let action_identifier = unsafe { response.actionIdentifier() }; - if let Some(handler) = &self.ivars().action_handler { - if let Ok(handler) = handler.lock() { - handler(&action_identifier.to_string()); - } - } - - completion_handler.call(()) - } - - #[unsafe(method(userNotificationCenter:willPresentNotification:withCompletionHandler:))] - fn user_notification_center_will_present_notification_with_completion_handler( - &self, - _center: &UNUserNotificationCenter, - _notification: &UNNotification, - completion_handler: &Block, - ) { - let options = UNNotificationPresentationOptions::Banner - | UNNotificationPresentationOptions::Sound; - - completion_handler.call((options,)) - } - } -); - -impl NotificationDelegate { - fn new(mtm: MainThreadMarker) -> objc2::rc::Retained { - let handler = Arc::new(Mutex::new(|action_id: &str| { - println!("Received notification action: {}", action_id); - })); - - let this = Self::alloc(mtm).set_ivars(NotificationDelegateIvars { - action_handler: Some(handler), - }); - unsafe { msg_send![super(this), init] } - } -} - -pub fn send_notification(title: &str, body: &str, actions: &[(&str, &str)]) { - let _ = MainThreadMarker::new().unwrap(); - let un_center = unsafe { UNUserNotificationCenter::currentNotificationCenter() }; - - let authorization_options = UNAuthorizationOptions::Alert - | UNAuthorizationOptions::Sound - | UNAuthorizationOptions::Badge; - - let (tx, rx) = std::sync::mpsc::channel::(); - let completion_handler = RcBlock::new(move |granted: Bool, _error: *mut NSError| { - let _ = tx.send(granted.as_bool()); - }); - - unsafe { - un_center.requestAuthorizationWithOptions_completionHandler( - authorization_options, - &completion_handler, - ); - } - - if let Ok(granted) = rx.recv() { - if !granted { - return; - } - } - - let content = unsafe { UNMutableNotificationContent::new() }; - let sound = unsafe { objc2_user_notifications::UNNotificationSound::defaultSound() }; - unsafe { - content.setTitle(&NSString::from_str(title)); - content.setBody(&NSString::from_str(body)); - content.setSound(Some(&sound)); - } - - if !actions.is_empty() { - let mut notification_actions = Vec::new(); - - for (identifier, title) in actions { - let action = unsafe { - UNNotificationAction::actionWithIdentifier_title_options( - &NSString::from_str(identifier), - &NSString::from_str(title), - UNNotificationActionOptions::Foreground, - ) - }; - notification_actions.push(action); - } - - let _ = unsafe { - let mut array = NSArray::from_retained_slice(¬ification_actions); - for action in ¬ification_actions { - array = array.arrayByAddingObject(action); - } - array - }; - } - - let trigger = - unsafe { UNTimeIntervalNotificationTrigger::triggerWithTimeInterval_repeats(1.0, false) }; - - let request_identifier = format!( - "notification-{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - ); - - let request = unsafe { - UNNotificationRequest::requestWithIdentifier_content_trigger( - &NSString::from_str(&request_identifier), - &content, - Some(&trigger), - ) - }; - - let (tx, rx) = std::sync::mpsc::channel::(); - let completion_handler = RcBlock::new(move |error: *mut NSError| { - let success = error.is_null(); - let _ = tx.send(success); - }); - - unsafe { - un_center.addNotificationRequest_withCompletionHandler(&request, Some(&completion_handler)); - } - - let _ = rx.recv(); -} - -fn setup_notification_center() -> objc2::rc::Retained { - let mtm = MainThreadMarker::new().unwrap(); - - let un = unsafe { UNUserNotificationCenter::currentNotificationCenter() }; - let delegate = NotificationDelegate::new(mtm); - unsafe { un.setDelegate(Some(ProtocolObject::from_ref(&*delegate))) }; - - delegate -} - -pub fn run2() { - let _delegate = setup_notification_center(); - - // Example notification with actions - send_notification( - "Notification Title", - "This is a test notification with actions", - &[("accept", "Accept"), ("decline", "Decline")], - ); -} - -pub fn run() { - // https://developer.apple.com/documentation/appkit/nsworkspace/didlaunchapplicationnotification - let workspace = unsafe { NSWorkspace::sharedWorkspace() }; - - unsafe { - let key = NSString::from_str("didLaunchApplication"); - workspace.addObserver_forKeyPath_options_context( - // TODO - &NSObject::new(), - &key, - NSKeyValueObservingOptions::empty(), - std::ptr::null_mut(), - ); - } - - let _nc = unsafe { NSNotificationCenter::defaultCenter() }; - // nc.addObserver_selector_name_object(observer, a_selector, a_name, an_object); - - let _un = unsafe { objc2_user_notifications::UNUserNotificationCenter::new() }; - // un.requestAuthorizationWithOptions_completionHandler(options, completion_handler); -} diff --git a/crates/notification2/Cargo.toml b/crates/notification2/Cargo.toml deleted file mode 100644 index d810430e0..000000000 --- a/crates/notification2/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "notification2" -version = "0.1.0" -edition = "2021" - -[dependencies] -serde = { workspace = true, features = ["derive"] } -specta = { workspace = true, features = ["derive"] } -wezterm = { git = "https://github.com/yujonglee/wezterm", rev = "b26ac3c", package = "wezterm-toast-notification" } - -[target."cfg(target_os = \"macos\")".dependencies] -objc2 = { workspace = true } -objc2-foundation = { workspace = true } -objc2-user-notifications = { workspace = true } -block2 = { workspace = true } diff --git a/crates/notification2/src/lib.rs b/crates/notification2/src/lib.rs deleted file mode 100644 index dd445ed90..000000000 --- a/crates/notification2/src/lib.rs +++ /dev/null @@ -1,47 +0,0 @@ -pub use wezterm::ToastNotification as Notification; - -#[cfg(target_os = "macos")] -mod macos; - -pub fn show(notif: Notification) { - if cfg!(debug_assertions) { - return; - } - - // ensure the notification system is initialized before showing - #[cfg(target_os = "macos")] - wezterm::macos_initialize(); - - wezterm::show(notif); -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, specta::Type)] -pub enum NotificationPermission { - Granted, - NotGrantedAndShouldRequest, - NotGrantedAndShouldAskManual, -} - -pub fn request_notification_permission() { - #[cfg(target_os = "macos")] - macos::request_notification_permission(); -} - -pub fn open_notification_settings() -> std::io::Result<()> { - #[cfg(target_os = "macos")] - { - return macos::open_notification_settings(); - } - - #[cfg(not(target_os = "macos"))] - { - return Ok(()); - } -} - -pub fn check_notification_permission( - completion: impl Fn(Result) + 'static, -) { - #[cfg(target_os = "macos")] - macos::check_notification_permission(completion); -} diff --git a/crates/notification2/src/macos.rs b/crates/notification2/src/macos.rs deleted file mode 100644 index 094145f12..000000000 --- a/crates/notification2/src/macos.rs +++ /dev/null @@ -1,56 +0,0 @@ -use std::ptr::NonNull; -use std::sync::LazyLock; - -use block2::RcBlock; -use objc2::rc::Retained; -use objc2_user_notifications::{ - UNAuthorizationStatus, UNNotificationSettings, UNUserNotificationCenter, -}; - -use crate::NotificationPermission; - -const CENTER: LazyLock> = - LazyLock::new(|| unsafe { UNUserNotificationCenter::currentNotificationCenter() }); - -pub fn request_notification_permission() { - if cfg!(debug_assertions) { - return; - } - - wezterm::macos_initialize(); -} - -pub fn open_notification_settings() -> std::io::Result<()> { - std::process::Command::new("open") - .arg("x-apple.systempreferences:com.apple.Notifications-Settings.extension") - .spawn()? - .wait()?; - Ok(()) -} - -pub fn check_notification_permission( - completion: impl Fn(Result) + 'static, -) { - if cfg!(debug_assertions) { - completion(Ok(NotificationPermission::Granted)); - return; - } - - let completion_block = RcBlock::new(move |settings: NonNull| { - let settings = unsafe { settings.as_ref() }; - let auth_status = unsafe { settings.authorizationStatus() }; - - let result = match auth_status { - UNAuthorizationStatus::Authorized => NotificationPermission::Granted, - UNAuthorizationStatus::NotDetermined => { - NotificationPermission::NotGrantedAndShouldRequest - } - _ => NotificationPermission::NotGrantedAndShouldAskManual, - }; - completion(Ok(result)) - }); - - unsafe { - CENTER.getNotificationSettingsWithCompletionHandler(&completion_block); - } -} diff --git a/plugins/notification/Cargo.toml b/plugins/notification/Cargo.toml index bbe41a200..feb5d42f1 100644 --- a/plugins/notification/Cargo.toml +++ b/plugins/notification/Cargo.toml @@ -17,7 +17,7 @@ tauri-plugin-store = { workspace = true } [dependencies] hypr-db-user = { workspace = true } hypr-detect = { workspace = true } -hypr-notification2 = { workspace = true } +hypr-notification = { workspace = true } tauri-plugin-db = { workspace = true } tauri-plugin-store2 = { workspace = true } diff --git a/plugins/notification/build.rs b/plugins/notification/build.rs index e8a6e35f5..27708c29a 100644 --- a/plugins/notification/build.rs +++ b/plugins/notification/build.rs @@ -1,11 +1,9 @@ const COMMANDS: &[&str] = &[ + "show_notification", "get_event_notification", "set_event_notification", "get_detect_notification", "set_detect_notification", - "open_notification_settings", - "request_notification_permission", - "check_notification_permission", "start_detect_notification", "stop_detect_notification", "start_event_notification", diff --git a/plugins/notification/js/bindings.gen.ts b/plugins/notification/js/bindings.gen.ts index 98b9d2659..7e9ffeb56 100644 --- a/plugins/notification/js/bindings.gen.ts +++ b/plugins/notification/js/bindings.gen.ts @@ -7,6 +7,9 @@ export const commands = { +async showNotification() : Promise { + return await TAURI_INVOKE("plugin:notification|show_notification"); +}, async getEventNotification() : Promise { return await TAURI_INVOKE("plugin:notification|get_event_notification"); }, @@ -19,15 +22,6 @@ async getDetectNotification() : Promise { async setDetectNotification(enabled: boolean) : Promise { return await TAURI_INVOKE("plugin:notification|set_detect_notification", { enabled }); }, -async openNotificationSettings() : Promise { - return await TAURI_INVOKE("plugin:notification|open_notification_settings"); -}, -async requestNotificationPermission() : Promise { - return await TAURI_INVOKE("plugin:notification|request_notification_permission"); -}, -async checkNotificationPermission() : Promise { - return await TAURI_INVOKE("plugin:notification|check_notification_permission"); -}, async startDetectNotification() : Promise { return await TAURI_INVOKE("plugin:notification|start_detect_notification"); }, @@ -52,7 +46,7 @@ async stopEventNotification() : Promise { /** user-defined types **/ -export type NotificationPermission = "Granted" | "NotGrantedAndShouldRequest" | "NotGrantedAndShouldAskManual" + /** tauri-specta globals **/ diff --git a/plugins/notification/permissions/autogenerated/commands/show_notification.toml b/plugins/notification/permissions/autogenerated/commands/show_notification.toml new file mode 100644 index 000000000..0aab63164 --- /dev/null +++ b/plugins/notification/permissions/autogenerated/commands/show_notification.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-show-notification" +description = "Enables the show_notification command without any pre-configured scope." +commands.allow = ["show_notification"] + +[[permission]] +identifier = "deny-show-notification" +description = "Denies the show_notification command without any pre-configured scope." +commands.deny = ["show_notification"] diff --git a/plugins/notification/permissions/autogenerated/reference.md b/plugins/notification/permissions/autogenerated/reference.md index 8721757ec..0a7603b72 100644 --- a/plugins/notification/permissions/autogenerated/reference.md +++ b/plugins/notification/permissions/autogenerated/reference.md @@ -4,6 +4,7 @@ Default permissions for the plugin #### This default permission set includes the following: +- `allow-show-notification` - `allow-get-event-notification` - `allow-set-event-notification` - `allow-get-detect-notification` @@ -210,6 +211,32 @@ Denies the set_event_notification command without any pre-configured scope. +`notification:allow-show-notification` + + + + +Enables the show_notification command without any pre-configured scope. + + + + + + + +`notification:deny-show-notification` + + + + +Denies the show_notification command without any pre-configured scope. + + + + + + + `notification:allow-start-detect-notification` diff --git a/plugins/notification/permissions/default.toml b/plugins/notification/permissions/default.toml index dc006d03e..ff6523eec 100644 --- a/plugins/notification/permissions/default.toml +++ b/plugins/notification/permissions/default.toml @@ -1,6 +1,7 @@ [default] description = "Default permissions for the plugin" permissions = [ + "allow-show-notification", "allow-get-event-notification", "allow-set-event-notification", "allow-get-detect-notification", diff --git a/plugins/notification/permissions/schemas/schema.json b/plugins/notification/permissions/schemas/schema.json index 4bb3cb498..abeb85ba5 100644 --- a/plugins/notification/permissions/schemas/schema.json +++ b/plugins/notification/permissions/schemas/schema.json @@ -378,6 +378,18 @@ "const": "deny-set-event-notification", "markdownDescription": "Denies the set_event_notification command without any pre-configured scope." }, + { + "description": "Enables the show_notification command without any pre-configured scope.", + "type": "string", + "const": "allow-show-notification", + "markdownDescription": "Enables the show_notification command without any pre-configured scope." + }, + { + "description": "Denies the show_notification command without any pre-configured scope.", + "type": "string", + "const": "deny-show-notification", + "markdownDescription": "Denies the show_notification command without any pre-configured scope." + }, { "description": "Enables the start_detect_notification command without any pre-configured scope.", "type": "string", @@ -427,10 +439,10 @@ "markdownDescription": "Denies the stop_event_notification command without any pre-configured scope." }, { - "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-get-event-notification`\n- `allow-set-event-notification`\n- `allow-get-detect-notification`\n- `allow-set-detect-notification`\n- `allow-open-notification-settings`\n- `allow-request-notification-permission`\n- `allow-check-notification-permission`\n- `allow-start-detect-notification`\n- `allow-stop-detect-notification`\n- `allow-start-event-notification`\n- `allow-stop-event-notification`", + "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-show-notification`\n- `allow-get-event-notification`\n- `allow-set-event-notification`\n- `allow-get-detect-notification`\n- `allow-set-detect-notification`\n- `allow-open-notification-settings`\n- `allow-request-notification-permission`\n- `allow-check-notification-permission`\n- `allow-start-detect-notification`\n- `allow-stop-detect-notification`\n- `allow-start-event-notification`\n- `allow-stop-event-notification`", "type": "string", "const": "default", - "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-get-event-notification`\n- `allow-set-event-notification`\n- `allow-get-detect-notification`\n- `allow-set-detect-notification`\n- `allow-open-notification-settings`\n- `allow-request-notification-permission`\n- `allow-check-notification-permission`\n- `allow-start-detect-notification`\n- `allow-stop-detect-notification`\n- `allow-start-event-notification`\n- `allow-stop-event-notification`" + "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-show-notification`\n- `allow-get-event-notification`\n- `allow-set-event-notification`\n- `allow-get-detect-notification`\n- `allow-set-detect-notification`\n- `allow-open-notification-settings`\n- `allow-request-notification-permission`\n- `allow-check-notification-permission`\n- `allow-start-detect-notification`\n- `allow-stop-detect-notification`\n- `allow-start-event-notification`\n- `allow-stop-event-notification`" } ] } diff --git a/plugins/notification/src/commands.rs b/plugins/notification/src/commands.rs index 23fd5a835..427797212 100644 --- a/plugins/notification/src/commands.rs +++ b/plugins/notification/src/commands.rs @@ -1,5 +1,13 @@ use crate::NotificationPluginExt; +#[tauri::command] +#[specta::specta] +pub(crate) async fn show_notification( + app: tauri::AppHandle, +) -> Result<(), String> { + app.show_notification().map_err(|e| e.to_string()) +} + #[tauri::command] #[specta::specta] pub(crate) async fn get_event_notification( @@ -36,35 +44,6 @@ pub(crate) async fn set_detect_notification( .map_err(|e| e.to_string()) } -#[tauri::command] -#[specta::specta] -pub(crate) async fn open_notification_settings( - app: tauri::AppHandle, -) -> Result<(), String> { - app.open_notification_settings().map_err(|e| e.to_string()) -} - -#[tauri::command] -#[specta::specta] -pub(crate) async fn request_notification_permission( - app: tauri::AppHandle, -) -> Result<(), String> { - app.request_notification_permission() - .map_err(|e| e.to_string()) -} - -#[tauri::command] -#[specta::specta] -pub(crate) async fn check_notification_permission( - app: tauri::AppHandle, -) -> Result { - let permission = app - .check_notification_permission() - .await - .map_err(|e| e.to_string())?; - Ok(permission) -} - #[tauri::command] #[specta::specta] pub(crate) async fn start_detect_notification( diff --git a/plugins/notification/src/ext.rs b/plugins/notification/src/ext.rs index 3120d6b41..68a957f3b 100644 --- a/plugins/notification/src/ext.rs +++ b/plugins/notification/src/ext.rs @@ -1,5 +1,4 @@ -use std::{future::Future, sync::mpsc}; -use tokio::time::{timeout, Duration}; +use std::future::Future; use crate::error::Error; use tauri_plugin_store2::StorePluginExt; @@ -7,6 +6,7 @@ use tauri_plugin_store2::StorePluginExt; pub trait NotificationPluginExt { fn notification_store(&self) -> tauri_plugin_store2::ScopedStore; + fn show_notification(&self) -> Result<(), Error>; fn get_event_notification(&self) -> Result; fn set_event_notification(&self, enabled: bool) -> Result<(), Error>; @@ -18,12 +18,6 @@ pub trait NotificationPluginExt { fn start_detect_notification(&self) -> Result<(), Error>; fn stop_detect_notification(&self) -> Result<(), Error>; - - fn open_notification_settings(&self) -> Result<(), Error>; - fn request_notification_permission(&self) -> Result<(), Error>; - fn check_notification_permission( - &self, - ) -> impl Future>; } impl> NotificationPluginExt for T { @@ -31,6 +25,12 @@ impl> NotificationPluginExt for T { self.scoped_store(crate::PLUGIN_NAME).unwrap() } + #[tracing::instrument(skip(self))] + fn show_notification(&self) -> Result<(), Error> { + hypr_notification::show(); + Ok(()) + } + #[tracing::instrument(skip(self))] fn get_event_notification(&self) -> Result { let store = self.notification_store(); @@ -101,14 +101,14 @@ impl> NotificationPluginExt for T { #[tracing::instrument(skip(self))] fn start_detect_notification(&self) -> Result<(), Error> { let cb = hypr_detect::new_callback(move |bundle_id| { - let notif = hypr_notification2::Notification { - title: "Meeting detected".to_string(), - message: "Click here to start writing a note".to_string(), - url: Some("hypr://hyprnote.com/notification".to_string()), - timeout: Some(std::time::Duration::from_secs(10)), - }; - - hypr_notification2::show(notif); + // let notif = hypr_notification2::Notification { + // title: "Meeting detected".to_string(), + // message: "Click here to start writing a note".to_string(), + // url: Some("hypr://hyprnote.com/notification".to_string()), + // timeout: Some(std::time::Duration::from_secs(10)), + // }; + + hypr_notification::show(); }); let state = self.state::(); @@ -128,34 +128,4 @@ impl> NotificationPluginExt for T { } Ok(()) } - - #[tracing::instrument(skip(self))] - fn open_notification_settings(&self) -> Result<(), Error> { - hypr_notification2::open_notification_settings().map_err(Error::Io) - } - - #[tracing::instrument(skip(self))] - fn request_notification_permission(&self) -> Result<(), Error> { - hypr_notification2::request_notification_permission(); - Ok(()) - } - - #[tracing::instrument(skip(self))] - async fn check_notification_permission( - &self, - ) -> Result { - let (tx, rx) = mpsc::channel(); - - hypr_notification2::check_notification_permission(move |result| { - let _ = tx.send(result); - }); - - timeout(Duration::from_secs(3), async move { - rx.recv() - .map_err(|_| Error::ChannelClosed) - .and_then(|result| result.map_err(|_| Error::ChannelClosed)) - }) - .await - .map_err(|_| Error::PermissionTimeout)? - } } diff --git a/plugins/notification/src/lib.rs b/plugins/notification/src/lib.rs index 6a3f9e3fc..fc373ab05 100644 --- a/plugins/notification/src/lib.rs +++ b/plugins/notification/src/lib.rs @@ -25,13 +25,11 @@ fn make_specta_builder() -> tauri_specta::Builder { tauri_specta::Builder::::new() .plugin_name(PLUGIN_NAME) .commands(tauri_specta::collect_commands![ + commands::show_notification::, commands::get_event_notification::, commands::set_event_notification::, commands::get_detect_notification::, commands::set_detect_notification::, - commands::open_notification_settings::, - commands::request_notification_permission::, - commands::check_notification_permission::, commands::start_detect_notification::, commands::stop_detect_notification::, commands::start_event_notification::, diff --git a/plugins/notification/src/worker.rs b/plugins/notification/src/worker.rs index 57c1d320a..23000f9b2 100644 --- a/plugins/notification/src/worker.rs +++ b/plugins/notification/src/worker.rs @@ -43,15 +43,16 @@ pub async fn perform_event_notification(_job: Job, ctx: Data) -> Re // Wrap in AssertUnwindSafe and handle the panic properly if let Err(e) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - hypr_notification2::show(hypr_notification2::Notification { - title: "Meeting starting in 5 minutes".to_string(), - message: event.name.clone(), - url: Some(format!( - "hypr://hyprnote.com/notification?event_id={}", - event.id - )), - timeout: Some(std::time::Duration::from_secs(10)), - }); + // hypr_notification2::Notification { + // title: "Meeting starting in 5 minutes".to_string(), + // message: event.name.clone(), + // url: Some(format!( + // "hypr://hyprnote.com/notification?event_id={}", + // event.id + // )), + // timeout: Some(std::time::Duration::from_secs(10)), + // } + hypr_notification::show(); })) { // Convert panic payload to string for logging let panic_msg = if let Some(s) = e.downcast_ref::<&str>() { From c251b073ef7a275288fa587c169ac84753e86705 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Tue, 26 Aug 2025 13:09:22 -0700 Subject: [PATCH 2/6] more wip --- Cargo.lock | 9 + Cargo.toml | 1 + .../settings/views/notifications.tsx | 14 +- crates/notification-interface/Cargo.toml | 8 + crates/notification-interface/src/lib.rs | 7 + crates/notification-macos/Cargo.toml | 2 + crates/notification-macos/src/lib.rs | 40 ++++- .../swift-lib/src/lib.swift | 159 +++++++++--------- plugins/notification/js/bindings.gen.ts | 7 +- plugins/notification/src/commands.rs | 3 +- plugins/notification/src/ext.rs | 14 +- plugins/notification/src/worker.rs | 21 +-- 12 files changed, 178 insertions(+), 107 deletions(-) create mode 100644 crates/notification-interface/Cargo.toml create mode 100644 crates/notification-interface/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index bfc24ce02..01c96b5f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9235,10 +9235,19 @@ dependencies = [ "serde", ] +[[package]] +name = "notification-interface" +version = "0.1.0" +dependencies = [ + "serde", + "specta", +] + [[package]] name = "notification-macos" version = "0.1.0" dependencies = [ + "notification-interface", "swift-rs 1.0.7 (git+https://github.com/Brendonovich/swift-rs?rev=01980f9)", ] diff --git a/Cargo.toml b/Cargo.toml index f043a7e54..3d8dd015d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,7 @@ hypr-moonshine = { path = "crates/moonshine", package = "moonshine" } hypr-nango = { path = "crates/nango", package = "nango" } hypr-network = { path = "crates/network", package = "network" } hypr-notification = { path = "crates/notification", package = "notification" } +hypr-notification-interface = { path = "crates/notification-interface", package = "notification-interface" } hypr-notification-macos = { path = "crates/notification-macos", package = "notification-macos" } hypr-notion = { path = "crates/notion", package = "notion" } hypr-onnx = { path = "crates/onnx", package = "onnx" } diff --git a/apps/desktop/src/components/settings/views/notifications.tsx b/apps/desktop/src/components/settings/views/notifications.tsx index bea85873b..9e2005d7f 100644 --- a/apps/desktop/src/components/settings/views/notifications.tsx +++ b/apps/desktop/src/components/settings/views/notifications.tsx @@ -39,7 +39,12 @@ export default function NotificationsComponent() { mutationFn: async (v: Schema) => { if (v.event) { notificationCommands.setEventNotification(true); - notificationCommands.showNotification(); + notificationCommands.showNotification({ + title: "Test", + message: "Test", + url: "https://hypr.ai", + timeout: { secs: 5, nanos: 0 }, + }); } else { notificationCommands.setEventNotification(false); } @@ -59,7 +64,12 @@ export default function NotificationsComponent() { mutationFn: async (v: Schema) => { if (v.detect) { notificationCommands.setDetectNotification(true); - notificationCommands.showNotification(); + notificationCommands.showNotification({ + title: "Test", + message: "Test", + url: "https://hypr.ai", + timeout: { secs: 5, nanos: 0 }, + }); } else { notificationCommands.setDetectNotification(false); } diff --git a/crates/notification-interface/Cargo.toml b/crates/notification-interface/Cargo.toml new file mode 100644 index 000000000..35758afd1 --- /dev/null +++ b/crates/notification-interface/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "notification-interface" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { workspace = true, features = ["derive"] } +specta = { workspace = true, features = ["derive"] } diff --git a/crates/notification-interface/src/lib.rs b/crates/notification-interface/src/lib.rs new file mode 100644 index 000000000..9f86e947d --- /dev/null +++ b/crates/notification-interface/src/lib.rs @@ -0,0 +1,7 @@ +#[derive(Debug, serde::Serialize, serde::Deserialize, specta::Type)] +pub struct Notification { + pub title: String, + pub message: String, + pub url: Option, + pub timeout: Option, +} diff --git a/crates/notification-macos/Cargo.toml b/crates/notification-macos/Cargo.toml index 29c393eda..1794d1a4b 100644 --- a/crates/notification-macos/Cargo.toml +++ b/crates/notification-macos/Cargo.toml @@ -8,3 +8,5 @@ swift-rs = { workspace = true, features = ["build"] } [target.'cfg(target_os = "macos")'.dependencies] swift-rs = { workspace = true } + +hypr-notification-interface = { workspace = true, package = "notification-interface" } diff --git a/crates/notification-macos/src/lib.rs b/crates/notification-macos/src/lib.rs index 90df8e172..a0d5c30c5 100644 --- a/crates/notification-macos/src/lib.rs +++ b/crates/notification-macos/src/lib.rs @@ -1,13 +1,31 @@ +pub use hypr_notification_interface::*; + #[cfg(target_os = "macos")] -use swift_rs::{swift, Bool}; +use swift_rs::{swift, Bool, SRString}; #[cfg(target_os = "macos")] -swift!(fn _show() -> Bool); +swift!(fn _show_notification( + title: &SRString, + message: &SRString, + url: &SRString, + has_url: Bool, + timeout_seconds: f64 +) -> Bool); #[cfg(target_os = "macos")] -pub fn show() { +pub fn show(notification: &hypr_notification_interface::Notification) { unsafe { - _show(); + let title = SRString::from(notification.title.as_str()); + let message = SRString::from(notification.message.as_str()); + let url = notification + .url + .as_ref() + .map(|u| SRString::from(u.as_str())) + .unwrap_or_else(|| SRString::from("")); + let has_url = notification.url.is_some(); + let timeout_seconds = notification.timeout.map(|d| d.as_secs_f64()).unwrap_or(5.0); + + _show_notification(&title, &message, &url, has_url, timeout_seconds); } } @@ -16,10 +34,14 @@ mod tests { use super::*; #[test] - fn test_nspanel() { - println!("show1"); - show(); - println!("show2"); - std::thread::sleep(std::time::Duration::from_secs(10)); + fn test_notification() { + let notification = hypr_notification_interface::Notification { + title: "Test Title".to_string(), + message: "Test message content".to_string(), + url: Some("https://example.com".to_string()), + timeout: Some(std::time::Duration::from_secs(3)), + }; + + show(¬ification); } } diff --git a/crates/notification-macos/swift-lib/src/lib.swift b/crates/notification-macos/swift-lib/src/lib.swift index ef1f30f9e..12f2aaa25 100644 --- a/crates/notification-macos/swift-lib/src/lib.swift +++ b/crates/notification-macos/swift-lib/src/lib.swift @@ -1,58 +1,56 @@ -import SwiftRs -import Cocoa import AVFoundation - -// NOTIFICATION FEATURES: -// - Audio: Plays system notification sound ("Ping") when shown -// - Clickable: Click anywhere on the notification to dismiss immediately -// - Auto-dismiss timeout: Automatically closes after 5 seconds if not clicked -// - Animation: Slides in from right with fade-in, slides out to right with fade-out -// - Visual: Native macOS appearance with blur effect and rounded corners +import Cocoa +import SwiftRs // Store panel reference to prevent deallocation private var sharedPanel: NSPanel? +private var sharedUrl: String? -// Custom view that handles clicks to dismiss notification class ClickableView: NSView { var trackingArea: NSTrackingArea? var isHovering = false - + override func updateTrackingAreas() { super.updateTrackingAreas() - + if let existingArea = trackingArea { removeTrackingArea(existingArea) } - + let options: NSTrackingArea.Options = [ .activeAlways, .mouseEnteredAndExited, - .inVisibleRect + .inVisibleRect, ] - + trackingArea = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil) if let area = trackingArea { addTrackingArea(area) } } - + override func mouseEntered(with event: NSEvent) { isHovering = true NSCursor.pointingHand.set() } - + override func mouseExited(with event: NSEvent) { isHovering = false NSCursor.arrow.set() } - + override func mouseDown(with event: NSEvent) { + // Open URL if provided + if let urlString = sharedUrl, let url = URL(string: urlString) { + NSWorkspace.shared.open(url) + } + if let panel = sharedPanel { // Slide out to the right with fade NSAnimationContext.runAnimationGroup({ context in context.duration = 0.25 context.timingFunction = CAMediaTimingFunction(name: .easeIn) - + let currentFrame = panel.frame let targetFrame = NSRect( x: currentFrame.origin.x + currentFrame.width, @@ -60,65 +58,72 @@ class ClickableView: NSView { width: currentFrame.width, height: currentFrame.height ) - + panel.animator().setFrame(targetFrame, display: true) panel.animator().alphaValue = 0 }) { panel.close() sharedPanel = nil + sharedUrl = nil } } } } -// Play notification sound -private func playNotificationSound() { - // Use system notification sound - NSSound(named: .init("Ping"))?.play() - // Alternative sounds available: - // "Basso", "Blow", "Bottle", "Frog", "Funk", "Glass", "Hero", - // "Morse", "Ping", "Pop", "Purr", "Sosumi", "Submarine", "Tink" -} - -@_cdecl("_show") -public func _show() -> Bool { +@_cdecl("_show_notification") +public func _showNotification( + title: SRString, + message: SRString, + url: SRString, + hasUrl: Bool, + timeoutSeconds: Double +) -> Bool { // Initialize NSApplication if not already initialized let app = NSApplication.shared - + + // Convert SRString to Swift String + let titleStr = title.toString() + let messageStr = message.toString() + let urlStr = hasUrl ? url.toString() : nil + + // Store URL globally for click handler + sharedUrl = urlStr + // Use async to avoid potential deadlocks DispatchQueue.main.async { // Initialize the app if needed if app.delegate == nil { app.setActivationPolicy(.regular) } - + // Get screen dimensions guard let screen = NSScreen.main else { return } let screenRect = screen.visibleFrame - + // Notification dimensions (like native macOS notifications) let notificationWidth: CGFloat = 360 let notificationHeight: CGFloat = 80 let rightMargin: CGFloat = 12 let topMargin: CGFloat = 12 - + // Calculate final position (top-right corner) let finalXPos = screenRect.maxX - notificationWidth - rightMargin let finalYPos = screenRect.maxY - notificationHeight - topMargin - + // Start position (off-screen to the right) let startXPos = screenRect.maxX + 10 - + // Create NSPanel with borderless style (no title bar) let panel = NSPanel( - contentRect: NSRect(x: startXPos, y: finalYPos, width: notificationWidth, height: notificationHeight), + contentRect: NSRect( + x: startXPos, y: finalYPos, width: notificationWidth, height: notificationHeight), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false ) - + // Configure panel appearance - panel.level = .floating // Use floating level for notifications + panel.level = .floating // Use floating level for notifications panel.isFloatingPanel = true panel.hidesOnDeactivate = false panel.isOpaque = false @@ -126,29 +131,30 @@ public func _show() -> Bool { panel.hasShadow = true panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient, .ignoresCycle] panel.isMovableByWindowBackground = false - panel.alphaValue = 0 // Start invisible for fade-in - + panel.alphaValue = 0 // Start invisible for fade-in + // Create custom content view with rounded corners and background - let contentView = NSView(frame: NSRect(x: 0, y: 0, width: notificationWidth, height: notificationHeight)) + let contentView = NSView( + frame: NSRect(x: 0, y: 0, width: notificationWidth, height: notificationHeight)) contentView.wantsLayer = true contentView.layer?.cornerRadius = 10 contentView.layer?.masksToBounds = true - + // Shadow configuration for depth contentView.layer?.shadowColor = NSColor.black.cgColor contentView.layer?.shadowOpacity = 0.2 contentView.layer?.shadowOffset = CGSize(width: 0, height: 2) contentView.layer?.shadowRadius = 8 - + // Use visual effect view for native blur background let visualEffectView = NSVisualEffectView(frame: contentView.bounds) - visualEffectView.material = .popover // More native-like material + visualEffectView.material = .popover // More native-like material visualEffectView.state = .active visualEffectView.blendingMode = .behindWindow visualEffectView.wantsLayer = true visualEffectView.layer?.cornerRadius = 10 contentView.addSubview(visualEffectView) - + // Create horizontal stack view for icon and text let horizontalStack = NSStackView() horizontalStack.orientation = .horizontal @@ -156,9 +162,10 @@ public func _show() -> Bool { horizontalStack.spacing = 12 horizontalStack.edgeInsets = NSEdgeInsets(top: 15, left: 15, bottom: 15, right: 15) horizontalStack.translatesAutoresizingMaskIntoConstraints = false - - // Create app icon (bell emoji as placeholder) - let iconView = NSTextField(labelWithString: "🔔") + + // Create app icon (bell emoji as placeholder, or link icon if URL is present) + let iconString = urlStr != nil ? "🔗" : "🔔" + let iconView = NSTextField(labelWithString: iconString) iconView.font = NSFont.systemFont(ofSize: 28) iconView.alignment = .center iconView.backgroundColor = .clear @@ -166,24 +173,26 @@ public func _show() -> Bool { iconView.isEditable = false iconView.widthAnchor.constraint(equalToConstant: 40).isActive = true horizontalStack.addArrangedSubview(iconView) - + // Create vertical stack for title and message let textStack = NSStackView() textStack.orientation = .vertical textStack.alignment = .leading textStack.spacing = 2 - + // Create title label - let titleLabel = NSTextField(labelWithString: "Hyprnote") + let titleLabel = NSTextField(labelWithString: titleStr) titleLabel.font = NSFont.systemFont(ofSize: 13, weight: .semibold) titleLabel.textColor = .labelColor titleLabel.backgroundColor = .clear titleLabel.isBezeled = false titleLabel.isEditable = false + titleLabel.lineBreakMode = .byTruncatingTail + titleLabel.maximumNumberOfLines = 1 textStack.addArrangedSubview(titleLabel) - + // Create message label - let messageLabel = NSTextField(labelWithString: "Your notification is ready") + let messageLabel = NSTextField(labelWithString: messageStr) messageLabel.font = NSFont.systemFont(ofSize: 12) messageLabel.textColor = .secondaryLabelColor messageLabel.backgroundColor = .clear @@ -192,42 +201,40 @@ public func _show() -> Bool { messageLabel.lineBreakMode = .byTruncatingTail messageLabel.maximumNumberOfLines = 2 textStack.addArrangedSubview(messageLabel) - + horizontalStack.addArrangedSubview(textStack) - + // Add stack to visual effect view visualEffectView.addSubview(horizontalStack) - + // Set up constraints NSLayoutConstraint.activate([ horizontalStack.leadingAnchor.constraint(equalTo: visualEffectView.leadingAnchor), horizontalStack.trailingAnchor.constraint(equalTo: visualEffectView.trailingAnchor), horizontalStack.topAnchor.constraint(equalTo: visualEffectView.topAnchor), - horizontalStack.bottomAnchor.constraint(equalTo: visualEffectView.bottomAnchor) + horizontalStack.bottomAnchor.constraint(equalTo: visualEffectView.bottomAnchor), ]) - + // Create clickable content view - let clickableContentView = ClickableView(frame: NSRect(x: 0, y: 0, width: notificationWidth, height: notificationHeight)) + let clickableContentView = ClickableView( + frame: NSRect(x: 0, y: 0, width: notificationWidth, height: notificationHeight)) clickableContentView.wantsLayer = true clickableContentView.layer?.cornerRadius = 10 clickableContentView.layer?.masksToBounds = true - + // Move visual effect view to clickable content view contentView.removeFromSuperview() clickableContentView.addSubview(visualEffectView) - + // Set the clickable content view as panel's content panel.contentView = clickableContentView - + // Store panel reference to prevent deallocation sharedPanel = panel - + // Show the panel (starts off-screen to the right) panel.makeKeyAndOrderFront(nil) - - // Play notification sound - playNotificationSound() - + // Animate slide-in from right with fade-in NSAnimationContext.runAnimationGroup({ context in context.duration = 0.35 @@ -238,35 +245,35 @@ public func _show() -> Bool { ) panel.animator().alphaValue = 1.0 }) { - // Auto-dismiss after 5 seconds if not clicked - DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { + // Auto-dismiss after specified timeout + DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) { if let currentPanel = sharedPanel { // Animate slide-out to right with fade NSAnimationContext.runAnimationGroup({ context in context.duration = 0.3 context.timingFunction = CAMediaTimingFunction(name: .easeIn) - + let exitFrame = NSRect( x: screenRect.maxX + 10, y: finalYPos, width: notificationWidth, height: notificationHeight ) - + currentPanel.animator().setFrame(exitFrame, display: true) currentPanel.animator().alphaValue = 0 }) { currentPanel.close() sharedPanel = nil + sharedUrl = nil } } } } - + } - + // Give some time for the async block to execute Thread.sleep(forTimeInterval: 0.1) - return true } diff --git a/plugins/notification/js/bindings.gen.ts b/plugins/notification/js/bindings.gen.ts index 7e9ffeb56..beb6af416 100644 --- a/plugins/notification/js/bindings.gen.ts +++ b/plugins/notification/js/bindings.gen.ts @@ -7,8 +7,8 @@ export const commands = { -async showNotification() : Promise { - return await TAURI_INVOKE("plugin:notification|show_notification"); +async showNotification(v: Notification) : Promise { + return await TAURI_INVOKE("plugin:notification|show_notification", { v }); }, async getEventNotification() : Promise { return await TAURI_INVOKE("plugin:notification|get_event_notification"); @@ -46,7 +46,8 @@ async stopEventNotification() : Promise { /** user-defined types **/ - +export type Duration = { secs: number; nanos: number } +export type Notification = { title: string; message: string; url: string | null; timeout: Duration | null } /** tauri-specta globals **/ diff --git a/plugins/notification/src/commands.rs b/plugins/notification/src/commands.rs index 427797212..b73613b0c 100644 --- a/plugins/notification/src/commands.rs +++ b/plugins/notification/src/commands.rs @@ -4,8 +4,9 @@ use crate::NotificationPluginExt; #[specta::specta] pub(crate) async fn show_notification( app: tauri::AppHandle, + v: hypr_notification::Notification, ) -> Result<(), String> { - app.show_notification().map_err(|e| e.to_string()) + app.show_notification(v).map_err(|e| e.to_string()) } #[tauri::command] diff --git a/plugins/notification/src/ext.rs b/plugins/notification/src/ext.rs index 68a957f3b..f27f9846d 100644 --- a/plugins/notification/src/ext.rs +++ b/plugins/notification/src/ext.rs @@ -6,7 +6,8 @@ use tauri_plugin_store2::StorePluginExt; pub trait NotificationPluginExt { fn notification_store(&self) -> tauri_plugin_store2::ScopedStore; - fn show_notification(&self) -> Result<(), Error>; + fn show_notification(&self, notification: hypr_notification::Notification) + -> Result<(), Error>; fn get_event_notification(&self) -> Result; fn set_event_notification(&self, enabled: bool) -> Result<(), Error>; @@ -26,8 +27,8 @@ impl> NotificationPluginExt for T { } #[tracing::instrument(skip(self))] - fn show_notification(&self) -> Result<(), Error> { - hypr_notification::show(); + fn show_notification(&self, v: hypr_notification::Notification) -> Result<(), Error> { + hypr_notification::show(&v); Ok(()) } @@ -108,7 +109,12 @@ impl> NotificationPluginExt for T { // timeout: Some(std::time::Duration::from_secs(10)), // }; - hypr_notification::show(); + hypr_notification::show(&hypr_notification::Notification { + title: "Hello".to_string(), + message: "Hello".to_string(), + url: None, + timeout: Some(std::time::Duration::from_secs(10)), + }); }); let state = self.state::(); diff --git a/plugins/notification/src/worker.rs b/plugins/notification/src/worker.rs index 23000f9b2..1d6fa34d3 100644 --- a/plugins/notification/src/worker.rs +++ b/plugins/notification/src/worker.rs @@ -41,20 +41,17 @@ pub async fn perform_event_notification(_job: Job, ctx: Data) -> Re if let Some(event) = latest_event.first() { tracing::info!("Found upcoming event - showing notification"); - // Wrap in AssertUnwindSafe and handle the panic properly if let Err(e) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - // hypr_notification2::Notification { - // title: "Meeting starting in 5 minutes".to_string(), - // message: event.name.clone(), - // url: Some(format!( - // "hypr://hyprnote.com/notification?event_id={}", - // event.id - // )), - // timeout: Some(std::time::Duration::from_secs(10)), - // } - hypr_notification::show(); + hypr_notification::show(&hypr_notification::Notification { + title: "Meeting starting in 5 minutes".to_string(), + message: event.name.clone(), + url: Some(format!( + "hypr://hyprnote.com/notification?event_id={}", + event.id + )), + timeout: Some(std::time::Duration::from_secs(10)), + }); })) { - // Convert panic payload to string for logging let panic_msg = if let Some(s) = e.downcast_ref::<&str>() { s.to_string() } else if let Some(s) = e.downcast_ref::() { From 0837290d5bac2deaee2948b564707abd7ec4c5e8 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Tue, 26 Aug 2025 13:30:04 -0700 Subject: [PATCH 3/6] wip --- .../swift-lib/src/lib.swift | 312 +++++++++++------- 1 file changed, 188 insertions(+), 124 deletions(-) diff --git a/crates/notification-macos/swift-lib/src/lib.swift b/crates/notification-macos/swift-lib/src/lib.swift index 12f2aaa25..afd00dfdc 100644 --- a/crates/notification-macos/swift-lib/src/lib.swift +++ b/crates/notification-macos/swift-lib/src/lib.swift @@ -9,6 +9,7 @@ private var sharedUrl: String? class ClickableView: NSView { var trackingArea: NSTrackingArea? var isHovering = false + var onHover: ((Bool) -> Void)? override func updateTrackingAreas() { super.updateTrackingAreas() @@ -32,40 +33,72 @@ class ClickableView: NSView { override func mouseEntered(with event: NSEvent) { isHovering = true NSCursor.pointingHand.set() + onHover?(true) } override func mouseExited(with event: NSEvent) { isHovering = false NSCursor.arrow.set() + onHover?(false) } override func mouseDown(with event: NSEvent) { + // Visual feedback + alphaValue = 0.95 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.alphaValue = 1.0 + } + // Open URL if provided if let urlString = sharedUrl, let url = URL(string: urlString) { NSWorkspace.shared.open(url) } - if let panel = sharedPanel { - // Slide out to the right with fade - NSAnimationContext.runAnimationGroup({ context in - context.duration = 0.25 - context.timingFunction = CAMediaTimingFunction(name: .easeIn) - - let currentFrame = panel.frame - let targetFrame = NSRect( - x: currentFrame.origin.x + currentFrame.width, - y: currentFrame.origin.y, - width: currentFrame.width, - height: currentFrame.height - ) - - panel.animator().setFrame(targetFrame, display: true) - panel.animator().alphaValue = 0 - }) { - panel.close() - sharedPanel = nil - sharedUrl = nil - } + dismissNotification() + } +} + +class CloseButton: NSButton { + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setup() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + private func setup() { + wantsLayer = true + layer?.cornerRadius = 8 + layer?.backgroundColor = NSColor(white: 0.5, alpha: 0.3).cgColor + isBordered = false + + // Set styled title with color + let attributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 12, weight: .medium), + .foregroundColor: NSColor(white: 0.9, alpha: 0.9), + ] + attributedTitle = NSAttributedString(string: "✕", attributes: attributes) + alphaValue = 0 + } + + override func mouseDown(with event: NSEvent) { + dismissNotification() + } +} + +func dismissNotification() { + if let panel = sharedPanel { + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.2 + context.timingFunction = CAMediaTimingFunction(name: .easeIn) + panel.animator().alphaValue = 0 + }) { + panel.close() + sharedPanel = nil + sharedUrl = nil } } } @@ -100,20 +133,20 @@ public func _showNotification( guard let screen = NSScreen.main else { return } let screenRect = screen.visibleFrame - // Notification dimensions (like native macOS notifications) + // Notification dimensions let notificationWidth: CGFloat = 360 - let notificationHeight: CGFloat = 80 - let rightMargin: CGFloat = 12 - let topMargin: CGFloat = 12 + let notificationHeight: CGFloat = 75 + let rightMargin: CGFloat = 15 + let topMargin: CGFloat = 15 // Calculate final position (top-right corner) let finalXPos = screenRect.maxX - notificationWidth - rightMargin let finalYPos = screenRect.maxY - notificationHeight - topMargin - // Start position (off-screen to the right) + // Start position (slide in from right) let startXPos = screenRect.maxX + 10 - // Create NSPanel with borderless style (no title bar) + // Create NSPanel let panel = NSPanel( contentRect: NSRect( x: startXPos, y: finalYPos, width: notificationWidth, height: notificationHeight), @@ -122,8 +155,8 @@ public func _showNotification( defer: false ) - // Configure panel appearance - panel.level = .floating // Use floating level for notifications + // Configure panel + panel.level = .statusBar panel.isFloatingPanel = true panel.hidesOnDeactivate = false panel.isOpaque = false @@ -131,59 +164,114 @@ public func _showNotification( panel.hasShadow = true panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient, .ignoresCycle] panel.isMovableByWindowBackground = false - panel.alphaValue = 0 // Start invisible for fade-in + panel.alphaValue = 0 - // Create custom content view with rounded corners and background - let contentView = NSView( + // Create clickable content view + let clickableView = ClickableView( frame: NSRect(x: 0, y: 0, width: notificationWidth, height: notificationHeight)) - contentView.wantsLayer = true - contentView.layer?.cornerRadius = 10 - contentView.layer?.masksToBounds = true - - // Shadow configuration for depth - contentView.layer?.shadowColor = NSColor.black.cgColor - contentView.layer?.shadowOpacity = 0.2 - contentView.layer?.shadowOffset = CGSize(width: 0, height: 2) - contentView.layer?.shadowRadius = 8 - - // Use visual effect view for native blur background - let visualEffectView = NSVisualEffectView(frame: contentView.bounds) - visualEffectView.material = .popover // More native-like material - visualEffectView.state = .active - visualEffectView.blendingMode = .behindWindow - visualEffectView.wantsLayer = true - visualEffectView.layer?.cornerRadius = 10 - contentView.addSubview(visualEffectView) - - // Create horizontal stack view for icon and text - let horizontalStack = NSStackView() - horizontalStack.orientation = .horizontal - horizontalStack.alignment = .centerY - horizontalStack.spacing = 12 - horizontalStack.edgeInsets = NSEdgeInsets(top: 15, left: 15, bottom: 15, right: 15) - horizontalStack.translatesAutoresizingMaskIntoConstraints = false - - // Create app icon (bell emoji as placeholder, or link icon if URL is present) - let iconString = urlStr != nil ? "🔗" : "🔔" - let iconView = NSTextField(labelWithString: iconString) - iconView.font = NSFont.systemFont(ofSize: 28) - iconView.alignment = .center - iconView.backgroundColor = .clear - iconView.isBezeled = false - iconView.isEditable = false - iconView.widthAnchor.constraint(equalToConstant: 40).isActive = true - horizontalStack.addArrangedSubview(iconView) - - // Create vertical stack for title and message + clickableView.wantsLayer = true + + // Main container + let container = NSView(frame: clickableView.bounds) + container.wantsLayer = true + container.layer?.cornerRadius = 11 + container.layer?.masksToBounds = false + + // Shadow for depth + container.layer?.shadowColor = NSColor.black.cgColor + container.layer?.shadowOpacity = 0.2 + container.layer?.shadowOffset = CGSize(width: 0, height: 2) + container.layer?.shadowRadius = 10 + + // Visual effect view for background + let effectView = NSVisualEffectView(frame: container.bounds) + effectView.material = .hudWindow // Dark translucent material + effectView.state = .active + effectView.blendingMode = .behindWindow + effectView.wantsLayer = true + effectView.layer?.cornerRadius = 11 + effectView.layer?.masksToBounds = true + container.addSubview(effectView) + + // Add subtle border for definition + let borderLayer = CALayer() + borderLayer.frame = effectView.bounds + borderLayer.cornerRadius = 11 + borderLayer.borderWidth = 0.5 + borderLayer.borderColor = NSColor(white: 1.0, alpha: 0.05).cgColor + effectView.layer?.addSublayer(borderLayer) + + // Content stack + let contentStack = NSStackView() + contentStack.orientation = .horizontal + contentStack.alignment = .centerY + contentStack.spacing = 12 + contentStack.translatesAutoresizingMaskIntoConstraints = false + effectView.addSubview(contentStack) + + NSLayoutConstraint.activate([ + contentStack.leadingAnchor.constraint(equalTo: effectView.leadingAnchor, constant: 14), + contentStack.trailingAnchor.constraint(equalTo: effectView.trailingAnchor, constant: -14), + contentStack.centerYAnchor.constraint(equalTo: effectView.centerYAnchor), + ]) + + // Icon placeholder - REPLACE THIS WITH YOUR SVG/PNG + let iconContainer = NSView() + iconContainer.wantsLayer = true + iconContainer.layer?.cornerRadius = 10 + iconContainer.widthAnchor.constraint(equalToConstant: 42).isActive = true + iconContainer.heightAnchor.constraint(equalToConstant: 42).isActive = true + + // Simple gradient background for now + let gradientLayer = CAGradientLayer() + gradientLayer.frame = CGRect(x: 0, y: 0, width: 42, height: 42) + gradientLayer.cornerRadius = 10 + gradientLayer.colors = + urlStr != nil + ? [NSColor.systemBlue.cgColor, NSColor(red: 0.2, green: 0.4, blue: 0.8, alpha: 1).cgColor] + : [NSColor.systemGreen.cgColor, NSColor(red: 0.2, green: 0.6, blue: 0.4, alpha: 1).cgColor] + gradientLayer.startPoint = CGPoint(x: 0, y: 0) + gradientLayer.endPoint = CGPoint(x: 1, y: 1) + iconContainer.layer?.addSublayer(gradientLayer) + + // Temporary emoji icon - REPLACE WITH NSImageView for your SVG/PNG + let tempIcon = NSTextField(labelWithString: urlStr != nil ? "🔗" : "🔔") + tempIcon.font = NSFont.systemFont(ofSize: 20) + tempIcon.textColor = .white + tempIcon.alignment = .center + tempIcon.translatesAutoresizingMaskIntoConstraints = false + iconContainer.addSubview(tempIcon) + NSLayoutConstraint.activate([ + tempIcon.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor), + tempIcon.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor), + ]) + + /* TO USE YOUR OWN ICON, REPLACE THE ABOVE WITH: + let iconImageView = NSImageView() + iconImageView.image = NSImage(named: "your-icon-name") // or load from path + iconImageView.imageScaling = .scaleProportionallyUpOrDown + iconImageView.translatesAutoresizingMaskIntoConstraints = false + 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) + ]) + */ + + contentStack.addArrangedSubview(iconContainer) + + // Text container let textStack = NSStackView() textStack.orientation = .vertical textStack.alignment = .leading textStack.spacing = 2 - // Create title label + // Title let titleLabel = NSTextField(labelWithString: titleStr) titleLabel.font = NSFont.systemFont(ofSize: 13, weight: .semibold) - titleLabel.textColor = .labelColor + titleLabel.textColor = NSColor.labelColor titleLabel.backgroundColor = .clear titleLabel.isBezeled = false titleLabel.isEditable = false @@ -191,10 +279,10 @@ public func _showNotification( titleLabel.maximumNumberOfLines = 1 textStack.addArrangedSubview(titleLabel) - // Create message label + // Message let messageLabel = NSTextField(labelWithString: messageStr) - messageLabel.font = NSFont.systemFont(ofSize: 12) - messageLabel.textColor = .secondaryLabelColor + messageLabel.font = NSFont.systemFont(ofSize: 12, weight: .regular) + messageLabel.textColor = NSColor.secondaryLabelColor messageLabel.backgroundColor = .clear messageLabel.isBezeled = false messageLabel.isEditable = false @@ -202,42 +290,40 @@ public func _showNotification( messageLabel.maximumNumberOfLines = 2 textStack.addArrangedSubview(messageLabel) - horizontalStack.addArrangedSubview(textStack) + contentStack.addArrangedSubview(textStack) - // Add stack to visual effect view - visualEffectView.addSubview(horizontalStack) + // Add close button + let closeButton = CloseButton(frame: NSRect(x: 0, y: 0, width: 24, height: 24)) + closeButton.translatesAutoresizingMaskIntoConstraints = false + effectView.addSubview(closeButton) - // Set up constraints NSLayoutConstraint.activate([ - horizontalStack.leadingAnchor.constraint(equalTo: visualEffectView.leadingAnchor), - horizontalStack.trailingAnchor.constraint(equalTo: visualEffectView.trailingAnchor), - horizontalStack.topAnchor.constraint(equalTo: visualEffectView.topAnchor), - horizontalStack.bottomAnchor.constraint(equalTo: visualEffectView.bottomAnchor), + closeButton.topAnchor.constraint(equalTo: effectView.topAnchor, constant: 8), + closeButton.trailingAnchor.constraint(equalTo: effectView.trailingAnchor, constant: -8), + closeButton.widthAnchor.constraint(equalToConstant: 24), + closeButton.heightAnchor.constraint(equalToConstant: 24), ]) - // Create clickable content view - let clickableContentView = ClickableView( - frame: NSRect(x: 0, y: 0, width: notificationWidth, height: notificationHeight)) - clickableContentView.wantsLayer = true - clickableContentView.layer?.cornerRadius = 10 - clickableContentView.layer?.masksToBounds = true - - // Move visual effect view to clickable content view - contentView.removeFromSuperview() - clickableContentView.addSubview(visualEffectView) + // Show close button on hover + clickableView.onHover = { isHovering in + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.15 + closeButton.animator().alphaValue = isHovering ? 0.8 : 0 + } + } - // Set the clickable content view as panel's content - panel.contentView = clickableContentView + clickableView.addSubview(container) + panel.contentView = clickableView - // Store panel reference to prevent deallocation + // Store panel reference sharedPanel = panel - // Show the panel (starts off-screen to the right) + // Show panel panel.makeKeyAndOrderFront(nil) - // Animate slide-in from right with fade-in + // Animate slide-in NSAnimationContext.runAnimationGroup({ context in - context.duration = 0.35 + context.duration = 0.3 context.timingFunction = CAMediaTimingFunction(name: .easeOut) panel.animator().setFrame( NSRect(x: finalXPos, y: finalYPos, width: notificationWidth, height: notificationHeight), @@ -245,35 +331,13 @@ public func _showNotification( ) panel.animator().alphaValue = 1.0 }) { - // Auto-dismiss after specified timeout + // Auto-dismiss after timeout DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) { - if let currentPanel = sharedPanel { - // Animate slide-out to right with fade - NSAnimationContext.runAnimationGroup({ context in - context.duration = 0.3 - context.timingFunction = CAMediaTimingFunction(name: .easeIn) - - let exitFrame = NSRect( - x: screenRect.maxX + 10, - y: finalYPos, - width: notificationWidth, - height: notificationHeight - ) - - currentPanel.animator().setFrame(exitFrame, display: true) - currentPanel.animator().alphaValue = 0 - }) { - currentPanel.close() - sharedPanel = nil - sharedUrl = nil - } - } + dismissNotification() } } - } - // Give some time for the async block to execute Thread.sleep(forTimeInterval: 0.1) return true } From 1f5eadd246de9b2d689e0a469713406570f632bc Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Tue, 26 Aug 2025 13:55:19 -0700 Subject: [PATCH 4/6] add example --- crates/notification-macos/Cargo.toml | 7 ++- .../examples/test_notification.rs | 44 +++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 crates/notification-macos/examples/test_notification.rs diff --git a/crates/notification-macos/Cargo.toml b/crates/notification-macos/Cargo.toml index 1794d1a4b..de30c84fa 100644 --- a/crates/notification-macos/Cargo.toml +++ b/crates/notification-macos/Cargo.toml @@ -3,10 +3,9 @@ name = "notification-macos" version = "0.1.0" edition = "2021" -[target.'cfg(target_os = "macos")'.build-dependencies] +[build-dependencies] swift-rs = { workspace = true, features = ["build"] } -[target.'cfg(target_os = "macos")'.dependencies] +[dependencies] +hypr-notification-interface = { workspace = true } swift-rs = { workspace = true } - -hypr-notification-interface = { workspace = true, package = "notification-interface" } diff --git a/crates/notification-macos/examples/test_notification.rs b/crates/notification-macos/examples/test_notification.rs new file mode 100644 index 000000000..9a400bf99 --- /dev/null +++ b/crates/notification-macos/examples/test_notification.rs @@ -0,0 +1,44 @@ +use notification_macos::*; +use std::time::Duration; + +#[cfg(target_os = "macos")] +#[link(name = "AppKit", kind = "framework")] +#[link(name = "Foundation", kind = "framework")] +extern "C" { + fn NSApplicationLoad() -> bool; + fn CFRunLoopRun(); + fn CFRunLoopStop(rl: *const std::ffi::c_void); + fn CFRunLoopGetMain() -> *const std::ffi::c_void; +} + +fn main() { + #[cfg(target_os = "macos")] + { + unsafe { + NSApplicationLoad(); + } + + std::thread::spawn(|| { + std::thread::sleep(Duration::from_millis(100)); + + let notification = Notification { + title: "Test Notification".to_string(), + message: "This is a test message from Rust".to_string(), + url: Some("https://example.com".to_string()), + timeout: Some(Duration::from_secs(3)), + }; + + show(¬ification); + + std::thread::sleep(Duration::from_secs(5)); + unsafe { + let main_loop = CFRunLoopGetMain(); + CFRunLoopStop(main_loop); + } + }); + + unsafe { + CFRunLoopRun(); + } + } +} From ad0b9bbeabd360b5a9993fb17fd609f41c8ac05b Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Tue, 26 Aug 2025 15:31:39 -0700 Subject: [PATCH 5/6] refactor --- .../swift-lib/src/lib.swift | 281 ++++++++++++------ 1 file changed, 185 insertions(+), 96 deletions(-) diff --git a/crates/notification-macos/swift-lib/src/lib.swift b/crates/notification-macos/swift-lib/src/lib.swift index afd00dfdc..7ce0b6954 100644 --- a/crates/notification-macos/swift-lib/src/lib.swift +++ b/crates/notification-macos/swift-lib/src/lib.swift @@ -2,10 +2,11 @@ import AVFoundation import Cocoa import SwiftRs -// Store panel reference to prevent deallocation +// MARK: - Global State Management private var sharedPanel: NSPanel? private var sharedUrl: String? +// MARK: - Custom UI Components class ClickableView: NSView { var trackingArea: NSTrackingArea? var isHovering = false @@ -54,7 +55,7 @@ class ClickableView: NSView { NSWorkspace.shared.open(url) } - dismissNotification() + NotificationManager.shared.dismiss() } } @@ -85,77 +86,85 @@ class CloseButton: NSButton { } override func mouseDown(with event: NSEvent) { - dismissNotification() + NotificationManager.shared.dismiss() } } -func dismissNotification() { - if let panel = sharedPanel { - NSAnimationContext.runAnimationGroup({ context in - context.duration = 0.2 - context.timingFunction = CAMediaTimingFunction(name: .easeIn) - panel.animator().alphaValue = 0 - }) { - panel.close() - sharedPanel = nil - sharedUrl = nil - } +// MARK: - Notification Manager +class NotificationManager { + static let shared = NotificationManager() + private init() {} + + // MARK: - Configuration Constants + private struct Config { + static let notificationWidth: CGFloat = 360 + static let notificationHeight: CGFloat = 75 + static let rightMargin: CGFloat = 15 + static let topMargin: CGFloat = 15 + static let slideInOffset: CGFloat = 10 } -} -@_cdecl("_show_notification") -public func _showNotification( - title: SRString, - message: SRString, - url: SRString, - hasUrl: Bool, - timeoutSeconds: Double -) -> Bool { - // Initialize NSApplication if not already initialized - let app = NSApplication.shared - - // Convert SRString to Swift String - let titleStr = title.toString() - let messageStr = message.toString() - let urlStr = hasUrl ? url.toString() : nil + // MARK: - Public Methods + func show(title: String, message: String, url: String?, timeoutSeconds: Double) { + sharedUrl = url + + DispatchQueue.main.async { [weak self] in + self?.setupApplicationIfNeeded() + self?.createAndShowNotification( + title: title, + message: message, + hasUrl: url != nil, + timeoutSeconds: timeoutSeconds + ) + } + } - // Store URL globally for click handler - sharedUrl = urlStr + func dismiss() { + dismissNotification() + } - // Use async to avoid potential deadlocks - DispatchQueue.main.async { - // Initialize the app if needed + // MARK: - Private Methods + private func setupApplicationIfNeeded() { + let app = NSApplication.shared if app.delegate == nil { app.setActivationPolicy(.regular) } + } - // Get screen dimensions + private func createAndShowNotification( + title: String, message: String, hasUrl: Bool, timeoutSeconds: Double + ) { guard let screen = NSScreen.main else { return } - let screenRect = screen.visibleFrame - // Notification dimensions - let notificationWidth: CGFloat = 360 - let notificationHeight: CGFloat = 75 - let rightMargin: CGFloat = 15 - let topMargin: CGFloat = 15 + let panel = createPanel(screen: screen) + let clickableView = createClickableView() + let container = createContainer(clickableView: clickableView) + let effectView = createEffectView(container: container) - // Calculate final position (top-right corner) - let finalXPos = screenRect.maxX - notificationWidth - rightMargin - let finalYPos = screenRect.maxY - notificationHeight - topMargin + setupContentStack(effectView: effectView, title: title, message: message, hasUrl: hasUrl) - // Start position (slide in from right) - let startXPos = screenRect.maxX + 10 + clickableView.addSubview(container) + panel.contentView = clickableView + + sharedPanel = panel + showWithAnimation(panel: panel, screen: screen, timeoutSeconds: timeoutSeconds) + } + + private func createPanel(screen: NSScreen) -> NSPanel { + let screenRect = screen.visibleFrame + let startXPos = screenRect.maxX + Config.slideInOffset + let finalYPos = screenRect.maxY - Config.notificationHeight - Config.topMargin - // Create NSPanel let panel = NSPanel( contentRect: NSRect( - x: startXPos, y: finalYPos, width: notificationWidth, height: notificationHeight), + x: startXPos, y: finalYPos, + width: Config.notificationWidth, height: Config.notificationHeight + ), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false ) - // Configure panel panel.level = .statusBar panel.isFloatingPanel = true panel.hidesOnDeactivate = false @@ -166,12 +175,16 @@ public func _showNotification( panel.isMovableByWindowBackground = false panel.alphaValue = 0 - // Create clickable content view - let clickableView = ClickableView( - frame: NSRect(x: 0, y: 0, width: notificationWidth, height: notificationHeight)) - clickableView.wantsLayer = true + return panel + } + + private func createClickableView() -> ClickableView { + return ClickableView( + frame: NSRect(x: 0, y: 0, width: Config.notificationWidth, height: Config.notificationHeight) + ) + } - // Main container + private func createContainer(clickableView: ClickableView) -> NSView { let container = NSView(frame: clickableView.bounds) container.wantsLayer = true container.layer?.cornerRadius = 11 @@ -183,15 +196,17 @@ public func _showNotification( container.layer?.shadowOffset = CGSize(width: 0, height: 2) container.layer?.shadowRadius = 10 - // Visual effect view for background + return container + } + + private func createEffectView(container: NSView) -> NSVisualEffectView { let effectView = NSVisualEffectView(frame: container.bounds) - effectView.material = .hudWindow // Dark translucent material + effectView.material = .hudWindow effectView.state = .active effectView.blendingMode = .behindWindow effectView.wantsLayer = true effectView.layer?.cornerRadius = 11 effectView.layer?.masksToBounds = true - container.addSubview(effectView) // Add subtle border for definition let borderLayer = CALayer() @@ -201,7 +216,26 @@ public func _showNotification( borderLayer.borderColor = NSColor(white: 1.0, alpha: 0.05).cgColor effectView.layer?.addSublayer(borderLayer) - // Content stack + container.addSubview(effectView) + return effectView + } + + private func setupContentStack( + effectView: NSVisualEffectView, title: String, message: String, hasUrl: Bool + ) { + let contentStack = createContentStack(effectView: effectView) + + let iconContainer = createIconContainer(hasUrl: hasUrl) + let textStack = createTextStack(title: title, message: message) + let closeButton = createCloseButton(effectView: effectView) + + contentStack.addArrangedSubview(iconContainer) + contentStack.addArrangedSubview(textStack) + + setupCloseButtonHover(effectView: effectView, closeButton: closeButton) + } + + private func createContentStack(effectView: NSVisualEffectView) -> NSStackView { let contentStack = NSStackView() contentStack.orientation = .horizontal contentStack.alignment = .centerY @@ -215,61 +249,73 @@ public func _showNotification( contentStack.centerYAnchor.constraint(equalTo: effectView.centerYAnchor), ]) - // Icon placeholder - REPLACE THIS WITH YOUR SVG/PNG + return contentStack + } + + private func createIconContainer(hasUrl: Bool) -> NSView { let iconContainer = NSView() iconContainer.wantsLayer = true iconContainer.layer?.cornerRadius = 10 iconContainer.widthAnchor.constraint(equalToConstant: 42).isActive = true iconContainer.heightAnchor.constraint(equalToConstant: 42).isActive = true - // Simple gradient background for now + // Simple gradient background let gradientLayer = CAGradientLayer() gradientLayer.frame = CGRect(x: 0, y: 0, width: 42, height: 42) gradientLayer.cornerRadius = 10 gradientLayer.colors = - urlStr != nil + hasUrl ? [NSColor.systemBlue.cgColor, NSColor(red: 0.2, green: 0.4, blue: 0.8, alpha: 1).cgColor] : [NSColor.systemGreen.cgColor, NSColor(red: 0.2, green: 0.6, blue: 0.4, alpha: 1).cgColor] gradientLayer.startPoint = CGPoint(x: 0, y: 0) gradientLayer.endPoint = CGPoint(x: 1, y: 1) iconContainer.layer?.addSublayer(gradientLayer) - // Temporary emoji icon - REPLACE WITH NSImageView for your SVG/PNG - let tempIcon = NSTextField(labelWithString: urlStr != nil ? "🔗" : "🔔") - tempIcon.font = NSFont.systemFont(ofSize: 20) - tempIcon.textColor = .white - tempIcon.alignment = .center - tempIcon.translatesAutoresizingMaskIntoConstraints = false - iconContainer.addSubview(tempIcon) + // App icon from bundle + let iconImageView = createAppIconView() + iconContainer.addSubview(iconImageView) + NSLayoutConstraint.activate([ - tempIcon.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor), - tempIcon.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor), + iconImageView.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor), + iconImageView.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor), + iconImageView.widthAnchor.constraint(equalToConstant: 28), + iconImageView.heightAnchor.constraint(equalToConstant: 28), ]) - /* TO USE YOUR OWN ICON, REPLACE THE ABOVE WITH: + return iconContainer + } + + private func createAppIconView() -> NSImageView { let iconImageView = NSImageView() - iconImageView.image = NSImage(named: "your-icon-name") // or load from path + + // Get the app's main icon from the bundle + if let appIcon = NSApp.applicationIconImage { + iconImageView.image = appIcon + } else { + iconImageView.image = NSImage(named: NSImage.applicationIconName) + } + iconImageView.imageScaling = .scaleProportionallyUpOrDown iconImageView.translatesAutoresizingMaskIntoConstraints = false - 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) - ]) - */ - contentStack.addArrangedSubview(iconContainer) + // Add subtle shadow to the icon for better contrast + iconImageView.wantsLayer = true + iconImageView.layer?.shadowColor = NSColor.black.cgColor + iconImageView.layer?.shadowOpacity = 0.3 + iconImageView.layer?.shadowOffset = CGSize(width: 0, height: 1) + iconImageView.layer?.shadowRadius = 2 + + return iconImageView + } - // Text container + private func createTextStack(title: String, message: String) -> NSStackView { let textStack = NSStackView() textStack.orientation = .vertical textStack.alignment = .leading textStack.spacing = 2 // Title - let titleLabel = NSTextField(labelWithString: titleStr) + let titleLabel = NSTextField(labelWithString: title) titleLabel.font = NSFont.systemFont(ofSize: 13, weight: .semibold) titleLabel.textColor = NSColor.labelColor titleLabel.backgroundColor = .clear @@ -280,7 +326,7 @@ public func _showNotification( textStack.addArrangedSubview(titleLabel) // Message - let messageLabel = NSTextField(labelWithString: messageStr) + let messageLabel = NSTextField(labelWithString: message) messageLabel.font = NSFont.systemFont(ofSize: 12, weight: .regular) messageLabel.textColor = NSColor.secondaryLabelColor messageLabel.backgroundColor = .clear @@ -290,9 +336,10 @@ public func _showNotification( messageLabel.maximumNumberOfLines = 2 textStack.addArrangedSubview(messageLabel) - contentStack.addArrangedSubview(textStack) + return textStack + } - // Add close button + private func createCloseButton(effectView: NSVisualEffectView) -> CloseButton { let closeButton = CloseButton(frame: NSRect(x: 0, y: 0, width: 24, height: 24)) closeButton.translatesAutoresizingMaskIntoConstraints = false effectView.addSubview(closeButton) @@ -304,21 +351,25 @@ public func _showNotification( closeButton.heightAnchor.constraint(equalToConstant: 24), ]) - // Show close button on hover + return closeButton + } + + private func setupCloseButtonHover(effectView: NSVisualEffectView, closeButton: CloseButton) { + guard let clickableView = effectView.superview?.superview as? ClickableView else { return } + clickableView.onHover = { isHovering in NSAnimationContext.runAnimationGroup { context in context.duration = 0.15 closeButton.animator().alphaValue = isHovering ? 0.8 : 0 } } + } - clickableView.addSubview(container) - panel.contentView = clickableView - - // Store panel reference - sharedPanel = panel + private func showWithAnimation(panel: NSPanel, screen: NSScreen, timeoutSeconds: Double) { + let screenRect = screen.visibleFrame + let finalXPos = screenRect.maxX - Config.notificationWidth - Config.rightMargin + let finalYPos = screenRect.maxY - Config.notificationHeight - Config.topMargin - // Show panel panel.makeKeyAndOrderFront(nil) // Animate slide-in @@ -326,17 +377,55 @@ public func _showNotification( context.duration = 0.3 context.timingFunction = CAMediaTimingFunction(name: .easeOut) panel.animator().setFrame( - NSRect(x: finalXPos, y: finalYPos, width: notificationWidth, height: notificationHeight), + NSRect( + x: finalXPos, y: finalYPos, width: Config.notificationWidth, + height: Config.notificationHeight), display: true ) panel.animator().alphaValue = 1.0 }) { // Auto-dismiss after timeout DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) { - dismissNotification() + NotificationManager.shared.dismiss() } } } +} + +// MARK: - Global Dismiss Function (for backward compatibility) +func dismissNotification() { + if let panel = sharedPanel { + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.2 + context.timingFunction = CAMediaTimingFunction(name: .easeIn) + panel.animator().alphaValue = 0 + }) { + panel.close() + sharedPanel = nil + sharedUrl = nil + } + } +} + +// MARK: - C API Binding (Minimal) +@_cdecl("_show_notification") +public func _showNotification( + title: SRString, + message: SRString, + url: SRString, + hasUrl: Bool, + timeoutSeconds: Double +) -> Bool { + let titleStr = title.toString() + let messageStr = message.toString() + let urlStr = hasUrl ? url.toString() : nil + + NotificationManager.shared.show( + title: titleStr, + message: messageStr, + url: urlStr, + timeoutSeconds: timeoutSeconds + ) Thread.sleep(forTimeInterval: 0.1) return true From f918054977accd5246981656d0cb43bb7f0136eb Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Tue, 26 Aug 2025 16:34:55 -0700 Subject: [PATCH 6/6] wip --- .../swift-lib/src/lib.swift | 455 +++++++++++++++--- 1 file changed, 379 insertions(+), 76 deletions(-) diff --git a/crates/notification-macos/swift-lib/src/lib.swift b/crates/notification-macos/swift-lib/src/lib.swift index 7ce0b6954..8b7b51a10 100644 --- a/crates/notification-macos/swift-lib/src/lib.swift +++ b/crates/notification-macos/swift-lib/src/lib.swift @@ -2,45 +2,151 @@ import AVFoundation import Cocoa import SwiftRs -// MARK: - Global State Management -private var sharedPanel: NSPanel? -private var sharedUrl: String? +// MARK: - Notification Instance +class NotificationInstance { + let id = UUID() + let panel: NSPanel + let clickableView: ClickableView + let url: String? + private var dismissTimer: DispatchWorkItem? + + init(panel: NSPanel, clickableView: ClickableView, url: String?) { + self.panel = panel + self.clickableView = clickableView + self.url = url + } + + func startDismissTimer(timeoutSeconds: Double) { + dismissTimer?.cancel() + let timer = DispatchWorkItem { [weak self] in + self?.dismiss() + } + dismissTimer = timer + DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds, execute: timer) + } + + func dismiss() { + dismissTimer?.cancel() + dismissTimer = nil + + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.2 + context.timingFunction = CAMediaTimingFunction(name: .easeIn) + self.panel.animator().alphaValue = 0 + }) { + self.panel.close() + NotificationManager.shared.removeNotification(self) + } + } + + deinit { + dismissTimer?.cancel() + } +} // MARK: - Custom UI Components class ClickableView: NSView { var trackingArea: NSTrackingArea? var isHovering = false var onHover: ((Bool) -> Void)? + weak var notification: NotificationInstance? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setupView() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupView() + } + + private func setupView() { + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + } override func updateTrackingAreas() { super.updateTrackingAreas() - if let existingArea = trackingArea { - removeTrackingArea(existingArea) + // Remove ALL existing tracking areas to ensure clean state + for area in trackingAreas { + removeTrackingArea(area) } + trackingArea = nil + // Create new tracking area that covers the entire view + // Use .activeAlways for non-activating panels let options: NSTrackingArea.Options = [ .activeAlways, .mouseEnteredAndExited, + .mouseMoved, .inVisibleRect, + .enabledDuringMouseDrag, ] - trackingArea = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil) - if let area = trackingArea { - addTrackingArea(area) + let area = NSTrackingArea( + rect: bounds, + options: options, + owner: self, + userInfo: nil + ) + + addTrackingArea(area) + trackingArea = area + + // Immediately reconcile hover state in case the cursor is already inside + updateHoverStateFromCurrentMouseLocation() + } + + private func updateHoverStateFromCurrentMouseLocation() { + guard let win = window else { return } + let global = win.mouseLocationOutsideOfEventStream + let local = convert(global, from: nil) + let inside = bounds.contains(local) + + if inside != isHovering { + isHovering = inside + if inside && notification?.url != nil { + NSCursor.pointingHand.set() + } else { + NSCursor.arrow.set() + } + onHover?(inside) // call synchronously } } override func mouseEntered(with event: NSEvent) { + super.mouseEntered(with: event) isHovering = true - NSCursor.pointingHand.set() - onHover?(true) + if let url = notification?.url { + NSCursor.pointingHand.set() + } + onHover?(true) // call synchronously } override func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) isHovering = false NSCursor.arrow.set() - onHover?(false) + onHover?(false) // call synchronously + } + + // Add mouseMoved to help with tracking + override func mouseMoved(with event: NSEvent) { + super.mouseMoved(with: event) + let location = convert(event.locationInWindow, from: nil) + let isInside = bounds.contains(location) + + if isInside != isHovering { + isHovering = isInside + if isInside && notification?.url != nil { + NSCursor.pointingHand.set() + } else { + NSCursor.arrow.set() + } + onHover?(isInside) // call synchronously + } } override func mouseDown(with event: NSEvent) { @@ -51,15 +157,25 @@ class ClickableView: NSView { } // Open URL if provided - if let urlString = sharedUrl, let url = URL(string: urlString) { + if let urlString = notification?.url, let url = URL(string: urlString) { NSWorkspace.shared.open(url) } - NotificationManager.shared.dismiss() + notification?.dismiss() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window != nil { + updateTrackingAreas() + } } } class CloseButton: NSButton { + weak var notification: NotificationInstance? + var trackingArea: NSTrackingArea? + override init(frame frameRect: NSRect) { super.init(frame: frameRect) setup() @@ -72,21 +188,58 @@ class CloseButton: NSButton { private func setup() { wantsLayer = true - layer?.cornerRadius = 8 - layer?.backgroundColor = NSColor(white: 0.5, alpha: 0.3).cgColor + layer?.cornerRadius = 12 + layer?.backgroundColor = NSColor(white: 0.0, alpha: 0.4).cgColor isBordered = false - // Set styled title with color let attributes: [NSAttributedString.Key: Any] = [ - .font: NSFont.systemFont(ofSize: 12, weight: .medium), - .foregroundColor: NSColor(white: 0.9, alpha: 0.9), + .font: NSFont.systemFont(ofSize: 13, weight: .medium), + .foregroundColor: NSColor.white, ] attributedTitle = NSAttributedString(string: "✕", attributes: attributes) + + // Initially hidden alphaValue = 0 + isHidden = true + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + + if let existingArea = trackingArea { + removeTrackingArea(existingArea) + trackingArea = nil + } + + let area = NSTrackingArea( + rect: bounds, + options: [.activeAlways, .mouseEnteredAndExited, .inVisibleRect], + owner: self, + userInfo: nil + ) + addTrackingArea(area) + trackingArea = area } override func mouseDown(with event: NSEvent) { - NotificationManager.shared.dismiss() + // Add visual feedback + layer?.backgroundColor = NSColor(white: 0.0, alpha: 0.6).cgColor + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.layer?.backgroundColor = NSColor(white: 0.0, alpha: 0.4).cgColor + } + + notification?.dismiss() + } + + override func mouseEntered(with event: NSEvent) { + super.mouseEntered(with: event) + layer?.backgroundColor = NSColor(white: 0.0, alpha: 0.5).cgColor + } + + override func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) + layer?.backgroundColor = NSColor(white: 0.0, alpha: 0.4).cgColor } } @@ -95,6 +248,15 @@ class NotificationManager { static let shared = NotificationManager() private init() {} + // MARK: - State Management + private var activeNotifications: [UUID: NotificationInstance] = [:] + private let maxNotifications = 5 + private let notificationSpacing: CGFloat = 10 + + // Global mouse monitor to make hover work even when the app/panel is not key + private var globalMouseMonitor: Any? + private var hoverStates: [UUID: Bool] = [:] + // MARK: - Configuration Constants private struct Config { static let notificationWidth: CGFloat = 360 @@ -106,58 +268,135 @@ class NotificationManager { // MARK: - Public Methods func show(title: String, message: String, url: String?, timeoutSeconds: Double) { - sharedUrl = url - DispatchQueue.main.async { [weak self] in - self?.setupApplicationIfNeeded() - self?.createAndShowNotification( + guard let self else { return } + self.setupApplicationIfNeeded() + self.createAndShowNotification( title: title, message: message, - hasUrl: url != nil, + url: url, timeoutSeconds: timeoutSeconds ) } } func dismiss() { - dismissNotification() + // Dismiss the most recent notification + if let mostRecent = activeNotifications.values.max(by: { + $0.panel.frame.minY < $1.panel.frame.minY + }) { + mostRecent.dismiss() + } + } + + func dismissAll() { + activeNotifications.values.forEach { $0.dismiss() } + } + + func removeNotification(_ notification: NotificationInstance) { + activeNotifications.removeValue(forKey: notification.id) + hoverStates.removeValue(forKey: notification.id) + repositionNotifications() + stopGlobalMouseMonitorIfNeeded() } // MARK: - Private Methods private func setupApplicationIfNeeded() { let app = NSApplication.shared if app.delegate == nil { - app.setActivationPolicy(.regular) + app.setActivationPolicy(.accessory) // Better background behavior + } + } + + private func manageNotificationLimit() { + // Remove oldest notifications if we exceed the limit + while activeNotifications.count >= maxNotifications { + if let oldest = activeNotifications.values.min(by: { + $0.panel.frame.minY > $1.panel.frame.minY + }) { + oldest.dismiss() + } } } private func createAndShowNotification( - title: String, message: String, hasUrl: Bool, timeoutSeconds: Double + title: String, message: String, url: String?, timeoutSeconds: Double ) { guard let screen = NSScreen.main else { return } - let panel = createPanel(screen: screen) + manageNotificationLimit() + + let yPosition = calculateYPosition(screen: screen) + let panel = createPanel(screen: screen, yPosition: yPosition) let clickableView = createClickableView() let container = createContainer(clickableView: clickableView) let effectView = createEffectView(container: container) - setupContentStack(effectView: effectView, title: title, message: message, hasUrl: hasUrl) + let notification = NotificationInstance(panel: panel, clickableView: clickableView, url: url) + clickableView.notification = notification + + setupContentStack( + effectView: effectView, + title: title, + message: message, + hasUrl: url != nil, + notification: notification + ) clickableView.addSubview(container) panel.contentView = clickableView - sharedPanel = panel - showWithAnimation(panel: panel, screen: screen, timeoutSeconds: timeoutSeconds) + activeNotifications[notification.id] = notification + hoverStates[notification.id] = false + + showWithAnimation(notification: notification, screen: screen, timeoutSeconds: timeoutSeconds) + ensureGlobalMouseMonitor() + } + + private func calculateYPosition(screen: NSScreen) -> CGFloat { + let screenRect = screen.visibleFrame + let baseY = screenRect.maxY - Config.notificationHeight - Config.topMargin + + // Stack notifications vertically + let occupiedHeight = + activeNotifications.count * Int(Config.notificationHeight + notificationSpacing) + return baseY - CGFloat(occupiedHeight) + } + + private func repositionNotifications() { + guard let screen = NSScreen.main else { return } + + let sortedNotifications = activeNotifications.values.sorted { + $0.panel.frame.minY > $1.panel.frame.minY + } + + for (index, notification) in sortedNotifications.enumerated() { + let newY = + calculateYPosition(screen: screen) + CGFloat(index) + * (Config.notificationHeight + notificationSpacing) + let currentFrame = notification.panel.frame + let newFrame = NSRect( + x: currentFrame.minX, + y: newY, + width: currentFrame.width, + height: currentFrame.height + ) + + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.2 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + notification.panel.animator().setFrame(newFrame, display: true) + } + } } - private func createPanel(screen: NSScreen) -> NSPanel { + private func createPanel(screen: NSScreen, yPosition: CGFloat) -> NSPanel { let screenRect = screen.visibleFrame let startXPos = screenRect.maxX + Config.slideInOffset - let finalYPos = screenRect.maxY - Config.notificationHeight - Config.topMargin let panel = NSPanel( contentRect: NSRect( - x: startXPos, y: finalYPos, + x: startXPos, y: yPosition, width: Config.notificationWidth, height: Config.notificationHeight ), styleMask: [.borderless, .nonactivatingPanel], @@ -171,17 +410,27 @@ class NotificationManager { panel.isOpaque = false panel.backgroundColor = .clear panel.hasShadow = true - panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient, .ignoresCycle] + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle] panel.isMovableByWindowBackground = false panel.alphaValue = 0 + // Enable mouse events + panel.ignoresMouseEvents = false + panel.acceptsMouseMovedEvents = true + return panel } private func createClickableView() -> ClickableView { - return ClickableView( + let clickableView = ClickableView( frame: NSRect(x: 0, y: 0, width: Config.notificationWidth, height: Config.notificationHeight) ) + + // Ensure tracking areas are set up + clickableView.wantsLayer = true + clickableView.layer?.backgroundColor = NSColor.clear.cgColor + + return clickableView } private func createContainer(clickableView: ClickableView) -> NSView { @@ -189,8 +438,8 @@ class NotificationManager { container.wantsLayer = true container.layer?.cornerRadius = 11 container.layer?.masksToBounds = false + container.autoresizingMask = [.width, .height] - // Shadow for depth container.layer?.shadowColor = NSColor.black.cgColor container.layer?.shadowOpacity = 0.2 container.layer?.shadowOffset = CGSize(width: 0, height: 2) @@ -207,8 +456,8 @@ class NotificationManager { effectView.wantsLayer = true effectView.layer?.cornerRadius = 11 effectView.layer?.masksToBounds = true + effectView.autoresizingMask = [.width, .height] - // Add subtle border for definition let borderLayer = CALayer() borderLayer.frame = effectView.bounds borderLayer.cornerRadius = 11 @@ -221,18 +470,23 @@ class NotificationManager { } private func setupContentStack( - effectView: NSVisualEffectView, title: String, message: String, hasUrl: Bool + effectView: NSVisualEffectView, + title: String, + message: String, + hasUrl: Bool, + notification: NotificationInstance ) { let contentStack = createContentStack(effectView: effectView) let iconContainer = createIconContainer(hasUrl: hasUrl) let textStack = createTextStack(title: title, message: message) - let closeButton = createCloseButton(effectView: effectView) + let closeButton = createCloseButton(effectView: effectView, notification: notification) contentStack.addArrangedSubview(iconContainer) contentStack.addArrangedSubview(textStack) - setupCloseButtonHover(effectView: effectView, closeButton: closeButton) + // Setup hover functionality for close button - show/hide on notification hover + setupCloseButtonHover(clickableView: notification.clickableView, closeButton: closeButton) } private func createContentStack(effectView: NSVisualEffectView) -> NSStackView { @@ -259,7 +513,6 @@ class NotificationManager { iconContainer.widthAnchor.constraint(equalToConstant: 42).isActive = true iconContainer.heightAnchor.constraint(equalToConstant: 42).isActive = true - // Simple gradient background let gradientLayer = CAGradientLayer() gradientLayer.frame = CGRect(x: 0, y: 0, width: 42, height: 42) gradientLayer.cornerRadius = 10 @@ -271,7 +524,6 @@ class NotificationManager { gradientLayer.endPoint = CGPoint(x: 1, y: 1) iconContainer.layer?.addSublayer(gradientLayer) - // App icon from bundle let iconImageView = createAppIconView() iconContainer.addSubview(iconImageView) @@ -288,7 +540,6 @@ class NotificationManager { private func createAppIconView() -> NSImageView { let iconImageView = NSImageView() - // Get the app's main icon from the bundle if let appIcon = NSApp.applicationIconImage { iconImageView.image = appIcon } else { @@ -298,7 +549,6 @@ class NotificationManager { iconImageView.imageScaling = .scaleProportionallyUpOrDown iconImageView.translatesAutoresizingMaskIntoConstraints = false - // Add subtle shadow to the icon for better contrast iconImageView.wantsLayer = true iconImageView.layer?.shadowColor = NSColor.black.cgColor iconImageView.layer?.shadowOpacity = 0.3 @@ -314,7 +564,6 @@ class NotificationManager { textStack.alignment = .leading textStack.spacing = 2 - // Title let titleLabel = NSTextField(labelWithString: title) titleLabel.font = NSFont.systemFont(ofSize: 13, weight: .semibold) titleLabel.textColor = NSColor.labelColor @@ -325,7 +574,6 @@ class NotificationManager { titleLabel.maximumNumberOfLines = 1 textStack.addArrangedSubview(titleLabel) - // Message let messageLabel = NSTextField(labelWithString: message) messageLabel.font = NSFont.systemFont(ofSize: 12, weight: .regular) messageLabel.textColor = NSColor.secondaryLabelColor @@ -339,8 +587,11 @@ class NotificationManager { return textStack } - private func createCloseButton(effectView: NSVisualEffectView) -> CloseButton { + private func createCloseButton(effectView: NSVisualEffectView, notification: NotificationInstance) + -> CloseButton + { let closeButton = CloseButton(frame: NSRect(x: 0, y: 0, width: 24, height: 24)) + closeButton.notification = notification closeButton.translatesAutoresizingMaskIntoConstraints = false effectView.addSubview(closeButton) @@ -354,60 +605,112 @@ class NotificationManager { return closeButton } - private func setupCloseButtonHover(effectView: NSVisualEffectView, closeButton: CloseButton) { - guard let clickableView = effectView.superview?.superview as? ClickableView else { return } + private func setupCloseButtonHover(clickableView: ClickableView, closeButton: CloseButton) { + // Start hidden so it doesn't intercept events + closeButton.alphaValue = 0 + closeButton.isHidden = true clickableView.onHover = { isHovering in - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.15 - closeButton.animator().alphaValue = isHovering ? 0.8 : 0 + if isHovering { + closeButton.isHidden = false } + + NSAnimationContext.runAnimationGroup( + { context in + context.duration = 0.15 + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + closeButton.animator().alphaValue = isHovering ? 0.9 : 0 + }, + completionHandler: { + if !isHovering { + // After fade-out completes, hide to stop intercepting mouse events + closeButton.isHidden = true + } + }) } } - private func showWithAnimation(panel: NSPanel, screen: NSScreen, timeoutSeconds: Double) { + private func showWithAnimation( + notification: NotificationInstance, screen: NSScreen, timeoutSeconds: Double + ) { let screenRect = screen.visibleFrame let finalXPos = screenRect.maxX - Config.notificationWidth - Config.rightMargin - let finalYPos = screenRect.maxY - Config.notificationHeight - Config.topMargin + let currentFrame = notification.panel.frame - panel.makeKeyAndOrderFront(nil) + notification.panel.orderFront(nil) // Animate slide-in NSAnimationContext.runAnimationGroup({ context in context.duration = 0.3 context.timingFunction = CAMediaTimingFunction(name: .easeOut) - panel.animator().setFrame( + notification.panel.animator().setFrame( NSRect( - x: finalXPos, y: finalYPos, width: Config.notificationWidth, - height: Config.notificationHeight), + x: finalXPos, y: currentFrame.minY, + width: Config.notificationWidth, height: Config.notificationHeight), display: true ) - panel.animator().alphaValue = 1.0 + notification.panel.animator().alphaValue = 1.0 }) { - // Auto-dismiss after timeout - DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) { - NotificationManager.shared.dismiss() + // Ensure tracking areas are properly set up after animation + DispatchQueue.main.async { + notification.clickableView.updateTrackingAreas() + notification.clickableView.window?.invalidateCursorRects(for: notification.clickableView) + notification.clickableView.window?.resetCursorRects() + // Force an immediate hover check using the global mouse + self.updateHoverForAll(atScreenPoint: NSEvent.mouseLocation) } + + // Start auto-dismiss timer + notification.startDismissTimer(timeoutSeconds: timeoutSeconds) } } -} -// MARK: - Global Dismiss Function (for backward compatibility) -func dismissNotification() { - if let panel = sharedPanel { - NSAnimationContext.runAnimationGroup({ context in - context.duration = 0.2 - context.timingFunction = CAMediaTimingFunction(name: .easeIn) - panel.animator().alphaValue = 0 - }) { - panel.close() - sharedPanel = nil - sharedUrl = nil + // MARK: - Global mouse monitoring (robust hover even when app/panel is not key) + private func ensureGlobalMouseMonitor() { + guard globalMouseMonitor == nil else { return } + globalMouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: [ + .mouseMoved, .leftMouseDragged, .rightMouseDragged, + ]) { [weak self] _ in + guard let self else { return } + let pt = NSEvent.mouseLocation // screen coordinates + DispatchQueue.main.async { + self.updateHoverForAll(atScreenPoint: pt) + } + } + // Also a local monitor to handle when app is active (faster updates) + NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved, .leftMouseDragged, .rightMouseDragged]) + { [weak self] event in + if let self = self { + let pt = NSEvent.mouseLocation + self.updateHoverForAll(atScreenPoint: pt) + } + return event + } + } + + private func stopGlobalMouseMonitorIfNeeded() { + if activeNotifications.isEmpty { + if let monitor = globalMouseMonitor { + NSEvent.removeMonitor(monitor) + globalMouseMonitor = nil + } + } + } + + private func updateHoverForAll(atScreenPoint pt: NSPoint) { + for (id, notif) in activeNotifications { + let inside = notif.panel.frame.contains(pt) + let prev = hoverStates[id] ?? false + if inside != prev { + hoverStates[id] = inside + // Drive the same onHover used by tracking areas + notif.clickableView.onHover?(inside) + } } } } -// MARK: - C API Binding (Minimal) +// MARK: - C API Binding @_cdecl("_show_notification") public func _showNotification( title: SRString,