Implement live syncback feature and enhance API for service data transfer

- Added a new `performSyncback` function in the `App` module to handle the synchronization of service data from the game to the server.
- Introduced a `SyncbackConfirm` UI component to confirm syncback actions from the user.
- Updated the API to include a new `/api/syncback` endpoint for handling syncback requests, validating protocol and server versions.
- Enhanced the `SyncbackSignal` mechanism to facilitate communication between the server and the syncback process.
- Added tests to ensure the functionality of the live syncback feature and its integration with the CLI.

These changes improve the overall synchronization capabilities of the application, allowing for real-time updates and better project management.
This commit is contained in:
Joe 2026-02-18 04:36:57 -06:00
parent 6f8e5a5cf2
commit 307ac81aec
17 changed files with 1411 additions and 97 deletions

View file

@ -1,8 +1,16 @@
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
@ -27,8 +35,11 @@ local ignorePlaceIds = require(Plugin.ignorePlaceIds)
local timeUtil = require(Plugin.timeUtil)
local Theme = require(script.Theme)
local Http = require(Packages.Http)
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)
@ -604,6 +615,68 @@ function App:useRunningConnectionInfo()
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
Promise.new(function(resolve, reject)
local services = {}
for _, className in SYNCBACK_SERVICES do
local ok, service = pcall(game.FindService, game, className)
if ok and service and #service:GetChildren() > 0 then
local buf = SerializationService:SerializeInstancesAsync(service:GetChildren())
table.insert(services, {
className = className,
data = buf,
})
end
end
local url = ("http://%s:%s/api/syncback"):format(host, port)
local body = Http.msgpackEncode({
protocolVersion = Config.protocolVersion,
serverVersion = Config.expectedServerVersionString,
placeId = game.PlaceId,
services = services,
})
Http.post(url, body)
:andThen(function(response)
if response.StatusCode >= 200 and response.StatusCode < 300 then
resolve()
else
reject(
`Syncback request failed ({response.StatusCode}): {response.Body or "unknown error"}`
)
end
end)
:catch(reject)
end)
: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
@ -1027,23 +1100,29 @@ function App:render()
}, {
Tooltips = e(Tooltip.Container, nil),
NotConnectedPage = createPageElement(AppStatus.NotConnected, {
host = self.host,
onHostChange = self.setHost,
port = self.port,
onPortChange = self.setPort,
NotConnectedPage = createPageElement(AppStatus.NotConnected, {
host = self.host,
onHostChange = self.setHost,
port = self.port,
onPortChange = self.setPort,
onConnect = function()
self:startSession()
end,
onConnect = function()
self:startSession()
end,
onNavigateSettings = function()
self.backPage = AppStatus.NotConnected
self:setState({
appStatus = AppStatus.Settings,
})
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,
@ -1117,22 +1196,100 @@ function App:render()
}),
}),
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,
}),
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,
}),
}),
}),
toggleAction = e(StudioPluginAction, {
SyncbackConfirm = e(StudioPluginGui, {
id = "Atlas_SyncbackConfirm",
title = "Full Syncback",
active = self.state.showingSyncbackConfirm == true,
isEphemeral = true,
initDockState = Enum.InitialDockState.Float,
overridePreviousState = true,
floatingSize = Vector2.new(400, 150),
minimumSize = Vector2.new(300, 120),
zIndexBehavior = Enum.ZIndexBehavior.Sibling,
onClose = function()
self:setState({ showingSyncbackConfirm = false })
end,
}, {
Content = Theme.with(function(theme)
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.",
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",
layoutOrder = 1,
onClick = function()
self:setState({ showingSyncbackConfirm = false })
end,
}),
Confirm = e(TextButton, {
text = "Syncback",
style = "Danger",
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",