Files
OneCAD-Draft/src/io/web_recent.rs
T

264 lines
8.7 KiB
Rust

//! 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<String, OpenStoreState>,
> = 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<u8>,
}
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::<web_sys::FileSystemFileHandle>()
.map_err(js_error)?;
let writable = JsFuture::from(handle.create_writable())
.await
.map_err(js_error)?
.dyn_into::<web_sys::FileSystemWritableFileStream>()
.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<Vec<u8>, 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<Vec<u8>, 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<web_sys::File, String> {
let handle = JsFuture::from(directory.get_file_handle(key))
.await
.map_err(js_error)?
.dyn_into::<web_sys::FileSystemFileHandle>()
.map_err(js_error)?;
JsFuture::from(handle.get_file())
.await
.map_err(js_error)?
.dyn_into::<web_sys::File>()
.map_err(js_error)
}
async fn read_blob(blob: &web_sys::Blob) -> Result<Vec<u8>, 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<Option<Thumbnail>, 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<web_sys::FileSystemDirectoryHandle, String> {
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::<web_sys::FileSystemDirectoryHandle>()
.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::<web_sys::FileSystemDirectoryHandle>()
.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<u8> {
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<u8>) -> Option<Thumbnail> {
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:?}"))
}