diff --git a/Cargo.lock b/Cargo.lock index c9fc34278..bae9302c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -257,6 +257,9 @@ name = "arbitrary" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] [[package]] name = "arboard" @@ -1770,6 +1773,17 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -5265,14 +5279,18 @@ dependencies = [ "lpa-client", "lpa-link", "lpa-server", + "lpc-history", "lpc-model", "lpc-shared", "lpc-view", "lpc-wire", "lpfs", + "serde", + "serde_json", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "zip", ] [[package]] @@ -5289,6 +5307,7 @@ dependencies = [ "lpa-fs-opfs", "lpa-studio-core", "lpa-studio-web-story-macros", + "lpfs", "serde", "syn 2.0.117", "wasm-bindgen", @@ -10808,12 +10827,41 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror 2.0.18", + "zopfli", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zstd" version = "0.13.3" diff --git a/lp-app/lpa-client/Cargo.toml b/lp-app/lpa-client/Cargo.toml index 3d3253171..da4e74d17 100644 --- a/lp-app/lpa-client/Cargo.toml +++ b/lp-app/lpa-client/Cargo.toml @@ -18,9 +18,9 @@ async-trait = "0.1" serde = { version = "1", features = ["derive"] } lp-riscv-elf = { path = "../../lp-riscv/lp-riscv-elf", optional = true, features = ["std"] } lp-riscv-emu = { path = "../../lp-riscv/lp-riscv-emu", optional = true, default-features = false } -log = { workspace = true, features = ["std"], optional = true } serialport = { version = "4.8", optional = true } hashbrown = { workspace = true, optional = true } +log = { workspace = true, features = ["std"], optional = true } [features] default = ["host"] diff --git a/lp-app/lpa-client/src/client.rs b/lp-app/lpa-client/src/client.rs index 8b8635f9c..c7f3ef41e 100644 --- a/lp-app/lpa-client/src/client.rs +++ b/lp-app/lpa-client/src/client.rs @@ -554,6 +554,36 @@ where Ok(ClientOutcome::new((), events)) } + /// Whole-project replace, then load: StopAll → clear dir → chunked + /// writes → LoadProject. The open-a-library-project primitive + /// (load-as-push, D19). + pub async fn replace_and_load_project( + &mut self, + project_id: &str, + files: &[(String, Vec)], + ) -> ClientResult> { + let mut events = Vec::new(); + let stop = self.send_request(ClientRequest::StopAllProjects).await?; + events.extend(stop.events); + validate_project_deploy_response(&ClientRequest::StopAllProjects, &stop.value.msg)?; + + let deploy_files: Vec = files + .iter() + .map(|(path, bytes)| ProjectDeployFile::new(path.clone(), bytes.clone())) + .collect(); + let replace = self.replace_project_files(project_id, deploy_files).await?; + events.extend(replace.events); + + let request = ClientRequest::LoadProject { + path: crate::project_deploy::project_load_path(project_id), + }; + let outcome = self.send_request(request.clone()).await?; + events.extend(outcome.events); + let handle = validate_project_deploy_response(&request, &outcome.value.msg)? + .ok_or_else(|| ClientError::Protocol("load did not return a handle".into()))?; + Ok(ClientOutcome::new(handle, events)) + } + /// Canonical package hash of a project directory (push/pull verify). pub async fn hash_package(&mut self, project_id: &str) -> ClientResult> { let outcome = self diff --git a/lp-app/lpa-studio-core/Cargo.toml b/lp-app/lpa-studio-core/Cargo.toml index 3c048e4fc..676786e0b 100644 --- a/lp-app/lpa-studio-core/Cargo.toml +++ b/lp-app/lpa-studio-core/Cargo.toml @@ -11,10 +11,15 @@ description = "Headless LightPlayer Studio application core" [dependencies] lpa-client = { path = "../lpa-client", default-features = false } lpa-link = { path = "../lpa-link" } +lpc-history = { path = "../../lp-core/lpc-history", default-features = false } lpc-model = { path = "../../lp-core/lpc-model" } lpc-view = { path = "../../lp-core/lpc-view" } lpc-wire = { path = "../../lp-core/lpc-wire" } +lpfs = { path = "../../lp-base/lpfs", default-features = false } log = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +zip = { version = "2", default-features = false, features = ["deflate"] } async-trait = { version = "0.1", optional = true } js-sys = { version = "0.3", optional = true } wasm-bindgen = { version = "0.2", optional = true } @@ -26,7 +31,7 @@ web-sys = { version = "0.3", optional = true, features = ["Window"] } # LightPlayer server (simulator session, no UI). Never built for wasm. lpa-server = { path = "../lpa-server" } lpc-shared = { path = "../../lp-core/lpc-shared", features = ["std"] } -lpfs = { path = "../../lp-base/lpfs", features = ["std"] } +lpfs = { path = "../../lp-base/lpfs", features = ["std"] } # std for host tests [features] default = [] diff --git a/lp-app/lpa-studio-core/src/app/library/library_store.rs b/lp-app/lpa-studio-core/src/app/library/library_store.rs new file mode 100644 index 000000000..3610ac6e4 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/library/library_store.rs @@ -0,0 +1,582 @@ +//! `LibraryStore`: package CRUD + history integration over the mounted store. + +use std::cell::RefCell; +use std::rc::Rc; + +use lpc_history::{ + ContentHash, EventKind, EventLog, HistoryEvent, PrefixedUid, ProjectHistory, SnapshotStore, +}; +use lpc_model::{AsLpPath, LpPath}; +use lpfs::{FsError, LpFs}; + +use super::package_manifest::{self, ManifestFields}; +use super::package_meta::{self, PackageMeta, PackageProvenance}; +use super::package_slug::unique_slug; +use super::{HISTORY_DIR, PACKAGES_DIR}; + +/// Library operation failure. +#[derive(Debug, Clone)] +pub enum LibraryError { + Fs(String), + Manifest(String), + Meta(String), + History(String), + NotFound(String), +} + +impl core::fmt::Display for LibraryError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + LibraryError::Fs(m) => write!(f, "library fs: {m}"), + LibraryError::Manifest(m) => write!(f, "manifest: {m}"), + LibraryError::Meta(m) => write!(f, "meta: {m}"), + LibraryError::History(m) => write!(f, "history: {m}"), + LibraryError::NotFound(m) => write!(f, "not found: {m}"), + } + } +} + +impl From for LibraryError { + fn from(e: FsError) -> Self { + LibraryError::Fs(e.to_string()) + } +} + +/// One library package, as the gallery will list it. +#[derive(Debug, Clone, PartialEq)] +pub struct PackageSummary { + pub uid: PrefixedUid, + pub name: String, + pub kind: String, + pub slug: String, +} + +/// An opened package: chrooted views + replayed history. +pub struct PackageHandle { + pub uid: PrefixedUid, + pub slug: String, + pub package_fs: Rc>, + pub history_fs: Rc>, + pub history: ProjectHistory, +} + +impl PackageHandle { + /// Snapshot the package and record a `Saved` event — unless the content + /// hash equals the current head (no-op guard: no event spam). + pub fn record_save(&mut self, at: f64) -> Result, LibraryError> { + let hash = { + let history_fs = self.history_fs.borrow(); + let snapshots = SnapshotStore::new(&*history_fs); + let package_fs = self.package_fs.borrow(); + let (hash, _) = snapshots + .put_package(&*package_fs) + .map_err(|e| LibraryError::History(e.to_string()))?; + hash + }; + if self.history.head() == Some(hash) { + return Ok(None); + } + let event = self.history.record_save(hash, at); + let history_fs = self.history_fs.borrow(); + EventLog::new(&*history_fs) + .append(&event) + .map_err(|e| LibraryError::History(e.to_string()))?; + Ok(Some(hash)) + } + + /// Apply one pulled file update: `Some(bytes)` upserts, `None` deletes + /// (a tombstone for a file the library never had is tolerated). + pub fn apply_update(&self, path: &LpPath, content: Option<&[u8]>) -> Result<(), LibraryError> { + let package_fs = self.package_fs.borrow(); + match content { + Some(bytes) => package_fs.write_file(path, bytes)?, + None => match package_fs.delete_file(path) { + Ok(()) | Err(FsError::NotFound(_)) => {} + Err(e) => return Err(e.into()), + }, + } + Ok(()) + } + + /// All package files as (relative path, bytes) — the push payload. + pub fn read_all_files(&self) -> Result)>, LibraryError> { + let package_fs = self.package_fs.borrow(); + let mut files = Vec::new(); + let entries = match package_fs.list_dir("/".as_path(), true) { + Ok(entries) => entries, + Err(FsError::NotFound(_)) => Vec::new(), + Err(e) => return Err(e.into()), + }; + for entry in entries { + if package_fs.is_dir(entry.as_path()).unwrap_or(false) { + continue; + } + let bytes = package_fs.read_file(entry.as_path())?; + files.push((entry.as_str().trim_start_matches('/').to_string(), bytes)); + } + files.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(files) + } + + /// Locally computed canonical package hash (push/pull verification). + pub fn content_hash(&self) -> Result { + let package_fs = self.package_fs.borrow(); + lpc_history::hash_package(&*package_fs) + .map(|(hash, _)| hash) + .map_err(|e| LibraryError::History(e.to_string())) + } +} + +/// The library: package CRUD over a caller-supplied store. +/// +/// Randomness is injected (`random` supplies uid bytes) per the sans-IO +/// discipline; timestamps arrive as arguments. +#[derive(Clone)] +pub struct LibraryStore { + fs: Rc>, + random: Rc [u8; 16]>, +} + +impl LibraryStore { + pub fn new(fs: Rc>, random: Rc [u8; 16]>) -> Self { + Self { fs, random } + } + + pub fn list(&self) -> Result, LibraryError> { + let mut summaries = Vec::new(); + for slug in self.package_slugs()? { + match self.read_summary(&slug) { + Ok(summary) => summaries.push(summary), + Err(e) => log::warn!("skipping package dir {slug}: {e}"), + } + } + summaries.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(summaries) + } + + /// Create a package from files (the primitive behind create/seed/import). + /// + /// Ensures a manifest exists (minimal one if `files` lacks it), applies + /// `fallback_name` when the manifest has no name, mints the uid, writes + /// the provenance sidecar, and initializes history (origin event + the + /// initial save snapshot). + pub fn install_package( + &self, + fallback_name: &str, + files: &[(String, Vec)], + provenance: PackageProvenance, + now: f64, + ) -> Result { + let slug = unique_slug(fallback_name, &self.package_slugs()?); + let package_fs = self.chroot_package(&slug)?; + { + let view = package_fs.borrow(); + for (relative, bytes) in files { + let path = format!("/{}", relative.trim_start_matches('/')); + view.write_file(path.as_str().as_path(), bytes)?; + } + if !view.file_exists(package_manifest::MANIFEST_PATH.as_path())? { + let minimal = serde_json::json!({ "kind": "Project", "name": fallback_name }); + view.write_file( + package_manifest::MANIFEST_PATH.as_path(), + serde_json::to_vec_pretty(&minimal) + .map_err(|e| LibraryError::Manifest(e.to_string()))? + .as_slice(), + )?; + } + let fields = package_manifest::read_manifest(&*view)?; + if fields.name.is_none() { + package_manifest::set_name(&*view, fallback_name)?; + } + package_manifest::ensure_uid(&*view, &(self.random)())?; + package_meta::write_meta( + &*view, + &PackageMeta { + provenance: provenance.clone(), + created_at: now, + }, + )?; + } + + let summary = self.read_summary(&slug)?; + // initialize history: origin from provenance, then the initial save + let mut handle = self.open(summary.uid)?; + handle.record_save(now)?; + Ok(summary) + } + + /// Create an empty project with a minimal manifest. + pub fn create(&self, name: &str, now: f64) -> Result { + self.install_package(name, &[], PackageProvenance::Created, now) + } + + /// Duplicate = fork at head: independent copy with fork provenance. + pub fn duplicate( + &self, + uid: PrefixedUid, + new_name: &str, + now: f64, + ) -> Result { + let source = self.open(uid)?; + let head = source.history.head(); + let files: Vec<(String, Vec)> = source + .read_all_files()? + .into_iter() + .filter(|(path, _)| path != ".lp/meta.json") + .collect(); + let provenance = match head { + Some(version) => PackageProvenance::ForkedFrom { + parent_project: uid.to_string(), + parent_version: version.to_string(), + }, + None => PackageProvenance::Created, + }; + // the copy must mint its own uid: drop the manifest's before install + let package = self.install_files_with_fresh_uid(new_name, &files, provenance, now)?; + Ok(package) + } + + pub fn rename(&self, uid: PrefixedUid, name: &str) -> Result<(), LibraryError> { + let handle = self.open(uid)?; + let view = handle.package_fs.borrow(); + package_manifest::set_name(&*view, name) + } + + pub fn delete(&self, uid: PrefixedUid) -> Result<(), LibraryError> { + let slug = self + .slug_for_uid(uid)? + .ok_or_else(|| LibraryError::NotFound(uid.to_string()))?; + let fs = self.fs.borrow(); + fs.delete_dir(format!("{PACKAGES_DIR}/{slug}").as_str().as_path())?; + match fs.delete_dir(format!("{HISTORY_DIR}/{uid}").as_str().as_path()) { + Ok(()) | Err(FsError::NotFound(_)) => Ok(()), + Err(e) => Err(e.into()), + } + } + + pub fn open(&self, uid: PrefixedUid) -> Result { + let slug = self + .slug_for_uid(uid)? + .ok_or_else(|| LibraryError::NotFound(uid.to_string()))?; + let package_fs = self.chroot_package(&slug)?; + let history_fs = { + let fs = self.fs.borrow(); + fs.chroot(format!("{HISTORY_DIR}/{uid}").as_str().as_path())? + }; + let history = { + let view = history_fs.borrow(); + let log = EventLog::new(&*view); + let events = log + .read_all() + .map_err(|e| LibraryError::History(e.to_string()))?; + if events.is_empty() { + let meta = { + let package_view = package_fs.borrow(); + package_meta::read_meta(&*package_view)? + }; + let origin = origin_event_for(meta); + log.append(&origin) + .map_err(|e| LibraryError::History(e.to_string()))?; + ProjectHistory::new(origin).map_err(|e| LibraryError::History(e.to_string()))? + } else { + ProjectHistory::from_events(events) + .map_err(|e| LibraryError::History(e.to_string()))? + } + }; + Ok(PackageHandle { + uid, + slug, + package_fs, + history_fs, + history, + }) + } + + /// Find a package by its provenance source (seed-once checks). + pub fn find_seeded_from(&self, source: &str) -> Result, LibraryError> { + for slug in self.package_slugs()? { + let package_fs = self.chroot_package(&slug)?; + let meta = { + let view = package_fs.borrow(); + package_meta::read_meta(&*view)? + }; + if let Some(PackageMeta { + provenance: PackageProvenance::SeededFrom { source: s }, + .. + }) = meta + { + if s == source { + return Ok(Some(self.read_summary(&slug)?)); + } + } + } + Ok(None) + } + + pub(crate) fn install_files_with_fresh_uid( + &self, + name: &str, + files: &[(String, Vec)], + provenance: PackageProvenance, + now: f64, + ) -> Result { + let mut files: Vec<(String, Vec)> = files.to_vec(); + if let Some((_, manifest_bytes)) = files.iter_mut().find(|(path, _)| path == "project.json") + { + let mut value: serde_json::Value = serde_json::from_slice(manifest_bytes) + .map_err(|e| LibraryError::Manifest(e.to_string()))?; + if let serde_json::Value::Object(map) = &mut value { + map.remove("uid"); + map.insert( + "name".to_string(), + serde_json::Value::String(name.to_string()), + ); + } + *manifest_bytes = serde_json::to_vec_pretty(&value) + .map_err(|e| LibraryError::Manifest(e.to_string()))?; + } + self.install_package(name, &files, provenance, now) + } + + fn package_slugs(&self) -> Result, LibraryError> { + let fs = self.fs.borrow(); + let entries = match fs.list_dir(PACKAGES_DIR.as_path(), false) { + Ok(entries) => entries, + Err(FsError::NotFound(_)) => Vec::new(), + Err(e) => return Err(e.into()), + }; + let mut slugs = Vec::new(); + for entry in entries { + if fs.is_dir(entry.as_path()).unwrap_or(false) { + if let Some(slug) = entry.as_str().rsplit('/').next() { + if !slug.is_empty() { + slugs.push(slug.to_string()); + } + } + } + } + slugs.sort(); + Ok(slugs) + } + + fn read_summary(&self, slug: &str) -> Result { + let package_fs = self.chroot_package(slug)?; + let view = package_fs.borrow(); + let ManifestFields { uid, name, kind } = package_manifest::read_manifest(&*view)?; + let uid = uid + .ok_or_else(|| LibraryError::Manifest(format!("package {slug} has no uid")))? + .parse() + .map_err(|e| LibraryError::Manifest(format!("package {slug} uid: {e}")))?; + Ok(PackageSummary { + uid, + name: name.unwrap_or_else(|| slug.to_string()), + kind, + slug: slug.to_string(), + }) + } + + fn slug_for_uid(&self, uid: PrefixedUid) -> Result, LibraryError> { + for slug in self.package_slugs()? { + let package_fs = self.chroot_package(&slug)?; + let view = package_fs.borrow(); + if let Ok(fields) = package_manifest::read_manifest(&*view) { + if fields.uid.as_deref() == Some(uid.to_string().as_str()) { + return Ok(Some(slug)); + } + } + } + Ok(None) + } + + fn chroot_package(&self, slug: &str) -> Result>, LibraryError> { + let fs = self.fs.borrow(); + Ok(fs.chroot(format!("{PACKAGES_DIR}/{slug}").as_str().as_path())?) + } +} + +fn origin_event_for(meta: Option) -> HistoryEvent { + let (at, provenance) = meta.map_or((0.0, PackageProvenance::Created), |m| { + (m.created_at, m.provenance) + }); + let kind = match provenance { + PackageProvenance::Created => EventKind::Created, + PackageProvenance::SeededFrom { source } => EventKind::RemixedFrom { + source, + source_version: None, + }, + PackageProvenance::ImportedZip { .. } => EventKind::ImportedZip, + PackageProvenance::ForkedFrom { + parent_project, + parent_version, + } => match (parent_project.parse(), parent_version.parse()) { + (Ok(parent_project), Ok(parent_version)) => EventKind::ForkedFrom { + parent_project, + parent_version, + }, + _ => { + log::warn!("unparseable fork provenance; falling back to Created origin"); + EventKind::Created + } + }, + }; + HistoryEvent { at, kind } +} + +#[cfg(test)] +mod tests { + use super::*; + use lpfs::LpFsMemory; + + fn store() -> LibraryStore { + let counter = Rc::new(RefCell::new(0u8)); + LibraryStore::new( + Rc::new(RefCell::new(LpFsMemory::new())), + Rc::new(move || { + *counter.borrow_mut() += 1; + [*counter.borrow(); 16] + }), + ) + } + + fn demo_files() -> Vec<(String, Vec)> { + vec![ + ( + "project.json".to_string(), + br#"{"kind":"Project","name":"demo","nodes":{"clock":{"ref":"./clock.json"}}}"# + .to_vec(), + ), + ("clock.json".to_string(), br#"{"kind":"Clock"}"#.to_vec()), + ("shader.glsl".to_string(), b"void main() {}".to_vec()), + ] + } + + #[test] + fn create_mints_uid_sidecar_slug_and_history() { + let store = store(); + let summary = store.create("My Project!", 1.0).unwrap(); + assert_eq!(summary.slug, "my-project"); + assert_eq!(summary.name, "My Project!"); + + let handle = store.open(summary.uid).unwrap(); + assert!(handle.history.head().is_some(), "initial save recorded"); + let meta = package_meta::read_meta(&*handle.package_fs.borrow()) + .unwrap() + .unwrap(); + assert_eq!(meta.provenance, PackageProvenance::Created); + } + + #[test] + fn install_keeps_manifest_name_and_mints_uid() { + let store = store(); + let summary = store + .install_package( + "fallback", + &demo_files(), + PackageProvenance::SeededFrom { + source: "examples/basic".to_string(), + }, + 2.0, + ) + .unwrap(); + assert_eq!(summary.name, "demo"); + assert!(store.find_seeded_from("examples/basic").unwrap().is_some()); + assert!(store.find_seeded_from("examples/other").unwrap().is_none()); + } + + #[test] + fn duplicate_forks_at_head_with_fresh_uid() { + let store = store(); + let original = store + .install_package("demo", &demo_files(), PackageProvenance::Created, 1.0) + .unwrap(); + let original_head = store.open(original.uid).unwrap().history.head().unwrap(); + + let copy = store.duplicate(original.uid, "demo copy", 2.0).unwrap(); + assert_ne!(copy.uid, original.uid); + assert_eq!(copy.slug, "demo-copy"); + + let copy_handle = store.open(copy.uid).unwrap(); + // fork origin seeds the line with the parent head (v1); the copy's + // own first save (with its new uid in the manifest) becomes v2 — + // identity is part of content, so the heads honestly differ + assert_eq!(copy_handle.history.version_number(original_head), Some(1)); + assert!(copy_handle.history.contains(original_head)); + let copy_head = copy_handle.history.head().unwrap(); + assert_ne!(copy_head, original_head); + assert_eq!(copy_handle.history.version_number(copy_head), Some(2)); + // source untouched + let source_files = store.open(original.uid).unwrap().read_all_files().unwrap(); + assert_eq!(source_files.len(), 4); // 3 demo files + sidecar + } + + #[test] + fn rename_patches_name_only() { + let store = store(); + let summary = store + .install_package("demo", &demo_files(), PackageProvenance::Created, 1.0) + .unwrap(); + store.rename(summary.uid, "renamed").unwrap(); + let listed = store.list().unwrap(); + assert_eq!(listed[0].name, "renamed"); + assert_eq!(listed[0].uid, summary.uid); + assert_eq!(listed[0].slug, "demo"); // dir unchanged + } + + #[test] + fn delete_removes_package_and_history() { + let store = store(); + let summary = store.create("gone", 1.0).unwrap(); + store.delete(summary.uid).unwrap(); + assert!(store.list().unwrap().is_empty()); + assert!(store.open(summary.uid).is_err()); + } + + #[test] + fn open_round_trips_history_across_store_instances() { + let fs: Rc> = Rc::new(RefCell::new(LpFsMemory::new())); + let store = LibraryStore::new(fs.clone(), Rc::new(|| [3u8; 16])); + let summary = store + .install_package("demo", &demo_files(), PackageProvenance::Created, 1.0) + .unwrap(); + let mut handle = store.open(summary.uid).unwrap(); + handle + .apply_update("/shader.glsl".as_path(), Some(b"void main() { /*2*/ }")) + .unwrap(); + let saved = handle.record_save(2.0).unwrap(); + assert!(saved.is_some()); + // unchanged content: no-op + assert!(handle.record_save(3.0).unwrap().is_none()); + + let store2 = LibraryStore::new(fs, Rc::new(|| [4u8; 16])); + let handle2 = store2.open(summary.uid).unwrap(); + assert_eq!(handle2.history.head(), handle.history.head()); + assert_eq!( + handle2.history.events().len(), + handle.history.events().len() + ); + } + + #[test] + fn record_save_restores_via_snapshot() { + let store = store(); + let summary = store + .install_package("demo", &demo_files(), PackageProvenance::Created, 1.0) + .unwrap(); + let mut handle = store.open(summary.uid).unwrap(); + let v1 = handle.history.head().unwrap(); + handle + .apply_update("/shader.glsl".as_path(), Some(b"v2")) + .unwrap(); + handle.record_save(2.0).unwrap(); + + // materialize v1 back out of the snapshot store + let history_fs = handle.history_fs.borrow(); + let snapshots = SnapshotStore::new(&*history_fs); + let restored = lpfs::LpFsMemory::new(); + snapshots.materialize(&v1, &restored).unwrap(); + assert_eq!( + restored.read_file("/shader.glsl".as_path()).unwrap(), + b"void main() {}" + ); + } +} diff --git a/lp-app/lpa-studio-core/src/app/library/mod.rs b/lp-app/lpa-studio-core/src/app/library/mod.rs new file mode 100644 index 000000000..db006fa7b --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/library/mod.rs @@ -0,0 +1,30 @@ +//! The local project library: uid-bearing packages over the mounted store. +//! +//! Platform-neutral (sans-IO habits): operates on a caller-supplied +//! `Rc>` — the OPFS-backed store in the browser, +//! `LpFsMemory` in host tests — with randomness and timestamps injected. +//! +//! Invariants: +//! - **The uid is identity** (`prj_…` in `project.json`); directory names +//! are human-friendly slugs and may collide-suffix freely. +//! - Layout: `/packages//` package dirs; `/history//` +//! lpc-history roots — beside, never inside, the package. +//! - Provenance lives in the package at `/.lp/meta.json` (excluded from +//! the canonical content hash by the lph1 spec) and seeds the history's +//! origin event. + +pub mod library_store; +pub mod package_manifest; +pub mod package_meta; +pub mod package_slug; +pub mod package_zip; + +pub use library_store::{LibraryError, LibraryStore, PackageHandle, PackageSummary}; +pub use package_meta::{PackageMeta, PackageProvenance}; +pub use package_zip::{export_package, import_zip}; + +/// Package directories live here (absolute path inside the store). +pub const PACKAGES_DIR: &str = "/packages"; + +/// Per-project history roots live here, keyed by project uid. +pub const HISTORY_DIR: &str = "/history"; diff --git a/lp-app/lpa-studio-core/src/app/library/package_manifest.rs b/lp-app/lpa-studio-core/src/app/library/package_manifest.rs new file mode 100644 index 000000000..c6ad588ee --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/library/package_manifest.rs @@ -0,0 +1,205 @@ +//! `project.json` manifest access: read fields, patch in place. +//! +//! Patching never re-serializes the node graph: the manifest is read as a +//! `serde_json::Value`, the field is set, and the whole value is written +//! back — unknown keys and the `nodes` table pass through untouched. + +use lpc_history::{PrefixedUid, UidPrefix}; +use lpc_model::AsLpPath; +use lpfs::LpFs; + +use super::library_store::LibraryError; + +pub const MANIFEST_PATH: &str = "/project.json"; + +/// The manifest fields the library cares about (the rest passes through). +#[derive(Debug, Clone)] +pub struct ManifestFields { + pub uid: Option, + pub name: Option, + pub kind: String, +} + +pub fn read_manifest(fs: &dyn LpFs) -> Result { + let value = read_value(fs)?; + Ok(ManifestFields { + uid: value + .get("uid") + .and_then(|v| v.as_str()) + .map(str::to_string), + name: value + .get("name") + .and_then(|v| v.as_str()) + .map(str::to_string), + kind: value + .get("kind") + .and_then(|v| v.as_str()) + .unwrap_or("Project") + .to_string(), + }) +} + +/// Ensure the manifest carries a uid, minting one from `random` if absent. +/// Returns the (existing or minted) uid. +pub fn ensure_uid(fs: &dyn LpFs, random: &[u8; 16]) -> Result { + let mut value = read_value(fs)?; + if let Some(existing) = value.get("uid").and_then(|v| v.as_str()) { + return existing + .parse() + .map_err(|e| LibraryError::Manifest(format!("invalid uid {existing:?}: {e}"))); + } + let uid = PrefixedUid::mint(UidPrefix::Project, random); + set_field( + &mut value, + "uid", + serde_json::Value::String(uid.to_string()), + ); + write_value(fs, &value)?; + Ok(uid) +} + +pub fn set_name(fs: &dyn LpFs, name: &str) -> Result<(), LibraryError> { + let mut value = read_value(fs)?; + set_field( + &mut value, + "name", + serde_json::Value::String(name.to_string()), + ); + write_value(fs, &value) +} + +fn set_field(value: &mut serde_json::Value, key: &str, field: serde_json::Value) { + if let serde_json::Value::Object(map) = value { + map.insert(key.to_string(), field); + } +} + +fn read_value(fs: &dyn LpFs) -> Result { + let bytes = fs + .read_file(MANIFEST_PATH.as_path()) + .map_err(|e| LibraryError::Manifest(format!("read project.json: {e}")))?; + serde_json::from_slice(&bytes) + .map_err(|e| LibraryError::Manifest(format!("parse project.json: {e}"))) +} + +fn write_value(fs: &dyn LpFs, value: &serde_json::Value) -> Result<(), LibraryError> { + // The slot codec streams and requires the top-level `kind` to precede + // the variant's fields, and serde_json's map is ordered alphabetically — + // so emit a canonical key order (matching ProjectDef's declaration + // order) instead of serializing the map directly. + const CANONICAL_ORDER: [&str; 4] = ["kind", "format", "uid", "name"]; + let serde_json::Value::Object(map) = value else { + return Err(LibraryError::Manifest( + "project.json root must be an object".to_string(), + )); + }; + let mut ordered = Vec::new(); + for key in CANONICAL_ORDER { + if let Some(v) = map.get(key) { + ordered.push((key.to_string(), v.clone())); + } + } + for (key, v) in map { + if !CANONICAL_ORDER.contains(&key.as_str()) { + ordered.push((key.clone(), v.clone())); + } + } + let mut out = String::from("{\n"); + for (index, (key, v)) in ordered.iter().enumerate() { + let rendered = serde_json::to_string_pretty(v) + .map_err(|e| LibraryError::Manifest(format!("serialize project.json: {e}")))?; + // indent nested lines to match to_string_pretty at depth 1 + let indented = rendered.replace('\n', "\n "); + out.push_str(&format!( + " {}: {indented}", + serde_json::Value::String(key.clone()) + )); + if index + 1 < ordered.len() { + out.push(','); + } + out.push('\n'); + } + out.push('}'); + out.push('\n'); + fs.write_file(MANIFEST_PATH.as_path(), out.as_bytes()) + .map_err(|e| LibraryError::Manifest(format!("write project.json: {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use lpfs::LpFsMemory; + + const MANIFEST: &[u8] = br#"{ + "kind": "Project", + "name": "fluid", + "nodes": { "clock": { "ref": "./clock.json" }, "custom-key": 7 } +}"#; + + #[test] + fn ensure_uid_mints_once_and_preserves_unknown_fields() { + let fs = LpFsMemory::new(); + fs.write_file(MANIFEST_PATH.as_path(), MANIFEST).unwrap(); + + let minted = ensure_uid(&fs, &[7u8; 16]).unwrap(); + let again = ensure_uid(&fs, &[9u8; 16]).unwrap(); + assert_eq!(minted, again, "second call must keep the existing uid"); + + let value: serde_json::Value = + serde_json::from_slice(&fs.read_file(MANIFEST_PATH.as_path()).unwrap()).unwrap(); + assert_eq!(value["uid"].as_str().unwrap(), minted.to_string()); + assert_eq!(value["nodes"]["custom-key"], 7); + assert_eq!(value["nodes"]["clock"]["ref"], "./clock.json"); + } + + #[test] + fn set_name_patches_only_the_name() { + let fs = LpFsMemory::new(); + fs.write_file(MANIFEST_PATH.as_path(), MANIFEST).unwrap(); + set_name(&fs, "renamed").unwrap(); + let fields = read_manifest(&fs).unwrap(); + assert_eq!(fields.name.as_deref(), Some("renamed")); + assert_eq!(fields.kind, "Project"); + let value: serde_json::Value = + serde_json::from_slice(&fs.read_file(MANIFEST_PATH.as_path()).unwrap()).unwrap(); + assert_eq!(value["nodes"]["custom-key"], 7); + } + + #[test] + fn patched_manifest_parses_through_the_slot_codec() { + // The whole point of canonical ordering + the ProjectDef uid slot: + // a library-patched manifest must load on the runtime. + let fs = LpFsMemory::new(); + fs.write_file( + MANIFEST_PATH.as_path(), + br#"{ + "kind": "Project", + "format": 1, + "name": "basic", + "nodes": { "clock": { "ref": "./clock.json" } } +}"#, + ) + .unwrap(); + ensure_uid(&fs, &[5u8; 16]).unwrap(); + set_name(&fs, "renamed").unwrap(); + let bytes = fs.read_file(MANIFEST_PATH.as_path()).unwrap(); + let text = core::str::from_utf8(&bytes).unwrap(); + assert!( + text.trim_start().starts_with("{\n \"kind\""), + "kind must lead: {text}" + ); + lpc_model::NodeDef::from_json_str(text) + .unwrap_or_else(|e| panic!("codec rejected patched manifest: {e}\n{text}")); + } + + #[test] + fn invalid_uid_is_rejected() { + let fs = LpFsMemory::new(); + fs.write_file( + MANIFEST_PATH.as_path(), + br#"{"kind":"Project","uid":"garbage"}"#, + ) + .unwrap(); + assert!(ensure_uid(&fs, &[1u8; 16]).is_err()); + } +} 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 new file mode 100644 index 000000000..96045603d --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/library/package_meta.rs @@ -0,0 +1,81 @@ +//! The provenance sidecar `/.lp/meta.json`. +//! +//! Lives inside the package (so it travels on export/pull) but under the +//! reserved `/.lp/` namespace, which the lph1 hash spec excludes — metadata +//! churn never changes a package's content hash. + +use lpc_model::AsLpPath; +use lpfs::LpFs; +use serde::{Deserialize, Serialize}; + +use super::library_store::LibraryError; + +pub const META_PATH: &str = "/.lp/meta.json"; + +/// Where a package came from. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PackageProvenance { + /// Created from scratch in this library. + Created, + /// Seeded from a bundled source (e.g. `examples/basic`). + SeededFrom { source: String }, + /// Imported from a zip archive; the archive's own uid, if it had one. + ImportedZip { original_uid: Option }, + /// Forked from another project's version. + ForkedFrom { + parent_project: String, + parent_version: String, + }, +} + +/// The sidecar contents. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PackageMeta { + pub provenance: PackageProvenance, + /// f64 epoch seconds, caller-supplied. + pub created_at: f64, +} + +pub fn read_meta(fs: &dyn LpFs) -> Result, LibraryError> { + if !fs + .file_exists(META_PATH.as_path()) + .map_err(|e| LibraryError::Meta(format!("{e}")))? + { + return Ok(None); + } + 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}"))) +} + +pub fn write_meta(fs: &dyn LpFs, meta: &PackageMeta) -> Result<(), LibraryError> { + let bytes = serde_json::to_vec_pretty(meta) + .map_err(|e| LibraryError::Meta(format!("serialize meta: {e}")))?; + fs.write_file(META_PATH.as_path(), &bytes) + .map_err(|e| LibraryError::Meta(format!("write meta: {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use lpfs::LpFsMemory; + + #[test] + fn round_trips_and_is_absent_by_default() { + let fs = LpFsMemory::new(); + assert!(read_meta(&fs).unwrap().is_none()); + let meta = PackageMeta { + provenance: PackageProvenance::SeededFrom { + source: "examples/basic".to_string(), + }, + created_at: 1700000000.5, + }; + write_meta(&fs, &meta).unwrap(); + assert_eq!(read_meta(&fs).unwrap().unwrap(), meta); + } +} diff --git a/lp-app/lpa-studio-core/src/app/library/package_slug.rs b/lp-app/lpa-studio-core/src/app/library/package_slug.rs new file mode 100644 index 000000000..65366f0e9 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/library/package_slug.rs @@ -0,0 +1,58 @@ +//! Human-friendly directory slugs. Dirs are not identity — the uid is. + +/// Slugify a display name: lowercase ASCII alphanumerics and hyphens. +pub fn slugify(name: &str) -> String { + let mut slug = String::new(); + let mut last_hyphen = true; // suppress leading hyphens + for c in name.chars() { + if c.is_ascii_alphanumeric() { + slug.push(c.to_ascii_lowercase()); + last_hyphen = false; + } else if !last_hyphen { + slug.push('-'); + last_hyphen = true; + } + } + while slug.ends_with('-') { + slug.pop(); + } + if slug.is_empty() { + "project".to_string() + } else { + slug + } +} + +/// First of `slug`, `slug-2`, `slug-3`, … not present in `taken`. +pub fn unique_slug(name: &str, taken: &[String]) -> String { + let base = slugify(name); + if !taken.iter().any(|t| t == &base) { + return base; + } + for i in 2.. { + let candidate = format!("{base}-{i}"); + if !taken.iter().any(|t| t == &candidate) { + return candidate; + } + } + unreachable!("unbounded suffix search") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slugifies_names() { + assert_eq!(slugify("Fyeah Sign!"), "fyeah-sign"); + assert_eq!(slugify(" Luna's Porch "), "luna-s-porch"); + assert_eq!(slugify("电"), "project"); + } + + #[test] + fn suffixes_collisions() { + let taken = vec!["basic".to_string(), "basic-2".to_string()]; + assert_eq!(unique_slug("Basic", &taken), "basic-3"); + assert_eq!(unique_slug("Other", &taken), "other"); + } +} diff --git a/lp-app/lpa-studio-core/src/app/library/package_zip.rs b/lp-app/lpa-studio-core/src/app/library/package_zip.rs new file mode 100644 index 000000000..51a710def --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/library/package_zip.rs @@ -0,0 +1,253 @@ +//! Zip import/export for packages (library-level codec; UI lands in M4). +//! +//! Export: every package file including `/.lp/meta.json` (provenance +//! travels), never `/history/**`, under a single top-level directory named +//! by the slug (the friendly unzip experience), deflated, deterministic +//! entry order. +//! +//! Import: mints a **new uid** (zips get shared; colliding uids would break +//! identity) with `ImportedZip` provenance recording the archive's own uid +//! when it had one. Tolerates Finder noise (`__MACOSX/`, `.DS_Store`) and a +//! nested top-level directory. + +use std::io::{Cursor, Read, Write}; + +use zip::write::SimpleFileOptions; + +use super::library_store::{LibraryError, LibraryStore, PackageHandle, PackageSummary}; +use super::package_meta::PackageProvenance; + +/// Serialize a package to zip bytes. +pub fn export_package(handle: &PackageHandle) -> Result, LibraryError> { + let files = handle.read_all_files()?; // sorted relative paths + let mut cursor = Cursor::new(Vec::new()); + { + let mut writer = zip::ZipWriter::new(&mut cursor); + let options = + SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + for (relative, bytes) in &files { + writer + .start_file(format!("{}/{relative}", handle.slug), options) + .map_err(|e| LibraryError::Meta(format!("zip: {e}")))?; + writer + .write_all(bytes) + .map_err(|e| LibraryError::Meta(format!("zip: {e}")))?; + } + writer + .finish() + .map_err(|e| LibraryError::Meta(format!("zip: {e}")))?; + } + Ok(cursor.into_inner()) +} + +/// Install a package from zip bytes. See module docs for uid semantics. +pub fn import_zip( + store: &LibraryStore, + bytes: &[u8], + now: f64, +) -> Result { + let mut archive = zip::ZipArchive::new(Cursor::new(bytes)) + .map_err(|e| LibraryError::Meta(format!("not a zip archive: {e}")))?; + + // collect entries, tolerating archiver noise + let mut entries: Vec<(String, Vec)> = Vec::new(); + for index in 0..archive.len() { + let mut file = archive + .by_index(index) + .map_err(|e| LibraryError::Meta(format!("zip entry: {e}")))?; + if file.is_dir() { + continue; + } + let name = file.name().to_string(); + if name.starts_with("__MACOSX/") || name.ends_with(".DS_Store") { + continue; + } + let mut content = Vec::new(); + file.read_to_end(&mut content) + .map_err(|e| LibraryError::Meta(format!("zip read {name}: {e}")))?; + entries.push((name, content)); + } + + // locate the directory holding project.json (top level or one deep) + let manifest_entry = entries + .iter() + .map(|(name, _)| name.as_str()) + .filter(|name| *name == "project.json" || name.ends_with("/project.json")) + .min_by_key(|name| name.matches('/').count()) + .ok_or_else(|| LibraryError::Manifest("no project.json in this zip".to_string()))?; + let prefix = manifest_entry.trim_end_matches("project.json").to_string(); + + let files: Vec<(String, Vec)> = entries + .iter() + .filter_map(|(name, bytes)| { + name.strip_prefix(&prefix) + .map(|relative| (relative.to_string(), bytes.clone())) + }) + .filter(|(relative, _)| !relative.is_empty()) + .collect(); + + // the archive's own identity, if it had one, rides the provenance + let manifest_bytes = files + .iter() + .find(|(relative, _)| relative == "project.json") + .map(|(_, bytes)| bytes.clone()) + .expect("manifest located above"); + let manifest: serde_json::Value = serde_json::from_slice(&manifest_bytes) + .map_err(|e| LibraryError::Manifest(format!("zip project.json: {e}")))?; + let original_uid = manifest + .get("uid") + .and_then(|v| v.as_str()) + .map(str::to_string); + let name = manifest + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("Imported project") + .to_string(); + + store.install_files_with_fresh_uid( + &name, + &files, + PackageProvenance::ImportedZip { original_uid }, + now, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use lpfs::{LpFs, LpFsMemory}; + use std::cell::RefCell; + use std::rc::Rc; + + fn store_with_seed(seed: u8) -> LibraryStore { + LibraryStore::new( + Rc::new(RefCell::new(LpFsMemory::new())), + Rc::new(move || [seed; 16]), + ) + } + + fn store() -> LibraryStore { + store_with_seed(9) + } + + fn seeded(store: &LibraryStore) -> PackageSummary { + store + .install_package( + "demo", + &[ + ( + "project.json".to_string(), + br#"{"kind":"Project","name":"demo","nodes":{}}"#.to_vec(), + ), + ("shader.glsl".to_string(), b"void main() {}".to_vec()), + ("assets/map.bin".to_string(), vec![0u8, 159, 146, 150]), + ], + PackageProvenance::Created, + 1.0, + ) + .unwrap() + } + + #[test] + fn export_import_round_trips_with_fresh_uid() { + let source_store = store(); + let source = seeded(&source_store); + let handle = source_store.open(source.uid).unwrap(); + let bytes = export_package(&handle).unwrap(); + + let dest_store = store_with_seed(42); + let imported = import_zip(&dest_store, &bytes, 2.0).unwrap(); + assert_ne!(imported.uid, source.uid, "import must mint a fresh uid"); + assert_eq!(imported.name, "demo"); + + let imported_handle = dest_store.open(imported.uid).unwrap(); + let files = imported_handle.read_all_files().unwrap(); + let shader = files.iter().find(|(p, _)| p == "shader.glsl").unwrap(); + assert_eq!(shader.1, b"void main() {}"); + let binary = files.iter().find(|(p, _)| p == "assets/map.bin").unwrap(); + assert_eq!(binary.1, vec![0u8, 159, 146, 150]); + + // provenance records the original uid + let meta = super::super::package_meta::read_meta(&*imported_handle.package_fs.borrow()) + .unwrap() + .unwrap(); + assert_eq!( + meta.provenance, + PackageProvenance::ImportedZip { + original_uid: Some(source.uid.to_string()) + } + ); + } + + #[test] + fn tolerates_finder_noise_and_nesting() { + let source_store = store(); + let source = seeded(&source_store); + let handle = source_store.open(source.uid).unwrap(); + let clean = export_package(&handle).unwrap(); + + // rebuild the archive with Finder junk alongside the nested dir + let mut archive = zip::ZipArchive::new(Cursor::new(clean.as_slice())).unwrap(); + let mut cursor = Cursor::new(Vec::new()); + { + let mut writer = zip::ZipWriter::new(&mut cursor); + let options = SimpleFileOptions::default(); + writer.start_file("__MACOSX/._junk", options).unwrap(); + writer.write_all(b"junk").unwrap(); + writer.start_file("demo/.DS_Store", options).unwrap(); + writer.write_all(b"junk").unwrap(); + for index in 0..archive.len() { + let mut file = archive.by_index(index).unwrap(); + let name = file.name().to_string(); + let mut content = Vec::new(); + file.read_to_end(&mut content).unwrap(); + writer.start_file(name, options).unwrap(); + writer.write_all(&content).unwrap(); + } + writer.finish().unwrap(); + } + + let dest_store = store(); + let imported = import_zip(&dest_store, &cursor.into_inner(), 2.0).unwrap(); + let files = dest_store + .open(imported.uid) + .unwrap() + .read_all_files() + .unwrap(); + assert!(files.iter().any(|(p, _)| p == "shader.glsl")); + assert!(!files.iter().any(|(p, _)| p.contains("DS_Store"))); + } + + #[test] + fn missing_manifest_errors_cleanly() { + let mut cursor = Cursor::new(Vec::new()); + { + let mut writer = zip::ZipWriter::new(&mut cursor); + writer + .start_file("readme.txt", SimpleFileOptions::default()) + .unwrap(); + writer.write_all(b"hi").unwrap(); + writer.finish().unwrap(); + } + let err = import_zip(&store(), &cursor.into_inner(), 1.0).unwrap_err(); + assert!(err.to_string().contains("no project.json")); + + let err = import_zip(&store(), b"not a zip", 1.0).unwrap_err(); + assert!(err.to_string().contains("not a zip")); + } + + #[test] + fn export_excludes_history_and_includes_sidecar() { + let source_store = store(); + let source = seeded(&source_store); + let handle = source_store.open(source.uid).unwrap(); + let bytes = export_package(&handle).unwrap(); + + let mut archive = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap(); + let names: Vec = (0..archive.len()) + .map(|i| archive.by_index(i).unwrap().name().to_string()) + .collect(); + assert!(names.iter().any(|n| n.ends_with(".lp/meta.json"))); + assert!(!names.iter().any(|n| n.contains("history"))); + } +} diff --git a/lp-app/lpa-studio-core/src/app/mod.rs b/lp-app/lpa-studio-core/src/app/mod.rs index 550b7a953..1e879cb40 100644 --- a/lp-app/lpa-studio-core/src/app/mod.rs +++ b/lp-app/lpa-studio-core/src/app/mod.rs @@ -1,6 +1,8 @@ pub mod device; +pub mod library; pub mod link; pub mod node; +pub mod places; pub mod project; pub mod server; pub mod studio; diff --git a/lp-app/lpa-studio-core/src/app/places/device_identity.rs b/lp-app/lpa-studio-core/src/app/places/device_identity.rs new file mode 100644 index 000000000..bd53922b3 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/places/device_identity.rs @@ -0,0 +1,45 @@ +//! The on-device identity convention: `/.lp/device.json`. +//! +//! Stamped at provisioning (M5's flow); USB port metadata can't distinguish +//! identical boards, so the device filesystem carries its own identity. +//! Format decided here; M5 wires the stamping and reading over the +//! protocol's fs requests. + +use serde::{Deserialize, Serialize}; + +pub const DEVICE_IDENTITY_PATH: &str = "/.lp/device.json"; + +/// A device's stamped identity. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeviceIdentity { + /// `dev_…` uid. + pub uid: String, + /// Human name, gently insisted on at provisioning ("Luna's porch sign"). + pub name: String, +} + +impl DeviceIdentity { + pub fn to_json_bytes(&self) -> Vec { + serde_json::to_vec_pretty(self).expect("device identity serializes") + } + + pub fn from_json_bytes(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes).map_err(|e| e.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips() { + let identity = DeviceIdentity { + uid: "dev_0000000000000001".to_string(), + name: "Porch sign".to_string(), + }; + let bytes = identity.to_json_bytes(); + assert_eq!(DeviceIdentity::from_json_bytes(&bytes).unwrap(), identity); + } +} diff --git a/lp-app/lpa-studio-core/src/app/places/device_registry.rs b/lp-app/lpa-studio-core/src/app/places/device_registry.rs new file mode 100644 index 000000000..2ca2f9902 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/places/device_registry.rs @@ -0,0 +1,119 @@ +//! The device registry: remembered devices, persisted in the library store. +//! +//! `/registry.json` at the store root. A device remembers which line was +//! last pushed to it (M1's `DeviceAssociation`), so behind/up-to-date is +//! computed against the right project (fleet vs family — D11). + +use std::cell::RefCell; +use std::rc::Rc; + +use lpc_history::DeviceAssociation; +use lpc_model::AsLpPath; +use lpfs::{FsError, LpFs}; +use serde::{Deserialize, Serialize}; + +use crate::app::library::LibraryError; + +pub const REGISTRY_PATH: &str = "/registry.json"; + +/// One remembered device. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RegisteredDevice { + /// `dev_…` uid (string form; stamped on the device per M5's flow). + pub uid: String, + pub name: String, + /// f64 epoch seconds, caller-supplied. + pub last_seen_at: f64, + /// What was last pushed to it, if anything. + pub association: Option, +} + +/// Load/save wrapper over the store. +pub struct DeviceRegistry { + fs: Rc>, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct RegistryFile { + devices: Vec, +} + +impl DeviceRegistry { + pub fn new(fs: Rc>) -> Self { + Self { fs } + } + + pub fn list(&self) -> Result, LibraryError> { + Ok(self.load()?.devices) + } + + /// Insert or update by uid. + pub fn upsert(&self, device: RegisteredDevice) -> Result<(), LibraryError> { + let mut file = self.load()?; + match file.devices.iter_mut().find(|d| d.uid == device.uid) { + Some(existing) => *existing = device, + None => file.devices.push(device), + } + self.save(&file) + } + + fn load(&self) -> Result { + let fs = self.fs.borrow(); + let bytes = match fs.read_file(REGISTRY_PATH.as_path()) { + Ok(bytes) => bytes, + Err(FsError::NotFound(_)) => return Ok(RegistryFile::default()), + Err(e) => return Err(LibraryError::Fs(e.to_string())), + }; + serde_json::from_slice(&bytes).map_err(|e| LibraryError::Meta(format!("registry: {e}"))) + } + + fn save(&self, file: &RegistryFile) -> Result<(), LibraryError> { + let bytes = serde_json::to_vec_pretty(file) + .map_err(|e| LibraryError::Meta(format!("registry: {e}")))?; + let fs = self.fs.borrow(); + fs.write_file(REGISTRY_PATH.as_path(), &bytes) + .map_err(|e| LibraryError::Fs(e.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use lpc_history::{ContentHash, PrefixedUid, UidPrefix}; + use lpfs::LpFsMemory; + + #[test] + fn upsert_and_round_trip() { + let fs: Rc> = Rc::new(RefCell::new(LpFsMemory::new())); + let registry = DeviceRegistry::new(fs.clone()); + assert!(registry.list().unwrap().is_empty()); + + let device = RegisteredDevice { + uid: "dev_0000000000000001".to_string(), + name: "Luna's porch sign".to_string(), + last_seen_at: 1.0, + association: Some(DeviceAssociation { + device: PrefixedUid::mint(UidPrefix::Device, &[1u8; 16]), + project: PrefixedUid::mint(UidPrefix::Project, &[2u8; 16]), + version: ContentHash::of(b"v3"), + at: 1.0, + }), + }; + registry.upsert(device.clone()).unwrap(); + registry + .upsert(RegisteredDevice { + last_seen_at: 2.0, + ..device.clone() + }) + .unwrap(); + + let listed = DeviceRegistry::new(fs).list().unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].last_seen_at, 2.0); + assert_eq!( + listed[0].association.as_ref().unwrap().version, + ContentHash::of(b"v3") + ); + } +} diff --git a/lp-app/lpa-studio-core/src/app/places/mod.rs b/lp-app/lpa-studio-core/src/app/places/mod.rs new file mode 100644 index 000000000..f8e2ad38d --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/places/mod.rs @@ -0,0 +1,17 @@ +//! Places: everywhere a project can live (roadmap D18/D19). +//! +//! The library is the source of truth; runtimes (simulator now, devices in +//! M5) are places projects are pushed to and pulled from. The trait here is +//! deliberately small — it establishes the seam (kind + capacity) that M4's +//! gallery and M5's device flows grow against; the ops live on the concrete +//! types until real callers shape the abstraction. + +pub mod device_identity; +pub mod device_registry; +pub mod place; +pub mod runtime_place; + +pub use device_identity::{DEVICE_IDENTITY_PATH, DeviceIdentity}; +pub use device_registry::{DeviceRegistry, RegisteredDevice}; +pub use place::{Place, PlaceDescriptor, PlaceKind}; +pub use runtime_place::{RuntimePlace, relate_runtime_content}; diff --git a/lp-app/lpa-studio-core/src/app/places/place.rs b/lp-app/lpa-studio-core/src/app/places/place.rs new file mode 100644 index 000000000..4d3b72faa --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/places/place.rs @@ -0,0 +1,48 @@ +//! The place seam: kind and capacity. + +use crate::app::library::{LibraryError, LibraryStore, PackageSummary}; + +/// What sort of place this is. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlaceKind { + /// The local library — the source of truth. + Library, + /// An ephemeral simulator runtime (a device with no memory — D19). + SimRuntime, + /// A physical device (serial today, networked later). + Device, +} + +/// Capacity and kind, the facts the UI shapes itself around (D18: the +/// device card IS the slot). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PlaceDescriptor { + pub kind: PlaceKind, + /// `None` = unbounded (the library); `Some(1)` = single-slot runtimes. + pub capacity: Option, +} + +/// A place a project can live. Grown deliberately small — see module docs. +pub trait Place { + fn descriptor(&self) -> PlaceDescriptor; +} + +/// The library as a place. +pub struct LibraryPlace { + pub store: LibraryStore, +} + +impl LibraryPlace { + pub fn list(&self) -> Result, LibraryError> { + self.store.list() + } +} + +impl Place for LibraryPlace { + fn descriptor(&self) -> PlaceDescriptor { + PlaceDescriptor { + kind: PlaceKind::Library, + capacity: None, + } + } +} diff --git a/lp-app/lpa-studio-core/src/app/places/runtime_place.rs b/lp-app/lpa-studio-core/src/app/places/runtime_place.rs new file mode 100644 index 000000000..f2cfeda54 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/places/runtime_place.rs @@ -0,0 +1,88 @@ +//! Runtime places: the simulator (and, with M5, devices) as push/pull +//! targets. + +use lpa_link::LinkConnectionKind; +use lpc_history::{ContentHash, ProjectHistory, SyncRelation}; + +use super::place::{Place, PlaceDescriptor, PlaceKind}; + +/// A connected runtime as a place. One storage slot today (the studio uses +/// a single `"studio"` storage id); real multi-slot capacity is a future +/// place capability. +pub struct RuntimePlace { + connection_kind: LinkConnectionKind, +} + +impl RuntimePlace { + pub fn new(connection_kind: LinkConnectionKind) -> Self { + Self { connection_kind } + } + + pub fn is_device(&self) -> bool { + !matches!( + self.connection_kind, + LinkConnectionKind::BrowserWorker { .. } + ) + } +} + +impl Place for RuntimePlace { + fn descriptor(&self) -> PlaceDescriptor { + let kind = if self.is_device() { + PlaceKind::Device + } else { + PlaceKind::SimRuntime + }; + PlaceDescriptor { + kind, + capacity: Some(1), + } + } +} + +/// Relate a runtime's observed package hash to a library project's line. +/// +/// The connect-as-pull decision surface (D11): `AtHead` → nothing to do; +/// `Behind` → offer Update (never as the default click); `Diverged` → the +/// copy was already banked at connect, offer adopt / keep-both / replace. +/// A runtime whose package has an *unknown uid* has no history to relate — +/// the caller installs a new library package with pulled provenance instead +/// of calling this. +pub fn relate_runtime_content(history: &ProjectHistory, observed: ContentHash) -> SyncRelation { + history.classify(observed) +} + +#[cfg(test)] +mod tests { + use super::*; + use lpc_history::{EventKind, HistoryEvent}; + + #[test] + fn descriptor_derives_from_connection_kind() { + let sim = RuntimePlace::new(LinkConnectionKind::BrowserWorker { + protocol: "json".to_string(), + }); + assert_eq!(sim.descriptor().kind, PlaceKind::SimRuntime); + assert_eq!(sim.descriptor().capacity, Some(1)); + } + + #[test] + fn relate_covers_the_connect_matrix() { + let mut history = ProjectHistory::new(HistoryEvent { + at: 1.0, + kind: EventKind::Created, + }) + .unwrap(); + let v1 = ContentHash::of(b"v1"); + let v2 = ContentHash::of(b"v2"); + history.record_save(v1, 2.0); + history.record_save(v2, 3.0); + + assert_eq!(relate_runtime_content(&history, v2), SyncRelation::AtHead); + assert_eq!(relate_runtime_content(&history, v1), SyncRelation::Behind); + assert_eq!( + relate_runtime_content(&history, ContentHash::of(b"foreign")), + SyncRelation::Diverged + ); + } +} diff --git a/lp-app/lpa-studio-core/src/app/project/project_controller.rs b/lp-app/lpa-studio-core/src/app/project/project_controller.rs index dde9fdcd8..63c5105d1 100644 --- a/lp-app/lpa-studio-core/src/app/project/project_controller.rs +++ b/lp-app/lpa-studio-core/src/app/project/project_controller.rs @@ -44,6 +44,24 @@ pub struct ProjectController { def_artifacts: BTreeMap, /// Monotonic correlation-id source for overlay mutation commands. next_mutation_cmd_id: u64, + /// The local library, when the platform mounted a store (browser). + /// Absent on host tests — flows degrade to the legacy deploy path. + library: Option, +} + +/// Library wiring for load-as-push / save-as-pull (roadmap M3). +struct LibraryContext { + store: crate::app::library::LibraryStore, + now_secs: std::rc::Rc f64>, + active: Option, +} + +/// The open library package backing the running project. +struct ActiveLibraryProject { + handle: crate::app::library::PackageHandle, + /// Runtime fs revision the library is synced to (advances on each + /// successful save-as-pull). + last_synced: lpc_model::FsVersion, } impl ProjectController { @@ -59,9 +77,23 @@ impl ProjectController { edit_buffer: BTreeMap::new(), def_artifacts: BTreeMap::new(), next_mutation_cmd_id: 1, + library: None, } } + /// Attach the mounted library (browser shell, after the store mounts). + pub fn set_library( + &mut self, + store: crate::app::library::LibraryStore, + now_secs: std::rc::Rc f64>, + ) { + self.library = Some(LibraryContext { + store, + now_secs, + active: None, + }); + } + pub fn set_state(&mut self, state: ProjectState) { if !matches!(state, ProjectState::Ready { .. }) { self.clear_loaded_project_state(); @@ -378,12 +410,123 @@ impl ProjectController { server: &mut StudioServerClient, ) -> Result, UiError> { self.mark_loading_demo(); + if self.library.is_some() { + return self.open_seeded_demo_from_library(server).await; + } + // legacy path (host tests, storeless platforms): deploy the bundled + // files directly — no persistence let loaded = server.load_demo_project().await?; self.mark_ready(loaded.project_id, loaded.handle_id, loaded.inventory); self.def_artifacts = loaded.node_def_artifacts; Ok(loaded.logs) } + /// Load-as-push (D19): seed the demo into the library once, then open + /// the library copy by replacing the sim's project with the library + /// head. A page refresh re-pushes the head — it never reseeds. + async fn open_seeded_demo_from_library( + &mut self, + server: &mut StudioServerClient, + ) -> Result, UiError> { + use crate::app::library::PackageProvenance; + use crate::app::project::demo_project::{DEMO_PROJECT_ID, demo_project_deploy_files}; + + let context = self.library.as_mut().expect("checked by caller"); + let now = (context.now_secs)(); + let summary = match context + .store + .find_seeded_from(DEMO_PROJECT_ID) + .map_err(library_ui_error)? + { + Some(existing) => existing, + None => { + let files: Vec<(String, Vec)> = demo_project_deploy_files() + .iter() + .map(|f| (f.relative_path().to_string(), f.bytes().to_vec())) + .collect(); + context + .store + .install_package( + "Basic", + &files, + PackageProvenance::SeededFrom { + source: DEMO_PROJECT_ID.to_string(), + }, + now, + ) + .map_err(library_ui_error)? + } + }; + + let handle = context.store.open(summary.uid).map_err(library_ui_error)?; + let files = handle.read_all_files().map_err(library_ui_error)?; + let expected_hash = handle.content_hash().map_err(library_ui_error)?.to_string(); + + let loaded = server.open_library_project(&files, &expected_hash).await?; + context.active = Some(ActiveLibraryProject { + handle, + last_synced: loaded.synced_version, + }); + self.mark_ready(summary.name, loaded.handle_id, loaded.inventory); + self.def_artifacts = loaded.node_def_artifacts; + Ok(loaded.logs) + } + + /// Save-as-pull (D20/D8): after a successful commit, pull the changed + /// files into the library copy and record a `Saved` event. A failure + /// here never fails the user's save — the runtime committed fine; we + /// surface a warning and retry on the next save (`last_synced` only + /// advances on full success). + async fn pull_committed_changes_into_library( + &mut self, + server: &mut StudioServerClient, + ) -> Result, UiError> { + let Some(context) = self.library.as_mut() else { + return Ok(None); + }; + let Some(active) = context.active.as_mut() else { + return Ok(None); + }; + let now = (context.now_secs)(); + + let pulled = server + .pull_changed_files( + crate::app::project::demo_project::DEMO_PROJECT_STORAGE_ID, + active.last_synced, + ) + .await?; + if pulled.updates.is_empty() { + active.last_synced = pulled.version; + return Ok(None); + } + for update in &pulled.updates { + let path = format!("/{}", update.path.trim_start_matches('/')); + active + .handle + .apply_update(lpc_model::LpPath::new(&path), update.content.as_deref()) + .map_err(library_ui_error)?; + } + active.handle.record_save(now).map_err(library_ui_error)?; + active.last_synced = pulled.version; + + // corruption tripwire: library copy must now match the runtime + let local = active + .handle + .content_hash() + .map_err(library_ui_error)? + .to_string(); + let (remote, _) = server + .hash_package(crate::app::project::demo_project::DEMO_PROJECT_STORAGE_ID) + .await?; + if local != remote { + log::error!("library/runtime hash mismatch after save: {local} vs {remote}"); + return Ok(Some(UiNotice::warning( + "Saved, but the library copy differs from the simulator — please report this", + ))); + } + Ok(None) + } + pub async fn connect_running_project( &mut self, server: &mut StudioServerClient, @@ -907,10 +1050,21 @@ impl ProjectController { } else { UiNotice::info(format!("Saved {written} project file(s)")) }; - Ok(ProjectEditRun { - notices: UiNotices::new().with_notice(notice), - logs, - }) + let mut notices = UiNotices::new().with_notice(notice); + if written > 0 { + // save-as-pull: the library copy tracks every committed save + match self.pull_committed_changes_into_library(server).await { + Ok(Some(warning)) => notices = notices.with_notice(warning), + Ok(None) => {} + Err(e) => { + log::warn!("save-as-pull failed (will retry on next save): {e:?}"); + notices = notices.with_notice(UiNotice::warning( + "Saved to the simulator, but not yet to your library — will retry on the next save", + )); + } + } + } + Ok(ProjectEditRun { notices, logs }) } /// Discard every pending edit: the local edit buffer clears immediately @@ -1404,6 +1558,10 @@ fn format_bytes(bytes: u64) -> String { } } +fn library_ui_error(e: crate::app::library::LibraryError) -> UiError { + UiError::MissingSession(format!("library: {e}")) +} + #[cfg(test)] mod tests { use lpc_model::{ diff --git a/lp-app/lpa-studio-core/src/app/server/studio_server_client.rs b/lp-app/lpa-studio-core/src/app/server/studio_server_client.rs index b6e9b51f9..a18a4693e 100644 --- a/lp-app/lpa-studio-core/src/app/server/studio_server_client.rs +++ b/lp-app/lpa-studio-core/src/app/server/studio_server_client.rs @@ -95,6 +95,70 @@ impl StudioServerClient { core::mem::take(&mut *self.pending_logs.borrow_mut()) } + /// Open a library project on the runtime: whole-project replace → + /// hash verification → load → version probe (load-as-push, D19). + /// + /// `expected_hash` is the library copy's canonical hash; a mismatch is + /// a hard error (the runtime would be running something other than the + /// library head). + pub async fn open_library_project( + &mut self, + files: &[(String, Vec)], + expected_hash: &str, + ) -> Result { + let deploy = self + .client + .replace_and_load_project(DEMO_PROJECT_STORAGE_ID, files) + .await + .map_err(map_client_error)?; + let handle = deploy.value; + let mut logs = map_client_events(deploy.events); + + let hash = self + .client + .hash_package(DEMO_PROJECT_STORAGE_ID) + .await + .map_err(map_client_error)?; + logs.extend(map_client_events(hash.events)); + if hash.value != expected_hash { + return Err(map_client_error( + lpa_client::client_error::ClientError::Protocol(format!( + "pushed project hash mismatch: runtime {} vs library {expected_hash}", + hash.value + )), + )); + } + + // version probe: an empty changes page carries the runtime's + // current fs revision — the baseline for save-as-pull + let version_probe = self + .client + .pull_changed_files( + DEMO_PROJECT_STORAGE_ID, + lpc_model::FsVersion::new(i64::MAX - 1), + ) + .await + .map_err(map_client_error)?; + logs.extend(map_client_events(version_probe.events)); + let (_, synced_version) = version_probe.value; + + let inventory = self + .client + .project_inventory_read(handle) + .await + .map_err(map_client_error)?; + logs.extend(map_client_events(inventory.events)); + logs.extend(self.take_pending_logs()); + + Ok(LoadedLibraryProject { + handle_id: handle.id(), + inventory: ProjectInventorySummary::from(&inventory.value), + node_def_artifacts: node_def_artifacts(&inventory.value), + synced_version, + logs, + }) + } + /// Pull files changed under a project since `since` (save-as-pull / /// connect-as-pull; roadmap M2b). Returns updates + the next `since`. pub async fn pull_changed_files( @@ -149,6 +213,19 @@ impl StudioServerClient { } } +/// Result of opening a library project on the runtime. +pub struct LoadedLibraryProject { + pub handle_id: u32, + pub inventory: ProjectInventorySummary, + /// Runtime node id → containing def artifact (see + /// [`LoadedDemoProject::node_def_artifacts`]). + pub node_def_artifacts: BTreeMap, + /// The runtime fs revision right after the push — save-as-pull's first + /// `since`. + pub synced_version: lpc_model::FsVersion, + pub logs: Vec, +} + /// Result of a changed-files pull: reassembled updates, the fs version to /// use as the next pull's `since`, and protocol logs. pub struct PulledFiles { diff --git a/lp-app/lpa-studio-core/src/app/studio/studio_actor.rs b/lp-app/lpa-studio-core/src/app/studio/studio_actor.rs index e798aee38..085237404 100644 --- a/lp-app/lpa-studio-core/src/app/studio/studio_actor.rs +++ b/lp-app/lpa-studio-core/src/app/studio/studio_actor.rs @@ -175,6 +175,9 @@ where // final snapshot reflects the reshaped console. Actions run next // (preemption-as-priority); the coalesced tick runs after. Shutdown // only ends the loop after the batch is processed. + if let Some(attachment) = plan.attach_library { + self.controller.attach_library(attachment.0); + } for command in plan.console { self.controller.apply_console_command(command); } @@ -369,6 +372,9 @@ struct CommandPlan { actions: Vec, tick: bool, shutdown: bool, + /// A library attachment to install before actions (at most one; the + /// shell sends it once, ahead of any project action). + attach_library: Option, } impl CommandPlan { @@ -377,8 +383,10 @@ impl CommandPlan { let mut actions = Vec::new(); let mut tick = false; let mut shutdown = false; + let mut attach_library = None; for command in batch { match command { + StudioCommand::AttachLibrary(attachment) => attach_library = Some(attachment), StudioCommand::Action(action) => push_action_coalesced(&mut actions, action), // Not a local console mutation: a device-level change is a // server round-trip, so convert it into the equivalent device @@ -406,6 +414,7 @@ impl CommandPlan { actions, tick, shutdown, + attach_library, } } } @@ -488,7 +497,11 @@ fn command_preempts_passive(command: &StudioCommand) -> bool { // A queued console command, tick, or shutdown does not preempt an // in-flight pull: console mutations are display-side and can wait for // the batch after the pull completes. - StudioCommand::Console(_) | StudioCommand::RefreshTick | StudioCommand::Shutdown => false, + // An attachment is synchronous installation work, same as console. + StudioCommand::AttachLibrary(_) + | StudioCommand::Console(_) + | StudioCommand::RefreshTick + | StudioCommand::Shutdown => false, } } diff --git a/lp-app/lpa-studio-core/src/app/studio/studio_command.rs b/lp-app/lpa-studio-core/src/app/studio/studio_command.rs index 6f9f88568..079c71c23 100644 --- a/lp-app/lpa-studio-core/src/app/studio/studio_command.rs +++ b/lp-app/lpa-studio-core/src/app/studio/studio_command.rs @@ -8,11 +8,27 @@ //! actions ahead of ticks and coalesces redundant ticks (see the actor loop). use crate::UiAction; +use crate::app::library::LibraryStore; use crate::app::studio::console_command::ConsoleCommand; +/// A mounted library store riding the command queue (Debug-opaque: the +/// store holds an fs handle and an rng closure). +#[derive(Clone)] +pub struct LibraryAttachment(pub LibraryStore); + +impl core::fmt::Debug for LibraryAttachment { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("LibraryAttachment(..)") + } +} + /// A single input to the studio actor's command queue. #[derive(Clone, Debug)] pub enum StudioCommand { + /// Attach the mounted local library (sent by the platform shell once + /// the store is ready, before any project action). Applied + /// synchronously by the actor ahead of the batch's actions. + AttachLibrary(LibraryAttachment), /// A user-invoked action. Dispatched through the controller; its /// [`ActionClass`](crate::ActionClass) decides whether it preempts an /// in-flight passive pull. diff --git a/lp-app/lpa-studio-core/src/app/studio/studio_controller.rs b/lp-app/lpa-studio-core/src/app/studio/studio_controller.rs index ad4519f65..c46fd376e 100644 --- a/lp-app/lpa-studio-core/src/app/studio/studio_controller.rs +++ b/lp-app/lpa-studio-core/src/app/studio/studio_controller.rs @@ -209,6 +209,13 @@ impl StudioController { /// Apply a console command (from [`StudioCommand::Console`]): mutate the /// display filter or clear the ring, and mark the view dirty so the next /// gate emits the reshaped console. + /// Install the mounted library into the project flows (load-as-push / + /// save-as-pull — roadmap M3). Shares the controller's stamping clock. + pub fn attach_library(&mut self, store: crate::app::library::LibraryStore) { + let clock = std::rc::Rc::clone(&self.now_secs); + self.project.set_library(store, clock); + } + pub fn apply_console_command(&mut self, command: ConsoleCommand) { match command { ConsoleCommand::SetMinLevel(level) => self.log_filter.min_level = level, diff --git a/lp-app/lpa-studio-web/Cargo.toml b/lp-app/lpa-studio-web/Cargo.toml index be15723e7..60d6bee92 100644 --- a/lp-app/lpa-studio-web/Cargo.toml +++ b/lp-app/lpa-studio-web/Cargo.toml @@ -24,6 +24,7 @@ lpa-studio-web-story-macros = { path = "../lpa-studio-web-story-macros", optiona serde = { version = "1", features = ["derive"] } wasm-bindgen = "0.2" web-sys = { version = "0.3", features = [ + "Crypto", "Event", "EventTarget", "History", @@ -36,6 +37,7 @@ web-sys = { version = "0.3", features = [ [target.'cfg(target_arch = "wasm32")'.dependencies] lpa-fs-opfs = { path = "../lpa-fs-opfs" } +lpfs = { path = "../../lp-base/lpfs" } wasm-bindgen-futures = "0.4" [build-dependencies] diff --git a/lp-app/lpa-studio-web/src/local_store.rs b/lp-app/lpa-studio-web/src/local_store.rs index ea9521bd0..43c53b948 100644 --- a/lp-app/lpa-studio-web/src/local_store.rs +++ b/lp-app/lpa-studio-web/src/local_store.rs @@ -62,6 +62,33 @@ mod wasm { LOCAL_STORE.with(|s| s.borrow().clone()) } + /// The library layer over the mounted store, with browser randomness + /// (crypto.getRandomValues) injected for uid minting. + pub fn library_store() -> Option { + let store = local_store()?; + let fs: std::rc::Rc> = + std::rc::Rc::new(std::cell::RefCell::new(store)); + Some(lpa_studio_core::app::library::LibraryStore::new( + fs, + std::rc::Rc::new(random_bytes), + )) + } + + fn random_bytes() -> [u8; 16] { + let mut bytes = [0u8; 16]; + let filled = web_sys::window() + .and_then(|w| w.crypto().ok()) + .and_then(|c| c.get_random_values_with_u8_array(&mut bytes).ok()) + .is_some(); + if !filled { + // last-resort fallback; uids only need uniqueness, not secrecy + for b in bytes.iter_mut() { + *b = (js_sys::Math::random() * 256.0) as u8; + } + } + bytes + } + /// Request best-effort storage durability; fire-and-forget, logged. pub fn request_persist() { let Some(window) = web_sys::window() else { @@ -118,10 +145,8 @@ mod wasm { } } -// `wasm::local_store()` (the mounted-store accessor) is deliberately not -// re-exported yet: its consumer is the places layer (roadmap M3). #[cfg(target_arch = "wasm32")] -pub use wasm::{init_local_store, request_persist}; +pub use wasm::{init_local_store, library_store, request_persist}; /// Host builds run unit tests only and never mount a store. #[cfg(not(target_arch = "wasm32"))] diff --git a/lp-app/lpa-studio-web/src/web_app.rs b/lp-app/lpa-studio-web/src/web_app.rs index fe87b1125..40db6c9ba 100644 --- a/lp-app/lpa-studio-web/src/web_app.rs +++ b/lp-app/lpa-studio-web/src/web_app.rs @@ -76,33 +76,39 @@ pub fn App() -> Element { } }); - // Mount the local project store: request durability, take the library - // lock, load OPFS into memory, start the flusher. The simulator never - // sees this store (D19/D20 — the sim is an ephemeral place). Spawned - // from use_hook so it runs exactly once per app instance (a use_future - // here restarts with the render loop and would mount repeatedly). + // The local project store: mounted in the startup hook below (which + // also attaches the library and only then fires the connect action). let mut store_status = use_signal(|| LocalStoreStatus::Initializing); - use_hook(move || { - local_store::request_persist(); - spawn(async move { - store_status.set(local_store::init_local_store().await); - }); - }); let on_store_retry = move |_| { spawn(async move { store_status.set(local_store::init_local_store().await); }); }; + // Startup ordering matters: the library must attach before the connect + // action runs, or the demo would load through the legacy (storeless) + // path on first paint. The store mount is awaited here; the sim still + // starts (without persistence) if the store is unavailable. let startup_intent = use_hook(studio_url::read_connection_intent); let startup_bridge = bridge.clone(); - let _startup_task = use_future(move || { + use_hook(move || { let startup_bridge = startup_bridge.clone(); - async move { + spawn(async move { + local_store::request_persist(); + let status = local_store::init_local_store().await; + #[cfg(target_arch = "wasm32")] + if status == LocalStoreStatus::Ready { + if let Some(library) = local_store::library_store() { + startup_bridge.tx.send(StudioCommand::AttachLibrary( + lpa_studio_core::app::studio::studio_command::LibraryAttachment(library), + )); + } + } + store_status.set(status); if let Some(action) = startup_intent.and_then(|intent| intent.startup_action()) { startup_bridge.tx.send(StudioCommand::Action(action)); } - } + }); }); let refresh_bridge = bridge.clone(); diff --git a/lp-cli/src/commands/create/project.rs b/lp-cli/src/commands/create/project.rs index f2c33231c..97e72370b 100644 --- a/lp-cli/src/commands/create/project.rs +++ b/lp-cli/src/commands/create/project.rs @@ -207,6 +207,7 @@ fn write_project_json(fs: &dyn LpFs, name: &str) -> Result<()> { } let project = ProjectDef { format: ProjectDef::current_format_slot(), + uid: OptionSlot::none(), name: OptionSlot::some(ValueSlot::new(String::from(name))), nodes: MapSlot::new(nodes), }; diff --git a/lp-core/lpc-model/src/nodes/project/project_def.rs b/lp-core/lpc-model/src/nodes/project/project_def.rs index e8012040d..ea1e4134a 100644 --- a/lp-core/lpc-model/src/nodes/project/project_def.rs +++ b/lp-core/lpc-model/src/nodes/project/project_def.rs @@ -20,6 +20,10 @@ pub const PROJECT_FORMAT_VERSION: u32 = 1; pub struct ProjectDef { /// Authored format version; see [`PROJECT_FORMAT_VERSION`]. pub format: OptionSlot>, + /// Stable project identity (`prj_…`, base-62), minted by the library + /// when a project enters it. Travels with the files: parity checks, + /// history, and device associations key off it (PM roadmap M1/M3). + pub uid: OptionSlot>, pub name: OptionSlot>, /// Named child node positions owned by this project. pub nodes: MapSlot, diff --git a/lp-core/lpc-shared/src/project/builder.rs b/lp-core/lpc-shared/src/project/builder.rs index 8999b8779..a8e40f842 100644 --- a/lp-core/lpc-shared/src/project/builder.rs +++ b/lp-core/lpc-shared/src/project/builder.rs @@ -208,6 +208,7 @@ impl ProjectBuilder { } let project = ProjectDef { format: ProjectDef::current_format_slot(), + uid: OptionSlot::none(), name: OptionSlot::some(ValueSlot::new(self.name.clone())), nodes: MapSlot::new(nodes), }; diff --git a/lp-fw/fw-browser/src/tests.rs b/lp-fw/fw-browser/src/tests.rs index 5fb5259c2..3a34f3a1c 100644 --- a/lp-fw/fw-browser/src/tests.rs +++ b/lp-fw/fw-browser/src/tests.rs @@ -435,6 +435,79 @@ fn file_sync_round_trips_over_the_protocol() { ); } +#[wasm_bindgen_test] +fn load_project_tolerates_library_artifacts() { + // M3 pushes add a uid field to project.json and a /.lp/meta.json + // sidecar; loading must tolerate both (A/B to isolate failures). + fw_browser_init_exports(wasm_bindgen::exports()); + + let case = |label: &str, add_uid: bool, add_sidecar: bool| { + let runtime_id = create_runtime(label).expect("create runtime"); + let mut next_id = 1u64; + let project_fs = build_smoke_project(); + for (path, mut content) in collect_project_files(&project_fs.borrow()) { + if add_uid && path == "project.json" { + // canonical insertion: kind stays first (streaming codec) + let text = String::from_utf8(content).unwrap(); + let patched = text.replacen( + "\"kind\": \"Project\",", + "\"kind\": \"Project\",\n \"uid\": \"prj_0000000000000042\",", + 1, + ); + assert_ne!(patched, text, "kind anchor not found in manifest"); + content = patched.into_bytes(); + } + let full_path = format!("/projects/{label}/{path}").as_path_buf(); + let responses = send_protocol_request( + runtime_id, + next_request_id(&mut next_id), + ClientRequest::Filesystem(FsRequest::Write { + path: full_path, + data: content, + }), + 1, + ); + assert!(!responses.is_empty(), "{label}: write got no response"); + } + if add_sidecar { + let responses = send_protocol_request( + runtime_id, + next_request_id(&mut next_id), + ClientRequest::Filesystem(FsRequest::Write { + path: format!("/projects/{label}/.lp/meta.json").as_path_buf(), + data: br#"{"provenance":{"seededFrom":{"source":"examples/basic"}},"createdAt":1.0}"#.to_vec(), + }), + 1, + ); + assert!( + !responses.is_empty(), + "{label}: sidecar write got no response" + ); + } + let responses = send_protocol_request( + runtime_id, + next_request_id(&mut next_id), + ClientRequest::LoadProject { + path: label.to_string(), + }, + 1, + ); + assert!( + !responses.is_empty(), + "{label}: LoadProject got NO response (worker would hang)" + ); + match &responses[0].msg { + WireServerMsgBody::LoadProject { .. } => {} + other => panic!("{label}: LoadProject failed: {other:?}"), + } + }; + + case("lib-plain", false, false); + case("lib-uid", true, false); + case("lib-sidecar", false, true); + case("lib-both", true, true); +} + fn next_request_id(next_id: &mut u64) -> u64 { let id = *next_id; *next_id += 1; diff --git a/schemas/node.schema.json b/schemas/node.schema.json index c883db146..894b46738 100644 --- a/schemas/node.schema.json +++ b/schemas/node.schema.json @@ -347,6 +347,9 @@ ] }, "type": "object" + }, + "uid": { + "type": "string" } }, "required": [ diff --git a/schemas/project.schema.json b/schemas/project.schema.json index a4e9039c5..ed289f6d1 100644 --- a/schemas/project.schema.json +++ b/schemas/project.schema.json @@ -43,6 +43,9 @@ ] }, "type": "object" + }, + "uid": { + "type": "string" } }, "required": [ diff --git a/schemas/shapes/lpc_model.nodes.project.project_def.ProjectDef.json b/schemas/shapes/lpc_model.nodes.project.project_def.ProjectDef.json index 4e0fb5d20..d06bfd87e 100644 --- a/schemas/shapes/lpc_model.nodes.project.project_def.ProjectDef.json +++ b/schemas/shapes/lpc_model.nodes.project.project_def.ProjectDef.json @@ -19,6 +19,24 @@ } } }, + { + "name": "uid", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "editor": "plain", + "id": 2612013983, + "meta": {}, + "ty": "string" + } + } + } + } + } + }, { "name": "name", "shape": {