Files
core-cooldown/src-tauri/src/config.rs

422 lines
14 KiB
Rust

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<TimeRange>,
}
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<DaySchedule>, // 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<i32>,
pub main_window_y: Option<i32>,
pub main_window_width: Option<u32>,
pub main_window_height: Option<u32>,
pub mini_window_x: Option<i32>,
pub mini_window_y: Option<i32>,
}
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<PathBuf> {
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<Self> {
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::<u8>(), parts[1].parse::<u8>()) {
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::<u32>().unwrap_or(0);
let minutes = parts[1].parse::<u32>().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();
}
}