diff --git a/CHANGELOG.md b/CHANGELOG.md index fbc01f684..ba9a0542f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # Rojo Changelog ## Unreleased Changes + +* Added headless API for Studio companion plugins. ([#639]) * Support for a `$schema` field in all special JSON files (`.project.json`, `.model.json`, and `.meta.json`) ([#974]) * Projects may now manually link `Ref` properties together using `Attributes`. ([#843]) This has two parts: using `id` or `$id` in JSON files or a `Rojo_Target` attribute, an Instance @@ -77,6 +79,7 @@ **All** sync rules are reset between project files, so they must be specified in each one when nesting them. This is to ensure that nothing can break other projects by changing how files are synced! +[#639]: https://github.com/rojo-rbx/rojo/pull/639 [#813]: https://github.com/rojo-rbx/rojo/pull/813 [#832]: https://github.com/rojo-rbx/rojo/pull/832 [#834]: https://github.com/rojo-rbx/rojo/pull/834 @@ -163,6 +166,7 @@ ## [7.4.0-rc1] - October 3, 2023 ### Additions + #### Project format * Added support for `.toml` files to `$path` ([#633]) * Added support for `Font` and `CFrame` attributes ([rbx-dom#299], [rbx-dom#296]) diff --git a/assets/images/icons/settings.png b/assets/images/icons/settings.png new file mode 100644 index 000000000..7cfac3ac4 Binary files /dev/null and b/assets/images/icons/settings.png differ diff --git a/assets/images/syncsuccess.png b/assets/images/icons/syncsuccess.png similarity index 100% rename from assets/images/syncsuccess.png rename to assets/images/icons/syncsuccess.png diff --git a/assets/images/syncwarning.png b/assets/images/icons/syncwarning.png similarity index 100% rename from assets/images/syncwarning.png rename to assets/images/icons/syncwarning.png diff --git a/assets/images/icons/thirdParty.png b/assets/images/icons/thirdParty.png new file mode 100644 index 000000000..cdb1a32cd Binary files /dev/null and b/assets/images/icons/thirdParty.png differ diff --git a/assets/images/icons/transact.png b/assets/images/icons/transact.png new file mode 100644 index 000000000..be49074cb Binary files /dev/null and b/assets/images/icons/transact.png differ diff --git a/plugin/src/App/Components/ScrollingFrame.lua b/plugin/src/App/Components/ScrollingFrame.lua index f2113be03..12b0189eb 100644 --- a/plugin/src/App/Components/ScrollingFrame.lua +++ b/plugin/src/App/Components/ScrollingFrame.lua @@ -31,6 +31,7 @@ local function ScrollingFrame(props) ElasticBehavior = Enum.ElasticBehavior.Always, ScrollingDirection = props.scrollingDirection or Enum.ScrollingDirection.Y, + LayoutOrder = props.layoutOrder, Size = props.size, Position = props.position, AnchorPoint = props.anchorPoint, diff --git a/plugin/src/App/ConflictAPIPopup.lua b/plugin/src/App/ConflictAPIPopup.lua new file mode 100644 index 000000000..2d2c45f64 --- /dev/null +++ b/plugin/src/App/ConflictAPIPopup.lua @@ -0,0 +1,129 @@ +local Rojo = script:FindFirstAncestor("Rojo") +local Plugin = Rojo.Plugin +local Packages = Rojo.Packages + +local Roact = require(Packages.Roact) + +local Config = require(Plugin.Config) +local Version = require(Plugin.Version) +local Theme = require(Plugin.App.Theme) + +local TextButton = require(Plugin.App.Components.TextButton) + +local e = Roact.createElement + +local ConflictAPIPopup = Roact.Component:extend("ConflictAPIPopup") + +function ConflictAPIPopup:render() + return Theme.with(function(theme) + theme = theme.Settings + + return e("Frame", { + BackgroundTransparency = 1, + Size = UDim2.new(1, 0, 1, 0), + }, { + Padding = e("UIPadding", { + PaddingLeft = UDim.new(0, 20), + PaddingRight = UDim.new(0, 20), + PaddingTop = UDim.new(0, 15), + PaddingBottom = UDim.new(0, 15), + }), + + Details = 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, 15), + HorizontalAlignment = Enum.HorizontalAlignment.Center, + }), + + Info = e("TextLabel", { + BackgroundTransparency = 1, + Size = UDim2.new(1, 0, 0, 0), + AutomaticSize = Enum.AutomaticSize.Y, + Text = "There is already a Rojo API exposed by a Rojo plugin. Do you want to overwrite it with this one?", + Font = Enum.Font.GothamMedium, + TextSize = 17, + TextColor3 = theme.Setting.NameColor, + TextXAlignment = Enum.TextXAlignment.Left, + TextWrapped = true, + TextTransparency = self.props.transparency, + LayoutOrder = 1, + }), + + Existing = e("TextLabel", { + BackgroundTransparency = 1, + Size = UDim2.new(1, 0, 0, 0), + AutomaticSize = Enum.AutomaticSize.Y, + Text = string.format( + "Existing: Version %s, Protocol %d", + Version.display(self.props.existingAPI.Version), + self.props.existingAPI.ProtocolVersion + ), + Font = Enum.Font.Gotham, + TextSize = 15, + TextColor3 = theme.Setting.DescriptionColor, + TextXAlignment = Enum.TextXAlignment.Left, + TextTransparency = self.props.transparency, + LayoutOrder = 2, + }), + + Incoming = e("TextLabel", { + BackgroundTransparency = 1, + Size = UDim2.new(1, 0, 0, 0), + AutomaticSize = Enum.AutomaticSize.Y, + Text = string.format( + "Incoming: Version %s, Protocol %d", + Version.display(Config.version), + Config.protocolVersion + ), + Font = Enum.Font.Gotham, + TextSize = 15, + TextColor3 = theme.Setting.DescriptionColor, + TextXAlignment = Enum.TextXAlignment.Left, + TextTransparency = self.props.transparency, + LayoutOrder = 3, + }), + }), + + Actions = e("Frame", { + BackgroundTransparency = 1, + Size = UDim2.new(1, 0, 0, 34), + Position = UDim2.fromScale(0, 1), + AnchorPoint = Vector2.new(0, 1), + }, { + Layout = e("UIListLayout", { + FillDirection = Enum.FillDirection.Horizontal, + SortOrder = Enum.SortOrder.LayoutOrder, + Padding = UDim.new(0, 10), + HorizontalAlignment = Enum.HorizontalAlignment.Right, + }), + + Keep = e(TextButton, { + text = "Keep", + style = "Bordered", + transparency = self.props.transparency, + layoutOrder = 1, + onClick = function() + self.props.onDeny() + end, + }), + + Overwrite = e(TextButton, { + text = "Overwrite", + style = "Solid", + transparency = self.props.transparency, + layoutOrder = 2, + onClick = function() + self.props.onAccept() + end, + }), + }), + }) + end) +end + +return ConflictAPIPopup diff --git a/plugin/src/App/Notifications.lua b/plugin/src/App/Notifications.lua index 682758cbf..e934cc244 100644 --- a/plugin/src/App/Notifications.lua +++ b/plugin/src/App/Notifications.lua @@ -119,14 +119,18 @@ function Notification:render() buttonsX += (count - 1) * 5 end - local paddingY, logoSize = 20, 32 + local paddingX, paddingY, logoSize = 24, 20, 32 + local sourceY = if self.props.source then logoSize + 5 else 0 + local sourceX = if self.props.source + then TextService:GetTextSize(self.props.source, 15, Enum.Font.GothamMedium, Vector2.new(350, 15)).X + logoSize + 5 + else 0 local actionsY = if self.props.actions then 35 else 0 - local contentX = math.max(textBounds.X, buttonsX) + local contentX = math.max(textBounds.X, buttonsX, sourceX) + (if self.props.thirdParty then 0 else logoSize + 3) + 2 local size = self.binding:map(function(value) return UDim2.fromOffset( - (35 + 40 + contentX) * value, - 5 + actionsY + paddingY + math.max(logoSize, textBounds.Y) + (paddingX + contentX) * value, + 5 + actionsY + sourceY + paddingY + math.max(logoSize, textBounds.Y) ) end) @@ -146,56 +150,69 @@ function Notification:render() transparency = transparency, size = UDim2.new(1, 0, 1, 0), }, { - Contents = e("Frame", { - Size = UDim2.new(0, 35 + contentX, 1, -paddingY), - Position = UDim2.new(0, 0, 0, paddingY / 2), + Logo = e("ImageLabel", { + ImageTransparency = transparency, + Image = if self.props.thirdParty + then Assets.Images.ThirdPartyPlugin + else Assets.Images.PluginButton, BackgroundTransparency = 1, - }, { - Logo = e("ImageLabel", { - ImageTransparency = transparency, - Image = Assets.Images.PluginButton, - BackgroundTransparency = 1, - Size = UDim2.new(0, logoSize, 0, logoSize), - Position = UDim2.new(0, 0, 0, 0), - AnchorPoint = Vector2.new(0, 0), - }), - Info = e("TextLabel", { - Text = self.props.text, + Size = UDim2.new(0, logoSize, 0, logoSize), + Position = UDim2.new(0, 0, 0, 0), + AnchorPoint = Vector2.new(0, 0), + }), + Source = if self.props.source + then e("TextLabel", { + Text = self.props.source, Font = Enum.Font.GothamMedium, TextSize = 15, TextColor3 = theme.Notification.InfoColor, TextTransparency = transparency, TextXAlignment = Enum.TextXAlignment.Left, - TextWrapped = true, - - Size = UDim2.new(0, textBounds.X, 0, textBounds.Y), - Position = UDim2.fromOffset(35, 0), + TextTruncate = Enum.TextTruncate.AtEnd, - LayoutOrder = 1, + Size = UDim2.new(1, -logoSize - 5, 0, logoSize), + Position = UDim2.fromOffset(logoSize + 5, 0), BackgroundTransparency = 1, - }), - Actions = if self.props.actions - then e("Frame", { - Size = UDim2.new(1, -40, 0, 35), - Position = UDim2.new(1, 0, 1, 0), - AnchorPoint = Vector2.new(1, 1), - BackgroundTransparency = 1, - }, { - Layout = e("UIListLayout", { - FillDirection = Enum.FillDirection.Horizontal, - HorizontalAlignment = Enum.HorizontalAlignment.Right, - VerticalAlignment = Enum.VerticalAlignment.Center, - SortOrder = Enum.SortOrder.LayoutOrder, - Padding = UDim.new(0, 5), - }), - Buttons = Roact.createFragment(actionButtons), - }) - else nil, + }) + else nil, + Message = e("TextLabel", { + Text = self.props.text, + Font = Enum.Font.GothamMedium, + TextSize = 15, + TextColor3 = theme.Notification.InfoColor, + TextTransparency = transparency, + TextXAlignment = Enum.TextXAlignment.Left, + TextWrapped = true, + + Size = UDim2.new(0, textBounds.X, 0, textBounds.Y), + Position = if self.props.thirdParty + then UDim2.fromOffset(0, logoSize + 5) + else UDim2.fromOffset(logoSize + 3, 0), + BackgroundTransparency = 1, }), + Actions = if self.props.actions + then e("Frame", { + Size = UDim2.new(1, -40, 0, 35), + Position = UDim2.new(1, 0, 1, 0), + AnchorPoint = Vector2.new(1, 1), + BackgroundTransparency = 1, + }, { + Layout = e("UIListLayout", { + FillDirection = Enum.FillDirection.Horizontal, + HorizontalAlignment = Enum.HorizontalAlignment.Right, + VerticalAlignment = Enum.VerticalAlignment.Center, + SortOrder = Enum.SortOrder.LayoutOrder, + Padding = UDim.new(0, 5), + }), + Buttons = Roact.createFragment(actionButtons), + }) + else nil, Padding = e("UIPadding", { - PaddingLeft = UDim.new(0, 17), - PaddingRight = UDim.new(0, 15), + PaddingLeft = UDim.new(0, paddingX / 2), + PaddingRight = UDim.new(0, paddingX / 2), + PaddingTop = UDim.new(0, paddingY / 2), + PaddingBottom = UDim.new(0, paddingY / 2), }), }), }) @@ -214,6 +231,8 @@ function Notifications:render() timestamp = notif.timestamp, timeout = notif.timeout, actions = notif.actions, + source = notif.source, + thirdParty = notif.thirdParty, layoutOrder = (notif.timestamp - baseClock), onClose = function() self.props.onClose(id) diff --git a/plugin/src/App/PermissionPopup.lua b/plugin/src/App/PermissionPopup.lua new file mode 100644 index 000000000..d63cf5d5c --- /dev/null +++ b/plugin/src/App/PermissionPopup.lua @@ -0,0 +1,328 @@ +local Rojo = script:FindFirstAncestor("Rojo") +local Plugin = Rojo.Plugin +local Packages = Rojo.Packages + +local Roact = require(Packages.Roact) + +local Assets = require(Plugin.Assets) +local Theme = require(Plugin.App.Theme) + +local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame) +local TextButton = require(Plugin.App.Components.TextButton) + +local e = Roact.createElement + +local DIVIDER_FADE_SIZE = 0.1 + +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)) +end + +function PermissionPopup:render() + return Theme.with(function(theme) + theme = theme.Settings + + local thumbnail = Assets.Images.ThirdPartyPlugin + local thumbnailId = string.match(self.props.source, "cloud_(%d+)") + if thumbnailId then + thumbnail = string.format("rbxthumb://type=Asset&id=%s&w=150&h=150", thumbnailId) + end + + local apiRequests = { + Event = {}, + Property = {}, + Method = {}, + } + for index, api in self.props.apis do + local apiDesc = self.props.apiDescriptions[api] + + apiRequests[apiDesc.Type][api] = e("Frame", { + LayoutOrder = index, + BackgroundTransparency = 1, + Size = UDim2.new(1, 0, 0, 17), + AutomaticSize = Enum.AutomaticSize.Y, + }, { + Divider = e("Frame", { + BackgroundColor3 = theme.DividerColor, + BackgroundTransparency = self.props.transparency, + Size = UDim2.new(1, 0, 0, 1), + Position = UDim2.new(0, 0, 0, -2), + 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), + }), + }), + }), + Name = e("TextLabel", { + BackgroundTransparency = 1, + Position = UDim2.new(0, 0, 0, 0), + Size = UDim2.new(0, 140, 0, 17), + TextWrapped = true, + AutomaticSize = Enum.AutomaticSize.Y, + Text = api, + Font = Enum.Font.Gotham, + TextSize = 17, + TextColor3 = theme.Setting.NameColor, + TextXAlignment = Enum.TextXAlignment.Left, + TextTransparency = self.props.transparency, + }), + Desc = e("TextLabel", { + BackgroundTransparency = 1, + Position = UDim2.new(0, 145, 0, 0), + Size = UDim2.new(1, -145, 0, 17), + TextWrapped = true, + AutomaticSize = Enum.AutomaticSize.Y, + Text = apiDesc.Description, + Font = Enum.Font.Gotham, + TextSize = 15, + TextColor3 = theme.Setting.DescriptionColor, + TextXAlignment = Enum.TextXAlignment.Left, + TextTransparency = self.props.transparency, + }), + }) + end + + -- Add labels to explain the api types + if next(apiRequests.Event) then + apiRequests.Event["_apiTypeInfo"] = e("TextLabel", { + LayoutOrder = -1, + BackgroundTransparency = 1, + Size = UDim2.new(1, 0, 0, 18), + Text = string.format("%s will be able to listen to these events:", self.props.name), + TextWrapped = true, + AutomaticSize = Enum.AutomaticSize.Y, + Font = Enum.Font.GothamMedium, + TextSize = 17, + TextColor3 = theme.Setting.NameColor, + TextXAlignment = Enum.TextXAlignment.Left, + TextTransparency = self.props.transparency, + }, e("UIPadding", { PaddingBottom = UDim.new(0, 8) })) + end + if next(apiRequests.Property) then + apiRequests.Property["_apiTypeInfo"] = e("TextLabel", { + LayoutOrder = -1, + BackgroundTransparency = 1, + Size = UDim2.new(1, 0, 0, 18), + Text = string.format("%s will be able to read these properties:", self.props.name), + TextWrapped = true, + AutomaticSize = Enum.AutomaticSize.Y, + Font = Enum.Font.GothamMedium, + TextSize = 17, + TextColor3 = theme.Setting.NameColor, + TextXAlignment = Enum.TextXAlignment.Left, + TextTransparency = self.props.transparency, + }, e("UIPadding", { PaddingBottom = UDim.new(0, 8) })) + end + if next(apiRequests.Method) then + apiRequests.Method["_apiTypeInfo"] = e("TextLabel", { + LayoutOrder = -1, + BackgroundTransparency = 1, + Size = UDim2.new(1, 0, 0, 18), + Text = string.format("%s will be able to call these methods:", self.props.name), + TextWrapped = true, + AutomaticSize = Enum.AutomaticSize.Y, + Font = Enum.Font.GothamMedium, + TextSize = 17, + TextColor3 = theme.Setting.NameColor, + TextXAlignment = Enum.TextXAlignment.Left, + TextTransparency = self.props.transparency, + }, e("UIPadding", { PaddingBottom = UDim.new(0, 8) })) + 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, 15), + HorizontalAlignment = Enum.HorizontalAlignment.Center, + }), + + Padding = e("UIPadding", { + PaddingLeft = UDim.new(0, 20), + PaddingRight = UDim.new(0, 20), + PaddingTop = UDim.new(0, 15), + PaddingBottom = UDim.new(0, 15), + }), + + Icons = e("Frame", { + BackgroundTransparency = 1, + Size = UDim2.new(1, 0, 0, 32), + LayoutOrder = 1, + }, { + Layout = e("UIListLayout", { + FillDirection = Enum.FillDirection.Horizontal, + SortOrder = Enum.SortOrder.LayoutOrder, + Padding = UDim.new(0, 5), + HorizontalAlignment = Enum.HorizontalAlignment.Center, + VerticalAlignment = Enum.VerticalAlignment.Center, + }), + + ThirdPartyIcon = e("ImageLabel", { + BackgroundTransparency = 1, + Size = UDim2.new(0, 32, 0, 32), + Image = thumbnail, + LayoutOrder = 1, + }), + + TransactIcon = e("ImageLabel", { + BackgroundTransparency = 1, + Size = UDim2.new(0, 24, 0, 24), + Image = Assets.Images.Icons.Transact, + ImageColor3 = theme.Setting.DescriptionColor, + LayoutOrder = 2, + }), + + RojoIcon = e("ImageLabel", { + BackgroundTransparency = 1, + Size = UDim2.new(0, 32, 0, 32), + Image = Assets.Images.PluginButton, + LayoutOrder = 3, + }), + }), + + Info = e("TextLabel", { + BackgroundTransparency = 1, + Size = UDim2.new(1, 0, 0, 0), + AutomaticSize = Enum.AutomaticSize.Y, + Text = string.format("%s is asking to use the Rojo API", self.props.name or "[Unknown]"), + Font = Enum.Font.GothamBold, + TextSize = 17, + TextColor3 = theme.Setting.NameColor, + TextXAlignment = Enum.TextXAlignment.Center, + TextWrapped = true, + TextTransparency = self.props.transparency, + LayoutOrder = 2, + + [Roact.Change.AbsoluteSize] = function(rbx) + self.setInfoSize(rbx.AbsoluteSize) + end, + }), + + Divider = e("Frame", { + LayoutOrder = 3, + BackgroundColor3 = theme.DividerColor, + BackgroundTransparency = self.props.transparency, + Size = UDim2.new(1, 0, 0, 1), + Position = UDim2.new(0, 0, 0, -2), + 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), + }), + }), + }), + + ScrollingFrame = e(ScrollingFrame, { + size = self.infoSize:map(function(infoSize) + return UDim2.new(0.9, 0, 1, -infoSize.Y - 140) + end), + layoutOrder = 9, + contentSize = self.contentSize, + transparency = self.props.transparency, + }, { + Layout = e("UIListLayout", { + FillDirection = Enum.FillDirection.Vertical, + SortOrder = Enum.SortOrder.LayoutOrder, + Padding = UDim.new(0, 18), + + [Roact.Change.AbsoluteContentSize] = function(object) + self.setContentSize(object.AbsoluteContentSize) + end, + }), + + PropertyRequests = e("Frame", { + BackgroundTransparency = 1, + Size = UDim2.new(1, 0, 0, 0), + AutomaticSize = Enum.AutomaticSize.Y, + LayoutOrder = 1, + }, { + APIs = Roact.createFragment(apiRequests.Property), + Layout = e("UIListLayout", { + FillDirection = Enum.FillDirection.Vertical, + SortOrder = Enum.SortOrder.LayoutOrder, + Padding = UDim.new(0, 4), + }), + }), + + EventRequests = e("Frame", { + BackgroundTransparency = 1, + Size = UDim2.new(1, 0, 0, 0), + AutomaticSize = Enum.AutomaticSize.Y, + LayoutOrder = 2, + }, { + APIs = Roact.createFragment(apiRequests.Event), + Layout = e("UIListLayout", { + FillDirection = Enum.FillDirection.Vertical, + SortOrder = Enum.SortOrder.LayoutOrder, + Padding = UDim.new(0, 4), + }), + }), + + MethodRequests = e("Frame", { + BackgroundTransparency = 1, + Size = UDim2.new(1, 0, 0, 0), + AutomaticSize = Enum.AutomaticSize.Y, + LayoutOrder = 3, + }, { + APIs = Roact.createFragment(apiRequests.Method), + Layout = e("UIListLayout", { + FillDirection = Enum.FillDirection.Vertical, + SortOrder = Enum.SortOrder.LayoutOrder, + Padding = UDim.new(0, 4), + }), + }), + }), + + Actions = e("Frame", { + BackgroundTransparency = 1, + Size = UDim2.new(1, 0, 0, 34), + LayoutOrder = 10, + }, { + Layout = e("UIListLayout", { + FillDirection = Enum.FillDirection.Horizontal, + HorizontalAlignment = Enum.HorizontalAlignment.Center, + VerticalAlignment = Enum.VerticalAlignment.Center, + SortOrder = Enum.SortOrder.LayoutOrder, + Padding = UDim.new(0, 10), + }), + + Deny = e(TextButton, { + text = "Deny", + style = "Bordered", + transparency = self.props.transparency, + layoutOrder = 1, + onClick = function() + self.props.responseEvent:Fire(false) + end, + }), + + Allow = e(TextButton, { + text = "Allow", + style = "Solid", + transparency = self.props.transparency, + layoutOrder = 2, + onClick = function() + self.props.responseEvent:Fire(true) + end, + }), + }), + }) + end) +end + +return PermissionPopup diff --git a/plugin/src/App/StatusPages/Permissions/Listing.lua b/plugin/src/App/StatusPages/Permissions/Listing.lua new file mode 100644 index 000000000..ed3f7b354 --- /dev/null +++ b/plugin/src/App/StatusPages/Permissions/Listing.lua @@ -0,0 +1,158 @@ +local TextService = game:GetService("TextService") + +local Rojo = script:FindFirstAncestor("Rojo") +local Plugin = Rojo.Plugin +local Packages = Rojo.Packages + +local Roact = require(Packages.Roact) + +local Assets = require(Plugin.Assets) +local Theme = require(Plugin.App.Theme) + +local SlicedImage = require(Plugin.App.Components.SlicedImage) + +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 Listing = Roact.Component:extend("Listing") + +function Listing:init() + self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0)) + self.containerSize, self.setContainerSize = Roact.createBinding(Vector2.new(0, 0)) +end + +function Listing:render() + return Theme.with(function(theme) + 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, + }, { + Settings = e("TextButton", { + Text = "", + BackgroundTransparency = 1, + Size = UDim2.fromOffset(28, 28), + Position = UDim2.fromScale(1, 0.5), + AnchorPoint = Vector2.new(1, 0.5), + + [Roact.Event.Activated] = function() + self.props.onClick() + end, + }, { + Button = e(SlicedImage, { + slice = Assets.Slices.RoundedBorder, + color = theme.Checkbox.Inactive.BorderColor, + transparency = self.props.transparency, + size = UDim2.new(1, 0, 1, 0), + }, { + Icon = e("ImageLabel", { + Image = Assets.Images.Icons.Settings, + ImageColor3 = theme.Notification.InfoColor, + ImageTransparency = self.props.transparency, + + Size = UDim2.new(0, 16, 0, 16), + Position = UDim2.new(0.5, 0, 0.5, 0), + AnchorPoint = Vector2.new(0.5, 0.5), + + BackgroundTransparency = 1, + }), + }), + }), + + 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.Settings.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.Settings.Setting.DescriptionColor, + TextXAlignment = Enum.TextXAlignment.Left, + TextTransparency = self.props.transparency, + TextWrapped = true, + + Size = self.containerSize:map(function(value) + local textBounds = getTextBounds( + self.props.description, + 14, + Enum.Font.Gotham, + 1.2, + Vector2.new(value.X - 40, math.huge) + ) + return UDim2.new(1, -40, 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.Settings.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 Listing diff --git a/plugin/src/App/StatusPages/Permissions/init.lua b/plugin/src/App/StatusPages/Permissions/init.lua new file mode 100644 index 000000000..c895e401a --- /dev/null +++ b/plugin/src/App/StatusPages/Permissions/init.lua @@ -0,0 +1,166 @@ +local Rojo = script:FindFirstAncestor("Rojo") +local Plugin = Rojo.Plugin +local Packages = Rojo.Packages + +local Roact = require(Packages.Roact) + +local Assets = require(Plugin.Assets) +local Theme = require(Plugin.App.Theme) + +local IconButton = require(Plugin.App.Components.IconButton) +local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame) +local Tooltip = require(Plugin.App.Components.Tooltip) +local Listing = require(script.Listing) + +local e = Roact.createElement + +local function Navbar(props) + return Theme.with(function(theme) + theme = theme.Settings.Navbar + + return e("Frame", { + Size = UDim2.new(1, 0, 0, 46), + LayoutOrder = props.layoutOrder, + BackgroundTransparency = 1, + }, { + Padding = e("UIPadding", { + PaddingLeft = UDim.new(0, 20), + PaddingRight = UDim.new(0, 20), + }), + + Back = e(IconButton, { + icon = Assets.Images.Icons.Back, + iconSize = 24, + color = theme.BackButtonColor, + transparency = props.transparency, + + position = UDim2.new(0, 0, 0.5, 0), + anchorPoint = Vector2.new(0, 0.5), + + onClick = props.onBack, + }, { + Tip = e(Tooltip.Trigger, { + text = "Back", + }), + }), + + Text = e("TextLabel", { + Text = "Permissions", + Font = Enum.Font.Gotham, + TextSize = 18, + TextColor3 = theme.TextColor, + TextTransparency = props.transparency, + + Size = UDim2.new(1, 0, 1, 0), + + BackgroundTransparency = 1, + }), + }) + end) +end + +local PermissionsPage = Roact.Component:extend("PermissionsPage") + +function PermissionsPage:init() + self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0)) + + self:setState({ + permissions = self.props.headlessAPI._permissions, + }) + + self.changedListener = self.props.headlessAPI._permissionsChanged:Connect(function() + self:setState({ + permissions = self.props.headlessAPI._permissions, + }) + end) +end + +function PermissionsPage:willUnmount() + self.changedListener:Disconnect() +end + +function PermissionsPage:render() + return Theme.with(function(theme) + theme = theme.Settings + + local sources = {} + for source, permissions in self.state.permissions do + if next(permissions) == nil then + continue + end + + local meta = self.props.headlessAPI:_getMetaFromSource(source) + sources[source] = e(Listing, { + layoutOrder = string.byte(source), + transparency = self.props.transparency, + + name = meta.Name, + description = string.format( + "%s plugin%s", + meta.Type, + if meta.Creator then " by " .. meta.Creator else "" + ), + + onClick = function() + self.props.onEdit( + self.props.headlessAPI._sourceToPlugin[source], + source, + meta, + self.props.headlessAPI._permissions[source] or {} + ) + end, + }) + end + + if next(sources) == nil then + sources.noSources = e("TextLabel", { + Text = "No third-party plugins have been granted permissions.", + Font = Enum.Font.Gotham, + TextSize = 18, + TextColor3 = theme.Setting.DescriptionColor, + TextTransparency = self.props.transparency, + TextWrapped = true, + + Size = UDim2.new(1, 0, 0, 48), + LayoutOrder = 0, + + BackgroundTransparency = 1, + }) + end + + return e("Frame", { + Size = UDim2.new(1, 0, 1, 0), + BackgroundTransparency = 1, + }, { + Navbar = e(Navbar, { + onBack = self.props.onBack, + transparency = self.props.transparency, + }), + + PluginSources = e(ScrollingFrame, { + size = UDim2.new(1, 0, 1, -47), + position = UDim2.new(0, 0, 0, 47), + contentSize = self.contentSize, + transparency = self.props.transparency, + }, { + Layout = e("UIListLayout", { + FillDirection = Enum.FillDirection.Vertical, + SortOrder = Enum.SortOrder.LayoutOrder, + + [Roact.Change.AbsoluteContentSize] = function(object) + self.setContentSize(object.AbsoluteContentSize) + end, + }), + + Padding = e("UIPadding", { + PaddingLeft = UDim.new(0, 20), + PaddingRight = UDim.new(0, 20), + }), + + Sources = Roact.createFragment(sources), + }), + }) + end) +end + +return PermissionsPage diff --git a/plugin/src/App/StatusPages/Settings/Setting.lua b/plugin/src/App/StatusPages/Settings/Setting.lua index 00c75d86d..75f391db5 100644 --- a/plugin/src/App/StatusPages/Settings/Setting.lua +++ b/plugin/src/App/StatusPages/Settings/Setting.lua @@ -58,19 +58,22 @@ function Setting:init() self.containerSize, self.setContainerSize = Roact.createBinding(Vector2.new(0, 0)) self.inputSize, self.setInputSize = Roact.createBinding(Vector2.new(0, 0)) - self:setState({ - setting = Settings:get(self.props.id), - }) - - self.changedCleanup = Settings:onChanged(self.props.id, function(value) + if self.props.id then self:setState({ - setting = value, + setting = Settings:get(self.props.id), }) - end) + self.changedCleanup = Settings:onChanged(self.props.id, function(value) + self:setState({ + setting = value, + }) + end) + end end function Setting:willUnmount() - self.changedCleanup() + if self.changedCleanup then + self.changedCleanup() + end end function Setting:render() diff --git a/plugin/src/App/StatusPages/Settings/init.lua b/plugin/src/App/StatusPages/Settings/init.lua index 122a9f281..31c76666c 100644 --- a/plugin/src/App/StatusPages/Settings/init.lua +++ b/plugin/src/App/StatusPages/Settings/init.lua @@ -12,6 +12,7 @@ local Theme = require(Plugin.App.Theme) local IconButton = require(Plugin.App.Components.IconButton) local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame) local Tooltip = require(Plugin.App.Components.Tooltip) +local SlicedImage = require(Plugin.App.Components.SlicedImage) local TextInput = require(Plugin.App.Components.TextInput) local Setting = require(script.Setting) @@ -82,8 +83,6 @@ function SettingsPage:render() end return Theme.with(function(theme) - theme = theme.Settings - return Roact.createFragment({ Navbar = e(Navbar, { onBack = self.props.onBack, @@ -162,6 +161,43 @@ function SettingsPage:render() layoutOrder = layoutIncrement(), }), + Permissions = e(Setting, { + name = "Third Party Permissions", + description = "Manage permissions for third party plugins", + transparency = self.props.transparency, + layoutOrder = layoutIncrement(), + input = e("TextButton", { + Text = "", + BackgroundTransparency = 1, + Size = UDim2.fromOffset(28, 28), + Position = UDim2.fromScale(1, 0.5), + AnchorPoint = Vector2.new(1, 0.5), + + [Roact.Event.Activated] = function() + self.props.onNavigatePermissions() + end, + }, { + Button = e(SlicedImage, { + slice = Assets.Slices.RoundedBackground, + color = theme.Checkbox.Active.BackgroundColor, + transparency = self.props.transparency, + size = UDim2.new(1, 0, 1, 0), + }, { + Icon = e("ImageLabel", { + Image = Assets.Images.Icons.Expand, + ImageColor3 = theme.Checkbox.Active.IconColor, + ImageTransparency = self.props.transparency, + + Size = UDim2.new(0, 16, 0, 16), + Position = UDim2.new(0.5, 0, 0.5, 0), + AnchorPoint = Vector2.new(0.5, 0.5), + + BackgroundTransparency = 1, + }), + }), + }), + }), + CheckForUpdates = e(Setting, { id = "checkForUpdates", name = "Check For Updates", diff --git a/plugin/src/App/StatusPages/init.lua b/plugin/src/App/StatusPages/init.lua index 111c9c55c..4ca4968b9 100644 --- a/plugin/src/App/StatusPages/init.lua +++ b/plugin/src/App/StatusPages/init.lua @@ -1,6 +1,7 @@ return { NotConnected = require(script.NotConnected), Settings = require(script.Settings), + Permissions = require(script.Permissions), Connecting = require(script.Connecting), Confirming = require(script.Confirming), Connected = require(script.Connected), diff --git a/plugin/src/App/init.lua b/plugin/src/App/init.lua index df1bccacb..877b2ff82 100644 --- a/plugin/src/App/init.lua +++ b/plugin/src/App/init.lua @@ -18,6 +18,7 @@ local strict = require(Plugin.strict) local Dictionary = require(Plugin.Dictionary) local ServeSession = require(Plugin.ServeSession) local ApiContext = require(Plugin.ApiContext) +local HeadlessAPI = require(Plugin.HeadlessAPI) local PatchSet = require(Plugin.PatchSet) local PatchTree = require(Plugin.PatchTree) local preloadAssets = require(Plugin.preloadAssets) @@ -28,6 +29,8 @@ local Theme = require(script.Theme) local Page = require(script.Page) local Notifications = require(script.Notifications) +local PermissionPopup = require(script.PermissionPopup) +local ConflictAPIPopup = require(script.ConflictAPIPopup) local Tooltip = require(script.Components.Tooltip) local StudioPluginAction = require(script.Components.Studio.StudioPluginAction) local StudioToolbar = require(script.Components.Studio.StudioToolbar) @@ -39,6 +42,7 @@ local StatusPages = require(script.StatusPages) local AppStatus = strict("AppStatus", { NotConnected = "NotConnected", Settings = "Settings", + Permissions = "Permissions", Connecting = "Connecting", Confirming = "Confirming", Connected = "Connected", @@ -137,8 +141,60 @@ function App:init() }, notifications = {}, toolbarIcon = Assets.Images.PluginButton, + popups = {}, }) + self.headlessAPI, self.readOnlyHeadlessAPI = HeadlessAPI.new(self) + + local existingAPIModule = game:FindFirstChild("Rojo") + if existingAPIModule and existingAPIModule:IsA("ModuleScript") then + local existingAPI = require(existingAPIModule :: ModuleScript) + + local responseEvent = Instance.new("BindableEvent") + responseEvent.Event:Once(function(accepted) + if accepted then + existingAPI.API = self.readOnlyHeadlessAPI + end + + responseEvent:Destroy() + self:setState(function(state) + state.popups["apiReplacement"] = nil + return state + end) + end) + + self:setState(function(state) + state.popups["apiReplacement"] = { + name = "Headless API Conflict", + dockState = Enum.InitialDockState.Float, + onClose = function() + responseEvent:Fire(false) + end, + content = e(ConflictAPIPopup, { + existingAPI = existingAPI.API, + onAccept = function() + responseEvent:Fire(true) + end, + onDeny = function() + responseEvent:Fire(false) + end, + transparency = Roact.createBinding(0), + }), + } + return state + end) + else + local ExposedAPIModule = Instance.new("ModuleScript") + ExposedAPIModule.Name = "Rojo" + ExposedAPIModule.Archivable = false + ExposedAPIModule.Source = "return { API = nil }" + + local ExposedAPI = require(ExposedAPIModule) + ExposedAPI.API = self.readOnlyHeadlessAPI + + ExposedAPIModule.Parent = game + end + if RunService:IsEdit() then self:checkForUpdates() @@ -237,6 +293,40 @@ function App:addNotification( end end +function App:addThirdPartyNotification( + source: string, + text: string, + timeout: number?, + actions: { + [string]: { text: string, style: string, layoutOrder: number, onClick: (any) -> () }, + }? +) + if not Settings:get("showNotifications") then + return + end + + self.notifId += 1 + local id = self.notifId + + local notifications = table.clone(self.state.notifications) + notifications[id] = { + text = text, + timestamp = DateTime.now().UnixTimestampMillis, + timeout = timeout or 3, + actions = actions, + thirdParty = true, + source = source, + } + + self:setState({ + notifications = notifications, + }) + + return function() + self:closeNotification(id) + end +end + function App:closeNotification(id: number) if not self.state.notifications[id] then return @@ -330,7 +420,7 @@ function App:setPriorSyncInfo(host: string, port: string, projectName: string) Settings:set("priorEndpoints", priorSyncInfos) end -function App:getHostAndPort() +function App:getHostAndPort(): (string, string) local host = self.host:getValue() local port = self.port:getValue() @@ -399,7 +489,60 @@ function App:releaseSyncLock() return end - Log.trace("Could not relase sync lock because it is owned by {}", lock.Value) + Log.trace("Could not release sync lock because it is owned by {}", lock.Value) +end + +function App:requestPermission( + plugin: Plugin, + source: string, + name: string, + apis: { string }, + initialState: boolean? +): { [string]: boolean } + local responseEvent = Instance.new("BindableEvent") + + Log.info("The third-party plugin '{}' is requesting permission to use the API!", name) + + local unloadProtection = if plugin + then plugin.Unloading:Connect(function() + Log.warn( + "Cancelling API permission request for '{}' because the third-party plugin has been removed.", + name + ) + responseEvent:Fire(initialState or false) + end) + else nil + + self:setState(function(state) + state.popups[source .. " Permissions"] = { + name = name, + content = e(PermissionPopup, { + responseEvent = responseEvent, + source = source, + name = name, + apis = apis, + apiDescriptions = self.headlessAPI._apiDescriptions, + transparency = Roact.createBinding(0), + }), + onClose = function() + responseEvent:Fire(initialState or false) + end, + } + return state + end) + + local response = responseEvent.Event:Wait() + responseEvent:Destroy() + if unloadProtection then + unloadProtection:Disconnect() + end + + self:setState(function(state) + state.popups[source .. " Permissions"] = nil + return state + end) + + return response end function App:isAutoConnectPlaytestServerAvailable() @@ -445,7 +588,7 @@ function App:useRunningConnectionInfo() self.setPort(port) end -function App:startSession() +function App:startSession(host: string?, port: string?) local claimedLock, priorOwner = self:claimSyncLock() if not claimedLock then local msg = string.format("Could not sync because user '%s' is already syncing", tostring(priorOwner)) @@ -461,7 +604,9 @@ function App:startSession() return end - local host, port = self:getHostAndPort() + if host == nil or port == nil then + host, port = self:getHostAndPort() + end local baseUrl = if string.find(host, "^https?://") then string.format("%s:%s", host, port) @@ -528,11 +673,15 @@ function App:startSession() }) self:addNotification("Connecting to session...") elseif status == ServeSession.Status.Connected then + local address = string.format("%s:%s", host :: string, port :: string) + + self.headlessAPI:_updateProperty("Address", address) + self.headlessAPI:_updateProperty("ProjectName", details) + self.knownProjects[details] = true self:setPriorSyncInfo(host, port, details) self:setRunningConnectionInfo(baseUrl) - local address = ("%s:%s"):format(host, port) self:setState({ appStatus = AppStatus.Connected, projectName = details, @@ -571,6 +720,12 @@ function App:startSession() self:addNotification("Disconnected from session.") end end + + self.headlessAPI:_updateProperty("Connected", status == ServeSession.Status.Connected) + if not self.headlessAPI.Connected then + self.headlessAPI:_updateProperty("Address", nil) + self.headlessAPI:_updateProperty("ProjectName", nil) + end end) serveSession:setConfirmCallback(function(instanceMap, patch, serverInfo) @@ -699,11 +854,33 @@ 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, + active = true, + isEphemeral = true, + + initDockState = popup.dockState or Enum.InitialDockState.Top, + initEnabled = true, + overridePreviousState = true, + floatingSize = Vector2.new(400, 300), + minimumSize = Vector2.new(390, 240) or popup.minimumSize, + + zIndexBehavior = Enum.ZIndexBehavior.Sibling, + + onClose = popup.onClose, + }, popup.content) + end + return e(StudioPluginContext.Provider, { value = self.props.plugin, }, { e(Theme.StudioProvider, nil, { tooltip = e(Tooltip.Provider, nil, { + popups = Roact.createFragment(popups), + gui = e(StudioPluginGui, { id = pluginName, title = pluginName, @@ -794,6 +971,38 @@ function App:render() appStatus = self.backPage or AppStatus.NotConnected, }) end, + + onNavigatePermissions = function() + self:setState({ + appStatus = AppStatus.Permissions, + }) + end, + }), + + Permissions = createPageElement(AppStatus.Permissions, { + headlessAPI = self.headlessAPI, + + onBack = function() + self:setState({ + appStatus = AppStatus.Settings, + }) + end, + + onEdit = function(plugin, source, meta, apiMap) + local name = meta.Name .. if meta.Creator then " by " .. meta.Creator else "" + local apiList = {} + for api in apiMap do + table.insert(apiList, api) + end + table.sort(apiList) + + local granted = self:requestPermission(plugin, source, name, apiList, true) + if granted then + self.headlessAPI:_setPermissions(source, name, apiList) + else + self.headlessAPI:_removePermissions(source, name) + end + end, }), Error = createPageElement(AppStatus.Error, { diff --git a/plugin/src/Assets.lua b/plugin/src/Assets.lua index e48c1217e..88fb12b10 100644 --- a/plugin/src/Assets.lua +++ b/plugin/src/Assets.lua @@ -20,11 +20,14 @@ local Assets = { PluginButton = "rbxassetid://3405341609", PluginButtonConnected = "rbxassetid://9529783993", PluginButtonWarning = "rbxassetid://9529784530", + ThirdPartyPlugin = "rbxassetid://11064843298", Icons = { Close = "rbxassetid://6012985953", Back = "rbxassetid://6017213752", Reset = "rbxassetid://10142422327", Expand = "rbxassetid://12045401097", + Settings = "rbxassetid://12046309515", + Transact = "rbxassetid://16350762910", Warning = "rbxassetid://16571019891", Debug = "rbxassetid://16588411361", Checkmark = "rbxassetid://16571012729", diff --git a/plugin/src/HeadlessAPI.lua b/plugin/src/HeadlessAPI.lua new file mode 100644 index 000000000..9bdad4964 --- /dev/null +++ b/plugin/src/HeadlessAPI.lua @@ -0,0 +1,480 @@ +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._rateLimit = {} + Rojo._sourceToPlugin = {} + Rojo._permissions = Settings:get("apiPermissions") or {} + Rojo._activePermissionRequests = {} + Rojo._changedEvent = Instance.new("BindableEvent") + Rojo._apiDescriptions = {} + + Rojo._apiDescriptions.Changed = { + Type = "Event", + Description = "An event that fires when a Rojo API property changes", + } + Rojo.Changed = Rojo._changedEvent.Event + + Rojo._apiDescriptions.Connected = { + Type = "Property", + Description = "Whether or not the plugin is connected to a Rojo server", + } + Rojo.Connected = if app.serveSession then app.serveSession:getStatus() == "Connected" else false + + Rojo._apiDescriptions.Address = { + Type = "Property", + Description = "The address (host:port) that the plugin is connected to", + } + Rojo.Address = nil + + Rojo._apiDescriptions.ProjectName = { + Type = "Property", + Description = "The name of the project that the plugin is connected to", + } + Rojo.ProjectName = nil + + Rojo._apiDescriptions.Version = { + Type = "Property", + Description = "The version of the plugin", + } + Rojo.Version = table.clone(Config.version) + + Rojo._apiDescriptions.ProtocolVersion = { + Type = "Property", + Description = "The protocol version that the plugin is using", + } + Rojo.ProtocolVersion = Config.protocolVersion + + function Rojo:_updateProperty(property: string, value: any?) + local oldValue = Rojo[property] + Rojo[property] = value + Rojo._changedEvent:Fire(property, value, oldValue) + end + + function Rojo:_getCallerSource() + local traceback = string.split(debug.traceback(), "\n") + local topLevel = traceback[#traceback - 1] + + local localPlugin = string.match(topLevel, "user_.-%.%w+") + if localPlugin then + return localPlugin + end + + local cloudPlugin = string.match(topLevel, "(cloud_%d-)%.") + if cloudPlugin then + return cloudPlugin + end + + return "RobloxStudio_CommandBar" + end + + function Rojo:_getCallerName() + local traceback = string.split(debug.traceback(), "\n") + local topLevel = traceback[#traceback - 1] + + local localPlugin = string.match(topLevel, "user_(.-)%.") + if localPlugin then + return localPlugin + end + + local cloudId, cloudInstance = string.match(topLevel, "cloud_(%d-)%.(.-)[^%w_%-]") + 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 "Command Bar" + end + + function Rojo:_getMetaFromSource(source) + local localPlugin = string.match(source, "user_(.+)") + if localPlugin then + return { + Type = "Local", + Name = localPlugin, + } + end + + local cloudId = string.match(source, "cloud_(%d+)") + if cloudId then + local info = cloudIdInfoCache[cloudId] + if info then + return { + Type = "Cloud", + Name = info.Name, + Creator = info.Creator.Name, + } + else + local success, newInfo = + pcall(MarketplaceService.GetProductInfo, MarketplaceService, tonumber(cloudId), Enum.InfoType.Asset) + if success then + cloudIdInfoCache[cloudId] = newInfo + return { + Type = "Cloud", + Name = newInfo.Name, + Creator = newInfo.Creator.Name, + } + end + end + end + + return { + Type = "Studio", + Name = "Command Bar", + } + end + + function Rojo:_getCallerType() + local traceback = string.split(debug.traceback(), "\n") + local topLevel = traceback[#traceback - 1] + + if string.find(topLevel, "user_") then + return "Local" + end + + if string.find(topLevel, "cloud_%d+%.") then + return "Cloud" + end + + return "CommandBar" + end + + local BUCKET, LIMIT = 10, 15 + function Rojo:_checkRateLimit(api: string): boolean + local source = Rojo:_getCallerSource() + + if Rojo._rateLimit[source] == nil then + Rojo._rateLimit[source] = { + [api] = 0, + } + elseif Rojo._rateLimit[source][api] == nil then + Rojo._rateLimit[source][api] = 0 + elseif Rojo._rateLimit[source][api] >= LIMIT then + -- No more than LIMIT requests per BUCKET seconds + return true + end + + Rojo._rateLimit[source][api] += 1 + task.delay(BUCKET, function() + Rojo._rateLimit[source][api] -= 1 + end) + + return false + end + + Rojo._permissionsChangedEvent = Instance.new("BindableEvent") + Rojo._permissionsChanged = Rojo._permissionsChangedEvent.Event + + function Rojo:_permissionCheck(key: string): boolean + if apiPermissionAllowlist[key] then + return true + end + + local source = Rojo:_getCallerSource() + if Rojo._permissions[source] == nil then + return false + end + + return not not Rojo._permissions[source][key] + end + + function Rojo:_setPermissions(source, name, permissions) + if next(permissions) == nil then + Rojo:_removePermissions(source, name) + return + end + + -- Set permissions + local sourcePermissions = {} + for _, api in permissions do + Log.info(string.format("Granting '%s' access to Rojo.%s", name, api)) + sourcePermissions[api] = true + end + + -- Update stored permissions + Rojo._permissions[source] = sourcePermissions + Settings:set("apiPermissions", Rojo._permissions) + + -- Share changes + Rojo._permissionsChangedEvent:Fire(source, sourcePermissions) + end + + function Rojo:_removePermissions(source, name) + Rojo._permissions[source] = nil + Log.info(string.format("Denying access to Rojo APIs for '%s'", name)) + + -- Update stored permissions + Settings:set("apiPermissions", Rojo._permissions) + + -- Share changes + Rojo._permissionsChangedEvent:Fire(source, nil) + end + + Rojo._apiDescriptions.RequestAccess = { + Type = "Method", + Description = "Used to gain access to Rojo API members", + } + function Rojo:RequestAccess(plugin: Plugin, apis: { string }): boolean + assert(type(apis) == "table", "Rojo:RequestAccess expects an array of valid API names as the second argument") + assert( + typeof(plugin) == "Instance" and plugin:IsA("Plugin"), + "Rojo:RequestAccess expects a Plugin as the first argument" + ) + + local source, name = Rojo:_getCallerSource(), Rojo:_getCallerName() + Rojo._sourceToPlugin[source] = plugin + + if Rojo:_checkRateLimit("RequestAccess") then + -- Because this opens a popup, we dont want to let users get spammed by it + return false + end + + if Rojo._activePermissionRequests[source] then + -- If a request is already active, exit + error( + "Rojo:RequestAccess cannot be called in multiple threads at once. Please call it once and wait for the response before calling it again.", + 2 + ) + end + Rojo._activePermissionRequests[source] = true + + -- Sanitize request + local sanitizedApis = {} + for _, api in apis do + if Rojo._apiDescriptions[api] ~= nil and table.find(sanitizedApis, api) == nil then + table.insert(sanitizedApis, api) + else + warn(string.format("Rojo.%s is not a valid API", tostring(api))) + end + end + assert(#sanitizedApis > 0, "Rojo:RequestAccess expects an array of valid API names") + table.sort(sanitizedApis) + + local alreadyAllowed = true + if Rojo._permissions[source] == nil then + alreadyAllowed = false + else + for _, api in sanitizedApis do + if not Rojo._permissions[source][api] then + alreadyAllowed = false + break + end + end + end + + if alreadyAllowed then + Rojo._activePermissionRequests[source] = nil + return true + end + + local granted = app:requestPermission(plugin, source, name, sanitizedApis, false) + if granted then + Rojo:_setPermissions(source, name, sanitizedApis) + else + Rojo:_removePermissions(source, name) + end + + Rojo._activePermissionRequests[source] = nil + return granted + end + + Rojo._apiDescriptions.Test = { + Type = "Method", + Description = "Prints the given arguments to the console. Useful during development for testing purposes.", + } + 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' (%s)", + table.concat(args, ", "), + Rojo:_getCallerName(), + Rojo:_getCallerSource() + ) + ) + end + + Rojo._apiDescriptions.ConnectAsync = { + Type = "Method", + Description = "Connects to a Rojo server", + } + function Rojo:ConnectAsync(host: string?, port: string?) + assert(type(host) == "string" or host == nil, "Host must be type `string?`") + assert(type(port) == "string" or port == nil, "Port must be type `string?`") + + if Rojo:_checkRateLimit("ConnectAsync") then + return + end + + app:startSession(host, port) + end + + Rojo._apiDescriptions.DisconnectAsync = { + Type = "Method", + Description = "Disconnects from the Rojo server", + } + function Rojo:DisconnectAsync() + if Rojo:_checkRateLimit("DisconnectAsync") then + return + end + + app:endSession() + end + + Rojo._apiDescriptions.GetSetting = { + Type = "Method", + Description = "Gets a Rojo setting", + } + function Rojo:GetSetting(setting: string): any + assert(type(setting) == "string", "Setting must be type `string`") + + return Settings:get(setting) + end + + Rojo._apiDescriptions.Notify = { + Type = "Method", + Description = "Shows a notification in the Rojo UI", + } + function Rojo:Notify( + msg: string, + timeout: number?, + actions: { [string]: { text: string, style: string, layoutOrder: number, onClick: () -> () } }? + ): () -> () + assert(type(msg) == "string", "Message must be type `string`") + assert(type(timeout) == "number" or timeout == nil, "Timeout must be type `number?`") + assert((actions == nil) or (type(actions) == "table"), "Actions must be table or nil") + + if Rojo:_checkRateLimit("Notify") then + return function() end + end + + local sanitizedActions = nil + if actions then + sanitizedActions = {} + for id, action in actions do + assert(type(id) == "string", "Actions key must be string") + local actionId = "Actions." .. id + assert(type(action) == "table", actionId .. " must be table") + assert(type(action.text) == "string", actionId .. ".text must be string") + assert(type(action.style) == "string", actionId .. ".style must be string") + assert( + action.style == "Solid" or action.style == "Bordered", + actionId .. ".style must be 'Solid' or 'Bordered'" + ) + assert(type(action.layoutOrder) == "number", actionId .. ".layoutOrder must be number") + assert(type(action.onClick) == "function", actionId .. ".onClick must be function") + + sanitizedActions[id] = { + text = action.text, + style = action.style, + layoutOrder = action.layoutOrder, + onClick = function() + task.spawn(action.onClick) + end, + } + end + end + + return app:addThirdPartyNotification(Rojo:_getCallerName(), msg, timeout, sanitizedActions) + end + + Rojo._apiDescriptions.GetHostAndPort = { + Type = "Method", + Description = "Gets the host and port that Rojo is set to", + } + function Rojo:GetHostAndPort(): (string, string) + return app:getHostAndPort() + end + + Rojo._apiDescriptions.CreateApiContext = { + Type = "Method", + Description = "Creates a new API context", + } + function Rojo:CreateApiContext(baseUrl: string) + assert(type(baseUrl) == "string", "Base URL must be type `string`") + + return ApiContext.new(baseUrl) + end + + local ReadOnly = newproxy(true) + local Metatable = getmetatable(ReadOnly) + Metatable.__index = function(_, key) + -- Don't expose private members + if string.find(key, "^_") then + return nil + end + + -- Existence check + if Rojo._apiDescriptions[key] == nil then + warn(string.format("Rojo.%s is not a valid API", tostring(key))) + 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 + Metatable.__newindex = function(_, key, value) + error(string.format("Attempted to set Rojo.%s to %q but it's a read-only value", key, value), 2) + return + end + Metatable.__metatable = "The metatable of the Rojo API is locked" + + return Rojo, ReadOnly +end + +return API diff --git a/plugin/src/Settings.lua b/plugin/src/Settings.lua index 7810635b3..17971ecd8 100644 --- a/plugin/src/Settings.lua +++ b/plugin/src/Settings.lua @@ -24,6 +24,7 @@ local defaultSettings = { logLevel = "Info", timingLogsEnabled = false, priorEndpoints = {}, + apiPermissions = {}, } local Settings = {}