Compare commits

..
9 Commits
Author SHA1 Message Date
Alec Schitzkat 99e943dbc5 BLD - Update GitHub release title to reflect beta status 2026-08-22 05:03:27 +02:00
Alec Schitzkat bd9f4c2695 BLD - Bump version to 0.2.7 2026-08-22 05:02:28 +02:00
Leon.Schmidt 4fa4bf3c78 ENH - centralize phone defaults and fixed permissions (#26)
* ENH - centralize phone configuration defaults

* FIX - enforce fixed phone permissions
2026-08-22 05:02:14 +02:00
DerEchteAlec a0f67fae80 FIX - synchronize Spanish locale with admin panel 2026-08-22 03:40:23 +02:00
Alec Schitzkat f7644bf973 BLD - Bump version to 0.2.6 2026-08-22 03:31:58 +02:00
Alec Schitzkat e8465e4732 Auto stash before merge of "dev" and "origin/dev" 2026-08-22 03:27:22 +02:00
DerEchteAlecandLeon.Schmidt a9143b0fba ADD - add in-game phone administration and configuration (#25)
* ADD - build in-game admin command center

* ADD - open admin panel by command

* ENH - rebuild admin panel as standalone editor

* ENH - compact admin workspace navigation

* ENH - expand admin device controls

* ENH - refine admin panel navigation style

* ADD - add SQL phone configurator

* FIX - expose complete phone configuration

* FIX - make company jobs freely configurable

* FIX - align configurator tables to left

* ENH - organize configurator collection controls

* ENH - organize nested configurator sections

* ENH - add configurator field descriptions

* FIX - hide fixed configurator add controls

* ENH - refine configurator editing experience

* ENH - refine admin configurator controls

* FIX - apply configurator settings instantly

* FIX - close live activity command registration

---------

Co-authored-by: Leon.Schmidt <leonmauricewill15@gmail.com>
2026-08-22 03:26:52 +02:00
Leon.Schmidt 7aa4677b81 ADD - support extended inventory metadata bridges (#23)
* ADD - support extended inventory metadata bridges

* FIX - align inventory contract tests
2026-08-21 22:04:15 +02:00
DerEchteAlec 5f8c835685 Revert "ADD - add in-game phone administration and configuration (#21)" (#24)
This reverts commit edd9677f43.
2026-08-21 21:49:05 +02:00
42 changed files with 5472 additions and 187 deletions
+1 -1
View File
@@ -119,5 +119,5 @@ jobs:
"${archive}.sha256" \ "${archive}.sha256" \
--verify-tag \ --verify-tag \
--generate-notes \ --generate-notes \
--title "Sky Phone $GITHUB_REF_NAME" --title "$GITHUB_REF_NAME Beta"
fi fi
+105 -20
View File
@@ -86,7 +86,7 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
| Layer | Supported options | | Layer | Supported options |
| --- | --- | | --- | --- |
| **Frameworks** | ESX Legacy, QBCore, Qbox | | **Frameworks** | ESX Legacy, QBCore, Qbox |
| **Inventories** | ox_inventory, qb-inventory, lj-inventory, qs-inventory, codem-inventory, core_inventory, mf-inventory, smx-inventory, hex_4_inventory, and native ESX inventory | | **Inventories** | ak47_inventory, codem-inventory, core_inventory, jaksam_inventory, jpr-inventory, lj-inventory, mf-inventory, one_inventory, origen_inventory, ox_inventory, ps-inventory, qb-inventory, qs-inventory, smx-inventory, tgiann-inventory, hex_4_inventory, and native ESX inventory |
| **Calls** | YACA, PMA Voice, SaltyChat | | **Calls** | YACA, PMA Voice, SaltyChat |
| **Radio** | YACA, PMA Voice, SaltyChat | | **Radio** | YACA, PMA Voice, SaltyChat |
| **Housing** | RTX Housing, Quasar Housing, VMS Housing, RX Housing, NoLag Properties, SN Properties, ESX Property, qbx_properties | | **Housing** | RTX Housing, Quasar Housing, VMS Housing, RX Housing, NoLag Properties, SN Properties, ESX Property, qbx_properties |
@@ -121,20 +121,33 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
| **Framework** | ESX Legacy (`es_extended`), QBCore (`qb-core`), or Qbox (`qbx_core`) | | **Framework** | ESX Legacy (`es_extended`), QBCore (`qb-core`), or Qbox (`qbx_core`) |
| **Inventory** | Choose one supported adapter from the table below | | **Inventory** | Choose one supported adapter from the table below |
| Inventory | Metadata support | Unique Phones | Notes | | Inventory (configuration value) | Metadata support | Unique Phones | Notes |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `ox_inventory` | Yes | Yes | Full per-item phone and physical SIM metadata | | `jaksam_inventory` (`jaksam`) | Yes | Yes | Direct per-slot metadata; usable items are registered through jaksam_inventory |
| `qb-inventory` | Yes | Yes | Uses item `info` metadata | | `qs-inventory` (`qs`) | Yes | Yes | Full per-slot metadata |
| `lj-inventory` | Yes | Yes | QBCore inventory with item `info` metadata | | `ps-inventory` (`ps`) | Yes | Yes | QBCore only; uses item `info` metadata |
| `qs-inventory` | Yes | Yes | Full per-slot metadata | | `codem-inventory` (`codem`) | Yes | Yes | Full per-slot metadata |
| `codem-inventory` | Yes | Yes | Full per-slot metadata | | `tgiann-inventory` (`tgiann`) | Yes | Yes | Per-slot metadata; item definitions must enable `hasMetadata` |
| `core_inventory` | Yes | Yes | Full per-slot metadata | | `core_inventory` (`core`) | Yes | Yes | Full per-slot metadata |
| `mf-inventory` | Yes | Yes | Supported with ESX | | `jpr-inventory` (`jpr`) | Yes | Yes | QBCore only; uses item `info` metadata |
| `smx-inventory` | Yes | Yes | Supported with ESX through the player metadata bridge | | `origen_inventory` (`origen`) | Yes | Yes | Full per-slot metadata |
| `hex_4_inventory` | **No metadata support** | **No, Unique Phones are not possible** | ESX only; set `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` | | `ak47_inventory` (`ak47`) | Yes | Yes | Uses per-slot item `info` metadata |
| Native ESX inventory | **No metadata support** | **No, Unique Phones are not possible** | Count-based items; set `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` | | `one_inventory` (`one`) | Yes | Yes | Full per-slot metadata |
| `ox_inventory` (`ox`) | Yes | Yes | Full per-item phone and physical SIM metadata |
| `mf-inventory` (`mf`) | Yes | Yes | ESX only |
| `smx-inventory` (`smx`) | Yes | Yes | ESX only; one metadata record per configured item name through the player metadata bridge |
| `lj-inventory` (`lj`) | Yes | Yes | QBCore inventory with item `info` metadata |
| `qb-inventory` (`qb`) | Yes | Yes | Uses item `info` metadata |
| `hex_4_inventory` (`hex`) | **No metadata support** | **No, Unique Phones are not possible** | ESX only; Sky Phone automatically disables unique phones and physical SIM cards |
| Native ESX inventory (`esx`) | **No metadata support** | **No, Unique Phones are not possible** | Count-based items; Sky Phone automatically disables unique phones and physical SIM cards |
`hex_4_inventory` and native ESX inventory cannot persist per-item metadata. Unique Phones and physical SIM cards are therefore unavailable with these adapters. `hex_4_inventory` and native ESX inventory cannot persist per-item metadata. Sky Phone therefore forces `Config.Phone.Unique` and `Config.Sim.Enabled` to `false` at runtime whenever either adapter is active.
`Config.Bridge.Inventory = "auto"` detects framework-compatible adapters in the table order. This deliberately matches the Sky inventory priority so a dedicated inventory is selected before a compatibility resource it may run beside. You may configure either the short value shown in parentheses or the exact resource name.
The adapters shared with `sky_base` are implemented locally inside Sky Phone. Installing or starting `sky_base` is not required; Sky Phone remains a standalone resource.
For configuration parity with `sky_base`, `qb-inv` is accepted as an alias for `qb`, while `qbox` selects the Qbox-native `ox_inventory` adapter.
### Voice ### Voice
@@ -203,6 +216,7 @@ The files contain clearly separated sections for:
| Section | Purpose | | Section | Purpose |
| --- | --- | | --- | --- |
| `Config.Bridge` | Framework, inventory, language, callback timeout, and debug mode | | `Config.Bridge` | Framework, inventory, language, callback timeout, and debug mode |
| `Config.CommandPermissions` | Fixed groups for the admin panel, test data, verification commands, and social moderation |
| `Config.Phone` | Phone item, movement, unique-device mode, and development command | | `Config.Phone` | Phone item, movement, unique-device mode, and development command |
| `Config.Sim` | Physical or virtual SIM behavior and number formatting | | `Config.Sim` | Physical or virtual SIM behavior and number formatting |
| `Config.Calls` / `Config.Radio` | Voice providers, call behavior, radio limits, and permissions | | `Config.Calls` / `Config.Radio` | Voice providers, call behavior, radio limits, and permissions |
@@ -228,21 +242,31 @@ Config.PhoneConfigurator = {
} }
``` ```
When enabled, `config.lua` and the server-only `media.lua` are first-run defaults. Sky Phone creates When enabled, the generated `source/shared/config_default.lua` is the shipped first-run baseline.
the `sky_phone_configurator` table automatically, loads its saved values before framework and phone The frontend build recreates this file from `config.lua` and the server-only `media.lua`; do not edit
the generated snapshot directly. 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 modules initialize, and exposes the editor through `/phonepanel`. Nothing autosaves: stage changes
in the Phone Configurator tool and press the green check. Saving verifies both SQL payloads and then 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 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. 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 Every configurable `Config.*` value from `config.lua`, including server-only sections, and every
`Config.Media` is discovered automatically. The bootstrap switch above intentionally remains value from `Config.Media` is discovered automatically. The bootstrap switch and
file-owned because it decides whether SQL configuration is loaded. Lists, nested objects, vectors, `Config.CommandPermissions` intentionally remain file-owned: the switch decides whether SQL
configuration is loaded, while permissions must remain authoritative outside the panel. The fixed
permission table is never displayed or overwritten by the Phone Configurator, and its stable keys do
not change when their commands are renamed in the panel. Lists, nested objects, vectors,
and numeric-keyed Lua tables use structured editors instead of raw JSON. Shipped schema rows stay and numeric-keyed Lua tables use structured editors instead of raw JSON. Shipped schema rows stay
editable but cannot be renamed, converted, or removed. Every list and table still accepts any number editable but cannot be renamed, converted, or removed. Every list and table still accepts any number
of additional rows; administrator-added rows remain removable. Company job keys are intentionally of additional rows; administrator-added rows remain removable. Company job keys are intentionally
fully removable because `Config.Companies.Definitions` is a freely managed job collection. fully removable because `Config.Companies.Definitions` is a freely managed job collection.
ESX and QBCore use the groups listed in `Config.CommandPermissions`. Qbox checks the configured ACE
objects first and then its framework groups. The standard Qbox `permissions.cfg` grants the `admin`
ACE to `group.admin`, so an identifier assigned to `group.admin` can open `/phonepanel` with the
shipped `phonepanel` permission list. Restart `sky_phone` after changing fixed permissions.
Media API keys and server peppers are never returned in plaintext to the NUI. Existing secrets are Media API keys and server peppers are never returned in plaintext to the NUI. Existing secrets are
shown only as configured and are replaced only when an administrator enters a new value. shown only as configured and are replaced only when an administrator enters a new value.
@@ -350,9 +374,9 @@ Default entries for unique phones with physical SIM cards:
Do not configure an LB Phone client event or client export. Sky Phone registers the usable items through its server-side inventory adapter. Do not configure an LB Phone client event or client export. Sky Phone registers the usable items through its server-side inventory adapter.
The server registers `Config.Phone.Item` as usable for every supported inventory adapter: `ox`, `qb`, `lj`, `qs`, `codem`, `core`, `mf`, `smx`, `hex`, and `esx`. Resource startup fails visibly if the selected adapter cannot complete that registration. The server registers `Config.Phone.Item` as usable for every supported inventory adapter: `ak47`, `codem`, `core`, `jaksam`, `jpr`, `lj`, `mf`, `one`, `origen`, `ox`, `ps`, `qb`, `qs`, `smx`, `tgiann`, `hex`, and `esx`. Resource startup fails visibly if the selected adapter or its resource is unavailable.
The `hex` and `esx` adapters use ESX's count-based item API. They require both `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` because this API cannot persist per-item phone or physical SIM metadata. `auto` selects `hex` when `hex_4_inventory` is started and otherwise falls back to `esx` on an ESX server when no metadata-capable inventory is detected. The `hex` and `esx` adapters use ESX's count-based item API, which cannot persist per-item phone or physical SIM metadata. Sky Phone automatically forces `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` while either adapter is active. `auto` selects `hex` when `hex_4_inventory` is started and otherwise falls back to `esx` on an ESX server when no metadata-capable inventory is detected.
### QBCore-style item tables ### QBCore-style item tables
@@ -361,6 +385,67 @@ The `hex` and `esx` adapters use ESX's count-based item API. They require both `
- Physical SIM items must always be unique. - Physical SIM items must always be unique.
- SIM items are not required when `Config.Sim.Enabled = false`. - SIM items are not required when `Config.Sim.Enabled = false`.
Example for `qb-inventory`, `lj-inventory`, `ps-inventory`, and `jpr-inventory`:
```lua
phone = {
name = "phone",
label = "iFruit Phone",
weight = 200,
type = "item",
image = "phone.png",
unique = true,
useable = true,
shouldClose = true,
description = "A personal mobile phone",
},
sky_phone_sim_registered = {
name = "sky_phone_sim_registered",
label = "Registered SIM",
weight = 5,
type = "item",
image = "sky_phone_sim_registered.png",
unique = true,
useable = true,
shouldClose = true,
},
sky_phone_sim_anonymous = {
name = "sky_phone_sim_anonymous",
label = "Anonymous SIM",
weight = 5,
type = "item",
image = "sky_phone_sim_anonymous.png",
unique = true,
useable = true,
shouldClose = true,
},
```
For `tgiann-inventory`, set `hasMetadata = true`, `useable = true`, and `shouldClose = true` on all three item definitions. Follow the inventory's own item schema for the remaining adapters; the required behavior is always the same: a unique phone or physical SIM must occupy its own slot and its metadata table must survive moving, dropping, storing, and trading the item.
### Unique Phones and metadata
Sky Phone owns the metadata values and writes them server-side. Do not pre-generate IMEIs or phone numbers in item definitions:
| Item | Metadata written by Sky Phone |
| --- | --- |
| Phone | `imei`; when a SIM is inserted, also `sim_id`, `phone_number`, and `formatted_number` |
| Physical SIM | `sim_metadata_version`, `sim_id`, `phone_number`, `formatted_number`, `sim_type`, and registration details where applicable |
When a metadata-capable phone item is used for the first time, Sky Phone reserves an IMEI and writes it back to that exact slot. Existing metadata is preserved. The adapter then reads the slot again and rejects the operation if the inventory did not persist the requested values.
For reliable Unique Phones:
- Set `Config.Phone.Unique = true`.
- Make the phone item non-stackable/unique. Every phone slot must contain exactly one item.
- If `Config.Sim.Enabled = true`, make both physical SIM items non-stackable/unique and metadata-capable too.
- Do not use inventory conversion, admin, crafting, or shop scripts that strip item metadata. Copying an item with its metadata also copies its IMEI; duplicated IMEIs are reported in the server console.
- When changing inventory systems, migrate the complete item metadata table. Without the old `imei`, the next use creates a new device identity and does not automatically attach the old handset data.
With `Config.Phone.Unique = false`, the handset identity is stored once per framework character instead of on each phone item. The phone item may stack. Physical SIMs still require per-item metadata, so `Config.Sim.Enabled` must be `false` on `hex` and native `esx`.
## Phone and SIM modes ## Phone and SIM modes
The two mode switches are independent: The two mode switches are independent:
+40 -5
View File
@@ -2,15 +2,49 @@ const fs = require('node:fs')
const path = require('node:path') const path = require('node:path')
const frontendRoot = __dirname const frontendRoot = __dirname
const resourceDirectory = path.join(frontendRoot, '..', 'sky_phone')
const sourceDirectory = path.join(frontendRoot, 'dist') const sourceDirectory = path.join(frontendRoot, 'dist')
const targetDirectory = path.join( const targetDirectory = path.join(resourceDirectory, 'source', 'html')
frontendRoot, const configDefaultPath = path.join(
'..', resourceDirectory,
'sky_phone',
'source', 'source',
'html', 'shared',
'config_default.lua',
) )
function readLuaSource(...segments) {
return fs
.readFileSync(path.join(resourceDirectory, ...segments), 'utf8')
.replace(/\r\n?/g, '\n')
.trimEnd()
}
function generateConfigDefault() {
const configSource = readLuaSource('config', 'config.lua').replace(
/\n?-- CONFIG_DEFAULT_EXCLUDE_START[\s\S]*?-- CONFIG_DEFAULT_EXCLUDE_END\n?/g,
'\n',
)
const mediaSource = readLuaSource('config', 'media.lua')
const generatedSource = [
'-- GENERATED by frontend/build.cjs from config/config.lua and config/media.lua - do not edit.',
'-- Escrowed snapshot of the shipped defaults used by the Phone Configurator.',
'local realConfig = Config',
'Config = {}',
'',
configSource,
'',
mediaSource,
'',
'ConfigDefaults = Config',
'Config = realConfig',
'',
].join('\n')
fs.mkdirSync(path.dirname(configDefaultPath), { recursive: true })
fs.writeFileSync(configDefaultPath, generatedSource, 'utf8')
console.log(`Generated Phone Configurator defaults at ${configDefaultPath}`)
}
if (!fs.existsSync(sourceDirectory)) { if (!fs.existsSync(sourceDirectory)) {
throw new Error( throw new Error(
'Missing frontend/dist. Run vite build before publishing the NUI.', 'Missing frontend/dist. Run vite build before publishing the NUI.',
@@ -28,4 +62,5 @@ const normalizedIndex = fs
.replace(/\n[ \t]*\n([ \t]*<\/body>)/g, '\n$1') .replace(/\n[ \t]*\n([ \t]*<\/body>)/g, '\n$1')
fs.writeFileSync(targetIndex, normalizedIndex) fs.writeFileSync(targetIndex, normalizedIndex)
generateConfigDefault()
console.log(`Published NUI to ${targetDirectory}`) console.log(`Published NUI to ${targetDirectory}`)
@@ -54,6 +54,14 @@ const config = readFileSync(
new URL('../../../sky_phone/config/config.lua', import.meta.url), new URL('../../../sky_phone/config/config.lua', import.meta.url),
'utf8', 'utf8',
) )
const configDefaultsSource = config.replace(
/\r?\n?-- CONFIG_DEFAULT_EXCLUDE_START[\s\S]*?-- CONFIG_DEFAULT_EXCLUDE_END\r?\n?/g,
'\n',
)
const mediaConfig = readFileSync(
new URL('../../../sky_phone/config/media.lua', import.meta.url),
'utf8',
)
const schema = readFileSync( const schema = readFileSync(
new URL('../../../sky_phone/sql/install.sql', import.meta.url), new URL('../../../sky_phone/sql/install.sql', import.meta.url),
'utf8', 'utf8',
@@ -69,6 +77,17 @@ const configuratorServer = readFileSync(
), ),
'utf8', 'utf8',
) )
const configDefault = readFileSync(
new URL(
'../../../sky_phone/source/shared/config_default.lua',
import.meta.url,
),
'utf8',
)
const frontendBuild = readFileSync(
new URL('../../build.cjs', import.meta.url),
'utf8',
)
const configuratorClient = readFileSync( const configuratorClient = readFileSync(
new URL( new URL(
'../../../sky_phone/source/client/phone_configurator.lua', '../../../sky_phone/source/client/phone_configurator.lua',
@@ -216,12 +235,14 @@ describe('standalone admin panel contracts', () => {
it('authorizes every server request without requiring a phone session', () => { it('authorizes every server request without requiring a phone session', () => {
expect(server).not.toContain('SkyPhone.RequireSession(source)') expect(server).not.toContain('SkyPhone.RequireSession(source)')
expect(server).toContain( expect(server).toContain(
'Bridge.Framework.HasAdminGroup(source, Config.AdminPanel.AdminGroups)', 'Bridge.Framework.HasPermission(source, "phonepanel")',
) )
expect(server).toContain('Config.AdminPanel.ReadRequestsPerMinute') expect(server).toContain('Config.AdminPanel.ReadRequestsPerMinute')
expect(server).toContain('Config.AdminPanel.ActionRequestsPerMinute') expect(server).toContain('Config.AdminPanel.ActionRequestsPerMinute')
expect(server).toContain('Config.AdminPanel.CredentialRevealsPerMinute') expect(server).toContain('Config.AdminPanel.CredentialRevealsPerMinute')
expect(config).toContain('Config.AdminPanel = {') expect(config).toContain('Config.AdminPanel = {')
expect(config).toContain('Config.CommandPermissions = {')
expect(config).not.toContain('AdminGroups =')
}) })
it('opens directly from the configurable command with dedicated focus', () => { it('opens directly from the configurable command with dedicated focus', () => {
@@ -272,6 +293,12 @@ describe('standalone admin panel contracts', () => {
expect(schema).toContain( expect(schema).toContain(
'CREATE TABLE IF NOT EXISTS `sky_phone_configurator`', 'CREATE TABLE IF NOT EXISTS `sky_phone_configurator`',
) )
expect(
manifest.indexOf("'source/shared/config_default.lua'"),
).toBeGreaterThan(manifest.indexOf("'config/media.lua'"))
expect(manifest.indexOf("'source/shared/config_default.lua'")).toBeLessThan(
manifest.indexOf("'source/server/phone_configurator.lua'"),
)
expect( expect(
manifest.indexOf("'source/server/phone_configurator.lua'"), manifest.indexOf("'source/server/phone_configurator.lua'"),
).toBeLessThan(manifest.indexOf("'source/bridge/server/framework.lua'")) ).toBeLessThan(manifest.indexOf("'source/bridge/server/framework.lua'"))
@@ -281,11 +308,36 @@ describe('standalone admin panel contracts', () => {
expect(configuratorServer).toContain('AND `revision` = ?') expect(configuratorServer).toContain('AND `revision` = ?')
expect(configuratorServer).toContain('configurator_enabled') expect(configuratorServer).toContain('configurator_enabled')
expect(configuratorServer).toMatch( expect(configuratorServer).toMatch(
/for key, value in pairs\(Config\)[\s\S]*?key ~= "Media"[\s\S]*?key ~= "PhoneConfigurator"/, /for key, value in pairs\(ConfigDefaults\)[\s\S]*?key ~= "Media"[\s\S]*?key ~= "PhoneConfigurator"/,
) )
expect(configuratorServer).toContain( expect(configuratorServer).toContain(
'default_media = serialize_value(Config.Media)', 'default_media = serialize_value(ConfigDefaults.Media)',
) )
expect(configDefault).toContain(
'GENERATED by frontend/build.cjs from config/config.lua and config/media.lua',
)
expect(configDefault).toContain('local realConfig = Config')
expect(configDefault).toContain('ConfigDefaults = Config')
expect(configDefault.replace(/\r\n?/g, '\n').trimEnd()).toBe(
[
'-- GENERATED by frontend/build.cjs from config/config.lua and config/media.lua - do not edit.',
'-- Escrowed snapshot of the shipped defaults used by the Phone Configurator.',
'local realConfig = Config',
'Config = {}',
'',
configDefaultsSource.replace(/\r\n?/g, '\n').trimEnd(),
'',
mediaConfig.replace(/\r\n?/g, '\n').trimEnd(),
'',
'ConfigDefaults = Config',
'Config = realConfig',
].join('\n'),
)
expect(frontendBuild).toContain("readLuaSource('config', 'config.lua')")
expect(frontendBuild).toContain("readLuaSource('config', 'media.lua')")
expect(frontendBuild).toContain("'config_default.lua'")
expect(configDefault).not.toContain('Config.PhoneConfigurator')
expect(configDefault).not.toContain('Config.CommandPermissions')
expect(configuratorServer).toContain( expect(configuratorServer).toContain(
'build_sections("config", stored_config', 'build_sections("config", stored_config',
) )
+83
View File
@@ -0,0 +1,83 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
function source(path: string): string {
return readFileSync(new URL(path, import.meta.url), 'utf8')
}
const config = source('../../sky_phone/config/config.lua')
const configDefault = source('../../sky_phone/source/shared/config_default.lua')
const framework = source('../../sky_phone/source/bridge/server/framework.lua')
const qbox = source('../../sky_phone/source/bridge/server/frameworks/qbox.lua')
const configurator = source(
'../../sky_phone/source/server/phone_configurator.lua',
)
const configuratorFixture = source('../testserver/configurator-fixture.cjs')
describe('fixed server permissions', () => {
it('defines every protected phone capability only in config.lua', () => {
expect(config).toContain('Config.CommandPermissions = {')
for (const permission of [
'phonepanel',
'phonetestdata',
'fliptokverify',
'picstagramverify',
'picstagramadmin',
]) {
expect(config).toMatch(new RegExp(`\\s${permission} = \\{`))
}
expect(config).not.toContain('AdminGroups =')
expect(configDefault).not.toContain('Config.PhoneConfigurator')
expect(configDefault).not.toContain('Config.CommandPermissions')
expect(configDefault).not.toContain('AdminGroups =')
})
it('keeps fixed permissions outside SQL and removes legacy group fields', () => {
expect(configurator).toContain('key ~= "CommandPermissions"')
expect(configurator).toContain('if key ~= "CommandPermissions" then')
expect(configuratorFixture).toContain('delete config.CommandPermissions')
for (const path of [
'AdminPanel.AdminGroups',
'TestData.AdminGroups',
'FlipTok.AdminGroups',
'Picstagram.AdminGroups',
]) {
expect(configurator).toContain(`["${path}"] = true`)
}
expect(configurator).toContain(
'Phone Configurator enabled: file-based settings from config.lua',
)
expect(configurator).toContain(
'(except Config.CommandPermissions) and media.lua are disabled',
)
})
it('authorizes Qbox through ACE before retaining framework group support', () => {
expect(framework).toContain(
'local groups = Config.CommandPermissions[permission]',
)
expect(qbox).toContain(
'if IsPlayerAceAllowed(tostring(source), group) then',
)
expect(qbox.indexOf('IsPlayerAceAllowed')).toBeLessThan(
qbox.indexOf('exports.qbx_core:HasGroup'),
)
})
it('uses stable permission identifiers for every protected operation', () => {
const expectations = [
['../../sky_phone/source/server/admin.lua', 'phonepanel'],
['../../sky_phone/source/server/testdata.lua', 'phonetestdata'],
['../../sky_phone/source/server/fliptok.lua', 'fliptokverify'],
['../../sky_phone/source/server/picstagram.lua', 'picstagramverify'],
['../../sky_phone/source/server/picstagram.lua', 'picstagramadmin'],
] as const
for (const [path, permission] of expectations) {
expect(source(path)).toContain(
`Bridge.Framework.HasPermission(source, "${permission}")`,
)
}
})
})
+44 -11
View File
@@ -3,17 +3,27 @@ import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
const readResourceFile = (path: string) => const readResourceFile = (path: string) =>
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8').replace(/\r\n/g, '\n') readFileSync(
new URL(`../../sky_phone/${path}`, import.meta.url),
'utf8',
).replace(/\r\n/g, '\n')
const readFrontendFile = (path: string) => const readFrontendFile = (path: string) =>
readFileSync(new URL(path, import.meta.url), 'utf8') readFileSync(new URL(path, import.meta.url), 'utf8')
const inventoryAdapters = [ const inventoryAdapters = [
['jaksam', 'source/bridge/server/inventory/jaksam.lua'],
['ox', 'source/bridge/server/inventory/ox.lua'], ['ox', 'source/bridge/server/inventory/ox.lua'],
['qb', 'source/bridge/server/inventory/qb.lua'], ['qb', 'source/bridge/server/inventory/qb.lua'],
['lj', 'source/bridge/server/inventory/qb.lua'], ['lj', 'source/bridge/server/inventory/qb.lua'],
['qs', 'source/bridge/server/inventory/qs.lua'], ['qs', 'source/bridge/server/inventory/qs.lua'],
['ps', 'source/bridge/server/inventory/ps.lua'],
['codem', 'source/bridge/server/inventory/codem.lua'], ['codem', 'source/bridge/server/inventory/codem.lua'],
['tgiann', 'source/bridge/server/inventory/tgiann.lua'],
['core', 'source/bridge/server/inventory/core.lua'], ['core', 'source/bridge/server/inventory/core.lua'],
['jpr', 'source/bridge/server/inventory/jpr.lua'],
['origen', 'source/bridge/server/inventory/origen.lua'],
['ak47', 'source/bridge/server/inventory/ak47.lua'],
['one', 'source/bridge/server/inventory/one.lua'],
['mf', 'source/bridge/server/inventory/mf.lua'], ['mf', 'source/bridge/server/inventory/mf.lua'],
['smx', 'source/bridge/server/inventory/smx.lua'], ['smx', 'source/bridge/server/inventory/smx.lua'],
['hex', 'source/bridge/server/inventory/esx.lua'], ['hex', 'source/bridge/server/inventory/esx.lua'],
@@ -42,17 +52,30 @@ describe('phone inventory contracts', () => {
) )
}) })
it('auto-detects HEX and limits count-based ESX inventories to metadata-free modes', () => { it('auto-detects registered inventories and forces metadata-free adapters into compatible modes', () => {
const inventoryBridge = readResourceFile( const inventoryBridge = readResourceFile(
'source/bridge/server/inventory.lua', 'source/bridge/server/inventory.lua',
) )
expect(inventoryBridge).toContain( expect(inventoryBridge).toContain(
'GetResourceState("hex_4_inventory") == "started"', '{ name = "hex", resource = "hex_4_inventory", framework = "esx", metadata = false },',
)
expect(inventoryBridge).toContain(
'GetResourceState(adapter.resource) == "started"',
)
expect(inventoryBridge).toContain('configured_inventory = adapter.name')
expect(inventoryBridge).toContain('selected_adapter.metadata == false')
expect(inventoryBridge).toContain('Config.Phone.Unique = false')
expect(inventoryBridge).toContain('Config.Sim.Enabled = false')
expect(inventoryBridge).toContain(
'does not support item metadata; unique phones and physical SIM cards were disabled automatically',
)
expect(inventoryBridge).toContain(
'AddEventHandler("sky_phone:configurator:serverUpdated"',
)
expect(inventoryBridge).not.toContain(
'cannot store unique phone or physical SIM metadata',
) )
expect(inventoryBridge).toContain('configured_inventory = "hex"')
expect(inventoryBridge).toContain('Config.Phone.Unique ~= false')
expect(inventoryBridge).toContain('Config.Sim.Enabled ~= false')
}) })
it('provides the LB IsOpen export alias from the authoritative client state', () => { it('provides the LB IsOpen export alias from the authoritative client state', () => {
@@ -103,9 +126,15 @@ describe('phone inventory contracts', () => {
expect(equippedNumberExport).toBeDefined() expect(equippedNumberExport).toBeDefined()
expect(equippedNumberResolver).toBeDefined() expect(equippedNumberResolver).toBeDefined()
expect(equippedNumberExport).toContain('type(player) == "number"') expect(equippedNumberExport).toContain('type(player) == "number"')
expect(equippedNumberExport).toContain('online_source_for_identifier(player)') expect(equippedNumberExport).toContain(
expect(phoneServer).toContain('Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)') 'online_source_for_identifier(player)',
expect(phoneServer).toContain('return resolve_equipped_phone_number(player)') )
expect(phoneServer).toContain(
'Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)',
)
expect(phoneServer).toContain(
'return resolve_equipped_phone_number(player)',
)
expect(equippedNumberExport).not.toContain('tonumber(player)') expect(equippedNumberExport).not.toContain('tonumber(player)')
expect(equippedNumberExport).not.toContain('equipped_phone_numbers[player]') expect(equippedNumberExport).not.toContain('equipped_phone_numbers[player]')
expect(equippedNumberResolver).not.toContain('return cached_number') expect(equippedNumberResolver).not.toContain('return cached_number')
@@ -160,7 +189,9 @@ describe('phone inventory contracts', () => {
expect(phoneClient).toContain( expect(phoneClient).toContain(
'TriggerEvent("sky_phone:client:phoneToggled", false)', 'TriggerEvent("sky_phone:client:phoneToggled", false)',
) )
expect(phoneBridge).toContain('TriggerEvent("lb-phone:numberChanged", phone_number)') expect(phoneBridge).toContain(
'TriggerEvent("lb-phone:numberChanged", phone_number)',
)
expect(phoneBridge).toContain('TriggerEvent("lb-phone:phoneToggled", open)') expect(phoneBridge).toContain('TriggerEvent("lb-phone:phoneToggled", open)')
}) })
@@ -213,7 +244,9 @@ describe('phone inventory contracts', () => {
expect(mediaServer).toContain( expect(mediaServer).toContain(
'TriggerEvent("sky_phone:server:galleryMediaDeleted", src, phone_number, deleted_link)', 'TriggerEvent("sky_phone:server:galleryMediaDeleted", src, phone_number, deleted_link)',
) )
expect(phoneBridge).toContain('TriggerEvent("lb-phone:phoneNumberGenerated"') expect(phoneBridge).toContain(
'TriggerEvent("lb-phone:phoneNumberGenerated"',
)
expect(phoneBridge).toContain('TriggerEvent("lb-phone:factoryReset"') expect(phoneBridge).toContain('TriggerEvent("lb-phone:factoryReset"')
expect(phoneBridge).toContain('TriggerEvent("lb-phone:deletedFromGallery"') expect(phoneBridge).toContain('TriggerEvent("lb-phone:deletedFromGallery"')
}) })
@@ -10,14 +10,22 @@ type LuaToken = {
} }
const frontendSourceDirectory = fileURLToPath(new URL('../', import.meta.url)) const frontendSourceDirectory = fileURLToPath(new URL('../', import.meta.url))
const englishLocaleSource = readFileSync( const localeDirectory = fileURLToPath(
new URL('../../../sky_phone/config/locales/en.lua', import.meta.url), new URL('../../../sky_phone/config/locales/', import.meta.url),
'utf8',
) )
const germanLocaleSource = readFileSync( const localeSources = new Map(
new URL('../../../sky_phone/config/locales/de.lua', import.meta.url), readdirSync(localeDirectory, { withFileTypes: true })
'utf8', .filter((entry) => entry.isFile() && entry.name.endsWith('.lua'))
.map((entry) => [
entry.name.replace(/\.lua$/, ''),
readFileSync(join(localeDirectory, entry.name), 'utf8'),
]),
) )
const englishLocaleSource = localeSources.get('en')
const germanLocaleSource = localeSources.get('de')
if (!englishLocaleSource || !germanLocaleSource) {
throw new Error('The bundled English and German phone locales are required.')
}
const phoneStoreSource = readFileSync( const phoneStoreSource = readFileSync(
new URL('./phone.ts', import.meta.url), new URL('./phone.ts', import.meta.url),
'utf8', 'utf8',
@@ -148,6 +156,10 @@ function collectPlaceholders(value: string): string[] {
return [...new Set(value.match(/\{[A-Za-z0-9_]+\}/g) ?? [])].sort() return [...new Set(value.match(/\{[A-Za-z0-9_]+\}/g) ?? [])].sort()
} }
function collectNumberTokens(value: string): string[] {
return value.match(/\d+/g) ?? []
}
function collectDefaultLocalePaths(source: string): Set<string> { function collectDefaultLocalePaths(source: string): Set<string> {
const ast = ts.createSourceFile( const ast = ts.createSourceFile(
'phone.ts', 'phone.ts',
@@ -223,10 +235,26 @@ function collectFrontendFiles(directory: string): string[] {
} }
describe('phone locale contract', () => { describe('phone locale contract', () => {
const englishValues = collectLuaLocaleValues(englishLocaleSource) const localeValues = new Map(
const germanValues = collectLuaLocaleValues(germanLocaleSource) [...localeSources].map(([locale, source]) => [
locale,
collectLuaLocaleValues(source),
]),
)
const englishValues = localeValues.get('en')!
const germanValues = localeValues.get('de')!
const translatedLocaleValues = [...localeValues].filter(
([locale]) => locale !== 'en',
)
const englishPaths = new Set(englishValues.keys()) const englishPaths = new Set(englishValues.keys())
it.each([...localeSources])(
'registers the %s locale under its file name',
(locale, source) => {
expect(source.match(/^Locales\["([^"]+)"\]/)?.[1]).toBe(locale)
},
)
it('keeps every bundled frontend fallback in en.lua', () => { it('keeps every bundled frontend fallback in en.lua', () => {
const missing = [...collectDefaultLocalePaths(phoneStoreSource)].filter( const missing = [...collectDefaultLocalePaths(phoneStoreSource)].filter(
(path) => !englishPaths.has(`Nui.${path}`), (path) => !englishPaths.has(`Nui.${path}`),
@@ -271,26 +299,52 @@ describe('phone locale contract', () => {
} }
}) })
it('keeps German structurally aligned with English', () => { it.each(translatedLocaleValues)(
const germanPaths = new Set(germanValues.keys()) 'keeps %s structurally aligned with English',
(_locale, values) => {
expect([...values.keys()].sort()).toEqual([...englishPaths].sort())
},
)
expect([...germanPaths].sort()).toEqual([...englishPaths].sort()) it.each(translatedLocaleValues)(
}) 'keeps %s interpolation placeholders aligned with English',
(_locale, values) => {
const mismatches = [...englishValues].flatMap(
([path, englishValue]) => {
const translatedValue = values.get(path)
return translatedValue !== undefined &&
JSON.stringify(collectPlaceholders(translatedValue)) !==
JSON.stringify(collectPlaceholders(englishValue))
? [
`${path}: ${collectPlaceholders(englishValue).join(', ')} != ${collectPlaceholders(translatedValue).join(', ')}`,
]
: []
},
)
it('keeps German interpolation placeholders aligned with English', () => { expect(mismatches).toEqual([])
const mismatches = [...englishValues].flatMap(([path, englishValue]) => { },
const germanValue = germanValues.get(path) )
return germanValue !== undefined &&
JSON.stringify(collectPlaceholders(germanValue)) !==
JSON.stringify(collectPlaceholders(englishValue))
? [
`${path}: ${collectPlaceholders(englishValue).join(', ')} != ${collectPlaceholders(germanValue).join(', ')}`,
]
: []
})
expect(mismatches).toEqual([]) it.each(translatedLocaleValues)(
}) 'keeps %s numeric source values intact',
(_locale, values) => {
const mismatches = [...englishValues].flatMap(([path, englishValue]) => {
const expected = collectNumberTokens(englishValue)
if (!expected.length) return []
const remaining = collectNumberTokens(values.get(path) ?? '')
for (const token of expected) {
const index = remaining.indexOf(token)
if (index >= 0) remaining.splice(index, 1)
else return [`${path}: missing numeric token ${token}`]
}
return []
})
expect(mismatches).toEqual([])
},
)
it('keeps standard app names German and custom game names unchanged', () => { it('keeps standard app names German and custom game names unchanged', () => {
const standardAppNames = { const standardAppNames = {
-1
View File
@@ -945,7 +945,6 @@ const adminPanelFallbackLocales = {
entry: 'Entry', entry: 'Entry',
general: 'General', general: 'General',
subtabs: { subtabs: {
AdminGroups: 'Admin Groups',
Dictionaries: 'Dictionaries', Dictionaries: 'Dictionaries',
Clips: 'Clips', Clips: 'Clips',
Transforms: 'Transforms', Transforms: 'Transforms',
@@ -16,9 +16,7 @@ describe('admin configurator descriptions', () => {
expect(configuratorDescriptionKey('Media.RequestTimeoutMs', 10000)).toBe( expect(configuratorDescriptionKey('Media.RequestTimeoutMs', 10000)).toBe(
'milliseconds', 'milliseconds',
) )
expect(configuratorDescriptionKey('AdminPanel.AdminGroups', [])).toBe( expect(configuratorDescriptionKey('Radio.AllowedJobs', [])).toBe('access')
'access',
)
expect(configuratorDescriptionKey('FiveManage.ApiKey', '')).toBe( expect(configuratorDescriptionKey('FiveManage.ApiKey', '')).toBe(
'credential', 'credential',
) )
+10 -13
View File
@@ -1,4 +1,4 @@
import { readFileSync } from 'node:fs' import { readdirSync, readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
@@ -41,24 +41,21 @@ describe('App Store preview catalog', () => {
expect(visualSignatures.size).toBe(storeAppIds.length) expect(visualSignatures.size).toBe(storeAppIds.length)
}) })
it('localizes every specialized preview in the fallback, English and German locales', () => { it('localizes every specialized preview in the fallback and every configured locale', () => {
const localeDirectory = fileURLToPath(
new URL('../../../sky_phone/config/locales/', import.meta.url),
)
const localeSources = [ const localeSources = [
readFileSync( readFileSync(
fileURLToPath(new URL('../stores/phone.ts', import.meta.url)), fileURLToPath(new URL('../stores/phone.ts', import.meta.url)),
'utf8', 'utf8',
), ),
readFileSync( ...readdirSync(localeDirectory)
fileURLToPath( .filter((fileName) => fileName.endsWith('.lua'))
new URL('../../../sky_phone/config/locales/en.lua', import.meta.url), .sort()
.map((fileName) =>
readFileSync(`${localeDirectory}/${fileName}`, 'utf8'),
), ),
'utf8',
),
readFileSync(
fileURLToPath(
new URL('../../../sky_phone/config/locales/de.lua', import.meta.url),
),
'utf8',
),
] ]
for (const appId of PREVIEWABLE_BUILTIN_APP_IDS) { for (const appId of PREVIEWABLE_BUILTIN_APP_IDS) {
@@ -661,6 +661,7 @@ function loadConfiguratorSections() {
) )
const media = mediaRoot.Media const media = mediaRoot.Media
delete config.PhoneConfigurator delete config.PhoneConfigurator
delete config.CommandPermissions
delete config.Media delete config.Media
return [...buildSections('config', config), ...buildSections('media', media)] return [...buildSections('config', config), ...buildSections('media', media)]
} }
@@ -61,14 +61,19 @@ function countStructure(structure: ConfiguratorStructure | undefined): number {
} }
describe('admin configurator fixture', () => { describe('admin configurator fixture', () => {
it('exposes every config.lua root through the full live preview', () => { it('exposes every SQL-managed config.lua root through the full live preview', () => {
const sections = loadConfiguratorSections() const sections = loadConfiguratorSections()
const fields = sections.flatMap((section) => section.fields) const fields = sections.flatMap((section) => section.fields)
const roots = [ const roots = [
...configSource.matchAll(/^\s{0,4}Config\.([A-Za-z0-9_]+)\s*=/gm), ...configSource.matchAll(/^\s{0,4}Config\.([A-Za-z0-9_]+)\s*=/gm),
] ]
.map((match) => match[1]) .map((match) => match[1])
.filter((root) => root !== 'Media' && root !== 'PhoneConfigurator') .filter(
(root) =>
root !== 'Media' &&
root !== 'PhoneConfigurator' &&
root !== 'CommandPermissions',
)
expect(sections).toHaveLength(45) expect(sections).toHaveLength(45)
expect( expect(
+15 -2
View File
@@ -134,7 +134,7 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
- `hex_4_inventory` - `hex_4_inventory`
- Native ESX inventory - Native ESX inventory
`mf-inventory` and `smx-inventory` are supported with ESX. The native ESX and HEX adapters use count-based items and therefore require unique phones and physical SIM cards to be disabled. `mf-inventory` and `smx-inventory` are supported with ESX. The native ESX and HEX adapters use count-based items, so Sky Phone automatically disables unique phones and physical SIM cards while either adapter is active.
### Voice ### Voice
@@ -196,6 +196,7 @@ The files contain clearly separated sections for:
| Section | Purpose | | Section | Purpose |
| --- | --- | | --- | --- |
| `Config.Bridge` | Framework, inventory, language, callback timeout, and debug mode | | `Config.Bridge` | Framework, inventory, language, callback timeout, and debug mode |
| `Config.CommandPermissions` | Fixed groups for the admin panel, test data, verification commands, and social moderation |
| `Config.Phone` | Phone item, movement, unique-device mode, and development command | | `Config.Phone` | Phone item, movement, unique-device mode, and development command |
| `Config.Sim` | Physical or virtual SIM behavior and number formatting | | `Config.Sim` | Physical or virtual SIM behavior and number formatting |
| `Config.Calls` / `Config.Radio` | Voice providers, call behavior, radio limits, and permissions | | `Config.Calls` / `Config.Radio` | Voice providers, call behavior, radio limits, and permissions |
@@ -211,6 +212,18 @@ The files contain clearly separated sections for:
Restart `sky_phone` after changing Lua configuration. Restart `sky_phone` after changing Lua configuration.
When `Config.PhoneConfigurator.Enabled` is enabled, the generated `source/shared/config_default.lua`
provides the shipped SQL baseline. The frontend build recreates it from `config.lua` and the
server-only `media.lua`; do not edit the generated snapshot directly.
`Config.PhoneConfigurator` and `Config.CommandPermissions` remain file-owned and are omitted from
the generated default snapshot. Permissions are not displayed in the Phone Configurator and stay
authoritative while SQL configuration is enabled. Their stable keys do not change when a command is
renamed in the panel. ESX and QBCore use their framework permissions. Qbox checks the configured ACE
objects first and then its framework groups, so the standard `permissions.cfg` mapping from
`group.admin` to the `admin` ACE works with the shipped `phonepanel` permission list. Restart
`sky_phone` after changing fixed permissions.
### Language ### Language
Available locales: Available locales:
@@ -312,7 +325,7 @@ Do not configure an LB Phone client event or client export. Sky Phone registers
The server registers `Config.Phone.Item` as usable for every supported inventory adapter: `ox`, `qb`, `lj`, `qs`, `codem`, `core`, `mf`, `smx`, `hex`, and `esx`. Resource startup fails visibly if the selected adapter cannot complete that registration. The server registers `Config.Phone.Item` as usable for every supported inventory adapter: `ox`, `qb`, `lj`, `qs`, `codem`, `core`, `mf`, `smx`, `hex`, and `esx`. Resource startup fails visibly if the selected adapter cannot complete that registration.
The `hex` and `esx` adapters use ESX's count-based item API. They require both `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` because this API cannot persist per-item phone or physical SIM metadata. `auto` selects `hex` when `hex_4_inventory` is started and otherwise falls back to `esx` on an ESX server when no metadata-capable inventory is detected. The `hex` and `esx` adapters use ESX's count-based item API, which cannot persist per-item phone or physical SIM metadata. Sky Phone automatically forces `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` while either adapter is active. `auto` selects `hex` when `hex_4_inventory` is started and otherwise falls back to `esx` on an ESX server when no metadata-capable inventory is detected.
### QBCore-style item tables ### QBCore-style item tables
+22 -9
View File
@@ -10,19 +10,36 @@
Keep option names unchanged. Restart sky_phone after editing this file. Keep option names unchanged. Restart sky_phone after editing this file.
]] ]]
-- When enabled, config.lua and media.lua only provide first-run defaults. -- CONFIG_DEFAULT_EXCLUDE_START
-- The active configuration is loaded from SQL and managed through /phonepanel. -- When enabled, the active configuration is loaded from SQL and managed through
-- /phonepanel. Frontend builds snapshot the shipped defaults from config.lua and
-- media.lua into source/shared/config_default.lua.
Config.PhoneConfigurator = { Config.PhoneConfigurator = {
Enabled = true, Enabled = true,
} }
-- Fixed server permissions. These values remain authoritative even while the
-- Phone Configurator is enabled and are intentionally not shown in its panel.
-- Group names use the active framework's permissions. On Qbox they also match
-- ACE objects such as "admin" from the standard permissions.cfg.
Config.CommandPermissions = {
phonepanel = { "god", "superadmin", "admin" },
phonetestdata = { "god", "superadmin", "admin" },
fliptokverify = { "god", "superadmin", "admin" },
picstagramverify = { "god", "superadmin", "admin" },
picstagramadmin = { "god", "superadmin", "admin" },
}
-- CONFIG_DEFAULT_EXCLUDE_END
-- ============================================================================= -- =============================================================================
-- Core, framework and device -- Core, framework and device
-- ============================================================================= -- =============================================================================
Config.Bridge = { Config.Bridge = {
Framework = "auto", -- auto, esx, qbox, qb Framework = "auto", -- auto, esx, qbox, qb
Inventory = "auto", -- auto, ox, qb, lj, qs, codem, core, mf, smx, hex, esx -- auto, ak47, codem, core, jaksam, jpr, lj, mf, one, origen, ox, ps, qb, qs, smx, tgiann, hex, esx
-- Compatibility aliases: qb-inv -> qb, qbox -> ox
Inventory = "auto",
Locale = "en", Locale = "en",
CallbackTimeout = 15000, CallbackTimeout = 15000,
Debug = false, -- true: show debug/info output; warnings and errors are always shown Debug = false, -- true: show debug/info output; warnings and errors are always shown
@@ -32,7 +49,7 @@ Config.Command = "phone"
Config.Phone = { Config.Phone = {
Item = "phone", Item = "phone",
Unique = true, -- true: data follows each phone item; false: one persistent phone per character; hex/esx require false Unique = true, -- true: data follows each phone item; false: one persistent phone per character; forced false for metadata-free inventories
Keybind = "F1", -- false disables the configurable phone key mapping Keybind = "F1", -- false disables the configurable phone key mapping
AllowMovement = true, -- true: game input stays active while the mobile phone is open AllowMovement = true, -- true: game input stays active while the mobile phone is open
HoldToLook = { HoldToLook = {
@@ -47,7 +64,6 @@ Config.TestData = {
Enabled = false, -- development/test servers only; keep disabled in production Enabled = false, -- development/test servers only; keep disabled in production
Command = "phonetestdata", Command = "phonetestdata",
AdminOnly = false, -- enable only on development servers; every run is scoped to the executing player's phone AdminOnly = false, -- enable only on development servers; every run is scoped to the executing player's phone
AdminGroups = { "admin", "superadmin" },
} }
Config.CustomApps = { Config.CustomApps = {
@@ -74,7 +90,6 @@ Config.Security = {
Config.AdminPanel = { Config.AdminPanel = {
Enabled = true, Enabled = true,
Command = "phonepanel", Command = "phonepanel",
AdminGroups = { "admin", "superadmin" },
MaximumPlayers = 128, MaximumPlayers = 128,
ReadRequestsPerMinute = 60, ReadRequestsPerMinute = 60,
ActionRequestsPerMinute = 30, ActionRequestsPerMinute = 30,
@@ -84,7 +99,7 @@ Config.AdminPanel = {
} }
Config.Sim = { Config.Sim = {
Enabled = true, -- false: devices receive a persistent random number automatically; hex/esx require false Enabled = true, -- false: devices receive a persistent random number automatically; forced false for metadata-free inventories
RegisteredItem = "sky_phone_sim_registered", RegisteredItem = "sky_phone_sim_registered",
AnonymousItem = "sky_phone_sim_anonymous", AnonymousItem = "sky_phone_sim_anonymous",
NumberLength = 10, -- total number of digits, including NumberPrefix NumberLength = 10, -- total number of digits, including NumberPrefix
@@ -483,7 +498,6 @@ Config.FlipTok = {
MaxPostMedia = 10, MaxPostMedia = 10,
MusicTracks = {}, MusicTracks = {},
VerifyCommand = "fliptokverify", VerifyCommand = "fliptokverify",
AdminGroups = { "admin" },
ReportWebhookConvar = "sky_phone_fliptok_report_webhook", ReportWebhookConvar = "sky_phone_fliptok_report_webhook",
} }
@@ -505,7 +519,6 @@ Config.Picstagram = {
ReportDetailsMaxLength = 500, ReportDetailsMaxLength = 500,
ReportReasons = { "spam", "harassment", "dangerous", "illegal", "other" }, ReportReasons = { "spam", "harassment", "dangerous", "illegal", "other" },
VerifyCommand = "picstagramverify", VerifyCommand = "picstagramverify",
AdminGroups = { "admin" },
} }
Config.Feather = { Config.Feather = {
-1
View File
@@ -2139,7 +2139,6 @@ Locales["de"] = {
} }
Locales["de"].Nui.AdminPanel.configurator.table.subtabs = { Locales["de"].Nui.AdminPanel.configurator.table.subtabs = {
AdminGroups = "Admin-Gruppen",
Dictionaries = "Animationsdateien", Dictionaries = "Animationsdateien",
Clips = "Clips", Clips = "Clips",
Transforms = "Transformationen", Transforms = "Transformationen",
-1
View File
@@ -2139,7 +2139,6 @@ Locales["en"] = {
} }
Locales["en"].Nui.AdminPanel.configurator.table.subtabs = { Locales["en"].Nui.AdminPanel.configurator.table.subtabs = {
AdminGroups = "Admin Groups",
Dictionaries = "Dictionaries", Dictionaries = "Dictionaries",
Clips = "Clips", Clips = "Clips",
Transforms = "Transforms", Transforms = "Transforms",
File diff suppressed because it is too large Load Diff
+10 -4
View File
@@ -2,19 +2,25 @@
Sky Phone media configuration Sky Phone media configuration
This file is loaded on the server only. Keep API keys private. When the This file is loaded on the server only. Keep API keys private. When the
phone configurator is enabled in config.lua, these values are first-run phone configurator is enabled in config.lua, the active media configuration
defaults and the active media configuration is loaded from SQL. is loaded from SQL and frontend builds copy the shipped values from this
file into source/shared/config_default.lua.
]] ]]
Config.Media = { Config.Media = {
GiphyApiKey = "", -- Paste the GIPHY API key here. -- Create or sign in to an account at https://developers.giphy.com/, open
-- the Developer Dashboard, choose "Create an API Key", and paste it here.
GiphyApiKey = "",
GifPageSize = 24, GifPageSize = 24,
GifRating = "pg-13", GifRating = "pg-13",
UrlMaxLength = 2048, UrlMaxLength = 2048,
AllowedGifHosts = { "giphy.com" }, AllowedGifHosts = { "giphy.com" },
-- Create or sign in at https://fivemanage.com/, create or select your team,
-- then open Dashboard > Tokens and create a token with Media access. Keep
-- this server-only token private and paste it here.
FiveManage = { FiveManage = {
ApiKey = "", -- Paste a newly generated FiveManage V3 Media API token here. ApiKey = "",
BaseUrl = "https://api.fivemanage.com/api/v3/file", BaseUrl = "https://api.fivemanage.com/api/v3/file",
RequestTimeoutMs = 10000, RequestTimeoutMs = 10000,
UploadTimeoutMs = 25000, UploadTimeoutMs = 25000,
+4 -1
View File
@@ -6,7 +6,7 @@ use_experimental_fxv2_oal 'yes'
author 'Sky-Systems' author 'Sky-Systems'
description 'Sky Phone' description 'Sky Phone'
version '0.2.5' version '0.2.7'
provide 'lb-phone' provide 'lb-phone'
provide '17mov_Phone' provide '17mov_Phone'
@@ -32,6 +32,7 @@ client_scripts {
'config/config.lua', 'config/config.lua',
'config/locales/en.lua', 'config/locales/en.lua',
'config/locales/de.lua', 'config/locales/de.lua',
'config/locales/es.lua',
'source/bridge/client/callbacks.lua', 'source/bridge/client/callbacks.lua',
'source/client/phone_configurator.lua', 'source/client/phone_configurator.lua',
'source/bridge/client/framework.lua', 'source/bridge/client/framework.lua',
@@ -77,6 +78,8 @@ server_scripts {
'config/media.lua', 'config/media.lua',
'config/locales/en.lua', 'config/locales/en.lua',
'config/locales/de.lua', 'config/locales/de.lua',
'config/locales/es.lua',
'source/shared/config_default.lua',
'source/server/nui_build_check.lua', 'source/server/nui_build_check.lua',
'source/server/update_check.lua', 'source/server/update_check.lua',
'source/bridge/server/database.lua', 'source/bridge/server/database.lua',
@@ -19,3 +19,23 @@ Bridge.Framework.Name = configured_framework
function Bridge.Framework.GetName() function Bridge.Framework.GetName()
return Bridge.Framework.Name return Bridge.Framework.Name
end end
function Bridge.Framework.HasPermission(source, permission)
if type(permission) ~= "string" or permission == "" then
error("[sky_phone] Permission identifiers must be non-empty strings.")
end
local groups = Config.CommandPermissions[permission]
if type(groups) ~= "table" or #groups == 0 then
local message = "[sky_phone] Config.CommandPermissions.%s must contain at least one group."
error(message:format(permission))
end
for index, group in ipairs(groups) do
if type(group) ~= "string" or group == "" then
local message = "[sky_phone] Config.CommandPermissions.%s[%s] must be a non-empty string."
error(message:format(permission, index))
end
end
return Bridge.Framework.HasAdminGroup(source, groups)
end
@@ -24,6 +24,13 @@ function Bridge.Framework.HasAdminGroup(source, groups)
if not player then if not player then
return false return false
end end
for _, group in ipairs(groups) do
if IsPlayerAceAllowed(tostring(source), group) then
return true
end
end
return exports.qbx_core:HasGroup(source, groups) return exports.qbx_core:HasGroup(source, groups)
end end
+92 -46
View File
@@ -1,60 +1,92 @@
local inventory_adapters = {
{ name = "jaksam", resource = "jaksam_inventory" },
{ name = "qs", resource = "qs-inventory" },
{ name = "ps", resource = "ps-inventory", framework = "qb" },
{ name = "codem", resource = "codem-inventory" },
{ name = "tgiann", resource = "tgiann-inventory" },
{ name = "core", resource = "core_inventory" },
{ name = "jpr", resource = "jpr-inventory", framework = "qb" },
{ name = "origen", resource = "origen_inventory" },
{ name = "ak47", resource = "ak47_inventory" },
{ name = "one", resource = "one_inventory" },
{ name = "ox", resource = "ox_inventory" },
{ name = "mf", resource = "mf-inventory", framework = "esx" },
{ name = "smx", resource = "smx-inventory", framework = "esx" },
{ name = "lj", resource = "lj-inventory" },
{ name = "qb", resource = "qb-inventory" },
{ name = "hex", resource = "hex_4_inventory", framework = "esx", metadata = false },
{ name = "esx", resource = "es_extended", framework = "esx", metadata = false },
}
local inventory_aliases = {
["qb-inv"] = "qb",
qbox = "ox",
}
local supported_inventories = {}
for _, adapter in ipairs(inventory_adapters) do
inventory_aliases[adapter.resource] = adapter.name
supported_inventories[adapter.name] = adapter
end
local configured_inventory = Config.Bridge.Inventory local configured_inventory = Config.Bridge.Inventory
configured_inventory = inventory_aliases[configured_inventory] or configured_inventory
local framework_name = Bridge.Framework.GetName()
if configured_inventory == "auto" then if configured_inventory == "auto" then
if GetResourceState("ox_inventory") == "started" then for _, adapter in ipairs(inventory_adapters) do
configured_inventory = "ox" local compatible_framework = not adapter.framework or adapter.framework == framework_name
elseif GetResourceState("qb-inventory") == "started" then if adapter.name ~= "esx" and compatible_framework and GetResourceState(adapter.resource) == "started" then
configured_inventory = "qb" configured_inventory = adapter.name
elseif GetResourceState("lj-inventory") == "started" then break
configured_inventory = "lj" end
elseif GetResourceState("qs-inventory") == "started" then end
configured_inventory = "qs"
elseif GetResourceState("codem-inventory") == "started" then if configured_inventory == "auto" and framework_name == "esx" then
configured_inventory = "codem"
elseif GetResourceState("core_inventory") == "started" then
configured_inventory = "core"
elseif GetResourceState("mf-inventory") == "started" then
configured_inventory = "mf"
elseif GetResourceState("smx-inventory") == "started" then
configured_inventory = "smx"
elseif GetResourceState("hex_4_inventory") == "started" then
configured_inventory = "hex"
elseif Bridge.Framework.GetName() == "esx" then
configured_inventory = "esx" configured_inventory = "esx"
end end
end end
local supported_inventories = { local selected_adapter = supported_inventories[configured_inventory]
ox = true, if not selected_adapter then
qb = true,
lj = true,
qs = true,
codem = true,
core = true,
mf = true,
smx = true,
hex = true,
esx = true,
}
if not supported_inventories[configured_inventory] then
error(("[sky_phone] Unsupported or unavailable inventory '%s'. Configure a supported inventory adapter."):format(tostring(configured_inventory))) error(("[sky_phone] Unsupported or unavailable inventory '%s'. Configure a supported inventory adapter."):format(tostring(configured_inventory)))
end end
if GetResourceState(selected_adapter.resource) ~= "started" then
error(("[sky_phone] Inventory '%s' is configured, but resource '%s' is not started.")
:format(tostring(configured_inventory), selected_adapter.resource))
end
if selected_adapter.framework and selected_adapter.framework ~= framework_name then
error(("[sky_phone] Inventory '%s' is only supported with framework '%s'.")
:format(tostring(configured_inventory), selected_adapter.framework))
end
Bridge.Inventory.Name = configured_inventory Bridge.Inventory.Name = configured_inventory
if configured_inventory == "hex" or configured_inventory == "esx" then local metadata_free_inventory = selected_adapter.metadata == false
if Bridge.Framework.GetName() ~= "esx" then
Bridge.Inventory.ConfigurationError = local function enforce_metadata_compatibility()
("[sky_phone] Inventory '%s' is only supported with ESX."):format(configured_inventory) if not metadata_free_inventory then
elseif Config.Phone.Unique ~= false or Config.Sim.Enabled ~= false then return
Bridge.Inventory.ConfigurationError = ("[sky_phone] Inventory '%s' cannot store unique phone or physical SIM metadata. Set Config.Phone.Unique = false and Config.Sim.Enabled = false, or configure a metadata-capable inventory.") end
:format(configured_inventory)
local modes_changed = Config.Phone.Unique ~= false or Config.Sim.Enabled ~= false
Config.Phone.Unique = false
Config.Sim.Enabled = false
if modes_changed then
Bridge.Debug(
"warn",
"[sky_phone] Inventory '%s' does not support item metadata; unique phones and physical SIM cards were disabled automatically.",
configured_inventory
)
end end
end end
function Bridge.Inventory.NormalizeItem(item, metadata_field) enforce_metadata_compatibility()
if not item then
AddEventHandler("sky_phone:configurator:serverUpdated", enforce_metadata_compatibility)
function Bridge.Inventory.NormalizeItem(item, metadata_field, fallback_slot)
if type(item) ~= "table" then
return nil return nil
end end
@@ -63,11 +95,15 @@ function Bridge.Inventory.NormalizeItem(item, metadata_field)
metadata = item.metadata or item.info metadata = item.metadata or item.info
end end
local slot = item.slot or item.id or fallback_slot
local numeric_slot = tonumber(slot)
local count = tonumber(item.count or item.amount or item.quantity) or 0
return { return {
name = item.name, name = item.name or item.item,
slot = item.slot or item.id, slot = numeric_slot or slot,
count = tonumber(item.count or item.amount) or 0, count = count,
amount = tonumber(item.amount or item.count) or 0, amount = count,
metadata = type(metadata) == "table" and metadata or {}, metadata = type(metadata) == "table" and metadata or {},
} }
end end
@@ -84,3 +120,13 @@ function Bridge.Inventory.MetadataMatches(actual, expected)
end end
return true return true
end end
function Bridge.Inventory.ResolveUsableItem(...)
for index = select("#", ...), 1, -1 do
local candidate = select(index, ...)
if type(candidate) == "table" and (candidate.name or candidate.item) then
return candidate
end
end
return nil
end
@@ -0,0 +1,87 @@
if Bridge.Inventory.Name ~= "ak47" then
return
end
local inventory = exports["ak47_inventory"]
local function normalize(item, fallback_slot)
local normalized = Bridge.Inventory.NormalizeItem(item, "info", fallback_slot)
if not normalized or not normalized.name or normalized.amount < 1 then
return nil
end
return normalized
end
function Bridge.Inventory.GetResourceName()
return "ak47_inventory"
end
function Bridge.Inventory.GetSlot(source, slot_id)
local identifier = Bridge.Framework.GetIdentifier(source)
local numeric_slot = tonumber(slot_id)
if not identifier or not numeric_slot then
return nil
end
return normalize(inventory:GetSlot(identifier, numeric_slot), numeric_slot)
end
function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
local identifier = Bridge.Framework.GetIdentifier(source)
if not identifier then
return {}
end
local matches = {}
local filter_metadata = type(metadata) == "table" and metadata or nil
for index, item in pairs(inventory:GetSlotsWithItem(identifier, item_name, filter_metadata, filter_metadata ~= nil) or {}) do
local normalized = normalize(item, tonumber(index) or index)
if normalized and normalized.name == item_name
and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then
matches[#matches + 1] = normalized
end
end
return matches
end
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
local identifier = Bridge.Framework.GetIdentifier(source)
local slot = identifier and Bridge.Inventory.GetSlot(source, slot_id) or nil
if not slot then
return false
end
local requested_metadata = type(metadata) == "table" and metadata or {}
inventory:SetItemInfo(identifier, slot.slot, requested_metadata)
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
return updated and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata) or false
end
function Bridge.Inventory.CanCarryItem(source, item_name, count)
return inventory:CanAddItem(source, item_name, count) == true
end
function Bridge.Inventory.AddItem(source, item_name, count, slot, metadata)
if not Bridge.Inventory.CanCarryItem(source, item_name, count, metadata) then
return false
end
return inventory:AddItem(source, item_name, count, slot, metadata or {}) == true
end
function Bridge.Inventory.RemoveItem(source, item_name, count, slot, metadata)
if metadata and not slot then
local slots = Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
slot = slots[1] and slots[1].slot or nil
if not slot then
return 0
end
end
local removed = inventory:RemoveItem(source, item_name, count, slot)
return removed ~= false and count or 0
end
function Bridge.Inventory.RegisterUsableItem(item_name, callback)
return Bridge.Framework.RegisterUsableItem(item_name, function(source, ...)
callback(source, normalize(Bridge.Inventory.ResolveUsableItem(...)))
end)
end
@@ -9,7 +9,8 @@ local function normalize(item)
end end
local function get_inventory(source) local function get_inventory(source)
return inventory:GetInventory(false, source) or {} local identifier = Bridge.Framework.GetIdentifier(source)
return identifier and inventory:GetInventory(identifier, source) or {}
end end
function Bridge.Inventory.GetResourceName() function Bridge.Inventory.GetResourceName()
@@ -27,9 +28,14 @@ end
function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata) function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
local matches = {} local matches = {}
for _, item in pairs(get_inventory(source)) do local items = inventory:GetItemsByName(source, item_name) or {}
if items.name then
items = { items }
end
for _, item in pairs(items) do
local normalized = normalize(item) local normalized = normalize(item)
if normalized.name == item_name and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then if normalized and normalized.name == item_name
and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then
matches[#matches + 1] = normalized matches[#matches + 1] = normalized
end end
end end
@@ -66,7 +72,7 @@ function Bridge.Inventory.RemoveItem(source, item_name, count, slot, metadata)
end end
function Bridge.Inventory.RegisterUsableItem(item_name, callback) function Bridge.Inventory.RegisterUsableItem(item_name, callback)
return Bridge.Framework.RegisterUsableItem(item_name, function(source, item) return Bridge.Framework.RegisterUsableItem(item_name, function(source, ...)
callback(source, normalize(item)) callback(source, normalize(Bridge.Inventory.ResolveUsableItem(...)))
end) end)
end end
@@ -39,11 +39,15 @@ end
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata) function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
local numeric_slot = tonumber(slot_id) local numeric_slot = tonumber(slot_id)
if not numeric_slot then local slot = numeric_slot and Bridge.Inventory.GetSlot(source, numeric_slot) or nil
if not slot then
return false return false
end end
inventory:setMetadata(source, numeric_slot, metadata or {})
return true local requested_metadata = type(metadata) == "table" and metadata or {}
inventory:setMetadata(source, numeric_slot, requested_metadata)
local updated = Bridge.Inventory.GetSlot(source, numeric_slot)
return updated and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata) or false
end end
function Bridge.Inventory.CanCarryItem() function Bridge.Inventory.CanCarryItem()
@@ -0,0 +1,94 @@
if Bridge.Inventory.Name ~= "jaksam" then
return
end
local inventory = exports["jaksam_inventory"]
local function normalize(item)
local normalized = Bridge.Inventory.NormalizeItem(item)
if not normalized or not normalized.name or normalized.amount < 1 then
return nil
end
return normalized
end
local function get_inventory(source)
local player_inventory = inventory:getInventory(source)
return type(player_inventory) == "table" and type(player_inventory.items) == "table"
and player_inventory.items or {}
end
function Bridge.Inventory.GetResourceName()
return "jaksam_inventory"
end
function Bridge.Inventory.GetSlot(source, slot_id)
for index, item in pairs(get_inventory(source)) do
local normalized = normalize(item)
local item_slot = normalized and (normalized.slot or tonumber(index)) or nil
if tostring(item_slot) == tostring(slot_id) then
normalized.slot = item_slot
return normalized
end
end
return nil
end
function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
local matches = {}
for index, item in pairs(get_inventory(source)) do
local normalized = normalize(item)
if normalized and normalized.name == item_name
and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then
normalized.slot = normalized.slot or tonumber(index) or index
matches[#matches + 1] = normalized
end
end
return matches
end
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
local slot = Bridge.Inventory.GetSlot(source, slot_id)
if not slot then
return false
end
local requested_metadata = type(metadata) == "table" and metadata or {}
local success = inventory:setItemMetadataInSlot(source, slot.slot, requested_metadata)
if success ~= true then
return false
end
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
return updated and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata) or false
end
function Bridge.Inventory.CanCarryItem(source, item_name, count)
return inventory:canCarryItem(source, item_name, count) == true
end
function Bridge.Inventory.AddItem(source, item_name, count, slot, metadata)
if not Bridge.Inventory.CanCarryItem(source, item_name, count, metadata) then
return false
end
local success = inventory:addItem(source, item_name, count, metadata or {}, slot)
return success == true
end
function Bridge.Inventory.RemoveItem(source, item_name, count, slot, metadata)
if metadata and not slot then
local slots = Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
slot = slots[1] and slots[1].slot or nil
if not slot then
return 0
end
end
local success = inventory:removeItem(source, item_name, count, nil, slot)
return success == true and count or 0
end
function Bridge.Inventory.RegisterUsableItem(item_name, callback)
inventory:registerUsableItem(item_name, function(source, ...)
callback(source, normalize(Bridge.Inventory.ResolveUsableItem(...)))
end)
return true
end
@@ -0,0 +1,109 @@
if Bridge.Inventory.Name ~= "jpr" then
return
end
if Bridge.Framework.GetName() ~= "qb" then
error("[sky_phone] jpr-inventory is only supported with QBCore.")
end
local inventory = exports["jpr-inventory"]
local QBCore = exports["qb-core"]:GetCoreObject()
local function normalize(item, fallback_slot)
local normalized = Bridge.Inventory.NormalizeItem(item, "info", fallback_slot)
if not normalized or not normalized.name or normalized.amount < 1 then
return nil
end
return normalized
end
local function get_inventory(source)
local player = QBCore.Functions.GetPlayer(tonumber(source))
return player and player.PlayerData.items or {}
end
local function restore_slot(source, slot, metadata)
local restored = inventory:AddItem(source, slot.name, slot.amount, slot.slot, metadata, "sky_phone:metadata-rollback")
if not restored then
Bridge.Debug(
"error",
"[sky_phone] jpr-inventory could not restore item '%s' in slot %s after a metadata update failed.",
tostring(slot.name),
tostring(slot.slot)
)
end
return restored == true
end
function Bridge.Inventory.GetResourceName()
return "jpr-inventory"
end
function Bridge.Inventory.GetSlot(source, slot_id)
local numeric_slot = tonumber(slot_id)
return numeric_slot and normalize(inventory:GetItemBySlot(source, numeric_slot), numeric_slot) or nil
end
function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
local matches = {}
for index, item in pairs(get_inventory(source)) do
local normalized = normalize(item, tonumber(index) or index)
if normalized and normalized.name == item_name
and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then
normalized.slot = normalized.slot or tonumber(index) or index
matches[#matches + 1] = normalized
end
end
return matches
end
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
local slot = Bridge.Inventory.GetSlot(source, slot_id)
if not slot or slot.amount < 1 then
return false
end
local previous_metadata = slot.metadata
local requested_metadata = type(metadata) == "table" and metadata or {}
if inventory:RemoveItem(source, slot.name, slot.amount, slot.slot, "sky_phone:metadata-update") ~= true then
return false
end
if inventory:AddItem(source, slot.name, slot.amount, slot.slot, requested_metadata, "sky_phone:metadata-update") ~= true then
restore_slot(source, slot, previous_metadata)
return false
end
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
if updated and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata) then
return true
end
inventory:RemoveItem(source, slot.name, slot.amount, slot.slot, "sky_phone:metadata-rollback")
restore_slot(source, slot, previous_metadata)
return false
end
function Bridge.Inventory.CanCarryItem()
return true
end
function Bridge.Inventory.AddItem(source, item_name, count, slot, metadata)
return inventory:AddItem(source, item_name, count, slot, metadata or {}, "sky_phone") == true
end
function Bridge.Inventory.RemoveItem(source, item_name, count, slot, metadata)
if metadata and not slot then
local slots = Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
slot = slots[1] and slots[1].slot or nil
if not slot then
return 0
end
end
return inventory:RemoveItem(source, item_name, count, slot, "sky_phone") == true and count or 0
end
function Bridge.Inventory.RegisterUsableItem(item_name, callback)
return Bridge.Framework.RegisterUsableItem(item_name, function(source, ...)
callback(source, normalize(Bridge.Inventory.ResolveUsableItem(...)))
end)
end
@@ -0,0 +1,76 @@
if Bridge.Inventory.Name ~= "one" then
return
end
local inventory = exports.one_inventory
local function normalize(item, fallback_slot)
local normalized = Bridge.Inventory.NormalizeItem(item, nil, fallback_slot)
if not normalized or not normalized.name or normalized.amount < 1 then
return nil
end
return normalized
end
function Bridge.Inventory.GetResourceName()
return "one_inventory"
end
function Bridge.Inventory.GetSlot(source, slot_id)
local numeric_slot = tonumber(slot_id)
return numeric_slot and normalize(inventory:GetSlot(source, numeric_slot), numeric_slot) or nil
end
function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
local matches = {}
for index, item in pairs(inventory:SearchInventory(source, item_name, metadata) or {}) do
local normalized = normalize(item, tonumber(index) or index)
if normalized and normalized.name == item_name
and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then
matches[#matches + 1] = normalized
end
end
return matches
end
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
local slot = Bridge.Inventory.GetSlot(source, slot_id)
if not slot then
return false
end
local requested_metadata = type(metadata) == "table" and metadata or {}
if inventory:SetItemMetadata(source, slot.slot, requested_metadata) ~= true then
return false
end
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
return updated and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata) or false
end
function Bridge.Inventory.CanCarryItem(source, item_name, count)
return inventory:CanCarryItem(source, item_name, count) == true
end
function Bridge.Inventory.AddItem(source, item_name, count, slot, metadata)
if not Bridge.Inventory.CanCarryItem(source, item_name, count, metadata) then
return false
end
return inventory:AddItem(source, item_name, count, metadata or {}, slot) == true
end
function Bridge.Inventory.RemoveItem(source, item_name, count, slot, metadata)
if metadata and not slot then
local slots = Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
slot = slots[1] and slots[1].slot or nil
if not slot then
return 0
end
end
return inventory:RemoveItem(source, item_name, count, metadata, slot) == true and count or 0
end
function Bridge.Inventory.RegisterUsableItem(item_name, callback)
return Bridge.Framework.RegisterUsableItem(item_name, function(source, ...)
callback(source, normalize(Bridge.Inventory.ResolveUsableItem(...)))
end)
end
@@ -0,0 +1,88 @@
if Bridge.Inventory.Name ~= "origen" then
return
end
local inventory = exports.origen_inventory
local function normalize(item)
local normalized = Bridge.Inventory.NormalizeItem(item)
if not normalized or not normalized.name or normalized.amount < 1 then
return nil
end
return normalized
end
local function get_inventory(source)
local player_inventory = inventory:GetInventory(source)
return type(player_inventory) == "table" and type(player_inventory.inventory) == "table"
and player_inventory.inventory or {}
end
function Bridge.Inventory.GetResourceName()
return "origen_inventory"
end
function Bridge.Inventory.GetSlot(source, slot_id)
for index, item in pairs(get_inventory(source)) do
local normalized = normalize(item)
local item_slot = normalized and (normalized.slot or tonumber(index)) or nil
if tostring(item_slot) == tostring(slot_id) then
normalized.slot = item_slot
return normalized
end
end
return nil
end
function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
local matches = {}
for index, item in pairs(get_inventory(source)) do
local normalized = normalize(item)
if normalized and normalized.name == item_name
and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then
normalized.slot = normalized.slot or tonumber(index) or index
matches[#matches + 1] = normalized
end
end
return matches
end
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
local slot = Bridge.Inventory.GetSlot(source, slot_id)
if not slot then
return false
end
local requested_metadata = type(metadata) == "table" and metadata or {}
inventory:setMetadata(source, slot.slot, requested_metadata)
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
return updated and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata) or false
end
function Bridge.Inventory.CanCarryItem(source, item_name, count)
return inventory:canCarryItem(source, item_name, count) == true
end
function Bridge.Inventory.AddItem(source, item_name, count, slot, metadata)
if not Bridge.Inventory.CanCarryItem(source, item_name, count, metadata) then
return false
end
return inventory:addItem(source, item_name, count, metadata or {}, slot, false) == true
end
function Bridge.Inventory.RemoveItem(source, item_name, count, slot, metadata)
if metadata and not slot then
local slots = Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
slot = slots[1] and slots[1].slot or nil
if not slot then
return 0
end
end
return inventory:removeItem(source, item_name, count, nil, slot, false) == true and count or 0
end
function Bridge.Inventory.RegisterUsableItem(item_name, callback)
return Bridge.Framework.RegisterUsableItem(item_name, function(source, ...)
callback(source, normalize(Bridge.Inventory.ResolveUsableItem(...)))
end)
end
@@ -44,8 +44,10 @@ function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
return false return false
end end
inventory:SetMetadata(source, tonumber(slot_id), metadata or {}) local requested_metadata = type(metadata) == "table" and metadata or {}
return true inventory:SetMetadata(source, tonumber(slot_id), requested_metadata)
local updated = Bridge.Inventory.GetSlot(source, slot_id)
return updated and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata) or false
end end
function Bridge.Inventory.CanCarryItem(source, item_name, count, metadata) function Bridge.Inventory.CanCarryItem(source, item_name, count, metadata)
@@ -0,0 +1,109 @@
if Bridge.Inventory.Name ~= "ps" then
return
end
if Bridge.Framework.GetName() ~= "qb" then
error("[sky_phone] ps-inventory is only supported with QBCore.")
end
local inventory = exports["ps-inventory"]
local QBCore = exports["qb-core"]:GetCoreObject()
local function normalize(item, fallback_slot)
local normalized = Bridge.Inventory.NormalizeItem(item, "info", fallback_slot)
if not normalized or not normalized.name or normalized.amount < 1 then
return nil
end
return normalized
end
local function get_inventory(source)
local player = QBCore.Functions.GetPlayer(tonumber(source))
return player and player.PlayerData.items or {}
end
local function restore_slot(source, slot, metadata)
local restored = inventory:AddItem(source, slot.name, slot.amount, slot.slot, metadata)
if not restored then
Bridge.Debug(
"error",
"[sky_phone] ps-inventory could not restore item '%s' in slot %s after a metadata update failed.",
tostring(slot.name),
tostring(slot.slot)
)
end
return restored == true
end
function Bridge.Inventory.GetResourceName()
return "ps-inventory"
end
function Bridge.Inventory.GetSlot(source, slot_id)
local numeric_slot = tonumber(slot_id)
return numeric_slot and normalize(inventory:GetItemBySlot(source, numeric_slot), numeric_slot) or nil
end
function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
local matches = {}
for index, item in pairs(get_inventory(source)) do
local normalized = normalize(item, tonumber(index) or index)
if normalized and normalized.name == item_name
and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then
normalized.slot = normalized.slot or tonumber(index) or index
matches[#matches + 1] = normalized
end
end
return matches
end
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
local slot = Bridge.Inventory.GetSlot(source, slot_id)
if not slot or slot.amount < 1 then
return false
end
local previous_metadata = slot.metadata
local requested_metadata = type(metadata) == "table" and metadata or {}
if inventory:RemoveItem(source, slot.name, slot.amount, slot.slot) ~= true then
return false
end
if inventory:AddItem(source, slot.name, slot.amount, slot.slot, requested_metadata) ~= true then
restore_slot(source, slot, previous_metadata)
return false
end
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
if updated and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata) then
return true
end
inventory:RemoveItem(source, slot.name, slot.amount, slot.slot)
restore_slot(source, slot, previous_metadata)
return false
end
function Bridge.Inventory.CanCarryItem()
return true
end
function Bridge.Inventory.AddItem(source, item_name, count, slot, metadata)
return inventory:AddItem(source, item_name, count, slot, metadata or {}) == true
end
function Bridge.Inventory.RemoveItem(source, item_name, count, slot, metadata)
if metadata and not slot then
local slots = Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
slot = slots[1] and slots[1].slot or nil
if not slot then
return 0
end
end
return inventory:RemoveItem(source, item_name, count, slot) == true and count or 0
end
function Bridge.Inventory.RegisterUsableItem(item_name, callback)
return Bridge.Framework.RegisterUsableItem(item_name, function(source, ...)
callback(source, normalize(Bridge.Inventory.ResolveUsableItem(...)))
end)
end
@@ -0,0 +1,75 @@
if Bridge.Inventory.Name ~= "tgiann" then
return
end
local inventory = exports["tgiann-inventory"]
local function normalize(item, fallback_slot)
local normalized = Bridge.Inventory.NormalizeItem(item, "info", fallback_slot)
if not normalized or not normalized.name or normalized.amount < 1 then
return nil
end
return normalized
end
function Bridge.Inventory.GetResourceName()
return "tgiann-inventory"
end
function Bridge.Inventory.GetSlot(source, slot_id)
local numeric_slot = tonumber(slot_id)
return numeric_slot and normalize(inventory:GetItemBySlot(source, numeric_slot), numeric_slot) or nil
end
function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
local matches = {}
for index, item in pairs(inventory:GetPlayerItems(source) or {}) do
local normalized = normalize(item, tonumber(index) or index)
if normalized and normalized.name == item_name
and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then
normalized.slot = normalized.slot or tonumber(index) or index
matches[#matches + 1] = normalized
end
end
return matches
end
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
local slot = Bridge.Inventory.GetSlot(source, slot_id)
if not slot then
return false
end
local requested_metadata = type(metadata) == "table" and metadata or {}
inventory:UpdateItemMetadata(source, slot.name, slot.slot, requested_metadata)
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
return updated and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata) or false
end
function Bridge.Inventory.CanCarryItem(source, item_name, count)
return inventory:CanCarryItem(source, item_name, count) == true
end
function Bridge.Inventory.AddItem(source, item_name, count, slot, metadata)
if not Bridge.Inventory.CanCarryItem(source, item_name, count, metadata) then
return false
end
return inventory:AddItem(source, item_name, count, slot, metadata or {}) == true
end
function Bridge.Inventory.RemoveItem(source, item_name, count, slot, metadata)
if metadata and not slot then
local slots = Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
slot = slots[1] and slots[1].slot or nil
if not slot then
return 0
end
end
return inventory:RemoveItem(source, item_name, count, slot, metadata) == true and count or 0
end
function Bridge.Inventory.RegisterUsableItem(item_name, callback)
return Bridge.Framework.RegisterUsableItem(item_name, function(source, ...)
callback(source, normalize(Bridge.Inventory.ResolveUsableItem(...)))
end)
end
+2 -2
View File
@@ -98,7 +98,7 @@ end
local function require_admin(source, operation, maximum) local function require_admin(source, operation, maximum)
if not Config.AdminPanel.Enabled if not Config.AdminPanel.Enabled
or not Bridge.Framework.HasAdminGroup(source, Config.AdminPanel.AdminGroups) or not Bridge.Framework.HasPermission(source, "phonepanel")
then then
Bridge.Debug("warn", "[sky_phone] Rejected admin panel access from source %s.", tostring(source)) Bridge.Debug("warn", "[sky_phone] Rejected admin panel access from source %s.", tostring(source))
return nil, { success = false, error = "not_authorized" } return nil, { success = false, error = "not_authorized" }
@@ -121,7 +121,7 @@ local function run_admin_command(command_source)
TriggerClientEvent("sky_phone:admin:command-error", player_source, "disabled") TriggerClientEvent("sky_phone:admin:command-error", player_source, "disabled")
return return
end end
if not Bridge.Framework.HasAdminGroup(player_source, Config.AdminPanel.AdminGroups) then if not Bridge.Framework.HasPermission(player_source, "phonepanel") then
Bridge.Debug( Bridge.Debug(
"warn", "warn",
"[sky_phone] Rejected admin panel command from source %s.", "[sky_phone] Rejected admin panel command from source %s.",
+1 -1
View File
@@ -943,7 +943,7 @@ local function run_verify_command(source, arguments)
notificationType = notification_type, notificationType = notification_type,
}) })
end end
if source ~= 0 and not Bridge.Framework.HasAdminGroup(source, Config.FlipTok.AdminGroups) then if source ~= 0 and not Bridge.Framework.HasPermission(source, "fliptokverify") then
send_command_feedback(command_locale.noPermission, "error") send_command_feedback(command_locale.noPermission, "error")
Bridge.Debug( Bridge.Debug(
"warn", "warn",
+45 -16
View File
@@ -17,6 +17,21 @@ local updated_at
local updated_by_name local updated_by_name
local is_sequence local is_sequence
local FIXED_CONFIG_PATHS = {
CommandPermissions = true,
["AdminPanel.AdminGroups"] = true,
["TestData.AdminGroups"] = true,
["FlipTok.AdminGroups"] = true,
["Picstagram.AdminGroups"] = true,
}
if configurator_enabled then
print(
"[sky_phone] Phone Configurator enabled: file-based settings from config.lua " ..
"(except Config.CommandPermissions) and media.lua are disabled; SQL configuration is active."
)
end
local CLIENT_CONFIG_KEYS = { local CLIENT_CONFIG_KEYS = {
AdminPanel = true, AdminPanel = true,
Animations = true, Animations = true,
@@ -252,7 +267,7 @@ local function upgrade_legacy_map(defaults, saved)
return { __skyType = "map", entries = entries } return { __skyType = "map", entries = entries }
end end
local function merge_values(defaults, saved, path) local function merge_values(defaults, saved, path, excluded_paths)
path = path or "" path = path or ""
if type(defaults) ~= "table" or type(saved) ~= "table" then if type(defaults) ~= "table" or type(saved) ~= "table" then
return copy_value(saved) return copy_value(saved)
@@ -278,7 +293,12 @@ local function merge_values(defaults, saved, path)
key = entry.key, key = entry.key,
keyType = entry.keyType, keyType = entry.keyType,
value = saved_entry value = saved_entry
and merge_values(entry.value, saved_entry.value, path .. "." .. tostring(entry.key)) and merge_values(
entry.value,
saved_entry.value,
path .. "." .. tostring(entry.key),
excluded_paths
)
or copy_value(entry.value), or copy_value(entry.value),
} }
included[identity] = true included[identity] = true
@@ -298,7 +318,7 @@ local function merge_values(defaults, saved, path)
local merged = copy_value(saved) local merged = copy_value(saved)
for index, child in ipairs(defaults) do for index, child in ipairs(defaults) do
merged[index] = saved[index] ~= nil merged[index] = saved[index] ~= nil
and merge_values(child, saved[index], path .. "." .. tostring(index)) and merge_values(child, saved[index], path .. "." .. tostring(index), excluded_paths)
or copy_value(child) or copy_value(child)
end end
return merged return merged
@@ -309,11 +329,13 @@ local function merge_values(defaults, saved, path)
local merged = copy_value(defaults) local merged = copy_value(defaults)
for key, child in pairs(saved) do for key, child in pairs(saved) do
if merged[key] ~= nil then local child_path = path == "" and tostring(key) or (path .. "." .. tostring(key))
local child_path = path == "" and tostring(key) or (path .. "." .. tostring(key)) if not excluded_paths or not excluded_paths[child_path] then
merged[key] = merge_values(merged[key], child, child_path) if merged[key] ~= nil then
else merged[key] = merge_values(merged[key], child, child_path, excluded_paths)
merged[key] = copy_value(child) else
merged[key] = copy_value(child)
end
end end
end end
return merged return merged
@@ -356,10 +378,12 @@ local function apply_runtime_configuration()
local runtime_config = deserialize_value(stored_config) local runtime_config = deserialize_value(stored_config)
for key, value in pairs(runtime_config) do for key, value in pairs(runtime_config) do
if type(Config[key]) == "table" and type(value) == "table" then if key ~= "CommandPermissions" then
apply_runtime_table(Config[key], value) if type(Config[key]) == "table" and type(value) == "table" then
else apply_runtime_table(Config[key], value)
Config[key] = value else
Config[key] = value
end
end end
end end
local runtime_media = deserialize_value(stored_media) local runtime_media = deserialize_value(stored_media)
@@ -1032,7 +1056,12 @@ local function read_stored_row()
end end
local function apply_stored_row(row) local function apply_stored_row(row)
stored_config = merge_values(default_config, decode_payload(row.config_payload, "config"), "") stored_config = merge_values(
default_config,
decode_payload(row.config_payload, "config"),
"",
FIXED_CONFIG_PATHS
)
stored_media = merge_values(default_media, decode_payload(row.media_payload, "media"), "") stored_media = merge_values(default_media, decode_payload(row.media_payload, "media"), "")
revision = tonumber(row.revision) or 1 revision = tonumber(row.revision) or 1
updated_at = row.updated_at updated_at = row.updated_at
@@ -1040,12 +1069,12 @@ local function apply_stored_row(row)
end end
default_config = {} default_config = {}
for key, value in pairs(Config) do for key, value in pairs(ConfigDefaults) do
if key ~= "Media" and key ~= "PhoneConfigurator" then if key ~= "Media" and key ~= "PhoneConfigurator" and key ~= "CommandPermissions" then
default_config[key] = serialize_value(value) default_config[key] = serialize_value(value)
end end
end end
default_media = serialize_value(Config.Media) default_media = serialize_value(ConfigDefaults.Media)
Bridge.Database.Migrate("sky_phone_configurator", { SkyPhoneConfiguratorSchema }) Bridge.Database.Migrate("sky_phone_configurator", { SkyPhoneConfiguratorSchema })
Bridge.Database.Query(([[ Bridge.Database.Query(([[
+2 -2
View File
@@ -319,7 +319,7 @@ local function create_activity(recipient_id, actor_id, kind, post_id)
end end
local function is_admin(source) local function is_admin(source)
return Bridge.Framework.HasAdminGroup(source, Config.Picstagram.AdminGroups) return Bridge.Framework.HasPermission(source, "picstagramadmin")
end end
Bridge.Callbacks.Register("sky_phone:picstagram:register", function(source, data) Bridge.Callbacks.Register("sky_phone:picstagram:register", function(source, data)
@@ -1464,7 +1464,7 @@ local function run_verify_command(source, args)
notificationType = notification_type, notificationType = notification_type,
}) })
end end
if source ~= 0 and not Bridge.Framework.HasAdminGroup(source, Config.Picstagram.AdminGroups) then if source ~= 0 and not Bridge.Framework.HasPermission(source, "picstagramverify") then
send_command_feedback(command_locale.noPermission, "error") send_command_feedback(command_locale.noPermission, "error")
Bridge.Debug( Bridge.Debug(
"warn", "warn",
+1 -1
View File
@@ -1045,7 +1045,7 @@ local function run_test_data_command(source)
Bridge.Debug("warn", "[sky_phone] The test data command must be run by an in-game player.") Bridge.Debug("warn", "[sky_phone] The test data command must be run by an in-game player.")
return return
end end
if Config.TestData.AdminOnly and not Bridge.Framework.HasAdminGroup(source, Config.TestData.AdminGroups) then if Config.TestData.AdminOnly and not Bridge.Framework.HasPermission(source, "phonetestdata") then
Bridge.Debug("warn", "[sky_phone] Source %s attempted to run the restricted test data command.", tostring(source)) Bridge.Debug("warn", "[sky_phone] Source %s attempted to run the restricted test data command.", tostring(source))
TriggerClientEvent("sky_phone:testdata:feedback", source, false) TriggerClientEvent("sky_phone:testdata:feedback", source, false)
return return
File diff suppressed because it is too large Load Diff
+64
View File
@@ -0,0 +1,64 @@
Config = {
Bridge = {
Framework = "qbox",
},
CommandPermissions = {
phonepanel = { "god", "superadmin", "admin" },
},
}
Bridge = {
Framework = {},
}
function GetResourceState(resource)
assert(resource == "qbx_core")
return "started"
end
function GetPlayers()
return { "1" }
end
local ace_permissions = {}
local framework_permission = false
function IsPlayerAceAllowed(source, permission)
assert(source == "1")
return ace_permissions[permission] == true
end
exports = {
qbx_core = {
GetPlayer = function(_, source)
if tonumber(source) ~= 1 then
return nil
end
return {
PlayerData = {
citizenid = "test-citizen",
},
}
end,
HasGroup = function(_, source, groups)
assert(source == 1)
assert(groups == Config.CommandPermissions.phonepanel)
return framework_permission
end,
},
}
dofile("sky_phone/source/bridge/server/framework.lua")
dofile("sky_phone/source/bridge/server/frameworks/qbox.lua")
ace_permissions.admin = true
assert(Bridge.Framework.HasPermission(1, "phonepanel"), "Qbox ACE permissions must grant access")
ace_permissions.admin = false
framework_permission = true
assert(Bridge.Framework.HasPermission(1, "phonepanel"), "Qbox framework groups must remain supported")
local success = pcall(Bridge.Framework.HasPermission, 1, "missing")
assert(not success, "Missing fixed permission definitions must fail loudly")
print("framework permission tests passed")
+18 -5
View File
@@ -1,3 +1,9 @@
local event_handlers = {}
AddEventHandler = function(event_name, callback)
event_handlers[event_name] = callback
end
local function reset_bridge(inventory_name, unique_phones, sim_cards_enabled) local function reset_bridge(inventory_name, unique_phones, sim_cards_enabled)
Config = { Config = {
Bridge = { Bridge = {
@@ -193,11 +199,18 @@ exports = {
} }
load_inventory_contract("sky_phone/source/bridge/server/inventory/esx.lua") load_inventory_contract("sky_phone/source/bridge/server/inventory/esx.lua")
local ok, configuration_error = pcall(Bridge.Inventory.RegisterUsableItem, "phone", function() assert(Config.Phone.Unique == false)
end) assert(Config.Sim.Enabled == false)
assert(not ok) assert(Bridge.Inventory.ConfigurationError == nil)
assert(configuration_error:find("Config.Phone.Unique = false", 1, true))
assert(configuration_error:find("Config.Sim.Enabled = false", 1, true)) Config.Phone.Unique = true
Config.Sim.Enabled = true
event_handlers["sky_phone:configurator:serverUpdated"]()
assert(Config.Phone.Unique == false)
assert(Config.Sim.Enabled == false)
assert(Bridge.Inventory.RegisterUsableItem("phone", function()
end))
local manifest_file = assert(io.open("sky_phone/fxmanifest.lua", "rb")) local manifest_file = assert(io.open("sky_phone/fxmanifest.lua", "rb"))
local manifest = manifest_file:read("*a") local manifest = manifest_file:read("*a")
+321
View File
@@ -0,0 +1,321 @@
AddEventHandler = function()
end
local function reset_bridge(inventory_name, resource_name, inventory_export, framework_name, extra_exports)
Config = {
Bridge = { Inventory = inventory_name },
Phone = { Unique = true },
Sim = { Enabled = true },
}
local usable_items = {}
Bridge = {
Debug = function()
end,
Framework = {
GetName = function()
return framework_name or "qb"
end,
GetIdentifier = function(source)
return ("player:%s"):format(source)
end,
RegisterUsableItem = function(item_name, callback)
usable_items[item_name] = callback
return true
end,
},
Inventory = {},
}
exports = extra_exports or {}
exports[resource_name] = inventory_export
GetResourceState = function(candidate)
return candidate == resource_name and "started" or "missing"
end
return usable_items
end
local function load_adapter(adapter_path)
dofile("sky_phone/source/bridge/server/inventory.lua")
dofile(adapter_path)
dofile("sky_phone/source/bridge/server/inventory_contract.lua")
end
local function assert_metadata(metadata, imei)
assert(type(metadata) == "table" and metadata.imei == imei)
end
Config = {
Bridge = { Inventory = "auto" },
Phone = { Unique = true },
Sim = { Enabled = true },
}
Bridge = {
Framework = {
GetName = function()
return "esx"
end,
},
Inventory = {},
}
GetResourceState = function(resource_name)
if resource_name == "ps-inventory" or resource_name == "tgiann-inventory" or resource_name == "ox_inventory" then
return "started"
end
return "missing"
end
dofile("sky_phone/source/bridge/server/inventory.lua")
assert(Bridge.Inventory.Name == "tgiann", "auto detection must follow the shared Sky inventory priority")
local ak47_items = {
[3] = { name = "phone", slot = 3, amount = 1, info = { owner = "kept" } },
}
local ak47 = {}
function ak47:GetSlot(identifier, slot)
assert(identifier == "player:21")
return ak47_items[slot]
end
function ak47:GetSlotsWithItem(identifier, item_name)
assert(identifier == "player:21" and item_name == "phone")
return ak47_items
end
function ak47:SetItemInfo(identifier, slot, metadata)
assert(identifier == "player:21")
ak47_items[slot].info = metadata
end
function ak47:CanAddItem()
return true
end
function ak47:AddItem()
return true
end
function ak47:RemoveItem()
return true
end
local ak47_usable = reset_bridge("ak47_inventory", "ak47_inventory", ak47)
load_adapter("sky_phone/source/bridge/server/inventory/ak47.lua")
assert(Bridge.Inventory.Name == "ak47", "resource-name aliases must resolve to adapter names")
assert(Bridge.Inventory.SetSlotMetadata(21, 3, { owner = "kept", imei = "111111111111111" }))
assert_metadata(Bridge.Inventory.GetSlot(21, 3).metadata, "111111111111111")
assert(Bridge.Inventory.RegisterUsableItem("phone", function(source, item)
assert(source == 21 and item.slot == 3)
end))
ak47_usable.phone(21, "phone", ak47_items[3])
local jaksam_items = {
[5] = { name = "phone", slot = 5, amount = 1, metadata = {} },
}
local jaksam_usable = {}
local jaksam = {}
function jaksam:getInventory()
return { items = jaksam_items }
end
function jaksam:setItemMetadataInSlot(_, slot, metadata)
jaksam_items[slot].metadata = metadata
return true
end
function jaksam:canCarryItem()
return true
end
function jaksam:addItem()
return true
end
function jaksam:removeItem()
return true
end
function jaksam:registerUsableItem(item_name, callback)
jaksam_usable[item_name] = callback
end
reset_bridge("jaksam", "jaksam_inventory", jaksam)
load_adapter("sky_phone/source/bridge/server/inventory/jaksam.lua")
assert(Bridge.Inventory.SetSlotMetadata(22, 5, { imei = "222222222222222" }))
assert_metadata(Bridge.Inventory.GetSlot(22, 5).metadata, "222222222222222")
assert(Bridge.Inventory.RegisterUsableItem("phone", function(source, item)
assert(source == 22 and item.slot == 5)
end))
jaksam_usable.phone(22, jaksam_items[5])
local one_items = {
[7] = { name = "phone", slot = 7, count = 1, metadata = {} },
}
local one = {}
function one:GetSlot(_, slot)
return one_items[slot]
end
function one:SearchInventory()
return one_items
end
function one:SetItemMetadata(_, slot, metadata)
one_items[slot].metadata = metadata
return true
end
function one:CanCarryItem()
return true
end
function one:AddItem()
return true
end
function one:RemoveItem()
return true
end
reset_bridge("one", "one_inventory", one)
load_adapter("sky_phone/source/bridge/server/inventory/one.lua")
assert(Bridge.Inventory.SetSlotMetadata(23, 7, { imei = "333333333333333" }))
assert_metadata(Bridge.Inventory.GetSlotsWithItem(23, "phone")[1].metadata, "333333333333333")
local origen_items = {
[9] = { name = "phone", slot = 9, amount = 1, metadata = {} },
}
local origen = {}
function origen:GetInventory()
return { inventory = origen_items }
end
function origen:setMetadata(_, slot, metadata)
origen_items[slot].metadata = metadata
end
function origen:canCarryItem()
return true
end
function origen:addItem()
return true
end
function origen:removeItem()
return true
end
reset_bridge("origen", "origen_inventory", origen)
load_adapter("sky_phone/source/bridge/server/inventory/origen.lua")
assert(Bridge.Inventory.SetSlotMetadata(24, 9, { imei = "444444444444444" }))
assert_metadata(Bridge.Inventory.GetSlot(24, 9).metadata, "444444444444444")
local tgiann_items = {
[11] = { name = "phone", slot = 11, amount = 1, info = {} },
}
local tgiann = {}
function tgiann:GetPlayerItems()
return tgiann_items
end
function tgiann:GetItemBySlot(_, slot)
return tgiann_items[slot]
end
function tgiann:UpdateItemMetadata(_, _, slot, metadata)
tgiann_items[slot].info = metadata
end
function tgiann:CanCarryItem()
return true
end
function tgiann:AddItem()
return true
end
function tgiann:RemoveItem()
return true
end
reset_bridge("tgiann", "tgiann-inventory", tgiann)
load_adapter("sky_phone/source/bridge/server/inventory/tgiann.lua")
assert(Bridge.Inventory.SetSlotMetadata(25, 11, { imei = "555555555555555" }))
assert_metadata(Bridge.Inventory.GetSlot(25, 11).metadata, "555555555555555")
local qb_items = {
[13] = { name = "phone", slot = 13, amount = 1, info = { owner = "kept" } },
}
local qb_player = { PlayerData = { items = qb_items } }
local qb_core = {
Functions = {
GetPlayer = function()
return qb_player
end,
},
}
local qb_export = {
GetCoreObject = function()
return qb_core
end,
}
local jpr_drop_next_metadata = true
local jpr = {}
function jpr:GetItemBySlot(_, slot)
return qb_items[slot]
end
function jpr:RemoveItem(_, _, _, slot)
qb_items[slot] = nil
return true
end
function jpr:AddItem(_, item_name, amount, slot, metadata)
qb_items[slot] = {
name = item_name,
slot = slot,
amount = amount,
info = jpr_drop_next_metadata and {} or metadata,
}
jpr_drop_next_metadata = false
return true
end
reset_bridge("jpr", "jpr-inventory", jpr, "qb", { ["qb-core"] = qb_export })
load_adapter("sky_phone/source/bridge/server/inventory/jpr.lua")
assert(not Bridge.Inventory.SetSlotMetadata(26, 13, { owner = "kept", imei = "666666666666666" }))
assert(qb_items[13] and qb_items[13].info.owner == "kept", "jpr metadata failure must restore the original item")
assert(Bridge.Inventory.SetSlotMetadata(26, 13, { owner = "kept", imei = "666666666666666" }))
assert_metadata(Bridge.Inventory.GetSlot(26, 13).metadata, "666666666666666")
qb_items[15] = { name = "phone", slot = 15, amount = 1, info = {} }
local ps = {}
function ps:GetItemBySlot(_, slot)
return qb_items[slot]
end
function ps:RemoveItem(_, _, _, slot)
qb_items[slot] = nil
return true
end
function ps:AddItem(_, item_name, amount, slot, metadata)
qb_items[slot] = { name = item_name, slot = slot, amount = amount, info = metadata }
return true
end
reset_bridge("ps", "ps-inventory", ps, "qb", { ["qb-core"] = qb_export })
load_adapter("sky_phone/source/bridge/server/inventory/ps.lua")
assert(Bridge.Inventory.SetSlotMetadata(27, 15, { imei = "777777777777777" }))
assert_metadata(Bridge.Inventory.GetSlot(27, 15).metadata, "777777777777777")
print("extended inventory bridge regression checks passed")
+1 -2
View File
@@ -25,7 +25,7 @@ Bridge = {
Debug = function() Debug = function()
end, end,
Framework = { Framework = {
HasAdminGroup = function() HasPermission = function()
return true return true
end, end,
}, },
@@ -42,7 +42,6 @@ Bridge = {
Config = { Config = {
AdminPanel = { AdminPanel = {
AdminGroups = { "admin" },
Command = "phoneadmin", Command = "phoneadmin",
Enabled = true, Enabled = true,
}, },