diff --git a/.gitignore b/.gitignore index 61bdf0b2..a660919d 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ # Macos file system junk ._* .DS_STORE + +# JetBrains IDEs +/.idea/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 45b241c8..66a958cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,8 +32,18 @@ Making a new release? Simply add the new header with the version and date undern ## Unreleased * `inf` and `nan` values in properties are now synced ([#1176]) +* Fixed a bug caused by having reference properties (such as `ObjectValue.Value`) that point to an Instance not included in syncback. ([#1179]) +* Fixed instance replacement fallback failing when too many instances needed to be replaced. ([#1192]) +* Fixed a bug where MacOS paths weren't being handled correctly. ([#1201]) +* Fixed a bug where the notification timeout thread would fail to cancel on unmount ([#1211]) +* Added a "Forget" option to the sync reminder notification to avoid being reminded for that place in the future ([#1215]) [#1176]: https://github.com/rojo-rbx/rojo/pull/1176 +[#1179]: https://github.com/rojo-rbx/rojo/pull/1179 +[#1192]: https://github.com/rojo-rbx/rojo/pull/1192 +[#1201]: https://github.com/rojo-rbx/rojo/pull/1201 +[#1211]: https://github.com/rojo-rbx/rojo/pull/1211 +[#1215]: https://github.com/rojo-rbx/rojo/pull/1215 ## [7.7.0-rc.1] (November 27th, 2025) diff --git a/Cargo.lock b/Cargo.lock index 49351dd3..4451b3bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1319,6 +1319,7 @@ dependencies = [ "fs-err", "notify", "serde", + "tempfile", ] [[package]] diff --git a/crates/memofs/CHANGELOG.md b/crates/memofs/CHANGELOG.md index b83bfeaf..c35b5399 100644 --- a/crates/memofs/CHANGELOG.md +++ b/crates/memofs/CHANGELOG.md @@ -1,6 +1,7 @@ # memofs Changelog ## Unreleased Changes +* Added `Vfs::canonicalize`. [#1201] ## 0.3.1 (2025-11-27) * Added `Vfs::exists`. [#1169] diff --git a/crates/memofs/Cargo.toml b/crates/memofs/Cargo.toml index ad0b2637..d1d8cc5e 100644 --- a/crates/memofs/Cargo.toml +++ b/crates/memofs/Cargo.toml @@ -19,3 +19,6 @@ crossbeam-channel = "0.5.12" fs-err = "2.11.0" notify = "4.0.17" serde = { version = "1.0.197", features = ["derive"] } + +[dev-dependencies] +tempfile = "3.10.1" diff --git a/crates/memofs/src/in_memory_fs.rs b/crates/memofs/src/in_memory_fs.rs index 3c825575..41006f1c 100644 --- a/crates/memofs/src/in_memory_fs.rs +++ b/crates/memofs/src/in_memory_fs.rs @@ -232,6 +232,33 @@ impl VfsBackend for InMemoryFs { } } + // TODO: We rely on Rojo to prepend cwd to any relative path before storing paths + // in MemoFS. The current implementation will error if no prepended absolute path + // is found. It really only normalizes paths within the provided path's context. + // Example: "/Users/username/project/../other/file.txt" -> + // "/Users/username/other/file.txt" + // Erroneous example: "/Users/../../other/file.txt" -> "/other/file.txt" + // This is not very robust. We should implement proper path normalization here or otherwise + // warn if we are missing context and can not fully canonicalize the path correctly. + fn canonicalize(&mut self, path: &Path) -> io::Result { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + std::path::Component::ParentDir => { + normalized.pop(); + } + std::path::Component::CurDir => {} + _ => normalized.push(component), + } + } + + let inner = self.inner.lock().unwrap(); + match inner.entries.get(&normalized) { + Some(_) => Ok(normalized), + None => not_found(&normalized), + } + } + fn event_receiver(&self) -> crossbeam_channel::Receiver { let inner = self.inner.lock().unwrap(); diff --git a/crates/memofs/src/lib.rs b/crates/memofs/src/lib.rs index bc7e329e..41d21100 100644 --- a/crates/memofs/src/lib.rs +++ b/crates/memofs/src/lib.rs @@ -77,6 +77,7 @@ pub trait VfsBackend: sealed::Sealed + Send + 'static { fn metadata(&mut self, path: &Path) -> io::Result; fn remove_file(&mut self, path: &Path) -> io::Result<()>; fn remove_dir_all(&mut self, path: &Path) -> io::Result<()>; + fn canonicalize(&mut self, path: &Path) -> io::Result; fn event_receiver(&self) -> crossbeam_channel::Receiver; fn watch(&mut self, path: &Path) -> io::Result<()>; @@ -225,6 +226,11 @@ impl VfsInner { self.backend.metadata(path) } + fn canonicalize>(&mut self, path: P) -> io::Result { + let path = path.as_ref(); + self.backend.canonicalize(path) + } + fn event_receiver(&self) -> crossbeam_channel::Receiver { self.backend.event_receiver() } @@ -413,6 +419,19 @@ impl Vfs { self.inner.lock().unwrap().metadata(path) } + /// Normalize a path via the underlying backend. + /// + /// Roughly equivalent to [`std::fs::canonicalize`][std::fs::canonicalize]. Relative paths are + /// resolved against the backend's current working directory (if applicable) and errors are + /// surfaced directly from the backend. + /// + /// [std::fs::canonicalize]: https://doc.rust-lang.org/stable/std/fs/fn.canonicalize.html + #[inline] + pub fn canonicalize>(&self, path: P) -> io::Result { + let path = path.as_ref(); + self.inner.lock().unwrap().canonicalize(path) + } + /// Retrieve a handle to the event receiver for this `Vfs`. #[inline] pub fn event_receiver(&self) -> crossbeam_channel::Receiver { @@ -540,6 +559,13 @@ impl VfsLock<'_> { self.inner.metadata(path) } + /// Normalize a path via the underlying backend. + #[inline] + pub fn normalize>(&mut self, path: P) -> io::Result { + let path = path.as_ref(); + self.inner.canonicalize(path) + } + /// Retrieve a handle to the event receiver for this `Vfs`. #[inline] pub fn event_receiver(&self) -> crossbeam_channel::Receiver { @@ -555,7 +581,9 @@ impl VfsLock<'_> { #[cfg(test)] mod test { - use crate::{InMemoryFs, Vfs, VfsSnapshot}; + use crate::{InMemoryFs, StdBackend, Vfs, VfsSnapshot}; + use std::io; + use std::path::PathBuf; /// https://github.com/rojo-rbx/rojo/issues/899 #[test] @@ -571,4 +599,62 @@ mod test { "bar\nfoo\n\n" ); } + + /// https://github.com/rojo-rbx/rojo/issues/1200 + #[test] + fn canonicalize_in_memory_success() { + let mut imfs = InMemoryFs::new(); + let contents = "Lorem ipsum dolor sit amet.".to_string(); + + imfs.load_snapshot("/test/file.txt", VfsSnapshot::file(contents.to_string())) + .unwrap(); + + let vfs = Vfs::new(imfs); + + assert_eq!( + vfs.canonicalize("/test/nested/../file.txt").unwrap(), + PathBuf::from("/test/file.txt") + ); + assert_eq!( + vfs.read_to_string(vfs.canonicalize("/test/nested/../file.txt").unwrap()) + .unwrap() + .to_string(), + contents.to_string() + ); + } + + #[test] + fn canonicalize_in_memory_missing_errors() { + let imfs = InMemoryFs::new(); + let vfs = Vfs::new(imfs); + + let err = vfs.canonicalize("test").unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::NotFound); + } + + #[test] + fn canonicalize_std_backend_success() { + let contents = "Lorem ipsum dolor sit amet.".to_string(); + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("file.txt"); + fs_err::write(&file_path, contents.to_string()).unwrap(); + + let vfs = Vfs::new(StdBackend::new()); + let canonicalized = vfs.canonicalize(&file_path).unwrap(); + assert_eq!(canonicalized, file_path.canonicalize().unwrap()); + assert_eq!( + vfs.read_to_string(&canonicalized).unwrap().to_string(), + contents.to_string() + ); + } + + #[test] + fn canonicalize_std_backend_missing_errors() { + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("test"); + + let vfs = Vfs::new(StdBackend::new()); + let err = vfs.canonicalize(&file_path).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::NotFound); + } } diff --git a/crates/memofs/src/noop_backend.rs b/crates/memofs/src/noop_backend.rs index 98b86d1a..b1ee301d 100644 --- a/crates/memofs/src/noop_backend.rs +++ b/crates/memofs/src/noop_backend.rs @@ -1,5 +1,5 @@ use std::io; -use std::path::Path; +use std::path::{Path, PathBuf}; use crate::{Metadata, ReadDir, VfsBackend, VfsEvent}; @@ -50,6 +50,10 @@ impl VfsBackend for NoopBackend { Err(io::Error::other("NoopBackend doesn't do anything")) } + fn canonicalize(&mut self, _path: &Path) -> io::Result { + Err(io::Error::other("NoopBackend doesn't do anything")) + } + fn event_receiver(&self) -> crossbeam_channel::Receiver { crossbeam_channel::never() } diff --git a/crates/memofs/src/std_backend.rs b/crates/memofs/src/std_backend.rs index f354d806..a25fa39d 100644 --- a/crates/memofs/src/std_backend.rs +++ b/crates/memofs/src/std_backend.rs @@ -106,6 +106,10 @@ impl VfsBackend for StdBackend { }) } + fn canonicalize(&mut self, path: &Path) -> io::Result { + fs_err::canonicalize(path) + } + fn event_receiver(&self) -> crossbeam_channel::Receiver { self.watcher_receiver.clone() } diff --git a/plugin/src/ApiContext.lua b/plugin/src/ApiContext.lua index fbaa0f3f..2ce6ea14 100644 --- a/plugin/src/ApiContext.lua +++ b/plugin/src/ApiContext.lua @@ -293,31 +293,39 @@ function ApiContext:open(id) end function ApiContext:serialize(ids: { string }) - local url = ("%s/api/serialize/%s"):format(self.__baseUrl, table.concat(ids, ",")) + local url = ("%s/api/serialize"):format(self.__baseUrl) + local request_body = Http.msgpackEncode({ sessionId = self.__sessionId, ids = ids }) - return Http.get(url):andThen(rejectFailedRequests):andThen(Http.Response.msgpack):andThen(function(body) - if body.sessionId ~= self.__sessionId then - return Promise.reject("Server changed ID") - end + return Http.post(url, request_body) + :andThen(rejectFailedRequests) + :andThen(Http.Response.msgpack) + :andThen(function(response_body) + if response_body.sessionId ~= self.__sessionId then + return Promise.reject("Server changed ID") + end - assert(validateApiSerialize(body)) + assert(validateApiSerialize(response_body)) - return body - end) + return response_body + end) end function ApiContext:refPatch(ids: { string }) - local url = ("%s/api/ref-patch/%s"):format(self.__baseUrl, table.concat(ids, ",")) + local url = ("%s/api/ref-patch"):format(self.__baseUrl) + local request_body = Http.msgpackEncode({ sessionId = self.__sessionId, ids = ids }) - return Http.get(url):andThen(rejectFailedRequests):andThen(Http.Response.msgpack):andThen(function(body) - if body.sessionId ~= self.__sessionId then - return Promise.reject("Server changed ID") - end + return Http.post(url, request_body) + :andThen(rejectFailedRequests) + :andThen(Http.Response.msgpack) + :andThen(function(response_body) + if response_body.sessionId ~= self.__sessionId then + return Promise.reject("Server changed ID") + end - assert(validateApiRefPatch(body)) + assert(validateApiRefPatch(response_body)) - return body - end) + return response_body + end) end return ApiContext diff --git a/plugin/src/App/Components/Notifications/FullscreenNotification.lua b/plugin/src/App/Components/Notifications/FullscreenNotification.lua index 215398b2..47d0ae6f 100644 --- a/plugin/src/App/Components/Notifications/FullscreenNotification.lua +++ b/plugin/src/App/Components/Notifications/FullscreenNotification.lua @@ -19,9 +19,15 @@ local FullscreenNotification = Roact.Component:extend("FullscreeFullscreenNotifi function FullscreenNotification:init() self.transparency, self.setTransparency = Roact.createBinding(0) self.lifetime = self.props.timeout + self.dismissed = false end function FullscreenNotification:dismiss() + if self.dismissed then + return + end + self.dismissed = true + if self.props.onClose then self.props.onClose() end @@ -59,7 +65,7 @@ function FullscreenNotification:didMount() end function FullscreenNotification:willUnmount() - if self.timeout and coroutine.status(self.timeout) ~= "dead" then + if self.timeout and coroutine.status(self.timeout) == "suspended" then task.cancel(self.timeout) end end diff --git a/plugin/src/App/Components/Notifications/Notification.lua b/plugin/src/App/Components/Notifications/Notification.lua index 47fb0d08..1d9464c9 100644 --- a/plugin/src/App/Components/Notifications/Notification.lua +++ b/plugin/src/App/Components/Notifications/Notification.lua @@ -25,6 +25,7 @@ function Notification:init() self.binding = bindingUtil.fromMotor(self.motor) self.lifetime = self.props.timeout + self.dismissed = false self.motor:onStep(function(value) if value <= 0 and self.props.onClose then @@ -34,6 +35,11 @@ function Notification:init() end function Notification:dismiss() + if self.dismissed then + return + end + self.dismissed = true + self.motor:setGoal(Flipper.Spring.new(0, { frequency = 5, dampingRatio = 1, @@ -75,7 +81,7 @@ function Notification:didMount() end function Notification:willUnmount() - if self.timeout and coroutine.status(self.timeout) ~= "dead" then + if self.timeout and coroutine.status(self.timeout) == "suspended" then task.cancel(self.timeout) end end diff --git a/plugin/src/App/init.lua b/plugin/src/App/init.lua index c83d9d1c..e6e4deee 100644 --- a/plugin/src/App/init.lua +++ b/plugin/src/App/init.lua @@ -301,6 +301,19 @@ function App:setPriorSyncInfo(host: string, port: string, projectName: string) Settings:set("priorEndpoints", priorSyncInfos) end +function App:forgetPriorSyncInfo() + local priorSyncInfos = Settings:get("priorEndpoints") + if not priorSyncInfos then + priorSyncInfos = {} + end + + local id = tostring(game.PlaceId) + priorSyncInfos[id] = nil + Log.trace("Erased last used endpoint for {}", game.PlaceId) + + Settings:set("priorEndpoints", priorSyncInfos) +end + function App:getHostAndPort() local host = self.host:getValue() local port = self.port:getValue() @@ -435,7 +448,8 @@ function App:checkSyncReminder() self:findActiveServer() :andThen(function(serverInfo, host, port) self:sendSyncReminder( - `Project '{serverInfo.projectName}' is serving at {host}:{port}.\nWould you like to connect?` + `Project '{serverInfo.projectName}' is serving at {host}:{port}.\nWould you like to connect?`, + { "Connect", "Dismiss" } ) end) :catch(function() @@ -446,7 +460,8 @@ function App:checkSyncReminder() local timeSinceSync = timeUtil.elapsedToText(os.time() - priorSyncInfo.timestamp) self:sendSyncReminder( - `You synced project '{priorSyncInfo.projectName}' to this place {timeSinceSync}.\nDid you mean to run 'rojo serve' and then connect?` + `You synced project '{priorSyncInfo.projectName}' to this place {timeSinceSync}.\nDid you mean to run 'rojo serve' and then connect?`, + { "Connect", "Forget", "Dismiss" } ) end end) @@ -486,12 +501,16 @@ function App:stopSyncReminderPolling() end end -function App:sendSyncReminder(message: string) +function App:sendSyncReminder(message: string, shownActions: { string }) local syncReminderMode = Settings:get("syncReminderMode") if syncReminderMode == "None" then return end + local connectIndex = table.find(shownActions, "Connect") + local forgetIndex = table.find(shownActions, "Forget") + local dismissIndex = table.find(shownActions, "Dismiss") + self.dismissSyncReminder = self:addNotification({ text = message, timeout = 120, @@ -500,24 +519,39 @@ function App:sendSyncReminder(message: string) self.dismissSyncReminder = nil end, actions = { - Connect = { - text = "Connect", - style = "Solid", - layoutOrder = 1, - onClick = function() - self:startSession() - end, - }, - Dismiss = { - text = "Dismiss", - style = "Bordered", - layoutOrder = 2, - onClick = function() - -- If the user dismisses the reminder, - -- then we don't need to remind them again - self:stopSyncReminderPolling() - end, - }, + Connect = if connectIndex + then { + text = "Connect", + style = "Solid", + layoutOrder = connectIndex, + onClick = function() + self:startSession() + end, + } + else nil, + Forget = if forgetIndex + then { + text = "Forget", + style = "Bordered", + layoutOrder = forgetIndex, + onClick = function() + -- The user doesn't want to be reminded again about this sync + self:forgetPriorSyncInfo() + end, + } + else nil, + Dismiss = if dismissIndex + then { + text = "Dismiss", + style = "Bordered", + layoutOrder = dismissIndex, + onClick = function() + -- If the user dismisses the reminder, + -- then we don't need to remind them again + self:stopSyncReminderPolling() + end, + } + else nil, }, }) end diff --git a/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__ref_properties_pruned-stdout.snap b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__ref_properties_pruned-stdout.snap new file mode 100644 index 00000000..3a9c2502 --- /dev/null +++ b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__ref_properties_pruned-stdout.snap @@ -0,0 +1,7 @@ +--- +source: tests/rojo_test/syncback_util.rs +expression: "String::from_utf8_lossy(&output.stdout)" +--- +Writing src/Pointer1.model.json +Writing src/Pointer2.model.json +Writing src/Pointer3.model.json diff --git a/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__ref_properties_pruned-src__Pointer1.model.json.snap b/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__ref_properties_pruned-src__Pointer1.model.json.snap new file mode 100644 index 00000000..aeee8256 --- /dev/null +++ b/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__ref_properties_pruned-src__Pointer1.model.json.snap @@ -0,0 +1,7 @@ +--- +source: tests/tests/syncback.rs +expression: src/Pointer1.model.json +--- +{ + "className": "ObjectValue" +} diff --git a/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__ref_properties_pruned-src__Pointer2.model.json.snap b/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__ref_properties_pruned-src__Pointer2.model.json.snap new file mode 100644 index 00000000..87da5680 --- /dev/null +++ b/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__ref_properties_pruned-src__Pointer2.model.json.snap @@ -0,0 +1,7 @@ +--- +source: tests/tests/syncback.rs +expression: src/Pointer2.model.json +--- +{ + "className": "ObjectValue" +} diff --git a/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__ref_properties_pruned-src__Pointer3.model.json.snap b/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__ref_properties_pruned-src__Pointer3.model.json.snap new file mode 100644 index 00000000..9c5e3b94 --- /dev/null +++ b/rojo-test/syncback-test-snapshots/end_to_end__tests__syncback__ref_properties_pruned-src__Pointer3.model.json.snap @@ -0,0 +1,7 @@ +--- +source: tests/tests/syncback.rs +expression: src/Pointer3.model.json +--- +{ + "className": "ObjectValue" +} diff --git a/rojo-test/syncback-tests/ref_properties_pruned/input-project/default.project.json b/rojo-test/syncback-tests/ref_properties_pruned/input-project/default.project.json new file mode 100644 index 00000000..9656479a --- /dev/null +++ b/rojo-test/syncback-tests/ref_properties_pruned/input-project/default.project.json @@ -0,0 +1,9 @@ +{ + "name": "ref_properties_pruned", + "tree": { + "$className": "DataModel", + "ServerScriptService": { + "$path": "src" + } + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/ref_properties_pruned/input-project/src/.gitkeep b/rojo-test/syncback-tests/ref_properties_pruned/input-project/src/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/rojo-test/syncback-tests/ref_properties_pruned/input.rbxl b/rojo-test/syncback-tests/ref_properties_pruned/input.rbxl new file mode 100644 index 00000000..1513ff11 Binary files /dev/null and b/rojo-test/syncback-tests/ref_properties_pruned/input.rbxl differ diff --git a/src/change_processor.rs b/src/change_processor.rs index 78d45acc..6c5c8224 100644 --- a/src/change_processor.rs +++ b/src/change_processor.rs @@ -1,12 +1,12 @@ -use std::{ - fs, - sync::{Arc, Mutex}, -}; - use crossbeam_channel::{select, Receiver, RecvError, Sender}; use jod_thread::JoinHandle; use memofs::{IoResultExt, Vfs, VfsEvent}; use rbx_dom_weak::types::{Ref, Variant}; +use std::path::PathBuf; +use std::{ + fs, + sync::{Arc, Mutex}, +}; use crate::{ message_queue::MessageQueue, @@ -114,6 +114,49 @@ struct JobThreadContext { } impl JobThreadContext { + /// Computes and applies patches to the DOM for a given file path. + /// + /// This function finds the nearest ancestor to the given path that has associated instances + /// in the tree. + /// It then computes and applies changes for each affected instance ID and + /// returns a vector of applied patch sets. + fn apply_patches(&self, path: PathBuf) -> Vec { + let mut tree = self.tree.lock().unwrap(); + let mut applied_patches = Vec::new(); + + // Find the nearest ancestor to this path that has + // associated instances in the tree. This helps make sure + // that we handle additions correctly, especially if we + // receive events for descendants of a large tree being + // created all at once. + let mut current_path = path.as_path(); + let affected_ids = loop { + let ids = tree.get_ids_at_path(current_path); + + log::trace!("Path {} affects IDs {:?}", current_path.display(), ids); + + if !ids.is_empty() { + break ids.to_vec(); + } + + log::trace!("Trying parent path..."); + match current_path.parent() { + Some(parent) => current_path = parent, + None => break Vec::new(), + } + }; + + for id in affected_ids { + if let Some(patch) = compute_and_apply_changes(&mut tree, &self.vfs, id) { + if !patch.is_empty() { + applied_patches.push(patch); + } + } + } + + applied_patches + } + fn handle_vfs_event(&self, event: VfsEvent) { log::trace!("Vfs event: {:?}", event); @@ -125,41 +168,16 @@ impl JobThreadContext { // For a given VFS event, we might have many changes to different parts // of the tree. Calculate and apply all of these changes. let applied_patches = match event { - VfsEvent::Create(path) | VfsEvent::Remove(path) | VfsEvent::Write(path) => { - let mut tree = self.tree.lock().unwrap(); - let mut applied_patches = Vec::new(); - - // Find the nearest ancestor to this path that has - // associated instances in the tree. This helps make sure - // that we handle additions correctly, especially if we - // receive events for descendants of a large tree being - // created all at once. - let mut current_path = path.as_path(); - let affected_ids = loop { - let ids = tree.get_ids_at_path(current_path); - - log::trace!("Path {} affects IDs {:?}", current_path.display(), ids); - - if !ids.is_empty() { - break ids.to_vec(); - } - - log::trace!("Trying parent path..."); - match current_path.parent() { - Some(parent) => current_path = parent, - None => break Vec::new(), - } - }; - - for id in affected_ids { - if let Some(patch) = compute_and_apply_changes(&mut tree, &self.vfs, id) { - if !patch.is_empty() { - applied_patches.push(patch); - } - } - } - - applied_patches + VfsEvent::Create(path) | VfsEvent::Write(path) => { + self.apply_patches(self.vfs.canonicalize(&path).unwrap()) + } + VfsEvent::Remove(path) => { + // MemoFS does not track parent removals yet, so we can canonicalize + // the parent path safely and then append the removed path's file name. + let parent = path.parent().unwrap(); + let file_name = path.file_name().unwrap(); + let parent_normalized = self.vfs.canonicalize(parent).unwrap(); + self.apply_patches(parent_normalized.join(file_name)) } _ => { log::warn!("Unhandled VFS event: {:?}", event); diff --git a/src/snapshot_middleware/csv.rs b/src/snapshot_middleware/csv.rs index 75a043d1..18fa1424 100644 --- a/src/snapshot_middleware/csv.rs +++ b/src/snapshot_middleware/csv.rs @@ -42,7 +42,7 @@ pub fn snapshot_csv( .metadata( InstanceMetadata::new() .instigating_source(path) - .relevant_paths(vec![path.to_path_buf()]), + .relevant_paths(vec![vfs.canonicalize(path)?]), ); AdjacentMetadata::read_and_apply_all(vfs, path, name, &mut snapshot)?; diff --git a/src/snapshot_middleware/dir.rs b/src/snapshot_middleware/dir.rs index b825542d..fb444c37 100644 --- a/src/snapshot_middleware/dir.rs +++ b/src/snapshot_middleware/dir.rs @@ -62,18 +62,19 @@ pub fn snapshot_dir_no_meta( } } + let normalized_path = vfs.canonicalize(path)?; let relevant_paths = vec![ - path.to_path_buf(), + normalized_path.clone(), // TODO: We shouldn't need to know about Lua existing in this // middleware. Should we figure out a way for that function to add // relevant paths to this middleware? - path.join("init.lua"), - path.join("init.luau"), - path.join("init.server.lua"), - path.join("init.server.luau"), - path.join("init.client.lua"), - path.join("init.client.luau"), - path.join("init.csv"), + normalized_path.join("init.lua"), + normalized_path.join("init.luau"), + normalized_path.join("init.server.lua"), + normalized_path.join("init.server.luau"), + normalized_path.join("init.client.lua"), + normalized_path.join("init.client.luau"), + normalized_path.join("init.csv"), ]; let snapshot = InstanceSnapshot::new() diff --git a/src/snapshot_middleware/json.rs b/src/snapshot_middleware/json.rs index 8cd029f5..1a9537ad 100644 --- a/src/snapshot_middleware/json.rs +++ b/src/snapshot_middleware/json.rs @@ -32,7 +32,7 @@ pub fn snapshot_json( .metadata( InstanceMetadata::new() .instigating_source(path) - .relevant_paths(vec![path.to_path_buf()]) + .relevant_paths(vec![vfs.canonicalize(path)?]) .context(context), ); diff --git a/src/snapshot_middleware/json_model.rs b/src/snapshot_middleware/json_model.rs index d19792cb..fdc2069e 100644 --- a/src/snapshot_middleware/json_model.rs +++ b/src/snapshot_middleware/json_model.rs @@ -59,7 +59,7 @@ pub fn snapshot_json_model( snapshot.metadata = snapshot .metadata .instigating_source(path) - .relevant_paths(vec![path.to_path_buf()]) + .relevant_paths(vec![vfs.canonicalize(path)?]) .context(context) .specified_id(id) .schema(schema); diff --git a/src/snapshot_middleware/lua.rs b/src/snapshot_middleware/lua.rs index eaa90952..a0058555 100644 --- a/src/snapshot_middleware/lua.rs +++ b/src/snapshot_middleware/lua.rs @@ -88,7 +88,7 @@ pub fn snapshot_lua( .metadata( InstanceMetadata::new() .instigating_source(path) - .relevant_paths(vec![path.to_path_buf()]) + .relevant_paths(vec![vfs.canonicalize(path)?]) .context(context), ); diff --git a/src/snapshot_middleware/rbxm.rs b/src/snapshot_middleware/rbxm.rs index f3693d52..6518be6a 100644 --- a/src/snapshot_middleware/rbxm.rs +++ b/src/snapshot_middleware/rbxm.rs @@ -28,7 +28,7 @@ pub fn snapshot_rbxm( .metadata( InstanceMetadata::new() .instigating_source(path) - .relevant_paths(vec![path.to_path_buf()]) + .relevant_paths(vec![vfs.canonicalize(path)?]) .context(context), ); diff --git a/src/snapshot_middleware/rbxmx.rs b/src/snapshot_middleware/rbxmx.rs index 59aff679..8f122324 100644 --- a/src/snapshot_middleware/rbxmx.rs +++ b/src/snapshot_middleware/rbxmx.rs @@ -31,7 +31,7 @@ pub fn snapshot_rbxmx( .metadata( InstanceMetadata::new() .instigating_source(path) - .relevant_paths(vec![path.to_path_buf()]) + .relevant_paths(vec![vfs.canonicalize(path)?]) .context(context), ); diff --git a/src/snapshot_middleware/toml.rs b/src/snapshot_middleware/toml.rs index 7262cfa9..3c42de6c 100644 --- a/src/snapshot_middleware/toml.rs +++ b/src/snapshot_middleware/toml.rs @@ -31,7 +31,7 @@ pub fn snapshot_toml( .metadata( InstanceMetadata::new() .instigating_source(path) - .relevant_paths(vec![path.to_path_buf()]) + .relevant_paths(vec![vfs.canonicalize(path)?]) .context(context), ); diff --git a/src/snapshot_middleware/txt.rs b/src/snapshot_middleware/txt.rs index 715354e0..c56aacdd 100644 --- a/src/snapshot_middleware/txt.rs +++ b/src/snapshot_middleware/txt.rs @@ -28,7 +28,7 @@ pub fn snapshot_txt( .metadata( InstanceMetadata::new() .instigating_source(path) - .relevant_paths(vec![path.to_path_buf()]) + .relevant_paths(vec![vfs.canonicalize(path)?]) .context(context), ); diff --git a/src/snapshot_middleware/yaml.rs b/src/snapshot_middleware/yaml.rs index c8132d49..10f744bc 100644 --- a/src/snapshot_middleware/yaml.rs +++ b/src/snapshot_middleware/yaml.rs @@ -37,7 +37,7 @@ pub fn snapshot_yaml( .metadata( InstanceMetadata::new() .instigating_source(path) - .relevant_paths(vec![path.to_path_buf()]) + .relevant_paths(vec![vfs.canonicalize(path)?]) .context(context), ); diff --git a/src/syncback/ref_properties.rs b/src/syncback/ref_properties.rs index 4a8385f8..054f61b8 100644 --- a/src/syncback/ref_properties.rs +++ b/src/syncback/ref_properties.rs @@ -8,7 +8,10 @@ use rbx_dom_weak::{ ustr, Instance, Ustr, WeakDom, }; -use crate::{multimap::MultiMap, REF_ID_ATTRIBUTE_NAME, REF_POINTER_ATTRIBUTE_PREFIX}; +use crate::{ + multimap::MultiMap, syncback::snapshot::inst_path, REF_ID_ATTRIBUTE_NAME, + REF_POINTER_ATTRIBUTE_PREFIX, +}; pub struct RefLinks { /// A map of referents to each of their Ref properties. @@ -50,6 +53,22 @@ pub fn collect_referents(dom: &WeakDom) -> RefLinks { continue; } + let target = match dom.get_by_ref(*prop_value) { + Some(inst) => inst, + None => { + // Properties that are Some but point to nothing may as + // well be `nil`. Roblox and us never produce these values + // but syncback prunes trees without adjusting ref + // properties for performance reasons. + log::warn!( + "Property {}.{} will be `nil` on the disk because the actual value is not being included in syncback", + inst_path(dom, inst_ref), + prop_name + ); + continue; + } + }; + links.insert( inst_ref, RefLink { @@ -58,10 +77,6 @@ pub fn collect_referents(dom: &WeakDom) -> RefLinks { }, ); - let target = dom - .get_by_ref(*prop_value) - .expect("Refs in DOM should point to valid Instances"); - // 1. Check if target has an ID if let Some(id) = get_existing_id(target) { // If it does, we need to check whether that ID is a duplicate diff --git a/src/web/api.rs b/src/web/api.rs index 57b99e2e..38163bb9 100644 --- a/src/web/api.rs +++ b/src/web/api.rs @@ -1,13 +1,7 @@ //! Defines Rojo's HTTP API, all under /api. These endpoints generally return //! JSON. -use std::{ - collections::{HashMap, HashSet}, - fs, - path::PathBuf, - str::FromStr, - sync::Arc, -}; +use std::{collections::HashMap, fs, path::PathBuf, str::FromStr, sync::Arc}; use futures::{sink::SinkExt, stream::StreamExt}; use hyper::{body, Body, Method, Request, Response, StatusCode}; @@ -29,7 +23,9 @@ use crate::{ }, util::{deserialize_msgpack, msgpack, msgpack_ok, serialize_msgpack}, }, - web_api::{InstanceUpdate, RefPatchResponse, SerializeResponse}, + web_api::{ + InstanceUpdate, RefPatchRequest, RefPatchResponse, SerializeRequest, SerializeResponse, + }, }; pub async fn call(serve_session: Arc, mut request: Request) -> Response { @@ -52,12 +48,8 @@ pub async fn call(serve_session: Arc, mut request: Request) ) } } - (&Method::GET, path) if path.starts_with("/api/serialize/") => { - service.handle_api_serialize(request).await - } - (&Method::GET, path) if path.starts_with("/api/ref-patch/") => { - service.handle_api_ref_patch(request).await - } + (&Method::POST, "/api/serialize") => service.handle_api_serialize(request).await, + (&Method::POST, "/api/ref-patch") => service.handle_api_ref_patch(request).await, (&Method::POST, path) if path.starts_with("/api/open/") => { service.handle_api_open(request).await @@ -228,22 +220,30 @@ impl ApiService { /// that correspond to the requested Instances. These values have their /// `Value` property set to point to the requested Instance. async fn handle_api_serialize(&self, request: Request) -> Response { - let argument = &request.uri().path()["/api/serialize/".len()..]; - let requested_ids: Result, _> = argument.split(',').map(Ref::from_str).collect(); + let session_id = self.serve_session.session_id(); + let body = body::to_bytes(request.into_body()).await.unwrap(); - let requested_ids = match requested_ids { - Ok(ids) => ids, - Err(_) => { + let request: SerializeRequest = match deserialize_msgpack(&body) { + Ok(request) => request, + Err(err) => { return msgpack( - ErrorResponse::bad_request("Malformed ID list"), + ErrorResponse::bad_request(format!("Invalid body: {}", err)), StatusCode::BAD_REQUEST, ); } }; + + if request.session_id != session_id { + return msgpack( + ErrorResponse::bad_request("Wrong session ID"), + StatusCode::BAD_REQUEST, + ); + } + let mut response_dom = WeakDom::new(InstanceBuilder::new("Folder")); let tree = self.serve_session.tree(); - for id in &requested_ids { + for id in &request.ids { if let Some(instance) = tree.get_instance(*id) { let clone = response_dom.insert( Ref::none(), @@ -289,20 +289,26 @@ impl ApiService { /// and referent properties need to be updated after the serialize /// endpoint is used. async fn handle_api_ref_patch(self, request: Request) -> Response { - let argument = &request.uri().path()["/api/ref-patch/".len()..]; - let requested_ids: Result, _> = - argument.split(',').map(Ref::from_str).collect(); + let session_id = self.serve_session.session_id(); + let body = body::to_bytes(request.into_body()).await.unwrap(); - let requested_ids = match requested_ids { - Ok(ids) => ids, - Err(_) => { + let request: RefPatchRequest = match deserialize_msgpack(&body) { + Ok(request) => request, + Err(err) => { return msgpack( - ErrorResponse::bad_request("Malformed ID list"), + ErrorResponse::bad_request(format!("Invalid body: {}", err)), StatusCode::BAD_REQUEST, ); } }; + if request.session_id != session_id { + return msgpack( + ErrorResponse::bad_request("Wrong session ID"), + StatusCode::BAD_REQUEST, + ); + } + let mut instance_updates: HashMap = HashMap::new(); let tree = self.serve_session.tree(); @@ -311,7 +317,7 @@ impl ApiService { let Variant::Ref(prop_value) = prop_value else { continue; }; - if let Some(target_id) = requested_ids.get(prop_value) { + if let Some(target_id) = request.ids.get(prop_value) { let instance_id = instance.id(); let update = instance_updates diff --git a/src/web/interface.rs b/src/web/interface.rs index a0d5459b..51307b67 100644 --- a/src/web/interface.rs +++ b/src/web/interface.rs @@ -238,6 +238,13 @@ pub struct OpenResponse { pub session_id: SessionId, } +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SerializeRequest { + pub session_id: SessionId, + pub ids: Vec, +} + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SerializeResponse { @@ -246,6 +253,13 @@ pub struct SerializeResponse { pub model_contents: Vec, } +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RefPatchRequest { + pub session_id: SessionId, + pub ids: HashSet, +} + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RefPatchResponse<'a> { diff --git a/tests/rojo_test/serve_util.rs b/tests/rojo_test/serve_util.rs index 2035c4b7..4b67a883 100644 --- a/tests/rojo_test/serve_util.rs +++ b/tests/rojo_test/serve_util.rs @@ -1,5 +1,4 @@ use std::{ - fmt::Write as _, fs, path::{Path, PathBuf}, process::Command, @@ -11,11 +10,15 @@ use std::{ use hyper_tungstenite::tungstenite::{connect, Message}; use rbx_dom_weak::types::Ref; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use tempfile::{tempdir, TempDir}; -use librojo::web_api::{ - ReadResponse, SerializeResponse, ServerInfoResponse, SocketPacket, SocketPacketType, +use librojo::{ + web_api::{ + ReadResponse, SerializeRequest, SerializeResponse, ServerInfoResponse, SocketPacket, + SocketPacketType, + }, + SessionId, }; use rojo_insta_ext::RedactionMap; @@ -221,21 +224,36 @@ impl TestServeSession { } } - pub fn get_api_serialize(&self, ids: &[Ref]) -> Result { - let mut id_list = String::with_capacity(ids.len() * 33); - for id in ids { - write!(id_list, "{id},").unwrap(); - } - id_list.pop(); + pub fn get_api_serialize( + &self, + ids: &[Ref], + session_id: SessionId, + ) -> Result { + let client = reqwest::blocking::Client::new(); + let url = format!("http://localhost:{}/api/serialize", self.port); + let body = serialize_msgpack(&SerializeRequest { + session_id, + ids: ids.to_vec(), + }) + .unwrap(); - let url = format!("http://localhost:{}/api/serialize/{}", self.port, id_list); - - let body = reqwest::blocking::get(url)?.bytes()?; + let body = client.post(url).body(body).send()?.bytes()?; Ok(deserialize_msgpack(&body).expect("Server returned malformed response")) } } +fn serialize_msgpack(value: T) -> Result, rmp_serde::encode::Error> { + let mut serialized = Vec::new(); + let mut serializer = rmp_serde::Serializer::new(&mut serialized) + .with_human_readable() + .with_struct_map(); + + value.serialize(&mut serializer)?; + + Ok(serialized) +} + fn deserialize_msgpack<'a, T: Deserialize<'a>>( input: &'a [u8], ) -> Result { diff --git a/tests/tests/serve.rs b/tests/tests/serve.rs index ee0528e1..748f1687 100644 --- a/tests/tests/serve.rs +++ b/tests/tests/serve.rs @@ -646,7 +646,7 @@ fn meshpart_with_id() { .unwrap(); let serialize_response = session - .get_api_serialize(&[*meshpart, *objectvalue]) + .get_api_serialize(&[*meshpart, *objectvalue], info.session_id) .unwrap(); // We don't assert a snapshot on the SerializeResponse because the model includes the @@ -673,7 +673,9 @@ fn forced_parent() { read_response.intern_and_redact(&mut redactions, root_id) ); - let serialize_response = session.get_api_serialize(&[root_id]).unwrap(); + let serialize_response = session + .get_api_serialize(&[root_id], info.session_id) + .unwrap(); assert_eq!(serialize_response.session_id, info.session_id); diff --git a/tests/tests/syncback.rs b/tests/tests/syncback.rs index 851b4e4d..31553496 100644 --- a/tests/tests/syncback.rs +++ b/tests/tests/syncback.rs @@ -71,8 +71,13 @@ syncback_tests! { ref_properties_conflict => ["src/Pointer_2.model.json", "src/Target_2.model.json"], // Ensures that having multiple pointers that are aimed at the same target doesn't trigger ref rewrites. ref_properties_duplicate => [], + // Ensures that ref properties that point to nothing after the prune both + // do not leave any trace of themselves + ref_properties_pruned => ["src/Pointer1.model.json", "src/Pointer2.model.json", "src/Pointer3.model.json"], // Ensures that the old middleware is respected during syncback respect_old_middleware => ["default.project.json", "src/model_json.model.json", "src/rbxm.rbxm", "src/rbxmx.rbxmx"], + // Ensures that the `$schema` field roundtrips with syncback + schema_roundtrip => ["default.project.json", "src/model.model.json", "src/init/init.meta.json", "src/adjacent.meta.json"], // Ensures that StringValues inside project files are written to the // project file, but only if they don't have `$path` set string_value_project => ["default.project.json"], @@ -81,5 +86,4 @@ syncback_tests! { sync_rules => ["src/module.modulescript", "src/text.text"], // Ensures that the `syncUnscriptable` setting works unscriptable_properties => ["default.project.json"], - schema_roundtrip => ["default.project.json", "src/model.model.json", "src/init/init.meta.json", "src/adjacent.meta.json"] }