generated from nhcarrigan/template
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: #44 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #44.
This commit is contained in:
@@ -46,6 +46,12 @@ pub struct HikariConfig {
|
||||
|
||||
#[serde(default)]
|
||||
pub greeting_custom_prompt: Option<String>,
|
||||
|
||||
#[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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 = @"
|
||||
<toast>
|
||||
<visual>
|
||||
<binding template="ToastText02">
|
||||
<text id="1">{}</text>
|
||||
<text id="2">{}</text>
|
||||
</binding>
|
||||
</visual>
|
||||
<audio src="ms-winsoundevent:Notification.Default" />
|
||||
</toast>
|
||||
"@
|
||||
|
||||
$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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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#"<toast>
|
||||
<visual>
|
||||
<binding template="ToastGeneric">
|
||||
<text>{}</text>
|
||||
<text>{}</text>
|
||||
</binding>
|
||||
</visual>
|
||||
<audio src="ms-winsoundevent:Notification.Default" />
|
||||
</toast>"#,
|
||||
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())
|
||||
}
|
||||
@@ -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 = @"
|
||||
<toast>
|
||||
<visual>
|
||||
<binding template="ToastGeneric">
|
||||
<text>{0}</text>
|
||||
<text>{1}</text>
|
||||
</binding>
|
||||
</visual>
|
||||
</toast>
|
||||
"@
|
||||
|
||||
$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())
|
||||
}
|
||||
Reference in New Issue
Block a user