Portable Windows break timer to prevent RSI and eye strain. Tauri v2 + Svelte 5 + Tailwind CSS v4. No installer, no telemetry, no data leaves the machine. CC0 public domain.
422 lines
14 KiB
Rust
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();
|
|
}
|
|
}
|