1068 lines
39 KiB
Rust
1068 lines
39 KiB
Rust
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, MicrobreakTickResult, TickResult, TimerManager, TimerSnapshot};
|
|
|
|
pub struct AppState {
|
|
pub timer: Arc<Mutex<TimerManager>>,
|
|
pub stats: Arc<Mutex<Stats>>,
|
|
}
|
|
|
|
// ── Tauri Commands ──────────────────────────────────────────────────────────
|
|
|
|
#[tauri::command]
|
|
fn get_config(state: State<AppState>) -> Config {
|
|
let timer = state.timer.lock().unwrap();
|
|
timer.pending_config.clone()
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn save_config(state: State<AppState>, 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<AppState>, 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<AppState>) -> Config {
|
|
let mut timer = state.timer.lock().unwrap();
|
|
timer.reset_config();
|
|
timer.pending_config.clone()
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn toggle_timer(state: State<AppState>) -> TimerSnapshot {
|
|
let mut timer = state.timer.lock().unwrap();
|
|
timer.toggle_timer();
|
|
timer.snapshot()
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn start_break_now(app: AppHandle, state: State<AppState>) -> 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<AppState>) -> 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<AppState>) -> 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<AppState>) -> TimerSnapshot {
|
|
let timer = state.timer.lock().unwrap();
|
|
timer.snapshot()
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn set_view(state: State<AppState>, view: AppView) {
|
|
let mut timer = state.timer.lock().unwrap();
|
|
timer.set_view(view);
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn get_stats(state: State<AppState>) -> stats::StatsSnapshot {
|
|
let s = state.stats.lock().unwrap();
|
|
let timer = state.timer.lock().unwrap();
|
|
let daily_goal = if timer.config.daily_goal_enabled {
|
|
timer.config.daily_goal_breaks
|
|
} else {
|
|
0
|
|
};
|
|
s.snapshot(daily_goal)
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn get_daily_history(state: State<AppState>, days: u32) -> Vec<stats::DayRecord> {
|
|
let s = state.stats.lock().unwrap();
|
|
s.recent_days(days)
|
|
}
|
|
|
|
// F7: Weekly summary command
|
|
#[tauri::command]
|
|
fn get_weekly_summary(state: State<AppState>, weeks: u32) -> Vec<stats::WeekSummary> {
|
|
let s = state.stats.lock().unwrap();
|
|
s.weekly_summary(weeks)
|
|
}
|
|
|
|
// F8: Auto-start on Windows login
|
|
#[tauri::command]
|
|
fn set_auto_start(enabled: bool) -> Result<(), String> {
|
|
#[cfg(windows)]
|
|
{
|
|
use std::ffi::OsStr;
|
|
use std::os::windows::ffi::OsStrExt;
|
|
use winapi::um::winreg::{RegOpenKeyExW, RegSetValueExW, RegDeleteValueW, HKEY_CURRENT_USER};
|
|
use winapi::um::winnt::{KEY_SET_VALUE, REG_SZ};
|
|
|
|
let sub_key: Vec<u16> = OsStr::new("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run")
|
|
.encode_wide()
|
|
.chain(std::iter::once(0))
|
|
.collect();
|
|
let value_name: Vec<u16> = OsStr::new("CoreCooldown")
|
|
.encode_wide()
|
|
.chain(std::iter::once(0))
|
|
.collect();
|
|
|
|
unsafe {
|
|
let mut hkey = std::ptr::null_mut();
|
|
let result = RegOpenKeyExW(
|
|
HKEY_CURRENT_USER,
|
|
sub_key.as_ptr(),
|
|
0,
|
|
KEY_SET_VALUE,
|
|
&mut hkey,
|
|
);
|
|
if result != 0 {
|
|
return Err("Failed to open registry key".to_string());
|
|
}
|
|
|
|
if enabled {
|
|
let exe_path = std::env::current_exe()
|
|
.map_err(|e| e.to_string())?;
|
|
let path_str = exe_path.to_string_lossy();
|
|
let value_data: Vec<u16> = OsStr::new(&*path_str)
|
|
.encode_wide()
|
|
.chain(std::iter::once(0))
|
|
.collect();
|
|
let res = RegSetValueExW(
|
|
hkey,
|
|
value_name.as_ptr(),
|
|
0,
|
|
REG_SZ,
|
|
value_data.as_ptr() as *const u8,
|
|
(value_data.len() * 2) as u32,
|
|
);
|
|
winapi::um::winreg::RegCloseKey(hkey);
|
|
if res != 0 {
|
|
return Err("Failed to set registry value".to_string());
|
|
}
|
|
} else {
|
|
let _res = RegDeleteValueW(hkey, value_name.as_ptr());
|
|
winapi::um::winreg::RegCloseKey(hkey);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
#[cfg(not(windows))]
|
|
{
|
|
Err("Auto-start is only supported on Windows".to_string())
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn get_auto_start_status() -> bool {
|
|
#[cfg(windows)]
|
|
{
|
|
use std::ffi::OsStr;
|
|
use std::os::windows::ffi::OsStrExt;
|
|
use winapi::um::winreg::{RegOpenKeyExW, RegQueryValueExW, HKEY_CURRENT_USER};
|
|
use winapi::um::winnt::KEY_READ;
|
|
|
|
let sub_key: Vec<u16> = OsStr::new("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run")
|
|
.encode_wide()
|
|
.chain(std::iter::once(0))
|
|
.collect();
|
|
let value_name: Vec<u16> = OsStr::new("CoreCooldown")
|
|
.encode_wide()
|
|
.chain(std::iter::once(0))
|
|
.collect();
|
|
|
|
unsafe {
|
|
let mut hkey = std::ptr::null_mut();
|
|
let result = RegOpenKeyExW(
|
|
HKEY_CURRENT_USER,
|
|
sub_key.as_ptr(),
|
|
0,
|
|
KEY_READ,
|
|
&mut hkey,
|
|
);
|
|
if result != 0 {
|
|
return false;
|
|
}
|
|
let res = RegQueryValueExW(
|
|
hkey,
|
|
value_name.as_ptr(),
|
|
std::ptr::null_mut(),
|
|
std::ptr::null_mut(),
|
|
std::ptr::null_mut(),
|
|
std::ptr::null_mut(),
|
|
);
|
|
winapi::um::winreg::RegCloseKey(hkey);
|
|
res == 0
|
|
}
|
|
}
|
|
#[cfg(not(windows))]
|
|
{
|
|
false
|
|
}
|
|
}
|
|
|
|
// ── 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<AppState>,
|
|
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.
|
|
/// F10: Optionally renders a green checkmark when daily goal is met.
|
|
fn render_tray_icon(
|
|
progress: f64,
|
|
is_break: bool,
|
|
is_paused: bool,
|
|
accent: (u8, u8, u8),
|
|
break_color: (u8, u8, u8),
|
|
goal_met: bool,
|
|
) -> Vec<u8> {
|
|
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 };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// F10: Draw a small green dot in the bottom-right corner when goal is met
|
|
if goal_met {
|
|
let dot_cx = 25.0_f64;
|
|
let dot_cy = 25.0_f64;
|
|
let dot_r = 4.0_f64;
|
|
for y in 20..32 {
|
|
for x in 20..32 {
|
|
let dx = x as f64 - dot_cx;
|
|
let dy = y as f64 - dot_cy;
|
|
let dist = (dx * dx + dy * dy).sqrt();
|
|
if dist <= dot_r {
|
|
let idx = (y * size + x) * 4;
|
|
rgba[idx] = 63; // green
|
|
rgba[idx + 1] = 185;
|
|
rgba[idx + 2] = 80;
|
|
rgba[idx + 3] = 255;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
rgba
|
|
}
|
|
|
|
fn update_tray(
|
|
tray: &TrayIcon,
|
|
snapshot: &TimerSnapshot,
|
|
accent: (u8, u8, u8),
|
|
break_color: (u8, u8, u8),
|
|
goal_met: bool,
|
|
) {
|
|
// Update tooltip
|
|
let tooltip = match snapshot.state {
|
|
timer::TimerState::Running => {
|
|
if snapshot.deferred_break_pending {
|
|
"Core Cooldown - Break deferred (fullscreen)".to_string()
|
|
} else {
|
|
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, goal_met);
|
|
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::<AppState>();
|
|
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::<AppState>().timer.clone();
|
|
let stats_ref = app.state::<AppState>().stats.clone();
|
|
let data_dir_clone = data_dir.clone();
|
|
std::thread::spawn(move || {
|
|
let mut dim_window_open = false;
|
|
let mut microbreak_window_open = false;
|
|
let mut break_deferred_notified = false;
|
|
|
|
loop {
|
|
std::thread::sleep(Duration::from_secs(1));
|
|
|
|
let (tick_result, mb_result, snapshot, accent_hex, break_hex, daily_goal, daily_goal_enabled, goal_met) = {
|
|
let mut timer = timer_ref.lock().unwrap();
|
|
let result = timer.tick();
|
|
let mb = timer.tick_microbreak();
|
|
let snap = timer.snapshot();
|
|
let ac = timer.config.accent_color.clone();
|
|
let bc = timer.config.break_color.clone();
|
|
let dg = timer.config.daily_goal_breaks;
|
|
let dge = timer.config.daily_goal_enabled;
|
|
// Check goal status
|
|
let s = stats_ref.lock().unwrap();
|
|
let goal_target = if dge { dg } else { 0 };
|
|
let ss = s.snapshot(goal_target);
|
|
(result, mb, snap, ac, bc, dg, dge, ss.daily_goal_met)
|
|
};
|
|
|
|
// 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, goal_met);
|
|
|
|
// Emit tick event with full snapshot
|
|
let _ = handle.emit("timer-tick", &snapshot);
|
|
|
|
// F5: Screen dim window management
|
|
if snapshot.screen_dim_active && !dim_window_open {
|
|
open_dim_overlay(&handle, &data_dir_clone);
|
|
dim_window_open = true;
|
|
} else if !snapshot.screen_dim_active && dim_window_open {
|
|
close_dim_overlay(&handle);
|
|
dim_window_open = false;
|
|
}
|
|
if snapshot.screen_dim_active {
|
|
let max_opacity = {
|
|
let t = timer_ref.lock().unwrap();
|
|
t.config.screen_dim_max_opacity
|
|
};
|
|
let _ = handle.emit("screen-dim-update", &serde_json::json!({
|
|
"progress": snapshot.screen_dim_progress,
|
|
"maxOpacity": max_opacity
|
|
}));
|
|
}
|
|
|
|
// F1: Microbreak window management
|
|
match mb_result {
|
|
MicrobreakTickResult::MicrobreakStarted => {
|
|
open_microbreak_window(&handle, &data_dir_clone);
|
|
microbreak_window_open = true;
|
|
let _ = handle.emit("microbreak-started", &());
|
|
}
|
|
MicrobreakTickResult::MicrobreakEnded => {
|
|
close_microbreak_window(&handle);
|
|
microbreak_window_open = false;
|
|
let _ = handle.emit("microbreak-ended", &());
|
|
}
|
|
MicrobreakTickResult::None => {}
|
|
}
|
|
|
|
// Emit specific events for state transitions
|
|
match tick_result {
|
|
TickResult::BreakStarted(payload) => {
|
|
// Close dim overlay if it was open
|
|
if dim_window_open {
|
|
close_dim_overlay(&handle);
|
|
dim_window_open = false;
|
|
}
|
|
// Close microbreak if active
|
|
if microbreak_window_open {
|
|
close_microbreak_window(&handle);
|
|
microbreak_window_open = false;
|
|
}
|
|
handle_break_start(&handle, payload.fullscreen_mode);
|
|
// F9: Multi-monitor overlays
|
|
{
|
|
let timer = timer_ref.lock().unwrap();
|
|
if timer.config.fullscreen_mode && timer.config.multi_monitor_break {
|
|
open_multi_monitor_overlays(&handle, &data_dir_clone);
|
|
}
|
|
}
|
|
let _ = handle.emit("break-started", &payload);
|
|
break_deferred_notified = false;
|
|
}
|
|
TickResult::BreakEnded => {
|
|
// Restore normal window state and close break window
|
|
handle_break_end(&handle);
|
|
// F9: Close multi-monitor overlays
|
|
close_multi_monitor_overlays(&handle);
|
|
// Record completed break in stats
|
|
let break_result = {
|
|
let timer = timer_ref.lock().unwrap();
|
|
let goal = if daily_goal_enabled { daily_goal } else { 0 };
|
|
let mut s = stats_ref.lock().unwrap();
|
|
s.record_break_completed(timer.break_total_duration, goal)
|
|
};
|
|
// F10: Emit milestone/goal events
|
|
if let Some(streak) = break_result.milestone_reached {
|
|
let _ = handle.emit("milestone-reached", &streak);
|
|
let timer = timer_ref.lock().unwrap();
|
|
if timer.config.streak_notifications {
|
|
let _ = handle
|
|
.notification()
|
|
.builder()
|
|
.title("Streak milestone!")
|
|
.body(&format!("{}-day streak! Keep it up!", streak))
|
|
.show();
|
|
}
|
|
}
|
|
if break_result.daily_goal_just_met {
|
|
let _ = handle.emit("daily-goal-met", &());
|
|
let timer = timer_ref.lock().unwrap();
|
|
if timer.config.streak_notifications {
|
|
let _ = handle
|
|
.notification()
|
|
.builder()
|
|
.title("Daily goal reached!")
|
|
.body("You've hit your break goal for today.")
|
|
.show();
|
|
}
|
|
}
|
|
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::BreakDeferred => {
|
|
// F2: Notify once when break gets deferred
|
|
if !break_deferred_notified {
|
|
break_deferred_notified = true;
|
|
let timer = timer_ref.lock().unwrap();
|
|
if timer.config.presentation_mode_notification {
|
|
let _ = handle
|
|
.notification()
|
|
.builder()
|
|
.title("Break deferred")
|
|
.body("Fullscreen app detected - break will start when you exit.")
|
|
.show();
|
|
}
|
|
let _ = handle.emit("break-deferred", &());
|
|
}
|
|
}
|
|
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_weekly_summary,
|
|
set_auto_start,
|
|
get_auto_start_status,
|
|
get_cursor_position,
|
|
save_window_position,
|
|
])
|
|
.run(tauri::generate_context!())
|
|
.expect("error while running tauri application");
|
|
}
|
|
|
|
fn setup_tray(app: &AppHandle) -> Result<TrayIcon, Box<dyn std::error::Error>> {
|
|
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<AppState> = 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<AppState> = _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<AppState> = _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<AppState> = 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();
|
|
}
|
|
}
|
|
|
|
// ── F1: Microbreak Window ──────────────────────────────────────────────────
|
|
|
|
fn open_microbreak_window(app: &AppHandle, data_dir: &std::path::Path) {
|
|
if app.get_webview_window("microbreak").is_some() {
|
|
return; // already open
|
|
}
|
|
|
|
let _ = tauri::WebviewWindowBuilder::new(
|
|
app,
|
|
"microbreak",
|
|
tauri::WebviewUrl::App("index.html?microbreak=1".into()),
|
|
)
|
|
.title("Eye Break")
|
|
.inner_size(400.0, 180.0)
|
|
.decorations(false)
|
|
.transparent(true)
|
|
.shadow(false)
|
|
.always_on_top(true)
|
|
.skip_taskbar(true)
|
|
.resizable(false)
|
|
.center()
|
|
.data_directory(data_dir.to_path_buf())
|
|
.build();
|
|
}
|
|
|
|
fn close_microbreak_window(app: &AppHandle) {
|
|
if let Some(win) = app.get_webview_window("microbreak") {
|
|
let _ = win.close();
|
|
}
|
|
}
|
|
|
|
// ── F5: Dim Overlay Window ──────────────────────────────────────────────────
|
|
|
|
fn open_dim_overlay(app: &AppHandle, data_dir: &std::path::Path) {
|
|
if app.get_webview_window("dim").is_some() {
|
|
return;
|
|
}
|
|
|
|
let builder = tauri::WebviewWindowBuilder::new(
|
|
app,
|
|
"dim",
|
|
tauri::WebviewUrl::App("index.html?dim=1".into()),
|
|
)
|
|
.title("")
|
|
.decorations(false)
|
|
.transparent(true)
|
|
.shadow(false)
|
|
.always_on_top(true)
|
|
.skip_taskbar(true)
|
|
.resizable(false)
|
|
.maximized(true)
|
|
.data_directory(data_dir.to_path_buf());
|
|
|
|
if let Ok(win) = builder.build() {
|
|
let _ = win.set_ignore_cursor_events(true);
|
|
}
|
|
}
|
|
|
|
fn close_dim_overlay(app: &AppHandle) {
|
|
if let Some(win) = app.get_webview_window("dim") {
|
|
let _ = win.close();
|
|
}
|
|
}
|
|
|
|
// ── F9: Multi-Monitor Break Overlays ────────────────────────────────────────
|
|
|
|
fn open_multi_monitor_overlays(app: &AppHandle, data_dir: &std::path::Path) {
|
|
let monitors = timer::get_all_monitors();
|
|
|
|
for (i, mon) in monitors.iter().enumerate() {
|
|
if mon.is_primary {
|
|
continue; // Primary is handled by the main break window
|
|
}
|
|
if i > 5 {
|
|
break; // Cap at 6 monitors
|
|
}
|
|
|
|
let label = format!("break-overlay-{}", i);
|
|
if app.get_webview_window(&label).is_some() {
|
|
continue;
|
|
}
|
|
|
|
let _ = tauri::WebviewWindowBuilder::new(
|
|
app,
|
|
&label,
|
|
tauri::WebviewUrl::App("index.html?breakoverlay=1".into()),
|
|
)
|
|
.title("")
|
|
.position(mon.x as f64, mon.y as f64)
|
|
.inner_size(mon.width as f64, mon.height as f64)
|
|
.decorations(false)
|
|
.transparent(true)
|
|
.shadow(false)
|
|
.always_on_top(true)
|
|
.skip_taskbar(true)
|
|
.resizable(false)
|
|
.data_directory(data_dir.to_path_buf())
|
|
.build();
|
|
}
|
|
}
|
|
|
|
fn close_multi_monitor_overlays(app: &AppHandle) {
|
|
// Close any window with label starting with "break-overlay-"
|
|
for i in 0..6 {
|
|
let label = format!("break-overlay-{}", i);
|
|
if let Some(win) = app.get_webview_window(&label) {
|
|
let _ = win.close();
|
|
}
|
|
}
|
|
}
|