mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 09:13:24 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1da9fee997 | ||
|
|
49c5964f7e | ||
|
|
3a0a9c0093 | ||
|
|
8b4a32bfab | ||
|
|
cb6c94a7d8 | ||
|
|
2059e19e1c | ||
|
|
24ebe8c3ff | ||
|
|
99e943dbc5 | ||
|
|
bd9f4c2695 | ||
|
|
4fa4bf3c78 |
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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[] {
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
PHONE_APPS,
|
||||||
|
isExternalPhoneApp,
|
||||||
|
isLaunchablePhoneApp,
|
||||||
|
} from '@/config/apps'
|
||||||
|
|
||||||
|
const repositoryRoot = join(import.meta.dirname, '../..')
|
||||||
|
const source = (path: string) =>
|
||||||
|
readFileSync(join(repositoryRoot, path), 'utf8')
|
||||||
|
|
||||||
|
const app = source('frontend/src/App.vue')
|
||||||
|
const appStore = source('frontend/src/stores/app-store.ts')
|
||||||
|
const appStoreView = source('frontend/src/views/apps/AppStoreApp.vue')
|
||||||
|
const appWindow = source('frontend/src/views/PhoneAppWindow.vue')
|
||||||
|
const client = source('sky_phone/source/client/main.lua')
|
||||||
|
const config = source('sky_phone/config/config.lua')
|
||||||
|
const configDefault = source('sky_phone/source/shared/config_default.lua')
|
||||||
|
const crypto = source('sky_phone/source/server/crypto.lua')
|
||||||
|
const phone = source('sky_phone/source/server/phone.lua')
|
||||||
|
const router = source('frontend/src/router/index.ts')
|
||||||
|
|
||||||
|
describe('configured app availability contract', () => {
|
||||||
|
it('keeps every built-in app in the file and runtime defaults', () => {
|
||||||
|
const builtInAppIds = PHONE_APPS.filter(
|
||||||
|
(app) =>
|
||||||
|
isLaunchablePhoneApp(app) && !app.adminOnly && !isExternalPhoneApp(app),
|
||||||
|
).map((app) => app.id)
|
||||||
|
|
||||||
|
for (const appId of builtInAppIds) {
|
||||||
|
const entry = appId.includes('-')
|
||||||
|
? `["${appId}"] = true`
|
||||||
|
: `${appId} = true`
|
||||||
|
expect(config).toContain(entry)
|
||||||
|
expect(configDefault).toContain(entry)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends disabled apps and reapplies them to an open phone after live saves', () => {
|
||||||
|
expect(phone).toContain('disabledApps = SkyPhone.GetDisabledApps()')
|
||||||
|
expect(phone).toMatch(
|
||||||
|
/function SkyPhone\.GetDisabledApps\(\)[\s\S]*?enabled == false/,
|
||||||
|
)
|
||||||
|
expect(client).toMatch(
|
||||||
|
/configurator:updated[\s\S]*?apply_disabled_apps\(device_payload\)[\s\S]*?device:updated/,
|
||||||
|
)
|
||||||
|
expect(app).toContain(
|
||||||
|
'appStore.hydrate(payload.device?.data.apps?.payload, payload.disabledApps)',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes disabled apps from every launch path without changing device installs', () => {
|
||||||
|
expect(appStore).toContain('disabledApps: [] as LaunchablePhoneAppId[]')
|
||||||
|
expect(appStore).toContain('if (!this.isAvailable(appId)) return false')
|
||||||
|
expect(appStoreView).toContain('appStore.isAvailable(app.id)')
|
||||||
|
expect(router).toContain('useAppStoreStore().isInstalled(to.params.appId)')
|
||||||
|
expect(appWindow).toContain('appStore.isInstalled(app.id)')
|
||||||
|
expect(app).toMatch(
|
||||||
|
/isPhoneAppId\(currentAppId\)[\s\S]*?!appStore\.isInstalled\(currentAppId\)[\s\S]*?router\.push\('\/'\)/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects crypto operations after the app is disabled at runtime', () => {
|
||||||
|
expect(crypto).toMatch(
|
||||||
|
/local function require_phone\(source\)[\s\S]*?not Config\.Crypto\.Enabled or not SkyPhone\.IsAppEnabled\("crypto"\)/,
|
||||||
|
)
|
||||||
|
expect(crypto).toMatch(
|
||||||
|
/local function refresh_crypto_runtime\(\)[\s\S]*?not Config\.Crypto\.Enabled or not SkyPhone\.IsAppEnabled\("crypto"\)[\s\S]*?sessions = \{\}/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -54,6 +54,14 @@ const config = readFileSync(
|
|||||||
new URL('../../../sky_phone/config/config.lua', import.meta.url),
|
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',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
function source(path: string): string {
|
||||||
|
return readFileSync(new URL(path, import.meta.url), 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = source('../../sky_phone/config/config.lua')
|
||||||
|
const configDefault = source('../../sky_phone/source/shared/config_default.lua')
|
||||||
|
const framework = source('../../sky_phone/source/bridge/server/framework.lua')
|
||||||
|
const qbox = source('../../sky_phone/source/bridge/server/frameworks/qbox.lua')
|
||||||
|
const configurator = source(
|
||||||
|
'../../sky_phone/source/server/phone_configurator.lua',
|
||||||
|
)
|
||||||
|
const configuratorFixture = source('../testserver/configurator-fixture.cjs')
|
||||||
|
|
||||||
|
describe('fixed server permissions', () => {
|
||||||
|
it('defines every protected phone capability only in config.lua', () => {
|
||||||
|
expect(config).toContain('Config.CommandPermissions = {')
|
||||||
|
for (const permission of [
|
||||||
|
'phonepanel',
|
||||||
|
'phonetestdata',
|
||||||
|
'fliptokverify',
|
||||||
|
'picstagramverify',
|
||||||
|
'picstagramadmin',
|
||||||
|
]) {
|
||||||
|
expect(config).toMatch(new RegExp(`\\s${permission} = \\{`))
|
||||||
|
}
|
||||||
|
expect(config).not.toContain('AdminGroups =')
|
||||||
|
expect(configDefault).not.toContain('Config.PhoneConfigurator')
|
||||||
|
expect(configDefault).not.toContain('Config.CommandPermissions')
|
||||||
|
expect(configDefault).not.toContain('AdminGroups =')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps fixed permissions outside SQL and removes legacy group fields', () => {
|
||||||
|
expect(configurator).toContain('key ~= "CommandPermissions"')
|
||||||
|
expect(configurator).toContain('if key ~= "CommandPermissions" then')
|
||||||
|
expect(configuratorFixture).toContain('delete config.CommandPermissions')
|
||||||
|
for (const path of [
|
||||||
|
'AdminPanel.AdminGroups',
|
||||||
|
'TestData.AdminGroups',
|
||||||
|
'FlipTok.AdminGroups',
|
||||||
|
'Picstagram.AdminGroups',
|
||||||
|
]) {
|
||||||
|
expect(configurator).toContain(`["${path}"] = true`)
|
||||||
|
}
|
||||||
|
expect(configurator).toContain(
|
||||||
|
'SKY PHONE CONFIGURATION FILES ARE DISABLED',
|
||||||
|
)
|
||||||
|
expect(configurator).toContain(
|
||||||
|
'^1 Runtime settings from config.lua and media.lua are DISABLED.^0',
|
||||||
|
)
|
||||||
|
expect(configurator).toContain(
|
||||||
|
'^1 Configure all phone and media settings IN GAME through /phonepanel.^0',
|
||||||
|
)
|
||||||
|
expect(configurator).toContain(
|
||||||
|
'^1 Only Config.PhoneConfigurator.Enabled and Config.CommandPermissions remain file-based.^0',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('authorizes Qbox through ACE before retaining framework group support', () => {
|
||||||
|
expect(framework).toContain(
|
||||||
|
'local groups = Config.CommandPermissions[permission]',
|
||||||
|
)
|
||||||
|
expect(qbox).toContain(
|
||||||
|
'if IsPlayerAceAllowed(tostring(source), group) then',
|
||||||
|
)
|
||||||
|
expect(qbox.indexOf('IsPlayerAceAllowed')).toBeLessThan(
|
||||||
|
qbox.indexOf('exports.qbx_core:HasGroup'),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses stable permission identifiers for every protected operation', () => {
|
||||||
|
const expectations = [
|
||||||
|
['../../sky_phone/source/server/admin.lua', 'phonepanel'],
|
||||||
|
['../../sky_phone/source/server/testdata.lua', 'phonetestdata'],
|
||||||
|
['../../sky_phone/source/server/fliptok.lua', 'fliptokverify'],
|
||||||
|
['../../sky_phone/source/server/picstagram.lua', 'picstagramverify'],
|
||||||
|
['../../sky_phone/source/server/picstagram.lua', 'picstagramadmin'],
|
||||||
|
] as const
|
||||||
|
|
||||||
|
for (const [path, permission] of expectations) {
|
||||||
|
expect(source(path)).toContain(
|
||||||
|
`Bridge.Framework.HasPermission(source, "${permission}")`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from 'vue-router'
|
} from 'vue-router'
|
||||||
|
|
||||||
import { isPhoneAppId } from '@/config/apps'
|
import { isPhoneAppId } from '@/config/apps'
|
||||||
|
import { useAppStoreStore } from '@/stores/app-store'
|
||||||
import PhoneAppWindow from '@/views/PhoneAppWindow.vue'
|
import PhoneAppWindow from '@/views/PhoneAppWindow.vue'
|
||||||
import SpringboardView from '@/views/SpringboardView.vue'
|
import SpringboardView from '@/views/SpringboardView.vue'
|
||||||
|
|
||||||
@@ -34,7 +35,9 @@ export default createRouter({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
beforeEnter: (to) =>
|
beforeEnter: (to) =>
|
||||||
typeof to.params.appId === 'string' && isPhoneAppId(to.params.appId)
|
typeof to.params.appId === 'string' &&
|
||||||
|
isPhoneAppId(to.params.appId) &&
|
||||||
|
useAppStoreStore().isInstalled(to.params.appId)
|
||||||
? true
|
? true
|
||||||
: '/',
|
: '/',
|
||||||
component: PhoneAppWindow,
|
component: PhoneAppWindow,
|
||||||
|
|||||||
@@ -15,7 +15,31 @@ 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 +49,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 +72,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 +175,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 +196,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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
@@ -261,6 +276,31 @@ describe('app store', () => {
|
|||||||
expect(apps.isInstalled('phone')).toBe(true)
|
expect(apps.isInstalled('phone')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('temporarily blocks server-disabled apps without changing their installation', () => {
|
||||||
|
const apps = useAppStoreStore()
|
||||||
|
apps.hydrate({ claimedApps: ['crypto'] }, [
|
||||||
|
'crypto',
|
||||||
|
'citywarn',
|
||||||
|
'not-an-app',
|
||||||
|
])
|
||||||
|
mocks.phone.saveDeviceNamespace.mockClear()
|
||||||
|
|
||||||
|
expect(apps.disabledApps).toEqual(['crypto', 'citywarn'])
|
||||||
|
expect(apps.isAvailable('crypto')).toBe(false)
|
||||||
|
expect(apps.isInstalled('crypto')).toBe(false)
|
||||||
|
expect(apps.isInstalled('citywarn')).toBe(false)
|
||||||
|
expect(apps.claimedApps).toEqual(['crypto'])
|
||||||
|
|
||||||
|
apps.installApp('crypto')
|
||||||
|
expect(apps.installingApps).toEqual({})
|
||||||
|
expect(mocks.phone.saveDeviceNamespace).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
apps.hydrate({ claimedApps: ['crypto'] })
|
||||||
|
expect(apps.isAvailable('crypto')).toBe(true)
|
||||||
|
expect(apps.isInstalled('crypto')).toBe(true)
|
||||||
|
expect(apps.isInstalled('citywarn')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
it('protects every default app from full uninstallation', () => {
|
it('protects every default app from full uninstallation', () => {
|
||||||
const apps = useAppStoreStore()
|
const apps = useAppStoreStore()
|
||||||
apps.hydrate(null)
|
apps.hydrate(null)
|
||||||
|
|||||||
@@ -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()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import { useRoute } from 'vue-router'
|
|||||||
|
|
||||||
import CustomAppFrame from '@/components/CustomAppFrame.vue'
|
import CustomAppFrame from '@/components/CustomAppFrame.vue'
|
||||||
import { getPhoneApp, isExternalPhoneApp } from '@/config/apps'
|
import { getPhoneApp, isExternalPhoneApp } from '@/config/apps'
|
||||||
|
import { useAppStoreStore } from '@/stores/app-store'
|
||||||
import { usePhoneStore } from '@/stores/phone'
|
import { usePhoneStore } from '@/stores/phone'
|
||||||
import { getCustomAppFrameKey } from '@/utils/customAppLifecycle'
|
import { getCustomAppFrameKey } from '@/utils/customAppLifecycle'
|
||||||
import AppStoreApp from '@/views/apps/AppStoreApp.vue'
|
import AppStoreApp from '@/views/apps/AppStoreApp.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const appStore = useAppStoreStore()
|
||||||
const phone = usePhoneStore()
|
const phone = usePhoneStore()
|
||||||
const app = computed(() => getPhoneApp(route.params.appId))
|
const app = computed(() => getPhoneApp(route.params.appId))
|
||||||
const builtinAppComponent = computed(() =>
|
const builtinAppComponent = computed(() =>
|
||||||
@@ -28,7 +30,7 @@ const launchStyle = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="app && !app.adminOnly"
|
v-if="app && !app.adminOnly && appStore.isInstalled(app.id)"
|
||||||
class="app-window"
|
class="app-window"
|
||||||
:class="{ 'app-window--citywarn': app.id === 'citywarn' }"
|
:class="{ 'app-window--citywarn': app.id === 'citywarn' }"
|
||||||
:style="launchStyle"
|
:style="launchStyle"
|
||||||
|
|||||||
@@ -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"',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(),
|
||||||
},
|
},
|
||||||
|
|||||||
+150
-122
@@ -371,6 +371,9 @@ local function initialize_markets()
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function require_phone(source)
|
local function require_phone(source)
|
||||||
|
if not Config.Crypto.Enabled or not SkyPhone.IsAppEnabled("crypto") then
|
||||||
|
return nil, nil, { success = false, error = "service_unavailable" }
|
||||||
|
end
|
||||||
local phone_session, error_response = SkyPhone.RequireSession(source)
|
local phone_session, error_response = SkyPhone.RequireSession(source)
|
||||||
if not phone_session then
|
if not phone_session then
|
||||||
return nil, nil, error_response
|
return nil, nil, error_response
|
||||||
@@ -661,7 +664,7 @@ local function with_exchange_lock(callback)
|
|||||||
end
|
end
|
||||||
|
|
||||||
Bridge.Callbacks.Register("sky_phone:crypto:bootstrap", function(source)
|
Bridge.Callbacks.Register("sky_phone:crypto:bootstrap", function(source)
|
||||||
if not Config.Crypto.Enabled then
|
if not Config.Crypto.Enabled or not SkyPhone.IsAppEnabled("crypto") then
|
||||||
return { success = false, error = "service_unavailable" }
|
return { success = false, error = "service_unavailable" }
|
||||||
end
|
end
|
||||||
local profile, error_response = authenticated_profile(source)
|
local profile, error_response = authenticated_profile(source)
|
||||||
@@ -1368,8 +1371,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 +1417,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 +1499,163 @@ 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
|
||||||
|
|
||||||
|
local function refresh_crypto_runtime()
|
||||||
|
initialize_markets()
|
||||||
|
start_crypto_schedulers()
|
||||||
|
if not Config.Crypto.Enabled or not SkyPhone.IsAppEnabled("crypto") then
|
||||||
|
sessions = {}
|
||||||
end
|
end
|
||||||
end)
|
end
|
||||||
|
|
||||||
|
AddEventHandler("sky_phone:configurator:serverUpdated", refresh_crypto_runtime)
|
||||||
|
start_crypto_schedulers()
|
||||||
|
|
||||||
end)
|
end)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 = {}
|
||||||
|
|||||||
@@ -17,9 +17,31 @@ 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
|
||||||
|
local border = "======================================================================"
|
||||||
|
print(([[
|
||||||
|
^1%s^0
|
||||||
|
^1 SKY PHONE CONFIGURATION FILES ARE DISABLED ^0
|
||||||
|
^1%s^0
|
||||||
|
^1 The Phone Configurator is ENABLED.^0
|
||||||
|
^1 Runtime settings from config.lua and media.lua are DISABLED.^0
|
||||||
|
^1 Configure all phone and media settings IN GAME through /phonepanel.^0
|
||||||
|
^1 Only Config.PhoneConfigurator.Enabled and Config.CommandPermissions remain file-based.^0
|
||||||
|
^1%s^0]]):format(border, border, border))
|
||||||
|
end
|
||||||
|
|
||||||
local CLIENT_CONFIG_KEYS = {
|
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 +274,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 +300,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 +325,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 +336,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 +385,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 +1063,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 +1076,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(([[
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -0,0 +1,64 @@
|
|||||||
|
Config = {
|
||||||
|
Bridge = {
|
||||||
|
Framework = "qbox",
|
||||||
|
},
|
||||||
|
CommandPermissions = {
|
||||||
|
phonepanel = { "god", "superadmin", "admin" },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
Bridge = {
|
||||||
|
Framework = {},
|
||||||
|
}
|
||||||
|
|
||||||
|
function GetResourceState(resource)
|
||||||
|
assert(resource == "qbx_core")
|
||||||
|
return "started"
|
||||||
|
end
|
||||||
|
|
||||||
|
function GetPlayers()
|
||||||
|
return { "1" }
|
||||||
|
end
|
||||||
|
|
||||||
|
local ace_permissions = {}
|
||||||
|
local framework_permission = false
|
||||||
|
|
||||||
|
function IsPlayerAceAllowed(source, permission)
|
||||||
|
assert(source == "1")
|
||||||
|
return ace_permissions[permission] == true
|
||||||
|
end
|
||||||
|
|
||||||
|
exports = {
|
||||||
|
qbx_core = {
|
||||||
|
GetPlayer = function(_, source)
|
||||||
|
if tonumber(source) ~= 1 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return {
|
||||||
|
PlayerData = {
|
||||||
|
citizenid = "test-citizen",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
HasGroup = function(_, source, groups)
|
||||||
|
assert(source == 1)
|
||||||
|
assert(groups == Config.CommandPermissions.phonepanel)
|
||||||
|
return framework_permission
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
dofile("sky_phone/source/bridge/server/framework.lua")
|
||||||
|
dofile("sky_phone/source/bridge/server/frameworks/qbox.lua")
|
||||||
|
|
||||||
|
ace_permissions.admin = true
|
||||||
|
assert(Bridge.Framework.HasPermission(1, "phonepanel"), "Qbox ACE permissions must grant access")
|
||||||
|
|
||||||
|
ace_permissions.admin = false
|
||||||
|
framework_permission = true
|
||||||
|
assert(Bridge.Framework.HasPermission(1, "phonepanel"), "Qbox framework groups must remain supported")
|
||||||
|
|
||||||
|
local success = pcall(Bridge.Framework.HasPermission, 1, "missing")
|
||||||
|
assert(not success, "Missing fixed permission definitions must fail loudly")
|
||||||
|
|
||||||
|
print("framework permission tests passed")
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
local event_handlers = {}
|
||||||
|
|
||||||
|
AddEventHandler = function(event_name, callback)
|
||||||
|
event_handlers[event_name] = callback
|
||||||
|
end
|
||||||
|
|
||||||
local function reset_bridge(inventory_name, unique_phones, sim_cards_enabled)
|
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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user