From fa5fda24d01a8f9c0478456677219029c9f3c829 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sun, 3 May 2026 16:45:32 +0800 Subject: [PATCH] feat: notify users when desktop attention is needed Add native system notifications as a desktop-wide attention channel for permission prompts and scheduled task completion. The implementation keeps notification presentation owned by the OS, adds a user-facing enable switch with permission handling, and lets scheduled tasks choose desktop notifications without routing that channel through IM adapters. Constraint: Notifications must use OS-native APIs without custom sound playback. Constraint: Desktop channel is local-only and must not be sent through IM adapter delivery. Rejected: Browser Notification API | not reliable inside the packaged Tauri desktop runtime. Rejected: Treat desktop as an IM channel | would leak a local-only channel into server-side adapter sending. Confidence: high Scope-risk: moderate Directive: Keep notification styling at the OS layer; business code should only provide title, body, dedupe, and routing decisions. Tested: bun run check:desktop Tested: bun run quality:pr Tested: Computer Use macOS debug app verification for settings toggle, permission prompt, scheduled task desktop channel, and task-run polling dedupe Not-tested: Windows and Linux native runtime smoke tests on physical hosts --- desktop/bun.lock | 3 + desktop/package.json | 1 + desktop/src-tauri/Cargo.lock | 483 +++++++++++++++++- desktop/src-tauri/Cargo.toml | 1 + desktop/src-tauri/capabilities/default.json | 1 + desktop/src-tauri/src/lib.rs | 1 + desktop/src/App.tsx | 2 + .../src/__tests__/generalSettings.test.tsx | 62 ++- desktop/src/components/tasks/NewTaskModal.tsx | 31 +- ...ScheduledTaskDesktopNotifications.test.tsx | 129 +++++ .../useScheduledTaskDesktopNotifications.ts | 117 +++++ desktop/src/i18n/locales/en.ts | 16 +- desktop/src/i18n/locales/zh.ts | 16 +- desktop/src/lib/desktopNotifications.test.ts | 128 +++++ desktop/src/lib/desktopNotifications.ts | 154 ++++++ desktop/src/pages/Settings.tsx | 106 ++++ desktop/src/stores/chatStore.test.ts | 19 + desktop/src/stores/chatStore.ts | 15 + desktop/src/stores/settingsStore.test.ts | 57 +++ desktop/src/stores/settingsStore.ts | 26 + desktop/src/types/settings.ts | 1 + desktop/src/types/task.ts | 2 +- src/server/services/cronService.ts | 2 +- src/server/services/notificationService.ts | 7 +- 24 files changed, 1367 insertions(+), 13 deletions(-) create mode 100644 desktop/src/hooks/useScheduledTaskDesktopNotifications.test.tsx create mode 100644 desktop/src/hooks/useScheduledTaskDesktopNotifications.ts create mode 100644 desktop/src/lib/desktopNotifications.test.ts create mode 100644 desktop/src/lib/desktopNotifications.ts diff --git a/desktop/bun.lock b/desktop/bun.lock index 13c4092d..07d09b95 100644 --- a/desktop/bun.lock +++ b/desktop/bun.lock @@ -39,6 +39,7 @@ "@tauri-apps/api": "^2.3.0", "@tauri-apps/cli": "^2.3.1", "@tauri-apps/plugin-dialog": "^2.2.1", + "@tauri-apps/plugin-notification": "^2.2.1", "@tauri-apps/plugin-process": "^2.2.1", "@tauri-apps/plugin-shell": "^2.2.1", "@tauri-apps/plugin-updater": "^2.2.1", @@ -334,6 +335,8 @@ "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.0", "https://registry.npmmirror.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.0.tgz", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw=="], + "@tauri-apps/plugin-notification": ["@tauri-apps/plugin-notification@2.3.3", "https://registry.npmmirror.com/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg=="], + "@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.1", "https://registry.npmmirror.com/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="], "@tauri-apps/plugin-shell": ["@tauri-apps/plugin-shell@2.3.5", "https://registry.npmmirror.com/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg=="], diff --git a/desktop/package.json b/desktop/package.json index 95e9c75a..7680df94 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -50,6 +50,7 @@ "@tauri-apps/api": "^2.3.0", "@tauri-apps/cli": "^2.3.1", "@tauri-apps/plugin-dialog": "^2.2.1", + "@tauri-apps/plugin-notification": "^2.2.1", "@tauri-apps/plugin-process": "^2.2.1", "@tauri-apps/plugin-shell": "^2.2.1", "@tauri-apps/plugin-updater": "^2.2.1" diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 365ad6f4..9efbfb10 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -56,6 +56,137 @@ dependencies = [ "derive_arbitrary", ] +[[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" @@ -151,6 +282,19 @@ dependencies = [ "objc2", ] +[[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 = "brotli" version = "8.0.2" @@ -338,6 +482,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-dialog", + "tauri-plugin-notification", "tauri-plugin-process", "tauri-plugin-shell", "tauri-plugin-updater", @@ -355,6 +500,15 @@ 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 = "convert_case" version = "0.4.0" @@ -777,6 +931,33 @@ 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 = "equivalent" version = "1.0.2" @@ -804,6 +985,27 @@ 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" @@ -963,6 +1165,19 @@ 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" @@ -1348,6 +1563,12 @@ 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" @@ -1946,6 +2167,18 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mac-notification-sys" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3" +dependencies = [ + "cc", + "objc2", + "objc2-foundation", + "time", +] + [[package]] name = "markup5ever" version = "0.14.1" @@ -2111,6 +2344,20 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "notify-rust" +version = "4.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bdaf6120b9df005d37e58f6b75329be6255450453fbeba9ce4192324f921fb9" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -2314,6 +2561,16 @@ 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_pipe" version = "1.2.3" @@ -2363,6 +2620,12 @@ 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" @@ -2591,6 +2854,17 @@ 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" @@ -2611,7 +2885,7 @@ checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml", + "quick-xml 0.39.2", "serde", "time", ] @@ -2629,6 +2903,20 @@ 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 = "portable-pty" version = "0.9.0" @@ -2758,6 +3046,15 @@ dependencies = [ "unicode-ident", ] +[[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.2" @@ -2813,6 +3110,16 @@ dependencies = [ "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", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -2833,6 +3140,16 @@ dependencies = [ "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", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -2851,6 +3168,15 @@ 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 = "rand_hc" version = "0.2.0" @@ -4010,6 +4336,25 @@ dependencies = [ "url", ] +[[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-process" version = "2.3.1" @@ -4176,6 +4521,18 @@ 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" @@ -4500,9 +4857,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -4552,6 +4921,17 @@ 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" @@ -5635,6 +6015,67 @@ 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" @@ -5732,3 +6173,43 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db0ecb8987cf5e92653c57c098f7f0e39a03112edb796f4fe089fb7eaa14ff" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.2", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b949b639ab1b4bed763aa7481ba0e368af68d8b55532f8ed4bec86a59f2ca98" +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/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 85fcb995..8e624e53 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -22,3 +22,4 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1.0.102" portable-pty = "0.9.0" +tauri-plugin-notification = "2" diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index b532e822..74d0a118 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -99,6 +99,7 @@ }, "dialog:allow-open", "dialog:allow-save", + "notification:default", "process:allow-exit", "process:allow-restart", "updater:default" diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 5b0312ce..96d3af24 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1223,6 +1223,7 @@ pub fn run() { .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_process::init()) + .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .invoke_handler(tauri::generate_handler![ get_server_url, diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 6cb2e391..6fb6fdfd 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -1,5 +1,7 @@ import { AppShell } from './components/layout/AppShell' +import { useScheduledTaskDesktopNotifications } from './hooks/useScheduledTaskDesktopNotifications' export function App() { + useScheduledTaskDesktopNotifications() return } diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index e7918317..526de846 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { fireEvent, render, screen, within } from '@testing-library/react' +import { act, fireEvent, render, screen, within } from '@testing-library/react' import '@testing-library/jest-dom' import { Settings } from '../pages/Settings' @@ -12,6 +12,11 @@ import type { ProviderPreset } from '../types/providerPreset' const MOCK_DELETE_PROVIDER = vi.fn() const MOCK_GET_SETTINGS = vi.fn() const MOCK_UPDATE_SETTINGS = vi.fn() +const desktopNotificationsMock = vi.hoisted(() => ({ + getDesktopNotificationPermission: vi.fn(), + requestDesktopNotificationPermission: vi.fn(), + openDesktopNotificationSettings: vi.fn(), +})) const providerStoreState = { providers: [] as SavedProvider[], activeId: null as string | null, @@ -47,6 +52,8 @@ vi.mock('../api/providers', () => ({ }, })) +vi.mock('../lib/desktopNotifications', () => desktopNotificationsMock) + vi.mock('../components/settings/ClaudeOfficialLogin', () => ({ ClaudeOfficialLogin: () =>
, })) @@ -87,6 +94,12 @@ vi.mock('../components/chat/CodeViewer', () => ({ describe('Settings > General tab', () => { beforeEach(() => { MOCK_DELETE_PROVIDER.mockReset() + desktopNotificationsMock.getDesktopNotificationPermission.mockReset() + desktopNotificationsMock.requestDesktopNotificationPermission.mockReset() + desktopNotificationsMock.openDesktopNotificationSettings.mockReset() + desktopNotificationsMock.getDesktopNotificationPermission.mockResolvedValue('default') + desktopNotificationsMock.requestDesktopNotificationPermission.mockResolvedValue('granted') + desktopNotificationsMock.openDesktopNotificationSettings.mockResolvedValue(true) MOCK_GET_SETTINGS.mockResolvedValue({}) MOCK_UPDATE_SETTINGS.mockResolvedValue({}) providerStoreState.providers = [] @@ -108,6 +121,7 @@ describe('Settings > General tab', () => { locale: 'en', thinkingEnabled: true, skipWebFetchPreflight: true, + desktopNotificationsEnabled: true, webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' }, setThinkingEnabled: vi.fn().mockImplementation(async (enabled: boolean) => { useSettingsStore.setState({ thinkingEnabled: enabled }) @@ -115,6 +129,9 @@ describe('Settings > General tab', () => { setSkipWebFetchPreflight: vi.fn().mockImplementation(async (enabled: boolean) => { useSettingsStore.setState({ skipWebFetchPreflight: enabled }) }), + setDesktopNotificationsEnabled: vi.fn().mockImplementation(async (enabled: boolean) => { + useSettingsStore.setState({ desktopNotificationsEnabled: enabled }) + }), setWebSearch: vi.fn().mockImplementation(async (webSearch) => { useSettingsStore.setState({ webSearch }) }), @@ -170,6 +187,49 @@ describe('Settings > General tab', () => { expect(useSettingsStore.getState().setThinkingEnabled).toHaveBeenCalledWith(false) }) + it('lets the user disable desktop system notifications', () => { + render() + + fireEvent.click(screen.getByText('General')) + + const toggle = screen.getByLabelText('Enable system notifications') + expect(toggle).toBeChecked() + fireEvent.click(toggle) + + expect(useSettingsStore.getState().setDesktopNotificationsEnabled).toHaveBeenCalledWith(false) + expect(desktopNotificationsMock.requestDesktopNotificationPermission).not.toHaveBeenCalled() + }) + + it('requests native notification permission when desktop notifications are enabled', async () => { + useSettingsStore.setState({ desktopNotificationsEnabled: false }) + render() + + fireEvent.click(screen.getByText('General')) + await act(async () => { + fireEvent.click(screen.getByLabelText('Enable system notifications')) + }) + + expect(useSettingsStore.getState().setDesktopNotificationsEnabled).toHaveBeenCalledWith(true) + await vi.waitFor(() => { + expect(desktopNotificationsMock.requestDesktopNotificationPermission).toHaveBeenCalledTimes(1) + }) + }) + + it('opens system settings when enabling notifications finds system denial', async () => { + useSettingsStore.setState({ desktopNotificationsEnabled: false }) + desktopNotificationsMock.requestDesktopNotificationPermission.mockResolvedValue('denied') + render() + + fireEvent.click(screen.getByText('General')) + await act(async () => { + fireEvent.click(screen.getByLabelText('Enable system notifications')) + }) + + await vi.waitFor(() => { + expect(desktopNotificationsMock.openDesktopNotificationSettings).toHaveBeenCalledTimes(1) + }) + }) + it('saves WebSearch fallback provider settings', () => { render() diff --git a/desktop/src/components/tasks/NewTaskModal.tsx b/desktop/src/components/tasks/NewTaskModal.tsx index 284dfefb..16a72e3e 100644 --- a/desktop/src/components/tasks/NewTaskModal.tsx +++ b/desktop/src/components/tasks/NewTaskModal.tsx @@ -12,6 +12,8 @@ import { describeCron, isValidCron, parseCron, type FrequencyKey } from '../../l import type { PermissionMode } from '../../types/settings' import type { CronTask } from '../../types/task' +type NotificationChannel = 'desktop' | 'telegram' | 'feishu' + type Props = { open: boolean onClose: () => void @@ -95,7 +97,7 @@ export function NewTaskModal({ open, onClose, editTask }: Props) { const [folderPath, setFolderPath] = useState(editTask?.folderPath || defaultWorkDir) const [useWorktree, setUseWorktree] = useState(editTask?.useWorktree || false) const [notifyEnabled, setNotifyEnabled] = useState(editTask?.notification?.enabled || false) - const [notifyChannels, setNotifyChannels] = useState<('telegram' | 'feishu')[]>(editTask?.notification?.channels || []) + const [notifyChannels, setNotifyChannels] = useState(editTask?.notification?.channels || []) const [isSubmitting, setIsSubmitting] = useState(false) // Enhanced scheduling state @@ -117,7 +119,8 @@ export function NewTaskModal({ open, onClose, editTask }: Props) { description.trim() && prompt.trim() && (frequency !== 'customCron' || isValidCron(customCron)) && - (frequency !== 'specificDays' || selectedDays.length > 0) + (frequency !== 'specificDays' || selectedDays.length > 0) && + (!notifyEnabled || notifyChannels.length > 0) const handleSubmit = async () => { if (!canSubmit) return @@ -331,7 +334,12 @@ export function NewTaskModal({ open, onClose, editTask }: Props) { setNotifyEnabled(e.target.checked)} + onChange={(e) => { + setNotifyEnabled(e.target.checked) + if (e.target.checked && notifyChannels.length === 0) { + setNotifyChannels(['desktop']) + } + }} className="w-4 h-4 rounded border-[var(--color-border)] accent-[var(--color-brand)]" />
@@ -342,6 +350,19 @@ export function NewTaskModal({ open, onClose, editTask }: Props) { {notifyEnabled && (
+
- {!isFeishuConfigured && !isTelegramConfigured && ( + {notifyChannels.length === 0 && (

warning - {t('newTask.noChannelConfigured')} + {t('newTask.noChannelSelected')}

)}
diff --git a/desktop/src/hooks/useScheduledTaskDesktopNotifications.test.tsx b/desktop/src/hooks/useScheduledTaskDesktopNotifications.test.tsx new file mode 100644 index 00000000..0c8efca1 --- /dev/null +++ b/desktop/src/hooks/useScheduledTaskDesktopNotifications.test.tsx @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render } from '@testing-library/react' +import { useScheduledTaskDesktopNotifications } from './useScheduledTaskDesktopNotifications' + +const { listMock, getRecentRunsMock, notifyDesktopMock } = vi.hoisted(() => ({ + listMock: vi.fn(), + getRecentRunsMock: vi.fn(), + notifyDesktopMock: vi.fn(), +})) + +vi.mock('../api/tasks', () => ({ + tasksApi: { + list: listMock, + getRecentRuns: getRecentRunsMock, + }, +})) + +vi.mock('../lib/desktopNotifications', () => ({ + notifyDesktop: notifyDesktopMock, +})) + +function Harness() { + useScheduledTaskDesktopNotifications() + return null +} + +describe('useScheduledTaskDesktopNotifications', () => { + beforeEach(() => { + vi.useFakeTimers() + localStorage.clear() + listMock.mockReset() + getRecentRunsMock.mockReset() + notifyDesktopMock.mockReset() + }) + + it('does not notify old runs on first poll and notifies new desktop-enabled task runs later', async () => { + listMock.mockResolvedValue({ + tasks: [{ + id: 'task-1', + name: 'Daily review', + cron: '* * * * *', + prompt: 'review', + enabled: true, + createdAt: 1, + notification: { enabled: true, channels: ['desktop'] }, + }], + }) + getRecentRunsMock + .mockResolvedValueOnce({ + runs: [{ + id: 'run-old', + taskId: 'task-1', + taskName: 'Daily review', + startedAt: '2026-05-03T00:00:00.000Z', + completedAt: '2026-05-03T00:00:01.000Z', + status: 'completed', + prompt: 'review', + output: 'old result', + }], + }) + .mockResolvedValueOnce({ + runs: [ + { + id: 'run-old', + taskId: 'task-1', + taskName: 'Daily review', + startedAt: '2026-05-03T00:00:00.000Z', + completedAt: '2026-05-03T00:00:01.000Z', + status: 'completed', + prompt: 'review', + output: 'old result', + }, + { + id: 'run-new', + taskId: 'task-1', + taskName: 'Daily review', + startedAt: '2026-05-03T00:01:00.000Z', + completedAt: '2026-05-03T00:01:01.000Z', + status: 'failed', + prompt: 'review', + error: 'provider timeout', + }, + ], + }) + + render() + await vi.waitFor(() => expect(getRecentRunsMock).toHaveBeenCalledTimes(1)) + expect(notifyDesktopMock).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(30_000) + await vi.waitFor(() => expect(notifyDesktopMock).toHaveBeenCalledTimes(1)) + expect(notifyDesktopMock).toHaveBeenCalledWith({ + dedupeKey: 'scheduled-task:run-new', + title: '定时任务 Daily review', + body: '失败: provider timeout', + }) + }) + + it('ignores task runs without the desktop notification channel', async () => { + listMock.mockResolvedValue({ + tasks: [{ + id: 'task-1', + name: 'IM only', + cron: '* * * * *', + prompt: 'review', + enabled: true, + createdAt: 1, + notification: { enabled: true, channels: ['telegram'] }, + }], + }) + getRecentRunsMock.mockResolvedValue({ + runs: [{ + id: 'run-1', + taskId: 'task-1', + taskName: 'IM only', + startedAt: '2026-05-03T00:00:00.000Z', + completedAt: '2026-05-03T00:00:01.000Z', + status: 'completed', + prompt: 'review', + }], + }) + + render() + await vi.waitFor(() => expect(getRecentRunsMock).toHaveBeenCalledTimes(1)) + await vi.advanceTimersByTimeAsync(30_000) + + expect(notifyDesktopMock).not.toHaveBeenCalled() + }) +}) diff --git a/desktop/src/hooks/useScheduledTaskDesktopNotifications.ts b/desktop/src/hooks/useScheduledTaskDesktopNotifications.ts new file mode 100644 index 00000000..7bf70ad3 --- /dev/null +++ b/desktop/src/hooks/useScheduledTaskDesktopNotifications.ts @@ -0,0 +1,117 @@ +import { useEffect } from 'react' +import { tasksApi } from '../api/tasks' +import { notifyDesktop } from '../lib/desktopNotifications' +import type { CronTask, TaskRun } from '../types/task' + +const POLL_INTERVAL_MS = 30_000 +const NOTIFIED_RUNS_STORAGE_KEY = 'cc-haha.notifiedDesktopTaskRuns.v1' +const MAX_STORED_RUN_IDS = 200 + +function isTerminalRun(run: TaskRun): boolean { + return run.status === 'completed' || run.status === 'failed' || run.status === 'timeout' +} + +function hasDesktopNotification(task: CronTask | undefined): boolean { + return !!task?.notification?.enabled && task.notification.channels.includes('desktop') +} + +function readNotifiedRunIds(): Set { + try { + const raw = localStorage.getItem(NOTIFIED_RUNS_STORAGE_KEY) + const parsed = raw ? JSON.parse(raw) : [] + return new Set(Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : []) + } catch { + return new Set() + } +} + +function writeNotifiedRunIds(runIds: Set): void { + try { + const trimmed = [...runIds].slice(-MAX_STORED_RUN_IDS) + localStorage.setItem(NOTIFIED_RUNS_STORAGE_KEY, JSON.stringify(trimmed)) + } catch { + // Notification dedupe is best-effort; storage failures should not break the app. + } +} + +function formatTaskRunNotification(run: TaskRun): { title: string; body: string } { + const status = run.status === 'completed' + ? '完成' + : run.status === 'failed' + ? '失败' + : '超时' + const detail = run.error || run.output || run.prompt + const body = detail + ? `${status}: ${detail.slice(0, 160)}` + : `状态: ${status}` + + return { + title: `定时任务 ${run.taskName || run.taskId}`, + body, + } +} + +export function collectDesktopNotifiableRuns( + tasks: CronTask[], + runs: TaskRun[], + notifiedRunIds: Set, +): TaskRun[] { + const taskById = new Map(tasks.map((task) => [task.id, task])) + return runs + .filter((run) => isTerminalRun(run)) + .filter((run) => hasDesktopNotification(taskById.get(run.taskId))) + .filter((run) => !notifiedRunIds.has(run.id)) + .sort((a, b) => Date.parse(a.completedAt ?? a.startedAt) - Date.parse(b.completedAt ?? b.startedAt)) +} + +export function useScheduledTaskDesktopNotifications(): void { + useEffect(() => { + let stopped = false + let initialized = false + + const poll = async () => { + try { + const [{ tasks }, { runs }] = await Promise.all([ + tasksApi.list(), + tasksApi.getRecentRuns(50), + ]) + if (stopped) return + + const notifiedRunIds = readNotifiedRunIds() + const pendingRuns = collectDesktopNotifiableRuns(tasks, runs, notifiedRunIds) + + if (!initialized) { + for (const run of pendingRuns) notifiedRunIds.add(run.id) + writeNotifiedRunIds(notifiedRunIds) + initialized = true + return + } + + for (const run of pendingRuns) { + const notification = formatTaskRunNotification(run) + notifyDesktop({ + dedupeKey: `scheduled-task:${run.id}`, + title: notification.title, + body: notification.body, + }) + notifiedRunIds.add(run.id) + } + writeNotifiedRunIds(notifiedRunIds) + } catch (err) { + if (typeof console !== 'undefined') { + console.warn('[scheduledTaskNotifications] failed to poll task runs:', err) + } + } + } + + void poll() + const interval = window.setInterval(() => { + void poll() + }, POLL_INTERVAL_MS) + + return () => { + stopped = true + window.clearInterval(interval) + } + }, []) +} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 11a426a7..d44a52b0 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -553,6 +553,18 @@ export const en = { 'settings.general.thinkingDescription': 'Controls whether new sessions start with model thinking enabled. When off, compatible providers such as DeepSeek receive an explicit non-thinking parameter.', 'settings.general.thinkingEnabled': 'Enable thinking mode', 'settings.general.thinkingHint': 'Turn this off to start new sessions with --thinking disabled; useful for DeepSeek V4 Flash/Pro and other non-thinking workflows.', + 'settings.general.notificationsTitle': 'System Notifications', + 'settings.general.notificationsDescription': 'Use native OS notifications for permission prompts and scheduled task results.', + 'settings.general.notificationsEnabled': 'Enable system notifications', + 'settings.general.notificationsHintOn': 'When enabled, the app will request notification permission and use the OS notification center.', + 'settings.general.notificationsHintOff': 'When disabled, permission prompts and scheduled tasks will not send desktop notifications.', + 'settings.general.notificationsStatus': 'Permission', + 'settings.general.notificationsStatusGranted': 'Granted', + 'settings.general.notificationsStatusDenied': 'Blocked by system settings', + 'settings.general.notificationsStatusDefault': 'Not requested', + 'settings.general.notificationsStatusUnsupported': 'Unavailable in this environment', + 'settings.general.notificationsAuthorize': 'Authorize', + 'settings.general.notificationsOpenSettings': 'Open Settings', 'settings.general.webFetchPreflightTitle': 'WebFetch Preflight', 'settings.general.webFetchPreflightDescription': 'Desktop sessions skip Claude\'s domain preflight by default to avoid false failures on third-party providers and restricted networks.', 'settings.general.webFetchPreflightEnabled': 'Skip WebFetch domain preflight', @@ -842,9 +854,11 @@ export const en = { 'newTask.notification': 'Notification', 'newTask.notifyOnComplete': 'Push notification on completion', 'newTask.notifyChannels': 'Notification channels', - 'newTask.notifyHint': 'Send the task title and result to your IM channels when done.', + 'newTask.notifyHint': 'Send a native desktop notification or IM message when the task finishes.', + 'newTask.notifyDesktop': 'Desktop', 'newTask.notConfigured': 'Not configured', 'newTask.noChannelConfigured': 'No IM channels configured yet. Go to Settings → IM Adapters to set up.', + 'newTask.noChannelSelected': 'Select at least one notification channel.', // ─── Cron Descriptions ────────────────────────────────────── 'cron.everyMinute': 'Runs every minute', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 7e01828e..1a98596b 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -555,6 +555,18 @@ export const zh: Record = { 'settings.general.thinkingDescription': '控制新会话是否启用模型思考。关闭后,DeepSeek 等兼容供应商会收到显式非思考模式参数。', 'settings.general.thinkingEnabled': '启用思考模式', 'settings.general.thinkingHint': '关闭后会以 --thinking disabled 启动新会话;适合 DeepSeek V4 Flash/Pro 等需要非思考模式的模型。', + 'settings.general.notificationsTitle': '系统通知', + 'settings.general.notificationsDescription': '使用操作系统原生通知提醒授权确认和定时任务结果。', + 'settings.general.notificationsEnabled': '启用系统通知', + 'settings.general.notificationsHintOn': '开启后会请求系统通知权限,并通过系统通知中心提醒。', + 'settings.general.notificationsHintOff': '关闭后,授权确认和定时任务都不会发送桌面通知。', + 'settings.general.notificationsStatus': '权限', + 'settings.general.notificationsStatusGranted': '已授权', + 'settings.general.notificationsStatusDenied': '已被系统设置阻止', + 'settings.general.notificationsStatusDefault': '尚未请求', + 'settings.general.notificationsStatusUnsupported': '当前环境不可用', + 'settings.general.notificationsAuthorize': '授权通知', + 'settings.general.notificationsOpenSettings': '打开系统设置', 'settings.general.webFetchPreflightTitle': 'WebFetch 预检', 'settings.general.webFetchPreflightDescription': '桌面端默认跳过 Claude 的域名预检,避免第三方服务商或受限网络下出现误报失败。', 'settings.general.webFetchPreflightEnabled': '跳过 WebFetch 域名预检', @@ -844,9 +856,11 @@ export const zh: Record = { 'newTask.notification': '通知', 'newTask.notifyOnComplete': '完成后推送通知', 'newTask.notifyChannels': '通知渠道', - 'newTask.notifyHint': '任务完成后将标题和结果推送到 IM 渠道。', + 'newTask.notifyHint': '任务完成后发送原生桌面通知或 IM 消息。', + 'newTask.notifyDesktop': '桌面', 'newTask.notConfigured': '未配置', 'newTask.noChannelConfigured': '尚未配置任何 IM 渠道,请前往 设置 → IM 接入 进行配置。', + 'newTask.noChannelSelected': '请至少选择一个通知渠道。', // ─── Cron 描述 ────────────────────────────────────── 'cron.everyMinute': '每分钟执行', diff --git a/desktop/src/lib/desktopNotifications.test.ts b/desktop/src/lib/desktopNotifications.test.ts new file mode 100644 index 00000000..bf6ddaec --- /dev/null +++ b/desktop/src/lib/desktopNotifications.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const notificationPluginMock = vi.hoisted(() => ({ + isPermissionGranted: vi.fn(), + requestPermission: vi.fn(), + sendNotification: vi.fn(), +})) + +vi.mock('@tauri-apps/plugin-notification', () => notificationPluginMock) + +import { + getDesktopNotificationPermission, + notifyDesktop, + requestDesktopNotificationPermission, + resetDesktopNotificationsForTests, + setNativeNotificationSenderForTests, +} from './desktopNotifications' +import { useSettingsStore } from '../stores/settingsStore' + +describe('desktopNotifications', () => { + beforeEach(() => { + vi.useRealTimers() + resetDesktopNotificationsForTests() + notificationPluginMock.isPermissionGranted.mockReset() + notificationPluginMock.requestPermission.mockReset() + notificationPluginMock.sendNotification.mockReset() + useSettingsStore.setState({ desktopNotificationsEnabled: true }) + }) + + it('requests native notification permission before sending through the Tauri plugin', async () => { + notificationPluginMock.isPermissionGranted.mockResolvedValue(false) + notificationPluginMock.requestPermission.mockResolvedValue('granted') + + notifyDesktop({ + dedupeKey: 'permission:1', + title: 'Permission required', + body: 'Approve command execution', + }) + + await vi.waitFor(() => expect(notificationPluginMock.sendNotification).toHaveBeenCalledTimes(1)) + expect(notificationPluginMock.isPermissionGranted).toHaveBeenCalledTimes(1) + expect(notificationPluginMock.requestPermission).toHaveBeenCalledTimes(1) + expect(notificationPluginMock.sendNotification).toHaveBeenCalledWith({ + title: 'Permission required', + body: 'Approve command execution', + }) + }) + + it('does not fall back to sound when native notification permission is denied', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + notificationPluginMock.isPermissionGranted.mockResolvedValue(false) + notificationPluginMock.requestPermission.mockResolvedValue('denied') + + notifyDesktop({ title: 'Permission required' }) + + await vi.waitFor(() => expect(warnSpy).toHaveBeenCalled()) + expect(notificationPluginMock.sendNotification).not.toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalledWith( + '[desktopNotifications] native notification permission was not granted', + ) + warnSpy.mockRestore() + }) + + it('does not send or consume dedupe keys when desktop notifications are disabled', async () => { + const sender = vi.fn(async () => true) + setNativeNotificationSenderForTests(sender) + useSettingsStore.setState({ desktopNotificationsEnabled: false }) + + notifyDesktop({ dedupeKey: 'permission:1', title: 'Permission required' }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(sender).not.toHaveBeenCalled() + + useSettingsStore.setState({ desktopNotificationsEnabled: true }) + notifyDesktop({ dedupeKey: 'permission:1', title: 'Permission required' }) + await vi.waitFor(() => expect(sender).toHaveBeenCalledTimes(1)) + }) + + it('reports and requests native notification permission', async () => { + notificationPluginMock.isPermissionGranted.mockResolvedValueOnce(false).mockResolvedValueOnce(false) + notificationPluginMock.requestPermission.mockResolvedValue('granted') + Object.defineProperty(window, 'Notification', { + configurable: true, + value: { permission: 'default' }, + }) + + await expect(getDesktopNotificationPermission()).resolves.toBe('default') + await expect(requestDesktopNotificationPermission()).resolves.toBe('granted') + expect(notificationPluginMock.requestPermission).toHaveBeenCalledTimes(1) + }) + + it('sends a native notification once for a dedupe key', async () => { + const sender = vi.fn(async () => true) + setNativeNotificationSenderForTests(sender) + + notifyDesktop({ + dedupeKey: 'permission:1', + title: 'Permission required', + body: 'Approve command execution', + }) + notifyDesktop({ + dedupeKey: 'permission:1', + title: 'Permission required', + body: 'Approve command execution', + }) + await vi.waitFor(() => expect(sender).toHaveBeenCalledTimes(1)) + + expect(sender).toHaveBeenCalledWith({ + title: 'Permission required', + body: 'Approve command execution', + }) + }) + + it('throttles bursts within the same cooldown scope', async () => { + vi.useFakeTimers() + const sender = vi.fn(async () => true) + setNativeNotificationSenderForTests(sender) + + notifyDesktop({ dedupeKey: 'permission:1', cooldownScope: 'permission', title: 'One' }) + notifyDesktop({ dedupeKey: 'permission:2', cooldownScope: 'permission', title: 'Two' }) + await vi.runAllTimersAsync() + expect(sender).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(751) + notifyDesktop({ dedupeKey: 'permission:3', cooldownScope: 'permission', title: 'Three' }) + await vi.runAllTimersAsync() + expect(sender).toHaveBeenCalledTimes(2) + }) +}) diff --git a/desktop/src/lib/desktopNotifications.ts b/desktop/src/lib/desktopNotifications.ts new file mode 100644 index 00000000..6e5b06a7 --- /dev/null +++ b/desktop/src/lib/desktopNotifications.ts @@ -0,0 +1,154 @@ +import { useSettingsStore } from '../stores/settingsStore' + +const DEFAULT_COOLDOWN_MS = 750 + +export type DesktopNotificationOptions = { + title: string + body?: string + dedupeKey?: string + cooldownScope?: string + cooldownMs?: number +} + +type NativeNotificationSender = (options: { title: string; body?: string }) => Promise | boolean +export type DesktopNotificationPermission = NotificationPermission | 'unsupported' + +const notifiedKeys = new Set() +const lastNotificationAtByScope = new Map() +let overrideNativeNotificationSender: NativeNotificationSender | null = null + +function readBrowserNotificationPermission(): DesktopNotificationPermission { + if (typeof window === 'undefined' || !('Notification' in window)) { + return 'unsupported' + } + return window.Notification.permission +} + +function detectPlatform(): 'darwin' | 'win32' | 'linux' | 'unknown' { + const platform = typeof navigator !== 'undefined' ? navigator.platform.toLowerCase() : '' + const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent.toLowerCase() : '' + const raw = `${platform} ${userAgent}` + if (raw.includes('mac')) return 'darwin' + if (raw.includes('win')) return 'win32' + if (raw.includes('linux')) return 'linux' + return 'unknown' +} + +function getNotificationSettingsUrl(): string | null { + switch (detectPlatform()) { + case 'darwin': + return 'x-apple.systempreferences:com.apple.preference.notifications' + case 'win32': + return 'ms-settings:notifications' + default: + return null + } +} + +export async function getDesktopNotificationPermission(): Promise { + try { + const { isPermissionGranted } = await import('@tauri-apps/plugin-notification') + if (await isPermissionGranted()) return 'granted' + } catch { + // Fall back to the Web Notification permission state below. + } + return readBrowserNotificationPermission() +} + +export async function requestDesktopNotificationPermission(): Promise { + try { + const { + isPermissionGranted, + requestPermission, + } = await import('@tauri-apps/plugin-notification') + + if (await isPermissionGranted()) return 'granted' + return await requestPermission() + } catch { + return readBrowserNotificationPermission() + } +} + +export async function openDesktopNotificationSettings(): Promise { + const url = getNotificationSettingsUrl() + if (!url) return false + + try { + const { open } = await import('@tauri-apps/plugin-shell') + await open(url) + return true + } catch { + try { + window.open(url, '_blank', 'noopener,noreferrer') + return true + } catch { + return false + } + } +} + +async function sendNativeNotification(options: { title: string; body?: string }): Promise { + const { + isPermissionGranted, + requestPermission, + sendNotification, + } = await import('@tauri-apps/plugin-notification') + + let permissionGranted = await isPermissionGranted() + if (!permissionGranted) { + const permission = await requestPermission() + permissionGranted = permission === 'granted' + } + + if (!permissionGranted) { + return false + } + + sendNotification(options) + return true +} + +export function notifyDesktop(options: DesktopNotificationOptions): void { + if (!useSettingsStore.getState().desktopNotificationsEnabled) { + return + } + + if (options.dedupeKey && notifiedKeys.has(options.dedupeKey)) { + return + } + + const cooldownScope = options.cooldownScope + if (cooldownScope) { + const now = Date.now() + const lastNotificationAt = lastNotificationAtByScope.get(cooldownScope) ?? 0 + if (now - lastNotificationAt < (options.cooldownMs ?? DEFAULT_COOLDOWN_MS)) { + return + } + lastNotificationAtByScope.set(cooldownScope, now) + } + + if (options.dedupeKey) { + notifiedKeys.add(options.dedupeKey) + } + + const sender = overrideNativeNotificationSender ?? sendNativeNotification + void Promise.resolve(sender({ title: options.title, body: options.body })).then((sent) => { + if (!sent && typeof console !== 'undefined') { + console.warn('[desktopNotifications] native notification permission was not granted') + } + }).catch((err) => { + if (typeof console !== 'undefined') { + console.warn('[desktopNotifications] failed to send native notification:', err) + } + }) +} + +export function resetDesktopNotificationsForTests(): void { + notifiedKeys.clear() + lastNotificationAtByScope.clear() + overrideNativeNotificationSender = null +} + +export function setNativeNotificationSenderForTests(sender: NativeNotificationSender | null): void { + overrideNativeNotificationSender = sender +} diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 7881bd7e..b4067485 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -31,6 +31,12 @@ import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin' import { useUpdateStore } from '../stores/updateStore' import { formatBytes } from '../lib/formatBytes' import { isTauriRuntime } from '../lib/desktopRuntime' +import { + getDesktopNotificationPermission, + openDesktopNotificationSettings, + requestDesktopNotificationPermission, + type DesktopNotificationPermission, +} from '../lib/desktopNotifications' export function Settings() { const [activeTab, setActiveTab] = useState('providers') @@ -883,17 +889,31 @@ function GeneralSettings() { setTheme, skipWebFetchPreflight, setSkipWebFetchPreflight, + desktopNotificationsEnabled, + setDesktopNotificationsEnabled, webSearch, setWebSearch, } = useSettingsStore() const t = useTranslation() const [webSearchDraft, setWebSearchDraft] = useState(webSearch) + const [notificationPermission, setNotificationPermission] = useState('default') + const [notificationActionRunning, setNotificationActionRunning] = useState(false) const webSearchDirty = JSON.stringify(webSearchDraft) !== JSON.stringify(webSearch) useEffect(() => { setWebSearchDraft(webSearch) }, [webSearch]) + useEffect(() => { + let cancelled = false + getDesktopNotificationPermission().then((permission) => { + if (!cancelled) setNotificationPermission(permission) + }) + return () => { + cancelled = true + } + }, []) + const EFFORT_LABELS: Record = { low: t('settings.general.effort.low'), medium: t('settings.general.effort.medium'), @@ -919,6 +939,46 @@ function GeneralSettings() { { value: 'disabled', label: t('settings.general.webSearch.mode.disabled') }, ] + const notificationStatusLabel: Record = { + granted: t('settings.general.notificationsStatusGranted'), + denied: t('settings.general.notificationsStatusDenied'), + default: t('settings.general.notificationsStatusDefault'), + unsupported: t('settings.general.notificationsStatusUnsupported'), + } + + const handleDesktopNotificationsToggle = async (enabled: boolean) => { + await setDesktopNotificationsEnabled(enabled) + if (!enabled) return + + setNotificationActionRunning(true) + try { + const permission = await requestDesktopNotificationPermission() + setNotificationPermission(permission) + if (permission === 'denied') { + await openDesktopNotificationSettings() + } + } finally { + setNotificationActionRunning(false) + } + } + + const handleNotificationPermissionAction = async () => { + setNotificationActionRunning(true) + try { + if (notificationPermission === 'denied') { + await openDesktopNotificationSettings() + } else { + const permission = await requestDesktopNotificationPermission() + setNotificationPermission(permission) + if (permission === 'denied') { + await openDesktopNotificationSettings() + } + } + } finally { + setNotificationActionRunning(false) + } + } + return (
{/* Appearance selector */} @@ -1000,6 +1060,52 @@ function GeneralSettings() {
+
+

{t('settings.general.notificationsTitle')}

+

{t('settings.general.notificationsDescription')}

+
+ + {desktopNotificationsEnabled && ( +
+
+ {t('settings.general.notificationsStatus')}: {notificationStatusLabel[notificationPermission]} +
+ {notificationPermission !== 'granted' && notificationPermission !== 'unsupported' && ( + + )} +
+ )} +
+
+

{t('settings.general.webFetchPreflightTitle')}

{t('settings.general.webFetchPreflightDescription')}

diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index b518964e..7eeb9094 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -15,6 +15,7 @@ const { markCompletedAndDismissedMock, resetCompletedTasksMock, refreshTasksMock, + notifyDesktopMock, cliTaskStoreSnapshot, } = vi.hoisted(() => ({ sendMock: vi.fn(), @@ -29,12 +30,17 @@ const { markCompletedAndDismissedMock: vi.fn(), resetCompletedTasksMock: vi.fn(async () => {}), refreshTasksMock: vi.fn(), + notifyDesktopMock: vi.fn(), cliTaskStoreSnapshot: { tasks: [] as Array<{ id: string; subject: string; status: string; activeForm?: string }>, sessionId: null as string | null, }, })) +vi.mock('../lib/desktopNotifications', () => ({ + notifyDesktop: notifyDesktopMock, +})) + vi.mock('../api/websocket', () => ({ wsManager: { connect: vi.fn(), @@ -113,6 +119,7 @@ describe('chatStore history mapping', () => { markCompletedAndDismissedMock.mockReset() resetCompletedTasksMock.mockReset() refreshTasksMock.mockReset() + notifyDesktopMock.mockReset() cliTaskStoreSnapshot.tasks = [] cliTaskStoreSnapshot.sessionId = null useSessionRuntimeStore.setState({ selections: {} }) @@ -686,6 +693,12 @@ describe('chatStore history mapping', () => { type: 'tool_use', toolUseId: 'tool-ask-1', }) + expect(notifyDesktopMock).toHaveBeenCalledWith({ + dedupeKey: 'permission:perm-ask-1', + cooldownScope: 'permission-prompt', + title: 'Claude Code Haha 需要你的确认', + body: 'AskUserQuestion 请求执行,正在等待允许。', + }) }) it('sends permission mode updates to the active session only', () => { @@ -993,6 +1006,12 @@ describe('chatStore history mapping', () => { expect( useChatStore.getState().sessions[TEST_SESSION_ID]?.chatState, ).toBe('permission_pending') + expect(notifyDesktopMock).toHaveBeenCalledWith({ + dedupeKey: 'computer-use-permission:cu-1', + cooldownScope: 'permission-prompt', + title: 'Claude Code Haha 需要你的确认', + body: 'Open Finder and inspect a file', + }) }) it('keeps delayed text blocks from one streamed assistant turn in a single message', () => { diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 754948da..00550aae 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -7,6 +7,7 @@ import { useCLITaskStore } from './cliTaskStore' import { useSessionRuntimeStore } from './sessionRuntimeStore' import { useTabStore } from './tabStore' import { randomSpinnerVerb } from '../config/spinnerVerbs' +import { notifyDesktop } from '../lib/desktopNotifications' import { AGENT_LIFECYCLE_TYPES } from '../types/team' import type { MessageEntry } from '../types/session' import type { PermissionMode } from '../types/settings' @@ -670,6 +671,14 @@ export const useChatStore = create((set, get) => ({ break case 'permission_request': + notifyDesktop({ + dedupeKey: `permission:${msg.requestId}`, + cooldownScope: 'permission-prompt', + title: 'Claude Code Haha 需要你的确认', + body: msg.toolName + ? `${msg.toolName} 请求执行,正在等待允许。` + : '有一个工具请求正在等待允许。', + }) update((s) => ({ pendingPermission: { requestId: msg.requestId, @@ -698,6 +707,12 @@ export const useChatStore = create((set, get) => ({ break case 'computer_use_permission_request': + notifyDesktop({ + dedupeKey: `computer-use-permission:${msg.requestId}`, + cooldownScope: 'permission-prompt', + title: 'Claude Code Haha 需要你的确认', + body: msg.request.reason || 'Computer Use 正在等待允许。', + }) update(() => ({ pendingComputerUsePermission: { requestId: msg.requestId, diff --git a/desktop/src/stores/settingsStore.test.ts b/desktop/src/stores/settingsStore.test.ts index 02a57ada..ab6246a0 100644 --- a/desktop/src/stores/settingsStore.test.ts +++ b/desktop/src/stores/settingsStore.test.ts @@ -20,3 +20,60 @@ describe('settingsStore locale defaults', () => { expect(useSettingsStore.getState().locale).toBe('en') }) }) + +describe('settingsStore desktop notification persistence', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + window.localStorage.clear() + }) + + it('persists the latest desktop notification toggle when saves overlap', async () => { + const pendingSaves: Array<() => void> = [] + const updateUser = vi.fn( + () => + new Promise<{ ok: true }>((resolve) => { + pendingSaves.push(() => resolve({ ok: true })) + }), + ) + + vi.doMock('../api/settings', () => ({ + settingsApi: { + getUser: vi.fn(), + updateUser, + getPermissionMode: vi.fn(), + setPermissionMode: vi.fn(), + getCliLauncherStatus: vi.fn(), + }, + })) + vi.doMock('../api/models', () => ({ + modelsApi: { + list: vi.fn(), + getCurrent: vi.fn(), + setCurrent: vi.fn(), + getEffort: vi.fn(), + setEffort: vi.fn(), + }, + })) + + const { useSettingsStore } = await import('./settingsStore') + + const firstSave = useSettingsStore.getState().setDesktopNotificationsEnabled(false) + await vi.waitFor(() => { + expect(updateUser).toHaveBeenCalledWith({ desktopNotificationsEnabled: false }) + }) + + const secondSave = useSettingsStore.getState().setDesktopNotificationsEnabled(true) + expect(useSettingsStore.getState().desktopNotificationsEnabled).toBe(true) + + pendingSaves.shift()?.() + await vi.waitFor(() => { + expect(updateUser).toHaveBeenCalledWith({ desktopNotificationsEnabled: true }) + }) + pendingSaves.shift()?.() + await Promise.all([firstSave, secondSave]) + + expect(updateUser).toHaveBeenLastCalledWith({ desktopNotificationsEnabled: true }) + expect(useSettingsStore.getState().desktopNotificationsEnabled).toBe(true) + }) +}) diff --git a/desktop/src/stores/settingsStore.ts b/desktop/src/stores/settingsStore.ts index 81668221..182401a8 100644 --- a/desktop/src/stores/settingsStore.ts +++ b/desktop/src/stores/settingsStore.ts @@ -6,6 +6,7 @@ import type { Locale } from '../i18n' import { useUIStore } from './uiStore' const LOCALE_STORAGE_KEY = 'cc-haha-locale' +let desktopNotificationsSaveQueue: Promise = Promise.resolve() function getStoredLocale(): Locale { try { @@ -25,6 +26,7 @@ type SettingsStore = { locale: Locale theme: ThemeMode skipWebFetchPreflight: boolean + desktopNotificationsEnabled: boolean webSearch: WebSearchSettings isLoading: boolean error: string | null @@ -37,6 +39,7 @@ type SettingsStore = { setLocale: (locale: Locale) => void setTheme: (theme: ThemeMode) => Promise setSkipWebFetchPreflight: (enabled: boolean) => Promise + setDesktopNotificationsEnabled: (enabled: boolean) => Promise setWebSearch: (settings: WebSearchSettings) => Promise } @@ -50,6 +53,7 @@ export const useSettingsStore = create((set, get) => ({ locale: getStoredLocale(), theme: useUIStore.getState().theme, skipWebFetchPreflight: true, + desktopNotificationsEnabled: true, webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' }, isLoading: false, error: null, @@ -75,6 +79,7 @@ export const useSettingsStore = create((set, get) => ({ thinkingEnabled: userSettings.alwaysThinkingEnabled !== false, theme, skipWebFetchPreflight: userSettings.skipWebFetchPreflight !== false, + desktopNotificationsEnabled: userSettings.desktopNotificationsEnabled !== false, webSearch: normalizeWebSearchSettings(userSettings.webSearch), isLoading: false, error: null, @@ -150,6 +155,27 @@ export const useSettingsStore = create((set, get) => ({ } }, + setDesktopNotificationsEnabled: async (enabled) => { + const prev = get().desktopNotificationsEnabled + set({ desktopNotificationsEnabled: enabled }) + const save = desktopNotificationsSaveQueue + .catch(() => undefined) + .then(async () => { + if (get().desktopNotificationsEnabled !== enabled) return + await settingsApi.updateUser({ desktopNotificationsEnabled: enabled }) + }) + + desktopNotificationsSaveQueue = save + + try { + await save + } catch { + if (get().desktopNotificationsEnabled === enabled) { + set({ desktopNotificationsEnabled: prev }) + } + } + }, + setWebSearch: async (webSearch) => { const prev = get().webSearch const next = normalizeWebSearchSettings(webSearch) diff --git a/desktop/src/types/settings.ts b/desktop/src/types/settings.ts index 0880fe4b..188955b4 100644 --- a/desktop/src/types/settings.ts +++ b/desktop/src/types/settings.ts @@ -27,6 +27,7 @@ export type UserSettings = { permissionMode?: PermissionMode theme?: ThemeMode skipWebFetchPreflight?: boolean + desktopNotificationsEnabled?: boolean webSearch?: WebSearchSettings [key: string]: unknown } diff --git a/desktop/src/types/task.ts b/desktop/src/types/task.ts index 9ccbbba3..f8904da2 100644 --- a/desktop/src/types/task.ts +++ b/desktop/src/types/task.ts @@ -2,7 +2,7 @@ export type TaskNotificationConfig = { enabled: boolean - channels: ('telegram' | 'feishu')[] + channels: ('desktop' | 'telegram' | 'feishu')[] } export type CronTask = { diff --git a/src/server/services/cronService.ts b/src/server/services/cronService.ts index a20ab924..0e147f5d 100644 --- a/src/server/services/cronService.ts +++ b/src/server/services/cronService.ts @@ -13,7 +13,7 @@ import { ApiError } from '../middleware/errorHandler.js' export type TaskNotificationConfig = { enabled: boolean - channels: ('telegram' | 'feishu')[] + channels: ('desktop' | 'telegram' | 'feishu')[] } export type CronTask = { diff --git a/src/server/services/notificationService.ts b/src/server/services/notificationService.ts index 024d427b..91b9542c 100644 --- a/src/server/services/notificationService.ts +++ b/src/server/services/notificationService.ts @@ -224,7 +224,10 @@ export async function sendTaskNotification( run: TaskRun, notification: TaskNotificationConfig, ): Promise { - if (!notification.enabled || notification.channels.length === 0) return + const imChannels = notification.channels.filter((channel): channel is 'telegram' | 'feishu' => + channel === 'telegram' || channel === 'feishu', + ) + if (!notification.enabled || imChannels.length === 0) return let config: AdapterFileConfig try { @@ -236,7 +239,7 @@ export async function sendTaskNotification( const markdown = buildMarkdown(run) - for (const channel of notification.channels) { + for (const channel of imChannels) { try { if (channel === 'telegram') { const botToken = config.telegram?.botToken