1use crate::models::WidgetConfig;
12#[cfg(all(feature = "image-prefetch", not(target_os = "android")))]
13use crate::models::{ImageElement, WidgetElement};
14
15pub 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 if let Some(cached) = cache_get(url) {
133 img.data = Some(cached);
134 return;
135 }
136 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}