mod config; #[cfg(all(windows, target_env = "gnu"))] mod msvc_compat; mod stats; mod timer; use config::Config; use stats::Stats; use std::sync::{Arc, Mutex}; use std::time::Duration; use tauri::{ image::Image, menu::{MenuBuilder, MenuItemBuilder}, tray::{TrayIcon, TrayIconBuilder}, AppHandle, Emitter, Manager, State, }; use tauri_plugin_notification::NotificationExt; use timer::{AppView, TickResult, TimerManager, TimerSnapshot}; pub struct AppState { pub timer: Arc>, pub stats: Arc>, } // ── Tauri Commands ────────────────────────────────────────────────────────── #[tauri::command] fn get_config(state: State) -> Config { let timer = state.timer.lock().unwrap(); timer.pending_config.clone() } #[tauri::command] fn save_config(state: State, config: Config) -> Result<(), String> { let mut timer = state.timer.lock().unwrap(); timer.update_config(config); timer.save_config() } #[tauri::command] fn update_pending_config(app: AppHandle, state: State, config: Config) { let mut timer = state.timer.lock().unwrap(); timer.update_config(config); // Notify all windows (break, mini) so they can pick up live config changes let _ = app.emit("config-changed", &()); } #[tauri::command] fn reset_config(state: State) -> Config { let mut timer = state.timer.lock().unwrap(); timer.reset_config(); timer.pending_config.clone() } #[tauri::command] fn toggle_timer(state: State) -> TimerSnapshot { let mut timer = state.timer.lock().unwrap(); timer.toggle_timer(); timer.snapshot() } #[tauri::command] fn start_break_now(app: AppHandle, state: State) -> TimerSnapshot { let (snapshot, fullscreen_mode) = { let mut timer = state.timer.lock().unwrap(); let payload = timer.start_break_now(); let fm = payload.as_ref().map(|p| p.fullscreen_mode); (timer.snapshot(), fm) }; // Window creation must happen after dropping the mutex if let Some(fm) = fullscreen_mode { handle_break_start(&app, fm); } snapshot } #[tauri::command] fn cancel_break(app: AppHandle, state: State) -> TimerSnapshot { let (snapshot, should_end) = { let mut timer = state.timer.lock().unwrap(); let was_break = timer.state == timer::TimerState::BreakActive; timer.cancel_break(); let ended = was_break && timer.state != timer::TimerState::BreakActive; if ended { let mut s = state.stats.lock().unwrap(); s.record_break_skipped(); } (timer.snapshot(), ended) }; if should_end { handle_break_end(&app); } snapshot } #[tauri::command] fn snooze(app: AppHandle, state: State) -> TimerSnapshot { let (snapshot, should_end) = { let mut timer = state.timer.lock().unwrap(); let was_break = timer.state == timer::TimerState::BreakActive; timer.snooze(); let ended = was_break && timer.state != timer::TimerState::BreakActive; if ended { let mut s = state.stats.lock().unwrap(); s.record_break_snoozed(); } (timer.snapshot(), ended) }; if should_end { handle_break_end(&app); } snapshot } #[tauri::command] fn get_timer_state(state: State) -> TimerSnapshot { let timer = state.timer.lock().unwrap(); timer.snapshot() } #[tauri::command] fn set_view(state: State, view: AppView) { let mut timer = state.timer.lock().unwrap(); timer.set_view(view); } #[tauri::command] fn get_stats(state: State) -> stats::StatsSnapshot { let s = state.stats.lock().unwrap(); s.snapshot() } #[tauri::command] fn get_daily_history(state: State, days: u32) -> Vec { let s = state.stats.lock().unwrap(); s.recent_days(days) } // ── Cursor / Window Position Commands ──────────────────────────────────── #[tauri::command] fn get_cursor_position() -> (i32, i32) { #[cfg(windows)] { use winapi::shared::windef::POINT; use winapi::um::winuser::GetCursorPos; let mut point = POINT { x: 0, y: 0 }; unsafe { GetCursorPos(&mut point); } (point.x, point.y) } #[cfg(not(windows))] { (0, 0) } } #[tauri::command] fn save_window_position( state: State, label: String, x: i32, y: i32, width: u32, height: u32, ) { let mut timer = state.timer.lock().unwrap(); match label.as_str() { "main" => { timer.pending_config.main_window_x = Some(x); timer.pending_config.main_window_y = Some(y); timer.pending_config.main_window_width = Some(width); timer.pending_config.main_window_height = Some(height); } "mini" => { timer.pending_config.mini_window_x = Some(x); timer.pending_config.mini_window_y = Some(y); } _ => {} } let _ = timer.save_config(); } // ── Dynamic Tray Icon Rendering ──────────────────────────────────────────── /// Parse a "#RRGGBB" hex color string into (r, g, b). Falls back to fallback on invalid input. fn parse_hex_color(hex: &str, fallback: (u8, u8, u8)) -> (u8, u8, u8) { if hex.len() == 7 && hex.starts_with('#') { let r = u8::from_str_radix(&hex[1..3], 16).unwrap_or(fallback.0); let g = u8::from_str_radix(&hex[3..5], 16).unwrap_or(fallback.1); let b = u8::from_str_radix(&hex[5..7], 16).unwrap_or(fallback.2); (r, g, b) } else { fallback } } /// Render a 32x32 RGBA icon with a progress arc using the given accent/break colors. fn render_tray_icon( progress: f64, is_break: bool, is_paused: bool, accent: (u8, u8, u8), break_color: (u8, u8, u8), ) -> Vec { let size: usize = 32; let mut rgba = vec![0u8; size * size * 4]; let center = size as f64 / 2.0; let outer_r = center - 1.0; let inner_r = outer_r - 4.0; let arc_color = if is_break { break_color } else { accent }; for y in 0..size { for x in 0..size { let dx = x as f64 - center; let dy = y as f64 - center; let dist = (dx * dx + dy * dy).sqrt(); let idx = (y * size + x) * 4; if dist >= inner_r && dist <= outer_r { // Determine angle (0 at top, clockwise) let angle = (dx.atan2(-dy) + std::f64::consts::PI) / (2.0 * std::f64::consts::PI); let in_arc = angle <= progress; if in_arc { rgba[idx] = arc_color.0; rgba[idx + 1] = arc_color.1; rgba[idx + 2] = arc_color.2; rgba[idx + 3] = 255; } else { // Background ring rgba[idx] = 60; rgba[idx + 1] = 60; rgba[idx + 2] = 60; rgba[idx + 3] = if is_paused { 100 } else { 180 }; } } } } rgba } fn update_tray( tray: &TrayIcon, snapshot: &TimerSnapshot, accent: (u8, u8, u8), break_color: (u8, u8, u8), ) { // Update tooltip let tooltip = match snapshot.state { timer::TimerState::Running => { let m = snapshot.time_remaining / 60; let s = snapshot.time_remaining % 60; format!("Core Cooldown - {:02}:{:02} until break", m, s) } timer::TimerState::Paused => { if snapshot.idle_paused { "Core Cooldown - Paused (idle)".to_string() } else { "Core Cooldown - Paused".to_string() } } timer::TimerState::BreakActive => { let m = snapshot.break_time_remaining / 60; let s = snapshot.break_time_remaining % 60; format!("Core Cooldown - Break {:02}:{:02}", m, s) } }; let _ = tray.set_tooltip(Some(&tooltip)); // Update icon let (progress, is_break, is_paused) = match snapshot.state { timer::TimerState::Running => (snapshot.progress, false, false), timer::TimerState::Paused => (snapshot.progress, false, true), timer::TimerState::BreakActive => (snapshot.break_progress, true, false), }; let icon_data = render_tray_icon(progress, is_break, is_paused, accent, break_color); let icon = Image::new_owned(icon_data, 32, 32); let _ = tray.set_icon(Some(icon)); } // ── App Builder ───────────────────────────────────────────────────────────── #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_global_shortcut::Builder::new().build()) .setup(|app| { // Portable data directory for WebView2 data (next to the exe) let data_dir = std::env::current_exe() .ok() .and_then(|p| p.parent().map(|d| d.join("data"))) .unwrap_or_else(|| std::path::PathBuf::from("data")); // Create main window (was previously in tauri.conf.json) tauri::WebviewWindowBuilder::new( app, "main", tauri::WebviewUrl::App("index.html".into()), ) .title("Core Cooldown") .inner_size(480.0, 700.0) .decorations(false) .transparent(true) .shadow(true) .data_directory(data_dir.clone()) .build()?; // Create break window (hidden, was previously in tauri.conf.json) tauri::WebviewWindowBuilder::new( app, "break", tauri::WebviewUrl::App("index.html?break=1".into()), ) .title("Core Cooldown - Break") .inner_size(900.0, 540.0) .decorations(false) .transparent(true) .shadow(false) .always_on_top(true) .skip_taskbar(true) .resizable(false) .visible(false) .center() .data_directory(data_dir.clone()) .build()?; let timer_manager = TimerManager::new(); let loaded_stats = Stats::load_or_default(); let state = AppState { timer: Arc::new(Mutex::new(timer_manager)), stats: Arc::new(Mutex::new(loaded_stats)), }; app.manage(state); // Restore saved window position { let st = app.state::(); let timer = st.timer.lock().unwrap(); let cfg = &timer.pending_config; if let (Some(x), Some(y)) = (cfg.main_window_x, cfg.main_window_y) { if let Some(win) = app.get_webview_window("main") { let _ = win.set_position(tauri::Position::Physical( tauri::PhysicalPosition::new(x, y), )); } } if let (Some(w), Some(h)) = (cfg.main_window_width, cfg.main_window_height) { if let Some(win) = app.get_webview_window("main") { let _ = win.set_size(tauri::Size::Physical(tauri::PhysicalSize::new(w, h))); } } } // Set up system tray let tray = setup_tray(app.handle())?; // Set up global shortcuts setup_shortcuts(app.handle()); // Start the timer tick thread let handle = app.handle().clone(); let timer_ref = app.state::().timer.clone(); let stats_ref = app.state::().stats.clone(); std::thread::spawn(move || { loop { std::thread::sleep(Duration::from_secs(1)); let (tick_result, snapshot, accent_hex, break_hex) = { let mut timer = timer_ref.lock().unwrap(); let result = timer.tick(); let snap = timer.snapshot(); let ac = timer.config.accent_color.clone(); let bc = timer.config.break_color.clone(); (result, snap, ac, bc) }; // Update tray icon and tooltip with configured colors let accent = parse_hex_color(&accent_hex, (255, 77, 0)); let break_c = parse_hex_color(&break_hex, (124, 106, 239)); update_tray(&tray, &snapshot, accent, break_c); // Emit tick event with full snapshot let _ = handle.emit("timer-tick", &snapshot); // Emit specific events for state transitions match tick_result { TickResult::BreakStarted(payload) => { handle_break_start(&handle, payload.fullscreen_mode); let _ = handle.emit("break-started", &payload); } TickResult::BreakEnded => { // Restore normal window state and close break window handle_break_end(&handle); // Record completed break in stats { let timer = timer_ref.lock().unwrap(); let mut s = stats_ref.lock().unwrap(); s.record_break_completed(timer.break_total_duration); } let _ = handle .notification() .builder() .title("Break complete") .body("Great job! Back to work.") .show(); let _ = handle.emit("break-ended", &()); } TickResult::PreBreakWarning { seconds_until_break, } => { let secs = seconds_until_break; let msg = if secs >= 60 { format!( "Break in {} minute{}", secs / 60, if secs / 60 == 1 { "" } else { "s" } ) } else { format!("Break in {} seconds", secs) }; let _ = handle .notification() .builder() .title("Core Cooldown") .body(&msg) .show(); let _ = handle.emit("prebreak-warning", &secs); } TickResult::NaturalBreakDetected { duration_seconds } => { // Record natural break in stats if enabled { let timer = timer_ref.lock().unwrap(); if timer.config.smart_break_count_stats { let mut s = stats_ref.lock().unwrap(); s.record_natural_break(duration_seconds); } } let mins = duration_seconds / 60; let msg = if mins >= 1 { format!( "You've been away for {} minute{}. Break timer has been reset.", mins, if mins == 1 { "" } else { "s" } ) } else { format!( "You've been away for {} seconds. Break timer has been reset.", duration_seconds ) }; let _ = handle .notification() .builder() .title("Natural break detected") .body(&msg) .show(); let _ = handle.emit("natural-break-detected", &duration_seconds); } TickResult::None => {} } } }); Ok(()) }) // Handle window close events - only exit when the main window is closed .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { .. } = event { if window.label() == "main" { window.app_handle().exit(0); } // Mini and break windows just close normally without killing the app } }) .invoke_handler(tauri::generate_handler![ get_config, save_config, update_pending_config, reset_config, toggle_timer, start_break_now, cancel_break, snooze, get_timer_state, set_view, get_stats, get_daily_history, get_cursor_position, save_window_position, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } fn setup_tray(app: &AppHandle) -> Result> { let show_i = MenuItemBuilder::with_id("show", "Show").build(app)?; let pause_i = MenuItemBuilder::with_id("pause", "Pause/Resume").build(app)?; let mini_i = MenuItemBuilder::with_id("mini", "Mini Mode").build(app)?; let quit_i = MenuItemBuilder::with_id("quit", "Quit").build(app)?; let menu = MenuBuilder::new(app) .item(&show_i) .item(&pause_i) .item(&mini_i) .separator() .item(&quit_i) .build()?; let tray = TrayIconBuilder::new() .menu(&menu) .tooltip("Core Cooldown") .on_menu_event(move |app, event| match event.id().as_ref() { "show" => { if let Some(window) = app.get_webview_window("main") { let _ = window.show(); let _ = window.unminimize(); let _ = window.set_focus(); } } "pause" => { let state: State = app.state(); let mut timer = state.timer.lock().unwrap(); timer.toggle_timer(); } "mini" => { toggle_mini_window(app); } "quit" => { app.exit(0); } _ => {} }) .on_tray_icon_event(|tray, event| { if let tauri::tray::TrayIconEvent::Click { button: tauri::tray::MouseButton::Left, .. } = event { let app = tray.app_handle(); if let Some(window) = app.get_webview_window("main") { let _ = window.show(); let _ = window.unminimize(); let _ = window.set_focus(); } } }) .build(app)?; Ok(tray) } // ── Global Shortcuts ─────────────────────────────────────────────────────── fn setup_shortcuts(app: &AppHandle) { use tauri_plugin_global_shortcut::GlobalShortcutExt; let _ = app .global_shortcut() .on_shortcut("Ctrl+Shift+P", move |_app, _shortcut, event| { if event.state == tauri_plugin_global_shortcut::ShortcutState::Pressed { let state: State = _app.state(); let mut timer = state.timer.lock().unwrap(); timer.toggle_timer(); } }); let _ = app .global_shortcut() .on_shortcut("Ctrl+Shift+B", move |_app, _shortcut, event| { if event.state == tauri_plugin_global_shortcut::ShortcutState::Pressed { let state: State = _app.state(); let mut timer = state.timer.lock().unwrap(); let payload = timer.start_break_now(); if let Some(ref p) = payload { handle_break_start(_app, p.fullscreen_mode); } } }); let _ = app .global_shortcut() .on_shortcut("Ctrl+Shift+S", move |_app, _shortcut, event| { if event.state == tauri_plugin_global_shortcut::ShortcutState::Pressed { if let Some(window) = _app.get_webview_window("main") { if window.is_visible().unwrap_or(false) { let _ = window.hide(); } else { let _ = window.show(); let _ = window.unminimize(); let _ = window.set_focus(); } } } }); } // ── Break Window ──────────────────────────────────────────────────────────── fn open_break_window(app: &AppHandle) { if let Some(win) = app.get_webview_window("break") { let _ = win.show(); let _ = win.set_focus(); } } fn close_break_window(app: &AppHandle) { if let Some(win) = app.get_webview_window("break") { let _ = win.hide(); } } /// Handle break start: either fullscreen on main window, or open a separate modal break window. fn handle_break_start(app: &AppHandle, fullscreen_mode: bool) { if fullscreen_mode { // Fullscreen: show break inside the main window if let Some(window) = app.get_webview_window("main") { let _ = window.set_always_on_top(true); let _ = window.show(); let _ = window.set_focus(); let _ = window.set_fullscreen(true); } } else { // Modal: open a separate centered break window open_break_window(app); } } /// Handle break end: restore main window state and close break window if open. fn handle_break_end(app: &AppHandle) { if let Some(window) = app.get_webview_window("main") { let _ = window.set_always_on_top(false); let _ = window.set_fullscreen(false); } close_break_window(app); } // ── Mini Mode ────────────────────────────────────────────────────────────── fn toggle_mini_window(app: &AppHandle) { if let Some(mini) = app.get_webview_window("mini") { // Close existing mini window let _ = mini.close(); } else { // Read saved position from config let (mx, my) = { let st: State = app.state(); let timer = st.timer.lock().unwrap(); ( timer.pending_config.mini_window_x, timer.pending_config.mini_window_y, ) }; // Portable data directory for WebView2 let data_dir = std::env::current_exe() .ok() .and_then(|p| p.parent().map(|d| d.join("data"))) .unwrap_or_else(|| std::path::PathBuf::from("data")); // Create mini window let mut builder = tauri::WebviewWindowBuilder::new( app, "mini", tauri::WebviewUrl::App("index.html?mini=1".into()), ) .title("Core Cooldown") .inner_size(184.0, 92.0) .decorations(false) .transparent(true) .shadow(false) .always_on_top(true) .skip_taskbar(true) .resizable(false) .data_directory(data_dir); if let (Some(x), Some(y)) = (mx, my) { builder = builder.position(x as f64, y as f64); } let _ = builder.build(); } }