1use std::path::Path;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum NotificationLevel {
11 Info,
12 Success,
13 Warning,
14 Error,
15}
16
17#[derive(Debug, Clone)]
19pub struct Notification {
20 pub title: String,
22 pub message: String,
24 pub level: NotificationLevel,
26 pub duration: Option<u32>,
28 pub sound: bool,
30 pub icon: Option<String>,
32}
33
34#[derive(Debug, Clone)]
36pub struct TrayMenuItem {
37 pub label: String,
39 pub action: String,
41 pub enabled: bool,
43 pub checked: bool,
45 pub submenu: Vec<TrayMenuItem>,
47}
48
49#[derive(Debug, Clone)]
51pub struct SystemTray {
52 pub icon: String,
54 pub tooltip: String,
56 pub menu: Vec<TrayMenuItem>,
58 pub show_notifications: bool,
60}
61
62#[derive(Debug, Clone)]
64pub struct FileAssociation {
65 pub extension: String,
67 pub mime_type: String,
69 pub description: String,
71 pub icon: Option<String>,
73 pub command: String,
75}
76
77pub struct DesktopIntegration {
79 notifications_enabled: bool,
80 tray_enabled: bool,
81}
82
83impl DesktopIntegration {
84 pub fn new() -> Self {
86 Self {
87 notifications_enabled: true,
88 tray_enabled: false,
89 }
90 }
91
92 pub fn show_notification(
94 &self,
95 notification: &Notification,
96 ) -> Result<(), Box<dyn std::error::Error>> {
97 if !self.notifications_enabled {
98 return Ok(());
99 }
100
101 #[cfg(target_os = "windows")]
102 {
103 self.show_windows_notification(notification)
104 }
105 #[cfg(target_os = "macos")]
106 {
107 self.show_macos_notification(notification)
108 }
109 #[cfg(target_os = "linux")]
110 {
111 self.show_linux_notification(notification)
112 }
113 #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
114 {
115 println!(
117 "[{}] {}: {}",
118 format_notification_level(notification.level),
119 notification.title,
120 notification.message
121 );
122 Ok(())
123 }
124 }
125
126 pub fn init_system_tray(
128 &mut self,
129 config: &SystemTray,
130 ) -> Result<(), Box<dyn std::error::Error>> {
131 #[cfg(target_os = "windows")]
132 {
133 self.init_windows_tray(config)
134 }
135 #[cfg(target_os = "macos")]
136 {
137 self.init_macos_tray(config)
138 }
139 #[cfg(target_os = "linux")]
140 {
141 self.init_linux_tray(config)
142 }
143 #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
144 {
145 tracing::warn!("System tray not supported on this platform");
146 Ok(())
147 }
148 }
149
150 pub fn register_file_associations(
152 &self,
153 associations: &[FileAssociation],
154 ) -> Result<(), Box<dyn std::error::Error>> {
155 for association in associations {
156 self.register_file_association(association)?;
157 }
158 Ok(())
159 }
160
161 pub fn register_file_association(
163 &self,
164 association: &FileAssociation,
165 ) -> Result<(), Box<dyn std::error::Error>> {
166 #[cfg(target_os = "windows")]
167 {
168 self.register_windows_file_association(association)
169 }
170 #[cfg(target_os = "macos")]
171 {
172 self.register_macos_file_association(association)
173 }
174 #[cfg(target_os = "linux")]
175 {
176 self.register_linux_file_association(association)
177 }
178 #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
179 {
180 tracing::warn!("File associations not supported on this platform");
181 Ok(())
182 }
183 }
184
185 pub fn set_notifications_enabled(&mut self, enabled: bool) {
187 self.notifications_enabled = enabled;
188 }
189
190 pub fn notifications_supported(&self) -> bool {
192 #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
193 {
194 true
195 }
196 #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
197 {
198 false
199 }
200 }
201
202 pub fn system_tray_supported(&self) -> bool {
204 #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
205 {
206 true
207 }
208 #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
209 {
210 false
211 }
212 }
213
214 pub fn notify_synthesis_complete(
216 &self,
217 output_file: &Path,
218 duration: f32,
219 ) -> Result<(), Box<dyn std::error::Error>> {
220 let notification = Notification {
221 title: "VoiRS Synthesis Complete".to_string(),
222 message: format!(
223 "Audio generated successfully in {:.1}s\nOutput: {}",
224 duration,
225 output_file
226 .file_name()
227 .and_then(|name| name.to_str())
228 .unwrap_or("output.wav")
229 ),
230 level: NotificationLevel::Success,
231 duration: Some(5000), sound: true,
233 icon: None,
234 };
235
236 self.show_notification(¬ification)
237 }
238
239 pub fn notify_error(&self, error: &str) -> Result<(), Box<dyn std::error::Error>> {
241 let notification = Notification {
242 title: "VoiRS Error".to_string(),
243 message: error.to_string(),
244 level: NotificationLevel::Error,
245 duration: Some(10000), sound: true,
247 icon: None,
248 };
249
250 self.show_notification(¬ification)
251 }
252
253 pub fn notify_progress(
255 &self,
256 message: &str,
257 progress: f32,
258 ) -> Result<(), Box<dyn std::error::Error>> {
259 let notification = Notification {
260 title: "VoiRS Progress".to_string(),
261 message: format!("{} ({:.0}%)", message, progress * 100.0),
262 level: NotificationLevel::Info,
263 duration: Some(3000), sound: false,
265 icon: None,
266 };
267
268 self.show_notification(¬ification)
269 }
270
271 pub fn create_default_tray_config(&self) -> SystemTray {
273 SystemTray {
274 icon: "voirs-icon.png".to_string(),
275 tooltip: "VoiRS - Text-to-Speech Synthesis".to_string(),
276 menu: vec![
277 TrayMenuItem {
278 label: "Quick Synthesis".to_string(),
279 action: "quick_synthesis".to_string(),
280 enabled: true,
281 checked: false,
282 submenu: Vec::new(),
283 },
284 TrayMenuItem {
285 label: "Interactive Mode".to_string(),
286 action: "interactive_mode".to_string(),
287 enabled: true,
288 checked: false,
289 submenu: Vec::new(),
290 },
291 TrayMenuItem {
292 label: "Server Mode".to_string(),
293 action: "toggle_server".to_string(),
294 enabled: true,
295 checked: false,
296 submenu: Vec::new(),
297 },
298 TrayMenuItem {
299 label: "Settings".to_string(),
300 action: "settings".to_string(),
301 enabled: true,
302 checked: false,
303 submenu: vec![
304 TrayMenuItem {
305 label: "Preferences".to_string(),
306 action: "preferences".to_string(),
307 enabled: true,
308 checked: false,
309 submenu: Vec::new(),
310 },
311 TrayMenuItem {
312 label: "Voice Manager".to_string(),
313 action: "voice_manager".to_string(),
314 enabled: true,
315 checked: false,
316 submenu: Vec::new(),
317 },
318 ],
319 },
320 TrayMenuItem {
321 label: "Quit".to_string(),
322 action: "quit".to_string(),
323 enabled: true,
324 checked: false,
325 submenu: Vec::new(),
326 },
327 ],
328 show_notifications: true,
329 }
330 }
331
332 pub fn create_default_file_associations(&self) -> Vec<FileAssociation> {
334 vec![
335 FileAssociation {
336 extension: ".voirs".to_string(),
337 mime_type: "application/vnd.voirs.synthesis".to_string(),
338 description: "VoiRS Synthesis Configuration".to_string(),
339 icon: Some("voirs-file.png".to_string()),
340 command: "voirs synthesize-file".to_string(),
341 },
342 FileAssociation {
343 extension: ".ssml".to_string(),
344 mime_type: "application/ssml+xml".to_string(),
345 description: "Speech Synthesis Markup Language".to_string(),
346 icon: Some("ssml-file.png".to_string()),
347 command: "voirs synthesize".to_string(),
348 },
349 ]
350 }
351}
352
353#[cfg(target_os = "windows")]
356impl DesktopIntegration {
357 fn show_windows_notification(
358 &self,
359 notification: &Notification,
360 ) -> Result<(), Box<dyn std::error::Error>> {
361 use std::process::Command;
363
364 let script = format!(
366 r#"Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.MessageBox]::Show('{}', '{}', 'OK', '{}')"#,
367 notification.message,
368 notification.title,
369 match notification.level {
370 NotificationLevel::Error => "Error",
371 NotificationLevel::Warning => "Warning",
372 _ => "Information",
373 }
374 );
375
376 Command::new("powershell")
377 .arg("-Command")
378 .arg(&script)
379 .output()?;
380
381 Ok(())
382 }
383
384 fn init_windows_tray(
385 &mut self,
386 _config: &SystemTray,
387 ) -> Result<(), Box<dyn std::error::Error>> {
388 self.tray_enabled = true;
390 tracing::info!("Windows system tray initialized");
391 Ok(())
392 }
393
394 fn register_windows_file_association(
395 &self,
396 association: &FileAssociation,
397 ) -> Result<(), Box<dyn std::error::Error>> {
398 tracing::info!(
400 "Registering Windows file association for {}",
401 association.extension
402 );
403 Ok(())
404 }
405}
406
407#[cfg(target_os = "macos")]
408impl DesktopIntegration {
409 fn show_macos_notification(
410 &self,
411 notification: &Notification,
412 ) -> Result<(), Box<dyn std::error::Error>> {
413 use std::process::Command;
415
416 let script = format!(
417 r#"display notification "{}" with title "{}" sound name "default""#,
418 notification.message.replace('"', r#"\""#),
419 notification.title.replace('"', r#"\""#)
420 );
421
422 Command::new("osascript").arg("-e").arg(&script).output()?;
423
424 Ok(())
425 }
426
427 fn init_macos_tray(&mut self, _config: &SystemTray) -> Result<(), Box<dyn std::error::Error>> {
428 self.tray_enabled = true;
430 tracing::info!("macOS menu bar integration initialized");
431 Ok(())
432 }
433
434 fn register_macos_file_association(
435 &self,
436 association: &FileAssociation,
437 ) -> Result<(), Box<dyn std::error::Error>> {
438 tracing::info!(
440 "Registering macOS file association for {}",
441 association.extension
442 );
443 Ok(())
444 }
445}
446
447#[cfg(target_os = "linux")]
448impl DesktopIntegration {
449 fn show_linux_notification(
450 &self,
451 notification: &Notification,
452 ) -> Result<(), Box<dyn std::error::Error>> {
453 use std::process::Command;
455
456 let urgency = match notification.level {
457 NotificationLevel::Error => "critical",
458 NotificationLevel::Warning => "normal",
459 _ => "low",
460 };
461
462 let mut cmd = Command::new("notify-send");
463 cmd.arg("--urgency").arg(urgency);
464 cmd.arg("--app-name").arg("VoiRS");
465
466 if let Some(duration) = notification.duration {
467 cmd.arg("--expire-time").arg(duration.to_string());
468 }
469
470 cmd.arg(¬ification.title);
471 cmd.arg(¬ification.message);
472
473 cmd.output()?;
474 Ok(())
475 }
476
477 fn init_linux_tray(&mut self, _config: &SystemTray) -> Result<(), Box<dyn std::error::Error>> {
478 self.tray_enabled = true;
480 tracing::info!("Linux system tray initialized");
481 Ok(())
482 }
483
484 fn register_linux_file_association(
485 &self,
486 association: &FileAssociation,
487 ) -> Result<(), Box<dyn std::error::Error>> {
488 let desktop_entry = format!(
490 r#"[Desktop Entry]
491Name=VoiRS
492Comment={}
493Exec={}
494Icon={}
495Terminal=false
496Type=Application
497MimeType={};
498"#,
499 association.description,
500 association.command,
501 association.icon.as_deref().unwrap_or("voirs"),
502 association.mime_type
503 );
504
505 if let Some(home) = dirs::home_dir() {
507 let desktop_file = home
508 .join(".local")
509 .join("share")
510 .join("applications")
511 .join("voirs.desktop");
512
513 std::fs::create_dir_all(desktop_file.parent().unwrap())?;
514 std::fs::write(&desktop_file, desktop_entry)?;
515
516 std::process::Command::new("update-desktop-database")
518 .arg(desktop_file.parent().unwrap())
519 .output()
520 .ok(); }
522
523 tracing::info!(
524 "Registered Linux file association for {}",
525 association.extension
526 );
527 Ok(())
528 }
529}
530
531fn format_notification_level(level: NotificationLevel) -> &'static str {
534 match level {
535 NotificationLevel::Info => "INFO",
536 NotificationLevel::Success => "SUCCESS",
537 NotificationLevel::Warning => "WARNING",
538 NotificationLevel::Error => "ERROR",
539 }
540}
541
542impl Default for DesktopIntegration {
543 fn default() -> Self {
544 Self::new()
545 }
546}
547
548pub fn notify_synthesis_started(
552 integration: &DesktopIntegration,
553 text: &str,
554) -> Result<(), Box<dyn std::error::Error>> {
555 let preview = if text.len() > 50 {
556 format!("{}...", &text[..47])
557 } else {
558 text.to_string()
559 };
560
561 let notification = Notification {
562 title: "VoiRS Synthesis Started".to_string(),
563 message: format!("Synthesizing: {}", preview),
564 level: NotificationLevel::Info,
565 duration: Some(3000),
566 sound: false,
567 icon: None,
568 };
569
570 integration.show_notification(¬ification)
571}
572
573pub fn notify_batch_progress(
575 integration: &DesktopIntegration,
576 completed: usize,
577 total: usize,
578) -> Result<(), Box<dyn std::error::Error>> {
579 let notification = Notification {
580 title: "VoiRS Batch Processing".to_string(),
581 message: format!("Processed {} of {} files", completed, total),
582 level: NotificationLevel::Info,
583 duration: Some(2000),
584 sound: false,
585 icon: None,
586 };
587
588 integration.show_notification(¬ification)
589}
590
591pub fn notify_server_status(
593 integration: &DesktopIntegration,
594 running: bool,
595 port: u16,
596) -> Result<(), Box<dyn std::error::Error>> {
597 let notification = Notification {
598 title: "VoiRS Server".to_string(),
599 message: if running {
600 format!("Server started on port {}", port)
601 } else {
602 "Server stopped".to_string()
603 },
604 level: if running {
605 NotificationLevel::Success
606 } else {
607 NotificationLevel::Info
608 },
609 duration: Some(5000),
610 sound: running,
611 icon: None,
612 };
613
614 integration.show_notification(¬ification)
615}
616
617#[cfg(test)]
618mod tests {
619 use super::*;
620
621 #[test]
622 fn test_desktop_integration_creation() {
623 let integration = DesktopIntegration::new();
624 assert!(integration.notifications_enabled);
625 assert!(!integration.tray_enabled);
626 }
627
628 #[test]
629 fn test_notification_creation() {
630 let notification = Notification {
631 title: "Test".to_string(),
632 message: "Test message".to_string(),
633 level: NotificationLevel::Info,
634 duration: Some(1000),
635 sound: false,
636 icon: None,
637 };
638
639 assert_eq!(notification.title, "Test");
640 assert_eq!(notification.level, NotificationLevel::Info);
641 }
642
643 #[test]
644 fn test_tray_config_creation() {
645 let integration = DesktopIntegration::new();
646 let config = integration.create_default_tray_config();
647
648 assert!(!config.menu.is_empty());
649 assert_eq!(config.tooltip, "VoiRS - Text-to-Speech Synthesis");
650 }
651
652 #[test]
653 fn test_file_associations_creation() {
654 let integration = DesktopIntegration::new();
655 let associations = integration.create_default_file_associations();
656
657 assert!(!associations.is_empty());
658 assert!(associations.iter().any(|a| a.extension == ".voirs"));
659 }
660
661 #[test]
662 fn test_platform_support() {
663 let integration = DesktopIntegration::new();
664
665 #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
667 {
668 assert!(integration.notifications_supported());
669 assert!(integration.system_tray_supported());
670 }
671 }
672}