azul-sync 1.3.1 → 1.3.2

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