Skip to main content

voirs_cli/platform/
integration.rs

1//! System integration features
2//!
3//! This module provides desktop notifications, system tray integration,
4//! file associations, and other OS-level integration features.
5
6use std::path::Path;
7
8/// Notification severity levels
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum NotificationLevel {
11    Info,
12    Success,
13    Warning,
14    Error,
15}
16
17/// Notification configuration
18#[derive(Debug, Clone)]
19pub struct Notification {
20    /// Notification title
21    pub title: String,
22    /// Notification message body
23    pub message: String,
24    /// Severity level
25    pub level: NotificationLevel,
26    /// Duration in milliseconds (None for persistent)
27    pub duration: Option<u32>,
28    /// Whether to play a sound
29    pub sound: bool,
30    /// Application icon path
31    pub icon: Option<String>,
32}
33
34/// System tray menu item
35#[derive(Debug, Clone)]
36pub struct TrayMenuItem {
37    /// Menu item label
38    pub label: String,
39    /// Menu item action/command
40    pub action: String,
41    /// Whether item is enabled
42    pub enabled: bool,
43    /// Whether item is checked (for toggle items)
44    pub checked: bool,
45    /// Submenu items
46    pub submenu: Vec<TrayMenuItem>,
47}
48
49/// System tray configuration
50#[derive(Debug, Clone)]
51pub struct SystemTray {
52    /// Tray icon path
53    pub icon: String,
54    /// Tooltip text
55    pub tooltip: String,
56    /// Context menu items
57    pub menu: Vec<TrayMenuItem>,
58    /// Whether to show notifications
59    pub show_notifications: bool,
60}
61
62/// File association configuration
63#[derive(Debug, Clone)]
64pub struct FileAssociation {
65    /// File extension (e.g., ".voirs")
66    pub extension: String,
67    /// MIME type
68    pub mime_type: String,
69    /// Description
70    pub description: String,
71    /// Icon path
72    pub icon: Option<String>,
73    /// Default action command
74    pub command: String,
75}
76
77/// Desktop integration manager
78pub struct DesktopIntegration {
79    notifications_enabled: bool,
80    tray_enabled: bool,
81}
82
83impl DesktopIntegration {
84    /// Create new desktop integration manager
85    pub fn new() -> Self {
86        Self {
87            notifications_enabled: true,
88            tray_enabled: false,
89        }
90    }
91
92    /// Show a desktop notification
93    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            // Fallback: print to console
116            println!(
117                "[{}] {}: {}",
118                format_notification_level(notification.level),
119                notification.title,
120                notification.message
121            );
122            Ok(())
123        }
124    }
125
126    /// Initialize system tray
127    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    /// Register file associations
151    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    /// Register a single file association
162    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    /// Enable/disable notifications
186    pub fn set_notifications_enabled(&mut self, enabled: bool) {
187        self.notifications_enabled = enabled;
188    }
189
190    /// Check if notifications are supported
191    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    /// Check if system tray is supported
203    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    /// Send synthesis completion notification
215    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), // 5 seconds
232            sound: true,
233            icon: None,
234        };
235
236        self.show_notification(&notification)
237    }
238
239    /// Send error notification
240    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), // 10 seconds
246            sound: true,
247            icon: None,
248        };
249
250        self.show_notification(&notification)
251    }
252
253    /// Send progress notification
254    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), // 3 seconds
264            sound: false,
265            icon: None,
266        };
267
268        self.show_notification(&notification)
269    }
270
271    /// Create default system tray configuration
272    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    /// Create default file associations
333    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// Platform-specific implementations
354
355#[cfg(target_os = "windows")]
356impl DesktopIntegration {
357    fn show_windows_notification(
358        &self,
359        notification: &Notification,
360    ) -> Result<(), Box<dyn std::error::Error>> {
361        // Windows notification implementation using Windows API or winrt
362        use std::process::Command;
363
364        // Use PowerShell to show toast notification
365        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        // Windows system tray implementation
389        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        // Windows file association using registry
399        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        // macOS notification using osascript/AppleScript
414        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        // macOS menu bar integration
429        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        // macOS file association using LaunchServices
439        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        // Linux notification using notify-send or libnotify
454        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(&notification.title);
471        cmd.arg(&notification.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        // Linux system tray using StatusNotifierItem or AppIndicator
479        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        // Linux file association using .desktop files and MIME types
489        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        // Write .desktop file to ~/.local/share/applications/
506        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            // Update MIME database
517            std::process::Command::new("update-desktop-database")
518                .arg(desktop_file.parent().unwrap())
519                .output()
520                .ok(); // Ignore errors, this is optional
521        }
522
523        tracing::info!(
524            "Registered Linux file association for {}",
525            association.extension
526        );
527        Ok(())
528    }
529}
530
531// Helper functions
532
533fn 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
548/// Convenience functions for common notifications
549///
550/// Show synthesis started notification
551pub 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(&notification)
571}
572
573/// Show batch processing notification
574pub 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(&notification)
589}
590
591/// Show server status notification
592pub 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(&notification)
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        // These should return true on supported platforms
666        #[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}