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