roport 1.0.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/README.md +32 -0
- package/bin/roport.js +19 -0
- package/package.json +37 -0
- package/src/server/RojoSyncPlugin.server.luau +914 -0
- package/src/server.js +166 -0
package/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Roport CLI
|
|
2
|
+
|
|
3
|
+
The companion server for the **Roport** Roblox plugin. This tool enables two-way syncing between Visual Studio Code and Roblox Studio.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Install globally using npm:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g roport
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
Navigate to your project directory (where your `.meta.json` files are) and run:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
roport serve
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
This will start a local server on port `3456` (default).
|
|
22
|
+
|
|
23
|
+
## Options
|
|
24
|
+
|
|
25
|
+
- `-p, --port <number>`: Specify a custom port (default: 3456).
|
|
26
|
+
|
|
27
|
+
## Setup with Roblox
|
|
28
|
+
|
|
29
|
+
1. Install this CLI tool.
|
|
30
|
+
2. Install the **Roport** plugin in Roblox Studio.
|
|
31
|
+
3. Start the server with `roport serve`.
|
|
32
|
+
4. Connect via the plugin in Roblox Studio.
|
package/bin/roport.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { program } = require('commander');
|
|
4
|
+
const { startServer } = require('../src/server');
|
|
5
|
+
|
|
6
|
+
program
|
|
7
|
+
.name('roport')
|
|
8
|
+
.description('Roport Sync Tool for Roblox')
|
|
9
|
+
.version('1.0.0');
|
|
10
|
+
|
|
11
|
+
program
|
|
12
|
+
.command('serve')
|
|
13
|
+
.description('Start the sync server')
|
|
14
|
+
.option('-p, --port <number>', 'Port to run on', '3456')
|
|
15
|
+
.action((options) => {
|
|
16
|
+
startServer(parseInt(options.port));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "roport",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A sync server for Roblox development. Works with the Roport Roblox Plugin.",
|
|
5
|
+
"main": "src/server.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"roport": "./bin/roport.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node bin/roport.js serve"
|
|
11
|
+
},
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "https://github.com/Rydaguy101/RojoExportPlugin.git"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"roblox",
|
|
18
|
+
"rojo",
|
|
19
|
+
"sync",
|
|
20
|
+
"plugin",
|
|
21
|
+
"development"
|
|
22
|
+
],
|
|
23
|
+
"author": "Rydaguy101",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"files": [
|
|
26
|
+
"bin",
|
|
27
|
+
"src",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"express": "^4.18.2",
|
|
32
|
+
"fs-extra": "^11.1.1",
|
|
33
|
+
"body-parser": "^1.20.2",
|
|
34
|
+
"commander": "^11.0.0",
|
|
35
|
+
"chalk": "^4.1.2"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,914 @@
|
|
|
1
|
+
local HttpService = game:GetService("HttpService")
|
|
2
|
+
local RunService = game:GetService("RunService")
|
|
3
|
+
local TweenService = game:GetService("TweenService")
|
|
4
|
+
local ScriptEditorService = game:GetService("ScriptEditorService")
|
|
5
|
+
|
|
6
|
+
-- Prevent running as a normal script
|
|
7
|
+
if not plugin then return end
|
|
8
|
+
|
|
9
|
+
local TOOLBAR_NAME = "Rojo Tools"
|
|
10
|
+
local BUTTON_NAME = "Open Sync Panel"
|
|
11
|
+
local SERVER_URL_PING = "http://127.0.0.1:3456/ping"
|
|
12
|
+
local SERVER_URL_WRITE = "http://127.0.0.1:3456/write"
|
|
13
|
+
local SERVER_URL_BATCH = "http://127.0.0.1:3456/batch"
|
|
14
|
+
local SERVER_URL_DELETE = "http://127.0.0.1:3456/delete"
|
|
15
|
+
local SERVER_URL_POLL = "http://127.0.0.1:3456/poll"
|
|
16
|
+
|
|
17
|
+
-- Colors & Styling
|
|
18
|
+
local COLORS = {
|
|
19
|
+
BG = Color3.fromRGB(37, 37, 37),
|
|
20
|
+
HEADER = Color3.fromRGB(45, 45, 45),
|
|
21
|
+
BUTTON = Color3.fromRGB(55, 55, 55),
|
|
22
|
+
BUTTON_HOVER = Color3.fromRGB(65, 65, 65),
|
|
23
|
+
ACCENT = Color3.fromRGB(0, 122, 204), -- VS Code Blue
|
|
24
|
+
TEXT = Color3.fromRGB(245, 245, 245),
|
|
25
|
+
SUBTEXT = Color3.fromRGB(180, 180, 180),
|
|
26
|
+
SUCCESS = Color3.fromRGB(90, 200, 90),
|
|
27
|
+
ERROR = Color3.fromRGB(220, 80, 80),
|
|
28
|
+
WARNING = Color3.fromRGB(220, 200, 80)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
-- State
|
|
32
|
+
local state = {
|
|
33
|
+
isConnected = false,
|
|
34
|
+
isAutoSync = false,
|
|
35
|
+
isAutoPull = false,
|
|
36
|
+
trackedInstances = {},
|
|
37
|
+
pathCache = {}, -- [Instance] = "full/path/to/file.ext"
|
|
38
|
+
connections = {} -- [Instance] = {Connection, ...}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
-- UI Elements
|
|
42
|
+
local toolbar = plugin:CreateToolbar(TOOLBAR_NAME)
|
|
43
|
+
-- Note: You must upload icon.png to Roblox and replace "rbxassetid://0" with the actual Asset ID to see the logo.
|
|
44
|
+
local toggleButton
|
|
45
|
+
local success, err = pcall(function()
|
|
46
|
+
toggleButton = toolbar:CreateButton(BUTTON_NAME, "Open the Rojo Sync Panel", "rbxassetid://0")
|
|
47
|
+
end)
|
|
48
|
+
|
|
49
|
+
if not success then
|
|
50
|
+
warn("Roport: Failed to create toolbar button (it might already exist): " .. tostring(err))
|
|
51
|
+
return
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
local widgetInfo = DockWidgetPluginGuiInfo.new(
|
|
55
|
+
Enum.InitialDockState.Right,
|
|
56
|
+
false, false, 300, 250, 250, 200
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
local widget = plugin:CreateDockWidgetPluginGui("RojoSyncPanel", widgetInfo)
|
|
60
|
+
widget.Title = "Rojo Sync"
|
|
61
|
+
widget.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
|
|
62
|
+
|
|
63
|
+
-- Main Frame
|
|
64
|
+
local mainFrame = Instance.new("Frame")
|
|
65
|
+
mainFrame.Size = UDim2.new(1, 0, 1, 0)
|
|
66
|
+
mainFrame.BackgroundColor3 = COLORS.BG
|
|
67
|
+
mainFrame.BorderSizePixel = 0
|
|
68
|
+
mainFrame.Parent = widget
|
|
69
|
+
|
|
70
|
+
-- Toast Container (ZIndex 10)
|
|
71
|
+
local toastContainer = Instance.new("Frame")
|
|
72
|
+
toastContainer.Size = UDim2.new(1, -20, 1, -20)
|
|
73
|
+
toastContainer.Position = UDim2.new(0, 10, 0, 10)
|
|
74
|
+
toastContainer.BackgroundTransparency = 1
|
|
75
|
+
toastContainer.ZIndex = 10
|
|
76
|
+
toastContainer.Parent = mainFrame
|
|
77
|
+
|
|
78
|
+
local toastLayout = Instance.new("UIListLayout")
|
|
79
|
+
toastLayout.VerticalAlignment = Enum.VerticalAlignment.Bottom
|
|
80
|
+
toastLayout.HorizontalAlignment = Enum.HorizontalAlignment.Center
|
|
81
|
+
toastLayout.Padding = UDim.new(0, 5)
|
|
82
|
+
toastLayout.Parent = toastContainer
|
|
83
|
+
|
|
84
|
+
-- Modal Container (ZIndex 20)
|
|
85
|
+
local modalContainer = Instance.new("Frame")
|
|
86
|
+
modalContainer.Size = UDim2.new(1, 0, 1, 0)
|
|
87
|
+
modalContainer.BackgroundColor3 = Color3.new(0, 0, 0)
|
|
88
|
+
modalContainer.BackgroundTransparency = 1 -- Starts invisible
|
|
89
|
+
modalContainer.Visible = false
|
|
90
|
+
modalContainer.ZIndex = 20
|
|
91
|
+
modalContainer.Parent = mainFrame
|
|
92
|
+
|
|
93
|
+
-- Content Layout
|
|
94
|
+
local contentFrame = Instance.new("Frame")
|
|
95
|
+
contentFrame.Size = UDim2.new(1, 0, 1, 0)
|
|
96
|
+
contentFrame.BackgroundTransparency = 1
|
|
97
|
+
contentFrame.Parent = mainFrame
|
|
98
|
+
|
|
99
|
+
local listLayout = Instance.new("UIListLayout")
|
|
100
|
+
listLayout.Padding = UDim.new(0, 10)
|
|
101
|
+
listLayout.HorizontalAlignment = Enum.HorizontalAlignment.Center
|
|
102
|
+
listLayout.SortOrder = Enum.SortOrder.LayoutOrder
|
|
103
|
+
listLayout.Parent = contentFrame
|
|
104
|
+
|
|
105
|
+
local padding = Instance.new("UIPadding")
|
|
106
|
+
padding.PaddingTop = UDim.new(0, 15)
|
|
107
|
+
padding.PaddingLeft = UDim.new(0, 15)
|
|
108
|
+
padding.PaddingRight = UDim.new(0, 15)
|
|
109
|
+
padding.Parent = contentFrame
|
|
110
|
+
|
|
111
|
+
-- UI Helpers
|
|
112
|
+
local function createCorner(parent, radius)
|
|
113
|
+
local corner = Instance.new("UICorner")
|
|
114
|
+
corner.CornerRadius = UDim.new(0, radius or 6)
|
|
115
|
+
corner.Parent = parent
|
|
116
|
+
return corner
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
local function showToast(message, type)
|
|
120
|
+
local toast = Instance.new("Frame")
|
|
121
|
+
toast.Size = UDim2.new(1, 0, 0, 0) -- Animate height
|
|
122
|
+
toast.BackgroundTransparency = 1
|
|
123
|
+
toast.ClipsDescendants = true
|
|
124
|
+
toast.Parent = toastContainer
|
|
125
|
+
|
|
126
|
+
local inner = Instance.new("Frame")
|
|
127
|
+
inner.Size = UDim2.new(1, 0, 0, 35)
|
|
128
|
+
inner.BackgroundColor3 = COLORS.HEADER
|
|
129
|
+
inner.BorderSizePixel = 0
|
|
130
|
+
createCorner(inner, 6)
|
|
131
|
+
inner.Parent = toast
|
|
132
|
+
|
|
133
|
+
local bar = Instance.new("Frame")
|
|
134
|
+
bar.Size = UDim2.new(0, 4, 1, 0)
|
|
135
|
+
bar.BackgroundColor3 = type == "error" and COLORS.ERROR or (type == "success" and COLORS.SUCCESS or COLORS.ACCENT)
|
|
136
|
+
createCorner(bar, 6)
|
|
137
|
+
bar.Parent = inner
|
|
138
|
+
|
|
139
|
+
local label = Instance.new("TextLabel")
|
|
140
|
+
label.Size = UDim2.new(1, -15, 1, 0)
|
|
141
|
+
label.Position = UDim2.new(0, 10, 0, 0)
|
|
142
|
+
label.BackgroundTransparency = 1
|
|
143
|
+
label.Text = message
|
|
144
|
+
label.TextColor3 = COLORS.TEXT
|
|
145
|
+
label.Font = Enum.Font.SourceSans
|
|
146
|
+
label.TextSize = 14
|
|
147
|
+
label.TextXAlignment = Enum.TextXAlignment.Left
|
|
148
|
+
label.Parent = inner
|
|
149
|
+
|
|
150
|
+
-- Animation
|
|
151
|
+
local tweenInfo = TweenInfo.new(0.3, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
|
|
152
|
+
TweenService:Create(toast, tweenInfo, {Size = UDim2.new(1, 0, 0, 40)}):Play()
|
|
153
|
+
|
|
154
|
+
task.delay(3, function()
|
|
155
|
+
local out = TweenService:Create(toast, tweenInfo, {Size = UDim2.new(1, 0, 0, 0)})
|
|
156
|
+
out:Play()
|
|
157
|
+
out.Completed:Connect(function() toast:Destroy() end)
|
|
158
|
+
end)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
local function showModal(title, message, onConfirm)
|
|
162
|
+
modalContainer.Visible = true
|
|
163
|
+
TweenService:Create(modalContainer, TweenInfo.new(0.3), {BackgroundTransparency = 0.5}):Play()
|
|
164
|
+
|
|
165
|
+
-- Clear previous
|
|
166
|
+
for _, c in ipairs(modalContainer:GetChildren()) do c:Destroy() end
|
|
167
|
+
|
|
168
|
+
local box = Instance.new("Frame")
|
|
169
|
+
box.Size = UDim2.new(0.8, 0, 0, 140)
|
|
170
|
+
box.Position = UDim2.new(0.5, 0, 0.5, 0)
|
|
171
|
+
box.AnchorPoint = Vector2.new(0.5, 0.5)
|
|
172
|
+
box.BackgroundColor3 = COLORS.HEADER
|
|
173
|
+
createCorner(box, 8)
|
|
174
|
+
box.Parent = modalContainer
|
|
175
|
+
|
|
176
|
+
-- Scale up animation
|
|
177
|
+
local uiScale = Instance.new("UIScale")
|
|
178
|
+
uiScale.Scale = 0.8
|
|
179
|
+
uiScale.Parent = box
|
|
180
|
+
|
|
181
|
+
TweenService:Create(uiScale, TweenInfo.new(0.3, Enum.EasingStyle.Back), {Scale = 1}):Play()
|
|
182
|
+
|
|
183
|
+
local titleLbl = Instance.new("TextLabel")
|
|
184
|
+
titleLbl.Size = UDim2.new(1, 0, 0, 40)
|
|
185
|
+
titleLbl.BackgroundTransparency = 1
|
|
186
|
+
titleLbl.Text = title
|
|
187
|
+
titleLbl.Font = Enum.Font.SourceSansBold
|
|
188
|
+
titleLbl.TextSize = 18
|
|
189
|
+
titleLbl.TextColor3 = COLORS.TEXT
|
|
190
|
+
titleLbl.Parent = box
|
|
191
|
+
|
|
192
|
+
local msgLbl = Instance.new("TextLabel")
|
|
193
|
+
msgLbl.Size = UDim2.new(1, -20, 0, 50)
|
|
194
|
+
msgLbl.Position = UDim2.new(0, 10, 0, 40)
|
|
195
|
+
msgLbl.BackgroundTransparency = 1
|
|
196
|
+
msgLbl.Text = message
|
|
197
|
+
msgLbl.Font = Enum.Font.SourceSans
|
|
198
|
+
msgLbl.TextSize = 14
|
|
199
|
+
msgLbl.TextColor3 = COLORS.SUBTEXT
|
|
200
|
+
msgLbl.TextWrapped = true
|
|
201
|
+
msgLbl.Parent = box
|
|
202
|
+
|
|
203
|
+
local btnFrame = Instance.new("Frame")
|
|
204
|
+
btnFrame.Size = UDim2.new(1, -20, 0, 35)
|
|
205
|
+
btnFrame.Position = UDim2.new(0, 10, 1, -45)
|
|
206
|
+
btnFrame.BackgroundTransparency = 1
|
|
207
|
+
btnFrame.Parent = box
|
|
208
|
+
|
|
209
|
+
local layout = Instance.new("UIListLayout")
|
|
210
|
+
layout.FillDirection = Enum.FillDirection.Horizontal
|
|
211
|
+
layout.Padding = UDim.new(0, 10)
|
|
212
|
+
layout.HorizontalAlignment = Enum.HorizontalAlignment.Center
|
|
213
|
+
layout.Parent = btnFrame
|
|
214
|
+
|
|
215
|
+
local function makeBtn(text, color, cb)
|
|
216
|
+
local btn = Instance.new("TextButton")
|
|
217
|
+
btn.Size = UDim2.new(0.45, 0, 1, 0)
|
|
218
|
+
btn.BackgroundColor3 = color
|
|
219
|
+
btn.Text = text
|
|
220
|
+
btn.TextColor3 = COLORS.TEXT
|
|
221
|
+
btn.Font = Enum.Font.SourceSansBold
|
|
222
|
+
btn.TextSize = 14
|
|
223
|
+
createCorner(btn, 4)
|
|
224
|
+
btn.Parent = btnFrame
|
|
225
|
+
btn.MouseButton1Click:Connect(cb)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
makeBtn("Connect", COLORS.ACCENT, function()
|
|
229
|
+
TweenService:Create(modalContainer, TweenInfo.new(0.2), {BackgroundTransparency = 1}):Play()
|
|
230
|
+
modalContainer.Visible = false
|
|
231
|
+
onConfirm()
|
|
232
|
+
end)
|
|
233
|
+
|
|
234
|
+
makeBtn("Cancel", COLORS.BUTTON, function()
|
|
235
|
+
TweenService:Create(modalContainer, TweenInfo.new(0.2), {BackgroundTransparency = 1}):Play()
|
|
236
|
+
modalContainer.Visible = false
|
|
237
|
+
end)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
-- Components
|
|
241
|
+
local function createButton(text, icon, callback)
|
|
242
|
+
local btn = Instance.new("TextButton")
|
|
243
|
+
btn.Size = UDim2.new(1, 0, 0, 40)
|
|
244
|
+
btn.BackgroundColor3 = COLORS.BUTTON
|
|
245
|
+
btn.Text = ""
|
|
246
|
+
createCorner(btn, 6)
|
|
247
|
+
btn.Parent = contentFrame
|
|
248
|
+
|
|
249
|
+
local lbl = Instance.new("TextLabel")
|
|
250
|
+
lbl.Size = UDim2.new(1, 0, 1, 0)
|
|
251
|
+
lbl.BackgroundTransparency = 1
|
|
252
|
+
lbl.Text = text
|
|
253
|
+
lbl.Font = Enum.Font.SourceSansBold
|
|
254
|
+
lbl.TextSize = 16
|
|
255
|
+
lbl.TextColor3 = COLORS.TEXT
|
|
256
|
+
lbl.Parent = btn
|
|
257
|
+
|
|
258
|
+
btn.MouseButton1Click:Connect(callback)
|
|
259
|
+
return btn
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
-- Status Header
|
|
263
|
+
local statusFrame = Instance.new("Frame")
|
|
264
|
+
statusFrame.Size = UDim2.new(1, 0, 0, 30)
|
|
265
|
+
statusFrame.BackgroundTransparency = 1
|
|
266
|
+
statusFrame.LayoutOrder = 0
|
|
267
|
+
statusFrame.Parent = contentFrame
|
|
268
|
+
|
|
269
|
+
local statusDot = Instance.new("Frame")
|
|
270
|
+
statusDot.Size = UDim2.new(0, 10, 0, 10)
|
|
271
|
+
statusDot.Position = UDim2.new(0, 0, 0.5, -5)
|
|
272
|
+
statusDot.BackgroundColor3 = COLORS.ERROR
|
|
273
|
+
createCorner(statusDot, 10)
|
|
274
|
+
statusDot.Parent = statusFrame
|
|
275
|
+
|
|
276
|
+
local statusText = Instance.new("TextLabel")
|
|
277
|
+
statusText.Size = UDim2.new(1, -20, 1, 0)
|
|
278
|
+
statusText.Position = UDim2.new(0, 20, 0, 0)
|
|
279
|
+
statusText.BackgroundTransparency = 1
|
|
280
|
+
statusText.Text = "Disconnected"
|
|
281
|
+
statusText.Font = Enum.Font.SourceSans
|
|
282
|
+
statusText.TextSize = 14
|
|
283
|
+
statusText.TextColor3 = COLORS.SUBTEXT
|
|
284
|
+
statusText.TextXAlignment = Enum.TextXAlignment.Left
|
|
285
|
+
statusText.Parent = statusFrame
|
|
286
|
+
|
|
287
|
+
-- Helper Functions (Global)
|
|
288
|
+
local function sanitize(name)
|
|
289
|
+
return name:gsub("[\\/:*?\"<>|]", "_")
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
local function getPath(inst)
|
|
293
|
+
local p = {}
|
|
294
|
+
local c = inst
|
|
295
|
+
local s = nil
|
|
296
|
+
while c and c~=game do
|
|
297
|
+
if c==game:GetService("ServerScriptService") then s="src/server" break
|
|
298
|
+
elseif c==game:GetService("ReplicatedStorage") then s="src/shared" break
|
|
299
|
+
elseif c==game:GetService("StarterPlayer").StarterPlayerScripts then s="src/client" break
|
|
300
|
+
elseif c==game:GetService("Workspace") then s="src/workspace" break
|
|
301
|
+
elseif c==game:GetService("StarterGui") then s="src/interface" break
|
|
302
|
+
elseif c==game:GetService("StarterPack") then s="src/tools" break
|
|
303
|
+
elseif c==game:GetService("Lighting") then s="src/lighting" break
|
|
304
|
+
elseif c==game:GetService("ReplicatedFirst") then s="src/first" break
|
|
305
|
+
elseif c==game:GetService("SoundService") then s="src/sounds" break
|
|
306
|
+
end
|
|
307
|
+
table.insert(p,1,(sanitize(c.Name)))
|
|
308
|
+
c=c.Parent
|
|
309
|
+
end
|
|
310
|
+
return s and (s.."/"..table.concat(p,"/"))
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
local function serializeValue(val)
|
|
314
|
+
local t = typeof(val)
|
|
315
|
+
if t == "Vector3" then return {val.X, val.Y, val.Z}
|
|
316
|
+
elseif t == "Vector2" then return {val.X, val.Y}
|
|
317
|
+
elseif t == "Color3" then return {val.R, val.G, val.B}
|
|
318
|
+
elseif t == "CFrame" then return {val:GetComponents()}
|
|
319
|
+
elseif t == "UDim2" then return {val.X.Scale, val.X.Offset, val.Y.Scale, val.Y.Offset}
|
|
320
|
+
elseif t == "UDim" then return {val.Scale, val.Offset}
|
|
321
|
+
elseif t == "Rect" then return {val.Min.X, val.Min.Y, val.Max.X, val.Max.Y}
|
|
322
|
+
elseif t == "NumberRange" then return {val.Min, val.Max}
|
|
323
|
+
elseif t == "EnumItem" then return val.Name
|
|
324
|
+
elseif t == "Instance" then return val.Name -- Reference by name (weak)
|
|
325
|
+
elseif t == "ColorSequence" then
|
|
326
|
+
local kps = {}
|
|
327
|
+
for _, kp in ipairs(val.Keypoints) do table.insert(kps, {kp.Time, {kp.Value.R, kp.Value.G, kp.Value.B}}) end
|
|
328
|
+
return {type="ColorSequence", keypoints=kps}
|
|
329
|
+
elseif t == "NumberSequence" then
|
|
330
|
+
local kps = {}
|
|
331
|
+
for _, kp in ipairs(val.Keypoints) do table.insert(kps, {kp.Time, kp.Value, kp.Envelope}) end
|
|
332
|
+
return {type="NumberSequence", keypoints=kps}
|
|
333
|
+
else return val end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
local PROPERTY_MAP = {
|
|
337
|
+
Instance = {"Name", "Archivable", "ClassName"},
|
|
338
|
+
BasePart = {"Size", "Position", "Color", "Transparency", "Anchored", "CanCollide", "Material", "Reflectance", "CastShadow", "Locked", "Shape"},
|
|
339
|
+
GuiObject = {"Size", "Position", "AnchorPoint", "BackgroundColor3", "BackgroundTransparency", "BorderColor3", "BorderSizePixel", "Visible", "ZIndex", "LayoutOrder", "ClipsDescendants", "Rotation"},
|
|
340
|
+
TextLabel = {"Text", "TextColor3", "TextSize", "Font", "TextTransparency", "TextXAlignment", "TextYAlignment", "TextScaled", "TextWrapped", "RichText"},
|
|
341
|
+
TextButton = {"Text", "TextColor3", "TextSize", "Font", "TextTransparency", "TextXAlignment", "TextYAlignment", "TextScaled", "TextWrapped", "RichText"},
|
|
342
|
+
TextBox = {"Text", "TextColor3", "TextSize", "Font", "TextTransparency", "TextXAlignment", "TextYAlignment", "TextScaled", "TextWrapped", "PlaceholderText", "ClearTextOnFocus", "MultiLine"},
|
|
343
|
+
ImageLabel = {"Image", "ImageColor3", "ImageTransparency", "ScaleType", "SliceCenter", "TileSize"},
|
|
344
|
+
ImageButton = {"Image", "ImageColor3", "ImageTransparency", "ScaleType", "SliceCenter", "TileSize"},
|
|
345
|
+
ValueBase = {"Value"},
|
|
346
|
+
Sound = {"SoundId", "Volume", "PlaybackSpeed", "Looped", "Playing", "TimePosition"},
|
|
347
|
+
Animation = {"AnimationId"},
|
|
348
|
+
Decal = {"Texture", "Color3", "Transparency", "Face"},
|
|
349
|
+
Texture = {"Texture", "Color3", "Transparency", "Face", "StudsPerTileU", "StudsPerTileV", "OffsetStudsU", "OffsetStudsV"},
|
|
350
|
+
Light = {"Color", "Brightness", "Enabled", "Shadows", "Range"},
|
|
351
|
+
SpotLight = {"Angle", "Face"},
|
|
352
|
+
SurfaceLight = {"Angle", "Face"},
|
|
353
|
+
DataModelMesh = {"Scale", "Offset", "VertexColor"},
|
|
354
|
+
SpecialMesh = {"MeshId", "TextureId", "MeshType"},
|
|
355
|
+
Humanoid = {"Health", "MaxHealth", "WalkSpeed", "JumpPower", "DisplayName", "RigType", "HipHeight"},
|
|
356
|
+
Tool = {"RequiresHandle", "CanBeDropped", "ToolTip", "Enabled", "Grip"},
|
|
357
|
+
Accessory = {"AttachmentPoint"},
|
|
358
|
+
Clothing = {"Color3"},
|
|
359
|
+
Shirt = {"ShirtTemplate"},
|
|
360
|
+
Pants = {"PantsTemplate"},
|
|
361
|
+
ParticleEmitter = {"Color", "LightEmission", "LightInfluence", "Size", "Texture", "Transparency", "ZOffset", "EmissionDirection", "Enabled", "Lifetime", "Rate", "Rotation", "RotSpeed", "Speed", "SpreadAngle"},
|
|
362
|
+
Trail = {"Color", "Enabled", "FaceCamera", "Lifetime", "LightEmission", "LightInfluence", "MaxLength", "MinLength", "Texture", "TextureLength", "Transparency", "WidthScale"},
|
|
363
|
+
Beam = {"Color", "Enabled", "FaceCamera", "LightEmission", "LightInfluence", "Texture", "TextureLength", "TextureMode", "Transparency", "Width0", "Width1", "ZOffset"},
|
|
364
|
+
Fire = {"Color", "Enabled", "Heat", "Size", "SecondaryColor"},
|
|
365
|
+
Smoke = {"Color", "Enabled", "Opacity", "RiseVelocity", "Size"},
|
|
366
|
+
Sparkles = {"SparkleColor", "Enabled"},
|
|
367
|
+
PostEffect = {"Enabled"},
|
|
368
|
+
BlurEffect = {"Size"},
|
|
369
|
+
BloomEffect = {"Intensity", "Size", "Threshold"},
|
|
370
|
+
ColorCorrectionEffect = {"Brightness", "Contrast", "Saturation", "TintColor"},
|
|
371
|
+
SunRaysEffect = {"Intensity", "Spread"},
|
|
372
|
+
Atmosphere = {"Density", "Offset", "Haze", "Color", "Decay", "Glare"},
|
|
373
|
+
Sky = {"SkyboxBk", "SkyboxDn", "SkyboxFt", "SkyboxLf", "SkyboxRt", "SkyboxUp", "SunTextureId", "MoonTextureId", "StarCount"},
|
|
374
|
+
UICorner = {"CornerRadius"},
|
|
375
|
+
UIStroke = {"Color", "Thickness", "Transparency", "ApplyStrokeMode", "LineJoinMode"},
|
|
376
|
+
UIGradient = {"Color", "Offset", "Rotation", "Transparency"},
|
|
377
|
+
UIPadding = {"PaddingBottom", "PaddingLeft", "PaddingRight", "PaddingTop"},
|
|
378
|
+
UIScale = {"Scale"},
|
|
379
|
+
UIListLayout = {"Padding", "FillDirection", "HorizontalAlignment", "VerticalAlignment", "SortOrder", "ItemLineAlignment"},
|
|
380
|
+
UIGridLayout = {"CellPadding", "CellSize", "FillDirection", "HorizontalAlignment", "VerticalAlignment", "SortOrder", "StartCorner"},
|
|
381
|
+
UITableLayout = {"Padding", "FillDirection", "HorizontalAlignment", "VerticalAlignment", "SortOrder"},
|
|
382
|
+
UIPageLayout = {"Padding", "FillDirection", "HorizontalAlignment", "VerticalAlignment", "SortOrder", "Animated", "Circular", "EasingDirection", "EasingStyle", "GamepadInputEnabled", "ScrollWheelInputEnabled", "TouchInputEnabled", "TweenTime"},
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
local function getProperties(inst)
|
|
386
|
+
local props = {
|
|
387
|
+
className = inst.ClassName,
|
|
388
|
+
name = inst.Name,
|
|
389
|
+
properties = {},
|
|
390
|
+
attributes = {},
|
|
391
|
+
tags = game:GetService("CollectionService"):GetTags(inst)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
-- Serialize Attributes
|
|
395
|
+
for k, v in pairs(inst:GetAttributes()) do
|
|
396
|
+
props.attributes[k] = serializeValue(v)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
-- Table-driven property serialization
|
|
400
|
+
for className, propList in pairs(PROPERTY_MAP) do
|
|
401
|
+
if inst:IsA(className) then
|
|
402
|
+
for _, propName in ipairs(propList) do
|
|
403
|
+
pcall(function()
|
|
404
|
+
props.properties[propName] = serializeValue(inst[propName])
|
|
405
|
+
end)
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
return props
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
local function getExtension(inst)
|
|
414
|
+
if inst:IsA("Script") then return ".server.luau"
|
|
415
|
+
elseif inst:IsA("LocalScript") then return ".client.luau"
|
|
416
|
+
elseif inst:IsA("ModuleScript") then return ".luau"
|
|
417
|
+
else return "/init.meta.json" end
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
local function getContent(inst)
|
|
421
|
+
if inst:IsA("Script") or inst:IsA("LocalScript") or inst:IsA("ModuleScript") then
|
|
422
|
+
return inst.Source
|
|
423
|
+
else
|
|
424
|
+
return HttpService:JSONEncode(getProperties(inst))
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
local function getInfo(inst)
|
|
429
|
+
return getExtension(inst), getContent(inst)
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
local function syncInstance(inst)
|
|
433
|
+
local path = getPath(inst)
|
|
434
|
+
if not path then return end
|
|
435
|
+
|
|
436
|
+
local ext = getExtension(inst)
|
|
437
|
+
local content = getContent(inst)
|
|
438
|
+
local fullPath = path..ext
|
|
439
|
+
|
|
440
|
+
-- Register for tracking
|
|
441
|
+
registerObject(inst, fullPath)
|
|
442
|
+
|
|
443
|
+
pcall(function()
|
|
444
|
+
HttpService:PostAsync(SERVER_URL_BATCH, HttpService:JSONEncode({
|
|
445
|
+
files = {{filePath = fullPath, content = content}}
|
|
446
|
+
}))
|
|
447
|
+
end)
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
function registerObject(inst, fullPath)
|
|
451
|
+
if state.pathCache[inst] == fullPath then return end
|
|
452
|
+
state.pathCache[inst] = fullPath
|
|
453
|
+
|
|
454
|
+
-- Clear old connections
|
|
455
|
+
if state.connections[inst] then
|
|
456
|
+
for _, c in pairs(state.connections[inst]) do c:Disconnect() end
|
|
457
|
+
end
|
|
458
|
+
state.connections[inst] = {}
|
|
459
|
+
|
|
460
|
+
-- 1. Name Changed (Rename)
|
|
461
|
+
local nameCon = inst:GetPropertyChangedSignal("Name"):Connect(function()
|
|
462
|
+
if not state.isAutoSync then return end
|
|
463
|
+
local oldPath = state.pathCache[inst]
|
|
464
|
+
local newBasePath = getPath(inst)
|
|
465
|
+
if not newBasePath then return end -- Moved to invalid location
|
|
466
|
+
|
|
467
|
+
local newPath = newBasePath .. getExtension(inst)
|
|
468
|
+
|
|
469
|
+
if oldPath and newPath and oldPath ~= newPath then
|
|
470
|
+
-- Delete old
|
|
471
|
+
pcall(function()
|
|
472
|
+
HttpService:PostAsync(SERVER_URL_DELETE, HttpService:JSONEncode({files={oldPath}}))
|
|
473
|
+
end)
|
|
474
|
+
-- Sync new
|
|
475
|
+
syncInstance(inst)
|
|
476
|
+
end
|
|
477
|
+
end)
|
|
478
|
+
local cons = state.connections[inst]
|
|
479
|
+
cons[#cons+1] = nameCon
|
|
480
|
+
|
|
481
|
+
-- 2. Ancestry Changed (Move or Delete)
|
|
482
|
+
local ancestryCon = inst.AncestryChanged:Connect(function(_, parent)
|
|
483
|
+
if not state.isAutoSync then return end
|
|
484
|
+
|
|
485
|
+
if parent == nil then
|
|
486
|
+
-- Deleted
|
|
487
|
+
local oldPath = state.pathCache[inst]
|
|
488
|
+
if oldPath then
|
|
489
|
+
pcall(function()
|
|
490
|
+
HttpService:PostAsync(SERVER_URL_DELETE, HttpService:JSONEncode({files={oldPath}}))
|
|
491
|
+
end)
|
|
492
|
+
state.pathCache[inst] = nil
|
|
493
|
+
if state.connections[inst] then
|
|
494
|
+
for _, c in pairs(state.connections[inst]) do c:Disconnect() end
|
|
495
|
+
state.connections[inst] = nil
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
else
|
|
499
|
+
-- Moved
|
|
500
|
+
local oldPath = state.pathCache[inst]
|
|
501
|
+
local newBasePath = getPath(inst)
|
|
502
|
+
if not newBasePath then return end
|
|
503
|
+
|
|
504
|
+
local newPath = newBasePath .. getExtension(inst)
|
|
505
|
+
|
|
506
|
+
if oldPath and newPath and oldPath ~= newPath then
|
|
507
|
+
pcall(function()
|
|
508
|
+
HttpService:PostAsync(SERVER_URL_DELETE, HttpService:JSONEncode({files={oldPath}}))
|
|
509
|
+
end)
|
|
510
|
+
syncInstance(inst)
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
end)
|
|
514
|
+
cons[#cons+1] = ancestryCon
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
local function syncAll()
|
|
518
|
+
if not state.isConnected then showToast("Not connected!", "error") return end
|
|
519
|
+
showToast("Starting Batch Sync...", "info")
|
|
520
|
+
|
|
521
|
+
local batch = {}
|
|
522
|
+
local services = {
|
|
523
|
+
game:GetService("Workspace"),
|
|
524
|
+
game:GetService("ServerScriptService"),
|
|
525
|
+
game:GetService("ReplicatedStorage"),
|
|
526
|
+
game:GetService("StarterPlayer").StarterPlayerScripts,
|
|
527
|
+
game:GetService("StarterGui"),
|
|
528
|
+
game:GetService("StarterPack"),
|
|
529
|
+
game:GetService("Lighting"),
|
|
530
|
+
game:GetService("ReplicatedFirst"),
|
|
531
|
+
game:GetService("SoundService")
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
local count = 0
|
|
535
|
+
for _, s in ipairs(services) do
|
|
536
|
+
-- Sync the service itself (folder)
|
|
537
|
+
local servicePath = getPath(s)
|
|
538
|
+
if servicePath then
|
|
539
|
+
-- We don't need content for the root folders usually, but we can ensure they exist
|
|
540
|
+
-- For now, let's just focus on descendants
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
for _, d in ipairs(s:GetDescendants()) do
|
|
544
|
+
-- Skip Terrain and Camera to reduce noise/issues
|
|
545
|
+
if d:IsA("Terrain") or d:IsA("Camera") then continue end
|
|
546
|
+
-- Skip self to prevent recursion
|
|
547
|
+
if d == script or d == plugin then continue end
|
|
548
|
+
|
|
549
|
+
local path = getPath(d)
|
|
550
|
+
if path then
|
|
551
|
+
local ext, content = getInfo(d)
|
|
552
|
+
if ext and content then
|
|
553
|
+
local fullPath = path..ext
|
|
554
|
+
table.insert(batch, {filePath = fullPath, content = content})
|
|
555
|
+
registerObject(d, fullPath) -- Start tracking
|
|
556
|
+
count = count + 1
|
|
557
|
+
print("Roport: Queued " .. d.Name .. " -> " .. fullPath)
|
|
558
|
+
end
|
|
559
|
+
else
|
|
560
|
+
-- Debug why path is nil
|
|
561
|
+
print("Roport: Skipped " .. d.Name .. " (No Path) Parent: " .. (d.Parent and d.Parent.Name or "nil"))
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
print("Roport: Found " .. count .. " items to sync.")
|
|
567
|
+
if count > 0 then
|
|
568
|
+
print("Roport: First item path: " .. batch[1].filePath)
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
-- Send
|
|
572
|
+
task.spawn(function()
|
|
573
|
+
local chunkSize = 50
|
|
574
|
+
for i = 1, #batch, chunkSize do
|
|
575
|
+
local chunk = {}
|
|
576
|
+
for j = i, math.min(i + chunkSize - 1, #batch) do table.insert(chunk, batch[j]) end
|
|
577
|
+
local success, err = pcall(function()
|
|
578
|
+
HttpService:PostAsync(SERVER_URL_BATCH, HttpService:JSONEncode({files=chunk}))
|
|
579
|
+
end)
|
|
580
|
+
if not success then
|
|
581
|
+
warn("Roport: Batch sync failed: " .. tostring(err))
|
|
582
|
+
end
|
|
583
|
+
task.wait(0.1)
|
|
584
|
+
end
|
|
585
|
+
showToast("Sync Complete!", "success")
|
|
586
|
+
end)
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
-- Buttons
|
|
590
|
+
local syncBtn = createButton("Sync All (Push)", "", syncAll)
|
|
591
|
+
|
|
592
|
+
local function pullChanges()
|
|
593
|
+
if not state.isConnected then showToast("Connect first!", "error") return end
|
|
594
|
+
showToast("Checking for updates...", "info")
|
|
595
|
+
|
|
596
|
+
local success, res = pcall(function()
|
|
597
|
+
return HttpService:GetAsync(SERVER_URL_POLL .. "?t=" .. os.time())
|
|
598
|
+
end)
|
|
599
|
+
|
|
600
|
+
if success then
|
|
601
|
+
local data = HttpService:JSONDecode(res)
|
|
602
|
+
if data.changes and #data.changes > 0 then
|
|
603
|
+
print("Roport: Pulling " .. #data.changes .. " files...")
|
|
604
|
+
for _, change in ipairs(data.changes) do
|
|
605
|
+
applyUpdate(change.filePath, change.content)
|
|
606
|
+
end
|
|
607
|
+
showToast("Updated " .. #data.changes .. " files", "success")
|
|
608
|
+
else
|
|
609
|
+
showToast("No changes found", "info")
|
|
610
|
+
end
|
|
611
|
+
else
|
|
612
|
+
warn("Roport: Pull failed: " .. tostring(res))
|
|
613
|
+
showToast("Pull Failed", "error")
|
|
614
|
+
end
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
local pullBtn = createButton("Pull Changes", "", pullChanges)
|
|
618
|
+
|
|
619
|
+
local autoPullBtn = createButton("Auto-Pull: OFF", "", function()
|
|
620
|
+
if not state.isConnected then showToast("Connect first!", "error") return end
|
|
621
|
+
state.isAutoPull = not state.isAutoPull
|
|
622
|
+
|
|
623
|
+
local lbl = autoPullBtn:FindFirstChild("TextLabel")
|
|
624
|
+
|
|
625
|
+
if state.isAutoPull then
|
|
626
|
+
showToast("Auto-Pull Enabled", "success")
|
|
627
|
+
if lbl then lbl.Text = "Auto-Pull: ON" end
|
|
628
|
+
autoPullBtn.BackgroundColor3 = COLORS.ACCENT
|
|
629
|
+
else
|
|
630
|
+
showToast("Auto-Pull Disabled", "info")
|
|
631
|
+
if lbl then lbl.Text = "Auto-Pull: OFF" end
|
|
632
|
+
autoPullBtn.BackgroundColor3 = COLORS.BUTTON
|
|
633
|
+
end
|
|
634
|
+
end)
|
|
635
|
+
|
|
636
|
+
local autoBtn -- Forward declaration
|
|
637
|
+
|
|
638
|
+
autoBtn = createButton("Auto-Sync: OFF", "", function()
|
|
639
|
+
if not state.isConnected then showToast("Connect first!", "error") return end
|
|
640
|
+
state.isAutoSync = not state.isAutoSync
|
|
641
|
+
|
|
642
|
+
local lbl = autoBtn:FindFirstChild("TextLabel")
|
|
643
|
+
|
|
644
|
+
if state.isAutoSync then
|
|
645
|
+
showToast("Auto-Sync Enabled", "success")
|
|
646
|
+
if lbl then lbl.Text = "Auto-Sync: ON" end
|
|
647
|
+
autoBtn.BackgroundColor3 = COLORS.ACCENT
|
|
648
|
+
else
|
|
649
|
+
showToast("Auto-Sync Disabled", "info")
|
|
650
|
+
if lbl then lbl.Text = "Auto-Sync: OFF" end
|
|
651
|
+
autoBtn.BackgroundColor3 = COLORS.BUTTON
|
|
652
|
+
end
|
|
653
|
+
end)
|
|
654
|
+
|
|
655
|
+
-- Auto Sync Logic
|
|
656
|
+
local dirtyScripts = {}
|
|
657
|
+
local lastSyncTime = 0
|
|
658
|
+
local SYNC_INTERVAL = 2 -- Seconds
|
|
659
|
+
|
|
660
|
+
ScriptEditorService.TextDocumentDidChange:Connect(function(document, changes)
|
|
661
|
+
if not state.isAutoSync or not state.isConnected then return end
|
|
662
|
+
|
|
663
|
+
local script = document:GetScript()
|
|
664
|
+
if script then
|
|
665
|
+
dirtyScripts[script] = true
|
|
666
|
+
end
|
|
667
|
+
end)
|
|
668
|
+
|
|
669
|
+
task.spawn(function()
|
|
670
|
+
while true do
|
|
671
|
+
task.wait(0.5)
|
|
672
|
+
if state.isAutoSync and state.isConnected then
|
|
673
|
+
if os.time() - lastSyncTime >= SYNC_INTERVAL then
|
|
674
|
+
local batch = {}
|
|
675
|
+
local count = 0
|
|
676
|
+
|
|
677
|
+
for script, _ in pairs(dirtyScripts) do
|
|
678
|
+
if script.Parent then -- Ensure script still exists
|
|
679
|
+
local path = getPath(script)
|
|
680
|
+
if path then
|
|
681
|
+
local ext, content = getInfo(script)
|
|
682
|
+
if ext and content then
|
|
683
|
+
local fullPath = path..ext
|
|
684
|
+
table.insert(batch, {filePath = fullPath, content = content})
|
|
685
|
+
count = count + 1
|
|
686
|
+
registerObject(script, fullPath) -- Ensure tracking
|
|
687
|
+
end
|
|
688
|
+
end
|
|
689
|
+
end
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
if count > 0 then
|
|
693
|
+
-- Clear dirty list immediately to avoid double sync
|
|
694
|
+
dirtyScripts = {}
|
|
695
|
+
|
|
696
|
+
local success, err = pcall(function()
|
|
697
|
+
HttpService:PostAsync(SERVER_URL_BATCH, HttpService:JSONEncode({files=batch}))
|
|
698
|
+
end)
|
|
699
|
+
|
|
700
|
+
if success then
|
|
701
|
+
lastSyncTime = os.time()
|
|
702
|
+
print("Roport: Auto-synced " .. count .. " scripts")
|
|
703
|
+
else
|
|
704
|
+
warn("Roport: Auto-sync failed: " .. tostring(err))
|
|
705
|
+
end
|
|
706
|
+
end
|
|
707
|
+
end
|
|
708
|
+
end
|
|
709
|
+
end
|
|
710
|
+
end)
|
|
711
|
+
|
|
712
|
+
-- Connection Logic
|
|
713
|
+
local hasPrompted = false
|
|
714
|
+
|
|
715
|
+
local function checkConnection()
|
|
716
|
+
local success, _ = pcall(function() return HttpService:GetAsync(SERVER_URL_PING) end)
|
|
717
|
+
|
|
718
|
+
if success then
|
|
719
|
+
if not state.isConnected then
|
|
720
|
+
-- Server just appeared
|
|
721
|
+
if not hasPrompted then
|
|
722
|
+
print("Roport: Server detected, prompting user...")
|
|
723
|
+
hasPrompted = true
|
|
724
|
+
showModal("Server Detected", "VS Code sync server is running. Connect now?", function()
|
|
725
|
+
state.isConnected = true
|
|
726
|
+
statusDot.BackgroundColor3 = COLORS.SUCCESS
|
|
727
|
+
statusText.Text = "Connected"
|
|
728
|
+
showToast("Connected to VS Code", "success")
|
|
729
|
+
|
|
730
|
+
-- Ask for initial sync
|
|
731
|
+
task.wait(0.5)
|
|
732
|
+
showModal("Initial Sync", "Do you want to sync the workspace to VS Code now?", function()
|
|
733
|
+
syncAll()
|
|
734
|
+
end)
|
|
735
|
+
end)
|
|
736
|
+
end
|
|
737
|
+
end
|
|
738
|
+
else
|
|
739
|
+
state.isConnected = false
|
|
740
|
+
statusDot.BackgroundColor3 = COLORS.ERROR
|
|
741
|
+
statusText.Text = "Disconnected (Server Offline)"
|
|
742
|
+
hasPrompted = false -- Reset so we prompt again if it comes back
|
|
743
|
+
end
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
-- Two-Way Sync Logic
|
|
747
|
+
local function findInstanceByPath(path)
|
|
748
|
+
-- Remove extension
|
|
749
|
+
local cleanPath = path:gsub("%.server%.luau$", ""):gsub("%.client%.luau$", ""):gsub("%.luau$", ""):gsub("/init%.meta%.json$", "")
|
|
750
|
+
|
|
751
|
+
local parts = cleanPath:split("/")
|
|
752
|
+
if parts[1] ~= "src" then return nil end
|
|
753
|
+
|
|
754
|
+
local current = nil
|
|
755
|
+
|
|
756
|
+
-- Map src/xxx to Service
|
|
757
|
+
local serviceMap = {
|
|
758
|
+
workspace = game:GetService("Workspace"),
|
|
759
|
+
server = game:GetService("ServerScriptService"),
|
|
760
|
+
shared = game:GetService("ReplicatedStorage"),
|
|
761
|
+
client = game:GetService("StarterPlayer").StarterPlayerScripts,
|
|
762
|
+
interface = game:GetService("StarterGui"),
|
|
763
|
+
tools = game:GetService("StarterPack"),
|
|
764
|
+
lighting = game:GetService("Lighting"),
|
|
765
|
+
first = game:GetService("ReplicatedFirst"),
|
|
766
|
+
sounds = game:GetService("SoundService")
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
current = serviceMap[parts[2]]
|
|
770
|
+
if not current then return nil end
|
|
771
|
+
|
|
772
|
+
for i = 3, #parts do
|
|
773
|
+
local name = parts[i]
|
|
774
|
+
current = current:FindFirstChild(name)
|
|
775
|
+
if not current then return nil end
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
return current
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
local function createInstanceByPath(path, content)
|
|
782
|
+
local cleanPath = path:gsub("%.server%.luau$", ""):gsub("%.client%.luau$", ""):gsub("%.luau$", ""):gsub("/init%.meta%.json$", "")
|
|
783
|
+
local parts = cleanPath:split("/")
|
|
784
|
+
if parts[1] ~= "src" then return nil end
|
|
785
|
+
|
|
786
|
+
local serviceMap = {
|
|
787
|
+
workspace = game:GetService("Workspace"),
|
|
788
|
+
server = game:GetService("ServerScriptService"),
|
|
789
|
+
shared = game:GetService("ReplicatedStorage"),
|
|
790
|
+
client = game:GetService("StarterPlayer").StarterPlayerScripts,
|
|
791
|
+
interface = game:GetService("StarterGui"),
|
|
792
|
+
tools = game:GetService("StarterPack"),
|
|
793
|
+
lighting = game:GetService("Lighting"),
|
|
794
|
+
first = game:GetService("ReplicatedFirst"),
|
|
795
|
+
sounds = game:GetService("SoundService")
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
local current = serviceMap[parts[2]]
|
|
799
|
+
if not current then return nil end
|
|
800
|
+
|
|
801
|
+
for i = 3, #parts do
|
|
802
|
+
local name = parts[i]
|
|
803
|
+
local nextInst = current:FindFirstChild(name)
|
|
804
|
+
|
|
805
|
+
if not nextInst then
|
|
806
|
+
local isLast = (i == #parts)
|
|
807
|
+
local className = "Folder"
|
|
808
|
+
|
|
809
|
+
if isLast then
|
|
810
|
+
if path:match("%.server%.luau$") then className = "Script"
|
|
811
|
+
elseif path:match("%.client%.luau$") then className = "LocalScript"
|
|
812
|
+
elseif path:match("%.luau$") then className = "ModuleScript"
|
|
813
|
+
elseif path:match("%.json$") then
|
|
814
|
+
-- Try to peek className from content
|
|
815
|
+
local success, data = pcall(function() return HttpService:JSONDecode(content) end)
|
|
816
|
+
if success and data.className then
|
|
817
|
+
className = data.className
|
|
818
|
+
end
|
|
819
|
+
end
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
local success, newInst = pcall(function() return Instance.new(className) end)
|
|
823
|
+
if success and newInst then
|
|
824
|
+
newInst.Name = name
|
|
825
|
+
newInst.Parent = current
|
|
826
|
+
nextInst = newInst
|
|
827
|
+
else
|
|
828
|
+
warn("Roport: Failed to create " .. className .. " for " .. name)
|
|
829
|
+
return nil
|
|
830
|
+
end
|
|
831
|
+
end
|
|
832
|
+
current = nextInst
|
|
833
|
+
end
|
|
834
|
+
return current
|
|
835
|
+
end
|
|
836
|
+
|
|
837
|
+
local function applyUpdate(filePath, content)
|
|
838
|
+
local inst = findInstanceByPath(filePath)
|
|
839
|
+
if not inst then
|
|
840
|
+
inst = createInstanceByPath(filePath, content)
|
|
841
|
+
if not inst then return end
|
|
842
|
+
end
|
|
843
|
+
|
|
844
|
+
if filePath:match("%.json$") then
|
|
845
|
+
local success, data = pcall(function() return HttpService:JSONDecode(content) end)
|
|
846
|
+
if not success then return end
|
|
847
|
+
|
|
848
|
+
-- Update properties
|
|
849
|
+
if data.properties then
|
|
850
|
+
for prop, val in pairs(data.properties) do
|
|
851
|
+
pcall(function()
|
|
852
|
+
local currentVal = inst[prop]
|
|
853
|
+
local targetType = typeof(currentVal)
|
|
854
|
+
|
|
855
|
+
if type(val) == "table" and targetType ~= "table" then
|
|
856
|
+
-- Deserialize complex types
|
|
857
|
+
if targetType == "Color3" then
|
|
858
|
+
inst[prop] = Color3.new(val[1], val[2], val[3])
|
|
859
|
+
elseif targetType == "Vector3" then
|
|
860
|
+
inst[prop] = Vector3.new(val[1], val[2], val[3])
|
|
861
|
+
elseif targetType == "UDim2" then
|
|
862
|
+
inst[prop] = UDim2.new(val[1], val[2], val[3], val[4])
|
|
863
|
+
elseif targetType == "UDim" then
|
|
864
|
+
inst[prop] = UDim.new(val[1], val[2])
|
|
865
|
+
elseif targetType == "Rect" then
|
|
866
|
+
inst[prop] = Rect.new(val[1], val[2], val[3], val[4])
|
|
867
|
+
elseif targetType == "NumberRange" then
|
|
868
|
+
inst[prop] = NumberRange.new(val[1], val[2])
|
|
869
|
+
end
|
|
870
|
+
else
|
|
871
|
+
inst[prop] = val
|
|
872
|
+
end
|
|
873
|
+
end)
|
|
874
|
+
end
|
|
875
|
+
end
|
|
876
|
+
elseif filePath:match("%.luau$") or filePath:match("%.lua$") then
|
|
877
|
+
if inst:IsA("LuaSourceContainer") then
|
|
878
|
+
inst.Source = content
|
|
879
|
+
end
|
|
880
|
+
end
|
|
881
|
+
print("Roport: Updated " .. inst.Name .. " from VS Code")
|
|
882
|
+
end
|
|
883
|
+
|
|
884
|
+
task.spawn(function()
|
|
885
|
+
while true do
|
|
886
|
+
task.wait(1)
|
|
887
|
+
if state.isConnected and state.isAutoPull then
|
|
888
|
+
local success, res = pcall(function()
|
|
889
|
+
return HttpService:GetAsync(SERVER_URL_POLL .. "?t=" .. os.time())
|
|
890
|
+
end)
|
|
891
|
+
|
|
892
|
+
if success then
|
|
893
|
+
local data = HttpService:JSONDecode(res)
|
|
894
|
+
if data.changes and #data.changes > 0 then
|
|
895
|
+
print("Roport: Received " .. #data.changes .. " updates from VS Code")
|
|
896
|
+
for _, change in ipairs(data.changes) do
|
|
897
|
+
applyUpdate(change.filePath, change.content)
|
|
898
|
+
end
|
|
899
|
+
end
|
|
900
|
+
else
|
|
901
|
+
warn("Roport: Poll failed: " .. tostring(res))
|
|
902
|
+
end
|
|
903
|
+
end
|
|
904
|
+
end
|
|
905
|
+
end)
|
|
906
|
+
|
|
907
|
+
task.spawn(function()
|
|
908
|
+
while true do
|
|
909
|
+
checkConnection()
|
|
910
|
+
task.wait(3)
|
|
911
|
+
end
|
|
912
|
+
end)
|
|
913
|
+
|
|
914
|
+
toggleButton.Click:Connect(function() widget.Enabled = not widget.Enabled end)
|
package/src/server.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const fs = require('fs-extra');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const chalk = require('chalk');
|
|
5
|
+
|
|
6
|
+
function startServer(port) {
|
|
7
|
+
const app = express();
|
|
8
|
+
|
|
9
|
+
// Determine project root
|
|
10
|
+
let projectRoot = process.cwd();
|
|
11
|
+
if (fs.existsSync(path.join(projectRoot, 'src'))) {
|
|
12
|
+
// We are in the root
|
|
13
|
+
} else if (fs.existsSync(path.join(projectRoot, '../src'))) {
|
|
14
|
+
// We are in cli/ or similar
|
|
15
|
+
projectRoot = path.resolve(projectRoot, '..');
|
|
16
|
+
}
|
|
17
|
+
console.log(chalk.cyan(`Project Root: ${projectRoot}`));
|
|
18
|
+
|
|
19
|
+
// State for two-way sync
|
|
20
|
+
let changedFiles = new Set();
|
|
21
|
+
let isWriting = false; // Lock to prevent loops (Roblox -> File -> Watcher -> Roblox)
|
|
22
|
+
|
|
23
|
+
// Watch for file changes locally
|
|
24
|
+
try {
|
|
25
|
+
console.log(chalk.gray(`Starting file watcher on ${projectRoot}...`));
|
|
26
|
+
fs.watch(projectRoot, { recursive: true }, (eventType, filename) => {
|
|
27
|
+
// Debug log
|
|
28
|
+
// console.log('Raw event:', eventType, filename);
|
|
29
|
+
|
|
30
|
+
if (filename && !isWriting) {
|
|
31
|
+
// Ignore system folders and the cli folder itself if we are watching root
|
|
32
|
+
if (filename.includes('node_modules') || filename.includes('.git') || filename.startsWith('cli')) return;
|
|
33
|
+
|
|
34
|
+
// Normalize path to forward slashes
|
|
35
|
+
const relativePath = filename.replace(/\\/g, '/');
|
|
36
|
+
|
|
37
|
+
// Debounce/Deduplicate slightly
|
|
38
|
+
if (!changedFiles.has(relativePath)) {
|
|
39
|
+
changedFiles.add(relativePath);
|
|
40
|
+
console.log(chalk.yellow(`File changed: ${relativePath} (${eventType})`));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
} catch (e) {
|
|
45
|
+
console.warn(chalk.red("File watching failed (might not be supported on this OS):"), e);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Increase limit for large file batches
|
|
49
|
+
app.use(express.json({ limit: '50mb' }));
|
|
50
|
+
|
|
51
|
+
app.get('/ping', (req, res) => {
|
|
52
|
+
res.send('pong');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// New endpoint for Roblox to poll for changes
|
|
56
|
+
app.get('/poll', async (req, res) => {
|
|
57
|
+
if (changedFiles.size === 0) {
|
|
58
|
+
return res.send({ changes: [] });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const changes = [];
|
|
62
|
+
const filesToProcess = Array.from(changedFiles);
|
|
63
|
+
changedFiles.clear(); // Clear immediately so we don't send duplicates
|
|
64
|
+
|
|
65
|
+
for (const filePath of filesToProcess) {
|
|
66
|
+
try {
|
|
67
|
+
const fullPath = path.resolve(projectRoot, filePath);
|
|
68
|
+
// Only send if file still exists (it might have been deleted)
|
|
69
|
+
if (await fs.pathExists(fullPath)) {
|
|
70
|
+
const stat = await fs.stat(fullPath);
|
|
71
|
+
if (stat.isFile()) {
|
|
72
|
+
const content = await fs.readFile(fullPath, 'utf8');
|
|
73
|
+
changes.push({ filePath, content });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} catch (e) {
|
|
77
|
+
console.error(chalk.red(`Error reading changed file ${filePath}:`), e);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (changes.length > 0) {
|
|
82
|
+
console.log(chalk.magenta(`Sending ${changes.length} updates to Roblox`));
|
|
83
|
+
}
|
|
84
|
+
res.send({ changes });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
app.post('/batch', async (req, res) => {
|
|
88
|
+
const { files } = req.body;
|
|
89
|
+
|
|
90
|
+
if (!files || !Array.isArray(files)) {
|
|
91
|
+
return res.status(400).send('Invalid body');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
console.log(chalk.blue(`Received batch of ${files.length} files`));
|
|
95
|
+
|
|
96
|
+
isWriting = true; // Lock watcher
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
for (const file of files) {
|
|
100
|
+
const { filePath, content } = file;
|
|
101
|
+
// Prevent directory traversal
|
|
102
|
+
const safePath = path.resolve(projectRoot, filePath);
|
|
103
|
+
if (!safePath.startsWith(projectRoot)) {
|
|
104
|
+
console.warn(chalk.yellow(`Skipping unsafe path: ${filePath}`));
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (filePath.endsWith('.json')) {
|
|
109
|
+
try {
|
|
110
|
+
const json = JSON.parse(content);
|
|
111
|
+
await fs.outputFile(safePath, JSON.stringify(json, null, 2));
|
|
112
|
+
} catch (e) {
|
|
113
|
+
await fs.outputFile(safePath, content);
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
await fs.outputFile(safePath, content);
|
|
117
|
+
}
|
|
118
|
+
console.log(chalk.green(`Synced: ${filePath}`));
|
|
119
|
+
}
|
|
120
|
+
res.send({ success: true });
|
|
121
|
+
} catch (err) {
|
|
122
|
+
console.error(chalk.red('Error syncing files:'), err);
|
|
123
|
+
res.status(500).send({ error: err.message });
|
|
124
|
+
} finally {
|
|
125
|
+
// Release lock after a short delay to let FS settle
|
|
126
|
+
setTimeout(() => { isWriting = false; }, 500);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
app.post('/delete', async (req, res) => {
|
|
131
|
+
const { files } = req.body;
|
|
132
|
+
|
|
133
|
+
if (!files || !Array.isArray(files)) {
|
|
134
|
+
return res.status(400).send('Invalid body');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
console.log(chalk.blue(`Received delete request for ${files.length} files`));
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
for (const filePath of files) {
|
|
141
|
+
// Prevent directory traversal
|
|
142
|
+
const safePath = path.resolve(projectRoot, filePath);
|
|
143
|
+
if (!safePath.startsWith(projectRoot)) {
|
|
144
|
+
console.warn(chalk.yellow(`Skipping unsafe delete path: ${filePath}`));
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (await fs.pathExists(safePath)) {
|
|
149
|
+
await fs.remove(safePath);
|
|
150
|
+
console.log(chalk.magenta(`Deleted: ${filePath}`));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
res.send({ success: true });
|
|
154
|
+
} catch (err) {
|
|
155
|
+
console.error(chalk.red('Error deleting files:'), err);
|
|
156
|
+
res.status(500).send({ error: err.message });
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
app.listen(port, () => {
|
|
161
|
+
console.log(chalk.cyan(`Roport server running on http://127.0.0.1:${port}`));
|
|
162
|
+
console.log(chalk.gray('Waiting for requests...'));
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = { startServer };
|