diff --git a/plugin/src/Api.lua b/plugin/src/Api.lua deleted file mode 100644 index 131acf6f..00000000 --- a/plugin/src/Api.lua +++ /dev/null @@ -1,112 +0,0 @@ -local HttpService = game:GetService("HttpService") - -local Config = require(script.Parent.Config) -local Promise = require(script.Parent.Promise) -local Version = require(script.Parent.Version) - -local Api = {} -Api.__index = Api - -Api.Error = { - ServerIdMismatch = "ServerIdMismatch", -} - -setmetatable(Api.Error, { - __index = function(_, key) - error("Invalid API.Error name " .. key, 2) - end -}) - ---[[ - Api.connect(Http) -> Promise - - Create a new Api using the given HTTP implementation. - - Attempting to invoke methods on an invalid conext will throw errors! -]] -function Api.connect(http) - local context = { - http = http, - serverId = nil, - currentTime = 0, - } - - setmetatable(context, Api) - - return context:_start() -end - -function Api:_start() - return self.http:get("/") - :andThen(function(response) - response = response:json() - - if response.protocolVersion ~= Config.protocolVersion then - local message = ( - "Found a Rojo dev server, but it's using a different protocol version, and is incompatible." .. - "\nMake sure you have matching versions of both the Rojo plugin and server!" .. - "\n\nYour client is version %s, with protocol version %s. It expects server version %s." .. - "\nYour server is version %s, with protocol version %s." .. - "\n\nGo to https://github.com/LPGhatguy/rojo for more details." - ):format( - Version.display(Config.version), Config.protocolVersion, - Config.expectedApiVersionString, - response.serverVersion, response.protocolVersion - ) - - return Promise.reject(message) - end - - self.serverId = response.serverId - self.currentTime = response.currentTime - - return self - end) -end - -function Api:getInfo() - return self.http:get("/") - :andThen(function(response) - response = response:json() - - if response.serverId ~= self.serverId then - return Promise.reject(Api.Error.ServerIdMismatch) - end - - return response - end) -end - -function Api:read(paths) - local body = HttpService:JSONEncode(paths) - - return self.http:post("/read", body) - :andThen(function(response) - response = response:json() - - if response.serverId ~= self.serverId then - return Promise.reject(Api.Error.ServerIdMismatch) - end - - return response.items - end) -end - -function Api:getChanges() - local url = ("/changes/%f"):format(self.currentTime) - - return self.http:get(url) - :andThen(function(response) - response = response:json() - - if response.serverId ~= self.serverId then - return Promise.reject(Api.Error.ServerIdMismatch) - end - - self.currentTime = response.currentTime - - return response.changes - end) -end - -return Api diff --git a/plugin/src/ApiContext.lua b/plugin/src/ApiContext.lua new file mode 100644 index 00000000..885e5a29 --- /dev/null +++ b/plugin/src/ApiContext.lua @@ -0,0 +1,63 @@ +local HttpService = game:GetService("HttpService") + +local Config = require(script.Parent.Config) +local Promise = require(script.Parent.Promise) +local Version = require(script.Parent.Version) +local Http = require(script.Parent.Http) + +local ApiContext = {} +ApiContext.__index = ApiContext + +ApiContext.Error = { + ServerIdMismatch = "ServerIdMismatch", +} + +setmetatable(ApiContext.Error, { + __index = function(_, key) + error("Invalid API.Error name " .. key, 2) + end +}) + +function ApiContext.new(url, onMessage) + assert(type(url) == "string") + assert(type(onMessage) == "function") + + local context = { + url = url, + onMessage = onMessage, + serverId = nil, + currentTime = 0, + } + + setmetatable(context, ApiContext) + + return context +end + +function ApiContext:connect() + return Http.get(self.url) + :andThen(function(response) + response = response:json() + + if response.protocolVersion ~= Config.protocolVersion then + local message = ( + "Found a Rojo dev server, but it's using a different protocol version, and is incompatible." .. + "\nMake sure you have matching versions of both the Rojo plugin and server!" .. + "\n\nYour client is version %s, with protocol version %s. It expects server version %s." .. + "\nYour server is version %s, with protocol version %s." .. + "\n\nGo to https://github.com/LPGhatguy/rojo for more details." + ):format( + Version.display(Config.version), Config.protocolVersion, + Config.expectedApiContextVersionString, + response.serverVersion, response.protocolVersion + ) + + return Promise.reject(message) + end + + self.serverId = response.serverId + self.currentTime = response.currentTime + end) +end + +return ApiContext diff --git a/plugin/src/Config.lua b/plugin/src/Config.lua index 8c8aaa5c..a3bb163a 100644 --- a/plugin/src/Config.lua +++ b/plugin/src/Config.lua @@ -1,7 +1,7 @@ return { - pollingRate = 0.2, - version = {0, 4, 4}, - expectedServerVersionString = "0.4.x", - protocolVersion = 1, + version = {0, 5, 0}, + expectedServerVersionString = "0.5.x", + protocolVersion = 2, + port = 34872, dev = false, } diff --git a/plugin/src/Http.lua b/plugin/src/Http.lua index 98997b9f..0f8f9129 100644 --- a/plugin/src/Http.lua +++ b/plugin/src/Http.lua @@ -13,26 +13,13 @@ local function dprint(...) end local Http = {} -Http.__index = Http -function Http.new(baseUrl) - assert(type(baseUrl) == "string", "Http.new needs a baseUrl!") - - local http = { - baseUrl = baseUrl - } - - setmetatable(http, Http) - - return http -end - -function Http:get(endpoint) - dprint("\nGET", endpoint) +function Http.get(url) + dprint("\nGET", url) return Promise.new(function(resolve, reject) spawn(function() local ok, result = pcall(function() - return HttpService:GetAsync(self.baseUrl .. endpoint, true) + return HttpService:GetAsync(url, true) end) if ok then @@ -45,13 +32,13 @@ function Http:get(endpoint) end) end -function Http:post(endpoint, body) - dprint("\nPOST", endpoint) +function Http.post(url, body) + dprint("\nPOST", url) dprint(body) return Promise.new(function(resolve, reject) spawn(function() local ok, result = pcall(function() - return HttpService:PostAsync(self.baseUrl .. endpoint, body) + return HttpService:PostAsync(url, body) end) if ok then diff --git a/plugin/src/Main.server.lua b/plugin/src/Main.server.lua index c6570229..2f46c8b1 100644 --- a/plugin/src/Main.server.lua +++ b/plugin/src/Main.server.lua @@ -2,7 +2,7 @@ if not plugin then return end -local Plugin = require(script.Parent.Plugin) +local Session = require(script.Parent.Session) local Config = require(script.Parent.Config) local Version = require(script.Parent.Version) @@ -39,40 +39,16 @@ local function checkUpgrade() end local function main() - local pluginInstance = Plugin.new() - local displayedVersion = Config.dev and "DEV" or Version.display(Config.version) - local toolbar = plugin:CreateToolbar("Rojo Plugin " .. displayedVersion) + local toolbar = plugin:CreateToolbar("Rojo " .. displayedVersion) - toolbar:CreateButton("Test Connection", "Connect to Rojo Server", "") + toolbar:CreateButton("Connect", "Connect to Rojo Session", "") .Click:Connect(function() checkUpgrade() + Session.new() - pluginInstance:connect() - :catch(function(err) - warn(err) - end) - end) - - toolbar:CreateButton("Sync In", "Sync into Roblox Studio", "") - .Click:Connect(function() - checkUpgrade() - - pluginInstance:syncIn() - :catch(function(err) - warn(err) - end) - end) - - toolbar:CreateButton("Toggle Polling", "Poll server for changes", "") - .Click:Connect(function() - checkUpgrade() - - pluginInstance:togglePolling() - :catch(function(err) - warn(err) - end) + error("NYI") end) end diff --git a/plugin/src/Plugin.lua b/plugin/src/Plugin.lua deleted file mode 100644 index 2a734f70..00000000 --- a/plugin/src/Plugin.lua +++ /dev/null @@ -1,257 +0,0 @@ -local Config = require(script.Parent.Config) -local Http = require(script.Parent.Http) -local Api = require(script.Parent.Api) -local Promise = require(script.Parent.Promise) -local Reconciler = require(script.Parent.Reconciler) - -local function collectMatch(source, pattern) - local result = {} - - for match in source:gmatch(pattern) do - table.insert(result, match) - end - - return result -end - -local Plugin = {} -Plugin.__index = Plugin - -function Plugin.new() - local address = "localhost" - local port = Config.dev and 8001 or 8000 - - local remote = ("http://%s:%d"):format(address, port) - - local self = { - _http = Http.new(remote), - _reconciler = Reconciler.new(), - _api = nil, - _polling = false, - } - - setmetatable(self, Plugin) - - do - local screenGui = Instance.new("ScreenGui") - screenGui.Name = "Rojo UI" - screenGui.Parent = game.CoreGui - screenGui.DisplayOrder = -1 - screenGui.Enabled = false - - local label = Instance.new("TextLabel") - label.Font = Enum.Font.SourceSans - label.TextSize = 20 - label.Text = "Rojo polling..." - label.BackgroundColor3 = Color3.fromRGB(31, 31, 31) - label.BackgroundTransparency = 0.5 - label.BorderSizePixel = 0 - label.TextColor3 = Color3.new(1, 1, 1) - label.Size = UDim2.new(0, 120, 0, 28) - label.Position = UDim2.new(0, 0, 0, 0) - label.Parent = screenGui - - self._label = screenGui - end - - return self -end - ---[[ - Clears all state and issues a notice to the user that the plugin has - restarted. -]] -function Plugin:restart() - warn("The server has changed since the last request, reloading plugin...") - - self._reconciler:clear() - self._api = nil - self._polling = false -end - -function Plugin:api() - if not self._api then - self._api = Api.connect(self._http) - :catch(function(err) - self._api = nil - return Promise.reject(err) - end) - end - - return self._api -end - -function Plugin:connect() - print("Testing connection...") - - return self:api() - :andThen(function(api) - local ok, info = api:getInfo():await() - - if not ok then - return Promise.reject(info) - end - - print("Server found!") - print("Protocol version:", info.protocolVersion) - print("Server version:", info.serverVersion) - end) - :catch(function(err) - if err == Api.Error.ServerIdMismatch then - self:restart() - return self:connect() - else - return Promise.reject(err) - end - end) -end - -function Plugin:togglePolling() - if self._polling then - return self:stopPolling() - else - return self:startPolling() - end -end - -function Plugin:stopPolling() - if not self._polling then - return Promise.resolve(false) - end - - print("Stopped polling.") - - self._polling = false - self._label.Enabled = false - - return Promise.resolve(true) -end - -function Plugin:_pull(api, project, routes) - return api:read(routes) - :andThen(function(items) - for index = 1, #routes do - local itemRoute = routes[index] - local partitionName = itemRoute[1] - local partition = project.partitions[partitionName] - local item = items[index] - - local partitionRoute = collectMatch(partition.target, "[^.]+") - - -- If the item route's length was 1, we need to rename the instance to - -- line up with the partition's root object name. - -- - -- This is a HACK! - if #itemRoute == 1 then - if item then - local objectName = partition.target:match("[^.]+$") - item.Name = objectName - end - end - - local fullRoute = {} - for _, piece in ipairs(partitionRoute) do - table.insert(fullRoute, piece) - end - - for i = 2, #itemRoute do - table.insert(fullRoute, itemRoute[i]) - end - - self._reconciler:reconcileRoute(fullRoute, item, itemRoute) - end - end) -end - -function Plugin:startPolling() - if self._polling then - return - end - - print("Starting to poll...") - - self._polling = true - self._label.Enabled = true - - return self:api() - :andThen(function(api) - local syncOk, result = self:syncIn():await() - - if not syncOk then - return Promise.reject(result) - end - - local infoOk, info = api:getInfo():await() - - if not infoOk then - return Promise.reject(info) - end - - while self._polling do - local changesOk, changes = api:getChanges():await() - - if not changesOk then - return Promise.reject(changes) - end - - if #changes > 0 then - local routes = {} - - for _, change in ipairs(changes) do - table.insert(routes, change.route) - end - - local pullOk, pullResult = self:_pull(api, info.project, routes):await() - - if not pullOk then - return Promise.reject(pullResult) - end - end - - wait(Config.pollingRate) - end - end) - :catch(function(err) - self:stopPolling() - - if err == Api.Error.ServerIdMismatch then - self:restart() - return self:startPolling() - else - return Promise.reject(err) - end - end) -end - -function Plugin:syncIn() - print("Syncing from server...") - - return self:api() - :andThen(function(api) - local ok, info = api:getInfo():await() - - if not ok then - return Promise.reject(info) - end - - local routes = {} - - for name in pairs(info.project.partitions) do - table.insert(routes, {name}) - end - - self:_pull(api, info.project, routes) - - print("Sync successful!") - end) - :catch(function(err) - if err == Api.Error.ServerIdMismatch then - self:restart() - return self:syncIn() - else - return Promise.reject(err) - end - end) -end - -return Plugin diff --git a/plugin/src/Reconciler.lua b/plugin/src/Reconciler.lua deleted file mode 100644 index b69929fa..00000000 --- a/plugin/src/Reconciler.lua +++ /dev/null @@ -1,214 +0,0 @@ -local RouteMap = require(script.Parent.RouteMap) - -local function classEqual(rbx, className) - if className == "*" then - return true - end - - return rbx.ClassName == className -end - -local function reparent(rbx, parent) - if rbx then - -- It's possible that 'rbx' is a service or some other object that we - -- can't change the parent of. That's the only reason why Parent would - -- fail except for rbx being previously destroyed! - pcall(function() - rbx.Parent = parent - end) - end -end - ---[[ - Attempts to match up Roblox instances and object specifiers for - reconciliation. - - An object is considered a match if they have the same Name and ClassName. - - primaryChildren and secondaryChildren can each be either a list of Roblox - instances or object specifiers. Since they share a common shape, switching - the two around isn't problematic! - - visited is expected to be an empty table initially. It will be filled with - the set of children that have been visited so far. -]] -local function findNextChildPair(primaryChildren, secondaryChildren, visited) - for _, primaryChild in ipairs(primaryChildren) do - if not visited[primaryChild] then - visited[primaryChild] = true - - for _, secondaryChild in ipairs(secondaryChildren) do - if primaryChild.ClassName == secondaryChild.ClassName and primaryChild.Name == secondaryChild.Name then - visited[secondaryChild] = true - - return primaryChild, secondaryChild - end - end - - return primaryChild, nil - end - end - - return nil, nil -end - -local Reconciler = {} -Reconciler.__index = Reconciler - -function Reconciler.new() - local reconciler = { - _routeMap = RouteMap.new(), - } - - setmetatable(reconciler, Reconciler) - - return reconciler -end - ---[[ - A semi-smart algorithm that attempts to apply the given item's children to - an existing Roblox object. -]] -function Reconciler:_reconcileChildren(rbx, item) - local visited = {} - local rbxChildren = rbx:GetChildren() - - -- Reconcile any children that were added or updated - while true do - local itemChild, rbxChild = findNextChildPair(item.Children, rbxChildren, visited) - - if not itemChild then - break - end - - reparent(self:reconcile(rbxChild, itemChild), rbx) - end - - -- Reconcile any children that were deleted - while true do - local rbxChild, itemChild = findNextChildPair(rbxChildren, item.Children, visited) - - if not rbxChild then - break - end - - reparent(self:reconcile(rbxChild, itemChild), rbx) - end -end - ---[[ - Construct a new Roblox object from the given item. -]] -function Reconciler:_reify(item) - local className = item.ClassName - - -- "*" represents a match of any class. It reifies as a folder! - if className == "*" then - className = "Folder" - end - - local rbx = Instance.new(className) - rbx.Name = item.Name - - for key, property in pairs(item.Properties) do - -- TODO: Check for compound types, like Vector3! - rbx[key] = property.Value - end - - self:_reconcileChildren(rbx, item) - - if item.Route then - self._routeMap:insert(item.Route, rbx) - end - - return rbx -end - ---[[ - Clears any state that the Reconciler has, effectively restarting it. -]] -function Reconciler:clear() - self._routeMap:clear() -end - ---[[ - Apply the changes represented by the given item to a Roblox object that's a - child of the given instance. -]] -function Reconciler:reconcile(rbx, item) - -- Item was deleted - if not item then - if rbx then - rbx:Destroy() - end - - return nil - end - - -- Item was created! - if not rbx then - return self:_reify(item) - end - - -- Item changed type! - if not classEqual(rbx, item.ClassName) then - rbx:Destroy() - - rbx = self:_reify(item) - end - - -- Apply all properties, Roblox will de-duplicate changes - for key, property in pairs(item.Properties) do - -- TODO: Transform property value based on property.Type - -- Right now, we assume that 'value' is primitive! - - rbx[key] = property.Value - end - - -- Use a dumb algorithm for reconciling children - self:_reconcileChildren(rbx, item) - - if item.Route then - self._routeMap:insert(item.Route, rbx) - end - - return rbx -end - -function Reconciler:reconcileRoute(route, item, itemRoute) - local parent - local rbx = game - - for i = 1, #route do - local piece = route[i] - - local child = rbx:FindFirstChild(piece) - - -- We should get services instead of making folders here. - if rbx == game and not child then - local _ - _, child = pcall(game.GetService, game, piece) - end - - -- We don't want to create a folder if we're reaching our target item! - if not child and i ~= #route then - child = Instance.new("Folder") - child.Parent = rbx - child.Name = piece - end - - parent = rbx - rbx = child - end - - -- Let's check the route map! - if not rbx then - rbx = self._routeMap:get(itemRoute) - end - - rbx = self:reconcile(rbx, item) - - reparent(rbx, parent) -end - -return Reconciler diff --git a/plugin/src/RouteMap.lua b/plugin/src/RouteMap.lua deleted file mode 100644 index a707f7cd..00000000 --- a/plugin/src/RouteMap.lua +++ /dev/null @@ -1,103 +0,0 @@ ---[[ - A map from Route objects (given by the server) to Roblox instances (created - by the plugin). -]] - -local function hashRoute(route) - return table.concat(route, "/") -end - -local RouteMap = {} -RouteMap.__index = RouteMap - -function RouteMap.new() - local self = { - _map = {}, - _reverseMap = {}, - _connectionsByRbx = {}, - } - - setmetatable(self, RouteMap) - - return self -end - -function RouteMap:insert(route, rbx) - local hashed = hashRoute(route) - - self._map[hashed] = rbx - self._reverseMap[rbx] = hashed - self._connectionsByRbx[rbx] = rbx.AncestryChanged:Connect(function(_, parent) - if parent == nil then - self:removeByRbx(rbx) - end - end) -end - -function RouteMap:get(route) - return self._map[hashRoute(route)] -end - -function RouteMap:removeByRoute(route) - local hashedRoute = hashRoute(route) - local rbx = self._map[hashedRoute] - - if rbx then - self._map[hashedRoute] = nil - self._reverseMap[rbx] = nil - self._connectionsByRbx[rbx] = nil - end -end - -function RouteMap:removeByRbx(rbx) - local hashedRoute = self._reverseMap[rbx] - - if hashedRoute then - self._map[hashedRoute] = nil - self._reverseMap[rbx] = nil - self._connectionsByRbx[rbx] = nil - end -end - -function RouteMap:removeRbxDescendants(parentRbx) - for rbx in pairs(self._reverseMap) do - if rbx:IsDescendantOf(parentRbx) then - self:removeByRbx(rbx) - end - end -end - -function RouteMap:clear() - self._map = {} - self._reverseMap = {} - - for _, connection in pairs(self._connectionsByRbx) do - connection:Disconnect() - end - - self._connectionsByRbx = {} -end - -function RouteMap:visualize() - -- Log all of our keys so that the visualization has a stable order. - local keys = {} - - for key in pairs(self._map) do - table.insert(keys, key) - end - - table.sort(keys) - - local buffer = {} - for _, key in ipairs(keys) do - local visualized = ("- %s: %s"):format( - key, - self._map[key]:GetFullName() - ) - table.insert(buffer, visualized) - end - - return table.concat(buffer, "\n") -end - -return RouteMap diff --git a/plugin/src/Session.lua b/plugin/src/Session.lua new file mode 100644 index 00000000..744bbac4 --- /dev/null +++ b/plugin/src/Session.lua @@ -0,0 +1,19 @@ +local Config = require(script.Parent.Config) +local Http = require(script.Parent.Http) +local ApiContext = require(script.Parent.ApiContext) +local Promise = require(script.Parent.Promise) + +local REMOTE_URL = ("http://localhost:%d"):format(Config.port) + +local Session = {} +Session.__index = Session + +function Session.new() + local self = {} + + setmetatable(self, Session) + + return self +end + +return Session