Stomp out old plugin, begin foundations for v2 plugin

This commit is contained in:
Lucien Greathouse 2018-05-18 00:46:07 -07:00
parent de02966db6
commit 3e4b595e3b
9 changed files with 97 additions and 738 deletions

View file

@ -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<Api>
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

63
plugin/src/ApiContext.lua Normal file
View file

@ -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

View file

@ -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,
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

19
plugin/src/Session.lua Normal file
View file

@ -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