diff --git a/lp-app/lpa-fs-opfs/src/opfs_write.rs b/lp-app/lpa-fs-opfs/src/opfs_write.rs index 8a537ce1f..ac13898db 100644 --- a/lp-app/lpa-fs-opfs/src/opfs_write.rs +++ b/lp-app/lpa-fs-opfs/src/opfs_write.rs @@ -25,6 +25,12 @@ fn split_parent(path: &LpPath) -> (String, String) { /// Write `bytes` to `path` (absolute lp-style) below `root`, creating parent /// directories as needed. Atomic per file (commit happens at close). +/// +/// The atomic swap only protects *content* — `getFileHandle(create)` makes +/// the entry itself immediately. A write that fails after creation would +/// otherwise leave a persistent empty husk (seen on iOS Safari, where a +/// jetsammed tab can abandon a flush mid-file), so a failed write to a file +/// that did not previously exist removes the husk best-effort. pub async fn write_file( root: &FileSystemDirectoryHandle, path: &LpPath, @@ -33,6 +39,8 @@ pub async fn write_file( let (parent_path, name) = split_parent(path); let parent = open_dir(root, &parent_path, true).await?; + let existed = JsFuture::from(parent.get_file_handle(&name)).await.is_ok(); + let options = FileSystemGetFileOptions::new(); options.set_create(true); let handle = JsFuture::from(parent.get_file_handle_with_options(&name, &options)) @@ -42,6 +50,20 @@ pub async fn write_file( .dyn_into() .map_err(|e| OpfsError::new("get_file_handle", path.as_str().to_string(), e))?; + let result = write_via_writable(&handle, path, bytes).await; + if result.is_err() && !existed { + let _ = JsFuture::from(parent.remove_entry(&name)).await; + } + result +} + +/// The stage-and-swap half of [`write_file`]: create a writable, write, +/// close (the commit point). +async fn write_via_writable( + handle: &FileSystemFileHandle, + path: &LpPath, + bytes: &[u8], +) -> Result<(), OpfsError> { let stream = JsFuture::from(handle.create_writable()) .await .map_err(|e| OpfsError::new("create_writable", path.as_str().to_string(), e))?; diff --git a/lp-app/lpa-studio-core/src/app/library/package_meta.rs b/lp-app/lpa-studio-core/src/app/library/package_meta.rs index 83331ac1a..62e9bfa2f 100644 --- a/lp-app/lpa-studio-core/src/app/library/package_meta.rs +++ b/lp-app/lpa-studio-core/src/app/library/package_meta.rs @@ -54,9 +54,17 @@ pub fn read_meta(fs: &dyn LpFs) -> Result, LibraryError> { let bytes = fs .read_file(META_PATH.as_path()) .map_err(|e| LibraryError::Meta(format!("read meta: {e}")))?; - serde_json::from_slice(&bytes) - .map(Some) - .map_err(|e| LibraryError::Meta(format!("parse meta: {e}"))) + // Lenient parse: provenance is a best-effort sidecar, and browser + // storage can leave it torn or zero-filled (an iOS Safari tab killed + // mid-flush). Treating damage as "absent" keeps it from bricking + // project open — the origin event falls back to `Created`. + match serde_json::from_slice(&bytes) { + Ok(meta) => Ok(Some(meta)), + Err(e) => { + log::warn!("package meta unreadable (treating as absent): parse meta: {e}"); + Ok(None) + } + } } pub fn write_meta(fs: &dyn LpFs, meta: &PackageMeta) -> Result<(), LibraryError> { @@ -84,4 +92,17 @@ mod tests { write_meta(&fs, &meta).unwrap(); assert_eq!(read_meta(&fs).unwrap().unwrap(), meta); } + + #[test] + fn damaged_meta_reads_as_absent() { + use lpc_model::AsLpPath; + let fs = LpFsMemory::new(); + // Zero-filled sidecar: the signature of a browser tab killed + // mid-flush (size persisted, content lost). + fs.write_file(META_PATH.as_path(), &[0u8; 64]).unwrap(); + assert!(read_meta(&fs).unwrap().is_none()); + // Empty husk (created, never committed). + fs.write_file(META_PATH.as_path(), &[]).unwrap(); + assert!(read_meta(&fs).unwrap().is_none()); + } }