Compare commits

...
6 Commits
Author SHA1 Message Date
Alec Schitzkat 8b4a32bfab BLD - Bump version to 0.2.8 2026-08-22 23:09:55 +02:00
DerEchteAlec cb6c94a7d8 FIX - harden phone stability and app controls (#28) 2026-08-22 19:48:30 +02:00
Leon.Schmidt 2059e19e1c ENH - add live admin dashboard statistics (#27) 2026-08-22 18:33:52 +02:00
Alec Schitzkat 99e943dbc5 BLD - Update GitHub release title to reflect beta status 2026-08-22 05:03:27 +02:00
Alec Schitzkat bd9f4c2695 BLD - Bump version to 0.2.7 2026-08-22 05:02:28 +02:00
Leon.Schmidt 4fa4bf3c78 ENH - centralize phone defaults and fixed permissions (#26)
* ENH - centralize phone configuration defaults

* FIX - enforce fixed phone permissions
2026-08-22 05:02:14 +02:00
54 changed files with 2946 additions and 945 deletions
+1 -1
View File
@@ -119,5 +119,5 @@ jobs:
"${archive}.sha256" \ "${archive}.sha256" \
--verify-tag \ --verify-tag \
--generate-notes \ --generate-notes \
--title "Sky Phone $GITHUB_REF_NAME" --title "$GITHUB_REF_NAME Beta"
fi fi
+20 -9
View File
@@ -138,10 +138,10 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
| `smx-inventory` (`smx`) | Yes | Yes | ESX only; one metadata record per configured item name through the player metadata bridge | | `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 | | `lj-inventory` (`lj`) | Yes | Yes | QBCore inventory with item `info` metadata |
| `qb-inventory` (`qb`) | Yes | Yes | Uses 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; set `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` | | `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; set `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` | | 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. `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.
@@ -216,6 +216,7 @@ The files contain clearly separated sections for:
| Section | Purpose | | Section | Purpose |
| --- | --- | | --- | --- |
| `Config.Bridge` | Framework, inventory, language, callback timeout, and debug mode | | `Config.Bridge` | Framework, inventory, language, callback timeout, and debug mode |
| `Config.CommandPermissions` | Fixed groups for the admin panel, test data, verification commands, and social moderation |
| `Config.Phone` | Phone item, movement, unique-device mode, and development command | | `Config.Phone` | Phone item, movement, unique-device mode, and development command |
| `Config.Sim` | Physical or virtual SIM behavior and number formatting | | `Config.Sim` | Physical or virtual SIM behavior and number formatting |
| `Config.Calls` / `Config.Radio` | Voice providers, call behavior, radio limits, and permissions | | `Config.Calls` / `Config.Radio` | Voice providers, call behavior, radio limits, and permissions |
@@ -241,21 +242,31 @@ Config.PhoneConfigurator = {
} }
``` ```
When enabled, `config.lua` and the server-only `media.lua` are first-run defaults. Sky Phone creates When enabled, the generated `source/shared/config_default.lua` is the shipped first-run baseline.
the `sky_phone_configurator` table automatically, loads its saved values before framework and phone The frontend build recreates this file from `config.lua` and the server-only `media.lua`; do not edit
the generated snapshot directly. Sky Phone creates the `sky_phone_configurator` table automatically,
loads its saved values before framework and phone
modules initialize, and exposes the editor through `/phonepanel`. Nothing autosaves: stage changes modules initialize, and exposes the editor through `/phonepanel`. Nothing autosaves: stage changes
in the Phone Configurator tool and press the green check. Saving verifies both SQL payloads and then in the Phone Configurator tool and press the green check. Saving verifies both SQL payloads and then
applies the new server, client, media, app, item, command, provider, animation, and UI values through applies the new server, client, media, app, item, command, provider, animation, and UI values through
Sky Phone's internal runtime refresh. It does not execute a resource restart command. Sky Phone's internal runtime refresh. It does not execute a resource restart command.
Every `Config.*` value from `config.lua`, including server-only sections, and every value from Every configurable `Config.*` value from `config.lua`, including server-only sections, and every
`Config.Media` is discovered automatically. The bootstrap switch above intentionally remains value from `Config.Media` is discovered automatically. The bootstrap switch and
file-owned because it decides whether SQL configuration is loaded. Lists, nested objects, vectors, `Config.CommandPermissions` intentionally remain file-owned: the switch decides whether SQL
configuration is loaded, while permissions must remain authoritative outside the panel. The fixed
permission table is never displayed or overwritten by the Phone Configurator, and its stable keys do
not change when their commands are renamed in the panel. Lists, nested objects, vectors,
and numeric-keyed Lua tables use structured editors instead of raw JSON. Shipped schema rows stay and numeric-keyed Lua tables use structured editors instead of raw JSON. Shipped schema rows stay
editable but cannot be renamed, converted, or removed. Every list and table still accepts any number editable but cannot be renamed, converted, or removed. Every list and table still accepts any number
of additional rows; administrator-added rows remain removable. Company job keys are intentionally of additional rows; administrator-added rows remain removable. Company job keys are intentionally
fully removable because `Config.Companies.Definitions` is a freely managed job collection. fully removable because `Config.Companies.Definitions` is a freely managed job collection.
ESX and QBCore use the groups listed in `Config.CommandPermissions`. Qbox checks the configured ACE
objects first and then its framework groups. The standard Qbox `permissions.cfg` grants the `admin`
ACE to `group.admin`, so an identifier assigned to `group.admin` can open `/phonepanel` with the
shipped `phonepanel` permission list. Restart `sky_phone` after changing fixed permissions.
Media API keys and server peppers are never returned in plaintext to the NUI. Existing secrets are Media API keys and server peppers are never returned in plaintext to the NUI. Existing secrets are
shown only as configured and are replaced only when an administrator enters a new value. shown only as configured and are replaced only when an administrator enters a new value.
@@ -365,7 +376,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: `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 server registers `Config.Phone.Item` as usable for every supported inventory adapter: `ak47`, `codem`, `core`, `jaksam`, `jpr`, `lj`, `mf`, `one`, `origen`, `ox`, `ps`, `qb`, `qs`, `smx`, `tgiann`, `hex`, and `esx`. Resource startup fails visibly if the selected adapter or its resource is unavailable.
The `hex` and `esx` adapters use ESX's count-based item API. They require both `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` because this API cannot persist per-item phone or physical SIM metadata. `auto` selects `hex` when `hex_4_inventory` is started and otherwise falls back to `esx` on an ESX server when no metadata-capable inventory is detected. The `hex` and `esx` adapters use ESX's count-based item API, which cannot persist per-item phone or physical SIM metadata. Sky Phone automatically forces `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` while either adapter is active. `auto` selects `hex` when `hex_4_inventory` is started and otherwise falls back to `esx` on an ESX server when no metadata-capable inventory is detected.
### QBCore-style item tables ### QBCore-style item tables
+40 -5
View File
@@ -2,15 +2,49 @@ const fs = require('node:fs')
const path = require('node:path') const path = require('node:path')
const frontendRoot = __dirname const frontendRoot = __dirname
const resourceDirectory = path.join(frontendRoot, '..', 'sky_phone')
const sourceDirectory = path.join(frontendRoot, 'dist') const sourceDirectory = path.join(frontendRoot, 'dist')
const targetDirectory = path.join( const targetDirectory = path.join(resourceDirectory, 'source', 'html')
frontendRoot, const configDefaultPath = path.join(
'..', resourceDirectory,
'sky_phone',
'source', 'source',
'html', 'shared',
'config_default.lua',
) )
function readLuaSource(...segments) {
return fs
.readFileSync(path.join(resourceDirectory, ...segments), 'utf8')
.replace(/\r\n?/g, '\n')
.trimEnd()
}
function generateConfigDefault() {
const configSource = readLuaSource('config', 'config.lua').replace(
/\n?-- CONFIG_DEFAULT_EXCLUDE_START[\s\S]*?-- CONFIG_DEFAULT_EXCLUDE_END\n?/g,
'\n',
)
const mediaSource = readLuaSource('config', 'media.lua')
const generatedSource = [
'-- GENERATED by frontend/build.cjs from config/config.lua and config/media.lua - do not edit.',
'-- Escrowed snapshot of the shipped defaults used by the Phone Configurator.',
'local realConfig = Config',
'Config = {}',
'',
configSource,
'',
mediaSource,
'',
'ConfigDefaults = Config',
'Config = realConfig',
'',
].join('\n')
fs.mkdirSync(path.dirname(configDefaultPath), { recursive: true })
fs.writeFileSync(configDefaultPath, generatedSource, 'utf8')
console.log(`Generated Phone Configurator defaults at ${configDefaultPath}`)
}
if (!fs.existsSync(sourceDirectory)) { if (!fs.existsSync(sourceDirectory)) {
throw new Error( throw new Error(
'Missing frontend/dist. Run vite build before publishing the NUI.', 'Missing frontend/dist. Run vite build before publishing the NUI.',
@@ -28,4 +62,5 @@ const normalizedIndex = fs
.replace(/\n[ \t]*\n([ \t]*<\/body>)/g, '\n$1') .replace(/\n[ \t]*\n([ \t]*<\/body>)/g, '\n$1')
fs.writeFileSync(targetIndex, normalizedIndex) fs.writeFileSync(targetIndex, normalizedIndex)
generateConfigDefault()
console.log(`Published NUI to ${targetDirectory}`) console.log(`Published NUI to ${targetDirectory}`)
+10 -1
View File
@@ -530,8 +530,17 @@ function hydratePhone(payload: PhoneOpenPayload): void {
clock.hydrate(payload.device?.data.alarms?.payload) clock.hydrate(payload.device?.data.alarms?.payload)
games.hydrate(payload.device?.data.games?.payload) games.hydrate(payload.device?.data.games?.payload)
media.hydrate(payload.device?.data.media?.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) 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[] { function getInstalledNavigationAppIds(): string[] {
@@ -54,6 +54,14 @@ const config = readFileSync(
new URL('../../../sky_phone/config/config.lua', import.meta.url), new URL('../../../sky_phone/config/config.lua', import.meta.url),
'utf8', 'utf8',
) )
const configDefaultsSource = config.replace(
/\r?\n?-- CONFIG_DEFAULT_EXCLUDE_START[\s\S]*?-- CONFIG_DEFAULT_EXCLUDE_END\r?\n?/g,
'\n',
)
const mediaConfig = readFileSync(
new URL('../../../sky_phone/config/media.lua', import.meta.url),
'utf8',
)
const schema = readFileSync( const schema = readFileSync(
new URL('../../../sky_phone/sql/install.sql', import.meta.url), new URL('../../../sky_phone/sql/install.sql', import.meta.url),
'utf8', 'utf8',
@@ -69,6 +77,17 @@ const configuratorServer = readFileSync(
), ),
'utf8', 'utf8',
) )
const configDefault = readFileSync(
new URL(
'../../../sky_phone/source/shared/config_default.lua',
import.meta.url,
),
'utf8',
)
const frontendBuild = readFileSync(
new URL('../../build.cjs', import.meta.url),
'utf8',
)
const configuratorClient = readFileSync( const configuratorClient = readFileSync(
new URL( new URL(
'../../../sky_phone/source/client/phone_configurator.lua', '../../../sky_phone/source/client/phone_configurator.lua',
@@ -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('<RefreshCw')
expect(source).not.toContain("kind: 'refresh'") expect(source).not.toContain("kind: 'refresh'")
expect(source).toContain("'sky-phone-admin-accent'") expect(source).not.toContain("'sky-phone-admin-accent'")
expect(source).toContain("'sky-phone-admin-font-family'") expect(source).not.toContain("'sky-phone-admin-font-family'")
expect(source).toContain("'sky-phone-admin-font-size'") expect(source).not.toContain("'sky-phone-admin-font-size'")
expect(source).toContain("'--admin-accent': accentColor") expect(source).not.toContain("t('appearance.")
expect(source).toContain("'--admin-font-family': activeAdminFontFamily") expect(source).toContain('--admin-accent: #00b8e4')
expect(source).toContain( expect(source).toContain('class="admin-panel-statistics-layout"')
"'--admin-font-scale': String(adminFontSize / 100)", expect(source).toContain('class="admin-panel-activity-grid"')
) expect(source).toContain('class="admin-panel-coverage-list"')
expect(source).toContain('class="admin-panel-color-value"') expect(source).toContain('statisticsTimer = window.setInterval')
expect(source).toContain('class="admin-panel-rgb-fields"') expect(source).toContain('15_000')
expect(source).toContain('class="admin-panel-rgb-range"') expect(server).toContain('AS `messages_today`')
expect(source).toContain('class="admin-panel-font-select"') expect(server).toContain('AS `calls_today`')
expect(source).toContain('class="admin-panel-font-select__menu"') expect(server).toContain('AS `active_devices`')
expect(source).toContain('class="admin-panel-font-size-control"') expect(server).toContain('auditEntries = tonumber(stats.audit_entries)')
expect(source).not.toContain('type="color"')
expect(source).toContain('color-mix(in srgb, var(--admin-green)') expect(source).toContain('color-mix(in srgb, var(--admin-green)')
expect(source).toContain('--admin-toggle-on: #63d471') expect(source).toContain('--admin-toggle-on: #63d471')
expect(source).toContain('background: var(--admin-toggle-on)') 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', () => { it('connects every operation through the standard NUI callback bridge', () => {
expect(bridge).toContain('admin = [[') expect(bridge).toContain('admin = [[')
for (const endpoint of [ for (const endpoint of [
@@ -216,12 +252,14 @@ describe('standalone admin panel contracts', () => {
it('authorizes every server request without requiring a phone session', () => { it('authorizes every server request without requiring a phone session', () => {
expect(server).not.toContain('SkyPhone.RequireSession(source)') expect(server).not.toContain('SkyPhone.RequireSession(source)')
expect(server).toContain( expect(server).toContain(
'Bridge.Framework.HasAdminGroup(source, Config.AdminPanel.AdminGroups)', 'Bridge.Framework.HasPermission(source, "phonepanel")',
) )
expect(server).toContain('Config.AdminPanel.ReadRequestsPerMinute') expect(server).toContain('Config.AdminPanel.ReadRequestsPerMinute')
expect(server).toContain('Config.AdminPanel.ActionRequestsPerMinute') expect(server).toContain('Config.AdminPanel.ActionRequestsPerMinute')
expect(server).toContain('Config.AdminPanel.CredentialRevealsPerMinute') expect(server).toContain('Config.AdminPanel.CredentialRevealsPerMinute')
expect(config).toContain('Config.AdminPanel = {') expect(config).toContain('Config.AdminPanel = {')
expect(config).toContain('Config.CommandPermissions = {')
expect(config).not.toContain('AdminGroups =')
}) })
it('opens directly from the configurable command with dedicated focus', () => { it('opens directly from the configurable command with dedicated focus', () => {
@@ -272,6 +310,12 @@ describe('standalone admin panel contracts', () => {
expect(schema).toContain( expect(schema).toContain(
'CREATE TABLE IF NOT EXISTS `sky_phone_configurator`', 'CREATE TABLE IF NOT EXISTS `sky_phone_configurator`',
) )
expect(
manifest.indexOf("'source/shared/config_default.lua'"),
).toBeGreaterThan(manifest.indexOf("'config/media.lua'"))
expect(manifest.indexOf("'source/shared/config_default.lua'")).toBeLessThan(
manifest.indexOf("'source/server/phone_configurator.lua'"),
)
expect( expect(
manifest.indexOf("'source/server/phone_configurator.lua'"), manifest.indexOf("'source/server/phone_configurator.lua'"),
).toBeLessThan(manifest.indexOf("'source/bridge/server/framework.lua'")) ).toBeLessThan(manifest.indexOf("'source/bridge/server/framework.lua'"))
@@ -281,11 +325,36 @@ describe('standalone admin panel contracts', () => {
expect(configuratorServer).toContain('AND `revision` = ?') expect(configuratorServer).toContain('AND `revision` = ?')
expect(configuratorServer).toContain('configurator_enabled') expect(configuratorServer).toContain('configurator_enabled')
expect(configuratorServer).toMatch( expect(configuratorServer).toMatch(
/for key, value in pairs\(Config\)[\s\S]*?key ~= "Media"[\s\S]*?key ~= "PhoneConfigurator"/, /for key, value in pairs\(ConfigDefaults\)[\s\S]*?key ~= "Media"[\s\S]*?key ~= "PhoneConfigurator"/,
) )
expect(configuratorServer).toContain( expect(configuratorServer).toContain(
'default_media = serialize_value(Config.Media)', 'default_media = serialize_value(ConfigDefaults.Media)',
) )
expect(configDefault).toContain(
'GENERATED by frontend/build.cjs from config/config.lua and config/media.lua',
)
expect(configDefault).toContain('local realConfig = Config')
expect(configDefault).toContain('ConfigDefaults = Config')
expect(configDefault.replace(/\r\n?/g, '\n').trimEnd()).toBe(
[
'-- GENERATED by frontend/build.cjs from config/config.lua and config/media.lua - do not edit.',
'-- Escrowed snapshot of the shipped defaults used by the Phone Configurator.',
'local realConfig = Config',
'Config = {}',
'',
configDefaultsSource.replace(/\r\n?/g, '\n').trimEnd(),
'',
mediaConfig.replace(/\r\n?/g, '\n').trimEnd(),
'',
'ConfigDefaults = Config',
'Config = realConfig',
].join('\n'),
)
expect(frontendBuild).toContain("readLuaSource('config', 'config.lua')")
expect(frontendBuild).toContain("readLuaSource('config', 'media.lua')")
expect(frontendBuild).toContain("'config_default.lua'")
expect(configDefault).not.toContain('Config.PhoneConfigurator')
expect(configDefault).not.toContain('Config.CommandPermissions')
expect(configuratorServer).toContain( expect(configuratorServer).toContain(
'build_sections("config", stored_config', 'build_sections("config", stored_config',
) )
+205 -551
View File
@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
ChartNoAxesCombined,
BadgeDollarSign, BadgeDollarSign,
BriefcaseBusiness, BriefcaseBusiness,
Check, Check,
ChevronDown,
ChevronRight, ChevronRight,
CircleUserRound, CircleUserRound,
Clipboard, Clipboard,
@@ -16,7 +16,6 @@ import {
LoaderCircle, LoaderCircle,
LockKeyhole, LockKeyhole,
MessageSquare, MessageSquare,
Palette,
PhoneCall, PhoneCall,
PhoneForwarded, PhoneForwarded,
Save, Save,
@@ -81,17 +80,6 @@ type AdminTab =
type ConfiguratorScope = 'config' | 'media' type ConfiguratorScope = 'config' | 'media'
type DeviceAction = 'reset-passcode' | 'change-number' | 'factory-reset' type DeviceAction = 'reset-passcode' | 'change-number' | 'factory-reset'
type PendingAction = { kind: 'close' } | { kind: 'player'; source: number } 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 emit = defineEmits<{ close: [] }>()
const admin = useAdminStore() const admin = useAdminStore()
const phone = usePhoneStore() const phone = usePhoneStore()
@@ -109,61 +97,11 @@ const revealDialogImei = ref('')
const deviceAction = ref<DeviceAction | null>(null) const deviceAction = ref<DeviceAction | null>(null)
const deviceActionInput = ref('') const deviceActionInput = ref('')
const discardDialog = ref(false) const discardDialog = ref(false)
const fontMenuOpen = ref(false)
const pendingAction = ref<PendingAction | null>(null) const pendingAction = ref<PendingAction | null>(null)
const toast = ref('') const toast = ref('')
const toastTone = ref<'error' | 'success'>('success') const toastTone = ref<'error' | 'success'>('success')
let toastTimer: number | undefined let toastTimer: number | undefined
let statisticsTimer: 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),
}
})
const filteredPlayers = computed(() => { const filteredPlayers = computed(() => {
const needle = playerQuery.value.trim().toLocaleLowerCase(phone.lang) const needle = playerQuery.value.trim().toLocaleLowerCase(phone.lang)
@@ -196,7 +134,9 @@ const manageableApps = computed(() => {
const needle = appQuery.value.trim().toLocaleLowerCase(phone.lang) const needle = appQuery.value.trim().toLocaleLowerCase(phone.lang)
return PHONE_APPS.filter( return PHONE_APPS.filter(
(app): app is LaunchablePhoneAppDefinition => (app): app is LaunchablePhoneAppDefinition =>
isLaunchablePhoneApp(app) && !app.adminOnly, isLaunchablePhoneApp(app) &&
!app.adminOnly &&
!admin.disabledApps.includes(app.id),
) )
.filter( .filter(
(app) => (app) =>
@@ -595,7 +535,9 @@ function queueAction(action: PendingAction): void {
function selectTab(nextTab: AdminTab): void { function selectTab(nextTab: AdminTab): void {
tab.value = nextTab tab.value = nextTab
fontMenuOpen.value = false if (nextTab === 'overview' && admin.initialized && !admin.loading) {
void admin.load()
}
if (nextTab === 'configurator' && !admin.configurator) { if (nextTab === 'configurator' && !admin.configurator) {
void admin.loadConfigurator().then((loaded) => { void admin.loadConfigurator().then((loaded) => {
if (!loaded) showToast(errorText(), 'error') if (!loaded) showToast(errorText(), 'error')
@@ -603,61 +545,13 @@ function selectTab(nextTab: AdminTab): void {
} }
} }
function selectAccent(color: string): void { function formatStatistic(value: number): string {
if (!/^#[0-9a-f]{6}$/i.test(color)) return return value.toLocaleString(phone.lang)
accentColor.value = color.toLowerCase()
window.localStorage.setItem('sky-phone-admin-accent', accentColor.value)
} }
function updateAccent(event: Event): void { function statisticPercentage(value: number, total: number): number {
const input = event.currentTarget as HTMLInputElement if (total <= 0) return 0
if (/^#[0-9a-f]{6}$/i.test(input.value)) selectAccent(input.value) return Math.min(100, Math.round((value / total) * 100))
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),
)
} }
async function runAction(action: PendingAction): Promise<void> { async function runAction(action: PendingAction): Promise<void> {
@@ -918,10 +812,6 @@ function auditDescription(entry: AdminAuditEntry): string {
function onKeydown(event: KeyboardEvent): void { function onKeydown(event: KeyboardEvent): void {
if (event.key !== 'Escape') return if (event.key !== 'Escape') return
event.preventDefault() event.preventDefault()
if (fontMenuOpen.value) {
fontMenuOpen.value = false
return
}
if (revealDialogImei.value) { if (revealDialogImei.value) {
revealDialogImei.value = '' revealDialogImei.value = ''
return return
@@ -941,46 +831,21 @@ function onKeydown(event: KeyboardEvent): void {
onMounted(() => { onMounted(() => {
document.addEventListener('keydown', onKeydown) 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() void refreshData()
statisticsTimer = window.setInterval(() => {
if (tab.value === 'overview' && !admin.loading) void admin.load()
}, 15_000)
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('keydown', onKeydown) document.removeEventListener('keydown', onKeydown)
if (toastTimer) window.clearTimeout(toastTimer) if (toastTimer) window.clearTimeout(toastTimer)
if (statisticsTimer) window.clearInterval(statisticsTimer)
}) })
</script> </script>
<template> <template>
<div <div class="admin-panel-overlay" role="dialog" :aria-label="t('name')">
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-window"> <div class="admin-panel-window">
<header class="admin-panel-header"> <header class="admin-panel-header">
<div class="admin-panel-brand"> <div class="admin-panel-brand">
@@ -1384,22 +1249,22 @@ onBeforeUnmount(() => {
<article> <article>
<UsersRound :size="18" /> <UsersRound :size="18" />
<span>{{ t('overview.online') }}</span> <span>{{ t('overview.online') }}</span>
<strong>{{ admin.stats.online }}</strong> <strong>{{ formatStatistic(admin.stats.online) }}</strong>
</article> </article>
<article> <article>
<Smartphone :size="18" /> <Smartphone :size="18" />
<span>{{ t('overview.devices') }}</span> <span>{{ t('overview.devices') }}</span>
<strong>{{ admin.stats.devices }}</strong> <strong>{{ formatStatistic(admin.stats.devices) }}</strong>
</article> </article>
<article> <article>
<Database :size="18" /> <Database :size="18" />
<span>{{ t('overview.accounts') }}</span> <span>{{ t('overview.accounts') }}</span>
<strong>{{ admin.stats.accounts }}</strong> <strong>{{ formatStatistic(admin.stats.accounts) }}</strong>
</article> </article>
<article> <article>
<ScrollText :size="18" /> <ScrollText :size="18" />
<span>{{ t('overview.audit') }}</span> <span>{{ t('overview.audit') }}</span>
<strong>{{ admin.audit.length }}</strong> <strong>{{ formatStatistic(admin.stats.auditEntries) }}</strong>
</article> </article>
</div> </div>
@@ -1408,146 +1273,100 @@ onBeforeUnmount(() => {
> >
<div class="admin-panel-section-card__heading"> <div class="admin-panel-section-card__heading">
<div> <div>
<span>{{ t('appearance.eyebrow') }}</span> <span>{{ t('statistics.eyebrow') }}</span>
<h2>{{ t('appearance.title') }}</h2> <h2>{{ t('statistics.title') }}</h2>
<p>{{ t('appearance.body') }}</p> <p>{{ t('statistics.body') }}</p>
</div> </div>
<Palette :size="20" /> <ChartNoAxesCombined :size="20" />
</div> </div>
<div class="admin-panel-accent-picker"> <div class="admin-panel-statistics-layout">
<button <section class="admin-panel-activity-statistics">
v-for="option in accentOptions" <div class="admin-panel-statistics-heading">
:key="option.color" <div>
type="button" <strong>{{ t('statistics.today') }}</strong>
:class="{ 'is-active': accentColor === option.color }" <small>{{ t('statistics.todayBody') }}</small>
: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> </div>
</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>
<section class="admin-panel-appearance-control"> <section class="admin-panel-coverage-statistics">
<span> <div class="admin-panel-statistics-heading">
<strong>{{ t('appearance.controls.fontFamily') }}</strong> <div>
<small>{{ t('appearance.controls.fontFamilyBody') }}</small> <strong>{{ t('statistics.coverage') }}</strong>
</span> <small>{{ t('statistics.coverageBody') }}</small>
<div </div>
class="admin-panel-font-select" </div>
@focusout="closeFontMenu" <div class="admin-panel-coverage-list">
> <article
<button v-for="metric in [
type="button" {
:aria-label="t('appearance.controls.fontFamily')" key: 'linkedDevices',
aria-haspopup="listbox" value: admin.stats.linkedDevices,
:aria-expanded="fontMenuOpen" },
@click="fontMenuOpen = !fontMenuOpen" {
key: 'simDevices',
value: admin.stats.simDevices,
},
{
key: 'activeDevices',
value: admin.stats.activeDevices,
},
]"
:key="metric.key"
> >
<span :style="{ fontFamily: activeAdminFontFamily }"> <div>
{{ t(`appearance.controls.fonts.${adminFontFamily}`) }} <strong>{{ t(`statistics.${metric.key}`) }}</strong>
</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)"
>
<span>{{ <span>{{
t(`appearance.controls.fonts.${option.key}`) t('statistics.ofDevices', {
count: formatStatistic(metric.value),
total: formatStatistic(admin.stats.devices),
})
}}</span> }}</span>
<Check </div>
v-if="adminFontFamily === option.key" <div
:size="13" class="admin-panel-statistics-progress"
/> role="progressbar"
</button> :aria-label="t(`statistics.${metric.key}`)"
</div> :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> </div>
</section> </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> </div>
</article> </article>
</section> </section>
<section <section
@@ -2488,7 +2307,8 @@ onBeforeUnmount(() => {
--admin-text: #f0f3f0; --admin-text: #f0f3f0;
--admin-muted: #818781; --admin-muted: #818781;
--admin-dim: #555b55; --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-green-soft: color-mix(in srgb, var(--admin-green) 9%, transparent);
--admin-toggle-on: #63d471; --admin-toggle-on: #63d471;
--admin-row-hover: linear-gradient( --admin-row-hover: linear-gradient(
@@ -2512,10 +2332,7 @@ onBeforeUnmount(() => {
padding: 2.5vh 2.5vw; padding: 2.5vh 2.5vw;
color: var(--admin-text); color: var(--admin-text);
background: transparent; background: transparent;
font-family: var( font-family: var(--sky-font-family, Inter, sans-serif);
--admin-font-family,
var(--sky-font-family, Inter, sans-serif)
);
pointer-events: auto; pointer-events: auto;
} }
@@ -2529,7 +2346,6 @@ onBeforeUnmount(() => {
box-shadow: box-shadow:
0 20px 56px rgba(0, 0, 0, 0.72), 0 20px 56px rgba(0, 0, 0, 0.72),
inset 0 1px rgba(255, 255, 255, 0.018); inset 0 1px rgba(255, 255, 255, 0.018);
zoom: var(--admin-font-scale, 1);
} }
.admin-panel-header { .admin-panel-header {
@@ -2653,15 +2469,19 @@ onBeforeUnmount(() => {
} }
.admin-panel-save.is-ready { .admin-panel-save.is-ready {
color: #74d66f; color: var(--admin-green);
background: transparent; 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) { .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; 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 { .admin-panel-close:hover {
@@ -3352,293 +3172,127 @@ button:disabled {
gap: 8px; gap: 8px;
} }
.admin-panel-accent-picker { .admin-panel-statistics-layout {
display: grid; display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
gap: 3px; gap: 8px;
padding: 3px; }
border: 0;
.admin-panel-activity-statistics,
.admin-panel-coverage-statistics {
min-width: 0;
padding: 10px;
border-radius: 3px; border-radius: 3px;
background: #0b0d0c; background: #0b0d0c;
} }
.admin-panel-accent-picker button { .admin-panel-statistics-heading {
min-height: 38px; margin-bottom: 9px;
}
.admin-panel-statistics-heading > div {
display: grid; display: grid;
grid-template-columns: 18px minmax(0, 1fr) 16px; gap: 2px;
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;
} }
.admin-panel-accent-picker button:hover, .admin-panel-statistics-heading strong {
.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 {
color: #d9ddd9; color: #d9ddd9;
font-size: 9px; font-size: 9px;
font-weight: 600; font-weight: 600;
} }
.admin-panel-appearance-control small { .admin-panel-statistics-heading small {
color: var(--admin-muted); color: var(--admin-muted);
font-size: 8px; 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; min-width: 0;
display: grid; display: grid;
grid-template-columns: auto minmax(230px, 1fr); grid-template-columns: 20px minmax(0, 1fr);
align-items: center; align-items: center;
gap: 14px; gap: 2px 6px;
padding: 10px;
background: #121412;
} }
.admin-panel-color-value { .admin-panel-activity-grid svg {
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 {
color: var(--admin-green); color: var(--admin-green);
} }
.admin-panel-font-size-control { .admin-panel-activity-grid strong {
display: grid; overflow: hidden;
grid-template-columns: minmax(80px, 1fr) 38px; font-size: 13px;
align-items: center; font-weight: 620;
gap: 9px; text-overflow: ellipsis;
white-space: nowrap;
} }
.admin-panel-font-size-control input { .admin-panel-activity-grid span {
width: 100%; 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; height: 3px;
accent-color: var(--admin-green); overflow: hidden;
cursor: pointer; border-radius: 999px;
background: #1d201e;
} }
.admin-panel-font-size-control > strong { .admin-panel-statistics-progress > span {
color: var(--admin-green); height: 100%;
text-align: right; 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 { .admin-panel-section-card__heading {
@@ -61,16 +61,22 @@ const passcodeError = ref('')
const passcodeLength = ref<4 | 6>(phone.security.length === 4 ? 4 : 6) const passcodeLength = ref<4 | 6>(phone.security.length === 4 ? 4 : 6)
const notificationsEnabled = ref(true) const notificationsEnabled = ref(true)
const notificationSounds = 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 setupCompleteBusy = ref(false)
const setupCompleteError = ref('') const setupCompleteError = ref('')
const setupApps = ( const setupApps = computed(() =>
['banking', 'garage', 'skyride', 'citymarkt', 'picstagram', 'snake'] as const (
).flatMap((id) => { ['banking', 'garage', 'skyride', 'citymarkt', 'picstagram', 'snake'] as const
const app = getPhoneApp(id) ).flatMap((id) => {
return app ? [app] : [] const app = getPhoneApp(id)
}) return app && appStore.isAvailable(id) ? [app] : []
}),
)
const wallpaperChoices = WALLPAPER_IDS const wallpaperChoices = WALLPAPER_IDS
const progress = computed(() => `${((step.value + 1) / 10) * 100}%`) const progress = computed(() => `${((step.value + 1) / 10) * 100}%`)
const displayName = computed(() => { const displayName = computed(() => {
+83
View File
@@ -0,0 +1,83 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
function source(path: string): string {
return readFileSync(new URL(path, import.meta.url), 'utf8')
}
const config = source('../../sky_phone/config/config.lua')
const configDefault = source('../../sky_phone/source/shared/config_default.lua')
const framework = source('../../sky_phone/source/bridge/server/framework.lua')
const qbox = source('../../sky_phone/source/bridge/server/frameworks/qbox.lua')
const configurator = source(
'../../sky_phone/source/server/phone_configurator.lua',
)
const configuratorFixture = source('../testserver/configurator-fixture.cjs')
describe('fixed server permissions', () => {
it('defines every protected phone capability only in config.lua', () => {
expect(config).toContain('Config.CommandPermissions = {')
for (const permission of [
'phonepanel',
'phonetestdata',
'fliptokverify',
'picstagramverify',
'picstagramadmin',
]) {
expect(config).toMatch(new RegExp(`\\s${permission} = \\{`))
}
expect(config).not.toContain('AdminGroups =')
expect(configDefault).not.toContain('Config.PhoneConfigurator')
expect(configDefault).not.toContain('Config.CommandPermissions')
expect(configDefault).not.toContain('AdminGroups =')
})
it('keeps fixed permissions outside SQL and removes legacy group fields', () => {
expect(configurator).toContain('key ~= "CommandPermissions"')
expect(configurator).toContain('if key ~= "CommandPermissions" then')
expect(configuratorFixture).toContain('delete config.CommandPermissions')
for (const path of [
'AdminPanel.AdminGroups',
'TestData.AdminGroups',
'FlipTok.AdminGroups',
'Picstagram.AdminGroups',
]) {
expect(configurator).toContain(`["${path}"] = true`)
}
expect(configurator).toContain(
'Phone Configurator enabled: file-based settings from config.lua',
)
expect(configurator).toContain(
'(except Config.CommandPermissions) and media.lua are disabled',
)
})
it('authorizes Qbox through ACE before retaining framework group support', () => {
expect(framework).toContain(
'local groups = Config.CommandPermissions[permission]',
)
expect(qbox).toContain(
'if IsPlayerAceAllowed(tostring(source), group) then',
)
expect(qbox.indexOf('IsPlayerAceAllowed')).toBeLessThan(
qbox.indexOf('exports.qbx_core:HasGroup'),
)
})
it('uses stable permission identifiers for every protected operation', () => {
const expectations = [
['../../sky_phone/source/server/admin.lua', 'phonepanel'],
['../../sky_phone/source/server/testdata.lua', 'phonetestdata'],
['../../sky_phone/source/server/fliptok.lua', 'fliptokverify'],
['../../sky_phone/source/server/picstagram.lua', 'picstagramverify'],
['../../sky_phone/source/server/picstagram.lua', 'picstagramadmin'],
] as const
for (const [path, permission] of expectations) {
expect(source(path)).toContain(
`Bridge.Framework.HasPermission(source, "${permission}")`,
)
}
})
})
+50 -6
View File
@@ -52,7 +52,42 @@ describe('phone inventory contracts', () => {
) )
}) })
it('auto-detects registered inventories and limits metadata-free adapters', () => { 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( const inventoryBridge = readResourceFile(
'source/bridge/server/inventory.lua', 'source/bridge/server/inventory.lua',
) )
@@ -65,8 +100,17 @@ describe('phone inventory contracts', () => {
) )
expect(inventoryBridge).toContain('configured_inventory = adapter.name') expect(inventoryBridge).toContain('configured_inventory = adapter.name')
expect(inventoryBridge).toContain('selected_adapter.metadata == false') expect(inventoryBridge).toContain('selected_adapter.metadata == false')
expect(inventoryBridge).toContain('Config.Phone.Unique ~= false') expect(inventoryBridge).toContain('Config.Phone.Unique = false')
expect(inventoryBridge).toContain('Config.Sim.Enabled ~= 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',
)
}) })
it('provides the LB IsOpen export alias from the authoritative client state', () => { it('provides the LB IsOpen export alias from the authoritative client state', () => {
@@ -157,10 +201,10 @@ describe('phone inventory contracts', () => {
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "ToggleOpen"', 'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "ToggleOpen"',
) )
expect(phoneClient).toContain( expect(phoneClient).toContain(
'local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})', 'local result = Bridge.Callbacks.Trigger(callback_name, {})',
) )
expect(phoneClient).toMatch( 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( expect(phoneBridge).toContain(
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "IsPhoneOnScreen"', 'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "IsPhoneOnScreen"',
@@ -256,7 +300,7 @@ describe('phone inventory contracts', () => {
'if active_key_mapping_command == command_name then', 'if active_key_mapping_command == command_name then',
) )
expect(phoneClient).toContain( expect(phoneClient).toContain(
'Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})', 'request_phone_open("sky_phone:device:open-request")',
) )
expect(phoneServer).toContain( expect(phoneServer).toContain(
'Bridge.Callbacks.Register("sky_phone:device:open-request", function(source)', 'Bridge.Callbacks.Register("sky_phone:device:open-request", function(source)',
+32 -1
View File
@@ -15,7 +15,32 @@ import type {
} from '@/types/admin' } from '@/types/admin'
import { nuiCall, type NuiResponse } from '@/utils/nui' 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', { export const useAdminStore = defineStore('admin', {
state: () => ({ state: () => ({
@@ -25,6 +50,7 @@ export const useAdminStore = defineStore('admin', {
configurator: null as AdminConfigurator | null, configurator: null as AdminConfigurator | null,
configuratorLoading: false, configuratorLoading: false,
detailLoading: false, detailLoading: false,
disabledApps: [] as string[],
error: '', error: '',
initialized: false, initialized: false,
loading: false, loading: false,
@@ -47,6 +73,9 @@ export const useAdminStore = defineStore('admin', {
return false return false
} }
this.players = response.data.players this.players = response.data.players
this.disabledApps = Array.isArray(response.data.disabledApps)
? response.data.disabledApps
: []
this.stats = response.data.stats this.stats = response.data.stats
this.audit = response.data.audit this.audit = response.data.audit
this.error = '' this.error = ''
@@ -147,6 +176,7 @@ export const useAdminStore = defineStore('admin', {
return false return false
} }
this.configurator = response.data this.configurator = response.data
this.disabledApps = disabledAppsFromConfigurator(response.data)
this.error = '' this.error = ''
return true return true
}, },
@@ -167,6 +197,7 @@ export const useAdminStore = defineStore('admin', {
this.actionKey = '' this.actionKey = ''
if (response.success && response.data) { if (response.success && response.data) {
this.configurator = response.data this.configurator = response.data
this.disabledApps = disabledAppsFromConfigurator(response.data)
this.error = '' this.error = ''
} else { } else {
if (response.data) this.configurator = response.data if (response.data) this.configurator = response.data
+15
View File
@@ -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', () => { it('drops the retired admin app from persisted phone layouts', () => {
const apps = useAppStoreStore() const apps = useAppStoreStore()
+73 -20
View File
@@ -45,28 +45,49 @@ const pendingInstallations = new WeakMap<
Map<LaunchablePhoneAppId, PendingInstallation> Map<LaunchablePhoneAppId, PendingInstallation>
>() >()
function getDefaultGridIds(): LaunchablePhoneAppId[] { function isAppDisabled(
return PHONE_APPS.filter((app) => app.dockOrder === null) 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) .sort((a, b) => a.gridOrder - b.gridOrder)
.map((app) => app.id) .map((app) => app.id)
} }
function getDefaultDockIds(): LaunchablePhoneAppId[] { function getDefaultDockIds(
return PHONE_APPS.filter((app) => app.dockOrder !== null) 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)) .sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
.map((app) => app.id) .map((app) => app.id)
} }
function getDefaultInstalledIds(): LaunchablePhoneAppId[] { function getDefaultInstalledIds(
disabledApps: readonly LaunchablePhoneAppId[] = [],
): LaunchablePhoneAppId[] {
return PHONE_APPS.filter((app) => { return PHONE_APPS.filter((app) => {
if (app.adminOnly) return false if (app.adminOnly || isAppDisabled(app.id, disabledApps)) return false
return isExternalPhoneApp(app) return isExternalPhoneApp(app)
? app.defaultInstalled ? app.defaultInstalled
: DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) : DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id)
}).map((app) => 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) const app = getPhoneApp(appId)
return app return app
? !isPhoneAppRemovable(app) ? !isPhoneAppRemovable(app)
@@ -104,6 +125,7 @@ function hasUninstalledBuiltinApp(
export const useAppStoreStore = defineStore('app-store', { export const useAppStoreStore = defineStore('app-store', {
state: () => ({ state: () => ({
claimedApps: [] as LaunchablePhoneAppId[], claimedApps: [] as LaunchablePhoneAppId[],
disabledApps: [] as LaunchablePhoneAppId[],
uninstalledApps: [] as LaunchablePhoneAppId[], uninstalledApps: [] as LaunchablePhoneAppId[],
homeLayout: createDefaultHomeLayout( homeLayout: createDefaultHomeLayout(
getDefaultInstalledIds(), getDefaultInstalledIds(),
@@ -130,6 +152,7 @@ export const useAppStoreStore = defineStore('app-store', {
return true return true
}, },
claimApp(id: LaunchablePhoneAppId): void { claimApp(id: LaunchablePhoneAppId): void {
if (!this.isAvailable(id)) return
this.uninstalledApps = this.uninstalledApps.filter( this.uninstalledApps = this.uninstalledApps.filter(
(appId) => appId !== id, (appId) => appId !== id,
) )
@@ -150,6 +173,7 @@ export const useAppStoreStore = defineStore('app-store', {
this.installingApps = {} this.installingApps = {}
}, },
installApp(id: LaunchablePhoneAppId): void { installApp(id: LaunchablePhoneAppId): void {
if (!this.isAvailable(id)) return
const installed = this.isInstalled(id) const installed = this.isInstalled(id)
if ( if (
this.installingApps[id] || this.installingApps[id] ||
@@ -191,6 +215,10 @@ export const useAppStoreStore = defineStore('app-store', {
) )
return return
} }
if (!this.isAvailable(id)) {
delete this.installingApps[id]
return
}
if (reportInstall && !isExternalPhoneApp(getPhoneApp(id))) { if (reportInstall && !isExternalPhoneApp(getPhoneApp(id))) {
delete this.installingApps[id] delete this.installingApps[id]
console.error( console.error(
@@ -220,8 +248,14 @@ export const useAppStoreStore = defineStore('app-store', {
installations.set(id, { deviceImei, timer, token }) installations.set(id, { deviceImei, timer, token })
pendingInstallations.set(this, installations) pendingInstallations.set(this, installations)
}, },
hydrate(payload: unknown): void { hydrate(payload: unknown, disabledApps: unknown = []): void {
this.cancelPendingInstalls() this.cancelPendingInstalls()
this.disabledApps = Array.isArray(disabledApps)
? disabledApps.filter(
(id): id is LaunchablePhoneAppId =>
typeof id === 'string' && isPhoneAppId(id),
)
: []
const data = payload as { const data = payload as {
claimedApps?: unknown claimedApps?: unknown
homeLayout?: unknown homeLayout?: unknown
@@ -256,16 +290,22 @@ export const useAppStoreStore = defineStore('app-store', {
}) })
: [] : []
const installedIds = [ const installedIds = [
...new Set([...getDefaultInstalledIds(), ...this.claimedApps]), ...new Set([
].filter((id) => !this.uninstalledApps.includes(id)) ...getDefaultInstalledIds(this.disabledApps),
...this.claimedApps,
]),
].filter(
(id) =>
!this.uninstalledApps.includes(id) && this.isAvailable(id),
)
const removedLegacyDefaults = hasUninstalledBuiltinApp( const removedLegacyDefaults = hasUninstalledBuiltinApp(
data?.homeLayout, data?.homeLayout,
installedIds, installedIds,
) )
const defaults = createDefaultHomeLayout( const defaults = createDefaultHomeLayout(
installedIds, installedIds,
getDefaultGridIds(), getDefaultGridIds(this.disabledApps),
getDefaultDockIds(), getDefaultDockIds(this.disabledApps),
) )
const parsedHomeLayout = parseHomeLayout( const parsedHomeLayout = parseHomeLayout(
data?.homeLayout, data?.homeLayout,
@@ -277,8 +317,9 @@ export const useAppStoreStore = defineStore('app-store', {
const removedDockGridDuplicates = const removedDockGridDuplicates =
normalizedHomeLayout !== parsedHomeLayout normalizedHomeLayout !== parsedHomeLayout
this.homeLayout = normalizedHomeLayout this.homeLayout = normalizedHomeLayout
const protectedHiddenAppIds = const protectedHiddenAppIds = this.homeLayout.hidden.filter((appId) =>
this.homeLayout.hidden.filter(isProtectedHomeApp) isProtectedHomeApp(appId, this.disabledApps),
)
for (const appId of protectedHiddenAppIds) { for (const appId of protectedHiddenAppIds) {
this.homeLayout = restoreHomeApp(this.homeLayout, appId) this.homeLayout = restoreHomeApp(this.homeLayout, appId)
} }
@@ -310,7 +351,11 @@ export const useAppStoreStore = defineStore('app-store', {
this.persist() this.persist()
} }
}, },
isAvailable(appId: LaunchablePhoneAppId): boolean {
return !isAppDisabled(appId, this.disabledApps)
},
isInstalled(appId: LaunchablePhoneAppId): boolean { isInstalled(appId: LaunchablePhoneAppId): boolean {
if (!this.isAvailable(appId)) return false
const app = getPhoneApp(appId) const app = getPhoneApp(appId)
if (app?.adminOnly) return false if (app?.adminOnly) return false
if (this.uninstalledApps.includes(appId)) return false if (this.uninstalledApps.includes(appId)) return false
@@ -322,12 +367,18 @@ export const useAppStoreStore = defineStore('app-store', {
}, },
reconcileCatalog(): void { reconcileCatalog(): void {
const installedIds = [ const installedIds = [
...new Set([...getDefaultInstalledIds(), ...this.claimedApps]), ...new Set([
].filter((id) => !this.uninstalledApps.includes(id)) ...getDefaultInstalledIds(this.disabledApps),
...this.claimedApps,
]),
].filter(
(id) =>
!this.uninstalledApps.includes(id) && this.isAvailable(id),
)
const defaults = createDefaultHomeLayout( const defaults = createDefaultHomeLayout(
installedIds, installedIds,
getDefaultGridIds(), getDefaultGridIds(this.disabledApps),
getDefaultDockIds(), getDefaultDockIds(this.disabledApps),
) )
const previous = JSON.stringify(this.homeLayout) const previous = JSON.stringify(this.homeLayout)
this.homeLayout = removeDockGridDuplicates( this.homeLayout = removeDockGridDuplicates(
@@ -335,7 +386,7 @@ export const useAppStoreStore = defineStore('app-store', {
) )
for (const appId of [...this.homeLayout.hidden]) { for (const appId of [...this.homeLayout.hidden]) {
if (isProtectedHomeApp(appId)) { if (isProtectedHomeApp(appId, this.disabledApps)) {
this.homeLayout = restoreHomeApp(this.homeLayout, appId) this.homeLayout = restoreHomeApp(this.homeLayout, appId)
} }
} }
@@ -345,6 +396,7 @@ export const useAppStoreStore = defineStore('app-store', {
} }
}, },
recordLaunch(appId: LaunchablePhoneAppId): void { recordLaunch(appId: LaunchablePhoneAppId): void {
if (!this.isAvailable(appId)) return
this.launchCounts[appId] = (this.launchCounts[appId] ?? 0) + 1 this.launchCounts[appId] = (this.launchCounts[appId] ?? 0) + 1
this.persist() this.persist()
}, },
@@ -480,12 +532,13 @@ export const useAppStoreStore = defineStore('app-store', {
return true return true
}, },
removeHomeApp(appId: LaunchablePhoneAppId): void { removeHomeApp(appId: LaunchablePhoneAppId): void {
if (isProtectedHomeApp(appId)) return if (isProtectedHomeApp(appId, this.disabledApps)) return
this.homeLayout = removeHomeApp(this.homeLayout, appId) this.homeLayout = removeHomeApp(this.homeLayout, appId)
this.persist() this.persist()
}, },
restoreHomeApp(appId: LaunchablePhoneAppId): void { restoreHomeApp(appId: LaunchablePhoneAppId): void {
if (!this.isAvailable(appId)) return
this.homeLayout = restoreHomeApp(this.homeLayout, appId) this.homeLayout = restoreHomeApp(this.homeLayout, appId)
this.persist() this.persist()
}, },
+16 -35
View File
@@ -33,6 +33,7 @@ export type PasscodeResponseData = {
export type PhoneOpenPayload = { export type PhoneOpenPayload = {
account?: DeviceBootstrap['account'] account?: DeviceBootstrap['account']
device?: PhoneDevice device?: PhoneDevice
disabledApps?: string[]
fallbackLocales?: LocaleTree fallbackLocales?: LocaleTree
lang?: string lang?: string
locales?: LocaleTree locales?: LocaleTree
@@ -848,40 +849,21 @@ const adminPanelFallbackLocales = {
auditFeature: 'Review sensitive admin actions', auditFeature: 'Review sensitive admin actions',
configuratorFeature: 'Manage config.lua and media.lua through SQL', configuratorFeature: 'Manage config.lua and media.lua through SQL',
}, },
appearance: { statistics: {
eyebrow: 'Appearance', eyebrow: 'Live data',
title: 'Interface', title: 'Phone statistics',
body: 'Personalize colors and typography across the admin workspace.', body: 'Current usage and device status, refreshed every 15 seconds.',
colors: { today: 'Activity today',
emerald: 'Emerald', todayBody: 'Phone and admin activity since midnight.',
blue: 'Blue', messagesToday: 'Messages',
violet: 'Violet', callsToday: 'Calls',
orange: 'Orange', auditToday: 'Admin actions',
red: 'Red', coverage: 'Device coverage',
}, coverageBody: 'Current share of all stored phones.',
controls: { linkedDevices: 'Account linked',
customColor: 'Custom color', simDevices: 'SIM assigned',
customColorBody: 'Choose any RGB accent or enter its channel values.', activeDevices: 'Updated in 24 hours',
red: 'R', ofDevices: '{count} of {total} devices',
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',
},
},
}, },
configurator: { configurator: {
context: 'Runtime configuration', context: 'Runtime configuration',
@@ -945,7 +927,6 @@ const adminPanelFallbackLocales = {
entry: 'Entry', entry: 'Entry',
general: 'General', general: 'General',
subtabs: { subtabs: {
AdminGroups: 'Admin Groups',
Dictionaries: 'Dictionaries', Dictionaries: 'Dictionaries',
Clips: 'Clips', Clips: 'Clips',
Transforms: 'Transforms', Transforms: 'Transforms',
+8
View File
@@ -1,7 +1,14 @@
export type AdminStats = { export type AdminStats = {
accounts: number accounts: number
activeDevices: number
auditEntries: number
auditToday: number
callsToday: number
devices: number devices: number
linkedDevices: number
messagesToday: number
online: number online: number
simDevices: number
} }
export type AdminPlayerSummary = { export type AdminPlayerSummary = {
@@ -78,6 +85,7 @@ export type AdminAuditEntry = {
export type AdminBootstrap = { export type AdminBootstrap = {
audit: AdminAuditEntry[] audit: AdminAuditEntry[]
disabledApps: string[]
players: AdminPlayerSummary[] players: AdminPlayerSummary[]
stats: AdminStats stats: AdminStats
} }
@@ -16,9 +16,7 @@ describe('admin configurator descriptions', () => {
expect(configuratorDescriptionKey('Media.RequestTimeoutMs', 10000)).toBe( expect(configuratorDescriptionKey('Media.RequestTimeoutMs', 10000)).toBe(
'milliseconds', 'milliseconds',
) )
expect(configuratorDescriptionKey('AdminPanel.AdminGroups', [])).toBe( expect(configuratorDescriptionKey('Radio.AllowedJobs', [])).toBe('access')
'access',
)
expect(configuratorDescriptionKey('FiveManage.ApiKey', '')).toBe( expect(configuratorDescriptionKey('FiveManage.ApiKey', '')).toBe(
'credential', 'credential',
) )
+5
View File
@@ -2,11 +2,16 @@ import { describe, expect, it } from 'vitest'
import type { LaunchablePhoneAppId } from '@/types/apps' import type { LaunchablePhoneAppId } from '@/types/apps'
import { import {
DEFAULT_PHONE_PREFERENCES, DEFAULT_PHONE_PREFERENCES,
PHONE_SCALE_STEP,
PHONE_SETUP_LAST_STEP, PHONE_SETUP_LAST_STEP,
parsePhonePreferences, parsePhonePreferences,
WALLPAPER_IDS, WALLPAPER_IDS,
} from './preferences' } from './preferences'
describe('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', () => { it('starts Setup Assistant for a phone without saved settings', () => {
const value = parsePhonePreferences(null) const value = parsePhonePreferences(null)
+1 -1
View File
@@ -38,7 +38,7 @@ export const WALLPAPER_IDS = [
] as const ] as const
export const PHONE_SCALE_MIN = 75 export const PHONE_SCALE_MIN = 75
export const PHONE_SCALE_MAX = 150 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 const PHONE_SETUP_LAST_STEP = 9
export type AppearanceMode = (typeof APPEARANCE_MODE_IDS)[number] export type AppearanceMode = (typeof APPEARANCE_MODE_IDS)[number]
@@ -81,6 +81,7 @@ describe('AppStoreApp Sky navigation contract', () => {
expect(source).toContain('@click="profileOpened = true"') expect(source).toContain('@click="profileOpened = true"')
expect(source).toContain('const installedApps = computed') expect(source).toContain('const installedApps = computed')
expect(source).toContain('return !appStore.isInstalled(app.id)') expect(source).toContain('return !appStore.isInstalled(app.id)')
expect(source).toContain('if (!appStore.isAvailable(app.id)) return false')
expect(source).toContain( expect(source).toContain(
'class="store-account__apps phone-effect--expensive-shadow"', 'class="store-account__apps phone-effect--expensive-shadow"',
) )
+5 -1
View File
@@ -122,6 +122,7 @@ const catalog = computed(() =>
if (!isLaunchablePhoneApp(app) || app.id === 'app-store' || app.adminOnly) { if (!isLaunchablePhoneApp(app) || app.id === 'app-store' || app.adminOnly) {
return false return false
} }
if (!appStore.isAvailable(app.id)) return false
return !appStore.isInstalled(app.id) return !appStore.isInstalled(app.id)
}).sort((a, b) => a.gridOrder - b.gridOrder), }).sort((a, b) => a.gridOrder - b.gridOrder),
@@ -129,7 +130,9 @@ const catalog = computed(() =>
const installedApps = computed(() => const installedApps = computed(() =>
PHONE_APPS.filter( PHONE_APPS.filter(
(app): app is LaunchablePhoneAppDefinition => (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), ).sort((a, b) => a.gridOrder - b.gridOrder),
) )
const installedGameCount = computed( const installedGameCount = computed(
@@ -143,6 +146,7 @@ const dailyCandidates = computed(() =>
!app.adminOnly && !app.adminOnly &&
!isExternalPhoneApp(app) && !isExternalPhoneApp(app) &&
app.id !== 'app-store' && app.id !== 'app-store' &&
appStore.isAvailable(app.id) &&
!DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) && !DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) &&
!appStore.isInstalled(app.id), !appStore.isInstalled(app.id),
), ),
@@ -369,6 +369,22 @@ describe('VaultX crypto app contracts', () => {
expect(testServer).toContain('function advanceCryptoCycle(') 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', () => { it('stores cash in price-scale minor units throughout the ledger', () => {
expect(server).toContain( expect(server).toContain(
'local ledger_amount = amount * Config.Crypto.PriceScale', 'local ledger_amount = amount * Config.Crypto.PriceScale',
@@ -6,6 +6,14 @@ const source = readFileSync(
new URL('./DarkChatApp.vue', import.meta.url), new URL('./DarkChatApp.vue', import.meta.url),
'utf8', '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', () => { describe('DarkChatApp Sky UI contract', () => {
it('uses first-party Sky UI without direct Konsta markup', () => { 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, /\.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', () => { it('uses the shared full-width Sky tab bar for phone sections', () => {
expect(source).toContain('<sky-tab-bar') expect(source).toContain('<sky-tab-bar')
expect(source).toContain('<sky-tab-button') 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', () => { it('uses shared interactive liquid glass surfaces for phone controls', () => {
-1
View File
@@ -2250,7 +2250,6 @@ onBeforeUnmount(() => {
position: relative; position: relative;
padding-top: 6px; padding-top: 6px;
padding-right: 24px; padding-right: 24px;
padding-bottom: 20px;
} }
.phone-contacts-header { .phone-contacts-header {
@@ -116,6 +116,20 @@ describe('voice provider contracts', () => {
expect(phoneApp).not.toContain('callMuted = !callMuted') 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', () => { it('passes Yaca radio volume arguments in the documented order', () => {
expect(clientRadio).toContain( expect(clientRadio).toContain(
'changeRadioChannelVolumeRaw(volume / 100, 1)', 'changeRadioChannelVolumeRaw(volume / 100, 1)',
@@ -661,6 +661,7 @@ function loadConfiguratorSections() {
) )
const media = mediaRoot.Media const media = mediaRoot.Media
delete config.PhoneConfigurator delete config.PhoneConfigurator
delete config.CommandPermissions
delete config.Media delete config.Media
return [...buildSections('config', config), ...buildSections('media', media)] return [...buildSections('config', config), ...buildSections('media', media)]
} }
@@ -61,16 +61,21 @@ function countStructure(structure: ConfiguratorStructure | undefined): number {
} }
describe('admin configurator fixture', () => { describe('admin configurator fixture', () => {
it('exposes every config.lua root through the full live preview', () => { it('exposes every SQL-managed config.lua root through the full live preview', () => {
const sections = loadConfiguratorSections() const sections = loadConfiguratorSections()
const fields = sections.flatMap((section) => section.fields) const fields = sections.flatMap((section) => section.fields)
const roots = [ const roots = [
...configSource.matchAll(/^\s{0,4}Config\.([A-Za-z0-9_]+)\s*=/gm), ...configSource.matchAll(/^\s{0,4}Config\.([A-Za-z0-9_]+)\s*=/gm),
] ]
.map((match) => match[1]) .map((match) => match[1])
.filter((root) => root !== 'Media' && root !== 'PhoneConfigurator') .filter(
(root) =>
root !== 'Media' &&
root !== 'PhoneConfigurator' &&
root !== 'CommandPermissions',
)
expect(sections).toHaveLength(45) expect(sections).toHaveLength(46)
expect( expect(
fields.reduce( fields.reduce(
(total, field) => total + countStructure(field.structure), (total, field) => total + countStructure(field.structure),
+13 -1
View File
@@ -4726,6 +4726,7 @@ function adminMockBootstrap() {
targetSource: 2, targetSource: 2,
}, },
], ],
disabledApps: [],
players: [1, 2].map((source) => { players: [1, 2].map((source) => {
const player = adminMockPlayerDetail(source) const player = adminMockPlayerDetail(source)
return { return {
@@ -4740,7 +4741,18 @@ function adminMockBootstrap() {
source, 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
View File
@@ -134,7 +134,7 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
- `hex_4_inventory` - `hex_4_inventory`
- Native ESX inventory - Native ESX inventory
`mf-inventory` and `smx-inventory` are supported with ESX. The native ESX and HEX adapters use count-based items and therefore require unique phones and physical SIM cards to be disabled. `mf-inventory` and `smx-inventory` are supported with ESX. The native ESX and HEX adapters use count-based items, so Sky Phone automatically disables unique phones and physical SIM cards while either adapter is active.
### Voice ### Voice
@@ -196,6 +196,7 @@ The files contain clearly separated sections for:
| Section | Purpose | | Section | Purpose |
| --- | --- | | --- | --- |
| `Config.Bridge` | Framework, inventory, language, callback timeout, and debug mode | | `Config.Bridge` | Framework, inventory, language, callback timeout, and debug mode |
| `Config.CommandPermissions` | Fixed groups for the admin panel, test data, verification commands, and social moderation |
| `Config.Phone` | Phone item, movement, unique-device mode, and development command | | `Config.Phone` | Phone item, movement, unique-device mode, and development command |
| `Config.Sim` | Physical or virtual SIM behavior and number formatting | | `Config.Sim` | Physical or virtual SIM behavior and number formatting |
| `Config.Calls` / `Config.Radio` | Voice providers, call behavior, radio limits, and permissions | | `Config.Calls` / `Config.Radio` | Voice providers, call behavior, radio limits, and permissions |
@@ -211,6 +212,18 @@ The files contain clearly separated sections for:
Restart `sky_phone` after changing Lua configuration. Restart `sky_phone` after changing Lua configuration.
When `Config.PhoneConfigurator.Enabled` is enabled, the generated `source/shared/config_default.lua`
provides the shipped SQL baseline. The frontend build recreates it from `config.lua` and the
server-only `media.lua`; do not edit the generated snapshot directly.
`Config.PhoneConfigurator` and `Config.CommandPermissions` remain file-owned and are omitted from
the generated default snapshot. Permissions are not displayed in the Phone Configurator and stay
authoritative while SQL configuration is enabled. Their stable keys do not change when a command is
renamed in the panel. ESX and QBCore use their framework permissions. Qbox checks the configured ACE
objects first and then its framework groups, so the standard `permissions.cfg` mapping from
`group.admin` to the `admin` ACE works with the shipped `phonepanel` permission list. Restart
`sky_phone` after changing fixed permissions.
### Language ### Language
Available locales: Available locales:
@@ -312,7 +325,7 @@ Do not configure an LB Phone client event or client export. Sky Phone registers
The server registers `Config.Phone.Item` as usable for every supported inventory adapter: `ox`, `qb`, `lj`, `qs`, `codem`, `core`, `mf`, `smx`, `hex`, and `esx`. Resource startup fails visibly if the selected adapter cannot complete that registration. The server registers `Config.Phone.Item` as usable for every supported inventory adapter: `ox`, `qb`, `lj`, `qs`, `codem`, `core`, `mf`, `smx`, `hex`, and `esx`. Resource startup fails visibly if the selected adapter cannot complete that registration.
The `hex` and `esx` adapters use ESX's count-based item API. They require both `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` because this API cannot persist per-item phone or physical SIM metadata. `auto` selects `hex` when `hex_4_inventory` is started and otherwise falls back to `esx` on an ESX server when no metadata-capable inventory is detected. The `hex` and `esx` adapters use ESX's count-based item API, which cannot persist per-item phone or physical SIM metadata. Sky Phone automatically forces `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` while either adapter is active. `auto` selects `hex` when `hex_4_inventory` is started and otherwise falls back to `esx` on an ESX server when no metadata-capable inventory is detected.
### QBCore-style item tables ### QBCore-style item tables
+68 -9
View File
@@ -10,12 +10,27 @@
Keep option names unchanged. Restart sky_phone after editing this file. Keep option names unchanged. Restart sky_phone after editing this file.
]] ]]
-- When enabled, config.lua and media.lua only provide first-run defaults. -- CONFIG_DEFAULT_EXCLUDE_START
-- The active configuration is loaded from SQL and managed through /phonepanel. -- When enabled, the active configuration is loaded from SQL and managed through
-- /phonepanel. Frontend builds snapshot the shipped defaults from config.lua and
-- media.lua into source/shared/config_default.lua.
Config.PhoneConfigurator = { Config.PhoneConfigurator = {
Enabled = true, Enabled = true,
} }
-- Fixed server permissions. These values remain authoritative even while the
-- Phone Configurator is enabled and are intentionally not shown in its panel.
-- Group names use the active framework's permissions. On Qbox they also match
-- ACE objects such as "admin" from the standard permissions.cfg.
Config.CommandPermissions = {
phonepanel = { "god", "superadmin", "admin" },
phonetestdata = { "god", "superadmin", "admin" },
fliptokverify = { "god", "superadmin", "admin" },
picstagramverify = { "god", "superadmin", "admin" },
picstagramadmin = { "god", "superadmin", "admin" },
}
-- CONFIG_DEFAULT_EXCLUDE_END
-- ============================================================================= -- =============================================================================
-- Core, framework and device -- Core, framework and device
-- ============================================================================= -- =============================================================================
@@ -34,8 +49,9 @@ Config.Command = "phone"
Config.Phone = { Config.Phone = {
Item = "phone", Item = "phone",
Unique = true, -- true: data follows each phone item; false: one persistent phone per character; hex/esx require false Unique = true, -- true: data follows each phone item; false: one persistent phone per character; forced false for metadata-free inventories
Keybind = "F1", -- false disables the configurable phone key mapping Keybind = "F1", -- false disables the configurable phone key mapping
OpenRequestsPerMinute = 20,
AllowMovement = true, -- true: game input stays active while the mobile phone is open AllowMovement = true, -- true: game input stays active while the mobile phone is open
HoldToLook = { HoldToLook = {
Enabled = true, -- hold the configured control to hide the cursor and look around; independent of AllowMovement Enabled = true, -- hold the configured control to hide the cursor and look around; independent of AllowMovement
@@ -45,11 +61,56 @@ Config.Phone = {
DeviceName = "iFruit 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 = { Config.TestData = {
Enabled = false, -- development/test servers only; keep disabled in production Enabled = false, -- development/test servers only; keep disabled in production
Command = "phonetestdata", Command = "phonetestdata",
AdminOnly = false, -- enable only on development servers; every run is scoped to the executing player's phone AdminOnly = false, -- enable only on development servers; every run is scoped to the executing player's phone
AdminGroups = { "admin", "superadmin" },
} }
Config.CustomApps = { Config.CustomApps = {
@@ -76,7 +137,6 @@ Config.Security = {
Config.AdminPanel = { Config.AdminPanel = {
Enabled = true, Enabled = true,
Command = "phonepanel", Command = "phonepanel",
AdminGroups = { "admin", "superadmin" },
MaximumPlayers = 128, MaximumPlayers = 128,
ReadRequestsPerMinute = 60, ReadRequestsPerMinute = 60,
ActionRequestsPerMinute = 30, ActionRequestsPerMinute = 30,
@@ -86,7 +146,7 @@ Config.AdminPanel = {
} }
Config.Sim = { Config.Sim = {
Enabled = true, -- false: devices receive a persistent random number automatically; hex/esx require false Enabled = true, -- false: devices receive a persistent random number automatically; forced false for metadata-free inventories
RegisteredItem = "sky_phone_sim_registered", RegisteredItem = "sky_phone_sim_registered",
AnonymousItem = "sky_phone_sim_anonymous", AnonymousItem = "sky_phone_sim_anonymous",
NumberLength = 10, -- total number of digits, including NumberPrefix NumberLength = 10, -- total number of digits, including NumberPrefix
@@ -103,7 +163,7 @@ Config.Speaker = {
} }
Config.Calls = { 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, RingSeconds = 30,
ContactNameMaxLength = 80, ContactNameMaxLength = 80,
ContactNotesMaxLength = 500, ContactNotesMaxLength = 500,
@@ -288,6 +348,7 @@ Config.DarkChat = {
VoiceMaxBase64Length = 360000, VoiceMaxBase64Length = 360000,
VoiceWaveformSamples = 48, VoiceWaveformSamples = 48,
CleanupIntervalSeconds = 30, CleanupIntervalSeconds = 30,
CleanupBatchSize = 250,
AllowedDisappearTimers = { AllowedDisappearTimers = {
[0] = true, [0] = true,
[-1] = true, -- after reading [-1] = true, -- after reading
@@ -485,7 +546,6 @@ Config.FlipTok = {
MaxPostMedia = 10, MaxPostMedia = 10,
MusicTracks = {}, MusicTracks = {},
VerifyCommand = "fliptokverify", VerifyCommand = "fliptokverify",
AdminGroups = { "admin" },
ReportWebhookConvar = "sky_phone_fliptok_report_webhook", ReportWebhookConvar = "sky_phone_fliptok_report_webhook",
} }
@@ -507,7 +567,6 @@ Config.Picstagram = {
ReportDetailsMaxLength = 500, ReportDetailsMaxLength = 500,
ReportReasons = { "spam", "harassment", "dangerous", "illegal", "other" }, ReportReasons = { "spam", "harassment", "dangerous", "illegal", "other" },
VerifyCommand = "picstagramverify", VerifyCommand = "picstagramverify",
AdminGroups = { "admin" },
} }
Config.Feather = { Config.Feather = {
+2 -28
View File
@@ -43,6 +43,7 @@ Locales["de"] = {
invalid_sim = "Diese SIM-Karte besitzt ungültige Metadaten.", invalid_sim = "Diese SIM-Karte besitzt ungültige Metadaten.",
phone_required = "Du benötigst ein Handy in deinem Inventar.", phone_required = "Du benötigst ein Handy in deinem Inventar.",
operation_in_progress = "Eine andere Handy-Aktion wird bereits ausgeführt.", 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.", voice_unavailable = "Der konfigurierte Sprachdienst ist nicht verfügbar.",
default = "Das Handy konnte nicht geöffnet werden.", 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...", 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" }, 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" }, 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" } } }, 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." }, 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" }, 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 = { Locales["de"].Nui.AdminPanel.configurator.table.subtabs = {
AdminGroups = "Admin-Gruppen",
Dictionaries = "Animationsdateien", Dictionaries = "Animationsdateien",
Clips = "Clips", Clips = "Clips",
Transforms = "Transformationen", Transforms = "Transformationen",
@@ -2176,29 +2176,3 @@ Locales["de"].Nui.AdminPanel.configurator.table.subtabs = {
AllowedJobs = "Erlaubte Jobs", AllowedJobs = "Erlaubte Jobs",
Websites = "Webseiten", 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",
},
}
+2 -28
View File
@@ -43,6 +43,7 @@ Locales["en"] = {
invalid_sim = "This SIM card has invalid metadata.", invalid_sim = "This SIM card has invalid metadata.",
phone_required = "You need a phone in your inventory.", phone_required = "You need a phone in your inventory.",
operation_in_progress = "Another phone operation is already in progress.", 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.", voice_unavailable = "The configured phone voice service is unavailable.",
default = "The phone could not be opened.", 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...", 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" }, 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" }, 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" } } }, 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." }, 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" }, 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 = { Locales["en"].Nui.AdminPanel.configurator.table.subtabs = {
AdminGroups = "Admin Groups",
Dictionaries = "Dictionaries", Dictionaries = "Dictionaries",
Clips = "Clips", Clips = "Clips",
Transforms = "Transforms", Transforms = "Transforms",
@@ -2176,29 +2176,3 @@ Locales["en"].Nui.AdminPanel.configurator.table.subtabs = {
AllowedJobs = "Allowed Jobs", AllowedJobs = "Allowed Jobs",
Websites = "Websites", 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",
},
}
+2 -29
View File
@@ -43,6 +43,7 @@ Locales["es"] = {
invalid_sim = "Esta tarjeta SIM tiene metadatos inválidos.", invalid_sim = "Esta tarjeta SIM tiene metadatos inválidos.",
phone_required = "Necesitas un teléfono en tu inventario.", phone_required = "Necesitas un teléfono en tu inventario.",
operation_in_progress = "Otra operación de teléfono ya está en progreso.", operation_in_progress = "Otra operación de teléfono ya está en progreso.",
rate_limited = "El teléfono se abrió demasiadas veces. Inténtalo de nuevo en un momento.",
voice_unavailable = "El servicio de voz configurado para el teléfono no está disponible.", voice_unavailable = "El servicio de voz configurado para el teléfono no está disponible.",
default = "No se pudo abrir el teléfono.", default = "No se pudo abrir el teléfono.",
}, },
@@ -205,7 +206,7 @@ Locales["es"] = {
name = "Administrador de teléfono", subtitle = "Administración", navigation = "Navegación del administrador", refresh = "Actualizar los datos del administrador", loading = "Cargar datos protegidos...", name = "Administrador de teléfono", subtitle = "Administración", navigation = "Navegación del administrador", refresh = "Actualizar los datos del administrador", loading = "Cargar datos protegidos...",
tabs = { overview = "Sinopsis", players = "Los jugadores", devices = "Los dispositivos", apps = "Aplicaciones", accounts = "Cuentas", messages = "Mensajes", calls = "Las llamadas", moderation = "Moderación", audit = "Auditoría", configurator = "Configuración de teléfono" }, tabs = { overview = "Sinopsis", players = "Los jugadores", devices = "Los dispositivos", apps = "Aplicaciones", accounts = "Cuentas", messages = "Mensajes", calls = "Las llamadas", moderation = "Moderación", audit = "Auditoría", configurator = "Configuración de teléfono" },
overview = { eyebrow = "El servidor", title = "Tablero de control", body = "Jugadores, dispositivos, aplicaciones y datos telefónicos.", stats = "Estadísticas telefónicas del servidor", online = "En línea", devices = "Los dispositivos", accounts = "Cuentas", audit = "Entradas de auditoría", control = "Navegación", features = "Los módulos", featuresBody = "Abre un módulo de administración.", recent = "Actividad reciente", playerFeature = "Identidad, finanzas, trabajo y deber", deviceFeature = "IMEI, SIM, número y actividad", appFeature = "Instalar o eliminar aplicaciones telefónicas", accountFeature = "Acceso a la cuenta y credenciales protegidas", messageFeature = "Revisar la actividad reciente de SMS", callFeature = "Revisar la actividad reciente de las llamadas", moderationFeature = "Resetear datos de acceso, número o dispositivo", auditFeature = "Revisar las acciones del administrador sensibles", configuratorFeature = "Administrar config.lua y media.lua a través de SQL" }, overview = { eyebrow = "El servidor", title = "Tablero de control", body = "Jugadores, dispositivos, aplicaciones y datos telefónicos.", stats = "Estadísticas telefónicas del servidor", online = "En línea", devices = "Los dispositivos", accounts = "Cuentas", audit = "Entradas de auditoría", control = "Navegación", features = "Los módulos", featuresBody = "Abre un módulo de administración.", recent = "Actividad reciente", playerFeature = "Identidad, finanzas, trabajo y deber", deviceFeature = "IMEI, SIM, número y actividad", appFeature = "Instalar o eliminar aplicaciones telefónicas", accountFeature = "Acceso a la cuenta y credenciales protegidas", messageFeature = "Revisar la actividad reciente de SMS", callFeature = "Revisar la actividad reciente de las llamadas", moderationFeature = "Resetear datos de acceso, número o dispositivo", auditFeature = "Revisar las acciones del administrador sensibles", configuratorFeature = "Administrar config.lua y media.lua a través de SQL" },
appearance = { eyebrow = "Apariencia", title = "Color de acento", body = "Cambia el acento en todo el espacio de trabajo del administrador.", colors = { emerald = "Esmeralda", blue = "Azul", violet = "Violeta", orange = "Naranja", red = "Rojo" } }, statistics = { eyebrow = "Datos en vivo", title = "Estadísticas del teléfono", body = "Uso actual y estado de los dispositivos, actualizado cada 15 segundos.", today = "Actividad de hoy", todayBody = "Actividad telefónica y administrativa desde medianoche.", messagesToday = "Mensajes", callsToday = "Llamadas", auditToday = "Acciones de administrador", coverage = "Cobertura de dispositivos", coverageBody = "Proporción actual de todos los teléfonos guardados.", linkedDevices = "Cuenta vinculada", simDevices = "SIM asignada", activeDevices = "Actualizado en 24 horas", ofDevices = "{count} de {total} dispositivos" },
configurator = { context = "Configuración de tiempo de ejecución", eyebrow = "Herramienta del sistema", sections = "Configuración", search = "Configuración de búsqueda o caminos", configScope = "config.lua", mediaScope = "media.lua", noResults = "Ninguna configuración correspondiente", loading = "Cargar la configuración de SQL...", title = "Configuración de teléfono", body = "Gestionar la configuración de teléfono y medios desde el espacio de trabajo de administrador protegido.", disabledTitle = "Configuración SQL no está activa", disabledBody = "Habilitar el configurador al principio de config.lua y reiniciar sky_phone. Hasta entonces, los valores del archivo permanecen activos y la edición está bloqueada.", manualSave = "Salvamiento manual", refreshNotice = "Nada se escribe automáticamente. La verificación verde verifica config.lua y media.lua en SQL y actualiza inmediatamente la configuración del servidor activo, el cliente, los medios y la interfaz de usuario.", fieldCount = "Campos {count}", secretConfigured = "Configuración secreta · introducir un reemplazo", invalidValue = "Comprueba el valor de la tabla o número resaltado.", saved = "Configuración SQL guardada y aplicada.", descriptions = { featureToggle = "Enciende o apaga {name}.", boolean = "Controla si {name} está permitido.", number = "Establece el valor numérico para {name}.", text = "Establece el valor de texto utilizado para {name}.", optionalText = "Establece el valor opcional para {name}; apague para desactivarlo.", list = "Gestiona todas las entradas utilizadas para {name}.", table = "Agrupa las configuraciones relacionadas para {name}.", credential = "Almacena la credencial protegida utilizada por {name}.", url = "Establece la URL o el punto final utilizado por {name}.", hosts = "Define qué dominios están permitidos para {name}.", milliseconds = "Establece el tiempo de {name} en milisegundos.", seconds = "Establece el tiempo de {name} en segundos.", rateLimit = "Limita la cantidad de acciones {name} permitidas por minuto.", byteLimit = "Establece el tamaño máximo de datos permitido para {name}.", textLimit = "Establece la longitud máxima de texto permitida para {name}.", distance = "Establece la distancia mundial utilizada para {name}.", coordinates = "Establece las coordenadas del mundo o la orientación para {name}.", gameAsset = "Establece el modelo GTA o el accesorio utilizado para {name}.", animation = "Establece el activo de animación utilizado para {name}.", access = "Define los puestos de trabajo, los grupos o el nivel de permiso para {name}.", integration = "Selecciona el marco o proveedor conectado para {name}.", path = "Establece la ruta de almacenamiento o recurso utilizada para {name}.", color = "Establece el color de interfaz utilizado para {name}.", displayText = "Establece el texto que se muestra a los jugadores para {name}.", phoneNumber = "Establece el número de teléfono o servicio utilizado para {name}.", routing = "Controla cómo se envía las solicitudes entrantes para {name}.", command = "Establece el comando de chat utilizado para abrir o ejecutar {name}.", locale = "Selecciona el idioma utilizado para {name}.", debug = "Controla el diagnóstico detallado de {name}.", mediaQuality = "Establece la calidad o volumen de los medios utilizados para {name}.", amount = "Establece la cantidad máxima o mostrada para {name}." }, table = { list = "Lista", table = "Mesa de llaves", vector = "Vector", entry = "Entrada", general = "Generales", addRow = "Añadir fila", addField = "Añadir campo", remove = "Eliminar", emptyList = "Aún no hay filas. Agregue la primera fila con más.", emptyTable = "Aún no hay campos. Añade la primera llave de abajo.", keyPlaceholder = "Nueva llave", convertToList = "Lista de uso", convertToMap = "Usa la tabla de teclas digitalizada", convertToTable = "Usa la tabla de teclas", types = { string = "El texto", number = "Número", boolean = "Cambiador", list = "Lista", table = "Cuadro" } } }, configurator = { context = "Configuración de tiempo de ejecución", eyebrow = "Herramienta del sistema", sections = "Configuración", search = "Configuración de búsqueda o caminos", configScope = "config.lua", mediaScope = "media.lua", noResults = "Ninguna configuración correspondiente", loading = "Cargar la configuración de SQL...", title = "Configuración de teléfono", body = "Gestionar la configuración de teléfono y medios desde el espacio de trabajo de administrador protegido.", disabledTitle = "Configuración SQL no está activa", disabledBody = "Habilitar el configurador al principio de config.lua y reiniciar sky_phone. Hasta entonces, los valores del archivo permanecen activos y la edición está bloqueada.", manualSave = "Salvamiento manual", refreshNotice = "Nada se escribe automáticamente. La verificación verde verifica config.lua y media.lua en SQL y actualiza inmediatamente la configuración del servidor activo, el cliente, los medios y la interfaz de usuario.", fieldCount = "Campos {count}", secretConfigured = "Configuración secreta · introducir un reemplazo", invalidValue = "Comprueba el valor de la tabla o número resaltado.", saved = "Configuración SQL guardada y aplicada.", descriptions = { featureToggle = "Enciende o apaga {name}.", boolean = "Controla si {name} está permitido.", number = "Establece el valor numérico para {name}.", text = "Establece el valor de texto utilizado para {name}.", optionalText = "Establece el valor opcional para {name}; apague para desactivarlo.", list = "Gestiona todas las entradas utilizadas para {name}.", table = "Agrupa las configuraciones relacionadas para {name}.", credential = "Almacena la credencial protegida utilizada por {name}.", url = "Establece la URL o el punto final utilizado por {name}.", hosts = "Define qué dominios están permitidos para {name}.", milliseconds = "Establece el tiempo de {name} en milisegundos.", seconds = "Establece el tiempo de {name} en segundos.", rateLimit = "Limita la cantidad de acciones {name} permitidas por minuto.", byteLimit = "Establece el tamaño máximo de datos permitido para {name}.", textLimit = "Establece la longitud máxima de texto permitida para {name}.", distance = "Establece la distancia mundial utilizada para {name}.", coordinates = "Establece las coordenadas del mundo o la orientación para {name}.", gameAsset = "Establece el modelo GTA o el accesorio utilizado para {name}.", animation = "Establece el activo de animación utilizado para {name}.", access = "Define los puestos de trabajo, los grupos o el nivel de permiso para {name}.", integration = "Selecciona el marco o proveedor conectado para {name}.", path = "Establece la ruta de almacenamiento o recurso utilizada para {name}.", color = "Establece el color de interfaz utilizado para {name}.", displayText = "Establece el texto que se muestra a los jugadores para {name}.", phoneNumber = "Establece el número de teléfono o servicio utilizado para {name}.", routing = "Controla cómo se envía las solicitudes entrantes para {name}.", command = "Establece el comando de chat utilizado para abrir o ejecutar {name}.", locale = "Selecciona el idioma utilizado para {name}.", debug = "Controla el diagnóstico detallado de {name}.", mediaQuality = "Establece la calidad o volumen de los medios utilizados para {name}.", amount = "Establece la cantidad máxima o mostrada para {name}." }, table = { list = "Lista", table = "Mesa de llaves", vector = "Vector", entry = "Entrada", general = "Generales", addRow = "Añadir fila", addField = "Añadir campo", remove = "Eliminar", emptyList = "Aún no hay filas. Agregue la primera fila con más.", emptyTable = "Aún no hay campos. Añade la primera llave de abajo.", keyPlaceholder = "Nueva llave", convertToList = "Lista de uso", convertToMap = "Usa la tabla de teclas digitalizada", convertToTable = "Usa la tabla de teclas", types = { string = "El texto", number = "Número", boolean = "Cambiador", list = "Lista", table = "Cuadro" } } },
players = { eyebrow = "sesiones activas", title = "Jugadores en línea", online = "En línea ahora", empty = "No se encontraron jugadores", emptyBody = "Ajusta la búsqueda o actualiza la lista de jugadores en vivo." }, players = { eyebrow = "sesiones activas", title = "Jugadores en línea", online = "En línea ahora", empty = "No se encontraron jugadores", emptyBody = "Ajusta la búsqueda o actualiza la lista de jugadores en vivo." },
search = { players = "Nombre, identificación, trabajo o número de búsqueda", apps = "Aplicaciones de búsqueda", clear = "Limpiar búsqueda" }, search = { players = "Nombre, identificación, trabajo o número de búsqueda", apps = "Aplicaciones de búsqueda", clear = "Limpiar búsqueda" },
@@ -2139,7 +2140,6 @@ Locales["es"] = {
} }
Locales["es"].Nui.AdminPanel.configurator.table.subtabs = { Locales["es"].Nui.AdminPanel.configurator.table.subtabs = {
AdminGroups = "Grupos de administradores",
Dictionaries = "Diccionarios", Dictionaries = "Diccionarios",
Clips = "Los clips", Clips = "Los clips",
Transforms = "Transformaciones", Transforms = "Transformaciones",
@@ -2176,30 +2176,3 @@ Locales["es"].Nui.AdminPanel.configurator.table.subtabs = {
AllowedJobs = "Trabajos permitidos", AllowedJobs = "Trabajos permitidos",
Websites = "Las páginas web", Websites = "Las páginas web",
} }
Locales["es"].Nui.AdminPanel.appearance.title = "Interfaz"
Locales["es"].Nui.AdminPanel.appearance.body = "Personaliza los colores y la tipografía en el espacio de trabajo del administrador."
Locales["es"].Nui.AdminPanel.appearance.controls = {
customColor = "Color personalizado",
customColorBody = "Seleccione cualquier acento RGB o ingrese sus valores de canal.",
red = "R",
green = "G",
blue = "EN EL CASO DE B",
hex = "El color HEX",
value = "el valor",
fontFamily = "Familia de fuentes",
fontFamilyBody = "Seleccione la tipografía utilizada por el panel de administración completo.",
fontSize = "Tamaño de fuente",
fontSizeBody = "Escala el texto y los controles para una fácil legibilidad.",
fonts = {
inter = "Inter",
system = "El sistema",
classic = "Clásico",
verdana = "Verdana",
tahoma = "Tahoma",
trebuchet = "Arbúsqueda",
georgia = "Georgia",
mono = "Monospacio",
},
}
+10 -4
View File
@@ -2,19 +2,25 @@
Sky Phone media configuration Sky Phone media configuration
This file is loaded on the server only. Keep API keys private. When the This file is loaded on the server only. Keep API keys private. When the
phone configurator is enabled in config.lua, these values are first-run phone configurator is enabled in config.lua, the active media configuration
defaults and the active media configuration is loaded from SQL. is loaded from SQL and frontend builds copy the shipped values from this
file into source/shared/config_default.lua.
]] ]]
Config.Media = { Config.Media = {
GiphyApiKey = "", -- Paste the GIPHY API key here. -- Create or sign in to an account at https://developers.giphy.com/, open
-- the Developer Dashboard, choose "Create an API Key", and paste it here.
GiphyApiKey = "",
GifPageSize = 24, GifPageSize = 24,
GifRating = "pg-13", GifRating = "pg-13",
UrlMaxLength = 2048, UrlMaxLength = 2048,
AllowedGifHosts = { "giphy.com" }, AllowedGifHosts = { "giphy.com" },
-- Create or sign in at https://fivemanage.com/, create or select your team,
-- then open Dashboard > Tokens and create a token with Media access. Keep
-- this server-only token private and paste it here.
FiveManage = { FiveManage = {
ApiKey = "", -- Paste a newly generated FiveManage V3 Media API token here. ApiKey = "",
BaseUrl = "https://api.fivemanage.com/api/v3/file", BaseUrl = "https://api.fivemanage.com/api/v3/file",
RequestTimeoutMs = 10000, RequestTimeoutMs = 10000,
UploadTimeoutMs = 25000, UploadTimeoutMs = 25000,
+2 -1
View File
@@ -6,7 +6,7 @@ use_experimental_fxv2_oal 'yes'
author 'Sky-Systems' author 'Sky-Systems'
description 'Sky Phone' description 'Sky Phone'
version '0.2.6' version '0.2.8'
provide 'lb-phone' provide 'lb-phone'
provide '17mov_Phone' provide '17mov_Phone'
@@ -79,6 +79,7 @@ server_scripts {
'config/locales/en.lua', 'config/locales/en.lua',
'config/locales/de.lua', 'config/locales/de.lua',
'config/locales/es.lua', 'config/locales/es.lua',
'source/shared/config_default.lua',
'source/server/nui_build_check.lua', 'source/server/nui_build_check.lua',
'source/server/update_check.lua', 'source/server/update_check.lua',
'source/bridge/server/database.lua', 'source/bridge/server/database.lua',
+9
View File
@@ -11,6 +11,15 @@ local provider_aliases = {
local function resolve_provider() local function resolve_provider()
local configured = tostring(Config.Calls.VoiceProvider or "") 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 selected = provider_aliases[configured] or configured
local resource_name = provider_resources[selected] local resource_name = provider_resources[selected]
if resource_name and GetResourceState(resource_name) == "started" then if resource_name and GetResourceState(resource_name) == "started" then
@@ -19,3 +19,23 @@ Bridge.Framework.Name = configured_framework
function Bridge.Framework.GetName() function Bridge.Framework.GetName()
return Bridge.Framework.Name return Bridge.Framework.Name
end end
function Bridge.Framework.HasPermission(source, permission)
if type(permission) ~= "string" or permission == "" then
error("[sky_phone] Permission identifiers must be non-empty strings.")
end
local groups = Config.CommandPermissions[permission]
if type(groups) ~= "table" or #groups == 0 then
local message = "[sky_phone] Config.CommandPermissions.%s must contain at least one group."
error(message:format(permission))
end
for index, group in ipairs(groups) do
if type(group) ~= "string" or group == "" then
local message = "[sky_phone] Config.CommandPermissions.%s[%s] must be a non-empty string."
error(message:format(permission, index))
end
end
return Bridge.Framework.HasAdminGroup(source, groups)
end
@@ -24,6 +24,13 @@ function Bridge.Framework.HasAdminGroup(source, groups)
if not player then if not player then
return false return false
end end
for _, group in ipairs(groups) do
if IsPlayerAceAllowed(tostring(source), group) then
return true
end
end
return exports.qbx_core:HasGroup(source, groups) return exports.qbx_core:HasGroup(source, groups)
end end
+21 -4
View File
@@ -61,13 +61,30 @@ end
Bridge.Inventory.Name = configured_inventory Bridge.Inventory.Name = configured_inventory
if selected_adapter.metadata == false then local metadata_free_inventory = selected_adapter.metadata == false
if 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.") local function enforce_metadata_compatibility()
:format(configured_inventory) 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
end end
enforce_metadata_compatibility()
AddEventHandler("sky_phone:configurator:serverUpdated", enforce_metadata_compatibility)
function Bridge.Inventory.NormalizeItem(item, metadata_field, fallback_slot) function Bridge.Inventory.NormalizeItem(item, metadata_field, fallback_slot)
if type(item) ~= "table" then if type(item) ~= "table" then
return nil return nil
@@ -23,8 +23,8 @@ end
function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata) function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
local matches = {} local matches = {}
for index, item in pairs(inventory:SearchInventory(source, item_name, metadata) or {}) do for _, slot_id in ipairs(inventory:GetSlotIdsWithItem(source, item_name, metadata) or {}) do
local normalized = normalize(item, tonumber(index) or index) local normalized = Bridge.Inventory.GetSlot(source, slot_id)
if normalized and normalized.name == item_name if normalized and normalized.name == item_name
and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then
matches[#matches + 1] = normalized matches[#matches + 1] = normalized
@@ -40,7 +40,7 @@ function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
end end
local requested_metadata = type(metadata) == "table" and metadata or {} local requested_metadata = type(metadata) == "table" and metadata or {}
if inventory:SetItemMetadata(source, slot.slot, requested_metadata) ~= true then if inventory:SetItemMetadata(source, slot.slot, requested_metadata) == false then
return false return false
end end
local updated = Bridge.Inventory.GetSlot(source, slot.slot) local updated = Bridge.Inventory.GetSlot(source, slot.slot)
+9
View File
@@ -41,6 +41,15 @@ end
local function resolve_call_provider() local function resolve_call_provider()
local configured = tostring(Config.Calls.VoiceProvider or "") 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 selected = call_provider_aliases[configured] or configured
local resource_name = call_provider_resources[selected] local resource_name = call_provider_resources[selected]
if resource_name and GetResourceState(resource_name) == "started" then if resource_name and GetResourceState(resource_name) == "started" then
+56 -13
View File
@@ -75,6 +75,21 @@ Bridge.Debug("debug", "[sky_phone] Client script initialized.", { always = true
local locale, locale_name = SkyPhoneLocales.Resolve(Config.Bridge.Locale) 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() local function send_admin_panel_open()
SendNUIMessage({ SendNUIMessage({
type = "admin:open", type = "admin:open",
@@ -136,6 +151,7 @@ AddEventHandler("sky_phone:configurator:updated", function()
refresh_admin_command_suggestion() refresh_admin_command_suggestion()
SkyPhoneApps.SendCatalog() SkyPhoneApps.SendCatalog()
if is_open and device_payload then if is_open and device_payload then
apply_disabled_apps(device_payload)
device_payload.lang = locale_name device_payload.lang = locale_name
device_payload.locales = locale.Nui device_payload.locales = locale.Nui
device_payload.fallbackLocales = Locales.en.Nui device_payload.fallbackLocales = Locales.en.Nui
@@ -175,6 +191,22 @@ local function close_phone(close_device_session)
end end
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) local function toggle_phone(open, no_focus)
if open ~= nil and type(open) ~= "boolean" then if open ~= nil and type(open) ~= "boolean" then
Bridge.Debug("error", "[sky_phone] Rejected invalid phone toggle state.") 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) should_open = not (is_open or open_requested)
end end
if not should_open then if not should_open then
if open == nil and open_requested and not is_open then
return true
end
close_phone() close_phone()
return true return true
end end
@@ -202,12 +237,7 @@ local function toggle_phone(open, no_focus)
return true return true
end end
local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {}) return request_phone_open("sky_phone:device:open-request")
if type(result) ~= "table" or result.success ~= true then
open_without_focus = false
return false
end
return true
end end
SkyPhoneClient.Toggle = toggle_phone SkyPhoneClient.Toggle = toggle_phone
@@ -217,13 +247,16 @@ AddEventHandler("sky_phone:client:forceClose", function()
end) end)
local function run_development_command() local function run_development_command()
if is_open or open_requested then if is_open then
close_phone() close_phone()
return return
end end
if open_requested then
return
end
open_without_focus = false open_without_focus = false
Bridge.Callbacks.Trigger("sky_phone:device:development-open", {}) request_phone_open("sky_phone:device:development-open")
end end
refresh_development_command = function() refresh_development_command = function()
@@ -256,13 +289,16 @@ end
refresh_development_command() refresh_development_command()
local function run_phone_toggle() local function run_phone_toggle()
if is_open or open_requested then if is_open then
close_phone() close_phone()
return return
end end
if open_requested then
return
end
open_without_focus = false open_without_focus = false
Bridge.Callbacks.Trigger("sky_phone:device:open-request", {}) request_phone_open("sky_phone:device:open-request")
end end
RegisterCommand("sky_phone_live_activity_open", function() RegisterCommand("sky_phone_live_activity_open", function()
@@ -271,8 +307,7 @@ RegisterCommand("sky_phone_live_activity_open", function()
end end
open_home_requested = true open_home_requested = true
local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {}) if not request_phone_open("sky_phone:device:open-request") then
if not result or not result.success then
open_home_requested = false open_home_requested = false
end end
end, false) end, false)
@@ -459,6 +494,10 @@ end)
RegisterNetEvent("sky_phone:device:open", function(data) RegisterNetEvent("sky_phone:device:open", function(data)
if type(data) ~= "table" or type(data.device) ~= "table" or type(data.device.imei) ~= "string" then 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.") Bridge.Debug("error", "[sky_phone] Rejected invalid device open data.")
if not is_open then
open_requested = false
open_without_focus = false
end
return return
end end
Bridge.Debug( Bridge.Debug(
@@ -468,6 +507,7 @@ RegisterNetEvent("sky_phone:device:open", function(data)
tostring(data.account ~= nil), tostring(data.account ~= nil),
{ always = true } { always = true }
) )
apply_disabled_apps(data)
device_payload = data device_payload = data
update_equipped_phone_number(data) update_equipped_phone_number(data)
open_requested = true open_requested = true
@@ -484,6 +524,7 @@ RegisterNetEvent("sky_phone:device:updated", function(data)
Bridge.Debug("error", "[sky_phone] Rejected invalid device update data.") Bridge.Debug("error", "[sky_phone] Rejected invalid device update data.")
return return
end end
apply_disabled_apps(data)
device_payload = data device_payload = data
update_equipped_phone_number(data) update_equipped_phone_number(data)
SendNUIMessage({ type = "device:updated", data = data }) SendNUIMessage({ type = "device:updated", data = data })
@@ -499,8 +540,10 @@ RegisterNetEvent("sky_phone:device:invalidated", function()
end) end)
RegisterNetEvent("sky_phone:device:error", function(error_code) 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_without_focus = false
open_home_requested = false
end end
Bridge.Debug( Bridge.Debug(
"debug", "debug",
+28 -5
View File
@@ -98,7 +98,7 @@ end
local function require_admin(source, operation, maximum) local function require_admin(source, operation, maximum)
if not Config.AdminPanel.Enabled if not Config.AdminPanel.Enabled
or not Bridge.Framework.HasAdminGroup(source, Config.AdminPanel.AdminGroups) or not Bridge.Framework.HasPermission(source, "phonepanel")
then then
Bridge.Debug("warn", "[sky_phone] Rejected admin panel access from source %s.", tostring(source)) Bridge.Debug("warn", "[sky_phone] Rejected admin panel access from source %s.", tostring(source))
return nil, { success = false, error = "not_authorized" } return nil, { success = false, error = "not_authorized" }
@@ -121,7 +121,7 @@ local function run_admin_command(command_source)
TriggerClientEvent("sky_phone:admin:command-error", player_source, "disabled") TriggerClientEvent("sky_phone:admin:command-error", player_source, "disabled")
return return
end end
if not Bridge.Framework.HasAdminGroup(player_source, Config.AdminPanel.AdminGroups) then if not Bridge.Framework.HasPermission(player_source, "phonepanel") then
Bridge.Debug( Bridge.Debug(
"warn", "warn",
"[sky_phone] Rejected admin panel command from source %s.", "[sky_phone] Rejected admin panel command from source %s.",
@@ -373,6 +373,9 @@ end
local function app_metadata(app_id) local function app_metadata(app_id)
if BUILTIN_APPS[app_id] then if BUILTIN_APPS[app_id] then
if not SkyPhone.IsAppEnabled(app_id) then
return nil
end
return { return {
defaultInstalled = DEFAULT_INSTALLED_APPS[app_id] == true, defaultInstalled = DEFAULT_INSTALLED_APPS[app_id] == true,
removable = PROTECTED_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([[ local totals = Bridge.Database.Query([[
SELECT SELECT
(SELECT COUNT(*) FROM `sky_phone_devices`) AS `devices`, (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 { return {
success = true, success = true,
data = { data = {
players = players, players = players,
disabledApps = SkyPhone.GetDisabledApps(),
stats = { stats = {
online = #players, online = #players,
devices = tonumber(totals[1] and totals[1].devices) or 0, devices = tonumber(stats.devices) or 0,
accounts = tonumber(totals[1] and totals[1].accounts) 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(), audit = load_audit(),
}, },
+144 -122
View File
@@ -1368,8 +1368,6 @@ ensure_schema()
migrate_crypto_keys() migrate_crypto_keys()
initialize_markets() initialize_markets()
AddEventHandler("sky_phone:configurator:serverUpdated", initialize_markets)
local function reconcile_settlements(include_recent) 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 age_clause = include_recent and "" or " AND settlement.`updated_at` < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)"
local rows = Bridge.Database.Query([[ local rows = Bridge.Database.Query([[
@@ -1416,8 +1414,6 @@ local function reconcile_settlements(include_recent)
end end
end end
reconcile_settlements(true)
local function crypto_random_int(minimum, maximum, failure_message) local function crypto_random_int(minimum, maximum, failure_message)
local value = exports[GetCurrentResourceName()]:CryptoRandomInt(minimum, maximum) local value = exports[GetCurrentResourceName()]:CryptoRandomInt(minimum, maximum)
if type(value) ~= "number" then if type(value) ~= "number" then
@@ -1500,134 +1496,160 @@ local function advance_market_cycle(config, dynamics)
return dynamics.cycle_bias return dynamics.cycle_bias
end end
CreateThread(function() local scheduler_generation = 0
while true do
Wait(5 * 60 * 1000) local function start_crypto_schedulers()
reconcile_settlements(false) scheduler_generation = scheduler_generation + 1
if Config.Crypto.Enabled ~= true then
return
end end
end)
CreateThread(function() local generation = scheduler_generation
while true do reconcile_settlements(true)
local tick_seconds = crypto_random_int(
Config.Crypto.PriceTickMinimumSeconds, CreateThread(function()
Config.Crypto.PriceTickMaximumSeconds + 1, while scheduler_generation == generation and Config.Crypto.Enabled == true do
"[sky_phone] Crypto entropy provider did not return a market tick interval." Wait(5 * 60 * 1000)
) if scheduler_generation ~= generation or Config.Crypto.Enabled ~= true then
Wait(tick_seconds * 1000) break
with_exchange_lock(function() end
local market_count = crypto_random_int( reconcile_settlements(false)
Config.Crypto.MarketsPerTickMinimum, end
Config.Crypto.MarketsPerTickMaximum + 1, end)
"[sky_phone] Crypto entropy provider did not return a market count."
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() Wait(tick_seconds * 1000)
local changed_markets = {} if scheduler_generation ~= generation or Config.Crypto.Enabled ~= true then
market_count = math.min(market_count, #market_order) 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 for offset = 0, market_count - 1 do
local order_index = ((market_cursor + offset - 1) % #market_order) + 1 local order_index = ((market_cursor + offset - 1) % #market_order) + 1
local market_id = market_order[order_index] local market_id = market_order[order_index]
local config = markets[market_id] local config = markets[market_id]
local row = Bridge.Database.Query( local row = Bridge.Database.Query(
"SELECT `price`,`version`,`status` FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1", "SELECT `price`,`version`,`status` FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1",
{ market_id } { market_id }
)[1] )[1]
if row and row.status == "active" then if row and row.status == "active" then
local price = tonumber(row.price) or config.InitialPrice local price = tonumber(row.price) or config.InitialPrice
local impulse = crypto_random_int( local impulse = crypto_random_int(
-config.VolatilityBasisPoints, -config.VolatilityBasisPoints,
config.VolatilityBasisPoints + 1, config.VolatilityBasisPoints + 1,
"[sky_phone] Crypto entropy provider did not return a market movement." "[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."
) )
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, 0,
2, 10000,
"[sky_phone] Crypto entropy provider did not return a market shock direction." "[sky_phone] Crypto entropy provider did not return a market shock roll."
) )
local direction = direction_roll == 0 and -1 or 1 local dynamics = market_dynamics[market_id] or {
shock = direction * config.VolatilityBasisPoints * multiplier momentum = 0,
end 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 local deviation = math.floor(
* Config.Crypto.MaximumMovementMultiplier (config.InitialPrice - price) * 10000 / config.InitialPrice
local movement = impulse + dynamics.momentum + cycle_bias )
+ global_market_trend + reversion + shock local reversion = truncate_integer(
movement = math.max(-maximum_movement, math.min(maximum_movement, movement)) deviation * Config.Crypto.MeanReversionBasisPoints / 10000
if movement > 0 then )
movement = math.floor(movement / Config.Crypto.TickMovementDivisor) local shock = 0
elseif movement < 0 then if shock_roll < Config.Crypto.MarketShockChanceBasisPoints then
movement = math.ceil(movement / Config.Crypto.TickMovementDivisor) local multiplier = crypto_random_int(
end Config.Crypto.MarketShockMinimumMultiplier,
local next_price = math.floor(price * (10000 + movement) / 10000) Config.Crypto.MarketShockMaximumMultiplier + 1,
if next_price == price and movement ~= 0 then "[sky_phone] Crypto entropy provider did not return a market shock multiplier."
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 }) 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 end
end market_cursor = ((market_cursor + market_count - 1) % #market_order) + 1
market_cursor = ((market_cursor + market_count - 1) % #market_order) + 1 if #changed_markets > 0 then
if #changed_markets > 0 then TriggerClientEvent("sky_phone:crypto:changed", -1, {
TriggerClientEvent("sky_phone:crypto:changed", -1, { markets = market_dtos(changed_markets),
markets = market_dtos(changed_markets), updatedAt = os.time() * 1000,
updatedAt = os.time() * 1000, })
}) end
end end)
end) end
end end)
end) end
local function refresh_crypto_runtime()
initialize_markets()
start_crypto_schedulers()
end
AddEventHandler("sky_phone:configurator:serverUpdated", refresh_crypto_runtime)
start_crypto_schedulers()
end) end)
+7 -1
View File
@@ -931,10 +931,16 @@ end)
CreateThread(function() CreateThread(function()
while true do while true do
Wait(Config.DarkChat.CleanupIntervalSeconds * 1000) Wait(Config.DarkChat.CleanupIntervalSeconds * 1000)
local batch_size = math.max(
1,
math.floor(tonumber(Config.DarkChat.CleanupBatchSize) or 250)
)
Bridge.Database.Query([[ Bridge.Database.Query([[
DELETE FROM `sky_phone_darkchat_messages` DELETE FROM `sky_phone_darkchat_messages`
WHERE `expires_at` IS NOT NULL AND `expires_at` <= CURRENT_TIMESTAMP WHERE `expires_at` IS NOT NULL AND `expires_at` <= CURRENT_TIMESTAMP
]], {}) ORDER BY `expires_at`, `id`
LIMIT ?
]], { batch_size })
end end
end) end)
+1 -1
View File
@@ -943,7 +943,7 @@ local function run_verify_command(source, arguments)
notificationType = notification_type, notificationType = notification_type,
}) })
end end
if source ~= 0 and not Bridge.Framework.HasAdminGroup(source, Config.FlipTok.AdminGroups) then if source ~= 0 and not Bridge.Framework.HasPermission(source, "fliptokverify") then
send_command_feedback(command_locale.noPermission, "error") send_command_feedback(command_locale.noPermission, "error")
Bridge.Debug( Bridge.Debug(
"warn", "warn",
+52 -1
View File
@@ -54,12 +54,28 @@ Bridge.Debug("debug", "[sky_phone] Server initialization started after database
SkyPhone = {} 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 sessions = {}
local preferred_device_imeis = {} local preferred_device_imeis = {}
local equipped_phone_numbers = {} local equipped_phone_numbers = {}
local equipped_phone_identifiers = {} local equipped_phone_identifiers = {}
local equipped_phone_sources = {} local equipped_phone_sources = {}
local operation_attempts = {} local operation_attempts = {}
local phone_open_in_progress = {}
local character_device_cache = {} local character_device_cache = {}
local function trim(value) local function trim(value)
@@ -548,6 +564,7 @@ local function bootstrap(source, security, security_loaded)
return { return {
token = session.token, token = session.token,
disabledApps = SkyPhone.GetDisabledApps(),
phoneNumberFormat = { phoneNumberFormat = {
length = Config.Sim.NumberLength, length = Config.Sim.NumberLength,
groups = Config.Sim.NumberGroups, groups = Config.Sim.NumberGroups,
@@ -839,7 +856,7 @@ function SkyPhone.RefreshDevice(imei)
end end
end end
local function open_phone(source, used_item) local function perform_phone_open(source, used_item)
local opened_at = GetGameTimer() local opened_at = GetGameTimer()
Bridge.Debug( Bridge.Debug(
"debug", "debug",
@@ -926,6 +943,38 @@ local function open_phone(source, used_item)
return true return true
end 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 phone_open_handler = open_phone
flush_pending_phone_opens() flush_pending_phone_opens()
@@ -1028,6 +1077,7 @@ AddEventHandler("playerDropped", function()
sessions[source] = nil sessions[source] = nil
discard_equipped_phone_number(source) discard_equipped_phone_number(source)
operation_attempts[source] = nil operation_attempts[source] = nil
phone_open_in_progress[source] = nil
character_device_cache[source] = nil character_device_cache[source] = nil
preferred_device_imeis[source] = nil preferred_device_imeis[source] = nil
end) end)
@@ -1036,6 +1086,7 @@ AddEventHandler("onResourceStop", function(resource_name)
if resource_name == GetCurrentResourceName() then if resource_name == GetCurrentResourceName() then
sessions = {} sessions = {}
operation_attempts = {} operation_attempts = {}
phone_open_in_progress = {}
character_device_cache = {} character_device_cache = {}
preferred_device_imeis = {} preferred_device_imeis = {}
equipped_phone_numbers = {} equipped_phone_numbers = {}
+46 -16
View File
@@ -17,9 +17,25 @@ local updated_at
local updated_by_name local updated_by_name
local is_sequence local is_sequence
local FIXED_CONFIG_PATHS = {
CommandPermissions = true,
["AdminPanel.AdminGroups"] = true,
["TestData.AdminGroups"] = true,
["FlipTok.AdminGroups"] = true,
["Picstagram.AdminGroups"] = true,
}
if configurator_enabled then
print(
"[sky_phone] Phone Configurator enabled: file-based settings from config.lua " ..
"(except Config.CommandPermissions) and media.lua are disabled; SQL configuration is active."
)
end
local CLIENT_CONFIG_KEYS = { local CLIENT_CONFIG_KEYS = {
AdminPanel = true, AdminPanel = true,
Animations = true, Animations = true,
Apps = true,
Banking = true, Banking = true,
Billing = true, Billing = true,
Bridge = true, Bridge = true,
@@ -252,7 +268,7 @@ local function upgrade_legacy_map(defaults, saved)
return { __skyType = "map", entries = entries } return { __skyType = "map", entries = entries }
end end
local function merge_values(defaults, saved, path) local function merge_values(defaults, saved, path, excluded_paths)
path = path or "" path = path or ""
if type(defaults) ~= "table" or type(saved) ~= "table" then if type(defaults) ~= "table" or type(saved) ~= "table" then
return copy_value(saved) return copy_value(saved)
@@ -278,7 +294,12 @@ local function merge_values(defaults, saved, path)
key = entry.key, key = entry.key,
keyType = entry.keyType, keyType = entry.keyType,
value = saved_entry value = saved_entry
and merge_values(entry.value, saved_entry.value, path .. "." .. tostring(entry.key)) and merge_values(
entry.value,
saved_entry.value,
path .. "." .. tostring(entry.key),
excluded_paths
)
or copy_value(entry.value), or copy_value(entry.value),
} }
included[identity] = true included[identity] = true
@@ -298,7 +319,7 @@ local function merge_values(defaults, saved, path)
local merged = copy_value(saved) local merged = copy_value(saved)
for index, child in ipairs(defaults) do for index, child in ipairs(defaults) do
merged[index] = saved[index] ~= nil merged[index] = saved[index] ~= nil
and merge_values(child, saved[index], path .. "." .. tostring(index)) and merge_values(child, saved[index], path .. "." .. tostring(index), excluded_paths)
or copy_value(child) or copy_value(child)
end end
return merged return merged
@@ -309,11 +330,13 @@ local function merge_values(defaults, saved, path)
local merged = copy_value(defaults) local merged = copy_value(defaults)
for key, child in pairs(saved) do for key, child in pairs(saved) do
if merged[key] ~= nil then local child_path = path == "" and tostring(key) or (path .. "." .. tostring(key))
local child_path = path == "" and tostring(key) or (path .. "." .. tostring(key)) if not excluded_paths or not excluded_paths[child_path] then
merged[key] = merge_values(merged[key], child, child_path) if merged[key] ~= nil then
else merged[key] = merge_values(merged[key], child, child_path, excluded_paths)
merged[key] = copy_value(child) else
merged[key] = copy_value(child)
end
end end
end end
return merged return merged
@@ -356,10 +379,12 @@ local function apply_runtime_configuration()
local runtime_config = deserialize_value(stored_config) local runtime_config = deserialize_value(stored_config)
for key, value in pairs(runtime_config) do for key, value in pairs(runtime_config) do
if type(Config[key]) == "table" and type(value) == "table" then if key ~= "CommandPermissions" then
apply_runtime_table(Config[key], value) if type(Config[key]) == "table" and type(value) == "table" then
else apply_runtime_table(Config[key], value)
Config[key] = value else
Config[key] = value
end
end end
end end
local runtime_media = deserialize_value(stored_media) local runtime_media = deserialize_value(stored_media)
@@ -1032,7 +1057,12 @@ local function read_stored_row()
end end
local function apply_stored_row(row) local function apply_stored_row(row)
stored_config = merge_values(default_config, decode_payload(row.config_payload, "config"), "") stored_config = merge_values(
default_config,
decode_payload(row.config_payload, "config"),
"",
FIXED_CONFIG_PATHS
)
stored_media = merge_values(default_media, decode_payload(row.media_payload, "media"), "") stored_media = merge_values(default_media, decode_payload(row.media_payload, "media"), "")
revision = tonumber(row.revision) or 1 revision = tonumber(row.revision) or 1
updated_at = row.updated_at updated_at = row.updated_at
@@ -1040,12 +1070,12 @@ local function apply_stored_row(row)
end end
default_config = {} default_config = {}
for key, value in pairs(Config) do for key, value in pairs(ConfigDefaults) do
if key ~= "Media" and key ~= "PhoneConfigurator" then if key ~= "Media" and key ~= "PhoneConfigurator" and key ~= "CommandPermissions" then
default_config[key] = serialize_value(value) default_config[key] = serialize_value(value)
end end
end end
default_media = serialize_value(Config.Media) default_media = serialize_value(ConfigDefaults.Media)
Bridge.Database.Migrate("sky_phone_configurator", { SkyPhoneConfiguratorSchema }) Bridge.Database.Migrate("sky_phone_configurator", { SkyPhoneConfiguratorSchema })
Bridge.Database.Query(([[ Bridge.Database.Query(([[
+2 -2
View File
@@ -319,7 +319,7 @@ local function create_activity(recipient_id, actor_id, kind, post_id)
end end
local function is_admin(source) local function is_admin(source)
return Bridge.Framework.HasAdminGroup(source, Config.Picstagram.AdminGroups) return Bridge.Framework.HasPermission(source, "picstagramadmin")
end end
Bridge.Callbacks.Register("sky_phone:picstagram:register", function(source, data) Bridge.Callbacks.Register("sky_phone:picstagram:register", function(source, data)
@@ -1464,7 +1464,7 @@ local function run_verify_command(source, args)
notificationType = notification_type, notificationType = notification_type,
}) })
end end
if source ~= 0 and not Bridge.Framework.HasAdminGroup(source, Config.Picstagram.AdminGroups) then if source ~= 0 and not Bridge.Framework.HasPermission(source, "picstagramverify") then
send_command_feedback(command_locale.noPermission, "error") send_command_feedback(command_locale.noPermission, "error")
Bridge.Debug( Bridge.Debug(
"warn", "warn",
+1 -1
View File
@@ -1045,7 +1045,7 @@ local function run_test_data_command(source)
Bridge.Debug("warn", "[sky_phone] The test data command must be run by an in-game player.") Bridge.Debug("warn", "[sky_phone] The test data command must be run by an in-game player.")
return return
end end
if Config.TestData.AdminOnly and not Bridge.Framework.HasAdminGroup(source, Config.TestData.AdminGroups) then if Config.TestData.AdminOnly and not Bridge.Framework.HasPermission(source, "phonetestdata") then
Bridge.Debug("warn", "[sky_phone] Source %s attempted to run the restricted test data command.", tostring(source)) Bridge.Debug("warn", "[sky_phone] Source %s attempted to run the restricted test data command.", tostring(source))
TriggerClientEvent("sky_phone:testdata:feedback", source, false) TriggerClientEvent("sky_phone:testdata:feedback", source, false)
return return
File diff suppressed because it is too large Load Diff
+64
View File
@@ -0,0 +1,64 @@
Config = {
Bridge = {
Framework = "qbox",
},
CommandPermissions = {
phonepanel = { "god", "superadmin", "admin" },
},
}
Bridge = {
Framework = {},
}
function GetResourceState(resource)
assert(resource == "qbx_core")
return "started"
end
function GetPlayers()
return { "1" }
end
local ace_permissions = {}
local framework_permission = false
function IsPlayerAceAllowed(source, permission)
assert(source == "1")
return ace_permissions[permission] == true
end
exports = {
qbx_core = {
GetPlayer = function(_, source)
if tonumber(source) ~= 1 then
return nil
end
return {
PlayerData = {
citizenid = "test-citizen",
},
}
end,
HasGroup = function(_, source, groups)
assert(source == 1)
assert(groups == Config.CommandPermissions.phonepanel)
return framework_permission
end,
},
}
dofile("sky_phone/source/bridge/server/framework.lua")
dofile("sky_phone/source/bridge/server/frameworks/qbox.lua")
ace_permissions.admin = true
assert(Bridge.Framework.HasPermission(1, "phonepanel"), "Qbox ACE permissions must grant access")
ace_permissions.admin = false
framework_permission = true
assert(Bridge.Framework.HasPermission(1, "phonepanel"), "Qbox framework groups must remain supported")
local success = pcall(Bridge.Framework.HasPermission, 1, "missing")
assert(not success, "Missing fixed permission definitions must fail loudly")
print("framework permission tests passed")
+18 -5
View File
@@ -1,3 +1,9 @@
local event_handlers = {}
AddEventHandler = function(event_name, callback)
event_handlers[event_name] = callback
end
local function reset_bridge(inventory_name, unique_phones, sim_cards_enabled) local function reset_bridge(inventory_name, unique_phones, sim_cards_enabled)
Config = { Config = {
Bridge = { Bridge = {
@@ -193,11 +199,18 @@ exports = {
} }
load_inventory_contract("sky_phone/source/bridge/server/inventory/esx.lua") load_inventory_contract("sky_phone/source/bridge/server/inventory/esx.lua")
local ok, configuration_error = pcall(Bridge.Inventory.RegisterUsableItem, "phone", function() assert(Config.Phone.Unique == false)
end) assert(Config.Sim.Enabled == false)
assert(not ok) assert(Bridge.Inventory.ConfigurationError == nil)
assert(configuration_error:find("Config.Phone.Unique = false", 1, true))
assert(configuration_error:find("Config.Sim.Enabled = false", 1, true)) Config.Phone.Unique = true
Config.Sim.Enabled = true
event_handlers["sky_phone:configurator:serverUpdated"]()
assert(Config.Phone.Unique == false)
assert(Config.Sim.Enabled == false)
assert(Bridge.Inventory.RegisterUsableItem("phone", function()
end))
local manifest_file = assert(io.open("sky_phone/fxmanifest.lua", "rb")) local manifest_file = assert(io.open("sky_phone/fxmanifest.lua", "rb"))
local manifest = manifest_file:read("*a") local manifest = manifest_file:read("*a")
+9 -3
View File
@@ -1,3 +1,6 @@
AddEventHandler = function()
end
local function reset_bridge(inventory_name, resource_name, inventory_export, framework_name, extra_exports) local function reset_bridge(inventory_name, resource_name, inventory_export, framework_name, extra_exports)
Config = { Config = {
Bridge = { Inventory = inventory_name }, Bridge = { Inventory = inventory_name },
@@ -156,13 +159,16 @@ function one:GetSlot(_, slot)
return one_items[slot] return one_items[slot]
end end
function one:SearchInventory() function one:GetSlotIdsWithItem(_, item_name, metadata)
return one_items assert(item_name == "phone")
if metadata and metadata.imei ~= one_items[7].metadata.imei then
return {}
end
return { 7 }
end end
function one:SetItemMetadata(_, slot, metadata) function one:SetItemMetadata(_, slot, metadata)
one_items[slot].metadata = metadata one_items[slot].metadata = metadata
return true
end end
function one:CanCarryItem() function one:CanCarryItem()
+1 -2
View File
@@ -25,7 +25,7 @@ Bridge = {
Debug = function() Debug = function()
end, end,
Framework = { Framework = {
HasAdminGroup = function() HasPermission = function()
return true return true
end, end,
}, },
@@ -42,7 +42,6 @@ Bridge = {
Config = { Config = {
AdminPanel = { AdminPanel = {
AdminGroups = { "admin" },
Command = "phoneadmin", Command = "phoneadmin",
Enabled = true, Enabled = true,
}, },