From a8f98406e1b5963144cb6e578dc8262ab28c795a Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Mon, 19 Jan 2026 16:18:25 -0800 Subject: [PATCH] feat: add notification sounds (#44) ### Explanation _No response_ ### Issue _No response_ ### Attestations - [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/) - [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/). - [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/). ### Dependencies - [ ] I have pinned the dependencies to a specific patch version. ### Style - [ ] I have run the linter and resolved any errors. - [ ] My pull request uses an appropriate title, matching the conventional commit standards. - [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request. ### Tests - [ ] My contribution adds new code, and I have added tests to cover it. - [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes. - [ ] All new and existing tests pass locally with my changes. - [ ] Code coverage remains at or above the configured threshold. ### Documentation _No response_ ### Versioning _No response_ Reviewed-on: https://git.nhcarrigan.com/nhcarrigan/hikari-desktop/pulls/44 Co-authored-by: Naomi Carrigan Co-committed-by: Naomi Carrigan --- package.json | 4 +- pnpm-lock.yaml | 20 ++ src-tauri/Cargo.lock | 275 +++++++++++++++++- src-tauri/Cargo.toml | 10 + src-tauri/src/config.rs | 18 ++ src-tauri/src/lib.rs | 16 + src-tauri/src/notifications.rs | 96 ++++++ src-tauri/src/vbs_notification.rs | 74 +++++ src-tauri/src/windows_toast.rs | 63 ++++ src-tauri/src/wsl_notifications.rs | 84 ++++++ src/lib/components/ConfigSidebar.svelte | 78 +++++ src/lib/components/InputBar.svelte | 4 + .../components/NotificationDebugger.svelte | 153 ++++++++++ src/lib/components/StatusBar.svelte | 2 + src/lib/notifications/index.ts | 4 + src/lib/notifications/notificationManager.ts | 121 ++++++++ src/lib/notifications/rules.ts | 103 +++++++ src/lib/notifications/soundPlayer.ts | 61 ++++ src/lib/notifications/terminalNotifier.ts | 50 ++++ src/lib/notifications/testNotifications.ts | 42 +++ src/lib/notifications/types.ts | 48 +++ .../notifications/wslNotificationHelper.ts | 16 + src/lib/stores/config.ts | 4 + src/lib/stores/notifications.ts | 22 ++ src/lib/tauri.ts | 51 +++- src/routes/+page.svelte | 29 +- static/sounds/access-please.mp3 | Bin 0 -> 15236 bytes static/sounds/connected.mp3 | Bin 0 -> 11516 bytes static/sounds/im-done.mp3 | Bin 0 -> 10101 bytes static/sounds/oh-no.mp3 | Bin 0 -> 9837 bytes static/sounds/test-notification.html | 93 ++++++ static/sounds/working-on-it.mp3 | Bin 0 -> 10653 bytes 32 files changed, 1512 insertions(+), 29 deletions(-) create mode 100644 src-tauri/src/notifications.rs create mode 100644 src-tauri/src/vbs_notification.rs create mode 100644 src-tauri/src/windows_toast.rs create mode 100644 src-tauri/src/wsl_notifications.rs create mode 100644 src/lib/components/NotificationDebugger.svelte create mode 100644 src/lib/notifications/index.ts create mode 100644 src/lib/notifications/notificationManager.ts create mode 100644 src/lib/notifications/rules.ts create mode 100644 src/lib/notifications/soundPlayer.ts create mode 100644 src/lib/notifications/terminalNotifier.ts create mode 100644 src/lib/notifications/testNotifications.ts create mode 100644 src/lib/notifications/types.ts create mode 100644 src/lib/notifications/wslNotificationHelper.ts create mode 100644 src/lib/stores/notifications.ts create mode 100644 static/sounds/access-please.mp3 create mode 100644 static/sounds/connected.mp3 create mode 100644 static/sounds/im-done.mp3 create mode 100644 static/sounds/oh-no.mp3 create mode 100644 static/sounds/test-notification.html create mode 100644 static/sounds/working-on-it.mp3 diff --git a/package.json b/package.json index 31c3337..c23d9d6 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,9 @@ "@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-shell": "^2.3.4", - "@tauri-apps/plugin-store": "^2" + "@tauri-apps/plugin-store": "^2", + "@tauri-apps/plugin-notification": "^2", + "@tauri-apps/plugin-os": "^2" }, "devDependencies": { "@eslint/js": "^9.39.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c71d0ad..1876ce3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,15 @@ importers: '@tauri-apps/plugin-dialog': specifier: ^2 version: 2.6.0 + '@tauri-apps/plugin-notification': + specifier: ^2 + version: 2.3.3 '@tauri-apps/plugin-opener': specifier: ^2 version: 2.5.3 + '@tauri-apps/plugin-os': + specifier: ^2 + version: 2.3.2 '@tauri-apps/plugin-shell': specifier: ^2.3.4 version: 2.3.4 @@ -726,9 +732,15 @@ packages: '@tauri-apps/plugin-dialog@2.6.0': resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==} + '@tauri-apps/plugin-notification@2.3.3': + resolution: {integrity: sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==} + '@tauri-apps/plugin-opener@2.5.3': resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==} + '@tauri-apps/plugin-os@2.3.2': + resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==} + '@tauri-apps/plugin-shell@2.3.4': resolution: {integrity: sha512-ktsRWf8wHLD17aZEyqE8c5x98eNAuTizR1FSX475zQ4TxaiJnhwksLygQz+AGwckJL5bfEP13nWrlTNQJUpKpA==} @@ -2263,10 +2275,18 @@ snapshots: dependencies: '@tauri-apps/api': 2.9.1 + '@tauri-apps/plugin-notification@2.3.3': + dependencies: + '@tauri-apps/api': 2.9.1 + '@tauri-apps/plugin-opener@2.5.3': dependencies: '@tauri-apps/api': 2.9.1 + '@tauri-apps/plugin-os@2.3.2': + dependencies: + '@tauri-apps/api': 2.9.1 + '@tauri-apps/plugin-shell@2.3.4': dependencies: '@tauri-apps/api': 2.9.1 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b3df986..a95f0e3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -429,6 +429,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.43" @@ -1173,6 +1179,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -1401,12 +1417,15 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-dialog", + "tauri-plugin-notification", "tauri-plugin-opener", + "tauri-plugin-os", "tauri-plugin-shell", "tauri-plugin-store", "tempfile", "tokio", "uuid", + "windows 0.62.2", ] [[package]] @@ -1909,6 +1928,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.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621" +dependencies = [ + "cc", + "objc2", + "objc2-foundation", + "time", +] + [[package]] name = "markup5ever" version = "0.14.1" @@ -2039,12 +2070,38 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nodrop" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "notify-rust" +version = "4.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6442248665a5aa2514e794af3b39661a8e73033b1cc5e59899e1276117ee4400" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -2169,6 +2226,16 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-text" version = "0.3.2" @@ -2273,8 +2340,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.10.0", + "block2", "objc2", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", "objc2-foundation", ] @@ -2328,6 +2414,22 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_info" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224" +dependencies = [ + "android_system_properties", + "log", + "nix", + "objc2", + "objc2-foundation", + "objc2-ui-kit", + "serde", + "windows-sys 0.61.2", +] + [[package]] name = "os_pipe" version = "1.2.3" @@ -2575,7 +2677,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.13.0", - "quick-xml", + "quick-xml 0.38.4", "serde", "time", ] @@ -2705,6 +2807,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.38.4" @@ -2754,6 +2865,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -2774,6 +2895,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" @@ -2792,6 +2923,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" @@ -3479,6 +3619,15 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -3526,7 +3675,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -3597,7 +3746,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -3720,6 +3869,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.2", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.17", + "time", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.3" @@ -3738,10 +3906,28 @@ dependencies = [ "tauri-plugin", "thiserror 2.0.17", "url", - "windows", + "windows 0.61.3", "zbus", ] +[[package]] +name = "tauri-plugin-os" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f08346c8deb39e96f86973da0e2d76cbb933d7ac9b750f6dc4daf955a6f997" +dependencies = [ + "gethostname", + "log", + "os_info", + "serde", + "serde_json", + "serialize-to-javascript", + "sys-locale", + "tauri", + "tauri-plugin", + "thiserror 2.0.17", +] + [[package]] name = "tauri-plugin-shell" version = "2.3.4" @@ -3801,7 +3987,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -3827,7 +4013,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -3880,6 +4066,18 @@ dependencies = [ "toml 0.9.11+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.17", + "windows 0.61.3", + "windows-version", +] + [[package]] name = "tempfile" version = "3.24.0" @@ -4557,7 +4755,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-implement", "windows-interface", @@ -4581,7 +4779,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.17", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -4637,11 +4835,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -4653,6 +4863,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -4687,7 +4906,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -4734,6 +4964,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -4863,6 +5103,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-version" version = "0.1.7" @@ -5089,7 +5338,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ffe1ec5..cb622d0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,5 +23,15 @@ tokio = { version = "1", features = ["full"] } parking_lot = "0.12" uuid = { version = "1", features = ["v4"] } tauri-plugin-store = "2.4.2" +tauri-plugin-notification = "2" +tauri-plugin-os = "2" tempfile = "3" +[target.'cfg(windows)'.dependencies] +windows = { version = "0.62", features = [ + "Data_Xml_Dom", + "UI_Notifications", + "Win32_System_Com", + "Win32_Foundation", +] } + diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index e8923b6..8aaf580 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -46,6 +46,12 @@ pub struct HikariConfig { #[serde(default)] pub greeting_custom_prompt: Option, + + #[serde(default = "default_notifications_enabled")] + pub notifications_enabled: bool, + + #[serde(default = "default_notification_volume")] + pub notification_volume: f32, } impl Default for HikariConfig { @@ -59,6 +65,8 @@ impl Default for HikariConfig { theme: Theme::default(), greeting_enabled: true, greeting_custom_prompt: None, + notifications_enabled: true, + notification_volume: 0.7, } } } @@ -67,6 +75,14 @@ fn default_greeting_enabled() -> bool { true } +fn default_notifications_enabled() -> bool { + true +} + +fn default_notification_volume() -> f32 { + 0.7 +} + #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] #[serde(rename_all = "lowercase")] pub enum Theme { @@ -103,6 +119,8 @@ mod tests { theme: Theme::Light, greeting_enabled: true, greeting_custom_prompt: Some("Hello!".to_string()), + notifications_enabled: true, + notification_volume: 0.7, }; let json = serde_json::to_string(&config).unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d529cbc..744d378 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,10 +1,18 @@ mod commands; mod config; +mod notifications; mod types; mod wsl_bridge; +mod wsl_notifications; +mod vbs_notification; +mod windows_toast; use commands::*; +use notifications::*; use wsl_bridge::create_shared_bridge; +use wsl_notifications::*; +use vbs_notification::*; +use windows_toast::*; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -15,6 +23,8 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_store::Builder::new().build()) + .plugin(tauri_plugin_notification::init()) + .plugin(tauri_plugin_os::init()) .manage(bridge) .invoke_handler(tauri::generate_handler![ start_claude, @@ -25,6 +35,12 @@ pub fn run() { select_wsl_directory, get_config, save_config, + send_windows_notification, + send_simple_notification, + send_windows_toast, + send_notify_send, + send_wsl_notification, + send_vbs_notification, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/notifications.rs b/src-tauri/src/notifications.rs new file mode 100644 index 0000000..d0f421c --- /dev/null +++ b/src-tauri/src/notifications.rs @@ -0,0 +1,96 @@ +use tauri::command; +use std::process::Command; + +#[command] +pub async fn send_notify_send(title: String, body: String) -> Result<(), String> { + // Use notify-send for Linux/WSL + let output = Command::new("notify-send") + .arg(&title) + .arg(&body) + .arg("--urgency=normal") + .arg("--app-name=Hikari Desktop") + .output() + .map_err(|e| format!("Failed to execute notify-send: {}. Make sure libnotify-bin is installed.", e))?; + + if !output.status.success() { + let error = String::from_utf8_lossy(&output.stderr); + return Err(format!("notify-send failed: {}", error)); + } + + Ok(()) +} + +#[command] +pub async fn send_windows_notification(title: String, body: String) -> Result<(), String> { + // Create PowerShell script for Windows Toast Notification + let ps_script = format!( + r#" +[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null +[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null + +$APP_ID = 'Hikari Desktop' + +$template = @" + + + + {} + {} + + + +"@ + +$xml = New-Object Windows.Data.Xml.Dom.XmlDocument +$xml.LoadXml($template) + +$toast = New-Object Windows.UI.Notifications.ToastNotification $xml +[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($APP_ID).Show($toast) +"#, + title.replace("\"", "`\""), + body.replace("\"", "`\"") + ); + + // Try PowerShell Core first (pwsh), then fall back to Windows PowerShell + let output = Command::new("pwsh.exe") + .arg("-NoProfile") + .arg("-WindowStyle") + .arg("Hidden") + .arg("-Command") + .arg(&ps_script) + .output() + .or_else(|_| { + Command::new("powershell.exe") + .arg("-NoProfile") + .arg("-WindowStyle") + .arg("Hidden") + .arg("-Command") + .arg(&ps_script) + .output() + }) + .map_err(|e| format!("Failed to execute PowerShell: {}", e))?; + + if !output.status.success() { + let error = String::from_utf8_lossy(&output.stderr); + return Err(format!("PowerShell script failed: {}", error)); + } + + Ok(()) +} + +// Alternative: Use Windows built-in MSG command for simple notifications +#[command] +pub async fn send_simple_notification(title: String, body: String) -> Result<(), String> { + let message = format!("{}\n\n{}", title, body); + + Command::new("cmd.exe") + .arg("/c") + .arg("msg") + .arg("*") + .arg(&message) + .output() + .map_err(|e| format!("Failed to send message: {}", e))?; + + Ok(()) +} \ No newline at end of file diff --git a/src-tauri/src/vbs_notification.rs b/src-tauri/src/vbs_notification.rs new file mode 100644 index 0000000..3667a6e --- /dev/null +++ b/src-tauri/src/vbs_notification.rs @@ -0,0 +1,74 @@ +use std::process::Command; +use std::io::Write; +use tempfile::NamedTempFile; +use tauri::command; + +#[command] +pub async fn send_vbs_notification(title: String, body: String) -> Result<(), String> { + // Create a VBScript that shows a Windows notification + let vbs_content = format!( + r#" +Set objShell = CreateObject("WScript.Shell") +objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64 +"#, + body.replace("\"", "\"\"").replace("\n", "\" & vbCrLf & \""), + title.replace("\"", "\"\""), + title.replace("\"", "\"\"") + ); + + // Create a temporary VBS file + let mut temp_file = NamedTempFile::new() + .map_err(|e| format!("Failed to create temp file: {}", e))?; + + temp_file + .write_all(vbs_content.as_bytes()) + .map_err(|e| format!("Failed to write VBS content: {}", e))?; + + let temp_path = temp_file.path().to_string_lossy().to_string(); + + // Convert WSL path to Windows path + let windows_path = if temp_path.starts_with("/mnt/") { + // Convert /mnt/c/... to C:\... + let path_parts: Vec<&str> = temp_path.split('/').collect(); + if path_parts.len() > 2 { + let drive_letter = path_parts[2].to_uppercase(); + let rest_of_path = path_parts[3..].join("\\"); + format!("{}:\\{}", drive_letter, rest_of_path) + } else { + temp_path.clone() + } + } else if temp_path.starts_with("/tmp/") { + // WSL temp files might be in a different location + // Try to use wslpath to convert + let output = Command::new("wslpath") + .arg("-w") + .arg(&temp_path) + .output(); + + if let Ok(result) = output { + if result.status.success() { + String::from_utf8_lossy(&result.stdout).trim().to_string() + } else { + temp_path.clone() + } + } else { + temp_path.clone() + } + } else { + temp_path.clone() + }; + + // Execute the VBScript using wscript.exe + let output = Command::new("/mnt/c/Windows/System32/wscript.exe") + .arg("//NoLogo") + .arg(&windows_path) + .output() + .map_err(|e| format!("Failed to execute VBScript: {}", e))?; + + if !output.status.success() { + let error = String::from_utf8_lossy(&output.stderr); + return Err(format!("VBScript execution failed: {}", error)); + } + + Ok(()) +} \ No newline at end of file diff --git a/src-tauri/src/windows_toast.rs b/src-tauri/src/windows_toast.rs new file mode 100644 index 0000000..8bf3e48 --- /dev/null +++ b/src-tauri/src/windows_toast.rs @@ -0,0 +1,63 @@ +use tauri::command; + +#[cfg(target_os = "windows")] +use windows::{ + core::{HSTRING, Result as WindowsResult}, + Data::Xml::Dom::*, + UI::Notifications::*, +}; + +#[cfg(target_os = "windows")] +#[command] +pub async fn send_windows_toast(title: String, body: String) -> Result<(), String> { + show_toast_notification(&title, &body) + .map_err(|e| format!("Failed to show toast notification: {}", e)) +} + +#[cfg(target_os = "windows")] +fn show_toast_notification(title: &str, body: &str) -> WindowsResult<()> { + // Create the XML for the toast notification + let toast_xml = format!( + r#" + + + {} + {} + + + "#, + escape_xml(title), + escape_xml(body) + ); + + let xml_doc = XmlDocument::new()?; + xml_doc.LoadXml(&HSTRING::from(toast_xml))?; + + // Create the toast notification + let toast = ToastNotification::CreateToastNotification(&xml_doc)?; + + // Create a toast notifier with an application ID + let notifier = ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from("Hikari Desktop"))?; + + // Show the notification + notifier.Show(&toast)?; + + Ok(()) +} + +#[cfg(target_os = "windows")] +fn escape_xml(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +// Stub for non-Windows platforms +#[cfg(not(target_os = "windows"))] +#[command] +pub async fn send_windows_toast(_title: String, _body: String) -> Result<(), String> { + Err("Windows toast notifications are only available on Windows".to_string()) +} \ No newline at end of file diff --git a/src-tauri/src/wsl_notifications.rs b/src-tauri/src/wsl_notifications.rs new file mode 100644 index 0000000..ea61d5c --- /dev/null +++ b/src-tauri/src/wsl_notifications.rs @@ -0,0 +1,84 @@ +use std::process::Command; +use tauri::command; + +#[command] +pub async fn send_wsl_notification(title: String, body: String) -> Result<(), String> { + // Method 1: Try Windows 10/11 toast notification using PowerShell + let toast_command = format!( + r#" +Add-Type -AssemblyName System.Runtime.WindowsRuntime +$null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] +$null = [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] + +$APP_ID = 'Hikari Desktop' + +$template = @" + + + + {0} + {1} + + + +"@ + +$xml = New-Object Windows.Data.Xml.Dom.XmlDocument +$xml.LoadXml($template -f ('{0}' -replace "'", "''"), ('{1}' -replace "'", "''")) + +$toast = New-Object Windows.UI.Notifications.ToastNotification $xml +$notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($APP_ID) +$notifier.Show($toast) +"#, + title.replace("'", "''").replace("\"", "\\\""), + body.replace("'", "''").replace("\"", "\\\"") + ); + + // Try PowerShell.exe through WSL + let output = Command::new("/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe") + .arg("-NoProfile") + .arg("-ExecutionPolicy") + .arg("Bypass") + .arg("-WindowStyle") + .arg("Hidden") + .arg("-Command") + .arg(&toast_command) + .output(); + + match output { + Ok(result) => { + if result.status.success() { + println!("WSL notification sent successfully"); + return Ok(()); + } else { + let stderr = String::from_utf8_lossy(&result.stderr); + println!("PowerShell toast failed: {}", stderr); + } + } + Err(e) => { + println!("Failed to run PowerShell: {}", e); + } + } + + // Skip msg.exe as it creates alert boxes + // Method 2 removed + + // Method 3: Try wsl-notify-send if available + let notify_result = Command::new("wsl-notify-send") + .arg("--appId") + .arg("HikariDesktop") + .arg("--category") + .arg(&title) + .arg(&body) + .output(); + + if let Ok(result) = notify_result { + if result.status.success() { + println!("Notification sent via wsl-notify-send"); + return Ok(()); + } + } + + // If all methods fail, return an error + Err("All WSL notification methods failed".to_string()) +} \ No newline at end of file diff --git a/src/lib/components/ConfigSidebar.svelte b/src/lib/components/ConfigSidebar.svelte index fe53587..69366aa 100644 --- a/src/lib/components/ConfigSidebar.svelte +++ b/src/lib/components/ConfigSidebar.svelte @@ -11,6 +11,8 @@ theme: "dark", greeting_enabled: true, greeting_custom_prompt: null, + notifications_enabled: true, + notification_volume: 0.7, }); let isOpen = $state(false); @@ -394,6 +396,51 @@ + +
+

+ Notifications +

+ + +
+ +
+ + +
+ +
+ + + {Math.round(config.notification_volume * 100)}% + +
+
+ +
+ Sound notifications will play when I complete tasks, encounter errors, or need permissions. +
+
+
+ + diff --git a/src/lib/components/InputBar.svelte b/src/lib/components/InputBar.svelte index 3696dd9..025ca8d 100644 --- a/src/lib/components/InputBar.svelte +++ b/src/lib/components/InputBar.svelte @@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import { claudeStore } from "$lib/stores/claude"; import { characterState } from "$lib/stores/character"; + import { handleNewUserMessage } from "$lib/notifications/rules"; let inputValue = $state(""); let isSubmitting = $state(false); @@ -20,6 +21,9 @@ isSubmitting = true; inputValue = ""; + // Reset notification state for new user message + handleNewUserMessage(); + claudeStore.addLine("user", message); characterState.setState("thinking"); diff --git a/src/lib/components/NotificationDebugger.svelte b/src/lib/components/NotificationDebugger.svelte new file mode 100644 index 0000000..5084006 --- /dev/null +++ b/src/lib/components/NotificationDebugger.svelte @@ -0,0 +1,153 @@ + + +
+

Notification Method Debugger

+ +
+ + + +
+ + {#if results.length > 0} +
+

Test Results:

+ {#each results as result (result.method)} +
+ {result.method}: + {result.success ? "✓ Success" : "✗ Failed"} + {#if result.error} +
{result.error}
+ {/if} +
+ {/each} +
+ {/if} +
+ + diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index 4d6ca1f..2d3b7b2 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -25,6 +25,8 @@ theme: "dark", greeting_enabled: true, greeting_custom_prompt: null, + notifications_enabled: true, + notification_volume: 0.5, }); onMount(async () => { diff --git a/src/lib/notifications/index.ts b/src/lib/notifications/index.ts new file mode 100644 index 0000000..6afb31b --- /dev/null +++ b/src/lib/notifications/index.ts @@ -0,0 +1,4 @@ +export * from "./types"; +export { soundPlayer } from "./soundPlayer"; +export { notificationManager } from "./notificationManager"; +export { initializeNotificationRules } from "./rules"; diff --git a/src/lib/notifications/notificationManager.ts b/src/lib/notifications/notificationManager.ts new file mode 100644 index 0000000..6900281 --- /dev/null +++ b/src/lib/notifications/notificationManager.ts @@ -0,0 +1,121 @@ +import { soundPlayer } from "./soundPlayer"; +import { NotificationType, NOTIFICATION_SOUNDS } from "./types"; +import { invoke } from "@tauri-apps/api/core"; +import { sendTerminalNotification } from "./terminalNotifier"; + +class NotificationManager { + async notify(type: NotificationType, message?: string): Promise { + // Always play sound (if enabled) + await soundPlayer.play(type); + + const sound = NOTIFICATION_SOUNDS[type]; + const title = sound.phrase; + const body = message || this.getDefaultMessage(type); + + // Try multiple notification methods in order + const notificationMethods = [ + // Method 1: Try Windows PowerShell (best for system tray notifications) + async () => { + console.log("Trying Windows PowerShell notifications..."); + await invoke("send_windows_notification", { title, body }); + }, + + // Method 2: Try native Windows toast (for Windows builds) + async () => { + console.log("Trying native Windows toast..."); + await invoke("send_windows_toast", { title, body }); + }, + + // Method 3: Try WSL-specific notification (Windows toast via PowerShell - for WSL) + async () => { + console.log("Trying WSL notification..."); + await invoke("send_wsl_notification", { title, body }); + }, + + // Method 4: Try native Tauri notifications + async () => { + console.log("Trying Tauri native notifications..."); + const { sendNotification, isPermissionGranted, requestPermission } = + await import("@tauri-apps/plugin-notification"); + + let hasPermission = await isPermissionGranted(); + if (!hasPermission) { + const permission = await requestPermission(); + hasPermission = permission === "granted"; + } + + if (hasPermission) { + await sendNotification({ title, body }); + } else { + throw new Error("Notification permission denied"); + } + }, + + // Method 5: Try notify-send (for native Linux) + async () => { + console.log("Trying notify-send..."); + await invoke("send_notify_send", { title, body }); + }, + + // Skip VBScript and simple message as they create popup dialogs + // Only use them in the debugger for testing + ]; + + // Try each method until one succeeds + for (const method of notificationMethods) { + try { + await method(); + console.log("Notification sent successfully"); + return; // Success, stop trying other methods + } catch (error) { + console.warn("Notification method failed:", error); + // Continue to next method + } + } + + console.error("All notification methods failed, using terminal notification"); + // Final fallback: Show in terminal + sendTerminalNotification(type, body); + } + + private getDefaultMessage(type: NotificationType): string { + switch (type) { + case NotificationType.SUCCESS: + return "Task completed successfully!"; + case NotificationType.ERROR: + return "Something went wrong..."; + case NotificationType.PERMISSION: + return "Permission needed to continue"; + case NotificationType.CONNECTION: + return "Successfully connected to Claude Code"; + case NotificationType.TASK_START: + return "Starting task..."; + default: + return "Notification"; + } + } + + // Helper methods for common notifications + async notifySuccess(message?: string): Promise { + await this.notify(NotificationType.SUCCESS, message); + } + + async notifyError(message?: string): Promise { + await this.notify(NotificationType.ERROR, message); + } + + async notifyPermission(message?: string): Promise { + await this.notify(NotificationType.PERMISSION, message); + } + + async notifyConnection(message?: string): Promise { + await this.notify(NotificationType.CONNECTION, message); + } + + async notifyTaskStart(message?: string): Promise { + await this.notify(NotificationType.TASK_START, message); + } +} + +// Export singleton instance +export const notificationManager = new NotificationManager(); diff --git a/src/lib/notifications/rules.ts b/src/lib/notifications/rules.ts new file mode 100644 index 0000000..6756e28 --- /dev/null +++ b/src/lib/notifications/rules.ts @@ -0,0 +1,103 @@ +import { characterState } from "$lib/stores/character"; +import { notificationManager } from "./notificationManager"; +import type { CharacterState } from "$lib/types/states"; +import type { ConnectionStatus } from "$lib/types/messages"; + +// Track previous states to detect transitions +let previousCharacterState: CharacterState | null = null; +let previousConnectionStatus: ConnectionStatus | null = null; +let taskStartTime: number | null = null; +let hasNotifiedTaskStart = false; + +export function handleCharacterStateChange(newState: CharacterState): void { + // Detect state transitions + if (previousCharacterState === newState) return; + + // Task completion: any state -> success + if (newState === "success" && previousCharacterState !== null) { + const taskDuration = taskStartTime ? Date.now() - taskStartTime : 0; + // Only notify for tasks that took more than 2 seconds + if (taskDuration > 2000) { + notificationManager.notifySuccess(); + } + taskStartTime = null; + } + + // Error occurred + if (newState === "error" && previousCharacterState !== "error") { + notificationManager.notifyError(); + } + + // Permission needed + if (newState === "permission") { + notificationManager.notifyPermission(); + } + + // Starting long tasks - only notify once per response + if ( + (newState === "coding" || newState === "searching") && + previousCharacterState !== "coding" && + previousCharacterState !== "searching" && + !hasNotifiedTaskStart + ) { + taskStartTime = Date.now(); + hasNotifiedTaskStart = true; + notificationManager.notifyTaskStart(); + } + + previousCharacterState = newState; +} + +export function handleConnectionStatusChange(newStatus: ConnectionStatus): void { + // Only notify on successful connection after being disconnected + if ( + newStatus === "connected" && + previousConnectionStatus && + previousConnectionStatus !== "connected" + ) { + notificationManager.notifyConnection(); + } + + previousConnectionStatus = newStatus; +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function handleToolExecution(_toolName: string): void { + // For now, we don't notify on every tool execution + // But we could add specific rules here if needed +} + +// Reset notification state for a new response +export function handleNewUserMessage(): void { + hasNotifiedTaskStart = false; +} + +// Store unsubscribe functions +let unsubscribeCharacterState: (() => void) | null = null; + +// Initialize listeners +export function initializeNotificationRules(): void { + // Clean up any existing subscriptions first + cleanupNotificationRules(); + + // Subscribe to character state changes + unsubscribeCharacterState = characterState.subscribe((state) => { + handleCharacterStateChange(state); + }); + + // We'll connect to connection status in the next step +} + +// Cleanup function to prevent duplicate listeners +export function cleanupNotificationRules(): void { + if (unsubscribeCharacterState) { + unsubscribeCharacterState(); + unsubscribeCharacterState = null; + } + + // Reset state tracking + previousCharacterState = null; + previousConnectionStatus = null; + taskStartTime = null; + hasNotifiedTaskStart = false; +} diff --git a/src/lib/notifications/soundPlayer.ts b/src/lib/notifications/soundPlayer.ts new file mode 100644 index 0000000..8157a80 --- /dev/null +++ b/src/lib/notifications/soundPlayer.ts @@ -0,0 +1,61 @@ +import { NOTIFICATION_SOUNDS, type NotificationType } from "./types"; + +class SoundPlayer { + private audioCache: Map = new Map(); + private enabled: boolean = true; + private globalVolume: number = 1.0; + + constructor() { + // Preload all essential sounds + this.preloadSounds(); + } + + private preloadSounds(): void { + Object.entries(NOTIFICATION_SOUNDS).forEach(([type, sound]) => { + const audio = new Audio(`/sounds/${sound.filename}`); + audio.preload = "auto"; + audio.volume = (sound.volume || 0.7) * this.globalVolume; + this.audioCache.set(type as NotificationType, audio); + }); + } + + async play(type: NotificationType): Promise { + if (!this.enabled) return; + + try { + const audio = this.audioCache.get(type); + if (!audio) { + console.warn(`No audio found for notification type: ${type}`); + return; + } + + // Clone the audio to allow overlapping sounds + const audioClone = audio.cloneNode() as HTMLAudioElement; + audioClone.volume = audio.volume; + + await audioClone.play(); + } catch (error) { + console.error("Failed to play notification sound:", error); + } + } + + setEnabled(enabled: boolean): void { + this.enabled = enabled; + } + + setGlobalVolume(volume: number): void { + this.globalVolume = Math.max(0, Math.min(1, volume)); + // Update all cached audio volumes + this.audioCache.forEach((audio, type) => { + const sound = NOTIFICATION_SOUNDS[type]; + audio.volume = (sound.volume || 0.7) * this.globalVolume; + }); + } + + isEnabled(): boolean { + return this.enabled; + } +} + +// Export singleton instance +export const soundPlayer = new SoundPlayer(); diff --git a/src/lib/notifications/terminalNotifier.ts b/src/lib/notifications/terminalNotifier.ts new file mode 100644 index 0000000..970626e --- /dev/null +++ b/src/lib/notifications/terminalNotifier.ts @@ -0,0 +1,50 @@ +import { claudeStore } from "$lib/stores/claude"; +import { NOTIFICATION_SOUNDS, type NotificationType } from "./types"; + +export function sendTerminalNotification(type: NotificationType, message?: string): void { + const sound = NOTIFICATION_SOUNDS[type]; + const title = sound.phrase; + + // Create a formatted notification message + const separator = "═".repeat(50); + const timestamp = new Date().toLocaleTimeString(); + + let emoji = ""; + let color = ""; + + switch (type) { + case "success": + emoji = "✨"; + color = "\x1b[32m"; // Green + break; + case "error": + emoji = "❌"; + color = "\x1b[31m"; // Red + break; + case "permission": + emoji = "🔐"; + color = "\x1b[33m"; // Yellow + break; + case "connection": + emoji = "🔗"; + color = "\x1b[36m"; // Cyan + break; + case "task_start": + emoji = "🚀"; + color = "\x1b[34m"; // Blue + break; + } + + const reset = "\x1b[0m"; + + // Format the notification + const notification = ` +${color}${separator}${reset} +${emoji} ${color}NOTIFICATION${reset} - ${timestamp} +${color}${title}${reset} +${message || ""} +${color}${separator}${reset}`; + + // Add to terminal as a special system message + claudeStore.addLine("system", notification); +} diff --git a/src/lib/notifications/testNotifications.ts b/src/lib/notifications/testNotifications.ts new file mode 100644 index 0000000..262c411 --- /dev/null +++ b/src/lib/notifications/testNotifications.ts @@ -0,0 +1,42 @@ +import { notificationManager } from "./notificationManager"; + +// Test function to trigger each notification type +export async function testAllNotifications() { + console.log("Testing all notification types..."); + + // Test success notification + setTimeout(async () => { + console.log("Testing SUCCESS notification"); + await notificationManager.notifySuccess("Test task completed!"); + }, 1000); + + // Test error notification + setTimeout(async () => { + console.log("Testing ERROR notification"); + await notificationManager.notifyError("Test error occurred!"); + }, 3000); + + // Test permission notification + setTimeout(async () => { + console.log("Testing PERMISSION notification"); + await notificationManager.notifyPermission("Test permission request!"); + }, 5000); + + // Test connection notification + setTimeout(async () => { + console.log("Testing CONNECTION notification"); + await notificationManager.notifyConnection("Test connection established!"); + }, 7000); + + // Test task start notification + setTimeout(async () => { + console.log("Testing TASK_START notification"); + await notificationManager.notifyTaskStart("Test task starting!"); + }, 9000); +} + +// Make it available on window for easy testing +if (typeof window !== "undefined") { + (window as unknown as { testNotifications: typeof testAllNotifications }).testNotifications = + testAllNotifications; +} diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts new file mode 100644 index 0000000..ceeae60 --- /dev/null +++ b/src/lib/notifications/types.ts @@ -0,0 +1,48 @@ +export enum NotificationType { + SUCCESS = "success", + ERROR = "error", + PERMISSION = "permission", + CONNECTION = "connection", + TASK_START = "task_start", +} + +export interface NotificationSound { + type: NotificationType; + filename: string; + phrase: string; + volume?: number; +} + +// Essential notification sounds mapping +export const NOTIFICATION_SOUNDS: Record = { + [NotificationType.SUCCESS]: { + type: NotificationType.SUCCESS, + filename: "im-done.mp3", + phrase: "I'm done!", + volume: 0.7, + }, + [NotificationType.ERROR]: { + type: NotificationType.ERROR, + filename: "oh-no.mp3", + phrase: "Oh no...", + volume: 0.8, + }, + [NotificationType.PERMISSION]: { + type: NotificationType.PERMISSION, + filename: "access-please.mp3", + phrase: "Access please!", + volume: 0.9, + }, + [NotificationType.CONNECTION]: { + type: NotificationType.CONNECTION, + filename: "connected.mp3", + phrase: "Connected!", + volume: 0.7, + }, + [NotificationType.TASK_START]: { + type: NotificationType.TASK_START, + filename: "working-on-it.mp3", + phrase: "Working on it!", + volume: 0.6, + }, +}; diff --git a/src/lib/notifications/wslNotificationHelper.ts b/src/lib/notifications/wslNotificationHelper.ts new file mode 100644 index 0000000..8851f00 --- /dev/null +++ b/src/lib/notifications/wslNotificationHelper.ts @@ -0,0 +1,16 @@ +import { invoke } from "@tauri-apps/api/core"; +import { platform } from "@tauri-apps/plugin-os"; + +export async function sendWSLNotification(title: string, body: string): Promise { + const currentPlatform = await platform(); + + // Check if we're on Windows (WSL shows as 'windows') + if (currentPlatform === "windows") { + try { + // Use PowerShell to send Windows native notifications + await invoke("send_windows_notification", { title, body }); + } catch (error) { + console.error("Failed to send Windows notification:", error); + } + } +} diff --git a/src/lib/stores/config.ts b/src/lib/stores/config.ts index 7d5aa45..3187e4d 100644 --- a/src/lib/stores/config.ts +++ b/src/lib/stores/config.ts @@ -12,6 +12,8 @@ export interface HikariConfig { theme: Theme; greeting_enabled: boolean; greeting_custom_prompt: string | null; + notifications_enabled: boolean; + notification_volume: number; } const defaultConfig: HikariConfig = { @@ -23,6 +25,8 @@ const defaultConfig: HikariConfig = { theme: "dark", greeting_enabled: true, greeting_custom_prompt: null, + notifications_enabled: true, + notification_volume: 0.7, }; function createConfigStore() { diff --git a/src/lib/stores/notifications.ts b/src/lib/stores/notifications.ts new file mode 100644 index 0000000..c001417 --- /dev/null +++ b/src/lib/stores/notifications.ts @@ -0,0 +1,22 @@ +import { derived } from "svelte/store"; +import { configStore } from "./config"; +import { soundPlayer } from "$lib/notifications"; + +// Sync notification settings with config +export const notificationSettings = derived(configStore.config, ($config) => { + soundPlayer.setEnabled($config.notifications_enabled); + soundPlayer.setGlobalVolume($config.notification_volume); + + return { + enabled: $config.notifications_enabled, + volume: $config.notification_volume, + }; +}); + +// Helper to update notification settings +export async function updateNotificationSettings(enabled: boolean, volume: number) { + await configStore.updateConfig({ + notifications_enabled: enabled, + notification_volume: volume, + }); +} diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 69f5461..849574f 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -5,6 +5,12 @@ import { characterState } from "$lib/stores/character"; import { configStore } from "$lib/stores/config"; import type { ConnectionStatus, PermissionPromptEvent } from "$lib/types/messages"; import type { CharacterState } from "$lib/types/states"; +import { + initializeNotificationRules, + cleanupNotificationRules, + handleConnectionStatusChange, + handleNewUserMessage, +} from "$lib/notifications/rules"; interface StateChangePayload { state: CharacterState; @@ -12,6 +18,7 @@ interface StateChangePayload { } let hasConnectedThisSession = false; +let unlisteners: Array<() => void> = []; function getTimeOfDay(): string { const hour = new Date().getHours(); @@ -44,6 +51,9 @@ async function sendGreeting() { // Don't show the system prompt in the UI - just trigger Claude to respond characterState.setState("thinking"); + // Reset notification state for greeting + handleNewUserMessage(); + try { await invoke("send_prompt", { message: greetingPrompt }); } catch (error) { @@ -60,10 +70,19 @@ interface OutputPayload { } export async function initializeTauriListeners() { - await listen("claude:connection", async (event) => { + // Cleanup any existing listeners first + cleanupTauriListeners(); + + // Initialize notification rules + initializeNotificationRules(); + + const connectionUnlisten = await listen("claude:connection", async (event) => { const status = event.payload as ConnectionStatus; claudeStore.setConnectionStatus(status); + // Handle notification for connection status + handleConnectionStatusChange(status); + if (status === "connected") { claudeStore.addLine("system", "Connected to Claude Code"); characterState.setState("idle"); @@ -81,8 +100,9 @@ export async function initializeTauriListeners() { characterState.setTemporaryState("error", 3000); } }); + unlisteners.push(connectionUnlisten); - await listen("claude:state", (event) => { + const stateUnlisten = await listen("claude:state", (event) => { const { state } = event.payload; const stateMap: Record = { @@ -105,8 +125,9 @@ export async function initializeTauriListeners() { characterState.setState(mappedState); } }); + unlisteners.push(stateUnlisten); - await listen("claude:output", (event) => { + const outputUnlisten = await listen("claude:output", (event) => { const { line_type, content, tool_name } = event.payload; claudeStore.addLine( line_type as "user" | "assistant" | "system" | "tool" | "error", @@ -114,21 +135,25 @@ export async function initializeTauriListeners() { tool_name || undefined ); }); + unlisteners.push(outputUnlisten); - await listen("claude:stream", () => { + const streamUnlisten = await listen("claude:stream", () => { // no-op }); + unlisteners.push(streamUnlisten); - await listen("claude:session", (event) => { + const sessionUnlisten = await listen("claude:session", (event) => { claudeStore.setSessionId(event.payload); claudeStore.addLine("system", `Session: ${event.payload.substring(0, 8)}...`); }); + unlisteners.push(sessionUnlisten); - await listen("claude:cwd", (event) => { + const cwdUnlisten = await listen("claude:cwd", (event) => { claudeStore.setWorkingDirectory(event.payload); }); + unlisteners.push(cwdUnlisten); - await listen("claude:permission", (event) => { + const permissionUnlisten = await listen("claude:permission", (event) => { const { id, tool_name, tool_input, description } = event.payload; claudeStore.requestPermission({ id, @@ -138,6 +163,18 @@ export async function initializeTauriListeners() { }); claudeStore.addLine("system", `Permission requested for: ${tool_name}`); }); + unlisteners.push(permissionUnlisten); console.log("Tauri event listeners initialized"); } + +export function cleanupTauriListeners() { + // Cleanup all event listeners + unlisteners.forEach((unlisten) => unlisten()); + unlisteners = []; + + // Cleanup notification rules + cleanupNotificationRules(); + + console.log("Tauri event listeners cleaned up"); +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index c76da79..6c309a8 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,7 +1,8 @@ diff --git a/static/sounds/access-please.mp3 b/static/sounds/access-please.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..bc20791b0552342b4fa37d8491a2792ef0eb3e5e GIT binary patch literal 15236 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 y^*pNha&5jm8mc6<@=%W&am~g46{7 literal 0 HcmV?d00001 diff --git a/static/sounds/im-done.mp3 b/static/sounds/im-done.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..23251cd0c07bf151c28374c78a4be8704a1d5a1b GIT binary patch literal 10101 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 FKLChL#)|*| literal 0 HcmV?d00001 diff --git a/static/sounds/oh-no.mp3 b/static/sounds/oh-no.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..a7bb9aaaaf191355c4e594bc19199b3560b9e2ed GIT binary patch literal 9837 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 literal 0 HcmV?d00001 diff --git a/static/sounds/test-notification.html b/static/sounds/test-notification.html new file mode 100644 index 0000000..0892844 --- /dev/null +++ b/static/sounds/test-notification.html @@ -0,0 +1,93 @@ + + + + Notification Sound Test + + + +

🎵 Hikari Notification Test Page

+ +
+

Required Sound Files:

+

Please generate these TTS sounds and place them in /static/sounds/:

+ +
+ im-done.mp3 - "I'm done!" (cheerful, accomplished) +
+
+ oh-no.mp3 - "Oh no..." (concerned, but not panicked) +
+
+ access-please.mp3 - "Access please!" (polite, questioning) +
+
connected.mp3 - "Connected!" (happy, energetic)
+
+ working-on-it.mp3 - "Working on it!" (determined, focused) +
+
+ +

Test Sounds:

+

Click to test each notification sound (once files are in place):

+ + + + + + + + + + diff --git a/static/sounds/working-on-it.mp3 b/static/sounds/working-on-it.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..b30a106e7a29b257f9e08a82a0e53c437516cea9 GIT binary patch literal 10653 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