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 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 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#[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#[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}