1use std::path::PathBuf;
5
6#[cfg(target_os = "macos")]
7const LABEL: &str = "com.leanctx.autoupdate";
8
9#[derive(Debug, Clone)]
10pub struct ScheduleInfo {
11 pub enabled: bool,
12 pub mechanism: String,
13 pub interval_hours: u64,
14 pub scheduler_path: Option<PathBuf>,
15 pub last_check: Option<String>,
16}
17
18impl std::fmt::Display for ScheduleInfo {
19 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20 if self.enabled {
21 write!(
22 f,
23 "Auto-update: enabled ({}, every {}h)",
24 self.mechanism, self.interval_hours
25 )?;
26 if let Some(ref path) = self.scheduler_path {
27 write!(f, "\n Scheduler: {}", path.display())?;
28 }
29 if let Some(ref last) = self.last_check {
30 write!(f, "\n Last check: {last}")?;
31 }
32 } else {
33 write!(f, "Auto-update: disabled")?;
34 }
35 Ok(())
36 }
37}
38
39pub fn install_schedule(interval_hours: u64) -> Result<ScheduleInfo, String> {
40 let binary = std::path::PathBuf::from(super::portable_binary::resolve_portable_binary());
41
42 #[cfg(target_os = "macos")]
43 return install_macos_launchagent(&binary, interval_hours * 3600, interval_hours);
44
45 #[cfg(target_os = "linux")]
46 return install_linux_scheduler(&binary, interval_hours);
47
48 #[cfg(target_os = "windows")]
49 return install_windows_task(&binary, interval_hours);
50
51 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
52 {
53 let _ = binary;
54 Err("Auto-update scheduling not supported on this platform".to_string())
55 }
56}
57
58pub fn remove_schedule() -> Result<(), String> {
59 #[cfg(target_os = "macos")]
60 return remove_macos_launchagent();
61
62 #[cfg(target_os = "linux")]
63 return remove_linux_scheduler();
64
65 #[cfg(target_os = "windows")]
66 return remove_windows_task();
67
68 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
69 Ok(())
70}
71
72pub fn schedule_status() -> ScheduleInfo {
73 #[cfg(target_os = "macos")]
74 return macos_status();
75
76 #[cfg(target_os = "linux")]
77 return linux_status();
78
79 #[cfg(target_os = "windows")]
80 return windows_status();
81
82 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
83 ScheduleInfo {
84 enabled: false,
85 mechanism: "unsupported".into(),
86 interval_hours: 0,
87 scheduler_path: None,
88 last_check: None,
89 }
90}
91
92#[cfg(target_os = "macos")]
95fn plist_path() -> PathBuf {
96 dirs::home_dir()
97 .unwrap_or_else(|| PathBuf::from("/tmp"))
98 .join("Library/LaunchAgents")
99 .join(format!("{LABEL}.plist"))
100}
101
102#[cfg(target_os = "macos")]
103fn install_macos_launchagent(
104 binary: &std::path::Path,
105 interval_secs: u64,
106 interval_hours: u64,
107) -> Result<ScheduleInfo, String> {
108 let path = plist_path();
109 if let Some(dir) = path.parent() {
110 std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
111 }
112
113 let log_dir =
117 crate::core::paths::state_dir().unwrap_or_else(|_| std::env::temp_dir().join("lean-ctx"));
118 let _ = std::fs::create_dir_all(&log_dir);
119
120 let binary_str = binary.to_string_lossy();
121 let stdout_log = log_dir.join("autoupdate-stdout.log");
122 let stderr_log = log_dir.join("autoupdate-stderr.log");
123
124 let program_args = crate::core::tcc_guard_sandbox::program_args_xml(
128 &crate::core::tcc_guard_sandbox::wrap_launchd_args(
129 &binary_str,
130 &["update", "--quiet", "--scheduled"],
131 ),
132 " ",
133 );
134
135 let plist = format!(
136 r#"<?xml version="1.0" encoding="UTF-8"?>
137<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
138<plist version="1.0">
139<dict>
140 <key>Label</key>
141 <string>{LABEL}</string>
142 <key>ProgramArguments</key>
143 <array>
144{program_args}
145 </array>
146 <key>StartInterval</key>
147 <integer>{interval_secs}</integer>
148 <key>RunAtLoad</key>
149 <false/>
150 <key>StandardOutPath</key>
151 <string>{}</string>
152 <key>StandardErrorPath</key>
153 <string>{}</string>
154</dict>
155</plist>"#,
156 stdout_log.display(),
157 stderr_log.display()
158 );
159
160 crate::core::launchd::bootout(LABEL, &path);
161
162 std::fs::write(&path, plist).map_err(|e| format!("Failed to write plist: {e}"))?;
163
164 if !crate::core::launchd::bootstrap(LABEL, &path) {
165 return Err("launchctl bootstrap failed; check: launchctl print gui/$(id -u)".into());
166 }
167
168 Ok(ScheduleInfo {
169 enabled: true,
170 mechanism: "LaunchAgent".into(),
171 interval_hours,
172 scheduler_path: Some(path),
173 last_check: None,
174 })
175}
176
177#[cfg(target_os = "macos")]
178fn remove_macos_launchagent() -> Result<(), String> {
179 let path = plist_path();
180 if path.exists() {
181 crate::core::launchd::bootout(LABEL, &path);
182 std::fs::remove_file(&path).map_err(|e| format!("Failed to remove plist: {e}"))?;
183 }
184 Ok(())
185}
186
187#[cfg(target_os = "macos")]
188fn macos_status() -> ScheduleInfo {
189 let path = plist_path();
190 let enabled = path.exists();
191 let interval_hours = if enabled {
192 std::fs::read_to_string(&path)
193 .ok()
194 .and_then(|content| {
195 let idx = content.find("<key>StartInterval</key>")?;
196 let after = &content[idx..];
197 let int_start = after.find("<integer>")? + 9;
198 let int_end = after.find("</integer>")?;
199 after[int_start..int_end].parse::<u64>().ok()
200 })
201 .map_or(6, |s| s / 3600)
202 } else {
203 0
204 };
205 ScheduleInfo {
206 enabled,
207 mechanism: "LaunchAgent".into(),
208 interval_hours,
209 scheduler_path: Some(path),
210 last_check: read_last_check_time(),
211 }
212}
213
214#[cfg(target_os = "linux")]
217fn has_systemd() -> bool {
218 std::path::Path::new("/run/systemd/system").exists()
219}
220
221#[cfg(target_os = "linux")]
222fn systemd_dir() -> PathBuf {
223 dirs::home_dir()
224 .unwrap_or_else(|| PathBuf::from("/tmp"))
225 .join(".config/systemd/user")
226}
227
228#[cfg(target_os = "linux")]
229fn install_linux_scheduler(
230 binary: &std::path::Path,
231 interval_hours: u64,
232) -> Result<ScheduleInfo, String> {
233 if has_systemd() {
234 install_linux_systemd(binary, interval_hours)
235 } else {
236 install_linux_cron(binary, interval_hours)
237 }
238}
239
240#[cfg(target_os = "linux")]
241fn install_linux_systemd(
242 binary: &std::path::Path,
243 interval_hours: u64,
244) -> Result<ScheduleInfo, String> {
245 let dir = systemd_dir();
246 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
247
248 let binary_str = binary.to_string_lossy();
249
250 let service = format!(
251 "[Unit]\nDescription=lean-ctx auto-updater\n\n[Service]\nType=oneshot\nExecStart={binary_str} update --quiet --scheduled\n"
252 );
253 let timer = format!(
254 "[Unit]\nDescription=lean-ctx auto-update timer\n\n[Timer]\nOnBootSec=1h\nOnUnitActiveSec={interval_hours}h\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n"
255 );
256
257 std::fs::write(dir.join("lean-ctx-autoupdate.service"), service).map_err(|e| e.to_string())?;
258 let timer_path = dir.join("lean-ctx-autoupdate.timer");
259 std::fs::write(&timer_path, timer).map_err(|e| e.to_string())?;
260
261 let _ = std::process::Command::new("systemctl")
262 .args(["--user", "daemon-reload"])
263 .output();
264 let out = std::process::Command::new("systemctl")
265 .args(["--user", "enable", "--now", "lean-ctx-autoupdate.timer"])
266 .output()
267 .map_err(|e| e.to_string())?;
268
269 if !out.status.success() {
270 return Err(format!(
271 "systemctl enable failed: {}",
272 String::from_utf8_lossy(&out.stderr)
273 ));
274 }
275
276 Ok(ScheduleInfo {
277 enabled: true,
278 mechanism: "systemd timer".into(),
279 interval_hours,
280 scheduler_path: Some(timer_path),
281 last_check: None,
282 })
283}
284
285#[cfg(target_os = "linux")]
286fn install_linux_cron(
287 binary: &std::path::Path,
288 interval_hours: u64,
289) -> Result<ScheduleInfo, String> {
290 let cron_expr = if interval_hours <= 1 {
291 "0 * * * *".to_string()
292 } else if interval_hours >= 24 {
293 "0 4 * * *".to_string()
294 } else {
295 format!("0 */{interval_hours} * * *")
296 };
297
298 let entry = format!(
299 "{cron_expr} {} update --quiet --scheduled",
300 binary.to_string_lossy()
301 );
302
303 let existing = std::process::Command::new("crontab")
304 .arg("-l")
305 .output()
306 .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
307 .unwrap_or_default();
308
309 let filtered: String = existing
310 .lines()
311 .filter(|l| !l.contains("lean-ctx") || !l.contains("update"))
312 .chain(std::iter::once(entry.as_str()))
313 .collect::<Vec<_>>()
314 .join("\n")
315 + "\n";
316
317 let mut child = std::process::Command::new("crontab")
318 .arg("-")
319 .stdin(std::process::Stdio::piped())
320 .spawn()
321 .map_err(|e| e.to_string())?;
322
323 use std::io::Write;
324 child
325 .stdin
326 .take()
327 .ok_or_else(|| "failed to open crontab stdin".to_string())?
328 .write_all(filtered.as_bytes())
329 .map_err(|e| e.to_string())?;
330 child.wait().map_err(|e| e.to_string())?;
331
332 Ok(ScheduleInfo {
333 enabled: true,
334 mechanism: "cron".into(),
335 interval_hours,
336 scheduler_path: None,
337 last_check: None,
338 })
339}
340
341#[cfg(target_os = "linux")]
342#[allow(clippy::unnecessary_wraps)]
343fn remove_linux_scheduler() -> Result<(), String> {
344 let dir = systemd_dir();
345 let timer = dir.join("lean-ctx-autoupdate.timer");
346 let service = dir.join("lean-ctx-autoupdate.service");
347 if timer.exists() {
348 let _ = std::process::Command::new("systemctl")
349 .args(["--user", "disable", "--now", "lean-ctx-autoupdate.timer"])
350 .output();
351 let _ = std::fs::remove_file(&timer);
352 let _ = std::fs::remove_file(&service);
353 let _ = std::process::Command::new("systemctl")
354 .args(["--user", "daemon-reload"])
355 .output();
356 }
357
358 if let Ok(out) = std::process::Command::new("crontab").arg("-l").output() {
359 let existing = String::from_utf8_lossy(&out.stdout).to_string();
360 if existing.contains("lean-ctx") && existing.contains("update") {
361 let filtered: String = existing
362 .lines()
363 .filter(|l| !(l.contains("lean-ctx") && l.contains("update")))
364 .collect::<Vec<_>>()
365 .join("\n")
366 + "\n";
367 if let Ok(mut child) = std::process::Command::new("crontab")
368 .arg("-")
369 .stdin(std::process::Stdio::piped())
370 .spawn()
371 {
372 use std::io::Write;
373 if let Some(mut stdin) = child.stdin.take() {
374 let _ = stdin.write_all(filtered.as_bytes());
375 }
376 let _ = child.wait();
377 }
378 }
379 }
380 Ok(())
381}
382
383#[cfg(target_os = "linux")]
384fn linux_status() -> ScheduleInfo {
385 let timer = systemd_dir().join("lean-ctx-autoupdate.timer");
386 if timer.exists() {
387 return ScheduleInfo {
388 enabled: true,
389 mechanism: "systemd timer".into(),
390 interval_hours: 6,
391 scheduler_path: Some(timer),
392 last_check: read_last_check_time(),
393 };
394 }
395 if let Ok(out) = std::process::Command::new("crontab").arg("-l").output() {
396 let crontab = String::from_utf8_lossy(&out.stdout);
397 if crontab.contains("lean-ctx") && crontab.contains("update") {
398 return ScheduleInfo {
399 enabled: true,
400 mechanism: "cron".into(),
401 interval_hours: 6,
402 scheduler_path: None,
403 last_check: read_last_check_time(),
404 };
405 }
406 }
407 ScheduleInfo {
408 enabled: false,
409 mechanism: "none".into(),
410 interval_hours: 0,
411 scheduler_path: None,
412 last_check: None,
413 }
414}
415
416#[cfg(target_os = "windows")]
419fn install_windows_task(
420 binary: &std::path::Path,
421 interval_hours: u64,
422) -> Result<ScheduleInfo, String> {
423 let binary_str = binary.to_string_lossy();
424 let out = std::process::Command::new("schtasks")
425 .args([
426 "/Create",
427 "/F",
428 "/TN",
429 "lean-ctx autoupdate",
430 "/TR",
431 &format!("\"{binary_str}\" update --quiet --scheduled"),
432 "/SC",
433 "HOURLY",
434 "/MO",
435 &interval_hours.to_string(),
436 "/RL",
437 "HIGHEST",
438 ])
439 .output()
440 .map_err(|e| e.to_string())?;
441
442 if !out.status.success() {
443 return Err(format!(
444 "schtasks failed: {}",
445 String::from_utf8_lossy(&out.stderr)
446 ));
447 }
448
449 Ok(ScheduleInfo {
450 enabled: true,
451 mechanism: "Task Scheduler".into(),
452 interval_hours,
453 scheduler_path: None,
454 last_check: None,
455 })
456}
457
458#[cfg(target_os = "windows")]
459fn remove_windows_task() -> Result<(), String> {
460 let _ = std::process::Command::new("schtasks")
461 .args(["/Delete", "/F", "/TN", "lean-ctx autoupdate"])
462 .output();
463 Ok(())
464}
465
466#[cfg(target_os = "windows")]
467fn windows_status() -> ScheduleInfo {
468 let out = std::process::Command::new("schtasks")
469 .args(["/Query", "/TN", "lean-ctx autoupdate", "/FO", "LIST"])
470 .output();
471
472 let enabled = out.as_ref().is_ok_and(|o| o.status.success());
473 ScheduleInfo {
474 enabled,
475 mechanism: "Task Scheduler".into(),
476 interval_hours: if enabled { 6 } else { 0 },
477 scheduler_path: None,
478 last_check: read_last_check_time(),
479 }
480}
481
482fn read_last_check_time() -> Option<String> {
485 let path = crate::core::paths::cache_dir()
486 .ok()?
487 .join("latest-version.json");
488 let content = std::fs::read_to_string(path).ok()?;
489 let v: serde_json::Value = serde_json::from_str(&content).ok()?;
490 let ts = v["checked_at"].as_u64()?;
491 let dt = chrono::DateTime::from_timestamp(ts as i64, 0)?;
492 Some(dt.format("%Y-%m-%d %H:%M UTC").to_string())
493}
494
495pub fn has_user_decided() -> bool {
497 let Some(config_path) = crate::core::config::Config::path() else {
501 return false;
502 };
503 let content = std::fs::read_to_string(config_path).unwrap_or_default();
504 content.contains("auto_update")
505}
506
507pub fn set_auto_update(enabled: bool, notify_only: bool, interval_hours: u64) {
510 let Some(config_path) = crate::core::config::Config::path() else {
511 return;
512 };
513 if let Some(dir) = config_path.parent() {
514 let _ = std::fs::create_dir_all(dir);
515 }
516
517 let mut doc = crate::config_io::load_toml_document(&config_path);
518 apply_auto_update(&mut doc, enabled, notify_only, interval_hours);
519 let _ = crate::config_io::write_toml_document(&config_path, &doc);
520}
521
522fn apply_auto_update(
525 doc: &mut toml_edit::DocumentMut,
526 enabled: bool,
527 notify_only: bool,
528 interval_hours: u64,
529) {
530 let updates = doc["updates"].or_insert(toml_edit::table());
531 updates["auto_update"] = toml_edit::value(enabled);
532 updates["check_interval_hours"] = toml_edit::value(interval_hours as i64);
533 updates["notify_only"] = toml_edit::value(notify_only);
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539
540 #[test]
541 fn schedule_info_display_disabled() {
542 let info = ScheduleInfo {
543 enabled: false,
544 mechanism: "none".into(),
545 interval_hours: 0,
546 scheduler_path: None,
547 last_check: None,
548 };
549 assert!(info.to_string().contains("disabled"));
550 }
551
552 #[test]
553 fn schedule_info_display_enabled() {
554 let info = ScheduleInfo {
555 enabled: true,
556 mechanism: "LaunchAgent".into(),
557 interval_hours: 6,
558 scheduler_path: Some(PathBuf::from("/tmp/test.plist")),
559 last_check: Some("2026-05-17 10:00 UTC".into()),
560 };
561 let s = info.to_string();
562 assert!(s.contains("enabled"));
563 assert!(s.contains("LaunchAgent"));
564 assert!(s.contains("6h"));
565 }
566
567 #[test]
568 fn apply_auto_update_preserves_existing_keys_and_comments() {
569 let mut doc = "\
570# important user comment
571buddy_enabled = true
572"
573 .parse::<toml_edit::DocumentMut>()
574 .unwrap();
575
576 apply_auto_update(&mut doc, true, false, 12);
577
578 let result = doc.to_string();
579 assert!(result.contains("auto_update = true"));
580 assert!(result.contains("check_interval_hours = 12"));
581 assert!(result.contains("notify_only = false"));
582 assert!(result.contains("buddy_enabled = true"));
584 assert!(result.contains("# important user comment"));
585 }
586
587 #[test]
588 fn apply_auto_update_overwrites_only_updates_section() {
589 let mut doc = "\
590[updates]
591auto_update = false
592check_interval_hours = 99
593"
594 .parse::<toml_edit::DocumentMut>()
595 .unwrap();
596
597 apply_auto_update(&mut doc, true, true, 6);
598
599 let result = doc.to_string();
600 assert!(result.contains("auto_update = true"));
601 assert!(result.contains("check_interval_hours = 6"));
602 assert!(result.contains("notify_only = true"));
603 assert!(!result.contains("check_interval_hours = 99"));
604 }
605
606 #[test]
607 fn has_user_decided_false_by_default() {
608 let _ = has_user_decided();
611 }
612}