Files
rojo/plugin/src/App/init.lua
2024-11-05 22:47:07 +00:00

910 lines
25 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 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 timeUtil = require(Plugin.timeUtil)
local Theme = require(script.Theme)
local Page = require(script.Page)
local Notifications = require(script.Notifications)
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",
Connecting = "Connecting",
Confirming = "Confirming",
Connected = "Connected",
Error = "Error",
})
local e = Roact.createElement
local App = Roact.Component:extend("App")
function App:init()
preloadAssets()
local priorSyncInfo = self:getPriorSyncInfo()
self.host, self.setHost = Roact.createBinding(priorSyncInfo.host or "")
self.port, self.setPort = Roact.createBinding(priorSyncInfo.port 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.disconnectUpdatesCheckChanged = Settings:onChanged("checkForUpdates", function()
self:checkForUpdates()
end)
self.disconnectPrereleasesCheckChanged = Settings:onChanged("checkForPrereleases", function()
self:checkForUpdates()
end)
self:setState({
appStatus = AppStatus.NotConnected,
guiEnabled = false,
confirmData = {},
patchData = {
patch = PatchSet.newEmpty(),
unapplied = PatchSet.newEmpty(),
timestamp = os.time(),
},
notifications = {},
toolbarIcon = Assets.Images.PluginButton,
})
if RunService:IsEdit() then
self:checkForUpdates()
if
Settings:get("syncReminder")
and self.serveSession == nil
and self:getPriorSyncInfo().timestamp ~= nil
and (self:isSyncLockAvailable())
then
local syncInfo = self:getPriorSyncInfo()
local timeSinceSync = timeUtil.elapsedToText(os.time() - syncInfo.timestamp)
local syncDetail = if syncInfo.projectName
then `project '{syncInfo.projectName}'`
else `{syncInfo.host or Config.defaultHost}:{syncInfo.port or Config.defaultPort}`
self:addNotification(
`You synced {syncDetail} to this place {timeSinceSync}. 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
if self:isAutoConnectPlaytestServerAvailable() then
self:useRunningConnectionInfo()
self:startSession()
end
self.autoConnectPlaytestServerListener = Settings:onChanged("autoConnectPlaytestServer", function(enabled)
if enabled then
if self:isAutoConnectPlaytestServerWriteable() and self.serveSession ~= nil then
-- Write the existing session
local baseUrl = self.serveSession.__apiContext.__baseUrl
self:setRunningConnectionInfo(baseUrl)
end
else
self:clearRunningConnectionInfo()
end
end)
end
function App:willUnmount()
self.waypointConnection:Disconnect()
self.confirmationBindable:Destroy()
self.disconnectUpdatesCheckChanged()
self.disconnectPrereleasesCheckChanged()
self.autoConnectPlaytestServerListener()
self:clearRunningConnectionInfo()
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: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:checkForUpdates()
if not Settings:get("checkForUpdates") then
return
end
local isLocalInstall = string.find(debug.traceback(), "\n[^\n]-user_.-$") ~= nil
local latestCompatibleVersion = Version.retrieveLatestCompatible({
version = Config.version,
includePrereleases = isLocalInstall and Settings:get("checkForPrereleases"),
})
if not latestCompatibleVersion then
return
end
self:addNotification(
string.format(
"A newer compatible version of Rojo, %s, was published %s! Go to the Rojo releases page to learn more.",
Version.display(latestCompatibleVersion.version),
timeUtil.elapsedToText(DateTime.now().UnixTimestamp - latestCompatibleVersion.publishedUnixTimestamp)
),
500,
{
Dismiss = {
text = "Dismiss",
style = "Bordered",
layoutOrder = 2,
onClick = function(notification)
notification:dismiss()
end,
},
}
)
end
function App:getPriorSyncInfo(): { host: string?, port: string?, projectName: string?, timestamp: number? }
local priorSyncInfos = Settings:get("priorEndpoints")
if not priorSyncInfos then
return {}
end
local id = tostring(game.PlaceId)
if ignorePlaceIds[id] then
return {}
end
return priorSyncInfos[id] or {}
end
function App:setPriorSyncInfo(host: string, port: string, projectName: string)
local priorSyncInfos = Settings:get("priorEndpoints")
if not priorSyncInfos then
priorSyncInfos = {}
end
local now = os.time()
-- Clear any stale saves to avoid disc bloat
for placeId, syncInfo in priorSyncInfos do
if now - (syncInfo.timestamp or now) > 12_960_000 then
priorSyncInfos[placeId] = nil
Log.trace("Cleared stale saved endpoint for {}", placeId)
end
end
local id = tostring(game.PlaceId)
if ignorePlaceIds[id] then
return
end
priorSyncInfos[id] = {
host = if host ~= Config.defaultHost then host else nil,
port = if port ~= Config.defaultPort then port else nil,
projectName = projectName,
timestamp = now,
}
Log.trace("Saved last used endpoint for {}", game.PlaceId)
Settings:set("priorEndpoints", priorSyncInfos)
end
function App:getHostAndPort()
local host = self.host:getValue()
local port = self.port:getValue()
return if #host > 0 then host else Config.defaultHost, if #port > 0 then port else Config.defaultPort
end
function App:isSyncLockAvailable()
if #Players:GetPlayers() == 0 then
-- Team Create is not active, so no one can be holding the lock
return true
end
local lock = ServerStorage:FindFirstChild("__Rojo_SessionLock")
if not lock then
-- No lock is made yet, so it is available
return true
end
if lock.Value and lock.Value ~= Players.LocalPlayer and lock.Value.Parent then
-- Someone else is holding the lock
return false, lock.Value
end
-- The lock exists, but is not claimed
return true
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 isAvailable, priorOwner = self:isSyncLockAvailable()
if not isAvailable then
Log.trace("Skipping sync lock because it is already claimed")
return false, priorOwner
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
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 relase sync lock because it is owned by {}", lock.Value)
end
function App:isAutoConnectPlaytestServerAvailable()
return RunService:IsRunMode()
and RunService:IsServer()
and Settings:get("autoConnectPlaytestServer")
and workspace:GetAttribute("__Rojo_ConnectionUrl")
end
function App:isAutoConnectPlaytestServerWriteable()
return RunService:IsEdit() and Settings:get("autoConnectPlaytestServer")
end
function App:setRunningConnectionInfo(baseUrl: string)
if not self:isAutoConnectPlaytestServerWriteable() then
return
end
Log.trace("Setting connection info for play solo auto-connect")
workspace:SetAttribute("__Rojo_ConnectionUrl", baseUrl)
end
function App:clearRunningConnectionInfo()
if not RunService:IsEdit() then
-- Only write connection info from edit mode
return
end
Log.trace("Clearing connection info for play solo auto-connect")
workspace:SetAttribute("__Rojo_ConnectionUrl", nil)
end
function App:useRunningConnectionInfo()
local connectionInfo = workspace:GetAttribute("__Rojo_ConnectionUrl")
if not connectionInfo then
return
end
Log.trace("Using connection info for play solo auto-connect")
local host, port = string.match(connectionInfo, "^(.+):(.-)$")
self.setHost(host)
self.setPort(port)
end
function App:startSession()
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
local host, port = self:getHostAndPort()
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,
twoWaySync = Settings:get("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 = DateTime.now().UnixTimestamp
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:setState({
appStatus = AppStatus.Connecting,
toolbarIcon = Assets.Images.PluginButton,
})
self:addNotification("Connecting to session...")
elseif status == ServeSession.Status.Connected then
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,
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:clearRunningConnectionInfo()
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
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
-- Play solo auto-connect does not require confirmation
if self:isAutoConnectPlaytestServerAvailable() then
Log.trace("Accepting patch without confirmation because play solo auto-connect is enabled")
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
elseif confirmationBehavior == "Never" then
Log.trace("Accepting patch without confirmation because behavior is set to Never")
return "Accept"
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
return e(StudioPluginContext.Provider, {
value = self.props.plugin,
}, {
e(Theme.StudioProvider, nil, {
tooltip = e(Tooltip.Provider, nil, {
gui = e(StudioPluginGui, {
id = pluginName,
title = pluginName,
active = self.state.guiEnabled,
isEphemeral = false,
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,
}),
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