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 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 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}