reminder_cli/
daemon.rs

1use crate::notification::send_notification;
2use crate::storage::Storage;
3use crate::{log_debug, log_error, log_info};
4use anyhow::{Context, Result};
5use chrono::Local;
6use std::fs;
7use std::process::{Command, Stdio};
8use std::thread;
9use std::time::Duration;
10
11const POLL_INTERVAL_SECS: u64 = 10;
12const HEARTBEAT_INTERVAL_SECS: u64 = 30;
13const HEARTBEAT_TIMEOUT_SECS: u64 = 120;
14
15pub fn start_daemon() -> Result<()> {
16    let pid_file = Storage::pid_file_path()?;
17
18    if is_daemon_running()? {
19        println!("Daemon is already running");
20        return Ok(());
21    }
22
23    let exe = std::env::current_exe()?;
24
25    let child = Command::new(exe)
26        .arg("daemon")
27        .arg("run")
28        .stdin(Stdio::null())
29        .stdout(Stdio::null())
30        .stderr(Stdio::null())
31        .spawn()
32        .context("Failed to start daemon process")?;
33
34    fs::write(&pid_file, child.id().to_string())?;
35    println!("Daemon started with PID: {}", child.id());
36
37    Ok(())
38}
39
40pub fn stop_daemon() -> Result<()> {
41    let pid_file = Storage::pid_file_path()?;
42
43    if !pid_file.exists() {
44        println!("Daemon is not running");
45        return Ok(());
46    }
47
48    let pid_str = fs::read_to_string(&pid_file)?;
49    let pid: i32 = pid_str.trim().parse()?;
50
51    #[cfg(unix)]
52    {
53        let _ = Command::new("kill").arg(pid.to_string()).status();
54    }
55
56    #[cfg(windows)]
57    {
58        let _ = Command::new("taskkill")
59            .args(["/PID", &pid.to_string(), "/F"])
60            .status();
61    }
62
63    fs::remove_file(&pid_file)?;
64    println!("Daemon stopped");
65
66    Ok(())
67}
68
69pub fn daemon_status() -> Result<()> {
70    let running = is_daemon_running()?;
71    let healthy = is_daemon_healthy()?;
72
73    if running {
74        let pid_file = Storage::pid_file_path()?;
75        let pid = fs::read_to_string(&pid_file)?;
76        println!("Daemon is running (PID: {})", pid.trim());
77
78        if healthy {
79            println!("Health: OK (heartbeat active)");
80        } else {
81            println!("Health: WARNING (heartbeat stale - daemon may be stuck)");
82        }
83
84        // Show last heartbeat time
85        if let Ok(heartbeat_path) = Storage::heartbeat_file_path() {
86            if heartbeat_path.exists() {
87                if let Ok(content) = fs::read_to_string(&heartbeat_path) {
88                    if let Ok(timestamp) = content.trim().parse::<i64>() {
89                        let dt = chrono::DateTime::from_timestamp(timestamp, 0)
90                            .map(|t| t.with_timezone(&Local));
91                        if let Some(dt) = dt {
92                            println!("Last heartbeat: {}", dt.format("%Y-%m-%d %H:%M:%S"));
93                        }
94                    }
95                }
96            }
97        }
98    } else {
99        println!("Daemon is not running");
100    }
101    Ok(())
102}
103
104pub fn is_daemon_running() -> Result<bool> {
105    let pid_file = Storage::pid_file_path()?;
106
107    if !pid_file.exists() {
108        return Ok(false);
109    }
110
111    let pid_str = fs::read_to_string(&pid_file)?;
112    let pid: u32 = match pid_str.trim().parse() {
113        Ok(p) => p,
114        Err(_) => {
115            fs::remove_file(&pid_file)?;
116            return Ok(false);
117        }
118    };
119
120    #[cfg(unix)]
121    {
122        let output = Command::new("kill").args(["-0", &pid.to_string()]).output();
123
124        match output {
125            Ok(o) => Ok(o.status.success()),
126            Err(_) => {
127                fs::remove_file(&pid_file)?;
128                Ok(false)
129            }
130        }
131    }
132
133    #[cfg(windows)]
134    {
135        let output = Command::new("tasklist")
136            .args(["/FI", &format!("PID eq {}", pid)])
137            .output();
138
139        match output {
140            Ok(o) => {
141                let stdout = String::from_utf8_lossy(&o.stdout);
142                Ok(stdout.contains(&pid.to_string()))
143            }
144            Err(_) => {
145                fs::remove_file(&pid_file)?;
146                Ok(false)
147            }
148        }
149    }
150}
151
152fn write_heartbeat() {
153    if let Ok(heartbeat_path) = Storage::heartbeat_file_path() {
154        let timestamp = Local::now().timestamp().to_string();
155        let _ = fs::write(heartbeat_path, timestamp);
156    }
157}
158
159fn check_heartbeat() -> Result<bool> {
160    let heartbeat_path = Storage::heartbeat_file_path()?;
161
162    if !heartbeat_path.exists() {
163        return Ok(false);
164    }
165
166    let content = fs::read_to_string(&heartbeat_path)?;
167    let timestamp: i64 = content.trim().parse().unwrap_or(0);
168    let now = Local::now().timestamp();
169
170    Ok((now - timestamp) < HEARTBEAT_TIMEOUT_SECS as i64)
171}
172
173pub fn is_daemon_healthy() -> Result<bool> {
174    if !is_daemon_running()? {
175        return Ok(false);
176    }
177    check_heartbeat()
178}
179
180pub fn run_daemon_loop() -> Result<()> {
181    let storage = Storage::new()?;
182    log_info!("Daemon started");
183    write_heartbeat();
184
185    let mut heartbeat_counter = 0u64;
186
187    loop {
188        match storage.load() {
189            Ok(mut reminders) => {
190                let mut updated = false;
191
192                for reminder in reminders.iter_mut() {
193                    if reminder.is_due() {
194                        log_info!("Triggering reminder: {}", reminder.title);
195
196                        if let Err(e) = send_notification(reminder) {
197                            log_error!("Failed to send notification: {}", e);
198                        }
199                        reminder.calculate_next_trigger();
200                        updated = true;
201                    }
202                }
203
204                if updated {
205                    if let Err(e) = storage.save(&reminders) {
206                        log_error!("Failed to save reminders: {}", e);
207                    }
208                }
209            }
210            Err(e) => {
211                log_error!("Failed to load reminders: {}", e);
212            }
213        }
214
215        // Write heartbeat periodically
216        heartbeat_counter += POLL_INTERVAL_SECS;
217        if heartbeat_counter >= HEARTBEAT_INTERVAL_SECS {
218            write_heartbeat();
219            log_debug!("Heartbeat written");
220            heartbeat_counter = 0;
221        }
222
223        thread::sleep(Duration::from_secs(POLL_INTERVAL_SECS));
224    }
225}
226
227/// Generate launchd plist for macOS auto-start
228#[cfg(target_os = "macos")]
229pub fn generate_launchd_plist() -> Result<String> {
230    let exe = std::env::current_exe()?;
231    let plist = format!(
232        r#"<?xml version="1.0" encoding="UTF-8"?>
233<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
234<plist version="1.0">
235<dict>
236    <key>Label</key>
237    <string>com.reminder-cli.daemon</string>
238    <key>ProgramArguments</key>
239    <array>
240        <string>{}</string>
241        <string>daemon</string>
242        <string>run</string>
243    </array>
244    <key>RunAtLoad</key>
245    <true/>
246    <key>KeepAlive</key>
247    <true/>
248</dict>
249</plist>"#,
250        exe.display()
251    );
252    Ok(plist)
253}
254
255/// Generate systemd service for Linux auto-start
256#[cfg(target_os = "linux")]
257pub fn generate_systemd_service() -> Result<String> {
258    let exe = std::env::current_exe()?;
259    let service = format!(
260        r#"[Unit]
261Description=Reminder CLI Daemon
262After=network.target
263
264[Service]
265Type=simple
266ExecStart={} daemon run
267Restart=always
268RestartSec=10
269
270[Install]
271WantedBy=default.target"#,
272        exe.display()
273    );
274    Ok(service)
275}
276
277pub fn install_autostart() -> Result<()> {
278    #[cfg(target_os = "macos")]
279    {
280        let plist = generate_launchd_plist()?;
281        let plist_path = dirs::home_dir()
282            .context("Failed to get home directory")?
283            .join("Library/LaunchAgents/com.reminder-cli.daemon.plist");
284
285        fs::write(&plist_path, plist)?;
286        println!("Created launchd plist at: {}", plist_path.display());
287        println!("To enable: launchctl load {}", plist_path.display());
288    }
289
290    #[cfg(target_os = "linux")]
291    {
292        let service = generate_systemd_service()?;
293        let service_path = dirs::home_dir()
294            .context("Failed to get home directory")?
295            .join(".config/systemd/user/reminder-cli.service");
296
297        if let Some(parent) = service_path.parent() {
298            fs::create_dir_all(parent)?;
299        }
300
301        fs::write(&service_path, service)?;
302        println!("Created systemd service at: {}", service_path.display());
303        println!("To enable: systemctl --user enable --now reminder-cli");
304    }
305
306    #[cfg(target_os = "windows")]
307    {
308        println!(
309            "Windows auto-start: Add a shortcut to 'reminder daemon start' in your Startup folder"
310        );
311        println!(
312            "Startup folder: {}",
313            dirs::data_local_dir()
314                .map(|p| p
315                    .parent()
316                    .unwrap_or(&p)
317                    .join("Roaming/Microsoft/Windows/Start Menu/Programs/Startup")
318                    .display()
319                    .to_string())
320                .unwrap_or_else(|| "Unknown".to_string())
321        );
322    }
323
324    Ok(())
325}