use std::cell::RefCell;
use std::collections::{HashMap, VecDeque};
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::Arc;
use std::sync::mpsc::{self, Receiver, Sender};
use std::time::{Duration, Instant};
use reactive_core::{Emitter, ReadSignal, RwSignal, Task, signal, spawn_stream};
use renderer_assets::SvgData;
use ui_core::{AssetSource, AssetState};
const DEFAULT_ATTEMPTS: u32 = 8;
const DEFAULT_RETRY_DELAY: Duration = Duration::from_secs(4);
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15);
fn cache_file_name(id: &str) -> String {
let simple = id.len() <= 32
&& id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
if simple {
return format!("{id}.svg");
}
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
id.hash(&mut hasher);
let hash = hasher.finish();
let sanitized: String = id
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.take(32)
.collect();
format!("{sanitized}_{hash:x}.svg")
}
#[derive(Clone)]
struct Fetch {
url_template: String,
cache_dir: Option<PathBuf>,
max_attempts: u32,
retry_delay: Duration,
timeout: Duration,
}
type Delivery = (String, Option<Arc<SvgData>>);
#[derive(Default)]
struct Store {
states: RefCell<HashMap<String, RwSignal<AssetState<Arc<SvgData>>>>>,
}
pub struct HttpAssetSource {
store: Rc<Store>,
requests: Sender<String>,
task: RefCell<Option<Task>>,
fetch: Fetch,
started: RefCell<bool>,
incoming: RefCell<Option<Receiver<String>>>,
}
impl HttpAssetSource {
pub fn new(url_template: impl Into<String>) -> Self {
let (requests, incoming) = mpsc::channel();
Self {
store: Rc::new(Store::default()),
requests,
task: RefCell::new(None),
fetch: Fetch {
url_template: url_template.into(),
cache_dir: None,
max_attempts: DEFAULT_ATTEMPTS,
retry_delay: DEFAULT_RETRY_DELAY,
timeout: DEFAULT_TIMEOUT,
},
started: RefCell::new(false),
incoming: RefCell::new(Some(incoming)),
}
}
pub fn cached_in(mut self, dir: impl Into<PathBuf>) -> Self {
self.fetch.cache_dir = Some(dir.into());
self
}
pub fn with_retry(mut self, attempts: u32, delay: Duration) -> Self {
self.fetch.max_attempts = attempts.max(1);
self.fetch.retry_delay = delay;
self
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.fetch.timeout = timeout;
self
}
fn ensure_worker(&self) {
if *self.started.borrow() {
return;
}
let Some(incoming) = self.incoming.borrow_mut().take() else {
return;
};
*self.started.borrow_mut() = true;
let fetch = self.fetch.clone();
let store = Rc::clone(&self.store);
let task = spawn_stream(
move |out| run_worker(incoming, fetch, out),
move |(id, data): Delivery| {
let handle = store.states.borrow().get(&id).cloned();
if let Some(handle) = handle {
handle.set(match data {
Some(svg) => AssetState::Ready(svg),
None => AssetState::Failed,
});
}
},
|| {},
);
*self.task.borrow_mut() = Some(task);
}
}
impl Drop for HttpAssetSource {
fn drop(&mut self) {
if let Some(task) = self.task.borrow_mut().take() {
task.cancel();
}
}
}
impl AssetSource for HttpAssetSource {
fn svg(&self, id: &str) -> ReadSignal<AssetState<Arc<SvgData>>> {
self.ensure_worker();
if let Some(existing) = self.store.states.borrow().get(id) {
return existing.read_only();
}
let handle = signal(AssetState::Loading);
let read = handle.read_only();
self.store
.states
.borrow_mut()
.insert(id.to_string(), handle);
let _ = self.requests.send(id.to_string());
read
}
}
fn run_worker(requests: Receiver<String>, fetch: Fetch, out: Emitter<Delivery>) {
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(fetch.timeout))
.build()
.into();
let mut ready: VecDeque<(String, u32)> = VecDeque::new();
let mut retries: Vec<(String, u32, Instant)> = Vec::new();
loop {
if out.is_cancelled() {
return;
}
let now = Instant::now();
retries.retain(|(id, attempts, at)| {
if *at <= now {
ready.push_back((id.clone(), *attempts));
false
} else {
true
}
});
if ready.is_empty() {
match retries.iter().map(|(_, _, at)| *at).min() {
Some(at) => {
match requests.recv_timeout(at.saturating_duration_since(Instant::now())) {
Ok(id) => ready.push_back((id, 0)),
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(mpsc::RecvTimeoutError::Disconnected) => return,
}
}
None => match requests.recv() {
Ok(id) => ready.push_back((id, 0)),
Err(_) => return,
},
}
}
while let Ok(id) = requests.try_recv() {
ready.push_back((id, 0));
}
let Some((id, attempts)) = ready.pop_front() else {
continue;
};
match load(&id, &fetch, &agent) {
Some(svg) => out.emit((id, Some(svg))),
None => {
let attempts = attempts + 1;
if attempts >= fetch.max_attempts {
tracing::warn!(
"asset '{id}' gave up after {attempts} attempts; check the name and the endpoint"
);
out.emit((id, None));
} else {
retries.push((id, attempts, Instant::now() + fetch.retry_delay));
}
}
}
}
}
fn cached(id: &str, cache_dir: Option<&PathBuf>) -> Option<Arc<SvgData>> {
let path = cache_dir?.join(cache_file_name(id));
let text = std::fs::read_to_string(path).ok()?;
SvgData::from_str(&text).ok().map(Arc::new)
}
fn load(id: &str, fetch: &Fetch, agent: &ureq::Agent) -> Option<Arc<SvgData>> {
if let Some(svg) = cached(id, fetch.cache_dir.as_ref()) {
return Some(svg);
}
let url = fetch.url_template.replace("{name}", id);
let body = agent
.get(&url)
.call()
.ok()?
.body_mut()
.read_to_string()
.ok()?;
let svg = SvgData::from_str(&body).ok()?;
if let Some(dir) = fetch.cache_dir.as_ref() {
write_cache(dir, id, &body);
}
Some(Arc::new(svg))
}
fn write_cache(dir: &Path, id: &str, body: &str) {
if let Err(e) = std::fs::create_dir_all(dir) {
tracing::warn!("could not create asset cache {}: {e}", dir.display());
return;
}
let path = dir.join(cache_file_name(id));
if let Err(e) = std::fs::write(&path, body) {
tracing::warn!("could not cache asset {}: {e}", path.display());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_plain_id_is_its_own_file_name() {
assert_eq!(cache_file_name("arrow-right"), "arrow-right.svg");
}
#[test]
fn ids_that_differ_only_in_punctuation_do_not_share_a_file() {
let colon = cache_file_name("mdi:home");
let slash = cache_file_name("mdi/home");
assert!(colon.starts_with("mdi_home_"), "{colon}");
assert_ne!(colon, slash);
}
#[test]
fn an_id_cannot_escape_the_cache_directory() {
let name = cache_file_name("../../etc/passwd");
assert!(!name.contains('/'), "{name}");
assert!(!name.contains(".."), "{name}");
assert_eq!(
Path::new("/cache").join(&name).parent().unwrap(),
Path::new("/cache")
);
}
#[test]
fn the_template_substitutes_the_whole_id() {
let url = "https://api.iconify.design/{name}.svg".replace("{name}", "mdi/home");
assert_eq!(url, "https://api.iconify.design/mdi/home.svg");
}
}