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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-03 16:45:32 +08:00
parent e99c567dea
commit fa5fda24d0
24 changed files with 1367 additions and 13 deletions

View File

@ -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=="],

View File

@ -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"

View File

@ -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",
]

View File

@ -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"

View File

@ -99,6 +99,7 @@
},
"dialog:allow-open",
"dialog:allow-save",
"notification:default",
"process:allow-exit",
"process:allow-restart",
"updater:default"

View File

@ -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,

View File

@ -1,5 +1,7 @@
import { AppShell } from './components/layout/AppShell'
import { useScheduledTaskDesktopNotifications } from './hooks/useScheduledTaskDesktopNotifications'
export function App() {
useScheduledTaskDesktopNotifications()
return <AppShell />
}

View File

@ -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: () => <div data-testid="claude-official-login" />,
}))
@ -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(<Settings />)
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(<Settings />)
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(<Settings />)
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(<Settings />)

View File

@ -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<NotificationChannel[]>(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) {
<input
type="checkbox"
checked={notifyEnabled}
onChange={(e) => 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)]"
/>
<div>
@ -342,6 +350,19 @@ export function NewTaskModal({ open, onClose, editTask }: Props) {
{notifyEnabled && (
<div className="flex flex-col gap-2 pl-7">
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={notifyChannels.includes('desktop')}
onChange={(e) => {
setNotifyChannels((prev) =>
e.target.checked ? [...prev, 'desktop'] : prev.filter((c) => c !== 'desktop'),
)
}}
className="w-3.5 h-3.5 rounded border-[var(--color-border)] accent-[var(--color-brand)]"
/>
<span className="text-sm text-[var(--color-text-primary)]">{t('newTask.notifyDesktop')}</span>
</label>
<label className={`flex items-center gap-2 ${isFeishuConfigured ? 'cursor-pointer' : 'opacity-50 cursor-not-allowed'}`}>
<input
type="checkbox"
@ -377,10 +398,10 @@ export function NewTaskModal({ open, onClose, editTask }: Props) {
)}
</label>
</div>
{!isFeishuConfigured && !isTelegramConfigured && (
{notifyChannels.length === 0 && (
<p className="text-xs text-[var(--color-warning)]">
<span className="material-symbols-outlined text-[12px] align-middle mr-1">warning</span>
{t('newTask.noChannelConfigured')}
{t('newTask.noChannelSelected')}
</p>
)}
</div>

View File

@ -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(<Harness />)
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(<Harness />)
await vi.waitFor(() => expect(getRecentRunsMock).toHaveBeenCalledTimes(1))
await vi.advanceTimersByTimeAsync(30_000)
expect(notifyDesktopMock).not.toHaveBeenCalled()
})
})

View File

@ -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<string> {
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<string>): 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<string>,
): 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)
}
}, [])
}

View File

@ -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',

View File

@ -555,6 +555,18 @@ export const zh: Record<TranslationKey, string> = {
'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<TranslationKey, string> = {
'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': '每分钟执行',

View File

@ -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)
})
})

View File

@ -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> | boolean
export type DesktopNotificationPermission = NotificationPermission | 'unsupported'
const notifiedKeys = new Set<string>()
const lastNotificationAtByScope = new Map<string, number>()
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<DesktopNotificationPermission> {
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<DesktopNotificationPermission> {
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<boolean> {
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<boolean> {
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
}

View File

@ -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<SettingsTab>('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<DesktopNotificationPermission>('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<EffortLevel, string> = {
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<DesktopNotificationPermission, string> = {
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 (
<div className="max-w-xl">
{/* Appearance selector */}
@ -1000,6 +1060,52 @@ function GeneralSettings() {
</label>
</div>
<div className="mt-8">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.notificationsTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.notificationsDescription')}</p>
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
aria-label={t('settings.general.notificationsEnabled')}
checked={desktopNotificationsEnabled}
onChange={(e) => void handleDesktopNotificationsToggle(e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-[var(--color-border)] text-[var(--color-brand)] focus:ring-[var(--color-brand)]"
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.general.notificationsEnabled')}
</div>
<div className="text-xs text-[var(--color-text-tertiary)] mt-1 leading-5">
{desktopNotificationsEnabled
? t('settings.general.notificationsHintOn')
: t('settings.general.notificationsHintOff')}
</div>
</div>
</label>
{desktopNotificationsEnabled && (
<div className="mt-3 flex items-center justify-between gap-3 border-t border-[var(--color-border)]/60 pt-3">
<div className="min-w-0 text-xs text-[var(--color-text-tertiary)]">
{t('settings.general.notificationsStatus')}: {notificationStatusLabel[notificationPermission]}
</div>
{notificationPermission !== 'granted' && notificationPermission !== 'unsupported' && (
<Button
size="sm"
variant="secondary"
className="px-3 whitespace-nowrap"
disabled={notificationActionRunning}
onClick={() => void handleNotificationPermissionAction()}
>
{notificationPermission === 'denied'
? t('settings.general.notificationsOpenSettings')
: t('settings.general.notificationsAuthorize')}
</Button>
)}
</div>
)}
</div>
</div>
<div className="mt-8">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.webFetchPreflightTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.webFetchPreflightDescription')}</p>

View File

@ -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', () => {

View File

@ -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<ChatStore>((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<ChatStore>((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,

View File

@ -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)
})
})

View File

@ -6,6 +6,7 @@ import type { Locale } from '../i18n'
import { useUIStore } from './uiStore'
const LOCALE_STORAGE_KEY = 'cc-haha-locale'
let desktopNotificationsSaveQueue: Promise<void> = 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<void>
setSkipWebFetchPreflight: (enabled: boolean) => Promise<void>
setDesktopNotificationsEnabled: (enabled: boolean) => Promise<void>
setWebSearch: (settings: WebSearchSettings) => Promise<void>
}
@ -50,6 +53,7 @@ export const useSettingsStore = create<SettingsStore>((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<SettingsStore>((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<SettingsStore>((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)

View File

@ -27,6 +27,7 @@ export type UserSettings = {
permissionMode?: PermissionMode
theme?: ThemeMode
skipWebFetchPreflight?: boolean
desktopNotificationsEnabled?: boolean
webSearch?: WebSearchSettings
[key: string]: unknown
}

View File

@ -2,7 +2,7 @@
export type TaskNotificationConfig = {
enabled: boolean
channels: ('telegram' | 'feishu')[]
channels: ('desktop' | 'telegram' | 'feishu')[]
}
export type CronTask = {

View File

@ -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 = {

View File

@ -224,7 +224,10 @@ export async function sendTaskNotification(
run: TaskRun,
notification: TaskNotificationConfig,
): Promise<void> {
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