//! Browser-backed copies of recently opened drawings. //! //! A web file picker exposes bytes and a display name, not a reusable native //! path. Keep the last-opened copy in the origin-private file system (OPFS) so //! the Start page can reopen it without asking the user to pick it again. use wasm_bindgen::JsCast; use wasm_bindgen_futures::JsFuture; struct OpenStoreState { latest_id: u64, latest_bytes: std::sync::Arc<[u8]>, active_writers: usize, } thread_local! { static OPEN_STORES: std::cell::RefCell< std::collections::HashMap, > = std::cell::RefCell::new(std::collections::HashMap::new()); } const RECENT_DIRECTORY: &str = "opencadstudio-recent"; const THUMBNAIL_MAGIC: &[u8; 4] = b"OCST"; const THUMBNAIL_MAX_DIM: u32 = 256; pub struct Thumbnail { pub width: u32, pub height: u32, pub rgba: Vec, } pub async fn store(name: &str, bytes: &[u8]) -> Result<(), String> { let directory = recent_directory(true).await?; write_entry(&directory, &cache_key(name), bytes).await?; if let Some(image) = dwg_thumbnailer::extract_bytes(bytes, THUMBNAIL_MAX_DIM) { let thumbnail = encode_thumbnail(image); // The drawing copy is the durable part of a recent entry. Thumbnail // caching must not make an otherwise successful open/save fail under // browser quota pressure. let _ = write_entry(&directory, &thumbnail_key(name), &thumbnail).await; } else { // An overwrite may replace a drawing that had a preview with one that // does not. Do not leave the old image attached to the new file. let _ = JsFuture::from(directory.remove_entry(&thumbnail_key(name))).await; } Ok(()) } pub async fn store_open( name: &str, bytes: std::sync::Arc<[u8]>, open_id: u64, ) -> Result<(), String> { let key = name.to_string(); let (mut pending_id, mut pending_bytes) = OPEN_STORES.with(|stores| { let mut stores = stores.borrow_mut(); let state = stores.entry(key.clone()).or_insert_with(|| OpenStoreState { latest_id: open_id, latest_bytes: std::sync::Arc::clone(&bytes), active_writers: 0, }); state.active_writers = state.active_writers.saturating_add(1); if open_id > state.latest_id { state.latest_id = open_id; state.latest_bytes = bytes; } (state.latest_id, std::sync::Arc::clone(&state.latest_bytes)) }); loop { if let Err(error) = store(name, &pending_bytes).await { OPEN_STORES.with(|stores| { let mut stores = stores.borrow_mut(); let remove = if let Some(state) = stores.get_mut(&key) { state.active_writers = state.active_writers.saturating_sub(1); state.active_writers == 0 } else { false }; if remove { stores.remove(&key); } }); return Err(error); } let latest = OPEN_STORES.with(|stores| { let mut stores = stores.borrow_mut(); let Some(state) = stores.get_mut(&key) else { return None; }; if state.latest_id != pending_id { return Some(( state.latest_id, std::sync::Arc::clone(&state.latest_bytes), )); } state.active_writers = state.active_writers.saturating_sub(1); if state.active_writers == 0 { stores.remove(&key); } None }); match latest { Some((latest_id, latest_bytes)) => { pending_id = latest_id; pending_bytes = latest_bytes; } None => return Ok(()), } } } async fn write_entry( directory: &web_sys::FileSystemDirectoryHandle, key: &str, bytes: &[u8], ) -> Result<(), String> { let options = web_sys::FileSystemGetFileOptions::new(); options.set_create(true); let handle = JsFuture::from(directory.get_file_handle_with_options(key, &options)) .await .map_err(js_error)? .dyn_into::() .map_err(js_error)?; let writable = JsFuture::from(handle.create_writable()) .await .map_err(js_error)? .dyn_into::() .map_err(js_error)?; let write = writable.write_with_u8_array(bytes).map_err(js_error)?; JsFuture::from(write).await.map_err(js_error)?; JsFuture::from(writable.close()).await.map_err(js_error)?; Ok(()) } pub async fn read(name: &str) -> Result, String> { let directory = recent_directory(false).await?; read_entry(&directory, &cache_key(name)).await } async fn read_entry( directory: &web_sys::FileSystemDirectoryHandle, key: &str, ) -> Result, String> { let file = get_file(directory, key).await?; read_blob(file.as_ref()).await } async fn get_file( directory: &web_sys::FileSystemDirectoryHandle, key: &str, ) -> Result { let handle = JsFuture::from(directory.get_file_handle(key)) .await .map_err(js_error)? .dyn_into::() .map_err(js_error)?; JsFuture::from(handle.get_file()) .await .map_err(js_error)? .dyn_into::() .map_err(js_error) } async fn read_blob(blob: &web_sys::Blob) -> Result, String> { let buffer = JsFuture::from(blob.array_buffer()) .await .map_err(js_error)?; Ok(js_sys::Uint8Array::new(&buffer).to_vec()) } pub async fn read_thumbnail(name: &str) -> Result, String> { let directory = recent_directory(false).await?; match read_entry(&directory, &thumbnail_key(name)).await { Ok(bytes) => Ok(decode_thumbnail(bytes)), Err(_) => Ok(None), } } pub async fn remove(name: &str) -> Result<(), String> { let directory = recent_directory(false).await?; let drawing = JsFuture::from(directory.remove_entry(&cache_key(name))) .await .map_err(js_error); let _ = JsFuture::from(directory.remove_entry(&thumbnail_key(name))).await; drawing.map(|_| ()) } async fn recent_directory(create: bool) -> Result { let window = web_sys::window().ok_or_else(|| "browser window unavailable".to_string())?; let root = JsFuture::from(window.navigator().storage().get_directory()) .await .map_err(js_error)? .dyn_into::() .map_err(js_error)?; let options = web_sys::FileSystemGetDirectoryOptions::new(); options.set_create(create); JsFuture::from(root.get_directory_handle_with_options(RECENT_DIRECTORY, &options)) .await .map_err(js_error)? .dyn_into::() .map_err(js_error) } /// Stable, short OPFS entry name. The original display name remains in /// `AppConfig::recent`; only the browser-private cache uses this key. fn cache_key(name: &str) -> String { format!("{}.cad", name_hash(name)) } fn thumbnail_key(name: &str) -> String { format!("{}.thumb", name_hash(name)) } fn name_hash(name: &str) -> String { let mut hash = 0xcbf29ce484222325_u64; for byte in name.as_bytes() { hash ^= u64::from(*byte); hash = hash.wrapping_mul(0x100000001b3); } format!("{hash:016x}") } fn encode_thumbnail(image: dwg_thumbnailer::RgbaImage) -> Vec { let mut bytes = Vec::with_capacity(12 + image.as_raw().len()); bytes.extend_from_slice(THUMBNAIL_MAGIC); bytes.extend_from_slice(&image.width().to_le_bytes()); bytes.extend_from_slice(&image.height().to_le_bytes()); bytes.extend_from_slice(image.as_raw()); bytes } fn decode_thumbnail(bytes: Vec) -> Option { if bytes.get(..4)? != THUMBNAIL_MAGIC { return None; } let width = u32::from_le_bytes(bytes.get(4..8)?.try_into().ok()?); let height = u32::from_le_bytes(bytes.get(8..12)?.try_into().ok()?); let expected = usize::try_from(width) .ok()? .checked_mul(usize::try_from(height).ok()?)? .checked_mul(4)?; if width == 0 || height == 0 || width > THUMBNAIL_MAX_DIM || height > THUMBNAIL_MAX_DIM || bytes.len() != 12 + expected { return None; } Some(Thumbnail { width, height, rgba: bytes[12..].to_vec(), }) } fn js_error(value: wasm_bindgen::JsValue) -> String { value .as_string() .unwrap_or_else(|| format!("browser storage error: {value:?}")) }