├── LICENSE
├── README.md
├── client.lua
├── dot.bmp
├── meta.xml
├── server.lua
└── status.png
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Marek Kulik
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Bootloader
2 |
3 | A resource for server bootloading and resource keep-alive for a Multi Theft Auto server.
4 |
5 | Bootloading in the context of a MTA:SA server means this resource will start pre-configured resources in an undefined order. Furthermore, this bootloader will also keep pre-configured resources alive by checking the resource state with a timer - this behavior can be disabled.
6 |
7 | The act of enabling or disabling a resource in the bootloader is logged by the server.
8 |
9 | This resource is production-ready and can be used as-is.
10 |
11 | ## Installation
12 |
13 | ### Summary
14 |
15 | 1. Upload to your server's **resources** directory
16 | 2. Refresh your server with `/refresh` command
17 | 3. Grant ACL permissions with `/aclrequest allow bootloader all`
18 | 4. Add `command.bootloader` to **Admin** ACL permission list
19 | 5. Add bootloader to **mtaserver.conf** resource startup list
20 |
21 | ### How to install this resource
22 |
23 | Place this resource in your server's **resources** directory to install it.
24 |
25 | If your MTA:SA server is already running, you must execute the `/refresh` command (either as administrator or in the server console - without the slash prefix).
26 |
27 | This resource has two ACL requests and it will __NOT__ work without these permissions, since they're essential to the functionality. This resource needs access to **startResource** and **stopResource** functions to start and stop other resources, respectively. You can grant these permissions with the command: `/aclrequest allow bootloader all`
28 |
29 | To allow administrators to open the GUI configuration panel, they must be granted the permission to use the ACL right `command.bootloader`. You can do this either by manually editing your ACL.xml __AND__ executing `/reloadacl` on the server or by adding the right to the **Admin** ACL through the admin panel **Resources** tab.
30 |
31 | This resource is meant to be put into your **mtaserver.conf** like this:
32 | ```xml
33 |
34 |
35 |
36 |
37 |
38 | ```
39 |
40 | You should move the **bootloader** to the end of the resource list, if you encounter any issues. You should also mark the bootloader as `protected="1"` to prevent any user from stopping the resource.
41 |
42 | ## Configuration
43 |
44 | The bootloader exposes a few resource-level settings, which you can set manually by editing **settings.xml** (not recommended) or by opening the resource configuration through the admin panel **Resources** tab.
45 |
46 | | Setting | Description |
47 | | ---------------- | -------------------------------------------------- |
48 | | \*keepAlive | Periodically start checked, but stopped, resources |
49 | | \*showGamemodes | Display gamemode resources in the GUI |
50 | | \*showMaps | Display map resources in the GUI |
51 | | \*showRaceAddons | Display race addon resources in the GUI |
52 |
53 | **NOTE:** The bootloader stores the list of enabled resources in the server's **settings.xml** file. It uses the key `@resources` and the value is a list of enabled resource names concatenated by a comma `,` character.
54 |
55 | ## Usage
56 |
57 | You open the bootloader configuration window with the `/bootloader` command. You can easily bind this command to any key (example: `/bind f4 bootloader`).
58 |
59 | The configuration panel will receive the resource list in batches from the server (indicated by the loading spinner in the window title) to avoid hanging either the server or client. You can use the panel before the loading finishes.
60 |
61 | Every line in the resource list of the bootloader GUI represents a single resource on the server. The circle indicates whether the resource is running (circle is green) or stopped (circle is gray). The checkbox indicates whether the resource is enabled for bootloading and keep-alive.
62 |
63 | 
64 |
65 | To find a resource in the resource list, you can apply several filters through the filter editbox in the configuration panel. The filter text is separated by whitespace and every *token* must match for a resource to appear.
66 |
67 | You can filter by...
68 |
69 | - resource name
70 | - resource type with `#type` (corresponds to the `` meta.xml value).
71 | - resource state with `~on` or `~off` (read: is the resource running?)
72 | - enabled state with `@on` or `@off` (read: resource is checked)
73 |
74 | Any filter can be prefixed with a `!` to filter out results. By default, a filter includes a match in the resource list.
75 |
76 | 
77 | 
78 |
79 | **NOTE:** By enabling a resource, the server will automatically start the resource.
80 | By disabling a resource, the server will automatically stop the resource.
81 |
82 | ## Screenshots
83 |
84 | 
85 |
86 | 
87 |
88 | 
89 |
90 | ## License
91 |
92 | Licensed under the [MIT](LICENSE) license.
93 |
--------------------------------------------------------------------------------
/client.lua:
--------------------------------------------------------------------------------
1 | --------------------------------------------------------------------------------
2 | -- Access control and session management
3 | --------------------------------------------------------------------------------
4 | function onToggleConfigurationPanel(enabled)
5 | if enabled then
6 | openBootloaderWindow()
7 | requestBootloaderResourceDataList()
8 | else
9 | closeBootloaderWindow()
10 | end
11 | end
12 | addEvent("Bootloader.toggleConfigurationPanel", true)
13 | addEventHandler("Bootloader.toggleConfigurationPanel", resourceRoot, onToggleConfigurationPanel, false)
14 |
15 | --------------------------------------------------------------------------------
16 | -- Data stream events
17 | --------------------------------------------------------------------------------
18 | function requestBootloaderResourceDataList()
19 | preBootloaderResourceDataListRequest()
20 | triggerServerEvent("BootloaderClient.requestResourceDataList", resourceRoot)
21 | end
22 |
23 | function handleBootloaderResourceDataListBatch(resourceDataBatch)
24 | processBootloaderResourceDataBatch(resourceDataBatch)
25 | end
26 | addEvent("Bootloader.resourceDataListBatch", true)
27 | addEventHandler("Bootloader.resourceDataListBatch", resourceRoot, handleBootloaderResourceDataListBatch, false)
28 |
29 | function handleBootloaderResourceDataListComplete()
30 | completeBootloaderResourceDataBatch()
31 | end
32 | addEvent("Bootloader.resourceDataListComplete", true)
33 | addEventHandler("Bootloader.resourceDataListComplete", resourceRoot, handleBootloaderResourceDataListComplete, false)
34 |
35 | function requestBootloaderResourceToggle(resourceName, enabled)
36 | triggerServerEvent("BootloaderClient.toggleBootloaderResource", resourceRoot, resourceName, enabled)
37 | end
38 |
39 | function handleBootloaderResourceDataResponse(resourceName, isEnabled, isRunning)
40 | processBootloaderResourceData(resourceName, isEnabled, isRunning)
41 | end
42 | addEvent("Bootloader.resourceDataResponse", true)
43 | addEventHandler("Bootloader.resourceDataResponse", resourceRoot, handleBootloaderResourceDataResponse, false)
44 |
45 | --------------------------------------------------------------------------------
46 | -- Graphical User Interface
47 | --------------------------------------------------------------------------------
48 | local gui = {}
49 | local minimumWindowWidth = 400
50 | local minimumWindowHeight = 400
51 | local windowTitle = "Bootloader Configuration"
52 |
53 | function openBootloaderWindow()
54 | if gui.window ~= nil then
55 | return
56 | end
57 |
58 | gui.width = 600
59 | gui.height = 600
60 | gui.rows = {}
61 | gui.checkboxes = {}
62 | gui.resources = {}
63 |
64 | local innerWidth = gui.width - 20
65 | local screenWidth, screenHeight = guiGetScreenSize()
66 | local posX = (screenWidth - gui.width) / 2
67 | local posY = (screenHeight - gui.height) / 2
68 | gui.window = guiCreateWindow(posX, posY, gui.width, gui.height, windowTitle, false)
69 | addEventHandler("onClientGUISize", gui.window, onBootloaderWindowResize, false)
70 |
71 | local function createHeader(x, y, text)
72 | local label = guiCreateLabel(x, y, 100, 15, text, false, gui.window)
73 | guiLabelSetColor(label, 160, 160, 192)
74 | return label
75 | end
76 |
77 | gui.headers = {}
78 | gui.headers[1] = createHeader(20, 25, "State")
79 | gui.headers[2] = createHeader(75, 25, "Name")
80 | gui.headers[3] = createHeader(210, 25, "Description")
81 |
82 | local function createBackgroundBorder(x, y, width, height)
83 | local image = guiCreateStaticImage(x, y, width, height, "dot.bmp", false, gui.window)
84 | guiStaticImageSetColor(image, 160, 160, 190, 255)
85 | guiSetEnabled(image, false)
86 | guiForceSetAlpha(image, 1.0)
87 | return image
88 | end
89 |
90 | gui.background = guiCreateStaticImage(10, 42, innerWidth, gui.height - 84, "dot.bmp", false, gui.window)
91 | guiStaticImageSetVerticalBackground(gui.background, 40, 40, 70, 200, 0, 0, 0, 200)
92 | guiSetEnabled(gui.background, false)
93 | guiForceSetAlpha(gui.background, 1.0)
94 |
95 | gui.backgroundborder_top = createBackgroundBorder(10, 42, innerWidth, 1)
96 | gui.backgroundborder_bottom = createBackgroundBorder(10, gui.height - 42, innerWidth, 1)
97 | gui.backgroundborder_left = createBackgroundBorder(10, 42, 1, gui.height - 84)
98 | gui.backgroundborder_right = createBackgroundBorder(10 + innerWidth, 42, 1, gui.height - 84)
99 |
100 | local bottomY = gui.height - 35
101 |
102 | gui.filters = {}
103 | gui.filter_label = guiCreateLabel(10, bottomY + 5, 30, 25, "Filter:", false, gui.window)
104 | gui.filter_edit = guiCreateEdit(50, bottomY, gui.width - 260, 25, "", false, gui.window)
105 | gui.filter_help = guiCreateLabel(10, 5, gui.width - 260, 25, "Type: #script | State: ~on, ~off | Enabled: @on, @off", false, gui.filter_edit)
106 | guiLabelSetColor(gui.filter_help, 60, 60, 60)
107 | guiSetEnabled(gui.filter_help, false)
108 | addEventHandler("onClientGUIChanged", gui.filter_edit, onBootloaderFilterChanged, false)
109 |
110 | gui.refresh = guiCreateButton(gui.width - 200, bottomY, 90, 25, "Refresh", false, gui.window)
111 | addEventHandler("onClientGUIClick", gui.refresh, onBootloaderRefreshButtonClick, false)
112 |
113 | gui.close = guiCreateButton(gui.width - 100, bottomY, 90, 25, "Close", false, gui.window)
114 | addEventHandler("onClientGUIClick", gui.close, onBootloaderCloseButtonClick, false)
115 |
116 | showCursor(true)
117 | guiSetInputEnabled(true)
118 | end
119 |
120 | function closeBootloaderWindow()
121 | destroyElement(gui.window)
122 | guiSetInputEnabled(false)
123 | showCursor(false)
124 | gui = {}
125 | end
126 |
127 | function onBootloaderWindowResize()
128 | gui.width, gui.height = guiGetSize(source, false)
129 |
130 | if gui.width < minimumWindowWidth or gui.height < minimumWindowWidth then
131 | gui.width = math.max(minimumWindowWidth, gui.width)
132 | gui.height = math.max(minimumWindowWidth, gui.height)
133 | return guiSetSize(source, gui.width, gui.height, false)
134 | end
135 |
136 | if gui.scrollpane then
137 | guiSetSize(gui.scrollpane, gui.width - 35, gui.height - 100, false)
138 | end
139 |
140 | local innerWidth = gui.width - 20
141 |
142 | guiSetSize(gui.background, innerWidth, gui.height - 84, false)
143 |
144 | guiSetSize(gui.backgroundborder_top, innerWidth, 1, false)
145 | guiSetSize(gui.backgroundborder_bottom, innerWidth, 1, false)
146 | guiSetSize(gui.backgroundborder_left, 1, gui.height - 84, false)
147 | guiSetSize(gui.backgroundborder_right, 1, gui.height - 84, false)
148 |
149 | guiSetPosition(gui.backgroundborder_bottom, 10, gui.height - 42, false)
150 | guiSetPosition(gui.backgroundborder_right, 10 + innerWidth, 42, false)
151 |
152 | local bottomY = gui.height - 35
153 |
154 | guiSetSize(gui.filter_edit, gui.width - 260, 25, false)
155 |
156 | guiSetPosition(gui.filter_label, 10, bottomY + 5, false)
157 | guiSetPosition(gui.filter_edit, 50, bottomY, false)
158 | guiSetPosition(gui.refresh, gui.width - 200, bottomY, false)
159 | guiSetPosition(gui.close, gui.width - 100, bottomY, false)
160 | end
161 |
162 | function onBootloaderFilterChanged()
163 | local filter = guiGetText(gui.filter_edit) or ""
164 | local filters = split(utf8.fold(filter), " ")
165 | local length = 0
166 | gui.filters = {}
167 |
168 | if filters then
169 | for i = 1, #filters do
170 | local filter = {
171 | text = utf8.trim(filters[i]),
172 | negation = false,
173 | }
174 |
175 | if utf8.sub(filter.text, 1, 1) == "!" then
176 | filter.negation = true
177 | filter.text = utf8.sub(filter.text, 2)
178 | end
179 |
180 | if filter.text ~= "" then
181 | length = length + 1
182 | gui.filters[length] = filter
183 | end
184 | end
185 | end
186 |
187 | guiSetVisible(gui.filter_help, filter == "")
188 | updateBootloaderScrollpaneLayout()
189 | end
190 |
191 | function onBootloaderCloseButtonClick()
192 | closeBootloaderWindow()
193 | triggerServerEvent("BootloaderClient.closePanel", resourceRoot)
194 | end
195 |
196 | function onBootloaderRefreshButtonClick()
197 | requestBootloaderResourceDataList()
198 | end
199 |
200 | function onBootloaderScrollpaneClick()
201 | if getElementType(source) == "gui-checkbox" then
202 | local row = gui.checkboxes[source]
203 |
204 | if row ~= nil then
205 | local selected = guiCheckBoxGetSelected(source)
206 | guiSetEnabled(source, false)
207 | requestBootloaderResourceToggle(row.data.name, selected)
208 | end
209 | end
210 | end
211 |
212 | local spinnersText = {
213 | "⢎⡰", "⢎⡡", "⢎⡑", "⢎⠱", "⠎⡱", "⢊⡱", "⢌⡱", "⢆⡱",
214 | }
215 |
216 | local spinnersLength = #spinnersText
217 |
218 | function updateBootloaderWindowSpinner()
219 | gui.spinner = ((gui.spinner + 1) % spinnersLength) + 1
220 | guiSetText(gui.window, ("%s - Loading %s"):format(windowTitle, spinnersText[gui.spinner]))
221 | end
222 |
223 | function preBootloaderResourceDataListRequest()
224 | if gui.window == nil then
225 | return
226 | end
227 |
228 | clearBootloaderScrollpane()
229 |
230 | gui.spinner = 0
231 | updateBootloaderWindowSpinner()
232 | end
233 |
234 | function processBootloaderResourceDataBatch(resourceDataBatch)
235 | if gui.window == nil then
236 | return
237 | end
238 |
239 | if gui.scrollpane == nil then
240 | createBootloaderScrollpane()
241 | end
242 |
243 | createBootloaderResourceRows(resourceDataBatch)
244 | sortBootloaderResourceRows()
245 | updateBootloaderScrollpaneLayout()
246 |
247 | updateBootloaderWindowSpinner()
248 | end
249 |
250 | function completeBootloaderResourceDataBatch()
251 | if gui.window == nil then
252 | return
253 | end
254 |
255 | gui.spinner = nil
256 | guiSetText(gui.window, windowTitle)
257 | end
258 |
259 | function createBootloaderScrollpane()
260 | gui.scrollpane = guiCreateScrollPane(20, 50, gui.width - 35, gui.height - 100, false, gui.window)
261 | gui.scrollpadding = guiCreateLabel(0, 0, 1, 1, "", false, gui.scrollpane)
262 | addEventHandler("onClientGUIClick", gui.scrollpane, onBootloaderScrollpaneClick)
263 | guiSetProperty(gui.scrollpane, "VertStepSize", 0.05)
264 | guiForceSetAlpha(gui.scrollpane, 1.0)
265 | end
266 |
267 | function clearBootloaderScrollpane()
268 | if gui.scrollpane then
269 | destroyElement(gui.scrollpane)
270 | gui.scrollpane = nil
271 | gui.rows = {}
272 | gui.checkboxes = {}
273 | gui.resources = {}
274 | end
275 | end
276 |
277 | function generateResourceDataFilter(data)
278 | return utf8.fold(table.concat({
279 | data.name,
280 | "#"..(data.type or "none"),
281 | (data.running and "~on" or "~off"),
282 | (data.enabled and "@on" or "@off"),
283 | }, " "))
284 | end
285 |
286 | function createBootloaderResourceRows(resourceDataBatch)
287 | local rowsLength = #gui.rows
288 |
289 | for i = 1, #resourceDataBatch do
290 | local data = resourceDataBatch[i]
291 |
292 | -- Trim string values
293 | for key, value in pairs(data) do
294 | if type(value) == "string" then
295 | value = utf8.trim(value)
296 |
297 | if value == "" then
298 | data[key] = false
299 | else
300 | data[key] = value
301 | end
302 | end
303 | end
304 |
305 | -- Create table data for row
306 | local row = {
307 | data = data,
308 | filter = generateResourceDataFilter(data),
309 | sortableValue = utf8.fold(data.name),
310 | widths = {},
311 | gui = {},
312 | }
313 |
314 | -- Create row gui
315 | row.gui.status = guiCreateStaticImage(0, 0, 20, 20, "status.png", false, gui.scrollpane)
316 | guiSetEnabled(row.gui.status, false)
317 |
318 | if data.running then
319 | guiStaticImageSetColor(row.gui.status, 100, 255, 100, 255)
320 | else
321 | guiStaticImageSetColor(row.gui.status, 100, 100, 100, 255)
322 | end
323 |
324 | row.gui.checkbox = guiCreateCheckBox(0, 0, 0, 20, "", data.enabled, false, gui.scrollpane)
325 |
326 | row.gui.name = guiCreateLabel(0, 0, 0, 20, data.name, false, gui.scrollpane)
327 | guiSetEnabled(row.gui.name, false)
328 |
329 | row.gui.description = guiCreateLabel(0, 0, 0, 20, data.description or "-", false, gui.scrollpane)
330 | guiSetEnabled(row.gui.description, false)
331 |
332 | -- Calculate column widths
333 | row.widths.name = guiLabelGetTextExtent(row.gui.name)
334 | guiSetSize(row.gui.name, row.widths.name, 20, false)
335 |
336 | row.widths.description = guiLabelGetTextExtent(row.gui.description)
337 | guiSetSize(row.gui.description, row.widths.description, 20, false)
338 |
339 | -- Store row references
340 | gui.rows[i + rowsLength] = row
341 | gui.checkboxes[row.gui.checkbox] = row
342 | gui.resources[data.name] = row
343 | end
344 | end
345 |
346 | function sortBootloaderResourceRows()
347 | table.sort(gui.rows, function (rowA, rowB)
348 | return rowA.sortableValue < rowB.sortableValue
349 | end)
350 | end
351 |
352 | function updateBootloaderScrollpaneLayout()
353 | local visible_length = 0
354 |
355 | local column_width = {
356 | [1] = 55,
357 | [2] = 100,
358 | [3] = 200,
359 | }
360 |
361 | for i = 1, #gui.rows do
362 | local row = gui.rows[i]
363 | row.visible = true
364 |
365 | if gui.filters[1] then
366 | for i = 1, #gui.filters do
367 | local filter = gui.filters[i]
368 | local matched = (utf8.find(row.filter, filter.text, 1, true) ~= nil)
369 |
370 | if filter.negation then
371 | matched = not matched
372 | end
373 |
374 | row.visible = matched
375 |
376 | if not row.visible then
377 | break
378 | end
379 | end
380 | end
381 |
382 | if row.visible then
383 | visible_length = visible_length + 1
384 |
385 | local r, g, b = 255, 255, 100
386 |
387 | if (visible_length % 2) == 0 then
388 | r, g, b = 255, 255, 170
389 | end
390 |
391 | row.rgb = { r, g, b }
392 |
393 | if not row.data.running then
394 | r = r * 0.5
395 | g = g * 0.5
396 | b = b * 0.5
397 | end
398 |
399 | guiLabelSetColor(row.gui.name, r, g, b)
400 | guiLabelSetColor(row.gui.description, r, g, b)
401 |
402 | column_width[2] = math.max(column_width[2], row.widths.name)
403 | column_width[3] = math.max(column_width[3], row.widths.description)
404 |
405 | -- Apply the real width to the labels
406 | guiSetSize(row.gui.name, row.widths.name, 20, false)
407 | guiSetSize(row.gui.description, row.widths.description, 20, false)
408 |
409 | guiSetSize(row.gui.status, 20, 20, false)
410 | else
411 | -- Reset the size of the labels because the scrollbars inside the scrollpane
412 | -- include invisible items in the scrollbar-visibility calculation
413 | guiSetSize(row.gui.name, 0, 0, false)
414 | guiSetSize(row.gui.description, 0, 0, false)
415 | end
416 |
417 | for name, element in pairs(row.gui) do
418 | guiSetVisible(element, row.visible)
419 |
420 | if not row.visible then
421 | guiSetPosition(element, 0, 0, false)
422 | end
423 | end
424 | end
425 |
426 | local column_position = {}
427 | column_position[1] = 0
428 | column_position[2] = column_position[1] + column_width[1]
429 | column_position[3] = 10 + column_position[2] + column_width[2]
430 |
431 | for i = 1, 3 do
432 | local header = gui.headers[i]
433 | local px, py = guiGetPosition(header, false)
434 | guiSetPosition(header, column_position[i] + 20, py, false)
435 | end
436 |
437 | if visible_length == 0 then
438 | guiSetPosition(gui.scrollpadding, 0, 0, false)
439 | return
440 | end
441 |
442 | local y = 0
443 |
444 | for i = 1, #gui.rows do
445 | local row = gui.rows[i]
446 |
447 | if row.visible then
448 | guiSetPosition(row.gui.status, 0, y, false)
449 | guiSetPosition(row.gui.checkbox, 30, y, false)
450 | guiSetPosition(row.gui.name, column_position[2], y, false)
451 | guiSetPosition(row.gui.description, column_position[3], y, false)
452 |
453 | guiSetSize(row.gui.checkbox, column_width[2] + 25, 20, false)
454 |
455 | y = y + 20
456 | end
457 | end
458 |
459 | local paddingX = 25 + column_position[3] + column_width[3]
460 | local paddingY = y + 20
461 | guiSetPosition(gui.scrollpadding, paddingX, paddingY, false)
462 | end
463 |
464 | function processBootloaderResourceData(resourceName, isEnabled, isRunning)
465 | if gui.window == nil then
466 | return
467 | end
468 |
469 | local row = gui.resources[resourceName]
470 |
471 | if row == nil then
472 | return
473 | end
474 |
475 | updateBootloaderResourceRow(row, isEnabled, isRunning)
476 | end
477 |
478 | function updateBootloaderResourceRow(row, isEnabled, isRunning)
479 | row.data.enabled = isEnabled
480 | row.data.running = isRunning
481 | row.filter = generateResourceDataFilter(row.data)
482 | guiCheckBoxSetSelected(row.gui.checkbox, isEnabled)
483 | guiSetEnabled(row.gui.checkbox, true)
484 |
485 | local r, g, b = unpack(row.rgb)
486 |
487 | if isRunning then
488 | guiStaticImageSetColor(row.gui.status, 100, 255, 100, 255)
489 | else
490 | guiStaticImageSetColor(row.gui.status, 100, 100, 100, 255)
491 |
492 | r = r * 0.5
493 | g = g * 0.5
494 | b = b * 0.5
495 | end
496 |
497 | guiLabelSetColor(row.gui.name, r, g, b)
498 | guiLabelSetColor(row.gui.description, r, g, b)
499 | end
500 |
501 | --------------------------------------------------------------------------------
502 | -- Utility and extension functions
503 | --------------------------------------------------------------------------------
504 | function utf8.trim(self)
505 | assert(type(self) == "string", "expected string at argument 1, got ".. type(self))
506 | local from = utf8.match(self, "^%s*()")
507 | return from > utf8.len(self) and "" or utf8.match(self, ".*%S", from)
508 | end
509 |
510 | function guiStaticImageSetColor(guiElement, r, g, b, a)
511 | local c = ("%02X%02X%02X%02X"):format(a, r, g, b)
512 | local value = ("tl:%s tr:%s bl:%s br:%s"):format(c, c, c, c)
513 | guiSetProperty(guiElement, "ImageColours", value)
514 | end
515 |
516 | function guiStaticImageSetVerticalBackground(guiElement, r1, g1, b1, a1, r2, g2, b2, a2)
517 | local c1 = ("%02X%02X%02X%02X"):format(a1, r1, g1, b1)
518 | local c2 = ("%02X%02X%02X%02X"):format(a2, r2, g2, b2)
519 | local value = ("tl:%s tr:%s bl:%s br:%s"):format(c1, c1, c2, c2)
520 | guiSetProperty(guiElement, "ImageColours", value)
521 | end
522 |
523 | function guiForceSetAlpha(element, alpha)
524 | guiSetProperty(element, "InheritsAlpha", "False")
525 | guiSetAlpha(element, alpha)
526 | end
527 |
--------------------------------------------------------------------------------
/dot.bmp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/botder/mtasa-bootloader/4d386d7a36e0865e92ff289ce88ebac50ee9ed4f/dot.bmp
--------------------------------------------------------------------------------
/meta.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
17 |
22 |
27 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/server.lua:
--------------------------------------------------------------------------------
1 | --------------------------------------------------------------------------------
2 | -- Bootloader entrypoint
3 | --------------------------------------------------------------------------------
4 | function onThisResourceStart()
5 | prepareBootloaderSettings()
6 | startBootloaderResources()
7 | setTimer(keepAliveTimerCallback, 10 * 60000, 0)
8 | end
9 | addEventHandler("onResourceStart", resourceRoot, onThisResourceStart, false)
10 |
11 | function startBootloaderResources()
12 | local resourceList = getBootloaderResources()
13 |
14 | for i = 1, #resourceList do
15 | local resource = resourceList[i]
16 |
17 | if getResourceState(resource) == "loaded" then
18 | startResource(resource, true)
19 | end
20 | end
21 | end
22 |
23 | function keepAliveTimerCallback()
24 | local keepAlive = getBool("keepAlive", true)
25 |
26 | if keepAlive then
27 | startBootloaderResources()
28 | end
29 | end
30 |
31 | --------------------------------------------------------------------------------
32 | -- Settings management
33 | --------------------------------------------------------------------------------
34 | local settingsResourceDict = {}
35 |
36 | function prepareBootloaderSettings()
37 | local resourceNameList = getBootloaderResourcesSetting()
38 |
39 | for i = 1, #resourceNameList do
40 | local resourceName = resourceNameList[i]
41 | settingsResourceDict[resourceName] = true
42 | end
43 | end
44 |
45 | function getBootloaderResourceNamesDict()
46 | return settingsResourceDict
47 | end
48 |
49 | function isBootloaderResource(resourceName)
50 | return settingsResourceDict[resourceName] ~= nil
51 | end
52 |
53 | function getBootloaderResources()
54 | local result = {}
55 | local length = 0
56 |
57 | for resourceName in pairs(settingsResourceDict) do
58 | local resource = getResourceFromName(resourceName)
59 |
60 | if resource then
61 | length = length + 1
62 | result[length] = resource
63 | end
64 | end
65 |
66 | return result
67 | end
68 |
69 | function getBootloaderResourcesSetting()
70 | local result = {}
71 | local length = 0
72 | local resourceNameList = get("@resources") or ""
73 |
74 | for index, resourceName in pairs(split(resourceNameList, ",")) do
75 | resourceName = utf8.trim(resourceName)
76 |
77 | if resourceName ~= "" then
78 | length = length + 1
79 | result[length] = resourceName
80 | end
81 | end
82 |
83 | return result
84 | end
85 |
86 | function saveBootloaderResourcesSetting()
87 | local resourceNameList = {}
88 | local length = 0
89 |
90 | for resourceName in pairs(settingsResourceDict) do
91 | if getResourceFromName(resourceName) then
92 | length = length + 1
93 | resourceNameList[length] = resourceName
94 | end
95 | end
96 |
97 | set("@resources", table.concat(resourceNameList, ","))
98 | end
99 |
100 | function toggleBootloaderResource(resourceName, enabled)
101 | if enabled and settingsResourceDict[resourceName] == nil then
102 | settingsResourceDict[resourceName] = true
103 | saveBootloaderResourcesSetting()
104 | elseif not enabled and settingsResourceDict[resourceName] ~= nil then
105 | settingsResourceDict[resourceName] = nil
106 | saveBootloaderResourcesSetting()
107 | end
108 | end
109 |
110 | --------------------------------------------------------------------------------
111 | -- Access control and session management
112 | --------------------------------------------------------------------------------
113 | local activePlayerSessions = {}
114 |
115 | function getPlayerSession(player)
116 | return activePlayerSessions[player]
117 | end
118 |
119 | function clearPlayerSession(player)
120 | local session = activePlayerSessions[player]
121 | activePlayerSessions[player] = nil
122 |
123 | if session then
124 | stopSessionDataTransfers(session)
125 | end
126 | end
127 |
128 | function onPlayerBootloaderCommand(player)
129 | local enabled = false
130 |
131 | if activePlayerSessions[player] ~= nil then
132 | activePlayerSessions[player] = nil
133 | else
134 | activePlayerSessions[player] = {}
135 | enabled = true
136 | end
137 |
138 | triggerClientEvent(player, "Bootloader.toggleConfigurationPanel", resourceRoot, enabled)
139 | end
140 | addCommandHandler("bootloader", onPlayerBootloaderCommand, true, false)
141 |
142 | function onPlayerQuit()
143 | clearPlayerSession(source)
144 | end
145 | addEventHandler("onPlayerQuit", root, onPlayerQuit)
146 |
147 | function onClientBootloaderClosePanel()
148 | clearPlayerSession(client)
149 | end
150 | addEvent("BootloaderClient.closePanel", true)
151 | addEventHandler("BootloaderClient.closePanel", resourceRoot, onClientBootloaderClosePanel, false)
152 |
153 | function isPlayerBootloaderAuthorized(player)
154 | return activePlayerSessions[player] ~= nil
155 | end
156 |
157 | --------------------------------------------------------------------------------
158 | -- Data stream events
159 | --------------------------------------------------------------------------------
160 | function stopSessionDataTransfers(session)
161 | stopSessionResourceDataListTransfer(session)
162 | end
163 |
164 | function onClientBootloaderResourceDataListRequest()
165 | sendPlayerBootloaderResourceDataList(client)
166 | end
167 | addEvent("BootloaderClient.requestResourceDataList", true)
168 | addEventHandler("BootloaderClient.requestResourceDataList", resourceRoot, onClientBootloaderResourceDataListRequest, false)
169 |
170 | function onClientBootloaderToggleResource(resourceName, enabled)
171 | if type(resourceName) ~= "string" or resourceName == "" then
172 | return
173 | end
174 |
175 | if type(enabled) ~= "boolean" then
176 | return
177 | end
178 |
179 | local resource = getResourceFromName(resourceName)
180 |
181 | if not resource then
182 | return sendPlayerBootloaderResourceDataList(client)
183 | end
184 |
185 | if not isPlayerBootloaderAuthorized(client) then
186 | return
187 | end
188 |
189 | toggleBootloaderResource(resourceName, enabled)
190 | enabled = isBootloaderResource(resourceName)
191 |
192 | outputServerLog(("BOOTLOADER: Resource '%s' has been %s by player '%s' (account: %s, ip: %s, serial: %s)"):format(
193 | resourceName,
194 | (enabled and "enabled" or "disabled"),
195 | getPlayerName(client),
196 | getAccountName(getPlayerAccount(client)),
197 | getPlayerIP(client),
198 | getPlayerSerial(client)
199 | ))
200 |
201 | local resourceState = getResourceState(resource)
202 |
203 | if resourceState == "loaded" and enabled then
204 | startResource(resource, true)
205 | elseif resourceState == "running" and not enabled then
206 | stopResource(resource)
207 | end
208 |
209 | setTimer(sendPlayerBootloaderResourceData, 100, 1, client, resourceName)
210 | end
211 | addEvent("BootloaderClient.toggleBootloaderResource", true)
212 | addEventHandler("BootloaderClient.toggleBootloaderResource", resourceRoot, onClientBootloaderToggleResource, false)
213 |
214 | function stopSessionResourceDataListTransfer(session)
215 | local transfer = session.resourcesBatchTransfer
216 | session.resourcesBatchTransfer = nil
217 |
218 | if transfer ~= nil then
219 | if isTimer(transfer.timer) then
220 | killTimer(transfer.timer)
221 | end
222 | end
223 | end
224 |
225 | function sendPlayerResourceDataListThread(player, session, transfer)
226 | local length = #transfer.resources
227 |
228 | if length > 0 then
229 | local index = 1
230 |
231 | while index <= length do
232 | local batch = {}
233 | local batchLength = 0
234 | local batchSize = 5
235 |
236 | while index <= length and batchLength < batchSize do
237 | local resource = transfer.resources[index]
238 | index = index + 1
239 |
240 | if getUserdataType(resource) == "resource-data" then
241 | local resourceName = getResourceName(resource)
242 |
243 | local data = {
244 | enabled = transfer.enableds[resourceName] ~= nil,
245 | running = getResourceState(resource) == "running",
246 | name = resourceName,
247 | type = getResourceInfo(resource, "type"),
248 | description = getResourceInfo(resource, "description"),
249 | }
250 |
251 | batchLength = batchLength + 1
252 | batch[batchLength] = data
253 | end
254 | end
255 |
256 | if batchLength > 0 then
257 | triggerClientEvent(player, "Bootloader.resourceDataListBatch", resourceRoot, batch)
258 | coroutine.yield()
259 | end
260 | end
261 | end
262 |
263 | triggerClientEvent(player, "Bootloader.resourceDataListComplete", resourceRoot)
264 | stopSessionResourceDataListTransfer(session)
265 | end
266 |
267 | function sendPlayerBootloaderResourceDataList(player)
268 | local session = getPlayerSession(player)
269 |
270 | if session == nil then
271 | return
272 | end
273 |
274 | stopSessionResourceDataListTransfer(session)
275 |
276 | local coroutine = coroutine.wrap(sendPlayerResourceDataListThread)
277 |
278 | local transfer = {
279 | resources = getResourcesSnapshot(),
280 | enableds = table.shallowcopy(getBootloaderResourceNamesDict()),
281 | coroutine = coroutine,
282 | timer = setTimer(coroutine, 100, 0),
283 | }
284 |
285 | session.resourcesBatchTransfer = transfer
286 |
287 | coroutine(player, session, transfer)
288 | end
289 |
290 | function sendPlayerBootloaderResourceData(player, resourceName)
291 | if not isPlayerBootloaderAuthorized(player) then
292 | return
293 | end
294 |
295 | local resource = getResourceFromName(resourceName)
296 |
297 | if not resource then
298 | return sendPlayerBootloaderResourceDataList(player)
299 | end
300 |
301 | local enabled = isBootloaderResource(resourceName)
302 | local running = getResourceState(resource) == "running"
303 | triggerClientEvent(player, "Bootloader.resourceDataResponse", resourceRoot, resourceName, enabled, running)
304 | end
305 |
306 | --------------------------------------------------------------------------------
307 | -- Utility and extension functions
308 | --------------------------------------------------------------------------------
309 | function getResourcesSnapshot()
310 | local thisResource = getThisResource()
311 | local resources = getResources()
312 | local snapshot = {}
313 | local snapshotLength = 0
314 | local showGamemodes = getBool("showGamemodes", false)
315 | local showMaps = getBool("showMaps", false)
316 | local showRaceAddons = getBool("showRaceAddons", true)
317 |
318 | for i = 1, #resources do
319 | local resource = resources[i]
320 |
321 | if resource ~= thisResource then
322 | local include = true
323 | local resourceType = getResourceInfo(resource, "type")
324 | local resourceAddon = getResourceInfo(resource, "addon")
325 |
326 | if resourceType == "gamemode" and not showGamemodes then
327 | include = false
328 | elseif resourceType == "map" and not showMaps then
329 | include = false
330 | elseif resourceAddon == "race" and not showRaceAddons then
331 | include = false
332 | end
333 |
334 | if include then
335 | snapshotLength = snapshotLength + 1
336 | snapshot[snapshotLength] = resource
337 | end
338 | end
339 | end
340 |
341 | return snapshot
342 | end
343 |
344 | function utf8.trim(self)
345 | assert(type(self) == "string", "expected string at argument 1, got ".. type(self))
346 | local from = utf8.match(self, "^%s*()")
347 | return from > utf8.len(self) and "" or utf8.match(self, ".*%S", from)
348 | end
349 |
350 | function table.shallowcopy(input)
351 | local result = {}
352 |
353 | for key, value in pairs(input) do
354 | result[key] = value
355 | end
356 |
357 | return result
358 | end
359 |
360 | function getBool(settingName, defaultValue)
361 | local value = get(settingName)
362 |
363 | if value == false then
364 | return defaultValue
365 | else
366 | return value == "true"
367 | end
368 | end
369 |
--------------------------------------------------------------------------------
/status.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/botder/mtasa-bootloader/4d386d7a36e0865e92ff289ce88ebac50ee9ed4f/status.png
--------------------------------------------------------------------------------