Skip to main content

tauri_plugin_widgets/
image_prefetch.rs

1//! Prefetch remote `image.url` into `data:` URIs for hosts that cannot fetch
2//! (WidgetKit). Android keeps its own `localPath` pipeline — Rust prefetch is a
3//! no-op there so we do not inflate SharedPreferences / Binder with base64.
4//!
5//! Behind the `image-prefetch` feature (`ureq`). In-memory + disk cache by URL
6//! with TTL so `startWidgetUpdater` does not hit the network every tick.
7//! The command path only reads the cache; network fetches run on a background
8//! thread so a slow host cannot stall `set_widget_config`.
9//! Successful prefetch **keeps** `url` so Android / Adaptive Cards can still use it.
10
11use crate::models::WidgetConfig;
12#[cfg(all(feature = "image-prefetch", not(target_os = "android")))]
13use crate::models::{ImageElement, WidgetElement};
14
15/// Walk the config and fill `image.data` from http(s) URLs when needed.
16///
17/// - Does **not** clear `url` (consumers choose `data` / `localPath` / `url`).
18/// - No-op on Android and when the `image-prefetch` feature is disabled.
19pub fn prefetch_remote_images(config: &mut WidgetConfig) {
20    #[cfg(all(feature = "image-prefetch", not(target_os = "android")))]
21    {
22        prefetch_remote_images_inner(config);
23    }
24    #[cfg(not(all(feature = "image-prefetch", not(target_os = "android"))))]
25    {
26        let _ = config;
27    }
28}
29
30#[cfg(all(feature = "image-prefetch", not(target_os = "android")))]
31mod imp {
32    use super::*;
33    use std::collections::HashMap;
34    use std::sync::Mutex;
35    use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
36
37    const MAX_BYTES: usize = 3 * 1024 * 1024;
38    const TIMEOUT: Duration = Duration::from_secs(10);
39    const CACHE_TTL: Duration = Duration::from_secs(300);
40
41    #[derive(Clone)]
42    struct CacheEntry {
43        data_uri: String,
44        fetched_at: Instant,
45    }
46
47    static MEMORY: Mutex<Option<HashMap<String, CacheEntry>>> = Mutex::new(None);
48    static IN_FLIGHT: Mutex<Option<std::collections::HashSet<String>>> = Mutex::new(None);
49
50    fn memory() -> std::sync::MutexGuard<'static, Option<HashMap<String, CacheEntry>>> {
51        MEMORY.lock().unwrap_or_else(|e| e.into_inner())
52    }
53
54    pub(super) fn prefetch_remote_images_inner(config: &mut WidgetConfig) {
55        if let Some(el) = config.small.as_mut() {
56            walk_el(el);
57        }
58        if let Some(el) = config.medium.as_mut() {
59            walk_el(el);
60        }
61        if let Some(el) = config.large.as_mut() {
62            walk_el(el);
63        }
64    }
65
66    fn walk_el(el: &mut WidgetElement) {
67        match el {
68            WidgetElement::Image(img) => prefetch_image(img),
69            WidgetElement::VStack(v) => {
70                for c in &mut v.children {
71                    walk_el(c);
72                }
73            }
74            WidgetElement::HStack(v) => {
75                for c in &mut v.children {
76                    walk_el(c);
77                }
78            }
79            WidgetElement::ZStack(v) => {
80                for c in &mut v.children {
81                    walk_el(c);
82                }
83            }
84            WidgetElement::Grid(v) => {
85                for c in &mut v.children {
86                    walk_el(c);
87                }
88            }
89            WidgetElement::Container(v) => {
90                for c in &mut v.children {
91                    walk_el(c);
92                }
93            }
94            WidgetElement::Link(v) => {
95                for c in &mut v.children {
96                    walk_el(c);
97                }
98            }
99            WidgetElement::List(_)
100            | WidgetElement::Button(_)
101            | WidgetElement::Toggle(_)
102            | WidgetElement::Text(_)
103            | WidgetElement::Label(_)
104            | WidgetElement::Progress(_)
105            | WidgetElement::Gauge(_)
106            | WidgetElement::Divider(_)
107            | WidgetElement::Spacer(_)
108            | WidgetElement::Shape(_)
109            | WidgetElement::Date(_)
110            | WidgetElement::Timer(_)
111            | WidgetElement::Chart(_)
112            | WidgetElement::Canvas(_) => {}
113        }
114    }
115
116    fn prefetch_image(img: &mut ImageElement) {
117        let Some(url) = img.url.as_deref() else {
118            return;
119        };
120        let url = url.trim();
121        if !(url.starts_with("http://") || url.starts_with("https://")) {
122            return;
123        }
124        if img
125            .data
126            .as_deref()
127            .map(|d| !d.trim().is_empty())
128            .unwrap_or(false)
129        {
130            return;
131        }
132        // Hot path: memory/disk only — never block `set_widget_config` on HTTP.
133        if let Some(cached) = cache_get(url) {
134            img.data = Some(cached);
135            return;
136        }
137        spawn_background_fetch(url.to_string());
138    }
139
140    fn spawn_background_fetch(url: String) {
141        {
142            let mut guard = IN_FLIGHT.lock().unwrap_or_else(|e| e.into_inner());
143            let set = guard.get_or_insert_with(std::collections::HashSet::new);
144            if !set.insert(url.clone()) {
145                return;
146            }
147        }
148        std::thread::Builder::new()
149            .name("widget-image-prefetch".into())
150            .spawn(move || {
151                match fetch_as_data_uri(&url) {
152                    Ok(data_uri) => cache_put(&url, data_uri),
153                    Err(err) => log::warn!("image.url prefetch failed for {url}: {err}"),
154                }
155                if let Ok(mut guard) = IN_FLIGHT.lock() {
156                    if let Some(set) = guard.as_mut() {
157                        set.remove(&url);
158                    }
159                }
160            })
161            .ok();
162    }
163
164    fn cache_get(url: &str) -> Option<String> {
165        let mut guard = memory();
166        let map = guard.get_or_insert_with(HashMap::new);
167        if let Some(e) = map.get(url) {
168            if e.fetched_at.elapsed() < CACHE_TTL {
169                return Some(e.data_uri.clone());
170            }
171            map.remove(url);
172        }
173        disk_cache_get(url).map(|data_uri| {
174            map.insert(
175                url.into(),
176                CacheEntry {
177                    data_uri: data_uri.clone(),
178                    fetched_at: Instant::now(),
179                },
180            );
181            data_uri
182        })
183    }
184
185    pub(super) fn cache_put(url: &str, data_uri: String) {
186        {
187            let mut guard = memory();
188            let map = guard.get_or_insert_with(HashMap::new);
189            map.insert(
190                url.into(),
191                CacheEntry {
192                    data_uri: data_uri.clone(),
193                    fetched_at: Instant::now(),
194                },
195            );
196        }
197        disk_cache_put(url, &data_uri);
198    }
199
200    fn disk_cache_dir() -> Option<std::path::PathBuf> {
201        let base = dirs_next_cache().unwrap_or_else(std::env::temp_dir);
202        let dir = base.join("tauri-plugin-widgets").join("image-prefetch");
203        std::fs::create_dir_all(&dir).ok()?;
204        Some(dir)
205    }
206
207    fn dirs_next_cache() -> Option<std::path::PathBuf> {
208        #[cfg(target_os = "macos")]
209        {
210            std::env::var_os("HOME").map(|h| {
211                std::path::PathBuf::from(h)
212                    .join("Library")
213                    .join("Caches")
214            })
215        }
216        #[cfg(target_os = "windows")]
217        {
218            std::env::var_os("LOCALAPPDATA").map(std::path::PathBuf::from)
219        }
220        #[cfg(all(unix, not(target_os = "macos")))]
221        {
222            std::env::var_os("XDG_CACHE_HOME")
223                .map(std::path::PathBuf::from)
224                .or_else(|| {
225                    std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".cache"))
226                })
227        }
228        #[cfg(not(any(target_os = "macos", target_os = "windows", unix)))]
229        {
230            None
231        }
232    }
233
234    fn cache_key(url: &str) -> String {
235        use std::collections::hash_map::DefaultHasher;
236        use std::hash::{Hash, Hasher};
237        let mut h = DefaultHasher::new();
238        url.hash(&mut h);
239        format!("{:016x}", h.finish())
240    }
241
242    fn disk_cache_get(url: &str) -> Option<String> {
243        let dir = disk_cache_dir()?;
244        let path = dir.join(cache_key(url));
245        let meta_path = path.with_extension("meta");
246        let meta = std::fs::read_to_string(&meta_path).ok()?;
247        let ts: u64 = meta.trim().parse().ok()?;
248        let now = SystemTime::now()
249            .duration_since(UNIX_EPOCH)
250            .ok()?
251            .as_secs();
252        if now.saturating_sub(ts) > CACHE_TTL.as_secs() {
253            let _ = std::fs::remove_file(&path);
254            let _ = std::fs::remove_file(&meta_path);
255            return None;
256        }
257        std::fs::read_to_string(path).ok()
258    }
259
260    fn disk_cache_put(url: &str, data_uri: &str) {
261        let Some(dir) = disk_cache_dir() else {
262            return;
263        };
264        let path = dir.join(cache_key(url));
265        let meta_path = path.with_extension("meta");
266        let ts = SystemTime::now()
267            .duration_since(UNIX_EPOCH)
268            .map(|d| d.as_secs())
269            .unwrap_or(0);
270        let _ = std::fs::write(&path, data_uri);
271        let _ = std::fs::write(&meta_path, ts.to_string());
272    }
273
274    fn fetch_as_data_uri(url: &str) -> Result<String, String> {
275        use base64::{engine::general_purpose::STANDARD as B64, Engine};
276        use std::io::Read;
277
278        let resp = ureq::get(url)
279            .timeout(TIMEOUT)
280            .call()
281            .map_err(|e| e.to_string())?;
282        let content_type = resp
283            .header("content-type")
284            .unwrap_or("image/png")
285            .split(';')
286            .next()
287            .unwrap_or("image/png")
288            .trim()
289            .to_string();
290        if !content_type.starts_with("image/") && content_type != "application/octet-stream" {
291            return Err(format!("unexpected content-type {content_type}"));
292        }
293        let mut buf = Vec::new();
294        let mut reader = resp.into_reader();
295        reader
296            .by_ref()
297            .take(MAX_BYTES as u64 + 1)
298            .read_to_end(&mut buf)
299            .map_err(|e| e.to_string())?;
300        if buf.is_empty() {
301            return Err("empty body".into());
302        }
303        if buf.len() > MAX_BYTES {
304            return Err("image exceeds 3 MiB limit".into());
305        }
306        let mime = if content_type.starts_with("image/") {
307            content_type
308        } else {
309            guess_mime(url).into()
310        };
311        Ok(format!("data:{mime};base64,{}", B64.encode(&buf)))
312    }
313
314    fn guess_mime(url: &str) -> &'static str {
315        let lower = url.to_ascii_lowercase();
316        if lower.contains(".jpg") || lower.contains(".jpeg") {
317            "image/jpeg"
318        } else if lower.contains(".webp") {
319            "image/webp"
320        } else if lower.contains(".gif") {
321            "image/gif"
322        } else {
323            "image/png"
324        }
325    }
326}
327
328#[cfg(all(feature = "image-prefetch", not(target_os = "android")))]
329use imp::prefetch_remote_images_inner;
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use crate::models::ImageElement;
335
336    #[test]
337    fn leaves_non_http_url_alone() {
338        let mut cfg = WidgetConfig {
339            version: 1,
340            small: Some(WidgetElement::Image(ImageElement {
341                system_name: None,
342                data: None,
343                url: Some("data:image/png;base64,QQ==".into()),
344                size: Some(16.0),
345                color: None,
346                content_mode: None,
347                style: Default::default(),
348            })),
349            medium: None,
350            large: None,
351        };
352        prefetch_remote_images(&mut cfg);
353        match cfg.small.as_ref().unwrap() {
354            WidgetElement::Image(img) => {
355                assert_eq!(img.url.as_deref(), Some("data:image/png;base64,QQ=="));
356                assert!(img.data.is_none());
357            }
358            _ => panic!("expected image"),
359        }
360    }
361
362    #[test]
363    fn skips_when_data_already_set() {
364        let mut cfg = WidgetConfig {
365            version: 1,
366            small: Some(WidgetElement::Image(ImageElement {
367                system_name: None,
368                data: Some("data:image/png;base64,QQ==".into()),
369                url: Some("https://example.com/a.png".into()),
370                size: None,
371                color: None,
372                content_mode: None,
373                style: Default::default(),
374            })),
375            medium: None,
376            large: None,
377        };
378        prefetch_remote_images(&mut cfg);
379        match cfg.small.as_ref().unwrap() {
380            WidgetElement::Image(img) => {
381                assert_eq!(img.url.as_deref(), Some("https://example.com/a.png"));
382                assert!(img.data.as_ref().unwrap().starts_with("data:"));
383            }
384            _ => panic!("expected image"),
385        }
386    }
387
388    #[test]
389    fn keeps_url_when_serving_from_memory_cache() {
390        #[cfg(all(feature = "image-prefetch", not(target_os = "android")))]
391        {
392            imp::cache_put(
393                "https://example.com/cached.png",
394                "data:image/png;base64,QQ==".into(),
395            );
396            let mut cfg = WidgetConfig {
397                version: 1,
398                small: Some(WidgetElement::Image(ImageElement {
399                    system_name: None,
400                    data: None,
401                    url: Some("https://example.com/cached.png".into()),
402                    size: None,
403                    color: None,
404                    content_mode: None,
405                    style: Default::default(),
406                })),
407                medium: None,
408                large: None,
409            };
410            prefetch_remote_images(&mut cfg);
411            match cfg.small.as_ref().unwrap() {
412                WidgetElement::Image(img) => {
413                    assert_eq!(img.url.as_deref(), Some("https://example.com/cached.png"));
414                    assert_eq!(img.data.as_deref(), Some("data:image/png;base64,QQ=="));
415                }
416                _ => panic!("expected image"),
417            }
418        }
419    }
420}