diff --git a/package-lock.json b/package-lock.json index 4bc9725..cc52d3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,6 @@ "@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-fs": "^2", "@tauri-apps/plugin-log": "^2", - "@tauri-apps/plugin-notification": "^2", "@tauri-apps/plugin-os": "^2", "@tauri-apps/plugin-process": "^2", "@tauri-apps/plugin-shell": "^2", @@ -2898,15 +2897,6 @@ "@tauri-apps/api": "^2.8.0" } }, - "node_modules/@tauri-apps/plugin-notification": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz", - "integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.8.0" - } - }, "node_modules/@tauri-apps/plugin-os": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-os/-/plugin-os-2.3.2.tgz", diff --git a/package.json b/package.json index 5a66be0..185425e 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,6 @@ "@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-fs": "^2", "@tauri-apps/plugin-log": "^2", - "@tauri-apps/plugin-notification": "^2", "@tauri-apps/plugin-os": "^2", "@tauri-apps/plugin-process": "^2", "@tauri-apps/plugin-shell": "^2", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0b57476..9ee88eb 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -79,12 +79,16 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" name = "aos-mail" version = "0.1.9" dependencies = [ + "block2 0.6.2", "cocoa", "core-foundation 0.10.1", "core-foundation-sys", "keyring", "log", "objc", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-user-notifications", "serde", "serde_json", "tauri", @@ -92,7 +96,6 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-log", - "tauri-plugin-notification", "tauri-plugin-os", "tauri-plugin-process", "tauri-plugin-shell", @@ -119,137 +122,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" -[[package]] -name = "async-broadcast" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" -dependencies = [ - "event-listener", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-executor" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand", - "futures-lite", - "pin-project-lite", - "slab", -] - -[[package]] -name = "async-io" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" -dependencies = [ - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-io", - "futures-lite", - "parking", - "polling", - "rustix", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-lock" -version = "3.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" -dependencies = [ - "event-listener", - "event-listener-strategy", - "pin-project-lite", -] - -[[package]] -name = "async-process" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" -dependencies = [ - "async-channel", - "async-io", - "async-lock", - "async-signal", - "async-task", - "blocking", - "cfg-if", - "event-listener", - "futures-lite", - "rustix", -] - -[[package]] -name = "async-recursion" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "async-signal" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" -dependencies = [ - "async-io", - "async-lock", - "atomic-waker", - "cfg-if", - "futures-core", - "futures-io", - "rustix", - "signal-hook-registry", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "atk" version = "0.18.2" @@ -372,19 +244,6 @@ dependencies = [ "objc2 0.6.4", ] -[[package]] -name = "blocking" -version = "1.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" -dependencies = [ - "async-channel", - "async-task", - "futures-io", - "futures-lite", - "piper", -] - [[package]] name = "borsh" version = "1.6.1" @@ -658,15 +517,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "cookie" version = "0.18.1" @@ -1081,33 +931,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "endi" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" - -[[package]] -name = "enumflags2" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" -dependencies = [ - "enumflags2_derive", - "serde", -] - -[[package]] -name = "enumflags2_derive" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "env_filter" version = "0.1.4" @@ -1145,27 +968,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "event-listener" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener", - "pin-project-lite", -] - [[package]] name = "fastrand" version = "2.4.1" @@ -1319,19 +1121,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" -[[package]] -name = "futures-lite" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" -dependencies = [ - "fastrand", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] - [[package]] name = "futures-macro" version = "0.3.32" @@ -1710,12 +1499,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - [[package]] name = "hex" version = "0.4.3" @@ -2305,18 +2088,6 @@ dependencies = [ "value-bag", ] -[[package]] -name = "mac-notification-sys" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3" -dependencies = [ - "cc", - "objc2 0.6.4", - "objc2-foundation 0.3.2", - "time", -] - [[package]] name = "malloc_buf" version = "0.0.6" @@ -2448,20 +2219,6 @@ dependencies = [ "libc", ] -[[package]] -name = "notify-rust" -version = "4.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50ff2e74231b72c832d82982193b417f230945be6bdb5575b251d941d31adb00" -dependencies = [ - "futures-lite", - "log", - "mac-notification-sys", - "serde", - "tauri-winrt-notification", - "zbus", -] - [[package]] name = "num-conv" version = "0.2.1" @@ -2800,7 +2557,10 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" dependencies = [ + "bitflags 2.11.1", + "block2 0.6.2", "objc2 0.6.4", + "objc2-core-location", "objc2-foundation 0.3.2", ] @@ -2848,16 +2608,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "ordered-stream" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" -dependencies = [ - "futures-core", - "pin-project-lite", -] - [[package]] name = "os_info" version = "3.14.0" @@ -2923,12 +2673,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - [[package]] name = "parking_lot" version = "0.12.5" @@ -3023,17 +2767,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "piper" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" -dependencies = [ - "atomic-waker", - "fastrand", - "futures-io", -] - [[package]] name = "pkg-config" version = "0.3.33" @@ -3054,7 +2787,7 @@ checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml 0.39.3", + "quick-xml", "serde", "time", ] @@ -3085,20 +2818,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix", - "windows-sys 0.61.2", -] - [[package]] name = "potential_utf" version = "0.1.5" @@ -3221,15 +2940,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "quick-xml" -version = "0.37.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" -dependencies = [ - "memchr", -] - [[package]] name = "quick-xml" version = "0.39.3" @@ -3273,18 +2983,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", + "rand_chacha", + "rand_core", ] [[package]] @@ -3294,17 +2994,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", + "rand_core", ] [[package]] @@ -3316,15 +3006,6 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3534,7 +3215,7 @@ dependencies = [ "borsh", "bytes", "num-traits", - "rand 0.8.6", + "rand", "rkyv", "serde", "serde_json", @@ -4492,25 +4173,6 @@ dependencies = [ "time", ] -[[package]] -name = "tauri-plugin-notification" -version = "2.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" -dependencies = [ - "log", - "notify-rust", - "rand 0.9.4", - "serde", - "serde_json", - "serde_repr", - "tauri", - "tauri-plugin", - "thiserror 2.0.18", - "time", - "url", -] - [[package]] name = "tauri-plugin-os" version = "2.3.2" @@ -4724,18 +4386,6 @@ dependencies = [ "toml 1.1.2+spec-1.1.0", ] -[[package]] -name = "tauri-winrt-notification" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" -dependencies = [ - "quick-xml 0.37.5", - "thiserror 2.0.18", - "windows", - "windows-version", -] - [[package]] name = "tempfile" version = "3.27.0" @@ -5144,17 +4794,6 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" -[[package]] -name = "uds_windows" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" -dependencies = [ - "memoffset", - "tempfile", - "windows-sys 0.61.2", -] - [[package]] name = "unic-char-property" version = "0.9.0" @@ -6258,67 +5897,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zbus" -version = "5.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1" -dependencies = [ - "async-broadcast", - "async-executor", - "async-io", - "async-lock", - "async-process", - "async-recursion", - "async-task", - "async-trait", - "blocking", - "enumflags2", - "event-listener", - "futures-core", - "futures-lite", - "hex", - "libc", - "ordered-stream", - "rustix", - "serde", - "serde_repr", - "tracing", - "uds_windows", - "uuid", - "windows-sys 0.61.2", - "winnow 1.0.2", - "zbus_macros", - "zbus_names", - "zvariant", -] - -[[package]] -name = "zbus_macros" -version = "5.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", - "zbus_names", - "zvariant", - "zvariant_utils", -] - -[[package]] -name = "zbus_names" -version = "4.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" -dependencies = [ - "serde", - "winnow 1.0.2", - "zvariant", -] - [[package]] name = "zerocopy" version = "0.8.48" @@ -6416,43 +5994,3 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" - -[[package]] -name = "zvariant" -version = "5.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee" -dependencies = [ - "endi", - "enumflags2", - "serde", - "winnow 1.0.2", - "zvariant_derive", - "zvariant_utils", -] - -[[package]] -name = "zvariant_derive" -version = "5.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", - "zvariant_utils", -] - -[[package]] -name = "zvariant_utils" -version = "3.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "syn 2.0.117", - "winnow 1.0.2", -] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ff24dbe..4b559d7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -17,7 +17,6 @@ tauri = { version = "2", features = ["macos-private-api", "tray-icon", "devtools tauri-plugin-shell = "2" tauri-plugin-fs = "2" tauri-plugin-dialog = "2" -tauri-plugin-notification = "2" tauri-plugin-updater = "2" tauri-plugin-store = "2" tauri-plugin-os = "2" @@ -43,6 +42,17 @@ cocoa = "0.26" objc = "0.2" core-foundation = "0.10" core-foundation-sys = "0.8" +# Used by native_notifications.rs to drive UNUserNotificationCenter directly, +# replacing tauri-plugin-notification on macOS. The plugin's underlying +# notify-rust → mac-notification-sys chain still uses NSUserNotificationCenter +# (deprecated since 10.14), which on Sequoia delivers to Notification Center +# but does not show banner alerts. UNUserNotificationCenter is the modern API +# every native Mac app uses, and the only one that produces banners on +# current macOS. +objc2 = "0.6" +objc2-foundation = { version = "0.3", features = ["NSString", "NSDictionary", "NSError", "NSDate", "NSArray", "NSObject"] } +objc2-user-notifications = { version = "0.3", features = ["UNUserNotificationCenter", "UNNotificationContent", "UNNotificationRequest", "UNNotificationTrigger", "UNNotificationResponse", "UNNotificationSettings"] } +block2 = "0.6" [profile.release] panic = "abort" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index bb0ee2d..9c64cdb 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -11,7 +11,6 @@ "shell:default", "fs:default", "dialog:default", - "notification:default", "os:default", "process:default", "store:default", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 30a40a4..d730fd3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -20,6 +20,9 @@ mod sidecar; #[cfg(target_os = "macos")] mod mac_polish; +#[cfg(target_os = "macos")] +mod native_notifications; + #[cfg(target_os = "macos")] fn apply_macos_vibrancy(window: &tauri::WebviewWindow) -> tauri::Result<()> { use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial, NSVisualEffectState}; @@ -126,6 +129,60 @@ fn get_pending_mailto( Ok(state.take()) } +/// Prompt the user for notification permission via UNUserNotificationCenter, +/// the modern macOS framework. We route around tauri-plugin-notification +/// because its underlying notify-rust uses the deprecated +/// NSUserNotificationCenter, which on Sequoia delivers to Notification +/// Center but doesn't show banners. Returns the resolved state — on every +/// call after the first one, macOS doesn't re-prompt and we get back the +/// cached decision. +#[tauri::command] +fn notify_request_permission() -> Result { + #[cfg(target_os = "macos")] + { + native_notifications::request_authorization() + .map(|s| serde_json::to_value(s).unwrap_or_default()) + .map(|v| v.as_str().unwrap_or("not_determined").to_string()) + } + #[cfg(not(target_os = "macos"))] + { + Ok("denied".to_string()) + } +} + +/// Query the live notification permission state without prompting. Used by +/// the renderer's permission probe at boot and by Settings to keep the +/// toggle's visible state in sync if the user changed the system setting +/// while the app was running. +#[tauri::command] +fn notify_permission_state() -> Result { + #[cfg(target_os = "macos")] + { + native_notifications::authorization_state() + .map(|s| serde_json::to_value(s).unwrap_or_default()) + .map(|v| v.as_str().unwrap_or("not_determined").to_string()) + } + #[cfg(not(target_os = "macos"))] + { + Ok("denied".to_string()) + } +} + +/// Deliver a notification with the given title + body. macOS handles all the +/// chrome (icon, sound, banner timing) based on per-app System Settings. +#[tauri::command] +fn notify_send(title: String, body: String) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + native_notifications::send(&title, &body) + } + #[cfg(not(target_os = "macos"))] + { + let _ = (title, body); + Ok(()) + } +} + /// In-memory mailto queue. Holds ONE pending parsed URL — that's all macOS /// needs at cold start; subsequent mailto opens come through the live event /// channel and are already routed to the renderer by the time they arrive. @@ -157,7 +214,6 @@ pub fn run() { .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_store::Builder::default().build()) @@ -197,6 +253,9 @@ pub fn run() { set_default_mail_app, is_default_mail_app, get_pending_mailto, + notify_request_permission, + notify_permission_state, + notify_send, keychain::keychain_set, keychain::keychain_get, keychain::keychain_delete, diff --git a/src-tauri/src/native_notifications.rs b/src-tauri/src/native_notifications.rs new file mode 100644 index 0000000..ca2acaf --- /dev/null +++ b/src-tauri/src/native_notifications.rs @@ -0,0 +1,181 @@ +// macOS-only: UNUserNotificationCenter wrapper that replaces +// tauri-plugin-notification on this platform. +// +// Why this exists: +// tauri-plugin-notification → notify-rust → mac-notification-sys all use +// NSUserNotificationCenter, which Apple deprecated in macOS 10.14. On +// Sequoia (and progressively on earlier modern macOS), notifications via +// that API still reach Notification Center but no longer pop as banners — +// Apple's slow-roll deprecation behavior. Every native Mac app users +// recognize (Mail, Slack, Things, Linear) is on UNUserNotificationCenter. +// The fix is to talk to UN ourselves; no upstream plugin migration is +// needed. +// +// What's wired up: +// - request_authorization() → request alert+sound+badge permission +// - authorization_state() → query current state without prompting +// - send(title, body) → deliver a notification immediately +// +// What's NOT wired up (deferred to a follow-up): +// - delegate for foreground willPresent / didReceive callbacks +// (background banners pop with no delegate; foreground notifications go +// to NC silently — that's the UN default and is fine for v1) +// - rich content, attachments, actions, threadIdentifier +// - per-notification click → thread routing (the renderer used to do this +// via plugin-notification's onAction; UN's didReceiveResponse is the +// equivalent and lands in v2) +// +// The dev-mode caveat: +// UNUserNotificationCenter requires the calling process to be a valid +// .app bundle. `tauri dev` wraps the binary into one, so this works, +// but the bundle identifier the OS resolves is derived from the dev +// build path — different from production. Notification settings the +// user grants to the dev build do not transfer to the installed app. + +#![cfg(target_os = "macos")] + +use std::ptr::NonNull; +use std::sync::mpsc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use block2::RcBlock; +use objc2::rc::Retained; +use objc2::runtime::Bool; +use objc2_foundation::{NSError, NSString}; +use objc2_user_notifications::{ + UNAuthorizationOptions, UNAuthorizationStatus, UNMutableNotificationContent, + UNNotificationRequest, UNNotificationSettings, UNUserNotificationCenter, +}; +use serde::Serialize; + +/// Plain-text shape we hand back across the JS bridge. +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthState { + NotDetermined, + Denied, + Authorized, + Provisional, + Ephemeral, +} + +impl From for AuthState { + fn from(s: UNAuthorizationStatus) -> Self { + match s { + UNAuthorizationStatus::NotDetermined => AuthState::NotDetermined, + UNAuthorizationStatus::Denied => AuthState::Denied, + UNAuthorizationStatus::Authorized => AuthState::Authorized, + UNAuthorizationStatus::Provisional => AuthState::Provisional, + UNAuthorizationStatus::Ephemeral => AuthState::Ephemeral, + _ => AuthState::NotDetermined, + } + } +} + +/// Prompt the user (once, then no-op on subsequent calls — macOS caches +/// the decision). Returns the resulting state. +/// +/// Blocks the calling thread on a mpsc channel until UN's completion +/// handler fires. Tauri runs each sync command on a worker thread, so +/// this doesn't stall the tokio runtime or the main thread. +pub fn request_authorization() -> Result { + let center = UNUserNotificationCenter::currentNotificationCenter(); + let options = + UNAuthorizationOptions::Alert | UNAuthorizationOptions::Sound | UNAuthorizationOptions::Badge; + + let (tx, rx) = mpsc::channel::>(); + let tx_clone = tx.clone(); + let handler = RcBlock::new(move |_granted: Bool, error: *mut NSError| { + let result = if error.is_null() { + Ok(()) + } else { + let err = unsafe { Retained::retain(error) }; + let msg = err + .map(|e| e.localizedDescription().to_string()) + .unwrap_or_else(|| "unknown UN error".to_string()); + Err(msg) + }; + let _ = tx_clone.send(result); + }); + + center.requestAuthorizationWithOptions_completionHandler(options, &handler); + drop(tx); + + rx.recv() + .map_err(|e| format!("UN auth completion never fired: {e}"))??; + + // Authorization granted/denied is reflected in the live settings; query + // those to avoid lying about Provisional vs Authorized. + authorization_state() +} + +/// Query state without prompting. +pub fn authorization_state() -> Result { + let center = UNUserNotificationCenter::currentNotificationCenter(); + + let (tx, rx) = mpsc::channel::(); + let tx_clone = tx.clone(); + let handler = RcBlock::new(move |settings: NonNull| { + // UN guarantees non-null on this callback; the `NonNull` wrapper in + // the generated bindings is what enforces it. + let state = unsafe { settings.as_ref().authorizationStatus() }.into(); + let _ = tx_clone.send(state); + }); + + center.getNotificationSettingsWithCompletionHandler(&handler); + drop(tx); + + rx.recv() + .map_err(|e| format!("UN settings completion never fired: {e}")) +} + +/// Deliver a notification immediately. No trigger → UN fires it as soon as +/// the request is accepted. Banners pop when the app is backgrounded. +/// Foreground delivery goes to Notification Center silently until we wire +/// up a willPresent delegate (deferred). +pub fn send(title: &str, body: &str) -> Result<(), String> { + let center = UNUserNotificationCenter::currentNotificationCenter(); + + let content = UNMutableNotificationContent::new(); + let title_ns = NSString::from_str(title); + let body_ns = NSString::from_str(body); + content.setTitle(&title_ns); + content.setBody(&body_ns); + + // UN requires a unique non-empty identifier per request. Same ID + // replaces a delivered notification, which is fine — we want fresh + // notifications, not deduped ones, so the timestamp + nanos suffices. + let id_str = format!("aos-mail-{}", monotonic_nanos()); + let id_ns = NSString::from_str(&id_str); + + let request = + UNNotificationRequest::requestWithIdentifier_content_trigger(&id_ns, &content, None); + + let (tx, rx) = mpsc::channel::>(); + let tx_clone = tx.clone(); + let handler = RcBlock::new(move |error: *mut NSError| { + let result = if error.is_null() { + Ok(()) + } else { + let err = unsafe { Retained::retain(error) }; + let msg = err + .map(|e| e.localizedDescription().to_string()) + .unwrap_or_else(|| "unknown UN error".to_string()); + Err(msg) + }; + let _ = tx_clone.send(result); + }); + + center.addNotificationRequest_withCompletionHandler(&request, Some(&handler)); + drop(tx); + + rx.recv() + .map_err(|e| format!("UN add completion never fired: {e}"))? +} + +fn monotonic_nanos() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) +} diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index c6e14e0..ec3e58c 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -463,20 +463,20 @@ export function SettingsPanel({ onClose, initialTab }: SettingsPanelProps) { setNotificationsEnabled(enabled); await window.api.settings.set({ notificationsEnabled: enabled }); // Toggling ON should re-prompt for OS permission if it isn't granted yet — - // otherwise the toggle says "on" but nothing fires. tauri-plugin-notification - // only re-prompts when the cached state is undecided; in the unsigned dev - // build the prompt may also be no-opped by macOS Gatekeeper, which is why - // the Test button (which sends an actual notification) is the user's - // best fallback for verifying the wiring end-to-end. + // otherwise the toggle says "on" but nothing fires. macOS only shows the + // permission prompt once per app lifetime; after that requestPermission + // returns the cached state without showing UI. The Test button (which + // sends an actual notification) is the user's best fallback for + // verifying the wiring end-to-end. if (!enabled) return; const isTauri = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; if (!isTauri) return; try { - const { isPermissionGranted, requestPermission } = - await import("@tauri-apps/plugin-notification"); - const granted = await isPermissionGranted(); - if (!granted) { - await requestPermission(); + const { getNotificationPermission, requestNotificationPermission } = + await import("../services/notifications"); + const live = await getNotificationPermission(); + if (live !== "authorized" && live !== "provisional") { + await requestNotificationPermission(); } } catch (err) { console.warn("[notifications] permission re-probe failed:", err); diff --git a/src/renderer/components/SetupWizard.tsx b/src/renderer/components/SetupWizard.tsx index 408fb1f..77681bc 100644 --- a/src/renderer/components/SetupWizard.tsx +++ b/src/renderer/components/SetupWizard.tsx @@ -638,20 +638,21 @@ export function SetupWizard({ onComplete, initialStep }: SetupWizardProps) { onClick={async () => { setNotificationPermission("requesting"); try { - const { isPermissionGranted, requestPermission } = - await import("@tauri-apps/plugin-notification"); - const already = await isPermissionGranted(); - if (already) { + const { getNotificationPermission, requestNotificationPermission } = + await import("../services/notifications"); + const live = await getNotificationPermission(); + if (live === "authorized" || live === "provisional") { setNotificationPermission("granted"); // Persist the toggle so the rest of the app respects it. await window.api.settings.set({ notificationsEnabled: true }); return; } - const result = await requestPermission(); - // macOS returns "granted" / "denied" / "default". "default" = - // the user dismissed the prompt without deciding; treat as - // not-yet-decided so the button stays available. - if (result === "granted") { + const result = await requestNotificationPermission(); + // UN returns "authorized" / "denied" / "provisional" / + // "ephemeral" / "not_determined". The first two + // count as success; "not_determined" means the user + // dismissed the prompt — keep the button enabled. + if (result === "authorized" || result === "provisional") { setNotificationPermission("granted"); await window.api.settings.set({ notificationsEnabled: true }); } else if (result === "denied") { diff --git a/src/renderer/services/notifications.ts b/src/renderer/services/notifications.ts index 48bad4a..dd0ce1d 100644 --- a/src/renderer/services/notifications.ts +++ b/src/renderer/services/notifications.ts @@ -1,34 +1,30 @@ // Native macOS notifications for new mail. // -// Wraps `@tauri-apps/plugin-notification` with the project-specific behaviors: -// - permission gating (request once, remember the answer) -// - settings gating (`notificationsEnabled` honored on every fire) -// - coalescing: 5+ emails in one batch collapse into a single summary -// - click routing: tapping the notification focuses the window AND (when -// the user clicks within ~10 seconds) selects the corresponding thread. +// Backed by our own Tauri commands (notify_*) which talk to +// UNUserNotificationCenter directly. We replaced `@tauri-apps/plugin- +// notification` because its underlying notify-rust → mac-notification-sys +// chain still uses NSUserNotificationCenter (deprecated since macOS 10.14), +// which on Sequoia delivers to Notification Center but doesn't pop banners. // -// Click-routing on macOS is best-effort. The Tauri notification plugin uses -// `notify_rust` under the hood, which doesn't expose a per-notification -// click event without registering custom action types up front. We register -// a single "open" action type at startup so clicks deliver via the -// `onAction` event channel; for notifications fired before the action types -// land we fall back to relying on macOS's native foregrounding behavior -// (clicking the notification activates the app, which brings the window -// forward via our menu/dock state). - -import { - isPermissionGranted, - requestPermission, - sendNotification, - onAction, - registerActionTypes, -} from "@tauri-apps/plugin-notification"; +// Same public surface as before: +// - initNotifications() — one-time permission probe at boot +// - notifyNewEmails(emails) — fire (or coalesce) notifications for a batch +// - testNotification() — Settings panel "fire a sample" +// +// What's deliberately simpler than the previous plugin-based version: +// - No actionTypeId / no per-notification routing. Tap on a notification +// brings the app forward (macOS handles that without a delegate). We +// don't yet route to a specific thread on click — the Rust side +// would need a UNUserNotificationCenterDelegate that emits Tauri +// events; that's a follow-up. +// - No `onAction` handler. The user just sees the notification, clicks +// it, app comes forward. + import bridge from "../lib/bridge"; -import { focusMainWindow } from "../lib/mac-polish"; import { useAppStore } from "../store"; import type { DashboardEmail } from "../../shared/types"; -const NEW_MAIL_ACTION_TYPE = "aos-mail.new-mail"; +type AuthState = "not_determined" | "denied" | "authorized" | "provisional" | "ephemeral"; let initialized = false; let permissionGranted: boolean | null = null; @@ -51,10 +47,20 @@ function senderName(from: string): string { return from.replace(/[<>]/g, "").trim() || "(no sender)"; } +async function invokeCmd(cmd: string, args?: Record): Promise { + if (!bridge.isTauri) return null; + const { invoke: tauriInvoke } = await import("@tauri-apps/api/core"); + return (await tauriInvoke(cmd, args)) as T; +} + +/** "authorized" and "provisional" both let notifications fire; the rest do not. */ +function stateAllowsDelivery(state: AuthState | null): boolean { + return state === "authorized" || state === "provisional"; +} + /** - * One-time setup: probe (and request) notification permission, register the - * click handler. Idempotent — safe to call from React effects that may fire - * twice in StrictMode. + * One-time setup: probe (and request) notification permission. Idempotent — + * safe to call from React effects that may fire twice in StrictMode. */ export async function initNotifications(): Promise { if (initialized) return; @@ -66,47 +72,22 @@ export async function initNotifications(): Promise { } try { - permissionGranted = await isPermissionGranted(); - if (!permissionGranted) { - const requested = await requestPermission(); - permissionGranted = requested === "granted"; + const live = await invokeCmd("notify_permission_state"); + if (stateAllowsDelivery(live)) { + permissionGranted = true; + return; } + if (live === "denied") { + permissionGranted = false; + return; + } + // not_determined — prompt the user. + const requested = await invokeCmd("notify_request_permission"); + permissionGranted = stateAllowsDelivery(requested); } catch (err) { console.warn("[notifications] permission probe failed:", err); permissionGranted = false; } - - // Register a single "Open" action so notifications deliver clicks through - // `onAction`. Without this, plain notifications on macOS fire only the - // implicit close — we wouldn't know which thread to route to. - try { - await registerActionTypes([ - { - id: NEW_MAIL_ACTION_TYPE, - actions: [{ id: "open", title: "Open" }], - }, - ]); - } catch (err) { - // Non-fatal: clicking the notification will still bring the app forward - // via macOS's native foregrounding; the user just won't auto-jump to the - // specific thread. - console.warn("[notifications] action-type registration failed:", err); - } - - // One global click handler. Tauri delivers `extra` back unchanged from the - // sendNotification call site — we use that to route to the right thread. - try { - await onAction((evt) => { - const extra = (evt.extra ?? {}) as Record; - const emailId = typeof extra.emailId === "string" ? extra.emailId : null; - void focusMainWindow(); - if (emailId) { - useAppStore.getState().setSelectedEmailId(emailId); - } - }); - } catch (err) { - console.warn("[notifications] click handler attach failed:", err); - } } /** Reads the current toggle from the persisted settings via the store. */ @@ -118,8 +99,7 @@ function notificationsEnabled(): boolean { /** * Surface a notification (or coalesced summary) for a batch of new emails. * Skips silently when permission is missing or the user has disabled - * notifications in Settings. Filters out sent-only items (the user already - * sent those; nothing to be notified about). + * notifications in Settings. Filters out sent-only items. */ export async function notifyNewEmails(emails: DashboardEmail[]): Promise { if (!bridge.isTauri) return; @@ -132,13 +112,10 @@ export async function notifyNewEmails(emails: DashboardEmail[]): Promise { if (incoming.length === 0) return; if (incoming.length >= COALESCE_THRESHOLD) { - // One summary instead of N popups; tapping it focuses the window without - // a thread selection (no single thread to route to). - sendNotification({ + // One summary instead of N popups. + await invokeCmd("notify_send", { title: `${incoming.length} new messages`, body: "Open AOS Mail to triage your inbox.", - actionTypeId: NEW_MAIL_ACTION_TYPE, - extra: { kind: "summary" }, }); return; } @@ -147,16 +124,9 @@ export async function notifyNewEmails(emails: DashboardEmail[]): Promise { const subject = email.subject || "(no subject)"; const snippet = email.snippet ?? ""; const body = snippet ? `${subject} — ${snippet}` : subject; - sendNotification({ + await invokeCmd("notify_send", { title: senderName(email.from), body: truncate(body, 80), - actionTypeId: NEW_MAIL_ACTION_TYPE, - extra: { - kind: "email", - emailId: email.id, - threadId: email.threadId, - accountId: email.accountId ?? "", - }, }); } } @@ -165,17 +135,17 @@ export async function notifyNewEmails(emails: DashboardEmail[]): Promise { * Fire a sample notification so the user can verify their permission/toggle * wiring without waiting for real mail. * - * Returns a structured outcome so the caller can surface a useful error in - * the Settings panel — three failure modes are distinct: - * - "browser": not running under Tauri (e.g. Storybook). Nothing to do. - * - "denied": macOS denied permission and there's no path to re-prompt - * from JS. Caller can deep-link to System Settings. - * - "disabled": user has the toggle turned off; we honor that and don't - * fire a "test" notification either. + * Returns a structured outcome so the Settings panel can surface a useful + * error — three failure modes are distinct: + * - "browser": not running under Tauri (e.g. Storybook). Nothing to do. + * - "denied": macOS denied permission and there's no path to re-prompt + * from JS. Caller can deep-link to System Settings. + * - "disabled": user has the toggle turned off; we honor that and don't + * fire a "test" notification either. * - * On the happy path we re-request permission first if the cached state is - * stale — handles the case where the user denied at boot, then granted - * permission via System Settings without restarting the app. + * On the happy path we re-probe permission first — handles the case where + * the user denied at boot, then granted permission via System Settings + * without restarting the app. */ export async function testNotification(): Promise< { ok: true } | { ok: false; reason: "browser" | "denied" | "disabled" } @@ -183,15 +153,17 @@ export async function testNotification(): Promise< if (!bridge.isTauri) return { ok: false, reason: "browser" }; if (!notificationsEnabled()) return { ok: false, reason: "disabled" }; - // Re-probe permission. The cached permissionGranted may be stale if the + // Re-probe permission. The cached `permissionGranted` may be stale if the // user changed System Settings since the last initNotifications call. try { - const live = await isPermissionGranted(); - if (live) { + const live = await invokeCmd("notify_permission_state"); + if (stateAllowsDelivery(live)) { permissionGranted = true; + } else if (live === "not_determined") { + const requested = await invokeCmd("notify_request_permission"); + permissionGranted = stateAllowsDelivery(requested); } else { - const requested = await requestPermission(); - permissionGranted = requested === "granted"; + permissionGranted = false; } } catch (err) { console.warn("[notifications] permission probe failed:", err); @@ -199,11 +171,35 @@ export async function testNotification(): Promise< } if (!permissionGranted) return { ok: false, reason: "denied" }; - sendNotification({ + await invokeCmd("notify_send", { title: "AOS Mail", body: "This is what new mail will look like.", - actionTypeId: NEW_MAIL_ACTION_TYPE, - extra: { kind: "test" }, }); return { ok: true }; } + +/** + * Public state probe — used by SetupWizard to display the permission badge + * and decide whether to render the "Allow notifications" button. + */ +export async function getNotificationPermission(): Promise { + if (!bridge.isTauri) return "denied"; + const live = await invokeCmd("notify_permission_state"); + return live ?? "not_determined"; +} + +/** + * Trigger the OS permission prompt. Idempotent on macOS — after the first + * grant/deny, the OS just returns the cached decision instead of prompting + * again. Returns the resolved state. + */ +export async function requestNotificationPermission(): Promise { + if (!bridge.isTauri) return "denied"; + const requested = await invokeCmd("notify_request_permission"); + if (requested && stateAllowsDelivery(requested)) { + permissionGranted = true; + } else if (requested === "denied") { + permissionGranted = false; + } + return requested ?? "not_determined"; +}