diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 000000000..dcba947ad --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# stylua formatting +0f8e1625d572a5fe0f7b5c08653ff92cc837d346 diff --git a/plugin/src/ApiContext.lua b/plugin/src/ApiContext.lua index 01b7ec455..19f46ac12 100644 --- a/plugin/src/ApiContext.lua +++ b/plugin/src/ApiContext.lua @@ -239,4 +239,23 @@ function ApiContext:open(id) end) end +function ApiContext:fetch(ids: { string }) + local url = ("%s/api/fetch"):format(self.__baseUrl) + local requestBody = { + sessionId = self.__sessionId, + idList = ids, + } + + return Http.post(url, Http.jsonEncode(requestBody)) + :andThen(rejectFailedRequests) + :andThen(Http.Response.json) + :andThen(function(responseBody) + if responseBody.sessionId ~= self.__sessionId then + return Promise.reject("Server changed ID") + end + + return responseBody + end) +end + return ApiContext diff --git a/plugin/src/App/StatusPages/Settings/init.lua b/plugin/src/App/StatusPages/Settings/init.lua index 688be85a3..6c4a4b97f 100644 --- a/plugin/src/App/StatusPages/Settings/init.lua +++ b/plugin/src/App/StatusPages/Settings/init.lua @@ -155,6 +155,16 @@ function SettingsPage:render() layoutOrder = 5, }), + FetchOnPatchFail = e(Setting, { + id = "fetchOnPatchFail", + name = "Load Model On Patch Fail", + description = "Whenever a patch fails to fully apply, send a request to the server to have it " + .. "placed into the local file system for the plugin to load.", + locked = self.props.syncActive, + transparency = self.props.transparency, + layoutOrder = 6, + }), + OpenScriptsExternally = e(Setting, { id = "openScriptsExternally", name = "Open Scripts Externally", @@ -162,7 +172,7 @@ function SettingsPage:render() locked = self.props.syncActive, experimental = true, transparency = self.props.transparency, - layoutOrder = 6, + layoutOrder = 7, }), TwoWaySync = e(Setting, { @@ -172,7 +182,7 @@ function SettingsPage:render() locked = self.props.syncActive, experimental = true, transparency = self.props.transparency, - layoutOrder = 7, + layoutOrder = 8, }), LogLevel = e(Setting, { diff --git a/plugin/src/App/init.lua b/plugin/src/App/init.lua index cfebcad8c..1d96568ae 100644 --- a/plugin/src/App/init.lua +++ b/plugin/src/App/init.lua @@ -347,6 +347,7 @@ function App:startSession() local sessionOptions = { openScriptsExternally = Settings:get("openScriptsExternally"), twoWaySync = Settings:get("twoWaySync"), + fetchOnPatchFail = Settings:get("fetchOnPatchFail"), } local baseUrl = if string.find(host, "^https?://") @@ -358,6 +359,7 @@ function App:startSession() apiContext = apiContext, openScriptsExternally = sessionOptions.openScriptsExternally, twoWaySync = sessionOptions.twoWaySync, + fetchOnPatchFail = sessionOptions.fetchOnPatchFail, }) self.cleanupPrecommit = serveSession.__reconciler:hookPrecommit(function(patch, instanceMap) diff --git a/plugin/src/Reconciler/applyPatch.lua b/plugin/src/Reconciler/applyPatch.lua index 4faa73105..a625ebe93 100644 --- a/plugin/src/Reconciler/applyPatch.lua +++ b/plugin/src/Reconciler/applyPatch.lua @@ -5,8 +5,6 @@ Patches can come from the server or be generated by the client. ]] -local ChangeHistoryService = game:GetService("ChangeHistoryService") - local Packages = script.Parent.Parent.Parent.Packages local Log = require(Packages.Log) @@ -19,8 +17,6 @@ local reify = require(script.Parent.reify) local setProperty = require(script.Parent.setProperty) local function applyPatch(instanceMap, patch) - local patchTimestamp = DateTime.now():FormatLocalTime("LTS", "en-us") - -- Tracks any portions of the patch that could not be applied to the DOM. local unappliedPatch = PatchSet.newEmpty() @@ -203,8 +199,6 @@ local function applyPatch(instanceMap, patch) end end - ChangeHistoryService:SetWaypoint("Rojo: Patch " .. patchTimestamp) - return unappliedPatch end diff --git a/plugin/src/Reconciler/fetchInstances.lua b/plugin/src/Reconciler/fetchInstances.lua new file mode 100644 index 000000000..464243335 --- /dev/null +++ b/plugin/src/Reconciler/fetchInstances.lua @@ -0,0 +1,73 @@ +local Selection = game:GetService("Selection") + +local Rojo = script:FindFirstAncestor("Rojo") +local invariant = require(script.Parent.Parent.invariant) + +local Log = require(Rojo.Packages.Log) + +local function fetchInstances(idList, instanceMap, apiContext) + return apiContext:fetch(idList):andThen(function(body: { sessionId: string, path: string }) + -- The endpoint `api/fetech/idlist` returns a table that contains + -- `sessionId` and `path`. The value of `path` is the name of a + -- file in the Content folder that may be loaded via `GetObjects`. + local objects = game:GetObjects("rbxasset://" .. body.path) + -- `ReferentMap` is a folder that contains nothing but + -- ObjectValues which will be named after entries in `instanceMap` + -- and have their `Value` property point towards a new Instance + -- that it can be swapped out with. In turn, `reified` is a + -- container for all of the objects pointed to by those instances. + local map = objects[1]:FindFirstChild("ReferentMap") + local reified = objects[1]:FindFirstChild("Reified") + if map == nil then + invariant("The fetch endpoint returned a malformed folder: missing ReferentMap") + end + if reified == nil then + invariant("The fetch endpoint returned a malformed folder: missing Reified") + end + + -- We want to preserve selection between replacements. + local selected = Selection:Get() + local selectedMap = {} + for i, inst in selected do + selectedMap[inst] = i + end + + for _, entry in map:GetChildren() do + if entry:IsA("ObjectValue") then + local key, value = entry.Name, entry.Value + if value == nil or not value:IsDescendantOf(reified) then + invariant("ReferentMap contained entry {} that was parented to an outside source", key) + else + -- This could be a problem if Roblox ever supports + -- parallel access to the DataModel but right now, + -- there's no way this results in a data race. + local oldInstance: Instance = instanceMap.fromIds[key] + instanceMap:insert(key, value) + Log.trace("Swapping Instance {} out", key) + + local oldParent = oldInstance.Parent + local children = oldInstance:GetChildren() + for _, child in children do + child.Parent = value + end + value.Parent = oldParent + if selectedMap[oldInstance] then + -- Swapping section like this preserves order + -- It might not matter, but it's also basically free + -- So we may as well. + selected[selectedMap[oldInstance]] = value + end + + -- So long and thanks for all the fish :-) + oldInstance:Destroy() + end + else + invariant("ReferentMap entry `{}` was a `{}` and not an ObjectValue", entry.Name, entry.ClassName) + end + end + + Selection:Set(selected) + end) +end + +return fetchInstances diff --git a/plugin/src/Reconciler/init.lua b/plugin/src/Reconciler/init.lua index 129457827..3eae4759c 100644 --- a/plugin/src/Reconciler/init.lua +++ b/plugin/src/Reconciler/init.lua @@ -2,10 +2,14 @@ This module defines the meat of the Rojo plugin and how it manages tracking and mutating the Roblox DOM. ]] +local ChangeHistoryService = game:GetService("ChangeHistoryService") local Packages = script.Parent.Parent.Packages local Log = require(Packages.Log) +local PatchSet = require(script.Parent.PatchSet) + +local fetchInstances = require(script.fetchInstances) local applyPatch = require(script.applyPatch) local hydrate = require(script.hydrate) local diff = require(script.diff) @@ -13,10 +17,13 @@ local diff = require(script.diff) local Reconciler = {} Reconciler.__index = Reconciler -function Reconciler.new(instanceMap) +function Reconciler.new(instanceMap, apiContext, fetchOnPatchFail: boolean) local self = { -- Tracks all of the instances known by the reconciler by ID. __instanceMap = instanceMap, + -- An API context for sending requests back to the server + __apiContext = apiContext, + __fetchOnPatchFail = fetchOnPatchFail, __precommitCallbacks = {}, __postcommitCallbacks = {}, } @@ -64,8 +71,29 @@ function Reconciler:applyPatch(patch) end end + local patchTimestamp = DateTime.now():FormatLocalTime("LTS", "en-us") + local unappliedPatch = applyPatch(self.__instanceMap, patch) + if self.__fetchOnPatchFail then + -- TODO Is it worth doing this for additions that fail? + -- It seems unlikely that a valid Instance can't be made with `Instance.new` + -- but can be made using GetObjects + if PatchSet.hasUpdates(unappliedPatch) then + local idList = table.create(#unappliedPatch.updated) + for i, entry in unappliedPatch.updated do + idList[i] = entry.id + end + -- TODO this is destructive to any properties that are + -- overwritten by the user but not known to Rojo. We can probably + -- mitigate that by keeping tabs of any instances we need to swap. + fetchInstances(idList, self.__instanceMap, self.__apiContext) + table.clear(unappliedPatch.updated) + end + end + + ChangeHistoryService:SetWaypoint("Rojo: Patch " .. patchTimestamp) + for _, callback in self.__postcommitCallbacks do local success, err = pcall(callback, patch, self.__instanceMap, unappliedPatch) if not success then diff --git a/plugin/src/ServeSession.lua b/plugin/src/ServeSession.lua index e5bbb12f1..3bd82079c 100644 --- a/plugin/src/ServeSession.lua +++ b/plugin/src/ServeSession.lua @@ -52,6 +52,7 @@ local validateServeOptions = t.strictInterface({ apiContext = t.table, openScriptsExternally = t.boolean, twoWaySync = t.boolean, + fetchOnPatchFail = t.boolean, }) function ServeSession.new(options) @@ -73,7 +74,7 @@ function ServeSession.new(options) local instanceMap = InstanceMap.new(onInstanceChanged) local changeBatcher = ChangeBatcher.new(instanceMap, onChangesFlushed) - local reconciler = Reconciler.new(instanceMap) + local reconciler = Reconciler.new(instanceMap, options.apiContext, options.fetchOnPatchFail) local connections = {} @@ -91,6 +92,7 @@ function ServeSession.new(options) __apiContext = options.apiContext, __openScriptsExternally = options.openScriptsExternally, __twoWaySync = options.twoWaySync, + __fetchOnPatchFail = options.fetchOnPatchFail, __reconciler = reconciler, __instanceMap = instanceMap, __changeBatcher = changeBatcher, diff --git a/plugin/src/Settings.lua b/plugin/src/Settings.lua index 194dd4c7a..680a2494a 100644 --- a/plugin/src/Settings.lua +++ b/plugin/src/Settings.lua @@ -13,6 +13,7 @@ local defaultSettings = { openScriptsExternally = false, twoWaySync = false, showNotifications = true, + fetchOnPatchFail = true, syncReminder = true, confirmationBehavior = "Initial", largeChangesConfirmationThreshold = 5, diff --git a/src/web/api.rs b/src/web/api.rs index 338862f7c..0129bb064 100644 --- a/src/web/api.rs +++ b/src/web/api.rs @@ -1,28 +1,36 @@ //! Defines Rojo's HTTP API, all under /api. These endpoints generally return //! JSON. -use std::{collections::HashMap, fs, path::PathBuf, str::FromStr, sync::Arc}; +use std::{collections::HashMap, fs, io, io::BufWriter, path::PathBuf, str::FromStr, sync::Arc}; use hyper::{body, Body, Method, Request, Response, StatusCode}; use opener::OpenError; -use rbx_dom_weak::types::Ref; +use rbx_dom_weak::{ + types::{Ref, Variant}, + InstanceBuilder, WeakDom, +}; +use roblox_install::RobloxStudio; +use uuid::Uuid; use crate::{ serve_session::ServeSession, snapshot::{InstanceWithMeta, PatchSet, PatchUpdate}, web::{ interface::{ - ErrorResponse, Instance, OpenResponse, ReadResponse, ServerInfoResponse, - SubscribeMessage, SubscribeResponse, WriteRequest, WriteResponse, PROTOCOL_VERSION, - SERVER_VERSION, + ErrorResponse, FetchRequest, FetchResponse, Instance, OpenResponse, ReadResponse, + ServerInfoResponse, SubscribeMessage, SubscribeResponse, WriteRequest, WriteResponse, + PROTOCOL_VERSION, SERVER_VERSION, }, util::{json, json_ok}, }, }; +const FETCH_DIR_NAME: &str = ".rojo"; + pub async fn call(serve_session: Arc, request: Request) -> Response { let service = ApiService::new(serve_session); + log::debug!("{} request received to {}", request.method(), request.uri()); match (request.method(), request.uri().path()) { (&Method::GET, "/api/rojo") => service.handle_api_rojo().await, (&Method::GET, path) if path.starts_with("/api/read/") => { @@ -37,6 +45,8 @@ pub async fn call(serve_session: Arc, request: Request) -> R (&Method::POST, "/api/write") => service.handle_api_write(request).await, + (&Method::POST, "/api/fetch") => service.handle_api_fetch_post(request).await, + (_method, path) => json( ErrorResponse::not_found(format!("Route not found: {}", path)), StatusCode::NOT_FOUND, @@ -275,6 +285,109 @@ impl ApiService { session_id: self.serve_session.session_id(), }) } + + async fn handle_api_fetch_post(&self, request: Request) -> Response { + let body = body::to_bytes(request.into_body()).await.unwrap(); + + let request: FetchRequest = match serde_json::from_slice(&body) { + Ok(request) => request, + Err(err) => { + return json( + ErrorResponse::bad_request(format!("Malformed request body: {}", err)), + StatusCode::BAD_REQUEST, + ); + } + }; + + let content_dir = match RobloxStudio::locate() { + Ok(path) => path.content_path().to_path_buf(), + Err(_) => { + return json( + ErrorResponse::internal_error("Cannot locate Studio install"), + StatusCode::INTERNAL_SERVER_ERROR, + ) + } + }; + + let temp_dir = content_dir.join(FETCH_DIR_NAME); + match fs::create_dir(&temp_dir) { + // We want to silently move on if the folder already exists + Err(err) if err.kind() != io::ErrorKind::AlreadyExists => { + return json( + ErrorResponse::internal_error(format!( + "Could not create Rojo content directory: {}", + &temp_dir.display() + )), + StatusCode::INTERNAL_SERVER_ERROR, + ); + } + _ => {} + } + + let uuid = Uuid::new_v4(); + let mut file_name = PathBuf::from(uuid.to_string()); + file_name.set_extension("rbxm"); + + let out_path = temp_dir.join(&file_name); + let relative_path = PathBuf::from(FETCH_DIR_NAME).join(file_name); + + let mut writer = BufWriter::new(match fs::File::create(&out_path) { + Ok(handle) => handle, + Err(_) => { + return json( + ErrorResponse::internal_error("Could not create temporary file"), + StatusCode::INTERNAL_SERVER_ERROR, + ); + } + }); + + let tree = self.serve_session.tree(); + let inner_tree = tree.inner(); + let mut sub_tree = WeakDom::new(InstanceBuilder::new("Folder")); + let reify_ref = sub_tree.insert( + sub_tree.root_ref(), + InstanceBuilder::new("Folder").with_name("Reified"), + ); + let map_ref = sub_tree.insert( + sub_tree.root_ref(), + InstanceBuilder::new("Folder").with_name("ReferentMap"), + ); + // Because referents can't be cleanly communicated across a network + // boundary, we have to get creative. So for every Instance we're + // building into a model, an ObjectValue is created's named after the + // old referent and points to the fetched copy of the Instance. + for referent in request.id_list { + if inner_tree.get_by_ref(referent).is_some() { + log::trace!("Creating clone of {referent} into subtree"); + let new_ref = generate_fetch_copy(&inner_tree, &mut sub_tree, reify_ref, referent); + sub_tree.insert( + map_ref, + InstanceBuilder::new("ObjectValue") + .with_property("Value", Variant::Ref(new_ref)) + .with_name(referent.to_string()), + ); + } else { + return json( + ErrorResponse::bad_request("Invalid ID provided to fetch endpoint"), + StatusCode::BAD_REQUEST, + ); + } + } + if let Err(_) = rbx_binary::to_writer(&mut writer, &sub_tree, &[sub_tree.root_ref()]) { + return json( + ErrorResponse::internal_error("Could not build subtree into model file"), + StatusCode::INTERNAL_SERVER_ERROR, + ); + } + drop(tree); + + log::debug!("Wrote model file to {}", out_path.display()); + + json_ok(FetchResponse { + session_id: self.serve_session.session_id(), + path: relative_path.to_string_lossy(), + }) + } } /// If this instance is represented by a script, try to find the correct .lua or .luau @@ -305,3 +418,47 @@ fn pick_script_path(instance: InstanceWithMeta<'_>) -> Option { }) .map(|path| path.to_owned()) } + +/// Creates a copy of the Instance pointed to by `referent` from `old_tree`, +/// puts it inside of `new_tree`, and parents it to `parent_ref`. +/// +/// In the event that the Instance is of a class with special parent +/// requirements such as `Attachment`, it will instead make an Instance +/// of the required parent class and place the cloned Instance as a child +/// of that Instance. +/// +/// Regardless, the referent of the clone is returned. +fn generate_fetch_copy( + old_tree: &WeakDom, + new_tree: &mut WeakDom, + parent_ref: Ref, + referent: Ref, +) -> Ref { + // We can't use `clone_into_external` here because it also clones the + // subtree + let old_inst = old_tree.get_by_ref(referent).unwrap(); + let new_ref = new_tree.insert( + Ref::none(), + InstanceBuilder::new(&old_inst.class) + .with_name(&old_inst.name) + .with_properties(old_inst.properties.clone()), + ); + + // Certain classes need to have specific parents otherwise Studio + // doesn't want to load the model. + let real_parent = match old_inst.class.as_str() { + // These are services, but they're listed here for posterity. + "Terrain" | "StarterPlayerScripts" | "StarterCharacterScripts" => parent_ref, + + "Attachment" => new_tree.insert(parent_ref, InstanceBuilder::new("Part")), + "Bone" => new_tree.insert(parent_ref, InstanceBuilder::new("Part")), + "Animator" => new_tree.insert(parent_ref, InstanceBuilder::new("Humanoid")), + "BaseWrap" => new_tree.insert(parent_ref, InstanceBuilder::new("MeshPart")), + "WrapLayer" => new_tree.insert(parent_ref, InstanceBuilder::new("MeshPart")), + "WrapTarget" => new_tree.insert(parent_ref, InstanceBuilder::new("MeshPart")), + _ => parent_ref, + }; + new_tree.transfer_within(new_ref, real_parent); + + new_ref +} diff --git a/src/web/interface.rs b/src/web/interface.rs index c10e14bc9..8c6ad1892 100644 --- a/src/web/interface.rs +++ b/src/web/interface.rs @@ -204,6 +204,22 @@ pub struct OpenResponse { pub session_id: SessionId, } +/// A request POSTed to /api/fetch +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FetchRequest { + pub session_id: SessionId, + pub id_list: Vec, +} + +/// Response body from POST /api/fetch +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FetchResponse<'a> { + pub session_id: SessionId, + pub path: Cow<'a, str>, +} + /// General response type returned from all Rojo routes #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")]