Skip to main content

mural_client/
lib.rs

1use std::{
2    ffi::OsStr,
3    path::{Path, PathBuf},
4};
5
6mod cli;
7
8mod config;
9pub(crate) use config::Config;
10
11mod env;
12
13mod error;
14pub(crate) use error::Error;
15
16pub(crate) mod prelude;
17use prelude::*;
18
19async fn delay_until_next_update(config: &Config) -> Result<jiff::Span> {
20    let interval = reqwest::get(format!("{}/api/interval", config.server_url()))
21        .await
22        .map_err(Error::IntervalRequest)?
23        .text()
24        .await
25        .map_err(Error::IntervalRequest)?
26        .parse::<u64>()
27        .map_err(|_| Error::InvalidInterval)?;
28
29    let current_timestamp = jiff::Timestamp::now();
30    let next_timestamp = jiff::Timestamp::from_millisecond(
31        ((current_timestamp.as_millisecond() as f64 / (interval * 1000) as f64) + 1.0).floor()
32            as i64
33            * (interval * 1000) as i64,
34    )
35    .expect("calculation should always succeed");
36    Ok(next_timestamp - current_timestamp)
37}
38
39async fn current_digest(config: &Config) -> Result<String> {
40    let digest_response = reqwest::get(format!(
41        "{}/api/pool/{}/digest",
42        config.server_url(),
43        config.pool_name()
44    ))
45    .await
46    .map_err(|e| Error::DigestRequest(e.to_string()))?;
47
48    if !digest_response.status().is_success() {
49        return Err(Error::DigestRequest("response was not 200".to_string()));
50    }
51
52    digest_response
53        .text()
54        .await
55        .map_err(|e| Error::DigestRequest(e.to_string()))
56}
57
58fn find_wallpaper_path(wallpapers_path: &Path, digest: &str) -> Result<Option<PathBuf>> {
59    Ok(std::fs::read_dir(wallpapers_path)
60        .map_err(|e| Error::WallpaperList {
61            io_error: e,
62            wallpapers_path: wallpapers_path.display().to_string(),
63        })?
64        .collect::<Result<Vec<std::fs::DirEntry>, _>>()
65        .map_err(|e| Error::WallpaperList {
66            io_error: e,
67            wallpapers_path: wallpapers_path.display().to_string(),
68        })?
69        .iter()
70        .map(|dir_entry| dir_entry.path())
71        .find(|wallpaper_path| {
72            wallpaper_path
73                .file_stem()
74                .map(|file_stem| file_stem == OsStr::new(digest))
75                .unwrap_or(false)
76        }))
77}
78
79async fn download_current_wallpaper(
80    wallpapers_path: &Path,
81    config: &Config,
82    digest: &str,
83) -> Result<PathBuf> {
84    info!("downloading current wallpaper");
85    let wallpaper_response = reqwest::get(format!(
86        "{}/api/pool/{}/wallpaper",
87        config.server_url(),
88        config.pool_name()
89    ))
90    .await
91    .map_err(|e| Error::WallpaperRequest(e.to_string()))?;
92
93    if !wallpaper_response.status().is_success() {
94        return Err(Error::WallpaperRequest("response was not 200".to_string()));
95    }
96
97    let content_type = wallpaper_response
98        .headers()
99        .get("Content-Type")
100        .expect("should always have a Content-Type")
101        .to_str()
102        .expect("content-type should always be a valid &str")
103        .to_string();
104    let extension = content_type
105        .split("/")
106        .nth(1)
107        .expect("content-type should always contain a slash");
108    let wallpaper_path = wallpapers_path.join(format!("{}.{}", digest, extension));
109
110    let image_content = wallpaper_response
111        .bytes()
112        .await
113        .map_err(|e| Error::WallpaperRequest(e.to_string()))?;
114
115    std::fs::write(&wallpaper_path, image_content).map_err(Error::WallpaperWrite)?;
116
117    Ok(wallpaper_path)
118}
119
120fn set_wallpaper(wallpaper_path: &Path) -> Result<()> {
121    let exit_status = match std::env::var("XDG_CURRENT_DESKTOP")
122        .unwrap_or_default()
123        .as_str()
124    {
125        "GNOME" => {
126            let mut exit_status = std::process::Command::new("gsettings")
127                .arg("set")
128                .arg("org.gnome.desktop.background")
129                .arg("picture-uri")
130                .arg(format!("file://{}", wallpaper_path.display()))
131                .spawn()
132                .map_err(Error::WallpaperSetCommand)?
133                .wait()
134                .map_err(Error::WallpaperSetCommand)?;
135            if exit_status.success() {
136                exit_status = std::process::Command::new("gsettings")
137                    .arg("set")
138                    .arg("org.gnome.desktop.background")
139                    .arg("picture-uri-dark")
140                    .arg(format!("file://{}", wallpaper_path.display()))
141                    .spawn()
142                    .map_err(Error::WallpaperSetCommand)?
143                    .wait()
144                    .map_err(Error::WallpaperSetCommand)?;
145            }
146            exit_status
147        }
148        _ => std::process::Command::new("swww")
149            .arg("img")
150            .arg("--transition-type")
151            .arg("fade")
152            .arg("--transition-bezier")
153            .arg("0,0,1,1")
154            .arg(wallpaper_path)
155            .spawn()
156            .map_err(Error::WallpaperSetCommand)?
157            .wait()
158            .map_err(Error::WallpaperSetCommand)?,
159    };
160
161    if !exit_status.success() {
162        // TODO: include stderr in error message
163        return Err(Error::WallpaperSet {
164            exit_code: exit_status.code().unwrap_or_default(),
165        });
166    }
167
168    Ok(())
169}
170
171async fn update_wallpaper(
172    config: &Config,
173    wallpapers_path: &Path,
174    last_digest: &str,
175) -> Result<String> {
176    let current_digest = current_digest(config).await?;
177    if current_digest == last_digest {
178        info!("the wallpaper did not change; skipping");
179    } else {
180        let wallpaper_path = match find_wallpaper_path(wallpapers_path, &current_digest)? {
181            Some(wallpaper_path) => wallpaper_path,
182            None => download_current_wallpaper(wallpapers_path, config, &current_digest).await?,
183        };
184        info!("setting a new wallpaper");
185        set_wallpaper(&wallpaper_path)?;
186    }
187
188    Ok(current_digest)
189}
190
191pub async fn run() -> Result<()> {
192    env::load_dotenv()?;
193
194    let matches = cli::get_command().get_matches();
195    let custom_config_dir = matches.get_one::<PathBuf>("config-dir");
196
197    let data_home_path = directories::ProjectDirs::from("ch", "Mural Sync", "Mural Client")
198        .map(|project_dirs| {
199            project_dirs
200                .data_local_dir()
201                .to_path_buf()
202                .parent()
203                .expect("the directories crate always returns full paths")
204                .join("mural-client")
205        })
206        .ok_or(Error::DataHome)?;
207    let wallpapers_path = data_home_path.join("wallpapers");
208    let _ = std::fs::create_dir_all(&wallpapers_path);
209
210    let mut last_digest = String::new();
211    let mut delay = jiff::Span::new().seconds(5);
212
213    loop {
214        info!("updating wallpaper");
215        let config = Config::load(custom_config_dir)?;
216
217        last_digest = match update_wallpaper(&config, &wallpapers_path, &last_digest).await {
218            Ok(new_digest) => new_digest,
219            Err(e) => {
220                error!("updating wallpaper failed: {}", e);
221                last_digest
222            }
223        };
224
225        delay = match delay_until_next_update(&config).await {
226            Ok(new_delay) => new_delay,
227            Err(e) => {
228                error!("getting delay failed: {}", e);
229                delay
230            }
231        };
232        std::thread::sleep(std::time::Duration::from_millis(
233            delay
234                .total(jiff::Unit::Millisecond)
235                .expect("should only fail if the unit is bigger than hours") as u64
236                + 1,
237        ));
238    }
239}