rojo/plugin/src/App/init.lua

980 lines
26 KiB
Lua

local ChangeHistoryService = game:GetService("ChangeHistoryService")
local Players = game:GetService("Players")
local ServerStorage = game:GetService("ServerStorage")
local RunService = game:GetService("RunService")
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Log = require(Packages.Log)
local Assets = require(Plugin.Assets)
local Version = require(Plugin.Version)
local Config = require(Plugin.Config)
local Settings = require(Plugin.Settings)
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)
local soundPlayer = require(Plugin.soundPlayer)
local ignorePlaceIds = require(Plugin.ignorePlaceIds)
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)
local StudioToggleButton = require(script.Components.Studio.StudioToggleButton)
local StudioPluginGui = require(script.Components.Studio.StudioPluginGui)
local StudioPluginContext = require(script.Components.Studio.StudioPluginContext)
local StatusPages = require(script.StatusPages)
local AppStatus = strict("AppStatus", {
NotConnected = "NotConnected",
Settings = "Settings",
Permissions = "Permissions",
Connecting = "Connecting",
Confirming = "Confirming",
Connected = "Connected",
Error = "Error",
})
local e = Roact.createElement
local App = Roact.Component:extend("App")
function App:init()
preloadAssets()
local priorHost, priorPort = self:getPriorEndpoint()
self.host, self.setHost = Roact.createBinding(priorHost or "")
self.port, self.setPort = Roact.createBinding(priorPort or "")
self.confirmationBindable = Instance.new("BindableEvent")
self.confirmationEvent = self.confirmationBindable.Event
self.knownProjects = {}
self.notifId = 0
self.waypointConnection = ChangeHistoryService.OnUndo:Connect(function(action: string)
if not string.find(action, "^Rojo: Patch") then
return
end
local undoConnection, redoConnection = nil, nil
local function cleanup()
undoConnection:Disconnect()
redoConnection:Disconnect()
end
Log.warn(
string.format(
"You've undone '%s'.\nIf this was not intended, please Redo in the topbar or with Ctrl/⌘+Y.",
action
)
)
local dismissNotif = self:addNotification(
string.format("You've undone '%s'.\nIf this was not intended, please restore.", action),
10,
{
Restore = {
text = "Restore",
style = "Solid",
layoutOrder = 1,
onClick = function(notification)
cleanup()
notification:dismiss()
ChangeHistoryService:Redo()
end,
},
Dismiss = {
text = "Dismiss",
style = "Bordered",
layoutOrder = 2,
onClick = function(notification)
cleanup()
notification:dismiss()
end,
},
}
)
undoConnection = ChangeHistoryService.OnUndo:Once(function()
-- Our notif is now out of date- redoing will not restore the patch
-- since we've undone even further. Dismiss the notif.
cleanup()
dismissNotif()
end)
redoConnection = ChangeHistoryService.OnRedo:Once(function(redoneAction: string)
if redoneAction == action then
-- The user has restored the patch, so we can dismiss the notif
cleanup()
dismissNotif()
end
end)
end)
self:setState({
appStatus = AppStatus.NotConnected,
guiEnabled = false,
confirmData = {},
patchData = {
patch = PatchSet.newEmpty(),
unapplied = PatchSet.newEmpty(),
timestamp = os.time(),
},
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()
and self.serveSession == nil
and Settings:get("syncReminder")
and self:getLastSyncTimestamp()
then
self:addNotification("You've previously synced this place. Would you like to reconnect?", 300, {
Connect = {
text = "Connect",
style = "Solid",
layoutOrder = 1,
onClick = function(notification)
notification:dismiss()
self:startSession()
end,
},
Dismiss = {
text = "Dismiss",
style = "Bordered",
layoutOrder = 2,
onClick = function(notification)
notification:dismiss()
end,
},
})
end
end
function App:willUnmount()
self.waypointConnection:Disconnect()
self.confirmationBindable:Destroy()
end
function App:addNotification(
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,
}
self:setState({
notifications = notifications,
})
return function()
self:closeNotification(id)
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
end
local notifications = table.clone(self.state.notifications)
notifications[id] = nil
self:setState({
notifications = notifications,
})
end
function App:getPriorEndpoint()
local priorEndpoints = Settings:get("priorEndpoints")
if not priorEndpoints then
return
end
local id = tostring(game.PlaceId)
if ignorePlaceIds[id] then
return
end
local place = priorEndpoints[id]
if not place then
return
end
return place.host, place.port
end
function App:getLastSyncTimestamp()
local priorEndpoints = Settings:get("priorEndpoints")
if not priorEndpoints then
return
end
local id = tostring(game.PlaceId)
if ignorePlaceIds[id] then
return
end
local place = priorEndpoints[id]
if not place then
return
end
return place.timestamp
end
function App:setPriorEndpoint(host: string, port: string)
local priorEndpoints = Settings:get("priorEndpoints")
if not priorEndpoints then
priorEndpoints = {}
end
-- Clear any stale saves to avoid disc bloat
for placeId, endpoint in priorEndpoints do
if os.time() - endpoint.timestamp > 12_960_000 then
priorEndpoints[placeId] = nil
Log.trace("Cleared stale saved endpoint for {}", placeId)
end
end
local id = tostring(game.PlaceId)
if ignorePlaceIds[id] then
return
end
priorEndpoints[id] = {
host = if host ~= Config.defaultHost then host else nil,
port = if port ~= Config.defaultPort then port else nil,
timestamp = os.time(),
}
Log.trace("Saved last used endpoint for {}", game.PlaceId)
Settings:set("priorEndpoints", priorEndpoints)
end
function App:getHostAndPort(): (string, string)
local host = self.host:getValue()
local port = self.port:getValue()
local host = if #host > 0 then host else Config.defaultHost
local port = if #port > 0 then port else Config.defaultPort
return host, port
end
function App:claimSyncLock()
if #Players:GetPlayers() == 0 then
Log.trace("Skipping sync lock because this isn't in Team Create")
return true
end
local lock = ServerStorage:FindFirstChild("__Rojo_SessionLock")
if not lock then
lock = Instance.new("ObjectValue")
lock.Name = "__Rojo_SessionLock"
lock.Archivable = false
lock.Value = Players.LocalPlayer
lock.Parent = ServerStorage
Log.trace("Created and claimed sync lock")
return true
end
if lock.Value and lock.Value ~= Players.LocalPlayer and lock.Value.Parent then
Log.trace("Found existing sync lock owned by {}", lock.Value)
return false, lock.Value
end
lock.Value = Players.LocalPlayer
Log.trace("Claimed existing sync lock")
return true
end
function App:releaseSyncLock()
local lock = ServerStorage:FindFirstChild("__Rojo_SessionLock")
if not lock then
Log.trace("No sync lock found, assumed released")
return
end
if lock.Value == Players.LocalPlayer then
lock.Value = nil
Log.trace("Released sync lock")
return
end
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: {[string]: 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)
end) else nil
self:setState(function(state)
state.popups[source .. " Permissions"] = {
name = name,
content = e(PermissionPopup, {
responseEvent = responseEvent,
initialState = initialState,
source = source,
name = name,
apis = apis,
apiDescriptions = self.headlessAPI._apiDescriptions,
transparency = Roact.createBinding(0),
}),
onClose = function()
responseEvent:Fire(initialState)
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: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))
Log.warn(msg)
self:addNotification(msg, 10)
self:setState({
appStatus = AppStatus.Error,
errorMessage = msg,
toolbarIcon = Assets.Images.PluginButtonWarning,
})
return
end
if host == nil or port == nil then
host, port = self:getHostAndPort()
end
local sessionOptions = {
openScriptsExternally = Settings:get("openScriptsExternally"),
twoWaySync = Settings:get("twoWaySync"),
}
local baseUrl = if string.find(host, "^https?://")
then string.format("%s:%s", host, port)
else string.format("http://%s:%s", host, port)
local apiContext = ApiContext.new(baseUrl)
local serveSession = ServeSession.new({
apiContext = apiContext,
openScriptsExternally = sessionOptions.openScriptsExternally,
twoWaySync = sessionOptions.twoWaySync,
})
self.cleanupPrecommit = serveSession.__reconciler:hookPrecommit(function(patch, instanceMap)
-- Build new tree for patch
self:setState({
patchTree = PatchTree.build(patch, instanceMap, { "Property", "Old", "New" }),
})
end)
self.cleanupPostcommit = serveSession.__reconciler:hookPostcommit(function(patch, instanceMap, unappliedPatch)
-- Update tree with unapplied metadata
self:setState(function(prevState)
return {
patchTree = PatchTree.updateMetadata(prevState.patchTree, patch, instanceMap, unappliedPatch),
}
end)
end)
serveSession:hookPostcommit(function(patch, _instanceMap, unapplied)
local now = os.time()
local old = self.state.patchData
if PatchSet.isEmpty(patch) then
-- Ignore empty patch, but update timestamp
self:setState({
patchData = {
patch = old.patch,
unapplied = old.unapplied,
timestamp = now,
},
})
return
end
if now - old.timestamp < 2 then
-- Patches that apply in the same second are
-- considered to be part of the same change for human clarity
patch = PatchSet.assign(PatchSet.newEmpty(), old.patch, patch)
unapplied = PatchSet.assign(PatchSet.newEmpty(), old.unapplied, unapplied)
end
self:setState({
patchData = {
patch = patch,
unapplied = unapplied,
timestamp = now,
},
})
end)
serveSession:onStatusChanged(function(status, details)
if status == ServeSession.Status.Connecting then
self:setPriorEndpoint(host, port)
self:setState({
appStatus = AppStatus.Connecting,
toolbarIcon = Assets.Images.PluginButton,
})
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:setState({
appStatus = AppStatus.Connected,
projectName = details,
address = address,
toolbarIcon = Assets.Images.PluginButtonConnected,
})
self:addNotification(string.format("Connected to session '%s' at %s.", details, address), 5)
elseif status == ServeSession.Status.Disconnected then
self.serveSession = nil
self:releaseSyncLock()
self:setState({
patchData = {
patch = PatchSet.newEmpty(),
unapplied = PatchSet.newEmpty(),
timestamp = os.time(),
},
})
-- Details being present indicates that this
-- disconnection was from an error.
if details ~= nil then
Log.warn("Disconnected from an error: {}", details)
self:setState({
appStatus = AppStatus.Error,
errorMessage = tostring(details),
toolbarIcon = Assets.Images.PluginButtonWarning,
})
self:addNotification(tostring(details), 10)
else
self:setState({
appStatus = AppStatus.NotConnected,
toolbarIcon = Assets.Images.PluginButton,
})
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)
if PatchSet.isEmpty(patch) then
Log.trace("Accepting patch without confirmation because it is empty")
return "Accept"
end
local confirmationBehavior = Settings:get("confirmationBehavior")
if confirmationBehavior == "Initial" then
-- Only confirm if we haven't synced this project yet this session
if self.knownProjects[serverInfo.projectName] then
Log.trace("Accepting patch without confirmation because project has already been connected and behavior is set to Initial")
return "Accept"
end
elseif confirmationBehavior == "Large Changes" then
-- Only confirm if the patch impacts many instances
if PatchSet.countInstances(patch) < Settings:get("largeChangesConfirmationThreshold") then
Log.trace("Accepting patch without confirmation because patch is small and behavior is set to Large Changes")
return "Accept"
end
elseif confirmationBehavior == "Unlisted PlaceId" then
-- Only confirm if the current placeId is not in the servePlaceIds allowlist
if serverInfo.expectedPlaceIds then
local isListed = table.find(serverInfo.expectedPlaceIds, game.PlaceId) ~= nil
if isListed then
Log.trace("Accepting patch without confirmation because placeId is listed and behavior is set to Unlisted PlaceId")
return "Accept"
end
end
end
-- The datamodel name gets overwritten by Studio, making confirmation of it intrusive
-- and unnecessary. This special case allows it to be accepted without confirmation.
if
PatchSet.hasAdditions(patch) == false
and PatchSet.hasRemoves(patch) == false
and PatchSet.containsOnlyInstance(patch, instanceMap, game)
then
local datamodelUpdates = PatchSet.getUpdateForInstance(patch, instanceMap, game)
if
datamodelUpdates ~= nil
and next(datamodelUpdates.changedProperties) == nil
and datamodelUpdates.changedClassName == nil
then
Log.trace("Accepting patch without confirmation because it only contains a datamodel name change")
return "Accept"
end
end
self:setState({
appStatus = AppStatus.Confirming,
confirmData = {
instanceMap = instanceMap,
patch = patch,
serverInfo = serverInfo,
},
toolbarIcon = Assets.Images.PluginButton,
})
self:addNotification(
string.format(
"Please accept%sor abort the initializing sync session.",
Settings:get("twoWaySync") and ", reject, " or " "
),
7
)
return self.confirmationEvent:Wait()
end)
serveSession:start()
self.serveSession = serveSession
end
function App:endSession()
if self.serveSession == nil then
return
end
Log.trace("Disconnecting session")
self.serveSession:stop()
self.serveSession = nil
self:setState({
appStatus = AppStatus.NotConnected,
})
if self.cleanupPrecommit ~= nil then
self.cleanupPrecommit()
end
if self.cleanupPostcommit ~= nil then
self.cleanupPostcommit()
end
Log.trace("Session terminated by user")
end
function App:render()
local pluginName = "Rojo " .. Version.display(Config.version)
local function createPageElement(appStatus, additionalProps)
additionalProps = additionalProps or {}
local props = Dictionary.merge(additionalProps, {
component = StatusPages[appStatus],
active = self.state.appStatus == appStatus,
})
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,
initDockState = popup.dockState or Enum.InitialDockState.Top,
initEnabled = true,
overridePreviousState = true,
floatingSize = Vector2.new(400, 300),
minimumSize = Vector2.new(390, 240),
zIndexBehavior = Enum.ZIndexBehavior.Sibling,
onClose = popup.onClose,
}, {
Content = popup.content,
Background = Theme.with(function(theme)
return e("Frame", {
Size = UDim2.new(1, 0, 1, 0),
BackgroundColor3 = theme.BackgroundColor,
ZIndex = 0,
BorderSizePixel = 0,
})
end),
})
end
return e(StudioPluginContext.Provider, {
value = self.props.plugin,
}, {
e(Theme.StudioProvider, nil, {
e(Tooltip.Provider, nil, {
popups = Roact.createFragment(popups),
gui = e(StudioPluginGui, {
id = pluginName,
title = pluginName,
active = self.state.guiEnabled,
initDockState = Enum.InitialDockState.Right,
overridePreviousState = false,
floatingSize = Vector2.new(320, 210),
minimumSize = Vector2.new(300, 210),
zIndexBehavior = Enum.ZIndexBehavior.Sibling,
onInitialState = function(initialState)
self:setState({
guiEnabled = initialState,
})
end,
onClose = function()
self:setState({
guiEnabled = false,
})
end,
}, {
Tooltips = e(Tooltip.Container, nil),
NotConnectedPage = createPageElement(AppStatus.NotConnected, {
host = self.host,
onHostChange = self.setHost,
port = self.port,
onPortChange = self.setPort,
onConnect = function()
self:startSession()
end,
onNavigateSettings = function()
self.backPage = AppStatus.NotConnected
self:setState({
appStatus = AppStatus.Settings,
})
end,
}),
ConfirmingPage = createPageElement(AppStatus.Confirming, {
confirmData = self.state.confirmData,
createPopup = not self.state.guiEnabled,
onAbort = function()
self.confirmationBindable:Fire("Abort")
end,
onAccept = function()
self.confirmationBindable:Fire("Accept")
end,
onReject = function()
self.confirmationBindable:Fire("Reject")
end,
}),
Connecting = createPageElement(AppStatus.Connecting),
Connected = createPageElement(AppStatus.Connected, {
projectName = self.state.projectName,
address = self.state.address,
patchTree = self.state.patchTree,
patchData = self.state.patchData,
serveSession = self.serveSession,
onDisconnect = function()
self:endSession()
end,
onNavigateSettings = function()
self.backPage = AppStatus.Connected
self:setState({
appStatus = AppStatus.Settings,
})
end,
}),
Settings = createPageElement(AppStatus.Settings, {
syncActive = self.serveSession ~= nil and self.serveSession:getStatus() == ServeSession.Status.Connected,
onBack = function()
self:setState({
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
local response = self:requestPermission(
plugin,
source,
name,
apiList,
apiMap
)
self.headlessAPI:_setPermissions(source, name, response)
end,
}),
Error = createPageElement(AppStatus.Error, {
errorMessage = self.state.errorMessage,
onClose = function()
self:setState({
appStatus = AppStatus.NotConnected,
toolbarIcon = Assets.Images.PluginButton,
})
end,
}),
}),
RojoNotifications = e("ScreenGui", {
ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
ResetOnSpawn = false,
DisplayOrder = 100,
}, {
layout = e("UIListLayout", {
SortOrder = Enum.SortOrder.LayoutOrder,
HorizontalAlignment = Enum.HorizontalAlignment.Right,
VerticalAlignment = Enum.VerticalAlignment.Bottom,
Padding = UDim.new(0, 5),
}),
padding = e("UIPadding", {
PaddingTop = UDim.new(0, 5),
PaddingBottom = UDim.new(0, 5),
PaddingLeft = UDim.new(0, 5),
PaddingRight = UDim.new(0, 5),
}),
notifs = e(Notifications, {
soundPlayer = self.props.soundPlayer,
notifications = self.state.notifications,
onClose = function(id)
self:closeNotification(id)
end,
}),
}),
}),
toggleAction = e(StudioPluginAction, {
name = "RojoConnection",
title = "Rojo: Connect/Disconnect",
description = "Toggles the server for a Rojo sync session",
icon = Assets.Images.PluginButton,
bindable = true,
onTriggered = function()
if self.serveSession == nil or self.serveSession:getStatus() == ServeSession.Status.NotStarted then
self:startSession()
elseif
self.serveSession ~= nil and self.serveSession:getStatus() == ServeSession.Status.Connected
then
self:endSession()
end
end,
}),
connectAction = e(StudioPluginAction, {
name = "RojoConnect",
title = "Rojo: Connect",
description = "Connects the server for a Rojo sync session",
icon = Assets.Images.PluginButton,
bindable = true,
onTriggered = function()
if self.serveSession == nil or self.serveSession:getStatus() == ServeSession.Status.NotStarted then
self:startSession()
end
end,
}),
disconnectAction = e(StudioPluginAction, {
name = "RojoDisconnect",
title = "Rojo: Disconnect",
description = "Disconnects the server for a Rojo sync session",
icon = Assets.Images.PluginButton,
bindable = true,
onTriggered = function()
if self.serveSession ~= nil and self.serveSession:getStatus() == ServeSession.Status.Connected then
self:endSession()
end
end,
}),
toolbar = e(StudioToolbar, {
name = pluginName,
}, {
button = e(StudioToggleButton, {
name = "Rojo",
tooltip = "Show or hide the Rojo panel",
icon = self.state.toolbarIcon,
active = self.state.guiEnabled,
enabled = true,
onClick = function()
self:setState(function(state)
return {
guiEnabled = not state.guiEnabled,
}
end)
end,
}),
}),
}),
})
end
return function(props)
local mergedProps = Dictionary.merge(props, {
soundPlayer = soundPlayer.new(Settings),
})
return e(App, mergedProps)
end