azul-sync 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitattributes +1 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +31 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
- package/README.md +142 -0
- package/dist/build.d.ts +19 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +92 -0
- package/dist/build.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +397 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +26 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +105 -0
- package/dist/config.js.map +1 -0
- package/dist/fs/fileWriter.d.ts +100 -0
- package/dist/fs/fileWriter.d.ts.map +1 -0
- package/dist/fs/fileWriter.js +342 -0
- package/dist/fs/fileWriter.js.map +1 -0
- package/dist/fs/treeManager.d.ts +84 -0
- package/dist/fs/treeManager.d.ts.map +1 -0
- package/dist/fs/treeManager.js +365 -0
- package/dist/fs/treeManager.js.map +1 -0
- package/dist/fs/watcher.d.ts +39 -0
- package/dist/fs/watcher.d.ts.map +1 -0
- package/dist/fs/watcher.js +120 -0
- package/dist/fs/watcher.js.map +1 -0
- package/dist/index.d.ts +61 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +349 -0
- package/dist/index.js.map +1 -0
- package/dist/ipc/httpPolling.d.ts +56 -0
- package/dist/ipc/httpPolling.d.ts.map +1 -0
- package/dist/ipc/httpPolling.js +171 -0
- package/dist/ipc/httpPolling.js.map +1 -0
- package/dist/ipc/messages.d.ts +112 -0
- package/dist/ipc/messages.d.ts.map +1 -0
- package/dist/ipc/messages.js +5 -0
- package/dist/ipc/messages.js.map +1 -0
- package/dist/ipc/server.d.ts +50 -0
- package/dist/ipc/server.d.ts.map +1 -0
- package/dist/ipc/server.js +168 -0
- package/dist/ipc/server.js.map +1 -0
- package/dist/pack.d.ts +19 -0
- package/dist/pack.d.ts.map +1 -0
- package/dist/pack.js +225 -0
- package/dist/pack.js.map +1 -0
- package/dist/push.d.ts +43 -0
- package/dist/push.d.ts.map +1 -0
- package/dist/push.js +532 -0
- package/dist/push.js.map +1 -0
- package/dist/rojo.d.ts +9 -0
- package/dist/rojo.d.ts.map +1 -0
- package/dist/rojo.js +114 -0
- package/dist/rojo.js.map +1 -0
- package/dist/snapshot/rojo.d.ts +39 -0
- package/dist/snapshot/rojo.d.ts.map +1 -0
- package/dist/snapshot/rojo.js +364 -0
- package/dist/snapshot/rojo.js.map +1 -0
- package/dist/snapshot.d.ts +23 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +132 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/sourcemap/generator.d.ts +78 -0
- package/dist/sourcemap/generator.d.ts.map +1 -0
- package/dist/sourcemap/generator.js +351 -0
- package/dist/sourcemap/generator.js.map +1 -0
- package/dist/sourcemap/propertyLoader.d.ts +19 -0
- package/dist/sourcemap/propertyLoader.d.ts.map +1 -0
- package/dist/sourcemap/propertyLoader.js +131 -0
- package/dist/sourcemap/propertyLoader.js.map +1 -0
- package/dist/util/id.d.ts +9 -0
- package/dist/util/id.d.ts.map +1 -0
- package/dist/util/id.js +14 -0
- package/dist/util/id.js.map +1 -0
- package/dist/util/log.d.ts +13 -0
- package/dist/util/log.d.ts.map +1 -0
- package/dist/util/log.js +51 -0
- package/dist/util/log.js.map +1 -0
- package/docs/assets/azul-logo.pdn +0 -0
- package/docs/assets/logo-200px.png +0 -0
- package/docs/assets/logo.png +0 -0
- package/docs/assets/plugin/toolbox.png +0 -0
- package/docs/assets/synced.png +0 -0
- package/package.json +41 -0
- package/plugin/README.md +54 -0
- package/plugin/sourcemap.json +264 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/Actor/AzulSync.server.luau +905 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/AzulService.luau +1010 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/Config.luau +29 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/Enums.luau +11 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/CollapsibleTitledSection.luau +214 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/ColorPicker.luau +360 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/CustomTextButton.luau +170 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/DropdownMenu.luau +363 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/HorizontalLine.luau +43 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/ImageButtonWithText.luau +181 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledCheckbox.luau +295 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledColorInputPicker.luau +294 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledMultiChoice.luau +163 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledNumberInput.luau +312 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledRadioButton.luau +55 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledSlider.luau +151 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledTextInput.luau +222 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledToggleButton.luau +73 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/StatefulImageButton.luau +125 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/VerticalScrollingFrame.luau +100 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/VerticalSpacer.luau +35 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/VerticallyScalingListFrame.luau +107 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/GuiUtilities.luau +429 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/RbxGui.luau +4363 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/UI.luau +425 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/WebSocketClient.luau +161 -0
- package/src/build.ts +120 -0
- package/src/cli.ts +496 -0
- package/src/config.ts +170 -0
- package/src/fs/fileWriter.ts +414 -0
- package/src/fs/treeManager.ts +458 -0
- package/src/fs/watcher.ts +142 -0
- package/src/index.ts +450 -0
- package/src/ipc/httpPolling.ts +214 -0
- package/src/ipc/messages.ts +159 -0
- package/src/ipc/server.ts +196 -0
- package/src/pack.ts +309 -0
- package/src/push.ts +726 -0
- package/src/snapshot/rojo.ts +467 -0
- package/src/snapshot.ts +161 -0
- package/src/sourcemap/generator.ts +504 -0
- package/src/sourcemap/propertyLoader.ts +195 -0
- package/src/util/id.ts +15 -0
- package/src/util/log.ts +94 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,905 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Azul - Roblox Studio Plugin.
|
|
4
|
+
Companion plugin for Azul to sync scripts between Roblox Studio and external editors.
|
|
5
|
+
|
|
6
|
+
Ransomwave 2025
|
|
7
|
+
]]
|
|
8
|
+
|
|
9
|
+
-- Services
|
|
10
|
+
local HttpService = game:GetService("HttpService")
|
|
11
|
+
local RunService = game:GetService("RunService")
|
|
12
|
+
|
|
13
|
+
-- Prevent from running if the game is being Playtested
|
|
14
|
+
if RunService:IsRunning() then return end
|
|
15
|
+
|
|
16
|
+
local pluginParent = script.Parent
|
|
17
|
+
if not pluginParent then return end
|
|
18
|
+
local azulPluginFolder = pluginParent.Parent
|
|
19
|
+
if not azulPluginFolder then return end
|
|
20
|
+
|
|
21
|
+
-- Modules
|
|
22
|
+
local WebSocketClient = require("../WebSocketClient")
|
|
23
|
+
local UI = require("../UI")
|
|
24
|
+
local CONFIG = require("../Config")
|
|
25
|
+
local Enums = require("../Enums")
|
|
26
|
+
local AzulService = require("../AzulService")
|
|
27
|
+
|
|
28
|
+
-- Settings
|
|
29
|
+
local PUSH_CONFIG_PATH = {
|
|
30
|
+
"ServerStorage",
|
|
31
|
+
"Azul",
|
|
32
|
+
"Config",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
local SETTINGS_SCOPE = Enums.scope.GLOBAL -- or "project"
|
|
36
|
+
local SETTINGS_KEY = "AZUL_SETTINGS"
|
|
37
|
+
local SETTINGS_SCOPE_KEY = "AZUL_SETTINGS_SCOPE_BY_PLACE"
|
|
38
|
+
|
|
39
|
+
-- Logging helpers
|
|
40
|
+
local function debugPrint(...)
|
|
41
|
+
if CONFIG.SILENT_MODE or not CONFIG.DEBUG_MODE then return end
|
|
42
|
+
print(`[🐛 Azul]:`, ...)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
local function infoPrint(...)
|
|
46
|
+
if CONFIG.SILENT_MODE then return end
|
|
47
|
+
print(`[Azul]: `, ...)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
local function getScopeKey(scope)
|
|
51
|
+
local placeId = tostring(game.PlaceId or "0")
|
|
52
|
+
if scope == Enums.scope.PROJECT then return `project_{placeId}` end
|
|
53
|
+
return "global"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
local function getPlaceScopeSelectionKey()
|
|
57
|
+
return tostring(game.PlaceId or "0")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
local function saveSelectedScope()
|
|
61
|
+
local selectedScopes = plugin:GetSetting(SETTINGS_SCOPE_KEY)
|
|
62
|
+
if type(selectedScopes) ~= "table" then selectedScopes = {} end
|
|
63
|
+
|
|
64
|
+
selectedScopes[getPlaceScopeSelectionKey()] = SETTINGS_SCOPE
|
|
65
|
+
plugin:SetSetting(SETTINGS_SCOPE_KEY, selectedScopes)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
local function loadSelectedScope()
|
|
69
|
+
local selectedScopes = plugin:GetSetting(SETTINGS_SCOPE_KEY)
|
|
70
|
+
if type(selectedScopes) ~= "table" then return end
|
|
71
|
+
|
|
72
|
+
local scope = selectedScopes[getPlaceScopeSelectionKey()]
|
|
73
|
+
if scope == Enums.scope.GLOBAL or scope == Enums.scope.PROJECT then SETTINGS_SCOPE = scope end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
local _storedSettings = {}
|
|
77
|
+
|
|
78
|
+
-- Save settings
|
|
79
|
+
local function saveSettings()
|
|
80
|
+
local all = plugin:GetSetting(SETTINGS_KEY)
|
|
81
|
+
if type(all) ~= "table" then all = {} end
|
|
82
|
+
|
|
83
|
+
local scopeKey = getScopeKey(SETTINGS_SCOPE)
|
|
84
|
+
local scopedCopy = {}
|
|
85
|
+
for key, value in pairs(CONFIG) do
|
|
86
|
+
scopedCopy[key] = value
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
-- Always persist debug/silent at the global level
|
|
90
|
+
local globalScoped = all["global"]
|
|
91
|
+
if type(globalScoped) ~= "table" then globalScoped = {} end
|
|
92
|
+
globalScoped.DEBUG_MODE = CONFIG.DEBUG_MODE
|
|
93
|
+
globalScoped.SILENT_MODE = CONFIG.SILENT_MODE
|
|
94
|
+
all["global"] = globalScoped
|
|
95
|
+
|
|
96
|
+
all[scopeKey] = scopedCopy
|
|
97
|
+
_storedSettings = all
|
|
98
|
+
plugin:SetSetting(SETTINGS_KEY, all)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
local function loadSettings()
|
|
102
|
+
local all = plugin:GetSetting(SETTINGS_KEY)
|
|
103
|
+
if type(all) ~= "table" then
|
|
104
|
+
all = {}
|
|
105
|
+
-- Legacy flat settings fallback
|
|
106
|
+
for key, _ in pairs(CONFIG) do
|
|
107
|
+
local legacyValue = plugin:GetSetting(key)
|
|
108
|
+
if legacyValue ~= nil then CONFIG[key] = legacyValue end
|
|
109
|
+
end
|
|
110
|
+
_storedSettings = all
|
|
111
|
+
return
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
_storedSettings = all
|
|
115
|
+
local scopeKey = getScopeKey(SETTINGS_SCOPE)
|
|
116
|
+
local scoped = all[scopeKey]
|
|
117
|
+
local globalScoped = all["global"]
|
|
118
|
+
|
|
119
|
+
if type(scoped) ~= "table" then scoped = {} end
|
|
120
|
+
if type(globalScoped) ~= "table" then globalScoped = {} end
|
|
121
|
+
|
|
122
|
+
for key, _ in pairs(CONFIG) do
|
|
123
|
+
local value = scoped[key]
|
|
124
|
+
if value == nil and scopeKey ~= "global" then value = globalScoped[key] end
|
|
125
|
+
if value ~= nil then CONFIG[key] = value end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
-- DEBUG_MODE and SILENT_MODE always come from global
|
|
129
|
+
if globalScoped.DEBUG_MODE ~= nil then CONFIG.DEBUG_MODE = globalScoped.DEBUG_MODE end
|
|
130
|
+
if globalScoped.SILENT_MODE ~= nil then CONFIG.SILENT_MODE = globalScoped.SILENT_MODE end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
loadSelectedScope()
|
|
134
|
+
loadSettings()
|
|
135
|
+
|
|
136
|
+
-- Sync state
|
|
137
|
+
local syncEnabled = false
|
|
138
|
+
local wsClient = nil
|
|
139
|
+
local trackedInstances = {}
|
|
140
|
+
local guidMap = {}
|
|
141
|
+
local usedGuids = {}
|
|
142
|
+
local lastHeartbeat = 0
|
|
143
|
+
local applyingPatch = false
|
|
144
|
+
local lastPatchTime = {} -- Track last patch time per GUID to prevent loops
|
|
145
|
+
local recentPatches = {} -- Track which scripts were recently patched from daemon
|
|
146
|
+
local lastInstanceUpdate = {} -- Deduplicate identical rapid instanceUpdated payloads per GUID
|
|
147
|
+
local connections = {}
|
|
148
|
+
local suppressOutbound = false -- Prevent emitting events while applying inbound patches
|
|
149
|
+
|
|
150
|
+
local pendingInstanceUpdates = {}
|
|
151
|
+
local pendingScriptChanges = {}
|
|
152
|
+
local pendingScriptChangeReady = {}
|
|
153
|
+
local scriptChangeDebounceTokenByGuid = {}
|
|
154
|
+
local pendingDeletes = {}
|
|
155
|
+
|
|
156
|
+
local batchingEnabled = false
|
|
157
|
+
local batchFlushToken = 0 -- Incremented to invalidate pending flush timers
|
|
158
|
+
local batchAccumulator = 0
|
|
159
|
+
|
|
160
|
+
local BATCH_INTERVAL = 0.2 -- Seconds between outbound batch flushes
|
|
161
|
+
local BATCH_IDLE_WINDOW = 0.1 -- Seconds of inactivity before sending batch
|
|
162
|
+
local SCRIPT_CHANGE_DEBOUNCE = 0.35 -- Quiet window before script source changes are eligible to send
|
|
163
|
+
|
|
164
|
+
-- Utility: Check if instance is a script
|
|
165
|
+
local function isScript(instance)
|
|
166
|
+
return instance:IsA("Script") or instance:IsA("LocalScript") or instance:IsA("ModuleScript")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
local serviceSet = {}
|
|
170
|
+
|
|
171
|
+
AzulService.rebuildServiceSet(serviceSet)
|
|
172
|
+
|
|
173
|
+
-- Utility: Check if instance should be included in snapshot (all instances)
|
|
174
|
+
local function shouldIncludeInSnapshot(instance)
|
|
175
|
+
if not instance then return false end
|
|
176
|
+
|
|
177
|
+
return not AzulService.isExcluded(instance, serviceSet)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
-- Send message to daemon
|
|
181
|
+
local function sendMessage(messageTypeOrPayload, data: any?)
|
|
182
|
+
if not wsClient or not wsClient.connected then return false end
|
|
183
|
+
|
|
184
|
+
local message
|
|
185
|
+
if type(messageTypeOrPayload) == "table" then
|
|
186
|
+
message = {}
|
|
187
|
+
for key, value in pairs(messageTypeOrPayload) do
|
|
188
|
+
message[key] = value
|
|
189
|
+
end
|
|
190
|
+
else
|
|
191
|
+
message = {
|
|
192
|
+
type = messageTypeOrPayload,
|
|
193
|
+
}
|
|
194
|
+
for key, value in pairs(data or {}) do
|
|
195
|
+
message[key] = value
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
debugPrint(`Sending message: {message.type}`)
|
|
200
|
+
|
|
201
|
+
local json = HttpService:JSONEncode(message)
|
|
202
|
+
return wsClient:send(json)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
-- Toggle outbound emission (used while applying inbound patches)
|
|
206
|
+
local function setOutboundSuppressed(suppressed: boolean)
|
|
207
|
+
suppressOutbound = suppressed
|
|
208
|
+
if suppressed then
|
|
209
|
+
-- Drop any pending outbound changes to avoid echoing daemon writes
|
|
210
|
+
table.clear(pendingInstanceUpdates)
|
|
211
|
+
table.clear(pendingScriptChanges)
|
|
212
|
+
table.clear(pendingScriptChangeReady)
|
|
213
|
+
table.clear(scriptChangeDebounceTokenByGuid)
|
|
214
|
+
table.clear(pendingDeletes)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
-- Send full snapshot
|
|
219
|
+
local function sendFullSnapshot(options: { includeProperties: boolean, scriptsAndDescendantsOnly: boolean }?)
|
|
220
|
+
infoPrint("Sending full snapshot...")
|
|
221
|
+
|
|
222
|
+
local includeProperties = options and options.includeProperties == true
|
|
223
|
+
local scriptsAndDescendantsOnly = options and options.scriptsAndDescendantsOnly == true
|
|
224
|
+
local includeAncestorsOfScripts: { [Instance]: boolean } = {}
|
|
225
|
+
|
|
226
|
+
if scriptsAndDescendantsOnly then
|
|
227
|
+
for _, descendant in ipairs(game:GetDescendants()) do
|
|
228
|
+
if isScript(descendant) and shouldIncludeInSnapshot(descendant) then
|
|
229
|
+
local current: Instance? = descendant
|
|
230
|
+
while current and current ~= game do
|
|
231
|
+
if shouldIncludeInSnapshot(current) then includeAncestorsOfScripts[current] = true end
|
|
232
|
+
current = current.Parent
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
-- Reset tracking to ensure fresh GUID deduping
|
|
239
|
+
trackedInstances = {}
|
|
240
|
+
guidMap = {}
|
|
241
|
+
usedGuids = {}
|
|
242
|
+
|
|
243
|
+
local instances = {}
|
|
244
|
+
local scriptCount = 0
|
|
245
|
+
local batchSize = 400 -- Yield periodically to avoid plugin thread timeouts
|
|
246
|
+
|
|
247
|
+
-- Build a queue to iterate without deep recursion
|
|
248
|
+
local queue = {}
|
|
249
|
+
for _, service in ipairs(game:GetChildren()) do
|
|
250
|
+
if shouldIncludeInSnapshot(service) then
|
|
251
|
+
table.insert(queue, {
|
|
252
|
+
instance = service,
|
|
253
|
+
hasScriptAncestor = false,
|
|
254
|
+
})
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
local index = 1
|
|
259
|
+
while index <= #queue do
|
|
260
|
+
if not syncEnabled then
|
|
261
|
+
infoPrint("Snapshot aborted - sync disabled")
|
|
262
|
+
return
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
local queued = queue[index]
|
|
266
|
+
local instance = queued.instance
|
|
267
|
+
local hasScriptAncestor = queued.hasScriptAncestor == true
|
|
268
|
+
|
|
269
|
+
local includeByMode = true
|
|
270
|
+
if scriptsAndDescendantsOnly then
|
|
271
|
+
includeByMode = isScript(instance) or hasScriptAncestor or includeAncestorsOfScripts[instance] == true
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
if shouldIncludeInSnapshot(instance) and includeByMode then
|
|
275
|
+
local data = AzulService.instanceToData(instance, trackedInstances, guidMap, usedGuids, {
|
|
276
|
+
includeProperties = includeProperties,
|
|
277
|
+
})
|
|
278
|
+
if data then
|
|
279
|
+
table.insert(instances, data)
|
|
280
|
+
|
|
281
|
+
trackedInstances[instance] = data.guid
|
|
282
|
+
guidMap[data.guid] = instance
|
|
283
|
+
|
|
284
|
+
if isScript(instance) then
|
|
285
|
+
scriptCount += 1
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
-- Enqueue children that are eligible for traversal
|
|
291
|
+
for _, child in ipairs(instance:GetChildren()) do
|
|
292
|
+
if shouldIncludeInSnapshot(child) then
|
|
293
|
+
table.insert(queue, {
|
|
294
|
+
instance = child,
|
|
295
|
+
hasScriptAncestor = hasScriptAncestor or isScript(instance),
|
|
296
|
+
})
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
-- Yield periodically to avoid hitting Studio's execution time limit
|
|
301
|
+
if index % batchSize == 0 then task.wait() end
|
|
302
|
+
|
|
303
|
+
index += 1
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
-- Send snapshot
|
|
307
|
+
sendMessage("fullSnapshot", { data = instances })
|
|
308
|
+
infoPrint("Snapshot sent:", #instances, "instances (", scriptCount, "scripts )")
|
|
309
|
+
|
|
310
|
+
if syncEnabled then
|
|
311
|
+
if includeProperties or scriptsAndDescendantsOnly then
|
|
312
|
+
debugPrint(
|
|
313
|
+
`Snapshot sent with options (includeProperties={tostring(includeProperties)}, scriptsAndDescendantsOnly={tostring(
|
|
314
|
+
scriptsAndDescendantsOnly
|
|
315
|
+
)}) - stopping sync since "pack" is one-shot.`
|
|
316
|
+
)
|
|
317
|
+
stopSync()
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
batchingEnabled = true
|
|
321
|
+
batchFlushToken += 1 -- Invalidate any pre-snapshot timers
|
|
322
|
+
batchAccumulator = 0
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
-- Queue and batch outbound messages to reduce chatter
|
|
327
|
+
local function flushOutboundQueues()
|
|
328
|
+
local messages: { [number]: { type: string, data: any } } = {}
|
|
329
|
+
|
|
330
|
+
for _, data in pairs(pendingInstanceUpdates) do
|
|
331
|
+
table.insert(messages, {
|
|
332
|
+
type = "instanceUpdated",
|
|
333
|
+
data = data,
|
|
334
|
+
})
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
for _, payload in pairs(pendingScriptChanges) do
|
|
338
|
+
if pendingScriptChangeReady[payload.guid] == true then
|
|
339
|
+
table.insert(messages, {
|
|
340
|
+
type = "scriptChanged",
|
|
341
|
+
data = {
|
|
342
|
+
guid = payload.guid,
|
|
343
|
+
path = payload.path,
|
|
344
|
+
className = payload.className,
|
|
345
|
+
source = payload.source,
|
|
346
|
+
},
|
|
347
|
+
})
|
|
348
|
+
pendingScriptChanges[payload.guid] = nil
|
|
349
|
+
pendingScriptChangeReady[payload.guid] = nil
|
|
350
|
+
scriptChangeDebounceTokenByGuid[payload.guid] = nil
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
for guid, shouldSend in pairs(pendingDeletes) do
|
|
355
|
+
if shouldSend then table.insert(messages, {
|
|
356
|
+
type = "deleted",
|
|
357
|
+
data = {
|
|
358
|
+
guid = guid,
|
|
359
|
+
},
|
|
360
|
+
}) end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
table.clear(pendingInstanceUpdates)
|
|
364
|
+
table.clear(pendingDeletes)
|
|
365
|
+
|
|
366
|
+
if #messages == 0 then return end
|
|
367
|
+
|
|
368
|
+
if batchingEnabled and #messages > 1 then
|
|
369
|
+
if #messages > 200 then
|
|
370
|
+
debugPrint(`Too many messages ({#messages}) in batch, sending full snapshot instead.`)
|
|
371
|
+
sendFullSnapshot()
|
|
372
|
+
else
|
|
373
|
+
sendMessage("batch", { messages = messages })
|
|
374
|
+
end
|
|
375
|
+
else
|
|
376
|
+
for _, message in ipairs(messages) do
|
|
377
|
+
sendMessage(message)
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
-- Schedule a batch flush after a quiet window
|
|
383
|
+
local function scheduleBatchFlush()
|
|
384
|
+
if not batchingEnabled then return end
|
|
385
|
+
|
|
386
|
+
batchFlushToken += 1
|
|
387
|
+
local token = batchFlushToken
|
|
388
|
+
|
|
389
|
+
task.delay(BATCH_IDLE_WINDOW, function()
|
|
390
|
+
if token ~= batchFlushToken then return end
|
|
391
|
+
flushOutboundQueues()
|
|
392
|
+
end)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
local function queueInstanceUpdate(data)
|
|
396
|
+
if suppressOutbound or not syncEnabled then return end
|
|
397
|
+
if not data or not data.guid then return end
|
|
398
|
+
|
|
399
|
+
pendingInstanceUpdates[data.guid] = data
|
|
400
|
+
scheduleBatchFlush()
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
local function queueScriptChange(payload)
|
|
404
|
+
if suppressOutbound or not syncEnabled then return end
|
|
405
|
+
if not payload or not payload.guid then return end
|
|
406
|
+
|
|
407
|
+
pendingScriptChangeReady[payload.guid] = false
|
|
408
|
+
pendingScriptChanges[payload.guid] = payload
|
|
409
|
+
|
|
410
|
+
local token = (scriptChangeDebounceTokenByGuid[payload.guid] or 0) + 1
|
|
411
|
+
scriptChangeDebounceTokenByGuid[payload.guid] = token
|
|
412
|
+
|
|
413
|
+
task.delay(SCRIPT_CHANGE_DEBOUNCE, function()
|
|
414
|
+
if not syncEnabled or suppressOutbound then return end
|
|
415
|
+
if scriptChangeDebounceTokenByGuid[payload.guid] ~= token then return end
|
|
416
|
+
if not pendingScriptChanges[payload.guid] then return end
|
|
417
|
+
|
|
418
|
+
pendingScriptChangeReady[payload.guid] = true
|
|
419
|
+
scheduleBatchFlush()
|
|
420
|
+
end)
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
local function queueDeletion(guid: string)
|
|
424
|
+
if suppressOutbound or not syncEnabled then return end
|
|
425
|
+
if not guid or guid == "" then return end
|
|
426
|
+
|
|
427
|
+
pendingDeletes[guid] = true
|
|
428
|
+
scheduleBatchFlush()
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
-- Deduped instance update sender (used for adds, renames, reparent)
|
|
432
|
+
local function sendInstanceUpdateDedup(instance)
|
|
433
|
+
if suppressOutbound or not syncEnabled then return end
|
|
434
|
+
|
|
435
|
+
local data = AzulService.instanceToDataDedup(instance, trackedInstances, guidMap, usedGuids, lastInstanceUpdate)
|
|
436
|
+
if not data then return end
|
|
437
|
+
|
|
438
|
+
local guid = data.guid
|
|
439
|
+
trackedInstances[instance] = guid
|
|
440
|
+
guidMap[guid] = instance
|
|
441
|
+
|
|
442
|
+
queueInstanceUpdate(data)
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
-- Utility: Check if instance should be synced (scripts only)
|
|
446
|
+
local function shouldSync(instance)
|
|
447
|
+
if not instance then return false end
|
|
448
|
+
|
|
449
|
+
-- Only sync scripts
|
|
450
|
+
if not isScript(instance) then return false end
|
|
451
|
+
|
|
452
|
+
return not AzulService.isExcluded(instance, serviceSet)
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
-- Handle script change
|
|
456
|
+
local function onScriptChanged(changedScript: Script | LocalScript | ModuleScript)
|
|
457
|
+
if suppressOutbound or not shouldSync(changedScript) then return end
|
|
458
|
+
|
|
459
|
+
local guid = AzulService.getOrCreateGUID(changedScript, trackedInstances, guidMap, usedGuids)
|
|
460
|
+
|
|
461
|
+
-- Don't send changes if this was just patched from daemon
|
|
462
|
+
if recentPatches[guid] then
|
|
463
|
+
debugPrint("Ignoring change (was just patched from daemon):", `{changedScript.Parent}.{changedScript.Name}`)
|
|
464
|
+
recentPatches[guid] = nil
|
|
465
|
+
return
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
-- Don't send changes if we're applying a patch from daemon
|
|
469
|
+
if applyingPatch then return end
|
|
470
|
+
|
|
471
|
+
-- Don't send changes within 1 second of receiving a patch (debounce)
|
|
472
|
+
local lastPatch = lastPatchTime[guid] or 0
|
|
473
|
+
local now = tick()
|
|
474
|
+
if now - lastPatch < 1 then
|
|
475
|
+
debugPrint("Ignoring change (too soon after patch):", `{changedScript.Parent}.{changedScript.Name}`)
|
|
476
|
+
return
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
local path = AzulService.getInstancePath(changedScript)
|
|
480
|
+
if not path then return end
|
|
481
|
+
|
|
482
|
+
queueScriptChange({
|
|
483
|
+
guid = guid,
|
|
484
|
+
path = path,
|
|
485
|
+
className = changedScript.ClassName,
|
|
486
|
+
source = changedScript.Source,
|
|
487
|
+
})
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
-- Utility: register change listeners on an instance for name/parent/source updates
|
|
491
|
+
local function attachListeners(instance)
|
|
492
|
+
if not shouldIncludeInSnapshot(instance) then return end
|
|
493
|
+
|
|
494
|
+
-- Name changes should propagate to daemon (renames / path changes)
|
|
495
|
+
local nameConnnection = instance:GetPropertyChangedSignal("Name"):Connect(function()
|
|
496
|
+
sendInstanceUpdateDedup(instance)
|
|
497
|
+
end)
|
|
498
|
+
|
|
499
|
+
table.insert(connections, nameConnnection)
|
|
500
|
+
|
|
501
|
+
-- Parent changes (reparent/move) also change path
|
|
502
|
+
local parentConnection = instance:GetPropertyChangedSignal("Parent"):Connect(function()
|
|
503
|
+
-- If parent is nil (destroy in progress), rely on DescendantRemoving -> deleted
|
|
504
|
+
if instance.Parent == nil then return end
|
|
505
|
+
sendInstanceUpdateDedup(instance)
|
|
506
|
+
end)
|
|
507
|
+
|
|
508
|
+
table.insert(connections, parentConnection)
|
|
509
|
+
|
|
510
|
+
-- Source changes (scripts only)
|
|
511
|
+
if isScript(instance) then
|
|
512
|
+
local sourceConnection = (instance :: Script):GetPropertyChangedSignal("Source"):Connect(function()
|
|
513
|
+
if syncEnabled then onScriptChanged(instance :: Script) end
|
|
514
|
+
end)
|
|
515
|
+
table.insert(connections, sourceConnection)
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
local function wipeChildren(container: Instance)
|
|
520
|
+
for _, child in ipairs(container:GetChildren()) do
|
|
521
|
+
if AzulService.isProtectedRobloxContainer(child) then continue end
|
|
522
|
+
child:Destroy()
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
-- Locate the ModuleScript that carries push settings for this place
|
|
527
|
+
|
|
528
|
+
local function sendDaemonConfig(warnOnFail: boolean?)
|
|
529
|
+
local configPayload, err = AzulService.readPushConfig(PUSH_CONFIG_PATH)
|
|
530
|
+
if not configPayload then
|
|
531
|
+
if warnOnFail then
|
|
532
|
+
warn(`[Azul]: daemon config unavailable: {err}`)
|
|
533
|
+
else
|
|
534
|
+
debugPrint(`daemon config unavailable: {err}`)
|
|
535
|
+
end
|
|
536
|
+
return false
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
return sendMessage("pushConfig", {
|
|
540
|
+
config = configPayload,
|
|
541
|
+
})
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
-- Handle instance added
|
|
545
|
+
local function onInstanceAdded(instance: Instance)
|
|
546
|
+
if suppressOutbound then return end
|
|
547
|
+
-- Include all non-excluded instances (scripts + containers) so sourcemap stays accurate
|
|
548
|
+
if not shouldIncludeInSnapshot(instance) then return end
|
|
549
|
+
|
|
550
|
+
local data = AzulService.instanceToData(instance, trackedInstances, guidMap, usedGuids)
|
|
551
|
+
if not data then return end
|
|
552
|
+
-- local guid = data.guid
|
|
553
|
+
sendInstanceUpdateDedup(instance)
|
|
554
|
+
|
|
555
|
+
-- Track subsequent changes (rename/reparent/source)
|
|
556
|
+
attachListeners(instance)
|
|
557
|
+
|
|
558
|
+
-- Watch for source changes (scripts only)
|
|
559
|
+
-- (handled inside attachListeners)
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
-- Handle instance removed
|
|
563
|
+
local function onInstanceRemoved(instance)
|
|
564
|
+
if suppressOutbound then return end
|
|
565
|
+
|
|
566
|
+
if not trackedInstances[instance] then
|
|
567
|
+
-- debugPrint(`Instance is excluded: {instance.Parent}.{instance.Name}`)
|
|
568
|
+
return
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
local guid = trackedInstances[instance]
|
|
572
|
+
|
|
573
|
+
-- Fallback: if we never tracked this instance (edge cases), use debug id directly
|
|
574
|
+
if not guid then guid = instance:GetDebugId(0) end
|
|
575
|
+
|
|
576
|
+
if not guid then return end
|
|
577
|
+
|
|
578
|
+
trackedInstances[instance] = nil
|
|
579
|
+
guidMap[guid] = nil
|
|
580
|
+
usedGuids[guid] = nil
|
|
581
|
+
|
|
582
|
+
queueDeletion(guid)
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
-- Process incoming daemon message
|
|
586
|
+
local function processMessage(message)
|
|
587
|
+
debugPrint("Processing message type:", message.type)
|
|
588
|
+
|
|
589
|
+
if message.type == "patchScript" then
|
|
590
|
+
debugPrint("Patch requested for GUID:", message.guid)
|
|
591
|
+
-- Update script source
|
|
592
|
+
local instance = guidMap[message.guid]
|
|
593
|
+
if instance and isScript(instance) then
|
|
594
|
+
setOutboundSuppressed(true)
|
|
595
|
+
-- Mark this script as recently patched from daemon
|
|
596
|
+
recentPatches[message.guid] = true
|
|
597
|
+
|
|
598
|
+
-- Record patch time BEFORE applying to prevent echo
|
|
599
|
+
lastPatchTime[message.guid] = tick()
|
|
600
|
+
|
|
601
|
+
-- Update source (large scripts via ScriptEditorService)
|
|
602
|
+
applyingPatch = true
|
|
603
|
+
AzulService.setScriptSource(instance :: Script, message.source) -- We already checked isScript above, we can assert this cast
|
|
604
|
+
|
|
605
|
+
-- Keep flag set longer to cover any async events
|
|
606
|
+
task.delay(0.2, function()
|
|
607
|
+
applyingPatch = false
|
|
608
|
+
setOutboundSuppressed(false)
|
|
609
|
+
end)
|
|
610
|
+
|
|
611
|
+
infoPrint("Updated script:", `{instance.Parent}.{instance.Name}`)
|
|
612
|
+
|
|
613
|
+
-- Clear the patch marker after editor refresh completes
|
|
614
|
+
task.delay(0.2, function()
|
|
615
|
+
task.wait(0.2)
|
|
616
|
+
recentPatches[message.guid] = nil
|
|
617
|
+
end)
|
|
618
|
+
else
|
|
619
|
+
warn("[Azul]: Cannot apply patch - instance not found for GUID:", message.guid)
|
|
620
|
+
local count = 0
|
|
621
|
+
for _ in pairs(guidMap) do
|
|
622
|
+
count = count + 1
|
|
623
|
+
end
|
|
624
|
+
warn("[Azul]: Total tracked instances:", count)
|
|
625
|
+
end
|
|
626
|
+
elseif message.type == "requestSnapshot" then
|
|
627
|
+
-- Daemon is requesting a full snapshot
|
|
628
|
+
infoPrint("Snapshot requested by daemon")
|
|
629
|
+
sendFullSnapshot(message.options)
|
|
630
|
+
elseif message.type == "buildSnapshot" then
|
|
631
|
+
-- One-time push from filesystem into Studio
|
|
632
|
+
infoPrint("Applying build snapshot from daemon")
|
|
633
|
+
setOutboundSuppressed(true)
|
|
634
|
+
|
|
635
|
+
local ok, err = pcall(function()
|
|
636
|
+
local created, updated = AzulService.applySnapshotInstances(message.data)
|
|
637
|
+
-- Update tracking with new instances
|
|
638
|
+
for _, item in ipairs(message.data or {}) do
|
|
639
|
+
local instance = guidMap[item.guid]
|
|
640
|
+
if instance then
|
|
641
|
+
trackedInstances[instance] = item.guid
|
|
642
|
+
attachListeners(instance)
|
|
643
|
+
end
|
|
644
|
+
end
|
|
645
|
+
infoPrint(`Build snapshot applied ({created} created, {updated} updated)`)
|
|
646
|
+
end)
|
|
647
|
+
|
|
648
|
+
setOutboundSuppressed(false)
|
|
649
|
+
if not ok then warn("[Azul]: Error applying build snapshot", err) end
|
|
650
|
+
|
|
651
|
+
-- Disconnect after build completes (daemon will exit)
|
|
652
|
+
task.delay(0.5, function()
|
|
653
|
+
stopSync()
|
|
654
|
+
end)
|
|
655
|
+
elseif message.type == "pushSnapshot" then
|
|
656
|
+
infoPrint("Applying push snapshot from daemon")
|
|
657
|
+
setOutboundSuppressed(true)
|
|
658
|
+
|
|
659
|
+
local ok, err = pcall(function()
|
|
660
|
+
for _, mapping in ipairs(message.mappings or {}) do
|
|
661
|
+
local destination = mapping.destination or {}
|
|
662
|
+
if #destination == 0 then
|
|
663
|
+
warn("[Azul]: Push mapping missing destination; skipping")
|
|
664
|
+
continue
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
local targetContainer = AzulService.getOrCreatePath(destination)
|
|
668
|
+
if mapping.destructive then wipeChildren(targetContainer) end
|
|
669
|
+
|
|
670
|
+
local created = AzulService.applySnapshotInstances(mapping.instances or {})
|
|
671
|
+
-- Update tracking with new instances
|
|
672
|
+
for _, item in ipairs(mapping.instances or {}) do
|
|
673
|
+
local instance = guidMap[item.guid]
|
|
674
|
+
if instance then
|
|
675
|
+
trackedInstances[instance] = item.guid
|
|
676
|
+
attachListeners(instance)
|
|
677
|
+
end
|
|
678
|
+
end
|
|
679
|
+
infoPrint(`Push applied to {table.concat(destination, "/")} ({created} created/updated)`)
|
|
680
|
+
end
|
|
681
|
+
end)
|
|
682
|
+
|
|
683
|
+
setOutboundSuppressed(false)
|
|
684
|
+
if not ok then warn("[Azul]: Error applying push snapshot", err) end
|
|
685
|
+
|
|
686
|
+
-- Disconnect after push completes (daemon will exit)
|
|
687
|
+
task.delay(0.5, function()
|
|
688
|
+
stopSync()
|
|
689
|
+
end)
|
|
690
|
+
elseif message.type == "requestPushConfig" then
|
|
691
|
+
sendDaemonConfig(true)
|
|
692
|
+
elseif message.type == "error" then
|
|
693
|
+
warn("[Azul]: Daemon error:", message.message)
|
|
694
|
+
elseif message.type == "pong" then
|
|
695
|
+
-- Heartbeat response
|
|
696
|
+
debugPrint("Received pong")
|
|
697
|
+
else
|
|
698
|
+
warn("[Azul]: Unknown message type:", message.type)
|
|
699
|
+
end
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
-- Declare azulUI before using it
|
|
703
|
+
local azulUI
|
|
704
|
+
|
|
705
|
+
-- Start sync
|
|
706
|
+
local function startSync()
|
|
707
|
+
if syncEnabled then return end
|
|
708
|
+
|
|
709
|
+
infoPrint("Starting sync...")
|
|
710
|
+
syncEnabled = true
|
|
711
|
+
batchingEnabled = false
|
|
712
|
+
batchFlushToken += 1
|
|
713
|
+
|
|
714
|
+
if azulUI then azulUI:UpdateSyncState(true) end
|
|
715
|
+
|
|
716
|
+
-- Create and connect WebSocket client
|
|
717
|
+
wsClient = WebSocketClient.new(CONFIG.WS_URL, {
|
|
718
|
+
debugMode = CONFIG.DEBUG_MODE,
|
|
719
|
+
silentMode = CONFIG.SILENT_MODE,
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
-- Set up message handler
|
|
723
|
+
wsClient:on("message", function(message)
|
|
724
|
+
processMessage(message)
|
|
725
|
+
end)
|
|
726
|
+
|
|
727
|
+
-- Set up connection handler
|
|
728
|
+
wsClient:on("connect", function()
|
|
729
|
+
infoPrint("Connected to daemon")
|
|
730
|
+
-- Send initial snapshot after connection
|
|
731
|
+
sendDaemonConfig()
|
|
732
|
+
end)
|
|
733
|
+
|
|
734
|
+
-- Set up disconnect handler
|
|
735
|
+
wsClient:on("disconnect", function()
|
|
736
|
+
infoPrint("Disconnected from daemon")
|
|
737
|
+
stopSync()
|
|
738
|
+
end)
|
|
739
|
+
|
|
740
|
+
-- Set up error handler
|
|
741
|
+
wsClient:on("error", function(error)
|
|
742
|
+
warn("[Azul]: Connection error:", error)
|
|
743
|
+
end)
|
|
744
|
+
|
|
745
|
+
-- Connect to daemon
|
|
746
|
+
local connected = wsClient:connect()
|
|
747
|
+
|
|
748
|
+
if not connected then
|
|
749
|
+
warn("[Azul]: Failed to connect to daemon")
|
|
750
|
+
stopSync()
|
|
751
|
+
return
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
-- Defer listener setup to avoid blocking
|
|
755
|
+
task.defer(function()
|
|
756
|
+
-- Setup listeners for existing instances
|
|
757
|
+
local function setupListeners(parent)
|
|
758
|
+
for _, child in ipairs(parent:GetChildren()) do
|
|
759
|
+
attachListeners(child)
|
|
760
|
+
setupListeners(child)
|
|
761
|
+
end
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
for _, service in ipairs(game:GetChildren()) do
|
|
765
|
+
setupListeners(service)
|
|
766
|
+
task.wait() -- Yield between services
|
|
767
|
+
end
|
|
768
|
+
end)
|
|
769
|
+
|
|
770
|
+
-- Listen for new instances
|
|
771
|
+
local descendantAddedConnection = game.DescendantAdded:Connect(function(instance)
|
|
772
|
+
if syncEnabled then onInstanceAdded(instance) end
|
|
773
|
+
end)
|
|
774
|
+
table.insert(connections, descendantAddedConnection)
|
|
775
|
+
|
|
776
|
+
-- Listen for removed instances
|
|
777
|
+
local descendantRemovingConnection = game.DescendantRemoving:Connect(function(instance)
|
|
778
|
+
if syncEnabled then onInstanceRemoved(instance) end
|
|
779
|
+
end)
|
|
780
|
+
table.insert(connections, descendantRemovingConnection)
|
|
781
|
+
|
|
782
|
+
-- Start heartbeat
|
|
783
|
+
local heartbeatConnection = RunService.Heartbeat:Connect(function(dt)
|
|
784
|
+
if syncEnabled then
|
|
785
|
+
-- Send heartbeat periodically
|
|
786
|
+
local now = os.time()
|
|
787
|
+
if now - lastHeartbeat > CONFIG.HEARTBEAT_INTERVAL then
|
|
788
|
+
sendMessage("ping", {})
|
|
789
|
+
lastHeartbeat = now
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
if batchingEnabled then
|
|
793
|
+
-- Batch flush uses an idle timer; heartbeat just keeps the client alive
|
|
794
|
+
else
|
|
795
|
+
-- Legacy fast path before initial snapshot: flush at a fixed interval
|
|
796
|
+
batchAccumulator += dt
|
|
797
|
+
if batchAccumulator >= BATCH_INTERVAL then
|
|
798
|
+
batchAccumulator -= BATCH_INTERVAL
|
|
799
|
+
flushOutboundQueues()
|
|
800
|
+
end
|
|
801
|
+
end
|
|
802
|
+
end
|
|
803
|
+
end)
|
|
804
|
+
table.insert(connections, heartbeatConnection)
|
|
805
|
+
|
|
806
|
+
infoPrint("Sync enabled")
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
-- Stop sync
|
|
810
|
+
function stopSync()
|
|
811
|
+
if not syncEnabled then return end
|
|
812
|
+
|
|
813
|
+
sendMessage("clientDisconnect", {})
|
|
814
|
+
|
|
815
|
+
infoPrint("Stopping sync...")
|
|
816
|
+
syncEnabled = false
|
|
817
|
+
|
|
818
|
+
if azulUI then azulUI:UpdateSyncState(false) end
|
|
819
|
+
|
|
820
|
+
-- Close WebSocket connection
|
|
821
|
+
if wsClient then wsClient:disconnect() end
|
|
822
|
+
wsClient = nil :: any
|
|
823
|
+
|
|
824
|
+
-- Clear GUID attributes if configured
|
|
825
|
+
if CONFIG.CLEAR_GUIDS_ON_EXIT then
|
|
826
|
+
AzulService.clearAllGUIDAttributes()
|
|
827
|
+
infoPrint("Cleared all GUID attributes")
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
trackedInstances = {}
|
|
831
|
+
guidMap = {}
|
|
832
|
+
batchAccumulator = 0
|
|
833
|
+
table.clear(pendingInstanceUpdates)
|
|
834
|
+
table.clear(pendingScriptChanges)
|
|
835
|
+
table.clear(pendingScriptChangeReady)
|
|
836
|
+
table.clear(scriptChangeDebounceTokenByGuid)
|
|
837
|
+
table.clear(pendingDeletes)
|
|
838
|
+
|
|
839
|
+
batchingEnabled = false
|
|
840
|
+
batchFlushToken += 1
|
|
841
|
+
|
|
842
|
+
for _, conn in ipairs(connections) do
|
|
843
|
+
conn:Disconnect()
|
|
844
|
+
end
|
|
845
|
+
connections = {}
|
|
846
|
+
|
|
847
|
+
infoPrint("Sync stopped")
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
-- Cleanup on plugin unload
|
|
851
|
+
plugin.Unloading:Connect(function()
|
|
852
|
+
saveSettings()
|
|
853
|
+
saveSelectedScope()
|
|
854
|
+
stopSync()
|
|
855
|
+
end)
|
|
856
|
+
|
|
857
|
+
infoPrint("Plugin loaded. Click on the 'Azul' button to connect.")
|
|
858
|
+
debugPrint("Debug mode is enabled!")
|
|
859
|
+
debugPrint(`Service list type is set to: "{CONFIG.LIST_TYPE}"`)
|
|
860
|
+
|
|
861
|
+
-- Initialize UI
|
|
862
|
+
local uiCallbacks: UI.CallbackFunctions = {
|
|
863
|
+
onStartSync = startSync,
|
|
864
|
+
onStopSync = stopSync,
|
|
865
|
+
onConfigChanged = function(key, value)
|
|
866
|
+
debugPrint(`Config {key} updated to: {value}`)
|
|
867
|
+
end,
|
|
868
|
+
onSettingsScopeChanged = function(newScope)
|
|
869
|
+
local newScopeIsGlobal = newScope == Enums.scope.GLOBAL
|
|
870
|
+
if newScopeIsGlobal == (SETTINGS_SCOPE == Enums.scope.GLOBAL) then return end
|
|
871
|
+
|
|
872
|
+
-- Persist the current scope before switching
|
|
873
|
+
saveSettings()
|
|
874
|
+
SETTINGS_SCOPE = newScope
|
|
875
|
+
saveSelectedScope()
|
|
876
|
+
azulUI:SetSettingsScope(SETTINGS_SCOPE)
|
|
877
|
+
loadSettings()
|
|
878
|
+
AzulService.rebuildServiceSet(serviceSet)
|
|
879
|
+
azulUI:UpdateConfig()
|
|
880
|
+
|
|
881
|
+
local scopeLabel = if SETTINGS_SCOPE == Enums.scope.GLOBAL then "Global" else "Project"
|
|
882
|
+
infoPrint(`Settings scope set to {scopeLabel}`)
|
|
883
|
+
end,
|
|
884
|
+
onClearGuids = AzulService.clearAllGUIDAttributes,
|
|
885
|
+
onSourcemapReload = function()
|
|
886
|
+
infoPrint("Reloading sourcemap...")
|
|
887
|
+
sendFullSnapshot()
|
|
888
|
+
end,
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
local uiHelpers = {
|
|
892
|
+
debugPrint = debugPrint,
|
|
893
|
+
infoPrint = infoPrint,
|
|
894
|
+
saveSettings = saveSettings,
|
|
895
|
+
loadSettings = loadSettings,
|
|
896
|
+
rebuildServiceSet = function()
|
|
897
|
+
AzulService.rebuildServiceSet(serviceSet)
|
|
898
|
+
end,
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
azulUI = UI.new(plugin, uiCallbacks, SETTINGS_SCOPE, uiHelpers)
|
|
902
|
+
|
|
903
|
+
-- applyConfigToUI = function()
|
|
904
|
+
-- if azulUI then azulUI:UpdateConfig() end
|
|
905
|
+
-- end
|