use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; /// A single time range (e.g., 09:00 to 17:00) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TimeRange { pub start: String, // Format: "HH:MM" pub end: String, // Format: "HH:MM" } impl Default for TimeRange { fn default() -> Self { Self { start: "09:00".to_string(), end: "18:00".to_string(), } } } /// Schedule for a single day #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DaySchedule { pub enabled: bool, pub ranges: Vec, } impl Default for DaySchedule { fn default() -> Self { Self { enabled: true, ranges: vec![TimeRange::default()], } } } impl DaySchedule { /// Create a default schedule for weekend days (disabled by default) fn weekend_default() -> Self { Self { enabled: false, ranges: vec![TimeRange::default()], } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct Config { // Timer settings pub break_duration: u32, // Duration of each break in minutes (1-60) pub break_frequency: u32, // How often breaks occur in minutes (5-120) pub auto_start: bool, // Start timer automatically on app launch // Break settings pub break_title: String, // Title shown on break screen (e.g., "Rest your eyes") pub break_message: String, // Custom message shown during breaks pub fullscreen_mode: bool, // Use fullscreen break window vs notification pub strict_mode: bool, // Prevent user from skipping breaks pub allow_end_early: bool, // Allow ending break after 50% completion pub immediately_start_breaks: bool, // Skip pre-break notification, go straight to break // Working hours (per-day schedule with multiple ranges) pub working_hours_enabled: bool, pub working_hours_schedule: Vec, // 7 days: Mon, Tue, Wed, Thu, Fri, Sat, Sun // Appearance pub dark_mode: bool, pub color_scheme: String, pub backdrop_opacity: f32, // Break screen backdrop opacity (0.5-1.0) // Notifications pub notification_enabled: bool, pub notification_before_break: u32, // Notify X seconds before break (0 = no notification) // Advanced pub snooze_duration: u32, // Duration of snooze in minutes (1-30) pub snooze_limit: u32, // Max snoozes per break cycle (0 = unlimited, 1-5) pub skip_cooldown: u32, // Minimum time between skip operations in seconds // Sound pub sound_enabled: bool, // Play sounds on break events pub sound_volume: u32, // Volume 0-100 pub sound_preset: String, // Sound preset name: "bell", "chime", "soft", "digital" // Idle detection pub idle_detection_enabled: bool, pub idle_timeout: u32, // Seconds of inactivity before auto-pause (30-600) // Smart breaks - natural break detection pub smart_breaks_enabled: bool, pub smart_break_threshold: u32, // Seconds of idle to count as natural break (120-900) pub smart_break_count_stats: bool, // Include natural breaks in statistics // Break activities pub show_break_activities: bool, // UI pub ui_zoom: u32, // UI zoom percentage (50-200, default 100) pub accent_color: String, // Main accent color hex (e.g., "#ff4d00") pub break_color: String, // Break screen ring color hex (e.g., "#7c6aef") pub countdown_font: String, // Google Font family for countdown display (empty = system default) pub background_blobs_enabled: bool, // Animated gradient blobs background // Mini mode pub mini_click_through: bool, // Mini mode is click-through until hovered pub mini_hover_threshold: f32, // Seconds to hover before enabling drag (1.0-10.0) // Window positions (persisted between launches) pub main_window_x: Option, pub main_window_y: Option, pub main_window_width: Option, pub main_window_height: Option, pub mini_window_x: Option, pub mini_window_y: Option, } impl Default for Config { fn default() -> Self { Self { // Timer settings break_duration: 5, break_frequency: 25, auto_start: true, // Break settings break_title: "Rest your eyes".to_string(), break_message: "Look away from the screen. Stretch and relax.".to_string(), fullscreen_mode: true, strict_mode: false, allow_end_early: true, immediately_start_breaks: false, // Working hours working_hours_enabled: false, working_hours_schedule: vec![ DaySchedule::default(), // Monday DaySchedule::default(), // Tuesday DaySchedule::default(), // Wednesday DaySchedule::default(), // Thursday DaySchedule::default(), // Friday DaySchedule::weekend_default(), // Saturday DaySchedule::weekend_default(), // Sunday ], // Appearance dark_mode: true, color_scheme: "Ocean".to_string(), backdrop_opacity: 0.92, // Notifications notification_enabled: true, notification_before_break: 30, // Advanced snooze_duration: 5, snooze_limit: 3, skip_cooldown: 60, // Sound sound_enabled: true, sound_volume: 70, sound_preset: "bell".to_string(), // Idle detection idle_detection_enabled: true, idle_timeout: 120, // Smart breaks smart_breaks_enabled: true, smart_break_threshold: 300, // 5 minutes smart_break_count_stats: false, // Break activities show_break_activities: true, // UI ui_zoom: 100, accent_color: "#ff4d00".to_string(), break_color: "#7c6aef".to_string(), countdown_font: String::new(), background_blobs_enabled: false, // Mini mode mini_click_through: true, mini_hover_threshold: 3.0, // Window positions main_window_x: None, main_window_y: None, main_window_width: None, main_window_height: None, mini_window_x: None, mini_window_y: None, } } } impl Config { /// Get the path to the config file (portable: next to the exe) fn config_path() -> Result { let exe_path = std::env::current_exe().context("Failed to determine exe path")?; let exe_dir = exe_path.parent().context("Failed to determine exe directory")?; Ok(exe_dir.join("config.json")) } /// Load configuration from file, or return default if it doesn't exist pub fn load_or_default() -> Self { match Self::load() { Ok(config) => config, Err(e) => { eprintln!("Failed to load config, using defaults: {}", e); let default = Self::default(); // Try to save the default config if let Err(save_err) = default.save() { eprintln!("Failed to save default config: {}", save_err); } default } } } /// Load configuration from file pub fn load() -> Result { let config_path = Self::config_path()?; if !config_path.exists() { return Err(anyhow::anyhow!("Config file does not exist")); } let config_content = fs::read_to_string(&config_path).context("Failed to read config file")?; let config: Config = serde_json::from_str(&config_content).context("Failed to parse config file")?; // Validate and sanitize the loaded config Ok(config.validate()) } /// Save configuration to file pub fn save(&self) -> Result<()> { let config_path = Self::config_path()?; let config_content = serde_json::to_string_pretty(self).context("Failed to serialize config")?; fs::write(&config_path, config_content).context("Failed to write config file")?; Ok(()) } /// Validate and sanitize configuration values pub fn validate(mut self) -> Self { // Break duration: 1-60 minutes self.break_duration = self.break_duration.clamp(1, 60); // Break frequency: 5-120 minutes self.break_frequency = self.break_frequency.clamp(5, 120); // Notification before break: 0-300 seconds (0 = disabled) self.notification_before_break = self.notification_before_break.clamp(0, 300); // Snooze duration: 1-30 minutes self.snooze_duration = self.snooze_duration.clamp(1, 30); // Snooze limit: 0-5 (0 = unlimited) self.snooze_limit = self.snooze_limit.clamp(0, 5); // Skip cooldown: 0-600 seconds (0 = disabled) self.skip_cooldown = self.skip_cooldown.clamp(0, 600); // Backdrop opacity: 0.5-1.0 self.backdrop_opacity = self.backdrop_opacity.clamp(0.5, 1.0); // Validate break title if self.break_title.is_empty() { self.break_title = "Rest your eyes".to_string(); } else if self.break_title.len() > 100 { self.break_title.truncate(100); } // Validate working hours schedule if self.working_hours_schedule.len() != 7 { // Reset to default if invalid self.working_hours_schedule = vec![ DaySchedule::default(), // Monday DaySchedule::default(), // Tuesday DaySchedule::default(), // Wednesday DaySchedule::default(), // Thursday DaySchedule::default(), // Friday DaySchedule::weekend_default(), // Saturday DaySchedule::weekend_default(), // Sunday ]; } // Validate each day's ranges for day in &mut self.working_hours_schedule { // Ensure at least one range if day.ranges.is_empty() { day.ranges.push(TimeRange::default()); } // Validate each range for range in &mut day.ranges { if !Self::is_valid_time_format(&range.start) { range.start = "09:00".to_string(); } if !Self::is_valid_time_format(&range.end) { range.end = "18:00".to_string(); } // Ensure start < end let start_mins = Self::time_to_minutes(&range.start); let end_mins = Self::time_to_minutes(&range.end); if start_mins >= end_mins { range.end = "18:00".to_string(); } } } // Validate color scheme let valid_schemes = vec!["Ocean", "Forest", "Sunset", "Midnight", "Dawn"]; if !valid_schemes.contains(&self.color_scheme.as_str()) { self.color_scheme = "Ocean".to_string(); } // Sound volume: 0-100 self.sound_volume = self.sound_volume.clamp(0, 100); // Validate sound preset let valid_presets = vec![ "bell", "chime", "soft", "digital", "harp", "bowl", "rain", "whistle", ]; if !valid_presets.contains(&self.sound_preset.as_str()) { self.sound_preset = "bell".to_string(); } // Idle timeout: 30-600 seconds self.idle_timeout = self.idle_timeout.clamp(30, 600); // Smart break threshold: 120-900 seconds (2-15 minutes) self.smart_break_threshold = self.smart_break_threshold.clamp(120, 900); // Mini hover threshold: 1.0-10.0 seconds self.mini_hover_threshold = self.mini_hover_threshold.clamp(1.0, 10.0); // UI zoom: 50-200% self.ui_zoom = self.ui_zoom.clamp(50, 200); // Validate color hex strings if !Self::is_valid_hex_color(&self.accent_color) { self.accent_color = "#ff4d00".to_string(); } if !Self::is_valid_hex_color(&self.break_color) { self.break_color = "#7c6aef".to_string(); } // Validate break message length if self.break_message.is_empty() { self.break_message = "Time for a break! Stretch and relax your eyes.".to_string(); } else if self.break_message.len() > 500 { self.break_message.truncate(500); } self } /// Check if a time string is in valid HH:MM format fn is_valid_time_format(time: &str) -> bool { let parts: Vec<&str> = time.split(':').collect(); if parts.len() != 2 { return false; } if let (Ok(hours), Ok(minutes)) = (parts[0].parse::(), parts[1].parse::()) { hours < 24 && minutes < 60 } else { false } } /// Convert HH:MM time string to minutes since midnight pub fn time_to_minutes(time: &str) -> u32 { let parts: Vec<&str> = time.split(':').collect(); if parts.len() != 2 { return 0; } let hours = parts[0].parse::().unwrap_or(0); let minutes = parts[1].parse::().unwrap_or(0); hours * 60 + minutes } /// Check if a string is a valid hex color (#RRGGBB) fn is_valid_hex_color(color: &str) -> bool { color.len() == 7 && color.starts_with('#') && color[1..].chars().all(|c| c.is_ascii_hexdigit()) } /// Get break duration in seconds pub fn break_duration_seconds(&self) -> u64 { self.break_duration as u64 * 60 } /// Get break frequency in seconds pub fn break_frequency_seconds(&self) -> u64 { self.break_frequency as u64 * 60 } /// Get snooze duration in seconds pub fn snooze_duration_seconds(&self) -> u64 { self.snooze_duration as u64 * 60 } /// Reset to default values pub fn reset_to_default(&mut self) { *self = Self::default(); } }