diff --git a/CHANGELOG.md b/CHANGELOG.md index 088a96697..a5d0874c2 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 @@ -78,6 +80,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 @@ -165,6 +168,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/assets/images/icons/verified.png b/assets/images/icons/verified.png new file mode 100644 index 000000000..3aeecd53e Binary files /dev/null and b/assets/images/icons/verified.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..bfd91a448 --- /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) + local settingTheme = 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?", + FontFace = theme.Font.Main, + TextSize = theme.TextSize.Medium, + TextColor3 = settingTheme.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 + ), + FontFace = theme.Font.Thin, + TextSize = theme.TextSize.Body, + TextColor3 = settingTheme.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 + ), + FontFace = theme.Font.Thin, + TextSize = theme.TextSize.Body, + TextColor3 = settingTheme.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 5bb581c79..8ae10a7b3 100644 --- a/plugin/src/App/Notifications.lua +++ b/plugin/src/App/Notifications.lua @@ -12,6 +12,7 @@ local Theme = require(Plugin.App.Theme) local Assets = require(Plugin.Assets) local bindingUtil = require(Plugin.App.bindingUtil) local getTextBoundsAsync = require(Plugin.App.getTextBoundsAsync) +local getThirdPartyIcon = require(Plugin.getThirdPartyIcon) local BorderedContainer = require(Plugin.App.Components.BorderedContainer) local TextButton = require(Plugin.App.Components.TextButton) @@ -112,22 +113,36 @@ function Notification:render() buttonsX += (count - 1) * 5 end + local thirdPartyName = "" + if self.props.thirdParty and self.props.callerInfo then + thirdPartyName = self.props.callerInfo.Name .. " by " .. self.props.callerInfo.Creator.Name + end + local paddingY, logoSize = 20, 32 - local actionsY = if self.props.actions then 35 else 0 - local textXSpace = math.max(250, buttonsX) + 35 - local textBounds = Vector2.new( - textXSpace, - getTextBoundsAsync(self.props.text, theme.Font.Main, theme.TextSize.Body, textXSpace).Y - ) - local contentX = math.max(textBounds.X, buttonsX) + local actionsY = if self.props.actions then 37 else 0 + local thirdPartyBounds = if self.props.thirdParty + then getTextBoundsAsync(thirdPartyName, theme.Font.Main, theme.TextSize.Body, math.huge) + + ( + if self.props.callerInfo.Creator.HasVerifiedBadge + then Vector2.new(theme.TextSize.Body + 5, 0) + else Vector2.zero + ) + else Vector2.zero + local textXSpace = math.max(250, thirdPartyBounds.X, buttonsX) + 35 + local textBounds = getTextBoundsAsync(self.props.text, theme.Font.Main, theme.TextSize.Body, textXSpace) + local contentX = math.max(textBounds.X, thirdPartyBounds.X, buttonsX) local size = self.binding:map(function(value) return UDim2.fromOffset( (35 + 40 + contentX) * value, - 5 + actionsY + paddingY + math.max(logoSize, textBounds.Y) + 5 + actionsY + thirdPartyBounds.Y + paddingY + math.max(logoSize, textBounds.Y) ) end) + local logoAssetId = if self.props.thirdParty + then getThirdPartyIcon(self.props.callerInfo.Source) + else Assets.Images.PluginButton + return e("TextButton", { BackgroundTransparency = 1, Size = size, @@ -149,12 +164,42 @@ function Notification:render() }, { Logo = e("ImageLabel", { ImageTransparency = transparency, - Image = Assets.Images.PluginButton, + Image = logoAssetId, BackgroundTransparency = 1, Size = UDim2.new(0, logoSize, 0, logoSize), Position = UDim2.new(0, 0, 0, 0), AnchorPoint = Vector2.new(0, 0), }), + ThirdPartyName = if self.props.thirdParty + then e( + "TextLabel", + { + Text = thirdPartyName, + FontFace = theme.Font.Main, + TextSize = theme.TextSize.Body, + TextColor3 = theme.SubTextColor, + TextTransparency = transparency, + TextXAlignment = Enum.TextXAlignment.Left, + TextYAlignment = Enum.TextYAlignment.Top, + Size = UDim2.new(0, 0, 0, thirdPartyBounds.Y), + AutomaticSize = Enum.AutomaticSize.X, + Position = UDim2.fromOffset(35, 0), + + LayoutOrder = 1, + BackgroundTransparency = 1, + }, + if self.props.callerInfo and self.props.callerInfo.Creator.HasVerifiedBadge + then e("ImageLabel", { + Image = Assets.Images.Icons.Verified, + BackgroundTransparency = 1, + SizeConstraint = Enum.SizeConstraint.RelativeYY, + AnchorPoint = Vector2.new(0, 0.5), + Position = UDim2.new(1, 3, 0.5, 0), + Size = UDim2.fromScale(0.8, 0.8), + }) + else nil + ) + else nil, Info = e("TextLabel", { Text = self.props.text, FontFace = theme.Font.Main, @@ -162,11 +207,11 @@ function Notification:render() TextColor3 = theme.Notification.InfoColor, TextTransparency = transparency, TextXAlignment = Enum.TextXAlignment.Left, - TextYAlignment = Enum.TextYAlignment.Top, + TextYAlignment = Enum.TextYAlignment.Center, TextWrapped = true, - Size = UDim2.new(1, -35, 1, -actionsY), - Position = UDim2.fromOffset(35, 0), + Size = UDim2.new(0, textBounds.X, 1, -actionsY - thirdPartyBounds.Y), + Position = UDim2.fromOffset(35, thirdPartyBounds.Y), LayoutOrder = 1, BackgroundTransparency = 1, @@ -189,6 +234,23 @@ function Notification:render() }) else nil, }), + 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), @@ -208,12 +270,14 @@ function Notifications:render() for id, notif in self.props.notifications do notifs["NotifID_" .. id] = e(Notification, { - soundPlayer = self.props.soundPlayer, + layoutOrder = (notif.timestamp - baseClock), text = notif.text, timestamp = notif.timestamp, timeout = notif.timeout, actions = notif.actions, - layoutOrder = (notif.timestamp - baseClock), + soundPlayer = self.props.soundPlayer, + thirdParty = notif.thirdParty, + callerInfo = notif.callerInfo, onClose = function() self.props.onClose(id) end, diff --git a/plugin/src/App/PermissionPopup.lua b/plugin/src/App/PermissionPopup.lua new file mode 100644 index 000000000..662575a1e --- /dev/null +++ b/plugin/src/App/PermissionPopup.lua @@ -0,0 +1,326 @@ +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 getThirdPartyIcon = require(Plugin.getThirdPartyIcon) + +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) + local settingsTheme = theme.Settings + + local iconAsset = getThirdPartyIcon(self.props.source) + + 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 = settingsTheme.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, + FontFace = theme.Font.Thin, + TextSize = theme.TextSize.Medium, + TextColor3 = settingsTheme.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, + FontFace = theme.Font.Thin, + TextSize = theme.TextSize.Body, + TextColor3 = settingsTheme.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, + FontFace = theme.Font.Main, + TextSize = theme.TextSize.Medium, + TextColor3 = settingsTheme.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, + FontFace = theme.Font.Main, + TextSize = theme.TextSize.Medium, + TextColor3 = settingsTheme.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, + FontFace = theme.Font.Main, + TextSize = theme.TextSize.Medium, + TextColor3 = settingsTheme.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 = iconAsset, + LayoutOrder = 1, + }), + + TransactIcon = e("ImageLabel", { + BackgroundTransparency = 1, + Size = UDim2.new(0, 24, 0, 24), + Image = Assets.Images.Icons.Transact, + ImageColor3 = settingsTheme.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]"), + FontFace = theme.Font.Bold, + TextSize = theme.TextSize.Medium, + TextColor3 = settingsTheme.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 = settingsTheme.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/SourceListing.lua b/plugin/src/App/StatusPages/Permissions/SourceListing.lua new file mode 100644 index 000000000..708e88b35 --- /dev/null +++ b/plugin/src/App/StatusPages/Permissions/SourceListing.lua @@ -0,0 +1,203 @@ +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 getTextBoundsAsync = require(Plugin.App.getTextBoundsAsync) + +local SlicedImage = require(Plugin.App.Components.SlicedImage) +local Tooltip = require(Plugin.App.Components.Tooltip) + +local e = Roact.createElement + +local DIVIDER_FADE_SIZE = 0.1 + +local SourceListing = Roact.Component:extend("SourceListing") + +function SourceListing:init() + self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0)) + self.containerSize, self.setContainerSize = Roact.createBinding(Vector2.new(0, 0)) +end + +function SourceListing:render() + local callerInfoFromSource = self.props.callerInfoFromSource + + 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, + }), + }), + }), + + Info = e("Frame", { + Size = UDim2.new(1, 0, 1, 0), + BackgroundTransparency = 1, + }, { + PluginDetails = e("Frame", { + BackgroundTransparency = 1, + Size = UDim2.new(1, 0, 0, theme.TextSize.Medium + theme.TextSize.Body + 4), + }, { + Icon = e("ImageLabel", { + Image = self.props.icon, + BackgroundTransparency = 1, + SizeConstraint = Enum.SizeConstraint.RelativeYY, + AnchorPoint = Vector2.new(0, 0.5), + Position = UDim2.fromScale(0, 0.5), + Size = UDim2.fromScale(0.95, 0.95), + }), + + Name = e("TextLabel", { + Text = callerInfoFromSource.Name, + FontFace = theme.Font.Bold, + TextSize = theme.TextSize.Medium, + TextColor3 = theme.Settings.Setting.NameColor, + TextXAlignment = Enum.TextXAlignment.Left, + TextTransparency = self.props.transparency, + AutomaticSize = Enum.AutomaticSize.X, + Size = UDim2.new(0, 0, 0, theme.TextSize.Medium), + Position = UDim2.new(0, theme.TextSize.Medium + theme.TextSize.Body + 8, 0, 0), + BackgroundTransparency = 1, + }), + + Creator = e( + "TextLabel", + { + Text = "by " .. callerInfoFromSource.Creator.Name, + FontFace = theme.Font.Main, + TextSize = theme.TextSize.Body, + TextColor3 = theme.SubTextColor, + TextXAlignment = Enum.TextXAlignment.Left, + TextTransparency = self.props.transparency, + AutomaticSize = Enum.AutomaticSize.X, + Size = UDim2.fromOffset(0, theme.TextSize.Body), + Position = UDim2.new( + 0, + theme.TextSize.Medium + theme.TextSize.Body + 8, + 0, + theme.TextSize.Medium + 2 + ), + BackgroundTransparency = 1, + }, + if callerInfoFromSource.Creator.HasVerifiedBadge + then e( + "ImageLabel", + { + Image = Assets.Images.Icons.Verified, + BackgroundTransparency = 1, + SizeConstraint = Enum.SizeConstraint.RelativeYY, + AnchorPoint = Vector2.new(0, 0.5), + Position = UDim2.new(1, 3, 0.5, 0), + Size = UDim2.fromScale(0.8, 0.8), + }, + e(Tooltip.Trigger, { + text = "Creator has a verified badge", + }) + ) + else nil + ), + }), + + Description = e("TextLabel", { + Text = callerInfoFromSource.Description, + FontFace = theme.Font.Thin, + LineHeight = 1.2, + TextSize = theme.TextSize.Body, + TextColor3 = theme.Settings.Setting.DescriptionColor, + TextXAlignment = Enum.TextXAlignment.Left, + TextTransparency = self.props.transparency, + TextWrapped = true, + + Size = self.containerSize:map(function(value) + local textBounds = getTextBoundsAsync( + callerInfoFromSource.Description, + theme.Font.Thin, + theme.TextSize.Body, + value.X - 40, + false, + 1.2 + ) + return UDim2.new(1, -40, 0, textBounds.Y) + end), + + LayoutOrder = 2, + BackgroundTransparency = 1, + }), + + Layout = e("UIListLayout", { + VerticalAlignment = Enum.VerticalAlignment.Center, + HorizontalAlignment = Enum.HorizontalAlignment.Left, + 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 SourceListing diff --git a/plugin/src/App/StatusPages/Permissions/init.lua b/plugin/src/App/StatusPages/Permissions/init.lua new file mode 100644 index 000000000..7db4d1800 --- /dev/null +++ b/plugin/src/App/StatusPages/Permissions/init.lua @@ -0,0 +1,164 @@ +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 getThirdPartyIcon = require(Plugin.getThirdPartyIcon) + +local IconButton = require(Plugin.App.Components.IconButton) +local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame) +local Tooltip = require(Plugin.App.Components.Tooltip) +local SourceListing = require(script.SourceListing) + +local e = Roact.createElement + +local function Navbar(props) + return Theme.with(function(theme) + local navbarTheme = 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 = navbarTheme.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", + FontFace = theme.Font.Thin, + TextSize = theme.TextSize.Large, + TextColor3 = navbarTheme.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) + local settingsTheme = theme.Settings + + local sources = {} + for source, permissions in self.state.permissions do + if next(permissions) == nil then + continue + end + + local callerInfoFromSource = self.props.headlessAPI:_getCallerInfoFromSource(source) + sources[source] = e(SourceListing, { + layoutOrder = string.byte(source), + transparency = self.props.transparency, + + callerInfoFromSource = callerInfoFromSource, + + icon = getThirdPartyIcon(source), + + onClick = function() + self.props.onEdit( + self.props.headlessAPI._sourceToPlugin[source], + source, + callerInfoFromSource, + 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.", + FontFace = theme.Font.Thin, + TextSize = theme.TextSize.Large, + TextColor3 = settingsTheme.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 e9ea13c20..47e7596b2 100644 --- a/plugin/src/App/StatusPages/Settings/Setting.lua +++ b/plugin/src/App/StatusPages/Settings/Setting.lua @@ -30,21 +30,6 @@ local TAG_TYPES = { }, } -local function getTextBoundsWithLineHeight( - text: string, - font: Font, - textSize: number, - width: number, - lineHeight: number -) - local textBounds = getTextBoundsAsync(text, font, textSize, width) - - local lineCount = math.ceil(textBounds.Y / textSize) - local lineHeightAbsolute = textSize * lineHeight - - return Vector2.new(textBounds.X, lineHeightAbsolute * lineCount - (lineHeightAbsolute - textSize)) -end - local function getThemeColorFromPath(theme, path) local color = theme for _, key in path do @@ -63,19 +48,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() @@ -202,11 +190,12 @@ function Setting:render() inputSize = self.inputSize, }):map(function(values) local offset = values.inputSize.X + 5 - local textBounds = getTextBoundsWithLineHeight( + local textBounds = getTextBoundsAsync( self.props.description, theme.Font.Main, theme.TextSize.Body, values.containerSize.X - offset, + true, 1.2 ) return UDim2.new(1, -offset, 0, textBounds.Y) diff --git a/plugin/src/App/StatusPages/Settings/init.lua b/plugin/src/App/StatusPages/Settings/init.lua index e0f3413ce..b2657d223 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) @@ -81,181 +82,220 @@ function SettingsPage:render() return layoutOrder end - return Roact.createFragment({ - Navbar = e(Navbar, { - onBack = self.props.onBack, - transparency = self.props.transparency, - layoutOrder = layoutIncrement(), - }), - Content = e(ScrollingFrame, { - size = UDim2.new(1, 0, 1, -47), - position = UDim2.new(0, 0, 0, 47), - contentSize = self.contentSize, - transparency = self.props.transparency, - }, { - ShowNotifications = e(Setting, { - id = "showNotifications", - name = "Show Notifications", - description = "Popup notifications in viewport", + return Theme.with(function(theme) + return Roact.createFragment({ + Navbar = e(Navbar, { + onBack = self.props.onBack, transparency = self.props.transparency, layoutOrder = layoutIncrement(), }), - - SyncReminder = e(Setting, { - id = "syncReminder", - name = "Sync Reminder", - description = "Notify to sync when opening a place that has previously been synced", + Content = e(ScrollingFrame, { + size = UDim2.new(1, 0, 1, -47), + position = UDim2.new(0, 0, 0, 47), + contentSize = self.contentSize, transparency = self.props.transparency, - visible = Settings:getBinding("showNotifications"), - layoutOrder = layoutIncrement(), - }), + }, { + ShowNotifications = e(Setting, { + id = "showNotifications", + name = "Show Notifications", + description = "Popup notifications in viewport", + transparency = self.props.transparency, + layoutOrder = layoutIncrement(), + }), - ConfirmationBehavior = e(Setting, { - id = "confirmationBehavior", - name = "Confirmation Behavior", - description = "When to prompt for confirmation before syncing", - transparency = self.props.transparency, - layoutOrder = layoutIncrement(), + SyncReminder = e(Setting, { + id = "syncReminder", + name = "Sync Reminder", + description = "Notify to sync when opening a place that has previously been synced", + transparency = self.props.transparency, + visible = Settings:getBinding("showNotifications"), + layoutOrder = layoutIncrement(), + }), - options = confirmationBehaviors, - }), + ConfirmationBehavior = e(Setting, { + id = "confirmationBehavior", + name = "Confirmation Behavior", + description = "When to prompt for confirmation before syncing", + transparency = self.props.transparency, + layoutOrder = layoutIncrement(), - LargeChangesConfirmationThreshold = e(Setting, { - id = "largeChangesConfirmationThreshold", - name = "Confirmation Threshold", - description = "How many modified instances to be considered a large change", - transparency = self.props.transparency, - layoutOrder = layoutIncrement(), - visible = Settings:getBinding("confirmationBehavior"):map(function(value) - return value == "Large Changes" - end), - input = e(TextInput, { - size = UDim2.new(0, 40, 0, 28), - text = Settings:getBinding("largeChangesConfirmationThreshold"):map(function(value) - return tostring(value) + options = confirmationBehaviors, + }), + + LargeChangesConfirmationThreshold = e(Setting, { + id = "largeChangesConfirmationThreshold", + name = "Confirmation Threshold", + description = "How many modified instances to be considered a large change", + transparency = self.props.transparency, + layoutOrder = layoutIncrement(), + visible = Settings:getBinding("confirmationBehavior"):map(function(value) + return value == "Large Changes" end), + input = e(TextInput, { + size = UDim2.new(0, 40, 0, 28), + text = Settings:getBinding("largeChangesConfirmationThreshold"):map(function(value) + return tostring(value) + end), + transparency = self.props.transparency, + enabled = true, + onEntered = function(text) + local number = tonumber(string.match(text, "%d+")) + if number then + Settings:set("largeChangesConfirmationThreshold", math.clamp(number, 1, 999)) + else + -- Force text back to last valid value + Settings:set( + "largeChangesConfirmationThreshold", + Settings:get("largeChangesConfirmationThreshold") + ) + end + end, + }), + }), + + Permissions = e(Setting, { + name = "Third Party Permissions", + description = "Manage permissions for third party plugins", transparency = self.props.transparency, - enabled = true, - onEntered = function(text) - local number = tonumber(string.match(text, "%d+")) - if number then - Settings:set("largeChangesConfirmationThreshold", math.clamp(number, 1, 999)) - else - -- Force text back to last valid value - Settings:set( - "largeChangesConfirmationThreshold", - Settings:get("largeChangesConfirmationThreshold") - ) - end - end, + 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, + }), + }), + }), }), - }), - PlaySounds = e(Setting, { - id = "playSounds", - name = "Play Sounds", - description = "Toggle sound effects", - transparency = self.props.transparency, - layoutOrder = layoutIncrement(), - }), + PlaySounds = e(Setting, { + id = "playSounds", + name = "Play Sounds", + description = "Toggle sound effects", + transparency = self.props.transparency, + layoutOrder = layoutIncrement(), + }), - CheckForUpdates = e(Setting, { - id = "checkForUpdates", - name = "Check For Updates", - description = "Notify about newer compatible Rojo releases", - transparency = self.props.transparency, - layoutOrder = layoutIncrement(), - }), + CheckForUpdates = e(Setting, { + id = "checkForUpdates", + name = "Check For Updates", + description = "Notify about newer compatible Rojo releases", + transparency = self.props.transparency, + layoutOrder = layoutIncrement(), + }), - CheckForPreleases = e(Setting, { - id = "checkForPrereleases", - name = "Include Prerelease Updates", - description = "Include prereleases when checking for updates", - transparency = self.props.transparency, - layoutOrder = layoutIncrement(), - visible = if string.find(debug.traceback(), "\n[^\n]-user_.-$") == nil - then false -- Must be a local install to allow prerelease checks - else Settings:getBinding("checkForUpdates"), - }), + CheckForPreleases = e(Setting, { + id = "checkForPrereleases", + name = "Include Prerelease Updates", + description = "Include prereleases when checking for updates", + transparency = self.props.transparency, + layoutOrder = layoutIncrement(), + visible = if string.find(debug.traceback(), "\n[^\n]-user_.-$") == nil + then false -- Must be a local install to allow prerelease checks + else Settings:getBinding("checkForUpdates"), + }), - AutoConnectPlaytestServer = e(Setting, { - id = "autoConnectPlaytestServer", - name = "Auto Connect Playtest Server", - description = "Automatically connect game server to Rojo when playtesting while connected in Edit", - tag = "unstable", - transparency = self.props.transparency, - layoutOrder = layoutIncrement(), - }), + AutoConnectPlaytestServer = e(Setting, { + id = "autoConnectPlaytestServer", + name = "Auto Connect Playtest Server", + description = "Automatically connect game server to Rojo when playtesting while connected in Edit", + tag = "unstable", + transparency = self.props.transparency, + layoutOrder = layoutIncrement(), + }), - OpenScriptsExternally = e(Setting, { - id = "openScriptsExternally", - name = "Open Scripts Externally", - description = "Attempt to open scripts in an external editor", - tag = "unstable", - transparency = self.props.transparency, - layoutOrder = layoutIncrement(), - }), + OpenScriptsExternally = e(Setting, { + id = "openScriptsExternally", + name = "Open Scripts Externally", + description = "Attempt to open scripts in an external editor", + tag = "unstable", + transparency = self.props.transparency, + layoutOrder = layoutIncrement(), + }), - TwoWaySync = e(Setting, { - id = "twoWaySync", - name = "Two-Way Sync", - description = "Editing files in Studio will sync them into the filesystem", - locked = self.props.syncActive, - tag = "unstable", - transparency = self.props.transparency, - layoutOrder = layoutIncrement(), - }), + TwoWaySync = e(Setting, { + id = "twoWaySync", + name = "Two-Way Sync", + description = "Editing files in Studio will sync them into the filesystem", + locked = self.props.syncActive, + tag = "unstable", + transparency = self.props.transparency, + layoutOrder = layoutIncrement(), + }), - LogLevel = e(Setting, { - id = "logLevel", - name = "Log Level", - description = "Plugin output verbosity level", - tag = "debug", - transparency = self.props.transparency, - layoutOrder = layoutIncrement(), + LogLevel = e(Setting, { + id = "logLevel", + name = "Log Level", + description = "Plugin output verbosity level", + tag = "debug", + transparency = self.props.transparency, + layoutOrder = layoutIncrement(), - options = invertedLevels, - showReset = Settings:getBinding("logLevel"):map(function(value) - return value ~= "Info" - end), - onReset = function() - Settings:set("logLevel", "Info") - end, - }), + options = invertedLevels, + showReset = Settings:getBinding("logLevel"):map(function(value) + return value ~= "Info" + end), + onReset = function() + Settings:set("logLevel", "Info") + end, + }), - TypecheckingEnabled = e(Setting, { - id = "typecheckingEnabled", - name = "Typechecking", - description = "Toggle typechecking on the API surface", - tag = "debug", - transparency = self.props.transparency, - layoutOrder = layoutIncrement(), - }), + TypecheckingEnabled = e(Setting, { + id = "typecheckingEnabled", + name = "Typechecking", + description = "Toggle typechecking on the API surface", + tag = "debug", + transparency = self.props.transparency, + layoutOrder = layoutIncrement(), + }), - TimingLogsEnabled = e(Setting, { - id = "timingLogsEnabled", - name = "Timing Logs", - description = "Toggle logging timing of internal actions for benchmarking Rojo performance", - tag = "debug", - transparency = self.props.transparency, - layoutOrder = layoutIncrement(), - }), + TimingLogsEnabled = e(Setting, { + id = "timingLogsEnabled", + name = "Timing Logs", + description = "Toggle logging timing of internal actions for benchmarking Rojo performance", + tag = "debug", + transparency = self.props.transparency, + layoutOrder = layoutIncrement(), + }), - Layout = e("UIListLayout", { - FillDirection = Enum.FillDirection.Vertical, - SortOrder = Enum.SortOrder.LayoutOrder, + Layout = e("UIListLayout", { + FillDirection = Enum.FillDirection.Vertical, + SortOrder = Enum.SortOrder.LayoutOrder, - [Roact.Change.AbsoluteContentSize] = function(object) - self.setContentSize(object.AbsoluteContentSize) - end, - }), + [Roact.Change.AbsoluteContentSize] = function(object) + self.setContentSize(object.AbsoluteContentSize) + end, + }), - Padding = e("UIPadding", { - PaddingLeft = UDim.new(0, 20), - PaddingRight = UDim.new(0, 20), + Padding = e("UIPadding", { + PaddingLeft = UDim.new(0, 20), + PaddingRight = UDim.new(0, 20), + }), }), - }), - }) + }) + end) end return SettingsPage 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/getTextBoundsAsync.lua b/plugin/src/App/getTextBoundsAsync.lua index 2813903e6..de69d3056 100644 --- a/plugin/src/App/getTextBoundsAsync.lua +++ b/plugin/src/App/getTextBoundsAsync.lua @@ -12,7 +12,8 @@ local function getTextBoundsAsync( font: Font, textSize: number, width: number, - richText: boolean? + richText: boolean?, + lineHeight: number? ): Vector2 if type(text) ~= "string" then Log.warn(`Invalid text. Expected string, received {type(text)} instead`) @@ -35,6 +36,13 @@ local function getTextBoundsAsync( return Vector2.zero end + if lineHeight and type(lineHeight) == "number" then + local lineCount = math.ceil(bounds.Y / textSize) + local lineHeightAbsolute = textSize * lineHeight + + return Vector2.new(bounds.X, lineHeightAbsolute * lineCount - (lineHeightAbsolute - textSize)) + end + return bounds end diff --git a/plugin/src/App/init.lua b/plugin/src/App/init.lua index df1bccacb..7624caa4f 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( + callerInfo: HeadlessAPI.CallerInfo, + 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, + callerInfo = callerInfo, + } + + 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, callerInfo, apiMap) + local name = string.format("%s by %s", callerInfo.Name, callerInfo.Creator.Name) + 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..88ff93021 100644 --- a/plugin/src/Assets.lua +++ b/plugin/src/Assets.lua @@ -20,17 +20,21 @@ 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", Exclamation = "rbxassetid://16571172190", SyncSuccess = "rbxassetid://16565035221", SyncWarning = "rbxassetid://16565325171", + Verified = "rbxassetid://128443712983654", }, Diff = { Add = "rbxassetid://10434145835", diff --git a/plugin/src/HeadlessAPI.lua b/plugin/src/HeadlessAPI.lua new file mode 100644 index 000000000..9e8e2f63f --- /dev/null +++ b/plugin/src/HeadlessAPI.lua @@ -0,0 +1,532 @@ +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 cloudIdProductInfoCache = {} +local apiPermissionAllowlist = { + Version = true, + ProtocolVersion = true, + RequestAccess = true, +} + +export type CallerInfo = { + Source: string, + Type: "Local" | "Cloud" | "Studio", + Name: string, + Description: string, + Creator: { + Name: string, + Id: number, + HasVerifiedBadge: boolean, + }, +} + +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 = cloudIdProductInfoCache[cloudId] + if not info then + local success, newInfo = + pcall(MarketplaceService.GetProductInfo, MarketplaceService, tonumber(cloudId), Enum.InfoType.Asset) + if success then + cloudIdProductInfoCache[cloudId] = newInfo + info = newInfo + end + end + + if info then + return info.Name + .. " by " + .. (if info.Creator.CreatorType == "User" then "@" else "") + .. info.Creator.Name + else + -- 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 + end + + return "Command Bar" + end + + function Rojo:_getCallerInfoFromSource(source: string): CallerInfo + local localPlugin = string.match(source, "user_(.+)") + if localPlugin then + return { + Source = source, + Type = "Local", + Name = localPlugin, + Description = "Locally installed plugin.", + Creator = { + Name = "Unknown", + Id = 0, + HasVerifiedBadge = false, + }, + } + end + + local cloudId = string.match(source, "cloud_(%d+)") + if cloudId then + local info = cloudIdProductInfoCache[cloudId] + if not info then + local success, newInfo = + pcall(MarketplaceService.GetProductInfo, MarketplaceService, tonumber(cloudId), Enum.InfoType.Asset) + if success then + cloudIdProductInfoCache[cloudId] = newInfo + info = newInfo + end + end + + if info then + return { + Source = source, + Type = "Cloud", + Name = info.Name, + Description = info.Description, + Creator = { + Name = (if info.Creator.CreatorType == "User" then "@" else "") .. info.Creator.Name, + Id = info.Creator.CreatorTargetId, + HasVerifiedBadge = info.Creator.HasVerifiedBadge, + }, + } + else + return { + Source = source, + Type = "Cloud", + Name = source, + Description = "Could not retrieve plugin asset info.", + Creator = { + Name = "Unknown", + Id = 0, + HasVerifiedBadge = false, + }, + } + end + end + + return { + Source = source, + Type = "Studio", + Name = "Command Bar", + Description = "Command bar in Roblox Studio.", + Creator = { + Name = "N/A", + Id = 0, + HasVerifiedBadge = false, + }, + } + 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:_getCallerInfoFromSource(Rojo:_getCallerSource()), + 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 = {} diff --git a/plugin/src/getThirdPartyIcon.lua b/plugin/src/getThirdPartyIcon.lua new file mode 100644 index 000000000..7c6c856c6 --- /dev/null +++ b/plugin/src/getThirdPartyIcon.lua @@ -0,0 +1,17 @@ +local Rojo = script:FindFirstAncestor("Rojo") +local Plugin = Rojo.Plugin + +local Assets = require(Plugin.Assets) + +return function(source: string?) + if not source then + return Assets.Images.ThirdPartyPlugin + end + + local sourceId = string.match(source, "cloud_(%d+)") + if not sourceId then + return Assets.Images.ThirdPartyPlugin + end + + return string.format("rbxthumb://type=Asset&id=%s&w=150&h=150", sourceId) +end