mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-22 21:41:37 +00:00
- Improved the syncback functionality by detecting unchanged output file hashes to avoid unnecessary writes, optimizing performance for larger projects. - Updated the patch selection logic to ensure that auto-selection only occurs when the current selection matches the default, enhancing user experience during patch reviews. These changes aim to streamline file synchronization and improve the clarity of patch management in the application.
1389 lines
39 KiB
Lua
1389 lines
39 KiB
Lua
local ChangeHistoryService = game:GetService("ChangeHistoryService")
|
|
local Players = game:GetService("Players")
|
|
local SerializationService = game:GetService("SerializationService")
|
|
local ServerStorage = game:GetService("ServerStorage")
|
|
local RunService = game:GetService("RunService")
|
|
|
|
local SYNCBACK_SERVICES = {
|
|
"Lighting",
|
|
"MaterialService",
|
|
"ReplicatedFirst",
|
|
"ReplicatedStorage",
|
|
"ServerScriptService",
|
|
"ServerStorage",
|
|
"SoundService",
|
|
"StarterGui",
|
|
"StarterPack",
|
|
"StarterPlayer",
|
|
"Teams",
|
|
"TextChatService",
|
|
"VoiceChatService",
|
|
"Workspace",
|
|
}
|
|
|
|
local Rojo = script:FindFirstAncestor("Rojo")
|
|
local Plugin = Rojo.Plugin
|
|
local Packages = Rojo.Packages
|
|
|
|
local Roact = require(Packages.Roact)
|
|
local Log = require(Packages.Log)
|
|
local Promise = require(Packages.Promise)
|
|
|
|
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 Http = require(Packages.Http)
|
|
|
|
local encodeService = require(Plugin.ChangeBatcher.encodeService)
|
|
|
|
local Page = require(script.Page)
|
|
local Notifications = require(script.Components.Notifications)
|
|
local TextButton = require(script.Components.TextButton)
|
|
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, "^Atlas: 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({
|
|
text = string.format("You've undone '%s'.\nIf this was not intended, please restore.", action),
|
|
timeout = 10,
|
|
onClose = function()
|
|
cleanup()
|
|
end,
|
|
actions = {
|
|
Restore = {
|
|
text = "Restore",
|
|
style = "Solid",
|
|
layoutOrder = 1,
|
|
onClick = function()
|
|
ChangeHistoryService:Redo()
|
|
end,
|
|
},
|
|
Dismiss = {
|
|
text = "Dismiss",
|
|
style = "Bordered",
|
|
layoutOrder = 2,
|
|
},
|
|
},
|
|
})
|
|
|
|
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()
|
|
if dismissNotif then
|
|
dismissNotif()
|
|
end
|
|
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()
|
|
if dismissNotif then
|
|
dismissNotif()
|
|
end
|
|
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()
|
|
|
|
self:startSyncReminderPolling()
|
|
self.disconnectSyncReminderPollingChanged = Settings:onChanged("syncReminderPolling", function(enabled)
|
|
if enabled then
|
|
self:startSyncReminderPolling()
|
|
else
|
|
self:stopSyncReminderPolling()
|
|
end
|
|
end)
|
|
|
|
self:tryAutoReconnect():andThen(function(didReconnect)
|
|
if not didReconnect then
|
|
self:checkSyncReminder()
|
|
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:endSession()
|
|
|
|
self.waypointConnection:Disconnect()
|
|
self.confirmationBindable:Destroy()
|
|
|
|
self.disconnectUpdatesCheckChanged()
|
|
self.disconnectPrereleasesCheckChanged()
|
|
if self.disconnectSyncReminderPollingChanged then
|
|
self.disconnectSyncReminderPollingChanged()
|
|
end
|
|
|
|
self:stopSyncReminderPolling()
|
|
|
|
self.autoConnectPlaytestServerListener()
|
|
self:clearRunningConnectionInfo()
|
|
end
|
|
|
|
function App:addNotification(notif: {
|
|
text: string,
|
|
isFullscreen: boolean?,
|
|
timeout: number?,
|
|
actions: { [string]: { text: string, style: string, layoutOrder: number, onClick: (any) -> ()? } }?,
|
|
onClose: (any) -> ()?,
|
|
})
|
|
if not Settings:get("showNotifications") then
|
|
return
|
|
end
|
|
|
|
self.notifId += 1
|
|
local id = self.notifId
|
|
|
|
self:setState(function(prevState)
|
|
local notifications = table.clone(prevState.notifications)
|
|
notifications[id] = Dictionary.merge({
|
|
timeout = notif.timeout or 5,
|
|
isFullscreen = notif.isFullscreen or false,
|
|
}, notif)
|
|
|
|
return {
|
|
notifications = notifications,
|
|
}
|
|
end)
|
|
|
|
return function()
|
|
self:closeNotification(id)
|
|
end
|
|
end
|
|
|
|
function App:closeNotification(id: number)
|
|
if not self.state.notifications[id] then
|
|
return
|
|
end
|
|
|
|
self:setState(function(prevState)
|
|
local notifications = table.clone(prevState.notifications)
|
|
notifications[id] = nil
|
|
|
|
return {
|
|
notifications = notifications,
|
|
}
|
|
end)
|
|
end
|
|
|
|
function App:checkForUpdates()
|
|
local updateMessage = Version.getUpdateMessage()
|
|
|
|
if updateMessage then
|
|
self:addNotification({
|
|
text = updateMessage,
|
|
timeout = 500,
|
|
actions = {
|
|
Dismiss = {
|
|
text = "Dismiss",
|
|
style = "Bordered",
|
|
layoutOrder = 2,
|
|
},
|
|
},
|
|
})
|
|
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:forgetPriorSyncInfo()
|
|
local priorSyncInfos = Settings:get("priorEndpoints")
|
|
if not priorSyncInfos then
|
|
priorSyncInfos = {}
|
|
end
|
|
|
|
local id = tostring(game.PlaceId)
|
|
priorSyncInfos[id] = nil
|
|
Log.trace("Erased 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("__Atlas_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("__Atlas_SessionLock")
|
|
if not lock then
|
|
lock = Instance.new("ObjectValue")
|
|
lock.Name = "__Atlas_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("__Atlas_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:findActiveServer()
|
|
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)
|
|
|
|
Log.trace("Checking for active sync server at {}", baseUrl)
|
|
|
|
local apiContext = ApiContext.new(baseUrl)
|
|
return apiContext:connect():andThen(function(serverInfo)
|
|
apiContext:disconnect()
|
|
return serverInfo, host, port
|
|
end)
|
|
end
|
|
|
|
function App:tryAutoReconnect()
|
|
if not Settings:get("autoReconnect") then
|
|
return Promise.resolve(false)
|
|
end
|
|
|
|
local priorSyncInfo = self:getPriorSyncInfo()
|
|
if not priorSyncInfo.projectName then
|
|
Log.trace("No prior sync info found, skipping auto-reconnect")
|
|
return Promise.resolve(false)
|
|
end
|
|
|
|
return self:findActiveServer()
|
|
:andThen(function(serverInfo)
|
|
-- change
|
|
if serverInfo.projectName == priorSyncInfo.projectName then
|
|
Log.trace("Auto-reconnect found matching server, reconnecting...")
|
|
self:addNotification({
|
|
text = `Auto-reconnect discovered project '{serverInfo.projectName}'...`,
|
|
})
|
|
self:startSession()
|
|
return true
|
|
end
|
|
Log.trace("Auto-reconnect found different server, not reconnecting")
|
|
return false
|
|
end)
|
|
:catch(function()
|
|
Log.trace("Auto-reconnect did not find a server, not reconnecting")
|
|
return false
|
|
end)
|
|
end
|
|
|
|
function App:checkSyncReminder()
|
|
local syncReminderMode = Settings:get("syncReminderMode")
|
|
if syncReminderMode == "None" then
|
|
return
|
|
end
|
|
|
|
if self.serveSession ~= nil or not self:isSyncLockAvailable() then
|
|
-- Already syncing or cannot sync, no reason to remind
|
|
return
|
|
end
|
|
|
|
local priorSyncInfo = self:getPriorSyncInfo()
|
|
|
|
self:findActiveServer()
|
|
:andThen(function(serverInfo, host, port)
|
|
self:sendSyncReminder(
|
|
`Project '{serverInfo.projectName}' is serving at {host}:{port}.\nWould you like to connect?`,
|
|
{ "Connect", "Dismiss" }
|
|
)
|
|
end)
|
|
:catch(function()
|
|
if priorSyncInfo.timestamp and priorSyncInfo.projectName then
|
|
-- We didn't find an active server,
|
|
-- but this place has a prior sync
|
|
-- so we should remind the user to serve
|
|
|
|
local timeSinceSync = timeUtil.elapsedToText(os.time() - priorSyncInfo.timestamp)
|
|
self:sendSyncReminder(
|
|
`You synced project '{priorSyncInfo.projectName}' to this place {timeSinceSync}.\nDid you mean to run 'atlas serve' and then connect?`,
|
|
{ "Connect", "Forget", "Dismiss" }
|
|
)
|
|
end
|
|
end)
|
|
end
|
|
|
|
function App:startSyncReminderPolling()
|
|
if
|
|
self.syncReminderPollingThread ~= nil
|
|
or Settings:get("syncReminderMode") == "None"
|
|
or not Settings:get("syncReminderPolling")
|
|
then
|
|
return
|
|
end
|
|
|
|
Log.trace("Starting sync reminder polling thread")
|
|
self.syncReminderPollingThread = task.spawn(function()
|
|
while task.wait(30) do
|
|
if self.syncReminderPollingThread == nil then
|
|
-- The polling thread was stopped, so exit
|
|
return
|
|
end
|
|
if self.dismissSyncReminder then
|
|
-- There is already a sync reminder being shown
|
|
task.wait(5)
|
|
continue
|
|
end
|
|
self:checkSyncReminder()
|
|
end
|
|
end)
|
|
end
|
|
|
|
function App:stopSyncReminderPolling()
|
|
if self.syncReminderPollingThread then
|
|
Log.trace("Stopping sync reminder polling thread")
|
|
task.cancel(self.syncReminderPollingThread)
|
|
self.syncReminderPollingThread = nil
|
|
end
|
|
end
|
|
|
|
function App:sendSyncReminder(message: string, shownActions: { string })
|
|
local syncReminderMode = Settings:get("syncReminderMode")
|
|
if syncReminderMode == "None" then
|
|
return
|
|
end
|
|
|
|
local connectIndex = table.find(shownActions, "Connect")
|
|
local forgetIndex = table.find(shownActions, "Forget")
|
|
local dismissIndex = table.find(shownActions, "Dismiss")
|
|
|
|
self.dismissSyncReminder = self:addNotification({
|
|
text = message,
|
|
timeout = 120,
|
|
isFullscreen = Settings:get("syncReminderMode") == "Fullscreen",
|
|
onClose = function()
|
|
self.dismissSyncReminder = nil
|
|
end,
|
|
actions = {
|
|
Connect = if connectIndex
|
|
then {
|
|
text = "Connect",
|
|
style = "Solid",
|
|
layoutOrder = connectIndex,
|
|
onClick = function()
|
|
self:startSession()
|
|
end,
|
|
}
|
|
else nil,
|
|
Forget = if forgetIndex
|
|
then {
|
|
text = "Forget",
|
|
style = "Bordered",
|
|
layoutOrder = forgetIndex,
|
|
onClick = function()
|
|
-- The user doesn't want to be reminded again about this sync
|
|
self:forgetPriorSyncInfo()
|
|
end,
|
|
}
|
|
else nil,
|
|
Dismiss = if dismissIndex
|
|
then {
|
|
text = "Dismiss",
|
|
style = "Bordered",
|
|
layoutOrder = dismissIndex,
|
|
onClick = function()
|
|
-- If the user dismisses the reminder,
|
|
-- then we don't need to remind them again
|
|
self:stopSyncReminderPolling()
|
|
end,
|
|
}
|
|
else nil,
|
|
},
|
|
})
|
|
end
|
|
|
|
function App:isAutoConnectPlaytestServerAvailable()
|
|
return RunService:IsRunning()
|
|
and RunService:IsStudio()
|
|
and RunService:IsServer()
|
|
and Settings:get("autoConnectPlaytestServer")
|
|
and workspace:GetAttribute("__Atlas_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("__Atlas_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("__Atlas_ConnectionUrl", nil)
|
|
end
|
|
|
|
function App:useRunningConnectionInfo()
|
|
local connectionInfo = workspace:GetAttribute("__Atlas_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:performSyncback()
|
|
self:setState({ showingSyncbackConfirm = false })
|
|
|
|
local host = self.host:getValue()
|
|
local port = self.port:getValue()
|
|
|
|
if host == "" then
|
|
host = Config.defaultHost
|
|
end
|
|
if port == "" then
|
|
port = Config.defaultPort
|
|
end
|
|
|
|
local services = {}
|
|
local allChildren = {}
|
|
local allCarriers = {}
|
|
for _, className in SYNCBACK_SERVICES do
|
|
local ok, service = pcall(game.FindService, game, className)
|
|
if ok and service then
|
|
local chunk, refTargets = encodeService(service)
|
|
for _, child in service:GetChildren() do
|
|
table.insert(allChildren, child)
|
|
end
|
|
for _, carrier in refTargets do
|
|
table.insert(allChildren, carrier)
|
|
table.insert(allCarriers, carrier)
|
|
end
|
|
table.insert(services, chunk)
|
|
end
|
|
end
|
|
|
|
local data = buffer.create(0)
|
|
if #allChildren > 0 then
|
|
data = SerializationService:SerializeInstancesAsync(allChildren)
|
|
end
|
|
|
|
for _, carrier in allCarriers do
|
|
carrier:Destroy()
|
|
end
|
|
|
|
local url = ("http://%s:%s/api/syncback"):format(host, port)
|
|
local body = Http.msgpackEncode({
|
|
protocolVersion = Config.protocolVersion,
|
|
serverVersion = Config.expectedServerVersionString,
|
|
placeId = game.PlaceId,
|
|
data = data,
|
|
services = services,
|
|
})
|
|
|
|
Http.post(url, body)
|
|
:andThen(function()
|
|
Log.info("Syncback data sent to server.")
|
|
self:addNotification({
|
|
text = "Syncback data sent. Server is processing.",
|
|
timeout = 10,
|
|
})
|
|
end)
|
|
:catch(function(err)
|
|
Log.warn("Syncback failed: " .. tostring(err))
|
|
self:addNotification({
|
|
text = "Syncback failed: " .. tostring(err),
|
|
timeout = 10,
|
|
})
|
|
end)
|
|
end
|
|
|
|
function App:startSession()
|
|
-- If a session is already in progress, tear it down first.
|
|
-- In one-shot mode the sync lock is skipped, so this is the only guard
|
|
-- against overlapping sessions (e.g., auto-reconnect racing with a
|
|
-- manual connect).
|
|
if self.serveSession ~= nil then
|
|
Log.trace("Ending existing session before starting a new one")
|
|
self:endSession()
|
|
end
|
|
|
|
-- Skip session lock in one-shot mode since it's a quick sync-and-disconnect
|
|
if not Settings:get("oneShotSync") then
|
|
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({
|
|
text = msg,
|
|
timeout = 10,
|
|
})
|
|
self:setState({
|
|
appStatus = AppStatus.Error,
|
|
errorMessage = msg,
|
|
toolbarIcon = Assets.Images.PluginButtonWarning,
|
|
})
|
|
|
|
return
|
|
end
|
|
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"),
|
|
})
|
|
|
|
serveSession:setUpdateLoadingTextCallback(function(text: string)
|
|
self:setState({
|
|
connectingText = text,
|
|
})
|
|
end)
|
|
|
|
local cachedServerInfo = nil
|
|
|
|
self.cleanupPrecommit = serveSession:hookPrecommit(function(patch, instanceMap)
|
|
local gitMetadata = cachedServerInfo and cachedServerInfo.gitMetadata
|
|
self:setState({
|
|
patchTree = PatchTree.build(patch, instanceMap, { "Property", "Old", "New" }, gitMetadata),
|
|
})
|
|
end)
|
|
self.cleanupPostcommit = serveSession:hookPostcommit(function(patch, instanceMap, unappliedPatch)
|
|
local now = DateTime.now().UnixTimestamp
|
|
self:setState(function(prevState)
|
|
local oldPatchData = prevState.patchData
|
|
local newPatchData = {
|
|
patch = patch,
|
|
unapplied = unappliedPatch,
|
|
timestamp = now,
|
|
}
|
|
|
|
if PatchSet.isEmpty(patch) then
|
|
-- Keep existing patch info, but use new timestamp
|
|
newPatchData.patch = oldPatchData.patch
|
|
newPatchData.unapplied = oldPatchData.unapplied
|
|
elseif now - oldPatchData.timestamp < 2 then
|
|
-- Patches that apply in the same second are combined for human clarity
|
|
newPatchData.patch = PatchSet.assign(PatchSet.newEmpty(), oldPatchData.patch, patch)
|
|
newPatchData.unapplied = PatchSet.assign(PatchSet.newEmpty(), oldPatchData.unapplied, unappliedPatch)
|
|
end
|
|
|
|
return {
|
|
patchTree = PatchTree.updateMetadata(prevState.patchTree, patch, instanceMap, unappliedPatch),
|
|
patchData = newPatchData,
|
|
}
|
|
end)
|
|
end)
|
|
|
|
serveSession:onStatusChanged(function(status, details)
|
|
if status == ServeSession.Status.Connecting then
|
|
if self.dismissSyncReminder then
|
|
self.dismissSyncReminder()
|
|
self.dismissSyncReminder = nil
|
|
end
|
|
|
|
self:setState({
|
|
appStatus = AppStatus.Connecting,
|
|
toolbarIcon = Assets.Images.PluginButton,
|
|
})
|
|
self:addNotification({
|
|
text = "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)
|
|
|
|
if Settings:get("oneShotSync") then
|
|
-- One-shot mode: Don't show Connected page since we're about to disconnect.
|
|
-- Show a loading state while the patch applies and writes complete.
|
|
self:setState({
|
|
appStatus = AppStatus.Connecting,
|
|
connectingText = "Completing sync...",
|
|
toolbarIcon = Assets.Images.PluginButton,
|
|
})
|
|
self:addNotification({
|
|
text = string.format("Synced with '%s'. Completing...", details),
|
|
})
|
|
-- Safety: if endSession hasn't fired within 30s, force disconnect.
|
|
-- This catches cases where the promise chain silently breaks
|
|
-- (e.g., write request hangs, promise never resolves).
|
|
-- Capture the current session so we only kill THIS session, not a
|
|
-- different one that may have replaced it (e.g., auto-reconnect race).
|
|
local timeoutSession = serveSession
|
|
task.delay(30, function()
|
|
if
|
|
self.serveSession ~= nil
|
|
and self.serveSession == timeoutSession
|
|
and Settings:get("oneShotSync")
|
|
then
|
|
Log.warn("One-shot sync safety timeout: forcing disconnect")
|
|
self:endSession()
|
|
end
|
|
end)
|
|
else
|
|
self:setState({
|
|
appStatus = AppStatus.Connected,
|
|
projectName = details,
|
|
address = address,
|
|
toolbarIcon = Assets.Images.PluginButtonConnected,
|
|
})
|
|
self:addNotification({
|
|
text = string.format("Connected to session '%s' at %s.", details, address),
|
|
})
|
|
end
|
|
elseif status == ServeSession.Status.Disconnected then
|
|
self.serveSession = nil
|
|
-- Only release lock if we claimed it (not in one-shot mode)
|
|
if not Settings:get("oneShotSync") then
|
|
self:releaseSyncLock()
|
|
end
|
|
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({
|
|
text = tostring(details),
|
|
timeout = 10,
|
|
})
|
|
else
|
|
self:setState({
|
|
appStatus = AppStatus.NotConnected,
|
|
toolbarIcon = Assets.Images.PluginButton,
|
|
})
|
|
self:addNotification({
|
|
text = "Disconnected from session.",
|
|
timeout = 10,
|
|
})
|
|
end
|
|
end
|
|
end)
|
|
|
|
local initialSyncConfirmed = false
|
|
|
|
serveSession:setConfirmCallback(function(instanceMap, patch, serverInfo)
|
|
cachedServerInfo = serverInfo
|
|
|
|
-- Filter out the DataModel name change from the patch
|
|
-- The project name (DataModel.Name) is managed by Studio independently
|
|
PatchSet.removeDataModelName(patch, instanceMap)
|
|
|
|
local isOneShotMode = Settings:get("oneShotSync")
|
|
|
|
-- ONE-SHOT MODE: After the initial sync is confirmed, skip any
|
|
-- subsequent patches entirely. These are server echoes from processing
|
|
-- our pull request (e.g. VFS re-snapshots after file deletions).
|
|
-- The session is about to disconnect — applying them is pointless,
|
|
-- and without this guard the echo triggers a second confirmation
|
|
-- dialog that races with endSession() and gets stuck.
|
|
if isOneShotMode and initialSyncConfirmed then
|
|
Log.info("One-shot mode: skipping post-initial-sync echo patch")
|
|
return "Skip"
|
|
end
|
|
|
|
-- ONE-SHOT MODE: Never auto-accept, always require explicit confirmation
|
|
-- This ensures nothing can "sneak through" without user review
|
|
if not isOneShotMode then
|
|
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 ~= "Always" then
|
|
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
|
|
end
|
|
else
|
|
Log.info("One-shot mode: skipping all auto-accept paths, requiring explicit confirmation")
|
|
end
|
|
|
|
self:setState({
|
|
connectingText = "Computing diff view...",
|
|
})
|
|
self:setState({
|
|
appStatus = AppStatus.Confirming,
|
|
patchTree = PatchTree.build(
|
|
patch,
|
|
instanceMap,
|
|
{ "Property", "Current", "Incoming" },
|
|
serverInfo.gitMetadata
|
|
),
|
|
confirmData = {
|
|
serverInfo = serverInfo,
|
|
},
|
|
toolbarIcon = Assets.Images.PluginButton,
|
|
})
|
|
|
|
self:addNotification({
|
|
text = "Please review and confirm the sync changes, or disconnect.",
|
|
timeout = 7,
|
|
})
|
|
|
|
local result = self.confirmationEvent:Wait()
|
|
|
|
-- One-shot sync: Don't transition to Connected UI
|
|
-- The actual disconnect is handled by setInitialSyncCompleteCallback
|
|
-- to ensure any pending writes complete first
|
|
if Settings:get("oneShotSync") then
|
|
initialSyncConfirmed = true
|
|
return result
|
|
end
|
|
|
|
-- Reset UI state back to Connected after confirmation
|
|
-- This is needed for ongoing WebSocket patches where the session
|
|
-- is already connected and won't trigger a status change
|
|
if self.serveSession and self.serveSession:getStatus() == ServeSession.Status.Connected then
|
|
self:setState({
|
|
appStatus = AppStatus.Connected,
|
|
toolbarIcon = Assets.Images.PluginButtonConnected,
|
|
-- Clear patchTree to avoid animation issues when the
|
|
-- PatchVisualizer unmounts while Flipper motors are running
|
|
patchTree = nil,
|
|
})
|
|
end
|
|
|
|
return result
|
|
end)
|
|
|
|
serveSession:setPatchUpdateCallback(function(instanceMap, patch, changedIds)
|
|
-- If all changes have been reverted, auto-accept the empty patch
|
|
if PatchSet.isEmpty(patch) then
|
|
Log.trace("Patch became empty after merging, auto-accepting")
|
|
-- Return empty selections to accept all (nothing to do)
|
|
self.confirmationBindable:Fire({ type = "Confirm", selections = {} })
|
|
return
|
|
end
|
|
|
|
-- Update the patchTree when new changes arrive during confirmation
|
|
-- The changedIds parameter contains IDs of items that were modified
|
|
-- so their selections can be reset (user must re-review)
|
|
local gitMetadata = cachedServerInfo and cachedServerInfo.gitMetadata
|
|
self:setState({
|
|
patchTree = PatchTree.build(patch, instanceMap, { "Property", "Current", "Incoming" }, gitMetadata),
|
|
changedIds = changedIds, -- Pass to UI so it can unselect changed items
|
|
})
|
|
end)
|
|
|
|
-- One-shot sync: disconnect after initial sync is fully complete (including any writes)
|
|
-- This ensures that "pull" changes are sent to the server before disconnecting
|
|
serveSession:setInitialSyncCompleteCallback(function()
|
|
if Settings:get("oneShotSync") and self.serveSession then
|
|
Log.info("One-shot sync: disconnecting after initial sync complete")
|
|
self:endSession()
|
|
end
|
|
end)
|
|
|
|
serveSession:start()
|
|
|
|
self.serveSession = serveSession
|
|
end
|
|
|
|
function App:endSession()
|
|
if self.serveSession == nil then
|
|
-- Safety: if the session reference was cleared (e.g., by a Disconnected handler)
|
|
-- but the UI is still stuck on Connected/Connecting, force reset to NotConnected.
|
|
-- This handles race conditions where onStatusChanged(Connected) fires after
|
|
-- onStatusChanged(Disconnected) due to a coroutine resuming on a dead session.
|
|
if
|
|
self.state.appStatus ~= AppStatus.NotConnected
|
|
and self.state.appStatus ~= AppStatus.Error
|
|
and self.state.appStatus ~= AppStatus.Settings
|
|
then
|
|
self:setState({
|
|
appStatus = AppStatus.NotConnected,
|
|
toolbarIcon = Assets.Images.PluginButton,
|
|
})
|
|
end
|
|
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 = "Atlas " .. 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,
|
|
|
|
onSyncback = function()
|
|
self:setState({
|
|
showingSyncbackConfirm = true,
|
|
})
|
|
end,
|
|
|
|
onNavigateSettings = function()
|
|
self.backPage = AppStatus.NotConnected
|
|
self:setState({
|
|
appStatus = AppStatus.Settings,
|
|
})
|
|
end,
|
|
}),
|
|
|
|
ConfirmingPage = createPageElement(AppStatus.Confirming, {
|
|
confirmData = self.state.confirmData,
|
|
patchTree = self.state.patchTree,
|
|
changedIds = self.state.changedIds,
|
|
createPopup = not self.state.guiEnabled,
|
|
|
|
onAbort = function()
|
|
self.confirmationBindable:Fire("Abort")
|
|
end,
|
|
onConfirm = function(selections)
|
|
local autoSelectedIds = {}
|
|
if self.state.patchTree then
|
|
self.state.patchTree:forEach(function(node)
|
|
if
|
|
node.patchType
|
|
and node.defaultSelection ~= nil
|
|
and selections[node.id] == node.defaultSelection
|
|
then
|
|
autoSelectedIds[node.id] = true
|
|
end
|
|
end)
|
|
end
|
|
self.confirmationBindable:Fire({
|
|
type = "Confirm",
|
|
selections = selections,
|
|
autoSelectedIds = autoSelectedIds,
|
|
})
|
|
end,
|
|
}),
|
|
|
|
Connecting = createPageElement(AppStatus.Connecting, {
|
|
text = self.state.connectingText,
|
|
}),
|
|
|
|
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,
|
|
}, {
|
|
Notifications = e(Notifications, {
|
|
soundPlayer = self.props.soundPlayer,
|
|
notifications = self.state.notifications,
|
|
onClose = function(id)
|
|
self:closeNotification(id)
|
|
end,
|
|
}),
|
|
}),
|
|
}),
|
|
|
|
SyncbackConfirm = e(Theme.StudioProvider, nil, {
|
|
e(StudioPluginGui, {
|
|
id = "Atlas_SyncbackConfirm",
|
|
title = "⚠️ Full Syncback (BETA) ⚠️",
|
|
active = self.state.showingSyncbackConfirm == true,
|
|
isEphemeral = true,
|
|
|
|
initDockState = Enum.InitialDockState.Float,
|
|
overridePreviousState = true,
|
|
floatingSize = Vector2.new(400, 250),
|
|
minimumSize = Vector2.new(300, 220),
|
|
|
|
zIndexBehavior = Enum.ZIndexBehavior.Sibling,
|
|
|
|
onClose = function()
|
|
self:setState({ showingSyncbackConfirm = false })
|
|
end,
|
|
}, {
|
|
Content = Theme.with(function(theme)
|
|
local noTransparency = Roact.createBinding(0)
|
|
|
|
return e("Frame", {
|
|
Size = UDim2.fromScale(1, 1),
|
|
BackgroundColor3 = theme.BackgroundColor,
|
|
BorderSizePixel = 0,
|
|
}, {
|
|
Padding = e("UIPadding", {
|
|
PaddingLeft = UDim.new(0, 16),
|
|
PaddingRight = UDim.new(0, 16),
|
|
PaddingTop = UDim.new(0, 16),
|
|
PaddingBottom = UDim.new(0, 16),
|
|
}),
|
|
Layout = e("UIListLayout", {
|
|
FillDirection = Enum.FillDirection.Vertical,
|
|
SortOrder = Enum.SortOrder.LayoutOrder,
|
|
Padding = UDim.new(0, 16),
|
|
VerticalAlignment = Enum.VerticalAlignment.Center,
|
|
}),
|
|
Message = e("TextLabel", {
|
|
Text = "This will overwrite your project files with the current Studio state. This cannot be undone.\n\nNote: Live syncback produces slightly different formatting than CLI syncback (atlas syncback). This may cause minor git diffs even when the data is identical. Pick one method and stick with it to avoid unnecessary churn.",
|
|
TextWrapped = true,
|
|
FontFace = theme.Font.Main,
|
|
TextSize = theme.TextSize.Body,
|
|
TextColor3 = theme.TextColor,
|
|
Size = UDim2.new(1, 0, 0, 0),
|
|
AutomaticSize = Enum.AutomaticSize.Y,
|
|
BackgroundTransparency = 1,
|
|
LayoutOrder = 1,
|
|
}),
|
|
Buttons = e("Frame", {
|
|
Size = UDim2.new(1, 0, 0, 34),
|
|
BackgroundTransparency = 1,
|
|
LayoutOrder = 2,
|
|
}, {
|
|
Layout = e("UIListLayout", {
|
|
HorizontalAlignment = Enum.HorizontalAlignment.Right,
|
|
FillDirection = Enum.FillDirection.Horizontal,
|
|
SortOrder = Enum.SortOrder.LayoutOrder,
|
|
Padding = UDim.new(0, 10),
|
|
}),
|
|
Cancel = e(TextButton, {
|
|
text = "Cancel",
|
|
style = "Bordered",
|
|
transparency = noTransparency,
|
|
layoutOrder = 1,
|
|
onClick = function()
|
|
self:setState({ showingSyncbackConfirm = false })
|
|
end,
|
|
}),
|
|
Confirm = e(TextButton, {
|
|
text = "Syncback",
|
|
style = "Danger",
|
|
transparency = noTransparency,
|
|
layoutOrder = 2,
|
|
onClick = function()
|
|
self:performSyncback()
|
|
end,
|
|
}),
|
|
}),
|
|
})
|
|
end),
|
|
}),
|
|
}),
|
|
|
|
toggleAction = e(StudioPluginAction, {
|
|
name = "AtlasConnection",
|
|
title = "Atlas: Connect/Disconnect",
|
|
description = "Toggles the server for an Atlas 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 = "AtlasConnect",
|
|
title = "Atlas: Connect",
|
|
description = "Connects the server for an Atlas 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 = "AtlasDisconnect",
|
|
title = "Atlas: Disconnect",
|
|
description = "Disconnects the server for an Atlas 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 = "Atlas",
|
|
tooltip = "Show or hide the Atlas 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
|