FIX - apply configurator settings instantly

This commit is contained in:
Leon.Schmidt
2026-08-21 16:48:54 +02:00
parent 1949a37006
commit 7df62153a2
44 changed files with 969 additions and 252 deletions
+3 -2
View File
@@ -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
@@ -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,
}"
>
<header class="config-structured-editor__bar">
<header v-if="!usesFixedTableLayout" class="config-structured-editor__bar">
<span>
<TableProperties :size="14" />
{{
@@ -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 {
@@ -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('<AdminConfigValueEditor')
expect(source).toContain('function configuratorFieldRepeatsSection(')
expect(source).toContain('v-if="!configuratorFieldRepeatsSection(field)"')
expect(source).toContain('class="admin-panel-config-scopes"')
expect(source).toContain("selectConfiguratorScope('config')")
expect(source).toContain("selectConfiguratorScope('media')")
@@ -317,6 +380,16 @@ describe('standalone admin panel contracts', () => {
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(')
+17 -3
View File
@@ -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(() => {
<Save :size="18" />
<div>
<strong>{{ t('configurator.manualSave') }}</strong>
<p>{{ t('configurator.restartNotice') }}</p>
<p>{{ t('configurator.refreshNotice') }}</p>
</div>
</article>
@@ -1621,7 +1631,10 @@ onBeforeUnmount(() => {
),
}"
>
<span class="admin-panel-config-field__copy">
<span
v-if="!configuratorFieldRepeatsSection(field)"
class="admin-panel-config-field__copy"
>
<strong>{{ field.label }}</strong>
<small
:title="
@@ -2477,6 +2490,7 @@ onBeforeUnmount(() => {
--admin-dim: #555b55;
--admin-green: var(--admin-accent, #74d66f);
--admin-green-soft: color-mix(in srgb, var(--admin-green) 9%, transparent);
--admin-toggle-on: #63d471;
--admin-row-hover: linear-gradient(
90deg,
#1a1c1b 0%,
@@ -4302,7 +4316,7 @@ button:disabled {
}
.admin-panel-config-toggle input:checked + i {
background: color-mix(in srgb, var(--admin-green) 72%, #1f321f);
background: var(--admin-toggle-on);
}
.admin-panel-config-toggle input:checked + i::after {
+31 -4
View File
@@ -34,9 +34,12 @@ describe('phone inventory contracts', () => {
const phoneServer = readResourceFile('source/server/phone.lua')
expect(phoneServer).toContain(
'Bridge.Inventory.RegisterUsableItem(Config.Phone.Item, open_phone)',
'Bridge.Inventory.RegisterUsableItem(item_name, function(...)',
)
expect(phoneServer).toContain('if Config.Phone.Item == item_name then')
expect(phoneServer).toContain(
'if not Bridge.Inventory.RegisterUsableItem(item_name, function(...)',
)
expect(phoneServer).toContain('if not usable_registered then')
})
it('auto-detects HEX and limits count-based ESX inventories to metadata-free modes', () => {
@@ -221,8 +224,12 @@ describe('phone inventory contracts', () => {
const phoneServer = readResourceFile('source/server/phone.lua')
expect(config).toContain('Keybind = "F1"')
expect(phoneClient).toContain('refresh_phone_key_mapping = function()')
expect(phoneClient).toContain(
'RegisterKeyMapping("sky_phone_toggle", locale.Controls.OpenPhone, "keyboard", Config.Phone.Keybind)',
'RegisterKeyMapping(command_name, locale.Controls.OpenPhone, "keyboard", key_name)',
)
expect(phoneClient).toContain(
'if active_key_mapping_command == command_name then',
)
expect(phoneClient).toContain(
'Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})',
@@ -232,6 +239,26 @@ describe('phone inventory contracts', () => {
)
})
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` = ?')
+3 -3
View File
@@ -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.',
+9 -4
View File
@@ -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', () => {
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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" },
+1 -1
View File
@@ -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" },
+16 -3
View File
@@ -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
+17 -2
View File
@@ -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()
@@ -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)
+6
View File
@@ -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
+39 -9
View File
@@ -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,
+5
View File
@@ -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)
+5
View File
@@ -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"
+130 -36
View File
@@ -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)
@@ -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
+26 -8
View File
@@ -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)
+23 -1
View File
@@ -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)
+12
View File
@@ -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.")
+19
View File
@@ -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" })
+25 -5
View File
@@ -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)
+13 -3
View File
@@ -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
+16 -3
View File
@@ -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()
+4
View File
@@ -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,
+15 -2
View File
@@ -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([[
@@ -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
+34 -16
View File
@@ -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
+50 -15
View File
@@ -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)
+15 -5
View File
@@ -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
+15 -5
View File
@@ -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)
+6
View File
@@ -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
+17 -2
View File
@@ -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
+36 -26
View File
@@ -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
+76 -28
View File
@@ -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
@@ -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",
+40 -6
View File
@@ -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)
+12
View File
@@ -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.")
+40 -16
View File
@@ -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`
+22 -12
View File
@@ -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.*,
+32 -6
View File
@@ -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
+25 -14
View File
@@ -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