reminder_cli/
daemon.rs

1use crate::notification::send_notification;
2use crate::storage::Storage;
3use anyhow::{Context, Result};
4use chrono::Local;
5use std::fs::{self, OpenOptions};
6use std::io::Write;
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!(
93                                "Last heartbeat: {}",
94                                dt.format("%Y-%m-%d %H:%M:%S")
95                            );
96                        }
97                    }
98                }
99            }
100        }
101    } else {
102        println!("Daemon is not running");
103    }
104    Ok(())
105}
106
107pub fn is_daemon_running() -> Result<bool> {
108    let pid_file = Storage::pid_file_path()?;
109
110    if !pid_file.exists() {
111        return Ok(false);
112    }
113
114    let pid_str = fs::read_to_string(&pid_file)?;
115    let pid: u32 = match pid_str.trim().parse() {
116        Ok(p) => p,
117        Err(_) => {
118            fs::remove_file(&pid_file)?;
119            return Ok(false);
120        }
121    };
122
123    #[cfg(unix)]
124    {
125        let output = Command::new("kill")
126            .args(["-0", &pid.to_string()])
127            .output();
128
129        match output {
130            Ok(o) => Ok(o.status.success()),
131            Err(_) => {
132                fs::remove_file(&pid_file)?;
133                Ok(false)
134            }
135        }
136    }
137
138    #[cfg(windows)]
139    {
140        let output = Command::new("tasklist")
141            .args(["/FI", &format!("PID eq {}", pid)])
142            .output();
143
144        match output {
145            Ok(o) => {
146                let stdout = String::from_utf8_lossy(&o.stdout);
147                Ok(stdout.contains(&pid.to_string()))
148            }
149            Err(_) => {
150                fs::remove_file(&pid_file)?;
151                Ok(false)
152            }
153        }
154    }
155}
156
157fn log_daemon(message: &str) {
158    if let Ok(log_path) = Storage::log_file_path() {
159        if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(log_path) {
160            let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S");
161            let _ = writeln!(file, "[{}] {}", timestamp, message);
162        }
163    }
164}
165
166fn write_heartbeat() {
167    if let Ok(heartbeat_path) = Storage::heartbeat_file_path() {
168        let timestamp = Local::now().timestamp().to_string();
169        let _ = fs::write(heartbeat_path, timestamp);
170    }
171}
172
173fn check_heartbeat() -> Result<bool> {
174    let heartbeat_path = Storage::heartbeat_file_path()?;
175
176    if !heartbeat_path.exists() {
177        return Ok(false);
178    }
179
180    let content = fs::read_to_string(&heartbeat_path)?;
181    let timestamp: i64 = content.trim().parse().unwrap_or(0);
182    let now = Local::now().timestamp();
183
184    Ok((now - timestamp) < HEARTBEAT_TIMEOUT_SECS as i64)
185}
186
187pub fn is_daemon_healthy() -> Result<bool> {
188    if !is_daemon_running()? {
189        return Ok(false);
190    }
191    check_heartbeat()
192}
193
194pub fn run_daemon_loop() -> Result<()> {
195    let storage = Storage::new()?;
196    log_daemon("Daemon started");
197    write_heartbeat();
198
199    let mut heartbeat_counter = 0u64;
200
201    loop {
202        match storage.load() {
203            Ok(mut reminders) => {
204                let mut updated = false;
205
206                for reminder in reminders.iter_mut() {
207                    if reminder.is_due() {
208                        log_daemon(&format!("Triggering reminder: {}", reminder.title));
209
210                        if let Err(e) = send_notification(reminder) {
211                            log_daemon(&format!("Failed to send notification: {}", e));
212                        }
213                        reminder.calculate_next_trigger();
214                        updated = true;
215                    }
216                }
217
218                if updated {
219                    if let Err(e) = storage.save(&reminders) {
220                        log_daemon(&format!("Failed to save reminders: {}", e));
221                    }
222                }
223            }
224            Err(e) => {
225                log_daemon(&format!("Failed to load reminders: {}", e));
226            }
227        }
228
229        // Write heartbeat periodically
230        heartbeat_counter += POLL_INTERVAL_SECS;
231        if heartbeat_counter >= HEARTBEAT_INTERVAL_SECS {
232            write_heartbeat();
233            heartbeat_counter = 0;
234        }
235
236        thread::sleep(Duration::from_secs(POLL_INTERVAL_SECS));
237    }
238}
239
240/// Generate launchd plist for macOS auto-start
241#[cfg(target_os = "macos")]
242pub fn generate_launchd_plist() -> Result<String> {
243    let exe = std::env::current_exe()?;
244    let plist = format!(
245        r#"<?xml version="1.0" encoding="UTF-8"?>
246<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
247<plist version="1.0">
248<dict>
249    <key>Label</key>
250    <string>com.reminder-cli.daemon</string>
251    <key>ProgramArguments</key>
252    <array>
253        <string>{}</string>
254        <string>daemon</string>
255        <string>run</string>
256    </array>
257    <key>RunAtLoad</key>
258    <true/>
259    <key>KeepAlive</key>
260    <true/>
261</dict>
262</plist>"#,
263        exe.display()
264    );
265    Ok(plist)
266}
267
268/// Generate systemd service for Linux auto-start
269#[cfg(target_os = "linux")]
270pub fn generate_systemd_service() -> Result<String> {
271    let exe = std::env::current_exe()?;
272    let service = format!(
273        r#"[Unit]
274Description=Reminder CLI Daemon
275After=network.target
276
277[Service]
278Type=simple
279ExecStart={} daemon run
280Restart=always
281RestartSec=10
282
283[Install]
284WantedBy=default.target"#,
285        exe.display()
286    );
287    Ok(service)
288}
289
290pub fn install_autostart() -> Result<()> {
291    #[cfg(target_os = "macos")]
292    {
293        let plist = generate_launchd_plist()?;
294        let plist_path = dirs::home_dir()
295            .context("Failed to get home directory")?
296            .join("Library/LaunchAgents/com.reminder-cli.daemon.plist");
297
298        fs::write(&plist_path, plist)?;
299        println!("Created launchd plist at: {}", plist_path.display());
300        println!("To enable: launchctl load {}", plist_path.display());
301    }
302
303    #[cfg(target_os = "linux")]
304    {
305        let service = generate_systemd_service()?;
306        let service_path = dirs::home_dir()
307            .context("Failed to get home directory")?
308            .join(".config/systemd/user/reminder-cli.service");
309
310        if let Some(parent) = service_path.parent() {
311            fs::create_dir_all(parent)?;
312        }
313
314        fs::write(&service_path, service)?;
315        println!("Created systemd service at: {}", service_path.display());
316        println!("To enable: systemctl --user enable --now reminder-cli");
317    }
318
319    #[cfg(target_os = "windows")]
320    {
321        println!("Windows auto-start: Add a shortcut to 'reminder daemon start' in your Startup folder");
322        println!(
323            "Startup folder: {}",
324            dirs::data_local_dir()
325                .map(|p| p
326                    .parent()
327                    .unwrap_or(&p)
328                    .join("Roaming/Microsoft/Windows/Start Menu/Programs/Startup")
329                    .display()
330                    .to_string())
331                .unwrap_or_else(|| "Unknown".to_string())
332        );
333    }
334
335    Ok(())
336}