diff --git a/desktop/bun.lock b/desktop/bun.lock
index 13c4092d..07d09b95 100644
--- a/desktop/bun.lock
+++ b/desktop/bun.lock
@@ -39,6 +39,7 @@
"@tauri-apps/api": "^2.3.0",
"@tauri-apps/cli": "^2.3.1",
"@tauri-apps/plugin-dialog": "^2.2.1",
+ "@tauri-apps/plugin-notification": "^2.2.1",
"@tauri-apps/plugin-process": "^2.2.1",
"@tauri-apps/plugin-shell": "^2.2.1",
"@tauri-apps/plugin-updater": "^2.2.1",
@@ -334,6 +335,8 @@
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.0", "https://registry.npmmirror.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.0.tgz", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw=="],
+ "@tauri-apps/plugin-notification": ["@tauri-apps/plugin-notification@2.3.3", "https://registry.npmmirror.com/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg=="],
+
"@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.1", "https://registry.npmmirror.com/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="],
"@tauri-apps/plugin-shell": ["@tauri-apps/plugin-shell@2.3.5", "https://registry.npmmirror.com/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg=="],
diff --git a/desktop/package.json b/desktop/package.json
index 95e9c75a..7680df94 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -50,6 +50,7 @@
"@tauri-apps/api": "^2.3.0",
"@tauri-apps/cli": "^2.3.1",
"@tauri-apps/plugin-dialog": "^2.2.1",
+ "@tauri-apps/plugin-notification": "^2.2.1",
"@tauri-apps/plugin-process": "^2.2.1",
"@tauri-apps/plugin-shell": "^2.2.1",
"@tauri-apps/plugin-updater": "^2.2.1"
diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock
index 365ad6f4..9efbfb10 100644
--- a/desktop/src-tauri/Cargo.lock
+++ b/desktop/src-tauri/Cargo.lock
@@ -56,6 +56,137 @@ dependencies = [
"derive_arbitrary",
]
+[[package]]
+name = "async-broadcast"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-channel"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
+dependencies = [
+ "concurrent-queue",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-executor"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
+dependencies = [
+ "async-task",
+ "concurrent-queue",
+ "fastrand",
+ "futures-lite",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "async-io"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
+dependencies = [
+ "autocfg",
+ "cfg-if",
+ "concurrent-queue",
+ "futures-io",
+ "futures-lite",
+ "parking",
+ "polling",
+ "rustix",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-lock"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-process"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
+dependencies = [
+ "async-channel",
+ "async-io",
+ "async-lock",
+ "async-signal",
+ "async-task",
+ "blocking",
+ "cfg-if",
+ "event-listener",
+ "futures-lite",
+ "rustix",
+]
+
+[[package]]
+name = "async-recursion"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "async-signal"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
+dependencies = [
+ "async-io",
+ "async-lock",
+ "atomic-waker",
+ "cfg-if",
+ "futures-core",
+ "futures-io",
+ "rustix",
+ "signal-hook-registry",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-task"
+version = "4.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
+
+[[package]]
+name = "async-trait"
+version = "0.1.89"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "atk"
version = "0.18.2"
@@ -151,6 +282,19 @@ dependencies = [
"objc2",
]
+[[package]]
+name = "blocking"
+version = "1.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
+dependencies = [
+ "async-channel",
+ "async-task",
+ "futures-io",
+ "futures-lite",
+ "piper",
+]
+
[[package]]
name = "brotli"
version = "8.0.2"
@@ -338,6 +482,7 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-dialog",
+ "tauri-plugin-notification",
"tauri-plugin-process",
"tauri-plugin-shell",
"tauri-plugin-updater",
@@ -355,6 +500,15 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
[[package]]
name = "convert_case"
version = "0.4.0"
@@ -777,6 +931,33 @@ dependencies = [
"cfg-if",
]
+[[package]]
+name = "endi"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
+
+[[package]]
+name = "enumflags2"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
+dependencies = [
+ "enumflags2_derive",
+ "serde",
+]
+
+[[package]]
+name = "enumflags2_derive"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -804,6 +985,27 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "event-listener"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
+dependencies = [
+ "concurrent-queue",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
[[package]]
name = "fastrand"
version = "2.4.1"
@@ -963,6 +1165,19 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
[[package]]
name = "futures-macro"
version = "0.3.32"
@@ -1348,6 +1563,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+[[package]]
+name = "hermit-abi"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
+
[[package]]
name = "hex"
version = "0.4.3"
@@ -1946,6 +2167,18 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
+[[package]]
+name = "mac-notification-sys"
+version = "0.6.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3"
+dependencies = [
+ "cc",
+ "objc2",
+ "objc2-foundation",
+ "time",
+]
+
[[package]]
name = "markup5ever"
version = "0.14.1"
@@ -2111,6 +2344,20 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
+[[package]]
+name = "notify-rust"
+version = "4.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3bdaf6120b9df005d37e58f6b75329be6255450453fbeba9ce4192324f921fb9"
+dependencies = [
+ "futures-lite",
+ "log",
+ "mac-notification-sys",
+ "serde",
+ "tauri-winrt-notification",
+ "zbus",
+]
+
[[package]]
name = "num-conv"
version = "0.2.1"
@@ -2314,6 +2561,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+[[package]]
+name = "ordered-stream"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+]
+
[[package]]
name = "os_pipe"
version = "1.2.3"
@@ -2363,6 +2620,12 @@ dependencies = [
"system-deps",
]
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -2591,6 +2854,17 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+[[package]]
+name = "piper"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
+dependencies = [
+ "atomic-waker",
+ "fastrand",
+ "futures-io",
+]
+
[[package]]
name = "pkg-config"
version = "0.3.33"
@@ -2611,7 +2885,7 @@ checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
dependencies = [
"base64 0.22.1",
"indexmap 2.14.0",
- "quick-xml",
+ "quick-xml 0.39.2",
"serde",
"time",
]
@@ -2629,6 +2903,20 @@ dependencies = [
"miniz_oxide",
]
+[[package]]
+name = "polling"
+version = "3.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
+dependencies = [
+ "cfg-if",
+ "concurrent-queue",
+ "hermit-abi",
+ "pin-project-lite",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "portable-pty"
version = "0.9.0"
@@ -2758,6 +3046,15 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "quick-xml"
+version = "0.37.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "quick-xml"
version = "0.39.2"
@@ -2813,6 +3110,16 @@ dependencies = [
"rand_core 0.6.4",
]
+[[package]]
+name = "rand"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
+dependencies = [
+ "rand_chacha 0.9.0",
+ "rand_core 0.9.5",
+]
+
[[package]]
name = "rand_chacha"
version = "0.2.2"
@@ -2833,6 +3140,16 @@ dependencies = [
"rand_core 0.6.4",
]
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.5",
+]
+
[[package]]
name = "rand_core"
version = "0.5.1"
@@ -2851,6 +3168,15 @@ dependencies = [
"getrandom 0.2.17",
]
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
[[package]]
name = "rand_hc"
version = "0.2.0"
@@ -4010,6 +4336,25 @@ dependencies = [
"url",
]
+[[package]]
+name = "tauri-plugin-notification"
+version = "2.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
+dependencies = [
+ "log",
+ "notify-rust",
+ "rand 0.9.4",
+ "serde",
+ "serde_json",
+ "serde_repr",
+ "tauri",
+ "tauri-plugin",
+ "thiserror 2.0.18",
+ "time",
+ "url",
+]
+
[[package]]
name = "tauri-plugin-process"
version = "2.3.1"
@@ -4176,6 +4521,18 @@ dependencies = [
"toml 1.1.2+spec-1.1.0",
]
+[[package]]
+name = "tauri-winrt-notification"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
+dependencies = [
+ "quick-xml 0.37.5",
+ "thiserror 2.0.18",
+ "windows",
+ "windows-version",
+]
+
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -4500,9 +4857,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
+ "tracing-attributes",
"tracing-core",
]
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "tracing-core"
version = "0.1.36"
@@ -4552,6 +4921,17 @@ version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
+[[package]]
+name = "uds_windows"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
+dependencies = [
+ "memoffset",
+ "tempfile",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "unic-char-property"
version = "0.9.0"
@@ -5635,6 +6015,67 @@ dependencies = [
"synstructure",
]
+[[package]]
+name = "zbus"
+version = "5.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1"
+dependencies = [
+ "async-broadcast",
+ "async-executor",
+ "async-io",
+ "async-lock",
+ "async-process",
+ "async-recursion",
+ "async-task",
+ "async-trait",
+ "blocking",
+ "enumflags2",
+ "event-listener",
+ "futures-core",
+ "futures-lite",
+ "hex",
+ "libc",
+ "ordered-stream",
+ "rustix",
+ "serde",
+ "serde_repr",
+ "tracing",
+ "uds_windows",
+ "uuid",
+ "windows-sys 0.61.2",
+ "winnow 1.0.2",
+ "zbus_macros",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus_macros"
+version = "5.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff"
+dependencies = [
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "zbus_names",
+ "zvariant",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zbus_names"
+version = "4.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d"
+dependencies = [
+ "serde",
+ "winnow 1.0.2",
+ "zvariant",
+]
+
[[package]]
name = "zerocopy"
version = "0.8.48"
@@ -5732,3 +6173,43 @@ name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+
+[[package]]
+name = "zvariant"
+version = "5.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db0ecb8987cf5e92653c57c098f7f0e39a03112edb796f4fe089fb7eaa14ff"
+dependencies = [
+ "endi",
+ "enumflags2",
+ "serde",
+ "winnow 1.0.2",
+ "zvariant_derive",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_derive"
+version = "5.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b949b639ab1b4bed763aa7481ba0e368af68d8b55532f8ed4bec86a59f2ca98"
+dependencies = [
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_utils"
+version = "3.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde",
+ "syn 2.0.117",
+ "winnow 1.0.2",
+]
diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml
index 85fcb995..8e624e53 100644
--- a/desktop/src-tauri/Cargo.toml
+++ b/desktop/src-tauri/Cargo.toml
@@ -22,3 +22,4 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1.0.102"
portable-pty = "0.9.0"
+tauri-plugin-notification = "2"
diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json
index b532e822..74d0a118 100644
--- a/desktop/src-tauri/capabilities/default.json
+++ b/desktop/src-tauri/capabilities/default.json
@@ -99,6 +99,7 @@
},
"dialog:allow-open",
"dialog:allow-save",
+ "notification:default",
"process:allow-exit",
"process:allow-restart",
"updater:default"
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 5b0312ce..96d3af24 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -1223,6 +1223,7 @@ pub fn run() {
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_process::init())
+ .plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.invoke_handler(tauri::generate_handler![
get_server_url,
diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx
index 6cb2e391..6fb6fdfd 100644
--- a/desktop/src/App.tsx
+++ b/desktop/src/App.tsx
@@ -1,5 +1,7 @@
import { AppShell } from './components/layout/AppShell'
+import { useScheduledTaskDesktopNotifications } from './hooks/useScheduledTaskDesktopNotifications'
export function App() {
+ useScheduledTaskDesktopNotifications()
return
}
diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx
index e7918317..526de846 100644
--- a/desktop/src/__tests__/generalSettings.test.tsx
+++ b/desktop/src/__tests__/generalSettings.test.tsx
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { fireEvent, render, screen, within } from '@testing-library/react'
+import { act, fireEvent, render, screen, within } from '@testing-library/react'
import '@testing-library/jest-dom'
import { Settings } from '../pages/Settings'
@@ -12,6 +12,11 @@ import type { ProviderPreset } from '../types/providerPreset'
const MOCK_DELETE_PROVIDER = vi.fn()
const MOCK_GET_SETTINGS = vi.fn()
const MOCK_UPDATE_SETTINGS = vi.fn()
+const desktopNotificationsMock = vi.hoisted(() => ({
+ getDesktopNotificationPermission: vi.fn(),
+ requestDesktopNotificationPermission: vi.fn(),
+ openDesktopNotificationSettings: vi.fn(),
+}))
const providerStoreState = {
providers: [] as SavedProvider[],
activeId: null as string | null,
@@ -47,6 +52,8 @@ vi.mock('../api/providers', () => ({
},
}))
+vi.mock('../lib/desktopNotifications', () => desktopNotificationsMock)
+
vi.mock('../components/settings/ClaudeOfficialLogin', () => ({
ClaudeOfficialLogin: () =>
,
}))
@@ -87,6 +94,12 @@ vi.mock('../components/chat/CodeViewer', () => ({
describe('Settings > General tab', () => {
beforeEach(() => {
MOCK_DELETE_PROVIDER.mockReset()
+ desktopNotificationsMock.getDesktopNotificationPermission.mockReset()
+ desktopNotificationsMock.requestDesktopNotificationPermission.mockReset()
+ desktopNotificationsMock.openDesktopNotificationSettings.mockReset()
+ desktopNotificationsMock.getDesktopNotificationPermission.mockResolvedValue('default')
+ desktopNotificationsMock.requestDesktopNotificationPermission.mockResolvedValue('granted')
+ desktopNotificationsMock.openDesktopNotificationSettings.mockResolvedValue(true)
MOCK_GET_SETTINGS.mockResolvedValue({})
MOCK_UPDATE_SETTINGS.mockResolvedValue({})
providerStoreState.providers = []
@@ -108,6 +121,7 @@ describe('Settings > General tab', () => {
locale: 'en',
thinkingEnabled: true,
skipWebFetchPreflight: true,
+ desktopNotificationsEnabled: true,
webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' },
setThinkingEnabled: vi.fn().mockImplementation(async (enabled: boolean) => {
useSettingsStore.setState({ thinkingEnabled: enabled })
@@ -115,6 +129,9 @@ describe('Settings > General tab', () => {
setSkipWebFetchPreflight: vi.fn().mockImplementation(async (enabled: boolean) => {
useSettingsStore.setState({ skipWebFetchPreflight: enabled })
}),
+ setDesktopNotificationsEnabled: vi.fn().mockImplementation(async (enabled: boolean) => {
+ useSettingsStore.setState({ desktopNotificationsEnabled: enabled })
+ }),
setWebSearch: vi.fn().mockImplementation(async (webSearch) => {
useSettingsStore.setState({ webSearch })
}),
@@ -170,6 +187,49 @@ describe('Settings > General tab', () => {
expect(useSettingsStore.getState().setThinkingEnabled).toHaveBeenCalledWith(false)
})
+ it('lets the user disable desktop system notifications', () => {
+ render()
+
+ fireEvent.click(screen.getByText('General'))
+
+ const toggle = screen.getByLabelText('Enable system notifications')
+ expect(toggle).toBeChecked()
+ fireEvent.click(toggle)
+
+ expect(useSettingsStore.getState().setDesktopNotificationsEnabled).toHaveBeenCalledWith(false)
+ expect(desktopNotificationsMock.requestDesktopNotificationPermission).not.toHaveBeenCalled()
+ })
+
+ it('requests native notification permission when desktop notifications are enabled', async () => {
+ useSettingsStore.setState({ desktopNotificationsEnabled: false })
+ render()
+
+ fireEvent.click(screen.getByText('General'))
+ await act(async () => {
+ fireEvent.click(screen.getByLabelText('Enable system notifications'))
+ })
+
+ expect(useSettingsStore.getState().setDesktopNotificationsEnabled).toHaveBeenCalledWith(true)
+ await vi.waitFor(() => {
+ expect(desktopNotificationsMock.requestDesktopNotificationPermission).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ it('opens system settings when enabling notifications finds system denial', async () => {
+ useSettingsStore.setState({ desktopNotificationsEnabled: false })
+ desktopNotificationsMock.requestDesktopNotificationPermission.mockResolvedValue('denied')
+ render()
+
+ fireEvent.click(screen.getByText('General'))
+ await act(async () => {
+ fireEvent.click(screen.getByLabelText('Enable system notifications'))
+ })
+
+ await vi.waitFor(() => {
+ expect(desktopNotificationsMock.openDesktopNotificationSettings).toHaveBeenCalledTimes(1)
+ })
+ })
+
it('saves WebSearch fallback provider settings', () => {
render()
diff --git a/desktop/src/components/tasks/NewTaskModal.tsx b/desktop/src/components/tasks/NewTaskModal.tsx
index 284dfefb..16a72e3e 100644
--- a/desktop/src/components/tasks/NewTaskModal.tsx
+++ b/desktop/src/components/tasks/NewTaskModal.tsx
@@ -12,6 +12,8 @@ import { describeCron, isValidCron, parseCron, type FrequencyKey } from '../../l
import type { PermissionMode } from '../../types/settings'
import type { CronTask } from '../../types/task'
+type NotificationChannel = 'desktop' | 'telegram' | 'feishu'
+
type Props = {
open: boolean
onClose: () => void
@@ -95,7 +97,7 @@ export function NewTaskModal({ open, onClose, editTask }: Props) {
const [folderPath, setFolderPath] = useState(editTask?.folderPath || defaultWorkDir)
const [useWorktree, setUseWorktree] = useState(editTask?.useWorktree || false)
const [notifyEnabled, setNotifyEnabled] = useState(editTask?.notification?.enabled || false)
- const [notifyChannels, setNotifyChannels] = useState<('telegram' | 'feishu')[]>(editTask?.notification?.channels || [])
+ const [notifyChannels, setNotifyChannels] = useState(editTask?.notification?.channels || [])
const [isSubmitting, setIsSubmitting] = useState(false)
// Enhanced scheduling state
@@ -117,7 +119,8 @@ export function NewTaskModal({ open, onClose, editTask }: Props) {
description.trim() &&
prompt.trim() &&
(frequency !== 'customCron' || isValidCron(customCron)) &&
- (frequency !== 'specificDays' || selectedDays.length > 0)
+ (frequency !== 'specificDays' || selectedDays.length > 0) &&
+ (!notifyEnabled || notifyChannels.length > 0)
const handleSubmit = async () => {
if (!canSubmit) return
@@ -331,7 +334,12 @@ export function NewTaskModal({ open, onClose, editTask }: Props) {
setNotifyEnabled(e.target.checked)}
+ onChange={(e) => {
+ setNotifyEnabled(e.target.checked)
+ if (e.target.checked && notifyChannels.length === 0) {
+ setNotifyChannels(['desktop'])
+ }
+ }}
className="w-4 h-4 rounded border-[var(--color-border)] accent-[var(--color-brand)]"
/>
@@ -342,6 +350,19 @@ export function NewTaskModal({ open, onClose, editTask }: Props) {
{notifyEnabled && (
diff --git a/desktop/src/hooks/useScheduledTaskDesktopNotifications.test.tsx b/desktop/src/hooks/useScheduledTaskDesktopNotifications.test.tsx
new file mode 100644
index 00000000..0c8efca1
--- /dev/null
+++ b/desktop/src/hooks/useScheduledTaskDesktopNotifications.test.tsx
@@ -0,0 +1,129 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { render } from '@testing-library/react'
+import { useScheduledTaskDesktopNotifications } from './useScheduledTaskDesktopNotifications'
+
+const { listMock, getRecentRunsMock, notifyDesktopMock } = vi.hoisted(() => ({
+ listMock: vi.fn(),
+ getRecentRunsMock: vi.fn(),
+ notifyDesktopMock: vi.fn(),
+}))
+
+vi.mock('../api/tasks', () => ({
+ tasksApi: {
+ list: listMock,
+ getRecentRuns: getRecentRunsMock,
+ },
+}))
+
+vi.mock('../lib/desktopNotifications', () => ({
+ notifyDesktop: notifyDesktopMock,
+}))
+
+function Harness() {
+ useScheduledTaskDesktopNotifications()
+ return null
+}
+
+describe('useScheduledTaskDesktopNotifications', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ localStorage.clear()
+ listMock.mockReset()
+ getRecentRunsMock.mockReset()
+ notifyDesktopMock.mockReset()
+ })
+
+ it('does not notify old runs on first poll and notifies new desktop-enabled task runs later', async () => {
+ listMock.mockResolvedValue({
+ tasks: [{
+ id: 'task-1',
+ name: 'Daily review',
+ cron: '* * * * *',
+ prompt: 'review',
+ enabled: true,
+ createdAt: 1,
+ notification: { enabled: true, channels: ['desktop'] },
+ }],
+ })
+ getRecentRunsMock
+ .mockResolvedValueOnce({
+ runs: [{
+ id: 'run-old',
+ taskId: 'task-1',
+ taskName: 'Daily review',
+ startedAt: '2026-05-03T00:00:00.000Z',
+ completedAt: '2026-05-03T00:00:01.000Z',
+ status: 'completed',
+ prompt: 'review',
+ output: 'old result',
+ }],
+ })
+ .mockResolvedValueOnce({
+ runs: [
+ {
+ id: 'run-old',
+ taskId: 'task-1',
+ taskName: 'Daily review',
+ startedAt: '2026-05-03T00:00:00.000Z',
+ completedAt: '2026-05-03T00:00:01.000Z',
+ status: 'completed',
+ prompt: 'review',
+ output: 'old result',
+ },
+ {
+ id: 'run-new',
+ taskId: 'task-1',
+ taskName: 'Daily review',
+ startedAt: '2026-05-03T00:01:00.000Z',
+ completedAt: '2026-05-03T00:01:01.000Z',
+ status: 'failed',
+ prompt: 'review',
+ error: 'provider timeout',
+ },
+ ],
+ })
+
+ render(
)
+ await vi.waitFor(() => expect(getRecentRunsMock).toHaveBeenCalledTimes(1))
+ expect(notifyDesktopMock).not.toHaveBeenCalled()
+
+ await vi.advanceTimersByTimeAsync(30_000)
+ await vi.waitFor(() => expect(notifyDesktopMock).toHaveBeenCalledTimes(1))
+ expect(notifyDesktopMock).toHaveBeenCalledWith({
+ dedupeKey: 'scheduled-task:run-new',
+ title: '定时任务 Daily review',
+ body: '失败: provider timeout',
+ })
+ })
+
+ it('ignores task runs without the desktop notification channel', async () => {
+ listMock.mockResolvedValue({
+ tasks: [{
+ id: 'task-1',
+ name: 'IM only',
+ cron: '* * * * *',
+ prompt: 'review',
+ enabled: true,
+ createdAt: 1,
+ notification: { enabled: true, channels: ['telegram'] },
+ }],
+ })
+ getRecentRunsMock.mockResolvedValue({
+ runs: [{
+ id: 'run-1',
+ taskId: 'task-1',
+ taskName: 'IM only',
+ startedAt: '2026-05-03T00:00:00.000Z',
+ completedAt: '2026-05-03T00:00:01.000Z',
+ status: 'completed',
+ prompt: 'review',
+ }],
+ })
+
+ render(
)
+ await vi.waitFor(() => expect(getRecentRunsMock).toHaveBeenCalledTimes(1))
+ await vi.advanceTimersByTimeAsync(30_000)
+
+ expect(notifyDesktopMock).not.toHaveBeenCalled()
+ })
+})
diff --git a/desktop/src/hooks/useScheduledTaskDesktopNotifications.ts b/desktop/src/hooks/useScheduledTaskDesktopNotifications.ts
new file mode 100644
index 00000000..7bf70ad3
--- /dev/null
+++ b/desktop/src/hooks/useScheduledTaskDesktopNotifications.ts
@@ -0,0 +1,117 @@
+import { useEffect } from 'react'
+import { tasksApi } from '../api/tasks'
+import { notifyDesktop } from '../lib/desktopNotifications'
+import type { CronTask, TaskRun } from '../types/task'
+
+const POLL_INTERVAL_MS = 30_000
+const NOTIFIED_RUNS_STORAGE_KEY = 'cc-haha.notifiedDesktopTaskRuns.v1'
+const MAX_STORED_RUN_IDS = 200
+
+function isTerminalRun(run: TaskRun): boolean {
+ return run.status === 'completed' || run.status === 'failed' || run.status === 'timeout'
+}
+
+function hasDesktopNotification(task: CronTask | undefined): boolean {
+ return !!task?.notification?.enabled && task.notification.channels.includes('desktop')
+}
+
+function readNotifiedRunIds(): Set
{
+ try {
+ const raw = localStorage.getItem(NOTIFIED_RUNS_STORAGE_KEY)
+ const parsed = raw ? JSON.parse(raw) : []
+ return new Set(Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [])
+ } catch {
+ return new Set()
+ }
+}
+
+function writeNotifiedRunIds(runIds: Set): void {
+ try {
+ const trimmed = [...runIds].slice(-MAX_STORED_RUN_IDS)
+ localStorage.setItem(NOTIFIED_RUNS_STORAGE_KEY, JSON.stringify(trimmed))
+ } catch {
+ // Notification dedupe is best-effort; storage failures should not break the app.
+ }
+}
+
+function formatTaskRunNotification(run: TaskRun): { title: string; body: string } {
+ const status = run.status === 'completed'
+ ? '完成'
+ : run.status === 'failed'
+ ? '失败'
+ : '超时'
+ const detail = run.error || run.output || run.prompt
+ const body = detail
+ ? `${status}: ${detail.slice(0, 160)}`
+ : `状态: ${status}`
+
+ return {
+ title: `定时任务 ${run.taskName || run.taskId}`,
+ body,
+ }
+}
+
+export function collectDesktopNotifiableRuns(
+ tasks: CronTask[],
+ runs: TaskRun[],
+ notifiedRunIds: Set,
+): TaskRun[] {
+ const taskById = new Map(tasks.map((task) => [task.id, task]))
+ return runs
+ .filter((run) => isTerminalRun(run))
+ .filter((run) => hasDesktopNotification(taskById.get(run.taskId)))
+ .filter((run) => !notifiedRunIds.has(run.id))
+ .sort((a, b) => Date.parse(a.completedAt ?? a.startedAt) - Date.parse(b.completedAt ?? b.startedAt))
+}
+
+export function useScheduledTaskDesktopNotifications(): void {
+ useEffect(() => {
+ let stopped = false
+ let initialized = false
+
+ const poll = async () => {
+ try {
+ const [{ tasks }, { runs }] = await Promise.all([
+ tasksApi.list(),
+ tasksApi.getRecentRuns(50),
+ ])
+ if (stopped) return
+
+ const notifiedRunIds = readNotifiedRunIds()
+ const pendingRuns = collectDesktopNotifiableRuns(tasks, runs, notifiedRunIds)
+
+ if (!initialized) {
+ for (const run of pendingRuns) notifiedRunIds.add(run.id)
+ writeNotifiedRunIds(notifiedRunIds)
+ initialized = true
+ return
+ }
+
+ for (const run of pendingRuns) {
+ const notification = formatTaskRunNotification(run)
+ notifyDesktop({
+ dedupeKey: `scheduled-task:${run.id}`,
+ title: notification.title,
+ body: notification.body,
+ })
+ notifiedRunIds.add(run.id)
+ }
+ writeNotifiedRunIds(notifiedRunIds)
+ } catch (err) {
+ if (typeof console !== 'undefined') {
+ console.warn('[scheduledTaskNotifications] failed to poll task runs:', err)
+ }
+ }
+ }
+
+ void poll()
+ const interval = window.setInterval(() => {
+ void poll()
+ }, POLL_INTERVAL_MS)
+
+ return () => {
+ stopped = true
+ window.clearInterval(interval)
+ }
+ }, [])
+}
diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts
index 11a426a7..d44a52b0 100644
--- a/desktop/src/i18n/locales/en.ts
+++ b/desktop/src/i18n/locales/en.ts
@@ -553,6 +553,18 @@ export const en = {
'settings.general.thinkingDescription': 'Controls whether new sessions start with model thinking enabled. When off, compatible providers such as DeepSeek receive an explicit non-thinking parameter.',
'settings.general.thinkingEnabled': 'Enable thinking mode',
'settings.general.thinkingHint': 'Turn this off to start new sessions with --thinking disabled; useful for DeepSeek V4 Flash/Pro and other non-thinking workflows.',
+ 'settings.general.notificationsTitle': 'System Notifications',
+ 'settings.general.notificationsDescription': 'Use native OS notifications for permission prompts and scheduled task results.',
+ 'settings.general.notificationsEnabled': 'Enable system notifications',
+ 'settings.general.notificationsHintOn': 'When enabled, the app will request notification permission and use the OS notification center.',
+ 'settings.general.notificationsHintOff': 'When disabled, permission prompts and scheduled tasks will not send desktop notifications.',
+ 'settings.general.notificationsStatus': 'Permission',
+ 'settings.general.notificationsStatusGranted': 'Granted',
+ 'settings.general.notificationsStatusDenied': 'Blocked by system settings',
+ 'settings.general.notificationsStatusDefault': 'Not requested',
+ 'settings.general.notificationsStatusUnsupported': 'Unavailable in this environment',
+ 'settings.general.notificationsAuthorize': 'Authorize',
+ 'settings.general.notificationsOpenSettings': 'Open Settings',
'settings.general.webFetchPreflightTitle': 'WebFetch Preflight',
'settings.general.webFetchPreflightDescription': 'Desktop sessions skip Claude\'s domain preflight by default to avoid false failures on third-party providers and restricted networks.',
'settings.general.webFetchPreflightEnabled': 'Skip WebFetch domain preflight',
@@ -842,9 +854,11 @@ export const en = {
'newTask.notification': 'Notification',
'newTask.notifyOnComplete': 'Push notification on completion',
'newTask.notifyChannels': 'Notification channels',
- 'newTask.notifyHint': 'Send the task title and result to your IM channels when done.',
+ 'newTask.notifyHint': 'Send a native desktop notification or IM message when the task finishes.',
+ 'newTask.notifyDesktop': 'Desktop',
'newTask.notConfigured': 'Not configured',
'newTask.noChannelConfigured': 'No IM channels configured yet. Go to Settings → IM Adapters to set up.',
+ 'newTask.noChannelSelected': 'Select at least one notification channel.',
// ─── Cron Descriptions ──────────────────────────────────────
'cron.everyMinute': 'Runs every minute',
diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts
index 7e01828e..1a98596b 100644
--- a/desktop/src/i18n/locales/zh.ts
+++ b/desktop/src/i18n/locales/zh.ts
@@ -555,6 +555,18 @@ export const zh: Record = {
'settings.general.thinkingDescription': '控制新会话是否启用模型思考。关闭后,DeepSeek 等兼容供应商会收到显式非思考模式参数。',
'settings.general.thinkingEnabled': '启用思考模式',
'settings.general.thinkingHint': '关闭后会以 --thinking disabled 启动新会话;适合 DeepSeek V4 Flash/Pro 等需要非思考模式的模型。',
+ 'settings.general.notificationsTitle': '系统通知',
+ 'settings.general.notificationsDescription': '使用操作系统原生通知提醒授权确认和定时任务结果。',
+ 'settings.general.notificationsEnabled': '启用系统通知',
+ 'settings.general.notificationsHintOn': '开启后会请求系统通知权限,并通过系统通知中心提醒。',
+ 'settings.general.notificationsHintOff': '关闭后,授权确认和定时任务都不会发送桌面通知。',
+ 'settings.general.notificationsStatus': '权限',
+ 'settings.general.notificationsStatusGranted': '已授权',
+ 'settings.general.notificationsStatusDenied': '已被系统设置阻止',
+ 'settings.general.notificationsStatusDefault': '尚未请求',
+ 'settings.general.notificationsStatusUnsupported': '当前环境不可用',
+ 'settings.general.notificationsAuthorize': '授权通知',
+ 'settings.general.notificationsOpenSettings': '打开系统设置',
'settings.general.webFetchPreflightTitle': 'WebFetch 预检',
'settings.general.webFetchPreflightDescription': '桌面端默认跳过 Claude 的域名预检,避免第三方服务商或受限网络下出现误报失败。',
'settings.general.webFetchPreflightEnabled': '跳过 WebFetch 域名预检',
@@ -844,9 +856,11 @@ export const zh: Record = {
'newTask.notification': '通知',
'newTask.notifyOnComplete': '完成后推送通知',
'newTask.notifyChannels': '通知渠道',
- 'newTask.notifyHint': '任务完成后将标题和结果推送到 IM 渠道。',
+ 'newTask.notifyHint': '任务完成后发送原生桌面通知或 IM 消息。',
+ 'newTask.notifyDesktop': '桌面',
'newTask.notConfigured': '未配置',
'newTask.noChannelConfigured': '尚未配置任何 IM 渠道,请前往 设置 → IM 接入 进行配置。',
+ 'newTask.noChannelSelected': '请至少选择一个通知渠道。',
// ─── Cron 描述 ──────────────────────────────────────
'cron.everyMinute': '每分钟执行',
diff --git a/desktop/src/lib/desktopNotifications.test.ts b/desktop/src/lib/desktopNotifications.test.ts
new file mode 100644
index 00000000..bf6ddaec
--- /dev/null
+++ b/desktop/src/lib/desktopNotifications.test.ts
@@ -0,0 +1,128 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const notificationPluginMock = vi.hoisted(() => ({
+ isPermissionGranted: vi.fn(),
+ requestPermission: vi.fn(),
+ sendNotification: vi.fn(),
+}))
+
+vi.mock('@tauri-apps/plugin-notification', () => notificationPluginMock)
+
+import {
+ getDesktopNotificationPermission,
+ notifyDesktop,
+ requestDesktopNotificationPermission,
+ resetDesktopNotificationsForTests,
+ setNativeNotificationSenderForTests,
+} from './desktopNotifications'
+import { useSettingsStore } from '../stores/settingsStore'
+
+describe('desktopNotifications', () => {
+ beforeEach(() => {
+ vi.useRealTimers()
+ resetDesktopNotificationsForTests()
+ notificationPluginMock.isPermissionGranted.mockReset()
+ notificationPluginMock.requestPermission.mockReset()
+ notificationPluginMock.sendNotification.mockReset()
+ useSettingsStore.setState({ desktopNotificationsEnabled: true })
+ })
+
+ it('requests native notification permission before sending through the Tauri plugin', async () => {
+ notificationPluginMock.isPermissionGranted.mockResolvedValue(false)
+ notificationPluginMock.requestPermission.mockResolvedValue('granted')
+
+ notifyDesktop({
+ dedupeKey: 'permission:1',
+ title: 'Permission required',
+ body: 'Approve command execution',
+ })
+
+ await vi.waitFor(() => expect(notificationPluginMock.sendNotification).toHaveBeenCalledTimes(1))
+ expect(notificationPluginMock.isPermissionGranted).toHaveBeenCalledTimes(1)
+ expect(notificationPluginMock.requestPermission).toHaveBeenCalledTimes(1)
+ expect(notificationPluginMock.sendNotification).toHaveBeenCalledWith({
+ title: 'Permission required',
+ body: 'Approve command execution',
+ })
+ })
+
+ it('does not fall back to sound when native notification permission is denied', async () => {
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ notificationPluginMock.isPermissionGranted.mockResolvedValue(false)
+ notificationPluginMock.requestPermission.mockResolvedValue('denied')
+
+ notifyDesktop({ title: 'Permission required' })
+
+ await vi.waitFor(() => expect(warnSpy).toHaveBeenCalled())
+ expect(notificationPluginMock.sendNotification).not.toHaveBeenCalled()
+ expect(warnSpy).toHaveBeenCalledWith(
+ '[desktopNotifications] native notification permission was not granted',
+ )
+ warnSpy.mockRestore()
+ })
+
+ it('does not send or consume dedupe keys when desktop notifications are disabled', async () => {
+ const sender = vi.fn(async () => true)
+ setNativeNotificationSenderForTests(sender)
+ useSettingsStore.setState({ desktopNotificationsEnabled: false })
+
+ notifyDesktop({ dedupeKey: 'permission:1', title: 'Permission required' })
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ expect(sender).not.toHaveBeenCalled()
+
+ useSettingsStore.setState({ desktopNotificationsEnabled: true })
+ notifyDesktop({ dedupeKey: 'permission:1', title: 'Permission required' })
+ await vi.waitFor(() => expect(sender).toHaveBeenCalledTimes(1))
+ })
+
+ it('reports and requests native notification permission', async () => {
+ notificationPluginMock.isPermissionGranted.mockResolvedValueOnce(false).mockResolvedValueOnce(false)
+ notificationPluginMock.requestPermission.mockResolvedValue('granted')
+ Object.defineProperty(window, 'Notification', {
+ configurable: true,
+ value: { permission: 'default' },
+ })
+
+ await expect(getDesktopNotificationPermission()).resolves.toBe('default')
+ await expect(requestDesktopNotificationPermission()).resolves.toBe('granted')
+ expect(notificationPluginMock.requestPermission).toHaveBeenCalledTimes(1)
+ })
+
+ it('sends a native notification once for a dedupe key', async () => {
+ const sender = vi.fn(async () => true)
+ setNativeNotificationSenderForTests(sender)
+
+ notifyDesktop({
+ dedupeKey: 'permission:1',
+ title: 'Permission required',
+ body: 'Approve command execution',
+ })
+ notifyDesktop({
+ dedupeKey: 'permission:1',
+ title: 'Permission required',
+ body: 'Approve command execution',
+ })
+ await vi.waitFor(() => expect(sender).toHaveBeenCalledTimes(1))
+
+ expect(sender).toHaveBeenCalledWith({
+ title: 'Permission required',
+ body: 'Approve command execution',
+ })
+ })
+
+ it('throttles bursts within the same cooldown scope', async () => {
+ vi.useFakeTimers()
+ const sender = vi.fn(async () => true)
+ setNativeNotificationSenderForTests(sender)
+
+ notifyDesktop({ dedupeKey: 'permission:1', cooldownScope: 'permission', title: 'One' })
+ notifyDesktop({ dedupeKey: 'permission:2', cooldownScope: 'permission', title: 'Two' })
+ await vi.runAllTimersAsync()
+ expect(sender).toHaveBeenCalledTimes(1)
+
+ vi.advanceTimersByTime(751)
+ notifyDesktop({ dedupeKey: 'permission:3', cooldownScope: 'permission', title: 'Three' })
+ await vi.runAllTimersAsync()
+ expect(sender).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/desktop/src/lib/desktopNotifications.ts b/desktop/src/lib/desktopNotifications.ts
new file mode 100644
index 00000000..6e5b06a7
--- /dev/null
+++ b/desktop/src/lib/desktopNotifications.ts
@@ -0,0 +1,154 @@
+import { useSettingsStore } from '../stores/settingsStore'
+
+const DEFAULT_COOLDOWN_MS = 750
+
+export type DesktopNotificationOptions = {
+ title: string
+ body?: string
+ dedupeKey?: string
+ cooldownScope?: string
+ cooldownMs?: number
+}
+
+type NativeNotificationSender = (options: { title: string; body?: string }) => Promise | boolean
+export type DesktopNotificationPermission = NotificationPermission | 'unsupported'
+
+const notifiedKeys = new Set()
+const lastNotificationAtByScope = new Map()
+let overrideNativeNotificationSender: NativeNotificationSender | null = null
+
+function readBrowserNotificationPermission(): DesktopNotificationPermission {
+ if (typeof window === 'undefined' || !('Notification' in window)) {
+ return 'unsupported'
+ }
+ return window.Notification.permission
+}
+
+function detectPlatform(): 'darwin' | 'win32' | 'linux' | 'unknown' {
+ const platform = typeof navigator !== 'undefined' ? navigator.platform.toLowerCase() : ''
+ const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent.toLowerCase() : ''
+ const raw = `${platform} ${userAgent}`
+ if (raw.includes('mac')) return 'darwin'
+ if (raw.includes('win')) return 'win32'
+ if (raw.includes('linux')) return 'linux'
+ return 'unknown'
+}
+
+function getNotificationSettingsUrl(): string | null {
+ switch (detectPlatform()) {
+ case 'darwin':
+ return 'x-apple.systempreferences:com.apple.preference.notifications'
+ case 'win32':
+ return 'ms-settings:notifications'
+ default:
+ return null
+ }
+}
+
+export async function getDesktopNotificationPermission(): Promise {
+ try {
+ const { isPermissionGranted } = await import('@tauri-apps/plugin-notification')
+ if (await isPermissionGranted()) return 'granted'
+ } catch {
+ // Fall back to the Web Notification permission state below.
+ }
+ return readBrowserNotificationPermission()
+}
+
+export async function requestDesktopNotificationPermission(): Promise {
+ try {
+ const {
+ isPermissionGranted,
+ requestPermission,
+ } = await import('@tauri-apps/plugin-notification')
+
+ if (await isPermissionGranted()) return 'granted'
+ return await requestPermission()
+ } catch {
+ return readBrowserNotificationPermission()
+ }
+}
+
+export async function openDesktopNotificationSettings(): Promise {
+ const url = getNotificationSettingsUrl()
+ if (!url) return false
+
+ try {
+ const { open } = await import('@tauri-apps/plugin-shell')
+ await open(url)
+ return true
+ } catch {
+ try {
+ window.open(url, '_blank', 'noopener,noreferrer')
+ return true
+ } catch {
+ return false
+ }
+ }
+}
+
+async function sendNativeNotification(options: { title: string; body?: string }): Promise {
+ const {
+ isPermissionGranted,
+ requestPermission,
+ sendNotification,
+ } = await import('@tauri-apps/plugin-notification')
+
+ let permissionGranted = await isPermissionGranted()
+ if (!permissionGranted) {
+ const permission = await requestPermission()
+ permissionGranted = permission === 'granted'
+ }
+
+ if (!permissionGranted) {
+ return false
+ }
+
+ sendNotification(options)
+ return true
+}
+
+export function notifyDesktop(options: DesktopNotificationOptions): void {
+ if (!useSettingsStore.getState().desktopNotificationsEnabled) {
+ return
+ }
+
+ if (options.dedupeKey && notifiedKeys.has(options.dedupeKey)) {
+ return
+ }
+
+ const cooldownScope = options.cooldownScope
+ if (cooldownScope) {
+ const now = Date.now()
+ const lastNotificationAt = lastNotificationAtByScope.get(cooldownScope) ?? 0
+ if (now - lastNotificationAt < (options.cooldownMs ?? DEFAULT_COOLDOWN_MS)) {
+ return
+ }
+ lastNotificationAtByScope.set(cooldownScope, now)
+ }
+
+ if (options.dedupeKey) {
+ notifiedKeys.add(options.dedupeKey)
+ }
+
+ const sender = overrideNativeNotificationSender ?? sendNativeNotification
+ void Promise.resolve(sender({ title: options.title, body: options.body })).then((sent) => {
+ if (!sent && typeof console !== 'undefined') {
+ console.warn('[desktopNotifications] native notification permission was not granted')
+ }
+ }).catch((err) => {
+ if (typeof console !== 'undefined') {
+ console.warn('[desktopNotifications] failed to send native notification:', err)
+ }
+ })
+}
+
+export function resetDesktopNotificationsForTests(): void {
+ notifiedKeys.clear()
+ lastNotificationAtByScope.clear()
+ overrideNativeNotificationSender = null
+}
+
+export function setNativeNotificationSenderForTests(sender: NativeNotificationSender | null): void {
+ overrideNativeNotificationSender = sender
+}
diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx
index 7881bd7e..b4067485 100644
--- a/desktop/src/pages/Settings.tsx
+++ b/desktop/src/pages/Settings.tsx
@@ -31,6 +31,12 @@ import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin'
import { useUpdateStore } from '../stores/updateStore'
import { formatBytes } from '../lib/formatBytes'
import { isTauriRuntime } from '../lib/desktopRuntime'
+import {
+ getDesktopNotificationPermission,
+ openDesktopNotificationSettings,
+ requestDesktopNotificationPermission,
+ type DesktopNotificationPermission,
+} from '../lib/desktopNotifications'
export function Settings() {
const [activeTab, setActiveTab] = useState('providers')
@@ -883,17 +889,31 @@ function GeneralSettings() {
setTheme,
skipWebFetchPreflight,
setSkipWebFetchPreflight,
+ desktopNotificationsEnabled,
+ setDesktopNotificationsEnabled,
webSearch,
setWebSearch,
} = useSettingsStore()
const t = useTranslation()
const [webSearchDraft, setWebSearchDraft] = useState(webSearch)
+ const [notificationPermission, setNotificationPermission] = useState('default')
+ const [notificationActionRunning, setNotificationActionRunning] = useState(false)
const webSearchDirty = JSON.stringify(webSearchDraft) !== JSON.stringify(webSearch)
useEffect(() => {
setWebSearchDraft(webSearch)
}, [webSearch])
+ useEffect(() => {
+ let cancelled = false
+ getDesktopNotificationPermission().then((permission) => {
+ if (!cancelled) setNotificationPermission(permission)
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
const EFFORT_LABELS: Record = {
low: t('settings.general.effort.low'),
medium: t('settings.general.effort.medium'),
@@ -919,6 +939,46 @@ function GeneralSettings() {
{ value: 'disabled', label: t('settings.general.webSearch.mode.disabled') },
]
+ const notificationStatusLabel: Record = {
+ granted: t('settings.general.notificationsStatusGranted'),
+ denied: t('settings.general.notificationsStatusDenied'),
+ default: t('settings.general.notificationsStatusDefault'),
+ unsupported: t('settings.general.notificationsStatusUnsupported'),
+ }
+
+ const handleDesktopNotificationsToggle = async (enabled: boolean) => {
+ await setDesktopNotificationsEnabled(enabled)
+ if (!enabled) return
+
+ setNotificationActionRunning(true)
+ try {
+ const permission = await requestDesktopNotificationPermission()
+ setNotificationPermission(permission)
+ if (permission === 'denied') {
+ await openDesktopNotificationSettings()
+ }
+ } finally {
+ setNotificationActionRunning(false)
+ }
+ }
+
+ const handleNotificationPermissionAction = async () => {
+ setNotificationActionRunning(true)
+ try {
+ if (notificationPermission === 'denied') {
+ await openDesktopNotificationSettings()
+ } else {
+ const permission = await requestDesktopNotificationPermission()
+ setNotificationPermission(permission)
+ if (permission === 'denied') {
+ await openDesktopNotificationSettings()
+ }
+ }
+ } finally {
+ setNotificationActionRunning(false)
+ }
+ }
+
return (
{/* Appearance selector */}
@@ -1000,6 +1060,52 @@ function GeneralSettings() {
+
+
{t('settings.general.notificationsTitle')}
+
{t('settings.general.notificationsDescription')}
+
+
+ {desktopNotificationsEnabled && (
+
+
+ {t('settings.general.notificationsStatus')}: {notificationStatusLabel[notificationPermission]}
+
+ {notificationPermission !== 'granted' && notificationPermission !== 'unsupported' && (
+
+ )}
+
+ )}
+
+
+
{t('settings.general.webFetchPreflightTitle')}
{t('settings.general.webFetchPreflightDescription')}
diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts
index b518964e..7eeb9094 100644
--- a/desktop/src/stores/chatStore.test.ts
+++ b/desktop/src/stores/chatStore.test.ts
@@ -15,6 +15,7 @@ const {
markCompletedAndDismissedMock,
resetCompletedTasksMock,
refreshTasksMock,
+ notifyDesktopMock,
cliTaskStoreSnapshot,
} = vi.hoisted(() => ({
sendMock: vi.fn(),
@@ -29,12 +30,17 @@ const {
markCompletedAndDismissedMock: vi.fn(),
resetCompletedTasksMock: vi.fn(async () => {}),
refreshTasksMock: vi.fn(),
+ notifyDesktopMock: vi.fn(),
cliTaskStoreSnapshot: {
tasks: [] as Array<{ id: string; subject: string; status: string; activeForm?: string }>,
sessionId: null as string | null,
},
}))
+vi.mock('../lib/desktopNotifications', () => ({
+ notifyDesktop: notifyDesktopMock,
+}))
+
vi.mock('../api/websocket', () => ({
wsManager: {
connect: vi.fn(),
@@ -113,6 +119,7 @@ describe('chatStore history mapping', () => {
markCompletedAndDismissedMock.mockReset()
resetCompletedTasksMock.mockReset()
refreshTasksMock.mockReset()
+ notifyDesktopMock.mockReset()
cliTaskStoreSnapshot.tasks = []
cliTaskStoreSnapshot.sessionId = null
useSessionRuntimeStore.setState({ selections: {} })
@@ -686,6 +693,12 @@ describe('chatStore history mapping', () => {
type: 'tool_use',
toolUseId: 'tool-ask-1',
})
+ expect(notifyDesktopMock).toHaveBeenCalledWith({
+ dedupeKey: 'permission:perm-ask-1',
+ cooldownScope: 'permission-prompt',
+ title: 'Claude Code Haha 需要你的确认',
+ body: 'AskUserQuestion 请求执行,正在等待允许。',
+ })
})
it('sends permission mode updates to the active session only', () => {
@@ -993,6 +1006,12 @@ describe('chatStore history mapping', () => {
expect(
useChatStore.getState().sessions[TEST_SESSION_ID]?.chatState,
).toBe('permission_pending')
+ expect(notifyDesktopMock).toHaveBeenCalledWith({
+ dedupeKey: 'computer-use-permission:cu-1',
+ cooldownScope: 'permission-prompt',
+ title: 'Claude Code Haha 需要你的确认',
+ body: 'Open Finder and inspect a file',
+ })
})
it('keeps delayed text blocks from one streamed assistant turn in a single message', () => {
diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts
index 754948da..00550aae 100644
--- a/desktop/src/stores/chatStore.ts
+++ b/desktop/src/stores/chatStore.ts
@@ -7,6 +7,7 @@ import { useCLITaskStore } from './cliTaskStore'
import { useSessionRuntimeStore } from './sessionRuntimeStore'
import { useTabStore } from './tabStore'
import { randomSpinnerVerb } from '../config/spinnerVerbs'
+import { notifyDesktop } from '../lib/desktopNotifications'
import { AGENT_LIFECYCLE_TYPES } from '../types/team'
import type { MessageEntry } from '../types/session'
import type { PermissionMode } from '../types/settings'
@@ -670,6 +671,14 @@ export const useChatStore = create
((set, get) => ({
break
case 'permission_request':
+ notifyDesktop({
+ dedupeKey: `permission:${msg.requestId}`,
+ cooldownScope: 'permission-prompt',
+ title: 'Claude Code Haha 需要你的确认',
+ body: msg.toolName
+ ? `${msg.toolName} 请求执行,正在等待允许。`
+ : '有一个工具请求正在等待允许。',
+ })
update((s) => ({
pendingPermission: {
requestId: msg.requestId,
@@ -698,6 +707,12 @@ export const useChatStore = create((set, get) => ({
break
case 'computer_use_permission_request':
+ notifyDesktop({
+ dedupeKey: `computer-use-permission:${msg.requestId}`,
+ cooldownScope: 'permission-prompt',
+ title: 'Claude Code Haha 需要你的确认',
+ body: msg.request.reason || 'Computer Use 正在等待允许。',
+ })
update(() => ({
pendingComputerUsePermission: {
requestId: msg.requestId,
diff --git a/desktop/src/stores/settingsStore.test.ts b/desktop/src/stores/settingsStore.test.ts
index 02a57ada..ab6246a0 100644
--- a/desktop/src/stores/settingsStore.test.ts
+++ b/desktop/src/stores/settingsStore.test.ts
@@ -20,3 +20,60 @@ describe('settingsStore locale defaults', () => {
expect(useSettingsStore.getState().locale).toBe('en')
})
})
+
+describe('settingsStore desktop notification persistence', () => {
+ beforeEach(() => {
+ vi.resetModules()
+ vi.clearAllMocks()
+ window.localStorage.clear()
+ })
+
+ it('persists the latest desktop notification toggle when saves overlap', async () => {
+ const pendingSaves: Array<() => void> = []
+ const updateUser = vi.fn(
+ () =>
+ new Promise<{ ok: true }>((resolve) => {
+ pendingSaves.push(() => resolve({ ok: true }))
+ }),
+ )
+
+ vi.doMock('../api/settings', () => ({
+ settingsApi: {
+ getUser: vi.fn(),
+ updateUser,
+ getPermissionMode: vi.fn(),
+ setPermissionMode: vi.fn(),
+ getCliLauncherStatus: vi.fn(),
+ },
+ }))
+ vi.doMock('../api/models', () => ({
+ modelsApi: {
+ list: vi.fn(),
+ getCurrent: vi.fn(),
+ setCurrent: vi.fn(),
+ getEffort: vi.fn(),
+ setEffort: vi.fn(),
+ },
+ }))
+
+ const { useSettingsStore } = await import('./settingsStore')
+
+ const firstSave = useSettingsStore.getState().setDesktopNotificationsEnabled(false)
+ await vi.waitFor(() => {
+ expect(updateUser).toHaveBeenCalledWith({ desktopNotificationsEnabled: false })
+ })
+
+ const secondSave = useSettingsStore.getState().setDesktopNotificationsEnabled(true)
+ expect(useSettingsStore.getState().desktopNotificationsEnabled).toBe(true)
+
+ pendingSaves.shift()?.()
+ await vi.waitFor(() => {
+ expect(updateUser).toHaveBeenCalledWith({ desktopNotificationsEnabled: true })
+ })
+ pendingSaves.shift()?.()
+ await Promise.all([firstSave, secondSave])
+
+ expect(updateUser).toHaveBeenLastCalledWith({ desktopNotificationsEnabled: true })
+ expect(useSettingsStore.getState().desktopNotificationsEnabled).toBe(true)
+ })
+})
diff --git a/desktop/src/stores/settingsStore.ts b/desktop/src/stores/settingsStore.ts
index 81668221..182401a8 100644
--- a/desktop/src/stores/settingsStore.ts
+++ b/desktop/src/stores/settingsStore.ts
@@ -6,6 +6,7 @@ import type { Locale } from '../i18n'
import { useUIStore } from './uiStore'
const LOCALE_STORAGE_KEY = 'cc-haha-locale'
+let desktopNotificationsSaveQueue: Promise = Promise.resolve()
function getStoredLocale(): Locale {
try {
@@ -25,6 +26,7 @@ type SettingsStore = {
locale: Locale
theme: ThemeMode
skipWebFetchPreflight: boolean
+ desktopNotificationsEnabled: boolean
webSearch: WebSearchSettings
isLoading: boolean
error: string | null
@@ -37,6 +39,7 @@ type SettingsStore = {
setLocale: (locale: Locale) => void
setTheme: (theme: ThemeMode) => Promise
setSkipWebFetchPreflight: (enabled: boolean) => Promise
+ setDesktopNotificationsEnabled: (enabled: boolean) => Promise
setWebSearch: (settings: WebSearchSettings) => Promise
}
@@ -50,6 +53,7 @@ export const useSettingsStore = create((set, get) => ({
locale: getStoredLocale(),
theme: useUIStore.getState().theme,
skipWebFetchPreflight: true,
+ desktopNotificationsEnabled: true,
webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' },
isLoading: false,
error: null,
@@ -75,6 +79,7 @@ export const useSettingsStore = create((set, get) => ({
thinkingEnabled: userSettings.alwaysThinkingEnabled !== false,
theme,
skipWebFetchPreflight: userSettings.skipWebFetchPreflight !== false,
+ desktopNotificationsEnabled: userSettings.desktopNotificationsEnabled !== false,
webSearch: normalizeWebSearchSettings(userSettings.webSearch),
isLoading: false,
error: null,
@@ -150,6 +155,27 @@ export const useSettingsStore = create((set, get) => ({
}
},
+ setDesktopNotificationsEnabled: async (enabled) => {
+ const prev = get().desktopNotificationsEnabled
+ set({ desktopNotificationsEnabled: enabled })
+ const save = desktopNotificationsSaveQueue
+ .catch(() => undefined)
+ .then(async () => {
+ if (get().desktopNotificationsEnabled !== enabled) return
+ await settingsApi.updateUser({ desktopNotificationsEnabled: enabled })
+ })
+
+ desktopNotificationsSaveQueue = save
+
+ try {
+ await save
+ } catch {
+ if (get().desktopNotificationsEnabled === enabled) {
+ set({ desktopNotificationsEnabled: prev })
+ }
+ }
+ },
+
setWebSearch: async (webSearch) => {
const prev = get().webSearch
const next = normalizeWebSearchSettings(webSearch)
diff --git a/desktop/src/types/settings.ts b/desktop/src/types/settings.ts
index 0880fe4b..188955b4 100644
--- a/desktop/src/types/settings.ts
+++ b/desktop/src/types/settings.ts
@@ -27,6 +27,7 @@ export type UserSettings = {
permissionMode?: PermissionMode
theme?: ThemeMode
skipWebFetchPreflight?: boolean
+ desktopNotificationsEnabled?: boolean
webSearch?: WebSearchSettings
[key: string]: unknown
}
diff --git a/desktop/src/types/task.ts b/desktop/src/types/task.ts
index 9ccbbba3..f8904da2 100644
--- a/desktop/src/types/task.ts
+++ b/desktop/src/types/task.ts
@@ -2,7 +2,7 @@
export type TaskNotificationConfig = {
enabled: boolean
- channels: ('telegram' | 'feishu')[]
+ channels: ('desktop' | 'telegram' | 'feishu')[]
}
export type CronTask = {
diff --git a/src/server/services/cronService.ts b/src/server/services/cronService.ts
index a20ab924..0e147f5d 100644
--- a/src/server/services/cronService.ts
+++ b/src/server/services/cronService.ts
@@ -13,7 +13,7 @@ import { ApiError } from '../middleware/errorHandler.js'
export type TaskNotificationConfig = {
enabled: boolean
- channels: ('telegram' | 'feishu')[]
+ channels: ('desktop' | 'telegram' | 'feishu')[]
}
export type CronTask = {
diff --git a/src/server/services/notificationService.ts b/src/server/services/notificationService.ts
index 024d427b..91b9542c 100644
--- a/src/server/services/notificationService.ts
+++ b/src/server/services/notificationService.ts
@@ -224,7 +224,10 @@ export async function sendTaskNotification(
run: TaskRun,
notification: TaskNotificationConfig,
): Promise {
- if (!notification.enabled || notification.channels.length === 0) return
+ const imChannels = notification.channels.filter((channel): channel is 'telegram' | 'feishu' =>
+ channel === 'telegram' || channel === 'feishu',
+ )
+ if (!notification.enabled || imChannels.length === 0) return
let config: AdapterFileConfig
try {
@@ -236,7 +239,7 @@ export async function sendTaskNotification(
const markdown = buildMarkdown(run)
- for (const channel of notification.channels) {
+ for (const channel of imChannels) {
try {
if (channel === 'telegram') {
const botToken = config.telegram?.botToken