mural_client/
lib.rs

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