diff --git a/plugin/src/App/Components/ScrollingFrame.lua b/plugin/src/App/Components/ScrollingFrame.lua index 2231aacb..ae301799 100644 --- a/plugin/src/App/Components/ScrollingFrame.lua +++ b/plugin/src/App/Components/ScrollingFrame.lua @@ -25,6 +25,7 @@ local function ScrollingFrame(props) ElasticBehavior = Enum.ElasticBehavior.Always, ScrollingDirection = Enum.ScrollingDirection.Y, + LayoutOrder = props.layoutOrder, Size = props.size, Position = props.position, AnchorPoint = props.anchorPoint, diff --git a/plugin/src/App/Components/Toggle.lua b/plugin/src/App/Components/Toggle.lua new file mode 100644 index 00000000..924fe075 --- /dev/null +++ b/plugin/src/App/Components/Toggle.lua @@ -0,0 +1,134 @@ +local TextService = game:GetService("TextService") + +local Rojo = script:FindFirstAncestor("Rojo") +local Plugin = Rojo.Plugin +local Packages = Rojo.Packages + +local Roact = require(Packages.Roact) + +local Theme = require(Plugin.App.Theme) + +local Checkbox = require(Plugin.App.Components.Checkbox) + +local e = Roact.createElement + +local DIVIDER_FADE_SIZE = 0.1 + +local function getTextBounds(text, textSize, font, lineHeight, bounds) + local textBounds = TextService:GetTextSize(text, textSize, font, bounds) + + local lineCount = textBounds.Y / textSize + local lineHeightAbsolute = textSize * lineHeight + + return Vector2.new(textBounds.X, lineHeightAbsolute * lineCount - (lineHeightAbsolute - textSize)) +end + +local Toggle = Roact.Component:extend("Toggle") + +function Toggle:init() + self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0)) + self.containerSize, self.setContainerSize = Roact.createBinding(Vector2.new(0, 0)) +end + +function Toggle:render() + return Theme.with(function(theme) + theme = theme.Settings + + return e("Frame", { + Size = self.contentSize:map(function(value) + return UDim2.new(1, 0, 0, 20 + value.Y + 20) + end), + LayoutOrder = self.props.layoutOrder, + ZIndex = -self.props.layoutOrder, + BackgroundTransparency = 1, + + [Roact.Change.AbsoluteSize] = function(object) + self.setContainerSize(object.AbsoluteSize) + end, + }, { + Input = e(Checkbox, { + active = self.props.active, + transparency = self.props.transparency, + position = UDim2.new(1, 0, 0.5, 0), + anchorPoint = Vector2.new(1, 0.5), + onClick = self.props.onClick, + }), + + Text = e("Frame", { + Size = UDim2.new(1, 0, 1, 0), + BackgroundTransparency = 1, + }, { + Name = e("TextLabel", { + Text = self.props.name, + Font = Enum.Font.GothamBold, + TextSize = 17, + TextColor3 = theme.Setting.NameColor, + TextXAlignment = Enum.TextXAlignment.Left, + TextTransparency = self.props.transparency, + + Size = UDim2.new(1, 0, 0, 17), + + LayoutOrder = 1, + BackgroundTransparency = 1, + }), + + Description = e("TextLabel", { + Text = self.props.description, + Font = Enum.Font.Gotham, + LineHeight = 1.2, + TextSize = 14, + TextColor3 = theme.Setting.DescriptionColor, + TextXAlignment = Enum.TextXAlignment.Left, + TextTransparency = self.props.transparency, + TextWrapped = true, + + Size = self.containerSize:map(function(value) + local offset = self.props.options ~= nil and 120 or 40 + local textBounds = getTextBounds( + self.props.description, 14, Enum.Font.Gotham, 1.2, + Vector2.new(value.X - offset, math.huge) + ) + return UDim2.new(1, -offset, 0, textBounds.Y) + end), + + LayoutOrder = 2, + BackgroundTransparency = 1, + }), + + Layout = e("UIListLayout", { + VerticalAlignment = Enum.VerticalAlignment.Center, + FillDirection = Enum.FillDirection.Vertical, + SortOrder = Enum.SortOrder.LayoutOrder, + Padding = UDim.new(0, 6), + + [Roact.Change.AbsoluteContentSize] = function(object) + self.setContentSize(object.AbsoluteContentSize) + end, + }), + + Padding = e("UIPadding", { + PaddingTop = UDim.new(0, 20), + PaddingBottom = UDim.new(0, 20), + }), + }), + + Divider = e("Frame", { + BackgroundColor3 = theme.DividerColor, + BackgroundTransparency = self.props.transparency, + Size = UDim2.new(1, 0, 0, 1), + BorderSizePixel = 0, + }, { + Gradient = e("UIGradient", { + Transparency = NumberSequence.new({ + NumberSequenceKeypoint.new(0, 1), + NumberSequenceKeypoint.new(DIVIDER_FADE_SIZE, 0), + NumberSequenceKeypoint.new(1 - DIVIDER_FADE_SIZE, 0), + NumberSequenceKeypoint.new(1, 1), + }), + }), + }), + }) + end) +end + +return Toggle diff --git a/plugin/src/App/PermissionPopup.lua b/plugin/src/App/PermissionPopup.lua new file mode 100644 index 00000000..72947daa --- /dev/null +++ b/plugin/src/App/PermissionPopup.lua @@ -0,0 +1,125 @@ +local Rojo = script:FindFirstAncestor("Rojo") +local Plugin = Rojo.Plugin +local Packages = Rojo.Packages + +local Roact = require(Packages.Roact) + +local Theme = require(Plugin.App.Theme) + +local Toggle = require(Plugin.App.Components.Toggle) +local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame) +local TextButton = require(Plugin.App.Components.TextButton) + +local e = Roact.createElement + +local PermissionPopup = Roact.Component:extend("PermissionPopup") + +function PermissionPopup:init() + self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0)) + self.infoSize, self.setInfoSize = Roact.createBinding(Vector2.new(0, 0)) + + local response = {} + for _, api in self.props.apis do + response[api] = if self.props.initialState[api] == nil then true else self.props.initialState[api] + end + + self:setState({ + response = response, + }) +end + +function PermissionPopup:render() + return Theme.with(function(theme) + theme = theme.Settings + + local apiToggles = {} + for index, api in self.props.apis do + apiToggles[api] = e(Toggle, { + active = self.state.response[api], + name = api, + description = self.props.apiDescriptions[api], + transparency = self.props.transparency, + layoutOrder = index, + onClick = function() + self:setState(function(state) + state.response[api] = not state.response[api] + return state + end) + end, + }) + end + + return e("Frame", { + BackgroundTransparency = 1, + Size = UDim2.new(1, 0, 1, 0), + }, { + Layout = e("UIListLayout", { + FillDirection = Enum.FillDirection.Vertical, + SortOrder = Enum.SortOrder.LayoutOrder, + Padding = UDim.new(0, 5), + HorizontalAlignment = Enum.HorizontalAlignment.Right, + }), + + Padding = e("UIPadding", { + PaddingLeft = UDim.new(0, 20), + PaddingRight = UDim.new(0, 20), + PaddingTop = UDim.new(0, 15), + PaddingBottom = UDim.new(0, 15), + }), + + Info = e("TextLabel", { + BackgroundTransparency = 1, + Size = UDim2.new(1, 0, 0, 0), + AutomaticSize = Enum.AutomaticSize.Y, + Text = string.format("A third-party plugin, %s, is asking to use the following parts of the Rojo API. Please grant/deny access.", self.props.name or "[Unknown]"), + Font = Enum.Font.GothamMedium, + TextSize = 17, + TextColor3 = theme.Setting.NameColor, + TextXAlignment = Enum.TextXAlignment.Left, + TextWrapped = true, + TextTransparency = self.props.transparency, + LayoutOrder = 1, + + [Roact.Change.AbsoluteSize] = function(rbx) + self.setInfoSize(rbx.AbsoluteSize) + end, + }), + + Submit = e(TextButton, { + text = "Submit", + style = "Solid", + transparency = self.props.transparency, + layoutOrder = 3, + onClick = function() + self.props.responseEvent:Fire(self.state.response) + end, + }), + + ScrollingFrame = e(ScrollingFrame, { + size = self.infoSize:map(function(infoSize) + return UDim2.new(1, 0, 1, -infoSize.Y-44) + end), + layoutOrder = 2, + contentSize = self.contentSize, + transparency = self.props.transparency, + }, { + APIToggles = Roact.createFragment(apiToggles), + + Padding = e("UIPadding", { + PaddingRight = UDim.new(0, 15), + }), + + Layout = e("UIListLayout", { + FillDirection = Enum.FillDirection.Vertical, + SortOrder = Enum.SortOrder.LayoutOrder, + + [Roact.Change.AbsoluteContentSize] = function(object) + self.setContentSize(object.AbsoluteContentSize) + end, + }), + }), + }) + end) +end + +return PermissionPopup diff --git a/plugin/src/App/init.lua b/plugin/src/App/init.lua index 57ae92ef..da11f7de 100644 --- a/plugin/src/App/init.lua +++ b/plugin/src/App/init.lua @@ -23,6 +23,7 @@ local Theme = require(script.Theme) local Page = require(script.Page) local Notifications = require(script.Notifications) +local PermissionPopup = require(script.PermissionPopup) local StudioPluginAction = require(script.Components.Studio.StudioPluginAction) local StudioToolbar = require(script.Components.Studio.StudioToolbar) local StudioToggleButton = require(script.Components.Studio.StudioToggleButton) @@ -64,6 +65,7 @@ function App:init() guiEnabled = false, notifications = {}, toolbarIcon = Assets.Images.PluginButton, + popups = {}, }) end @@ -204,6 +206,39 @@ function App:releaseSyncLock() Log.trace("Could not relase sync lock because it is owned by {}", lock.Value) end +function App:requestPermission(source: string, name: string, apis: {string}, initialState: {[string]: boolean?}): {[string]: boolean} + local responseEvent = Instance.new("BindableEvent") + + self:setState(function(state) + state.popups[source] = { + responseEvent = responseEvent, + initialState = initialState, + name = name, + apis = apis, + content = e(PermissionPopup, { + responseEvent = responseEvent, + initialState = initialState, + source = source, + name = name, + apis = apis, + apiDescriptions = self.headlessAPI._apiDescriptions, + transparency = Roact.createBinding(0), + }), + } + return state + end) + + local response = responseEvent.Event:Wait() + responseEvent:Destroy() + + self:setState(function(state) + state.popups[source] = nil + return state + end) + + return response +end + function App:startSession(host: string?, port: string?) local claimedLock, priorOwner = self:claimSyncLock() if not claimedLock then @@ -364,10 +399,50 @@ function App:render() return e(Page, props) end + local popups = {} + for id, popup in self.state.popups do + popups["Rojo_"..id] = e(StudioPluginGui, { + id = id, + title = popup.name .. " Popup", + active = true, + + initDockState = Enum.InitialDockState.Top, + initEnabled = true, + overridePreviousState = true, + floatingSize = Vector2.new(400, 300), + minimumSize = Vector2.new(390, 240), + + zIndexBehavior = Enum.ZIndexBehavior.Sibling, + + onClose = function() + popup.responseEvent:Fire(popup.initialState) + popup.responseEvent:Destroy() + + self:setState(function(state) + state[id] = nil + return state + end) + end, + }, { + Content = popup.content, + + Background = Theme.with(function(theme) + return e("Frame", { + Size = UDim2.new(1, 0, 1, 0), + BackgroundColor3 = theme.BackgroundColor, + ZIndex = 0, + BorderSizePixel = 0, + }) + end), + }) + end + return e(StudioPluginContext.Provider, { value = self.props.plugin, }, { e(Theme.StudioProvider, nil, { + popups = Roact.createFragment(popups), + gui = e(StudioPluginGui, { id = pluginName, title = pluginName, diff --git a/plugin/src/HeadlessAPI.lua b/plugin/src/HeadlessAPI.lua index a8910e51..33168e5b 100644 --- a/plugin/src/HeadlessAPI.lua +++ b/plugin/src/HeadlessAPI.lua @@ -1,22 +1,44 @@ +local MarketplaceService = game:GetService("MarketplaceService") + local Parent = script:FindFirstAncestor("Rojo") local Plugin = Parent.Plugin +local Packages = Parent.Packages + +local Log = require(Packages.Log) local Config = require(Plugin.Config) local Settings = require(Plugin.Settings) local ApiContext = require(Plugin.ApiContext) +local cloudIdInfoCache = {} +local apiPermissionAllowlist = { + Version = true, + ProtocolVersion = true, + RequestAccess = true, +} + local API = {} function API.new(app) local Rojo = {} Rojo._notifRateLimit = {} + Rojo._permissions = Settings:get("apiPermissions") or {} Rojo._changedEvent = Instance.new("BindableEvent") + Rojo._apiDescriptions = {} Rojo.Changed = Rojo._changedEvent.Event + Rojo._apiDescriptions.Changed = "An event that fires when a headless API property changes" + Rojo.Connected = if app.serveSession then app.serveSession:getStatus() == "Connected" else false + Rojo._apiDescriptions.Connected = "Whether or not the plugin is connected to a Rojo server" + Rojo.Address = nil + Rojo._apiDescriptions.Address = "The address (host:port) that the plugin is connected to" + Rojo.ProjectName = nil + Rojo._apiDescriptions.ProjectName = "The name of the project that the plugin is connected to" + Rojo.Version = table.clone(Config.version) Rojo.ProtocolVersion = Config.protocolVersion @@ -26,59 +48,58 @@ function API.new(app) Rojo._changedEvent:Fire(property, value, oldValue) end - function Rojo:_getCallerFull() + function Rojo:_getCallerSource() local traceback = string.split(debug.traceback(), "\n") local topLevel = traceback[#traceback - 1] - local debugPlugin = string.match(topLevel, "^(PluginDebugService%.user_.-)%.") - if debugPlugin then - return debugPlugin - end - - local localPlugin = string.match(topLevel, "^(user_.-)%.") + local localPlugin = string.match(topLevel, "(user_.-)%.") if localPlugin then return localPlugin end - local cloudPlugin = string.match(topLevel, "(cloud_%d-%..-)%.") + local cloudPlugin = string.match(topLevel, "(cloud_%d-)%.") if cloudPlugin then return cloudPlugin end - return "RobloxStudio.CommandBar" + return "RobloxStudio_CommandBar" end - function Rojo:_getCaller() + function Rojo:_getCallerName() local traceback = string.split(debug.traceback(), "\n") local topLevel = traceback[#traceback - 1] - local debugPlugin = string.match(topLevel, "^PluginDebugService%.user_(.-)%.") - if debugPlugin then - return debugPlugin - end - - local localPlugin = string.match(topLevel, "^user_(.-)%.") + local localPlugin = string.match(topLevel, "user_(.-)%.") if localPlugin then return localPlugin end - local cloudPlugin = string.match(topLevel, "cloud_%d-%.(.-)%.") - if cloudPlugin then - return cloudPlugin + local cloudId, cloudInstance = string.match(topLevel, "cloud_(%d-)%.(.-)%.") + if cloudId then + local info = cloudIdInfoCache[cloudId] + if info then + return info.Name .. " by " .. info.Creator.Name + else + local success, newInfo = pcall(MarketplaceService.GetProductInfo, MarketplaceService, tonumber(cloudId), Enum.InfoType.Asset) + if success then + cloudIdInfoCache[cloudId] = newInfo + return newInfo.Name .. " by " .. newInfo.Creator.Name + end + end + + -- Fallback to the name of the instance uploaded inside this plugin + -- The reason this is not ideal is because creators often upload a folder named "Main" or something + return cloudInstance end - return "CommandBar" + return "Command Bar" end function Rojo:_getCallerType() local traceback = string.split(debug.traceback(), "\n") local topLevel = traceback[#traceback - 1] - if string.find(topLevel, "^PluginDebugService%.user_") then - return "Debug" - end - - if string.find(topLevel, "^user_") then + if string.find(topLevel, "user_") then return "Local" end @@ -89,28 +110,95 @@ function API.new(app) return "CommandBar" end - function Rojo:Test(...) - print("Rojo:Test called by", Rojo:_getCaller(), "with args", ...) + function Rojo:_permissionCheck(key: string): boolean + if apiPermissionAllowlist[key] then return true end + + local source = Rojo:_getCallerSource() + if Rojo._permissions[source] == nil then + Rojo._permissions[source] = {} + end + + return not not Rojo._permissions[source][key] end + function Rojo:RequestAccess(apis: {string}): {[string]: boolean} + local source, name = Rojo:_getCallerSource(), Rojo:_getCallerName() + + if Rojo._permissions[source] == nil then + Rojo._permissions[source] = {} + end + + local alreadyAllowed = true + for _, api in apis do + if not Rojo._permissions[source][api] then + alreadyAllowed = false + break + end + end + + if alreadyAllowed then + local response = {} + for _, api in apis do + response[api] = true + end + return response + end + + local response = app:requestPermission(source, name, apis, Rojo._permissions[source]) + + for api, granted in response do + Log.warn(string.format( + "%s Rojo.%s for '%s'", + granted and "Granting permission to" or "Denying permission to", api, name + )) + Rojo._permissions[source][api] = granted + end + Settings:set("apiPermissions", Rojo._permissions) + + return response + end + + function Rojo:Test(...) + local args = table.pack(...) + for i=1, args.n do + local v = args[i] + local t = type(v) + if t == "string" then + args[i] = string.format("%q", v) + else + args[i] = tostring(v) + end + end + + print(string.format( + "Rojo:Test(%s) called from '%s'", + table.concat(args, ", "), Rojo:_getCallerName() + )) + end + Rojo._apiDescriptions.Test = "Prints the given arguments to the console" + function Rojo:ConnectAsync(host: string?, port: number?) app:startSession(host, port) end + Rojo._apiDescriptions.ConnectAsync = "Connects to a Rojo server" function Rojo:DisconnectAsync() app:endSession() end + Rojo._apiDescriptions.DisconnectAsync = "Disconnects from the Rojo server" function Rojo:GetSetting(setting: string): any return Settings:get(setting) end + Rojo._apiDescriptions.GetSetting = "Gets a Rojo setting" function Rojo:SetSetting(setting: string, value: any) return Settings:set(setting, value) end + Rojo._apiDescriptions.SetSetting = "Sets a Rojo setting" function Rojo:Notify(msg: string, timeout: number?) - local source = Rojo:_getCallerFull() + local source = Rojo:_getCallerSource() if Rojo._notifRateLimit[source] == nil then Rojo._notifRateLimit[source] = 0 @@ -123,23 +211,37 @@ function API.new(app) Rojo._notifRateLimit[source] -= 1 end) - app:addThirdPartyNotification(Rojo:_getCaller(), msg, timeout) + app:addThirdPartyNotification(Rojo:_getCallerName(), msg, timeout) return end + Rojo._apiDescriptions.Notify = "Shows a notification in the Rojo UI" function Rojo:GetHostAndPort(): (string, string) return app:getHostAndPort() end + Rojo._apiDescriptions.GetHostAndPort = "Gets the host and port that Rojo is set to" function Rojo:CreateApiContext(baseUrl: string) return ApiContext.new(baseUrl) end + Rojo._apiDescriptions.CreateApiContext = "Creates a new API context" local ReadOnly = setmetatable({}, { __index = function(_, key) + -- Don't expose private members if string.find(key, "^_") then - return nil -- Don't expose private members + return nil end + + -- Permissions check + local granted = Rojo:_permissionCheck(key) + if not granted then + error(string.format( + "Attempted to read Rojo.%s, but the plugin does not have permission to do so.\nPlease first use Rojo:RequestAccess({ \"%s\" }) to gain access to this API.", + key, key + ), 2) + end + return Rojo[key] end, __newindex = function(_, key, value) diff --git a/plugin/src/Settings.lua b/plugin/src/Settings.lua index 0f23c86b..aff0b8ca 100644 --- a/plugin/src/Settings.lua +++ b/plugin/src/Settings.lua @@ -17,6 +17,7 @@ local defaultSettings = { typecheckingEnabled = false, logLevel = "Info", priorEndpoints = {}, + apiPermissions = {}, } local Settings = {}