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//! Cache hits are instant; cold misses fetch synchronously (≤ TIMEOUT) so the
8//! store write from `set_widget_config` already embeds `data:` for WidgetKit.
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
49    fn memory() -> std::sync::MutexGuard<'static, Option<HashMap<String, CacheEntry>>> {
50        MEMORY.lock().unwrap_or_else(|e| e.into_inner())
51    }
52
53    pub(super) fn prefetch_remote_images_inner(config: &mut WidgetConfig) {
54        if let Some(el) = config.small.as_mut() {
55            walk_el(el);
56        }
57        if let Some(el) = config.medium.as_mut() {
58            walk_el(el);
59        }
60        if let Some(el) = config.large.as_mut() {
61            walk_el(el);
62        }
63    }
64
65    fn walk_el(el: &mut WidgetElement) {
66        match el {
67            WidgetElement::Image(img) => prefetch_image(img),
68            WidgetElement::VStack(v) => {
69                for c in &mut v.children {
70                    walk_el(c);
71                }
72            }
73            WidgetElement::HStack(v) => {
74                for c in &mut v.children {
75                    walk_el(c);
76                }
77            }
78            WidgetElement::ZStack(v) => {
79                for c in &mut v.children {
80                    walk_el(c);
81                }
82            }
83            WidgetElement::Grid(v) => {
84                for c in &mut v.children {
85                    walk_el(c);
86                }
87            }
88            WidgetElement::Container(v) => {
89                for c in &mut v.children {
90                    walk_el(c);
91                }
92            }
93            WidgetElement::Link(v) => {
94                for c in &mut v.children {
95                    walk_el(c);
96                }
97            }
98            WidgetElement::List(_)
99            | WidgetElement::Button(_)
100            | WidgetElement::Toggle(_)
101            | WidgetElement::Text(_)
102            | WidgetElement::Label(_)
103            | WidgetElement::Progress(_)
104            | WidgetElement::Gauge(_)
105            | WidgetElement::Divider(_)
106            | WidgetElement::Spacer(_)
107            | WidgetElement::Shape(_)
108            | WidgetElement::Date(_)
109            | WidgetElement::Timer(_)
110            | WidgetElement::Chart(_)
111            | WidgetElement::Canvas(_) => {}
112        }
113    }
114
115    fn prefetch_image(img: &mut ImageElement) {
116        let Some(url) = img.url.as_deref() else {
117            return;
118        };
119        let url = url.trim();
120        if !(url.starts_with("http://") || url.starts_with("https://")) {
121            return;
122        }
123        if img
124            .data
125            .as_deref()
126            .map(|d| !d.trim().is_empty())
127            .unwrap_or(false)
128        {
129            return;
130        }
131        // Hot path: memory/disk only.
132        if let Some(cached) = cache_get(url) {
133            img.data = Some(cached);
134            return;
135        }
136        // Cold miss: fetch now so the upcoming store write includes `data:`.
137        // WidgetKit cannot HTTP-fetch; a background-only fill never rewrites the store.
138        match fetch_as_data_uri(url) {
139            Ok(data_uri) => {
140                cache_put(url, data_uri.clone());
141                img.data = Some(data_uri);
142            }
143            Err(err) => log::warn!("image.url prefetch failed for {url}: {err}"),
144        }
145    }
146
147    fn cache_get(url: &str) -> Option<String> {
148        let mut guard = memory();
149        let map = guard.get_or_insert_with(HashMap::new);
150        if let Some(e) = map.get(url) {
151            if e.fetched_at.elapsed() < CACHE_TTL {
152                return Some(e.data_uri.clone());
153            }
154            map.remove(url);
155        }
156        disk_cache_get(url).map(|data_uri| {
157            map.insert(
158                url.into(),
159                CacheEntry {
160                    data_uri: data_uri.clone(),
161                    fetched_at: Instant::now(),
162                },
163            );
164            data_uri
165        })
166    }
167
168    pub(super) fn cache_put(url: &str, data_uri: String) {
169        {
170            let mut guard = memory();
171            let map = guard.get_or_insert_with(HashMap::new);
172            map.insert(
173                url.into(),
174                CacheEntry {
175                    data_uri: data_uri.clone(),
176                    fetched_at: Instant::now(),
177                },
178            );
179        }
180        disk_cache_put(url, &data_uri);
181    }
182
183    fn disk_cache_dir() -> Option<std::path::PathBuf> {
184        let base = dirs_next_cache().unwrap_or_else(std::env::temp_dir);
185        let dir = base.join("tauri-plugin-widgets").join("image-prefetch");
186        std::fs::create_dir_all(&dir).ok()?;
187        Some(dir)
188    }
189
190    fn dirs_next_cache() -> Option<std::path::PathBuf> {
191        #[cfg(target_os = "macos")]
192        {
193            std::env::var_os("HOME").map(|h| {
194                std::path::PathBuf::from(h)
195                    .join("Library")
196                    .join("Caches")
197            })
198        }
199        #[cfg(target_os = "windows")]
200        {
201            std::env::var_os("LOCALAPPDATA").map(std::path::PathBuf::from)
202        }
203        #[cfg(all(unix, not(target_os = "macos")))]
204        {
205            std::env::var_os("XDG_CACHE_HOME")
206                .map(std::path::PathBuf::from)
207                .or_else(|| {
208                    std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".cache"))
209                })
210        }
211        #[cfg(not(any(target_os = "macos", target_os = "windows", unix)))]
212        {
213            None
214        }
215    }
216
217    fn cache_key(url: &str) -> String {
218        use std::collections::hash_map::DefaultHasher;
219        use std::hash::{Hash, Hasher};
220        let mut h = DefaultHasher::new();
221        url.hash(&mut h);
222        format!("{:016x}", h.finish())
223    }
224
225    fn disk_cache_get(url: &str) -> Option<String> {
226        let dir = disk_cache_dir()?;
227        let path = dir.join(cache_key(url));
228        let meta_path = path.with_extension("meta");
229        let meta = std::fs::read_to_string(&meta_path).ok()?;
230        let ts: u64 = meta.trim().parse().ok()?;
231        let now = SystemTime::now()
232            .duration_since(UNIX_EPOCH)
233            .ok()?
234            .as_secs();
235        if now.saturating_sub(ts) > CACHE_TTL.as_secs() {
236            let _ = std::fs::remove_file(&path);
237            let _ = std::fs::remove_file(&meta_path);
238            return None;
239        }
240        std::fs::read_to_string(path).ok()
241    }
242
243    fn disk_cache_put(url: &str, data_uri: &str) {
244        let Some(dir) = disk_cache_dir() else {
245            return;
246        };
247        let path = dir.join(cache_key(url));
248        let meta_path = path.with_extension("meta");
249        let ts = SystemTime::now()
250            .duration_since(UNIX_EPOCH)
251            .map(|d| d.as_secs())
252            .unwrap_or(0);
253        let _ = std::fs::write(&path, data_uri);
254        let _ = std::fs::write(&meta_path, ts.to_string());
255    }
256
257    fn fetch_as_data_uri(url: &str) -> Result<String, String> {
258        use base64::{engine::general_purpose::STANDARD as B64, Engine};
259        use std::io::Read;
260
261        let resp = ureq::get(url)
262            .timeout(TIMEOUT)
263            .call()
264            .map_err(|e| e.to_string())?;
265        let content_type = resp
266            .header("content-type")
267            .unwrap_or("image/png")
268            .split(';')
269            .next()
270            .unwrap_or("image/png")
271            .trim()
272            .to_string();
273        if !content_type.starts_with("image/") && content_type != "application/octet-stream" {
274            return Err(format!("unexpected content-type {content_type}"));
275        }
276        let mut buf = Vec::new();
277        let mut reader = resp.into_reader();
278        reader
279            .by_ref()
280            .take(MAX_BYTES as u64 + 1)
281            .read_to_end(&mut buf)
282            .map_err(|e| e.to_string())?;
283        if buf.is_empty() {
284            return Err("empty body".into());
285        }
286        if buf.len() > MAX_BYTES {
287            return Err("image exceeds 3 MiB limit".into());
288        }
289        let mime = if content_type.starts_with("image/") {
290            content_type
291        } else {
292            guess_mime(url).into()
293        };
294        Ok(format!("data:{mime};base64,{}", B64.encode(&buf)))
295    }
296
297    fn guess_mime(url: &str) -> &'static str {
298        let lower = url.to_ascii_lowercase();
299        if lower.contains(".jpg") || lower.contains(".jpeg") {
300            "image/jpeg"
301        } else if lower.contains(".webp") {
302            "image/webp"
303        } else if lower.contains(".gif") {
304            "image/gif"
305        } else {
306            "image/png"
307        }
308    }
309}
310
311#[cfg(all(feature = "image-prefetch", not(target_os = "android")))]
312use imp::prefetch_remote_images_inner;
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use crate::models::ImageElement;
318
319    #[test]
320    fn leaves_non_http_url_alone() {
321        let mut cfg = WidgetConfig {
322            version: 1,
323            small: Some(WidgetElement::Image(ImageElement {
324                system_name: None,
325                data: None,
326                url: Some("data:image/png;base64,QQ==".into()),
327                size: Some(16.0),
328                color: None,
329                content_mode: None,
330                style: Default::default(),
331            })),
332            medium: None,
333            large: None,
334        };
335        prefetch_remote_images(&mut cfg);
336        match cfg.small.as_ref().unwrap() {
337            WidgetElement::Image(img) => {
338                assert_eq!(img.url.as_deref(), Some("data:image/png;base64,QQ=="));
339                assert!(img.data.is_none());
340            }
341            _ => panic!("expected image"),
342        }
343    }
344
345    #[test]
346    fn skips_when_data_already_set() {
347        let mut cfg = WidgetConfig {
348            version: 1,
349            small: Some(WidgetElement::Image(ImageElement {
350                system_name: None,
351                data: Some("data:image/png;base64,QQ==".into()),
352                url: Some("https://example.com/a.png".into()),
353                size: None,
354                color: None,
355                content_mode: None,
356                style: Default::default(),
357            })),
358            medium: None,
359            large: None,
360        };
361        prefetch_remote_images(&mut cfg);
362        match cfg.small.as_ref().unwrap() {
363            WidgetElement::Image(img) => {
364                assert_eq!(img.url.as_deref(), Some("https://example.com/a.png"));
365                assert!(img.data.as_ref().unwrap().starts_with("data:"));
366            }
367            _ => panic!("expected image"),
368        }
369    }
370
371    #[test]
372    fn keeps_url_when_serving_from_memory_cache() {
373        #[cfg(all(feature = "image-prefetch", not(target_os = "android")))]
374        {
375            imp::cache_put(
376                "https://example.com/cached.png",
377                "data:image/png;base64,QQ==".into(),
378            );
379            let mut cfg = WidgetConfig {
380                version: 1,
381                small: Some(WidgetElement::Image(ImageElement {
382                    system_name: None,
383                    data: None,
384                    url: Some("https://example.com/cached.png".into()),
385                    size: None,
386                    color: None,
387                    content_mode: None,
388                    style: Default::default(),
389                })),
390                medium: None,
391                large: None,
392            };
393            prefetch_remote_images(&mut cfg);
394            match cfg.small.as_ref().unwrap() {
395                WidgetElement::Image(img) => {
396                    assert_eq!(img.url.as_deref(), Some("https://example.com/cached.png"));
397                    assert_eq!(img.data.as_deref(), Some("data:image/png;base64,QQ=="));
398                }
399                _ => panic!("expected image"),
400            }
401        }
402    }
403}