forked from 1b-agent/opencadstudio-upstream
264 lines
8.7 KiB
Rust
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:?}"))
|
|
}
|