mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
e99c567dea
commit
fa5fda24d0
@ -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=="],
|
||||
|
||||
@ -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"
|
||||
|
||||
483
desktop/src-tauri/Cargo.lock
generated
483
desktop/src-tauri/Cargo.lock
generated
@ -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",
|
||||
]
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -99,6 +99,7 @@
|
||||
},
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"notification:default",
|
||||
"process:allow-exit",
|
||||
"process:allow-restart",
|
||||
"updater:default"
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { AppShell } from './components/layout/AppShell'
|
||||
import { useScheduledTaskDesktopNotifications } from './hooks/useScheduledTaskDesktopNotifications'
|
||||
|
||||
export function App() {
|
||||
useScheduledTaskDesktopNotifications()
|
||||
return <AppShell />
|
||||
}
|
||||
|
||||
@ -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 />)
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
129
desktop/src/hooks/useScheduledTaskDesktopNotifications.test.tsx
Normal file
129
desktop/src/hooks/useScheduledTaskDesktopNotifications.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
117
desktop/src/hooks/useScheduledTaskDesktopNotifications.ts
Normal file
117
desktop/src/hooks/useScheduledTaskDesktopNotifications.ts
Normal 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)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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': '每分钟执行',
|
||||
|
||||
128
desktop/src/lib/desktopNotifications.test.ts
Normal file
128
desktop/src/lib/desktopNotifications.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
154
desktop/src/lib/desktopNotifications.ts
Normal file
154
desktop/src/lib/desktopNotifications.ts
Normal 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
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -27,6 +27,7 @@ export type UserSettings = {
|
||||
permissionMode?: PermissionMode
|
||||
theme?: ThemeMode
|
||||
skipWebFetchPreflight?: boolean
|
||||
desktopNotificationsEnabled?: boolean
|
||||
webSearch?: WebSearchSettings
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
export type TaskNotificationConfig = {
|
||||
enabled: boolean
|
||||
channels: ('telegram' | 'feishu')[]
|
||||
channels: ('desktop' | 'telegram' | 'feishu')[]
|
||||
}
|
||||
|
||||
export type CronTask = {
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user