mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 09:18:57 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1da9fee997 | |||
| 49c5964f7e | |||
| 3a0a9c0093 | |||
| 8b4a32bfab | |||
| cb6c94a7d8 | |||
| 2059e19e1c | |||
| 24ebe8c3ff | |||
| 99e943dbc5 | |||
| bd9f4c2695 | |||
| 4fa4bf3c78 | |||
| a0f67fae80 | |||
| f7644bf973 | |||
| e8465e4732 | |||
| a9143b0fba | |||
| 7aa4677b81 | |||
| 5f8c835685 |
@@ -119,5 +119,5 @@ jobs:
|
||||
"${archive}.sha256" \
|
||||
--verify-tag \
|
||||
--generate-notes \
|
||||
--title "Sky Phone $GITHUB_REF_NAME"
|
||||
--title "$GITHUB_REF_NAME Beta"
|
||||
fi
|
||||
|
||||
@@ -86,7 +86,7 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
|
||||
| Layer | Supported options |
|
||||
| --- | --- |
|
||||
| **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 |
|
||||
| **Radio** | YACA, PMA Voice, SaltyChat |
|
||||
| **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`) |
|
||||
| **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 |
|
||||
| `qb-inventory` | Yes | Yes | Uses item `info` metadata |
|
||||
| `lj-inventory` | Yes | Yes | QBCore inventory with item `info` metadata |
|
||||
| `qs-inventory` | Yes | Yes | Full per-slot metadata |
|
||||
| `codem-inventory` | Yes | Yes | Full per-slot metadata |
|
||||
| `core_inventory` | Yes | Yes | Full per-slot metadata |
|
||||
| `mf-inventory` | Yes | Yes | Supported with ESX |
|
||||
| `smx-inventory` | Yes | Yes | Supported with ESX through the player metadata bridge |
|
||||
| `hex_4_inventory` | **No metadata support** | **No, Unique Phones are not possible** | ESX only; set `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` |
|
||||
| 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` |
|
||||
| `jaksam_inventory` (`jaksam`) | Yes | Yes | Direct per-slot metadata; usable items are registered through jaksam_inventory |
|
||||
| `qs-inventory` (`qs`) | Yes | Yes | Full per-slot metadata |
|
||||
| `ps-inventory` (`ps`) | Yes | Yes | QBCore only; uses item `info` metadata |
|
||||
| `codem-inventory` (`codem`) | Yes | Yes | Full per-slot metadata |
|
||||
| `tgiann-inventory` (`tgiann`) | Yes | Yes | Per-slot metadata; item definitions must enable `hasMetadata` |
|
||||
| `core_inventory` (`core`) | Yes | Yes | Full per-slot metadata |
|
||||
| `jpr-inventory` (`jpr`) | Yes | Yes | QBCore only; uses item `info` metadata |
|
||||
| `origen_inventory` (`origen`) | Yes | Yes | Full per-slot metadata |
|
||||
| `ak47_inventory` (`ak47`) | Yes | Yes | Uses per-slot item `info` metadata |
|
||||
| `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
|
||||
|
||||
@@ -203,6 +216,7 @@ The files contain clearly separated sections for:
|
||||
| Section | Purpose |
|
||||
| --- | --- |
|
||||
| `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.Sim` | Physical or virtual SIM behavior and number formatting |
|
||||
| `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
|
||||
the `sky_phone_configurator` table automatically, loads its saved values before framework and phone
|
||||
When enabled, the generated `source/shared/config_default.lua` is the shipped first-run baseline.
|
||||
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
|
||||
in the Phone Configurator tool and press the green check. Saving verifies both SQL payloads and then
|
||||
applies the new server, client, media, app, item, command, provider, animation, and UI values through
|
||||
Sky Phone's internal runtime refresh. It does not execute a resource restart command.
|
||||
|
||||
Every `Config.*` value from `config.lua`, including server-only sections, and every value from
|
||||
`Config.Media` is discovered automatically. The bootstrap switch above intentionally remains
|
||||
file-owned because it decides whether SQL configuration is loaded. Lists, nested objects, vectors,
|
||||
Every configurable `Config.*` value from `config.lua`, including server-only sections, and every
|
||||
value from `Config.Media` is discovered automatically. The bootstrap switch and
|
||||
`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
|
||||
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
|
||||
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
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
- 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
|
||||
|
||||
The two mode switches are independent:
|
||||
|
||||
+40
-5
@@ -2,15 +2,49 @@ const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const frontendRoot = __dirname
|
||||
const resourceDirectory = path.join(frontendRoot, '..', 'sky_phone')
|
||||
const sourceDirectory = path.join(frontendRoot, 'dist')
|
||||
const targetDirectory = path.join(
|
||||
frontendRoot,
|
||||
'..',
|
||||
'sky_phone',
|
||||
const targetDirectory = path.join(resourceDirectory, 'source', 'html')
|
||||
const configDefaultPath = path.join(
|
||||
resourceDirectory,
|
||||
'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)) {
|
||||
throw new Error(
|
||||
'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')
|
||||
fs.writeFileSync(targetIndex, normalizedIndex)
|
||||
|
||||
generateConfigDefault()
|
||||
console.log(`Published NUI to ${targetDirectory}`)
|
||||
|
||||
+10
-1
@@ -530,8 +530,17 @@ function hydratePhone(payload: PhoneOpenPayload): void {
|
||||
clock.hydrate(payload.device?.data.alarms?.payload)
|
||||
games.hydrate(payload.device?.data.games?.payload)
|
||||
media.hydrate(payload.device?.data.media?.payload)
|
||||
appStore.hydrate(payload.device?.data.apps?.payload)
|
||||
appStore.hydrate(payload.device?.data.apps?.payload, payload.disabledApps)
|
||||
widgets.hydrate(payload.device?.data.widgets?.payload)
|
||||
|
||||
const currentAppId = route.params.appId
|
||||
if (
|
||||
typeof currentAppId === 'string' &&
|
||||
isPhoneAppId(currentAppId) &&
|
||||
!appStore.isInstalled(currentAppId)
|
||||
) {
|
||||
void router.push('/')
|
||||
}
|
||||
}
|
||||
|
||||
function getInstalledNavigationAppIds(): string[] {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
PHONE_APPS,
|
||||
isExternalPhoneApp,
|
||||
isLaunchablePhoneApp,
|
||||
} from '@/config/apps'
|
||||
|
||||
const repositoryRoot = join(import.meta.dirname, '../..')
|
||||
const source = (path: string) =>
|
||||
readFileSync(join(repositoryRoot, path), 'utf8')
|
||||
|
||||
const app = source('frontend/src/App.vue')
|
||||
const appStore = source('frontend/src/stores/app-store.ts')
|
||||
const appStoreView = source('frontend/src/views/apps/AppStoreApp.vue')
|
||||
const appWindow = source('frontend/src/views/PhoneAppWindow.vue')
|
||||
const client = source('sky_phone/source/client/main.lua')
|
||||
const config = source('sky_phone/config/config.lua')
|
||||
const configDefault = source('sky_phone/source/shared/config_default.lua')
|
||||
const crypto = source('sky_phone/source/server/crypto.lua')
|
||||
const phone = source('sky_phone/source/server/phone.lua')
|
||||
const router = source('frontend/src/router/index.ts')
|
||||
|
||||
describe('configured app availability contract', () => {
|
||||
it('keeps every built-in app in the file and runtime defaults', () => {
|
||||
const builtInAppIds = PHONE_APPS.filter(
|
||||
(app) =>
|
||||
isLaunchablePhoneApp(app) && !app.adminOnly && !isExternalPhoneApp(app),
|
||||
).map((app) => app.id)
|
||||
|
||||
for (const appId of builtInAppIds) {
|
||||
const entry = appId.includes('-')
|
||||
? `["${appId}"] = true`
|
||||
: `${appId} = true`
|
||||
expect(config).toContain(entry)
|
||||
expect(configDefault).toContain(entry)
|
||||
}
|
||||
})
|
||||
|
||||
it('sends disabled apps and reapplies them to an open phone after live saves', () => {
|
||||
expect(phone).toContain('disabledApps = SkyPhone.GetDisabledApps()')
|
||||
expect(phone).toMatch(
|
||||
/function SkyPhone\.GetDisabledApps\(\)[\s\S]*?enabled == false/,
|
||||
)
|
||||
expect(client).toMatch(
|
||||
/configurator:updated[\s\S]*?apply_disabled_apps\(device_payload\)[\s\S]*?device:updated/,
|
||||
)
|
||||
expect(app).toContain(
|
||||
'appStore.hydrate(payload.device?.data.apps?.payload, payload.disabledApps)',
|
||||
)
|
||||
})
|
||||
|
||||
it('removes disabled apps from every launch path without changing device installs', () => {
|
||||
expect(appStore).toContain('disabledApps: [] as LaunchablePhoneAppId[]')
|
||||
expect(appStore).toContain('if (!this.isAvailable(appId)) return false')
|
||||
expect(appStoreView).toContain('appStore.isAvailable(app.id)')
|
||||
expect(router).toContain('useAppStoreStore().isInstalled(to.params.appId)')
|
||||
expect(appWindow).toContain('appStore.isInstalled(app.id)')
|
||||
expect(app).toMatch(
|
||||
/isPhoneAppId\(currentAppId\)[\s\S]*?!appStore\.isInstalled\(currentAppId\)[\s\S]*?router\.push\('\/'\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects crypto operations after the app is disabled at runtime', () => {
|
||||
expect(crypto).toMatch(
|
||||
/local function require_phone\(source\)[\s\S]*?not Config\.Crypto\.Enabled or not SkyPhone\.IsAppEnabled\("crypto"\)/,
|
||||
)
|
||||
expect(crypto).toMatch(
|
||||
/local function refresh_crypto_runtime\(\)[\s\S]*?not Config\.Crypto\.Enabled or not SkyPhone\.IsAppEnabled\("crypto"\)[\s\S]*?sessions = \{\}/,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -54,6 +54,14 @@ const config = readFileSync(
|
||||
new URL('../../../sky_phone/config/config.lua', import.meta.url),
|
||||
'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(
|
||||
new URL('../../../sky_phone/sql/install.sql', import.meta.url),
|
||||
'utf8',
|
||||
@@ -69,6 +77,17 @@ const configuratorServer = readFileSync(
|
||||
),
|
||||
'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(
|
||||
new URL(
|
||||
'../../../sky_phone/source/client/phone_configurator.lua',
|
||||
@@ -133,24 +152,23 @@ describe('standalone admin panel contracts', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('removes manual reload and applies a persistent global accent choice', () => {
|
||||
it('uses a fixed blue accent and replaces appearance controls with live statistics', () => {
|
||||
expect(source).not.toContain('<RefreshCw')
|
||||
expect(source).not.toContain("kind: 'refresh'")
|
||||
expect(source).toContain("'sky-phone-admin-accent'")
|
||||
expect(source).toContain("'sky-phone-admin-font-family'")
|
||||
expect(source).toContain("'sky-phone-admin-font-size'")
|
||||
expect(source).toContain("'--admin-accent': accentColor")
|
||||
expect(source).toContain("'--admin-font-family': activeAdminFontFamily")
|
||||
expect(source).toContain(
|
||||
"'--admin-font-scale': String(adminFontSize / 100)",
|
||||
)
|
||||
expect(source).toContain('class="admin-panel-color-value"')
|
||||
expect(source).toContain('class="admin-panel-rgb-fields"')
|
||||
expect(source).toContain('class="admin-panel-rgb-range"')
|
||||
expect(source).toContain('class="admin-panel-font-select"')
|
||||
expect(source).toContain('class="admin-panel-font-select__menu"')
|
||||
expect(source).toContain('class="admin-panel-font-size-control"')
|
||||
expect(source).not.toContain('type="color"')
|
||||
expect(source).not.toContain("'sky-phone-admin-accent'")
|
||||
expect(source).not.toContain("'sky-phone-admin-font-family'")
|
||||
expect(source).not.toContain("'sky-phone-admin-font-size'")
|
||||
expect(source).not.toContain("t('appearance.")
|
||||
expect(source).toContain('--admin-accent: #00b8e4')
|
||||
expect(source).toContain('class="admin-panel-statistics-layout"')
|
||||
expect(source).toContain('class="admin-panel-activity-grid"')
|
||||
expect(source).toContain('class="admin-panel-coverage-list"')
|
||||
expect(source).toContain('statisticsTimer = window.setInterval')
|
||||
expect(source).toContain('15_000')
|
||||
expect(server).toContain('AS `messages_today`')
|
||||
expect(server).toContain('AS `calls_today`')
|
||||
expect(server).toContain('AS `active_devices`')
|
||||
expect(server).toContain('auditEntries = tonumber(stats.audit_entries)')
|
||||
expect(source).toContain('color-mix(in srgb, var(--admin-green)')
|
||||
expect(source).toContain('--admin-toggle-on: #63d471')
|
||||
expect(source).toContain('background: var(--admin-toggle-on)')
|
||||
@@ -170,6 +188,24 @@ describe('standalone admin panel contracts', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('applies global bundled-app availability to phones and admin management', () => {
|
||||
expect(config).toContain('Config.Apps = {')
|
||||
expect(config).toContain('memory = true,')
|
||||
expect(config).toContain('weather = true,')
|
||||
expect(configDefault).toContain('Config.Apps = {')
|
||||
expect(configuratorServer).toContain('Apps = true,')
|
||||
expect(phoneServer).toContain('function SkyPhone.IsAppEnabled(app_id)')
|
||||
expect(phoneServer).toContain('function SkyPhone.GetDisabledApps()')
|
||||
expect(phoneServer).toContain(
|
||||
'disabledApps = SkyPhone.GetDisabledApps()',
|
||||
)
|
||||
expect(server).toContain('if not SkyPhone.IsAppEnabled(app_id) then')
|
||||
expect(server).toContain('disabledApps = SkyPhone.GetDisabledApps()')
|
||||
expect(store).toContain('disabledApps: [] as string[]')
|
||||
expect(store).toContain('disabledAppsFromConfigurator(response.data)')
|
||||
expect(source).toContain('!admin.disabledApps.includes(app.id)')
|
||||
})
|
||||
|
||||
it('connects every operation through the standard NUI callback bridge', () => {
|
||||
expect(bridge).toContain('admin = [[')
|
||||
for (const endpoint of [
|
||||
@@ -216,12 +252,14 @@ describe('standalone admin panel contracts', () => {
|
||||
it('authorizes every server request without requiring a phone session', () => {
|
||||
expect(server).not.toContain('SkyPhone.RequireSession(source)')
|
||||
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.ActionRequestsPerMinute')
|
||||
expect(server).toContain('Config.AdminPanel.CredentialRevealsPerMinute')
|
||||
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', () => {
|
||||
@@ -272,6 +310,12 @@ describe('standalone admin panel contracts', () => {
|
||||
expect(schema).toContain(
|
||||
'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(
|
||||
manifest.indexOf("'source/server/phone_configurator.lua'"),
|
||||
).toBeLessThan(manifest.indexOf("'source/bridge/server/framework.lua'"))
|
||||
@@ -281,11 +325,36 @@ describe('standalone admin panel contracts', () => {
|
||||
expect(configuratorServer).toContain('AND `revision` = ?')
|
||||
expect(configuratorServer).toContain('configurator_enabled')
|
||||
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(
|
||||
'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(
|
||||
'build_sections("config", stored_config',
|
||||
)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ChartNoAxesCombined,
|
||||
BadgeDollarSign,
|
||||
BriefcaseBusiness,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleUserRound,
|
||||
Clipboard,
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
LoaderCircle,
|
||||
LockKeyhole,
|
||||
MessageSquare,
|
||||
Palette,
|
||||
PhoneCall,
|
||||
PhoneForwarded,
|
||||
Save,
|
||||
@@ -81,17 +80,6 @@ type AdminTab =
|
||||
type ConfiguratorScope = 'config' | 'media'
|
||||
type DeviceAction = 'reset-passcode' | 'change-number' | 'factory-reset'
|
||||
type PendingAction = { kind: 'close' } | { kind: 'player'; source: number }
|
||||
type AccentChannel = 'blue' | 'green' | 'red'
|
||||
type AdminFontFamily =
|
||||
| 'classic'
|
||||
| 'georgia'
|
||||
| 'inter'
|
||||
| 'mono'
|
||||
| 'system'
|
||||
| 'tahoma'
|
||||
| 'trebuchet'
|
||||
| 'verdana'
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
const admin = useAdminStore()
|
||||
const phone = usePhoneStore()
|
||||
@@ -109,61 +97,11 @@ const revealDialogImei = ref('')
|
||||
const deviceAction = ref<DeviceAction | null>(null)
|
||||
const deviceActionInput = ref('')
|
||||
const discardDialog = ref(false)
|
||||
const fontMenuOpen = ref(false)
|
||||
const pendingAction = ref<PendingAction | null>(null)
|
||||
const toast = ref('')
|
||||
const toastTone = ref<'error' | 'success'>('success')
|
||||
let toastTimer: number | undefined
|
||||
|
||||
const accentOptions = [
|
||||
{ color: '#74d66f', key: 'emerald' },
|
||||
{ color: '#4f9cff', key: 'blue' },
|
||||
{ color: '#a875ff', key: 'violet' },
|
||||
{ color: '#f0a24b', key: 'orange' },
|
||||
{ color: '#ef6969', key: 'red' },
|
||||
] as const
|
||||
const accentChannels = ['red', 'green', 'blue'] as const
|
||||
const fontFamilyOptions: Array<{
|
||||
key: AdminFontFamily
|
||||
value: string
|
||||
}> = [
|
||||
{ key: 'inter', value: 'var(--sky-font-family, Inter, sans-serif)' },
|
||||
{
|
||||
key: 'system',
|
||||
value:
|
||||
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||
},
|
||||
{ key: 'classic', value: 'Arial, Helvetica, sans-serif' },
|
||||
{ key: 'verdana', value: 'Verdana, Geneva, sans-serif' },
|
||||
{ key: 'tahoma', value: 'Tahoma, Geneva, sans-serif' },
|
||||
{
|
||||
key: 'trebuchet',
|
||||
value: '"Trebuchet MS", "Lucida Grande", sans-serif',
|
||||
},
|
||||
{ key: 'georgia', value: 'Georgia, "Times New Roman", serif' },
|
||||
{
|
||||
key: 'mono',
|
||||
value:
|
||||
'ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace',
|
||||
},
|
||||
]
|
||||
const accentColor = ref('#74d66f')
|
||||
const adminFontFamily = ref<AdminFontFamily>('inter')
|
||||
const adminFontSize = ref(100)
|
||||
const adminFontSizePreview = ref(100)
|
||||
const activeAdminFontFamily = computed(
|
||||
() =>
|
||||
fontFamilyOptions.find((option) => option.key === adminFontFamily.value)
|
||||
?.value ?? fontFamilyOptions[0].value,
|
||||
)
|
||||
const accentRgb = computed(() => {
|
||||
const color = accentColor.value.replace('#', '')
|
||||
return {
|
||||
red: Number.parseInt(color.slice(0, 2), 16),
|
||||
green: Number.parseInt(color.slice(2, 4), 16),
|
||||
blue: Number.parseInt(color.slice(4, 6), 16),
|
||||
}
|
||||
})
|
||||
let statisticsTimer: number | undefined
|
||||
|
||||
const filteredPlayers = computed(() => {
|
||||
const needle = playerQuery.value.trim().toLocaleLowerCase(phone.lang)
|
||||
@@ -196,7 +134,9 @@ const manageableApps = computed(() => {
|
||||
const needle = appQuery.value.trim().toLocaleLowerCase(phone.lang)
|
||||
return PHONE_APPS.filter(
|
||||
(app): app is LaunchablePhoneAppDefinition =>
|
||||
isLaunchablePhoneApp(app) && !app.adminOnly,
|
||||
isLaunchablePhoneApp(app) &&
|
||||
!app.adminOnly &&
|
||||
!admin.disabledApps.includes(app.id),
|
||||
)
|
||||
.filter(
|
||||
(app) =>
|
||||
@@ -595,7 +535,9 @@ function queueAction(action: PendingAction): void {
|
||||
|
||||
function selectTab(nextTab: AdminTab): void {
|
||||
tab.value = nextTab
|
||||
fontMenuOpen.value = false
|
||||
if (nextTab === 'overview' && admin.initialized && !admin.loading) {
|
||||
void admin.load()
|
||||
}
|
||||
if (nextTab === 'configurator' && !admin.configurator) {
|
||||
void admin.loadConfigurator().then((loaded) => {
|
||||
if (!loaded) showToast(errorText(), 'error')
|
||||
@@ -603,61 +545,13 @@ function selectTab(nextTab: AdminTab): void {
|
||||
}
|
||||
}
|
||||
|
||||
function selectAccent(color: string): void {
|
||||
if (!/^#[0-9a-f]{6}$/i.test(color)) return
|
||||
accentColor.value = color.toLowerCase()
|
||||
window.localStorage.setItem('sky-phone-admin-accent', accentColor.value)
|
||||
function formatStatistic(value: number): string {
|
||||
return value.toLocaleString(phone.lang)
|
||||
}
|
||||
|
||||
function updateAccent(event: Event): void {
|
||||
const input = event.currentTarget as HTMLInputElement
|
||||
if (/^#[0-9a-f]{6}$/i.test(input.value)) selectAccent(input.value)
|
||||
else input.value = accentColor.value.toUpperCase()
|
||||
}
|
||||
|
||||
function updateAccentChannel(channel: AccentChannel, event: Event): void {
|
||||
const input = event.currentTarget as HTMLInputElement
|
||||
const value = Math.min(255, Math.max(0, Number(input.value) || 0))
|
||||
const channels = { ...accentRgb.value, [channel]: value }
|
||||
selectAccent(
|
||||
`#${channels.red.toString(16).padStart(2, '0')}${channels.green
|
||||
.toString(16)
|
||||
.padStart(2, '0')}${channels.blue.toString(16).padStart(2, '0')}`,
|
||||
)
|
||||
}
|
||||
|
||||
function accentChannelGradient(channel: AccentChannel): string {
|
||||
const { red, green, blue } = accentRgb.value
|
||||
if (channel === 'red') {
|
||||
return `linear-gradient(90deg, rgb(0, ${green}, ${blue}), rgb(255, ${green}, ${blue}))`
|
||||
}
|
||||
if (channel === 'green') {
|
||||
return `linear-gradient(90deg, rgb(${red}, 0, ${blue}), rgb(${red}, 255, ${blue}))`
|
||||
}
|
||||
return `linear-gradient(90deg, rgb(${red}, ${green}, 0), rgb(${red}, ${green}, 255))`
|
||||
}
|
||||
|
||||
function selectAdminFontFamily(family: AdminFontFamily): void {
|
||||
adminFontFamily.value = family
|
||||
fontMenuOpen.value = false
|
||||
window.localStorage.setItem('sky-phone-admin-font-family', family)
|
||||
}
|
||||
|
||||
function closeFontMenu(event: FocusEvent): void {
|
||||
const menu = event.currentTarget as HTMLElement
|
||||
if (!menu.contains(event.relatedTarget as Node | null)) {
|
||||
fontMenuOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function updateAdminFontSize(event: Event): void {
|
||||
const size = Number((event.currentTarget as HTMLInputElement).value)
|
||||
adminFontSize.value = Math.min(115, Math.max(90, size))
|
||||
adminFontSizePreview.value = adminFontSize.value
|
||||
window.localStorage.setItem(
|
||||
'sky-phone-admin-font-size',
|
||||
String(adminFontSize.value),
|
||||
)
|
||||
function statisticPercentage(value: number, total: number): number {
|
||||
if (total <= 0) return 0
|
||||
return Math.min(100, Math.round((value / total) * 100))
|
||||
}
|
||||
|
||||
async function runAction(action: PendingAction): Promise<void> {
|
||||
@@ -918,10 +812,6 @@ function auditDescription(entry: AdminAuditEntry): string {
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (event.key !== 'Escape') return
|
||||
event.preventDefault()
|
||||
if (fontMenuOpen.value) {
|
||||
fontMenuOpen.value = false
|
||||
return
|
||||
}
|
||||
if (revealDialogImei.value) {
|
||||
revealDialogImei.value = ''
|
||||
return
|
||||
@@ -941,46 +831,21 @@ function onKeydown(event: KeyboardEvent): void {
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', onKeydown)
|
||||
const storedAccent = window.localStorage.getItem('sky-phone-admin-accent')
|
||||
if (storedAccent && /^#[0-9a-f]{6}$/i.test(storedAccent)) {
|
||||
accentColor.value = storedAccent.toLowerCase()
|
||||
}
|
||||
const storedFontFamily = window.localStorage.getItem(
|
||||
'sky-phone-admin-font-family',
|
||||
)
|
||||
if (
|
||||
storedFontFamily &&
|
||||
fontFamilyOptions.some((option) => option.key === storedFontFamily)
|
||||
) {
|
||||
adminFontFamily.value = storedFontFamily as AdminFontFamily
|
||||
}
|
||||
const storedFontSize = Number(
|
||||
window.localStorage.getItem('sky-phone-admin-font-size'),
|
||||
)
|
||||
if (storedFontSize >= 90 && storedFontSize <= 115) {
|
||||
adminFontSize.value = storedFontSize
|
||||
adminFontSizePreview.value = storedFontSize
|
||||
}
|
||||
void refreshData()
|
||||
statisticsTimer = window.setInterval(() => {
|
||||
if (tab.value === 'overview' && !admin.loading) void admin.load()
|
||||
}, 15_000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', onKeydown)
|
||||
if (toastTimer) window.clearTimeout(toastTimer)
|
||||
if (statisticsTimer) window.clearInterval(statisticsTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="admin-panel-overlay"
|
||||
role="dialog"
|
||||
:aria-label="t('name')"
|
||||
:style="{
|
||||
'--admin-accent': accentColor,
|
||||
'--admin-font-family': activeAdminFontFamily,
|
||||
'--admin-font-scale': String(adminFontSize / 100),
|
||||
}"
|
||||
>
|
||||
<div class="admin-panel-overlay" role="dialog" :aria-label="t('name')">
|
||||
<div class="admin-panel-window">
|
||||
<header class="admin-panel-header">
|
||||
<div class="admin-panel-brand">
|
||||
@@ -1384,22 +1249,22 @@ onBeforeUnmount(() => {
|
||||
<article>
|
||||
<UsersRound :size="18" />
|
||||
<span>{{ t('overview.online') }}</span>
|
||||
<strong>{{ admin.stats.online }}</strong>
|
||||
<strong>{{ formatStatistic(admin.stats.online) }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<Smartphone :size="18" />
|
||||
<span>{{ t('overview.devices') }}</span>
|
||||
<strong>{{ admin.stats.devices }}</strong>
|
||||
<strong>{{ formatStatistic(admin.stats.devices) }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<Database :size="18" />
|
||||
<span>{{ t('overview.accounts') }}</span>
|
||||
<strong>{{ admin.stats.accounts }}</strong>
|
||||
<strong>{{ formatStatistic(admin.stats.accounts) }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<ScrollText :size="18" />
|
||||
<span>{{ t('overview.audit') }}</span>
|
||||
<strong>{{ admin.audit.length }}</strong>
|
||||
<strong>{{ formatStatistic(admin.stats.auditEntries) }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -1408,146 +1273,100 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<div class="admin-panel-section-card__heading">
|
||||
<div>
|
||||
<span>{{ t('appearance.eyebrow') }}</span>
|
||||
<h2>{{ t('appearance.title') }}</h2>
|
||||
<p>{{ t('appearance.body') }}</p>
|
||||
<span>{{ t('statistics.eyebrow') }}</span>
|
||||
<h2>{{ t('statistics.title') }}</h2>
|
||||
<p>{{ t('statistics.body') }}</p>
|
||||
</div>
|
||||
<Palette :size="20" />
|
||||
<ChartNoAxesCombined :size="20" />
|
||||
</div>
|
||||
<div class="admin-panel-accent-picker">
|
||||
<button
|
||||
v-for="option in accentOptions"
|
||||
:key="option.color"
|
||||
type="button"
|
||||
:class="{ 'is-active': accentColor === option.color }"
|
||||
:aria-label="t('appearance.colors.' + option.key)"
|
||||
:title="t('appearance.colors.' + option.key)"
|
||||
@click="selectAccent(option.color)"
|
||||
>
|
||||
<span :style="{ backgroundColor: option.color }"></span>
|
||||
<strong>{{ t('appearance.colors.' + option.key) }}</strong>
|
||||
<Check v-if="accentColor === option.color" :size="15" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="admin-panel-appearance-controls">
|
||||
<section class="admin-panel-appearance-control is-color">
|
||||
<div>
|
||||
<strong>{{ t('appearance.controls.customColor') }}</strong>
|
||||
<small>{{
|
||||
t('appearance.controls.customColorBody')
|
||||
}}</small>
|
||||
</div>
|
||||
<div class="admin-panel-rgb-picker">
|
||||
<label class="admin-panel-color-value">
|
||||
<span :style="{ backgroundColor: accentColor }"></span>
|
||||
<input
|
||||
v-config-input-width
|
||||
type="text"
|
||||
maxlength="7"
|
||||
:value="accentColor.toUpperCase()"
|
||||
:aria-label="t('appearance.controls.hex')"
|
||||
@change="updateAccent"
|
||||
/>
|
||||
</label>
|
||||
<div class="admin-panel-rgb-fields">
|
||||
<label v-for="channel in accentChannels" :key="channel">
|
||||
<span>{{ t(`appearance.controls.${channel}`) }}</span>
|
||||
<input
|
||||
class="admin-panel-rgb-range"
|
||||
type="range"
|
||||
min="0"
|
||||
max="255"
|
||||
:value="accentRgb[channel]"
|
||||
:style="{
|
||||
background: accentChannelGradient(channel),
|
||||
}"
|
||||
:aria-label="t(`appearance.controls.${channel}`)"
|
||||
@input="updateAccentChannel(channel, $event)"
|
||||
/>
|
||||
<input
|
||||
v-config-input-width
|
||||
type="number"
|
||||
min="0"
|
||||
max="255"
|
||||
:value="accentRgb[channel]"
|
||||
:aria-label="`${t(`appearance.controls.${channel}`)} ${t('appearance.controls.value')}`"
|
||||
@input="updateAccentChannel(channel, $event)"
|
||||
/>
|
||||
</label>
|
||||
<div class="admin-panel-statistics-layout">
|
||||
<section class="admin-panel-activity-statistics">
|
||||
<div class="admin-panel-statistics-heading">
|
||||
<div>
|
||||
<strong>{{ t('statistics.today') }}</strong>
|
||||
<small>{{ t('statistics.todayBody') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-panel-activity-grid">
|
||||
<article>
|
||||
<MessageSquare :size="17" />
|
||||
<strong>{{
|
||||
formatStatistic(admin.stats.messagesToday)
|
||||
}}</strong>
|
||||
<span>{{ t('statistics.messagesToday') }}</span>
|
||||
</article>
|
||||
<article>
|
||||
<PhoneCall :size="17" />
|
||||
<strong>{{
|
||||
formatStatistic(admin.stats.callsToday)
|
||||
}}</strong>
|
||||
<span>{{ t('statistics.callsToday') }}</span>
|
||||
</article>
|
||||
<article>
|
||||
<ScrollText :size="17" />
|
||||
<strong>{{
|
||||
formatStatistic(admin.stats.auditToday)
|
||||
}}</strong>
|
||||
<span>{{ t('statistics.auditToday') }}</span>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-panel-appearance-control">
|
||||
<span>
|
||||
<strong>{{ t('appearance.controls.fontFamily') }}</strong>
|
||||
<small>{{ t('appearance.controls.fontFamilyBody') }}</small>
|
||||
</span>
|
||||
<div
|
||||
class="admin-panel-font-select"
|
||||
@focusout="closeFontMenu"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('appearance.controls.fontFamily')"
|
||||
aria-haspopup="listbox"
|
||||
:aria-expanded="fontMenuOpen"
|
||||
@click="fontMenuOpen = !fontMenuOpen"
|
||||
<section class="admin-panel-coverage-statistics">
|
||||
<div class="admin-panel-statistics-heading">
|
||||
<div>
|
||||
<strong>{{ t('statistics.coverage') }}</strong>
|
||||
<small>{{ t('statistics.coverageBody') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-panel-coverage-list">
|
||||
<article
|
||||
v-for="metric in [
|
||||
{
|
||||
key: 'linkedDevices',
|
||||
value: admin.stats.linkedDevices,
|
||||
},
|
||||
{
|
||||
key: 'simDevices',
|
||||
value: admin.stats.simDevices,
|
||||
},
|
||||
{
|
||||
key: 'activeDevices',
|
||||
value: admin.stats.activeDevices,
|
||||
},
|
||||
]"
|
||||
:key="metric.key"
|
||||
>
|
||||
<span :style="{ fontFamily: activeAdminFontFamily }">
|
||||
{{ t(`appearance.controls.fonts.${adminFontFamily}`) }}
|
||||
</span>
|
||||
<ChevronDown :size="14" />
|
||||
</button>
|
||||
<div
|
||||
v-if="fontMenuOpen"
|
||||
class="admin-panel-font-select__menu"
|
||||
role="listbox"
|
||||
:aria-label="t('appearance.controls.fontFamily')"
|
||||
>
|
||||
<button
|
||||
v-for="option in fontFamilyOptions"
|
||||
:key="option.key"
|
||||
type="button"
|
||||
role="option"
|
||||
:aria-selected="adminFontFamily === option.key"
|
||||
:class="{ 'is-active': adminFontFamily === option.key }"
|
||||
:style="{ fontFamily: option.value }"
|
||||
@click="selectAdminFontFamily(option.key)"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ t(`statistics.${metric.key}`) }}</strong>
|
||||
<span>{{
|
||||
t(`appearance.controls.fonts.${option.key}`)
|
||||
t('statistics.ofDevices', {
|
||||
count: formatStatistic(metric.value),
|
||||
total: formatStatistic(admin.stats.devices),
|
||||
})
|
||||
}}</span>
|
||||
<Check
|
||||
v-if="adminFontFamily === option.key"
|
||||
:size="13"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="admin-panel-statistics-progress"
|
||||
role="progressbar"
|
||||
:aria-label="t(`statistics.${metric.key}`)"
|
||||
:aria-valuemin="0"
|
||||
:aria-valuemax="100"
|
||||
:aria-valuenow="
|
||||
statisticPercentage(metric.value, admin.stats.devices)
|
||||
"
|
||||
>
|
||||
<span
|
||||
:style="{
|
||||
width: `${statisticPercentage(metric.value, admin.stats.devices)}%`,
|
||||
}"
|
||||
></span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<label class="admin-panel-appearance-control">
|
||||
<span>
|
||||
<strong>{{ t('appearance.controls.fontSize') }}</strong>
|
||||
<small>{{ t('appearance.controls.fontSizeBody') }}</small>
|
||||
</span>
|
||||
<div class="admin-panel-font-size-control">
|
||||
<input
|
||||
v-model.number="adminFontSizePreview"
|
||||
type="range"
|
||||
min="90"
|
||||
max="115"
|
||||
step="5"
|
||||
:aria-label="t('appearance.controls.fontSize')"
|
||||
@change="updateAdminFontSize"
|
||||
/>
|
||||
<strong>{{ adminFontSizePreview }}%</strong>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
</section>
|
||||
|
||||
<section
|
||||
@@ -2488,7 +2307,8 @@ onBeforeUnmount(() => {
|
||||
--admin-text: #f0f3f0;
|
||||
--admin-muted: #818781;
|
||||
--admin-dim: #555b55;
|
||||
--admin-green: var(--admin-accent, #74d66f);
|
||||
--admin-accent: #00b8e4;
|
||||
--admin-green: var(--admin-accent);
|
||||
--admin-green-soft: color-mix(in srgb, var(--admin-green) 9%, transparent);
|
||||
--admin-toggle-on: #63d471;
|
||||
--admin-row-hover: linear-gradient(
|
||||
@@ -2512,10 +2332,7 @@ onBeforeUnmount(() => {
|
||||
padding: 2.5vh 2.5vw;
|
||||
color: var(--admin-text);
|
||||
background: transparent;
|
||||
font-family: var(
|
||||
--admin-font-family,
|
||||
var(--sky-font-family, Inter, sans-serif)
|
||||
);
|
||||
font-family: var(--sky-font-family, Inter, sans-serif);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -2529,7 +2346,6 @@ onBeforeUnmount(() => {
|
||||
box-shadow:
|
||||
0 20px 56px rgba(0, 0, 0, 0.72),
|
||||
inset 0 1px rgba(255, 255, 255, 0.018);
|
||||
zoom: var(--admin-font-scale, 1);
|
||||
}
|
||||
|
||||
.admin-panel-header {
|
||||
@@ -2653,15 +2469,19 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.admin-panel-save.is-ready {
|
||||
color: #74d66f;
|
||||
color: var(--admin-green);
|
||||
background: transparent;
|
||||
filter: drop-shadow(0 0 5px rgba(116, 214, 111, 0.42));
|
||||
filter: drop-shadow(
|
||||
0 0 5px color-mix(in srgb, var(--admin-green) 42%, transparent)
|
||||
);
|
||||
}
|
||||
|
||||
.admin-panel-save.is-ready:hover:not(:disabled) {
|
||||
color: color-mix(in srgb, #74d66f 82%, white);
|
||||
color: color-mix(in srgb, var(--admin-green) 82%, white);
|
||||
background: transparent;
|
||||
filter: drop-shadow(0 0 7px rgba(116, 214, 111, 0.65));
|
||||
filter: drop-shadow(
|
||||
0 0 7px color-mix(in srgb, var(--admin-green) 65%, transparent)
|
||||
);
|
||||
}
|
||||
|
||||
.admin-panel-close:hover {
|
||||
@@ -3352,293 +3172,127 @@ button:disabled {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-panel-accent-picker {
|
||||
.admin-panel-statistics-layout {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 3px;
|
||||
padding: 3px;
|
||||
border: 0;
|
||||
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-panel-activity-statistics,
|
||||
.admin-panel-coverage-statistics {
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
border-radius: 3px;
|
||||
background: #0b0d0c;
|
||||
}
|
||||
|
||||
.admin-panel-accent-picker button {
|
||||
min-height: 38px;
|
||||
.admin-panel-statistics-heading {
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
|
||||
.admin-panel-statistics-heading > div {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr) 16px;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 2px;
|
||||
color: var(--admin-muted);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.admin-panel-accent-picker button:hover,
|
||||
.admin-panel-accent-picker button.is-active {
|
||||
color: var(--admin-text);
|
||||
background: var(--admin-row-active);
|
||||
}
|
||||
|
||||
.admin-panel-accent-picker button > span {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.admin-panel-accent-picker strong {
|
||||
overflow: hidden;
|
||||
font-size: 9px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-panel-accent-picker svg {
|
||||
color: var(--admin-green);
|
||||
}
|
||||
|
||||
.admin-panel-appearance-controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
margin-top: 8px;
|
||||
background: #0a0c0b;
|
||||
}
|
||||
|
||||
.admin-panel-appearance-control {
|
||||
min-height: 62px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(120px, 0.8fr);
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: #121412;
|
||||
}
|
||||
|
||||
.admin-panel-appearance-control.is-color {
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: minmax(170px, 0.7fr) minmax(320px, 1.3fr);
|
||||
}
|
||||
|
||||
.admin-panel-appearance-control > div:first-child,
|
||||
.admin-panel-appearance-control > span:first-child {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.admin-panel-appearance-control strong {
|
||||
.admin-panel-statistics-heading strong {
|
||||
color: #d9ddd9;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-panel-appearance-control small {
|
||||
.admin-panel-statistics-heading small {
|
||||
color: var(--admin-muted);
|
||||
font-size: 8px;
|
||||
line-height: 1.3;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.admin-panel-rgb-picker {
|
||||
.admin-panel-activity-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.admin-panel-activity-grid article {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(230px, 1fr);
|
||||
grid-template-columns: 20px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
gap: 2px 6px;
|
||||
padding: 10px;
|
||||
background: #121412;
|
||||
}
|
||||
|
||||
.admin-panel-color-value {
|
||||
min-width: 96px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0 8px;
|
||||
border-radius: 3px;
|
||||
outline: 1px solid var(--admin-border-strong);
|
||||
background: #1a1d1b;
|
||||
}
|
||||
|
||||
.admin-panel-color-value > span {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--admin-green) 48%, transparent);
|
||||
}
|
||||
|
||||
.admin-panel-color-value input {
|
||||
min-width: 0;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: #cbd0cb;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
font-size: 8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.admin-panel-rgb-fields {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.admin-panel-rgb-fields label {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 12px minmax(90px, 1fr) 42px;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.admin-panel-rgb-fields span {
|
||||
color: var(--admin-muted);
|
||||
font-size: 8px;
|
||||
font-weight: 650;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.admin-panel-rgb-fields input[type='number'] {
|
||||
width: 42px !important;
|
||||
max-width: 42px;
|
||||
height: 24px;
|
||||
padding: 0 6px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
outline: 1px solid var(--admin-border-strong);
|
||||
color: var(--admin-text);
|
||||
background: #1a1d1b;
|
||||
font: inherit;
|
||||
font-size: 8px;
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
.admin-panel-rgb-fields input[type='number']::-webkit-inner-spin-button,
|
||||
.admin-panel-rgb-fields input[type='number']::-webkit-outer-spin-button {
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.admin-panel-rgb-range {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
outline: 0;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-panel-rgb-range::-webkit-slider-thumb {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border: 2px solid #f3f5f3;
|
||||
border-radius: 50%;
|
||||
background: var(--admin-green);
|
||||
box-shadow: 0 0 0 2px #171a18;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.admin-panel-font-select {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-panel-font-select > button {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 0 9px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
outline: 1px solid var(--admin-border-strong);
|
||||
color: var(--admin-text);
|
||||
background: #1a1d1b;
|
||||
font: inherit;
|
||||
font-size: 9px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-panel-font-select > button svg {
|
||||
color: var(--admin-muted);
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.admin-panel-font-select > button[aria-expanded='true'] svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.admin-panel-font-select__menu {
|
||||
position: absolute;
|
||||
z-index: 12;
|
||||
top: calc(100% + 5px);
|
||||
right: 0;
|
||||
left: 0;
|
||||
max-height: 210px;
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
outline: 1px solid rgba(255, 255, 255, 0.09);
|
||||
background: #101211;
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.58);
|
||||
}
|
||||
|
||||
.admin-panel-font-select__menu button {
|
||||
min-height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 0 8px;
|
||||
border: 0;
|
||||
border-radius: 2px;
|
||||
color: var(--admin-muted);
|
||||
background: transparent;
|
||||
font-size: 9px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-panel-font-select__menu button:hover,
|
||||
.admin-panel-font-select__menu button.is-active {
|
||||
color: var(--admin-text);
|
||||
background: var(--admin-row-active);
|
||||
}
|
||||
|
||||
.admin-panel-font-select__menu svg {
|
||||
.admin-panel-activity-grid svg {
|
||||
color: var(--admin-green);
|
||||
}
|
||||
|
||||
.admin-panel-font-size-control {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(80px, 1fr) 38px;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
.admin-panel-activity-grid strong {
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-weight: 620;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-panel-font-size-control input {
|
||||
width: 100%;
|
||||
.admin-panel-activity-grid span {
|
||||
grid-column: 1 / -1;
|
||||
overflow: hidden;
|
||||
color: var(--admin-muted);
|
||||
font-size: 7px;
|
||||
text-overflow: ellipsis;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-panel-coverage-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-panel-coverage-list article {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.admin-panel-coverage-list article > div:first-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-panel-coverage-list strong,
|
||||
.admin-panel-coverage-list span {
|
||||
overflow: hidden;
|
||||
font-size: 8px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-panel-coverage-list strong {
|
||||
color: #d9ddd9;
|
||||
font-weight: 560;
|
||||
}
|
||||
|
||||
.admin-panel-coverage-list span {
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
|
||||
.admin-panel-statistics-progress {
|
||||
height: 3px;
|
||||
accent-color: var(--admin-green);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #1d201e;
|
||||
}
|
||||
|
||||
.admin-panel-font-size-control > strong {
|
||||
color: var(--admin-green);
|
||||
text-align: right;
|
||||
.admin-panel-statistics-progress > span {
|
||||
height: 100%;
|
||||
display: block;
|
||||
border-radius: inherit;
|
||||
background: var(--admin-green);
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--admin-green) 52%, transparent);
|
||||
transition: width 180ms ease;
|
||||
}
|
||||
|
||||
.admin-panel-section-card__heading {
|
||||
|
||||
@@ -61,16 +61,22 @@ const passcodeError = ref('')
|
||||
const passcodeLength = ref<4 | 6>(phone.security.length === 4 ? 4 : 6)
|
||||
const notificationsEnabled = ref(true)
|
||||
const notificationSounds = ref(true)
|
||||
const selectedApps = ref<BuiltinPhoneAppId[]>(['banking', 'garage', 'skyride'])
|
||||
const selectedApps = ref<BuiltinPhoneAppId[]>(
|
||||
(['banking', 'garage', 'skyride'] as const).filter((id) =>
|
||||
appStore.isAvailable(id),
|
||||
),
|
||||
)
|
||||
const setupCompleteBusy = ref(false)
|
||||
const setupCompleteError = ref('')
|
||||
|
||||
const setupApps = (
|
||||
['banking', 'garage', 'skyride', 'citymarkt', 'picstagram', 'snake'] as const
|
||||
).flatMap((id) => {
|
||||
const app = getPhoneApp(id)
|
||||
return app ? [app] : []
|
||||
})
|
||||
const setupApps = computed(() =>
|
||||
(
|
||||
['banking', 'garage', 'skyride', 'citymarkt', 'picstagram', 'snake'] as const
|
||||
).flatMap((id) => {
|
||||
const app = getPhoneApp(id)
|
||||
return app && appStore.isAvailable(id) ? [app] : []
|
||||
}),
|
||||
)
|
||||
const wallpaperChoices = WALLPAPER_IDS
|
||||
const progress = computed(() => `${((step.value + 1) / 10) * 100}%`)
|
||||
const displayName = computed(() => {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
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(
|
||||
'SKY PHONE CONFIGURATION FILES ARE DISABLED',
|
||||
)
|
||||
expect(configurator).toContain(
|
||||
'^1 Runtime settings from config.lua and media.lua are DISABLED.^0',
|
||||
)
|
||||
expect(configurator).toContain(
|
||||
'^1 Configure all phone and media settings IN GAME through /phonepanel.^0',
|
||||
)
|
||||
expect(configurator).toContain(
|
||||
'^1 Only Config.PhoneConfigurator.Enabled and Config.CommandPermissions remain file-based.^0',
|
||||
)
|
||||
})
|
||||
|
||||
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}")`,
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -3,17 +3,27 @@ import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
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) =>
|
||||
readFileSync(new URL(path, import.meta.url), 'utf8')
|
||||
|
||||
const inventoryAdapters = [
|
||||
['jaksam', 'source/bridge/server/inventory/jaksam.lua'],
|
||||
['ox', 'source/bridge/server/inventory/ox.lua'],
|
||||
['qb', 'source/bridge/server/inventory/qb.lua'],
|
||||
['lj', 'source/bridge/server/inventory/qb.lua'],
|
||||
['qs', 'source/bridge/server/inventory/qs.lua'],
|
||||
['ps', 'source/bridge/server/inventory/ps.lua'],
|
||||
['codem', 'source/bridge/server/inventory/codem.lua'],
|
||||
['tgiann', 'source/bridge/server/inventory/tgiann.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'],
|
||||
['smx', 'source/bridge/server/inventory/smx.lua'],
|
||||
['hex', 'source/bridge/server/inventory/esx.lua'],
|
||||
@@ -42,17 +52,65 @@ describe('phone inventory contracts', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('auto-detects HEX and limits count-based ESX inventories to metadata-free modes', () => {
|
||||
it('uses One Inventory slot ids and authoritative slot reads', () => {
|
||||
const adapter = readResourceFile(
|
||||
'source/bridge/server/inventory/one.lua',
|
||||
)
|
||||
|
||||
expect(adapter).toContain('inventory:GetSlotIdsWithItem(')
|
||||
expect(adapter).toContain(
|
||||
'local normalized = Bridge.Inventory.GetSlot(source, slot_id)',
|
||||
)
|
||||
expect(adapter).not.toContain('inventory:SearchInventory(')
|
||||
expect(adapter).toContain(
|
||||
'inventory:SetItemMetadata(source, slot.slot, requested_metadata) == false',
|
||||
)
|
||||
})
|
||||
|
||||
it('serializes and rate-limits phone bootstrap requests on both sides', () => {
|
||||
const phoneClient = readResourceFile('source/client/main.lua')
|
||||
const phoneServer = readResourceFile('source/server/phone.lua')
|
||||
|
||||
expect(phoneClient).toContain('local function request_phone_open(')
|
||||
expect(phoneClient).toMatch(
|
||||
/request_phone_open\(callback_name\)[\s\S]*?open_requested = true[\s\S]*?Bridge\.Callbacks\.Trigger\(callback_name, \{\}\)/,
|
||||
)
|
||||
expect(phoneClient).toMatch(
|
||||
/RegisterNetEvent\("sky_phone:device:error"[\s\S]*?if not is_open then[\s\S]*?open_requested = false/,
|
||||
)
|
||||
expect(phoneServer).toContain('local phone_open_in_progress = {}')
|
||||
expect(phoneServer).toContain(
|
||||
'SkyPhone.AllowOperation(source, "phone_open", request_limit, 60)',
|
||||
)
|
||||
expect(phoneServer).toContain(
|
||||
'pcall(perform_phone_open, source, used_item)',
|
||||
)
|
||||
})
|
||||
|
||||
it('auto-detects registered inventories and forces metadata-free adapters into compatible modes', () => {
|
||||
const inventoryBridge = readResourceFile(
|
||||
'source/bridge/server/inventory.lua',
|
||||
)
|
||||
|
||||
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', () => {
|
||||
@@ -103,9 +161,15 @@ describe('phone inventory contracts', () => {
|
||||
expect(equippedNumberExport).toBeDefined()
|
||||
expect(equippedNumberResolver).toBeDefined()
|
||||
expect(equippedNumberExport).toContain('type(player) == "number"')
|
||||
expect(equippedNumberExport).toContain('online_source_for_identifier(player)')
|
||||
expect(phoneServer).toContain('Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)')
|
||||
expect(phoneServer).toContain('return resolve_equipped_phone_number(player)')
|
||||
expect(equippedNumberExport).toContain(
|
||||
'online_source_for_identifier(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('equipped_phone_numbers[player]')
|
||||
expect(equippedNumberResolver).not.toContain('return cached_number')
|
||||
@@ -137,10 +201,10 @@ describe('phone inventory contracts', () => {
|
||||
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "ToggleOpen"',
|
||||
)
|
||||
expect(phoneClient).toContain(
|
||||
'local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})',
|
||||
'local result = Bridge.Callbacks.Trigger(callback_name, {})',
|
||||
)
|
||||
expect(phoneClient).toMatch(
|
||||
/result\.success ~= true[\s\S]*open_without_focus = false[\s\S]*return false/,
|
||||
/result\.success == true[\s\S]*open_requested = false[\s\S]*open_without_focus = false[\s\S]*return false/,
|
||||
)
|
||||
expect(phoneBridge).toContain(
|
||||
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "IsPhoneOnScreen"',
|
||||
@@ -160,7 +224,9 @@ describe('phone inventory contracts', () => {
|
||||
expect(phoneClient).toContain(
|
||||
'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)')
|
||||
})
|
||||
|
||||
@@ -213,7 +279,9 @@ describe('phone inventory contracts', () => {
|
||||
expect(mediaServer).toContain(
|
||||
'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:deletedFromGallery"')
|
||||
})
|
||||
@@ -232,7 +300,7 @@ describe('phone inventory contracts', () => {
|
||||
'if active_key_mapping_command == command_name then',
|
||||
)
|
||||
expect(phoneClient).toContain(
|
||||
'Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})',
|
||||
'request_phone_open("sky_phone:device:open-request")',
|
||||
)
|
||||
expect(phoneServer).toContain(
|
||||
'Bridge.Callbacks.Register("sky_phone:device:open-request", function(source)',
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from 'vue-router'
|
||||
|
||||
import { isPhoneAppId } from '@/config/apps'
|
||||
import { useAppStoreStore } from '@/stores/app-store'
|
||||
import PhoneAppWindow from '@/views/PhoneAppWindow.vue'
|
||||
import SpringboardView from '@/views/SpringboardView.vue'
|
||||
|
||||
@@ -34,7 +35,9 @@ export default createRouter({
|
||||
},
|
||||
{
|
||||
beforeEnter: (to) =>
|
||||
typeof to.params.appId === 'string' && isPhoneAppId(to.params.appId)
|
||||
typeof to.params.appId === 'string' &&
|
||||
isPhoneAppId(to.params.appId) &&
|
||||
useAppStoreStore().isInstalled(to.params.appId)
|
||||
? true
|
||||
: '/',
|
||||
component: PhoneAppWindow,
|
||||
|
||||
@@ -15,7 +15,31 @@ import type {
|
||||
} from '@/types/admin'
|
||||
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||
|
||||
const EMPTY_STATS: AdminStats = { accounts: 0, devices: 0, online: 0 }
|
||||
const EMPTY_STATS: AdminStats = {
|
||||
accounts: 0,
|
||||
activeDevices: 0,
|
||||
auditEntries: 0,
|
||||
auditToday: 0,
|
||||
callsToday: 0,
|
||||
devices: 0,
|
||||
linkedDevices: 0,
|
||||
messagesToday: 0,
|
||||
online: 0,
|
||||
simDevices: 0,
|
||||
}
|
||||
function disabledAppsFromConfigurator(
|
||||
configurator: AdminConfigurator,
|
||||
): string[] {
|
||||
const apps = configurator.sections
|
||||
.flatMap((section) => section.fields)
|
||||
.find((field) => field.scope === 'config' && field.path === 'Apps')?.value
|
||||
if (!apps || typeof apps !== 'object' || Array.isArray(apps)) return []
|
||||
|
||||
return Object.entries(apps)
|
||||
.filter(([, enabled]) => enabled === false)
|
||||
.map(([appId]) => appId)
|
||||
.sort()
|
||||
}
|
||||
|
||||
export const useAdminStore = defineStore('admin', {
|
||||
state: () => ({
|
||||
@@ -25,6 +49,7 @@ export const useAdminStore = defineStore('admin', {
|
||||
configurator: null as AdminConfigurator | null,
|
||||
configuratorLoading: false,
|
||||
detailLoading: false,
|
||||
disabledApps: [] as string[],
|
||||
error: '',
|
||||
initialized: false,
|
||||
loading: false,
|
||||
@@ -47,6 +72,9 @@ export const useAdminStore = defineStore('admin', {
|
||||
return false
|
||||
}
|
||||
this.players = response.data.players
|
||||
this.disabledApps = Array.isArray(response.data.disabledApps)
|
||||
? response.data.disabledApps
|
||||
: []
|
||||
this.stats = response.data.stats
|
||||
this.audit = response.data.audit
|
||||
this.error = ''
|
||||
@@ -147,6 +175,7 @@ export const useAdminStore = defineStore('admin', {
|
||||
return false
|
||||
}
|
||||
this.configurator = response.data
|
||||
this.disabledApps = disabledAppsFromConfigurator(response.data)
|
||||
this.error = ''
|
||||
return true
|
||||
},
|
||||
@@ -167,6 +196,7 @@ export const useAdminStore = defineStore('admin', {
|
||||
this.actionKey = ''
|
||||
if (response.success && response.data) {
|
||||
this.configurator = response.data
|
||||
this.disabledApps = disabledAppsFromConfigurator(response.data)
|
||||
this.error = ''
|
||||
} else {
|
||||
if (response.data) this.configurator = response.data
|
||||
|
||||
@@ -75,6 +75,21 @@ describe('app store', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('removes server-disabled apps without forgetting device claims', () => {
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
apps.hydrate({ claimedApps: ['weather', 'snake'] }, ['weather'])
|
||||
|
||||
expect(apps.isAvailable('weather')).toBe(false)
|
||||
expect(apps.isInstalled('weather')).toBe(false)
|
||||
expect(apps.homeLayout.grid).not.toContain('weather')
|
||||
expect(apps.claimedApps).toContain('weather')
|
||||
expect(apps.isInstalled('snake')).toBe(true)
|
||||
|
||||
apps.hydrate({ claimedApps: apps.claimedApps }, [])
|
||||
expect(apps.isInstalled('weather')).toBe(true)
|
||||
})
|
||||
|
||||
it('drops the retired admin app from persisted phone layouts', () => {
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
@@ -261,6 +276,31 @@ describe('app store', () => {
|
||||
expect(apps.isInstalled('phone')).toBe(true)
|
||||
})
|
||||
|
||||
it('temporarily blocks server-disabled apps without changing their installation', () => {
|
||||
const apps = useAppStoreStore()
|
||||
apps.hydrate({ claimedApps: ['crypto'] }, [
|
||||
'crypto',
|
||||
'citywarn',
|
||||
'not-an-app',
|
||||
])
|
||||
mocks.phone.saveDeviceNamespace.mockClear()
|
||||
|
||||
expect(apps.disabledApps).toEqual(['crypto', 'citywarn'])
|
||||
expect(apps.isAvailable('crypto')).toBe(false)
|
||||
expect(apps.isInstalled('crypto')).toBe(false)
|
||||
expect(apps.isInstalled('citywarn')).toBe(false)
|
||||
expect(apps.claimedApps).toEqual(['crypto'])
|
||||
|
||||
apps.installApp('crypto')
|
||||
expect(apps.installingApps).toEqual({})
|
||||
expect(mocks.phone.saveDeviceNamespace).not.toHaveBeenCalled()
|
||||
|
||||
apps.hydrate({ claimedApps: ['crypto'] })
|
||||
expect(apps.isAvailable('crypto')).toBe(true)
|
||||
expect(apps.isInstalled('crypto')).toBe(true)
|
||||
expect(apps.isInstalled('citywarn')).toBe(true)
|
||||
})
|
||||
|
||||
it('protects every default app from full uninstallation', () => {
|
||||
const apps = useAppStoreStore()
|
||||
apps.hydrate(null)
|
||||
|
||||
@@ -45,28 +45,49 @@ const pendingInstallations = new WeakMap<
|
||||
Map<LaunchablePhoneAppId, PendingInstallation>
|
||||
>()
|
||||
|
||||
function getDefaultGridIds(): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter((app) => app.dockOrder === null)
|
||||
function isAppDisabled(
|
||||
appId: LaunchablePhoneAppId,
|
||||
disabledApps: readonly LaunchablePhoneAppId[],
|
||||
): boolean {
|
||||
return disabledApps.includes(appId)
|
||||
}
|
||||
|
||||
function getDefaultGridIds(
|
||||
disabledApps: readonly LaunchablePhoneAppId[] = [],
|
||||
): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter(
|
||||
(app) => app.dockOrder === null && !isAppDisabled(app.id, disabledApps),
|
||||
)
|
||||
.sort((a, b) => a.gridOrder - b.gridOrder)
|
||||
.map((app) => app.id)
|
||||
}
|
||||
|
||||
function getDefaultDockIds(): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter((app) => app.dockOrder !== null)
|
||||
function getDefaultDockIds(
|
||||
disabledApps: readonly LaunchablePhoneAppId[] = [],
|
||||
): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter(
|
||||
(app) => app.dockOrder !== null && !isAppDisabled(app.id, disabledApps),
|
||||
)
|
||||
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
|
||||
.map((app) => app.id)
|
||||
}
|
||||
|
||||
function getDefaultInstalledIds(): LaunchablePhoneAppId[] {
|
||||
function getDefaultInstalledIds(
|
||||
disabledApps: readonly LaunchablePhoneAppId[] = [],
|
||||
): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter((app) => {
|
||||
if (app.adminOnly) return false
|
||||
if (app.adminOnly || isAppDisabled(app.id, disabledApps)) return false
|
||||
return isExternalPhoneApp(app)
|
||||
? app.defaultInstalled
|
||||
: DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id)
|
||||
}).map((app) => app.id)
|
||||
}
|
||||
|
||||
function isProtectedHomeApp(appId: LaunchablePhoneAppId): boolean {
|
||||
function isProtectedHomeApp(
|
||||
appId: LaunchablePhoneAppId,
|
||||
disabledApps: readonly LaunchablePhoneAppId[],
|
||||
): boolean {
|
||||
if (isAppDisabled(appId, disabledApps)) return false
|
||||
const app = getPhoneApp(appId)
|
||||
return app
|
||||
? !isPhoneAppRemovable(app)
|
||||
@@ -104,6 +125,7 @@ function hasUninstalledBuiltinApp(
|
||||
export const useAppStoreStore = defineStore('app-store', {
|
||||
state: () => ({
|
||||
claimedApps: [] as LaunchablePhoneAppId[],
|
||||
disabledApps: [] as LaunchablePhoneAppId[],
|
||||
uninstalledApps: [] as LaunchablePhoneAppId[],
|
||||
homeLayout: createDefaultHomeLayout(
|
||||
getDefaultInstalledIds(),
|
||||
@@ -130,6 +152,7 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
return true
|
||||
},
|
||||
claimApp(id: LaunchablePhoneAppId): void {
|
||||
if (!this.isAvailable(id)) return
|
||||
this.uninstalledApps = this.uninstalledApps.filter(
|
||||
(appId) => appId !== id,
|
||||
)
|
||||
@@ -150,6 +173,7 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
this.installingApps = {}
|
||||
},
|
||||
installApp(id: LaunchablePhoneAppId): void {
|
||||
if (!this.isAvailable(id)) return
|
||||
const installed = this.isInstalled(id)
|
||||
if (
|
||||
this.installingApps[id] ||
|
||||
@@ -191,6 +215,10 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!this.isAvailable(id)) {
|
||||
delete this.installingApps[id]
|
||||
return
|
||||
}
|
||||
if (reportInstall && !isExternalPhoneApp(getPhoneApp(id))) {
|
||||
delete this.installingApps[id]
|
||||
console.error(
|
||||
@@ -220,8 +248,14 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
installations.set(id, { deviceImei, timer, token })
|
||||
pendingInstallations.set(this, installations)
|
||||
},
|
||||
hydrate(payload: unknown): void {
|
||||
hydrate(payload: unknown, disabledApps: unknown = []): void {
|
||||
this.cancelPendingInstalls()
|
||||
this.disabledApps = Array.isArray(disabledApps)
|
||||
? disabledApps.filter(
|
||||
(id): id is LaunchablePhoneAppId =>
|
||||
typeof id === 'string' && isPhoneAppId(id),
|
||||
)
|
||||
: []
|
||||
const data = payload as {
|
||||
claimedApps?: unknown
|
||||
homeLayout?: unknown
|
||||
@@ -256,16 +290,22 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
})
|
||||
: []
|
||||
const installedIds = [
|
||||
...new Set([...getDefaultInstalledIds(), ...this.claimedApps]),
|
||||
].filter((id) => !this.uninstalledApps.includes(id))
|
||||
...new Set([
|
||||
...getDefaultInstalledIds(this.disabledApps),
|
||||
...this.claimedApps,
|
||||
]),
|
||||
].filter(
|
||||
(id) =>
|
||||
!this.uninstalledApps.includes(id) && this.isAvailable(id),
|
||||
)
|
||||
const removedLegacyDefaults = hasUninstalledBuiltinApp(
|
||||
data?.homeLayout,
|
||||
installedIds,
|
||||
)
|
||||
const defaults = createDefaultHomeLayout(
|
||||
installedIds,
|
||||
getDefaultGridIds(),
|
||||
getDefaultDockIds(),
|
||||
getDefaultGridIds(this.disabledApps),
|
||||
getDefaultDockIds(this.disabledApps),
|
||||
)
|
||||
const parsedHomeLayout = parseHomeLayout(
|
||||
data?.homeLayout,
|
||||
@@ -277,8 +317,9 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
const removedDockGridDuplicates =
|
||||
normalizedHomeLayout !== parsedHomeLayout
|
||||
this.homeLayout = normalizedHomeLayout
|
||||
const protectedHiddenAppIds =
|
||||
this.homeLayout.hidden.filter(isProtectedHomeApp)
|
||||
const protectedHiddenAppIds = this.homeLayout.hidden.filter((appId) =>
|
||||
isProtectedHomeApp(appId, this.disabledApps),
|
||||
)
|
||||
for (const appId of protectedHiddenAppIds) {
|
||||
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
|
||||
}
|
||||
@@ -310,7 +351,11 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
this.persist()
|
||||
}
|
||||
},
|
||||
isAvailable(appId: LaunchablePhoneAppId): boolean {
|
||||
return !isAppDisabled(appId, this.disabledApps)
|
||||
},
|
||||
isInstalled(appId: LaunchablePhoneAppId): boolean {
|
||||
if (!this.isAvailable(appId)) return false
|
||||
const app = getPhoneApp(appId)
|
||||
if (app?.adminOnly) return false
|
||||
if (this.uninstalledApps.includes(appId)) return false
|
||||
@@ -322,12 +367,18 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
},
|
||||
reconcileCatalog(): void {
|
||||
const installedIds = [
|
||||
...new Set([...getDefaultInstalledIds(), ...this.claimedApps]),
|
||||
].filter((id) => !this.uninstalledApps.includes(id))
|
||||
...new Set([
|
||||
...getDefaultInstalledIds(this.disabledApps),
|
||||
...this.claimedApps,
|
||||
]),
|
||||
].filter(
|
||||
(id) =>
|
||||
!this.uninstalledApps.includes(id) && this.isAvailable(id),
|
||||
)
|
||||
const defaults = createDefaultHomeLayout(
|
||||
installedIds,
|
||||
getDefaultGridIds(),
|
||||
getDefaultDockIds(),
|
||||
getDefaultGridIds(this.disabledApps),
|
||||
getDefaultDockIds(this.disabledApps),
|
||||
)
|
||||
const previous = JSON.stringify(this.homeLayout)
|
||||
this.homeLayout = removeDockGridDuplicates(
|
||||
@@ -335,7 +386,7 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
)
|
||||
|
||||
for (const appId of [...this.homeLayout.hidden]) {
|
||||
if (isProtectedHomeApp(appId)) {
|
||||
if (isProtectedHomeApp(appId, this.disabledApps)) {
|
||||
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
|
||||
}
|
||||
}
|
||||
@@ -345,6 +396,7 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
}
|
||||
},
|
||||
recordLaunch(appId: LaunchablePhoneAppId): void {
|
||||
if (!this.isAvailable(appId)) return
|
||||
this.launchCounts[appId] = (this.launchCounts[appId] ?? 0) + 1
|
||||
this.persist()
|
||||
},
|
||||
@@ -480,12 +532,13 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
return true
|
||||
},
|
||||
removeHomeApp(appId: LaunchablePhoneAppId): void {
|
||||
if (isProtectedHomeApp(appId)) return
|
||||
if (isProtectedHomeApp(appId, this.disabledApps)) return
|
||||
|
||||
this.homeLayout = removeHomeApp(this.homeLayout, appId)
|
||||
this.persist()
|
||||
},
|
||||
restoreHomeApp(appId: LaunchablePhoneAppId): void {
|
||||
if (!this.isAvailable(appId)) return
|
||||
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
|
||||
this.persist()
|
||||
},
|
||||
|
||||
@@ -10,14 +10,22 @@ type LuaToken = {
|
||||
}
|
||||
|
||||
const frontendSourceDirectory = fileURLToPath(new URL('../', import.meta.url))
|
||||
const englishLocaleSource = readFileSync(
|
||||
new URL('../../../sky_phone/config/locales/en.lua', import.meta.url),
|
||||
'utf8',
|
||||
const localeDirectory = fileURLToPath(
|
||||
new URL('../../../sky_phone/config/locales/', import.meta.url),
|
||||
)
|
||||
const germanLocaleSource = readFileSync(
|
||||
new URL('../../../sky_phone/config/locales/de.lua', import.meta.url),
|
||||
'utf8',
|
||||
const localeSources = new Map(
|
||||
readdirSync(localeDirectory, { withFileTypes: true })
|
||||
.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(
|
||||
new URL('./phone.ts', import.meta.url),
|
||||
'utf8',
|
||||
@@ -148,6 +156,10 @@ function collectPlaceholders(value: string): string[] {
|
||||
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> {
|
||||
const ast = ts.createSourceFile(
|
||||
'phone.ts',
|
||||
@@ -223,10 +235,26 @@ function collectFrontendFiles(directory: string): string[] {
|
||||
}
|
||||
|
||||
describe('phone locale contract', () => {
|
||||
const englishValues = collectLuaLocaleValues(englishLocaleSource)
|
||||
const germanValues = collectLuaLocaleValues(germanLocaleSource)
|
||||
const localeValues = new Map(
|
||||
[...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())
|
||||
|
||||
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', () => {
|
||||
const missing = [...collectDefaultLocalePaths(phoneStoreSource)].filter(
|
||||
(path) => !englishPaths.has(`Nui.${path}`),
|
||||
@@ -271,26 +299,52 @@ describe('phone locale contract', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps German structurally aligned with English', () => {
|
||||
const germanPaths = new Set(germanValues.keys())
|
||||
it.each(translatedLocaleValues)(
|
||||
'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', () => {
|
||||
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([])
|
||||
},
|
||||
)
|
||||
|
||||
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', () => {
|
||||
const standardAppNames = {
|
||||
|
||||
@@ -33,6 +33,7 @@ export type PasscodeResponseData = {
|
||||
export type PhoneOpenPayload = {
|
||||
account?: DeviceBootstrap['account']
|
||||
device?: PhoneDevice
|
||||
disabledApps?: string[]
|
||||
fallbackLocales?: LocaleTree
|
||||
lang?: string
|
||||
locales?: LocaleTree
|
||||
@@ -848,40 +849,21 @@ const adminPanelFallbackLocales = {
|
||||
auditFeature: 'Review sensitive admin actions',
|
||||
configuratorFeature: 'Manage config.lua and media.lua through SQL',
|
||||
},
|
||||
appearance: {
|
||||
eyebrow: 'Appearance',
|
||||
title: 'Interface',
|
||||
body: 'Personalize colors and typography across the admin workspace.',
|
||||
colors: {
|
||||
emerald: 'Emerald',
|
||||
blue: 'Blue',
|
||||
violet: 'Violet',
|
||||
orange: 'Orange',
|
||||
red: 'Red',
|
||||
},
|
||||
controls: {
|
||||
customColor: 'Custom color',
|
||||
customColorBody: 'Choose any RGB accent or enter its channel values.',
|
||||
red: 'R',
|
||||
green: 'G',
|
||||
blue: 'B',
|
||||
hex: 'HEX color',
|
||||
value: 'value',
|
||||
fontFamily: 'Font family',
|
||||
fontFamilyBody: 'Choose the typeface used by the complete admin panel.',
|
||||
fontSize: 'Font size',
|
||||
fontSizeBody: 'Scale text and controls for comfortable readability.',
|
||||
fonts: {
|
||||
inter: 'Inter',
|
||||
system: 'System',
|
||||
classic: 'Classic',
|
||||
verdana: 'Verdana',
|
||||
tahoma: 'Tahoma',
|
||||
trebuchet: 'Trebuchet',
|
||||
georgia: 'Georgia',
|
||||
mono: 'Monospace',
|
||||
},
|
||||
},
|
||||
statistics: {
|
||||
eyebrow: 'Live data',
|
||||
title: 'Phone statistics',
|
||||
body: 'Current usage and device status, refreshed every 15 seconds.',
|
||||
today: 'Activity today',
|
||||
todayBody: 'Phone and admin activity since midnight.',
|
||||
messagesToday: 'Messages',
|
||||
callsToday: 'Calls',
|
||||
auditToday: 'Admin actions',
|
||||
coverage: 'Device coverage',
|
||||
coverageBody: 'Current share of all stored phones.',
|
||||
linkedDevices: 'Account linked',
|
||||
simDevices: 'SIM assigned',
|
||||
activeDevices: 'Updated in 24 hours',
|
||||
ofDevices: '{count} of {total} devices',
|
||||
},
|
||||
configurator: {
|
||||
context: 'Runtime configuration',
|
||||
@@ -945,7 +927,6 @@ const adminPanelFallbackLocales = {
|
||||
entry: 'Entry',
|
||||
general: 'General',
|
||||
subtabs: {
|
||||
AdminGroups: 'Admin Groups',
|
||||
Dictionaries: 'Dictionaries',
|
||||
Clips: 'Clips',
|
||||
Transforms: 'Transforms',
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
export type AdminStats = {
|
||||
accounts: number
|
||||
activeDevices: number
|
||||
auditEntries: number
|
||||
auditToday: number
|
||||
callsToday: number
|
||||
devices: number
|
||||
linkedDevices: number
|
||||
messagesToday: number
|
||||
online: number
|
||||
simDevices: number
|
||||
}
|
||||
|
||||
export type AdminPlayerSummary = {
|
||||
@@ -78,6 +85,7 @@ export type AdminAuditEntry = {
|
||||
|
||||
export type AdminBootstrap = {
|
||||
audit: AdminAuditEntry[]
|
||||
disabledApps: string[]
|
||||
players: AdminPlayerSummary[]
|
||||
stats: AdminStats
|
||||
}
|
||||
|
||||
@@ -16,9 +16,7 @@ describe('admin configurator descriptions', () => {
|
||||
expect(configuratorDescriptionKey('Media.RequestTimeoutMs', 10000)).toBe(
|
||||
'milliseconds',
|
||||
)
|
||||
expect(configuratorDescriptionKey('AdminPanel.AdminGroups', [])).toBe(
|
||||
'access',
|
||||
)
|
||||
expect(configuratorDescriptionKey('Radio.AllowedJobs', [])).toBe('access')
|
||||
expect(configuratorDescriptionKey('FiveManage.ApiKey', '')).toBe(
|
||||
'credential',
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -41,24 +41,21 @@ describe('App Store preview catalog', () => {
|
||||
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 = [
|
||||
readFileSync(
|
||||
fileURLToPath(new URL('../stores/phone.ts', import.meta.url)),
|
||||
'utf8',
|
||||
),
|
||||
readFileSync(
|
||||
fileURLToPath(
|
||||
new URL('../../../sky_phone/config/locales/en.lua', import.meta.url),
|
||||
...readdirSync(localeDirectory)
|
||||
.filter((fileName) => fileName.endsWith('.lua'))
|
||||
.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) {
|
||||
|
||||
@@ -2,11 +2,16 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { LaunchablePhoneAppId } from '@/types/apps'
|
||||
import {
|
||||
DEFAULT_PHONE_PREFERENCES,
|
||||
PHONE_SCALE_STEP,
|
||||
PHONE_SETUP_LAST_STEP,
|
||||
parsePhonePreferences,
|
||||
WALLPAPER_IDS,
|
||||
} from './preferences'
|
||||
describe('preferences', () => {
|
||||
it('allows one-percent phone scale adjustments', () => {
|
||||
expect(PHONE_SCALE_STEP).toBe(1)
|
||||
})
|
||||
|
||||
it('starts Setup Assistant for a phone without saved settings', () => {
|
||||
const value = parsePhonePreferences(null)
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ export const WALLPAPER_IDS = [
|
||||
] as const
|
||||
export const PHONE_SCALE_MIN = 75
|
||||
export const PHONE_SCALE_MAX = 150
|
||||
export const PHONE_SCALE_STEP = 5
|
||||
export const PHONE_SCALE_STEP = 1
|
||||
export const PHONE_SETUP_LAST_STEP = 9
|
||||
|
||||
export type AppearanceMode = (typeof APPEARANCE_MODE_IDS)[number]
|
||||
|
||||
@@ -4,11 +4,13 @@ import { useRoute } from 'vue-router'
|
||||
|
||||
import CustomAppFrame from '@/components/CustomAppFrame.vue'
|
||||
import { getPhoneApp, isExternalPhoneApp } from '@/config/apps'
|
||||
import { useAppStoreStore } from '@/stores/app-store'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { getCustomAppFrameKey } from '@/utils/customAppLifecycle'
|
||||
import AppStoreApp from '@/views/apps/AppStoreApp.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const appStore = useAppStoreStore()
|
||||
const phone = usePhoneStore()
|
||||
const app = computed(() => getPhoneApp(route.params.appId))
|
||||
const builtinAppComponent = computed(() =>
|
||||
@@ -28,7 +30,7 @@ const launchStyle = computed(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="app && !app.adminOnly"
|
||||
v-if="app && !app.adminOnly && appStore.isInstalled(app.id)"
|
||||
class="app-window"
|
||||
:class="{ 'app-window--citywarn': app.id === 'citywarn' }"
|
||||
:style="launchStyle"
|
||||
|
||||
@@ -81,6 +81,7 @@ describe('AppStoreApp Sky navigation contract', () => {
|
||||
expect(source).toContain('@click="profileOpened = true"')
|
||||
expect(source).toContain('const installedApps = computed')
|
||||
expect(source).toContain('return !appStore.isInstalled(app.id)')
|
||||
expect(source).toContain('if (!appStore.isAvailable(app.id)) return false')
|
||||
expect(source).toContain(
|
||||
'class="store-account__apps phone-effect--expensive-shadow"',
|
||||
)
|
||||
|
||||
@@ -122,6 +122,7 @@ const catalog = computed(() =>
|
||||
if (!isLaunchablePhoneApp(app) || app.id === 'app-store' || app.adminOnly) {
|
||||
return false
|
||||
}
|
||||
if (!appStore.isAvailable(app.id)) return false
|
||||
|
||||
return !appStore.isInstalled(app.id)
|
||||
}).sort((a, b) => a.gridOrder - b.gridOrder),
|
||||
@@ -129,7 +130,9 @@ const catalog = computed(() =>
|
||||
const installedApps = computed(() =>
|
||||
PHONE_APPS.filter(
|
||||
(app): app is LaunchablePhoneAppDefinition =>
|
||||
isLaunchablePhoneApp(app) && appStore.isInstalled(app.id),
|
||||
isLaunchablePhoneApp(app) &&
|
||||
appStore.isAvailable(app.id) &&
|
||||
appStore.isInstalled(app.id),
|
||||
).sort((a, b) => a.gridOrder - b.gridOrder),
|
||||
)
|
||||
const installedGameCount = computed(
|
||||
@@ -143,6 +146,7 @@ const dailyCandidates = computed(() =>
|
||||
!app.adminOnly &&
|
||||
!isExternalPhoneApp(app) &&
|
||||
app.id !== 'app-store' &&
|
||||
appStore.isAvailable(app.id) &&
|
||||
!DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) &&
|
||||
!appStore.isInstalled(app.id),
|
||||
),
|
||||
|
||||
@@ -369,6 +369,22 @@ describe('VaultX crypto app contracts', () => {
|
||||
expect(testServer).toContain('function advanceCryptoCycle(')
|
||||
})
|
||||
|
||||
it('does not keep market and settlement workers active while disabled', () => {
|
||||
expect(server).toContain('local function start_crypto_schedulers()')
|
||||
expect(server).toMatch(
|
||||
/start_crypto_schedulers\(\)[\s\S]*?if Config\.Crypto\.Enabled ~= true then\s+return/,
|
||||
)
|
||||
expect(server).toContain(
|
||||
'while scheduler_generation == generation and Config.Crypto.Enabled == true do',
|
||||
)
|
||||
expect(server).toContain(
|
||||
'AddEventHandler("sky_phone:configurator:serverUpdated", refresh_crypto_runtime)',
|
||||
)
|
||||
expect(server).not.toMatch(
|
||||
/CreateThread\(function\(\)\s+while true do\s+Wait\(5 \* 60 \* 1000\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it('stores cash in price-scale minor units throughout the ledger', () => {
|
||||
expect(server).toContain(
|
||||
'local ledger_amount = amount * Config.Crypto.PriceScale',
|
||||
|
||||
@@ -6,6 +6,14 @@ const source = readFileSync(
|
||||
new URL('./DarkChatApp.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const server = readFileSync(
|
||||
new URL('../../../../sky_phone/source/server/darkchat.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const config = readFileSync(
|
||||
new URL('../../../../sky_phone/config/config.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('DarkChatApp Sky UI contract', () => {
|
||||
it('uses first-party Sky UI without direct Konsta markup', () => {
|
||||
@@ -130,4 +138,13 @@ describe('DarkChatApp Sky UI contract', () => {
|
||||
/\.dc-inbox-navbar :deep\(\.sky-navbar__title-container > div\)\s*\{[^}]*translateY\(-14px\)/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('cleans expired messages in bounded indexed batches', () => {
|
||||
expect(config).toContain('CleanupBatchSize = 250')
|
||||
expect(server).toContain('Config.DarkChat.CleanupBatchSize')
|
||||
expect(server).toMatch(
|
||||
/DELETE FROM `sky_phone_darkchat_messages`[\s\S]*?ORDER BY `expires_at`, `id`[\s\S]*?LIMIT \?/,
|
||||
)
|
||||
expect(server).toContain(']], { batch_size })')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,12 @@ describe('PhoneApp EasyShare contract', () => {
|
||||
it('uses the shared full-width Sky tab bar for phone sections', () => {
|
||||
expect(source).toContain('<sky-tab-bar')
|
||||
expect(source).toContain('<sky-tab-button')
|
||||
expect(source).toContain(
|
||||
'calc(var(--sky-tabbar-height) + var(--sky-safe-area-bottom) + 16px);',
|
||||
)
|
||||
expect(source).not.toMatch(
|
||||
/\.phone-contacts\s*\{[^}]*padding-bottom:\s*20px;/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('uses shared interactive liquid glass surfaces for phone controls', () => {
|
||||
|
||||
@@ -2250,7 +2250,6 @@ onBeforeUnmount(() => {
|
||||
position: relative;
|
||||
padding-top: 6px;
|
||||
padding-right: 24px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.phone-contacts-header {
|
||||
|
||||
@@ -116,6 +116,20 @@ describe('voice provider contracts', () => {
|
||||
expect(phoneApp).not.toContain('callMuted = !callMuted')
|
||||
})
|
||||
|
||||
it('supports explicit automatic call-provider discovery on client and server', () => {
|
||||
expect(config).toContain(
|
||||
'VoiceProvider = "pma", -- auto, yaca (alias: yaca-voice)',
|
||||
)
|
||||
expect(clientCalls).toContain('if configured == "auto" then')
|
||||
expect(serverVoice).toContain('if configured == "auto" then')
|
||||
expect(clientCalls).toContain(
|
||||
'for _, candidate in ipairs({ "yaca", "pma", "saltychat" }) do',
|
||||
)
|
||||
expect(serverVoice).toContain(
|
||||
'for _, candidate in ipairs({ "yaca", "pma", "saltychat" }) do',
|
||||
)
|
||||
})
|
||||
|
||||
it('passes Yaca radio volume arguments in the documented order', () => {
|
||||
expect(clientRadio).toContain(
|
||||
'changeRadioChannelVolumeRaw(volume / 100, 1)',
|
||||
|
||||
@@ -661,6 +661,7 @@ function loadConfiguratorSections() {
|
||||
)
|
||||
const media = mediaRoot.Media
|
||||
delete config.PhoneConfigurator
|
||||
delete config.CommandPermissions
|
||||
delete config.Media
|
||||
return [...buildSections('config', config), ...buildSections('media', media)]
|
||||
}
|
||||
|
||||
@@ -61,16 +61,21 @@ function countStructure(structure: ConfiguratorStructure | undefined): number {
|
||||
}
|
||||
|
||||
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 fields = sections.flatMap((section) => section.fields)
|
||||
const roots = [
|
||||
...configSource.matchAll(/^\s{0,4}Config\.([A-Za-z0-9_]+)\s*=/gm),
|
||||
]
|
||||
.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(46)
|
||||
expect(
|
||||
fields.reduce(
|
||||
(total, field) => total + countStructure(field.structure),
|
||||
|
||||
@@ -4726,6 +4726,7 @@ function adminMockBootstrap() {
|
||||
targetSource: 2,
|
||||
},
|
||||
],
|
||||
disabledApps: [],
|
||||
players: [1, 2].map((source) => {
|
||||
const player = adminMockPlayerDetail(source)
|
||||
return {
|
||||
@@ -4740,7 +4741,18 @@ function adminMockBootstrap() {
|
||||
source,
|
||||
}
|
||||
}),
|
||||
stats: { accounts: 24, devices: 31, online: 2 },
|
||||
stats: {
|
||||
accounts: 24,
|
||||
activeDevices: 19,
|
||||
auditEntries: 37,
|
||||
auditToday: 4,
|
||||
callsToday: 18,
|
||||
devices: 31,
|
||||
linkedDevices: 22,
|
||||
messagesToday: 146,
|
||||
online: 2,
|
||||
simDevices: 27,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-2
@@ -134,7 +134,7 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
|
||||
- `hex_4_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
|
||||
|
||||
@@ -196,6 +196,7 @@ The files contain clearly separated sections for:
|
||||
| Section | Purpose |
|
||||
| --- | --- |
|
||||
| `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.Sim` | Physical or virtual SIM behavior and number formatting |
|
||||
| `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.
|
||||
|
||||
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
|
||||
|
||||
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 `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
|
||||
|
||||
|
||||
+71
-10
@@ -10,19 +10,36 @@
|
||||
Keep option names unchanged. Restart sky_phone after editing this file.
|
||||
]]
|
||||
|
||||
-- When enabled, config.lua and media.lua only provide first-run defaults.
|
||||
-- The active configuration is loaded from SQL and managed through /phonepanel.
|
||||
-- CONFIG_DEFAULT_EXCLUDE_START
|
||||
-- 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 = {
|
||||
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
|
||||
-- =============================================================================
|
||||
|
||||
Config.Bridge = {
|
||||
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",
|
||||
CallbackTimeout = 15000,
|
||||
Debug = false, -- true: show debug/info output; warnings and errors are always shown
|
||||
@@ -32,8 +49,9 @@ Config.Command = "phone"
|
||||
|
||||
Config.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
|
||||
OpenRequestsPerMinute = 20,
|
||||
AllowMovement = true, -- true: game input stays active while the mobile phone is open
|
||||
HoldToLook = {
|
||||
Enabled = true, -- hold the configured control to hide the cursor and look around; independent of AllowMovement
|
||||
@@ -43,11 +61,56 @@ Config.Phone = {
|
||||
DeviceName = "iFruit Phone",
|
||||
}
|
||||
|
||||
-- Server-wide availability for bundled apps. Set an entry to false to hide it
|
||||
-- from every phone, the App Store and per-device app management.
|
||||
Config.Apps = {
|
||||
["app-store"] = true,
|
||||
banking = true,
|
||||
billing = true,
|
||||
calculator = true,
|
||||
calendar = true,
|
||||
camera = true,
|
||||
citymarkt = true,
|
||||
citywarn = true,
|
||||
clock = true,
|
||||
companies = true,
|
||||
crewlink = true,
|
||||
crypto = true,
|
||||
darkchat = true,
|
||||
feather = true,
|
||||
flare = true,
|
||||
fliptok = true,
|
||||
garage = true,
|
||||
health = true,
|
||||
house = true,
|
||||
["local-pages"] = true,
|
||||
mail = true,
|
||||
map = true,
|
||||
memory = true,
|
||||
memos = true,
|
||||
messages = true,
|
||||
minesweeper = true,
|
||||
music = true,
|
||||
["neon-drop"] = true,
|
||||
notes = true,
|
||||
["number-merge"] = true,
|
||||
phone = true,
|
||||
photos = true,
|
||||
picstagram = true,
|
||||
radio = true,
|
||||
settings = true,
|
||||
["sky-flappy"] = true,
|
||||
skyride = true,
|
||||
snake = true,
|
||||
["tower-stack"] = true,
|
||||
weather = true,
|
||||
["weazel-news"] = true,
|
||||
}
|
||||
|
||||
Config.TestData = {
|
||||
Enabled = false, -- development/test servers only; keep disabled in production
|
||||
Command = "phonetestdata",
|
||||
AdminOnly = false, -- enable only on development servers; every run is scoped to the executing player's phone
|
||||
AdminGroups = { "admin", "superadmin" },
|
||||
}
|
||||
|
||||
Config.CustomApps = {
|
||||
@@ -74,7 +137,6 @@ Config.Security = {
|
||||
Config.AdminPanel = {
|
||||
Enabled = true,
|
||||
Command = "phonepanel",
|
||||
AdminGroups = { "admin", "superadmin" },
|
||||
MaximumPlayers = 128,
|
||||
ReadRequestsPerMinute = 60,
|
||||
ActionRequestsPerMinute = 30,
|
||||
@@ -84,7 +146,7 @@ Config.AdminPanel = {
|
||||
}
|
||||
|
||||
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",
|
||||
AnonymousItem = "sky_phone_sim_anonymous",
|
||||
NumberLength = 10, -- total number of digits, including NumberPrefix
|
||||
@@ -101,7 +163,7 @@ Config.Speaker = {
|
||||
}
|
||||
|
||||
Config.Calls = {
|
||||
VoiceProvider = "pma", -- yaca (alias: yaca-voice), pma (alias: pma-voice), saltychat (alias: salty)
|
||||
VoiceProvider = "pma", -- auto, yaca (alias: yaca-voice), pma (alias: pma-voice), saltychat (alias: salty)
|
||||
RingSeconds = 30,
|
||||
ContactNameMaxLength = 80,
|
||||
ContactNotesMaxLength = 500,
|
||||
@@ -286,6 +348,7 @@ Config.DarkChat = {
|
||||
VoiceMaxBase64Length = 360000,
|
||||
VoiceWaveformSamples = 48,
|
||||
CleanupIntervalSeconds = 30,
|
||||
CleanupBatchSize = 250,
|
||||
AllowedDisappearTimers = {
|
||||
[0] = true,
|
||||
[-1] = true, -- after reading
|
||||
@@ -483,7 +546,6 @@ Config.FlipTok = {
|
||||
MaxPostMedia = 10,
|
||||
MusicTracks = {},
|
||||
VerifyCommand = "fliptokverify",
|
||||
AdminGroups = { "admin" },
|
||||
ReportWebhookConvar = "sky_phone_fliptok_report_webhook",
|
||||
}
|
||||
|
||||
@@ -505,7 +567,6 @@ Config.Picstagram = {
|
||||
ReportDetailsMaxLength = 500,
|
||||
ReportReasons = { "spam", "harassment", "dangerous", "illegal", "other" },
|
||||
VerifyCommand = "picstagramverify",
|
||||
AdminGroups = { "admin" },
|
||||
}
|
||||
|
||||
Config.Feather = {
|
||||
|
||||
@@ -43,6 +43,7 @@ Locales["de"] = {
|
||||
invalid_sim = "Diese SIM-Karte besitzt ungültige Metadaten.",
|
||||
phone_required = "Du benötigst ein Handy in deinem Inventar.",
|
||||
operation_in_progress = "Eine andere Handy-Aktion wird bereits ausgeführt.",
|
||||
rate_limited = "Das Handy wurde zu oft geöffnet. Versuch es gleich erneut.",
|
||||
voice_unavailable = "Der konfigurierte Sprachdienst ist nicht verfügbar.",
|
||||
default = "Das Handy konnte nicht geöffnet werden.",
|
||||
},
|
||||
@@ -205,7 +206,7 @@ Locales["de"] = {
|
||||
name = "Phone Admin", subtitle = "Administration", navigation = "Admin-Navigation", refresh = "Admin-Daten aktualisieren", loading = "Geschützte Daten werden geladen...",
|
||||
tabs = { overview = "Übersicht", players = "Spieler", devices = "Geräte", apps = "Apps", accounts = "Accounts", messages = "Nachrichten", calls = "Anrufe", moderation = "Moderation", audit = "Audit", configurator = "Phone Configurator" },
|
||||
overview = { eyebrow = "Server", title = "Dashboard", body = "Spieler, Geräte, Apps und Handydaten.", stats = "Server-Handystatistik", online = "Online", devices = "Geräte", accounts = "Accounts", audit = "Audit-Einträge", control = "Navigation", features = "Module", featuresBody = "Öffne ein Verwaltungsmodul.", recent = "Letzte Aktivitäten", playerFeature = "Identität, Finanzen, Job und Dienst", deviceFeature = "IMEI, SIM, Nummer und Aktivität", appFeature = "Handy-Apps installieren oder entfernen", accountFeature = "Accountzugriff und geschützte Zugangsdaten", messageFeature = "Letzte SMS-Aktivitäten prüfen", callFeature = "Letzte Anrufaktivitäten prüfen", moderationFeature = "Zugriff, Nummer oder Gerätedaten zurücksetzen", auditFeature = "Sensible Admin-Aktionen prüfen", configuratorFeature = "config.lua und media.lua über SQL verwalten" },
|
||||
appearance = { eyebrow = "Darstellung", title = "Akzentfarbe", body = "Ändere den Akzent im gesamten Admin-Arbeitsbereich.", colors = { emerald = "Smaragd", blue = "Blau", violet = "Violett", orange = "Orange", red = "Rot" } },
|
||||
statistics = { eyebrow = "Live-Daten", title = "Handy-Statistiken", body = "Aktuelle Nutzung und Gerätestatus, automatisch alle 15 Sekunden aktualisiert.", today = "Aktivität heute", todayBody = "Handy- und Admin-Aktivität seit Mitternacht.", messagesToday = "Nachrichten", callsToday = "Anrufe", auditToday = "Admin-Aktionen", coverage = "Geräteabdeckung", coverageBody = "Aktueller Anteil aller gespeicherten Handys.", linkedDevices = "Account verknüpft", simDevices = "SIM zugewiesen", activeDevices = "In 24 Stunden aktualisiert", ofDevices = "{count} von {total} Geräten" },
|
||||
configurator = { context = "Runtime-Konfiguration", eyebrow = "Systemwerkzeug", sections = "Konfiguration", search = "Einstellungen oder Pfade suchen", configScope = "config.lua", mediaScope = "media.lua", noResults = "Keine passenden Einstellungen", loading = "SQL-Konfiguration wird geladen...", title = "Phone Configurator", body = "Verwalte Handy- und Media-Einstellungen im geschützten Admin-Bereich.", disabledTitle = "SQL-Konfiguration ist nicht aktiv", disabledBody = "Aktiviere den Configurator am Anfang der config.lua und starte sky_phone neu. Bis dahin bleiben die Dateiwerte aktiv und die Bearbeitung gesperrt.", manualSave = "Manuelles Speichern", refreshNotice = "Nichts wird automatisch gespeichert. Der grüne Haken prüft config.lua und media.lua in SQL und aktualisiert die aktive Server-, Client-, Media- und UI-Konfiguration sofort intern.", fieldCount = "{count} Felder", secretConfigured = "Secret gesetzt · Ersatzwert eingeben", invalidValue = "Prüfe den markierten Tabellen- oder Zahlenwert.", saved = "SQL-Konfiguration gespeichert und übernommen.", descriptions = { featureToggle = "Schaltet {name} ein oder aus.", boolean = "Legt fest, ob {name} erlaubt ist.", number = "Legt den Zahlenwert für {name} fest.", text = "Legt den Textwert für {name} fest.", optionalText = "Legt den optionalen Wert für {name} fest; der Schalter deaktiviert ihn.", list = "Verwaltet alle Einträge für {name}.", table = "Bündelt die zusammengehörigen Einstellungen für {name}.", credential = "Speichert den geschützten Zugangsschlüssel für {name}.", url = "Legt die URL oder den Endpunkt für {name} fest.", hosts = "Legt die erlaubten Domains für {name} fest.", milliseconds = "Legt die Zeit für {name} in Millisekunden fest.", seconds = "Legt die Zeit für {name} in Sekunden fest.", rateLimit = "Begrenzt die Aktionen für {name} pro Minute.", byteLimit = "Legt die maximal erlaubte Datengröße für {name} fest.", textLimit = "Legt die maximal erlaubte Textlänge für {name} fest.", distance = "Legt die Distanz in der Spielwelt für {name} fest.", coordinates = "Legt Weltkoordinaten oder Ausrichtung für {name} fest.", gameAsset = "Legt das GTA-Modell oder Prop für {name} fest.", animation = "Legt die Animationsdatei für {name} fest.", access = "Legt Jobs, Gruppen oder Berechtigungsstufen für {name} fest.", integration = "Wählt Framework oder Anbieter für {name} aus.", path = "Legt den Speicher- oder Ressourcenpfad für {name} fest.", color = "Legt die Oberflächenfarbe für {name} fest.", displayText = "Legt den Spielern angezeigten Text für {name} fest.", phoneNumber = "Legt die Telefon- oder Servicenummer für {name} fest.", routing = "Steuert die Verteilung eingehender Anfragen für {name}.", command = "Legt den Chat-Befehl zum Öffnen oder Ausführen von {name} fest.", locale = "Wählt die Sprache für {name} aus.", debug = "Steuert ausführliche Diagnoseausgaben für {name}.", mediaQuality = "Legt Medienqualität oder Lautstärke für {name} fest.", amount = "Legt die maximale oder angezeigte Menge für {name} fest." }, table = { list = "Liste", table = "Schlüsseltabelle", vector = "Vektor", entry = "Eintrag", general = "Allgemein", addRow = "Zeile hinzufügen", addField = "Feld hinzufügen", remove = "Entfernen", emptyList = "Noch keine Zeilen. Füge die erste Zeile mit Plus hinzu.", emptyTable = "Noch keine Felder. Füge unten den ersten Schlüssel hinzu.", keyPlaceholder = "Neuer Schlüssel", convertToList = "Als Liste nutzen", convertToMap = "Als typisierte Schlüsseltabelle nutzen", convertToTable = "Als Schlüsseltabelle nutzen", types = { string = "Text", number = "Zahl", boolean = "Schalter", list = "Liste", table = "Tabelle" } } },
|
||||
players = { eyebrow = "Aktive Sitzungen", title = "Online-Spieler", online = "Jetzt online", empty = "Keine Spieler gefunden", emptyBody = "Passe die Suche an oder aktualisiere die Spielerliste." },
|
||||
search = { players = "Name, ID, Job oder Nummer suchen", apps = "Apps suchen", clear = "Suche leeren" },
|
||||
@@ -2139,7 +2140,6 @@ Locales["de"] = {
|
||||
}
|
||||
|
||||
Locales["de"].Nui.AdminPanel.configurator.table.subtabs = {
|
||||
AdminGroups = "Admin-Gruppen",
|
||||
Dictionaries = "Animationsdateien",
|
||||
Clips = "Clips",
|
||||
Transforms = "Transformationen",
|
||||
@@ -2176,29 +2176,3 @@ Locales["de"].Nui.AdminPanel.configurator.table.subtabs = {
|
||||
AllowedJobs = "Erlaubte Jobs",
|
||||
Websites = "Webseiten",
|
||||
}
|
||||
|
||||
Locales["de"].Nui.AdminPanel.appearance.title = "Oberfläche"
|
||||
Locales["de"].Nui.AdminPanel.appearance.body = "Passe Farben und Typografie im gesamten Admin-Bereich an."
|
||||
Locales["de"].Nui.AdminPanel.appearance.controls = {
|
||||
customColor = "Eigene Farbe",
|
||||
customColorBody = "Wähle einen beliebigen RGB-Akzent oder trage die Farbwerte ein.",
|
||||
red = "R",
|
||||
green = "G",
|
||||
blue = "B",
|
||||
hex = "HEX-Farbe",
|
||||
value = "Wert",
|
||||
fontFamily = "Schriftart",
|
||||
fontFamilyBody = "Wähle die Schrift für das gesamte Admin-Panel.",
|
||||
fontSize = "Schriftgröße",
|
||||
fontSizeBody = "Skaliere Text und Bedienelemente für eine angenehme Lesbarkeit.",
|
||||
fonts = {
|
||||
inter = "Inter",
|
||||
system = "System",
|
||||
classic = "Klassisch",
|
||||
verdana = "Verdana",
|
||||
tahoma = "Tahoma",
|
||||
trebuchet = "Trebuchet",
|
||||
georgia = "Georgia",
|
||||
mono = "Monospace",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ Locales["en"] = {
|
||||
invalid_sim = "This SIM card has invalid metadata.",
|
||||
phone_required = "You need a phone in your inventory.",
|
||||
operation_in_progress = "Another phone operation is already in progress.",
|
||||
rate_limited = "The phone was opened too often. Try again in a moment.",
|
||||
voice_unavailable = "The configured phone voice service is unavailable.",
|
||||
default = "The phone could not be opened.",
|
||||
},
|
||||
@@ -205,7 +206,7 @@ Locales["en"] = {
|
||||
name = "Phone Admin", subtitle = "Administration", navigation = "Admin navigation", refresh = "Refresh admin data", loading = "Loading protected data...",
|
||||
tabs = { overview = "Overview", players = "Players", devices = "Devices", apps = "Apps", accounts = "Accounts", messages = "Messages", calls = "Calls", moderation = "Moderation", audit = "Audit", configurator = "Phone configurator" },
|
||||
overview = { eyebrow = "Server", title = "Dashboard", body = "Players, devices, apps, and phone data.", stats = "Server phone statistics", online = "Online", devices = "Devices", accounts = "Accounts", audit = "Audit entries", control = "Navigation", features = "Modules", featuresBody = "Open an administration module.", recent = "Recent activity", playerFeature = "Identity, finances, job, and duty", deviceFeature = "IMEI, SIM, number, and activity", appFeature = "Install or remove phone apps", accountFeature = "Account access and protected credentials", messageFeature = "Review recent SMS activity", callFeature = "Review recent call activity", moderationFeature = "Reset access, number, or device data", auditFeature = "Review sensitive admin actions", configuratorFeature = "Manage config.lua and media.lua through SQL" },
|
||||
appearance = { eyebrow = "Appearance", title = "Accent color", body = "Change the accent across the complete admin workspace.", colors = { emerald = "Emerald", blue = "Blue", violet = "Violet", orange = "Orange", red = "Red" } },
|
||||
statistics = { eyebrow = "Live data", title = "Phone statistics", body = "Current usage and device status, refreshed every 15 seconds.", today = "Activity today", todayBody = "Phone and admin activity since midnight.", messagesToday = "Messages", callsToday = "Calls", auditToday = "Admin actions", coverage = "Device coverage", coverageBody = "Current share of all stored phones.", linkedDevices = "Account linked", simDevices = "SIM assigned", activeDevices = "Updated in 24 hours", ofDevices = "{count} of {total} devices" },
|
||||
configurator = { context = "Runtime configuration", eyebrow = "System tool", sections = "Configuration", search = "Search settings or paths", configScope = "config.lua", mediaScope = "media.lua", noResults = "No matching settings", loading = "Loading SQL configuration...", title = "Phone configurator", body = "Manage phone and media settings from the protected admin workspace.", disabledTitle = "SQL configuration is not active", disabledBody = "Enable the configurator at the beginning of config.lua and restart sky_phone. Until then, file values remain active and editing is locked.", manualSave = "Manual save", refreshNotice = "Nothing is written automatically. The green check verifies config.lua and media.lua in SQL and refreshes the active server, client, media and UI configuration immediately.", fieldCount = "{count} fields", secretConfigured = "Secret configured · enter a replacement", invalidValue = "Check the highlighted table or number value.", saved = "SQL configuration saved and applied.", descriptions = { featureToggle = "Turns {name} on or off.", boolean = "Controls whether {name} is allowed.", number = "Sets the numeric value for {name}.", text = "Sets the text value used for {name}.", optionalText = "Sets the optional value for {name}; switch it off to disable it.", list = "Manages all entries used for {name}.", table = "Groups the related settings for {name}.", credential = "Stores the protected credential used by {name}.", url = "Sets the URL or endpoint used by {name}.", hosts = "Defines which domains are allowed for {name}.", milliseconds = "Sets the timing for {name} in milliseconds.", seconds = "Sets the timing for {name} in seconds.", rateLimit = "Limits how many {name} actions are allowed per minute.", byteLimit = "Sets the maximum data size allowed for {name}.", textLimit = "Sets the maximum text length allowed for {name}.", distance = "Sets the world distance used for {name}.", coordinates = "Sets the world coordinates or orientation for {name}.", gameAsset = "Sets the GTA model or prop used for {name}.", animation = "Sets the animation asset used for {name}.", access = "Defines the jobs, groups or permission level for {name}.", integration = "Selects the connected framework or provider for {name}.", path = "Sets the storage or resource path used for {name}.", color = "Sets the interface color used for {name}.", displayText = "Sets the text shown to players for {name}.", phoneNumber = "Sets the phone or service number used for {name}.", routing = "Controls how incoming requests are routed for {name}.", command = "Sets the chat command used to open or run {name}.", locale = "Selects the language used for {name}.", debug = "Controls detailed diagnostic output for {name}.", mediaQuality = "Sets the media quality or volume used for {name}.", amount = "Sets the maximum or displayed amount for {name}." }, table = { list = "List", table = "Key table", vector = "Vector", entry = "Entry", general = "General", addRow = "Add row", addField = "Add field", remove = "Remove", emptyList = "No rows yet. Add the first row with plus.", emptyTable = "No fields yet. Add the first key below.", keyPlaceholder = "New key", convertToList = "Use list", convertToMap = "Use typed key table", convertToTable = "Use key table", types = { string = "Text", number = "Number", boolean = "Switch", list = "List", table = "Table" } } },
|
||||
players = { eyebrow = "Active sessions", title = "Online players", online = "Online now", empty = "No players found", emptyBody = "Adjust the search or refresh the live player list." },
|
||||
search = { players = "Search name, ID, job, or number", apps = "Search apps", clear = "Clear search" },
|
||||
@@ -2139,7 +2140,6 @@ Locales["en"] = {
|
||||
}
|
||||
|
||||
Locales["en"].Nui.AdminPanel.configurator.table.subtabs = {
|
||||
AdminGroups = "Admin Groups",
|
||||
Dictionaries = "Dictionaries",
|
||||
Clips = "Clips",
|
||||
Transforms = "Transforms",
|
||||
@@ -2176,29 +2176,3 @@ Locales["en"].Nui.AdminPanel.configurator.table.subtabs = {
|
||||
AllowedJobs = "Allowed Jobs",
|
||||
Websites = "Websites",
|
||||
}
|
||||
|
||||
Locales["en"].Nui.AdminPanel.appearance.title = "Interface"
|
||||
Locales["en"].Nui.AdminPanel.appearance.body = "Personalize colors and typography across the admin workspace."
|
||||
Locales["en"].Nui.AdminPanel.appearance.controls = {
|
||||
customColor = "Custom color",
|
||||
customColorBody = "Choose any RGB accent or enter its channel values.",
|
||||
red = "R",
|
||||
green = "G",
|
||||
blue = "B",
|
||||
hex = "HEX color",
|
||||
value = "value",
|
||||
fontFamily = "Font family",
|
||||
fontFamilyBody = "Choose the typeface used by the complete admin panel.",
|
||||
fontSize = "Font size",
|
||||
fontSizeBody = "Scale text and controls for comfortable readability.",
|
||||
fonts = {
|
||||
inter = "Inter",
|
||||
system = "System",
|
||||
classic = "Classic",
|
||||
verdana = "Verdana",
|
||||
tahoma = "Tahoma",
|
||||
trebuchet = "Trebuchet",
|
||||
georgia = "Georgia",
|
||||
mono = "Monospace",
|
||||
},
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,19 +2,25 @@
|
||||
Sky Phone media configuration
|
||||
|
||||
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
|
||||
defaults and the active media configuration is loaded from SQL.
|
||||
phone configurator is enabled in config.lua, the active media configuration
|
||||
is loaded from SQL and frontend builds copy the shipped values from this
|
||||
file into source/shared/config_default.lua.
|
||||
]]
|
||||
|
||||
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,
|
||||
GifRating = "pg-13",
|
||||
UrlMaxLength = 2048,
|
||||
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 = {
|
||||
ApiKey = "", -- Paste a newly generated FiveManage V3 Media API token here.
|
||||
ApiKey = "",
|
||||
BaseUrl = "https://api.fivemanage.com/api/v3/file",
|
||||
RequestTimeoutMs = 10000,
|
||||
UploadTimeoutMs = 25000,
|
||||
|
||||
@@ -6,7 +6,7 @@ use_experimental_fxv2_oal 'yes'
|
||||
|
||||
author 'Sky-Systems'
|
||||
description 'Sky Phone'
|
||||
version '0.2.5'
|
||||
version '0.2.8'
|
||||
|
||||
provide 'lb-phone'
|
||||
provide '17mov_Phone'
|
||||
@@ -32,6 +32,7 @@ client_scripts {
|
||||
'config/config.lua',
|
||||
'config/locales/en.lua',
|
||||
'config/locales/de.lua',
|
||||
'config/locales/es.lua',
|
||||
'source/bridge/client/callbacks.lua',
|
||||
'source/client/phone_configurator.lua',
|
||||
'source/bridge/client/framework.lua',
|
||||
@@ -77,6 +78,8 @@ server_scripts {
|
||||
'config/media.lua',
|
||||
'config/locales/en.lua',
|
||||
'config/locales/de.lua',
|
||||
'config/locales/es.lua',
|
||||
'source/shared/config_default.lua',
|
||||
'source/server/nui_build_check.lua',
|
||||
'source/server/update_check.lua',
|
||||
'source/bridge/server/database.lua',
|
||||
|
||||
@@ -11,6 +11,15 @@ local provider_aliases = {
|
||||
|
||||
local function resolve_provider()
|
||||
local configured = tostring(Config.Calls.VoiceProvider or "")
|
||||
if configured == "auto" then
|
||||
for _, candidate in ipairs({ "yaca", "pma", "saltychat" }) do
|
||||
if GetResourceState(provider_resources[candidate]) == "started" then
|
||||
return candidate
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local selected = provider_aliases[configured] or configured
|
||||
local resource_name = provider_resources[selected]
|
||||
if resource_name and GetResourceState(resource_name) == "started" then
|
||||
|
||||
@@ -19,3 +19,23 @@ Bridge.Framework.Name = configured_framework
|
||||
function Bridge.Framework.GetName()
|
||||
return Bridge.Framework.Name
|
||||
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
|
||||
return false
|
||||
end
|
||||
|
||||
for _, group in ipairs(groups) do
|
||||
if IsPlayerAceAllowed(tostring(source), group) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
return exports.qbx_core:HasGroup(source, groups)
|
||||
end
|
||||
|
||||
|
||||
@@ -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
|
||||
configured_inventory = inventory_aliases[configured_inventory] or configured_inventory
|
||||
local framework_name = Bridge.Framework.GetName()
|
||||
|
||||
if configured_inventory == "auto" then
|
||||
if GetResourceState("ox_inventory") == "started" then
|
||||
configured_inventory = "ox"
|
||||
elseif GetResourceState("qb-inventory") == "started" then
|
||||
configured_inventory = "qb"
|
||||
elseif GetResourceState("lj-inventory") == "started" then
|
||||
configured_inventory = "lj"
|
||||
elseif GetResourceState("qs-inventory") == "started" then
|
||||
configured_inventory = "qs"
|
||||
elseif GetResourceState("codem-inventory") == "started" 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
|
||||
for _, adapter in ipairs(inventory_adapters) do
|
||||
local compatible_framework = not adapter.framework or adapter.framework == framework_name
|
||||
if adapter.name ~= "esx" and compatible_framework and GetResourceState(adapter.resource) == "started" then
|
||||
configured_inventory = adapter.name
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if configured_inventory == "auto" and framework_name == "esx" then
|
||||
configured_inventory = "esx"
|
||||
end
|
||||
end
|
||||
|
||||
local supported_inventories = {
|
||||
ox = true,
|
||||
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
|
||||
local selected_adapter = supported_inventories[configured_inventory]
|
||||
if not selected_adapter then
|
||||
error(("[sky_phone] Unsupported or unavailable inventory '%s'. Configure a supported inventory adapter."):format(tostring(configured_inventory)))
|
||||
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
|
||||
|
||||
if configured_inventory == "hex" or configured_inventory == "esx" then
|
||||
if Bridge.Framework.GetName() ~= "esx" then
|
||||
Bridge.Inventory.ConfigurationError =
|
||||
("[sky_phone] Inventory '%s' is only supported with ESX."):format(configured_inventory)
|
||||
elseif Config.Phone.Unique ~= false or Config.Sim.Enabled ~= false then
|
||||
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.")
|
||||
:format(configured_inventory)
|
||||
local metadata_free_inventory = selected_adapter.metadata == false
|
||||
|
||||
local function enforce_metadata_compatibility()
|
||||
if not metadata_free_inventory then
|
||||
return
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
function Bridge.Inventory.NormalizeItem(item, metadata_field)
|
||||
if not item then
|
||||
enforce_metadata_compatibility()
|
||||
|
||||
AddEventHandler("sky_phone:configurator:serverUpdated", enforce_metadata_compatibility)
|
||||
|
||||
function Bridge.Inventory.NormalizeItem(item, metadata_field, fallback_slot)
|
||||
if type(item) ~= "table" then
|
||||
return nil
|
||||
end
|
||||
|
||||
@@ -63,11 +95,15 @@ function Bridge.Inventory.NormalizeItem(item, metadata_field)
|
||||
metadata = item.metadata or item.info
|
||||
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 {
|
||||
name = item.name,
|
||||
slot = item.slot or item.id,
|
||||
count = tonumber(item.count or item.amount) or 0,
|
||||
amount = tonumber(item.amount or item.count) or 0,
|
||||
name = item.name or item.item,
|
||||
slot = numeric_slot or slot,
|
||||
count = count,
|
||||
amount = count,
|
||||
metadata = type(metadata) == "table" and metadata or {},
|
||||
}
|
||||
end
|
||||
@@ -84,3 +120,13 @@ function Bridge.Inventory.MetadataMatches(actual, expected)
|
||||
end
|
||||
return true
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
function Bridge.Inventory.GetResourceName()
|
||||
@@ -27,9 +28,14 @@ end
|
||||
|
||||
function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
|
||||
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)
|
||||
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
|
||||
end
|
||||
end
|
||||
@@ -66,7 +72,7 @@ function Bridge.Inventory.RemoveItem(source, item_name, count, slot, metadata)
|
||||
end
|
||||
|
||||
function Bridge.Inventory.RegisterUsableItem(item_name, callback)
|
||||
return Bridge.Framework.RegisterUsableItem(item_name, function(source, item)
|
||||
callback(source, normalize(item))
|
||||
return Bridge.Framework.RegisterUsableItem(item_name, function(source, ...)
|
||||
callback(source, normalize(Bridge.Inventory.ResolveUsableItem(...)))
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -39,11 +39,15 @@ end
|
||||
|
||||
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
|
||||
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
|
||||
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
|
||||
|
||||
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 _, slot_id in ipairs(inventory:GetSlotIdsWithItem(source, item_name, metadata) or {}) do
|
||||
local normalized = Bridge.Inventory.GetSlot(source, slot_id)
|
||||
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) == false 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
|
||||
end
|
||||
|
||||
inventory:SetMetadata(source, tonumber(slot_id), metadata or {})
|
||||
return true
|
||||
local requested_metadata = type(metadata) == "table" and metadata or {}
|
||||
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
|
||||
|
||||
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
|
||||
@@ -41,6 +41,15 @@ end
|
||||
|
||||
local function resolve_call_provider()
|
||||
local configured = tostring(Config.Calls.VoiceProvider or "")
|
||||
if configured == "auto" then
|
||||
for _, candidate in ipairs({ "yaca", "pma", "saltychat" }) do
|
||||
if GetResourceState(call_provider_resources[candidate]) == "started" then
|
||||
return candidate
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local selected = call_provider_aliases[configured] or configured
|
||||
local resource_name = call_provider_resources[selected]
|
||||
if resource_name and GetResourceState(resource_name) == "started" then
|
||||
|
||||
@@ -75,6 +75,21 @@ Bridge.Debug("debug", "[sky_phone] Client script initialized.", { always = true
|
||||
|
||||
local locale, locale_name = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
|
||||
|
||||
local function apply_disabled_apps(payload)
|
||||
if type(payload) ~= "table" then
|
||||
return
|
||||
end
|
||||
|
||||
local disabled = {}
|
||||
for app_id, enabled in pairs(Config.Apps or {}) do
|
||||
if type(app_id) == "string" and enabled == false then
|
||||
disabled[#disabled + 1] = app_id
|
||||
end
|
||||
end
|
||||
table.sort(disabled)
|
||||
payload.disabledApps = disabled
|
||||
end
|
||||
|
||||
local function send_admin_panel_open()
|
||||
SendNUIMessage({
|
||||
type = "admin:open",
|
||||
@@ -136,6 +151,7 @@ AddEventHandler("sky_phone:configurator:updated", function()
|
||||
refresh_admin_command_suggestion()
|
||||
SkyPhoneApps.SendCatalog()
|
||||
if is_open and device_payload then
|
||||
apply_disabled_apps(device_payload)
|
||||
device_payload.lang = locale_name
|
||||
device_payload.locales = locale.Nui
|
||||
device_payload.fallbackLocales = Locales.en.Nui
|
||||
@@ -175,6 +191,22 @@ local function close_phone(close_device_session)
|
||||
end
|
||||
end
|
||||
|
||||
local function request_phone_open(callback_name)
|
||||
if is_open or open_requested then
|
||||
return true
|
||||
end
|
||||
|
||||
open_requested = true
|
||||
local result = Bridge.Callbacks.Trigger(callback_name, {})
|
||||
if type(result) == "table" and result.success == true then
|
||||
return true
|
||||
end
|
||||
|
||||
open_requested = false
|
||||
open_without_focus = false
|
||||
return false
|
||||
end
|
||||
|
||||
local function toggle_phone(open, no_focus)
|
||||
if open ~= nil and type(open) ~= "boolean" then
|
||||
Bridge.Debug("error", "[sky_phone] Rejected invalid phone toggle state.")
|
||||
@@ -190,6 +222,9 @@ local function toggle_phone(open, no_focus)
|
||||
should_open = not (is_open or open_requested)
|
||||
end
|
||||
if not should_open then
|
||||
if open == nil and open_requested and not is_open then
|
||||
return true
|
||||
end
|
||||
close_phone()
|
||||
return true
|
||||
end
|
||||
@@ -202,12 +237,7 @@ local function toggle_phone(open, no_focus)
|
||||
return true
|
||||
end
|
||||
|
||||
local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
|
||||
if type(result) ~= "table" or result.success ~= true then
|
||||
open_without_focus = false
|
||||
return false
|
||||
end
|
||||
return true
|
||||
return request_phone_open("sky_phone:device:open-request")
|
||||
end
|
||||
|
||||
SkyPhoneClient.Toggle = toggle_phone
|
||||
@@ -217,13 +247,16 @@ AddEventHandler("sky_phone:client:forceClose", function()
|
||||
end)
|
||||
|
||||
local function run_development_command()
|
||||
if is_open or open_requested then
|
||||
if is_open then
|
||||
close_phone()
|
||||
return
|
||||
end
|
||||
if open_requested then
|
||||
return
|
||||
end
|
||||
|
||||
open_without_focus = false
|
||||
Bridge.Callbacks.Trigger("sky_phone:device:development-open", {})
|
||||
request_phone_open("sky_phone:device:development-open")
|
||||
end
|
||||
|
||||
refresh_development_command = function()
|
||||
@@ -256,13 +289,16 @@ end
|
||||
refresh_development_command()
|
||||
|
||||
local function run_phone_toggle()
|
||||
if is_open or open_requested then
|
||||
if is_open then
|
||||
close_phone()
|
||||
return
|
||||
end
|
||||
if open_requested then
|
||||
return
|
||||
end
|
||||
|
||||
open_without_focus = false
|
||||
Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
|
||||
request_phone_open("sky_phone:device:open-request")
|
||||
end
|
||||
|
||||
RegisterCommand("sky_phone_live_activity_open", function()
|
||||
@@ -271,8 +307,7 @@ RegisterCommand("sky_phone_live_activity_open", function()
|
||||
end
|
||||
|
||||
open_home_requested = true
|
||||
local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
|
||||
if not result or not result.success then
|
||||
if not request_phone_open("sky_phone:device:open-request") then
|
||||
open_home_requested = false
|
||||
end
|
||||
end, false)
|
||||
@@ -459,6 +494,10 @@ end)
|
||||
RegisterNetEvent("sky_phone:device:open", function(data)
|
||||
if type(data) ~= "table" or type(data.device) ~= "table" or type(data.device.imei) ~= "string" then
|
||||
Bridge.Debug("error", "[sky_phone] Rejected invalid device open data.")
|
||||
if not is_open then
|
||||
open_requested = false
|
||||
open_without_focus = false
|
||||
end
|
||||
return
|
||||
end
|
||||
Bridge.Debug(
|
||||
@@ -468,6 +507,7 @@ RegisterNetEvent("sky_phone:device:open", function(data)
|
||||
tostring(data.account ~= nil),
|
||||
{ always = true }
|
||||
)
|
||||
apply_disabled_apps(data)
|
||||
device_payload = data
|
||||
update_equipped_phone_number(data)
|
||||
open_requested = true
|
||||
@@ -484,6 +524,7 @@ RegisterNetEvent("sky_phone:device:updated", function(data)
|
||||
Bridge.Debug("error", "[sky_phone] Rejected invalid device update data.")
|
||||
return
|
||||
end
|
||||
apply_disabled_apps(data)
|
||||
device_payload = data
|
||||
update_equipped_phone_number(data)
|
||||
SendNUIMessage({ type = "device:updated", data = data })
|
||||
@@ -499,8 +540,10 @@ RegisterNetEvent("sky_phone:device:invalidated", function()
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:device:error", function(error_code)
|
||||
if not is_open and not open_requested then
|
||||
if not is_open then
|
||||
open_requested = false
|
||||
open_without_focus = false
|
||||
open_home_requested = false
|
||||
end
|
||||
Bridge.Debug(
|
||||
"debug",
|
||||
|
||||
@@ -98,7 +98,7 @@ end
|
||||
|
||||
local function require_admin(source, operation, maximum)
|
||||
if not Config.AdminPanel.Enabled
|
||||
or not Bridge.Framework.HasAdminGroup(source, Config.AdminPanel.AdminGroups)
|
||||
or not Bridge.Framework.HasPermission(source, "phonepanel")
|
||||
then
|
||||
Bridge.Debug("warn", "[sky_phone] Rejected admin panel access from source %s.", tostring(source))
|
||||
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")
|
||||
return
|
||||
end
|
||||
if not Bridge.Framework.HasAdminGroup(player_source, Config.AdminPanel.AdminGroups) then
|
||||
if not Bridge.Framework.HasPermission(player_source, "phonepanel") then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] Rejected admin panel command from source %s.",
|
||||
@@ -373,6 +373,9 @@ end
|
||||
|
||||
local function app_metadata(app_id)
|
||||
if BUILTIN_APPS[app_id] then
|
||||
if not SkyPhone.IsAppEnabled(app_id) then
|
||||
return nil
|
||||
end
|
||||
return {
|
||||
defaultInstalled = DEFAULT_INSTALLED_APPS[app_id] == true,
|
||||
removable = PROTECTED_APPS[app_id] ~= true,
|
||||
@@ -463,16 +466,36 @@ Bridge.Callbacks.Register("sky_phone:admin:bootstrap", function(source)
|
||||
local totals = Bridge.Database.Query([[
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM `sky_phone_devices`) AS `devices`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_accounts`) AS `accounts`
|
||||
(SELECT COUNT(*) FROM `sky_phone_accounts`) AS `accounts`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_devices` WHERE `account_id` IS NOT NULL) AS `linked_devices`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_devices` WHERE `sim_id` IS NOT NULL) AS `sim_devices`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_devices`
|
||||
WHERE `updated_at` >= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 24 HOUR)) AS `active_devices`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_sms_messages`
|
||||
WHERE `created_at` >= CURRENT_DATE) AS `messages_today`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_calls`
|
||||
WHERE `started_at` >= CURRENT_DATE) AS `calls_today`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_admin_audit`) AS `audit_entries`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_admin_audit`
|
||||
WHERE `created_at` >= CURRENT_DATE) AS `audit_today`
|
||||
]], {})
|
||||
local stats = totals[1] or {}
|
||||
return {
|
||||
success = true,
|
||||
data = {
|
||||
players = players,
|
||||
disabledApps = SkyPhone.GetDisabledApps(),
|
||||
stats = {
|
||||
online = #players,
|
||||
devices = tonumber(totals[1] and totals[1].devices) or 0,
|
||||
accounts = tonumber(totals[1] and totals[1].accounts) or 0,
|
||||
devices = tonumber(stats.devices) or 0,
|
||||
accounts = tonumber(stats.accounts) or 0,
|
||||
linkedDevices = tonumber(stats.linked_devices) or 0,
|
||||
simDevices = tonumber(stats.sim_devices) or 0,
|
||||
activeDevices = tonumber(stats.active_devices) or 0,
|
||||
messagesToday = tonumber(stats.messages_today) or 0,
|
||||
callsToday = tonumber(stats.calls_today) or 0,
|
||||
auditEntries = tonumber(stats.audit_entries) or 0,
|
||||
auditToday = tonumber(stats.audit_today) or 0,
|
||||
},
|
||||
audit = load_audit(),
|
||||
},
|
||||
|
||||
+150
-122
@@ -371,6 +371,9 @@ local function initialize_markets()
|
||||
end
|
||||
|
||||
local function require_phone(source)
|
||||
if not Config.Crypto.Enabled or not SkyPhone.IsAppEnabled("crypto") then
|
||||
return nil, nil, { success = false, error = "service_unavailable" }
|
||||
end
|
||||
local phone_session, error_response = SkyPhone.RequireSession(source)
|
||||
if not phone_session then
|
||||
return nil, nil, error_response
|
||||
@@ -661,7 +664,7 @@ local function with_exchange_lock(callback)
|
||||
end
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:crypto:bootstrap", function(source)
|
||||
if not Config.Crypto.Enabled then
|
||||
if not Config.Crypto.Enabled or not SkyPhone.IsAppEnabled("crypto") then
|
||||
return { success = false, error = "service_unavailable" }
|
||||
end
|
||||
local profile, error_response = authenticated_profile(source)
|
||||
@@ -1368,8 +1371,6 @@ ensure_schema()
|
||||
migrate_crypto_keys()
|
||||
initialize_markets()
|
||||
|
||||
AddEventHandler("sky_phone:configurator:serverUpdated", initialize_markets)
|
||||
|
||||
local function reconcile_settlements(include_recent)
|
||||
local age_clause = include_recent and "" or " AND settlement.`updated_at` < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)"
|
||||
local rows = Bridge.Database.Query([[
|
||||
@@ -1416,8 +1417,6 @@ local function reconcile_settlements(include_recent)
|
||||
end
|
||||
end
|
||||
|
||||
reconcile_settlements(true)
|
||||
|
||||
local function crypto_random_int(minimum, maximum, failure_message)
|
||||
local value = exports[GetCurrentResourceName()]:CryptoRandomInt(minimum, maximum)
|
||||
if type(value) ~= "number" then
|
||||
@@ -1500,134 +1499,163 @@ local function advance_market_cycle(config, dynamics)
|
||||
return dynamics.cycle_bias
|
||||
end
|
||||
|
||||
CreateThread(function()
|
||||
while true do
|
||||
Wait(5 * 60 * 1000)
|
||||
reconcile_settlements(false)
|
||||
local scheduler_generation = 0
|
||||
|
||||
local function start_crypto_schedulers()
|
||||
scheduler_generation = scheduler_generation + 1
|
||||
if Config.Crypto.Enabled ~= true then
|
||||
return
|
||||
end
|
||||
end)
|
||||
|
||||
CreateThread(function()
|
||||
while true do
|
||||
local tick_seconds = crypto_random_int(
|
||||
Config.Crypto.PriceTickMinimumSeconds,
|
||||
Config.Crypto.PriceTickMaximumSeconds + 1,
|
||||
"[sky_phone] Crypto entropy provider did not return a market tick interval."
|
||||
)
|
||||
Wait(tick_seconds * 1000)
|
||||
with_exchange_lock(function()
|
||||
local market_count = crypto_random_int(
|
||||
Config.Crypto.MarketsPerTickMinimum,
|
||||
Config.Crypto.MarketsPerTickMaximum + 1,
|
||||
"[sky_phone] Crypto entropy provider did not return a market count."
|
||||
local generation = scheduler_generation
|
||||
reconcile_settlements(true)
|
||||
|
||||
CreateThread(function()
|
||||
while scheduler_generation == generation and Config.Crypto.Enabled == true do
|
||||
Wait(5 * 60 * 1000)
|
||||
if scheduler_generation ~= generation or Config.Crypto.Enabled ~= true then
|
||||
break
|
||||
end
|
||||
reconcile_settlements(false)
|
||||
end
|
||||
end)
|
||||
|
||||
CreateThread(function()
|
||||
while scheduler_generation == generation and Config.Crypto.Enabled == true do
|
||||
local tick_seconds = crypto_random_int(
|
||||
Config.Crypto.PriceTickMinimumSeconds,
|
||||
Config.Crypto.PriceTickMaximumSeconds + 1,
|
||||
"[sky_phone] Crypto entropy provider did not return a market tick interval."
|
||||
)
|
||||
advance_global_market_cycle()
|
||||
local changed_markets = {}
|
||||
market_count = math.min(market_count, #market_order)
|
||||
Wait(tick_seconds * 1000)
|
||||
if scheduler_generation ~= generation or Config.Crypto.Enabled ~= true then
|
||||
break
|
||||
end
|
||||
with_exchange_lock(function()
|
||||
local market_count = crypto_random_int(
|
||||
Config.Crypto.MarketsPerTickMinimum,
|
||||
Config.Crypto.MarketsPerTickMaximum + 1,
|
||||
"[sky_phone] Crypto entropy provider did not return a market count."
|
||||
)
|
||||
advance_global_market_cycle()
|
||||
local changed_markets = {}
|
||||
market_count = math.min(market_count, #market_order)
|
||||
|
||||
for offset = 0, market_count - 1 do
|
||||
local order_index = ((market_cursor + offset - 1) % #market_order) + 1
|
||||
local market_id = market_order[order_index]
|
||||
local config = markets[market_id]
|
||||
local row = Bridge.Database.Query(
|
||||
"SELECT `price`,`version`,`status` FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1",
|
||||
{ market_id }
|
||||
)[1]
|
||||
if row and row.status == "active" then
|
||||
local price = tonumber(row.price) or config.InitialPrice
|
||||
local impulse = crypto_random_int(
|
||||
-config.VolatilityBasisPoints,
|
||||
config.VolatilityBasisPoints + 1,
|
||||
"[sky_phone] Crypto entropy provider did not return a market movement."
|
||||
)
|
||||
if impulse > 0 then
|
||||
impulse = math.floor(impulse / Config.Crypto.RandomImpulseDivisor)
|
||||
elseif impulse < 0 then
|
||||
impulse = math.ceil(impulse / Config.Crypto.RandomImpulseDivisor)
|
||||
end
|
||||
local shock_roll = crypto_random_int(
|
||||
0,
|
||||
10000,
|
||||
"[sky_phone] Crypto entropy provider did not return a market shock roll."
|
||||
)
|
||||
local dynamics = market_dynamics[market_id] or {
|
||||
momentum = 0,
|
||||
cycle_bias = 0,
|
||||
cycle_direction = 0,
|
||||
cycle_remaining_ticks = 0,
|
||||
cycle_target = 0,
|
||||
}
|
||||
dynamics.momentum = truncate_integer((
|
||||
dynamics.momentum * Config.Crypto.MomentumDecayBasisPoints
|
||||
+ impulse * Config.Crypto.MomentumImpulseBasisPoints
|
||||
) / 10000)
|
||||
local cycle_bias = advance_market_cycle(config, dynamics)
|
||||
market_dynamics[market_id] = dynamics
|
||||
|
||||
local deviation = math.floor(
|
||||
(config.InitialPrice - price) * 10000 / config.InitialPrice
|
||||
)
|
||||
local reversion = truncate_integer(
|
||||
deviation * Config.Crypto.MeanReversionBasisPoints / 10000
|
||||
)
|
||||
local shock = 0
|
||||
if shock_roll < Config.Crypto.MarketShockChanceBasisPoints then
|
||||
local multiplier = crypto_random_int(
|
||||
Config.Crypto.MarketShockMinimumMultiplier,
|
||||
Config.Crypto.MarketShockMaximumMultiplier + 1,
|
||||
"[sky_phone] Crypto entropy provider did not return a market shock multiplier."
|
||||
for offset = 0, market_count - 1 do
|
||||
local order_index = ((market_cursor + offset - 1) % #market_order) + 1
|
||||
local market_id = market_order[order_index]
|
||||
local config = markets[market_id]
|
||||
local row = Bridge.Database.Query(
|
||||
"SELECT `price`,`version`,`status` FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1",
|
||||
{ market_id }
|
||||
)[1]
|
||||
if row and row.status == "active" then
|
||||
local price = tonumber(row.price) or config.InitialPrice
|
||||
local impulse = crypto_random_int(
|
||||
-config.VolatilityBasisPoints,
|
||||
config.VolatilityBasisPoints + 1,
|
||||
"[sky_phone] Crypto entropy provider did not return a market movement."
|
||||
)
|
||||
local direction_roll = crypto_random_int(
|
||||
if impulse > 0 then
|
||||
impulse = math.floor(impulse / Config.Crypto.RandomImpulseDivisor)
|
||||
elseif impulse < 0 then
|
||||
impulse = math.ceil(impulse / Config.Crypto.RandomImpulseDivisor)
|
||||
end
|
||||
local shock_roll = crypto_random_int(
|
||||
0,
|
||||
2,
|
||||
"[sky_phone] Crypto entropy provider did not return a market shock direction."
|
||||
10000,
|
||||
"[sky_phone] Crypto entropy provider did not return a market shock roll."
|
||||
)
|
||||
local direction = direction_roll == 0 and -1 or 1
|
||||
shock = direction * config.VolatilityBasisPoints * multiplier
|
||||
end
|
||||
local dynamics = market_dynamics[market_id] or {
|
||||
momentum = 0,
|
||||
cycle_bias = 0,
|
||||
cycle_direction = 0,
|
||||
cycle_remaining_ticks = 0,
|
||||
cycle_target = 0,
|
||||
}
|
||||
dynamics.momentum = truncate_integer((
|
||||
dynamics.momentum * Config.Crypto.MomentumDecayBasisPoints
|
||||
+ impulse * Config.Crypto.MomentumImpulseBasisPoints
|
||||
) / 10000)
|
||||
local cycle_bias = advance_market_cycle(config, dynamics)
|
||||
market_dynamics[market_id] = dynamics
|
||||
|
||||
local maximum_movement = config.VolatilityBasisPoints
|
||||
* Config.Crypto.MaximumMovementMultiplier
|
||||
local movement = impulse + dynamics.momentum + cycle_bias
|
||||
+ global_market_trend + reversion + shock
|
||||
movement = math.max(-maximum_movement, math.min(maximum_movement, movement))
|
||||
if movement > 0 then
|
||||
movement = math.floor(movement / Config.Crypto.TickMovementDivisor)
|
||||
elseif movement < 0 then
|
||||
movement = math.ceil(movement / Config.Crypto.TickMovementDivisor)
|
||||
end
|
||||
local next_price = math.floor(price * (10000 + movement) / 10000)
|
||||
if next_price == price and movement ~= 0 then
|
||||
next_price = price + (movement > 0 and 1 or -1)
|
||||
end
|
||||
next_price = math.max(config.MinimumPrice, math.min(config.MaximumPrice, next_price))
|
||||
local next_version = (tonumber(row.version) or 0) + 1
|
||||
if Bridge.Database.Transaction({
|
||||
{ query = [[UPDATE `sky_phone_crypto_markets` SET `price` = ?, `version` = ? WHERE `id` = ? AND `version` = ?]], params = { next_price, next_version, market_id, row.version } },
|
||||
{ query = [[INSERT INTO `sky_phone_crypto_market_ticks` (`market_id`,`version`,`price`) VALUES (?, ?, ?)]], params = { market_id, next_version, next_price } },
|
||||
}) then
|
||||
changed_markets[#changed_markets + 1] = market_id
|
||||
Bridge.Database.Query([[
|
||||
DELETE FROM `sky_phone_crypto_market_ticks`
|
||||
WHERE `market_id` = ? AND `id` NOT IN (
|
||||
SELECT `id` FROM (
|
||||
SELECT `id` FROM `sky_phone_crypto_market_ticks`
|
||||
WHERE `market_id` = ? ORDER BY `id` DESC LIMIT ?
|
||||
) retained
|
||||
local deviation = math.floor(
|
||||
(config.InitialPrice - price) * 10000 / config.InitialPrice
|
||||
)
|
||||
local reversion = truncate_integer(
|
||||
deviation * Config.Crypto.MeanReversionBasisPoints / 10000
|
||||
)
|
||||
local shock = 0
|
||||
if shock_roll < Config.Crypto.MarketShockChanceBasisPoints then
|
||||
local multiplier = crypto_random_int(
|
||||
Config.Crypto.MarketShockMinimumMultiplier,
|
||||
Config.Crypto.MarketShockMaximumMultiplier + 1,
|
||||
"[sky_phone] Crypto entropy provider did not return a market shock multiplier."
|
||||
)
|
||||
]], { market_id, market_id, Config.Crypto.HistoryRetentionTicks })
|
||||
local direction_roll = crypto_random_int(
|
||||
0,
|
||||
2,
|
||||
"[sky_phone] Crypto entropy provider did not return a market shock direction."
|
||||
)
|
||||
local direction = direction_roll == 0 and -1 or 1
|
||||
shock = direction * config.VolatilityBasisPoints * multiplier
|
||||
end
|
||||
|
||||
local maximum_movement = config.VolatilityBasisPoints
|
||||
* Config.Crypto.MaximumMovementMultiplier
|
||||
local movement = impulse + dynamics.momentum + cycle_bias
|
||||
+ global_market_trend + reversion + shock
|
||||
movement = math.max(-maximum_movement, math.min(maximum_movement, movement))
|
||||
if movement > 0 then
|
||||
movement = math.floor(movement / Config.Crypto.TickMovementDivisor)
|
||||
elseif movement < 0 then
|
||||
movement = math.ceil(movement / Config.Crypto.TickMovementDivisor)
|
||||
end
|
||||
local next_price = math.floor(price * (10000 + movement) / 10000)
|
||||
if next_price == price and movement ~= 0 then
|
||||
next_price = price + (movement > 0 and 1 or -1)
|
||||
end
|
||||
next_price = math.max(config.MinimumPrice, math.min(config.MaximumPrice, next_price))
|
||||
local next_version = (tonumber(row.version) or 0) + 1
|
||||
if Bridge.Database.Transaction({
|
||||
{ query = [[UPDATE `sky_phone_crypto_markets` SET `price` = ?, `version` = ? WHERE `id` = ? AND `version` = ?]], params = { next_price, next_version, market_id, row.version } },
|
||||
{ query = [[INSERT INTO `sky_phone_crypto_market_ticks` (`market_id`,`version`,`price`) VALUES (?, ?, ?)]], params = { market_id, next_version, next_price } },
|
||||
}) then
|
||||
changed_markets[#changed_markets + 1] = market_id
|
||||
Bridge.Database.Query([[
|
||||
DELETE FROM `sky_phone_crypto_market_ticks`
|
||||
WHERE `market_id` = ? AND `id` NOT IN (
|
||||
SELECT `id` FROM (
|
||||
SELECT `id` FROM `sky_phone_crypto_market_ticks`
|
||||
WHERE `market_id` = ? ORDER BY `id` DESC LIMIT ?
|
||||
) retained
|
||||
)
|
||||
]], { market_id, market_id, Config.Crypto.HistoryRetentionTicks })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
market_cursor = ((market_cursor + market_count - 1) % #market_order) + 1
|
||||
if #changed_markets > 0 then
|
||||
TriggerClientEvent("sky_phone:crypto:changed", -1, {
|
||||
markets = market_dtos(changed_markets),
|
||||
updatedAt = os.time() * 1000,
|
||||
})
|
||||
end
|
||||
end)
|
||||
market_cursor = ((market_cursor + market_count - 1) % #market_order) + 1
|
||||
if #changed_markets > 0 then
|
||||
TriggerClientEvent("sky_phone:crypto:changed", -1, {
|
||||
markets = market_dtos(changed_markets),
|
||||
updatedAt = os.time() * 1000,
|
||||
})
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
local function refresh_crypto_runtime()
|
||||
initialize_markets()
|
||||
start_crypto_schedulers()
|
||||
if not Config.Crypto.Enabled or not SkyPhone.IsAppEnabled("crypto") then
|
||||
sessions = {}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
AddEventHandler("sky_phone:configurator:serverUpdated", refresh_crypto_runtime)
|
||||
start_crypto_schedulers()
|
||||
|
||||
end)
|
||||
|
||||
@@ -931,10 +931,16 @@ end)
|
||||
CreateThread(function()
|
||||
while true do
|
||||
Wait(Config.DarkChat.CleanupIntervalSeconds * 1000)
|
||||
local batch_size = math.max(
|
||||
1,
|
||||
math.floor(tonumber(Config.DarkChat.CleanupBatchSize) or 250)
|
||||
)
|
||||
Bridge.Database.Query([[
|
||||
DELETE FROM `sky_phone_darkchat_messages`
|
||||
WHERE `expires_at` IS NOT NULL AND `expires_at` <= CURRENT_TIMESTAMP
|
||||
]], {})
|
||||
ORDER BY `expires_at`, `id`
|
||||
LIMIT ?
|
||||
]], { batch_size })
|
||||
end
|
||||
end)
|
||||
|
||||
|
||||
@@ -943,7 +943,7 @@ local function run_verify_command(source, arguments)
|
||||
notificationType = notification_type,
|
||||
})
|
||||
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")
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
|
||||
@@ -54,12 +54,28 @@ Bridge.Debug("debug", "[sky_phone] Server initialization started after database
|
||||
|
||||
SkyPhone = {}
|
||||
|
||||
function SkyPhone.IsAppEnabled(app_id)
|
||||
return type(Config.Apps) ~= "table" or Config.Apps[app_id] ~= false
|
||||
end
|
||||
|
||||
function SkyPhone.GetDisabledApps()
|
||||
local disabled = {}
|
||||
for app_id, enabled in pairs(Config.Apps or {}) do
|
||||
if type(app_id) == "string" and enabled == false then
|
||||
disabled[#disabled + 1] = app_id
|
||||
end
|
||||
end
|
||||
table.sort(disabled)
|
||||
return disabled
|
||||
end
|
||||
|
||||
local sessions = {}
|
||||
local preferred_device_imeis = {}
|
||||
local equipped_phone_numbers = {}
|
||||
local equipped_phone_identifiers = {}
|
||||
local equipped_phone_sources = {}
|
||||
local operation_attempts = {}
|
||||
local phone_open_in_progress = {}
|
||||
local character_device_cache = {}
|
||||
|
||||
local function trim(value)
|
||||
@@ -548,6 +564,7 @@ local function bootstrap(source, security, security_loaded)
|
||||
|
||||
return {
|
||||
token = session.token,
|
||||
disabledApps = SkyPhone.GetDisabledApps(),
|
||||
phoneNumberFormat = {
|
||||
length = Config.Sim.NumberLength,
|
||||
groups = Config.Sim.NumberGroups,
|
||||
@@ -839,7 +856,7 @@ function SkyPhone.RefreshDevice(imei)
|
||||
end
|
||||
end
|
||||
|
||||
local function open_phone(source, used_item)
|
||||
local function perform_phone_open(source, used_item)
|
||||
local opened_at = GetGameTimer()
|
||||
Bridge.Debug(
|
||||
"debug",
|
||||
@@ -926,6 +943,38 @@ local function open_phone(source, used_item)
|
||||
return true
|
||||
end
|
||||
|
||||
local function open_phone(source, used_item)
|
||||
if phone_open_in_progress[source] then
|
||||
TriggerClientEvent("sky_phone:device:error", source, "operation_in_progress")
|
||||
return false, "operation_in_progress"
|
||||
end
|
||||
|
||||
local request_limit = math.max(
|
||||
1,
|
||||
math.floor(tonumber(Config.Phone.OpenRequestsPerMinute) or 20)
|
||||
)
|
||||
if not SkyPhone.AllowOperation(source, "phone_open", request_limit, 60) then
|
||||
TriggerClientEvent("sky_phone:device:error", source, "rate_limited")
|
||||
return false, "rate_limited"
|
||||
end
|
||||
|
||||
phone_open_in_progress[source] = true
|
||||
local completed, success, error_code = pcall(perform_phone_open, source, used_item)
|
||||
phone_open_in_progress[source] = nil
|
||||
if not completed then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] Phone open failed unexpectedly for source %s: %s",
|
||||
tostring(source),
|
||||
tostring(success),
|
||||
{ always = true }
|
||||
)
|
||||
TriggerClientEvent("sky_phone:device:error", source, "request_failed")
|
||||
return false, "request_failed"
|
||||
end
|
||||
return success, error_code
|
||||
end
|
||||
|
||||
phone_open_handler = open_phone
|
||||
flush_pending_phone_opens()
|
||||
|
||||
@@ -1028,6 +1077,7 @@ AddEventHandler("playerDropped", function()
|
||||
sessions[source] = nil
|
||||
discard_equipped_phone_number(source)
|
||||
operation_attempts[source] = nil
|
||||
phone_open_in_progress[source] = nil
|
||||
character_device_cache[source] = nil
|
||||
preferred_device_imeis[source] = nil
|
||||
end)
|
||||
@@ -1036,6 +1086,7 @@ AddEventHandler("onResourceStop", function(resource_name)
|
||||
if resource_name == GetCurrentResourceName() then
|
||||
sessions = {}
|
||||
operation_attempts = {}
|
||||
phone_open_in_progress = {}
|
||||
character_device_cache = {}
|
||||
preferred_device_imeis = {}
|
||||
equipped_phone_numbers = {}
|
||||
|
||||
@@ -17,9 +17,31 @@ local updated_at
|
||||
local updated_by_name
|
||||
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
|
||||
local border = "======================================================================"
|
||||
print(([[
|
||||
^1%s^0
|
||||
^1 SKY PHONE CONFIGURATION FILES ARE DISABLED ^0
|
||||
^1%s^0
|
||||
^1 The Phone Configurator is ENABLED.^0
|
||||
^1 Runtime settings from config.lua and media.lua are DISABLED.^0
|
||||
^1 Configure all phone and media settings IN GAME through /phonepanel.^0
|
||||
^1 Only Config.PhoneConfigurator.Enabled and Config.CommandPermissions remain file-based.^0
|
||||
^1%s^0]]):format(border, border, border))
|
||||
end
|
||||
|
||||
local CLIENT_CONFIG_KEYS = {
|
||||
AdminPanel = true,
|
||||
Animations = true,
|
||||
Apps = true,
|
||||
Banking = true,
|
||||
Billing = true,
|
||||
Bridge = true,
|
||||
@@ -252,7 +274,7 @@ local function upgrade_legacy_map(defaults, saved)
|
||||
return { __skyType = "map", entries = entries }
|
||||
end
|
||||
|
||||
local function merge_values(defaults, saved, path)
|
||||
local function merge_values(defaults, saved, path, excluded_paths)
|
||||
path = path or ""
|
||||
if type(defaults) ~= "table" or type(saved) ~= "table" then
|
||||
return copy_value(saved)
|
||||
@@ -278,7 +300,12 @@ local function merge_values(defaults, saved, path)
|
||||
key = entry.key,
|
||||
keyType = entry.keyType,
|
||||
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),
|
||||
}
|
||||
included[identity] = true
|
||||
@@ -298,7 +325,7 @@ local function merge_values(defaults, saved, path)
|
||||
local merged = copy_value(saved)
|
||||
for index, child in ipairs(defaults) do
|
||||
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)
|
||||
end
|
||||
return merged
|
||||
@@ -309,11 +336,13 @@ local function merge_values(defaults, saved, path)
|
||||
|
||||
local merged = copy_value(defaults)
|
||||
for key, child in pairs(saved) do
|
||||
if merged[key] ~= nil then
|
||||
local child_path = path == "" and tostring(key) or (path .. "." .. tostring(key))
|
||||
merged[key] = merge_values(merged[key], child, child_path)
|
||||
else
|
||||
merged[key] = copy_value(child)
|
||||
local child_path = path == "" and tostring(key) or (path .. "." .. tostring(key))
|
||||
if not excluded_paths or not excluded_paths[child_path] then
|
||||
if merged[key] ~= nil then
|
||||
merged[key] = merge_values(merged[key], child, child_path, excluded_paths)
|
||||
else
|
||||
merged[key] = copy_value(child)
|
||||
end
|
||||
end
|
||||
end
|
||||
return merged
|
||||
@@ -356,10 +385,12 @@ local function apply_runtime_configuration()
|
||||
|
||||
local runtime_config = deserialize_value(stored_config)
|
||||
for key, value in pairs(runtime_config) do
|
||||
if type(Config[key]) == "table" and type(value) == "table" then
|
||||
apply_runtime_table(Config[key], value)
|
||||
else
|
||||
Config[key] = value
|
||||
if key ~= "CommandPermissions" then
|
||||
if type(Config[key]) == "table" and type(value) == "table" then
|
||||
apply_runtime_table(Config[key], value)
|
||||
else
|
||||
Config[key] = value
|
||||
end
|
||||
end
|
||||
end
|
||||
local runtime_media = deserialize_value(stored_media)
|
||||
@@ -1032,7 +1063,12 @@ local function read_stored_row()
|
||||
end
|
||||
|
||||
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"), "")
|
||||
revision = tonumber(row.revision) or 1
|
||||
updated_at = row.updated_at
|
||||
@@ -1040,12 +1076,12 @@ local function apply_stored_row(row)
|
||||
end
|
||||
|
||||
default_config = {}
|
||||
for key, value in pairs(Config) do
|
||||
if key ~= "Media" and key ~= "PhoneConfigurator" then
|
||||
for key, value in pairs(ConfigDefaults) do
|
||||
if key ~= "Media" and key ~= "PhoneConfigurator" and key ~= "CommandPermissions" then
|
||||
default_config[key] = serialize_value(value)
|
||||
end
|
||||
end
|
||||
default_media = serialize_value(Config.Media)
|
||||
default_media = serialize_value(ConfigDefaults.Media)
|
||||
|
||||
Bridge.Database.Migrate("sky_phone_configurator", { SkyPhoneConfiguratorSchema })
|
||||
Bridge.Database.Query(([[
|
||||
|
||||
@@ -319,7 +319,7 @@ local function create_activity(recipient_id, actor_id, kind, post_id)
|
||||
end
|
||||
|
||||
local function is_admin(source)
|
||||
return Bridge.Framework.HasAdminGroup(source, Config.Picstagram.AdminGroups)
|
||||
return Bridge.Framework.HasPermission(source, "picstagramadmin")
|
||||
end
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:picstagram:register", function(source, data)
|
||||
@@ -1464,7 +1464,7 @@ local function run_verify_command(source, args)
|
||||
notificationType = notification_type,
|
||||
})
|
||||
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")
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
|
||||
@@ -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.")
|
||||
return
|
||||
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))
|
||||
TriggerClientEvent("sky_phone:testdata:feedback", source, false)
|
||||
return
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
@@ -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)
|
||||
Config = {
|
||||
Bridge = {
|
||||
@@ -193,11 +199,18 @@ exports = {
|
||||
}
|
||||
load_inventory_contract("sky_phone/source/bridge/server/inventory/esx.lua")
|
||||
|
||||
local ok, configuration_error = pcall(Bridge.Inventory.RegisterUsableItem, "phone", function()
|
||||
end)
|
||||
assert(not ok)
|
||||
assert(configuration_error:find("Config.Phone.Unique = false", 1, true))
|
||||
assert(configuration_error:find("Config.Sim.Enabled = false", 1, true))
|
||||
assert(Config.Phone.Unique == false)
|
||||
assert(Config.Sim.Enabled == false)
|
||||
assert(Bridge.Inventory.ConfigurationError == nil)
|
||||
|
||||
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 = manifest_file:read("*a")
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
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:GetSlotIdsWithItem(_, item_name, metadata)
|
||||
assert(item_name == "phone")
|
||||
if metadata and metadata.imei ~= one_items[7].metadata.imei then
|
||||
return {}
|
||||
end
|
||||
return { 7 }
|
||||
end
|
||||
|
||||
function one:SetItemMetadata(_, slot, metadata)
|
||||
one_items[slot].metadata = metadata
|
||||
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")
|
||||
@@ -25,7 +25,7 @@ Bridge = {
|
||||
Debug = function()
|
||||
end,
|
||||
Framework = {
|
||||
HasAdminGroup = function()
|
||||
HasPermission = function()
|
||||
return true
|
||||
end,
|
||||
},
|
||||
@@ -42,7 +42,6 @@ Bridge = {
|
||||
|
||||
Config = {
|
||||
AdminPanel = {
|
||||
AdminGroups = { "admin" },
|
||||
Command = "phoneadmin",
|
||||
Enabled = true,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user