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 0000000..bc20791 Binary files /dev/null and b/static/sounds/access-please.mp3 differ diff --git a/static/sounds/connected.mp3 b/static/sounds/connected.mp3 new file mode 100644 index 0000000..3981aee Binary files /dev/null and b/static/sounds/connected.mp3 differ diff --git a/static/sounds/im-done.mp3 b/static/sounds/im-done.mp3 new file mode 100644 index 0000000..23251cd Binary files /dev/null and b/static/sounds/im-done.mp3 differ diff --git a/static/sounds/oh-no.mp3 b/static/sounds/oh-no.mp3 new file mode 100644 index 0000000..a7bb9aa Binary files /dev/null and b/static/sounds/oh-no.mp3 differ 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 0000000..b30a106 Binary files /dev/null and b/static/sounds/working-on-it.mp3 differ