From 7df62153a2771243a5783e274a9bca3ed7b66879 Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Fri, 21 Aug 2026 16:48:54 +0200 Subject: [PATCH] FIX - apply configurator settings instantly --- README.md | 5 +- .../src/components/AdminConfigValueEditor.vue | 21 ++- .../components/AdminPanel.contract.test.ts | 77 +++++++- frontend/src/components/AdminPanel.vue | 20 ++- frontend/src/phoneInventory.contract.test.ts | 35 +++- frontend/src/stores/phone.ts | 6 +- frontend/src/testdata.contract.test.ts | 13 +- sky_phone/config/config.lua | 4 +- sky_phone/config/locales/de.lua | 2 +- sky_phone/config/locales/en.lua | 2 +- sky_phone/source/bridge/client/framework.lua | 19 +- sky_phone/source/bridge/client/radio.lua | 19 +- sky_phone/source/bridge/server/housing.lua | 5 + sky_phone/source/client/animations.lua | 6 + sky_phone/source/client/custom_apps.lua | 48 ++++- sky_phone/source/client/focus.lua | 5 + sky_phone/source/client/garage.lua | 5 + sky_phone/source/client/main.lua | 166 ++++++++++++++---- sky_phone/source/client/notifications.lua | 5 + sky_phone/source/client/payphones.lua | 34 +++- .../source/client/phone_configurator.lua | 24 ++- sky_phone/source/client/public_api.lua | 12 ++ sky_phone/source/client/radio.lua | 19 ++ sky_phone/source/server/admin.lua | 30 +++- sky_phone/source/server/calls.lua | 16 +- sky_phone/source/server/companies.lua | 19 +- sky_phone/source/server/crewlink.lua | 4 + sky_phone/source/server/crypto.lua | 17 +- .../source/server/custom_app_storage.lua | 8 +- sky_phone/source/server/custom_apps.lua | 50 ++++-- sky_phone/source/server/fliptok.lua | 65 +++++-- sky_phone/source/server/marketplace.lua | 20 ++- sky_phone/source/server/media_import.lua | 20 ++- sky_phone/source/server/music.lua | 6 + sky_phone/source/server/pages.lua | 19 +- sky_phone/source/server/phone.lua | 62 ++++--- .../source/server/phone_configurator.lua | 104 ++++++++--- sky_phone/source/server/phone_security.lua | 4 + sky_phone/source/server/picstagram.lua | 46 ++++- sky_phone/source/server/public_api.lua | 12 ++ sky_phone/source/server/sim.lua | 56 ++++-- sky_phone/source/server/skyride.lua | 34 ++-- sky_phone/source/server/testdata.lua | 38 +++- sky_phone/source/server/weazel_news.lua | 39 ++-- 44 files changed, 969 insertions(+), 252 deletions(-) diff --git a/README.md b/README.md index 3b861fe..f30e6c5 100644 --- a/README.md +++ b/README.md @@ -224,8 +224,9 @@ Config.PhoneConfigurator = { When enabled, `config.lua` and the server-only `media.lua` are first-run defaults. Sky Phone creates the `sky_phone_configurator` table automatically, loads its saved values before framework and phone modules initialize, and exposes the editor through `/phonepanel`. Nothing autosaves: stage changes -in the Phone Configurator tool and press the green check. Restart `sky_phone` after changing -startup-bound settings such as framework, inventory, command, item, or provider configuration. +in the Phone Configurator tool and press the green check. Saving verifies both SQL payloads and then +applies the new server, client, media, app, item, command, provider, animation, and UI values through +Sky Phone's internal runtime refresh. It does not execute a resource restart command. Every `Config.*` value from `config.lua`, including server-only sections, and every value from `Config.Media` is discovered automatically. The bootstrap switch above intentionally remains diff --git a/frontend/src/components/AdminConfigValueEditor.vue b/frontend/src/components/AdminConfigValueEditor.vue index a04ecdb..ecf43d7 100644 --- a/frontend/src/components/AdminConfigValueEditor.vue +++ b/frontend/src/components/AdminConfigValueEditor.vue @@ -145,6 +145,10 @@ const canExtendTable = computed( !vectorType.value && (!tableStructure.value || tableStructure.value.mutableKeys === true), ) +const usesFixedTableLayout = computed( + () => + Boolean(tableStructure.value) && tableStructure.value?.mutableKeys !== true, +) const tableEntries = computed(() => Object.entries(tableValue.value).filter( ([key]) => key !== '__skyType' && (!mapType.value || key !== 'entries'), @@ -721,10 +725,11 @@ function mapEntryStructure( class="config-structured-editor" :class="{ 'has-structure': Boolean(structure), + 'is-fixed-table': usesFixedTableLayout, 'is-nested': depth > 0, }" > -
+
{{ @@ -1133,6 +1138,18 @@ function mapEntryStructure( background: #0f1110; } +.config-structured-editor.is-fixed-table { + overflow: visible; + border-radius: 0; + outline: 0; + background: transparent; +} + +.config-structured-editor.is-fixed-table + > .config-structured-editor__properties { + background: transparent; +} + .config-structured-editor.is-nested.has-structure > .config-structured-editor__bar { display: none; @@ -1644,7 +1661,7 @@ function mapEntryStructure( } .config-value-toggle input:checked + i { - background: color-mix(in srgb, var(--admin-green) 72%, #1f321f); + background: var(--admin-toggle-on); } .config-value-toggle input:checked + i::after { diff --git a/frontend/src/components/AdminPanel.contract.test.ts b/frontend/src/components/AdminPanel.contract.test.ts index 7c81314..823c9aa 100644 --- a/frontend/src/components/AdminPanel.contract.test.ts +++ b/frontend/src/components/AdminPanel.contract.test.ts @@ -76,6 +76,10 @@ const configuratorClient = readFileSync( ), 'utf8', ) +const mediaImportServer = readFileSync( + new URL('../../../sky_phone/source/server/media_import.lua', import.meta.url), + 'utf8', +) const configuratorValueEditor = readFileSync( new URL('./AdminConfigValueEditor.vue', import.meta.url), 'utf8', @@ -148,6 +152,11 @@ describe('standalone admin panel contracts', () => { expect(source).toContain('class="admin-panel-font-size-control"') expect(source).not.toContain('type="color"') expect(source).toContain('color-mix(in srgb, var(--admin-green)') + expect(source).toContain('--admin-toggle-on: #63d471') + expect(source).toContain('background: var(--admin-toggle-on)') + expect(configuratorValueEditor).toContain( + 'background: var(--admin-toggle-on)', + ) }) it('stages app changes locally and saves them only from the toolbar action', () => { @@ -217,8 +226,9 @@ describe('standalone admin panel contracts', () => { it('opens directly from the configurable command with dedicated focus', () => { expect(config).toContain('Command = "phonepanel"') + expect(server).toContain('local command_name = Config.AdminPanel.Command') expect(server).toContain( - 'RegisterCommand(Config.AdminPanel.Command, function(command_source)', + 'RegisterCommand(command_name, function(command_source)', ) expect(server).toContain( 'TriggerClientEvent("sky_phone:admin:launch", player_source)', @@ -257,7 +267,7 @@ describe('standalone admin panel contracts', () => { 'changes without matching Configurator support are incomplete', ) expect(config).toMatch( - /Config\.PhoneConfigurator\s*=\s*\{[\s\S]*?Enabled\s*=\s*false[\s\S]*?Config\.Bridge\s*=/, + /Config\.PhoneConfigurator\s*=\s*\{[\s\S]*?Enabled\s*=\s*true[\s\S]*?Config\.Bridge\s*=/, ) expect(schema).toContain( 'CREATE TABLE IF NOT EXISTS `sky_phone_configurator`', @@ -297,14 +307,67 @@ describe('standalone admin panel contracts', () => { expect(configuratorServer).toContain('if not structure.fields[key] then') expect(configuratorServer).toContain('field.type == "stringOrFalse"') expect(configuratorServer).not.toContain('Config.Media = client_payload') + expect(configuratorServer).toContain( + 'apply_runtime_table(Config[key], value)', + ) + expect(configuratorServer).toContain( + 'apply_runtime_table(Config.Media, runtime_media)', + ) + expect(configuratorServer).toContain('local function read_stored_row()') + expect(configuratorServer).toContain('local function apply_stored_row(row)') + expect(configuratorServer).toContain( + 'persisted_row.media_payload ~= media_encoded', + ) + expect(configuratorServer).toContain( + 'Phone configurator SQL verification failed', + ) + expect(server).not.toContain('ExecuteCommand(("restart %s")') + expect(configuratorServer).not.toContain('clear_runtime_table') + expect(configuratorServer).toContain( + 'TriggerEvent("sky_phone:configurator:serverUpdated", revision)', + ) + expect(configuratorServer).toContain( + 'function SkyPhoneConfigurator.Broadcast(target)', + ) + expect(configuratorServer).toContain('SkyPhoneConfigurator.Broadcast(-1)') + expect(configuratorServer).toContain('through the internal runtime refresh') expect(configuratorClient).toContain( 'Bridge.Callbacks.Trigger("sky_phone:configurator:runtime"', ) + expect(configuratorClient).toContain( + 'apply_runtime_table(Config[key], value)', + ) + expect(configuratorClient).not.toContain('clear_runtime_table') + expect(configuratorClient).toContain( + 'TriggerEvent("sky_phone:configurator:updated"', + ) + expect(phoneClient).toMatch( + /AddEventHandler\("sky_phone:configurator:updated"[\s\S]*?SkyPhoneLocales\.Resolve\(Config\.Bridge\.Locale\)[\s\S]*?SkyPhoneApps\.SendCatalog\(\)[\s\S]*?type = "device:updated"/, + ) + expect(server).toContain( + 'AddEventHandler("sky_phone:configurator:serverUpdated"', + ) + expect(phoneServer).toContain('register_configured_phone_item()') + expect(phoneServer).toContain( + 'AddEventHandler("sky_phone:configurator:serverUpdated"', + ) + expect(simServer).toContain('refresh_sim_types()') + expect(simServer).toContain( + 'AddEventHandler("sky_phone:configurator:serverUpdated"', + ) + expect(mediaImportServer).toContain('local website = {}') + expect(mediaImportServer).toContain('website._adapter = adapter') + expect(mediaImportServer).not.toContain('definition._adapter = adapter') + expect(mediaImportServer).toMatch( + /AddEventHandler\("sky_phone:configurator:serverUpdated"[\s\S]*?if initialized then[\s\S]*?build_registry\(\)/, + ) expect(source).toContain('class="admin-panel-rail__configurator"') expect(source).toContain( '.admin-panel-rail .admin-panel-rail__configurator', ) expect(source).toContain(' { expect(configuratorValueEditor).toContain( 'tableStructure.value.mutableKeys === true', ) + expect(configuratorValueEditor).toContain( + 'const usesFixedTableLayout = computed(', + ) + expect(configuratorValueEditor).toContain( + "'is-fixed-table': usesFixedTableLayout", + ) + expect(configuratorValueEditor).toContain('v-if="!usesFixedTableLayout"') + expect(configuratorValueEditor).toContain( + '.config-structured-editor.is-fixed-table', + ) expect(configuratorValueEditor).toContain('v-else-if="canExtendTable"') expect(configuratorValueEditor).toContain('function removeListRow(') expect(configuratorValueEditor).toContain('function removeTableField(') diff --git a/frontend/src/components/AdminPanel.vue b/frontend/src/components/AdminPanel.vue index 608242c..1b21dc4 100644 --- a/frontend/src/components/AdminPanel.vue +++ b/frontend/src/components/AdminPanel.vue @@ -281,6 +281,16 @@ function configuratorSectionCount(fields: AdminConfiguratorField[]): number { ) } +function configuratorFieldRepeatsSection( + field: AdminConfiguratorField, +): boolean { + const section = activeConfiguratorSection.value + if (!section || field.type !== 'json') return false + const normalize = (value: string) => + value.toLocaleLowerCase().replace(/[^a-z0-9]/g, '') + return normalize(field.label) === normalize(section.label) +} + function configuratorStructureContains( structure: AdminConfiguratorStructure | undefined, needle: string, @@ -1580,7 +1590,7 @@ onBeforeUnmount(() => {
{{ t('configurator.manualSave') }} -

{{ t('configurator.restartNotice') }}

+

{{ t('configurator.refreshNotice') }}

@@ -1621,7 +1631,10 @@ onBeforeUnmount(() => { ), }" > - + {{ field.label }} { ) }) + it('applies development command changes immediately without a resource restart', () => { + const phoneClient = readResourceFile('source/client/main.lua') + + expect(phoneClient).toContain('local active_development_command = nil') + expect(phoneClient).toContain('refresh_development_command = function()') + expect(phoneClient).toContain( + 'local command_name = Config.Phone.DevelopmentCommand and Config.Command or nil', + ) + expect(phoneClient).toContain('RegisterCommand(command_name, function()') + expect(phoneClient).toContain( + 'if active_development_command == command_name and Config.Phone.DevelopmentCommand then', + ) + expect(phoneClient).toContain( + 'TriggerEvent("chat:removeSuggestion", "/" .. active_development_command)', + ) + expect(phoneClient).toMatch( + /AddEventHandler\("sky_phone:configurator:updated", function\(\)[\s\S]*?refresh_development_command\(\)/, + ) + }) + it('opens a running live activity with Space without affecting normal gameplay', () => { const phoneClient = readResourceFile('source/client/main.lua') @@ -262,7 +289,7 @@ describe('phone inventory contracts', () => { const phoneServer = readResourceFile('source/server/phone.lua') const migration = readResourceFile('source/server/db_migrate.lua') - expect(phoneServer).toContain('if not unique_phones then') + expect(phoneServer).toContain('if Config.Phone.Unique == false then') expect(phoneServer).toContain('return map_character_device(source, slot)') expect(phoneServer).toContain('FROM `sky_phone_character_devices`') expect(phoneServer).toContain('WHERE `owner_identifier` = ?') diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index d7300ff..6402f01 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -896,12 +896,12 @@ const adminPanelFallbackLocales = { disabledBody: 'Enable the configurator at the beginning of config.lua and restart sky_phone. Until then, file values remain active and editing is locked.', manualSave: 'Manual save', - restartNotice: - 'Nothing is written automatically. Press the green check to persist all staged values. Startup-bound settings take full effect after restarting sky_phone.', + refreshNotice: + 'Nothing is written automatically. The green check verifies config.lua and media.lua in SQL and refreshes the active server, client, media and UI configuration immediately.', fieldCount: '{count} fields', secretConfigured: 'Secret configured · enter a replacement', invalidValue: 'Check the highlighted table or number value.', - saved: 'SQL configuration saved.', + saved: 'SQL configuration saved and applied.', descriptions: { featureToggle: 'Turns {name} on or off.', boolean: 'Controls whether {name} is allowed.', diff --git a/frontend/src/testdata.contract.test.ts b/frontend/src/testdata.contract.test.ts index 35272f6..5a71d4d 100644 --- a/frontend/src/testdata.contract.test.ts +++ b/frontend/src/testdata.contract.test.ts @@ -16,10 +16,15 @@ describe('test data seeding contracts', () => { ) }) - it('returns before registering the test-data command when disabled', () => { - expect( - testData.indexOf('if not Config.TestData.Enabled then'), - ).toBeLessThan(testData.indexOf('RegisterCommand(Config.TestData.Command')) + it('refreshes the test-data command when its runtime configuration changes', () => { + expect(testData).toContain('local function refresh_test_data_command()') + expect(testData).toContain( + 'active_test_data_command = Config.TestData.Enabled and Config.TestData.Command or nil', + ) + expect(testData).toContain( + 'AddEventHandler("sky_phone:configurator:serverUpdated", refresh_test_data_command)', + ) + expect(testData).not.toContain('RegisterCommand(Config.TestData.Command') }) it('moves an existing player SIM before attaching it to the selected phone', () => { diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index a670677..b6474c6 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -13,7 +13,7 @@ -- When enabled, config.lua and media.lua only provide first-run defaults. -- The active configuration is loaded from SQL and managed through /phonepanel. Config.PhoneConfigurator = { - Enabled = false, + Enabled = true, } -- ============================================================================= @@ -50,7 +50,7 @@ Config.CustomApps = { Enabled = true, BundledApps = true, ExternalApps = true, - Debug = true, -- detailed client traces for exports, registration, catalog sync and lifecycle events + Debug = false, -- detailed client traces for exports, registration, catalog sync and lifecycle events ReadyTimeoutMs = 8000, MaximumMessageBytes = 65536, MaximumStorageBytesPerApp = 262144, diff --git a/sky_phone/config/locales/de.lua b/sky_phone/config/locales/de.lua index bbec040..d1fe44b 100644 --- a/sky_phone/config/locales/de.lua +++ b/sky_phone/config/locales/de.lua @@ -206,7 +206,7 @@ Locales["de"] = { tabs = { overview = "Übersicht", players = "Spieler", devices = "Geräte", apps = "Apps", accounts = "Accounts", messages = "Nachrichten", calls = "Anrufe", moderation = "Moderation", audit = "Audit", configurator = "Phone Configurator" }, overview = { eyebrow = "Server", title = "Dashboard", body = "Spieler, Geräte, Apps und Handydaten.", stats = "Server-Handystatistik", online = "Online", devices = "Geräte", accounts = "Accounts", audit = "Audit-Einträge", control = "Navigation", features = "Module", featuresBody = "Öffne ein Verwaltungsmodul.", recent = "Letzte Aktivitäten", playerFeature = "Identität, Finanzen, Job und Dienst", deviceFeature = "IMEI, SIM, Nummer und Aktivität", appFeature = "Handy-Apps installieren oder entfernen", accountFeature = "Accountzugriff und geschützte Zugangsdaten", messageFeature = "Letzte SMS-Aktivitäten prüfen", callFeature = "Letzte Anrufaktivitäten prüfen", moderationFeature = "Zugriff, Nummer oder Gerätedaten zurücksetzen", auditFeature = "Sensible Admin-Aktionen prüfen", configuratorFeature = "config.lua und media.lua über SQL verwalten" }, appearance = { eyebrow = "Darstellung", title = "Akzentfarbe", body = "Ändere den Akzent im gesamten Admin-Arbeitsbereich.", colors = { emerald = "Smaragd", blue = "Blau", violet = "Violett", orange = "Orange", red = "Rot" } }, - configurator = { context = "Runtime-Konfiguration", eyebrow = "Systemwerkzeug", sections = "Konfiguration", search = "Einstellungen oder Pfade suchen", configScope = "config.lua", mediaScope = "media.lua", noResults = "Keine passenden Einstellungen", loading = "SQL-Konfiguration wird geladen...", title = "Phone Configurator", body = "Verwalte Handy- und Media-Einstellungen im geschützten Admin-Bereich.", disabledTitle = "SQL-Konfiguration ist nicht aktiv", disabledBody = "Aktiviere den Configurator am Anfang der config.lua und starte sky_phone neu. Bis dahin bleiben die Dateiwerte aktiv und die Bearbeitung gesperrt.", manualSave = "Manuelles Speichern", restartNotice = "Nichts wird automatisch gespeichert. Der grüne Haken schreibt alle vorgemerkten Werte. Startgebundene Einstellungen werden nach einem Neustart von sky_phone vollständig aktiv.", fieldCount = "{count} Felder", secretConfigured = "Secret gesetzt · Ersatzwert eingeben", invalidValue = "Prüfe den markierten Tabellen- oder Zahlenwert.", saved = "SQL-Konfiguration gespeichert.", descriptions = { featureToggle = "Schaltet {name} ein oder aus.", boolean = "Legt fest, ob {name} erlaubt ist.", number = "Legt den Zahlenwert für {name} fest.", text = "Legt den Textwert für {name} fest.", optionalText = "Legt den optionalen Wert für {name} fest; der Schalter deaktiviert ihn.", list = "Verwaltet alle Einträge für {name}.", table = "Bündelt die zusammengehörigen Einstellungen für {name}.", credential = "Speichert den geschützten Zugangsschlüssel für {name}.", url = "Legt die URL oder den Endpunkt für {name} fest.", hosts = "Legt die erlaubten Domains für {name} fest.", milliseconds = "Legt die Zeit für {name} in Millisekunden fest.", seconds = "Legt die Zeit für {name} in Sekunden fest.", rateLimit = "Begrenzt die Aktionen für {name} pro Minute.", byteLimit = "Legt die maximal erlaubte Datengröße für {name} fest.", textLimit = "Legt die maximal erlaubte Textlänge für {name} fest.", distance = "Legt die Distanz in der Spielwelt für {name} fest.", coordinates = "Legt Weltkoordinaten oder Ausrichtung für {name} fest.", gameAsset = "Legt das GTA-Modell oder Prop für {name} fest.", animation = "Legt die Animationsdatei für {name} fest.", access = "Legt Jobs, Gruppen oder Berechtigungsstufen für {name} fest.", integration = "Wählt Framework oder Anbieter für {name} aus.", path = "Legt den Speicher- oder Ressourcenpfad für {name} fest.", color = "Legt die Oberflächenfarbe für {name} fest.", displayText = "Legt den Spielern angezeigten Text für {name} fest.", phoneNumber = "Legt die Telefon- oder Servicenummer für {name} fest.", routing = "Steuert die Verteilung eingehender Anfragen für {name}.", command = "Legt den Chat-Befehl zum Öffnen oder Ausführen von {name} fest.", locale = "Wählt die Sprache für {name} aus.", debug = "Steuert ausführliche Diagnoseausgaben für {name}.", mediaQuality = "Legt Medienqualität oder Lautstärke für {name} fest.", amount = "Legt die maximale oder angezeigte Menge für {name} fest." }, table = { list = "Liste", table = "Schlüsseltabelle", vector = "Vektor", entry = "Eintrag", general = "Allgemein", addRow = "Zeile hinzufügen", addField = "Feld hinzufügen", remove = "Entfernen", emptyList = "Noch keine Zeilen. Füge die erste Zeile mit Plus hinzu.", emptyTable = "Noch keine Felder. Füge unten den ersten Schlüssel hinzu.", keyPlaceholder = "Neuer Schlüssel", convertToList = "Als Liste nutzen", convertToMap = "Als typisierte Schlüsseltabelle nutzen", convertToTable = "Als Schlüsseltabelle nutzen", types = { string = "Text", number = "Zahl", boolean = "Schalter", list = "Liste", table = "Tabelle" } } }, + configurator = { context = "Runtime-Konfiguration", eyebrow = "Systemwerkzeug", sections = "Konfiguration", search = "Einstellungen oder Pfade suchen", configScope = "config.lua", mediaScope = "media.lua", noResults = "Keine passenden Einstellungen", loading = "SQL-Konfiguration wird geladen...", title = "Phone Configurator", body = "Verwalte Handy- und Media-Einstellungen im geschützten Admin-Bereich.", disabledTitle = "SQL-Konfiguration ist nicht aktiv", disabledBody = "Aktiviere den Configurator am Anfang der config.lua und starte sky_phone neu. Bis dahin bleiben die Dateiwerte aktiv und die Bearbeitung gesperrt.", manualSave = "Manuelles Speichern", refreshNotice = "Nichts wird automatisch gespeichert. Der grüne Haken prüft config.lua und media.lua in SQL und aktualisiert die aktive Server-, Client-, Media- und UI-Konfiguration sofort intern.", fieldCount = "{count} Felder", secretConfigured = "Secret gesetzt · Ersatzwert eingeben", invalidValue = "Prüfe den markierten Tabellen- oder Zahlenwert.", saved = "SQL-Konfiguration gespeichert und übernommen.", descriptions = { featureToggle = "Schaltet {name} ein oder aus.", boolean = "Legt fest, ob {name} erlaubt ist.", number = "Legt den Zahlenwert für {name} fest.", text = "Legt den Textwert für {name} fest.", optionalText = "Legt den optionalen Wert für {name} fest; der Schalter deaktiviert ihn.", list = "Verwaltet alle Einträge für {name}.", table = "Bündelt die zusammengehörigen Einstellungen für {name}.", credential = "Speichert den geschützten Zugangsschlüssel für {name}.", url = "Legt die URL oder den Endpunkt für {name} fest.", hosts = "Legt die erlaubten Domains für {name} fest.", milliseconds = "Legt die Zeit für {name} in Millisekunden fest.", seconds = "Legt die Zeit für {name} in Sekunden fest.", rateLimit = "Begrenzt die Aktionen für {name} pro Minute.", byteLimit = "Legt die maximal erlaubte Datengröße für {name} fest.", textLimit = "Legt die maximal erlaubte Textlänge für {name} fest.", distance = "Legt die Distanz in der Spielwelt für {name} fest.", coordinates = "Legt Weltkoordinaten oder Ausrichtung für {name} fest.", gameAsset = "Legt das GTA-Modell oder Prop für {name} fest.", animation = "Legt die Animationsdatei für {name} fest.", access = "Legt Jobs, Gruppen oder Berechtigungsstufen für {name} fest.", integration = "Wählt Framework oder Anbieter für {name} aus.", path = "Legt den Speicher- oder Ressourcenpfad für {name} fest.", color = "Legt die Oberflächenfarbe für {name} fest.", displayText = "Legt den Spielern angezeigten Text für {name} fest.", phoneNumber = "Legt die Telefon- oder Servicenummer für {name} fest.", routing = "Steuert die Verteilung eingehender Anfragen für {name}.", command = "Legt den Chat-Befehl zum Öffnen oder Ausführen von {name} fest.", locale = "Wählt die Sprache für {name} aus.", debug = "Steuert ausführliche Diagnoseausgaben für {name}.", mediaQuality = "Legt Medienqualität oder Lautstärke für {name} fest.", amount = "Legt die maximale oder angezeigte Menge für {name} fest." }, table = { list = "Liste", table = "Schlüsseltabelle", vector = "Vektor", entry = "Eintrag", general = "Allgemein", addRow = "Zeile hinzufügen", addField = "Feld hinzufügen", remove = "Entfernen", emptyList = "Noch keine Zeilen. Füge die erste Zeile mit Plus hinzu.", emptyTable = "Noch keine Felder. Füge unten den ersten Schlüssel hinzu.", keyPlaceholder = "Neuer Schlüssel", convertToList = "Als Liste nutzen", convertToMap = "Als typisierte Schlüsseltabelle nutzen", convertToTable = "Als Schlüsseltabelle nutzen", types = { string = "Text", number = "Zahl", boolean = "Schalter", list = "Liste", table = "Tabelle" } } }, players = { eyebrow = "Aktive Sitzungen", title = "Online-Spieler", online = "Jetzt online", empty = "Keine Spieler gefunden", emptyBody = "Passe die Suche an oder aktualisiere die Spielerliste." }, search = { players = "Name, ID, Job oder Nummer suchen", apps = "Apps suchen", clear = "Suche leeren" }, detail = { character = "Charakterprofil", data = "Spielerdatenübersicht", cash = "Bargeld", bank = "Bank", job = "Job", duty = "Dienst", onDuty = "Im Dienst", offDuty = "Außer Dienst", identity = "Identität", playerData = "Spielerdaten", identifier = "Charakter-Identifier", birthdate = "Geburtsdatum", grade = "Job-Rang", unknown = "Unbekannt" }, diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 72c1f53..d6e2db7 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -206,7 +206,7 @@ Locales["en"] = { tabs = { overview = "Overview", players = "Players", devices = "Devices", apps = "Apps", accounts = "Accounts", messages = "Messages", calls = "Calls", moderation = "Moderation", audit = "Audit", configurator = "Phone configurator" }, overview = { eyebrow = "Server", title = "Dashboard", body = "Players, devices, apps, and phone data.", stats = "Server phone statistics", online = "Online", devices = "Devices", accounts = "Accounts", audit = "Audit entries", control = "Navigation", features = "Modules", featuresBody = "Open an administration module.", recent = "Recent activity", playerFeature = "Identity, finances, job, and duty", deviceFeature = "IMEI, SIM, number, and activity", appFeature = "Install or remove phone apps", accountFeature = "Account access and protected credentials", messageFeature = "Review recent SMS activity", callFeature = "Review recent call activity", moderationFeature = "Reset access, number, or device data", auditFeature = "Review sensitive admin actions", configuratorFeature = "Manage config.lua and media.lua through SQL" }, appearance = { eyebrow = "Appearance", title = "Accent color", body = "Change the accent across the complete admin workspace.", colors = { emerald = "Emerald", blue = "Blue", violet = "Violet", orange = "Orange", red = "Red" } }, - configurator = { context = "Runtime configuration", eyebrow = "System tool", sections = "Configuration", search = "Search settings or paths", configScope = "config.lua", mediaScope = "media.lua", noResults = "No matching settings", loading = "Loading SQL configuration...", title = "Phone configurator", body = "Manage phone and media settings from the protected admin workspace.", disabledTitle = "SQL configuration is not active", disabledBody = "Enable the configurator at the beginning of config.lua and restart sky_phone. Until then, file values remain active and editing is locked.", manualSave = "Manual save", restartNotice = "Nothing is written automatically. Press the green check to persist all staged values. Startup-bound settings take full effect after restarting sky_phone.", fieldCount = "{count} fields", secretConfigured = "Secret configured · enter a replacement", invalidValue = "Check the highlighted table or number value.", saved = "SQL configuration saved.", descriptions = { featureToggle = "Turns {name} on or off.", boolean = "Controls whether {name} is allowed.", number = "Sets the numeric value for {name}.", text = "Sets the text value used for {name}.", optionalText = "Sets the optional value for {name}; switch it off to disable it.", list = "Manages all entries used for {name}.", table = "Groups the related settings for {name}.", credential = "Stores the protected credential used by {name}.", url = "Sets the URL or endpoint used by {name}.", hosts = "Defines which domains are allowed for {name}.", milliseconds = "Sets the timing for {name} in milliseconds.", seconds = "Sets the timing for {name} in seconds.", rateLimit = "Limits how many {name} actions are allowed per minute.", byteLimit = "Sets the maximum data size allowed for {name}.", textLimit = "Sets the maximum text length allowed for {name}.", distance = "Sets the world distance used for {name}.", coordinates = "Sets the world coordinates or orientation for {name}.", gameAsset = "Sets the GTA model or prop used for {name}.", animation = "Sets the animation asset used for {name}.", access = "Defines the jobs, groups or permission level for {name}.", integration = "Selects the connected framework or provider for {name}.", path = "Sets the storage or resource path used for {name}.", color = "Sets the interface color used for {name}.", displayText = "Sets the text shown to players for {name}.", phoneNumber = "Sets the phone or service number used for {name}.", routing = "Controls how incoming requests are routed for {name}.", command = "Sets the chat command used to open or run {name}.", locale = "Selects the language used for {name}.", debug = "Controls detailed diagnostic output for {name}.", mediaQuality = "Sets the media quality or volume used for {name}.", amount = "Sets the maximum or displayed amount for {name}." }, table = { list = "List", table = "Key table", vector = "Vector", entry = "Entry", general = "General", addRow = "Add row", addField = "Add field", remove = "Remove", emptyList = "No rows yet. Add the first row with plus.", emptyTable = "No fields yet. Add the first key below.", keyPlaceholder = "New key", convertToList = "Use list", convertToMap = "Use typed key table", convertToTable = "Use key table", types = { string = "Text", number = "Number", boolean = "Switch", list = "List", table = "Table" } } }, + configurator = { context = "Runtime configuration", eyebrow = "System tool", sections = "Configuration", search = "Search settings or paths", configScope = "config.lua", mediaScope = "media.lua", noResults = "No matching settings", loading = "Loading SQL configuration...", title = "Phone configurator", body = "Manage phone and media settings from the protected admin workspace.", disabledTitle = "SQL configuration is not active", disabledBody = "Enable the configurator at the beginning of config.lua and restart sky_phone. Until then, file values remain active and editing is locked.", manualSave = "Manual save", refreshNotice = "Nothing is written automatically. The green check verifies config.lua and media.lua in SQL and refreshes the active server, client, media and UI configuration immediately.", fieldCount = "{count} fields", secretConfigured = "Secret configured · enter a replacement", invalidValue = "Check the highlighted table or number value.", saved = "SQL configuration saved and applied.", descriptions = { featureToggle = "Turns {name} on or off.", boolean = "Controls whether {name} is allowed.", number = "Sets the numeric value for {name}.", text = "Sets the text value used for {name}.", optionalText = "Sets the optional value for {name}; switch it off to disable it.", list = "Manages all entries used for {name}.", table = "Groups the related settings for {name}.", credential = "Stores the protected credential used by {name}.", url = "Sets the URL or endpoint used by {name}.", hosts = "Defines which domains are allowed for {name}.", milliseconds = "Sets the timing for {name} in milliseconds.", seconds = "Sets the timing for {name} in seconds.", rateLimit = "Limits how many {name} actions are allowed per minute.", byteLimit = "Sets the maximum data size allowed for {name}.", textLimit = "Sets the maximum text length allowed for {name}.", distance = "Sets the world distance used for {name}.", coordinates = "Sets the world coordinates or orientation for {name}.", gameAsset = "Sets the GTA model or prop used for {name}.", animation = "Sets the animation asset used for {name}.", access = "Defines the jobs, groups or permission level for {name}.", integration = "Selects the connected framework or provider for {name}.", path = "Sets the storage or resource path used for {name}.", color = "Sets the interface color used for {name}.", displayText = "Sets the text shown to players for {name}.", phoneNumber = "Sets the phone or service number used for {name}.", routing = "Controls how incoming requests are routed for {name}.", command = "Sets the chat command used to open or run {name}.", locale = "Selects the language used for {name}.", debug = "Controls detailed diagnostic output for {name}.", mediaQuality = "Sets the media quality or volume used for {name}.", amount = "Sets the maximum or displayed amount for {name}." }, table = { list = "List", table = "Key table", vector = "Vector", entry = "Entry", general = "General", addRow = "Add row", addField = "Add field", remove = "Remove", emptyList = "No rows yet. Add the first row with plus.", emptyTable = "No fields yet. Add the first key below.", keyPlaceholder = "New key", convertToList = "Use list", convertToMap = "Use typed key table", convertToTable = "Use key table", types = { string = "Text", number = "Number", boolean = "Switch", list = "List", table = "Table" } } }, players = { eyebrow = "Active sessions", title = "Online players", online = "Online now", empty = "No players found", emptyBody = "Adjust the search or refresh the live player list." }, search = { players = "Search name, ID, job, or number", apps = "Search apps", clear = "Clear search" }, detail = { character = "Character profile", data = "Player data overview", cash = "Cash", bank = "Bank", job = "Job", duty = "Duty", onDuty = "On duty", offDuty = "Off duty", identity = "Identity", playerData = "Player data", identifier = "Character identifier", birthdate = "Birthdate", grade = "Job grade", unknown = "Unknown" }, diff --git a/sky_phone/source/bridge/client/framework.lua b/sky_phone/source/bridge/client/framework.lua index 1bb8de6..4a157ee 100644 --- a/sky_phone/source/bridge/client/framework.lua +++ b/sky_phone/source/bridge/client/framework.lua @@ -21,11 +21,24 @@ local function resolve_framework() return nil end -framework_name = resolve_framework() -if not framework_name then - error("[sky_phone] No supported framework is running. Configure Config.Bridge.Framework.") +local function refresh_framework() + local resolved = resolve_framework() + if resolved ~= "esx" and resolved ~= "qbox" and resolved ~= "qb" then + error("[sky_phone] No supported framework is running. Configure Config.Bridge.Framework.") + end + if resolved == framework_name then + return + end + + framework_name = resolved + esx = nil + qb = nil end +refresh_framework() + +AddEventHandler("sky_phone:configurator:updated", refresh_framework) + function Bridge.Framework.GetName() return framework_name end diff --git a/sky_phone/source/bridge/client/radio.lua b/sky_phone/source/bridge/client/radio.lua index e6ac91f..442f025 100644 --- a/sky_phone/source/bridge/client/radio.lua +++ b/sky_phone/source/bridge/client/radio.lua @@ -1,4 +1,5 @@ local provider +local configured_voice_provider = Config.Radio.VoiceProvider local provider_resources = { yaca = "yaca-voice", pma = "pma-voice", @@ -138,8 +139,7 @@ function Bridge.Radio.Join(primary, secondary) return false end -function Bridge.Radio.Leave() - local selected = resolve_provider() +local function leave_provider(selected) if selected == "yaca" then exports["yaca-voice"]:enableRadio(false) elseif selected == "pma" then @@ -151,6 +151,10 @@ function Bridge.Radio.Leave() end end +function Bridge.Radio.Leave() + leave_provider(resolve_provider()) +end + function Bridge.Radio.SetVolume(volume) local selected = resolve_provider() if selected == "yaca" then @@ -165,6 +169,17 @@ function Bridge.Radio.SetVolume(volume) end end +AddEventHandler("sky_phone:configurator:updated", function() + if configured_voice_provider == Config.Radio.VoiceProvider then + return + end + + leave_provider(provider) + provider = nil + configured_voice_provider = Config.Radio.VoiceProvider + TriggerEvent("sky_phone:client:radioProviderUpdated") +end) + AddEventHandler("onResourceStop", function(resource_name) if resource_name == GetCurrentResourceName() then Bridge.Radio.Leave() diff --git a/sky_phone/source/bridge/server/housing.lua b/sky_phone/source/bridge/server/housing.lua index a0238da..ffc6494 100644 --- a/sky_phone/source/bridge/server/housing.lua +++ b/sky_phone/source/bridge/server/housing.lua @@ -111,3 +111,8 @@ end AddEventHandler("onResourceStart", reset_provider) AddEventHandler("onResourceStop", reset_provider) + +AddEventHandler("sky_phone:configurator:serverUpdated", function() + selected_provider_name = nil + last_provider_state = nil +end) diff --git a/sky_phone/source/client/animations.lua b/sky_phone/source/client/animations.lua index 911b29c..b37f6b6 100644 --- a/sky_phone/source/client/animations.lua +++ b/sky_phone/source/client/animations.lua @@ -384,6 +384,12 @@ reevaluate = function(force) end) end +AddEventHandler("sky_phone:configurator:updated", function() + animation_state.revision = animation_state.revision + 1 + cleanup_phone() + reevaluate(true) +end) + local function reset_animation_state() animation_state.phone_open = false animation_state.call_state = nil diff --git a/sky_phone/source/client/custom_apps.lua b/sky_phone/source/client/custom_apps.lua index 7714062..d569d78 100644 --- a/sky_phone/source/client/custom_apps.lua +++ b/sky_phone/source/client/custom_apps.lua @@ -1567,19 +1567,49 @@ SkyPhoneApps.SendCatalog = send_catalog SkyPhoneApps.SetPhoneOpen = set_phone_open SkyPhoneApps.RegisterClientHooks = register_bundled_client_hooks -if Config.CustomApps.Enabled and Config.CustomApps.BundledApps then - local bundled = SkyPhoneApps.GetBundledManifests() - for index = 1, #bundled do - local registered, register_error = register_app(normalize_bundled_manifest(bundled[index])) - if not registered then - error(("[sky_phone] Could not register bundled custom app '%s': %s"):format( - bundled[index].id, - register_error - )) +local function refresh_bundled_apps() + local manifests = Config.CustomApps.Enabled and Config.CustomApps.BundledApps + and SkyPhoneApps.GetBundledManifests() + or {} + local desired_ids = {} + for index = 1, #manifests do + desired_ids[manifests[index].id] = true + end + + local removals = {} + for app_id, app in pairs(apps_by_id) do + if app.catalog.bundled and not desired_ids[app_id] then + removals[#removals + 1] = app_id end end + for index = 1, #removals do + remove_registered_app(removals[index], true, false) + end + + for index = 1, #manifests do + local manifest = manifests[index] + local existing = apps_by_id[manifest.id] + local normalized = normalize_bundled_manifest(manifest) + if existing and existing.catalog.bundled then + normalized.hooks = existing.hooks + apps_by_id[manifest.id] = normalized + elseif not existing then + local registered, register_error = register_app(normalized) + if not registered then + error(("[sky_phone] Could not register bundled custom app '%s': %s"):format( + manifest.id, + register_error + )) + end + end + end + sync_catalog() end +refresh_bundled_apps() + +AddEventHandler("sky_phone:configurator:updated", refresh_bundled_apps) + SkyPhoneApps.CompatibilityCore = { Add = add_compatibility_app, AddServerAuthorized = add_server_authorized_compatibility_app, diff --git a/sky_phone/source/client/focus.lua b/sky_phone/source/client/focus.lua index df176d9..1e40d71 100644 --- a/sky_phone/source/client/focus.lua +++ b/sky_phone/source/client/focus.lua @@ -23,6 +23,11 @@ local block_game = false local block_look = false local game_input = false +AddEventHandler("sky_phone:configurator:updated", function() + state.allow_movement = Config.Phone.AllowMovement == true + SkyPhoneFocus.Reapply() +end) + function SkyPhoneFocus.ApplyFocusedControls() for _, group in ipairs(focused_control_groups) do DisableAllControlActions(group) diff --git a/sky_phone/source/client/garage.lua b/sky_phone/source/client/garage.lua index 38fee65..c3f5ef7 100644 --- a/sky_phone/source/client/garage.lua +++ b/sky_phone/source/client/garage.lua @@ -48,6 +48,11 @@ local vehicle_mods = { local phone_locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale) local garage_locale = phone_locale.Nui.Apps.garage +AddEventHandler("sky_phone:configurator:updated", function() + phone_locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale) + garage_locale = phone_locale.Nui.Apps.garage +end) + local function vehicle_kind(model_hash, fallback) if IsThisModelABoat(model_hash) then return "boat" diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index 0ef2f7d..e6b9dc9 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -9,6 +9,16 @@ local nui_generation = 0 local live_activity_active = false local open_home_requested = false local admin_panel_open = false +local suggested_admin_command = nil +local suggested_test_data_command = nil +local active_development_command = nil +local registered_development_commands = {} +local active_key_mapping_command = nil +local active_key_mapping_key = nil +local key_mapping_revision = 0 +local refresh_development_command +local refresh_phone_key_mapping +local refresh_test_data_command_suggestion local function get_equipped_phone_number() if not device_payload or not device_payload.device.sim then @@ -103,6 +113,39 @@ local function send_open_message() open_home_requested = false end +local function refresh_admin_command_suggestion() + if suggested_admin_command then + TriggerEvent("chat:removeSuggestion", "/" .. suggested_admin_command) + suggested_admin_command = nil + end + if Config.AdminPanel.Enabled then + suggested_admin_command = Config.AdminPanel.Command + TriggerEvent( + "chat:addSuggestion", + "/" .. suggested_admin_command, + locale.AdminCommand.CommandDescription + ) + end +end + +AddEventHandler("sky_phone:configurator:updated", function() + locale, locale_name = SkyPhoneLocales.Resolve(Config.Bridge.Locale) + refresh_development_command() + refresh_phone_key_mapping() + refresh_test_data_command_suggestion() + refresh_admin_command_suggestion() + SkyPhoneApps.SendCatalog() + if is_open and device_payload then + device_payload.lang = locale_name + device_payload.locales = locale.Nui + device_payload.fallbackLocales = Locales.en.Nui + SendNUIMessage({ type = "device:updated", data = device_payload }) + end + if admin_panel_open then + send_admin_panel_open() + end +end) + local function open_phone() if is_open or not device_payload then return @@ -173,18 +216,46 @@ AddEventHandler("sky_phone:client:forceClose", function() close_phone() end) -if Config.Phone.DevelopmentCommand then - RegisterCommand(Config.Command, function() - if is_open or open_requested then - close_phone() - return - end - open_without_focus = false - Bridge.Callbacks.Trigger("sky_phone:device:development-open", {}) - end, false) +local function run_development_command() + if is_open or open_requested then + close_phone() + return + end + + open_without_focus = false + Bridge.Callbacks.Trigger("sky_phone:device:development-open", {}) end -RegisterCommand("sky_phone_toggle", function() +refresh_development_command = function() + local command_name = Config.Phone.DevelopmentCommand and Config.Command or nil + if command_name ~= nil and (type(command_name) ~= "string" or command_name == "") then + error("[sky_phone] Config.Command must be a non-empty command name.") + end + + if active_development_command then + TriggerEvent("chat:removeSuggestion", "/" .. active_development_command) + end + + active_development_command = command_name + if not command_name then + return + end + + if not registered_development_commands[command_name] then + registered_development_commands[command_name] = true + RegisterCommand(command_name, function() + if active_development_command == command_name and Config.Phone.DevelopmentCommand then + run_development_command() + end + end, false) + end + + TriggerEvent("chat:addSuggestion", "/" .. command_name, locale.CommandDescription) +end + +refresh_development_command() + +local function run_phone_toggle() if is_open or open_requested then close_phone() return @@ -192,7 +263,7 @@ RegisterCommand("sky_phone_toggle", function() open_without_focus = false Bridge.Callbacks.Trigger("sky_phone:device:open-request", {}) -end, false) +end RegisterCommand("sky_phone_live_activity_open", function() if not live_activity_active or is_open or open_requested then @@ -204,7 +275,37 @@ RegisterCommand("sky_phone_live_activity_open", function() if not result or not result.success then open_home_requested = false end -end, false) +end + +RegisterCommand("sky_phone_toggle", run_phone_toggle, false) + +refresh_phone_key_mapping = function() + local key_name = Config.Phone.Keybind + if key_name ~= false and key_name ~= nil and (type(key_name) ~= "string" or key_name == "") then + error("[sky_phone] Config.Phone.Keybind must be a non-empty keyboard key name or false.") + end + if key_name == active_key_mapping_key then + return + end + + active_key_mapping_key = key_name + active_key_mapping_command = nil + if not key_name then + return + end + + key_mapping_revision = key_mapping_revision + 1 + local command_name = "sky_phone_toggle_config_" .. key_mapping_revision + active_key_mapping_command = command_name + RegisterCommand(command_name, function() + if active_key_mapping_command == command_name then + run_phone_toggle() + end + end, false) + RegisterKeyMapping(command_name, locale.Controls.OpenPhone, "keyboard", key_name) +end + +refresh_phone_key_mapping() RegisterKeyMapping( "sky_phone_live_activity_open", @@ -213,14 +314,19 @@ RegisterKeyMapping( "SPACE" ) -if Config.Phone.Keybind then - if type(Config.Phone.Keybind) ~= "string" or Config.Phone.Keybind == "" then - error("[sky_phone] Config.Phone.Keybind must be a non-empty keyboard key name or false.") +refresh_test_data_command_suggestion = function() + if suggested_test_data_command then + TriggerEvent("chat:removeSuggestion", "/" .. suggested_test_data_command) + suggested_test_data_command = nil + end + if Config.TestData.Enabled then + suggested_test_data_command = Config.TestData.Command + TriggerEvent("chat:addSuggestion", "/" .. suggested_test_data_command, locale.TestData.CommandDescription) end - - RegisterKeyMapping("sky_phone_toggle", locale.Controls.OpenPhone, "keyboard", Config.Phone.Keybind) end +refresh_test_data_command_suggestion() + RegisterNetEvent("sky_phone:testdata:feedback", function(success, detail) local test_data_locale = locale.TestData local message = success and test_data_locale.Success or test_data_locale.Failed @@ -407,19 +513,7 @@ RegisterNetEvent("sky_phone:device:error", function(error_code) end) CreateThread(function() - if Config.Phone.DevelopmentCommand then - TriggerEvent("chat:addSuggestion", "/" .. Config.Command, locale.CommandDescription) - end - if Config.TestData.Enabled then - TriggerEvent("chat:addSuggestion", "/" .. Config.TestData.Command, locale.TestData.CommandDescription) - end - if Config.AdminPanel.Enabled then - TriggerEvent( - "chat:addSuggestion", - "/" .. Config.AdminPanel.Command, - locale.AdminCommand.CommandDescription - ) - end + refresh_admin_command_suggestion() end) AddEventHandler("onResourceStop", function(resource_name) @@ -437,13 +531,13 @@ AddEventHandler("onResourceStop", function(resource_name) SkyPhoneSimPicker.Reset() SkyPhoneFocus.Reset() - if Config.Phone.DevelopmentCommand then - TriggerEvent("chat:removeSuggestion", "/" .. Config.Command) + if active_development_command then + TriggerEvent("chat:removeSuggestion", "/" .. active_development_command) end - if Config.TestData.Enabled then - TriggerEvent("chat:removeSuggestion", "/" .. Config.TestData.Command) + if suggested_test_data_command then + TriggerEvent("chat:removeSuggestion", "/" .. suggested_test_data_command) end - if Config.AdminPanel.Enabled then - TriggerEvent("chat:removeSuggestion", "/" .. Config.AdminPanel.Command) + if suggested_admin_command then + TriggerEvent("chat:removeSuggestion", "/" .. suggested_admin_command) end end) diff --git a/sky_phone/source/client/notifications.lua b/sky_phone/source/client/notifications.lua index 995cfc1..d4743b2 100644 --- a/sky_phone/source/client/notifications.lua +++ b/sky_phone/source/client/notifications.lua @@ -159,6 +159,11 @@ end) local phone_locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale) local app_locales = phone_locale.Nui.Apps +AddEventHandler("sky_phone:configurator:updated", function() + phone_locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale) + app_locales = phone_locale.Nui.Apps +end) + RegisterNetEvent("sky_phone:mail:new", function(data) local locale = app_locales.mail data.title = locale.name diff --git a/sky_phone/source/client/payphones.lua b/sky_phone/source/client/payphones.lua index 93ed5ec..5563f52 100644 --- a/sky_phone/source/client/payphones.lua +++ b/sky_phone/source/client/payphones.lua @@ -21,10 +21,16 @@ local remote_visuals = {} local custom_payphone_props = {} local configured_models = {} -for _, model_name in ipairs(Config.Payphones.Props or {}) do - configured_models[joaat(model_name)] = model_name + +local function refresh_configured_models() + configured_models = {} + for _, model_name in ipairs(Config.Payphones.Props or {}) do + configured_models[joaat(model_name)] = model_name + end end +refresh_configured_models() + local function valid_visual_number(value, maximum) local number = tonumber(value) if not number or number ~= number or math.abs(number) > maximum then @@ -156,6 +162,23 @@ end CreateThread(spawn_custom_payphones) +local function clear_custom_payphones() + for _, entity in ipairs(custom_payphone_props) do + if DoesEntityExist(entity) then + SetEntityAsMissionEntity(entity, true, true) + DeleteEntity(entity) + end + end + custom_payphone_props = {} +end + +AddEventHandler("sky_phone:configurator:updated", function() + locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale) + refresh_configured_models() + clear_custom_payphones() + CreateThread(spawn_custom_payphones) +end) + local function load_animation(dictionary) if HasAnimDictLoaded(dictionary) then return true @@ -726,10 +749,5 @@ AddEventHandler("onResourceStop", function(resource_name) for _, id in ipairs(visual_ids) do restore_remote_visual(id) end - for _, entity in ipairs(custom_payphone_props) do - if DoesEntityExist(entity) then - SetEntityAsMissionEntity(entity, true, true) - DeleteEntity(entity) - end - end + clear_custom_payphones() end) diff --git a/sky_phone/source/client/phone_configurator.lua b/sky_phone/source/client/phone_configurator.lua index cc666e0..5329bcd 100644 --- a/sky_phone/source/client/phone_configurator.lua +++ b/sky_phone/source/client/phone_configurator.lua @@ -33,6 +33,23 @@ local function deserialize_value(value) return decoded end +local function apply_runtime_table(current, replacement) + for key in pairs(current) do + if replacement[key] == nil then + current[key] = nil + end + end + + for key, value in pairs(replacement) do + local current_value = current[key] + if type(current_value) == "table" and type(value) == "table" then + apply_runtime_table(current_value, value) + else + current[key] = value + end + end +end + local function apply_runtime_config(payload) if type(payload) ~= "table" or payload.enabled ~= true then return @@ -43,8 +60,13 @@ local function apply_runtime_config(payload) local runtime_config = deserialize_value(payload.config) for key, value in pairs(runtime_config) do - Config[key] = value + if type(Config[key]) == "table" and type(value) == "table" then + apply_runtime_table(Config[key], value) + else + Config[key] = value + end end + TriggerEvent("sky_phone:configurator:updated", tonumber(payload.revision) or 0) end RegisterNetEvent("sky_phone:configurator:sync", function(payload) diff --git a/sky_phone/source/client/public_api.lua b/sky_phone/source/client/public_api.lua index 0b95e52..389a54f 100644 --- a/sky_phone/source/client/public_api.lua +++ b/sky_phone/source/client/public_api.lua @@ -44,6 +44,18 @@ local CLIENT_CAPABILITIES = { side = "client", } +local function refresh_configured_capabilities() + local enabled = Config.CustomApps.Enabled == true + local external = enabled and Config.CustomApps.ExternalApps == true + CLIENT_CAPABILITIES.features.customApps.enabled = enabled + CLIENT_CAPABILITIES.features.customApps.external = external + CLIENT_CAPABILITIES.features.notifications.customApps = external +end + +refresh_configured_capabilities() + +AddEventHandler("sky_phone:configurator:updated", refresh_configured_capabilities) + local custom_app_api = SkyPhoneApps.ClientPublicApi if type(custom_app_api) ~= "table" then error("[sky_phone] Client public API initialized before the custom app API.") diff --git a/sky_phone/source/client/radio.lua b/sky_phone/source/client/radio.lua index 0d2a338..12caf33 100644 --- a/sky_phone/source/client/radio.lua +++ b/sky_phone/source/client/radio.lua @@ -29,6 +29,10 @@ local function send_hud_config() SendNUIMessage({ type = "radio:hud-config", data = get_hud_config() }) end +AddEventHandler("sky_phone:configurator:updated", function() + send_hud_config() +end) + local function send_hud_members() local combined = {} for channel_id = 1, 2 do @@ -156,6 +160,21 @@ local function leave_radio() return request("disconnect") end +AddEventHandler("sky_phone:client:radioProviderUpdated", function() + if current_primary <= 0 then + return + end + if not Bridge.Radio.Join(current_primary, current_secondary) then + request("disconnect") + current_primary = 0 + current_secondary = 0 + clear_hud_members() + return + end + + Bridge.Radio.SetVolume(current_volume) +end) + RegisterNUICallback("radio:get", function(data, cb) if type(data) ~= "table" then cb({ success = false, error = "invalid_request" }) diff --git a/sky_phone/source/server/admin.lua b/sky_phone/source/server/admin.lua index 3147364..1923276 100644 --- a/sky_phone/source/server/admin.lua +++ b/sky_phone/source/server/admin.lua @@ -109,11 +109,9 @@ local function require_admin(source, operation, maximum) return true end -if type(Config.AdminPanel.Command) ~= "string" or Config.AdminPanel.Command == "" then - error("[sky_phone] Config.AdminPanel.Command must be a non-empty command name.") -end +local active_admin_command -RegisterCommand(Config.AdminPanel.Command, function(command_source) +local function run_admin_command(command_source) local player_source = tonumber(command_source) if not player_source or player_source < 1 then Bridge.Debug("warn", "[sky_phone] The admin panel command can only be used by a player.") @@ -134,7 +132,29 @@ RegisterCommand(Config.AdminPanel.Command, function(command_source) end TriggerClientEvent("sky_phone:admin:launch", player_source) -end, false) +end + +local function register_admin_command() + local command_name = Config.AdminPanel.Command + if type(command_name) ~= "string" or command_name == "" then + error("[sky_phone] Config.AdminPanel.Command must be a non-empty command name.") + end + if command_name == active_admin_command then + return + end + active_admin_command = command_name + RegisterCommand(command_name, function(command_source) + if active_admin_command == command_name then + run_admin_command(command_source) + end + end, false) +end + +register_admin_command() + +AddEventHandler("sky_phone:configurator:serverUpdated", function() + register_admin_command() +end) local function normalize_source(value) local player_source = tonumber(value) diff --git a/sky_phone/source/server/calls.lua b/sky_phone/source/server/calls.lua index 7d1889e..390f93b 100644 --- a/sky_phone/source/server/calls.lua +++ b/sky_phone/source/server/calls.lua @@ -1206,12 +1206,18 @@ Bridge.Callbacks.Register("sky_phone:calls:dial", function(source, data) end) local payphone_models = {} -for _, model_name in ipairs(Config.Payphones.Props or {}) do - if type(model_name) == "string" then - payphone_models[model_name] = true + +local function refresh_payphone_models() + payphone_models = {} + for _, model_name in ipairs(Config.Payphones.Props or {}) do + if type(model_name) == "string" then + payphone_models[model_name] = true + end end end +refresh_payphone_models() + if Config.Payphones.Enabled and not next(payphone_models) then Bridge.Debug( "error", @@ -1220,6 +1226,10 @@ if Config.Payphones.Enabled and not next(payphone_models) then ) end +AddEventHandler("sky_phone:configurator:serverUpdated", function() + refresh_payphone_models() +end) + local function valid_payphone_position(source, detected_booth) local ped = GetPlayerPed(source) if not ped or ped == 0 then diff --git a/sky_phone/source/server/companies.lua b/sky_phone/source/server/companies.lua index a7943f4..44cb187 100644 --- a/sky_phone/source/server/companies.lua +++ b/sky_phone/source/server/companies.lua @@ -905,9 +905,22 @@ local function cleanup_retained_data() end end -validate_configuration() -seed_companies() -tombstone_removed_companies() +local function refresh_runtime_configuration() + definitions = {} + definition_ids = {} + definitions_by_job = {} + service_lines_by_number = {} + validate_configuration() + seed_companies() + tombstone_removed_companies() +end + +refresh_runtime_configuration() + +AddEventHandler("sky_phone:configurator:serverUpdated", function() + refresh_runtime_configuration() +end) + cleanup_retained_data() CreateThread(function() diff --git a/sky_phone/source/server/crewlink.lua b/sky_phone/source/server/crewlink.lua index a765e58..1c6665c 100644 --- a/sky_phone/source/server/crewlink.lua +++ b/sky_phone/source/server/crewlink.lua @@ -1,5 +1,9 @@ Bridge.Database.AfterMigration("sky_phone", function() local password_pepper = tostring(Config.Server.CrewLinkPasswordPepper or "") +AddEventHandler("sky_phone:configurator:serverUpdated", function() + password_pepper = tostring(Config.Server.CrewLinkPasswordPepper or "") +end) + local role_levels = { guest = 1, member = 2, diff --git a/sky_phone/source/server/crypto.lua b/sky_phone/source/server/crypto.lua index adfac86..88a53c1 100644 --- a/sky_phone/source/server/crypto.lua +++ b/sky_phone/source/server/crypto.lua @@ -316,9 +316,16 @@ local function ceil_div(value, divisor) end local function initialize_markets() + local next_markets = {} + local next_market_order = {} + local next_market_dynamics = {} for _, config in ipairs(Config.Crypto.Markets) do - markets[config.Id] = config - market_order[#market_order + 1] = config.Id + if next_markets[config.Id] then + error(("[sky_phone] Config.Crypto.Markets contains duplicate id '%s'."):format(config.Id)) + end + next_markets[config.Id] = config + next_market_order[#next_market_order + 1] = config.Id + next_market_dynamics[config.Id] = market_dynamics[config.Id] Bridge.Database.Query([[ INSERT INTO `sky_phone_crypto_markets` (`id`,`asset_scale`,`price_scale`,`issued_supply`,`price`,`version`,`status`) @@ -357,6 +364,10 @@ local function initialize_markets() INSERT INTO `sky_phone_crypto_balances` (`account_id`,`asset_id`,`available`) VALUES ('treasury', 'CASH', ?) ON DUPLICATE KEY UPDATE `account_id` = VALUES(`account_id`) ]], { Config.Crypto.TreasuryCash * Config.Crypto.PriceScale }) + markets = next_markets + market_order = next_market_order + market_dynamics = next_market_dynamics + market_cursor = math.min(market_cursor, math.max(#market_order, 1)) end local function require_phone(source) @@ -1357,6 +1368,8 @@ ensure_schema() migrate_crypto_keys() initialize_markets() +AddEventHandler("sky_phone:configurator:serverUpdated", initialize_markets) + local function reconcile_settlements(include_recent) local age_clause = include_recent and "" or " AND settlement.`updated_at` < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)" local rows = Bridge.Database.Query([[ diff --git a/sky_phone/source/server/custom_app_storage.lua b/sky_phone/source/server/custom_app_storage.lua index c777e39..5346f44 100644 --- a/sky_phone/source/server/custom_app_storage.lua +++ b/sky_phone/source/server/custom_app_storage.lua @@ -1,8 +1,6 @@ Bridge.Database.AfterMigration("sky_phone", function() local STORAGE_JSON_MAX_DEPTH = 8 local STORAGE_JSON_MAX_NODES = 512 -local STORAGE_KEY_MAX_LENGTH = math.min(Config.CustomApps.MaximumStorageKeyLength, 64) -local STORAGE_VALUE_MAX_BYTES = math.min(Config.CustomApps.MaximumStorageValueBytes, 65536) local storage_write_locks = {} local function affected_rows(result) @@ -27,7 +25,7 @@ local function validate_storage_json(value, depth, state) return value == value and value ~= math.huge and value ~= -math.huge end if value_type == "string" then - return #value <= STORAGE_VALUE_MAX_BYTES + return #value <= math.min(Config.CustomApps.MaximumStorageValueBytes, 65536) end if value_type ~= "table" or state.seen[value] then return false @@ -120,7 +118,7 @@ local function validate_request(source, data, operation) end if type(data.key) ~= "string" or #data.key == 0 - or #data.key > STORAGE_KEY_MAX_LENGTH + or #data.key > math.min(Config.CustomApps.MaximumStorageKeyLength, 64) or not data.key:match("^[%w._-]+$") then return nil, nil, { success = false, error = "invalid_storage_key" } @@ -205,7 +203,7 @@ Bridge.Callbacks.Register("sky_phone:custom-app:storage:set", function(source, d local encoded, payload = pcall(json.encode, request.value) if not encoded or type(payload) ~= "string" - or #payload > STORAGE_VALUE_MAX_BYTES + or #payload > math.min(Config.CustomApps.MaximumStorageValueBytes, 65536) then return { success = false, error = "storage_value_too_large" } end diff --git a/sky_phone/source/server/custom_apps.lua b/sky_phone/source/server/custom_apps.lua index eac1bca..3319dbb 100644 --- a/sky_phone/source/server/custom_apps.lua +++ b/sky_phone/source/server/custom_apps.lua @@ -474,26 +474,44 @@ SkyPhoneApps.ServerPublicApi = { UpdateCustomAppPolicyFromAdapter = update_custom_app_policy_from_adapter, } -if Config.CustomApps.Enabled and Config.CustomApps.BundledApps then - local bundled = SkyPhoneApps.GetBundledManifests() - for index = 1, #bundled do - local manifest = bundled[index] - local registered, register_error = register_policy({ - adapterResource = nil, - bundled = true, - id = manifest.id, - ownerResource = current_resource, - permissions = build_permission_set(manifest.permissions), - }) - if not registered then - error(("[sky_phone] Could not register bundled custom app policy '%s': %s"):format( - manifest.id, - register_error - )) +local function refresh_bundled_policies() + local removals = {} + for app_id, policy in pairs(policies_by_id) do + if policy.bundled then + removals[#removals + 1] = app_id + end + end + for _, app_id in ipairs(removals) do + remove_policy(app_id) + end + + if Config.CustomApps.Enabled and Config.CustomApps.BundledApps then + local bundled = SkyPhoneApps.GetBundledManifests() + for index = 1, #bundled do + local manifest = bundled[index] + local registered, register_error = register_policy({ + adapterResource = nil, + bundled = true, + id = manifest.id, + ownerResource = current_resource, + permissions = build_permission_set(manifest.permissions), + }) + if not registered then + error(("[sky_phone] Could not register bundled custom app policy '%s': %s"):format( + manifest.id, + register_error + )) + end end end end +refresh_bundled_policies() + +AddEventHandler("sky_phone:configurator:serverUpdated", function() + refresh_bundled_policies() +end) + AddEventHandler("onResourceStop", function(resource_name) if resource_name == current_resource then return diff --git a/sky_phone/source/server/fliptok.lua b/sky_phone/source/server/fliptok.lua index 35b5762..7b53db7 100644 --- a/sky_phone/source/server/fliptok.lua +++ b/sky_phone/source/server/fliptok.lua @@ -14,7 +14,27 @@ local custom_music_extensions = { wav = true, webm = true, } -local password_pepper = tostring(Config.Server.FlipTokPasswordPepper or "") +local password_pepper = "" + +local function refresh_runtime_configuration() + password_pepper = tostring(Config.Server.FlipTokPasswordPepper or "") + music_tracks = {} + music_track_list = {} + for _, track in ipairs(Config.FlipTok.MusicTracks) do + local id = tostring(track.Id or track.id or "") + local title = tostring(track.Title or track.title or "") + local artist = tostring(track.Artist or track.artist or "") + local url = tostring(track.Url or track.url or "") + if id == "" or title == "" or artist == "" or url == "" then + error("[sky_phone] Every configured FlipTok music track requires Id, Title, Artist, and Url.") + end + local item = { id = id, title = title, artist = artist, url = url } + music_tracks[id] = item + music_track_list[#music_track_list + 1] = item + end +end + +refresh_runtime_configuration() if password_pepper == "" then Bridge.Debug( @@ -24,18 +44,9 @@ if password_pepper == "" then ) end -for _, track in ipairs(Config.FlipTok.MusicTracks) do - local id = tostring(track.Id or track.id or "") - local title = tostring(track.Title or track.title or "") - local artist = tostring(track.Artist or track.artist or "") - local url = tostring(track.Url or track.url or "") - if id == "" or title == "" or artist == "" or url == "" then - error("[sky_phone] Every configured FlipTok music track requires Id, Title, Artist, and Url.") - end - local item = { id = id, title = title, artist = artist, url = url } - music_tracks[id] = item - music_track_list[#music_track_list + 1] = item -end +AddEventHandler("sky_phone:configurator:serverUpdated", function() + refresh_runtime_configuration() +end) local function trim(value) if type(value) ~= "string" then return nil end @@ -913,7 +924,10 @@ Bridge.Callbacks.Register("sky_phone:fliptok:delete", function(source, data) return affected > 0 and { success = true } or { success = false, error = "video_not_found" } end) -RegisterCommand(Config.FlipTok.VerifyCommand, function(source, arguments) +local active_verify_command = nil +local registered_verify_commands = {} + +local function run_verify_command(source, arguments) local command_locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale).FlipTokCommand local function command_message(template, values) return template:gsub("{(%w+)}", function(key) return values[key] or "" end) @@ -973,5 +987,26 @@ RegisterCommand(Config.FlipTok.VerifyCommand, function(source, arguments) state = verified and command_locale.verified or command_locale.unverified, }) send_command_feedback(message, "success") -end, false) +end + +local function refresh_verify_command() + local command_name = Config.FlipTok.VerifyCommand + if type(command_name) ~= "string" or command_name == "" then + error("[sky_phone] Config.FlipTok.VerifyCommand must be a non-empty command name.") + end + active_verify_command = command_name + if registered_verify_commands[command_name] then + return + end + registered_verify_commands[command_name] = true + RegisterCommand(command_name, function(source, arguments) + if active_verify_command == command_name then + run_verify_command(source, arguments) + end + end, false) +end + +refresh_verify_command() + +AddEventHandler("sky_phone:configurator:serverUpdated", refresh_verify_command) end) diff --git a/sky_phone/source/server/marketplace.lua b/sky_phone/source/server/marketplace.lua index 3a39749..2a2a8f8 100644 --- a/sky_phone/source/server/marketplace.lua +++ b/sky_phone/source/server/marketplace.lua @@ -8,13 +8,23 @@ local offer_responses = { accepted = true, rejected = true } local public_statuses = { active = true, reserved = true } local seller_statuses = { active = true, reserved = true, sold = true, removed = true } -for _, category in ipairs(Config.Marketplace.Categories) do - categories[category] = true -end -for _, district in ipairs(Config.Marketplace.Districts) do - districts[district] = true +local function refresh_runtime_configuration() + categories = {} + districts = {} + for _, category in ipairs(Config.Marketplace.Categories) do + categories[category] = true + end + for _, district in ipairs(Config.Marketplace.Districts) do + districts[district] = true + end end +refresh_runtime_configuration() + +AddEventHandler("sky_phone:configurator:serverUpdated", function() + refresh_runtime_configuration() +end) + local function trim(value) if type(value) ~= "string" then return nil diff --git a/sky_phone/source/server/media_import.lua b/sky_phone/source/server/media_import.lua index ec52e79..2ba40c9 100644 --- a/sky_phone/source/server/media_import.lua +++ b/sky_phone/source/server/media_import.lua @@ -226,14 +226,18 @@ local function validate_website(definition) return nil, "invalid_required_ace" end - definition._adapter = adapter - definition._media_types = allowed_media_types - local valid, validation_error = adapter.Validate(definition) + local website = {} + for key, value in pairs(definition) do + website[key] = value + end + website._adapter = adapter + website._media_types = allowed_media_types + local valid, validation_error = adapter.Validate(website) if not valid then return nil, validation_error end - return definition + return website end local function build_registry() @@ -255,7 +259,7 @@ local function build_registry() if website_error == "missing_api_key" then Bridge.Debug( "warn", - "[sky_phone] Media import source '%s' at index %s is disabled because Config.Media.FiveManage.ApiKey is empty in server-only config/media.lua. Add a FiveManage V3 token with Media access and restart sky_phone.", + "[sky_phone] Media import source '%s' at index %s is disabled because Config.Media.FiveManage.ApiKey is empty. Add a FiveManage V3 token with Media access in the Phone Configurator and save it.", tostring(source_name or "unknown"), tostring(index) ) @@ -756,3 +760,9 @@ function SkyPhoneMediaImport.Initialize() import_candidates[source] = nil end) end + +AddEventHandler("sky_phone:configurator:serverUpdated", function() + if initialized then + build_registry() + end +end) diff --git a/sky_phone/source/server/music.lua b/sky_phone/source/server/music.lua index 040e372..a409b99 100644 --- a/sky_phone/source/server/music.lua +++ b/sky_phone/source/server/music.lua @@ -218,6 +218,10 @@ local function normalize_server_tracks() return end + server_tracks = {} + server_tracks_by_id = {} + configured_track_ids = {} + for index, configured in ipairs(Config.Music.Tracks or {}) do local id = type(configured) == "table" and trim(configured.Id) or nil local title = type(configured) == "table" and trim(configured.Title) or nil @@ -300,6 +304,8 @@ end normalize_server_tracks() +AddEventHandler("sky_phone:configurator:serverUpdated", normalize_server_tracks) + local function session_owner(source) local session, error_response = SkyPhone.RequireSession(source) if not session then diff --git a/sky_phone/source/server/pages.lua b/sky_phone/source/server/pages.lua index e3ac471..96f124f 100644 --- a/sky_phone/source/server/pages.lua +++ b/sky_phone/source/server/pages.lua @@ -1,8 +1,23 @@ Bridge.Database.AfterMigration("sky_phone", function() local categories = {} local districts = {} -for _, value in ipairs(Config.LocalPages.Categories) do categories[value] = true end -for _, value in ipairs(Config.Marketplace.Districts) do districts[value] = true end + +local function refresh_runtime_configuration() + categories = {} + districts = {} + for _, value in ipairs(Config.LocalPages.Categories) do + categories[value] = true + end + for _, value in ipairs(Config.Marketplace.Districts) do + districts[value] = true + end +end + +refresh_runtime_configuration() + +AddEventHandler("sky_phone:configurator:serverUpdated", function() + refresh_runtime_configuration() +end) local function trim(value) if type(value) ~= "string" then return nil end diff --git a/sky_phone/source/server/phone.lua b/sky_phone/source/server/phone.lua index 147fbad..a5c5ab6 100644 --- a/sky_phone/source/server/phone.lua +++ b/sky_phone/source/server/phone.lua @@ -52,8 +52,6 @@ Bridge.Debug("debug", "[sky_phone] Server initialization started after database SkyPhone = {} -local unique_phones = Config.Phone.Unique ~= false -local sim_cards_enabled = Config.Sim.Enabled ~= false local sessions = {} local preferred_device_imeis = {} local equipped_phone_numbers = {} @@ -275,7 +273,7 @@ end local function find_device_slots(source, imei) local matches = {} local slots = Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item) - if not unique_phones then + if Config.Phone.Unique == false then if not slots[1] then return matches end @@ -366,7 +364,7 @@ local function resolve_used_slot(source, used_item) ) end - if not unique_phones and #slots > 0 then + if Config.Phone.Unique == false and #slots > 0 then return slots[1] end @@ -413,7 +411,7 @@ local function ensure_device(source, slot) tostring(Bridge.Inventory.GetResourceName()), { always = true } ) - if not unique_phones then + if Config.Phone.Unique == false then if amount < 1 then return nil, "phone_slot_missing" end @@ -554,7 +552,7 @@ local function bootstrap(source, security, security_loaded) sim = device.sim_id and { id = device.sim_id, number = device.phone_number, - removable = sim_cards_enabled and tonumber(device.sim_is_virtual) ~= 1, + removable = Config.Sim.Enabled ~= false and tonumber(device.sim_is_virtual) ~= 1, type = device.sim_type, registered = device.registered_at ~= nil, } or nil, @@ -609,7 +607,7 @@ local function resolve_equipped_phone_number(source) end local imei - if unique_phones then + if Config.Phone.Unique ~= false then table.sort(slots, function(left, right) local left_slot = tonumber(left.slot) local right_slot = tonumber(right.slot) @@ -795,7 +793,7 @@ function SkyPhone.NotifyAccountDevices(account_id, event_name, data) for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do local source = tonumber(player_source) or player_source - if not unique_phones then + if Config.Phone.Unique == false then if Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)[1] then local imei = load_character_device(source) local device = imei and devices[imei] or nil @@ -936,26 +934,38 @@ function SkyPhone.OpenDeviceForCall(source, imei) return true end -Bridge.Debug( - "debug", - "[sky_phone] Registering usable item '%s' through inventory '%s'.", - Config.Phone.Item, - tostring(Bridge.Inventory.GetResourceName()), - { always = true } -) -local usable_registered = Bridge.Inventory.RegisterUsableItem(Config.Phone.Item, open_phone) -if not usable_registered then - error(("[sky_phone] Inventory '%s' did not register phone item '%s' as usable."):format( +local registered_usable_items = {} + +local function register_configured_phone_item() + local item_name = Config.Phone.Item + if registered_usable_items[item_name] then + return + end + Bridge.Debug( + "debug", + "[sky_phone] Registering usable item '%s' through inventory '%s'.", + item_name, tostring(Bridge.Inventory.GetResourceName()), - tostring(Config.Phone.Item) - )) + { always = true } + ) + if not Bridge.Inventory.RegisterUsableItem(item_name, function(...) + if Config.Phone.Item == item_name then + open_phone(...) + end + end) then + error(("[sky_phone] Inventory '%s' did not register phone item '%s' as usable."):format( + tostring(Bridge.Inventory.GetResourceName()), + tostring(item_name) + )) + end + registered_usable_items[item_name] = true end -Bridge.Debug( - "debug", - "[sky_phone] Usable item registration returned: %s.", - tostring(usable_registered), - { always = true } -) + +register_configured_phone_item() + +AddEventHandler("sky_phone:configurator:serverUpdated", function() + register_configured_phone_item() +end) Bridge.Callbacks.Register("sky_phone:device:close", function(source) sessions[source] = nil diff --git a/sky_phone/source/server/phone_configurator.lua b/sky_phone/source/server/phone_configurator.lua index 5348ae7..4d600c5 100644 --- a/sky_phone/source/server/phone_configurator.lua +++ b/sky_phone/source/server/phone_configurator.lua @@ -183,6 +183,23 @@ local function deserialize_value(value) return decoded end +local function apply_runtime_table(current, replacement) + for key in pairs(current) do + if replacement[key] == nil then + current[key] = nil + end + end + + for key, value in pairs(replacement) do + local current_value = current[key] + if type(current_value) == "table" and type(value) == "table" then + apply_runtime_table(current_value, value) + else + current[key] = value + end + end +end + is_sequence = function(value) if type(value) ~= "table" then return false @@ -339,9 +356,18 @@ local function apply_runtime_configuration() local runtime_config = deserialize_value(stored_config) for key, value in pairs(runtime_config) do - Config[key] = value + if type(Config[key]) == "table" and type(value) == "table" then + apply_runtime_table(Config[key], value) + else + Config[key] = value + end + end + local runtime_media = deserialize_value(stored_media) + if type(Config.Media) == "table" and type(runtime_media) == "table" then + apply_runtime_table(Config.Media, runtime_media) + else + Config.Media = runtime_media end - Config.Media = deserialize_value(stored_media) end local function humanize(value) @@ -983,6 +1009,36 @@ local function client_payload() return payload end +function SkyPhoneConfigurator.Broadcast(target) + TriggerClientEvent("sky_phone:configurator:sync", target or -1, { + config = client_payload(), + enabled = configurator_enabled, + revision = revision, + }) +end + +local function read_stored_row() + local rows = Bridge.Database.Query(([[ + SELECT `config_payload`, `media_payload`, `revision`, `updated_at`, `updated_by_name` + FROM `%s` + WHERE `id` = ? + LIMIT 1 + ]]):format(TABLE_NAME), { CONFIG_ROW_ID }) + local row = rows[1] + if not row then + error("[sky_phone] Phone configurator could not load its SQL row.") + end + return row +end + +local function apply_stored_row(row) + stored_config = merge_values(default_config, decode_payload(row.config_payload, "config"), "") + stored_media = merge_values(default_media, decode_payload(row.media_payload, "media"), "") + revision = tonumber(row.revision) or 1 + updated_at = row.updated_at + updated_by_name = row.updated_by_name +end + default_config = {} for key, value in pairs(Config) do if key ~= "Media" and key ~= "PhoneConfigurator" then @@ -1001,22 +1057,7 @@ Bridge.Database.Query(([[ encode_payload(default_media, "media"), }) -local rows = Bridge.Database.Query(([[ - SELECT `config_payload`, `media_payload`, `revision`, `updated_at`, `updated_by_name` - FROM `%s` - WHERE `id` = ? - LIMIT 1 -]]):format(TABLE_NAME), { CONFIG_ROW_ID }) -local row = rows[1] -if not row then - error("[sky_phone] Phone configurator could not load its SQL row after initialization.") -end - -stored_config = merge_values(default_config, decode_payload(row.config_payload, "config"), "") -stored_media = merge_values(default_media, decode_payload(row.media_payload, "media"), "") -revision = tonumber(row.revision) or 1 -updated_at = row.updated_at -updated_by_name = row.updated_by_name +apply_stored_row(read_stored_row()) apply_runtime_configuration() function SkyPhoneConfigurator.GetAdminData() @@ -1090,17 +1131,24 @@ function SkyPhoneConfigurator.Save(expected_revision, changes, actor_identifier, return { success = false, error = "revision_conflict", data = SkyPhoneConfigurator.GetAdminData() } end - stored_config = next_config - stored_media = next_media - revision = revision + 1 - updated_at = os.date("!%Y-%m-%d %H:%M:%S") - updated_by_name = tostring(actor_name or ""):sub(1, 120) + local expected_revision = revision + 1 + local persisted_row = read_stored_row() + if persisted_row.config_payload ~= config_encoded + or persisted_row.media_payload ~= media_encoded + or tonumber(persisted_row.revision) ~= expected_revision + then + error("[sky_phone] Phone configurator SQL verification failed after saving config and media payloads.") + end + apply_stored_row(persisted_row) apply_runtime_configuration() - TriggerClientEvent("sky_phone:configurator:sync", -1, { - config = client_payload(), - enabled = true, - revision = revision, - }) + TriggerEvent("sky_phone:configurator:serverUpdated", revision) + SkyPhoneConfigurator.Broadcast(-1) + Bridge.Debug( + "info", + "[sky_phone] Applied Phone Configurator revision %s through the internal runtime refresh.", + tostring(revision), + { always = true } + ) return { success = true, data = SkyPhoneConfigurator.GetAdminData() } end diff --git a/sky_phone/source/server/phone_security.lua b/sky_phone/source/server/phone_security.lua index f18a414..9e54b9e 100644 --- a/sky_phone/source/server/phone_security.lua +++ b/sky_phone/source/server/phone_security.lua @@ -2,6 +2,10 @@ Bridge.Database.AfterMigration("sky_phone", function() SkyPhoneSecurity = {} local passcode_pepper = tostring(Config.Server.PasscodePepper or "") +AddEventHandler("sky_phone:configurator:serverUpdated", function() + passcode_pepper = tostring(Config.Server.PasscodePepper or "") +end) + if passcode_pepper == "" then Bridge.Debug( "warn", diff --git a/sky_phone/source/server/picstagram.lua b/sky_phone/source/server/picstagram.lua index 9740d31..a496c5f 100644 --- a/sky_phone/source/server/picstagram.lua +++ b/sky_phone/source/server/picstagram.lua @@ -1,6 +1,16 @@ Bridge.Database.AfterMigration("sky_phone", function() local report_reasons = {} -local password_pepper = tostring(Config.Server.PicstagramPasswordPepper or "") +local password_pepper = "" + +local function refresh_runtime_configuration() + password_pepper = tostring(Config.Server.PicstagramPasswordPepper or "") + report_reasons = {} + for index = 1, #Config.Picstagram.ReportReasons do + report_reasons[Config.Picstagram.ReportReasons[index]] = true + end +end + +refresh_runtime_configuration() if password_pepper == "" then Bridge.Debug( @@ -10,9 +20,9 @@ if password_pepper == "" then ) end -for index = 1, #Config.Picstagram.ReportReasons do - report_reasons[Config.Picstagram.ReportReasons[index]] = true -end +AddEventHandler("sky_phone:configurator:serverUpdated", function() + refresh_runtime_configuration() +end) local function trim(value) if type(value) ~= "string" then @@ -1434,7 +1444,10 @@ Bridge.Callbacks.Register("sky_phone:picstagram:admin-resolve-report", function( return { success = true } end) -RegisterCommand(Config.Picstagram.VerifyCommand, function(source, args) +local active_verify_command = nil +local registered_verify_commands = {} + +local function run_verify_command(source, args) local command_locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale).PicstagramCommand local function command_message(template, values) return template:gsub("{(%w+)}", function(key) @@ -1489,5 +1502,26 @@ RegisterCommand(Config.Picstagram.VerifyCommand, function(source, args) handle = handle, state = state == 1 and command_locale.verified or command_locale.unverified, }), "success") -end, false) +end + +local function refresh_verify_command() + local command_name = Config.Picstagram.VerifyCommand + if type(command_name) ~= "string" or command_name == "" then + error("[sky_phone] Config.Picstagram.VerifyCommand must be a non-empty command name.") + end + active_verify_command = command_name + if registered_verify_commands[command_name] then + return + end + registered_verify_commands[command_name] = true + RegisterCommand(command_name, function(source, args) + if active_verify_command == command_name then + run_verify_command(source, args) + end + end, false) +end + +refresh_verify_command() + +AddEventHandler("sky_phone:configurator:serverUpdated", refresh_verify_command) end) diff --git a/sky_phone/source/server/public_api.lua b/sky_phone/source/server/public_api.lua index 29659e7..0f3099a 100644 --- a/sky_phone/source/server/public_api.lua +++ b/sky_phone/source/server/public_api.lua @@ -25,6 +25,18 @@ local SERVER_CAPABILITIES = { side = "server", } +local function refresh_configured_capabilities() + local enabled = Config.CustomApps.Enabled == true + local external = enabled and Config.CustomApps.ExternalApps == true + SERVER_CAPABILITIES.features.customApps.enabled = enabled + SERVER_CAPABILITIES.features.customApps.external = external + SERVER_CAPABILITIES.features.notifications.customApps = external +end + +refresh_configured_capabilities() + +AddEventHandler("sky_phone:configurator:serverUpdated", refresh_configured_capabilities) + local custom_app_api = SkyPhoneApps.ServerPublicApi if type(custom_app_api) ~= "table" then error("[sky_phone] Server public API initialized before the custom app policy API.") diff --git a/sky_phone/source/server/sim.lua b/sky_phone/source/server/sim.lua index fbc0b4d..e13d740 100644 --- a/sky_phone/source/server/sim.lua +++ b/sky_phone/source/server/sim.lua @@ -2,14 +2,18 @@ Bridge.Database.AfterMigration("sky_phone", function() SkyPhoneSim = {} -local unique_phones = Config.Phone.Unique ~= false -local sim_cards_enabled = Config.Sim.Enabled ~= false local pending_insertions = {} local operation_locks = {} -local sim_types = { - [Config.Sim.RegisteredItem] = "registered", - [Config.Sim.AnonymousItem] = "anonymous", -} +local sim_types = {} + +local function refresh_sim_types() + sim_types = { + [Config.Sim.RegisteredItem] = "registered", + [Config.Sim.AnonymousItem] = "anonymous", + } +end + +refresh_sim_types() local function affected_rows(result) if type(result) == "number" then @@ -68,7 +72,7 @@ local function sim_metadata(sim) end local function set_phone_sim_metadata(source, phone_slot, sim) - if not unique_phones then + if Config.Phone.Unique == false then return true end if not phone_slot then @@ -94,7 +98,7 @@ local function prepare_device(source, phone_slot, imei) end local sim = device.sim_id and load_sim(device.sim_id) or nil - if sim_cards_enabled then + if Config.Sim.Enabled ~= false then if sim and tonumber(sim.is_virtual) == 1 then local result = Bridge.Database.Query([[ UPDATE `sky_phone_devices` @@ -176,7 +180,7 @@ function SkyPhoneSim.ChangeNumber(source, imei, sim_id, value) end local phone_slot - if unique_phones then + if Config.Phone.Unique ~= false then for _, slot in ipairs(Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)) do if slot.metadata and slot.metadata.imei == imei then phone_slot = slot @@ -300,7 +304,7 @@ local function rollback_phone_metadata(source, phone_slot, old_sim) end local function insert_sim(source, phone_imei, confirmed) - if not sim_cards_enabled then + if Config.Sim.Enabled == false then return { success = false, error = "disabled" } end if operation_locks[source] then @@ -404,7 +408,7 @@ local function insert_sim(source, phone_imei, confirmed) end local function use_sim(source, used_item) - if not sim_cards_enabled then + if Config.Sim.Enabled == false then return false end local item_name = used_item and used_item.name @@ -455,11 +459,26 @@ end -- Register the guarded callbacks in both modes so a resource-only restart can -- replace registrations left behind in framework-owned usable-item tables. -Bridge.Inventory.RegisterUsableItem(Config.Sim.RegisteredItem, use_sim) -Bridge.Inventory.RegisterUsableItem(Config.Sim.AnonymousItem, use_sim) +local registered_sim_items = {} + +local function register_configured_sim_items() + for item_name in pairs(sim_types) do + if not registered_sim_items[item_name] then + local registered_item_name = item_name + Bridge.Inventory.RegisterUsableItem(registered_item_name, function(...) + if Config.Sim.Enabled ~= false and sim_types[registered_item_name] then + use_sim(...) + end + end) + registered_sim_items[registered_item_name] = true + end + end +end + +register_configured_sim_items() Bridge.Callbacks.Register("sky_phone:sim:insert", function(source, data) - if not sim_cards_enabled then + if Config.Sim.Enabled == false then return { success = false, error = "disabled" } end if type(data) ~= "table" or not SkyPhoneImei.IsValid(data.imei) then @@ -477,7 +496,7 @@ Bridge.Callbacks.Register("sky_phone:sim:picker-close", function(source) end) Bridge.Callbacks.Register("sky_phone:sim:eject", function(source) - if not sim_cards_enabled then + if Config.Sim.Enabled == false then return { success = false, error = "disabled" } end if operation_locks[source] then @@ -530,7 +549,12 @@ AddEventHandler("playerDropped", function() operation_locks[source] = nil end) -if sim_cards_enabled then +AddEventHandler("sky_phone:configurator:serverUpdated", function() + refresh_sim_types() + register_configured_sim_items() +end) + +if Config.Sim.Enabled ~= false then Bridge.Database.Query([[ UPDATE `sky_phone_devices` d INNER JOIN `sky_phone_sims` s ON s.`id` = d.`sim_id` diff --git a/sky_phone/source/server/skyride.lua b/sky_phone/source/server/skyride.lua index a7373ea..d1f3c2f 100644 --- a/sky_phone/source/server/skyride.lua +++ b/sky_phone/source/server/skyride.lua @@ -11,20 +11,30 @@ local online_drivers = {} local operation_locks = {} local services = {} -if Config.SkyRide.DistanceUnit ~= "kilometer" and Config.SkyRide.DistanceUnit ~= "mile" then - error(("[sky_phone] Invalid SkyRide distance unit '%s'."):format(tostring(Config.SkyRide.DistanceUnit))) +local function refresh_runtime_configuration() + if Config.SkyRide.DistanceUnit ~= "kilometer" and Config.SkyRide.DistanceUnit ~= "mile" then + error(("[sky_phone] Invalid SkyRide distance unit '%s'."):format(tostring(Config.SkyRide.DistanceUnit))) + end + + local next_services = {} + for index = 1, #Config.SkyRide.Services do + local service = Config.SkyRide.Services[index] + if service.Id ~= "taxi" and service.Id ~= "comfort" and service.Id ~= "xl" and service.Id ~= "premium" then + error(("[sky_phone] Invalid SkyRide service class '%s'."):format(tostring(service.Id))) + end + if next_services[service.Id] then + error(("[sky_phone] Duplicate SkyRide service class '%s'."):format(service.Id)) + end + next_services[service.Id] = service + end + services = next_services end -for index = 1, #Config.SkyRide.Services do - local service = Config.SkyRide.Services[index] - if service.Id ~= "taxi" and service.Id ~= "comfort" and service.Id ~= "xl" and service.Id ~= "premium" then - error(("[sky_phone] Invalid SkyRide service class '%s'."):format(tostring(service.Id))) - end - if services[service.Id] then - error(("[sky_phone] Duplicate SkyRide service class '%s'."):format(service.Id)) - end - services[service.Id] = service -end +refresh_runtime_configuration() + +AddEventHandler("sky_phone:configurator:serverUpdated", function() + refresh_runtime_configuration() +end) local ride_select = [[ SELECT r.*, diff --git a/sky_phone/source/server/testdata.lua b/sky_phone/source/server/testdata.lua index 657f68f..edd857a 100644 --- a/sky_phone/source/server/testdata.lua +++ b/sky_phone/source/server/testdata.lua @@ -1,9 +1,5 @@ Bridge.Database.AfterMigration("sky_phone", function() -if not Config.TestData.Enabled then - return -end - local photo_urls = { city = "https://images.unsplash.com/photo-1519501025264-65ba15a82390?auto=format&fit=crop&w=1200&q=80", car = "https://images.unsplash.com/photo-1493238792000-8113da705763?auto=format&fit=crop&w=1200&q=80", @@ -1041,7 +1037,10 @@ local function seed_for_source(source) return account.email end -RegisterCommand(Config.TestData.Command, function(source) +local function run_test_data_command(source) + if not Config.TestData.Enabled then + return + end if source <= 0 then Bridge.Debug("warn", "[sky_phone] The test data command must be run by an in-game player.") return @@ -1066,7 +1065,34 @@ RegisterCommand(Config.TestData.Command, function(source) end Bridge.Debug("info", "[sky_phone] Test data seeded for source %s.", tostring(source), { always = true }) TriggerClientEvent("sky_phone:testdata:feedback", source, true, result) -end, false) +end + +local active_test_data_command = nil +local registered_test_data_commands = {} + +local function refresh_test_data_command() + active_test_data_command = Config.TestData.Enabled and Config.TestData.Command or nil + if not active_test_data_command then + return + end + if type(active_test_data_command) ~= "string" or active_test_data_command == "" then + error("[sky_phone] Config.TestData.Command must be a non-empty command name.") + end + if registered_test_data_commands[active_test_data_command] then + return + end + local command_name = active_test_data_command + registered_test_data_commands[command_name] = true + RegisterCommand(command_name, function(source) + if active_test_data_command == command_name then + run_test_data_command(source) + end + end, false) +end + +refresh_test_data_command() + +AddEventHandler("sky_phone:configurator:serverUpdated", refresh_test_data_command) AddEventHandler("playerDropped", function() seed_attempts[source] = nil diff --git a/sky_phone/source/server/weazel_news.lua b/sky_phone/source/server/weazel_news.lua index bfde62e..9350e1b 100644 --- a/sky_phone/source/server/weazel_news.lua +++ b/sky_phone/source/server/weazel_news.lua @@ -55,25 +55,36 @@ if type(config.Categories) ~= "table" then end local categories = {} -for _, category in ipairs(config.Categories or {}) do - if type(category) ~= "string" or #category < 1 or #category > 32 - or not category:match("^[%l_]+$") or categories[category] - then - error(("[sky_phone] Invalid Weazel News category '%s'."):format(tostring(category))) + +local function refresh_categories() + local next_categories = {} + for _, category in ipairs(config.Categories or {}) do + if type(category) ~= "string" or #category < 1 or #category > 32 + or not category:match("^[%l_]+$") or next_categories[category] + then + error(("[sky_phone] Invalid Weazel News category '%s'."):format(tostring(category))) + end + next_categories[category] = true end - categories[category] = true -end -for category in pairs(supported_categories) do - if not categories[category] then - error(("[sky_phone] Config.WeazelNews.Categories is missing supported category '%s'."):format(category)) + for category in pairs(supported_categories) do + if not next_categories[category] then + error(("[sky_phone] Config.WeazelNews.Categories is missing supported category '%s'."):format(category)) + end end -end -for category in pairs(categories) do - if not supported_categories[category] then - error(("[sky_phone] Config.WeazelNews.Categories contains unsupported category '%s'."):format(category)) + for category in pairs(next_categories) do + if not supported_categories[category] then + error(("[sky_phone] Config.WeazelNews.Categories contains unsupported category '%s'."):format(category)) + end end + categories = next_categories end +refresh_categories() + +AddEventHandler("sky_phone:configurator:serverUpdated", function() + refresh_categories() +end) + if type(config.AllowedJobs) ~= "table" then error("[sky_phone] Config.WeazelNews.AllowedJobs must be a table.") end