Compare commits

...

13 Commits

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

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

* ADD - open admin panel by command

* ENH - rebuild admin panel as standalone editor

* ENH - compact admin workspace navigation

* ENH - expand admin device controls

* ENH - refine admin panel navigation style

* ADD - add SQL phone configurator

* FIX - expose complete phone configuration

* FIX - make company jobs freely configurable

* FIX - align configurator tables to left

* ENH - organize configurator collection controls

* ENH - organize nested configurator sections

* ENH - add configurator field descriptions

* FIX - hide fixed configurator add controls

* ENH - refine configurator editing experience

* ENH - refine admin configurator controls

* FIX - apply configurator settings instantly

* FIX - close live activity command registration

---------

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

* FIX - align inventory contract tests
2026-08-21 22:04:15 +02:00
DerEchteAlec 5f8c835685 Revert "ADD - add in-game phone administration and configuration (#21)" (#24)
This reverts commit edd9677f43.
2026-08-21 21:49:05 +02:00
DerEchteAlec 21913b585c FIX - use repository-native NUI build instructions (#22)
Replace the unavailable workspace-only build_frontend.bat hint with the frontend pnpm commands shipped in the GitHub source tree. Link the pushed frontend development guide and keep the local-only script name out of the startup diagnostic through regression coverage.
2026-08-21 18:14:39 +02:00
Alec Schitzkat e056d0f05c BLD - Bump version to 0.2.5 2026-08-21 17:35:36 +02:00
Leon.Schmidt edd9677f43 ADD - add in-game phone administration and configuration (#21)
* ADD - build in-game admin command center

* ADD - open admin panel by command

* ENH - rebuild admin panel as standalone editor

* ENH - compact admin workspace navigation

* ENH - expand admin device controls

* ENH - refine admin panel navigation style

* ADD - add SQL phone configurator

* FIX - expose complete phone configuration

* FIX - make company jobs freely configurable

* FIX - align configurator tables to left

* ENH - organize configurator collection controls

* ENH - organize nested configurator sections

* ENH - add configurator field descriptions

* FIX - hide fixed configurator add controls

* ENH - refine configurator editing experience

* ENH - refine admin configurator controls

* FIX - apply configurator settings instantly

* FIX - close live activity command registration
2026-08-21 17:28:30 +02:00
DerEchteAlec 97db163cd0 FIX - harden inventory contracts and resolve phone interaction issues (#20)
* FIX - repair inventory metadata and ESX contracts

Use the stable core_inventory metadata setter and validate the complete inventory adapter contract after provider bridges load. Preserve the real ESX configuration error instead of cascading into a missing RegisterUsableItem failure.

* FIX - clarify phone opening without SIM

* FIX - harden inventory and device contracts

Validate SIM number configuration before item handling, preserve QB metadata mutation contracts, and propagate phone-open failures to callers. Extend regression coverage for inventory bridges and device bootstrap errors.

* FIX - resolve reported phone interaction issues

Stop game-input passthrough while NUI text fields are focused and add an independent hold-to-look option. Propagate SIM number formatting to the frontend, migrate configured currency symbols to utf8mb4, and clarify SkyRide, VaultX, and DarkChat behavior with focused contract coverage.

* FIX - detect missing packaged phone UI

Validate the generated NUI entrypoint, its referenced assets, and required static media when the resource starts. Print an actionable server-console warning for incomplete source archives, package phone sounds through the manifest, and cover the detection contract with Lua regressions.
2026-08-21 17:19:27 +02:00
114 changed files with 19181 additions and 522 deletions
+2
View File
@@ -17,6 +17,8 @@ for (const requiredFragment of [
"fx_version 'cerulean'",
"node_version '22'",
"use_experimental_fxv2_oal 'yes'",
"'source/server/nui_build_check.lua'",
"'source/html/sounds/**'",
"ui_page 'source/html/index.html'",
]) {
if (!manifest.includes(requiredFragment)) {
+1 -1
View File
@@ -119,5 +119,5 @@ jobs:
"${archive}.sha256" \
--verify-tag \
--generate-notes \
--title "Sky Phone $GITHUB_REF_NAME"
--title "$GITHUB_REF_NAME Beta"
fi
+17
View File
@@ -92,6 +92,23 @@ as project instructions, not as optional documentation.
proof.
- Do not hand-edit generated frontend output.
## Phone Configurator parity (mandatory)
- Every new, changed, renamed, moved, or removed configurable value in
`sky_phone/config/config.lua` or `sky_phone/config/media.lua` **must** be
reflected in the in-game Phone Configurator in the same change. Config-only
changes without matching Configurator support are incomplete and must not be
committed or merged.
- Keep the Configurator's runtime schema, defaults, value types, fixed versus
extensible collection rules, labels, descriptions, English and German
locales, SQL persistence, validation, and save/load roundtrip aligned with
the Lua configuration.
- The Configurator must remain complete when file-based configuration is
disabled. Do not introduce a setting that can only be managed by editing Lua
after `Config.PhoneConfigurator.Enabled` is enabled.
- Add or update contract/fixture coverage so configuration parity regressions
fail automated checks.
## Working method and verification
1. Trace the relevant client, server, NUI, configuration, persistence, and event
+153 -21
View File
@@ -29,7 +29,7 @@
<p align="center">
<a href="https://www.sky-systems.net/shop/phone#live-demo"><strong>Live demo</strong></a>
&nbsp;&bull;&nbsp;
<a href="https://github.com/sky-systems/sky_phone"><strong>Download for free</strong></a>
<a href="https://github.com/sky-systems/sky_phone/releases/latest"><strong>Download for free</strong></a>
&nbsp;&bull;&nbsp;
<a href="https://discord.gg/sky-systems"><strong>Discord support</strong></a>
</p>
@@ -44,7 +44,7 @@ Sky Phone is a **free and open-source FiveM phone script** built to give serious
This is not a cut-down free alternative. Sky Phone includes the core experience server owners and players expect from a leading paid FiveM phone, plus full source access, no purchase price, no feature paywalls, and no forced ecosystem lock-in.
The production frontend is included, so a normal server installation does not require Node.js or pnpm.
The production frontend is included in the published release package, so a normal server installation does not require Node.js or pnpm. GitHub's automatically generated source archives do not contain that build.
## Why Sky Phone stands out
@@ -86,7 +86,7 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
| Layer | Supported options |
| --- | --- |
| **Frameworks** | ESX Legacy, QBCore, Qbox |
| **Inventories** | ox_inventory, qb-inventory, lj-inventory, qs-inventory, codem-inventory, core_inventory, mf-inventory, smx-inventory, hex_4_inventory, and native ESX inventory |
| **Inventories** | ak47_inventory, codem-inventory, core_inventory, jaksam_inventory, jpr-inventory, lj-inventory, mf-inventory, one_inventory, origen_inventory, ox_inventory, ps-inventory, qb-inventory, qs-inventory, smx-inventory, tgiann-inventory, hex_4_inventory, and native ESX inventory |
| **Calls** | YACA, PMA Voice, SaltyChat |
| **Radio** | YACA, PMA Voice, SaltyChat |
| **Housing** | RTX Housing, Quasar Housing, VMS Housing, RX Housing, NoLag Properties, SN Properties, ESX Property, qbx_properties |
@@ -121,20 +121,33 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
| **Framework** | ESX Legacy (`es_extended`), QBCore (`qb-core`), or Qbox (`qbx_core`) |
| **Inventory** | Choose one supported adapter from the table below |
| Inventory | Metadata support | Unique Phones | Notes |
| Inventory (configuration value) | Metadata support | Unique Phones | Notes |
| --- | --- | --- | --- |
| `ox_inventory` | Yes | Yes | Full per-item phone and physical SIM metadata |
| `qb-inventory` | Yes | Yes | Uses item `info` metadata |
| `lj-inventory` | Yes | Yes | QBCore inventory with item `info` metadata |
| `qs-inventory` | Yes | Yes | Full per-slot metadata |
| `codem-inventory` | Yes | Yes | Full per-slot metadata |
| `core_inventory` | Yes | Yes | Full per-slot metadata |
| `mf-inventory` | Yes | Yes | Supported with ESX |
| `smx-inventory` | Yes | Yes | Supported with ESX through the player metadata bridge |
| `hex_4_inventory` | **No metadata support** | **No, Unique Phones are not possible** | ESX only; set `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` |
| Native ESX inventory | **No metadata support** | **No, Unique Phones are not possible** | Count-based items; set `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` |
| `jaksam_inventory` (`jaksam`) | Yes | Yes | Direct per-slot metadata; usable items are registered through jaksam_inventory |
| `qs-inventory` (`qs`) | Yes | Yes | Full per-slot metadata |
| `ps-inventory` (`ps`) | Yes | Yes | QBCore only; uses item `info` metadata |
| `codem-inventory` (`codem`) | Yes | Yes | Full per-slot metadata |
| `tgiann-inventory` (`tgiann`) | Yes | Yes | Per-slot metadata; item definitions must enable `hasMetadata` |
| `core_inventory` (`core`) | Yes | Yes | Full per-slot metadata |
| `jpr-inventory` (`jpr`) | Yes | Yes | QBCore only; uses item `info` metadata |
| `origen_inventory` (`origen`) | Yes | Yes | Full per-slot metadata |
| `ak47_inventory` (`ak47`) | Yes | Yes | Uses per-slot item `info` metadata |
| `one_inventory` (`one`) | Yes | Yes | Full per-slot metadata |
| `ox_inventory` (`ox`) | Yes | Yes | Full per-item phone and physical SIM metadata |
| `mf-inventory` (`mf`) | Yes | Yes | ESX only |
| `smx-inventory` (`smx`) | Yes | Yes | ESX only; one metadata record per configured item name through the player metadata bridge |
| `lj-inventory` (`lj`) | Yes | Yes | QBCore inventory with item `info` metadata |
| `qb-inventory` (`qb`) | Yes | Yes | Uses item `info` metadata |
| `hex_4_inventory` (`hex`) | **No metadata support** | **No, Unique Phones are not possible** | ESX only; Sky Phone automatically disables unique phones and physical SIM cards |
| Native ESX inventory (`esx`) | **No metadata support** | **No, Unique Phones are not possible** | Count-based items; Sky Phone automatically disables unique phones and physical SIM cards |
`hex_4_inventory` and native ESX inventory cannot persist per-item metadata. Unique Phones and physical SIM cards are therefore unavailable with these adapters.
`hex_4_inventory` and native ESX inventory cannot persist per-item metadata. Sky Phone therefore forces `Config.Phone.Unique` and `Config.Sim.Enabled` to `false` at runtime whenever either adapter is active.
`Config.Bridge.Inventory = "auto"` detects framework-compatible adapters in the table order. This deliberately matches the Sky inventory priority so a dedicated inventory is selected before a compatibility resource it may run beside. You may configure either the short value shown in parentheses or the exact resource name.
The adapters shared with `sky_base` are implemented locally inside Sky Phone. Installing or starting `sky_base` is not required; Sky Phone remains a standalone resource.
For configuration parity with `sky_base`, `qb-inv` is accepted as an alias for `qb`, while `qbox` selects the Qbox-native `ox_inventory` adapter.
### Voice
@@ -160,8 +173,8 @@ Start the selected voice resource before Sky Phone.
## Quick installation
1. Copy the resource into your FiveM resources directory.
2. Keep the resource folder name `sky_phone`.
1. Download and extract the latest published [Sky Phone release](https://github.com/sky-systems/sky_phone/releases/latest). Do not use GitHub's automatically generated "Source code" archives for a server installation because they do not contain the built frontend.
2. Copy the included resource into your FiveM resources directory and keep its folder name `sky_phone`.
3. Start `oxmysql`, your framework, inventory, and voice resource before Sky Phone.
4. Review `sky_phone/config/config.lua` and `sky_phone/config/media.lua`.
5. Add the required inventory items.
@@ -182,6 +195,13 @@ Replace the example framework, inventory, and voice resources with the providers
Sky Phone creates and upgrades its database tables automatically. A manual SQL import is normally not required.
### How players open the phone
- Give the player the item configured in `Config.Phone.Item` (default: `phone`).
- Players can use that inventory item or press the configured keybind (default: `F1`).
- The keybind still verifies server-side that the player owns a configured phone item; it does not bypass inventory ownership.
- A SIM card is **not required to open or use the phone itself**. With `Config.Sim.Enabled = true`, only cellular features such as calls and messages require an inserted SIM.
## Configuration
Customer settings are organized in:
@@ -196,6 +216,7 @@ The files contain clearly separated sections for:
| Section | Purpose |
| --- | --- |
| `Config.Bridge` | Framework, inventory, language, callback timeout, and debug mode |
| `Config.CommandPermissions` | Fixed groups for the admin panel, test data, verification commands, and social moderation |
| `Config.Phone` | Phone item, movement, unique-device mode, and development command |
| `Config.Sim` | Physical or virtual SIM behavior and number formatting |
| `Config.Calls` / `Config.Radio` | Voice providers, call behavior, radio limits, and permissions |
@@ -211,6 +232,44 @@ The files contain clearly separated sections for:
Restart `sky_phone` after changing Lua configuration.
### In-game phone configurator
Set the switch at the beginning of `config/config.lua` to use SQL-backed configuration:
```lua
Config.PhoneConfigurator = {
Enabled = true,
}
```
When enabled, the generated `source/shared/config_default.lua` is the shipped first-run baseline.
The frontend build recreates this file from `config.lua` and the server-only `media.lua`; do not edit
the generated snapshot directly. Sky Phone creates the `sky_phone_configurator` table automatically,
loads its saved values before framework and phone
modules initialize, and exposes the editor through `/phonepanel`. Nothing autosaves: stage changes
in the Phone Configurator tool and press the green check. Saving verifies both SQL payloads and then
applies the new server, client, media, app, item, command, provider, animation, and UI values through
Sky Phone's internal runtime refresh. It does not execute a resource restart command.
Every configurable `Config.*` value from `config.lua`, including server-only sections, and every
value from `Config.Media` is discovered automatically. The bootstrap switch and
`Config.CommandPermissions` intentionally remain file-owned: the switch decides whether SQL
configuration is loaded, while permissions must remain authoritative outside the panel. The fixed
permission table is never displayed or overwritten by the Phone Configurator, and its stable keys do
not change when their commands are renamed in the panel. Lists, nested objects, vectors,
and numeric-keyed Lua tables use structured editors instead of raw JSON. Shipped schema rows stay
editable but cannot be renamed, converted, or removed. Every list and table still accepts any number
of additional rows; administrator-added rows remain removable. Company job keys are intentionally
fully removable because `Config.Companies.Definitions` is a freely managed job collection.
ESX and QBCore use the groups listed in `Config.CommandPermissions`. Qbox checks the configured ACE
objects first and then its framework groups. The standard Qbox `permissions.cfg` grants the `admin`
ACE to `group.admin`, so an identifier assigned to `group.admin` can open `/phonepanel` with the
shipped `phonepanel` permission list. Restart `sky_phone` after changing fixed permissions.
Media API keys and server peppers are never returned in plaintext to the NUI. Existing secrets are
shown only as configured and are replaced only when an administrator enters a new value.
### Language
Available locales:
@@ -315,9 +374,9 @@ Default entries for unique phones with physical SIM cards:
Do not configure an LB Phone client event or client export. Sky Phone registers the usable items through its server-side inventory adapter.
The server registers `Config.Phone.Item` as usable for every supported inventory adapter: `ox`, `qb`, `lj`, `qs`, `codem`, `core`, `mf`, `smx`, `hex`, and `esx`. Resource startup fails visibly if the selected adapter cannot complete that registration.
The server registers `Config.Phone.Item` as usable for every supported inventory adapter: `ak47`, `codem`, `core`, `jaksam`, `jpr`, `lj`, `mf`, `one`, `origen`, `ox`, `ps`, `qb`, `qs`, `smx`, `tgiann`, `hex`, and `esx`. Resource startup fails visibly if the selected adapter or its resource is unavailable.
The `hex` and `esx` adapters use ESX's count-based item API. They require both `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` because this API cannot persist per-item phone or physical SIM metadata. `auto` selects `hex` when `hex_4_inventory` is started and otherwise falls back to `esx` on an ESX server when no metadata-capable inventory is detected.
The `hex` and `esx` adapters use ESX's count-based item API, which cannot persist per-item phone or physical SIM metadata. Sky Phone automatically forces `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` while either adapter is active. `auto` selects `hex` when `hex_4_inventory` is started and otherwise falls back to `esx` on an ESX server when no metadata-capable inventory is detected.
### QBCore-style item tables
@@ -326,6 +385,67 @@ The `hex` and `esx` adapters use ESX's count-based item API. They require both `
- Physical SIM items must always be unique.
- SIM items are not required when `Config.Sim.Enabled = false`.
Example for `qb-inventory`, `lj-inventory`, `ps-inventory`, and `jpr-inventory`:
```lua
phone = {
name = "phone",
label = "iFruit Phone",
weight = 200,
type = "item",
image = "phone.png",
unique = true,
useable = true,
shouldClose = true,
description = "A personal mobile phone",
},
sky_phone_sim_registered = {
name = "sky_phone_sim_registered",
label = "Registered SIM",
weight = 5,
type = "item",
image = "sky_phone_sim_registered.png",
unique = true,
useable = true,
shouldClose = true,
},
sky_phone_sim_anonymous = {
name = "sky_phone_sim_anonymous",
label = "Anonymous SIM",
weight = 5,
type = "item",
image = "sky_phone_sim_anonymous.png",
unique = true,
useable = true,
shouldClose = true,
},
```
For `tgiann-inventory`, set `hasMetadata = true`, `useable = true`, and `shouldClose = true` on all three item definitions. Follow the inventory's own item schema for the remaining adapters; the required behavior is always the same: a unique phone or physical SIM must occupy its own slot and its metadata table must survive moving, dropping, storing, and trading the item.
### Unique Phones and metadata
Sky Phone owns the metadata values and writes them server-side. Do not pre-generate IMEIs or phone numbers in item definitions:
| Item | Metadata written by Sky Phone |
| --- | --- |
| Phone | `imei`; when a SIM is inserted, also `sim_id`, `phone_number`, and `formatted_number` |
| Physical SIM | `sim_metadata_version`, `sim_id`, `phone_number`, `formatted_number`, `sim_type`, and registration details where applicable |
When a metadata-capable phone item is used for the first time, Sky Phone reserves an IMEI and writes it back to that exact slot. Existing metadata is preserved. The adapter then reads the slot again and rejects the operation if the inventory did not persist the requested values.
For reliable Unique Phones:
- Set `Config.Phone.Unique = true`.
- Make the phone item non-stackable/unique. Every phone slot must contain exactly one item.
- If `Config.Sim.Enabled = true`, make both physical SIM items non-stackable/unique and metadata-capable too.
- Do not use inventory conversion, admin, crafting, or shop scripts that strip item metadata. Copying an item with its metadata also copies its IMEI; duplicated IMEIs are reported in the server console.
- When changing inventory systems, migrate the complete item metadata table. Without the old `imei`, the next use creates a new device identity and does not automatically attach the old handset data.
With `Config.Phone.Unique = false`, the handset identity is stored once per framework character instead of on each phone item. The phone item may stack. Physical SIMs still require per-item metadata, so `Config.Sim.Enabled` must be `false` on `hex` and native `esx`.
## Phone and SIM modes
The two mode switches are independent:
@@ -344,7 +464,7 @@ With unique phones, using an inventory item selects that exact handset whenever
| SIM mode | Behavior |
| --- | --- |
| `Enabled = true` | A registered or anonymous physical SIM item is required for cellular service. |
| `Enabled = true` | The phone opens with or without a SIM. A registered or anonymous physical SIM item is required only for cellular service such as calls and messages. |
| `Enabled = false` | Sky Phone creates a persistent automatic number for devices without a SIM. Physical SIM items are not required. |
When changing these modes on an existing production server, restart the resource and test with a copy of the database first. The first phone used after switching to non-unique mode may adopt an existing valid IMEI so its local data is preserved.
@@ -534,7 +654,11 @@ Select `rtx`, `quasar`, `vms`, `rx`, `nolag`, `sn`, `esx_property`, or `qbx_prop
### Companies
Company jobs, public profiles, service numbers, services, permissions, locations, and default availability are configured under `Config.Companies.Definitions`.
Company jobs, public profiles, service numbers, services, permissions, locations, and default
availability are configured under `Config.Companies.Definitions`. Definitions are not limited to the
shipped jobs: add any number of company IDs in the in-game configurator and fill the freely
configurable `Job` value in the automatically generated full company template. Existing job keys can
also be removed; the remaining Companies settings stay available as normal individual fields.
### Weazel News
@@ -602,12 +726,20 @@ pnpm build
### The phone item does nothing
- A warning that the inventory returned no configured phone item means an item definition, `Config.Phone.Item`, inventory selection, or player ownership problem. It is not caused by a missing SIM card.
- Confirm the framework and inventory are supported and started first.
- Confirm the item name matches `Config.Phone.Item`.
- Confirm the item is usable.
- In unique mode, confirm the phone is non-stackable.
- Check the server console for inventory adapter warnings.
### The resource starts but the phone UI is missing
- On startup, the server console prints `SKY PHONE UI BUILD IS MISSING OR INCOMPLETE`, lists the missing or invalid packaged files, and shows repository-native build commands.
- Install the latest published release package rather than GitHub's automatically generated source archive.
- Confirm `sky_phone/source/html/index.html`, `assets`, `img`, and `sounds` exist.
- Developers working from source must run the frontend production build before starting the resource.
### Calls connect without audio
- Confirm the configured voice resource is running.
+40 -5
View File
@@ -2,15 +2,49 @@ const fs = require('node:fs')
const path = require('node:path')
const frontendRoot = __dirname
const resourceDirectory = path.join(frontendRoot, '..', 'sky_phone')
const sourceDirectory = path.join(frontendRoot, 'dist')
const targetDirectory = path.join(
frontendRoot,
'..',
'sky_phone',
const targetDirectory = path.join(resourceDirectory, 'source', 'html')
const configDefaultPath = path.join(
resourceDirectory,
'source',
'html',
'shared',
'config_default.lua',
)
function readLuaSource(...segments) {
return fs
.readFileSync(path.join(resourceDirectory, ...segments), 'utf8')
.replace(/\r\n?/g, '\n')
.trimEnd()
}
function generateConfigDefault() {
const configSource = readLuaSource('config', 'config.lua').replace(
/\n?-- CONFIG_DEFAULT_EXCLUDE_START[\s\S]*?-- CONFIG_DEFAULT_EXCLUDE_END\n?/g,
'\n',
)
const mediaSource = readLuaSource('config', 'media.lua')
const generatedSource = [
'-- GENERATED by frontend/build.cjs from config/config.lua and config/media.lua - do not edit.',
'-- Escrowed snapshot of the shipped defaults used by the Phone Configurator.',
'local realConfig = Config',
'Config = {}',
'',
configSource,
'',
mediaSource,
'',
'ConfigDefaults = Config',
'Config = realConfig',
'',
].join('\n')
fs.mkdirSync(path.dirname(configDefaultPath), { recursive: true })
fs.writeFileSync(configDefaultPath, generatedSource, 'utf8')
console.log(`Generated Phone Configurator defaults at ${configDefaultPath}`)
}
if (!fs.existsSync(sourceDirectory)) {
throw new Error(
'Missing frontend/dist. Run vite build before publishing the NUI.',
@@ -28,4 +62,5 @@ const normalizedIndex = fs
.replace(/\n[ \t]*\n([ \t]*<\/body>)/g, '\n$1')
fs.writeFileSync(targetIndex, normalizedIndex)
generateConfigDefault()
console.log(`Published NUI to ${targetDirectory}`)
+43 -5
View File
@@ -11,6 +11,7 @@ import {
import { useRoute, useRouter } from 'vue-router'
import { SkyProvider } from '@/ui'
import AdminPanel from '@/components/AdminPanel.vue'
import PhoneHomeIndicator from '@/components/PhoneHomeIndicator.vue'
import PhoneControlCenter from '@/components/PhoneControlCenter.vue'
import PhoneDynamicIsland from '@/components/PhoneDynamicIsland.vue'
@@ -70,7 +71,7 @@ import type {
CompanyChangedPayload,
CompanyUnreadCounts,
} from '@/types/companies'
import type { PhoneCall } from '@/types/phone'
import type { PhoneCall, PhoneNumberFormat } from '@/types/phone'
import type { DynamicIslandActivity } from '@/types/dynamicIsland'
import type { EasyShareEvent } from '@/types/easyshare'
import type { CryptoMarketChangedData } from '@/types/crypto'
@@ -84,6 +85,7 @@ import { formatTimer } from '@/utils/clock'
import { parsePhonePreferences } from '@/utils/preferences'
import { getHairlinePixelStyle } from '@/utils/rendering'
import { isTextInputElement } from '@/utils/textInputFocus'
import { configurePhoneNumberFormat } from '@/utils/phone'
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
import SpringboardView from '@/views/SpringboardView.vue'
@@ -114,8 +116,13 @@ type AppMessage = {
| CustomAppCatalogEventData
| CustomAppEventData
| NavigationEventData
| AdminPanelOpenPayload
}
type AdminPanelOpenPayload = Required<
Pick<PhoneOpenPayload, 'fallbackLocales' | 'lang' | 'locales'>
>
type CustomAppCatalogEventData = {
apps?: unknown
}
@@ -133,6 +140,7 @@ type NavigationEventData = {
type SimPickerPayload = {
choices: SimPhoneChoice[]
number: string
phoneNumberFormat?: PhoneNumberFormat
}
type NotificationEventData = Omit<PhoneNotificationInput, 'device'> & {
@@ -355,6 +363,9 @@ const appTransitionName = computed(() =>
route.query.transition === 'app-switch' ? 'app-switch' : 'app-window',
)
const isLocked = ref(false)
const adminPanelOpen = ref(
isDevelopment && developmentParameters.has('adminPanel'),
)
const springboardEditing = ref(false)
const isUnlocking = ref(false)
const passcodeBusy = ref(false)
@@ -491,6 +502,7 @@ function getViewportScale(): number {
}
function hydratePhone(payload: PhoneOpenPayload): void {
configurePhoneNumberFormat(payload.phoneNumberFormat)
if (payload.device?.imei) {
companies.bindDeviceScope(
payload.device.imei,
@@ -627,6 +639,8 @@ function loadUnlockedPhoneData(): void {
}
function completePhoneSetup(): void {
const requestedRoute = pendingUnlockRoute.value
pendingUnlockRoute.value = null
setupPreviewDismissed.value = true
setupAppearanceSelected.value = false
isLocked.value = false
@@ -634,7 +648,7 @@ function completePhoneSetup(): void {
passcodeVisible.value = false
passcodeRequired.value = false
controlCenterOpened.value = false
void router.replace('/')
void router.replace(requestedRoute ?? '/')
loadUnlockedPhoneData()
}
@@ -718,7 +732,15 @@ function openDevelopmentPayphonePreview(): void {
function onMessage(event: MessageEvent<AppMessage>): void {
if (!isTrustedRootMessageSource(event.source, window)) return
if (event.data?.type === 'custom-apps:catalog') {
if (event.data?.type === 'admin:open') {
const data = event.data.data as AdminPanelOpenPayload | undefined
if (data?.lang && data.locales && data.fallbackLocales) {
phone.setLocale(data.lang, data.locales, data.fallbackLocales)
}
adminPanelOpen.value = true
} else if (event.data?.type === 'admin:close') {
adminPanelOpen.value = false
} else if (event.data?.type === 'custom-apps:catalog') {
appCatalog.replaceCatalog(event.data.data)
const catalogPayload = event.data.data as
| { apps?: unknown; debug?: unknown }
@@ -763,7 +785,12 @@ function onMessage(event: MessageEvent<AppMessage>): void {
isPhoneAppId(data.appId) &&
appStore.isInstalled(data.appId)
) {
void router.push(`/apps/${data.appId}`)
const requestedRoute = `/apps/${data.appId}`
if (setupRequired.value || isLocked.value) {
pendingUnlockRoute.value = requestedRoute
} else {
void router.push(requestedRoute)
}
} else {
console.error('[Navigation] Ignored an unavailable app target.')
}
@@ -1222,7 +1249,9 @@ function onMessage(event: MessageEvent<AppMessage>): void {
loadUnlockedPhoneData()
}
} else if (event.data?.type === 'sim:picker' && event.data.data) {
simPicker.value = event.data.data as unknown as SimPickerPayload
const payload = event.data.data as unknown as SimPickerPayload
configurePhoneNumberFormat(payload.phoneNumberFormat)
simPicker.value = payload
} else if (event.data?.type === 'sim:picker-close') {
simPicker.value = null
}
@@ -1726,6 +1755,15 @@ onBeforeUnmount(() => {
</script>
<template>
<SkyProvider
v-if="adminPanelOpen"
dark
:safe-areas="false"
accent="#74d66f"
accent-soft="rgba(116, 214, 111, 0.14)"
>
<AdminPanel @close="adminPanelOpen = false" />
</SkyProvider>
<PhoneMediaCapture />
<PhoneMemoRecorder />
<RadioHud />
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,507 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
new URL('./AdminPanel.vue', import.meta.url),
'utf8',
)
const agentInstructions = readFileSync(
new URL('../../../AGENTS.md', import.meta.url),
'utf8',
)
const app = readFileSync(new URL('../App.vue', import.meta.url), 'utf8')
const apps = readFileSync(new URL('../config/apps.ts', import.meta.url), 'utf8')
const store = readFileSync(
new URL('../stores/admin.ts', import.meta.url),
'utf8',
)
const server = readFileSync(
new URL('../../../sky_phone/source/server/admin.lua', import.meta.url),
'utf8',
)
const phoneServer = readFileSync(
new URL('../../../sky_phone/source/server/phone.lua', import.meta.url),
'utf8',
)
const persistenceServer = readFileSync(
new URL(
'../../../sky_phone/source/server/phone_persistence.lua',
import.meta.url,
),
'utf8',
)
const simServer = readFileSync(
new URL('../../../sky_phone/source/server/sim.lua', import.meta.url),
'utf8',
)
const phoneClient = readFileSync(
new URL('../../../sky_phone/source/client/main.lua', import.meta.url),
'utf8',
)
const focusClient = readFileSync(
new URL('../../../sky_phone/source/client/focus.lua', import.meta.url),
'utf8',
)
const bridge = readFileSync(
new URL(
'../../../sky_phone/source/client/nui_server_bridge.lua',
import.meta.url,
),
'utf8',
)
const config = readFileSync(
new URL('../../../sky_phone/config/config.lua', import.meta.url),
'utf8',
)
const configDefaultsSource = config.replace(
/\r?\n?-- CONFIG_DEFAULT_EXCLUDE_START[\s\S]*?-- CONFIG_DEFAULT_EXCLUDE_END\r?\n?/g,
'\n',
)
const mediaConfig = readFileSync(
new URL('../../../sky_phone/config/media.lua', import.meta.url),
'utf8',
)
const schema = readFileSync(
new URL('../../../sky_phone/sql/install.sql', import.meta.url),
'utf8',
)
const manifest = readFileSync(
new URL('../../../sky_phone/fxmanifest.lua', import.meta.url),
'utf8',
)
const configuratorServer = readFileSync(
new URL(
'../../../sky_phone/source/server/phone_configurator.lua',
import.meta.url,
),
'utf8',
)
const configDefault = readFileSync(
new URL(
'../../../sky_phone/source/shared/config_default.lua',
import.meta.url,
),
'utf8',
)
const frontendBuild = readFileSync(
new URL('../../build.cjs', import.meta.url),
'utf8',
)
const configuratorClient = readFileSync(
new URL(
'../../../sky_phone/source/client/phone_configurator.lua',
import.meta.url,
),
'utf8',
)
const mediaImportServer = readFileSync(
new URL('../../../sky_phone/source/server/media_import.lua', import.meta.url),
'utf8',
)
const configuratorValueEditor = readFileSync(
new URL('./AdminConfigValueEditor.vue', import.meta.url),
'utf8',
)
const configInputWidth = readFileSync(
new URL('../directives/configInputWidth.ts', import.meta.url),
'utf8',
)
describe('standalone admin panel contracts', () => {
it('renders as a dedicated full-screen editor outside the phone shell', () => {
expect(apps).not.toContain("id: 'admin'")
expect(app).toContain("event.data?.type === 'admin:open'")
expect(app).toContain('v-if="adminPanelOpen"')
expect(app).toContain('<AdminPanel')
expect(source).toContain("import { SkyButton } from '@/ui'")
expect(source).toContain('class="admin-panel-overlay"')
expect(source).toContain('class="admin-panel-rail"')
expect(source).toContain('class="admin-panel-directory"')
expect(source).toContain('class="admin-panel-editor"')
expect(source).toContain('pointer-events: auto')
expect(source).toContain('@media (prefers-reduced-motion: reduce)')
})
it('uses a compact transparent shell with dedicated admin workspaces', () => {
expect(source).toContain('background: transparent')
expect(source).toContain('width: min(76vw, 1220px)')
expect(source).toContain('height: min(74vh, 700px)')
expect(source).toContain('--admin-row-hover: linear-gradient')
expect(source).toContain('--admin-row-active: linear-gradient')
expect(source).toContain('background: var(--admin-nav-active)')
expect(source).not.toContain('admin-panel-brand__mark')
expect(source).not.toContain('admin-panel-profile-heading__status')
expect(source).not.toContain('backdrop-filter: blur(2px)')
expect(source).not.toContain("t('overview.recent')")
for (const tab of [
'overview',
'players',
'devices',
'apps',
'accounts',
'messages',
'calls',
'moderation',
'audit',
'configurator',
]) {
expect(source).toContain(`selectTab('${tab}')`)
expect(source).toContain(`t('tabs.${tab}')`)
}
})
it('removes manual reload and applies a persistent global accent choice', () => {
expect(source).not.toContain('<RefreshCw')
expect(source).not.toContain("kind: 'refresh'")
expect(source).toContain("'sky-phone-admin-accent'")
expect(source).toContain("'sky-phone-admin-font-family'")
expect(source).toContain("'sky-phone-admin-font-size'")
expect(source).toContain("'--admin-accent': accentColor")
expect(source).toContain("'--admin-font-family': activeAdminFontFamily")
expect(source).toContain(
"'--admin-font-scale': String(adminFontSize / 100)",
)
expect(source).toContain('class="admin-panel-color-value"')
expect(source).toContain('class="admin-panel-rgb-fields"')
expect(source).toContain('class="admin-panel-rgb-range"')
expect(source).toContain('class="admin-panel-font-select"')
expect(source).toContain('class="admin-panel-font-select__menu"')
expect(source).toContain('class="admin-panel-font-size-control"')
expect(source).not.toContain('type="color"')
expect(source).toContain('color-mix(in srgb, var(--admin-green)')
expect(source).toContain('--admin-toggle-on: #63d471')
expect(source).toContain('background: var(--admin-toggle-on)')
expect(configuratorValueEditor).toContain(
'background: var(--admin-toggle-on)',
)
})
it('stages app changes locally and saves them only from the toolbar action', () => {
expect(source).toContain('const drafts = ref<')
expect(source).toContain('@click="saveChanges"')
expect(source).toContain("t('editor.noAutoSave')")
expect(store).toContain("'admin:save-apps'")
expect(store).not.toContain("'admin:set-app'")
expect(server).toContain(
'Bridge.Callbacks.Register("sky_phone:admin:save-apps"',
)
})
it('connects every operation through the standard NUI callback bridge', () => {
expect(bridge).toContain('admin = [[')
for (const endpoint of [
'admin:bootstrap',
'admin:player',
'admin:save-apps',
'admin:reveal-password',
'admin:activity',
'admin:reset-passcode',
'admin:change-number',
'admin:factory-reset',
'admin:configurator',
'admin:save-configurator',
]) {
expect(store).toContain(endpoint)
}
})
it('protects activity views and device moderation with ownership and audit checks', () => {
for (const endpoint of [
'activity',
'reset-passcode',
'change-number',
'factory-reset',
]) {
expect(server).toContain(
`Bridge.Callbacks.Register("sky_phone:admin:${endpoint}"`,
)
}
expect(server).toContain('data.kind ~= "messages"')
expect(server).toContain('data.kind ~= "calls"')
expect(server).toContain('"view_messages"')
expect(server).toContain('"view_calls"')
expect(server).toContain('"reset_passcode"')
expect(server).toContain('"change_number"')
expect(server).toContain('"factory_reset"')
expect(simServer).toContain('function SkyPhoneSim.ChangeNumber(')
expect(simServer).toContain('UPDATE IGNORE `sky_phone_sims`')
expect(persistenceServer).toContain(
'function SkyPhonePersistence.FactoryReset(imei)',
)
})
it('authorizes every server request without requiring a phone session', () => {
expect(server).not.toContain('SkyPhone.RequireSession(source)')
expect(server).toContain(
'Bridge.Framework.HasPermission(source, "phonepanel")',
)
expect(server).toContain('Config.AdminPanel.ReadRequestsPerMinute')
expect(server).toContain('Config.AdminPanel.ActionRequestsPerMinute')
expect(server).toContain('Config.AdminPanel.CredentialRevealsPerMinute')
expect(config).toContain('Config.AdminPanel = {')
expect(config).toContain('Config.CommandPermissions = {')
expect(config).not.toContain('AdminGroups =')
})
it('opens directly from the configurable command with dedicated focus', () => {
expect(config).toContain('Command = "phonepanel"')
expect(server).toContain('local command_name = Config.AdminPanel.Command')
expect(server).toContain(
'RegisterCommand(command_name, function(command_source)',
)
expect(server).toContain(
'TriggerClientEvent("sky_phone:admin:launch", player_source)',
)
expect(phoneServer).not.toContain(
'RegisterCommand(Config.AdminPanel.Command',
)
expect(phoneClient).toContain('RegisterNetEvent("sky_phone:admin:launch"')
expect(phoneClient).toContain('SkyPhoneFocus.SetAdminPanel(true)')
expect(focusClient).toContain('function SkyPhoneFocus.SetAdminPanel(open)')
})
it('validates ownership, app policy, and revision before a batch mutation', () => {
expect(server).toContain('find_owned_device(target_source, data.imei)')
expect(server).toContain('app_metadata(change.appId)')
expect(server).toContain('error = "device_not_owned"')
expect(server).toContain('error = "app_protected"')
expect(server).toContain('AND `revision` = ?')
expect(server).toContain('SkyPhone.RefreshDevice(data.imei)')
})
it('gates plaintext account reveals behind confirmation and audit logging', () => {
expect(source).toContain('revealDialogImei')
expect(source).toContain("t('credentials.revealTitle')")
expect(server).toContain('"reveal_account_password"')
expect(server).toContain('write_audit(')
expect(server).not.toContain('passcode_hash` AS')
expect(schema).toContain(
'CREATE TABLE IF NOT EXISTS `sky_phone_admin_audit`',
)
})
it('loads the SQL phone configurator before framework-owned configuration is read', () => {
expect(agentInstructions).toContain('Phone Configurator parity (mandatory)')
expect(agentInstructions).toContain(
'changes without matching Configurator support are incomplete',
)
expect(config).toMatch(
/Config\.PhoneConfigurator\s*=\s*\{[\s\S]*?Enabled\s*=\s*true[\s\S]*?Config\.Bridge\s*=/,
)
expect(schema).toContain(
'CREATE TABLE IF NOT EXISTS `sky_phone_configurator`',
)
expect(
manifest.indexOf("'source/shared/config_default.lua'"),
).toBeGreaterThan(manifest.indexOf("'config/media.lua'"))
expect(manifest.indexOf("'source/shared/config_default.lua'")).toBeLessThan(
manifest.indexOf("'source/server/phone_configurator.lua'"),
)
expect(
manifest.indexOf("'source/server/phone_configurator.lua'"),
).toBeLessThan(manifest.indexOf("'source/bridge/server/framework.lua'"))
expect(
manifest.indexOf("'source/client/phone_configurator.lua'"),
).toBeLessThan(manifest.indexOf("'source/bridge/client/framework.lua'"))
expect(configuratorServer).toContain('AND `revision` = ?')
expect(configuratorServer).toContain('configurator_enabled')
expect(configuratorServer).toMatch(
/for key, value in pairs\(ConfigDefaults\)[\s\S]*?key ~= "Media"[\s\S]*?key ~= "PhoneConfigurator"/,
)
expect(configuratorServer).toContain(
'default_media = serialize_value(ConfigDefaults.Media)',
)
expect(configDefault).toContain(
'GENERATED by frontend/build.cjs from config/config.lua and config/media.lua',
)
expect(configDefault).toContain('local realConfig = Config')
expect(configDefault).toContain('ConfigDefaults = Config')
expect(configDefault.replace(/\r\n?/g, '\n').trimEnd()).toBe(
[
'-- GENERATED by frontend/build.cjs from config/config.lua and config/media.lua - do not edit.',
'-- Escrowed snapshot of the shipped defaults used by the Phone Configurator.',
'local realConfig = Config',
'Config = {}',
'',
configDefaultsSource.replace(/\r\n?/g, '\n').trimEnd(),
'',
mediaConfig.replace(/\r\n?/g, '\n').trimEnd(),
'',
'ConfigDefaults = Config',
'Config = realConfig',
].join('\n'),
)
expect(frontendBuild).toContain("readLuaSource('config', 'config.lua')")
expect(frontendBuild).toContain("readLuaSource('config', 'media.lua')")
expect(frontendBuild).toContain("'config_default.lua'")
expect(configDefault).not.toContain('Config.PhoneConfigurator')
expect(configDefault).not.toContain('Config.CommandPermissions')
expect(configuratorServer).toContain(
'build_sections("config", stored_config',
)
expect(configuratorServer).toContain('build_sections("media", stored_media')
expect(configuratorServer).toContain('sensitive_path')
expect(configuratorServer).toContain('restore_redacted_values')
expect(configuratorServer).toContain(
'{ __skyType = "map", entries = entries }',
)
expect(configuratorServer).toContain('validate_structured_value')
expect(configuratorServer).toContain('validate_locked_structure')
expect(configuratorServer).toContain('build_structure(default_value')
expect(configuratorServer).toContain('path == "Companies.Definitions"')
expect(configuratorServer).toContain('flatten_company_fields')
expect(configuratorServer).toContain('structure.mutableKeys')
expect(configuratorServer).toContain('local function empty_structure(')
expect(configuratorServer).toContain('template = items[1]')
expect(configuratorServer).toContain('structure.template')
expect(configuratorServer).toContain('if not structure.fields[key] then')
expect(configuratorServer).toContain('field.type == "stringOrFalse"')
expect(configuratorServer).not.toContain('Config.Media = client_payload')
expect(configuratorServer).toContain(
'apply_runtime_table(Config[key], value)',
)
expect(configuratorServer).toContain(
'apply_runtime_table(Config.Media, runtime_media)',
)
expect(configuratorServer).toContain('local function read_stored_row()')
expect(configuratorServer).toContain('local function apply_stored_row(row)')
expect(configuratorServer).toContain(
'persisted_row.media_payload ~= media_encoded',
)
expect(configuratorServer).toContain(
'Phone configurator SQL verification failed',
)
expect(server).not.toContain('ExecuteCommand(("restart %s")')
expect(configuratorServer).not.toContain('clear_runtime_table')
expect(configuratorServer).toContain(
'TriggerEvent("sky_phone:configurator:serverUpdated", revision)',
)
expect(configuratorServer).toContain(
'function SkyPhoneConfigurator.Broadcast(target)',
)
expect(configuratorServer).toContain('SkyPhoneConfigurator.Broadcast(-1)')
expect(configuratorServer).toContain('through the internal runtime refresh')
expect(configuratorClient).toContain(
'Bridge.Callbacks.Trigger("sky_phone:configurator:runtime"',
)
expect(configuratorClient).toContain(
'apply_runtime_table(Config[key], value)',
)
expect(configuratorClient).not.toContain('clear_runtime_table')
expect(configuratorClient).toContain(
'TriggerEvent("sky_phone:configurator:updated"',
)
expect(phoneClient).toMatch(
/AddEventHandler\("sky_phone:configurator:updated"[\s\S]*?SkyPhoneLocales\.Resolve\(Config\.Bridge\.Locale\)[\s\S]*?SkyPhoneApps\.SendCatalog\(\)[\s\S]*?type = "device:updated"/,
)
expect(server).toContain(
'AddEventHandler("sky_phone:configurator:serverUpdated"',
)
expect(phoneServer).toContain('register_configured_phone_item()')
expect(phoneServer).toContain(
'AddEventHandler("sky_phone:configurator:serverUpdated"',
)
expect(simServer).toContain('refresh_sim_types()')
expect(simServer).toContain(
'AddEventHandler("sky_phone:configurator:serverUpdated"',
)
expect(mediaImportServer).toContain('local website = {}')
expect(mediaImportServer).toContain('website._adapter = adapter')
expect(mediaImportServer).not.toContain('definition._adapter = adapter')
expect(mediaImportServer).toMatch(
/AddEventHandler\("sky_phone:configurator:serverUpdated"[\s\S]*?if initialized then[\s\S]*?build_registry\(\)/,
)
expect(source).toContain('class="admin-panel-rail__configurator"')
expect(source).toContain(
'.admin-panel-rail .admin-panel-rail__configurator',
)
expect(source).toContain('<AdminConfigValueEditor')
expect(source).toContain('function configuratorFieldRepeatsSection(')
expect(source).toContain('v-if="!configuratorFieldRepeatsSection(field)"')
expect(source).toContain('class="admin-panel-config-scopes"')
expect(source).toContain("selectConfiguratorScope('config')")
expect(source).toContain("selectConfiguratorScope('media')")
expect(source).not.toContain('class="admin-panel-config-meta"')
expect(configuratorValueEditor).toContain('function addListRow()')
expect(configuratorValueEditor).toContain('function addTableField()')
expect(configuratorValueEditor).toContain(
'const canExtendTable = computed(',
)
expect(configuratorValueEditor).toContain(
'tableStructure.value.mutableKeys === true',
)
expect(configuratorValueEditor).toContain(
'const usesFixedTableLayout = computed(',
)
expect(configuratorValueEditor).toContain(
"'is-fixed-table': usesFixedTableLayout",
)
expect(configuratorValueEditor).toContain('v-if="!usesFixedTableLayout"')
expect(configuratorValueEditor).toContain(
'.config-structured-editor.is-fixed-table',
)
expect(configuratorValueEditor).toContain('v-else-if="canExtendTable"')
expect(configuratorValueEditor).toContain('function removeListRow(')
expect(configuratorValueEditor).toContain('function removeTableField(')
expect(configuratorValueEditor).toContain('function addMapEntry()')
expect(configuratorValueEditor).toContain('function updateMapKey(')
expect(configuratorValueEditor).toContain('fixedMapEntryStructure(current)')
expect(configuratorValueEditor).toContain('listStructure?.items[index]')
expect(configuratorValueEditor).toContain('function listItemStructure(')
expect(configuratorValueEditor).toContain('const listTemplate = computed(')
expect(configuratorValueEditor).toContain('function structureTypeLabel(')
expect(configuratorValueEditor).toContain(
'class="config-structured-editor__fixed-type"',
)
expect(configuratorValueEditor).toContain(
'const rootTableTabs = computed<RootTableTab[]>(',
)
expect(configuratorValueEditor).toContain(
'class="config-structured-editor__tabs"',
)
expect(configuratorValueEditor).toContain(
'class="config-structured-editor__tab-panel"',
)
expect(source).toContain(':tab-label="configuratorSubtabLabel"')
expect(source).toContain('configurator.table.subtabs.${key}')
expect(configuratorValueEditor).toContain(
'props.tabLabel?.(key, tableValue.value[key]) ?? key',
)
expect(source).toContain('v-config-input-width')
expect(configuratorValueEditor).toContain('v-config-input-width')
expect(configInputWidth).toContain("input.addEventListener('input'")
expect(configInputWidth).toContain('input.scrollWidth')
expect(source).toContain('filter: drop-shadow')
expect(source).toContain("input[type='number']::-webkit-inner-spin-button")
expect(configuratorValueEditor).toContain(
"input[type='number']::-webkit-inner-spin-button",
)
expect(source).toContain('justify-self: start')
expect(configuratorValueEditor).toContain('function tableFieldStructure(')
expect(configuratorValueEditor).toContain('function isFixedTableField(')
expect(configuratorValueEditor).toContain('function blankCollectionValue(')
expect(configuratorValueEditor).toContain('function blankFromStructure(')
expect(source).toContain("'is-structured': field.type === 'json'")
expect(configuratorValueEditor).toContain('function isStructuredValue(')
expect(configuratorValueEditor).toContain(
'class="config-structured-editor__add-field is-list"',
)
expect(configuratorValueEditor).toContain(
'class="config-structured-editor__actions"',
)
expect(configuratorValueEditor).toContain('function toggleStructuredEntry(')
expect(configuratorValueEditor).toContain(
'class="config-structured-editor__section-toggle"',
)
expect(configuratorValueEditor).toContain(':aria-expanded=')
expect(configuratorValueEditor).toContain(
'@media (prefers-reduced-motion: reduce)',
)
expect(configuratorValueEditor).toContain(
'.config-structured-editor__property.has-structured-value',
)
expect(configuratorValueEditor).not.toContain('<textarea')
})
})
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -328,7 +328,10 @@ onBeforeUnmount(() => {
class="app-icon"
:class="[
app.iconClass,
{ 'app-icon--image': !iconFailed && app.id !== 'calendar' },
{
'app-icon--image':
!iconFailed && Boolean(app.iconImage) && app.id !== 'calendar',
},
]"
:style="iconStyle"
>
@@ -337,7 +340,7 @@ onBeforeUnmount(() => {
<b>{{ calendarDay }}</b>
</span>
<img
v-else-if="!iconFailed"
v-else-if="!iconFailed && app.iconImage"
:src="app.iconImage"
alt=""
draggable="false"
+1
View File
@@ -748,6 +748,7 @@ export function getPhoneAppLabel(
}
export function isPhoneAppRemovable(app: PhoneAppDefinition): boolean {
if (app.adminOnly) return false
return app.kind === 'external'
? app.removable && !app.defaultInstalled
: !DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) &&
@@ -0,0 +1,26 @@
import type { Directive } from 'vue'
const inputListeners = new WeakMap<HTMLInputElement, () => void>()
const MINIMUM_INPUT_WIDTH = 42
function syncInputWidth(input: HTMLInputElement): void {
input.style.width = '1px'
input.style.width = `${Math.max(MINIMUM_INPUT_WIDTH, Math.ceil(input.scrollWidth) + 2)}px`
}
export const vConfigInputWidth: Directive<HTMLInputElement> = {
mounted(input) {
const listener = () => syncInputWidth(input)
inputListeners.set(input, listener)
input.addEventListener('input', listener)
syncInputWidth(input)
},
updated(input) {
syncInputWidth(input)
},
beforeUnmount(input) {
const listener = inputListeners.get(input)
if (listener) input.removeEventListener('input', listener)
inputListeners.delete(input)
},
}
+83
View File
@@ -0,0 +1,83 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
function source(path: string): string {
return readFileSync(new URL(path, import.meta.url), 'utf8')
}
const config = source('../../sky_phone/config/config.lua')
const configDefault = source('../../sky_phone/source/shared/config_default.lua')
const framework = source('../../sky_phone/source/bridge/server/framework.lua')
const qbox = source('../../sky_phone/source/bridge/server/frameworks/qbox.lua')
const configurator = source(
'../../sky_phone/source/server/phone_configurator.lua',
)
const configuratorFixture = source('../testserver/configurator-fixture.cjs')
describe('fixed server permissions', () => {
it('defines every protected phone capability only in config.lua', () => {
expect(config).toContain('Config.CommandPermissions = {')
for (const permission of [
'phonepanel',
'phonetestdata',
'fliptokverify',
'picstagramverify',
'picstagramadmin',
]) {
expect(config).toMatch(new RegExp(`\\s${permission} = \\{`))
}
expect(config).not.toContain('AdminGroups =')
expect(configDefault).not.toContain('Config.PhoneConfigurator')
expect(configDefault).not.toContain('Config.CommandPermissions')
expect(configDefault).not.toContain('AdminGroups =')
})
it('keeps fixed permissions outside SQL and removes legacy group fields', () => {
expect(configurator).toContain('key ~= "CommandPermissions"')
expect(configurator).toContain('if key ~= "CommandPermissions" then')
expect(configuratorFixture).toContain('delete config.CommandPermissions')
for (const path of [
'AdminPanel.AdminGroups',
'TestData.AdminGroups',
'FlipTok.AdminGroups',
'Picstagram.AdminGroups',
]) {
expect(configurator).toContain(`["${path}"] = true`)
}
expect(configurator).toContain(
'Phone Configurator enabled: file-based settings from config.lua',
)
expect(configurator).toContain(
'(except Config.CommandPermissions) and media.lua are disabled',
)
})
it('authorizes Qbox through ACE before retaining framework group support', () => {
expect(framework).toContain(
'local groups = Config.CommandPermissions[permission]',
)
expect(qbox).toContain(
'if IsPlayerAceAllowed(tostring(source), group) then',
)
expect(qbox.indexOf('IsPlayerAceAllowed')).toBeLessThan(
qbox.indexOf('exports.qbx_core:HasGroup'),
)
})
it('uses stable permission identifiers for every protected operation', () => {
const expectations = [
['../../sky_phone/source/server/admin.lua', 'phonepanel'],
['../../sky_phone/source/server/testdata.lua', 'phonetestdata'],
['../../sky_phone/source/server/fliptok.lua', 'fliptokverify'],
['../../sky_phone/source/server/picstagram.lua', 'picstagramverify'],
['../../sky_phone/source/server/picstagram.lua', 'picstagramadmin'],
] as const
for (const [path, permission] of expectations) {
expect(source(path)).toContain(
`Bridge.Framework.HasPermission(source, "${permission}")`,
)
}
})
})
+75 -15
View File
@@ -3,17 +3,27 @@ import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const readResourceFile = (path: string) =>
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8').replace(/\r\n/g, '\n')
readFileSync(
new URL(`../../sky_phone/${path}`, import.meta.url),
'utf8',
).replace(/\r\n/g, '\n')
const readFrontendFile = (path: string) =>
readFileSync(new URL(path, import.meta.url), 'utf8')
const inventoryAdapters = [
['jaksam', 'source/bridge/server/inventory/jaksam.lua'],
['ox', 'source/bridge/server/inventory/ox.lua'],
['qb', 'source/bridge/server/inventory/qb.lua'],
['lj', 'source/bridge/server/inventory/qb.lua'],
['qs', 'source/bridge/server/inventory/qs.lua'],
['ps', 'source/bridge/server/inventory/ps.lua'],
['codem', 'source/bridge/server/inventory/codem.lua'],
['tgiann', 'source/bridge/server/inventory/tgiann.lua'],
['core', 'source/bridge/server/inventory/core.lua'],
['jpr', 'source/bridge/server/inventory/jpr.lua'],
['origen', 'source/bridge/server/inventory/origen.lua'],
['ak47', 'source/bridge/server/inventory/ak47.lua'],
['one', 'source/bridge/server/inventory/one.lua'],
['mf', 'source/bridge/server/inventory/mf.lua'],
['smx', 'source/bridge/server/inventory/smx.lua'],
['hex', 'source/bridge/server/inventory/esx.lua'],
@@ -34,22 +44,38 @@ describe('phone inventory contracts', () => {
const phoneServer = readResourceFile('source/server/phone.lua')
expect(phoneServer).toContain(
'Bridge.Inventory.RegisterUsableItem(Config.Phone.Item, open_phone)',
'Bridge.Inventory.RegisterUsableItem(item_name, function(...)',
)
expect(phoneServer).toContain('if Config.Phone.Item == item_name then')
expect(phoneServer).toContain(
'if not Bridge.Inventory.RegisterUsableItem(item_name, function(...)',
)
expect(phoneServer).toContain('if not usable_registered then')
})
it('auto-detects HEX and limits count-based ESX inventories to metadata-free modes', () => {
it('auto-detects registered inventories and forces metadata-free adapters into compatible modes', () => {
const inventoryBridge = readResourceFile(
'source/bridge/server/inventory.lua',
)
expect(inventoryBridge).toContain(
'GetResourceState("hex_4_inventory") == "started"',
'{ name = "hex", resource = "hex_4_inventory", framework = "esx", metadata = false },',
)
expect(inventoryBridge).toContain(
'GetResourceState(adapter.resource) == "started"',
)
expect(inventoryBridge).toContain('configured_inventory = adapter.name')
expect(inventoryBridge).toContain('selected_adapter.metadata == false')
expect(inventoryBridge).toContain('Config.Phone.Unique = false')
expect(inventoryBridge).toContain('Config.Sim.Enabled = false')
expect(inventoryBridge).toContain(
'does not support item metadata; unique phones and physical SIM cards were disabled automatically',
)
expect(inventoryBridge).toContain(
'AddEventHandler("sky_phone:configurator:serverUpdated"',
)
expect(inventoryBridge).not.toContain(
'cannot store unique phone or physical SIM metadata',
)
expect(inventoryBridge).toContain('configured_inventory = "hex"')
expect(inventoryBridge).toContain('Config.Phone.Unique ~= false')
expect(inventoryBridge).toContain('Config.Sim.Enabled ~= false')
})
it('provides the LB IsOpen export alias from the authoritative client state', () => {
@@ -100,9 +126,15 @@ describe('phone inventory contracts', () => {
expect(equippedNumberExport).toBeDefined()
expect(equippedNumberResolver).toBeDefined()
expect(equippedNumberExport).toContain('type(player) == "number"')
expect(equippedNumberExport).toContain('online_source_for_identifier(player)')
expect(phoneServer).toContain('Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)')
expect(phoneServer).toContain('return resolve_equipped_phone_number(player)')
expect(equippedNumberExport).toContain(
'online_source_for_identifier(player)',
)
expect(phoneServer).toContain(
'Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)',
)
expect(phoneServer).toContain(
'return resolve_equipped_phone_number(player)',
)
expect(equippedNumberExport).not.toContain('tonumber(player)')
expect(equippedNumberExport).not.toContain('equipped_phone_numbers[player]')
expect(equippedNumberResolver).not.toContain('return cached_number')
@@ -157,7 +189,9 @@ describe('phone inventory contracts', () => {
expect(phoneClient).toContain(
'TriggerEvent("sky_phone:client:phoneToggled", false)',
)
expect(phoneBridge).toContain('TriggerEvent("lb-phone:numberChanged", phone_number)')
expect(phoneBridge).toContain(
'TriggerEvent("lb-phone:numberChanged", phone_number)',
)
expect(phoneBridge).toContain('TriggerEvent("lb-phone:phoneToggled", open)')
})
@@ -210,7 +244,9 @@ describe('phone inventory contracts', () => {
expect(mediaServer).toContain(
'TriggerEvent("sky_phone:server:galleryMediaDeleted", src, phone_number, deleted_link)',
)
expect(phoneBridge).toContain('TriggerEvent("lb-phone:phoneNumberGenerated"')
expect(phoneBridge).toContain(
'TriggerEvent("lb-phone:phoneNumberGenerated"',
)
expect(phoneBridge).toContain('TriggerEvent("lb-phone:factoryReset"')
expect(phoneBridge).toContain('TriggerEvent("lb-phone:deletedFromGallery"')
})
@@ -221,8 +257,12 @@ describe('phone inventory contracts', () => {
const phoneServer = readResourceFile('source/server/phone.lua')
expect(config).toContain('Keybind = "F1"')
expect(phoneClient).toContain('refresh_phone_key_mapping = function()')
expect(phoneClient).toContain(
'RegisterKeyMapping("sky_phone_toggle", locale.Controls.OpenPhone, "keyboard", Config.Phone.Keybind)',
'RegisterKeyMapping(command_name, locale.Controls.OpenPhone, "keyboard", key_name)',
)
expect(phoneClient).toContain(
'if active_key_mapping_command == command_name then',
)
expect(phoneClient).toContain(
'Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})',
@@ -232,6 +272,26 @@ describe('phone inventory contracts', () => {
)
})
it('applies development command changes immediately without a resource restart', () => {
const phoneClient = readResourceFile('source/client/main.lua')
expect(phoneClient).toContain('local active_development_command = nil')
expect(phoneClient).toContain('refresh_development_command = function()')
expect(phoneClient).toContain(
'local command_name = Config.Phone.DevelopmentCommand and Config.Command or nil',
)
expect(phoneClient).toContain('RegisterCommand(command_name, function()')
expect(phoneClient).toContain(
'if active_development_command == command_name and Config.Phone.DevelopmentCommand then',
)
expect(phoneClient).toContain(
'TriggerEvent("chat:removeSuggestion", "/" .. active_development_command)',
)
expect(phoneClient).toMatch(
/AddEventHandler\("sky_phone:configurator:updated", function\(\)[\s\S]*?refresh_development_command\(\)/,
)
})
it('opens a running live activity with Space without affecting normal gameplay', () => {
const phoneClient = readResourceFile('source/client/main.lua')
@@ -262,7 +322,7 @@ describe('phone inventory contracts', () => {
const phoneServer = readResourceFile('source/server/phone.lua')
const migration = readResourceFile('source/server/db_migrate.lua')
expect(phoneServer).toContain('if not unique_phones then')
expect(phoneServer).toContain('if Config.Phone.Unique == false then')
expect(phoneServer).toContain('return map_character_device(source, slot)')
expect(phoneServer).toContain('FROM `sky_phone_character_devices`')
expect(phoneServer).toContain('WHERE `owner_identifier` = ?')
@@ -29,4 +29,10 @@ describe('neutral phone navigation contract', () => {
expect(navigationSource).toContain('if not installed_apps[normalized_app_id] then')
expect(navigationSource).toContain('if current_app_id ~= normalized_app_id then')
})
it('defers command-driven app routes until setup or device unlock completes', () => {
expect(appSource).toContain('if (setupRequired.value || isLocked.value)')
expect(appSource).toContain('pendingUnlockRoute.value = requestedRoute')
expect(appSource).toContain("void router.replace(requestedRoute ?? '/')")
})
})
+238
View File
@@ -0,0 +1,238 @@
import { defineStore } from 'pinia'
import type {
AdminAuditEntry,
AdminActivityResponse,
AdminBootstrap,
AdminCallActivity,
AdminConfigurator,
AdminConfiguratorChange,
AdminCredential,
AdminMessageActivity,
AdminPlayerDetail,
AdminPlayerSummary,
AdminStats,
} from '@/types/admin'
import { nuiCall, type NuiResponse } from '@/utils/nui'
const EMPTY_STATS: AdminStats = { accounts: 0, devices: 0, online: 0 }
export const useAdminStore = defineStore('admin', {
state: () => ({
actionKey: '',
activityKey: '',
audit: [] as AdminAuditEntry[],
configurator: null as AdminConfigurator | null,
configuratorLoading: false,
detailLoading: false,
error: '',
initialized: false,
loading: false,
players: [] as AdminPlayerSummary[],
revealedCredentials: {} as Record<string, AdminCredential>,
deviceActivity: {} as Record<
string,
{ calls?: AdminCallActivity[]; messages?: AdminMessageActivity[] }
>,
selectedPlayer: null as AdminPlayerDetail | null,
stats: { ...EMPTY_STATS },
}),
actions: {
async load(): Promise<boolean> {
this.loading = true
const response = await nuiCall<AdminBootstrap>('admin:bootstrap')
this.loading = false
if (!response.success || !response.data) {
this.error = response.error ?? 'request_failed'
return false
}
this.players = response.data.players
this.stats = response.data.stats
this.audit = response.data.audit
this.error = ''
this.initialized = true
return true
},
async openPlayer(source: number): Promise<boolean> {
this.detailLoading = true
this.revealedCredentials = {}
const response = await nuiCall<AdminPlayerDetail>('admin:player', {
source,
})
this.detailLoading = false
if (!response.success || !response.data) {
this.error = response.error ?? 'request_failed'
return false
}
this.selectedPlayer = response.data
this.error = ''
return true
},
closePlayer(): void {
this.selectedPlayer = null
this.revealedCredentials = {}
},
async saveApps(
source: number,
imei: string,
revision: number,
changes: Array<{ appId: string; installed: boolean }>,
): Promise<NuiResponse<AdminPlayerDetail>> {
this.actionKey = `${imei}:save`
const response = await nuiCall<AdminPlayerDetail>('admin:save-apps', {
changes,
imei,
revision,
source,
})
this.actionKey = ''
if (response.success && response.data) {
this.selectedPlayer = response.data
this.error = ''
} else {
this.error = response.error ?? 'request_failed'
}
return response
},
async revealPassword(
source: number,
imei: string,
): Promise<NuiResponse<AdminCredential>> {
this.actionKey = `${imei}:password`
const response = await nuiCall<AdminCredential>('admin:reveal-password', {
imei,
source,
})
this.actionKey = ''
if (response.success && response.data) {
this.revealedCredentials[imei] = response.data
this.error = ''
} else {
this.error = response.error ?? 'request_failed'
}
return response
},
async loadActivity(
source: number,
imei: string,
kind: 'messages' | 'calls',
): Promise<boolean> {
this.activityKey = `${imei}:${kind}`
const response = await nuiCall<AdminActivityResponse>('admin:activity', {
imei,
kind,
source,
})
this.activityKey = ''
if (!response.success || !response.data) {
this.error = response.error ?? 'request_failed'
return false
}
const activity = this.deviceActivity[imei] ?? {}
if (response.data.kind === 'messages') {
activity.messages = response.data.entries
} else {
activity.calls = response.data.entries
}
this.deviceActivity[imei] = activity
this.error = ''
return true
},
async loadConfigurator(): Promise<boolean> {
this.configuratorLoading = true
const response = await nuiCall<AdminConfigurator>('admin:configurator')
this.configuratorLoading = false
if (!response.success || !response.data) {
this.error = response.error ?? 'request_failed'
return false
}
this.configurator = response.data
this.error = ''
return true
},
async saveConfigurator(
changes: AdminConfiguratorChange[],
): Promise<NuiResponse<AdminConfigurator>> {
const current = this.configurator
if (!current) return { error: 'request_failed', success: false }
this.actionKey = 'configurator:save'
const response = await nuiCall<AdminConfigurator>(
'admin:save-configurator',
{
changes,
revision: current.revision,
},
)
this.actionKey = ''
if (response.success && response.data) {
this.configurator = response.data
this.error = ''
} else {
if (response.data) this.configurator = response.data
this.error = response.error ?? 'request_failed'
}
return response
},
async resetPasscode(
source: number,
imei: string,
): Promise<NuiResponse<AdminPlayerDetail>> {
this.actionKey = `${imei}:reset-passcode`
const response = await nuiCall<AdminPlayerDetail>(
'admin:reset-passcode',
{ imei, source },
)
this.actionKey = ''
if (response.success && response.data) {
this.selectedPlayer = response.data
delete this.revealedCredentials[imei]
this.error = ''
} else {
this.error = response.error ?? 'request_failed'
}
return response
},
async changeNumber(
source: number,
imei: string,
phoneNumber: string,
): Promise<NuiResponse<AdminPlayerDetail>> {
this.actionKey = `${imei}:change-number`
const response = await nuiCall<AdminPlayerDetail>('admin:change-number', {
imei,
phoneNumber,
source,
})
this.actionKey = ''
if (response.success && response.data) {
this.selectedPlayer = response.data
delete this.deviceActivity[imei]
this.error = ''
} else {
this.error = response.error ?? 'request_failed'
}
return response
},
async factoryReset(
source: number,
imei: string,
): Promise<NuiResponse<AdminPlayerDetail>> {
this.actionKey = `${imei}:factory-reset`
const response = await nuiCall<AdminPlayerDetail>('admin:factory-reset', {
imei,
source,
})
this.actionKey = ''
if (response.success && response.data) {
this.selectedPlayer = response.data
delete this.deviceActivity[imei]
delete this.revealedCredentials[imei]
this.error = ''
} else {
this.error = response.error ?? 'request_failed'
}
return response
},
},
})
+7
View File
@@ -75,6 +75,13 @@ describe('app store', () => {
}
})
it('drops the retired admin app from persisted phone layouts', () => {
const apps = useAppStoreStore()
apps.hydrate({ claimedApps: ['admin'] })
expect(apps.homeLayout.grid).not.toContain('admin')
})
it('migrates current layouts so dock apps are not repeated in the grid', () => {
const apps = useAppStoreStore()
+16 -12
View File
@@ -58,11 +58,12 @@ function getDefaultDockIds(): LaunchablePhoneAppId[] {
}
function getDefaultInstalledIds(): LaunchablePhoneAppId[] {
return PHONE_APPS.filter((app) =>
isExternalPhoneApp(app)
return PHONE_APPS.filter((app) => {
if (app.adminOnly) return false
return isExternalPhoneApp(app)
? app.defaultInstalled
: DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id),
).map((app) => app.id)
: DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id)
}).map((app) => app.id)
}
function isProtectedHomeApp(appId: LaunchablePhoneAppId): boolean {
@@ -237,13 +238,15 @@ export const useAppStoreStore = defineStore('app-store', {
layoutVersion === 5 ||
layoutVersion === 6
this.claimedApps = Array.isArray(data?.claimedApps)
? data.claimedApps.filter(
(id): id is LaunchablePhoneAppId =>
typeof id === 'string' &&
(isPhoneAppId(id) ||
(supportsPersistedExternalApps &&
isValidExternalPhoneAppId(id))),
)
? data.claimedApps.filter((id): id is LaunchablePhoneAppId => {
if (typeof id !== 'string') return false
const app = getPhoneApp(id)
if (app?.adminOnly) return false
return (
isPhoneAppId(id) ||
(supportsPersistedExternalApps && isValidExternalPhoneAppId(id))
)
})
: []
this.uninstalledApps = Array.isArray(data?.uninstalledApps)
? data.uninstalledApps.filter((id): id is LaunchablePhoneAppId => {
@@ -308,9 +311,10 @@ export const useAppStoreStore = defineStore('app-store', {
}
},
isInstalled(appId: LaunchablePhoneAppId): boolean {
const app = getPhoneApp(appId)
if (app?.adminOnly) return false
if (this.uninstalledApps.includes(appId)) return false
if (this.claimedApps.includes(appId)) return true
const app = getPhoneApp(appId)
if (!app) return false
return isExternalPhoneApp(app)
? app.defaultInstalled
+102 -25
View File
@@ -10,14 +10,22 @@ type LuaToken = {
}
const frontendSourceDirectory = fileURLToPath(new URL('../', import.meta.url))
const englishLocaleSource = readFileSync(
new URL('../../../sky_phone/config/locales/en.lua', import.meta.url),
'utf8',
const localeDirectory = fileURLToPath(
new URL('../../../sky_phone/config/locales/', import.meta.url),
)
const germanLocaleSource = readFileSync(
new URL('../../../sky_phone/config/locales/de.lua', import.meta.url),
'utf8',
const localeSources = new Map(
readdirSync(localeDirectory, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.lua'))
.map((entry) => [
entry.name.replace(/\.lua$/, ''),
readFileSync(join(localeDirectory, entry.name), 'utf8'),
]),
)
const englishLocaleSource = localeSources.get('en')
const germanLocaleSource = localeSources.get('de')
if (!englishLocaleSource || !germanLocaleSource) {
throw new Error('The bundled English and German phone locales are required.')
}
const phoneStoreSource = readFileSync(
new URL('./phone.ts', import.meta.url),
'utf8',
@@ -118,6 +126,29 @@ function collectLuaLocaleValues(source: string): Map<string, string> {
}
parseTable([])
while (position < tokens.length) {
const assignment = tokens.findIndex(
(token, index) => index >= position && token.kind === '=',
)
if (assignment < 0) break
const nui = tokens.findIndex(
(token, index) =>
index >= position && index < assignment && token.value === 'Nui',
)
if (nui < 0) {
position = assignment + 1
continue
}
const path = [
'Nui',
...tokens
.slice(nui + 1, assignment)
.filter((token) => token.kind === 'word')
.map((token) => token.value),
]
position = assignment + 1
parseValue(path)
}
return values
}
@@ -125,6 +156,10 @@ function collectPlaceholders(value: string): string[] {
return [...new Set(value.match(/\{[A-Za-z0-9_]+\}/g) ?? [])].sort()
}
function collectNumberTokens(value: string): string[] {
return value.match(/\d+/g) ?? []
}
function collectDefaultLocalePaths(source: string): Set<string> {
const ast = ts.createSourceFile(
'phone.ts',
@@ -200,10 +235,26 @@ function collectFrontendFiles(directory: string): string[] {
}
describe('phone locale contract', () => {
const englishValues = collectLuaLocaleValues(englishLocaleSource)
const germanValues = collectLuaLocaleValues(germanLocaleSource)
const localeValues = new Map(
[...localeSources].map(([locale, source]) => [
locale,
collectLuaLocaleValues(source),
]),
)
const englishValues = localeValues.get('en')!
const germanValues = localeValues.get('de')!
const translatedLocaleValues = [...localeValues].filter(
([locale]) => locale !== 'en',
)
const englishPaths = new Set(englishValues.keys())
it.each([...localeSources])(
'registers the %s locale under its file name',
(locale, source) => {
expect(source.match(/^Locales\["([^"]+)"\]/)?.[1]).toBe(locale)
},
)
it('keeps every bundled frontend fallback in en.lua', () => {
const missing = [...collectDefaultLocalePaths(phoneStoreSource)].filter(
(path) => !englishPaths.has(`Nui.${path}`),
@@ -248,26 +299,52 @@ describe('phone locale contract', () => {
}
})
it('keeps German structurally aligned with English', () => {
const germanPaths = new Set(germanValues.keys())
it.each(translatedLocaleValues)(
'keeps %s structurally aligned with English',
(_locale, values) => {
expect([...values.keys()].sort()).toEqual([...englishPaths].sort())
},
)
expect([...germanPaths].sort()).toEqual([...englishPaths].sort())
})
it.each(translatedLocaleValues)(
'keeps %s interpolation placeholders aligned with English',
(_locale, values) => {
const mismatches = [...englishValues].flatMap(
([path, englishValue]) => {
const translatedValue = values.get(path)
return translatedValue !== undefined &&
JSON.stringify(collectPlaceholders(translatedValue)) !==
JSON.stringify(collectPlaceholders(englishValue))
? [
`${path}: ${collectPlaceholders(englishValue).join(', ')} != ${collectPlaceholders(translatedValue).join(', ')}`,
]
: []
},
)
it('keeps German interpolation placeholders aligned with English', () => {
const mismatches = [...englishValues].flatMap(([path, englishValue]) => {
const germanValue = germanValues.get(path)
return germanValue !== undefined &&
JSON.stringify(collectPlaceholders(germanValue)) !==
JSON.stringify(collectPlaceholders(englishValue))
? [
`${path}: ${collectPlaceholders(englishValue).join(', ')} != ${collectPlaceholders(germanValue).join(', ')}`,
]
: []
})
expect(mismatches).toEqual([])
},
)
expect(mismatches).toEqual([])
})
it.each(translatedLocaleValues)(
'keeps %s numeric source values intact',
(_locale, values) => {
const mismatches = [...englishValues].flatMap(([path, englishValue]) => {
const expected = collectNumberTokens(englishValue)
if (!expected.length) return []
const remaining = collectNumberTokens(values.get(path) ?? '')
for (const token of expected) {
const index = remaining.indexOf(token)
if (index >= 0) remaining.splice(index, 1)
else return [`${path}: missing numeric token ${token}`]
}
return []
})
expect(mismatches).toEqual([])
},
)
it('keeps standard app names German and custom game names unchanged', () => {
const standardAppNames = {
+593 -39
View File
@@ -38,6 +38,7 @@ export type PhoneOpenPayload = {
locales?: LocaleTree
memos?: DeviceBootstrap['memos']
notes?: DeviceBootstrap['notes']
phoneNumberFormat?: DeviceBootstrap['phoneNumberFormat']
player?: DeviceBootstrap['player']
security?: DeviceSecurity
token?: string
@@ -638,7 +639,8 @@ const cryptoFallbackLocales = {
invalid_handle: 'Use 320 letters, numbers, dots or underscores.',
handle_taken: 'That VaultX handle is already taken.',
profile_exists: 'This character already owns a VaultX profile.',
invalid_password: 'Password must be 872 characters.',
invalid_password:
'Use 872 characters with uppercase, lowercase, a number, and a special character.',
password_mismatch: 'The passwords do not match.',
accept_terms: 'Confirm that this is a fictional in-game wallet.',
invalid_credentials: 'The password is incorrect.',
@@ -805,7 +807,403 @@ const citywarnFallbackLocales = {
},
}
const adminPanelFallbackLocales = {
name: 'Phone Admin',
subtitle: 'Administration',
navigation: 'Admin navigation',
refresh: 'Refresh admin data',
loading: 'Loading protected data...',
tabs: {
overview: 'Overview',
players: 'Players',
devices: 'Devices',
apps: 'Apps',
accounts: 'Accounts',
messages: 'Messages',
calls: 'Calls',
moderation: 'Moderation',
audit: 'Audit',
configurator: 'Phone configurator',
},
overview: {
eyebrow: 'Server',
title: 'Dashboard',
body: 'Players, devices, apps, and phone data.',
stats: 'Server phone statistics',
online: 'Online',
devices: 'Devices',
accounts: 'Accounts',
audit: 'Audit entries',
control: 'Navigation',
features: 'Modules',
featuresBody: 'Open an administration module.',
recent: 'Recent activity',
playerFeature: 'Identity, finances, job, and duty',
deviceFeature: 'IMEI, SIM, number, and activity',
appFeature: 'Install or remove phone apps',
accountFeature: 'Account access and protected credentials',
messageFeature: 'Review recent SMS activity',
callFeature: 'Review recent call activity',
moderationFeature: 'Reset access, number, or device data',
auditFeature: 'Review sensitive admin actions',
configuratorFeature: 'Manage config.lua and media.lua through SQL',
},
appearance: {
eyebrow: 'Appearance',
title: 'Interface',
body: 'Personalize colors and typography across the admin workspace.',
colors: {
emerald: 'Emerald',
blue: 'Blue',
violet: 'Violet',
orange: 'Orange',
red: 'Red',
},
controls: {
customColor: 'Custom color',
customColorBody: 'Choose any RGB accent or enter its channel values.',
red: 'R',
green: 'G',
blue: 'B',
hex: 'HEX color',
value: 'value',
fontFamily: 'Font family',
fontFamilyBody: 'Choose the typeface used by the complete admin panel.',
fontSize: 'Font size',
fontSizeBody: 'Scale text and controls for comfortable readability.',
fonts: {
inter: 'Inter',
system: 'System',
classic: 'Classic',
verdana: 'Verdana',
tahoma: 'Tahoma',
trebuchet: 'Trebuchet',
georgia: 'Georgia',
mono: 'Monospace',
},
},
},
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',
subtabs: {
Dictionaries: 'Dictionaries',
Clips: 'Clips',
Transforms: 'Transforms',
RateLimits: 'Rate Limits',
Publishers: 'Publishers',
ExternalPingResources: 'External Ping Resources',
Markets: 'Markets',
TrustedAdapters: 'Trusted Adapters',
AllowedDisappearTimers: 'Allowed Disappear Timers',
MusicTracks: 'Music Tracks',
VehicleImages: 'Vehicle Images',
Custom: 'Custom',
Valet: 'Valet',
AutoPriority: 'Automatic Priority',
Camera: 'Camera',
Categories: 'Categories',
Districts: 'Districts',
PhotoGradients: 'Photo Gradients',
LbPhone: 'LB Phone',
Tracks: 'Tracks',
Props: 'Props',
CustomLocations: 'Custom Locations',
Animation: 'Animation',
ReportReasons: 'Report Reasons',
DisplayName: 'Display Name',
Hud: 'HUD',
Badge: 'Badge',
LockedChannels: 'Locked Channels',
NumberGroups: 'Number Groups',
CustomFare: 'Custom Fare',
DriverJobs: 'Driver Jobs',
Services: 'Services',
QuickLocations: 'Quick Locations',
AllowedJobs: 'Allowed Jobs',
Websites: 'Websites',
},
addRow: 'Add row',
addField: 'Add field',
remove: 'Remove',
emptyList: 'No rows yet. Add the first row with plus.',
emptyTable: 'No fields yet. Add the first key below.',
keyPlaceholder: 'New key',
convertToList: 'Use list',
convertToMap: 'Use typed key table',
convertToTable: 'Use key table',
types: {
string: 'Text',
number: 'Number',
boolean: 'Switch',
list: 'List',
table: 'Table',
},
},
},
players: {
eyebrow: 'Active sessions',
title: 'Online players',
online: 'Online now',
empty: 'No players found',
emptyBody: 'Adjust the search or refresh the live player list.',
},
search: {
players: 'Search name, ID, job, or number',
apps: 'Search apps',
clear: 'Clear search',
},
detail: {
character: 'Character profile',
data: 'Player data overview',
cash: 'Cash',
bank: 'Bank',
job: 'Job',
duty: 'Duty',
onDuty: 'On duty',
offDuty: 'Off duty',
identity: 'Identity',
playerData: 'Player data',
identifier: 'Character identifier',
birthdate: 'Birthdate',
grade: 'Job grade',
unknown: 'Unknown',
},
devices: {
eyebrow: 'Device control',
title: 'Phones',
body: 'Inspect every phone assigned to the selected player.',
choose: 'Choose phone',
empty: 'No phone found',
emptyBody: 'This player currently has no phone device that can be managed.',
noNumber: 'No phone number',
noSim: 'No SIM',
imei: 'IMEI',
updated: 'Last activity',
apps: 'Claimed apps',
account: 'Linked account',
},
credentials: {
eyebrow: 'Protected data',
title: 'Credentials',
email: 'iFruit email',
password: 'iFruit password',
reveal: 'Reveal',
copy: 'Copy password',
copied: 'Password copied.',
noAccount: 'No iFruit account is linked to this phone.',
passcode: 'Device passcode',
passcodeHashed: '{length}-digit PIN · securely hashed and not recoverable',
passcodeDisabled: 'No passcode configured',
revealTitle: 'Reveal protected password?',
revealBody:
'This action is server-authorized, rate-limited, and written to the admin audit log.',
cancel: 'Cancel',
confirmReveal: 'Reveal password',
},
apps: {
eyebrow: 'Remote management',
title: 'App access',
description:
'Stage app access for this device. Nothing changes until you save.',
installed: 'Installed',
available: 'Available',
protected: 'System app',
changes: '{count} pending changes',
},
activity: {
protected: 'Protected activity',
messagesTitle: 'Messages',
messagesBody: 'Recent SMS activity for the selected SIM.',
callsTitle: 'Calls',
callsBody: 'Recent call activity for the selected SIM.',
loading: 'Loading activity...',
incoming: 'Incoming',
outgoing: 'Outgoing',
mediaMessage: '{type} message',
noMessages: 'No message activity found.',
noCalls: 'No call activity found.',
status: {
completed: 'Completed',
missed: 'Missed',
rejected: 'Rejected',
busy: 'Busy',
unanswered: 'Unanswered',
cancelled: 'Cancelled',
failed: 'Failed',
ringing: 'Ringing',
},
},
moderation: {
eyebrow: 'Device administration',
title: 'Moderation actions',
body: 'Every action is server-authorized, rate-limited, and audited.',
resetPasscode: 'Reset passcode',
resetPasscodeBody: 'Remove the device PIN and clear failed attempts.',
changeNumber: 'Change number',
changeNumberBody: 'Assign a new unique number to the current SIM.',
factoryReset: 'Factory reset',
factoryResetBody: 'Clear local device data and disconnect the account.',
saveFirst: 'Save or discard pending app changes first.',
phoneNumber: 'New phone number',
phoneNumberPlaceholder: 'Enter the full configured number',
typeToConfirm: 'Type {word} to confirm the factory reset.',
confirmWord: 'RESET',
cancel: 'Cancel',
'reset-passcodeSuccess': 'Passcode reset.',
'change-numberSuccess': 'Phone number changed.',
'factory-resetSuccess': 'Phone factory reset completed.',
dialogs: {
'reset-passcodeTitle': 'Reset device passcode?',
'reset-passcodeBody':
'The player can unlock this phone without the previous PIN afterward.',
'change-numberTitle': 'Change phone number?',
'change-numberBody':
'The new number must match the configured server number format and be unique.',
'factory-resetTitle': 'Factory reset this phone?',
'factory-resetBody':
'This clears local device data, app settings, security, and the linked account. This cannot be undone.',
},
confirm: {
'reset-passcode': 'Reset passcode',
'change-number': 'Change number',
'factory-reset': 'Factory reset',
},
},
editor: {
brand: 'SKY PHONE',
workspace: 'ADMIN',
players: 'Player directory',
audit: 'Audit log',
selectPlayer:
'Select a player to inspect identity, devices, credentials, and app access.',
save: 'Save changes',
saveHint: 'Apply pending changes',
saved: 'Changes saved.',
unsaved: 'Unsaved changes',
close: 'Close admin panel',
refresh: 'Refresh live data',
online: 'LIVE',
profile: 'PROFILE',
financial: 'FINANCIAL',
device: 'DEVICE',
security: 'SECURITY',
noAutoSave: 'Manual save',
noAutoSaveBody: 'Changes stay local until the green check is pressed.',
discardTitle: 'Discard unsaved changes?',
discardBody:
'Your staged app or configuration changes have not been saved.',
keepEditing: 'Keep editing',
discard: 'Discard changes',
saveFailed: 'Some changes could not be saved.',
noSelection: 'No player selected',
},
audit: {
eyebrow: 'Accountability',
title: 'Audit trail',
body: 'Sensitive reveals and remote app changes are recorded here.',
empty: 'No admin actions yet',
emptyBody: 'Protected actions will appear here after they are performed.',
by: '{actor} · target ID {target}',
actions: {
grant_app: 'App installed',
revoke_app: 'App removed',
reveal_account_password: 'Password revealed',
view_messages: 'Messages viewed',
view_calls: 'Calls viewed',
reset_passcode: 'Passcode reset',
change_number: 'Phone number changed',
factory_reset: 'Phone factory reset',
save_configuration: 'Configuration saved',
},
},
errors: {
not_authorized: 'You do not have access to the admin panel.',
rate_limited: 'Too many admin requests. Please wait.',
player_unavailable: 'That player is no longer online.',
device_not_owned: 'That phone no longer belongs to the selected player.',
invalid_app: 'That app is not registered on the server.',
app_protected: 'This system app cannot be removed.',
revision_conflict:
'The data changed in the meantime. Reopen the section and try again.',
configurator_disabled: 'Enable the phone configurator in config.lua first.',
invalid_field: 'That configuration field is no longer available.',
invalid_value: 'A configuration value is invalid.',
account_not_found: 'No iFruit account is linked to this phone.',
invalid_phone_number:
'Enter a phone number in the configured server format.',
phone_number_unchanged: 'This SIM already uses that phone number.',
phone_number_taken: 'That phone number is already assigned.',
no_sim: 'This phone has no SIM that can be changed.',
passcode_not_set: 'This phone has no passcode configured.',
device_not_found: 'This phone no longer exists.',
metadata_unsupported: 'The phone inventory metadata could not be updated.',
invalid_request: 'The admin request was invalid.',
request_failed: 'The admin request failed.',
default: 'The admin panel is temporarily unavailable.',
},
}
const defaultLocales: LocaleTree = {
AdminPanel: adminPanelFallbackLocales,
Apps: {
citywarn: citywarnFallbackLocales,
crypto: cryptoFallbackLocales,
@@ -2086,7 +2484,7 @@ const defaultLocales: LocaleTree = {
to: 'To:',
connectPrivately: 'Connect privately',
newChatBody:
'Enter an exact Dark-ID or invitation code. Unknown identities require confirmation.',
"Enter another person's Dark-ID or invitation code. You can share your own ID from your profile.",
darkIdOrInvite: 'Dark-ID or invitation code',
continue: 'Continue',
contacts: 'DarkChat Contacts',
@@ -2432,46 +2830,190 @@ const defaultLocales: LocaleTree = {
'neon-drop': 'Neon block-dropping puzzle',
},
previews: {
citywarn: { first: 'Live alerts', second: 'Safety zones', third: 'Incident updates' },
crypto: { first: 'Synthetic markets', second: 'Portfolio', third: 'Wallet transfers' },
health: { first: 'Activity rings', second: 'Medical ID', third: 'Health records' },
'weazel-news': { first: 'Top stories', second: 'Local reports', third: 'Breaking news' },
companies: { first: 'Business directory', second: 'Job requests', third: 'Services' },
music: { first: 'Now playing', second: 'Playlists', third: 'Music library' },
picstagram: { first: 'Photo feed', second: 'Stories', third: 'Profiles' },
feather: { first: 'Short posts', second: 'Following feed', third: 'Conversations' },
fliptok: { first: 'Video feed', second: 'Creator tools', third: 'Trends' },
flare: { first: 'Discover people', second: 'Matches', third: 'Live moments' },
calendar: { first: 'Upcoming events', second: 'Day planner', third: 'Reminders' },
radio: { first: 'Live channels', second: 'Team radio', third: 'Favorites' },
'local-pages': { first: 'Local pages', second: 'Reviews', third: 'City discovery' },
crewlink: { first: 'Crew roster', second: 'Shared locations', third: 'Coordination' },
phone: { first: 'Recent calls', second: 'Contacts', third: 'Voicemail' },
messages: { first: 'Conversations', second: 'Media sharing', third: 'Quick replies' },
darkchat: { first: 'Private chats', second: 'Secure groups', third: 'Invitations' },
garage: { first: 'Vehicle list', second: 'Parking locations', third: 'Valet' },
house: { first: 'Property access', second: 'Residents', third: 'Management' },
map: { first: 'Live navigation', second: 'Nearby places', third: 'Route guidance' },
skyride: { first: 'Ride booking', second: 'Driver tracking', third: 'Trip history' },
banking: { first: 'Account balance', second: 'Transfers', third: 'Transactions' },
billing: { first: 'Open invoices', second: 'Payment requests', third: 'Payment history' },
citywarn: {
first: 'Live alerts',
second: 'Safety zones',
third: 'Incident updates',
},
crypto: {
first: 'Synthetic markets',
second: 'Portfolio',
third: 'Wallet transfers',
},
health: {
first: 'Activity rings',
second: 'Medical ID',
third: 'Health records',
},
'weazel-news': {
first: 'Top stories',
second: 'Local reports',
third: 'Breaking news',
},
companies: {
first: 'Business directory',
second: 'Job requests',
third: 'Services',
},
music: {
first: 'Now playing',
second: 'Playlists',
third: 'Music library',
},
picstagram: {
first: 'Photo feed',
second: 'Stories',
third: 'Profiles',
},
feather: {
first: 'Short posts',
second: 'Following feed',
third: 'Conversations',
},
fliptok: {
first: 'Video feed',
second: 'Creator tools',
third: 'Trends',
},
flare: {
first: 'Discover people',
second: 'Matches',
third: 'Live moments',
},
calendar: {
first: 'Upcoming events',
second: 'Day planner',
third: 'Reminders',
},
radio: {
first: 'Live channels',
second: 'Team radio',
third: 'Favorites',
},
'local-pages': {
first: 'Local pages',
second: 'Reviews',
third: 'City discovery',
},
crewlink: {
first: 'Crew roster',
second: 'Shared locations',
third: 'Coordination',
},
phone: {
first: 'Recent calls',
second: 'Contacts',
third: 'Voicemail',
},
messages: {
first: 'Conversations',
second: 'Media sharing',
third: 'Quick replies',
},
darkchat: {
first: 'Private chats',
second: 'Secure groups',
third: 'Invitations',
},
garage: {
first: 'Vehicle list',
second: 'Parking locations',
third: 'Valet',
},
house: {
first: 'Property access',
second: 'Residents',
third: 'Management',
},
map: {
first: 'Live navigation',
second: 'Nearby places',
third: 'Route guidance',
},
skyride: {
first: 'Ride booking',
second: 'Driver tracking',
third: 'Trip history',
},
banking: {
first: 'Account balance',
second: 'Transfers',
third: 'Transactions',
},
billing: {
first: 'Open invoices',
second: 'Payment requests',
third: 'Payment history',
},
mail: { first: 'Inbox', second: 'Attachments', third: 'Mailboxes' },
notes: { first: 'Notes', second: 'Checklists', third: 'Pinned ideas' },
memos: { first: 'Voice recordings', second: 'Playback', third: 'Favorites' },
calculator: { first: 'Basic calculation', second: 'Scientific tools', third: 'History' },
camera: { first: 'Photo mode', second: 'Video capture', third: 'Zoom controls' },
memos: {
first: 'Voice recordings',
second: 'Playback',
third: 'Favorites',
},
calculator: {
first: 'Basic calculation',
second: 'Scientific tools',
third: 'History',
},
camera: {
first: 'Photo mode',
second: 'Video capture',
third: 'Zoom controls',
},
clock: { first: 'World clock', second: 'Alarms', third: 'Timers' },
weather: { first: 'Current weather', second: 'Hourly forecast', third: 'Seven-day outlook' },
photos: { first: 'Media library', second: 'Albums', third: 'Shared media' },
settings: { first: 'Device controls', second: 'Privacy', third: 'Personalization' },
weather: {
first: 'Current weather',
second: 'Hourly forecast',
third: 'Seven-day outlook',
},
photos: {
first: 'Media library',
second: 'Albums',
third: 'Shared media',
},
settings: {
first: 'Device controls',
second: 'Privacy',
third: 'Personalization',
},
snake: { first: 'High score', second: 'Speed', third: 'Classic grid' },
memory: { first: 'Matched pairs', second: 'Best time', third: 'Card themes' },
'number-merge': { first: 'Highest tile', second: 'Score', third: 'Strategy grid' },
minesweeper: { first: 'Mine counter', second: 'Best time', third: 'Difficulty' },
'tower-stack': { first: 'Tower height', second: 'Perfect drops', third: 'High score' },
'sky-flappy': { first: 'Flight score', second: 'Best run', third: 'Obstacles' },
citymarkt: { first: 'Listings', second: 'Categories', third: 'Saved offers' },
'neon-drop': { first: 'Lines cleared', second: 'Level', third: 'Neon pieces' },
memory: {
first: 'Matched pairs',
second: 'Best time',
third: 'Card themes',
},
'number-merge': {
first: 'Highest tile',
second: 'Score',
third: 'Strategy grid',
},
minesweeper: {
first: 'Mine counter',
second: 'Best time',
third: 'Difficulty',
},
'tower-stack': {
first: 'Tower height',
second: 'Perfect drops',
third: 'High score',
},
'sky-flappy': {
first: 'Flight score',
second: 'Best run',
third: 'Obstacles',
},
citymarkt: {
first: 'Listings',
second: 'Categories',
third: 'Saved offers',
},
'neon-drop': {
first: 'Lines cleared',
second: 'Level',
third: 'Neon pieces',
},
},
search: {
recommended: 'Recommended',
@@ -3036,6 +3578,8 @@ const defaultLocales: LocaleTree = {
viewRides: 'View Ride Options',
change: 'Change',
requestRide: 'Request SkyRide',
playerDriverNotice:
'SkyRide matches you with real player drivers. A driver must be online and accept your request.',
serviceMeta: '{eta} min away · {seats} seats',
distanceMeters: '{distance} m',
distanceKilometers: '{distance} km',
@@ -3106,7 +3650,8 @@ const defaultLocales: LocaleTree = {
cancelled: 'Cancelled',
},
statusBody: {
searching: 'We are matching you with a nearby driver.',
searching:
'Your request is waiting for an available player driver to accept it.',
accepted: 'Your driver is preparing to pick you up.',
driver_arriving: 'Your driver is on the way to your pickup.',
arrived: 'Your driver is waiting at the pickup point.',
@@ -5188,6 +5733,15 @@ export const usePhoneStore = defineStore('phone', {
this.cameraLandscape = false
this.isOpen = false
},
setLocale(
lang: string,
locales: LocaleTree,
fallbackLocales: LocaleTree,
): void {
this.lang = lang
this.locales = locales
this.fallbackLocales = fallbackLocales
},
open(payload: PhoneOpenPayload = {}): void {
const nextImei = payload.device?.imei ?? this.device?.imei ?? null
const nextToken = payload.token ?? this.deviceSessionToken
+9 -4
View File
@@ -16,10 +16,15 @@ describe('test data seeding contracts', () => {
)
})
it('returns before registering the test-data command when disabled', () => {
expect(
testData.indexOf('if not Config.TestData.Enabled then'),
).toBeLessThan(testData.indexOf('RegisterCommand(Config.TestData.Command'))
it('refreshes the test-data command when its runtime configuration changes', () => {
expect(testData).toContain('local function refresh_test_data_command()')
expect(testData).toContain(
'active_test_data_command = Config.TestData.Enabled and Config.TestData.Command or nil',
)
expect(testData).toContain(
'AddEventHandler("sky_phone:configurator:serverUpdated", refresh_test_data_command)',
)
expect(testData).not.toContain('RegisterCommand(Config.TestData.Command')
})
it('moves an existing player SIM before attaching it to the selected phone', () => {
+179
View File
@@ -0,0 +1,179 @@
export type AdminStats = {
accounts: number
devices: number
online: number
}
export type AdminPlayerSummary = {
deviceCount: number
grade: number
identifier: string
job: string
name: string
onDuty: boolean
phoneNumber: string | null
serverName: string
source: number
}
export type AdminDevice = {
account: {
email: string
id: number
passwordAvailable: boolean
} | null
apps: {
claimed: string[]
revision: number
uninstalled: string[]
}
createdAt: string
imei: string
name: string
number: string | null
security: {
enabled: boolean
failedAttempts: number
length: number | null
lockedUntil: number
}
simRegistered: boolean
simType: string | null
updatedAt: string
}
export type AdminPlayerDetail = {
birthdate: string
devices: AdminDevice[]
firstName: string
identifier: string
job: {
grade: number
gradeLabel: string
label: string
name: string
onDuty: boolean
}
lastName: string
money: {
bank: number
cash: number
currency: string
}
name: string
serverName: string
source: number
}
export type AdminAuditEntry = {
action: string
actorName: string
createdAt: string
details: Record<string, unknown>
deviceImei: string | null
id: number
targetIdentifier: string
targetSource: number | null
}
export type AdminBootstrap = {
audit: AdminAuditEntry[]
players: AdminPlayerSummary[]
stats: AdminStats
}
export type AdminCredential = {
email: string
password: string
}
export type AdminMessageActivity = {
body: string
createdAt: string
direction: 'incoming' | 'outgoing'
id: string
messageType: string
otherNumber: string
readAt: string | null
}
export type AdminCallActivity = {
answeredAt: string | null
direction: 'incoming' | 'outgoing'
durationSeconds: number
endedAt: string | null
id: string
otherNumber: string
startedAt: string
status: string
}
export type AdminActivityResponse =
| { entries: AdminMessageActivity[]; kind: 'messages' }
| { entries: AdminCallActivity[]; kind: 'calls' }
export type AdminConfiguratorField = {
configured?: boolean
label: string
path: string
structure?: AdminConfiguratorStructure
scope: 'config' | 'media'
sensitive: boolean
type: 'boolean' | 'json' | 'number' | 'string' | 'stringOrFalse'
value: unknown
}
export type AdminConfiguratorStructure =
| {
kind: 'list'
items: AdminConfiguratorStructure[]
template?: AdminConfiguratorStructure
}
| {
fields: Record<string, AdminConfiguratorStructure>
kind: 'table'
mutableKeys?: boolean
template?: AdminConfiguratorStructure
}
| {
entries: Array<{
key: number | string
keyType: 'number' | 'string'
structure: AdminConfiguratorStructure
}>
keyType?: 'number' | 'string'
kind: 'map'
template?: AdminConfiguratorStructure
}
| {
kind: 'value'
valueType: 'boolean' | 'number' | 'string'
}
| {
kind: 'optionalString'
}
| {
kind: 'vector'
vectorType: 'vector2' | 'vector3' | 'vector4'
}
export type AdminConfiguratorSection = {
fields: AdminConfiguratorField[]
id: string
label: string
scope: 'config' | 'media'
}
export type AdminConfigurator = {
enabled: boolean
revision: number
sections: AdminConfiguratorSection[]
updatedAt: string | null
updatedBy: string | null
}
export type AdminConfiguratorChange = {
path: string
scope: 'config' | 'media'
value: unknown
}
+1
View File
@@ -68,6 +68,7 @@ export type AppLaunchOrigin = {
}
type PhoneAppDefinitionBase = {
adminOnly?: boolean
category: PhoneAppCategory
dockOrder: number | null
gridOrder: number
+2 -1
View File
@@ -1,6 +1,6 @@
import type { Note } from '@/utils/notes'
import type { MemoDto } from '@/types/memos'
import type { PhoneSim } from '@/types/phone'
import type { PhoneNumberFormat, PhoneSim } from '@/types/phone'
export type DeviceDataEntry<T = unknown> = {
payload: T
@@ -50,6 +50,7 @@ export type DeviceBootstrap = {
device: PhoneDevice
memos: MemoDto[]
notes: Note[]
phoneNumberFormat: PhoneNumberFormat
player: PhonePlayerIdentity
security: DeviceSecurity
token: string
+5
View File
@@ -1,5 +1,10 @@
export type SimType = 'registered' | 'anonymous'
export type PhoneNumberFormat = {
groups: number[]
length: number
}
export type PhoneSim = {
id: string
number: string
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import type { AdminConfiguratorStructure } from '@/types/admin'
import {
configuratorDescriptionKey,
configuratorPathName,
describeConfiguratorValue,
} from './adminConfiguratorDescription'
describe('admin configurator descriptions', () => {
it('selects specific descriptions before generic value descriptions', () => {
expect(configuratorDescriptionKey('Bridge.CallbackTimeout', 15000)).toBe(
'milliseconds',
)
expect(configuratorDescriptionKey('Media.RequestTimeoutMs', 10000)).toBe(
'milliseconds',
)
expect(configuratorDescriptionKey('Radio.AllowedJobs', [])).toBe('access')
expect(configuratorDescriptionKey('FiveManage.ApiKey', '')).toBe(
'credential',
)
})
it('describes structured values from their schema', () => {
const vector: AdminConfiguratorStructure = {
kind: 'vector',
vectorType: 'vector3',
}
const table: AdminConfiguratorStructure = {
fields: {},
kind: 'table',
}
expect(configuratorDescriptionKey('Location', {}, vector)).toBe(
'coordinates',
)
expect(configuratorDescriptionKey('Settings', {}, table)).toBe('table')
})
it('passes a readable field name to the localized template', () => {
const translate = (key: string, params?: Record<string, string>) =>
`${key}:${params?.name}`
expect(
describeConfiguratorValue(
translate,
'CustomApps.MaximumStorageBytesPerApp',
262144,
),
).toBe('configurator.descriptions.byteLimit:Maximum Storage Bytes Per App')
expect(
describeConfiguratorValue(translate, 'Radio.AllowedJobs[2]', 'police'),
).toBe('configurator.descriptions.access:Allowed Jobs #2')
})
it('humanizes subtab keys', () => {
expect(configuratorPathName('ExternalPingResources')).toBe(
'External Ping Resources',
)
})
})
@@ -0,0 +1,103 @@
import type { AdminConfiguratorStructure } from '@/types/admin'
type ConfiguratorDescriptionTranslator = (
key: string,
params?: Record<string, string>,
) => string
export type AdminConfiguratorDescribe = (
path: string,
value: unknown,
structure?: AdminConfiguratorStructure,
label?: string,
) => string
const DESCRIPTION_RULES: Array<[RegExp, string]> = [
[/(?:^|\.)(?:apikey|token|password|secret)$/i, 'credential'],
[/(?:base|manifest|image|icon)?url$/i, 'url'],
[/(?:allowed)?(?:gif|media)?hosts?$/i, 'hosts'],
[
/(?:timeout|timeoutms|milliseconds|durationms|intervalms|pollms)$/i,
'milliseconds',
],
[/(?:timeoutseconds|seconds)$/i, 'seconds'],
[/perminute$/i, 'rateLimit'],
[/(?:maximum|max).*bytes/i, 'byteLimit'],
[/(?:maximum|max).*length$|length$/i, 'textLimit'],
[/(?:distance)$/i, 'distance'],
[/(?:location|position|rotation|coords|coordinates)$/i, 'coordinates'],
[/(?:model|prop|propmodel|customprop|replacementprop)$/i, 'gameAsset'],
[/(?:dictionary|dictionaries|clip|clips|pedclip|propclip)$/i, 'animation'],
[
/(?:permissions?|admingroups?|allowedjobs?|jobs?|minimumgrade|requiredace)$/i,
'access',
],
[/(?:framework|inventory|provider|voiceprovider|adapter)$/i, 'integration'],
[/(?:path)$/i, 'path'],
[/(?:color|colour|accent)$/i, 'color'],
[
/(?:label|name|title|description|address|district|locationlabel|devicename)$/i,
'displayText',
],
[/(?:number|callernumber|numberprefix)$/i, 'phoneNumber'],
[/(?:routing)$/i, 'routing'],
[/(?:command)$/i, 'command'],
[/(?:locale)$/i, 'locale'],
[/(?:debug)$/i, 'debug'],
[/(?:enabled|active|public|verified)$/i, 'featureToggle'],
[/(?:quality|bitratekbps|volume)$/i, 'mediaQuality'],
[
/(?:pagesize|batchsize|limit|count|maxselection|maximumplayers|samples|decimals)$/i,
'amount',
],
]
export function configuratorPathName(path: string): string {
const listEntry = path.match(/^(.*)\[(\d+)\]$/)
const source = listEntry?.[1] ?? path
const segment = source.split('.').at(-1) ?? source
const name = segment
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
.replace(/([a-z\d])([A-Z])/g, '$1 $2')
.replace(/[_-]+/g, ' ')
.trim()
return listEntry ? `${name} #${listEntry[2]}` : name
}
export function configuratorDescriptionKey(
path: string,
value: unknown,
structure?: AdminConfiguratorStructure,
): string {
const segment =
path
.replace(/\[\d+\]$/, '')
.split('.')
.at(-1) ?? path
const semanticRule = DESCRIPTION_RULES.find(([pattern]) =>
pattern.test(segment),
)
if (semanticRule) return semanticRule[1]
if (structure?.kind === 'vector') return 'coordinates'
if (structure?.kind === 'list' || Array.isArray(value)) return 'list'
if (structure?.kind === 'map' || structure?.kind === 'table') return 'table'
if (structure?.kind === 'optionalString') return 'optionalText'
if (value !== null && typeof value === 'object') return 'table'
if (typeof value === 'boolean') return 'boolean'
if (typeof value === 'number') return 'number'
return 'text'
}
export function describeConfiguratorValue(
translate: ConfiguratorDescriptionTranslator,
path: string,
value: unknown,
structure?: AdminConfiguratorStructure,
label?: string,
): string {
return translate(
`configurator.descriptions.${configuratorDescriptionKey(path, value, structure)}`,
{ name: label?.trim() || configuratorPathName(path) },
)
}
+4 -1
View File
@@ -13,7 +13,10 @@ const previewModules = import.meta.glob<string>(
const APP_STORE_PREVIEW_IMAGES = Object.fromEntries(
Object.entries(previewModules).map(([path, imageUrl]) => {
const appId = path.split('/').at(-1)?.replace(/\.jpg$/, '')
const appId = path
.split('/')
.at(-1)
?.replace(/\.jpg$/, '')
if (!appId) throw new Error(`Invalid App Store preview path: ${path}`)
return [appId, imageUrl]
}),
+14 -15
View File
@@ -1,4 +1,4 @@
import { readFileSync } from 'node:fs'
import { readdirSync, readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
@@ -17,7 +17,8 @@ function escapeRegExp(value: string): string {
describe('App Store preview catalog', () => {
it('contains a real captured screenshot for every built-in store app', () => {
const storeAppIds = PHONE_APPS.filter(
(app) => app.kind !== 'external' && app.id !== 'app-store',
(app) =>
app.kind !== 'external' && app.id !== 'app-store' && !app.adminOnly,
).map((app) => app.id)
expect([...APP_STORE_PREVIEW_IMAGE_IDS].sort()).toEqual(storeAppIds.sort())
@@ -25,7 +26,8 @@ describe('App Store preview catalog', () => {
it('provides specialized preview data for every built-in store app', () => {
const storeAppIds = PHONE_APPS.filter(
(app) => app.kind !== 'external' && app.id !== 'app-store',
(app) =>
app.kind !== 'external' && app.id !== 'app-store' && !app.adminOnly,
).map((app) => app.id)
expect([...PREVIEWABLE_BUILTIN_APP_IDS].sort()).toEqual(
@@ -39,24 +41,21 @@ describe('App Store preview catalog', () => {
expect(visualSignatures.size).toBe(storeAppIds.length)
})
it('localizes every specialized preview in the fallback, English and German locales', () => {
it('localizes every specialized preview in the fallback and every configured locale', () => {
const localeDirectory = fileURLToPath(
new URL('../../../sky_phone/config/locales/', import.meta.url),
)
const localeSources = [
readFileSync(
fileURLToPath(new URL('../stores/phone.ts', import.meta.url)),
'utf8',
),
readFileSync(
fileURLToPath(
new URL('../../../sky_phone/config/locales/en.lua', import.meta.url),
...readdirSync(localeDirectory)
.filter((fileName) => fileName.endsWith('.lua'))
.sort()
.map((fileName) =>
readFileSync(`${localeDirectory}/${fileName}`, 'utf8'),
),
'utf8',
),
readFileSync(
fileURLToPath(
new URL('../../../sky_phone/config/locales/de.lua', import.meta.url),
),
'utf8',
),
]
for (const appId of PREVIEWABLE_BUILTIN_APP_IDS) {
+14 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest'
import { formatPhoneNumber, normalizePhoneNumber } from './phone'
import {
configurePhoneNumberFormat,
formatPhoneNumber,
normalizePhoneNumber,
} from './phone'
describe('phone numbers', () => {
it('normalizes formatted ten digit values', () => {
@@ -13,4 +17,13 @@ describe('phone numbers', () => {
expect(formatPhoneNumber('5551')).toBe('555 1')
expect(formatPhoneNumber(5551234567)).toBe('555 123 4567')
})
it('uses the server-provided number length and display groups', () => {
configurePhoneNumberFormat({ groups: [4, 3, 3], length: 10 })
expect(formatPhoneNumber('0171234567')).toBe('0171 234 567')
expect(normalizePhoneNumber('0171 234 567')).toBe('0171234567')
configurePhoneNumberFormat()
})
})
+45 -4
View File
@@ -1,12 +1,53 @@
import type { PhoneNumberFormat } from '@/types/phone'
export const PHONE_NUMBER_LENGTH = 10
const DEFAULT_PHONE_NUMBER_FORMAT: PhoneNumberFormat = {
groups: [3, 3, 4],
length: PHONE_NUMBER_LENGTH,
}
let phoneNumberFormat: PhoneNumberFormat = DEFAULT_PHONE_NUMBER_FORMAT
export function configurePhoneNumberFormat(value?: PhoneNumberFormat): void {
const length = value?.length
const groups = value?.groups
if (
typeof length !== 'number' ||
!Number.isInteger(length) ||
length < 1 ||
length > 24 ||
!Array.isArray(groups) ||
groups.length === 0 ||
groups.some((group) => !Number.isInteger(group) || group < 1)
) {
phoneNumberFormat = DEFAULT_PHONE_NUMBER_FORMAT
return
}
phoneNumberFormat = {
groups: [...groups],
length,
}
}
export function normalizePhoneNumber(value: string): string | null {
const digits = value.replace(/\D/g, '')
return digits.length === PHONE_NUMBER_LENGTH ? digits : null
return digits.length === phoneNumberFormat.length ? digits : null
}
export function formatPhoneNumber(value: string | number): string {
const digits = String(value).replace(/\D/g, '').slice(0, PHONE_NUMBER_LENGTH)
const groups = [digits.slice(0, 3), digits.slice(3, 6), digits.slice(6, 10)]
return groups.filter(Boolean).join(' ')
const digits = String(value)
.replace(/\D/g, '')
.slice(0, phoneNumberFormat.length)
const formatted: string[] = []
let offset = 0
for (const size of phoneNumberFormat.groups) {
const group = digits.slice(offset, offset + size)
if (!group) break
formatted.push(group)
offset += size
}
if (offset < digits.length) formatted.push(digits.slice(offset))
return formatted.join(' ')
}
+1 -1
View File
@@ -28,7 +28,7 @@ const launchStyle = computed(() => {
<template>
<div
v-if="app"
v-if="app && !app.adminOnly"
class="app-window"
:class="{ 'app-window--citywarn': app.id === 'citywarn' }"
:style="launchStyle"
+2 -1
View File
@@ -119,7 +119,7 @@ const downloadDateDescription = computed(() =>
)
const catalog = computed(() =>
PHONE_APPS.filter((app): app is LaunchablePhoneAppDefinition => {
if (!isLaunchablePhoneApp(app) || app.id === 'app-store') {
if (!isLaunchablePhoneApp(app) || app.id === 'app-store' || app.adminOnly) {
return false
}
@@ -140,6 +140,7 @@ const dailyCandidates = computed(() =>
PHONE_APPS.filter(
(app): app is LaunchablePhoneAppDefinition =>
isLaunchablePhoneApp(app) &&
!app.adminOnly &&
!isExternalPhoneApp(app) &&
app.id !== 'app-store' &&
!DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) &&
@@ -119,6 +119,8 @@ describe('VaultX crypto app contracts', () => {
expect(source).not.toContain('class="auth-panel__heading"')
expect(source).not.toContain('class="auth-action-dock"')
expect(source).not.toContain('class="password-rules"')
expect(source).toContain('class="auth-password-hint"')
expect(source).toContain("t('auth.ruleSpecial')")
expect(source).toMatch(
/\.auth-shell\s*\{[^}]*width:\s*100%;[^}]*max-width:\s*338px;/s,
)
+10
View File
@@ -824,6 +824,10 @@ onUnmounted(() => {
:size="18"
/></button></template
></SkyField>
<p v-if="authMode === 'register'" class="auth-password-hint">
{{ t('auth.ruleLength') }} · {{ t('auth.ruleMixed') }} ·
{{ t('auth.ruleNumber') }} · {{ t('auth.ruleSpecial') }}
</p>
<p v-if="formError" class="error">{{ formError }}</p>
<SkyButton block large class="auth-submit" type="submit">{{
t(
@@ -2060,6 +2064,12 @@ onUnmounted(() => {
.auth-form {
gap: 12px;
}
.auth-password-hint {
margin: -3px 4px 0;
color: rgba(255, 255, 255, 0.58);
font-size: 11px;
line-height: 15px;
}
.auth-account {
display: grid;
grid-template-columns: 40px minmax(0, 1fr) 18px;
@@ -37,10 +37,17 @@ describe('DarkChatApp Sky UI contract', () => {
expect(newChat).toContain('class="dc-recipient-field"')
expect(newChat).toContain('layout="inline"')
expect(newChat).toContain('class="dc-new-chat-contacts"')
expect(newChat).toContain("{{ t('newChatBody') }}")
expect(newChat).toContain("{{ phone.t('Common.cancel') }}")
expect(newChat).not.toContain('class="dc-hero"')
})
it('rejects the current profile identifiers before opening confirmation', () => {
expect(source).toContain('darkchat.profile?.darkId')
expect(source).toContain('darkchat.profile?.inviteCode')
expect(source).toContain("showToast(errorText('self_chat'))")
})
it('keeps the search visually compact without shrinking its wrapper', () => {
expect(source).toMatch(
/\.dc-search :deep\(\.sky-searchbar__control\),[\s\S]*?height:\s*38px;[\s\S]*?min-height:\s*38px;/,
+22
View File
@@ -427,6 +427,20 @@ function closeReport(): void {
function requestStart(value = identifier.value): void {
const clean = value.trim()
if (!clean) return
const ownIdentifiers = [
darkchat.profile?.darkId,
darkchat.profile?.inviteCode,
]
if (
ownIdentifiers.some(
(ownIdentifier) =>
ownIdentifier?.toLocaleLowerCase(phone.lang) ===
clean.toLocaleLowerCase(phone.lang),
)
) {
showToast(errorText('self_chat'))
return
}
pendingIdentifier.value = clean
safetyOpen.value = true
}
@@ -1262,6 +1276,13 @@ onBeforeUnmount(() => {
padding-top: 2px;
}
.dc-new-chat-intro {
margin: 2px 2px 12px;
color: var(--dc-muted);
font-size: 13px;
line-height: 18px;
}
.dc-recipient-field,
.dc-new-chat-contacts {
margin: 0 calc(var(--sky-page-gutter) * -1) 12px;
@@ -2071,6 +2092,7 @@ onBeforeUnmount(() => {
</template>
</SkyNavbar>
<SkyScrollArea padded class="dc-new-chat-scroll">
<p class="dc-new-chat-intro">{{ t('newChatBody') }}</p>
<SkyList class="dc-recipient-field" density="compact" flush>
<SkyField
:label="t('to')"
@@ -18,6 +18,11 @@ describe('SkyRide app layout', () => {
expect(source).toContain('skyride-history-card__distance')
})
it('explains that requests require an online player driver', () => {
expect(source).toContain("phone.t('Apps.skyride.playerDriverNotice')")
expect(source).toContain('class="skyride-player-driver-notice"')
})
it('aligns rider and activity surfaces to one content width', () => {
expect(source).toMatch(
/\.skyride-location-list,\s*\.skyride-activity-list\s*\{[^}]*margin-inline:\s*2px !important;/s,
+10
View File
@@ -848,6 +848,9 @@ onBeforeUnmount(() => {
</k-button>
</div>
</k-card>
<p class="skyride-player-driver-notice">
{{ phone.t('Apps.skyride.playerDriverNotice') }}
</p>
<k-button
large
rounded
@@ -2109,6 +2112,13 @@ onBeforeUnmount(() => {
font-weight: 650;
}
.skyride-player-driver-notice {
margin: 2px 7px 8px;
color: var(--ride-muted);
font-size: 12px;
line-height: 17px;
}
.skyride-primary :deep(svg) {
margin-left: 4px;
}
@@ -0,0 +1,669 @@
const { readFileSync } = require('node:fs')
const { resolve } = require('node:path')
class LuaTable {
constructor() {
this.entries = []
this.nextArrayIndex = 1
}
get(key) {
return this.entries.find((entry) => entry.key === key)?.value
}
set(key, value) {
const entry = this.entries.find((candidate) => candidate.key === key)
if (entry) entry.value = value
else this.entries.push({ key, value })
}
}
function tokenize(source) {
const tokens = []
let index = 0
while (index < source.length) {
const character = source[index]
if (/\s/.test(character)) {
index += 1
continue
}
if (source.startsWith('--[[', index)) {
const end = source.indexOf(']]', index + 4)
index = end < 0 ? source.length : end + 2
continue
}
if (source.startsWith('--', index)) {
const end = source.indexOf('\n', index + 2)
index = end < 0 ? source.length : end + 1
continue
}
if (character === '"' || character === "'") {
const quote = character
let value = ''
index += 1
while (index < source.length && source[index] !== quote) {
if (source[index] !== '\\') {
value += source[index]
index += 1
continue
}
index += 1
const escaped = source[index]
const escapeValues = {
a: '\u0007',
b: '\b',
f: '\f',
n: '\n',
r: '\r',
t: '\t',
v: '\u000b',
}
value += escapeValues[escaped] ?? escaped
index += 1
}
if (source[index] !== quote)
throw new Error('Unterminated Lua string in configurator fixture.')
index += 1
tokens.push({ type: 'string', value })
continue
}
if (/[A-Za-z_]/.test(character)) {
const match = source.slice(index).match(/^[A-Za-z_][A-Za-z0-9_]*/)
tokens.push({ type: 'name', value: match[0] })
index += match[0].length
continue
}
if (
/\d/.test(character) ||
(character === '.' && /\d/.test(source[index + 1]))
) {
const match = source
.slice(index)
.match(/^(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/)
tokens.push({ type: 'number', value: Number(match[0]) })
index += match[0].length
continue
}
tokens.push({ type: 'symbol', value: character })
index += 1
}
return tokens
}
class LuaConfigParser {
constructor(source) {
this.tokens = tokenize(source)
this.index = 0
this.config = new LuaTable()
}
current(offset = 0) {
return this.tokens[this.index + offset]
}
matches(value, offset = 0) {
return this.current(offset)?.value === value
}
take(value) {
const token = this.current()
if (!token || (value !== undefined && token.value !== value)) {
throw new Error(
`Expected '${value}', received '${token?.value ?? 'end of file'}' in configurator fixture.`,
)
}
this.index += 1
return token
}
parse() {
while (this.current()) {
if (this.matches('Config')) {
const start = this.index
const path = this.parsePath()
if (path.length && this.matches('=')) {
this.take('=')
this.setPath(path, this.parseExpression())
continue
}
this.index = start + 1
continue
}
this.index += 1
}
return this.config
}
parsePath() {
this.take('Config')
const path = []
while (this.matches('.') && this.current(1)?.type === 'name') {
this.take('.')
path.push(this.take().value)
}
return path
}
getPath(path) {
let value = this.config
for (const key of path) {
if (!(value instanceof LuaTable)) return undefined
value = value.get(key)
}
return value
}
setPath(path, value) {
let parent = this.config
for (let index = 0; index < path.length - 1; index += 1) {
const key = path[index]
let child = parent.get(key)
if (!(child instanceof LuaTable)) {
child = new LuaTable()
parent.set(key, child)
}
parent = child
}
parent.set(path.at(-1), value)
}
parseExpression() {
return this.parseOr()
}
parseOr() {
let value = this.parseAdditive()
while (this.matches('or')) {
this.take('or')
const fallback = this.parseAdditive()
value =
value !== false && value !== null && value !== undefined
? value
: fallback
}
return value
}
parseAdditive() {
let value = this.parseMultiplicative()
while (this.matches('+') || this.matches('-')) {
const operator = this.take().value
const right = this.parseMultiplicative()
value =
operator === '+'
? Number(value) + Number(right)
: Number(value) - Number(right)
}
return value
}
parseMultiplicative() {
let value = this.parseUnary()
while (this.matches('*') || this.matches('/')) {
const operator = this.take().value
const right = this.parseUnary()
value =
operator === '*'
? Number(value) * Number(right)
: Number(value) / Number(right)
}
return value
}
parseUnary() {
if (this.matches('-')) {
this.take('-')
return -Number(this.parseUnary())
}
return this.parsePrimary()
}
parsePrimary() {
const token = this.current()
if (!token)
throw new Error(
'Unexpected end of Lua configuration in configurator fixture.',
)
if (token.type === 'number' || token.type === 'string') {
this.index += 1
return token.value
}
if (this.matches('true') || this.matches('false') || this.matches('nil')) {
this.index += 1
return token.value === 'true'
? true
: token.value === 'false'
? false
: null
}
if (this.matches('{')) return this.parseTable()
if (this.matches('(')) {
this.take('(')
const value = this.parseExpression()
this.take(')')
return value
}
if (this.matches('Config')) return this.getPath(this.parsePath())
if (token.type === 'name' && this.current(1)?.value === '(') {
const name = this.take().value
this.take('(')
const argumentsList = []
while (!this.matches(')')) {
argumentsList.push(this.parseExpression())
if (!this.matches(',')) break
this.take(',')
}
this.take(')')
if (/^vector[234]$/.test(name)) {
const axes = ['x', 'y', 'z', 'w']
return Object.fromEntries([
['__skyType', name],
...argumentsList.map((value, index) => [axes[index], value]),
])
}
throw new Error(`Unsupported Lua call '${name}' in configurator fixture.`)
}
throw new Error(
`Unsupported Lua token '${token.value}' in configurator fixture.`,
)
}
parseTable() {
const table = new LuaTable()
this.take('{')
while (!this.matches('}')) {
if (this.matches('[')) {
this.take('[')
const key = this.parseExpression()
this.take(']')
this.take('=')
table.set(key, this.parseExpression())
} else if (
this.current()?.type === 'name' &&
this.current(1)?.value === '='
) {
const key = this.take().value
this.take('=')
table.set(key, this.parseExpression())
} else {
table.set(table.nextArrayIndex, this.parseExpression())
table.nextArrayIndex += 1
}
if (this.matches(',') || this.matches(';')) this.index += 1
else if (!this.matches('}')) {
throw new Error(
`Expected a Lua table separator, received '${this.current()?.value ?? 'end of file'}'.`,
)
}
}
this.take('}')
return table
}
}
function serializeLuaValue(value) {
if (!(value instanceof LuaTable)) {
if (Array.isArray(value)) return value.map(serializeLuaValue)
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, child]) => [
key,
serializeLuaValue(child),
]),
)
}
return value
}
const numericKeys = value.entries
.filter((entry) => typeof entry.key === 'number')
.map((entry) => entry.key)
.sort((left, right) => left - right)
const isSequence =
numericKeys.length === value.entries.length &&
numericKeys.every((key, index) => key === index + 1)
if (isSequence) {
return [...value.entries]
.sort((left, right) => left.key - right.key)
.map((entry) => serializeLuaValue(entry.value))
}
if (value.entries.every((entry) => typeof entry.key === 'string')) {
return Object.fromEntries(
value.entries.map((entry) => [entry.key, serializeLuaValue(entry.value)]),
)
}
return {
__skyType: 'map',
entries: value.entries.map((entry) => ({
key: entry.key,
keyType: typeof entry.key,
value: serializeLuaValue(entry.value),
})),
}
}
function humanize(value) {
return String(value ?? '')
.replace(/[_-]+/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/([A-Za-z])(\d)/g, '$1 $2')
.replace(/^./, (character) => character.toUpperCase())
}
function sensitivePath(path) {
const leaf = path.split('.').at(-1) ?? path
const normalized = leaf.toLowerCase().replace(/[^a-z0-9]/g, '')
return (
normalized.includes('apikey') ||
normalized.includes('secret') ||
normalized.includes('pepper') ||
[
'password',
'token',
'authorization',
'credential',
'connectionstring',
].includes(normalized)
)
}
function maskValue(value, path) {
if (Array.isArray(value))
return value.map((child, index) => maskValue(child, `${path}.${index + 1}`))
if (value?.__skyType === 'map' && Array.isArray(value.entries)) {
return {
__skyType: 'map',
entries: value.entries.map((entry) => ({
key: entry.key,
keyType: entry.keyType,
value: maskValue(entry.value, `${path}.${entry.key}`),
})),
}
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, child]) => [
key,
maskValue(child, `${path}.${key}`),
]),
)
}
return typeof value === 'string' && sensitivePath(path)
? '***REDACTED***'
: value
}
function emptyStructure(scope, path) {
if (scope !== 'config') return undefined
if (path === 'Garage.VehicleImages.ModelNames') {
return {
entries: [],
keyType: 'number',
kind: 'map',
template: { kind: 'value', valueType: 'string' },
}
}
if (
path === 'CrewLink.ExternalPingResources' ||
path === 'CustomApps.TrustedAdapters'
) {
return {
fields: {},
kind: 'table',
mutableKeys: true,
template: { kind: 'value', valueType: 'boolean' },
}
}
if (path === 'FlipTok.MusicTracks') {
return {
items: [],
kind: 'list',
template: {
fields: {
Artist: { kind: 'value', valueType: 'string' },
Id: { kind: 'value', valueType: 'string' },
Title: { kind: 'value', valueType: 'string' },
Url: { kind: 'value', valueType: 'string' },
},
kind: 'table',
},
}
}
if (path === 'Music.Tracks') {
return {
items: [],
kind: 'list',
template: {
fields: {
Artist: { kind: 'value', valueType: 'string' },
Id: { kind: 'value', valueType: 'string' },
Title: { kind: 'value', valueType: 'string' },
},
kind: 'table',
},
}
}
if (path === 'Payphones.CustomLocations') {
return {
items: [],
kind: 'list',
template: { kind: 'vector', vectorType: 'vector4' },
}
}
if (/^Companies\.Definitions\.[^.]+\.Services$/.test(path)) {
return {
items: [],
kind: 'list',
template: {
fields: {
Description: { kind: 'value', valueType: 'string' },
Id: { kind: 'value', valueType: 'string' },
Price: { kind: 'value', valueType: 'string' },
RequestsEnabled: { kind: 'value', valueType: 'boolean' },
Title: { kind: 'value', valueType: 'string' },
},
kind: 'table',
},
}
}
return undefined
}
function buildStructure(value, scope, path) {
if (scope === 'config' && path === 'Phone.Keybind') {
return { kind: 'optionalString' }
}
if (
scope === 'config' &&
path === 'Companies.Definitions' &&
value !== null &&
typeof value === 'object' &&
!Array.isArray(value)
) {
const keys = Object.keys(value).sort()
const fields = Object.fromEntries(
keys.map((key) => [
key,
buildStructure(value[key], scope, `${path}.${key}`),
]),
)
return {
fields,
kind: 'table',
mutableKeys: true,
template: keys[0] ? fields[keys[0]] : undefined,
}
}
const configuredEmptyStructure =
Array.isArray(value) && value.length === 0
? emptyStructure(scope, path)
: undefined
if (configuredEmptyStructure) {
return configuredEmptyStructure
}
if (Array.isArray(value)) {
const items = value.map((child, index) =>
buildStructure(child, scope, `${path}.${index + 1}`),
)
return {
kind: 'list',
items,
template: items[0],
}
}
if (value?.__skyType === 'map' && Array.isArray(value.entries)) {
const entries = value.entries.map((entry) => ({
key: entry.key,
keyType: entry.keyType,
structure: buildStructure(entry.value, scope, `${path}.${entry.key}`),
}))
const keyTypes = new Set(entries.map((entry) => entry.keyType))
return {
kind: 'map',
entries,
keyType: keyTypes.size === 1 ? entries[0]?.keyType : undefined,
template: entries[0]?.structure,
}
}
if (/^vector[234]$/.test(value?.__skyType ?? '')) {
return { kind: 'vector', vectorType: value.__skyType }
}
if (value !== null && typeof value === 'object') {
return {
kind: 'table',
fields: Object.fromEntries(
Object.entries(value).map(([key, child]) => [
key,
buildStructure(child, scope, `${path}.${key}`),
]),
),
}
}
return { kind: 'value', valueType: typeof value }
}
function addField(fields, scope, path, value) {
const valueType = Array.isArray(value) ? 'json' : typeof value
const sensitive = typeof value === 'string' && sensitivePath(path)
fields.push({
configured: sensitive ? value !== '' : undefined,
label:
scope === 'config' && path === 'Companies.Definitions'
? 'Jobs'
: humanize(path.split('.').at(-1)),
path,
scope,
sensitive,
structure:
value !== null && typeof value === 'object'
? buildStructure(value, scope, path)
: undefined,
type:
scope === 'config' && path === 'Phone.Keybind'
? 'stringOrFalse'
: value !== null && typeof value === 'object'
? 'json'
: valueType,
value: sensitive ? '' : maskValue(value, path),
})
}
function flattenCompanyFields(fields, path, value) {
if (
path === 'Companies.Definitions' ||
value === null ||
typeof value !== 'object' ||
Array.isArray(value) ||
value.__skyType ||
Object.keys(value).length === 0
) {
addField(fields, 'config', path, value)
return
}
for (const key of Object.keys(value).sort()) {
flattenCompanyFields(fields, `${path}.${key}`, value[key])
}
}
function buildSections(scope, payload) {
const sections = []
const generalFields = []
for (const key of Object.keys(payload).sort()) {
const value = payload[key]
if (scope === 'config' && key === 'Companies') {
const fields = []
flattenCompanyFields(fields, 'Companies', value)
fields.sort((left, right) => {
if (left.path === 'Companies.Definitions') return 1
if (right.path === 'Companies.Definitions') return -1
return left.path.localeCompare(right.path)
})
sections.push({
fields,
id: 'config:Companies',
label: humanize(key),
scope,
})
} else if (
value !== null &&
typeof value === 'object' &&
!Array.isArray(value) &&
!value.__skyType &&
Object.keys(value).length > 0
) {
const fields = []
addField(fields, scope, key, value)
sections.push({
fields,
id: `${scope}:${key}`,
label: humanize(key),
scope,
})
} else {
addField(generalFields, scope, key, value)
}
}
if (generalFields.length) {
const general = {
fields: generalFields,
id: `${scope}:general`,
label: 'General',
scope,
}
if (scope === 'config') sections.unshift(general)
else sections.push(general)
}
return sections
}
function loadConfiguratorSections() {
const resourceRoot = resolve(__dirname, '../..')
const config = serializeLuaValue(
new LuaConfigParser(
readFileSync(
resolve(resourceRoot, 'sky_phone/config/config.lua'),
'utf8',
),
).parse(),
)
const mediaRoot = serializeLuaValue(
new LuaConfigParser(
readFileSync(resolve(resourceRoot, 'sky_phone/config/media.lua'), 'utf8'),
).parse(),
)
const media = mediaRoot.Media
delete config.PhoneConfigurator
delete config.CommandPermissions
delete config.Media
return [...buildSections('config', config), ...buildSections('media', media)]
}
module.exports = { loadConfiguratorSections }
@@ -0,0 +1,199 @@
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { describe, expect, it } from 'vitest'
type ConfiguratorField = {
label: string
path: string
structure?: ConfiguratorStructure
type: string
value: unknown
}
type ConfiguratorStructure = {
entries?: Array<{ structure: ConfiguratorStructure }>
fields?: Record<string, ConfiguratorStructure>
items?: ConfiguratorStructure[]
keyType?: 'number' | 'string'
kind: string
mutableKeys?: boolean
template?: ConfiguratorStructure
}
type ConfiguratorSection = {
fields: ConfiguratorField[]
id: string
scope: 'config' | 'media'
}
const require = createRequire(import.meta.url)
const { loadConfiguratorSections } = require('./configurator-fixture.cjs') as {
loadConfiguratorSections: () => ConfiguratorSection[]
}
const configSource = readFileSync(
new URL('../../sky_phone/config/config.lua', import.meta.url),
'utf8',
)
function countStructure(structure: ConfiguratorStructure | undefined): number {
if (!structure) return 1
if (structure.fields) {
return Object.values(structure.fields).reduce(
(total, field) => total + countStructure(field),
0,
)
}
if (structure.items) {
return structure.items.reduce(
(total, item) => total + countStructure(item),
0,
)
}
if (structure.entries) {
return structure.entries.reduce(
(total, entry) => total + countStructure(entry.structure),
0,
)
}
return 1
}
describe('admin configurator fixture', () => {
it('exposes every SQL-managed config.lua root through the full live preview', () => {
const sections = loadConfiguratorSections()
const fields = sections.flatMap((section) => section.fields)
const roots = [
...configSource.matchAll(/^\s{0,4}Config\.([A-Za-z0-9_]+)\s*=/gm),
]
.map((match) => match[1])
.filter(
(root) =>
root !== 'Media' &&
root !== 'PhoneConfigurator' &&
root !== 'CommandPermissions',
)
expect(sections).toHaveLength(45)
expect(
fields.reduce(
(total, field) => total + countStructure(field.structure),
0,
),
).toBeGreaterThan(700)
for (const root of new Set(roots)) {
expect(
sections.some(
(section) =>
section.id === `config:${root}` ||
section.fields.some(
(field) =>
field.path === root || field.path.startsWith(`${root}.`),
),
),
`Missing Config.${root}`,
).toBe(true)
}
})
it('preserves numeric Lua map keys and the false keybind option', () => {
const fields = loadConfiguratorSections().flatMap(
(section) => section.fields,
)
const darkChat = fields.find((field) => field.path === 'DarkChat')
const phone = fields.find((field) => field.path === 'Phone')
const companyJobs = fields.find(
(field) => field.path === 'Companies.Definitions',
)
const garage = fields.find((field) => field.path === 'Garage')
const timers = (darkChat?.value as Record<string, unknown> | undefined)
?.AllowedDisappearTimers
expect(timers).toMatchObject({
__skyType: 'map',
entries: expect.arrayContaining([
{ key: -1, keyType: 'number', value: true },
{ key: 0, keyType: 'number', value: true },
{ key: 604800, keyType: 'number', value: true },
]),
})
expect(darkChat?.structure?.fields?.AllowedDisappearTimers.kind).toBe('map')
expect(phone?.structure?.fields?.Keybind.kind).toBe('optionalString')
expect(companyJobs?.label).toBe('Jobs')
expect(companyJobs?.structure).toMatchObject({
fields: {
ambulance: { kind: 'table' },
police: { kind: 'table' },
},
kind: 'table',
mutableKeys: true,
template: { kind: 'table' },
})
expect(
garage?.structure?.fields?.VehicleImages.fields?.ModelNames.kind,
).toBe('map')
})
it('publishes fixed schemas for every empty configurable collection', () => {
const fields = loadConfiguratorSections().flatMap(
(section) => section.fields,
)
const root = (path: string) =>
fields.find((field) => field.path === path)?.structure
expect(root('Music')?.fields?.Tracks).toMatchObject({
items: [],
kind: 'list',
template: {
fields: {
Artist: { kind: 'value', valueType: 'string' },
Id: { kind: 'value', valueType: 'string' },
Title: { kind: 'value', valueType: 'string' },
},
kind: 'table',
},
})
expect(root('FlipTok')?.fields?.MusicTracks.template).toMatchObject({
fields: { Url: { kind: 'value', valueType: 'string' } },
kind: 'table',
})
expect(root('Payphones')?.fields?.CustomLocations.template).toEqual({
kind: 'vector',
vectorType: 'vector4',
})
expect(root('CrewLink')?.fields?.ExternalPingResources).toMatchObject({
fields: {},
kind: 'table',
mutableKeys: true,
template: { kind: 'value', valueType: 'boolean' },
})
expect(root('CustomApps')?.fields?.TrustedAdapters).toMatchObject({
fields: {},
kind: 'table',
mutableKeys: true,
template: { kind: 'value', valueType: 'boolean' },
})
expect(
root('Garage')?.fields?.VehicleImages.fields?.ModelNames,
).toMatchObject({
entries: [],
keyType: 'number',
kind: 'map',
template: { kind: 'value', valueType: 'string' },
})
expect(
root('Companies.Definitions')?.fields?.police.fields?.Services,
).toMatchObject({
items: [],
kind: 'list',
template: {
fields: {
Id: { kind: 'value', valueType: 'string' },
RequestsEnabled: { kind: 'value', valueType: 'boolean' },
},
kind: 'table',
},
})
})
})
+442 -3
View File
@@ -3,6 +3,8 @@ const { randomUUID } = require('node:crypto')
const cors = require('cors')
const express = require('express')
const { loadConfiguratorSections } = require('./configurator-fixture.cjs')
const app = express()
const port = Number(process.argv[2]) || 3001
@@ -3098,9 +3100,7 @@ const deviceData = {
ringtoneVolume: 80,
streamerMode: false,
wallpaper: 'custom',
wallpaperHistory: [
{ imageUrl: demoWallpaperUrl, wallpaper: 'custom' },
],
wallpaperHistory: [{ imageUrl: demoWallpaperUrl, wallpaper: 'custom' }],
wallpaperImageUrl: demoWallpaperUrl,
},
version: 1,
@@ -4650,11 +4650,303 @@ function companyWorkContext(testScenario = '') {
}
}
const adminMockApps = {
claimed: ['citymarkt', 'darkchat', 'feather', 'local-pages'],
revision: 3,
uninstalled: ['crypto', 'skyride'],
}
const adminMockDevices = {
1: { account: true, number: '555-0101', security: true },
2: { account: true, number: '555-0102', security: true },
}
function adminMockPlayerDetail(source = 1) {
const primary = source === 1
const deviceState = adminMockDevices[source] ?? adminMockDevices[1]
return {
birthdate: primary ? '1994-04-16' : '1998-11-03',
devices: [
{
account: deviceState.account
? {
email: primary ? 'demo@ifruit.com' : 'jordan@ifruit.com',
id: primary ? 1 : 2,
passwordAvailable: true,
}
: null,
apps: { ...adminMockApps },
createdAt: '2026-08-15 18:42:00',
imei: primary ? '356938035643809' : '356938035643810',
name: primary ? 'Personal iFruit Phone' : 'Service iFruit Phone',
number: deviceState.number,
security: {
enabled: deviceState.security,
failedAttempts: 0,
length: deviceState.security ? 6 : null,
lockedUntil: 0,
},
simRegistered: true,
simType: 'standard',
updatedAt: '2026-08-20 19:04:00',
},
],
firstName: primary ? 'Alex' : 'Jordan',
identifier: primary ? 'char1:demo' : 'char1:jordan',
job: {
grade: primary ? 4 : 1,
gradeLabel: primary ? 'Chief' : 'Officer',
label: 'Los Santos Police Department',
name: 'police',
onDuty: true,
},
lastName: primary ? 'Morgan' : 'Blake',
money: {
bank: primary ? 182450 : 28450,
cash: primary ? 2740 : 950,
currency: '$',
},
name: primary ? 'Alex Morgan' : 'Jordan Blake',
serverName: primary ? 'Skyline' : 'JordanB',
source,
}
}
function adminMockBootstrap() {
return {
audit: [
{
action: 'grant_app',
actorName: 'Skyline',
createdAt: '2026-08-20 19:04:00',
details: { appId: 'darkchat' },
deviceImei: '356938035643810',
id: 1,
targetIdentifier: 'char1:jordan',
targetSource: 2,
},
],
players: [1, 2].map((source) => {
const player = adminMockPlayerDetail(source)
return {
deviceCount: player.devices.length,
grade: player.job.grade,
identifier: player.identifier,
job: player.job.name,
name: player.name,
onDuty: player.job.onDuty,
phoneNumber: player.devices[0]?.number ?? null,
serverName: player.serverName,
source,
}
}),
stats: { accounts: 24, devices: 31, online: 2 },
}
}
const adminMockConfiguratorBase = {
enabled: true,
revision: 4,
sections: [
{
id: 'config:Bridge',
label: 'Bridge',
scope: 'config',
fields: [
{
label: 'Framework',
path: 'Bridge.Framework',
scope: 'config',
sensitive: false,
type: 'string',
value: 'auto',
},
{
label: 'Inventory',
path: 'Bridge.Inventory',
scope: 'config',
sensitive: false,
type: 'string',
value: 'auto',
},
{
label: 'Locale',
path: 'Bridge.Locale',
scope: 'config',
sensitive: false,
type: 'string',
value: 'de',
},
{
label: 'Callback Timeout',
path: 'Bridge.CallbackTimeout',
scope: 'config',
sensitive: false,
type: 'number',
value: 15000,
},
{
label: 'Debug',
path: 'Bridge.Debug',
scope: 'config',
sensitive: false,
type: 'boolean',
value: false,
},
],
},
{
id: 'config:Phone',
label: 'Phone',
scope: 'config',
fields: [
{
label: 'Item',
path: 'Phone.Item',
scope: 'config',
sensitive: false,
type: 'string',
value: 'phone',
},
{
label: 'Unique',
path: 'Phone.Unique',
scope: 'config',
sensitive: false,
type: 'boolean',
value: true,
},
{
label: 'Keybind',
path: 'Phone.Keybind',
scope: 'config',
sensitive: false,
type: 'string',
value: 'F1',
},
{
label: 'Device Name',
path: 'Phone.DeviceName',
scope: 'config',
sensitive: false,
type: 'string',
value: 'iFruit Phone',
},
],
},
{
id: 'config:Companies',
label: 'Companies',
scope: 'config',
fields: [
{
label: 'Enabled',
path: 'Companies.Enabled',
scope: 'config',
sensitive: false,
type: 'boolean',
value: true,
},
{
label: 'Categories',
path: 'Companies.Categories',
scope: 'config',
sensitive: false,
type: 'json',
value: ['public_services', 'vehicles', 'transport'],
},
],
},
{
id: 'media:FiveManage',
label: 'Five Manage',
scope: 'media',
fields: [
{
configured: true,
label: 'Api Key',
path: 'FiveManage.ApiKey',
scope: 'media',
sensitive: true,
type: 'string',
value: '',
},
{
label: 'Base Url',
path: 'FiveManage.BaseUrl',
scope: 'media',
sensitive: false,
type: 'string',
value: 'https://api.fivemanage.com/api/v3/file',
},
{
label: 'Upload Timeout Ms',
path: 'FiveManage.UploadTimeoutMs',
scope: 'media',
sensitive: false,
type: 'number',
value: 25000,
},
],
},
{
id: 'media:Import',
label: 'Import',
scope: 'media',
fields: [
{
label: 'Enabled',
path: 'Import.Enabled',
scope: 'media',
sensitive: false,
type: 'boolean',
value: true,
},
{
label: 'Websites',
path: 'Import.Websites',
scope: 'media',
sensitive: false,
type: 'json',
value: [
{
Adapter: 'fivemanage',
Enabled: true,
Id: 'fivemanage',
Label: 'FiveManage',
},
],
},
],
},
],
updatedAt: '2026-08-20 20:15:00',
updatedBy: 'Alex Morgan',
}
const adminMockConfigurator = {
...adminMockConfiguratorBase,
sections: loadConfiguratorSections(),
}
app.post('/api/:endpoint', async (request, response, next) => {
const endpoint = request.params.endpoint
const loggedBody = { ...request.body }
if (typeof loggedBody.password === 'string')
loggedBody.password = '<redacted>'
if (
endpoint === 'admin:save-configurator' &&
Array.isArray(loggedBody.changes)
) {
loggedBody.changes = loggedBody.changes.map((change) => ({
...change,
value: /api.?key|pepper|secret|token|password/i.test(
String(change.path ?? ''),
)
? '<redacted>'
: change.value,
}))
}
if (endpoint === 'memos:devCapture') {
loggedBody.audioDataUrl = `<${String(request.body.audioDataUrl ?? '').length} characters>`
}
@@ -4663,6 +4955,153 @@ app.post('/api/:endpoint', async (request, response, next) => {
response.json({ success: true, data: musicBootstrap() })
return
}
if (endpoint === 'admin:bootstrap') {
response.json({ success: true, data: adminMockBootstrap() })
return
}
if (endpoint === 'admin:configurator') {
response.json({ success: true, data: adminMockConfigurator })
return
}
if (endpoint === 'admin:save-configurator') {
const changes = Array.isArray(request.body.changes)
? request.body.changes
: []
for (const change of changes) {
for (const section of adminMockConfigurator.sections) {
const field = section.fields.find(
(candidate) =>
candidate.scope === change.scope && candidate.path === change.path,
)
if (!field) continue
if (field.sensitive)
field.configured = String(change.value ?? '') !== ''
else field.value = change.value
}
}
adminMockConfigurator.revision += 1
adminMockConfigurator.updatedAt = new Date().toISOString()
response.json({ success: true, data: adminMockConfigurator })
return
}
if (endpoint === 'admin:player') {
response.json({
success: true,
data: adminMockPlayerDetail(Number(request.body.source) || 1),
})
return
}
if (endpoint === 'admin:save-apps') {
const changes = Array.isArray(request.body.changes)
? request.body.changes
: []
for (const change of changes) {
const appId = String(change.appId ?? '')
const installed = change.installed === true
adminMockApps.claimed = adminMockApps.claimed.filter((id) => id !== appId)
adminMockApps.uninstalled = adminMockApps.uninstalled.filter(
(id) => id !== appId,
)
if (installed) adminMockApps.claimed.push(appId)
else adminMockApps.uninstalled.push(appId)
}
adminMockApps.revision += 1
response.json({
success: true,
data: adminMockPlayerDetail(Number(request.body.source) || 1),
})
return
}
if (endpoint === 'admin:close') {
response.json({ success: true })
return
}
if (endpoint === 'admin:reveal-password') {
response.json({
success: true,
data: { email: 'demo@ifruit.com', password: 'mock-only-password' },
})
return
}
if (endpoint === 'admin:activity') {
if (request.body.kind === 'messages') {
response.json({
success: true,
data: {
kind: 'messages',
entries: [
{
body: 'Meet at Mission Row in ten minutes.',
createdAt: '2026-08-20 19:03:00',
direction: 'outgoing',
id: 'admin-message-1',
messageType: 'text',
otherNumber: '555-0144',
readAt: '2026-08-20 19:03:30',
},
{
body: 'Copy, I am on my way.',
createdAt: '2026-08-20 18:58:00',
direction: 'incoming',
id: 'admin-message-2',
messageType: 'text',
otherNumber: '555-0199',
readAt: null,
},
],
},
})
return
}
response.json({
success: true,
data: {
kind: 'calls',
entries: [
{
answeredAt: '2026-08-20 18:49:05',
direction: 'incoming',
durationSeconds: 184,
endedAt: '2026-08-20 18:52:09',
id: 'admin-call-1',
otherNumber: '555-0177',
startedAt: '2026-08-20 18:49:00',
status: 'completed',
},
{
answeredAt: null,
direction: 'outgoing',
durationSeconds: 0,
endedAt: '2026-08-20 17:13:18',
id: 'admin-call-2',
otherNumber: '555-0112',
startedAt: '2026-08-20 17:13:00',
status: 'missed',
},
],
},
})
return
}
if (endpoint === 'admin:reset-passcode') {
const source = Number(request.body.source) || 1
adminMockDevices[source].security = false
response.json({ success: true, data: adminMockPlayerDetail(source) })
return
}
if (endpoint === 'admin:change-number') {
const source = Number(request.body.source) || 1
adminMockDevices[source].number = String(request.body.phoneNumber ?? '')
response.json({ success: true, data: adminMockPlayerDetail(source) })
return
}
if (endpoint === 'admin:factory-reset') {
const source = Number(request.body.source) || 1
adminMockDevices[source].account = false
adminMockDevices[source].security = false
response.json({ success: true, data: adminMockPlayerDetail(source) })
return
}
if (endpoint === 'music:add-youtube') {
const value = String(request.body.url ?? '')
const customTitle = String(request.body.title ?? '').trim()
+15 -2
View File
@@ -134,7 +134,7 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
- `hex_4_inventory`
- Native ESX inventory
`mf-inventory` and `smx-inventory` are supported with ESX. The native ESX and HEX adapters use count-based items and therefore require unique phones and physical SIM cards to be disabled.
`mf-inventory` and `smx-inventory` are supported with ESX. The native ESX and HEX adapters use count-based items, so Sky Phone automatically disables unique phones and physical SIM cards while either adapter is active.
### Voice
@@ -196,6 +196,7 @@ The files contain clearly separated sections for:
| Section | Purpose |
| --- | --- |
| `Config.Bridge` | Framework, inventory, language, callback timeout, and debug mode |
| `Config.CommandPermissions` | Fixed groups for the admin panel, test data, verification commands, and social moderation |
| `Config.Phone` | Phone item, movement, unique-device mode, and development command |
| `Config.Sim` | Physical or virtual SIM behavior and number formatting |
| `Config.Calls` / `Config.Radio` | Voice providers, call behavior, radio limits, and permissions |
@@ -211,6 +212,18 @@ The files contain clearly separated sections for:
Restart `sky_phone` after changing Lua configuration.
When `Config.PhoneConfigurator.Enabled` is enabled, the generated `source/shared/config_default.lua`
provides the shipped SQL baseline. The frontend build recreates it from `config.lua` and the
server-only `media.lua`; do not edit the generated snapshot directly.
`Config.PhoneConfigurator` and `Config.CommandPermissions` remain file-owned and are omitted from
the generated default snapshot. Permissions are not displayed in the Phone Configurator and stay
authoritative while SQL configuration is enabled. Their stable keys do not change when a command is
renamed in the panel. ESX and QBCore use their framework permissions. Qbox checks the configured ACE
objects first and then its framework groups, so the standard `permissions.cfg` mapping from
`group.admin` to the `admin` ACE works with the shipped `phonepanel` permission list. Restart
`sky_phone` after changing fixed permissions.
### Language
Available locales:
@@ -312,7 +325,7 @@ Do not configure an LB Phone client event or client export. Sky Phone registers
The server registers `Config.Phone.Item` as usable for every supported inventory adapter: `ox`, `qb`, `lj`, `qs`, `codem`, `core`, `mf`, `smx`, `hex`, and `esx`. Resource startup fails visibly if the selected adapter cannot complete that registration.
The `hex` and `esx` adapters use ESX's count-based item API. They require both `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` because this API cannot persist per-item phone or physical SIM metadata. `auto` selects `hex` when `hex_4_inventory` is started and otherwise falls back to `esx` on an ESX server when no metadata-capable inventory is detected.
The `hex` and `esx` adapters use ESX's count-based item API, which cannot persist per-item phone or physical SIM metadata. Sky Phone automatically forces `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` while either adapter is active. `auto` selects `hex` when `hex_4_inventory` is started and otherwise falls back to `esx` on an ESX server when no metadata-capable inventory is detected.
### QBCore-style item tables
+45 -10
View File
@@ -10,13 +10,36 @@
Keep option names unchanged. Restart sky_phone after editing this file.
]]
-- CONFIG_DEFAULT_EXCLUDE_START
-- When enabled, the active configuration is loaded from SQL and managed through
-- /phonepanel. Frontend builds snapshot the shipped defaults from config.lua and
-- media.lua into source/shared/config_default.lua.
Config.PhoneConfigurator = {
Enabled = true,
}
-- Fixed server permissions. These values remain authoritative even while the
-- Phone Configurator is enabled and are intentionally not shown in its panel.
-- Group names use the active framework's permissions. On Qbox they also match
-- ACE objects such as "admin" from the standard permissions.cfg.
Config.CommandPermissions = {
phonepanel = { "god", "superadmin", "admin" },
phonetestdata = { "god", "superadmin", "admin" },
fliptokverify = { "god", "superadmin", "admin" },
picstagramverify = { "god", "superadmin", "admin" },
picstagramadmin = { "god", "superadmin", "admin" },
}
-- CONFIG_DEFAULT_EXCLUDE_END
-- =============================================================================
-- Core, framework and device
-- =============================================================================
Config.Bridge = {
Framework = "auto", -- auto, esx, qbox, qb
Inventory = "auto", -- auto, ox, qb, lj, qs, codem, core, mf, smx, hex, esx
-- auto, ak47, codem, core, jaksam, jpr, lj, mf, one, origen, ox, ps, qb, qs, smx, tgiann, hex, esx
-- Compatibility aliases: qb-inv -> qb, qbox -> ox
Inventory = "auto",
Locale = "en",
CallbackTimeout = 15000,
Debug = false, -- true: show debug/info output; warnings and errors are always shown
@@ -26,9 +49,13 @@ Config.Command = "phone"
Config.Phone = {
Item = "phone",
Unique = true, -- true: data follows each phone item; false: one persistent phone per character; hex/esx require false
Unique = true, -- true: data follows each phone item; false: one persistent phone per character; forced false for metadata-free inventories
Keybind = "F1", -- false disables the configurable phone key mapping
AllowMovement = true, -- true: game input stays active while the mobile phone is open
HoldToLook = {
Enabled = true, -- hold the configured control to hide the cursor and look around; independent of AllowMovement
Control = 19, -- INPUT_CHARACTER_WHEEL (Left Alt by default)
},
DevelopmentCommand = true,
DeviceName = "iFruit Phone",
}
@@ -37,14 +64,13 @@ Config.TestData = {
Enabled = false, -- development/test servers only; keep disabled in production
Command = "phonetestdata",
AdminOnly = false, -- enable only on development servers; every run is scoped to the executing player's phone
AdminGroups = { "admin", "superadmin" },
}
Config.CustomApps = {
Enabled = true,
BundledApps = true,
ExternalApps = true,
Debug = true, -- detailed client traces for exports, registration, catalog sync and lifecycle events
Debug = false, -- detailed client traces for exports, registration, catalog sync and lifecycle events
ReadyTimeoutMs = 8000,
MaximumMessageBytes = 65536,
MaximumStorageBytesPerApp = 262144,
@@ -61,13 +87,24 @@ Config.Security = {
AttemptsPerMinute = 12,
}
Config.AdminPanel = {
Enabled = true,
Command = "phonepanel",
MaximumPlayers = 128,
ReadRequestsPerMinute = 60,
ActionRequestsPerMinute = 30,
CredentialRevealsPerMinute = 6,
AuditLimit = 40,
ActivityLimit = 40,
}
Config.Sim = {
Enabled = true, -- false: devices receive a persistent random number automatically; hex/esx require false
Enabled = true, -- false: devices receive a persistent random number automatically; forced false for metadata-free inventories
RegisteredItem = "sky_phone_sim_registered",
AnonymousItem = "sky_phone_sim_anonymous",
NumberLength = 10,
NumberPrefix = "",
NumberGroups = { 3, 3, 4 },
NumberLength = 10, -- total number of digits, including NumberPrefix
NumberPrefix = "", -- digits only; use "555", not "555-"
NumberGroups = { 3, 3, 4 }, -- display groups separated by spaces
}
-- =============================================================================
@@ -461,7 +498,6 @@ Config.FlipTok = {
MaxPostMedia = 10,
MusicTracks = {},
VerifyCommand = "fliptokverify",
AdminGroups = { "admin" },
ReportWebhookConvar = "sky_phone_fliptok_report_webhook",
}
@@ -483,7 +519,6 @@ Config.Picstagram = {
ReportDetailsMaxLength = 500,
ReportReasons = { "spam", "harassment", "dangerous", "illegal", "other" },
VerifyCommand = "picstagramverify",
AdminGroups = { "admin" },
}
Config.Feather = {
+99 -5
View File
@@ -1,5 +1,13 @@
Locales["de"] = {
CommandDescription = "Öffne dein Handy.",
AdminCommand = {
CommandDescription = "Öffne Handy-Administration und -Konfiguration.",
Errors = {
disabled = "Das Handy-Admin-Panel ist deaktiviert.",
not_authorized = "Du hast keinen Zugriff auf das Handy-Admin-Panel.",
default = "Das Handy-Admin-Panel konnte nicht geöffnet werden.",
},
},
Controls = {
OpenPhone = "Handy öffnen",
},
@@ -193,6 +201,28 @@ Locales["de"] = {
contacts = { name = "Favoriten", description = "Rufe deine Lieblingskontakte an oder schreibe ihnen.", choose = "Lieblingskontakte" },
},
},
AdminPanel = {
name = "Phone Admin", subtitle = "Administration", navigation = "Admin-Navigation", refresh = "Admin-Daten aktualisieren", loading = "Geschützte Daten werden geladen...",
tabs = { overview = "Übersicht", players = "Spieler", devices = "Geräte", apps = "Apps", accounts = "Accounts", messages = "Nachrichten", calls = "Anrufe", moderation = "Moderation", audit = "Audit", configurator = "Phone Configurator" },
overview = { eyebrow = "Server", title = "Dashboard", body = "Spieler, Geräte, Apps und Handydaten.", stats = "Server-Handystatistik", online = "Online", devices = "Geräte", accounts = "Accounts", audit = "Audit-Einträge", control = "Navigation", features = "Module", featuresBody = "Öffne ein Verwaltungsmodul.", recent = "Letzte Aktivitäten", playerFeature = "Identität, Finanzen, Job und Dienst", deviceFeature = "IMEI, SIM, Nummer und Aktivität", appFeature = "Handy-Apps installieren oder entfernen", accountFeature = "Accountzugriff und geschützte Zugangsdaten", messageFeature = "Letzte SMS-Aktivitäten prüfen", callFeature = "Letzte Anrufaktivitäten prüfen", moderationFeature = "Zugriff, Nummer oder Gerätedaten zurücksetzen", auditFeature = "Sensible Admin-Aktionen prüfen", configuratorFeature = "config.lua und media.lua über SQL verwalten" },
appearance = { eyebrow = "Darstellung", title = "Akzentfarbe", body = "Ändere den Akzent im gesamten Admin-Arbeitsbereich.", colors = { emerald = "Smaragd", blue = "Blau", violet = "Violett", orange = "Orange", red = "Rot" } },
configurator = { context = "Runtime-Konfiguration", eyebrow = "Systemwerkzeug", sections = "Konfiguration", search = "Einstellungen oder Pfade suchen", configScope = "config.lua", mediaScope = "media.lua", noResults = "Keine passenden Einstellungen", loading = "SQL-Konfiguration wird geladen...", title = "Phone Configurator", body = "Verwalte Handy- und Media-Einstellungen im geschützten Admin-Bereich.", disabledTitle = "SQL-Konfiguration ist nicht aktiv", disabledBody = "Aktiviere den Configurator am Anfang der config.lua und starte sky_phone neu. Bis dahin bleiben die Dateiwerte aktiv und die Bearbeitung gesperrt.", manualSave = "Manuelles Speichern", refreshNotice = "Nichts wird automatisch gespeichert. Der grüne Haken prüft config.lua und media.lua in SQL und aktualisiert die aktive Server-, Client-, Media- und UI-Konfiguration sofort intern.", fieldCount = "{count} Felder", secretConfigured = "Secret gesetzt · Ersatzwert eingeben", invalidValue = "Prüfe den markierten Tabellen- oder Zahlenwert.", saved = "SQL-Konfiguration gespeichert und übernommen.", descriptions = { featureToggle = "Schaltet {name} ein oder aus.", boolean = "Legt fest, ob {name} erlaubt ist.", number = "Legt den Zahlenwert für {name} fest.", text = "Legt den Textwert für {name} fest.", optionalText = "Legt den optionalen Wert für {name} fest; der Schalter deaktiviert ihn.", list = "Verwaltet alle Einträge für {name}.", table = "Bündelt die zusammengehörigen Einstellungen für {name}.", credential = "Speichert den geschützten Zugangsschlüssel für {name}.", url = "Legt die URL oder den Endpunkt für {name} fest.", hosts = "Legt die erlaubten Domains für {name} fest.", milliseconds = "Legt die Zeit für {name} in Millisekunden fest.", seconds = "Legt die Zeit für {name} in Sekunden fest.", rateLimit = "Begrenzt die Aktionen für {name} pro Minute.", byteLimit = "Legt die maximal erlaubte Datengröße für {name} fest.", textLimit = "Legt die maximal erlaubte Textlänge für {name} fest.", distance = "Legt die Distanz in der Spielwelt für {name} fest.", coordinates = "Legt Weltkoordinaten oder Ausrichtung für {name} fest.", gameAsset = "Legt das GTA-Modell oder Prop für {name} fest.", animation = "Legt die Animationsdatei für {name} fest.", access = "Legt Jobs, Gruppen oder Berechtigungsstufen für {name} fest.", integration = "Wählt Framework oder Anbieter für {name} aus.", path = "Legt den Speicher- oder Ressourcenpfad für {name} fest.", color = "Legt die Oberflächenfarbe für {name} fest.", displayText = "Legt den Spielern angezeigten Text für {name} fest.", phoneNumber = "Legt die Telefon- oder Servicenummer für {name} fest.", routing = "Steuert die Verteilung eingehender Anfragen für {name}.", command = "Legt den Chat-Befehl zum Öffnen oder Ausführen von {name} fest.", locale = "Wählt die Sprache für {name} aus.", debug = "Steuert ausführliche Diagnoseausgaben für {name}.", mediaQuality = "Legt Medienqualität oder Lautstärke für {name} fest.", amount = "Legt die maximale oder angezeigte Menge für {name} fest." }, table = { list = "Liste", table = "Schlüsseltabelle", vector = "Vektor", entry = "Eintrag", general = "Allgemein", addRow = "Zeile hinzufügen", addField = "Feld hinzufügen", remove = "Entfernen", emptyList = "Noch keine Zeilen. Füge die erste Zeile mit Plus hinzu.", emptyTable = "Noch keine Felder. Füge unten den ersten Schlüssel hinzu.", keyPlaceholder = "Neuer Schlüssel", convertToList = "Als Liste nutzen", convertToMap = "Als typisierte Schlüsseltabelle nutzen", convertToTable = "Als Schlüsseltabelle nutzen", types = { string = "Text", number = "Zahl", boolean = "Schalter", list = "Liste", table = "Tabelle" } } },
players = { eyebrow = "Aktive Sitzungen", title = "Online-Spieler", online = "Jetzt online", empty = "Keine Spieler gefunden", emptyBody = "Passe die Suche an oder aktualisiere die Spielerliste." },
search = { players = "Name, ID, Job oder Nummer suchen", apps = "Apps suchen", clear = "Suche leeren" },
detail = { character = "Charakterprofil", data = "Spielerdatenübersicht", cash = "Bargeld", bank = "Bank", job = "Job", duty = "Dienst", onDuty = "Im Dienst", offDuty = "Außer Dienst", identity = "Identität", playerData = "Spielerdaten", identifier = "Charakter-Identifier", birthdate = "Geburtsdatum", grade = "Job-Rang", unknown = "Unbekannt" },
devices = { eyebrow = "Gerätesteuerung", title = "Handys", body = "Prüfe alle Handys des ausgewählten Spielers.", choose = "Handy auswählen", empty = "Kein Handy gefunden", emptyBody = "Dieser Spieler besitzt aktuell kein verwaltbares Handy.", noNumber = "Keine Telefonnummer", noSim = "Keine SIM", imei = "IMEI", updated = "Letzte Aktivität", apps = "Zugewiesene Apps", account = "Verknüpfter Account" },
credentials = { eyebrow = "Geschützte Daten", title = "Zugangsdaten", email = "iFruit-E-Mail", password = "iFruit-Passwort", reveal = "Anzeigen", copy = "Passwort kopieren", copied = "Passwort kopiert.", noAccount = "Mit diesem Handy ist kein iFruit-Account verknüpft.", passcode = "Gerätecode", passcodeHashed = "{length}-stellige PIN · sicher gehasht und nicht wiederherstellbar", passcodeDisabled = "Kein Gerätecode eingerichtet", revealTitle = "Geschütztes Passwort anzeigen?", revealBody = "Diese Aktion wird serverseitig autorisiert, rate-limitiert und im Admin-Audit protokolliert.", cancel = "Abbrechen", confirmReveal = "Passwort anzeigen" },
apps = { eyebrow = "Fernverwaltung", title = "App-Zugriff", description = "Lege App-Zugriffe für dieses Gerät als Entwurf fest. Erst Speichern übernimmt die Änderungen.", installed = "Installiert", available = "Verfügbar", protected = "System-App", changes = "{count} offene Änderungen" },
activity = { protected = "Geschützte Aktivität", messagesTitle = "Nachrichten", messagesBody = "Letzte SMS-Aktivitäten der ausgewählten SIM.", callsTitle = "Anrufe", callsBody = "Letzte Anrufaktivitäten der ausgewählten SIM.", loading = "Aktivitäten werden geladen...", incoming = "Eingehend", outgoing = "Ausgehend", mediaMessage = "{type}-Nachricht", noMessages = "Keine Nachrichtenaktivität gefunden.", noCalls = "Keine Anrufaktivität gefunden.", status = { completed = "Abgeschlossen", missed = "Verpasst", rejected = "Abgelehnt", busy = "Besetzt", unanswered = "Unbeantwortet", cancelled = "Abgebrochen", failed = "Fehlgeschlagen", ringing = "Klingelt" } },
moderation = {
eyebrow = "Geräteverwaltung", title = "Moderationsaktionen", body = "Jede Aktion wird serverseitig autorisiert, rate-limitiert und protokolliert.", resetPasscode = "Gerätecode zurücksetzen", resetPasscodeBody = "Entfernt die Geräte-PIN und fehlgeschlagene Versuche.", changeNumber = "Nummer ändern", changeNumberBody = "Weist der aktuellen SIM eine neue eindeutige Nummer zu.", factoryReset = "Werksreset", factoryResetBody = "Löscht lokale Gerätedaten und trennt den Account.", saveFirst = "Speichere oder verwirf zuerst offene App-Änderungen.", phoneNumber = "Neue Telefonnummer", phoneNumberPlaceholder = "Vollständige konfigurierte Nummer eingeben", typeToConfirm = "Tippe {word}, um den Werksreset zu bestätigen.", confirmWord = "RESET", cancel = "Abbrechen", ["reset-passcodeSuccess"] = "Gerätecode zurückgesetzt.", ["change-numberSuccess"] = "Telefonnummer geändert.", ["factory-resetSuccess"] = "Werksreset abgeschlossen.",
dialogs = { ["reset-passcodeTitle"] = "Gerätecode zurücksetzen?", ["reset-passcodeBody"] = "Der Spieler kann dieses Handy danach ohne die bisherige PIN entsperren.", ["change-numberTitle"] = "Telefonnummer ändern?", ["change-numberBody"] = "Die neue Nummer muss dem Serverformat entsprechen und eindeutig sein.", ["factory-resetTitle"] = "Werksreset für dieses Handy?", ["factory-resetBody"] = "Lokale Gerätedaten, App-Einstellungen, Sicherheit und der verknüpfte Account werden gelöscht. Das kann nicht rückgängig gemacht werden." },
confirm = { ["reset-passcode"] = "Gerätecode zurücksetzen", ["change-number"] = "Nummer ändern", ["factory-reset"] = "Werksreset" },
},
audit = { eyebrow = "Nachvollziehbarkeit", title = "Audit-Verlauf", body = "Sensible Anzeigen und App-Änderungen werden hier protokolliert.", empty = "Noch keine Admin-Aktionen", emptyBody = "Geschützte Aktionen erscheinen hier, nachdem sie ausgeführt wurden.", by = "{actor} · Ziel-ID {target}", actions = { grant_app = "App installiert", revoke_app = "App entfernt", reveal_account_password = "Passwort angezeigt", view_messages = "Nachrichten angesehen", view_calls = "Anrufe angesehen", reset_passcode = "Gerätecode zurückgesetzt", change_number = "Telefonnummer geändert", factory_reset = "Werksreset ausgeführt", save_configuration = "Konfiguration gespeichert" } },
editor = { brand = "SKY PHONE", workspace = "ADMIN", players = "Spielerverzeichnis", audit = "Audit-Protokoll", selectPlayer = "Wähle einen Spieler, um Identität, Geräte, Zugangsdaten und App-Zugriffe zu prüfen.", save = "Änderungen speichern", saveHint = "Offene Änderungen übernehmen", saved = "Änderungen gespeichert.", unsaved = "Ungespeicherte Änderungen", close = "Admin-Panel schließen", refresh = "Live-Daten aktualisieren", online = "LIVE", profile = "PROFIL", financial = "FINANZEN", device = "GERÄT", security = "SICHERHEIT", noAutoSave = "Manuelles Speichern", noAutoSaveBody = "Änderungen bleiben lokal, bis der grüne Haken gedrückt wird.", discardTitle = "Ungespeicherte Änderungen verwerfen?", discardBody = "Deine vorgemerkten App- oder Konfigurationsänderungen wurden noch nicht gespeichert.", keepEditing = "Weiter bearbeiten", discard = "Änderungen verwerfen", saveFailed = "Einige Änderungen konnten nicht gespeichert werden.", noSelection = "Kein Spieler ausgewählt" },
errors = { not_authorized = "Du hast keinen Zugriff auf das Admin-Panel.", rate_limited = "Zu viele Admin-Anfragen. Bitte warte.", player_unavailable = "Dieser Spieler ist nicht mehr online.", device_not_owned = "Dieses Handy gehört nicht mehr zum ausgewählten Spieler.", invalid_app = "Diese App ist nicht auf dem Server registriert.", app_protected = "Diese System-App kann nicht entfernt werden.", revision_conflict = "Die Daten wurden zwischenzeitlich geändert. Öffne den Bereich neu und versuche es erneut.", configurator_disabled = "Aktiviere zuerst den Phone Configurator in der config.lua.", invalid_field = "Dieses Konfigurationsfeld ist nicht mehr verfügbar.", invalid_value = "Ein Konfigurationswert ist ungültig.", account_not_found = "Mit diesem Handy ist kein iFruit-Account verknüpft.", invalid_phone_number = "Gib eine Telefonnummer im konfigurierten Serverformat ein.", phone_number_unchanged = "Diese SIM verwendet diese Telefonnummer bereits.", phone_number_taken = "Diese Telefonnummer ist bereits vergeben.", no_sim = "Dieses Handy besitzt keine änderbare SIM.", passcode_not_set = "Für dieses Handy ist kein Gerätecode eingerichtet.", device_not_found = "Dieses Handy existiert nicht mehr.", metadata_unsupported = "Die Inventar-Metadaten des Handys konnten nicht aktualisiert werden.", invalid_request = "Die Admin-Anfrage war ungültig.", request_failed = "Die Admin-Anfrage ist fehlgeschlagen.", default = "Das Admin-Panel ist vorübergehend nicht verfügbar." },
},
Apps = {
health = {
name = "Gesundheit",
@@ -606,7 +636,7 @@ Locales["de"] = {
createIdentity = "Dunkle Identität erstellen", createIdentityBody = "Erstell eine private Identität, bevor du ein DarkChat-Gespräch anfängst.",
security = "Sicherheit", privateNetwork = "Privates Einladungs-Nur-Netzwerk", noResults = "Keine Ergebnisse", noResultsBody = "Versucht es mit einem anderen Alias oder Dark-ID.",
noChats = "Keine privaten Chats", noChatsBody = "Verbinde dich über eine Dark-ID oder einen Einladungscode. Es gibt keine öffentliche Suche.", newChat = "Neuer Chat", to = "An:",
connectPrivately = "Privat verbinden", newChatBody = "Gib einen genauen Dark-ID oder Einladungscode ein. Unbekannte Identitäten erfordern eine Bestätigung.", darkIdOrInvite = "Dark-ID oder Einladungscode", continue = "Weiter", contacts = "DarkChat Kontakte", contactsHint = "Gespeicherte private Identitäten erscheinen nur auf deinem DarkChat-Konto.", noContacts = "Keine gespeicherten DarkChat Kontakte", private = "Privat", shareIdentity = "Tippe auf deine private Identität",
connectPrivately = "Privat verbinden", newChatBody = "Gib die Dark-ID oder den Einladungscode einer anderen Person ein. Deine eigene ID kannst du im Profil teilen.", darkIdOrInvite = "Dark-ID oder Einladungscode", continue = "Weiter", contacts = "DarkChat Kontakte", contactsHint = "Gespeicherte private Identitäten erscheinen nur auf deinem DarkChat-Konto.", noContacts = "Keine gespeicherten DarkChat Kontakte", private = "Privat", shareIdentity = "Tippe auf deine private Identität",
message = "Dunkle Botschaft", activeNow = "Anteil der Tätigkeiten", encryptedSession = "Privatsitzung", serverPrivate = "Private server-gespeicherte Konversation",
emoji = "Emoji", gif = "GIF", gifs = "GIF", photo = "Foto", video = "Video", attachPhoto = "Foto anhängen", takePhoto = "Foto machen", attachGif = "GIF-Anlage", attachVideo = "Video anhängen", searchGifs = "GIF durchsuchen", loadMore = "Mehr laden",
voiceMessage = "Sprachnachricht", sending = "Senden", failed = "Nicht geliefert", delivered = "Lieferung", read = "Lesen", replying = "Antwort auf",
@@ -622,7 +652,7 @@ Locales["de"] = {
microphoneUnavailable = "Das Mikrofon ist nicht verfügbar.", recordingTooLarge = "Die Sprachbotschaft ist zu groß.",
errors = {
sign_out_failed = "Sky Cloud konnte sich nicht ausmelden.",
not_authenticated = "Melde dich zuerst bei deinem Sky Cloud-Konto an.", invalid_dark_id = "Gib einen gültigen Dark-ID oder Einladungscode ein.", profile_not_found = "Diese private Identität wurde nicht gefunden.", self_chat = "Du kannst deine Identität nicht selbst sagen.", conversation_not_found = "Diese Unterhaltung ist nicht verfügbar.", blocked = "Nachrichten werden in diesem Gespräch blockiert.", invalid_message = "Gib eine gültige Nachricht ein.", invalid_gif = "Diese GIF ist ungültig.", invalid_voice = "Diese Sprachnachricht ist ungültig.", invalid_attachment = "Dieses Foto oder Video ist nicht verfügbar.", invalid_profile = "Prüfe deinen Alias und deine Privatsphäre.", rate_limited = "Zu viele Anfragen. Versuch es gleich erneut.", gif_provider_unconfigured = "GIF-Suche ist nicht konfiguriert.", gif_provider_unauthorized = "Der GIF-Anbieterschlüssel ist ungültig.", gif_provider_rate_limited = "Die GIF-Suche ist ausgelastet. Versuch es gleich erneut.", gif_provider_failed = "GIFs sind vorübergehend nicht verfügbar.", default = "DarkChat konnte die Anfrage nicht abschließen.",
not_authenticated = "Melde dich zuerst bei deinem Sky Cloud-Konto an.", invalid_dark_id = "Gib eine gültige Dark-ID oder einen Einladungscode ein.", profile_not_found = "Diese private Identität wurde nicht gefunden.", self_chat = "Du kannst deiner eigenen Identität keine Nachricht senden.", conversation_not_found = "Diese Unterhaltung ist nicht verfügbar.", blocked = "Nachrichten werden in diesem Gespräch blockiert.", invalid_message = "Gib eine gültige Nachricht ein.", invalid_gif = "Dieses GIF ist ungültig.", invalid_voice = "Diese Sprachnachricht ist ungültig.", invalid_attachment = "Dieses Foto oder Video ist nicht verfügbar.", invalid_profile = "Prüfe deinen Alias und deine Privatsphäre.", rate_limited = "Zu viele Anfragen. Versuch es gleich erneut.", gif_provider_unconfigured = "GIF-Suche ist nicht konfiguriert.", gif_provider_unauthorized = "Der GIF-Anbieterschlüssel ist ungültig.", gif_provider_rate_limited = "Die GIF-Suche ist ausgelastet. Versuch es gleich erneut.", gif_provider_failed = "GIFs sind vorübergehend nicht verfügbar.", default = "DarkChat konnte die Anfrage nicht abschließen.",
},
},
messages = {
@@ -1069,7 +1099,7 @@ Locales["de"] = {
profile = { verified = "Charakterbesitz verifiziert", walletKey = "Öffentlicher Crypto-Key", walletKeyBody = "Teile diesen Key, um Krypto zu empfangen. Er gewährt keinen Kontozugriff.", copyKey = "Kopieren", copied = "Kopiert", shareKey = "Teilen", shareTitle = "Mein VaultX Crypto-Key", shareText = "Sende Krypto an meinen öffentlichen VaultX-Key:", trades = "Trades", volume = "Volumen", memberSince = "Mitglied seit", preferences = "Einstellungen", priceAlerts = "Kurswarnungen", priceAlertsBody = "Bei auffälligen Marktbewegungen benachrichtigen.", confirmations = "Handelsbestätigung", confirmationsBody = "Zusätzliche Bestätigung vor der Ausführung behalten.", hideBalances = "Privatsphäre-Modus", hideBalancesBody = "Beträge in VaultX verbergen.", identity = "Profilidentität", edit = "Bearbeiten", account = "Konto & Sicherheit", editTitle = "Profil bearbeiten", editBody = "Ändere deinen Benutzernamen oder lege ein neues Passwort fest. Dein aktuelles Passwort bestätigt Identitätsänderungen.", currentPassword = "Aktuelles Passwort", currentPasswordPlaceholder = "Für Namens- oder Passwortänderungen erforderlich", newPassword = "Neues Passwort", newPasswordPlaceholder = "Leer lassen, um das aktuelle Passwort zu behalten", passwordSecurity = "Passwörter werden speicherintensiv gehasht und nie wieder angezeigt.", saved = "Profil sicher gespeichert.", save = "Profil speichern", saveChanges = "Änderungen speichern", securityTitle = "Geschütztes Profil", securityBody = "Dein Profil bleibt an diesen Framework-Charakter und die Finanzsitzung gebunden." },
activityTypes = { buy = "Asset-Kauf", sell = "Asset-Verkauf", deposit = "Bankeinzahlung", withdrawal = "Bankauszahlung", transfer_in = "Krypto empfangen", transfer_out = "Krypto gesendet" },
statuses = { completed = "Abgeschlossen", pending = "Prüfung ausstehend", failed = "Fehlgeschlagen", manual_review = "Manuelle Prüfung" },
errors = { invalid_profile = "Prüfe deine VaultX-Profileinstellungen.", invalid_handle = "Nutze 320 Buchstaben, Zahlen, Punkte oder Unterstriche.", handle_taken = "Dieser VaultX-Benutzername ist bereits vergeben.", profile_exists = "Dieser Charakter besitzt bereits ein VaultX-Profil.", invalid_password = "Das Passwort muss 872 Zeichen lang sein.", password_mismatch = "Die Passwörter stimmen nicht überein.", accept_terms = "Bestätige, dass dies ein fiktives Ingame-Wallet ist.", invalid_credentials = "Das Passwort ist falsch.", locked = "Zu viele Versuche. Versuche es später erneut.", not_authenticated = "Logge dich zuerst bei VaultX ein.", invalid_amount = "Gib einen positiven ganzen Betrag ein.", invalid_quantity = "Gib eine Menge mit bis zu sechs Nachkommastellen ein.", invalid_wallet_key = "Gib einen vollständigen VX-Empfangsschlüssel ein.", recipient_not_found = "Zu diesem Key wurde kein aktives VaultX-Konto gefunden.", self_transfer = "Du kannst keine Krypto an deinen eigenen Key senden.", invalid_transfer = "Prüfe Empfänger, Asset und Krypto-Menge.", recipient_limit_exceeded = "Das Empfänger-Wallet kann diese Menge nicht aufnehmen.", insufficient_funds = "Dein verfügbarer Bestand reicht nicht aus.", insufficient_liquidity = "Die Börse kann diese Menge derzeit nicht ausführen.", quote_expired = "Das Angebot ist abgelaufen. Fordere ein neues an.", quote_unavailable = "Das Angebot ist nicht mehr verfügbar.", market_unavailable = "Dieser Markt ist vorübergehend pausiert.", limit_exceeded = "Die Anfrage überschreitet ein Börsenlimit.", duplicate_request = "Diese Anfrage wurde bereits verarbeitet.", rate_limited = "Zu viele Anfragen. Versuche es gleich erneut.", settlement_pending = "Eine Geldbewegung wartet bereits auf Prüfung.", service_unavailable = "VaultX ist vorübergehend nicht verfügbar.", request_failed = "Die sichere Börsenanfrage ist fehlgeschlagen.", default = "VaultX konnte die Anfrage nicht abschließen." },
errors = { invalid_profile = "Prüfe deine VaultX-Profileinstellungen.", invalid_handle = "Nutze 320 Buchstaben, Zahlen, Punkte oder Unterstriche.", handle_taken = "Dieser VaultX-Benutzername ist bereits vergeben.", profile_exists = "Dieser Charakter besitzt bereits ein VaultX-Profil.", invalid_password = "Nutze 872 Zeichen mit Groß- und Kleinbuchstaben, einer Zahl und einem Sonderzeichen.", password_mismatch = "Die Passwörter stimmen nicht überein.", accept_terms = "Bestätige, dass dies ein fiktives Ingame-Wallet ist.", invalid_credentials = "Das Passwort ist falsch.", locked = "Zu viele Versuche. Versuche es später erneut.", not_authenticated = "Logge dich zuerst bei VaultX ein.", invalid_amount = "Gib einen positiven ganzen Betrag ein.", invalid_quantity = "Gib eine Menge mit bis zu sechs Nachkommastellen ein.", invalid_wallet_key = "Gib einen vollständigen VX-Empfangsschlüssel ein.", recipient_not_found = "Zu diesem Key wurde kein aktives VaultX-Konto gefunden.", self_transfer = "Du kannst keine Krypto an deinen eigenen Key senden.", invalid_transfer = "Prüfe Empfänger, Asset und Krypto-Menge.", recipient_limit_exceeded = "Das Empfänger-Wallet kann diese Menge nicht aufnehmen.", insufficient_funds = "Dein verfügbarer Bestand reicht nicht aus.", insufficient_liquidity = "Die Börse kann diese Menge derzeit nicht ausführen.", quote_expired = "Das Angebot ist abgelaufen. Fordere ein neues an.", quote_unavailable = "Das Angebot ist nicht mehr verfügbar.", market_unavailable = "Dieser Markt ist vorübergehend pausiert.", limit_exceeded = "Die Anfrage überschreitet ein Börsenlimit.", duplicate_request = "Diese Anfrage wurde bereits verarbeitet.", rate_limited = "Zu viele Anfragen. Versuche es gleich erneut.", settlement_pending = "Eine Geldbewegung wartet bereits auf Prüfung.", service_unavailable = "VaultX ist vorübergehend nicht verfügbar.", request_failed = "Die sichere Börsenanfrage ist fehlgeschlagen.", default = "VaultX konnte die Anfrage nicht abschließen." },
},
banking = {
name = "Banking", welcome = "Willkommen zurück", totalBalance = "Gesamtsaldo", recentPeriod = "in jüngster Zeit",
@@ -1207,7 +1237,7 @@ Locales["de"] = {
zoomIn = "Vergrößern", zoomOut = "Auszoomen", resetMap = "Kartenansicht zurücksetzen",
savedPlaces = "Gespeicherte Plätze", savedPlace = "Gespeicherter Ort", quickDestinations = "Schnelle Reiseziele",
quickLocations = { ["legion-square"] = "Legionsplatz", ["diamond-casino"] = "Diamant Casino", airport = "Flughafen Los Santos", vinewood = "Weinholz" },
viewRides = "Ansicht Einstellungen für die Fahrt", change = "Veränderung", requestRide = "Anfrage SkyRide",
viewRides = "Fahrtoptionen anzeigen", change = "Ändern", requestRide = "SkyRide anfragen", playerDriverNotice = "SkyRide vermittelt an echte Spieler-Fahrer. Ein Fahrer muss online sein und deine Anfrage annehmen.",
serviceMeta = "{eta} min entfernt · {seats} Sitze", distanceMeters = "{distance} m",
distanceKilometers = "{distance} km", distanceMiles = "{distance} mi", durationMinutes = "{minutes} min",
fare = "Fahrpreis", calculatedFare = "Berechnet", customFare = "Eigener Preis",
@@ -1241,7 +1271,7 @@ Locales["de"] = {
arrived = "Fahrer angekommen", in_progress = "Auf dem Weg", completed = "Abgeschlossen", cancelled = "Abgebrochen",
},
statusBody = {
searching = "Wir passen zu dir mit einem Fahrer in der Nähe.", accepted = "Dein Fahrer will dich abholen.",
searching = "Deine Anfrage wartet darauf, dass ein verfügbarer Spieler-Fahrer sie annimmt.", accepted = "Dein Fahrer bereitet die Abholung vor.",
driver_arriving = "Dein Fahrer ist auf dem Weg zu deinem Pickup.", arrived = "Dein Fahrer wartet am Abholpunkt.",
in_progress = "Du bist auf dem Weg zu deinem Ziel.", completed = "Du bist am Ziel angekommen.",
cancelled = "Diese Fahrt ist nicht mehr aktiv.",
@@ -2107,3 +2137,67 @@ Locales["de"] = {
},
},
}
Locales["de"].Nui.AdminPanel.configurator.table.subtabs = {
Dictionaries = "Animationsdateien",
Clips = "Clips",
Transforms = "Transformationen",
RateLimits = "Ratenlimits",
Publishers = "Herausgeber",
ExternalPingResources = "Externe Ping-Ressourcen",
Markets = "Märkte",
TrustedAdapters = "Vertrauenswürdige Adapter",
AllowedDisappearTimers = "Erlaubte Löschzeiten",
MusicTracks = "Musiktitel",
VehicleImages = "Fahrzeugbilder",
Custom = "Benutzerdefiniert",
Valet = "Parkservice",
AutoPriority = "Automatische Priorität",
Camera = "Kamera",
Categories = "Kategorien",
Districts = "Bezirke",
PhotoGradients = "Fotoverläufe",
LbPhone = "LB Phone",
Tracks = "Titel",
Props = "Props",
CustomLocations = "Benutzerdefinierte Standorte",
Animation = "Animation",
ReportReasons = "Meldegründe",
DisplayName = "Anzeigename",
Hud = "HUD",
Badge = "Abzeichen",
LockedChannels = "Gesperrte Kanäle",
NumberGroups = "Nummerngruppen",
CustomFare = "Benutzerdefinierter Tarif",
DriverJobs = "Fahrerjobs",
Services = "Dienstleistungen",
QuickLocations = "Schnellziele",
AllowedJobs = "Erlaubte Jobs",
Websites = "Webseiten",
}
Locales["de"].Nui.AdminPanel.appearance.title = "Oberfläche"
Locales["de"].Nui.AdminPanel.appearance.body = "Passe Farben und Typografie im gesamten Admin-Bereich an."
Locales["de"].Nui.AdminPanel.appearance.controls = {
customColor = "Eigene Farbe",
customColorBody = "Wähle einen beliebigen RGB-Akzent oder trage die Farbwerte ein.",
red = "R",
green = "G",
blue = "B",
hex = "HEX-Farbe",
value = "Wert",
fontFamily = "Schriftart",
fontFamilyBody = "Wähle die Schrift für das gesamte Admin-Panel.",
fontSize = "Schriftgröße",
fontSizeBody = "Skaliere Text und Bedienelemente für eine angenehme Lesbarkeit.",
fonts = {
inter = "Inter",
system = "System",
classic = "Klassisch",
verdana = "Verdana",
tahoma = "Tahoma",
trebuchet = "Trebuchet",
georgia = "Georgia",
mono = "Monospace",
},
}
+98 -4
View File
@@ -1,5 +1,13 @@
Locales["en"] = {
CommandDescription = "Open your phone.",
AdminCommand = {
CommandDescription = "Open phone administration and configuration.",
Errors = {
disabled = "The phone admin panel is disabled.",
not_authorized = "You do not have access to the phone admin panel.",
default = "The phone admin panel could not be opened.",
},
},
Controls = {
OpenPhone = "Open phone",
},
@@ -193,6 +201,28 @@ Locales["en"] = {
contacts = { name = "Favorites", description = "Call or message your favorite contacts.", choose = "Favorite Contacts" },
},
},
AdminPanel = {
name = "Phone Admin", subtitle = "Administration", navigation = "Admin navigation", refresh = "Refresh admin data", loading = "Loading protected data...",
tabs = { overview = "Overview", players = "Players", devices = "Devices", apps = "Apps", accounts = "Accounts", messages = "Messages", calls = "Calls", moderation = "Moderation", audit = "Audit", configurator = "Phone configurator" },
overview = { eyebrow = "Server", title = "Dashboard", body = "Players, devices, apps, and phone data.", stats = "Server phone statistics", online = "Online", devices = "Devices", accounts = "Accounts", audit = "Audit entries", control = "Navigation", features = "Modules", featuresBody = "Open an administration module.", recent = "Recent activity", playerFeature = "Identity, finances, job, and duty", deviceFeature = "IMEI, SIM, number, and activity", appFeature = "Install or remove phone apps", accountFeature = "Account access and protected credentials", messageFeature = "Review recent SMS activity", callFeature = "Review recent call activity", moderationFeature = "Reset access, number, or device data", auditFeature = "Review sensitive admin actions", configuratorFeature = "Manage config.lua and media.lua through SQL" },
appearance = { eyebrow = "Appearance", title = "Accent color", body = "Change the accent across the complete admin workspace.", colors = { emerald = "Emerald", blue = "Blue", violet = "Violet", orange = "Orange", red = "Red" } },
configurator = { context = "Runtime configuration", eyebrow = "System tool", sections = "Configuration", search = "Search settings or paths", configScope = "config.lua", mediaScope = "media.lua", noResults = "No matching settings", loading = "Loading SQL configuration...", title = "Phone configurator", body = "Manage phone and media settings from the protected admin workspace.", disabledTitle = "SQL configuration is not active", disabledBody = "Enable the configurator at the beginning of config.lua and restart sky_phone. Until then, file values remain active and editing is locked.", manualSave = "Manual save", refreshNotice = "Nothing is written automatically. The green check verifies config.lua and media.lua in SQL and refreshes the active server, client, media and UI configuration immediately.", fieldCount = "{count} fields", secretConfigured = "Secret configured · enter a replacement", invalidValue = "Check the highlighted table or number value.", saved = "SQL configuration saved and applied.", descriptions = { featureToggle = "Turns {name} on or off.", boolean = "Controls whether {name} is allowed.", number = "Sets the numeric value for {name}.", text = "Sets the text value used for {name}.", optionalText = "Sets the optional value for {name}; switch it off to disable it.", list = "Manages all entries used for {name}.", table = "Groups the related settings for {name}.", credential = "Stores the protected credential used by {name}.", url = "Sets the URL or endpoint used by {name}.", hosts = "Defines which domains are allowed for {name}.", milliseconds = "Sets the timing for {name} in milliseconds.", seconds = "Sets the timing for {name} in seconds.", rateLimit = "Limits how many {name} actions are allowed per minute.", byteLimit = "Sets the maximum data size allowed for {name}.", textLimit = "Sets the maximum text length allowed for {name}.", distance = "Sets the world distance used for {name}.", coordinates = "Sets the world coordinates or orientation for {name}.", gameAsset = "Sets the GTA model or prop used for {name}.", animation = "Sets the animation asset used for {name}.", access = "Defines the jobs, groups or permission level for {name}.", integration = "Selects the connected framework or provider for {name}.", path = "Sets the storage or resource path used for {name}.", color = "Sets the interface color used for {name}.", displayText = "Sets the text shown to players for {name}.", phoneNumber = "Sets the phone or service number used for {name}.", routing = "Controls how incoming requests are routed for {name}.", command = "Sets the chat command used to open or run {name}.", locale = "Selects the language used for {name}.", debug = "Controls detailed diagnostic output for {name}.", mediaQuality = "Sets the media quality or volume used for {name}.", amount = "Sets the maximum or displayed amount for {name}." }, table = { list = "List", table = "Key table", vector = "Vector", entry = "Entry", general = "General", addRow = "Add row", addField = "Add field", remove = "Remove", emptyList = "No rows yet. Add the first row with plus.", emptyTable = "No fields yet. Add the first key below.", keyPlaceholder = "New key", convertToList = "Use list", convertToMap = "Use typed key table", convertToTable = "Use key table", types = { string = "Text", number = "Number", boolean = "Switch", list = "List", table = "Table" } } },
players = { eyebrow = "Active sessions", title = "Online players", online = "Online now", empty = "No players found", emptyBody = "Adjust the search or refresh the live player list." },
search = { players = "Search name, ID, job, or number", apps = "Search apps", clear = "Clear search" },
detail = { character = "Character profile", data = "Player data overview", cash = "Cash", bank = "Bank", job = "Job", duty = "Duty", onDuty = "On duty", offDuty = "Off duty", identity = "Identity", playerData = "Player data", identifier = "Character identifier", birthdate = "Birthdate", grade = "Job grade", unknown = "Unknown" },
devices = { eyebrow = "Device control", title = "Phones", body = "Inspect every phone assigned to the selected player.", choose = "Choose phone", empty = "No phone found", emptyBody = "This player currently has no phone device that can be managed.", noNumber = "No phone number", noSim = "No SIM", imei = "IMEI", updated = "Last activity", apps = "Claimed apps", account = "Linked account" },
credentials = { eyebrow = "Protected data", title = "Credentials", email = "iFruit email", password = "iFruit password", reveal = "Reveal", copy = "Copy password", copied = "Password copied.", noAccount = "No iFruit account is linked to this phone.", passcode = "Device passcode", passcodeHashed = "{length}-digit PIN · securely hashed and not recoverable", passcodeDisabled = "No passcode configured", revealTitle = "Reveal protected password?", revealBody = "This action is server-authorized, rate-limited, and written to the admin audit log.", cancel = "Cancel", confirmReveal = "Reveal password" },
apps = { eyebrow = "Remote management", title = "App access", description = "Stage app access for this device. Nothing changes until you save.", installed = "Installed", available = "Available", protected = "System app", changes = "{count} pending changes" },
activity = { protected = "Protected activity", messagesTitle = "Messages", messagesBody = "Recent SMS activity for the selected SIM.", callsTitle = "Calls", callsBody = "Recent call activity for the selected SIM.", loading = "Loading activity...", incoming = "Incoming", outgoing = "Outgoing", mediaMessage = "{type} message", noMessages = "No message activity found.", noCalls = "No call activity found.", status = { completed = "Completed", missed = "Missed", rejected = "Rejected", busy = "Busy", unanswered = "Unanswered", cancelled = "Cancelled", failed = "Failed", ringing = "Ringing" } },
moderation = {
eyebrow = "Device administration", title = "Moderation actions", body = "Every action is server-authorized, rate-limited, and audited.", resetPasscode = "Reset passcode", resetPasscodeBody = "Remove the device PIN and clear failed attempts.", changeNumber = "Change number", changeNumberBody = "Assign a new unique number to the current SIM.", factoryReset = "Factory reset", factoryResetBody = "Clear local device data and disconnect the account.", saveFirst = "Save or discard pending app changes first.", phoneNumber = "New phone number", phoneNumberPlaceholder = "Enter the full configured number", typeToConfirm = "Type {word} to confirm the factory reset.", confirmWord = "RESET", cancel = "Cancel", ["reset-passcodeSuccess"] = "Passcode reset.", ["change-numberSuccess"] = "Phone number changed.", ["factory-resetSuccess"] = "Phone factory reset completed.",
dialogs = { ["reset-passcodeTitle"] = "Reset device passcode?", ["reset-passcodeBody"] = "The player can unlock this phone without the previous PIN afterward.", ["change-numberTitle"] = "Change phone number?", ["change-numberBody"] = "The new number must match the configured server number format and be unique.", ["factory-resetTitle"] = "Factory reset this phone?", ["factory-resetBody"] = "This clears local device data, app settings, security, and the linked account. This cannot be undone." },
confirm = { ["reset-passcode"] = "Reset passcode", ["change-number"] = "Change number", ["factory-reset"] = "Factory reset" },
},
audit = { eyebrow = "Accountability", title = "Audit trail", body = "Sensitive reveals and remote app changes are recorded here.", empty = "No admin actions yet", emptyBody = "Protected actions will appear here after they are performed.", by = "{actor} · target ID {target}", actions = { grant_app = "App installed", revoke_app = "App removed", reveal_account_password = "Password revealed", view_messages = "Messages viewed", view_calls = "Calls viewed", reset_passcode = "Passcode reset", change_number = "Phone number changed", factory_reset = "Phone factory reset", save_configuration = "Configuration saved" } },
editor = { brand = "SKY PHONE", workspace = "ADMIN", players = "Player directory", audit = "Audit log", selectPlayer = "Select a player to inspect identity, devices, credentials, and app access.", save = "Save changes", saveHint = "Apply pending changes", saved = "Changes saved.", unsaved = "Unsaved changes", close = "Close admin panel", refresh = "Refresh live data", online = "LIVE", profile = "PROFILE", financial = "FINANCIAL", device = "DEVICE", security = "SECURITY", noAutoSave = "Manual save", noAutoSaveBody = "Changes stay local until the green check is pressed.", discardTitle = "Discard unsaved changes?", discardBody = "Your staged app or configuration changes have not been saved.", keepEditing = "Keep editing", discard = "Discard changes", saveFailed = "Some changes could not be saved.", noSelection = "No player selected" },
errors = { not_authorized = "You do not have access to the admin panel.", rate_limited = "Too many admin requests. Please wait.", player_unavailable = "That player is no longer online.", device_not_owned = "That phone no longer belongs to the selected player.", invalid_app = "That app is not registered on the server.", app_protected = "This system app cannot be removed.", revision_conflict = "The data changed in the meantime. Reopen the section and try again.", configurator_disabled = "Enable the phone configurator in config.lua first.", invalid_field = "That configuration field is no longer available.", invalid_value = "A configuration value is invalid.", account_not_found = "No iFruit account is linked to this phone.", invalid_phone_number = "Enter a phone number in the configured server format.", phone_number_unchanged = "This SIM already uses that phone number.", phone_number_taken = "That phone number is already assigned.", no_sim = "This phone has no SIM that can be changed.", passcode_not_set = "This phone has no passcode configured.", device_not_found = "This phone no longer exists.", metadata_unsupported = "The phone inventory metadata could not be updated.", invalid_request = "The admin request was invalid.", request_failed = "The admin request failed.", default = "The admin panel is temporarily unavailable." },
},
Apps = {
health = {
name = "Health",
@@ -606,7 +636,7 @@ Locales["en"] = {
createIdentity = "Create Dark Identity", createIdentityBody = "Create a private identity before starting a DarkChat conversation.",
security = "Security", privateNetwork = "Private invitation-only network", noResults = "No Results", noResultsBody = "Try another alias or Dark-ID.",
noChats = "No Private Chats", noChatsBody = "Connect with a Dark-ID or invitation code. There is no public search.", newChat = "New Chat", to = "To:",
connectPrivately = "Connect privately", newChatBody = "Enter an exact Dark-ID or invitation code. Unknown identities require confirmation.", darkIdOrInvite = "Dark-ID or invitation code", continue = "Continue", contacts = "DarkChat Contacts", contactsHint = "Saved private identities appear only on your DarkChat account.", noContacts = "No saved DarkChat contacts", private = "Private", shareIdentity = "Tap to share your private identity",
connectPrivately = "Connect privately", newChatBody = "Enter another person's Dark-ID or invitation code. You can share your own ID from your profile.", darkIdOrInvite = "Dark-ID or invitation code", continue = "Continue", contacts = "DarkChat Contacts", contactsHint = "Saved private identities appear only on your DarkChat account.", noContacts = "No saved DarkChat contacts", private = "Private", shareIdentity = "Tap to share your private identity",
message = "Dark message", activeNow = "Activity shared", encryptedSession = "Private session", serverPrivate = "Private server-stored conversation",
emoji = "Emoji", gif = "GIF", gifs = "GIFs", photo = "Photo", video = "Video", attachPhoto = "Attach Photo", takePhoto = "Take Photo", attachGif = "Attach GIF", attachVideo = "Attach Video", searchGifs = "Search GIFs", loadMore = "Load More",
voiceMessage = "Voice message", sending = "Sending", failed = "Not delivered", delivered = "Delivered", read = "Read", replying = "Replying to",
@@ -1069,7 +1099,7 @@ Locales["en"] = {
profile = { verified = "Character ownership verified", walletKey = "Public crypto key", walletKeyBody = "Share this key to receive crypto. It cannot access your account.", copyKey = "Copy", copied = "Copied", shareKey = "Share", shareTitle = "My VaultX crypto key", shareText = "Send crypto to my public VaultX key:", trades = "Trades", volume = "Volume", memberSince = "Member since", preferences = "Preferences", priceAlerts = "Price alerts", priceAlertsBody = "Notify me about notable market moves.", confirmations = "Trade confirmations", confirmationsBody = "Keep an extra confirmation before execution.", hideBalances = "Privacy mode", hideBalancesBody = "Hide balances across VaultX.", identity = "Profile identity", edit = "Edit", account = "Account & security", editTitle = "Edit profile", editBody = "Update your handle or set a new password. Your current password confirms identity changes.", currentPassword = "Current password", currentPasswordPlaceholder = "Required for handle or password changes", newPassword = "New password", newPasswordPlaceholder = "Leave blank to keep your current password", passwordSecurity = "Passwords use memory-hard hashing and are never shown again.", saved = "Profile saved securely.", save = "Save profile", saveChanges = "Save changes", securityTitle = "Protected profile", securityBody = "Your profile stays bound to this framework character and financial session." },
activityTypes = { buy = "Asset purchase", sell = "Asset sale", deposit = "Bank deposit", withdrawal = "Bank withdrawal", transfer_in = "Crypto received", transfer_out = "Crypto sent" },
statuses = { completed = "Completed", pending = "Pending review", failed = "Failed", manual_review = "Manual review" },
errors = { invalid_profile = "Check your VaultX profile settings.", invalid_handle = "Use 320 letters, numbers, dots or underscores.", handle_taken = "That VaultX handle is already taken.", profile_exists = "This character already owns a VaultX profile.", invalid_password = "Password must be 872 characters.", password_mismatch = "The passwords do not match.", accept_terms = "Confirm that this is a fictional in-game wallet.", invalid_credentials = "The password is incorrect.", locked = "Too many attempts. Try again later.", not_authenticated = "Sign in to VaultX first.", invalid_amount = "Enter a positive whole amount.", invalid_quantity = "Enter a quantity with up to six decimals.", invalid_wallet_key = "Enter a complete VX receiving key.", recipient_not_found = "No active VaultX account uses this key.", self_transfer = "You cannot send crypto to your own key.", invalid_transfer = "Verify a recipient, asset and valid crypto amount.", recipient_limit_exceeded = "The recipient wallet cannot hold that amount.", insufficient_funds = "Your available balance is too low.", insufficient_liquidity = "The exchange cannot fill that amount right now.", quote_expired = "This quote expired. Request a new one.", quote_unavailable = "The quote is no longer available.", market_unavailable = "This market is temporarily paused.", limit_exceeded = "This request exceeds an exchange limit.", duplicate_request = "This request was already processed.", rate_limited = "Too many requests. Try again shortly.", settlement_pending = "A money transfer is already pending review.", service_unavailable = "VaultX is temporarily unavailable.", request_failed = "The secure exchange request failed.", default = "VaultX could not complete the request." },
errors = { invalid_profile = "Check your VaultX profile settings.", invalid_handle = "Use 320 letters, numbers, dots or underscores.", handle_taken = "That VaultX handle is already taken.", profile_exists = "This character already owns a VaultX profile.", invalid_password = "Use 872 characters with uppercase, lowercase, a number, and a special character.", password_mismatch = "The passwords do not match.", accept_terms = "Confirm that this is a fictional in-game wallet.", invalid_credentials = "The password is incorrect.", locked = "Too many attempts. Try again later.", not_authenticated = "Sign in to VaultX first.", invalid_amount = "Enter a positive whole amount.", invalid_quantity = "Enter a quantity with up to six decimals.", invalid_wallet_key = "Enter a complete VX receiving key.", recipient_not_found = "No active VaultX account uses this key.", self_transfer = "You cannot send crypto to your own key.", invalid_transfer = "Verify a recipient, asset and valid crypto amount.", recipient_limit_exceeded = "The recipient wallet cannot hold that amount.", insufficient_funds = "Your available balance is too low.", insufficient_liquidity = "The exchange cannot fill that amount right now.", quote_expired = "This quote expired. Request a new one.", quote_unavailable = "The quote is no longer available.", market_unavailable = "This market is temporarily paused.", limit_exceeded = "This request exceeds an exchange limit.", duplicate_request = "This request was already processed.", rate_limited = "Too many requests. Try again shortly.", settlement_pending = "A money transfer is already pending review.", service_unavailable = "VaultX is temporarily unavailable.", request_failed = "The secure exchange request failed.", default = "VaultX could not complete the request." },
},
banking = {
name = "Banking", welcome = "Welcome back", totalBalance = "Total Balance", recentPeriod = "in recent activity",
@@ -1207,7 +1237,7 @@ Locales["en"] = {
zoomIn = "Zoom in", zoomOut = "Zoom out", resetMap = "Reset map view",
savedPlaces = "Saved places", savedPlace = "Saved place", quickDestinations = "Quick destinations",
quickLocations = { ["legion-square"] = "Legion Square", ["diamond-casino"] = "Diamond Casino", airport = "Los Santos Airport", vinewood = "Vinewood" },
viewRides = "View Ride Options", change = "Change", requestRide = "Request SkyRide",
viewRides = "View Ride Options", change = "Change", requestRide = "Request SkyRide", playerDriverNotice = "SkyRide matches you with real player drivers. A driver must be online and accept your request.",
serviceMeta = "{eta} min away · {seats} seats", distanceMeters = "{distance} m",
distanceKilometers = "{distance} km", distanceMiles = "{distance} mi", durationMinutes = "{minutes} min",
fare = "Trip fare", calculatedFare = "Calculated", customFare = "Own price",
@@ -1241,7 +1271,7 @@ Locales["en"] = {
arrived = "Driver arrived", in_progress = "On the way", completed = "Completed", cancelled = "Cancelled",
},
statusBody = {
searching = "We are matching you with a nearby driver.", accepted = "Your driver is preparing to pick you up.",
searching = "Your request is waiting for an available player driver to accept it.", accepted = "Your driver is preparing to pick you up.",
driver_arriving = "Your driver is on the way to your pickup.", arrived = "Your driver is waiting at the pickup point.",
in_progress = "You are on the way to your destination.", completed = "You have arrived at your destination.",
cancelled = "This ride is no longer active.",
@@ -2107,3 +2137,67 @@ Locales["en"] = {
},
},
}
Locales["en"].Nui.AdminPanel.configurator.table.subtabs = {
Dictionaries = "Dictionaries",
Clips = "Clips",
Transforms = "Transforms",
RateLimits = "Rate Limits",
Publishers = "Publishers",
ExternalPingResources = "External Ping Resources",
Markets = "Markets",
TrustedAdapters = "Trusted Adapters",
AllowedDisappearTimers = "Allowed Disappear Timers",
MusicTracks = "Music Tracks",
VehicleImages = "Vehicle Images",
Custom = "Custom",
Valet = "Valet",
AutoPriority = "Automatic Priority",
Camera = "Camera",
Categories = "Categories",
Districts = "Districts",
PhotoGradients = "Photo Gradients",
LbPhone = "LB Phone",
Tracks = "Tracks",
Props = "Props",
CustomLocations = "Custom Locations",
Animation = "Animation",
ReportReasons = "Report Reasons",
DisplayName = "Display Name",
Hud = "HUD",
Badge = "Badge",
LockedChannels = "Locked Channels",
NumberGroups = "Number Groups",
CustomFare = "Custom Fare",
DriverJobs = "Driver Jobs",
Services = "Services",
QuickLocations = "Quick Locations",
AllowedJobs = "Allowed Jobs",
Websites = "Websites",
}
Locales["en"].Nui.AdminPanel.appearance.title = "Interface"
Locales["en"].Nui.AdminPanel.appearance.body = "Personalize colors and typography across the admin workspace."
Locales["en"].Nui.AdminPanel.appearance.controls = {
customColor = "Custom color",
customColorBody = "Choose any RGB accent or enter its channel values.",
red = "R",
green = "G",
blue = "B",
hex = "HEX color",
value = "value",
fontFamily = "Font family",
fontFamilyBody = "Choose the typeface used by the complete admin panel.",
fontSize = "Font size",
fontSizeBody = "Scale text and controls for comfortable readability.",
fonts = {
inter = "Inter",
system = "System",
classic = "Classic",
verdana = "Verdana",
tahoma = "Tahoma",
trebuchet = "Trebuchet",
georgia = "Georgia",
mono = "Monospace",
},
}
File diff suppressed because it is too large Load Diff
+11 -4
View File
@@ -1,19 +1,26 @@
--[[
Sky Phone media configuration
This file is loaded on the server only. Keep API keys private and restart
sky_phone after changing providers, upload limits, or import sources.
This file is loaded on the server only. Keep API keys private. When the
phone configurator is enabled in config.lua, the active media configuration
is loaded from SQL and frontend builds copy the shipped values from this
file into source/shared/config_default.lua.
]]
Config.Media = {
GiphyApiKey = "", -- Paste the GIPHY API key here.
-- Create or sign in to an account at https://developers.giphy.com/, open
-- the Developer Dashboard, choose "Create an API Key", and paste it here.
GiphyApiKey = "",
GifPageSize = 24,
GifRating = "pg-13",
UrlMaxLength = 2048,
AllowedGifHosts = { "giphy.com" },
-- Create or sign in at https://fivemanage.com/, create or select your team,
-- then open Dashboard > Tokens and create a token with Media access. Keep
-- this server-only token private and paste it here.
FiveManage = {
ApiKey = "", -- Paste a newly generated FiveManage V3 Media API token here.
ApiKey = "",
BaseUrl = "https://api.fivemanage.com/api/v3/file",
RequestTimeoutMs = 10000,
UploadTimeoutMs = 25000,
+12 -2
View File
@@ -6,7 +6,7 @@ use_experimental_fxv2_oal 'yes'
author 'Sky-Systems'
description 'Sky Phone'
version '0.2.1'
version '0.2.7'
provide 'lb-phone'
provide '17mov_Phone'
@@ -32,8 +32,10 @@ client_scripts {
'config/config.lua',
'config/locales/en.lua',
'config/locales/de.lua',
'source/bridge/client/framework.lua',
'config/locales/es.lua',
'source/bridge/client/callbacks.lua',
'source/client/phone_configurator.lua',
'source/bridge/client/framework.lua',
'source/bridge/client/housing.lua',
'source/bridge/client/housing/*.lua',
'source/bridge/client/calls.lua',
@@ -76,16 +78,22 @@ server_scripts {
'config/media.lua',
'config/locales/en.lua',
'config/locales/de.lua',
'config/locales/es.lua',
'source/shared/config_default.lua',
'source/server/nui_build_check.lua',
'source/server/update_check.lua',
'source/bridge/server/database.lua',
'source/bridge/server/migrations.lua',
'source/server/phone_configurator_schema.lua',
'source/bridge/server/callbacks.lua',
'source/server/phone_configurator.lua',
'source/bridge/server/framework.lua',
'source/bridge/server/frameworks/*.lua',
'source/bridge/server/housing.lua',
'source/bridge/server/housing/*.lua',
'source/bridge/server/inventory.lua',
'source/bridge/server/inventory/*.lua',
'source/bridge/server/inventory_contract.lua',
'source/bridge/server/voice.lua',
'source/server/custom_apps.lua',
'source/server/media_metadata.lua',
@@ -99,6 +107,7 @@ server_scripts {
'source/server/phone.lua',
'source/server/device_directory.lua',
'source/server/db_migrate.lua',
'source/server/admin.lua',
'source/server/lb_phone_migration.lua',
'source/server/custom_app_storage.lua',
'source/server/payphones.lua',
@@ -150,6 +159,7 @@ files {
'source/html/index.html',
'source/html/assets/**',
'source/html/img/**',
'source/html/sounds/**',
'config/music/**',
}
+16 -3
View File
@@ -21,11 +21,24 @@ local function resolve_framework()
return nil
end
framework_name = resolve_framework()
if not framework_name then
error("[sky_phone] No supported framework is running. Configure Config.Bridge.Framework.")
local function refresh_framework()
local resolved = resolve_framework()
if resolved ~= "esx" and resolved ~= "qbox" and resolved ~= "qb" then
error("[sky_phone] No supported framework is running. Configure Config.Bridge.Framework.")
end
if resolved == framework_name then
return
end
framework_name = resolved
esx = nil
qb = nil
end
refresh_framework()
AddEventHandler("sky_phone:configurator:updated", refresh_framework)
function Bridge.Framework.GetName()
return framework_name
end
+17 -2
View File
@@ -1,4 +1,5 @@
local provider
local configured_voice_provider = Config.Radio.VoiceProvider
local provider_resources = {
yaca = "yaca-voice",
pma = "pma-voice",
@@ -138,8 +139,7 @@ function Bridge.Radio.Join(primary, secondary)
return false
end
function Bridge.Radio.Leave()
local selected = resolve_provider()
local function leave_provider(selected)
if selected == "yaca" then
exports["yaca-voice"]:enableRadio(false)
elseif selected == "pma" then
@@ -151,6 +151,10 @@ function Bridge.Radio.Leave()
end
end
function Bridge.Radio.Leave()
leave_provider(resolve_provider())
end
function Bridge.Radio.SetVolume(volume)
local selected = resolve_provider()
if selected == "yaca" then
@@ -165,6 +169,17 @@ function Bridge.Radio.SetVolume(volume)
end
end
AddEventHandler("sky_phone:configurator:updated", function()
if configured_voice_provider == Config.Radio.VoiceProvider then
return
end
leave_provider(provider)
provider = nil
configured_voice_provider = Config.Radio.VoiceProvider
TriggerEvent("sky_phone:client:radioProviderUpdated")
end)
AddEventHandler("onResourceStop", function(resource_name)
if resource_name == GetCurrentResourceName() then
Bridge.Radio.Leave()
@@ -19,3 +19,23 @@ Bridge.Framework.Name = configured_framework
function Bridge.Framework.GetName()
return Bridge.Framework.Name
end
function Bridge.Framework.HasPermission(source, permission)
if type(permission) ~= "string" or permission == "" then
error("[sky_phone] Permission identifiers must be non-empty strings.")
end
local groups = Config.CommandPermissions[permission]
if type(groups) ~= "table" or #groups == 0 then
local message = "[sky_phone] Config.CommandPermissions.%s must contain at least one group."
error(message:format(permission))
end
for index, group in ipairs(groups) do
if type(group) ~= "string" or group == "" then
local message = "[sky_phone] Config.CommandPermissions.%s[%s] must be a non-empty string."
error(message:format(permission, index))
end
end
return Bridge.Framework.HasAdminGroup(source, groups)
end
@@ -24,6 +24,13 @@ function Bridge.Framework.HasAdminGroup(source, groups)
if not player then
return false
end
for _, group in ipairs(groups) do
if IsPlayerAceAllowed(tostring(source), group) then
return true
end
end
return exports.qbx_core:HasGroup(source, groups)
end
@@ -111,3 +111,8 @@ end
AddEventHandler("onResourceStart", reset_provider)
AddEventHandler("onResourceStop", reset_provider)
AddEventHandler("sky_phone:configurator:serverUpdated", function()
selected_provider_name = nil
last_provider_state = nil
end)
+94 -50
View File
@@ -1,62 +1,92 @@
local inventory_adapters = {
{ name = "jaksam", resource = "jaksam_inventory" },
{ name = "qs", resource = "qs-inventory" },
{ name = "ps", resource = "ps-inventory", framework = "qb" },
{ name = "codem", resource = "codem-inventory" },
{ name = "tgiann", resource = "tgiann-inventory" },
{ name = "core", resource = "core_inventory" },
{ name = "jpr", resource = "jpr-inventory", framework = "qb" },
{ name = "origen", resource = "origen_inventory" },
{ name = "ak47", resource = "ak47_inventory" },
{ name = "one", resource = "one_inventory" },
{ name = "ox", resource = "ox_inventory" },
{ name = "mf", resource = "mf-inventory", framework = "esx" },
{ name = "smx", resource = "smx-inventory", framework = "esx" },
{ name = "lj", resource = "lj-inventory" },
{ name = "qb", resource = "qb-inventory" },
{ name = "hex", resource = "hex_4_inventory", framework = "esx", metadata = false },
{ name = "esx", resource = "es_extended", framework = "esx", metadata = false },
}
local inventory_aliases = {
["qb-inv"] = "qb",
qbox = "ox",
}
local supported_inventories = {}
for _, adapter in ipairs(inventory_adapters) do
inventory_aliases[adapter.resource] = adapter.name
supported_inventories[adapter.name] = adapter
end
local configured_inventory = Config.Bridge.Inventory
configured_inventory = inventory_aliases[configured_inventory] or configured_inventory
local framework_name = Bridge.Framework.GetName()
if configured_inventory == "auto" then
if GetResourceState("ox_inventory") == "started" then
configured_inventory = "ox"
elseif GetResourceState("qb-inventory") == "started" then
configured_inventory = "qb"
elseif GetResourceState("lj-inventory") == "started" then
configured_inventory = "lj"
elseif GetResourceState("qs-inventory") == "started" then
configured_inventory = "qs"
elseif GetResourceState("codem-inventory") == "started" then
configured_inventory = "codem"
elseif GetResourceState("core_inventory") == "started" then
configured_inventory = "core"
elseif GetResourceState("mf-inventory") == "started" then
configured_inventory = "mf"
elseif GetResourceState("smx-inventory") == "started" then
configured_inventory = "smx"
elseif GetResourceState("hex_4_inventory") == "started" then
configured_inventory = "hex"
elseif Bridge.Framework.GetName() == "esx" then
for _, adapter in ipairs(inventory_adapters) do
local compatible_framework = not adapter.framework or adapter.framework == framework_name
if adapter.name ~= "esx" and compatible_framework and GetResourceState(adapter.resource) == "started" then
configured_inventory = adapter.name
break
end
end
if configured_inventory == "auto" and framework_name == "esx" then
configured_inventory = "esx"
end
end
local supported_inventories = {
ox = true,
qb = true,
lj = true,
qs = true,
codem = true,
core = true,
mf = true,
smx = true,
hex = true,
esx = true,
}
if not supported_inventories[configured_inventory] then
local selected_adapter = supported_inventories[configured_inventory]
if not selected_adapter then
error(("[sky_phone] Unsupported or unavailable inventory '%s'. Configure a supported inventory adapter."):format(tostring(configured_inventory)))
end
if configured_inventory == "hex" or configured_inventory == "esx" then
if Bridge.Framework.GetName() ~= "esx" then
error(("[sky_phone] Inventory '%s' is only supported with ESX."):format(configured_inventory))
end
if Config.Phone.Unique ~= false then
error(("[sky_phone] Inventory '%s' cannot store unique phone metadata. Set Config.Phone.Unique = false or configure a metadata-capable inventory."):format(configured_inventory))
end
if Config.Sim.Enabled ~= false then
error(("[sky_phone] Inventory '%s' cannot store physical SIM metadata. Set Config.Sim.Enabled = false or configure a metadata-capable inventory."):format(configured_inventory))
end
if GetResourceState(selected_adapter.resource) ~= "started" then
error(("[sky_phone] Inventory '%s' is configured, but resource '%s' is not started.")
:format(tostring(configured_inventory), selected_adapter.resource))
end
if selected_adapter.framework and selected_adapter.framework ~= framework_name then
error(("[sky_phone] Inventory '%s' is only supported with framework '%s'.")
:format(tostring(configured_inventory), selected_adapter.framework))
end
Bridge.Inventory.Name = configured_inventory
function Bridge.Inventory.NormalizeItem(item, metadata_field)
if not item then
local metadata_free_inventory = selected_adapter.metadata == false
local function enforce_metadata_compatibility()
if not metadata_free_inventory then
return
end
local modes_changed = Config.Phone.Unique ~= false or Config.Sim.Enabled ~= false
Config.Phone.Unique = false
Config.Sim.Enabled = false
if modes_changed then
Bridge.Debug(
"warn",
"[sky_phone] Inventory '%s' does not support item metadata; unique phones and physical SIM cards were disabled automatically.",
configured_inventory
)
end
end
enforce_metadata_compatibility()
AddEventHandler("sky_phone:configurator:serverUpdated", enforce_metadata_compatibility)
function Bridge.Inventory.NormalizeItem(item, metadata_field, fallback_slot)
if type(item) ~= "table" then
return nil
end
@@ -65,11 +95,15 @@ function Bridge.Inventory.NormalizeItem(item, metadata_field)
metadata = item.metadata or item.info
end
local slot = item.slot or item.id or fallback_slot
local numeric_slot = tonumber(slot)
local count = tonumber(item.count or item.amount or item.quantity) or 0
return {
name = item.name,
slot = item.slot or item.id,
count = tonumber(item.count or item.amount) or 0,
amount = tonumber(item.amount or item.count) or 0,
name = item.name or item.item,
slot = numeric_slot or slot,
count = count,
amount = count,
metadata = type(metadata) == "table" and metadata or {},
}
end
@@ -86,3 +120,13 @@ function Bridge.Inventory.MetadataMatches(actual, expected)
end
return true
end
function Bridge.Inventory.ResolveUsableItem(...)
for index = select("#", ...), 1, -1 do
local candidate = select(index, ...)
if type(candidate) == "table" and (candidate.name or candidate.item) then
return candidate
end
end
return nil
end
@@ -0,0 +1,87 @@
if Bridge.Inventory.Name ~= "ak47" then
return
end
local inventory = exports["ak47_inventory"]
local function normalize(item, fallback_slot)
local normalized = Bridge.Inventory.NormalizeItem(item, "info", fallback_slot)
if not normalized or not normalized.name or normalized.amount < 1 then
return nil
end
return normalized
end
function Bridge.Inventory.GetResourceName()
return "ak47_inventory"
end
function Bridge.Inventory.GetSlot(source, slot_id)
local identifier = Bridge.Framework.GetIdentifier(source)
local numeric_slot = tonumber(slot_id)
if not identifier or not numeric_slot then
return nil
end
return normalize(inventory:GetSlot(identifier, numeric_slot), numeric_slot)
end
function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
local identifier = Bridge.Framework.GetIdentifier(source)
if not identifier then
return {}
end
local matches = {}
local filter_metadata = type(metadata) == "table" and metadata or nil
for index, item in pairs(inventory:GetSlotsWithItem(identifier, item_name, filter_metadata, filter_metadata ~= nil) or {}) do
local normalized = normalize(item, tonumber(index) or index)
if normalized and normalized.name == item_name
and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then
matches[#matches + 1] = normalized
end
end
return matches
end
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
local identifier = Bridge.Framework.GetIdentifier(source)
local slot = identifier and Bridge.Inventory.GetSlot(source, slot_id) or nil
if not slot then
return false
end
local requested_metadata = type(metadata) == "table" and metadata or {}
inventory:SetItemInfo(identifier, slot.slot, requested_metadata)
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
return updated and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata) or false
end
function Bridge.Inventory.CanCarryItem(source, item_name, count)
return inventory:CanAddItem(source, item_name, count) == true
end
function Bridge.Inventory.AddItem(source, item_name, count, slot, metadata)
if not Bridge.Inventory.CanCarryItem(source, item_name, count, metadata) then
return false
end
return inventory:AddItem(source, item_name, count, slot, metadata or {}) == true
end
function Bridge.Inventory.RemoveItem(source, item_name, count, slot, metadata)
if metadata and not slot then
local slots = Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
slot = slots[1] and slots[1].slot or nil
if not slot then
return 0
end
end
local removed = inventory:RemoveItem(source, item_name, count, slot)
return removed ~= false and count or 0
end
function Bridge.Inventory.RegisterUsableItem(item_name, callback)
return Bridge.Framework.RegisterUsableItem(item_name, function(source, ...)
callback(source, normalize(Bridge.Inventory.ResolveUsableItem(...)))
end)
end
@@ -9,7 +9,8 @@ local function normalize(item)
end
local function get_inventory(source)
return inventory:GetInventory(false, source) or {}
local identifier = Bridge.Framework.GetIdentifier(source)
return identifier and inventory:GetInventory(identifier, source) or {}
end
function Bridge.Inventory.GetResourceName()
@@ -27,9 +28,14 @@ end
function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
local matches = {}
for _, item in pairs(get_inventory(source)) do
local items = inventory:GetItemsByName(source, item_name) or {}
if items.name then
items = { items }
end
for _, item in pairs(items) do
local normalized = normalize(item)
if normalized.name == item_name and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then
if normalized and normalized.name == item_name
and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then
matches[#matches + 1] = normalized
end
end
@@ -66,7 +72,7 @@ function Bridge.Inventory.RemoveItem(source, item_name, count, slot, metadata)
end
function Bridge.Inventory.RegisterUsableItem(item_name, callback)
return Bridge.Framework.RegisterUsableItem(item_name, function(source, item)
callback(source, normalize(item))
return Bridge.Framework.RegisterUsableItem(item_name, function(source, ...)
callback(source, normalize(Bridge.Inventory.ResolveUsableItem(...)))
end)
end
@@ -38,13 +38,16 @@ function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
end
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
local slot = Bridge.Inventory.GetSlot(source, slot_id)
local numeric_slot = tonumber(slot_id)
local slot = numeric_slot and Bridge.Inventory.GetSlot(source, numeric_slot) or nil
if not slot then
return false
end
inventory:updateMetadata(source, slot.slot, metadata or {})
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
return updated and Bridge.Inventory.MetadataMatches(updated.metadata, metadata or {}) or false
local requested_metadata = type(metadata) == "table" and metadata or {}
inventory:setMetadata(source, numeric_slot, requested_metadata)
local updated = Bridge.Inventory.GetSlot(source, numeric_slot)
return updated and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata) or false
end
function Bridge.Inventory.CanCarryItem()
@@ -0,0 +1,94 @@
if Bridge.Inventory.Name ~= "jaksam" then
return
end
local inventory = exports["jaksam_inventory"]
local function normalize(item)
local normalized = Bridge.Inventory.NormalizeItem(item)
if not normalized or not normalized.name or normalized.amount < 1 then
return nil
end
return normalized
end
local function get_inventory(source)
local player_inventory = inventory:getInventory(source)
return type(player_inventory) == "table" and type(player_inventory.items) == "table"
and player_inventory.items or {}
end
function Bridge.Inventory.GetResourceName()
return "jaksam_inventory"
end
function Bridge.Inventory.GetSlot(source, slot_id)
for index, item in pairs(get_inventory(source)) do
local normalized = normalize(item)
local item_slot = normalized and (normalized.slot or tonumber(index)) or nil
if tostring(item_slot) == tostring(slot_id) then
normalized.slot = item_slot
return normalized
end
end
return nil
end
function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
local matches = {}
for index, item in pairs(get_inventory(source)) do
local normalized = normalize(item)
if normalized and normalized.name == item_name
and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then
normalized.slot = normalized.slot or tonumber(index) or index
matches[#matches + 1] = normalized
end
end
return matches
end
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
local slot = Bridge.Inventory.GetSlot(source, slot_id)
if not slot then
return false
end
local requested_metadata = type(metadata) == "table" and metadata or {}
local success = inventory:setItemMetadataInSlot(source, slot.slot, requested_metadata)
if success ~= true then
return false
end
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
return updated and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata) or false
end
function Bridge.Inventory.CanCarryItem(source, item_name, count)
return inventory:canCarryItem(source, item_name, count) == true
end
function Bridge.Inventory.AddItem(source, item_name, count, slot, metadata)
if not Bridge.Inventory.CanCarryItem(source, item_name, count, metadata) then
return false
end
local success = inventory:addItem(source, item_name, count, metadata or {}, slot)
return success == true
end
function Bridge.Inventory.RemoveItem(source, item_name, count, slot, metadata)
if metadata and not slot then
local slots = Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
slot = slots[1] and slots[1].slot or nil
if not slot then
return 0
end
end
local success = inventory:removeItem(source, item_name, count, nil, slot)
return success == true and count or 0
end
function Bridge.Inventory.RegisterUsableItem(item_name, callback)
inventory:registerUsableItem(item_name, function(source, ...)
callback(source, normalize(Bridge.Inventory.ResolveUsableItem(...)))
end)
return true
end
@@ -0,0 +1,109 @@
if Bridge.Inventory.Name ~= "jpr" then
return
end
if Bridge.Framework.GetName() ~= "qb" then
error("[sky_phone] jpr-inventory is only supported with QBCore.")
end
local inventory = exports["jpr-inventory"]
local QBCore = exports["qb-core"]:GetCoreObject()
local function normalize(item, fallback_slot)
local normalized = Bridge.Inventory.NormalizeItem(item, "info", fallback_slot)
if not normalized or not normalized.name or normalized.amount < 1 then
return nil
end
return normalized
end
local function get_inventory(source)
local player = QBCore.Functions.GetPlayer(tonumber(source))
return player and player.PlayerData.items or {}
end
local function restore_slot(source, slot, metadata)
local restored = inventory:AddItem(source, slot.name, slot.amount, slot.slot, metadata, "sky_phone:metadata-rollback")
if not restored then
Bridge.Debug(
"error",
"[sky_phone] jpr-inventory could not restore item '%s' in slot %s after a metadata update failed.",
tostring(slot.name),
tostring(slot.slot)
)
end
return restored == true
end
function Bridge.Inventory.GetResourceName()
return "jpr-inventory"
end
function Bridge.Inventory.GetSlot(source, slot_id)
local numeric_slot = tonumber(slot_id)
return numeric_slot and normalize(inventory:GetItemBySlot(source, numeric_slot), numeric_slot) or nil
end
function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
local matches = {}
for index, item in pairs(get_inventory(source)) do
local normalized = normalize(item, tonumber(index) or index)
if normalized and normalized.name == item_name
and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then
normalized.slot = normalized.slot or tonumber(index) or index
matches[#matches + 1] = normalized
end
end
return matches
end
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
local slot = Bridge.Inventory.GetSlot(source, slot_id)
if not slot or slot.amount < 1 then
return false
end
local previous_metadata = slot.metadata
local requested_metadata = type(metadata) == "table" and metadata or {}
if inventory:RemoveItem(source, slot.name, slot.amount, slot.slot, "sky_phone:metadata-update") ~= true then
return false
end
if inventory:AddItem(source, slot.name, slot.amount, slot.slot, requested_metadata, "sky_phone:metadata-update") ~= true then
restore_slot(source, slot, previous_metadata)
return false
end
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
if updated and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata) then
return true
end
inventory:RemoveItem(source, slot.name, slot.amount, slot.slot, "sky_phone:metadata-rollback")
restore_slot(source, slot, previous_metadata)
return false
end
function Bridge.Inventory.CanCarryItem()
return true
end
function Bridge.Inventory.AddItem(source, item_name, count, slot, metadata)
return inventory:AddItem(source, item_name, count, slot, metadata or {}, "sky_phone") == true
end
function Bridge.Inventory.RemoveItem(source, item_name, count, slot, metadata)
if metadata and not slot then
local slots = Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
slot = slots[1] and slots[1].slot or nil
if not slot then
return 0
end
end
return inventory:RemoveItem(source, item_name, count, slot, "sky_phone") == true and count or 0
end
function Bridge.Inventory.RegisterUsableItem(item_name, callback)
return Bridge.Framework.RegisterUsableItem(item_name, function(source, ...)
callback(source, normalize(Bridge.Inventory.ResolveUsableItem(...)))
end)
end
@@ -0,0 +1,76 @@
if Bridge.Inventory.Name ~= "one" then
return
end
local inventory = exports.one_inventory
local function normalize(item, fallback_slot)
local normalized = Bridge.Inventory.NormalizeItem(item, nil, fallback_slot)
if not normalized or not normalized.name or normalized.amount < 1 then
return nil
end
return normalized
end
function Bridge.Inventory.GetResourceName()
return "one_inventory"
end
function Bridge.Inventory.GetSlot(source, slot_id)
local numeric_slot = tonumber(slot_id)
return numeric_slot and normalize(inventory:GetSlot(source, numeric_slot), numeric_slot) or nil
end
function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
local matches = {}
for index, item in pairs(inventory:SearchInventory(source, item_name, metadata) or {}) do
local normalized = normalize(item, tonumber(index) or index)
if normalized and normalized.name == item_name
and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then
matches[#matches + 1] = normalized
end
end
return matches
end
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
local slot = Bridge.Inventory.GetSlot(source, slot_id)
if not slot then
return false
end
local requested_metadata = type(metadata) == "table" and metadata or {}
if inventory:SetItemMetadata(source, slot.slot, requested_metadata) ~= true then
return false
end
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
return updated and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata) or false
end
function Bridge.Inventory.CanCarryItem(source, item_name, count)
return inventory:CanCarryItem(source, item_name, count) == true
end
function Bridge.Inventory.AddItem(source, item_name, count, slot, metadata)
if not Bridge.Inventory.CanCarryItem(source, item_name, count, metadata) then
return false
end
return inventory:AddItem(source, item_name, count, metadata or {}, slot) == true
end
function Bridge.Inventory.RemoveItem(source, item_name, count, slot, metadata)
if metadata and not slot then
local slots = Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
slot = slots[1] and slots[1].slot or nil
if not slot then
return 0
end
end
return inventory:RemoveItem(source, item_name, count, metadata, slot) == true and count or 0
end
function Bridge.Inventory.RegisterUsableItem(item_name, callback)
return Bridge.Framework.RegisterUsableItem(item_name, function(source, ...)
callback(source, normalize(Bridge.Inventory.ResolveUsableItem(...)))
end)
end
@@ -0,0 +1,88 @@
if Bridge.Inventory.Name ~= "origen" then
return
end
local inventory = exports.origen_inventory
local function normalize(item)
local normalized = Bridge.Inventory.NormalizeItem(item)
if not normalized or not normalized.name or normalized.amount < 1 then
return nil
end
return normalized
end
local function get_inventory(source)
local player_inventory = inventory:GetInventory(source)
return type(player_inventory) == "table" and type(player_inventory.inventory) == "table"
and player_inventory.inventory or {}
end
function Bridge.Inventory.GetResourceName()
return "origen_inventory"
end
function Bridge.Inventory.GetSlot(source, slot_id)
for index, item in pairs(get_inventory(source)) do
local normalized = normalize(item)
local item_slot = normalized and (normalized.slot or tonumber(index)) or nil
if tostring(item_slot) == tostring(slot_id) then
normalized.slot = item_slot
return normalized
end
end
return nil
end
function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
local matches = {}
for index, item in pairs(get_inventory(source)) do
local normalized = normalize(item)
if normalized and normalized.name == item_name
and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then
normalized.slot = normalized.slot or tonumber(index) or index
matches[#matches + 1] = normalized
end
end
return matches
end
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
local slot = Bridge.Inventory.GetSlot(source, slot_id)
if not slot then
return false
end
local requested_metadata = type(metadata) == "table" and metadata or {}
inventory:setMetadata(source, slot.slot, requested_metadata)
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
return updated and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata) or false
end
function Bridge.Inventory.CanCarryItem(source, item_name, count)
return inventory:canCarryItem(source, item_name, count) == true
end
function Bridge.Inventory.AddItem(source, item_name, count, slot, metadata)
if not Bridge.Inventory.CanCarryItem(source, item_name, count, metadata) then
return false
end
return inventory:addItem(source, item_name, count, metadata or {}, slot, false) == true
end
function Bridge.Inventory.RemoveItem(source, item_name, count, slot, metadata)
if metadata and not slot then
local slots = Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
slot = slots[1] and slots[1].slot or nil
if not slot then
return 0
end
end
return inventory:removeItem(source, item_name, count, nil, slot, false) == true and count or 0
end
function Bridge.Inventory.RegisterUsableItem(item_name, callback)
return Bridge.Framework.RegisterUsableItem(item_name, function(source, ...)
callback(source, normalize(Bridge.Inventory.ResolveUsableItem(...)))
end)
end
@@ -44,8 +44,10 @@ function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
return false
end
inventory:SetMetadata(source, tonumber(slot_id), metadata or {})
return true
local requested_metadata = type(metadata) == "table" and metadata or {}
inventory:SetMetadata(source, tonumber(slot_id), requested_metadata)
local updated = Bridge.Inventory.GetSlot(source, slot_id)
return updated and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata) or false
end
function Bridge.Inventory.CanCarryItem(source, item_name, count, metadata)
@@ -0,0 +1,109 @@
if Bridge.Inventory.Name ~= "ps" then
return
end
if Bridge.Framework.GetName() ~= "qb" then
error("[sky_phone] ps-inventory is only supported with QBCore.")
end
local inventory = exports["ps-inventory"]
local QBCore = exports["qb-core"]:GetCoreObject()
local function normalize(item, fallback_slot)
local normalized = Bridge.Inventory.NormalizeItem(item, "info", fallback_slot)
if not normalized or not normalized.name or normalized.amount < 1 then
return nil
end
return normalized
end
local function get_inventory(source)
local player = QBCore.Functions.GetPlayer(tonumber(source))
return player and player.PlayerData.items or {}
end
local function restore_slot(source, slot, metadata)
local restored = inventory:AddItem(source, slot.name, slot.amount, slot.slot, metadata)
if not restored then
Bridge.Debug(
"error",
"[sky_phone] ps-inventory could not restore item '%s' in slot %s after a metadata update failed.",
tostring(slot.name),
tostring(slot.slot)
)
end
return restored == true
end
function Bridge.Inventory.GetResourceName()
return "ps-inventory"
end
function Bridge.Inventory.GetSlot(source, slot_id)
local numeric_slot = tonumber(slot_id)
return numeric_slot and normalize(inventory:GetItemBySlot(source, numeric_slot), numeric_slot) or nil
end
function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
local matches = {}
for index, item in pairs(get_inventory(source)) do
local normalized = normalize(item, tonumber(index) or index)
if normalized and normalized.name == item_name
and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then
normalized.slot = normalized.slot or tonumber(index) or index
matches[#matches + 1] = normalized
end
end
return matches
end
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
local slot = Bridge.Inventory.GetSlot(source, slot_id)
if not slot or slot.amount < 1 then
return false
end
local previous_metadata = slot.metadata
local requested_metadata = type(metadata) == "table" and metadata or {}
if inventory:RemoveItem(source, slot.name, slot.amount, slot.slot) ~= true then
return false
end
if inventory:AddItem(source, slot.name, slot.amount, slot.slot, requested_metadata) ~= true then
restore_slot(source, slot, previous_metadata)
return false
end
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
if updated and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata) then
return true
end
inventory:RemoveItem(source, slot.name, slot.amount, slot.slot)
restore_slot(source, slot, previous_metadata)
return false
end
function Bridge.Inventory.CanCarryItem()
return true
end
function Bridge.Inventory.AddItem(source, item_name, count, slot, metadata)
return inventory:AddItem(source, item_name, count, slot, metadata or {}) == true
end
function Bridge.Inventory.RemoveItem(source, item_name, count, slot, metadata)
if metadata and not slot then
local slots = Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
slot = slots[1] and slots[1].slot or nil
if not slot then
return 0
end
end
return inventory:RemoveItem(source, item_name, count, slot) == true and count or 0
end
function Bridge.Inventory.RegisterUsableItem(item_name, callback)
return Bridge.Framework.RegisterUsableItem(item_name, function(source, ...)
callback(source, normalize(Bridge.Inventory.ResolveUsableItem(...)))
end)
end
@@ -15,12 +15,16 @@ local function normalize(item)
return nil
end
local metadata = {}
for key, value in pairs(type(item.info) == "table" and item.info or {}) do
metadata[key] = value
end
return {
name = item.name,
slot = tonumber(item.slot),
count = tonumber(item.amount) or 0,
amount = tonumber(item.amount) or 0,
metadata = type(item.info) == "table" and item.info or {},
metadata = metadata,
}
end
@@ -77,11 +81,33 @@ function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
if not player then
return false
end
player.PlayerData.items[slot.slot].info = metadata or {}
local requested_metadata = type(metadata) == "table" and metadata or {}
player.PlayerData.items[slot.slot].info = requested_metadata
player.Functions.SetInventory(player.PlayerData.items, true)
return true
local updated = player.PlayerData.items[slot.slot]
return updated ~= nil and Bridge.Inventory.MetadataMatches(updated.info, requested_metadata)
end
return inventory:SetItemData(source, slot.name, "info", metadata or {}, slot.slot) == true
local amount = tonumber(slot.amount or slot.count) or 0
if amount <= 0 then
return false
end
local requested_metadata = type(metadata) == "table" and metadata or {}
if inventory:RemoveItem(source, slot.name, amount, slot.slot, "sky_phone:metadata-update") ~= true then
return false
end
if inventory:AddItem(source, slot.name, amount, slot.slot, requested_metadata, "sky_phone:metadata-update") ~= true then
return false
end
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
return updated
and updated.name == slot.name
and updated.amount == amount
and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata)
or false
end
function Bridge.Inventory.CanCarryItem(source, item_name, count)
@@ -0,0 +1,75 @@
if Bridge.Inventory.Name ~= "tgiann" then
return
end
local inventory = exports["tgiann-inventory"]
local function normalize(item, fallback_slot)
local normalized = Bridge.Inventory.NormalizeItem(item, "info", fallback_slot)
if not normalized or not normalized.name or normalized.amount < 1 then
return nil
end
return normalized
end
function Bridge.Inventory.GetResourceName()
return "tgiann-inventory"
end
function Bridge.Inventory.GetSlot(source, slot_id)
local numeric_slot = tonumber(slot_id)
return numeric_slot and normalize(inventory:GetItemBySlot(source, numeric_slot), numeric_slot) or nil
end
function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
local matches = {}
for index, item in pairs(inventory:GetPlayerItems(source) or {}) do
local normalized = normalize(item, tonumber(index) or index)
if normalized and normalized.name == item_name
and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then
normalized.slot = normalized.slot or tonumber(index) or index
matches[#matches + 1] = normalized
end
end
return matches
end
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
local slot = Bridge.Inventory.GetSlot(source, slot_id)
if not slot then
return false
end
local requested_metadata = type(metadata) == "table" and metadata or {}
inventory:UpdateItemMetadata(source, slot.name, slot.slot, requested_metadata)
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
return updated and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata) or false
end
function Bridge.Inventory.CanCarryItem(source, item_name, count)
return inventory:CanCarryItem(source, item_name, count) == true
end
function Bridge.Inventory.AddItem(source, item_name, count, slot, metadata)
if not Bridge.Inventory.CanCarryItem(source, item_name, count, metadata) then
return false
end
return inventory:AddItem(source, item_name, count, slot, metadata or {}) == true
end
function Bridge.Inventory.RemoveItem(source, item_name, count, slot, metadata)
if metadata and not slot then
local slots = Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
slot = slots[1] and slots[1].slot or nil
if not slot then
return 0
end
end
return inventory:RemoveItem(source, item_name, count, slot, metadata) == true and count or 0
end
function Bridge.Inventory.RegisterUsableItem(item_name, callback)
return Bridge.Framework.RegisterUsableItem(item_name, function(source, ...)
callback(source, normalize(Bridge.Inventory.ResolveUsableItem(...)))
end)
end
@@ -0,0 +1,26 @@
local required_methods = {
"GetResourceName",
"GetSlot",
"GetSlotsWithItem",
"SetSlotMetadata",
"CanCarryItem",
"AddItem",
"RemoveItem",
"RegisterUsableItem",
}
local configuration_error = Bridge.Inventory.ConfigurationError
for _, method_name in ipairs(required_methods) do
if configuration_error then
Bridge.Inventory[method_name] = function()
error(configuration_error, 2)
end
elseif type(Bridge.Inventory[method_name]) ~= "function" then
local contract_error = ("[sky_phone] Inventory adapter '%s' is missing required method '%s'.")
:format(tostring(Bridge.Inventory.Name), method_name)
Bridge.Inventory[method_name] = function()
error(contract_error, 2)
end
end
end
+6
View File
@@ -384,6 +384,12 @@ reevaluate = function(force)
end)
end
AddEventHandler("sky_phone:configurator:updated", function()
animation_state.revision = animation_state.revision + 1
cleanup_phone()
reevaluate(true)
end)
local function reset_animation_state()
animation_state.phone_open = false
animation_state.call_state = nil
+39 -9
View File
@@ -1567,19 +1567,49 @@ SkyPhoneApps.SendCatalog = send_catalog
SkyPhoneApps.SetPhoneOpen = set_phone_open
SkyPhoneApps.RegisterClientHooks = register_bundled_client_hooks
if Config.CustomApps.Enabled and Config.CustomApps.BundledApps then
local bundled = SkyPhoneApps.GetBundledManifests()
for index = 1, #bundled do
local registered, register_error = register_app(normalize_bundled_manifest(bundled[index]))
if not registered then
error(("[sky_phone] Could not register bundled custom app '%s': %s"):format(
bundled[index].id,
register_error
))
local function refresh_bundled_apps()
local manifests = Config.CustomApps.Enabled and Config.CustomApps.BundledApps
and SkyPhoneApps.GetBundledManifests()
or {}
local desired_ids = {}
for index = 1, #manifests do
desired_ids[manifests[index].id] = true
end
local removals = {}
for app_id, app in pairs(apps_by_id) do
if app.catalog.bundled and not desired_ids[app_id] then
removals[#removals + 1] = app_id
end
end
for index = 1, #removals do
remove_registered_app(removals[index], true, false)
end
for index = 1, #manifests do
local manifest = manifests[index]
local existing = apps_by_id[manifest.id]
local normalized = normalize_bundled_manifest(manifest)
if existing and existing.catalog.bundled then
normalized.hooks = existing.hooks
apps_by_id[manifest.id] = normalized
elseif not existing then
local registered, register_error = register_app(normalized)
if not registered then
error(("[sky_phone] Could not register bundled custom app '%s': %s"):format(
manifest.id,
register_error
))
end
end
end
sync_catalog()
end
refresh_bundled_apps()
AddEventHandler("sky_phone:configurator:updated", refresh_bundled_apps)
SkyPhoneApps.CompatibilityCore = {
Add = add_compatibility_app,
AddServerAuthorized = add_server_authorized_compatibility_app,
+87 -9
View File
@@ -3,9 +3,12 @@ SkyPhoneFocus = {}
local blocked_phone_controls = { 24, 140, 141, 142, 257, 263, 264 }
local blocked_phone_look_controls = { 1, 2, 3, 4, 5, 6 }
local focused_control_groups = { 0, 1, 2 }
local hold_to_look_enabled = false
local hold_to_look_control
local state = {
admin_panel_open = false,
activity_suspended = false,
allow_movement = Config.Phone.AllowMovement,
allow_movement = false,
call_focus = false,
camera_active = false,
camera_nui_focused = true,
@@ -13,6 +16,7 @@ local state = {
external_game_input = nil,
external_game_input_owner = nil,
is_open = false,
look_passthrough = false,
notification_focus = false,
payphone_focus = false,
sim_picker_open = false,
@@ -22,6 +26,38 @@ local block_game = false
local block_look = false
local game_input = false
local function refresh_focus_configuration()
local hold_to_look_config = Config.Phone.HoldToLook
local enabled = type(hold_to_look_config) == "table" and hold_to_look_config.Enabled == true
local control = enabled and tonumber(hold_to_look_config.Control) or nil
if enabled and (
not control
or control ~= math.floor(control)
or control < 0
) then
error("[sky_phone] Config.Phone.HoldToLook.Control must be a non-negative whole control index.")
end
hold_to_look_enabled = enabled
hold_to_look_control = control
state.allow_movement = Config.Phone.AllowMovement == true
end
local function allows_game_input(value)
local allowed = value.external_game_input
if allowed == nil then
allowed = value.allow_movement
end
return allowed == true
end
refresh_focus_configuration()
AddEventHandler("sky_phone:configurator:updated", function()
refresh_focus_configuration()
SkyPhoneFocus.Reapply()
end)
function SkyPhoneFocus.ApplyFocusedControls()
for _, group in ipairs(focused_control_groups) do
DisableAllControlActions(group)
@@ -42,6 +78,15 @@ function SkyPhoneFocus.ApplyGameInputControls(block_look)
end
function SkyPhoneFocus.Resolve(state)
if state.admin_panel_open then
return {
block_game = true,
cursor = true,
focused = true,
game_input = false,
keep_input = false,
}
end
if state.activity_suspended then
return { block_game = false, cursor = false, focused = false, game_input = false, keep_input = false }
end
@@ -51,20 +96,19 @@ function SkyPhoneFocus.Resolve(state)
if state.camera_active and not state.camera_nui_focused then
return { block_game = false, block_look = false, cursor = false, focused = true, game_input = true, keep_input = true }
end
local allow_game_input = state.external_game_input
if allow_game_input == nil then
allow_game_input = state.allow_movement
end
local game_input = state.is_open and allow_game_input and not state.camera_active
local game_input = state.is_open
and allows_game_input(state)
and not state.camera_active
and not state.text_input_focused
local focused = state.is_open
or state.notification_focus
or state.payphone_focus
or state.sim_picker_open
or (state.camera_active and state.camera_nui_focused)
local cursor = focused and not (state.is_open and state.cursor_disabled)
local cursor = focused and not (state.is_open and (state.cursor_disabled or state.look_passthrough))
return {
block_game = cursor and (not game_input or state.text_input_focused),
block_look = game_input and not state.cursor_disabled,
block_game = cursor and not game_input,
block_look = game_input and cursor,
cursor = cursor,
focused = focused,
game_input = game_input,
@@ -73,6 +117,16 @@ function SkyPhoneFocus.Resolve(state)
end
function SkyPhoneFocus.Reapply()
if state.look_passthrough and (
not hold_to_look_enabled
or not state.is_open
or state.cursor_disabled
or state.text_input_focused
or state.camera_active
or not allows_game_input(state)
) then
state.look_passthrough = false
end
local focus = SkyPhoneFocus.Resolve(state)
SetNuiFocus(focus.focused, focus.cursor)
SetNuiFocusKeepInput(focus.keep_input)
@@ -90,6 +144,7 @@ end
function SkyPhoneFocus.BeginNuiHydration()
-- Browser-owned focus claims cannot survive a CEF reload.
state.notification_focus = false
state.look_passthrough = false
state.text_input_focused = false
end
@@ -107,6 +162,16 @@ function SkyPhoneFocus.SetPhone(open, cursor_disabled)
state.cursor_disabled = false
state.external_game_input = nil
state.external_game_input_owner = nil
state.look_passthrough = false
state.text_input_focused = false
end
SkyPhoneFocus.Reapply()
end
function SkyPhoneFocus.SetAdminPanel(open)
state.admin_panel_open = open == true
if state.admin_panel_open then
state.notification_focus = false
state.text_input_focused = false
end
SkyPhoneFocus.Reapply()
@@ -124,6 +189,9 @@ end
function SkyPhoneFocus.SetTextInputFocused(active)
state.text_input_focused = active == true
if state.text_input_focused then
state.look_passthrough = false
end
SkyPhoneFocus.Reapply()
end
@@ -142,6 +210,7 @@ function SkyPhoneFocus.SetExternalGameInput(owner_resource, allow_game_input)
end
function SkyPhoneFocus.Reset()
state.admin_panel_open = false
state.activity_suspended = false
state.call_focus = false
state.camera_active = false
@@ -150,6 +219,7 @@ function SkyPhoneFocus.Reset()
state.external_game_input = nil
state.external_game_input_owner = nil
state.is_open = false
state.look_passthrough = false
state.notification_focus = false
state.payphone_focus = false
state.sim_picker_open = false
@@ -164,6 +234,14 @@ end
CreateThread(function()
while true do
if game_input or block_game then
local look_passthrough = hold_to_look_enabled
and game_input
and not state.cursor_disabled
and IsControlPressed(0, hold_to_look_control)
if look_passthrough ~= state.look_passthrough then
state.look_passthrough = look_passthrough
SkyPhoneFocus.Reapply()
end
if block_game then
SkyPhoneFocus.ApplyFocusedControls()
else
+5
View File
@@ -48,6 +48,11 @@ local vehicle_mods = {
local phone_locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
local garage_locale = phone_locale.Nui.Apps.garage
AddEventHandler("sky_phone:configurator:updated", function()
phone_locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
garage_locale = phone_locale.Nui.Apps.garage
end)
local function vehicle_kind(model_hash, fallback)
if IsThisModelABoat(model_hash) then
return "boat"
+186 -27
View File
@@ -8,6 +8,17 @@ local equipped_phone_number = nil
local nui_generation = 0
local live_activity_active = false
local open_home_requested = false
local admin_panel_open = false
local suggested_admin_command = nil
local suggested_test_data_command = nil
local active_development_command = nil
local registered_development_commands = {}
local active_key_mapping_command = nil
local active_key_mapping_key = nil
local key_mapping_revision = 0
local refresh_development_command
local refresh_phone_key_mapping
local refresh_test_data_command_suggestion
local function get_equipped_phone_number()
if not device_payload or not device_payload.device.sim then
@@ -64,6 +75,26 @@ Bridge.Debug("debug", "[sky_phone] Client script initialized.", { always = true
local locale, locale_name = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
local function send_admin_panel_open()
SendNUIMessage({
type = "admin:open",
data = {
lang = locale_name,
locales = locale.Nui,
fallbackLocales = Locales.en.Nui,
},
})
end
local function close_admin_panel()
if not admin_panel_open then
return
end
admin_panel_open = false
SkyPhoneFocus.SetAdminPanel(false)
SendNUIMessage({ type = "admin:close" })
end
local function send_open_message()
if not device_payload then
return
@@ -82,6 +113,39 @@ local function send_open_message()
open_home_requested = false
end
local function refresh_admin_command_suggestion()
if suggested_admin_command then
TriggerEvent("chat:removeSuggestion", "/" .. suggested_admin_command)
suggested_admin_command = nil
end
if Config.AdminPanel.Enabled then
suggested_admin_command = Config.AdminPanel.Command
TriggerEvent(
"chat:addSuggestion",
"/" .. suggested_admin_command,
locale.AdminCommand.CommandDescription
)
end
end
AddEventHandler("sky_phone:configurator:updated", function()
locale, locale_name = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
refresh_development_command()
refresh_phone_key_mapping()
refresh_test_data_command_suggestion()
refresh_admin_command_suggestion()
SkyPhoneApps.SendCatalog()
if is_open and device_payload then
device_payload.lang = locale_name
device_payload.locales = locale.Nui
device_payload.fallbackLocales = Locales.en.Nui
SendNUIMessage({ type = "device:updated", data = device_payload })
end
if admin_panel_open then
send_admin_panel_open()
end
end)
local function open_phone()
if is_open or not device_payload then
return
@@ -152,18 +216,46 @@ AddEventHandler("sky_phone:client:forceClose", function()
close_phone()
end)
if Config.Phone.DevelopmentCommand then
RegisterCommand(Config.Command, function()
if is_open or open_requested then
close_phone()
return
end
open_without_focus = false
Bridge.Callbacks.Trigger("sky_phone:device:development-open", {})
end, false)
local function run_development_command()
if is_open or open_requested then
close_phone()
return
end
open_without_focus = false
Bridge.Callbacks.Trigger("sky_phone:device:development-open", {})
end
RegisterCommand("sky_phone_toggle", function()
refresh_development_command = function()
local command_name = Config.Phone.DevelopmentCommand and Config.Command or nil
if command_name ~= nil and (type(command_name) ~= "string" or command_name == "") then
error("[sky_phone] Config.Command must be a non-empty command name.")
end
if active_development_command then
TriggerEvent("chat:removeSuggestion", "/" .. active_development_command)
end
active_development_command = command_name
if not command_name then
return
end
if not registered_development_commands[command_name] then
registered_development_commands[command_name] = true
RegisterCommand(command_name, function()
if active_development_command == command_name and Config.Phone.DevelopmentCommand then
run_development_command()
end
end, false)
end
TriggerEvent("chat:addSuggestion", "/" .. command_name, locale.CommandDescription)
end
refresh_development_command()
local function run_phone_toggle()
if is_open or open_requested then
close_phone()
return
@@ -171,7 +263,7 @@ RegisterCommand("sky_phone_toggle", function()
open_without_focus = false
Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
end, false)
end
RegisterCommand("sky_phone_live_activity_open", function()
if not live_activity_active or is_open or open_requested then
@@ -185,6 +277,36 @@ RegisterCommand("sky_phone_live_activity_open", function()
end
end, false)
RegisterCommand("sky_phone_toggle", run_phone_toggle, false)
refresh_phone_key_mapping = function()
local key_name = Config.Phone.Keybind
if key_name ~= false and key_name ~= nil and (type(key_name) ~= "string" or key_name == "") then
error("[sky_phone] Config.Phone.Keybind must be a non-empty keyboard key name or false.")
end
if key_name == active_key_mapping_key then
return
end
active_key_mapping_key = key_name
active_key_mapping_command = nil
if not key_name then
return
end
key_mapping_revision = key_mapping_revision + 1
local command_name = "sky_phone_toggle_config_" .. key_mapping_revision
active_key_mapping_command = command_name
RegisterCommand(command_name, function()
if active_key_mapping_command == command_name then
run_phone_toggle()
end
end, false)
RegisterKeyMapping(command_name, locale.Controls.OpenPhone, "keyboard", key_name)
end
refresh_phone_key_mapping()
RegisterKeyMapping(
"sky_phone_live_activity_open",
locale.Controls.OpenPhone,
@@ -192,14 +314,19 @@ RegisterKeyMapping(
"SPACE"
)
if Config.Phone.Keybind then
if type(Config.Phone.Keybind) ~= "string" or Config.Phone.Keybind == "" then
error("[sky_phone] Config.Phone.Keybind must be a non-empty keyboard key name or false.")
refresh_test_data_command_suggestion = function()
if suggested_test_data_command then
TriggerEvent("chat:removeSuggestion", "/" .. suggested_test_data_command)
suggested_test_data_command = nil
end
if Config.TestData.Enabled then
suggested_test_data_command = Config.TestData.Command
TriggerEvent("chat:addSuggestion", "/" .. suggested_test_data_command, locale.TestData.CommandDescription)
end
RegisterKeyMapping("sky_phone_toggle", locale.Controls.OpenPhone, "keyboard", Config.Phone.Keybind)
end
refresh_test_data_command_suggestion()
RegisterNetEvent("sky_phone:testdata:feedback", function(success, detail)
local test_data_locale = locale.TestData
local message = success and test_data_locale.Success or test_data_locale.Failed
@@ -224,6 +351,9 @@ RegisterNUICallback("ui:ready", function(data, cb)
if open_requested and device_payload then
send_open_message()
end
if admin_panel_open then
send_admin_panel_open()
end
SkyPhoneCalls.ReplayNui()
SkyPhoneSimPicker.ReplayNui()
SkyPhoneFocus.Reapply()
@@ -269,12 +399,42 @@ RegisterNUICallback("ui:opened", function(data, cb)
cb({ success = true })
end)
RegisterNetEvent("sky_phone:admin:launch", function()
if is_open or open_requested then
close_phone()
end
admin_panel_open = true
SkyPhoneFocus.SetAdminPanel(true)
send_admin_panel_open()
end)
RegisterNetEvent("sky_phone:admin:command-error", function(error_code)
local messages = locale.AdminCommand.Errors
Bridge.Framework.Notify(
"iFruit",
messages[error_code] or messages.default,
"error",
5000
)
end)
RegisterNUICallback("admin:close", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
close_admin_panel()
cb({ success = true })
end)
RegisterNUICallback("ui:input-focus", function(data, cb)
if type(data) ~= "table" or type(data.active) ~= "boolean" then
cb({ success = false, error = "invalid_request" })
return
end
SkyPhoneFocus.SetTextInputFocused(data.active and (is_open or open_requested))
SkyPhoneFocus.SetTextInputFocused(
data.active and (is_open or open_requested or admin_panel_open)
)
cb({ success = true })
end)
@@ -353,12 +513,7 @@ RegisterNetEvent("sky_phone:device:error", function(error_code)
end)
CreateThread(function()
if Config.Phone.DevelopmentCommand then
TriggerEvent("chat:addSuggestion", "/" .. Config.Command, locale.CommandDescription)
end
if Config.TestData.Enabled then
TriggerEvent("chat:addSuggestion", "/" .. Config.TestData.Command, locale.TestData.CommandDescription)
end
refresh_admin_command_suggestion()
end)
AddEventHandler("onResourceStop", function(resource_name)
@@ -369,16 +524,20 @@ AddEventHandler("onResourceStop", function(resource_name)
is_open = false
open_requested = false
open_without_focus = false
admin_panel_open = false
TriggerEvent("sky_phone:animation:reset")
SkyPhoneCalls.Reset()
SkyPhoneSimPicker.Reset()
SkyPhoneFocus.Reset()
if Config.Phone.DevelopmentCommand then
TriggerEvent("chat:removeSuggestion", "/" .. Config.Command)
if active_development_command then
TriggerEvent("chat:removeSuggestion", "/" .. active_development_command)
end
if Config.TestData.Enabled then
TriggerEvent("chat:removeSuggestion", "/" .. Config.TestData.Command)
if suggested_test_data_command then
TriggerEvent("chat:removeSuggestion", "/" .. suggested_test_data_command)
end
if suggested_admin_command then
TriggerEvent("chat:removeSuggestion", "/" .. suggested_admin_command)
end
end)
@@ -159,6 +159,11 @@ end)
local phone_locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
local app_locales = phone_locale.Nui.Apps
AddEventHandler("sky_phone:configurator:updated", function()
phone_locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
app_locales = phone_locale.Nui.Apps
end)
RegisterNetEvent("sky_phone:mail:new", function(data)
local locale = app_locales.mail
data.title = locale.name
@@ -1,5 +1,9 @@
local callback_groups = {
account = [[login register logout devices remove-device]],
admin = [[
bootstrap player save-apps reveal-password activity
reset-passcode change-number factory-reset configurator save-configurator
]],
banking = [[overview transfer]],
billing = [[overview list detail markRead pay dispute]],
calendar = [[list create update delete]],
+26 -8
View File
@@ -21,10 +21,16 @@ local remote_visuals = {}
local custom_payphone_props = {}
local configured_models = {}
for _, model_name in ipairs(Config.Payphones.Props or {}) do
configured_models[joaat(model_name)] = model_name
local function refresh_configured_models()
configured_models = {}
for _, model_name in ipairs(Config.Payphones.Props or {}) do
configured_models[joaat(model_name)] = model_name
end
end
refresh_configured_models()
local function valid_visual_number(value, maximum)
local number = tonumber(value)
if not number or number ~= number or math.abs(number) > maximum then
@@ -156,6 +162,23 @@ end
CreateThread(spawn_custom_payphones)
local function clear_custom_payphones()
for _, entity in ipairs(custom_payphone_props) do
if DoesEntityExist(entity) then
SetEntityAsMissionEntity(entity, true, true)
DeleteEntity(entity)
end
end
custom_payphone_props = {}
end
AddEventHandler("sky_phone:configurator:updated", function()
locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
refresh_configured_models()
clear_custom_payphones()
CreateThread(spawn_custom_payphones)
end)
local function load_animation(dictionary)
if HasAnimDictLoaded(dictionary) then
return true
@@ -726,10 +749,5 @@ AddEventHandler("onResourceStop", function(resource_name)
for _, id in ipairs(visual_ids) do
restore_remote_visual(id)
end
for _, entity in ipairs(custom_payphone_props) do
if DoesEntityExist(entity) then
SetEntityAsMissionEntity(entity, true, true)
DeleteEntity(entity)
end
end
clear_custom_payphones()
end)
@@ -0,0 +1,83 @@
local function deserialize_value(value)
if type(value) ~= "table" then
return value
end
if value.__skyType == "vector2" then
return vector2(tonumber(value.x) or 0.0, tonumber(value.y) or 0.0)
end
if value.__skyType == "vector3" then
return vector3(tonumber(value.x) or 0.0, tonumber(value.y) or 0.0, tonumber(value.z) or 0.0)
end
if value.__skyType == "vector4" then
return vector4(
tonumber(value.x) or 0.0,
tonumber(value.y) or 0.0,
tonumber(value.z) or 0.0,
tonumber(value.w) or 0.0
)
end
if value.__skyType == "map" then
local decoded = {}
for _, entry in ipairs(value.entries or {}) do
local key = entry.keyType == "number" and tonumber(entry.key) or entry.key
decoded[key] = deserialize_value(entry.value)
end
return decoded
end
local decoded = {}
for key, child in pairs(value) do
decoded[key] = deserialize_value(child)
end
return decoded
end
local function apply_runtime_table(current, replacement)
for key in pairs(current) do
if replacement[key] == nil then
current[key] = nil
end
end
for key, value in pairs(replacement) do
local current_value = current[key]
if type(current_value) == "table" and type(value) == "table" then
apply_runtime_table(current_value, value)
else
current[key] = value
end
end
end
local function apply_runtime_config(payload)
if type(payload) ~= "table" or payload.enabled ~= true then
return
end
if type(payload.config) ~= "table" then
error("[sky_phone] Phone configurator received an invalid client configuration payload.")
end
local runtime_config = deserialize_value(payload.config)
for key, value in pairs(runtime_config) do
if type(Config[key]) == "table" and type(value) == "table" then
apply_runtime_table(Config[key], value)
else
Config[key] = value
end
end
TriggerEvent("sky_phone:configurator:updated", tonumber(payload.revision) or 0)
end
RegisterNetEvent("sky_phone:configurator:sync", function(payload)
apply_runtime_config(payload)
end)
local response = Bridge.Callbacks.Trigger("sky_phone:configurator:runtime", {})
if not response or not response.success or type(response.data) ~= "table" then
if Config.PhoneConfigurator.Enabled then
error("[sky_phone] Phone configurator failed to load the client runtime configuration.")
end
return
end
apply_runtime_config(response.data)
+12
View File
@@ -44,6 +44,18 @@ local CLIENT_CAPABILITIES = {
side = "client",
}
local function refresh_configured_capabilities()
local enabled = Config.CustomApps.Enabled == true
local external = enabled and Config.CustomApps.ExternalApps == true
CLIENT_CAPABILITIES.features.customApps.enabled = enabled
CLIENT_CAPABILITIES.features.customApps.external = external
CLIENT_CAPABILITIES.features.notifications.customApps = external
end
refresh_configured_capabilities()
AddEventHandler("sky_phone:configurator:updated", refresh_configured_capabilities)
local custom_app_api = SkyPhoneApps.ClientPublicApi
if type(custom_app_api) ~= "table" then
error("[sky_phone] Client public API initialized before the custom app API.")
+19
View File
@@ -29,6 +29,10 @@ local function send_hud_config()
SendNUIMessage({ type = "radio:hud-config", data = get_hud_config() })
end
AddEventHandler("sky_phone:configurator:updated", function()
send_hud_config()
end)
local function send_hud_members()
local combined = {}
for channel_id = 1, 2 do
@@ -156,6 +160,21 @@ local function leave_radio()
return request("disconnect")
end
AddEventHandler("sky_phone:client:radioProviderUpdated", function()
if current_primary <= 0 then
return
end
if not Bridge.Radio.Join(current_primary, current_secondary) then
request("disconnect")
current_primary = 0
current_secondary = 0
clear_hud_members()
return
end
Bridge.Radio.SetVolume(current_volume)
end)
RegisterNUICallback("radio:get", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
+930
View File
@@ -0,0 +1,930 @@
Bridge.Database.AfterMigration("sky_phone", function()
local BUILTIN_APPS = {
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,
["app-store"] = true,
["local-pages"] = true,
mail = true,
map = true,
memos = true,
memory = 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,
}
local DEFAULT_INSTALLED_APPS = {
["app-store"] = true,
calculator = true,
calendar = true,
camera = true,
citywarn = true,
clock = true,
health = true,
mail = true,
map = true,
memos = true,
messages = true,
notes = true,
phone = true,
photos = true,
settings = true,
weather = true,
}
local PROTECTED_APPS = {
["app-store"] = true,
camera = true,
citywarn = true,
health = true,
mail = true,
messages = true,
phone = true,
photos = true,
settings = true,
}
local function affected_rows(result)
if type(result) == "number" then
return result
end
return type(result) == "table" and tonumber(result.affectedRows) or 0
end
local function trim(value)
if type(value) ~= "string" then
return ""
end
return value:match("^%s*(.-)%s*$")
end
local function player_name(source)
local first_name = trim(Bridge.Framework.GetFirstname(source))
local last_name = trim(Bridge.Framework.GetLastname(source))
local character_name = trim((first_name .. " " .. last_name))
return character_name ~= "" and character_name or GetPlayerName(source) or ("Player %s"):format(source)
end
local function require_admin(source, operation, maximum)
if not Config.AdminPanel.Enabled
or not Bridge.Framework.HasPermission(source, "phonepanel")
then
Bridge.Debug("warn", "[sky_phone] Rejected admin panel access from source %s.", tostring(source))
return nil, { success = false, error = "not_authorized" }
end
if not SkyPhone.AllowOperation(source, "admin_" .. operation, maximum, 60) then
return nil, { success = false, error = "rate_limited" }
end
return true
end
local active_admin_command
local function run_admin_command(command_source)
local player_source = tonumber(command_source)
if not player_source or player_source < 1 then
Bridge.Debug("warn", "[sky_phone] The admin panel command can only be used by a player.")
return
end
if not Config.AdminPanel.Enabled then
TriggerClientEvent("sky_phone:admin:command-error", player_source, "disabled")
return
end
if not Bridge.Framework.HasPermission(player_source, "phonepanel") then
Bridge.Debug(
"warn",
"[sky_phone] Rejected admin panel command from source %s.",
tostring(player_source)
)
TriggerClientEvent("sky_phone:admin:command-error", player_source, "not_authorized")
return
end
TriggerClientEvent("sky_phone:admin:launch", player_source)
end
local function register_admin_command()
local command_name = Config.AdminPanel.Command
if type(command_name) ~= "string" or command_name == "" then
error("[sky_phone] Config.AdminPanel.Command must be a non-empty command name.")
end
if command_name == active_admin_command then
return
end
active_admin_command = command_name
RegisterCommand(command_name, function(command_source)
if active_admin_command == command_name then
run_admin_command(command_source)
end
end, false)
end
register_admin_command()
AddEventHandler("sky_phone:configurator:serverUpdated", function()
register_admin_command()
end)
local function normalize_source(value)
local player_source = tonumber(value)
if not player_source or player_source < 1 or player_source ~= math.floor(player_source) then
return nil
end
if not Bridge.Framework.GetIdentifier(player_source) then
return nil
end
return player_source
end
local function collect_device_imeis(source, identifier)
local imeis = {}
local seen = {}
local function add_imei(imei)
if SkyPhoneImei.IsValid(imei) and not seen[imei] then
seen[imei] = true
imeis[#imeis + 1] = imei
end
end
for _, item in ipairs(Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)) do
add_imei(item.metadata and item.metadata.imei)
end
local rows = Bridge.Database.Query([[
SELECT `device_imei` AS `imei`
FROM `sky_phone_character_devices`
WHERE `owner_identifier` = ?
UNION
SELECT device.`imei`
FROM `sky_phone_devices` device
JOIN `sky_phone_sims` sim ON sim.`id` = device.`sim_id`
WHERE sim.`owner_identifier` = ?
]], { identifier, identifier })
for _, row in ipairs(rows) do
add_imei(row.imei)
end
table.sort(imeis)
return imeis
end
local function normalize_app_ids(value)
local normalized = {}
local seen = {}
if type(value) ~= "table" then
return normalized
end
for index = 1, #value do
local app_id = value[index]
if type(app_id) == "string"
and #app_id > 0
and #app_id <= 64
and not seen[app_id]
then
seen[app_id] = true
normalized[#normalized + 1] = app_id
end
end
return normalized
end
local function load_app_payload(encoded)
if type(encoded) ~= "string" or encoded == "" then
return {}, {}, {}
end
local payload = json.decode(encoded)
if type(payload) ~= "table" then
error("[sky_phone] Stored admin target app payload is not a JSON object.")
end
return payload, normalize_app_ids(payload.claimedApps), normalize_app_ids(payload.uninstalledApps)
end
local function load_player_devices(source, identifier)
local imeis = collect_device_imeis(source, identifier)
if #imeis == 0 then
return {}
end
local placeholders = {}
for index = 1, #imeis do
placeholders[index] = "?"
end
local rows = Bridge.Database.Query(([[
SELECT device.`imei`, device.`device_name`, device.`created_at`, device.`updated_at`,
sim.`phone_number`, sim.`sim_type`, sim.`registered_at`,
account.`id` AS `account_id`, account.`email` AS `account_email`,
security.`passcode_length`, security.`failed_attempts`, security.`locked_until`,
app_data.`payload` AS `apps_payload`, app_data.`revision` AS `apps_revision`
FROM `sky_phone_devices` device
LEFT JOIN `sky_phone_sims` sim ON sim.`id` = device.`sim_id`
LEFT JOIN `sky_phone_accounts` account ON account.`id` = device.`account_id`
LEFT JOIN `sky_phone_device_security` security ON security.`device_imei` = device.`imei`
LEFT JOIN `sky_phone_device_data` app_data
ON app_data.`device_imei` = device.`imei` AND app_data.`namespace` = 'apps'
WHERE device.`imei` IN (%s)
ORDER BY device.`updated_at` DESC, device.`imei` ASC
]]):format(table.concat(placeholders, ", ")), imeis)
local devices = {}
for _, row in ipairs(rows) do
local _, claimed_apps, uninstalled_apps = load_app_payload(row.apps_payload)
devices[#devices + 1] = {
imei = row.imei,
name = row.device_name,
createdAt = row.created_at,
updatedAt = row.updated_at,
number = row.phone_number,
simType = row.sim_type,
simRegistered = row.registered_at ~= nil,
account = row.account_id and {
id = tonumber(row.account_id),
email = row.account_email,
passwordAvailable = true,
} or nil,
security = {
enabled = row.passcode_length ~= nil,
length = row.passcode_length and tonumber(row.passcode_length) or nil,
failedAttempts = tonumber(row.failed_attempts) or 0,
lockedUntil = tonumber(row.locked_until) or 0,
},
apps = {
claimed = claimed_apps,
uninstalled = uninstalled_apps,
revision = tonumber(row.apps_revision) or 0,
},
}
end
return devices
end
local function build_player_summary(source)
local identifier = Bridge.Framework.GetIdentifier(source)
local devices = load_player_devices(source, identifier)
local job = Bridge.Framework.GetJob(source)
return {
source = source,
identifier = identifier,
name = player_name(source),
serverName = GetPlayerName(source) or "",
job = job.label ~= "" and job.label or job.name,
grade = job.grade,
onDuty = job.onDuty,
deviceCount = #devices,
phoneNumber = devices[1] and devices[1].number or nil,
}
end
local function list_players()
local sources = Bridge.Framework.GetPlayers()
table.sort(sources, function(left, right)
return tonumber(left) < tonumber(right)
end)
local players = {}
local maximum = math.max(1, math.floor(tonumber(Config.AdminPanel.MaximumPlayers) or 128))
for index = 1, math.min(#sources, maximum) do
local player_source = tonumber(sources[index])
if player_source and Bridge.Framework.GetIdentifier(player_source) then
players[#players + 1] = build_player_summary(player_source)
end
end
return players
end
local function load_player_detail(source)
local identifier = Bridge.Framework.GetIdentifier(source)
local job = Bridge.Framework.GetJob(source)
return {
source = source,
identifier = identifier,
name = player_name(source),
serverName = GetPlayerName(source) or "",
firstName = trim(Bridge.Framework.GetFirstname(source)),
lastName = trim(Bridge.Framework.GetLastname(source)),
birthdate = trim(Bridge.Framework.GetBirthdate(source)),
job = {
name = job.name,
label = job.label,
grade = job.grade,
gradeLabel = job.gradeLabel,
onDuty = job.onDuty,
},
money = {
bank = tonumber(Bridge.Framework.GetMoney(source, "bank")) or 0,
cash = tonumber(Bridge.Framework.GetMoney(source, "cash")) or 0,
currency = Config.Banking.Currency,
},
devices = load_player_devices(source, identifier),
}
end
local function find_owned_device(source, imei)
local identifier = Bridge.Framework.GetIdentifier(source)
for _, device in ipairs(load_player_devices(source, identifier)) do
if device.imei == imei then
return device, identifier
end
end
return nil, identifier
end
local function load_device_sim(imei)
local rows = Bridge.Database.Query([[
SELECT sim.`id`, sim.`phone_number`
FROM `sky_phone_devices` device
JOIN `sky_phone_sims` sim ON sim.`id` = device.`sim_id`
WHERE device.`imei` = ?
LIMIT 1
]], { imei })
return rows[1]
end
local function app_metadata(app_id)
if BUILTIN_APPS[app_id] then
return {
defaultInstalled = DEFAULT_INSTALLED_APPS[app_id] == true,
removable = PROTECTED_APPS[app_id] ~= true,
}
end
if SkyPhoneApps.GetPolicy(app_id) then
return { defaultInstalled = false, removable = true }
end
return nil
end
local function remove_app_id(values, app_id)
local next_values = {}
for index = 1, #values do
if values[index] ~= app_id then
next_values[#next_values + 1] = values[index]
end
end
return next_values
end
local function add_app_id(values, app_id)
for index = 1, #values do
if values[index] == app_id then
return values
end
end
values[#values + 1] = app_id
return values
end
local function write_audit(actor_source, target_source, target_identifier, imei, action, details)
local actor_identifier = Bridge.Framework.GetIdentifier(actor_source)
local result = Bridge.Database.Query([[
INSERT INTO `sky_phone_admin_audit`
(`actor_identifier`, `actor_name`, `target_identifier`, `target_source`, `device_imei`, `action`, `details`)
VALUES (?, ?, ?, ?, ?, ?, ?)
]], {
actor_identifier,
player_name(actor_source),
target_identifier,
target_source,
imei,
action,
json.encode(details or {}),
})
if affected_rows(result) ~= 1 then
error("[sky_phone] Admin audit insert did not affect exactly one row.")
end
end
local function load_audit()
local limit = math.max(1, math.min(100, math.floor(tonumber(Config.AdminPanel.AuditLimit) or 40)))
local rows = Bridge.Database.Query(([[
SELECT `id`, `actor_name`, `target_identifier`, `target_source`, `device_imei`,
`action`, `details`, `created_at`
FROM `sky_phone_admin_audit`
ORDER BY `id` DESC
LIMIT %s
]]):format(limit), {})
local audit = {}
for _, row in ipairs(rows) do
audit[#audit + 1] = {
id = tonumber(row.id),
actorName = row.actor_name,
targetIdentifier = row.target_identifier,
targetSource = row.target_source and tonumber(row.target_source) or nil,
deviceImei = row.device_imei,
action = row.action,
details = json.decode(row.details),
createdAt = row.created_at,
}
end
return audit
end
Bridge.Callbacks.Register("sky_phone:admin:bootstrap", function(source)
local authorized, error_response = require_admin(
source,
"bootstrap",
Config.AdminPanel.ReadRequestsPerMinute
)
if not authorized then
return error_response
end
local players = list_players()
local totals = Bridge.Database.Query([[
SELECT
(SELECT COUNT(*) FROM `sky_phone_devices`) AS `devices`,
(SELECT COUNT(*) FROM `sky_phone_accounts`) AS `accounts`
]], {})
return {
success = true,
data = {
players = players,
stats = {
online = #players,
devices = tonumber(totals[1] and totals[1].devices) or 0,
accounts = tonumber(totals[1] and totals[1].accounts) or 0,
},
audit = load_audit(),
},
}
end)
Bridge.Callbacks.Register("sky_phone:admin:player", function(source, data)
local authorized, error_response = require_admin(
source,
"player",
Config.AdminPanel.ReadRequestsPerMinute
)
if not authorized then
return error_response
end
local target_source = normalize_source(data and data.source)
if not target_source then
return { success = false, error = "player_unavailable" }
end
return { success = true, data = load_player_detail(target_source) }
end)
Bridge.Callbacks.Register("sky_phone:admin:save-apps", function(source, data)
local authorized, error_response = require_admin(
source,
"save_apps",
Config.AdminPanel.ActionRequestsPerMinute
)
if not authorized then
return error_response
end
if type(data) ~= "table"
or not SkyPhoneImei.IsValid(data.imei)
or type(data.revision) ~= "number"
or data.revision < 0
or data.revision ~= math.floor(data.revision)
or type(data.changes) ~= "table"
or #data.changes < 1
or #data.changes > 128
then
return { success = false, error = "invalid_request" }
end
local target_source = normalize_source(data.source)
if not target_source then
return { success = false, error = "player_unavailable" }
end
local target_device, target_identifier = find_owned_device(target_source, data.imei)
if not target_device then
return { success = false, error = "device_not_owned" }
end
local normalized_changes = {}
local seen_apps = {}
for index = 1, #data.changes do
local change = data.changes[index]
if type(change) ~= "table"
or type(change.appId) ~= "string"
or #change.appId < 1
or #change.appId > 64
or type(change.installed) ~= "boolean"
or seen_apps[change.appId]
then
return { success = false, error = "invalid_request" }
end
local metadata = app_metadata(change.appId)
if not metadata then
return { success = false, error = "invalid_app" }
end
if not change.installed and not metadata.removable then
return { success = false, error = "app_protected" }
end
seen_apps[change.appId] = true
normalized_changes[#normalized_changes + 1] = {
appId = change.appId,
installed = change.installed,
metadata = metadata,
}
end
local rows = Bridge.Database.Query([[
SELECT `payload`, `revision`
FROM `sky_phone_device_data`
WHERE `device_imei` = ? AND `namespace` = 'apps'
LIMIT 1
]], { data.imei })
local current_revision = tonumber(rows[1] and rows[1].revision) or 0
if current_revision ~= data.revision then
return { success = false, error = "revision_conflict" }
end
local payload, claimed_apps, uninstalled_apps = load_app_payload(rows[1] and rows[1].payload)
for index = 1, #normalized_changes do
local change = normalized_changes[index]
if change.installed then
uninstalled_apps = remove_app_id(uninstalled_apps, change.appId)
if not change.metadata.defaultInstalled then
claimed_apps = add_app_id(claimed_apps, change.appId)
end
else
claimed_apps = remove_app_id(claimed_apps, change.appId)
uninstalled_apps = add_app_id(uninstalled_apps, change.appId)
end
end
payload.claimedApps = claimed_apps
payload.uninstalledApps = #uninstalled_apps > 0 and uninstalled_apps or nil
local encoded = json.encode(payload)
if #encoded > 100000 then
return { success = false, error = "payload_too_large" }
end
if rows[1] then
local result = Bridge.Database.Query([[
UPDATE `sky_phone_device_data`
SET `payload` = ?, `revision` = `revision` + 1
WHERE `device_imei` = ? AND `namespace` = 'apps' AND `revision` = ?
]], { encoded, data.imei, data.revision })
if affected_rows(result) ~= 1 then
return { success = false, error = "revision_conflict" }
end
else
local result = Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_device_data` (`device_imei`, `namespace`, `payload`)
VALUES (?, 'apps', ?)
]], { data.imei, encoded })
if affected_rows(result) ~= 1 then
return { success = false, error = "revision_conflict" }
end
end
for index = 1, #normalized_changes do
local change = normalized_changes[index]
write_audit(
source,
target_source,
target_identifier,
data.imei,
change.installed and "grant_app" or "revoke_app",
{ appId = change.appId }
)
end
SkyPhone.RefreshDevice(data.imei)
return { success = true, data = load_player_detail(target_source) }
end)
Bridge.Callbacks.Register("sky_phone:admin:reveal-password", function(source, data)
local authorized, error_response = require_admin(
source,
"reveal_password",
Config.AdminPanel.CredentialRevealsPerMinute
)
if not authorized then
return error_response
end
if type(data) ~= "table" or not SkyPhoneImei.IsValid(data.imei) then
return { success = false, error = "invalid_request" }
end
local target_source = normalize_source(data.source)
if not target_source then
return { success = false, error = "player_unavailable" }
end
local device, target_identifier = find_owned_device(target_source, data.imei)
if not device then
return { success = false, error = "device_not_owned" }
end
local accounts = Bridge.Database.Query([[
SELECT account.`email`, account.`password`
FROM `sky_phone_devices` device
JOIN `sky_phone_accounts` account ON account.`id` = device.`account_id`
WHERE device.`imei` = ?
LIMIT 1
]], { data.imei })
if not accounts[1] then
return { success = false, error = "account_not_found" }
end
write_audit(
source,
target_source,
target_identifier,
data.imei,
"reveal_account_password",
{ email = accounts[1].email }
)
return {
success = true,
data = {
email = accounts[1].email,
password = accounts[1].password,
},
}
end)
Bridge.Callbacks.Register("sky_phone:admin:activity", function(source, data)
local authorized, error_response = require_admin(
source,
"activity",
Config.AdminPanel.ReadRequestsPerMinute
)
if not authorized then
return error_response
end
if type(data) ~= "table"
or not SkyPhoneImei.IsValid(data.imei)
or (data.kind ~= "messages" and data.kind ~= "calls")
then
return { success = false, error = "invalid_request" }
end
local target_source = normalize_source(data.source)
if not target_source then
return { success = false, error = "player_unavailable" }
end
local device, target_identifier = find_owned_device(target_source, data.imei)
if not device then
return { success = false, error = "device_not_owned" }
end
local audit_action = data.kind == "messages" and "view_messages" or "view_calls"
local sim = load_device_sim(data.imei)
if not sim then
write_audit(
source,
target_source,
target_identifier,
data.imei,
audit_action,
{ count = 0 }
)
return { success = true, data = { kind = data.kind, entries = {} } }
end
local limit = math.max(1, math.min(100, math.floor(tonumber(Config.AdminPanel.ActivityLimit) or 40)))
local entries = {}
if data.kind == "messages" then
local rows = Bridge.Database.Query(([[
SELECT `id`, `sender_sim_id`, `sender_number`, `recipient_number`, `message_type`,
`body`, `read_at`, `created_at`
FROM `sky_phone_sms_messages`
WHERE `sender_sim_id` = ? OR `recipient_sim_id` = ?
ORDER BY `created_at` DESC
LIMIT %s
]]):format(limit), { sim.id, sim.id })
for _, row in ipairs(rows) do
local outgoing = row.sender_sim_id == sim.id
entries[#entries + 1] = {
id = row.id,
direction = outgoing and "outgoing" or "incoming",
otherNumber = outgoing and row.recipient_number or row.sender_number,
messageType = row.message_type,
body = row.body,
readAt = row.read_at,
createdAt = row.created_at,
}
end
else
local rows = Bridge.Database.Query(([[
SELECT `id`, `caller_sim_id`, `caller_number`, `callee_number`, `status`,
`started_at`, `answered_at`, `ended_at`, `duration_seconds`
FROM `sky_phone_calls`
WHERE `caller_sim_id` = ? OR `callee_sim_id` = ?
ORDER BY `started_at` DESC
LIMIT %s
]]):format(limit), { sim.id, sim.id })
for _, row in ipairs(rows) do
local outgoing = row.caller_sim_id == sim.id
entries[#entries + 1] = {
id = row.id,
direction = outgoing and "outgoing" or "incoming",
otherNumber = outgoing and row.callee_number or row.caller_number,
status = row.status,
startedAt = row.started_at,
answeredAt = row.answered_at,
endedAt = row.ended_at,
durationSeconds = tonumber(row.duration_seconds) or 0,
}
end
end
write_audit(
source,
target_source,
target_identifier,
data.imei,
audit_action,
{ count = #entries }
)
return { success = true, data = { kind = data.kind, entries = entries } }
end)
Bridge.Callbacks.Register("sky_phone:admin:reset-passcode", function(source, data)
local authorized, error_response = require_admin(
source,
"reset_passcode",
Config.AdminPanel.ActionRequestsPerMinute
)
if not authorized then
return error_response
end
if type(data) ~= "table" or not SkyPhoneImei.IsValid(data.imei) then
return { success = false, error = "invalid_request" }
end
local target_source = normalize_source(data.source)
if not target_source then
return { success = false, error = "player_unavailable" }
end
local device, target_identifier = find_owned_device(target_source, data.imei)
if not device then
return { success = false, error = "device_not_owned" }
end
local result = Bridge.Database.Query(
"DELETE FROM `sky_phone_device_security` WHERE `device_imei` = ?",
{ data.imei }
)
if affected_rows(result) ~= 1 then
return { success = false, error = "passcode_not_set" }
end
write_audit(source, target_source, target_identifier, data.imei, "reset_passcode", {})
SkyPhone.RefreshDevice(data.imei)
return { success = true, data = load_player_detail(target_source) }
end)
Bridge.Callbacks.Register("sky_phone:admin:change-number", function(source, data)
local authorized, error_response = require_admin(
source,
"change_number",
Config.AdminPanel.ActionRequestsPerMinute
)
if not authorized then
return error_response
end
if type(data) ~= "table"
or not SkyPhoneImei.IsValid(data.imei)
or (type(data.phoneNumber) ~= "string" and type(data.phoneNumber) ~= "number")
then
return { success = false, error = "invalid_request" }
end
local target_source = normalize_source(data.source)
if not target_source then
return { success = false, error = "player_unavailable" }
end
local device, target_identifier = find_owned_device(target_source, data.imei)
if not device then
return { success = false, error = "device_not_owned" }
end
local sim = load_device_sim(data.imei)
if not sim then
return { success = false, error = "no_sim" }
end
local changed, number_or_error = SkyPhoneSim.ChangeNumber(
target_source,
data.imei,
sim.id,
data.phoneNumber
)
if not changed then
return { success = false, error = number_or_error }
end
SkyPhoneCompanies.ClearCallAvailability(target_source)
SkyPhoneCalls.EndForSim(sim.id, "number_changed")
write_audit(source, target_source, target_identifier, data.imei, "change_number", {
previousNumber = sim.phone_number,
phoneNumber = number_or_error,
})
SkyPhone.RefreshDevice(data.imei)
return { success = true, data = load_player_detail(target_source) }
end)
Bridge.Callbacks.Register("sky_phone:admin:factory-reset", function(source, data)
local authorized, error_response = require_admin(
source,
"factory_reset",
Config.AdminPanel.ActionRequestsPerMinute
)
if not authorized then
return error_response
end
if type(data) ~= "table" or not SkyPhoneImei.IsValid(data.imei) then
return { success = false, error = "invalid_request" }
end
local target_source = normalize_source(data.source)
if not target_source then
return { success = false, error = "player_unavailable" }
end
local device, target_identifier = find_owned_device(target_source, data.imei)
if not device then
return { success = false, error = "device_not_owned" }
end
local reset, phone_number_or_error = SkyPhonePersistence.FactoryReset(data.imei)
if not reset then
return { success = false, error = phone_number_or_error }
end
write_audit(source, target_source, target_identifier, data.imei, "factory_reset", {})
if phone_number_or_error then
TriggerEvent("sky_phone:server:factoryReset", target_source, phone_number_or_error)
end
SkyPhone.RefreshDevice(data.imei)
return { success = true, data = load_player_detail(target_source) }
end)
Bridge.Callbacks.Register("sky_phone:admin:configurator", function(source)
local authorized, error_response = require_admin(
source,
"configurator_read",
Config.AdminPanel.ReadRequestsPerMinute
)
if not authorized then
return error_response
end
return { success = true, data = SkyPhoneConfigurator.GetAdminData() }
end)
Bridge.Callbacks.Register("sky_phone:admin:save-configurator", function(source, data)
local authorized, error_response = require_admin(
source,
"configurator_save",
Config.AdminPanel.ActionRequestsPerMinute
)
if not authorized then
return error_response
end
if type(data) ~= "table" or type(data.changes) ~= "table" then
return { success = false, error = "invalid_request" }
end
local actor_identifier = Bridge.Framework.GetIdentifier(source)
if not actor_identifier then
return { success = false, error = "player_unavailable" }
end
local actor_name = player_name(source)
local response = SkyPhoneConfigurator.Save(
data.revision,
data.changes,
actor_identifier,
actor_name
)
if response.success then
write_audit(source, source, actor_identifier, nil, "save_configuration", {
changeCount = #data.changes,
revision = response.data.revision,
})
end
return response
end)
end)
+13 -3
View File
@@ -1206,12 +1206,18 @@ Bridge.Callbacks.Register("sky_phone:calls:dial", function(source, data)
end)
local payphone_models = {}
for _, model_name in ipairs(Config.Payphones.Props or {}) do
if type(model_name) == "string" then
payphone_models[model_name] = true
local function refresh_payphone_models()
payphone_models = {}
for _, model_name in ipairs(Config.Payphones.Props or {}) do
if type(model_name) == "string" then
payphone_models[model_name] = true
end
end
end
refresh_payphone_models()
if Config.Payphones.Enabled and not next(payphone_models) then
Bridge.Debug(
"error",
@@ -1220,6 +1226,10 @@ if Config.Payphones.Enabled and not next(payphone_models) then
)
end
AddEventHandler("sky_phone:configurator:serverUpdated", function()
refresh_payphone_models()
end)
local function valid_payphone_position(source, detected_booth)
local ped = GetPlayerPed(source)
if not ped or ped == 0 then
+16 -3
View File
@@ -905,9 +905,22 @@ local function cleanup_retained_data()
end
end
validate_configuration()
seed_companies()
tombstone_removed_companies()
local function refresh_runtime_configuration()
definitions = {}
definition_ids = {}
definitions_by_job = {}
service_lines_by_number = {}
validate_configuration()
seed_companies()
tombstone_removed_companies()
end
refresh_runtime_configuration()
AddEventHandler("sky_phone:configurator:serverUpdated", function()
refresh_runtime_configuration()
end)
cleanup_retained_data()
CreateThread(function()
+4
View File
@@ -1,5 +1,9 @@
Bridge.Database.AfterMigration("sky_phone", function()
local password_pepper = tostring(Config.Server.CrewLinkPasswordPepper or "")
AddEventHandler("sky_phone:configurator:serverUpdated", function()
password_pepper = tostring(Config.Server.CrewLinkPasswordPepper or "")
end)
local role_levels = {
guest = 1,
member = 2,
+15 -2
View File
@@ -316,9 +316,16 @@ local function ceil_div(value, divisor)
end
local function initialize_markets()
local next_markets = {}
local next_market_order = {}
local next_market_dynamics = {}
for _, config in ipairs(Config.Crypto.Markets) do
markets[config.Id] = config
market_order[#market_order + 1] = config.Id
if next_markets[config.Id] then
error(("[sky_phone] Config.Crypto.Markets contains duplicate id '%s'."):format(config.Id))
end
next_markets[config.Id] = config
next_market_order[#next_market_order + 1] = config.Id
next_market_dynamics[config.Id] = market_dynamics[config.Id]
Bridge.Database.Query([[
INSERT INTO `sky_phone_crypto_markets`
(`id`,`asset_scale`,`price_scale`,`issued_supply`,`price`,`version`,`status`)
@@ -357,6 +364,10 @@ local function initialize_markets()
INSERT INTO `sky_phone_crypto_balances` (`account_id`,`asset_id`,`available`)
VALUES ('treasury', 'CASH', ?) ON DUPLICATE KEY UPDATE `account_id` = VALUES(`account_id`)
]], { Config.Crypto.TreasuryCash * Config.Crypto.PriceScale })
markets = next_markets
market_order = next_market_order
market_dynamics = next_market_dynamics
market_cursor = math.min(market_cursor, math.max(#market_order, 1))
end
local function require_phone(source)
@@ -1357,6 +1368,8 @@ ensure_schema()
migrate_crypto_keys()
initialize_markets()
AddEventHandler("sky_phone:configurator:serverUpdated", initialize_markets)
local function reconcile_settlements(include_recent)
local age_clause = include_recent and "" or " AND settlement.`updated_at` < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)"
local rows = Bridge.Database.Query([[
@@ -1,8 +1,6 @@
Bridge.Database.AfterMigration("sky_phone", function()
local STORAGE_JSON_MAX_DEPTH = 8
local STORAGE_JSON_MAX_NODES = 512
local STORAGE_KEY_MAX_LENGTH = math.min(Config.CustomApps.MaximumStorageKeyLength, 64)
local STORAGE_VALUE_MAX_BYTES = math.min(Config.CustomApps.MaximumStorageValueBytes, 65536)
local storage_write_locks = {}
local function affected_rows(result)
@@ -27,7 +25,7 @@ local function validate_storage_json(value, depth, state)
return value == value and value ~= math.huge and value ~= -math.huge
end
if value_type == "string" then
return #value <= STORAGE_VALUE_MAX_BYTES
return #value <= math.min(Config.CustomApps.MaximumStorageValueBytes, 65536)
end
if value_type ~= "table" or state.seen[value] then
return false
@@ -120,7 +118,7 @@ local function validate_request(source, data, operation)
end
if type(data.key) ~= "string"
or #data.key == 0
or #data.key > STORAGE_KEY_MAX_LENGTH
or #data.key > math.min(Config.CustomApps.MaximumStorageKeyLength, 64)
or not data.key:match("^[%w._-]+$")
then
return nil, nil, { success = false, error = "invalid_storage_key" }
@@ -205,7 +203,7 @@ Bridge.Callbacks.Register("sky_phone:custom-app:storage:set", function(source, d
local encoded, payload = pcall(json.encode, request.value)
if not encoded
or type(payload) ~= "string"
or #payload > STORAGE_VALUE_MAX_BYTES
or #payload > math.min(Config.CustomApps.MaximumStorageValueBytes, 65536)
then
return { success = false, error = "storage_value_too_large" }
end
+34 -16
View File
@@ -474,26 +474,44 @@ SkyPhoneApps.ServerPublicApi = {
UpdateCustomAppPolicyFromAdapter = update_custom_app_policy_from_adapter,
}
if Config.CustomApps.Enabled and Config.CustomApps.BundledApps then
local bundled = SkyPhoneApps.GetBundledManifests()
for index = 1, #bundled do
local manifest = bundled[index]
local registered, register_error = register_policy({
adapterResource = nil,
bundled = true,
id = manifest.id,
ownerResource = current_resource,
permissions = build_permission_set(manifest.permissions),
})
if not registered then
error(("[sky_phone] Could not register bundled custom app policy '%s': %s"):format(
manifest.id,
register_error
))
local function refresh_bundled_policies()
local removals = {}
for app_id, policy in pairs(policies_by_id) do
if policy.bundled then
removals[#removals + 1] = app_id
end
end
for _, app_id in ipairs(removals) do
remove_policy(app_id)
end
if Config.CustomApps.Enabled and Config.CustomApps.BundledApps then
local bundled = SkyPhoneApps.GetBundledManifests()
for index = 1, #bundled do
local manifest = bundled[index]
local registered, register_error = register_policy({
adapterResource = nil,
bundled = true,
id = manifest.id,
ownerResource = current_resource,
permissions = build_permission_set(manifest.permissions),
})
if not registered then
error(("[sky_phone] Could not register bundled custom app policy '%s': %s"):format(
manifest.id,
register_error
))
end
end
end
end
refresh_bundled_policies()
AddEventHandler("sky_phone:configurator:serverUpdated", function()
refresh_bundled_policies()
end)
AddEventHandler("onResourceStop", function(resource_name)
if resource_name == current_resource then
return
+47 -5
View File
@@ -416,6 +416,46 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_admin_audit",
columns = {
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
{
name = "actor_identifier",
type = "VARCHAR(80) NOT NULL",
characterSet = "ascii",
collation = "ascii_bin",
},
{ name = "actor_name", type = "VARCHAR(120) NOT NULL" },
{
name = "target_identifier",
type = "VARCHAR(80) NOT NULL",
characterSet = "ascii",
collation = "ascii_bin",
},
{ name = "target_source", type = "INT UNSIGNED NULL" },
{
name = "device_imei",
type = "CHAR(15) NULL",
characterSet = "ascii",
collation = "ascii_bin",
},
{
name = "action",
type = "VARCHAR(48) NOT NULL",
characterSet = "ascii",
collation = "ascii_bin",
},
{ name = "details", type = "LONGTEXT NOT NULL" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
primaryKey = "id",
indexes = {
{ name = "idx_sky_phone_admin_audit_created", columns = "(`created_at`, `id`)" },
{ name = "idx_sky_phone_admin_audit_target", columns = "(`target_identifier`, `created_at`)" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_notes",
columns = {
@@ -699,7 +739,7 @@ local schema = {
{ name = "title", type = "VARCHAR(160) NOT NULL" },
{ name = "description", type = "VARCHAR(1000) NOT NULL DEFAULT ''" },
{ name = "amount", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "currency", type = "VARCHAR(8) NOT NULL", characterSet = "ascii", collation = "ascii_general_ci" },
{ name = "currency", type = "VARCHAR(8) NOT NULL", characterSet = "utf8mb4", collation = "utf8mb4_unicode_ci" },
{ name = "status", type = "ENUM('open', 'processing', 'paid', 'disputed', 'cancelled', 'refunded') NOT NULL DEFAULT 'open'" },
{ name = "read_at", type = "DATETIME NULL" },
{ name = "issued_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
@@ -1001,8 +1041,8 @@ local schema = {
{
name = "handle",
type = "VARCHAR(24) NOT NULL",
characterSet = "ascii",
collation = "ascii_general_ci",
characterSet = "utf8mb4",
collation = "utf8mb4_unicode_ci",
},
{ name = "bio", type = "VARCHAR(160) NOT NULL DEFAULT ''" },
{ name = "avatar_media_id", type = "BIGINT UNSIGNED NULL" },
@@ -2156,8 +2196,8 @@ local schema = {
{
name = "currency",
type = "VARCHAR(8) NOT NULL",
characterSet = "ascii",
collation = "ascii_general_ci",
characterSet = "utf8mb4",
collation = "utf8mb4_unicode_ci",
},
{ name = "driver_vehicle_model", type = "VARCHAR(64) NULL" },
{ name = "driver_vehicle_color", type = "VARCHAR(64) NULL" },
@@ -2909,6 +2949,8 @@ local schema = {
},
}
schema[#schema + 1] = SkyPhoneConfiguratorSchema
Bridge.Database.Migrate("sky_phone", schema)
Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_fliptok_video_media` (`video_id`, `media_id`, `sort_order`)
+51 -16
View File
@@ -14,7 +14,27 @@ local custom_music_extensions = {
wav = true,
webm = true,
}
local password_pepper = tostring(Config.Server.FlipTokPasswordPepper or "")
local password_pepper = ""
local function refresh_runtime_configuration()
password_pepper = tostring(Config.Server.FlipTokPasswordPepper or "")
music_tracks = {}
music_track_list = {}
for _, track in ipairs(Config.FlipTok.MusicTracks) do
local id = tostring(track.Id or track.id or "")
local title = tostring(track.Title or track.title or "")
local artist = tostring(track.Artist or track.artist or "")
local url = tostring(track.Url or track.url or "")
if id == "" or title == "" or artist == "" or url == "" then
error("[sky_phone] Every configured FlipTok music track requires Id, Title, Artist, and Url.")
end
local item = { id = id, title = title, artist = artist, url = url }
music_tracks[id] = item
music_track_list[#music_track_list + 1] = item
end
end
refresh_runtime_configuration()
if password_pepper == "" then
Bridge.Debug(
@@ -24,18 +44,9 @@ if password_pepper == "" then
)
end
for _, track in ipairs(Config.FlipTok.MusicTracks) do
local id = tostring(track.Id or track.id or "")
local title = tostring(track.Title or track.title or "")
local artist = tostring(track.Artist or track.artist or "")
local url = tostring(track.Url or track.url or "")
if id == "" or title == "" or artist == "" or url == "" then
error("[sky_phone] Every configured FlipTok music track requires Id, Title, Artist, and Url.")
end
local item = { id = id, title = title, artist = artist, url = url }
music_tracks[id] = item
music_track_list[#music_track_list + 1] = item
end
AddEventHandler("sky_phone:configurator:serverUpdated", function()
refresh_runtime_configuration()
end)
local function trim(value)
if type(value) ~= "string" then return nil end
@@ -913,7 +924,10 @@ Bridge.Callbacks.Register("sky_phone:fliptok:delete", function(source, data)
return affected > 0 and { success = true } or { success = false, error = "video_not_found" }
end)
RegisterCommand(Config.FlipTok.VerifyCommand, function(source, arguments)
local active_verify_command = nil
local registered_verify_commands = {}
local function run_verify_command(source, arguments)
local command_locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale).FlipTokCommand
local function command_message(template, values)
return template:gsub("{(%w+)}", function(key) return values[key] or "" end)
@@ -929,7 +943,7 @@ RegisterCommand(Config.FlipTok.VerifyCommand, function(source, arguments)
notificationType = notification_type,
})
end
if source ~= 0 and not Bridge.Framework.HasAdminGroup(source, Config.FlipTok.AdminGroups) then
if source ~= 0 and not Bridge.Framework.HasPermission(source, "fliptokverify") then
send_command_feedback(command_locale.noPermission, "error")
Bridge.Debug(
"warn",
@@ -973,5 +987,26 @@ RegisterCommand(Config.FlipTok.VerifyCommand, function(source, arguments)
state = verified and command_locale.verified or command_locale.unverified,
})
send_command_feedback(message, "success")
end, false)
end
local function refresh_verify_command()
local command_name = Config.FlipTok.VerifyCommand
if type(command_name) ~= "string" or command_name == "" then
error("[sky_phone] Config.FlipTok.VerifyCommand must be a non-empty command name.")
end
active_verify_command = command_name
if registered_verify_commands[command_name] then
return
end
registered_verify_commands[command_name] = true
RegisterCommand(command_name, function(source, arguments)
if active_verify_command == command_name then
run_verify_command(source, arguments)
end
end, false)
end
refresh_verify_command()
AddEventHandler("sky_phone:configurator:serverUpdated", refresh_verify_command)
end)
+15 -5
View File
@@ -8,13 +8,23 @@ local offer_responses = { accepted = true, rejected = true }
local public_statuses = { active = true, reserved = true }
local seller_statuses = { active = true, reserved = true, sold = true, removed = true }
for _, category in ipairs(Config.Marketplace.Categories) do
categories[category] = true
end
for _, district in ipairs(Config.Marketplace.Districts) do
districts[district] = true
local function refresh_runtime_configuration()
categories = {}
districts = {}
for _, category in ipairs(Config.Marketplace.Categories) do
categories[category] = true
end
for _, district in ipairs(Config.Marketplace.Districts) do
districts[district] = true
end
end
refresh_runtime_configuration()
AddEventHandler("sky_phone:configurator:serverUpdated", function()
refresh_runtime_configuration()
end)
local function trim(value)
if type(value) ~= "string" then
return nil
+15 -5
View File
@@ -226,14 +226,18 @@ local function validate_website(definition)
return nil, "invalid_required_ace"
end
definition._adapter = adapter
definition._media_types = allowed_media_types
local valid, validation_error = adapter.Validate(definition)
local website = {}
for key, value in pairs(definition) do
website[key] = value
end
website._adapter = adapter
website._media_types = allowed_media_types
local valid, validation_error = adapter.Validate(website)
if not valid then
return nil, validation_error
end
return definition
return website
end
local function build_registry()
@@ -255,7 +259,7 @@ local function build_registry()
if website_error == "missing_api_key" then
Bridge.Debug(
"warn",
"[sky_phone] Media import source '%s' at index %s is disabled because Config.Media.FiveManage.ApiKey is empty in server-only config/media.lua. Add a FiveManage V3 token with Media access and restart sky_phone.",
"[sky_phone] Media import source '%s' at index %s is disabled because Config.Media.FiveManage.ApiKey is empty. Add a FiveManage V3 token with Media access in the Phone Configurator and save it.",
tostring(source_name or "unknown"),
tostring(index)
)
@@ -756,3 +760,9 @@ function SkyPhoneMediaImport.Initialize()
import_candidates[source] = nil
end)
end
AddEventHandler("sky_phone:configurator:serverUpdated", function()
if initialized then
build_registry()
end
end)
+6
View File
@@ -218,6 +218,10 @@ local function normalize_server_tracks()
return
end
server_tracks = {}
server_tracks_by_id = {}
configured_track_ids = {}
for index, configured in ipairs(Config.Music.Tracks or {}) do
local id = type(configured) == "table" and trim(configured.Id) or nil
local title = type(configured) == "table" and trim(configured.Title) or nil
@@ -300,6 +304,8 @@ end
normalize_server_tracks()
AddEventHandler("sky_phone:configurator:serverUpdated", normalize_server_tracks)
local function session_owner(source)
local session, error_response = SkyPhone.RequireSession(source)
if not session then
+128
View File
@@ -0,0 +1,128 @@
local resource_name = GetCurrentResourceName()
local index_path = "source/html/index.html"
local required_static_files = {
"source/html/img/custom-app.svg",
"source/html/sounds/button.mp3",
}
local function load_non_empty_file(path)
local content = LoadResourceFile(resource_name, path)
if type(content) ~= "string" or content == "" then
return nil
end
return content
end
local function add_missing_path(missing_paths, seen_paths, path)
if seen_paths[path] then
return
end
seen_paths[path] = true
missing_paths[#missing_paths + 1] = path
end
local function normalize_entry_asset_path(url)
if type(url) ~= "string" then
return nil
end
local path = url:gsub("[?#].*$", "")
while path:sub(1, 2) == "./" do
path = path:sub(3)
end
if path:sub(1, 7) ~= "assets/" then
return nil
end
return "source/html/" .. path
end
local function collect_entry_asset_paths(index_html)
local paths = {}
local seen_paths = {}
for url in index_html:gmatch("src%s*=%s*[\"']([^\"']+)[\"']") do
local path = normalize_entry_asset_path(url)
if path and not seen_paths[path] then
seen_paths[path] = true
paths[#paths + 1] = path
end
end
for url in index_html:gmatch("href%s*=%s*[\"']([^\"']+)[\"']") do
local path = normalize_entry_asset_path(url)
if path and not seen_paths[path] then
seen_paths[path] = true
paths[#paths + 1] = path
end
end
return paths
end
local function find_missing_nui_files()
local missing_paths = {}
local seen_paths = {}
local index_html = load_non_empty_file(index_path)
if not index_html then
add_missing_path(missing_paths, seen_paths, index_path)
add_missing_path(missing_paths, seen_paths, "source/html/assets/* (generated entry assets)")
else
local entry_asset_paths = collect_entry_asset_paths(index_html)
if #entry_asset_paths == 0 then
add_missing_path(missing_paths, seen_paths, "source/html/assets/* (no entry asset referenced by index.html)")
else
for index = 1, #entry_asset_paths do
local path = entry_asset_paths[index]
if not load_non_empty_file(path) then
add_missing_path(missing_paths, seen_paths, path)
end
end
end
end
for index = 1, #required_static_files do
local path = required_static_files[index]
if not load_non_empty_file(path) then
add_missing_path(missing_paths, seen_paths, path)
end
end
return missing_paths
end
local function print_missing_nui_notice(missing_paths)
local border = "======================================================================"
local lines = {
("^1%s^0"):format(border),
"^1 SKY PHONE UI BUILD IS MISSING OR INCOMPLETE ^0",
("^1%s^0"):format(border),
"^3 The resource started, but the packaged phone UI cannot load.^0",
"",
"^3 Missing or invalid files:^0",
}
for index = 1, #missing_paths do
lines[#lines + 1] = ("^1 - %s^0"):format(missing_paths[index])
end
lines[#lines + 1] = ""
lines[#lines + 1] = "^3 Install the latest published release package, not GitHub's automatic source archive.^0"
lines[#lines + 1] = "^5 Release: https://github.com/sky-systems/sky_phone/releases/latest^0"
lines[#lines + 1] = "^3 Developers working from source:^0"
lines[#lines + 1] = "^5 cd frontend^0"
lines[#lines + 1] = "^5 pnpm install --frozen-lockfile^0"
lines[#lines + 1] = "^5 pnpm build^0"
lines[#lines + 1] = "^5 Build guide: https://github.com/sky-systems/sky_phone#frontend-development^0"
lines[#lines + 1] = ("^1%s^0"):format(border)
print(table.concat(lines, "\n"))
end
local missing_paths = find_missing_nui_files()
if #missing_paths > 0 then
print_missing_nui_notice(missing_paths)
end
+17 -2
View File
@@ -1,8 +1,23 @@
Bridge.Database.AfterMigration("sky_phone", function()
local categories = {}
local districts = {}
for _, value in ipairs(Config.LocalPages.Categories) do categories[value] = true end
for _, value in ipairs(Config.Marketplace.Districts) do districts[value] = true end
local function refresh_runtime_configuration()
categories = {}
districts = {}
for _, value in ipairs(Config.LocalPages.Categories) do
categories[value] = true
end
for _, value in ipairs(Config.Marketplace.Districts) do
districts[value] = true
end
end
refresh_runtime_configuration()
AddEventHandler("sky_phone:configurator:serverUpdated", function()
refresh_runtime_configuration()
end)
local function trim(value)
if type(value) ~= "string" then return nil end
+79 -37
View File
@@ -19,7 +19,8 @@ Bridge.Callbacks.Register("sky_phone:device:open-request", function(source)
return { success = true, data = { queued = true } }
end
return { success = phone_open_handler(source, nil) }
local success, error_code = phone_open_handler(source, nil)
return { success = success, error = error_code }
end)
Bridge.Callbacks.Register("sky_phone:device:development-open", function(source)
@@ -31,7 +32,8 @@ Bridge.Callbacks.Register("sky_phone:device:development-open", function(source)
return { success = true, data = { queued = true } }
end
return { success = phone_open_handler(source, nil) }
local success, error_code = phone_open_handler(source, nil)
return { success = success, error = error_code }
end)
AddEventHandler("playerDropped", function()
@@ -52,8 +54,6 @@ Bridge.Debug("debug", "[sky_phone] Server initialization started after database
SkyPhone = {}
local unique_phones = Config.Phone.Unique ~= false
local sim_cards_enabled = Config.Sim.Enabled ~= false
local sessions = {}
local preferred_device_imeis = {}
local equipped_phone_numbers = {}
@@ -275,7 +275,7 @@ end
local function find_device_slots(source, imei)
local matches = {}
local slots = Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)
if not unique_phones then
if Config.Phone.Unique == false then
if not slots[1] then
return matches
end
@@ -366,7 +366,7 @@ local function resolve_used_slot(source, used_item)
)
end
if not unique_phones and #slots > 0 then
if Config.Phone.Unique == false and #slots > 0 then
return slots[1]
end
@@ -394,9 +394,10 @@ local function resolve_used_slot(source, used_item)
Bridge.Debug(
"warn",
"[sky_phone] No phone item slot was available for source %s (%s candidates).",
tostring(source),
tostring(#slots)
"[sky_phone] Inventory '%s' returned no '%s' phone item for source %s. This is an item/configuration problem, not a missing SIM card.",
tostring(Bridge.Inventory.GetResourceName()),
tostring(Config.Phone.Item),
tostring(source)
)
return nil, "phone_required"
end
@@ -413,7 +414,7 @@ local function ensure_device(source, slot)
tostring(Bridge.Inventory.GetResourceName()),
{ always = true }
)
if not unique_phones then
if Config.Phone.Unique == false then
if amount < 1 then
return nil, "phone_slot_missing"
end
@@ -547,6 +548,10 @@ local function bootstrap(source, security, security_loaded)
return {
token = session.token,
phoneNumberFormat = {
length = Config.Sim.NumberLength,
groups = Config.Sim.NumberGroups,
},
security = SkyPhoneSecurity.Status(device.imei, security, security_loaded),
device = {
imei = device.imei,
@@ -554,7 +559,7 @@ local function bootstrap(source, security, security_loaded)
sim = device.sim_id and {
id = device.sim_id,
number = device.phone_number,
removable = sim_cards_enabled and tonumber(device.sim_is_virtual) ~= 1,
removable = Config.Sim.Enabled ~= false and tonumber(device.sim_is_virtual) ~= 1,
type = device.sim_type,
registered = device.registered_at ~= nil,
} or nil,
@@ -609,7 +614,7 @@ local function resolve_equipped_phone_number(source)
end
local imei
if unique_phones then
if Config.Phone.Unique ~= false then
table.sort(slots, function(left, right)
local left_slot = tonumber(left.slot)
local right_slot = tonumber(right.slot)
@@ -795,7 +800,7 @@ function SkyPhone.NotifyAccountDevices(account_id, event_name, data)
for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do
local source = tonumber(player_source) or player_source
if not unique_phones then
if Config.Phone.Unique == false then
if Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)[1] then
local imei = load_character_device(source)
local device = imei and devices[imei] or nil
@@ -852,7 +857,7 @@ local function open_phone(source, used_item)
{ always = true }
)
TriggerClientEvent("sky_phone:device:error", source, slot_error)
return false
return false, slot_error
end
local imei, error_code = ensure_device(source, slot)
@@ -866,7 +871,7 @@ local function open_phone(source, used_item)
{ always = true }
)
TriggerClientEvent("sky_phone:device:error", source, error_code)
return false
return false, error_code
end
local prepared, prepare_error = SkyPhoneSim.PrepareDevice(source, slot, imei)
if not prepared then
@@ -877,8 +882,9 @@ local function open_phone(source, used_item)
imei,
tostring(prepare_error)
)
TriggerClientEvent("sky_phone:device:error", source, prepare_error or "request_failed")
return false
local error_code = prepare_error or "request_failed"
TriggerClientEvent("sky_phone:device:error", source, error_code)
return false, error_code
end
local security = SkyPhoneSecurity.Load(imei)
@@ -892,7 +898,20 @@ local function open_phone(source, used_item)
unlocked = security == nil,
}
preferred_device_imeis[source] = imei
local payload = bootstrap(source, security, true)
local payload, bootstrap_error = bootstrap(source, security, true)
if not payload then
local error_code = type(bootstrap_error) == "table" and bootstrap_error.error or "request_failed"
Bridge.Debug(
"error",
"[sky_phone] Phone bootstrap failed for source %s slot %s IMEI %s: %s.",
tostring(source),
tostring(slot.slot),
imei,
tostring(error_code)
)
TriggerClientEvent("sky_phone:device:error", source, error_code)
return false, error_code
end
Bridge.Debug(
"debug",
"[sky_phone] Triggering client open for source %s slot %s IMEI %s account_linked=%s after %sms.",
@@ -932,30 +951,53 @@ function SkyPhone.OpenDeviceForCall(source, imei)
}
end
preferred_device_imeis[source] = imei
TriggerClientEvent("sky_phone:device:open", source, bootstrap(source))
local payload, bootstrap_error = bootstrap(source)
if not payload then
Bridge.Debug(
"error",
"[sky_phone] Call notification bootstrap failed for source %s IMEI %s: %s.",
tostring(source),
tostring(imei),
tostring(type(bootstrap_error) == "table" and bootstrap_error.error or "request_failed")
)
return false
end
TriggerClientEvent("sky_phone:device:open", source, payload)
return true
end
Bridge.Debug(
"debug",
"[sky_phone] Registering usable item '%s' through inventory '%s'.",
Config.Phone.Item,
tostring(Bridge.Inventory.GetResourceName()),
{ always = true }
)
local usable_registered = Bridge.Inventory.RegisterUsableItem(Config.Phone.Item, open_phone)
if not usable_registered then
error(("[sky_phone] Inventory '%s' did not register phone item '%s' as usable."):format(
local registered_usable_items = {}
local function register_configured_phone_item()
local item_name = Config.Phone.Item
if registered_usable_items[item_name] then
return
end
Bridge.Debug(
"debug",
"[sky_phone] Registering usable item '%s' through inventory '%s'.",
item_name,
tostring(Bridge.Inventory.GetResourceName()),
tostring(Config.Phone.Item)
))
{ always = true }
)
if not Bridge.Inventory.RegisterUsableItem(item_name, function(...)
if Config.Phone.Item == item_name then
open_phone(...)
end
end) then
error(("[sky_phone] Inventory '%s' did not register phone item '%s' as usable."):format(
tostring(Bridge.Inventory.GetResourceName()),
tostring(item_name)
))
end
registered_usable_items[item_name] = true
end
Bridge.Debug(
"debug",
"[sky_phone] Usable item registration returned: %s.",
tostring(usable_registered),
{ always = true }
)
register_configured_phone_item()
AddEventHandler("sky_phone:configurator:serverUpdated", function()
register_configured_phone_item()
end)
Bridge.Callbacks.Register("sky_phone:device:close", function(source)
sessions[source] = nil
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,23 @@
SkyPhoneConfiguratorSchema = {
name = "sky_phone_configurator",
columns = {
{ name = "id", type = "TINYINT UNSIGNED NOT NULL" },
{ name = "config_payload", type = "LONGTEXT NOT NULL" },
{ name = "media_payload", type = "LONGTEXT NOT NULL" },
{ name = "revision", type = "INT UNSIGNED NOT NULL DEFAULT 1" },
{
name = "updated_by_identifier",
type = "VARCHAR(80) NULL",
characterSet = "ascii",
collation = "ascii_bin",
},
{ name = "updated_by_name", type = "VARCHAR(120) NULL" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
{
name = "updated_at",
type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP",
},
},
primaryKey = "id",
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
}
+69 -51
View File
@@ -1,4 +1,6 @@
Bridge.Database.AfterMigration("sky_phone", function()
SkyPhonePersistence = {}
local max_device_data_bytes = 100000
local allowed_device_namespaces = {
settings = true,
@@ -150,6 +152,69 @@ Bridge.Callbacks.Register("sky_phone:notifications:save", function(source, data)
return { success = true }
end)
function SkyPhonePersistence.FactoryReset(imei)
if not SkyPhoneImei.IsValid(imei) then
return false, "invalid_request"
end
local device = SkyPhone.LoadDevice(imei)
if not device then
return false, "device_not_found"
end
local phone_number = device and device.phone_number or nil
local media_remote_ids = SkyPhoneMedia.GetDeviceRemoteIds(imei)
if not Bridge.Database.Transaction({
{
query = "DELETE FROM `sky_phone_device_security` WHERE `device_imei` = ?",
params = { imei },
},
{
query = "DELETE FROM `sky_phone_device_data` WHERE `device_imei` = ?",
params = { imei },
},
{
query = "DELETE FROM `sky_phone_custom_app_data` WHERE `device_imei` = ?",
params = { imei },
},
{
query = "DELETE FROM `sky_phone_notes` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { imei },
},
{
query = "DELETE FROM `sky_phone_media` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { imei },
},
{
query = "DELETE FROM `sky_phone_music_playlists` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { imei },
},
{
query = "DELETE FROM `sky_phone_music_youtube_songs` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { imei },
},
{
query = "DELETE FROM `sky_phone_contacts` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { imei },
},
{
query = "DELETE FROM `sky_phone_call_entries` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { imei },
},
{
query = "DELETE FROM `sky_phone_fliptok_sessions` WHERE `device_imei` = ?",
params = { imei },
},
{
query = "UPDATE `sky_phone_devices` SET `account_id` = NULL, `device_name` = ? WHERE `imei` = ?",
params = { Config.Phone.DeviceName, imei },
},
}) then
return false, "request_failed"
end
SkyPhoneMedia.CleanupRemoteFiles(media_remote_ids)
return true, phone_number
end
Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source)
if not SkyPhone.AllowOperation(source, "factory_reset", 3, 60) then
return { success = false, error = "rate_limited" }
@@ -158,58 +223,11 @@ Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source)
if not session then
return error_response
end
local device = SkyPhone.LoadDevice(session.imei)
local phone_number = device and device.phone_number or nil
local media_remote_ids = SkyPhoneMedia.GetDeviceRemoteIds(session.imei)
if not Bridge.Database.Transaction({
{
query = "DELETE FROM `sky_phone_device_security` WHERE `device_imei` = ?",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_device_data` WHERE `device_imei` = ?",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_custom_app_data` WHERE `device_imei` = ?",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_notes` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_media` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_music_playlists` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_music_youtube_songs` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_contacts` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_call_entries` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_fliptok_sessions` WHERE `device_imei` = ?",
params = { session.imei },
},
{
query = "UPDATE `sky_phone_devices` SET `account_id` = NULL, `device_name` = ? WHERE `imei` = ?",
params = { Config.Phone.DeviceName, session.imei },
},
}) then
return { success = false, error = "request_failed" }
local reset, phone_number = SkyPhonePersistence.FactoryReset(session.imei)
if not reset then
return { success = false, error = phone_number }
end
SkyPhoneMedia.CleanupRemoteFiles(media_remote_ids)
session.unlocked = true
if phone_number then
TriggerEvent("sky_phone:server:factoryReset", source, phone_number)
@@ -2,6 +2,10 @@ Bridge.Database.AfterMigration("sky_phone", function()
SkyPhoneSecurity = {}
local passcode_pepper = tostring(Config.Server.PasscodePepper or "")
AddEventHandler("sky_phone:configurator:serverUpdated", function()
passcode_pepper = tostring(Config.Server.PasscodePepper or "")
end)
if passcode_pepper == "" then
Bridge.Debug(
"warn",
+42 -8
View File
@@ -1,6 +1,16 @@
Bridge.Database.AfterMigration("sky_phone", function()
local report_reasons = {}
local password_pepper = tostring(Config.Server.PicstagramPasswordPepper or "")
local password_pepper = ""
local function refresh_runtime_configuration()
password_pepper = tostring(Config.Server.PicstagramPasswordPepper or "")
report_reasons = {}
for index = 1, #Config.Picstagram.ReportReasons do
report_reasons[Config.Picstagram.ReportReasons[index]] = true
end
end
refresh_runtime_configuration()
if password_pepper == "" then
Bridge.Debug(
@@ -10,9 +20,9 @@ if password_pepper == "" then
)
end
for index = 1, #Config.Picstagram.ReportReasons do
report_reasons[Config.Picstagram.ReportReasons[index]] = true
end
AddEventHandler("sky_phone:configurator:serverUpdated", function()
refresh_runtime_configuration()
end)
local function trim(value)
if type(value) ~= "string" then
@@ -309,7 +319,7 @@ local function create_activity(recipient_id, actor_id, kind, post_id)
end
local function is_admin(source)
return Bridge.Framework.HasAdminGroup(source, Config.Picstagram.AdminGroups)
return Bridge.Framework.HasPermission(source, "picstagramadmin")
end
Bridge.Callbacks.Register("sky_phone:picstagram:register", function(source, data)
@@ -1434,7 +1444,10 @@ Bridge.Callbacks.Register("sky_phone:picstagram:admin-resolve-report", function(
return { success = true }
end)
RegisterCommand(Config.Picstagram.VerifyCommand, function(source, args)
local active_verify_command = nil
local registered_verify_commands = {}
local function run_verify_command(source, args)
local command_locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale).PicstagramCommand
local function command_message(template, values)
return template:gsub("{(%w+)}", function(key)
@@ -1451,7 +1464,7 @@ RegisterCommand(Config.Picstagram.VerifyCommand, function(source, args)
notificationType = notification_type,
})
end
if source ~= 0 and not Bridge.Framework.HasAdminGroup(source, Config.Picstagram.AdminGroups) then
if source ~= 0 and not Bridge.Framework.HasPermission(source, "picstagramverify") then
send_command_feedback(command_locale.noPermission, "error")
Bridge.Debug(
"warn",
@@ -1489,5 +1502,26 @@ RegisterCommand(Config.Picstagram.VerifyCommand, function(source, args)
handle = handle,
state = state == 1 and command_locale.verified or command_locale.unverified,
}), "success")
end, false)
end
local function refresh_verify_command()
local command_name = Config.Picstagram.VerifyCommand
if type(command_name) ~= "string" or command_name == "" then
error("[sky_phone] Config.Picstagram.VerifyCommand must be a non-empty command name.")
end
active_verify_command = command_name
if registered_verify_commands[command_name] then
return
end
registered_verify_commands[command_name] = true
RegisterCommand(command_name, function(source, args)
if active_verify_command == command_name then
run_verify_command(source, args)
end
end, false)
end
refresh_verify_command()
AddEventHandler("sky_phone:configurator:serverUpdated", refresh_verify_command)
end)
+12
View File
@@ -25,6 +25,18 @@ local SERVER_CAPABILITIES = {
side = "server",
}
local function refresh_configured_capabilities()
local enabled = Config.CustomApps.Enabled == true
local external = enabled and Config.CustomApps.ExternalApps == true
SERVER_CAPABILITIES.features.customApps.enabled = enabled
SERVER_CAPABILITIES.features.customApps.external = external
SERVER_CAPABILITIES.features.notifications.customApps = external
end
refresh_configured_capabilities()
AddEventHandler("sky_phone:configurator:serverUpdated", refresh_configured_capabilities)
local custom_app_api = SkyPhoneApps.ServerPublicApi
if type(custom_app_api) ~= "table" then
error("[sky_phone] Server public API initialized before the custom app policy API.")
+111 -15
View File
@@ -2,14 +2,29 @@ Bridge.Database.AfterMigration("sky_phone", function()
SkyPhoneSim = {}
local unique_phones = Config.Phone.Unique ~= false
local sim_cards_enabled = Config.Sim.Enabled ~= false
local pending_insertions = {}
local operation_locks = {}
local sim_types = {
[Config.Sim.RegisteredItem] = "registered",
[Config.Sim.AnonymousItem] = "anonymous",
}
local sim_types = {}
local function validate_number_configuration()
local valid, validation_error = SkyPhoneSimNumber.ValidateConfiguration(
Config.Sim.NumberLength,
Config.Sim.NumberPrefix
)
if not valid then
error(("[sky_phone] Invalid SIM number configuration: %s."):format(validation_error))
end
end
local function refresh_sim_types()
sim_types = {
[Config.Sim.RegisteredItem] = "registered",
[Config.Sim.AnonymousItem] = "anonymous",
}
end
validate_number_configuration()
refresh_sim_types()
local function affected_rows(result)
if type(result) == "number" then
@@ -68,7 +83,7 @@ local function sim_metadata(sim)
end
local function set_phone_sim_metadata(source, phone_slot, sim)
if not unique_phones then
if Config.Phone.Unique == false then
return true
end
if not phone_slot then
@@ -94,7 +109,7 @@ local function prepare_device(source, phone_slot, imei)
end
local sim = device.sim_id and load_sim(device.sim_id) or nil
if sim_cards_enabled then
if Config.Sim.Enabled ~= false then
if sim and tonumber(sim.is_virtual) == 1 then
local result = Bridge.Database.Query([[
UPDATE `sky_phone_devices`
@@ -153,6 +168,62 @@ end
SkyPhoneSim.PrepareDevice = prepare_device
function SkyPhoneSim.ChangeNumber(source, imei, sim_id, value)
local number = SkyPhoneSimNumber.Normalize(value, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
if not number or SkyPhoneCompanies.IsServiceNumber(number) then
return false, "invalid_phone_number"
end
local sim = load_sim(sim_id)
if not sim then
return false, "no_sim"
end
if sim.phone_number == number then
return false, "phone_number_unchanged"
end
local existing = Bridge.Database.Query(
"SELECT `id` FROM `sky_phone_sims` WHERE `phone_number` = ? AND `id` <> ? LIMIT 1",
{ number, sim_id }
)
if existing[1] then
return false, "phone_number_taken"
end
local phone_slot
if Config.Phone.Unique ~= false then
for _, slot in ipairs(Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)) do
if slot.metadata and slot.metadata.imei == imei then
phone_slot = slot
break
end
end
end
local previous_number = sim.phone_number
sim.phone_number = number
if phone_slot and not set_phone_sim_metadata(source, phone_slot, sim) then
return false, "metadata_unsupported"
end
local result = Bridge.Database.Query([[
UPDATE IGNORE `sky_phone_sims`
SET `phone_number` = ?
WHERE `id` = ? AND `phone_number` = ?
]], { number, sim_id, previous_number })
if affected_rows(result) ~= 1 then
if phone_slot then
sim.phone_number = previous_number
if not set_phone_sim_metadata(source, phone_slot, sim) then
error("[sky_phone] Could not restore SIM metadata after a failed admin number change.")
end
end
return false, "phone_number_taken"
end
return true, number
end
local function resolve_used_sim(source, used_item, item_name)
local slot_id = used_item and (used_item.slot or used_item.id)
local slot = slot_id and Bridge.Inventory.GetSlot(source, slot_id) or nil
@@ -244,7 +315,7 @@ local function rollback_phone_metadata(source, phone_slot, old_sim)
end
local function insert_sim(source, phone_imei, confirmed)
if not sim_cards_enabled then
if Config.Sim.Enabled == false then
return { success = false, error = "disabled" }
end
if operation_locks[source] then
@@ -348,7 +419,7 @@ local function insert_sim(source, phone_imei, confirmed)
end
local function use_sim(source, used_item)
if not sim_cards_enabled then
if Config.Sim.Enabled == false then
return false
end
local item_name = used_item and used_item.name
@@ -393,17 +464,36 @@ local function use_sim(source, used_item)
TriggerClientEvent("sky_phone:sim:picker", source, {
choices = choices,
number = sim.phone_number,
phoneNumberFormat = {
length = Config.Sim.NumberLength,
groups = Config.Sim.NumberGroups,
},
})
return true
end
-- Register the guarded callbacks in both modes so a resource-only restart can
-- replace registrations left behind in framework-owned usable-item tables.
Bridge.Inventory.RegisterUsableItem(Config.Sim.RegisteredItem, use_sim)
Bridge.Inventory.RegisterUsableItem(Config.Sim.AnonymousItem, use_sim)
local registered_sim_items = {}
local function register_configured_sim_items()
for item_name in pairs(sim_types) do
if not registered_sim_items[item_name] then
local registered_item_name = item_name
Bridge.Inventory.RegisterUsableItem(registered_item_name, function(...)
if Config.Sim.Enabled ~= false and sim_types[registered_item_name] then
use_sim(...)
end
end)
registered_sim_items[registered_item_name] = true
end
end
end
register_configured_sim_items()
Bridge.Callbacks.Register("sky_phone:sim:insert", function(source, data)
if not sim_cards_enabled then
if Config.Sim.Enabled == false then
return { success = false, error = "disabled" }
end
if type(data) ~= "table" or not SkyPhoneImei.IsValid(data.imei) then
@@ -421,7 +511,7 @@ Bridge.Callbacks.Register("sky_phone:sim:picker-close", function(source)
end)
Bridge.Callbacks.Register("sky_phone:sim:eject", function(source)
if not sim_cards_enabled then
if Config.Sim.Enabled == false then
return { success = false, error = "disabled" }
end
if operation_locks[source] then
@@ -474,7 +564,13 @@ AddEventHandler("playerDropped", function()
operation_locks[source] = nil
end)
if sim_cards_enabled then
AddEventHandler("sky_phone:configurator:serverUpdated", function()
validate_number_configuration()
refresh_sim_types()
register_configured_sim_items()
end)
if Config.Sim.Enabled ~= false then
Bridge.Database.Query([[
UPDATE `sky_phone_devices` d
INNER JOIN `sky_phone_sims` s ON s.`id` = d.`sim_id`

Some files were not shown because too many files have changed in this diff Show More