use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::Duration;
use tokio::fs;
use tracing::debug;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DaemonStatus {
pub pid: u32,
pub started_at: DateTime<Utc>,
pub last_rotation: Option<DateTime<Utc>>,
pub next_rotation: DateTime<Utc>,
pub current_wallpaper: Option<String>,
pub config: DaemonConfig,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DaemonConfig {
pub interval_minutes: u32,
pub randomize: String,
pub source: String,
}
#[allow(dead_code)]
impl DaemonStatus {
pub fn new(config: &crate::config::Config) -> Self {
let now = Utc::now();
let interval = Duration::from_secs(config.timer.interval as u64 * 60);
let next_rotation = now + chrono::Duration::from_std(interval).unwrap();
Self {
pid: std::process::id(),
started_at: now,
last_rotation: None,
next_rotation,
current_wallpaper: None,
config: DaemonConfig {
interval_minutes: config.timer.interval,
randomize: config.timer.randomize.clone(),
source: config.sources.default.clone(),
},
updated_at: now,
}
}
pub fn update_rotation(&mut self, wallpaper_path: Option<String>) {
let now = Utc::now();
self.last_rotation = Some(now);
self.current_wallpaper = wallpaper_path;
let interval = Duration::from_secs(self.config.interval_minutes as u64 * 60);
let randomize_secs = parse_duration(&self.config.randomize).unwrap_or(0);
let total_interval = interval + Duration::from_secs(randomize_secs / 2);
self.next_rotation = now + chrono::Duration::from_std(total_interval).unwrap();
self.updated_at = now;
}
pub fn time_remaining(&self) -> Result<Duration> {
let now = Utc::now();
if self.next_rotation > now {
let remaining = self.next_rotation - now;
Ok(remaining.to_std().context("Invalid duration")?)
} else {
Ok(Duration::ZERO)
}
}
pub fn time_remaining_formatted(&self) -> String {
match self.time_remaining() {
Ok(duration) => {
if duration.is_zero() {
"Rotation overdue".to_string()
} else {
format_duration(duration)
}
}
Err(_) => "Unknown".to_string(),
}
}
pub fn is_stale(&self) -> bool {
let now = Utc::now();
let age = now - self.updated_at;
age.num_seconds() > 300 }
}
#[derive(Debug)]
pub struct DaemonStatusManager {
status_file: PathBuf,
status: Option<DaemonStatus>,
}
#[allow(dead_code)]
impl DaemonStatusManager {
pub fn new() -> Result<Self> {
let status_file = get_status_file_path()?;
Ok(Self { status_file, status: None })
}
pub async fn initialize_daemon(&mut self, config: &crate::config::Config) -> Result<()> {
self.status = Some(DaemonStatus::new(config));
self.save().await?;
debug!("Initialized daemon status at {}", self.status_file.display());
Ok(())
}
pub async fn update_rotation(&mut self, wallpaper_path: Option<String>) -> Result<()> {
if let Some(ref mut status) = self.status {
status.update_rotation(wallpaper_path);
self.save().await?;
debug!("Updated daemon status with new rotation");
}
Ok(())
}
pub async fn load(&mut self) -> Result<()> {
if self.status_file.exists() {
let content = fs::read_to_string(&self.status_file).await.context("Failed to read daemon status file")?;
let status: DaemonStatus = serde_json::from_str(&content).context("Failed to parse daemon status JSON")?;
self.status = Some(status);
debug!("Loaded daemon status from {}", self.status_file.display());
} else {
debug!("No daemon status file found");
}
Ok(())
}
async fn save(&self) -> Result<()> {
if let Some(ref status) = self.status {
if let Some(parent) = self.status_file.parent() {
fs::create_dir_all(parent).await.context("Failed to create status directory")?;
}
let json = serde_json::to_string_pretty(status).context("Failed to serialize daemon status")?;
fs::write(&self.status_file, json).await.context("Failed to write daemon status file")?;
}
Ok(())
}
pub async fn get_status(&mut self) -> Result<Option<DaemonStatus>> {
self.load().await?;
Ok(self.status.clone())
}
pub async fn is_daemon_running(&mut self) -> Result<bool> {
if let Some(status) = self.get_status().await? {
if status.is_stale() {
return Ok(false);
}
#[cfg(unix)]
{
use std::process::Command;
let output = Command::new("kill")
.arg("-0") .arg(status.pid.to_string())
.output();
match output {
Ok(result) => Ok(result.status.success()),
Err(_) => Ok(false),
}
}
#[cfg(not(unix))]
{
Ok(!status.is_stale())
}
} else {
Ok(false)
}
}
pub async fn cleanup(&self) -> Result<()> {
if self.status_file.exists() {
fs::remove_file(&self.status_file).await.context("Failed to remove daemon status file")?;
debug!("Cleaned up daemon status file");
}
Ok(())
}
}
fn get_status_file_path() -> Result<PathBuf> {
let home_dir = dirs::home_dir().context("Could not find home directory")?;
let runtime_dir = home_dir.join(".local/share/mksg/wallflow");
Ok(runtime_dir.join("daemon_status.json"))
}
#[allow(dead_code)]
fn parse_duration(duration_str: &str) -> Result<u64> {
let duration_str = duration_str.trim();
if duration_str == "0" || duration_str.is_empty() {
return Ok(0);
}
let (number_part, unit_part) = if let Some(pos) = duration_str.find(|c: char| c.is_alphabetic()) {
let (num, unit) = duration_str.split_at(pos);
(num, unit)
} else {
return duration_str.parse::<u64>().context("Invalid duration format");
};
let number: u64 = number_part.parse().context("Invalid number in duration")?;
let multiplier = match unit_part {
"s" | "sec" | "second" | "seconds" => 1,
"m" | "min" | "minute" | "minutes" => 60,
"h" | "hr" | "hour" | "hours" => 3600,
"d" | "day" | "days" => 86400,
_ => return Err(anyhow::anyhow!("Unknown duration unit: {}", unit_part)),
};
Ok(number * multiplier)
}
fn format_duration(duration: Duration) -> String {
let total_secs = duration.as_secs();
let hours = total_secs / 3600;
let minutes = (total_secs % 3600) / 60;
let seconds = total_secs % 60;
if hours > 0 {
format!("{}h {}m {}s", hours, minutes, seconds)
} else if minutes > 0 {
format!("{}m {}s", minutes, seconds)
} else {
format!("{}s", seconds)
}
}