mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:08:59 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b4a32bfab | |||
| cb6c94a7d8 | |||
| 2059e19e1c | |||
| 99e943dbc5 | |||
| bd9f4c2695 | |||
| 4fa4bf3c78 | |||
| a0f67fae80 | |||
| f7644bf973 | |||
| e8465e4732 | |||
| a9143b0fba | |||
| 7aa4677b81 | |||
| 5f8c835685 | |||
| 21913b585c | |||
| e056d0f05c | |||
| edd9677f43 | |||
| 97db163cd0 | |||
| 698420584d | |||
| 56901621a0 | |||
| 7b4e445394 | |||
| 7bbafd0026 |
@@ -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)) {
|
||||
|
||||
@@ -119,5 +119,5 @@ jobs:
|
||||
"${archive}.sha256" \
|
||||
--verify-tag \
|
||||
--generate-notes \
|
||||
--title "Sky Phone $GITHUB_REF_NAME"
|
||||
--title "$GITHUB_REF_NAME Beta"
|
||||
fi
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<p align="center">
|
||||
<a href="https://www.sky-systems.net/shop/phone#live-demo"><strong>Live demo</strong></a>
|
||||
•
|
||||
<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>
|
||||
•
|
||||
<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
@@ -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}`)
|
||||
|
||||
+17
-3
@@ -71,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'
|
||||
@@ -85,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'
|
||||
|
||||
@@ -139,6 +140,7 @@ type NavigationEventData = {
|
||||
type SimPickerPayload = {
|
||||
choices: SimPhoneChoice[]
|
||||
number: string
|
||||
phoneNumberFormat?: PhoneNumberFormat
|
||||
}
|
||||
|
||||
type NotificationEventData = Omit<PhoneNotificationInput, 'device'> & {
|
||||
@@ -500,6 +502,7 @@ function getViewportScale(): number {
|
||||
}
|
||||
|
||||
function hydratePhone(payload: PhoneOpenPayload): void {
|
||||
configurePhoneNumberFormat(payload.phoneNumberFormat)
|
||||
if (payload.device?.imei) {
|
||||
companies.bindDeviceScope(
|
||||
payload.device.imei,
|
||||
@@ -527,8 +530,17 @@ function hydratePhone(payload: PhoneOpenPayload): void {
|
||||
clock.hydrate(payload.device?.data.alarms?.payload)
|
||||
games.hydrate(payload.device?.data.games?.payload)
|
||||
media.hydrate(payload.device?.data.media?.payload)
|
||||
appStore.hydrate(payload.device?.data.apps?.payload)
|
||||
appStore.hydrate(payload.device?.data.apps?.payload, payload.disabledApps)
|
||||
widgets.hydrate(payload.device?.data.widgets?.payload)
|
||||
|
||||
const currentAppId = route.params.appId
|
||||
if (
|
||||
typeof currentAppId === 'string' &&
|
||||
isPhoneAppId(currentAppId) &&
|
||||
!appStore.isInstalled(currentAppId)
|
||||
) {
|
||||
void router.push('/')
|
||||
}
|
||||
}
|
||||
|
||||
function getInstalledNavigationAppIds(): string[] {
|
||||
@@ -1246,7 +1258,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
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,10 @@ 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(
|
||||
@@ -50,10 +54,59 @@ 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', () => {
|
||||
@@ -80,6 +133,7 @@ describe('standalone admin panel contracts', () => {
|
||||
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',
|
||||
@@ -91,18 +145,36 @@ describe('standalone admin panel contracts', () => {
|
||||
'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', () => {
|
||||
it('uses a fixed blue accent and replaces appearance controls with live statistics', () => {
|
||||
expect(source).not.toContain('<RefreshCw')
|
||||
expect(source).not.toContain("kind: 'refresh'")
|
||||
expect(source).toContain("'sky-phone-admin-accent'")
|
||||
expect(source).toContain(':style="{ \'--admin-accent\': accentColor }"')
|
||||
expect(source).not.toContain("'sky-phone-admin-accent'")
|
||||
expect(source).not.toContain("'sky-phone-admin-font-family'")
|
||||
expect(source).not.toContain("'sky-phone-admin-font-size'")
|
||||
expect(source).not.toContain("t('appearance.")
|
||||
expect(source).toContain('--admin-accent: #00b8e4')
|
||||
expect(source).toContain('class="admin-panel-statistics-layout"')
|
||||
expect(source).toContain('class="admin-panel-activity-grid"')
|
||||
expect(source).toContain('class="admin-panel-coverage-list"')
|
||||
expect(source).toContain('statisticsTimer = window.setInterval')
|
||||
expect(source).toContain('15_000')
|
||||
expect(server).toContain('AS `messages_today`')
|
||||
expect(server).toContain('AS `calls_today`')
|
||||
expect(server).toContain('AS `active_devices`')
|
||||
expect(server).toContain('auditEntries = tonumber(stats.audit_entries)')
|
||||
expect(source).toContain('color-mix(in srgb, var(--admin-green)')
|
||||
expect(source).toContain('--admin-toggle-on: #63d471')
|
||||
expect(source).toContain('background: var(--admin-toggle-on)')
|
||||
expect(configuratorValueEditor).toContain(
|
||||
'background: var(--admin-toggle-on)',
|
||||
)
|
||||
})
|
||||
|
||||
it('stages app changes locally and saves them only from the toolbar action', () => {
|
||||
@@ -116,6 +188,24 @@ describe('standalone admin panel contracts', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('applies global bundled-app availability to phones and admin management', () => {
|
||||
expect(config).toContain('Config.Apps = {')
|
||||
expect(config).toContain('memory = true,')
|
||||
expect(config).toContain('weather = true,')
|
||||
expect(configDefault).toContain('Config.Apps = {')
|
||||
expect(configuratorServer).toContain('Apps = true,')
|
||||
expect(phoneServer).toContain('function SkyPhone.IsAppEnabled(app_id)')
|
||||
expect(phoneServer).toContain('function SkyPhone.GetDisabledApps()')
|
||||
expect(phoneServer).toContain(
|
||||
'disabledApps = SkyPhone.GetDisabledApps()',
|
||||
)
|
||||
expect(server).toContain('if not SkyPhone.IsAppEnabled(app_id) then')
|
||||
expect(server).toContain('disabledApps = SkyPhone.GetDisabledApps()')
|
||||
expect(store).toContain('disabledApps: [] as string[]')
|
||||
expect(store).toContain('disabledAppsFromConfigurator(response.data)')
|
||||
expect(source).toContain('!admin.disabledApps.includes(app.id)')
|
||||
})
|
||||
|
||||
it('connects every operation through the standard NUI callback bridge', () => {
|
||||
expect(bridge).toContain('admin = [[')
|
||||
for (const endpoint of [
|
||||
@@ -127,6 +217,8 @@ describe('standalone admin panel contracts', () => {
|
||||
'admin:reset-passcode',
|
||||
'admin:change-number',
|
||||
'admin:factory-reset',
|
||||
'admin:configurator',
|
||||
'admin:save-configurator',
|
||||
]) {
|
||||
expect(store).toContain(endpoint)
|
||||
}
|
||||
@@ -160,18 +252,21 @@ describe('standalone admin panel contracts', () => {
|
||||
it('authorizes every server request without requiring a phone session', () => {
|
||||
expect(server).not.toContain('SkyPhone.RequireSession(source)')
|
||||
expect(server).toContain(
|
||||
'Bridge.Framework.HasAdminGroup(source, Config.AdminPanel.AdminGroups)',
|
||||
'Bridge.Framework.HasPermission(source, "phonepanel")',
|
||||
)
|
||||
expect(server).toContain('Config.AdminPanel.ReadRequestsPerMinute')
|
||||
expect(server).toContain('Config.AdminPanel.ActionRequestsPerMinute')
|
||||
expect(server).toContain('Config.AdminPanel.CredentialRevealsPerMinute')
|
||||
expect(config).toContain('Config.AdminPanel = {')
|
||||
expect(config).toContain('Config.CommandPermissions = {')
|
||||
expect(config).not.toContain('AdminGroups =')
|
||||
})
|
||||
|
||||
it('opens directly from the configurable command with dedicated focus', () => {
|
||||
expect(config).toContain('Command = "phoneadmin"')
|
||||
expect(config).toContain('Command = "phonepanel"')
|
||||
expect(server).toContain('local command_name = Config.AdminPanel.Command')
|
||||
expect(server).toContain(
|
||||
'RegisterCommand(Config.AdminPanel.Command, function(command_source)',
|
||||
'RegisterCommand(command_name, function(command_source)',
|
||||
)
|
||||
expect(server).toContain(
|
||||
'TriggerClientEvent("sky_phone:admin:launch", player_source)',
|
||||
@@ -203,4 +298,227 @@ describe('standalone admin panel contracts', () => {
|
||||
'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')
|
||||
})
|
||||
})
|
||||
|
||||
+1295
-142
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,12 @@ import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
||||
|
||||
type RecordingChunk = { blob: Blob; durationMs: number }
|
||||
type PendingVideo = { blob: Blob; fileName: string }
|
||||
type UploadFailureDebug = {
|
||||
correlationId: string
|
||||
message: string
|
||||
stage: string
|
||||
status?: number
|
||||
}
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const pendingVideos = new Map<string, PendingVideo>()
|
||||
@@ -130,7 +136,10 @@ function cleanupRecording(): void {
|
||||
try {
|
||||
activeRecorder.stop()
|
||||
} catch (error) {
|
||||
console.error('[Camera] Could not stop the failed media recorder.', error)
|
||||
console.error(
|
||||
'[Camera] Could not stop the failed media recorder.',
|
||||
error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -245,8 +254,7 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
|
||||
const activeRecorder = recorder
|
||||
removeRecorderErrorListener = bindMediaRecorderError(
|
||||
activeRecorder,
|
||||
() =>
|
||||
generation === recordingGeneration && recorder === activeRecorder,
|
||||
() => generation === recordingGeneration && recorder === activeRecorder,
|
||||
(event) => {
|
||||
console.error('[Camera] Media recorder failed while recording.', event)
|
||||
cleanupRecording()
|
||||
@@ -417,8 +425,32 @@ async function capturePhotoBlob(ready: UploadReady): Promise<Blob> {
|
||||
}
|
||||
}
|
||||
|
||||
async function failUpload(requestId: string, error: string): Promise<void> {
|
||||
await nuiCall('media:failUpload', { error, requestId })
|
||||
async function failUpload(
|
||||
requestId: string,
|
||||
error: string,
|
||||
debug: UploadFailureDebug,
|
||||
): Promise<void> {
|
||||
console.error('[Sky Phone Media] Upload failed.', {
|
||||
correlationId: debug.correlationId,
|
||||
detail: debug.message,
|
||||
error,
|
||||
stage: debug.stage,
|
||||
status: debug.status,
|
||||
})
|
||||
const response = await nuiCall('media:failUpload', {
|
||||
correlationId: debug.correlationId,
|
||||
debugMessage: debug.message,
|
||||
debugStage: debug.stage,
|
||||
debugStatus: debug.status,
|
||||
error,
|
||||
requestId,
|
||||
})
|
||||
if (!response.success) {
|
||||
console.error('[Sky Phone Media] Could not forward upload diagnostics.', {
|
||||
correlationId: debug.correlationId,
|
||||
error: response.error,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadReady(ready: UploadReady): Promise<void> {
|
||||
@@ -435,49 +467,87 @@ async function uploadReady(ready: UploadReady): Promise<void> {
|
||||
blob = await capturePhotoBlob(ready)
|
||||
fileName = `camera-${ready.correlationId}.${ready.photo?.Encoding ?? 'jpg'}`
|
||||
}
|
||||
} catch {
|
||||
await failUpload(ready.requestId, 'capture_failed')
|
||||
} catch (error) {
|
||||
await failUpload(ready.requestId, 'capture_failed', {
|
||||
correlationId: ready.correlationId,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stage: 'capture',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
console.info('[Sky Phone Media] Capture prepared for upload.', {
|
||||
bytes: blob.size,
|
||||
correlationId: ready.correlationId,
|
||||
mimeType: blob.type,
|
||||
type: ready.mediaType,
|
||||
})
|
||||
|
||||
const form = new FormData()
|
||||
form.append('file', blob, fileName)
|
||||
form.append(
|
||||
'metadata',
|
||||
JSON.stringify({ captureToken: ready.captureToken, source: 'sky_phone' }),
|
||||
)
|
||||
const controller = new AbortController()
|
||||
const timeout = window.setTimeout(
|
||||
() => controller.abort(),
|
||||
ready.uploadTimeoutMs ?? 25000,
|
||||
)
|
||||
let debugStage = 'provider_request'
|
||||
let debugStatus: number | undefined
|
||||
try {
|
||||
const response = await fetch(ready.presignedUrl, {
|
||||
body: form,
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
})
|
||||
debugStatus = response.status
|
||||
debugStage = 'provider_response'
|
||||
console.info('[Sky Phone Media] FiveManage upload responded.', {
|
||||
correlationId: ready.correlationId,
|
||||
status: response.status,
|
||||
})
|
||||
const text = await response.text()
|
||||
const body = JSON.parse(text) as {
|
||||
data?: { id?: string; url?: string }
|
||||
error?: string
|
||||
id?: string
|
||||
message?: string
|
||||
url?: string
|
||||
}
|
||||
const uploaded = body.data ?? body
|
||||
if (!response.ok || !uploaded.id || !uploaded.url) {
|
||||
throw new Error('upload_failed')
|
||||
throw new Error(
|
||||
(typeof body.error === 'string' && body.error) ||
|
||||
(typeof body.message === 'string' && body.message) ||
|
||||
'upload_failed',
|
||||
)
|
||||
}
|
||||
await nuiCall('media:completeUpload', {
|
||||
debugStage = 'completion_callback'
|
||||
const completion = await nuiCall('media:completeUpload', {
|
||||
correlationId: ready.correlationId,
|
||||
remoteId: uploaded.id,
|
||||
requestId: ready.requestId,
|
||||
url: uploaded.url,
|
||||
})
|
||||
if (!completion.success) {
|
||||
throw new Error(completion.error ?? 'completion_callback_failed')
|
||||
}
|
||||
console.info(
|
||||
'[Sky Phone Media] Upload completion forwarded to the server.',
|
||||
{
|
||||
correlationId: ready.correlationId,
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
await failUpload(
|
||||
ready.requestId,
|
||||
error instanceof DOMException && error.name === 'AbortError'
|
||||
? 'upload_timeout'
|
||||
: 'upload_failed',
|
||||
{
|
||||
correlationId: ready.correlationId,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stage: debugStage,
|
||||
status: debugStatus,
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
window.clearTimeout(timeout)
|
||||
@@ -523,7 +593,12 @@ function onMessage(event: MessageEvent): void {
|
||||
)
|
||||
}
|
||||
} else if (message.type === 'media:uploadReady') {
|
||||
void uploadReady(message.data as UploadReady)
|
||||
const ready = message.data as UploadReady
|
||||
console.info('[Sky Phone Media] Upload-ready received.', {
|
||||
correlationId: ready.correlationId,
|
||||
type: ready.mediaType,
|
||||
})
|
||||
void uploadReady(ready)
|
||||
} else if (message.type === 'media:uploadResult') {
|
||||
const correlationId = String(message.data?.correlationId ?? '')
|
||||
if (correlationId) pendingVideos.delete(correlationId)
|
||||
|
||||
@@ -365,6 +365,7 @@ async function stopRecording(data: Record<string, unknown>): Promise<void> {
|
||||
mimeType,
|
||||
note: finalMetadata.note,
|
||||
pinned: finalMetadata.pinned,
|
||||
sizeBytes: blob.size,
|
||||
title: finalMetadata.title,
|
||||
waveform,
|
||||
}
|
||||
@@ -458,14 +459,6 @@ async function uploadReady(ready: MemoUploadReady): Promise<void> {
|
||||
pending.requestId = ready.requestId
|
||||
const form = new FormData()
|
||||
form.append('file', pending.blob, pending.fileName)
|
||||
form.append(
|
||||
'metadata',
|
||||
JSON.stringify({
|
||||
captureToken: ready.captureToken,
|
||||
purpose: 'memo',
|
||||
source: 'sky_phone',
|
||||
}),
|
||||
)
|
||||
const controller = new AbortController()
|
||||
pending.abortController = controller
|
||||
const timeout = window.setTimeout(
|
||||
|
||||
@@ -61,16 +61,22 @@ const passcodeError = ref('')
|
||||
const passcodeLength = ref<4 | 6>(phone.security.length === 4 ? 4 : 6)
|
||||
const notificationsEnabled = ref(true)
|
||||
const notificationSounds = ref(true)
|
||||
const selectedApps = ref<BuiltinPhoneAppId[]>(['banking', 'garage', 'skyride'])
|
||||
const selectedApps = ref<BuiltinPhoneAppId[]>(
|
||||
(['banking', 'garage', 'skyride'] as const).filter((id) =>
|
||||
appStore.isAvailable(id),
|
||||
),
|
||||
)
|
||||
const setupCompleteBusy = ref(false)
|
||||
const setupCompleteError = ref('')
|
||||
|
||||
const setupApps = (
|
||||
['banking', 'garage', 'skyride', 'citymarkt', 'picstagram', 'snake'] as const
|
||||
).flatMap((id) => {
|
||||
const app = getPhoneApp(id)
|
||||
return app ? [app] : []
|
||||
})
|
||||
const setupApps = computed(() =>
|
||||
(
|
||||
['banking', 'garage', 'skyride', 'citymarkt', 'picstagram', 'snake'] as const
|
||||
).flatMap((id) => {
|
||||
const app = getPhoneApp(id)
|
||||
return app && appStore.isAvailable(id) ? [app] : []
|
||||
}),
|
||||
)
|
||||
const wallpaperChoices = WALLPAPER_IDS
|
||||
const progress = computed(() => `${((step.value + 1) / 10) * 100}%`)
|
||||
const displayName = computed(() => {
|
||||
|
||||
@@ -0,0 +1,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)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const mediaConfig = readFileSync(
|
||||
new URL('../../sky_phone/config/media.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const manifest = readFileSync(
|
||||
new URL('../../sky_phone/fxmanifest.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const mediaProviderConfig = readFileSync(
|
||||
new URL(
|
||||
'../../sky_phone/source/server/media_provider_config.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
const mediaImportAdapter = readFileSync(
|
||||
new URL(
|
||||
'../../sky_phone/source/server/media_import/fivemanage.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
const mediaServer = readFileSync(
|
||||
new URL('../../sky_phone/source/server/media.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const memoServer = readFileSync(
|
||||
new URL('../../sky_phone/source/server/memos.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const mediaCapture = readFileSync(
|
||||
new URL('./components/PhoneMediaCapture.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const memoRecorder = readFileSync(
|
||||
new URL('./components/PhoneMemoRecorder.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('FiveManage server configuration contract', () => {
|
||||
it('keeps the provider token in the server-only media config', () => {
|
||||
expect(mediaConfig).toMatch(/FiveManage\s*=\s*{\s*ApiKey\s*=/)
|
||||
expect(mediaProviderConfig).toContain(
|
||||
'return trim_key(Config.Media.FiveManage.ApiKey)',
|
||||
)
|
||||
expect(mediaProviderConfig).not.toContain('GetConvar')
|
||||
})
|
||||
|
||||
it('uses one resolver for Camera uploads and FiveManage imports', () => {
|
||||
expect(
|
||||
manifest.indexOf("'source/server/media_provider_config.lua'"),
|
||||
).toBeLessThan(manifest.indexOf("'source/server/media_import.lua'"))
|
||||
expect(mediaServer).toContain(
|
||||
'SkyPhoneMediaProviderConfig.FiveManageApiKey()',
|
||||
)
|
||||
expect(mediaImportAdapter).toContain(
|
||||
'SkyPhoneMediaProviderConfig.FiveManageApiKey(website.ApiKey)',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the direct FiveManage upload response flow for Camera and voice memos', () => {
|
||||
expect(mediaConfig).not.toContain('VerificationRetryDelaysMs')
|
||||
expect(mediaCapture).toContain("form.append('file', blob, fileName)")
|
||||
expect(mediaCapture).not.toContain("form.append('path'")
|
||||
expect(mediaCapture).not.toContain("form.append(\n 'metadata'")
|
||||
expect(memoRecorder).toContain(
|
||||
"form.append('file', pending.blob, pending.fileName)",
|
||||
)
|
||||
expect(memoRecorder).not.toContain("form.append('path'")
|
||||
expect(memoRecorder).not.toContain("form.append(\n 'metadata'")
|
||||
expect(mediaServer).toContain(
|
||||
'Accepting the direct FiveManage upload response',
|
||||
)
|
||||
expect(mediaServer).toContain('remote_id = remote_id')
|
||||
expect(mediaServer).toContain('url = uploaded_url')
|
||||
expect(mediaServer).not.toContain('"HEAD"')
|
||||
expect(mediaServer).not.toContain('authenticated upload-path lookup')
|
||||
})
|
||||
|
||||
it('allowlists the FiveManage API and media hosts', () => {
|
||||
expect(mediaServer).toContain('["api.fivemanage.com"] = true')
|
||||
expect(mediaServer).toContain('["fmapi.net"] = true')
|
||||
expect(mediaServer).toContain(
|
||||
'uploaded_host:lower() ~= "r2.fivemanage.com"',
|
||||
)
|
||||
})
|
||||
|
||||
it('validates and preserves the recorded memo size before upload', () => {
|
||||
expect(memoRecorder).toContain('sizeBytes: blob.size')
|
||||
expect(memoServer).toContain(
|
||||
'local size_bytes = tonumber(data.sizeBytes)',
|
||||
)
|
||||
expect(memoServer).toContain(
|
||||
'size_bytes < 1 or size_bytes > Config.Memos.MaximumBytes',
|
||||
)
|
||||
expect(memoServer).toContain('size_bytes = memo.size_bytes')
|
||||
expect(mediaServer).toContain('size = state.size_bytes')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const mediaCapture = readFileSync(
|
||||
new URL('./components/PhoneMediaCapture.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const mediaServer = readFileSync(
|
||||
new URL('../../sky_phone/source/server/media.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('media upload diagnostics contracts', () => {
|
||||
it('forwards browser upload failure context to the server log', () => {
|
||||
expect(mediaCapture).toContain("let debugStage = 'provider_request'")
|
||||
expect(mediaCapture).toContain('debugStatus: debug.status')
|
||||
expect(mediaServer).toContain('Client-reported upload failure')
|
||||
expect(mediaServer).toContain('diagnostic_text(data.debugMessage, 240)')
|
||||
})
|
||||
})
|
||||
@@ -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}")`,
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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,73 @@ 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('uses One Inventory slot ids and authoritative slot reads', () => {
|
||||
const adapter = readResourceFile(
|
||||
'source/bridge/server/inventory/one.lua',
|
||||
)
|
||||
|
||||
expect(adapter).toContain('inventory:GetSlotIdsWithItem(')
|
||||
expect(adapter).toContain(
|
||||
'local normalized = Bridge.Inventory.GetSlot(source, slot_id)',
|
||||
)
|
||||
expect(adapter).not.toContain('inventory:SearchInventory(')
|
||||
expect(adapter).toContain(
|
||||
'inventory:SetItemMetadata(source, slot.slot, requested_metadata) == false',
|
||||
)
|
||||
})
|
||||
|
||||
it('serializes and rate-limits phone bootstrap requests on both sides', () => {
|
||||
const phoneClient = readResourceFile('source/client/main.lua')
|
||||
const phoneServer = readResourceFile('source/server/phone.lua')
|
||||
|
||||
expect(phoneClient).toContain('local function request_phone_open(')
|
||||
expect(phoneClient).toMatch(
|
||||
/request_phone_open\(callback_name\)[\s\S]*?open_requested = true[\s\S]*?Bridge\.Callbacks\.Trigger\(callback_name, \{\}\)/,
|
||||
)
|
||||
expect(phoneClient).toMatch(
|
||||
/RegisterNetEvent\("sky_phone:device:error"[\s\S]*?if not is_open then[\s\S]*?open_requested = false/,
|
||||
)
|
||||
expect(phoneServer).toContain('local phone_open_in_progress = {}')
|
||||
expect(phoneServer).toContain(
|
||||
'SkyPhone.AllowOperation(source, "phone_open", request_limit, 60)',
|
||||
)
|
||||
expect(phoneServer).toContain(
|
||||
'pcall(perform_phone_open, source, used_item)',
|
||||
)
|
||||
})
|
||||
|
||||
it('auto-detects registered inventories and forces metadata-free adapters into compatible modes', () => {
|
||||
const inventoryBridge = readResourceFile(
|
||||
'source/bridge/server/inventory.lua',
|
||||
)
|
||||
|
||||
expect(inventoryBridge).toContain(
|
||||
'GetResourceState("hex_4_inventory") == "started"',
|
||||
'{ name = "hex", resource = "hex_4_inventory", framework = "esx", metadata = false },',
|
||||
)
|
||||
expect(inventoryBridge).toContain(
|
||||
'GetResourceState(adapter.resource) == "started"',
|
||||
)
|
||||
expect(inventoryBridge).toContain('configured_inventory = adapter.name')
|
||||
expect(inventoryBridge).toContain('selected_adapter.metadata == false')
|
||||
expect(inventoryBridge).toContain('Config.Phone.Unique = false')
|
||||
expect(inventoryBridge).toContain('Config.Sim.Enabled = false')
|
||||
expect(inventoryBridge).toContain(
|
||||
'does not support item metadata; unique phones and physical SIM cards were disabled automatically',
|
||||
)
|
||||
expect(inventoryBridge).toContain(
|
||||
'AddEventHandler("sky_phone:configurator:serverUpdated"',
|
||||
)
|
||||
expect(inventoryBridge).not.toContain(
|
||||
'cannot store unique phone or physical SIM metadata',
|
||||
)
|
||||
expect(inventoryBridge).toContain('configured_inventory = "hex"')
|
||||
expect(inventoryBridge).toContain('Config.Phone.Unique ~= false')
|
||||
expect(inventoryBridge).toContain('Config.Sim.Enabled ~= false')
|
||||
})
|
||||
|
||||
it('provides the LB IsOpen export alias from the authoritative client state', () => {
|
||||
@@ -100,9 +161,15 @@ describe('phone inventory contracts', () => {
|
||||
expect(equippedNumberExport).toBeDefined()
|
||||
expect(equippedNumberResolver).toBeDefined()
|
||||
expect(equippedNumberExport).toContain('type(player) == "number"')
|
||||
expect(equippedNumberExport).toContain('online_source_for_identifier(player)')
|
||||
expect(phoneServer).toContain('Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)')
|
||||
expect(phoneServer).toContain('return resolve_equipped_phone_number(player)')
|
||||
expect(equippedNumberExport).toContain(
|
||||
'online_source_for_identifier(player)',
|
||||
)
|
||||
expect(phoneServer).toContain(
|
||||
'Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)',
|
||||
)
|
||||
expect(phoneServer).toContain(
|
||||
'return resolve_equipped_phone_number(player)',
|
||||
)
|
||||
expect(equippedNumberExport).not.toContain('tonumber(player)')
|
||||
expect(equippedNumberExport).not.toContain('equipped_phone_numbers[player]')
|
||||
expect(equippedNumberResolver).not.toContain('return cached_number')
|
||||
@@ -134,10 +201,10 @@ describe('phone inventory contracts', () => {
|
||||
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "ToggleOpen"',
|
||||
)
|
||||
expect(phoneClient).toContain(
|
||||
'local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})',
|
||||
'local result = Bridge.Callbacks.Trigger(callback_name, {})',
|
||||
)
|
||||
expect(phoneClient).toMatch(
|
||||
/result\.success ~= true[\s\S]*open_without_focus = false[\s\S]*return false/,
|
||||
/result\.success == true[\s\S]*open_requested = false[\s\S]*open_without_focus = false[\s\S]*return false/,
|
||||
)
|
||||
expect(phoneBridge).toContain(
|
||||
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "IsPhoneOnScreen"',
|
||||
@@ -157,7 +224,9 @@ describe('phone inventory contracts', () => {
|
||||
expect(phoneClient).toContain(
|
||||
'TriggerEvent("sky_phone:client:phoneToggled", false)',
|
||||
)
|
||||
expect(phoneBridge).toContain('TriggerEvent("lb-phone:numberChanged", phone_number)')
|
||||
expect(phoneBridge).toContain(
|
||||
'TriggerEvent("lb-phone:numberChanged", phone_number)',
|
||||
)
|
||||
expect(phoneBridge).toContain('TriggerEvent("lb-phone:phoneToggled", open)')
|
||||
})
|
||||
|
||||
@@ -210,7 +279,9 @@ describe('phone inventory contracts', () => {
|
||||
expect(mediaServer).toContain(
|
||||
'TriggerEvent("sky_phone:server:galleryMediaDeleted", src, phone_number, deleted_link)',
|
||||
)
|
||||
expect(phoneBridge).toContain('TriggerEvent("lb-phone:phoneNumberGenerated"')
|
||||
expect(phoneBridge).toContain(
|
||||
'TriggerEvent("lb-phone:phoneNumberGenerated"',
|
||||
)
|
||||
expect(phoneBridge).toContain('TriggerEvent("lb-phone:factoryReset"')
|
||||
expect(phoneBridge).toContain('TriggerEvent("lb-phone:deletedFromGallery"')
|
||||
})
|
||||
@@ -221,17 +292,41 @@ 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(
|
||||
'Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})',
|
||||
'if active_key_mapping_command == command_name then',
|
||||
)
|
||||
expect(phoneClient).toContain(
|
||||
'request_phone_open("sky_phone:device:open-request")',
|
||||
)
|
||||
expect(phoneServer).toContain(
|
||||
'Bridge.Callbacks.Register("sky_phone:device:open-request", function(source)',
|
||||
)
|
||||
})
|
||||
|
||||
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 +357,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` = ?')
|
||||
|
||||
@@ -5,6 +5,8 @@ import type {
|
||||
AdminActivityResponse,
|
||||
AdminBootstrap,
|
||||
AdminCallActivity,
|
||||
AdminConfigurator,
|
||||
AdminConfiguratorChange,
|
||||
AdminCredential,
|
||||
AdminMessageActivity,
|
||||
AdminPlayerDetail,
|
||||
@@ -13,14 +15,42 @@ import type {
|
||||
} from '@/types/admin'
|
||||
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||
|
||||
const EMPTY_STATS: AdminStats = { accounts: 0, devices: 0, online: 0 }
|
||||
const EMPTY_STATS: AdminStats = {
|
||||
accounts: 0,
|
||||
activeDevices: 0,
|
||||
auditEntries: 0,
|
||||
auditToday: 0,
|
||||
callsToday: 0,
|
||||
devices: 0,
|
||||
linkedDevices: 0,
|
||||
messagesToday: 0,
|
||||
online: 0,
|
||||
simDevices: 0,
|
||||
}
|
||||
|
||||
function disabledAppsFromConfigurator(
|
||||
configurator: AdminConfigurator,
|
||||
): string[] {
|
||||
const apps = configurator.sections
|
||||
.flatMap((section) => section.fields)
|
||||
.find((field) => field.scope === 'config' && field.path === 'Apps')?.value
|
||||
if (!apps || typeof apps !== 'object' || Array.isArray(apps)) return []
|
||||
|
||||
return Object.entries(apps)
|
||||
.filter(([, enabled]) => enabled === false)
|
||||
.map(([appId]) => appId)
|
||||
.sort()
|
||||
}
|
||||
|
||||
export const useAdminStore = defineStore('admin', {
|
||||
state: () => ({
|
||||
actionKey: '',
|
||||
activityKey: '',
|
||||
audit: [] as AdminAuditEntry[],
|
||||
configurator: null as AdminConfigurator | null,
|
||||
configuratorLoading: false,
|
||||
detailLoading: false,
|
||||
disabledApps: [] as string[],
|
||||
error: '',
|
||||
initialized: false,
|
||||
loading: false,
|
||||
@@ -43,6 +73,9 @@ export const useAdminStore = defineStore('admin', {
|
||||
return false
|
||||
}
|
||||
this.players = response.data.players
|
||||
this.disabledApps = Array.isArray(response.data.disabledApps)
|
||||
? response.data.disabledApps
|
||||
: []
|
||||
this.stats = response.data.stats
|
||||
this.audit = response.data.audit
|
||||
this.error = ''
|
||||
@@ -134,6 +167,44 @@ export const useAdminStore = defineStore('admin', {
|
||||
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.disabledApps = disabledAppsFromConfigurator(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.disabledApps = disabledAppsFromConfigurator(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,
|
||||
|
||||
@@ -75,6 +75,21 @@ describe('app store', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('removes server-disabled apps without forgetting device claims', () => {
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
apps.hydrate({ claimedApps: ['weather', 'snake'] }, ['weather'])
|
||||
|
||||
expect(apps.isAvailable('weather')).toBe(false)
|
||||
expect(apps.isInstalled('weather')).toBe(false)
|
||||
expect(apps.homeLayout.grid).not.toContain('weather')
|
||||
expect(apps.claimedApps).toContain('weather')
|
||||
expect(apps.isInstalled('snake')).toBe(true)
|
||||
|
||||
apps.hydrate({ claimedApps: apps.claimedApps }, [])
|
||||
expect(apps.isInstalled('weather')).toBe(true)
|
||||
})
|
||||
|
||||
it('drops the retired admin app from persisted phone layouts', () => {
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
|
||||
@@ -45,28 +45,49 @@ const pendingInstallations = new WeakMap<
|
||||
Map<LaunchablePhoneAppId, PendingInstallation>
|
||||
>()
|
||||
|
||||
function getDefaultGridIds(): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter((app) => app.dockOrder === null)
|
||||
function isAppDisabled(
|
||||
appId: LaunchablePhoneAppId,
|
||||
disabledApps: readonly LaunchablePhoneAppId[],
|
||||
): boolean {
|
||||
return disabledApps.includes(appId)
|
||||
}
|
||||
|
||||
function getDefaultGridIds(
|
||||
disabledApps: readonly LaunchablePhoneAppId[] = [],
|
||||
): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter(
|
||||
(app) => app.dockOrder === null && !isAppDisabled(app.id, disabledApps),
|
||||
)
|
||||
.sort((a, b) => a.gridOrder - b.gridOrder)
|
||||
.map((app) => app.id)
|
||||
}
|
||||
|
||||
function getDefaultDockIds(): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter((app) => app.dockOrder !== null)
|
||||
function getDefaultDockIds(
|
||||
disabledApps: readonly LaunchablePhoneAppId[] = [],
|
||||
): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter(
|
||||
(app) => app.dockOrder !== null && !isAppDisabled(app.id, disabledApps),
|
||||
)
|
||||
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
|
||||
.map((app) => app.id)
|
||||
}
|
||||
|
||||
function getDefaultInstalledIds(): LaunchablePhoneAppId[] {
|
||||
function getDefaultInstalledIds(
|
||||
disabledApps: readonly LaunchablePhoneAppId[] = [],
|
||||
): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter((app) => {
|
||||
if (app.adminOnly) return false
|
||||
if (app.adminOnly || isAppDisabled(app.id, disabledApps)) return false
|
||||
return isExternalPhoneApp(app)
|
||||
? app.defaultInstalled
|
||||
: DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id)
|
||||
}).map((app) => app.id)
|
||||
}
|
||||
|
||||
function isProtectedHomeApp(appId: LaunchablePhoneAppId): boolean {
|
||||
function isProtectedHomeApp(
|
||||
appId: LaunchablePhoneAppId,
|
||||
disabledApps: readonly LaunchablePhoneAppId[],
|
||||
): boolean {
|
||||
if (isAppDisabled(appId, disabledApps)) return false
|
||||
const app = getPhoneApp(appId)
|
||||
return app
|
||||
? !isPhoneAppRemovable(app)
|
||||
@@ -104,6 +125,7 @@ function hasUninstalledBuiltinApp(
|
||||
export const useAppStoreStore = defineStore('app-store', {
|
||||
state: () => ({
|
||||
claimedApps: [] as LaunchablePhoneAppId[],
|
||||
disabledApps: [] as LaunchablePhoneAppId[],
|
||||
uninstalledApps: [] as LaunchablePhoneAppId[],
|
||||
homeLayout: createDefaultHomeLayout(
|
||||
getDefaultInstalledIds(),
|
||||
@@ -130,6 +152,7 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
return true
|
||||
},
|
||||
claimApp(id: LaunchablePhoneAppId): void {
|
||||
if (!this.isAvailable(id)) return
|
||||
this.uninstalledApps = this.uninstalledApps.filter(
|
||||
(appId) => appId !== id,
|
||||
)
|
||||
@@ -150,6 +173,7 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
this.installingApps = {}
|
||||
},
|
||||
installApp(id: LaunchablePhoneAppId): void {
|
||||
if (!this.isAvailable(id)) return
|
||||
const installed = this.isInstalled(id)
|
||||
if (
|
||||
this.installingApps[id] ||
|
||||
@@ -191,6 +215,10 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!this.isAvailable(id)) {
|
||||
delete this.installingApps[id]
|
||||
return
|
||||
}
|
||||
if (reportInstall && !isExternalPhoneApp(getPhoneApp(id))) {
|
||||
delete this.installingApps[id]
|
||||
console.error(
|
||||
@@ -220,8 +248,14 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
installations.set(id, { deviceImei, timer, token })
|
||||
pendingInstallations.set(this, installations)
|
||||
},
|
||||
hydrate(payload: unknown): void {
|
||||
hydrate(payload: unknown, disabledApps: unknown = []): void {
|
||||
this.cancelPendingInstalls()
|
||||
this.disabledApps = Array.isArray(disabledApps)
|
||||
? disabledApps.filter(
|
||||
(id): id is LaunchablePhoneAppId =>
|
||||
typeof id === 'string' && isPhoneAppId(id),
|
||||
)
|
||||
: []
|
||||
const data = payload as {
|
||||
claimedApps?: unknown
|
||||
homeLayout?: unknown
|
||||
@@ -256,16 +290,22 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
})
|
||||
: []
|
||||
const installedIds = [
|
||||
...new Set([...getDefaultInstalledIds(), ...this.claimedApps]),
|
||||
].filter((id) => !this.uninstalledApps.includes(id))
|
||||
...new Set([
|
||||
...getDefaultInstalledIds(this.disabledApps),
|
||||
...this.claimedApps,
|
||||
]),
|
||||
].filter(
|
||||
(id) =>
|
||||
!this.uninstalledApps.includes(id) && this.isAvailable(id),
|
||||
)
|
||||
const removedLegacyDefaults = hasUninstalledBuiltinApp(
|
||||
data?.homeLayout,
|
||||
installedIds,
|
||||
)
|
||||
const defaults = createDefaultHomeLayout(
|
||||
installedIds,
|
||||
getDefaultGridIds(),
|
||||
getDefaultDockIds(),
|
||||
getDefaultGridIds(this.disabledApps),
|
||||
getDefaultDockIds(this.disabledApps),
|
||||
)
|
||||
const parsedHomeLayout = parseHomeLayout(
|
||||
data?.homeLayout,
|
||||
@@ -277,8 +317,9 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
const removedDockGridDuplicates =
|
||||
normalizedHomeLayout !== parsedHomeLayout
|
||||
this.homeLayout = normalizedHomeLayout
|
||||
const protectedHiddenAppIds =
|
||||
this.homeLayout.hidden.filter(isProtectedHomeApp)
|
||||
const protectedHiddenAppIds = this.homeLayout.hidden.filter((appId) =>
|
||||
isProtectedHomeApp(appId, this.disabledApps),
|
||||
)
|
||||
for (const appId of protectedHiddenAppIds) {
|
||||
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
|
||||
}
|
||||
@@ -310,7 +351,11 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
this.persist()
|
||||
}
|
||||
},
|
||||
isAvailable(appId: LaunchablePhoneAppId): boolean {
|
||||
return !isAppDisabled(appId, this.disabledApps)
|
||||
},
|
||||
isInstalled(appId: LaunchablePhoneAppId): boolean {
|
||||
if (!this.isAvailable(appId)) return false
|
||||
const app = getPhoneApp(appId)
|
||||
if (app?.adminOnly) return false
|
||||
if (this.uninstalledApps.includes(appId)) return false
|
||||
@@ -322,12 +367,18 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
},
|
||||
reconcileCatalog(): void {
|
||||
const installedIds = [
|
||||
...new Set([...getDefaultInstalledIds(), ...this.claimedApps]),
|
||||
].filter((id) => !this.uninstalledApps.includes(id))
|
||||
...new Set([
|
||||
...getDefaultInstalledIds(this.disabledApps),
|
||||
...this.claimedApps,
|
||||
]),
|
||||
].filter(
|
||||
(id) =>
|
||||
!this.uninstalledApps.includes(id) && this.isAvailable(id),
|
||||
)
|
||||
const defaults = createDefaultHomeLayout(
|
||||
installedIds,
|
||||
getDefaultGridIds(),
|
||||
getDefaultDockIds(),
|
||||
getDefaultGridIds(this.disabledApps),
|
||||
getDefaultDockIds(this.disabledApps),
|
||||
)
|
||||
const previous = JSON.stringify(this.homeLayout)
|
||||
this.homeLayout = removeDockGridDuplicates(
|
||||
@@ -335,7 +386,7 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
)
|
||||
|
||||
for (const appId of [...this.homeLayout.hidden]) {
|
||||
if (isProtectedHomeApp(appId)) {
|
||||
if (isProtectedHomeApp(appId, this.disabledApps)) {
|
||||
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
|
||||
}
|
||||
}
|
||||
@@ -345,6 +396,7 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
}
|
||||
},
|
||||
recordLaunch(appId: LaunchablePhoneAppId): void {
|
||||
if (!this.isAvailable(appId)) return
|
||||
this.launchCounts[appId] = (this.launchCounts[appId] ?? 0) + 1
|
||||
this.persist()
|
||||
},
|
||||
@@ -480,12 +532,13 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
return true
|
||||
},
|
||||
removeHomeApp(appId: LaunchablePhoneAppId): void {
|
||||
if (isProtectedHomeApp(appId)) return
|
||||
if (isProtectedHomeApp(appId, this.disabledApps)) return
|
||||
|
||||
this.homeLayout = removeHomeApp(this.homeLayout, appId)
|
||||
this.persist()
|
||||
},
|
||||
restoreHomeApp(appId: LaunchablePhoneAppId): void {
|
||||
if (!this.isAvailable(appId)) return
|
||||
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
|
||||
this.persist()
|
||||
},
|
||||
|
||||
@@ -10,14 +10,22 @@ type LuaToken = {
|
||||
}
|
||||
|
||||
const frontendSourceDirectory = fileURLToPath(new URL('../', import.meta.url))
|
||||
const englishLocaleSource = readFileSync(
|
||||
new URL('../../../sky_phone/config/locales/en.lua', import.meta.url),
|
||||
'utf8',
|
||||
const localeDirectory = fileURLToPath(
|
||||
new URL('../../../sky_phone/config/locales/', import.meta.url),
|
||||
)
|
||||
const germanLocaleSource = readFileSync(
|
||||
new URL('../../../sky_phone/config/locales/de.lua', import.meta.url),
|
||||
'utf8',
|
||||
const localeSources = new Map(
|
||||
readdirSync(localeDirectory, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.lua'))
|
||||
.map((entry) => [
|
||||
entry.name.replace(/\.lua$/, ''),
|
||||
readFileSync(join(localeDirectory, entry.name), 'utf8'),
|
||||
]),
|
||||
)
|
||||
const englishLocaleSource = localeSources.get('en')
|
||||
const germanLocaleSource = localeSources.get('de')
|
||||
if (!englishLocaleSource || !germanLocaleSource) {
|
||||
throw new Error('The bundled English and German phone locales are required.')
|
||||
}
|
||||
const phoneStoreSource = readFileSync(
|
||||
new URL('./phone.ts', import.meta.url),
|
||||
'utf8',
|
||||
@@ -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 = {
|
||||
|
||||
+153
-15
@@ -33,11 +33,13 @@ export type PasscodeResponseData = {
|
||||
export type PhoneOpenPayload = {
|
||||
account?: DeviceBootstrap['account']
|
||||
device?: PhoneDevice
|
||||
disabledApps?: string[]
|
||||
fallbackLocales?: LocaleTree
|
||||
lang?: string
|
||||
locales?: LocaleTree
|
||||
memos?: DeviceBootstrap['memos']
|
||||
notes?: DeviceBootstrap['notes']
|
||||
phoneNumberFormat?: DeviceBootstrap['phoneNumberFormat']
|
||||
player?: DeviceBootstrap['player']
|
||||
security?: DeviceSecurity
|
||||
token?: string
|
||||
@@ -638,7 +640,8 @@ const cryptoFallbackLocales = {
|
||||
invalid_handle: 'Use 3–20 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 8–72 characters.',
|
||||
invalid_password:
|
||||
'Use 8–72 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.',
|
||||
@@ -821,6 +824,7 @@ const adminPanelFallbackLocales = {
|
||||
calls: 'Calls',
|
||||
moderation: 'Moderation',
|
||||
audit: 'Audit',
|
||||
configurator: 'Phone configurator',
|
||||
},
|
||||
overview: {
|
||||
eyebrow: 'Server',
|
||||
@@ -843,17 +847,138 @@ const adminPanelFallbackLocales = {
|
||||
callFeature: 'Review recent call activity',
|
||||
moderationFeature: 'Reset access, number, or device data',
|
||||
auditFeature: 'Review sensitive admin actions',
|
||||
configuratorFeature: 'Manage config.lua and media.lua through SQL',
|
||||
},
|
||||
appearance: {
|
||||
eyebrow: 'Appearance',
|
||||
title: 'Accent color',
|
||||
body: 'Change the accent across the complete admin workspace.',
|
||||
colors: {
|
||||
emerald: 'Emerald',
|
||||
blue: 'Blue',
|
||||
violet: 'Violet',
|
||||
orange: 'Orange',
|
||||
red: 'Red',
|
||||
statistics: {
|
||||
eyebrow: 'Live data',
|
||||
title: 'Phone statistics',
|
||||
body: 'Current usage and device status, refreshed every 15 seconds.',
|
||||
today: 'Activity today',
|
||||
todayBody: 'Phone and admin activity since midnight.',
|
||||
messagesToday: 'Messages',
|
||||
callsToday: 'Calls',
|
||||
auditToday: 'Admin actions',
|
||||
coverage: 'Device coverage',
|
||||
coverageBody: 'Current share of all stored phones.',
|
||||
linkedDevices: 'Account linked',
|
||||
simDevices: 'SIM assigned',
|
||||
activeDevices: 'Updated in 24 hours',
|
||||
ofDevices: '{count} of {total} devices',
|
||||
},
|
||||
configurator: {
|
||||
context: 'Runtime configuration',
|
||||
eyebrow: 'System tool',
|
||||
sections: 'Configuration',
|
||||
search: 'Search settings or paths',
|
||||
configScope: 'config.lua',
|
||||
mediaScope: 'media.lua',
|
||||
noResults: 'No matching settings',
|
||||
loading: 'Loading SQL configuration...',
|
||||
title: 'Phone configurator',
|
||||
body: 'Manage phone and media settings from the protected admin workspace.',
|
||||
disabledTitle: 'SQL configuration is not active',
|
||||
disabledBody:
|
||||
'Enable the configurator at the beginning of config.lua and restart sky_phone. Until then, file values remain active and editing is locked.',
|
||||
manualSave: 'Manual save',
|
||||
refreshNotice:
|
||||
'Nothing is written automatically. The green check verifies config.lua and media.lua in SQL and refreshes the active server, client, media and UI configuration immediately.',
|
||||
fieldCount: '{count} fields',
|
||||
secretConfigured: 'Secret configured · enter a replacement',
|
||||
invalidValue: 'Check the highlighted table or number value.',
|
||||
saved: 'SQL configuration saved and applied.',
|
||||
descriptions: {
|
||||
featureToggle: 'Turns {name} on or off.',
|
||||
boolean: 'Controls whether {name} is allowed.',
|
||||
number: 'Sets the numeric value for {name}.',
|
||||
text: 'Sets the text value used for {name}.',
|
||||
optionalText:
|
||||
'Sets the optional value for {name}; switch it off to disable it.',
|
||||
list: 'Manages all entries used for {name}.',
|
||||
table: 'Groups the related settings for {name}.',
|
||||
credential: 'Stores the protected credential used by {name}.',
|
||||
url: 'Sets the URL or endpoint used by {name}.',
|
||||
hosts: 'Defines which domains are allowed for {name}.',
|
||||
milliseconds: 'Sets the timing for {name} in milliseconds.',
|
||||
seconds: 'Sets the timing for {name} in seconds.',
|
||||
rateLimit: 'Limits how many {name} actions are allowed per minute.',
|
||||
byteLimit: 'Sets the maximum data size allowed for {name}.',
|
||||
textLimit: 'Sets the maximum text length allowed for {name}.',
|
||||
distance: 'Sets the world distance used for {name}.',
|
||||
coordinates: 'Sets the world coordinates or orientation for {name}.',
|
||||
gameAsset: 'Sets the GTA model or prop used for {name}.',
|
||||
animation: 'Sets the animation asset used for {name}.',
|
||||
access: 'Defines the jobs, groups or permission level for {name}.',
|
||||
integration: 'Selects the connected framework or provider for {name}.',
|
||||
path: 'Sets the storage or resource path used for {name}.',
|
||||
color: 'Sets the interface color used for {name}.',
|
||||
displayText: 'Sets the text shown to players for {name}.',
|
||||
phoneNumber: 'Sets the phone or service number used for {name}.',
|
||||
routing: 'Controls how incoming requests are routed for {name}.',
|
||||
command: 'Sets the chat command used to open or run {name}.',
|
||||
locale: 'Selects the language used for {name}.',
|
||||
debug: 'Controls detailed diagnostic output for {name}.',
|
||||
mediaQuality: 'Sets the media quality or volume used for {name}.',
|
||||
amount: 'Sets the maximum or displayed amount for {name}.',
|
||||
},
|
||||
table: {
|
||||
list: 'List',
|
||||
table: 'Key table',
|
||||
vector: 'Vector',
|
||||
entry: 'Entry',
|
||||
general: 'General',
|
||||
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: {
|
||||
@@ -1006,7 +1131,8 @@ const adminPanelFallbackLocales = {
|
||||
noAutoSave: 'Manual save',
|
||||
noAutoSaveBody: 'Changes stay local until the green check is pressed.',
|
||||
discardTitle: 'Discard unsaved changes?',
|
||||
discardBody: 'Your staged app changes have not been saved.',
|
||||
discardBody:
|
||||
'Your staged app or configuration changes have not been saved.',
|
||||
keepEditing: 'Keep editing',
|
||||
discard: 'Discard changes',
|
||||
saveFailed: 'Some changes could not be saved.',
|
||||
@@ -1028,6 +1154,7 @@ const adminPanelFallbackLocales = {
|
||||
reset_passcode: 'Passcode reset',
|
||||
change_number: 'Phone number changed',
|
||||
factory_reset: 'Phone factory reset',
|
||||
save_configuration: 'Configuration saved',
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
@@ -1038,7 +1165,10 @@ const adminPanelFallbackLocales = {
|
||||
invalid_app: 'That app is not registered on the server.',
|
||||
app_protected: 'This system app cannot be removed.',
|
||||
revision_conflict:
|
||||
'The phone changed in the meantime. Refresh and try again.',
|
||||
'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.',
|
||||
@@ -2336,7 +2466,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',
|
||||
@@ -3430,6 +3560,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',
|
||||
@@ -3500,7 +3632,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.',
|
||||
@@ -4406,6 +4539,11 @@ const defaultLocales: LocaleTree = {
|
||||
invalid_media_type: 'The uploaded media type is invalid.',
|
||||
invalid_upload: 'The upload could not be verified.',
|
||||
invalid_upload_token: 'The upload session is no longer valid.',
|
||||
media_provider_failed: 'The camera upload service is unavailable.',
|
||||
media_provider_rate_limited:
|
||||
'The camera upload service is busy. Try again shortly.',
|
||||
media_provider_unauthorized:
|
||||
'The configured FiveManage API key was rejected.',
|
||||
missing_config: 'Camera uploads are not configured.',
|
||||
microphone_unavailable:
|
||||
'Allow microphone access or mute the microphone before recording.',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
export type AdminStats = {
|
||||
accounts: number
|
||||
activeDevices: number
|
||||
auditEntries: number
|
||||
auditToday: number
|
||||
callsToday: number
|
||||
devices: number
|
||||
linkedDevices: number
|
||||
messagesToday: number
|
||||
online: number
|
||||
simDevices: number
|
||||
}
|
||||
|
||||
export type AdminPlayerSummary = {
|
||||
@@ -78,6 +85,7 @@ export type AdminAuditEntry = {
|
||||
|
||||
export type AdminBootstrap = {
|
||||
audit: AdminAuditEntry[]
|
||||
disabledApps: string[]
|
||||
players: AdminPlayerSummary[]
|
||||
stats: AdminStats
|
||||
}
|
||||
@@ -111,3 +119,69 @@ export type AdminCallActivity = {
|
||||
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,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
|
||||
|
||||
@@ -70,7 +70,6 @@ export type MediaImportResult = {
|
||||
}
|
||||
|
||||
export type UploadReady = {
|
||||
captureToken: string
|
||||
correlationId: string
|
||||
mediaType: MediaType
|
||||
photo?: {
|
||||
|
||||
@@ -39,7 +39,6 @@ export type MemoRecordingMetadata = {
|
||||
export type MemoUploadReady = {
|
||||
requestId: string
|
||||
correlationId: string
|
||||
captureToken: string
|
||||
presignedUrl: string
|
||||
uploadTimeoutMs?: number
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -41,24 +41,21 @@ describe('App Store preview catalog', () => {
|
||||
expect(visualSignatures.size).toBe(storeAppIds.length)
|
||||
})
|
||||
|
||||
it('localizes every specialized preview in the fallback, English and German locales', () => {
|
||||
it('localizes every specialized preview in the fallback and every configured locale', () => {
|
||||
const localeDirectory = fileURLToPath(
|
||||
new URL('../../../sky_phone/config/locales/', import.meta.url),
|
||||
)
|
||||
const localeSources = [
|
||||
readFileSync(
|
||||
fileURLToPath(new URL('../stores/phone.ts', import.meta.url)),
|
||||
'utf8',
|
||||
),
|
||||
readFileSync(
|
||||
fileURLToPath(
|
||||
new URL('../../../sky_phone/config/locales/en.lua', import.meta.url),
|
||||
...readdirSync(localeDirectory)
|
||||
.filter((fileName) => fileName.endsWith('.lua'))
|
||||
.sort()
|
||||
.map((fileName) =>
|
||||
readFileSync(`${localeDirectory}/${fileName}`, 'utf8'),
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
readFileSync(
|
||||
fileURLToPath(
|
||||
new URL('../../../sky_phone/config/locales/de.lua', import.meta.url),
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
]
|
||||
|
||||
for (const appId of PREVIEWABLE_BUILTIN_APP_IDS) {
|
||||
|
||||
@@ -158,6 +158,13 @@ describe('media utilities', () => {
|
||||
expect(mediaErrorKey('profile_photo_required')).toBe(
|
||||
'profile_photo_required',
|
||||
)
|
||||
expect(mediaErrorKey('media_provider_failed')).toBe('media_provider_failed')
|
||||
expect(mediaErrorKey('media_provider_rate_limited')).toBe(
|
||||
'media_provider_rate_limited',
|
||||
)
|
||||
expect(mediaErrorKey('media_provider_unauthorized')).toBe(
|
||||
'media_provider_unauthorized',
|
||||
)
|
||||
expect(mediaErrorKey('private_provider_error')).toBe('request_failed')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -95,6 +95,9 @@ export function mediaErrorKey(error?: string): string {
|
||||
'invalid_import_url',
|
||||
'invalid_upload',
|
||||
'invalid_upload_token',
|
||||
'media_provider_failed',
|
||||
'media_provider_rate_limited',
|
||||
'media_provider_unauthorized',
|
||||
'missing_config',
|
||||
'import_media_not_allowed',
|
||||
'import_media_too_large',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(' ')
|
||||
}
|
||||
|
||||
@@ -2,11 +2,16 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { LaunchablePhoneAppId } from '@/types/apps'
|
||||
import {
|
||||
DEFAULT_PHONE_PREFERENCES,
|
||||
PHONE_SCALE_STEP,
|
||||
PHONE_SETUP_LAST_STEP,
|
||||
parsePhonePreferences,
|
||||
WALLPAPER_IDS,
|
||||
} from './preferences'
|
||||
describe('preferences', () => {
|
||||
it('allows one-percent phone scale adjustments', () => {
|
||||
expect(PHONE_SCALE_STEP).toBe(1)
|
||||
})
|
||||
|
||||
it('starts Setup Assistant for a phone without saved settings', () => {
|
||||
const value = parsePhonePreferences(null)
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ export const WALLPAPER_IDS = [
|
||||
] as const
|
||||
export const PHONE_SCALE_MIN = 75
|
||||
export const PHONE_SCALE_MAX = 150
|
||||
export const PHONE_SCALE_STEP = 5
|
||||
export const PHONE_SCALE_STEP = 1
|
||||
export const PHONE_SETUP_LAST_STEP = 9
|
||||
|
||||
export type AppearanceMode = (typeof APPEARANCE_MODE_IDS)[number]
|
||||
|
||||
@@ -81,6 +81,7 @@ describe('AppStoreApp Sky navigation contract', () => {
|
||||
expect(source).toContain('@click="profileOpened = true"')
|
||||
expect(source).toContain('const installedApps = computed')
|
||||
expect(source).toContain('return !appStore.isInstalled(app.id)')
|
||||
expect(source).toContain('if (!appStore.isAvailable(app.id)) return false')
|
||||
expect(source).toContain(
|
||||
'class="store-account__apps phone-effect--expensive-shadow"',
|
||||
)
|
||||
|
||||
@@ -122,6 +122,7 @@ const catalog = computed(() =>
|
||||
if (!isLaunchablePhoneApp(app) || app.id === 'app-store' || app.adminOnly) {
|
||||
return false
|
||||
}
|
||||
if (!appStore.isAvailable(app.id)) return false
|
||||
|
||||
return !appStore.isInstalled(app.id)
|
||||
}).sort((a, b) => a.gridOrder - b.gridOrder),
|
||||
@@ -129,7 +130,9 @@ const catalog = computed(() =>
|
||||
const installedApps = computed(() =>
|
||||
PHONE_APPS.filter(
|
||||
(app): app is LaunchablePhoneAppDefinition =>
|
||||
isLaunchablePhoneApp(app) && appStore.isInstalled(app.id),
|
||||
isLaunchablePhoneApp(app) &&
|
||||
appStore.isAvailable(app.id) &&
|
||||
appStore.isInstalled(app.id),
|
||||
).sort((a, b) => a.gridOrder - b.gridOrder),
|
||||
)
|
||||
const installedGameCount = computed(
|
||||
@@ -143,6 +146,7 @@ const dailyCandidates = computed(() =>
|
||||
!app.adminOnly &&
|
||||
!isExternalPhoneApp(app) &&
|
||||
app.id !== 'app-store' &&
|
||||
appStore.isAvailable(app.id) &&
|
||||
!DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) &&
|
||||
!appStore.isInstalled(app.id),
|
||||
),
|
||||
|
||||
@@ -134,10 +134,30 @@ async function requestPhoto(): Promise<void> {
|
||||
window.setTimeout(() => void completeDevelopmentCapture(id, 'photo'), 700)
|
||||
return
|
||||
}
|
||||
await nuiCall('media:requestUpload', {
|
||||
console.info('[Sky Phone Media] Camera requested a photo upload.', {
|
||||
correlationId: id,
|
||||
})
|
||||
const response = await nuiCall('media:requestUpload', {
|
||||
correlationId: id,
|
||||
mediaType: 'photo',
|
||||
})
|
||||
if (!response.success) {
|
||||
console.error('[Sky Phone Media] Photo upload request was rejected.', {
|
||||
correlationId: id,
|
||||
error: response.error,
|
||||
})
|
||||
window.postMessage(
|
||||
{
|
||||
data: {
|
||||
correlationId: id,
|
||||
error: response.error ?? 'request_failed',
|
||||
success: false,
|
||||
},
|
||||
type: 'media:uploadResult',
|
||||
},
|
||||
'*',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function startRecording(): void {
|
||||
@@ -374,6 +394,11 @@ function onMessage(event: MessageEvent): void {
|
||||
} else if (message.type === 'media:uploadResult') {
|
||||
const result = message.data as UploadResult
|
||||
if (!result?.correlationId) return
|
||||
console.info('[Sky Phone Media] Camera received an upload result.', {
|
||||
correlationId: result.correlationId,
|
||||
error: result.error,
|
||||
success: result.success,
|
||||
})
|
||||
savingVideo.value = false
|
||||
if (result.success && result.media) {
|
||||
latestMedia.value = result.media
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -367,6 +369,22 @@ describe('VaultX crypto app contracts', () => {
|
||||
expect(testServer).toContain('function advanceCryptoCycle(')
|
||||
})
|
||||
|
||||
it('does not keep market and settlement workers active while disabled', () => {
|
||||
expect(server).toContain('local function start_crypto_schedulers()')
|
||||
expect(server).toMatch(
|
||||
/start_crypto_schedulers\(\)[\s\S]*?if Config\.Crypto\.Enabled ~= true then\s+return/,
|
||||
)
|
||||
expect(server).toContain(
|
||||
'while scheduler_generation == generation and Config.Crypto.Enabled == true do',
|
||||
)
|
||||
expect(server).toContain(
|
||||
'AddEventHandler("sky_phone:configurator:serverUpdated", refresh_crypto_runtime)',
|
||||
)
|
||||
expect(server).not.toMatch(
|
||||
/CreateThread\(function\(\)\s+while true do\s+Wait\(5 \* 60 \* 1000\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it('stores cash in price-scale minor units throughout the ledger', () => {
|
||||
expect(server).toContain(
|
||||
'local ledger_amount = amount * Config.Crypto.PriceScale',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,6 +6,14 @@ const source = readFileSync(
|
||||
new URL('./DarkChatApp.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const server = readFileSync(
|
||||
new URL('../../../../sky_phone/source/server/darkchat.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const config = readFileSync(
|
||||
new URL('../../../../sky_phone/config/config.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('DarkChatApp Sky UI contract', () => {
|
||||
it('uses first-party Sky UI without direct Konsta markup', () => {
|
||||
@@ -37,10 +45,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;/,
|
||||
@@ -123,4 +138,13 @@ describe('DarkChatApp Sky UI contract', () => {
|
||||
/\.dc-inbox-navbar :deep\(\.sky-navbar__title-container > div\)\s*\{[^}]*translateY\(-14px\)/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('cleans expired messages in bounded indexed batches', () => {
|
||||
expect(config).toContain('CleanupBatchSize = 250')
|
||||
expect(server).toContain('Config.DarkChat.CleanupBatchSize')
|
||||
expect(server).toMatch(
|
||||
/DELETE FROM `sky_phone_darkchat_messages`[\s\S]*?ORDER BY `expires_at`, `id`[\s\S]*?LIMIT \?/,
|
||||
)
|
||||
expect(server).toContain(']], { batch_size })')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')"
|
||||
|
||||
@@ -16,6 +16,12 @@ describe('PhoneApp EasyShare contract', () => {
|
||||
it('uses the shared full-width Sky tab bar for phone sections', () => {
|
||||
expect(source).toContain('<sky-tab-bar')
|
||||
expect(source).toContain('<sky-tab-button')
|
||||
expect(source).toContain(
|
||||
'calc(var(--sky-tabbar-height) + var(--sky-safe-area-bottom) + 16px);',
|
||||
)
|
||||
expect(source).not.toMatch(
|
||||
/\.phone-contacts\s*\{[^}]*padding-bottom:\s*20px;/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('uses shared interactive liquid glass surfaces for phone controls', () => {
|
||||
|
||||
@@ -2250,7 +2250,6 @@ onBeforeUnmount(() => {
|
||||
position: relative;
|
||||
padding-top: 6px;
|
||||
padding-right: 24px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.phone-contacts-header {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -116,6 +116,20 @@ describe('voice provider contracts', () => {
|
||||
expect(phoneApp).not.toContain('callMuted = !callMuted')
|
||||
})
|
||||
|
||||
it('supports explicit automatic call-provider discovery on client and server', () => {
|
||||
expect(config).toContain(
|
||||
'VoiceProvider = "pma", -- auto, yaca (alias: yaca-voice)',
|
||||
)
|
||||
expect(clientCalls).toContain('if configured == "auto" then')
|
||||
expect(serverVoice).toContain('if configured == "auto" then')
|
||||
expect(clientCalls).toContain(
|
||||
'for _, candidate in ipairs({ "yaca", "pma", "saltychat" }) do',
|
||||
)
|
||||
expect(serverVoice).toContain(
|
||||
'for _, candidate in ipairs({ "yaca", "pma", "saltychat" }) do',
|
||||
)
|
||||
})
|
||||
|
||||
it('passes Yaca radio volume arguments in the documented order', () => {
|
||||
expect(clientRadio).toContain(
|
||||
'changeRadioChannelVolumeRaw(volume / 100, 1)',
|
||||
|
||||
@@ -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(46)
|
||||
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',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -4724,6 +4726,7 @@ function adminMockBootstrap() {
|
||||
targetSource: 2,
|
||||
},
|
||||
],
|
||||
disabledApps: [],
|
||||
players: [1, 2].map((source) => {
|
||||
const player = adminMockPlayerDetail(source)
|
||||
return {
|
||||
@@ -4738,15 +4741,224 @@ function adminMockBootstrap() {
|
||||
source,
|
||||
}
|
||||
}),
|
||||
stats: { accounts: 24, devices: 31, online: 2 },
|
||||
stats: {
|
||||
accounts: 24,
|
||||
activeDevices: 19,
|
||||
auditEntries: 37,
|
||||
auditToday: 4,
|
||||
callsToday: 18,
|
||||
devices: 31,
|
||||
linkedDevices: 22,
|
||||
messagesToday: 146,
|
||||
online: 2,
|
||||
simDevices: 27,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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>`
|
||||
}
|
||||
@@ -4759,6 +4971,31 @@ app.post('/api/:endpoint', async (request, response, next) => {
|
||||
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,
|
||||
|
||||
+15
-2
@@ -134,7 +134,7 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
|
||||
- `hex_4_inventory`
|
||||
- Native ESX inventory
|
||||
|
||||
`mf-inventory` and `smx-inventory` are supported with ESX. The native ESX and HEX adapters use count-based items and therefore require unique phones and physical SIM cards to be disabled.
|
||||
`mf-inventory` and `smx-inventory` are supported with ESX. The native ESX and HEX adapters use count-based items, so Sky Phone automatically disables unique phones and physical SIM cards while either adapter is active.
|
||||
|
||||
### Voice
|
||||
|
||||
@@ -196,6 +196,7 @@ The files contain clearly separated sections for:
|
||||
| Section | Purpose |
|
||||
| --- | --- |
|
||||
| `Config.Bridge` | Framework, inventory, language, callback timeout, and debug mode |
|
||||
| `Config.CommandPermissions` | Fixed groups for the admin panel, test data, verification commands, and social moderation |
|
||||
| `Config.Phone` | Phone item, movement, unique-device mode, and development command |
|
||||
| `Config.Sim` | Physical or virtual SIM behavior and number formatting |
|
||||
| `Config.Calls` / `Config.Radio` | Voice providers, call behavior, radio limits, and permissions |
|
||||
@@ -211,6 +212,18 @@ The files contain clearly separated sections for:
|
||||
|
||||
Restart `sky_phone` after changing Lua configuration.
|
||||
|
||||
When `Config.PhoneConfigurator.Enabled` is enabled, the generated `source/shared/config_default.lua`
|
||||
provides the shipped SQL baseline. The frontend build recreates it from `config.lua` and the
|
||||
server-only `media.lua`; do not edit the generated snapshot directly.
|
||||
|
||||
`Config.PhoneConfigurator` and `Config.CommandPermissions` remain file-owned and are omitted from
|
||||
the generated default snapshot. Permissions are not displayed in the Phone Configurator and stay
|
||||
authoritative while SQL configuration is enabled. Their stable keys do not change when a command is
|
||||
renamed in the panel. ESX and QBCore use their framework permissions. Qbox checks the configured ACE
|
||||
objects first and then its framework groups, so the standard `permissions.cfg` mapping from
|
||||
`group.admin` to the `admin` ACE works with the shipped `phonepanel` permission list. Restart
|
||||
`sky_phone` after changing fixed permissions.
|
||||
|
||||
### Language
|
||||
|
||||
Available locales:
|
||||
@@ -312,7 +325,7 @@ Do not configure an LB Phone client event or client export. Sky Phone registers
|
||||
|
||||
The server registers `Config.Phone.Item` as usable for every supported inventory adapter: `ox`, `qb`, `lj`, `qs`, `codem`, `core`, `mf`, `smx`, `hex`, and `esx`. Resource startup fails visibly if the selected adapter cannot complete that registration.
|
||||
|
||||
The `hex` and `esx` adapters use ESX's count-based item API. They require both `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` because this API cannot persist per-item phone or physical SIM metadata. `auto` selects `hex` when `hex_4_inventory` is started and otherwise falls back to `esx` on an ESX server when no metadata-capable inventory is detected.
|
||||
The `hex` and `esx` adapters use ESX's count-based item API, which cannot persist per-item phone or physical SIM metadata. Sky Phone automatically forces `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` while either adapter is active. `auto` selects `hex` when `hex_4_inventory` is started and otherwise falls back to `esx` on an ESX server when no metadata-capable inventory is detected.
|
||||
|
||||
### QBCore-style item tables
|
||||
|
||||
|
||||
+84
-13
@@ -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,25 +49,75 @@ Config.Command = "phone"
|
||||
|
||||
Config.Phone = {
|
||||
Item = "phone",
|
||||
Unique = true, -- true: data follows each phone item; false: one persistent phone per character; hex/esx require false
|
||||
Unique = true, -- true: data follows each phone item; false: one persistent phone per character; forced false for metadata-free inventories
|
||||
Keybind = "F1", -- false disables the configurable phone key mapping
|
||||
OpenRequestsPerMinute = 20,
|
||||
AllowMovement = true, -- true: game input stays active while the mobile phone is open
|
||||
HoldToLook = {
|
||||
Enabled = true, -- hold the configured control to hide the cursor and look around; independent of AllowMovement
|
||||
Control = 19, -- INPUT_CHARACTER_WHEEL (Left Alt by default)
|
||||
},
|
||||
DevelopmentCommand = true,
|
||||
DeviceName = "iFruit Phone",
|
||||
}
|
||||
|
||||
-- Server-wide availability for bundled apps. Set an entry to false to hide it
|
||||
-- from every phone, the App Store and per-device app management.
|
||||
Config.Apps = {
|
||||
["app-store"] = true,
|
||||
banking = true,
|
||||
billing = true,
|
||||
calculator = true,
|
||||
calendar = true,
|
||||
camera = true,
|
||||
citymarkt = true,
|
||||
citywarn = true,
|
||||
clock = true,
|
||||
companies = true,
|
||||
crewlink = true,
|
||||
crypto = true,
|
||||
darkchat = true,
|
||||
feather = true,
|
||||
flare = true,
|
||||
fliptok = true,
|
||||
garage = true,
|
||||
health = true,
|
||||
house = true,
|
||||
["local-pages"] = true,
|
||||
mail = true,
|
||||
map = true,
|
||||
memory = true,
|
||||
memos = true,
|
||||
messages = true,
|
||||
minesweeper = true,
|
||||
music = true,
|
||||
["neon-drop"] = true,
|
||||
notes = true,
|
||||
["number-merge"] = true,
|
||||
phone = true,
|
||||
photos = true,
|
||||
picstagram = true,
|
||||
radio = true,
|
||||
settings = true,
|
||||
["sky-flappy"] = true,
|
||||
skyride = true,
|
||||
snake = true,
|
||||
["tower-stack"] = true,
|
||||
weather = true,
|
||||
["weazel-news"] = true,
|
||||
}
|
||||
|
||||
Config.TestData = {
|
||||
Enabled = false, -- development/test servers only; keep disabled in production
|
||||
Command = "phonetestdata",
|
||||
AdminOnly = false, -- enable only on development servers; every run is scoped to the executing player's phone
|
||||
AdminGroups = { "admin", "superadmin" },
|
||||
}
|
||||
|
||||
Config.CustomApps = {
|
||||
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,
|
||||
@@ -63,8 +136,7 @@ Config.Security = {
|
||||
|
||||
Config.AdminPanel = {
|
||||
Enabled = true,
|
||||
Command = "phoneadmin",
|
||||
AdminGroups = { "admin", "superadmin" },
|
||||
Command = "phonepanel",
|
||||
MaximumPlayers = 128,
|
||||
ReadRequestsPerMinute = 60,
|
||||
ActionRequestsPerMinute = 30,
|
||||
@@ -74,12 +146,12 @@ Config.AdminPanel = {
|
||||
}
|
||||
|
||||
Config.Sim = {
|
||||
Enabled = true, -- false: devices receive a persistent random number automatically; hex/esx require false
|
||||
Enabled = true, -- false: devices receive a persistent random number automatically; forced false for metadata-free inventories
|
||||
RegisteredItem = "sky_phone_sim_registered",
|
||||
AnonymousItem = "sky_phone_sim_anonymous",
|
||||
NumberLength = 10,
|
||||
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
|
||||
}
|
||||
|
||||
-- =============================================================================
|
||||
@@ -91,7 +163,7 @@ Config.Speaker = {
|
||||
}
|
||||
|
||||
Config.Calls = {
|
||||
VoiceProvider = "pma", -- yaca (alias: yaca-voice), pma (alias: pma-voice), saltychat (alias: salty)
|
||||
VoiceProvider = "pma", -- auto, yaca (alias: yaca-voice), pma (alias: pma-voice), saltychat (alias: salty)
|
||||
RingSeconds = 30,
|
||||
ContactNameMaxLength = 80,
|
||||
ContactNotesMaxLength = 500,
|
||||
@@ -276,6 +348,7 @@ Config.DarkChat = {
|
||||
VoiceMaxBase64Length = 360000,
|
||||
VoiceWaveformSamples = 48,
|
||||
CleanupIntervalSeconds = 30,
|
||||
CleanupBatchSize = 250,
|
||||
AllowedDisappearTimers = {
|
||||
[0] = true,
|
||||
[-1] = true, -- after reading
|
||||
@@ -473,7 +546,6 @@ Config.FlipTok = {
|
||||
MaxPostMedia = 10,
|
||||
MusicTracks = {},
|
||||
VerifyCommand = "fliptokverify",
|
||||
AdminGroups = { "admin" },
|
||||
ReportWebhookConvar = "sky_phone_fliptok_report_webhook",
|
||||
}
|
||||
|
||||
@@ -495,7 +567,6 @@ Config.Picstagram = {
|
||||
ReportDetailsMaxLength = 500,
|
||||
ReportReasons = { "spam", "harassment", "dangerous", "illegal", "other" },
|
||||
VerifyCommand = "picstagramverify",
|
||||
AdminGroups = { "admin" },
|
||||
}
|
||||
|
||||
Config.Feather = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
Locales["de"] = {
|
||||
CommandDescription = "Öffne dein Handy.",
|
||||
AdminCommand = {
|
||||
CommandDescription = "Öffne das geschützte Handy-Admin-Panel.",
|
||||
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.",
|
||||
@@ -43,6 +43,7 @@ Locales["de"] = {
|
||||
invalid_sim = "Diese SIM-Karte besitzt ungültige Metadaten.",
|
||||
phone_required = "Du benötigst ein Handy in deinem Inventar.",
|
||||
operation_in_progress = "Eine andere Handy-Aktion wird bereits ausgeführt.",
|
||||
rate_limited = "Das Handy wurde zu oft geöffnet. Versuch es gleich erneut.",
|
||||
voice_unavailable = "Der konfigurierte Sprachdienst ist nicht verfügbar.",
|
||||
default = "Das Handy konnte nicht geöffnet werden.",
|
||||
},
|
||||
@@ -203,9 +204,10 @@ Locales["de"] = {
|
||||
},
|
||||
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" },
|
||||
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" },
|
||||
appearance = { eyebrow = "Darstellung", title = "Akzentfarbe", body = "Ändere den Akzent im gesamten Admin-Arbeitsbereich.", colors = { emerald = "Smaragd", blue = "Blau", violet = "Violett", orange = "Orange", red = "Rot" } },
|
||||
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" },
|
||||
statistics = { eyebrow = "Live-Daten", title = "Handy-Statistiken", body = "Aktuelle Nutzung und Gerätestatus, automatisch alle 15 Sekunden aktualisiert.", today = "Aktivität heute", todayBody = "Handy- und Admin-Aktivität seit Mitternacht.", messagesToday = "Nachrichten", callsToday = "Anrufe", auditToday = "Admin-Aktionen", coverage = "Geräteabdeckung", coverageBody = "Aktueller Anteil aller gespeicherten Handys.", linkedDevices = "Account verknüpft", simDevices = "SIM zugewiesen", activeDevices = "In 24 Stunden aktualisiert", ofDevices = "{count} von {total} Geräten" },
|
||||
configurator = { context = "Runtime-Konfiguration", eyebrow = "Systemwerkzeug", sections = "Konfiguration", search = "Einstellungen oder Pfade suchen", configScope = "config.lua", mediaScope = "media.lua", noResults = "Keine passenden Einstellungen", loading = "SQL-Konfiguration wird geladen...", title = "Phone Configurator", body = "Verwalte Handy- und Media-Einstellungen im geschützten Admin-Bereich.", disabledTitle = "SQL-Konfiguration ist nicht aktiv", disabledBody = "Aktiviere den Configurator am Anfang der config.lua und starte sky_phone neu. Bis dahin bleiben die Dateiwerte aktiv und die Bearbeitung gesperrt.", manualSave = "Manuelles Speichern", refreshNotice = "Nichts wird automatisch gespeichert. Der grüne Haken prüft config.lua und media.lua in SQL und aktualisiert die aktive Server-, Client-, Media- und UI-Konfiguration sofort intern.", fieldCount = "{count} Felder", secretConfigured = "Secret gesetzt · Ersatzwert eingeben", invalidValue = "Prüfe den markierten Tabellen- oder Zahlenwert.", saved = "SQL-Konfiguration gespeichert und übernommen.", descriptions = { featureToggle = "Schaltet {name} ein oder aus.", boolean = "Legt fest, ob {name} erlaubt ist.", number = "Legt den Zahlenwert für {name} fest.", text = "Legt den Textwert für {name} fest.", optionalText = "Legt den optionalen Wert für {name} fest; der Schalter deaktiviert ihn.", list = "Verwaltet alle Einträge für {name}.", table = "Bündelt die zusammengehörigen Einstellungen für {name}.", credential = "Speichert den geschützten Zugangsschlüssel für {name}.", url = "Legt die URL oder den Endpunkt für {name} fest.", hosts = "Legt die erlaubten Domains für {name} fest.", milliseconds = "Legt die Zeit für {name} in Millisekunden fest.", seconds = "Legt die Zeit für {name} in Sekunden fest.", rateLimit = "Begrenzt die Aktionen für {name} pro Minute.", byteLimit = "Legt die maximal erlaubte Datengröße für {name} fest.", textLimit = "Legt die maximal erlaubte Textlänge für {name} fest.", distance = "Legt die Distanz in der Spielwelt für {name} fest.", coordinates = "Legt Weltkoordinaten oder Ausrichtung für {name} fest.", gameAsset = "Legt das GTA-Modell oder Prop für {name} fest.", animation = "Legt die Animationsdatei für {name} fest.", access = "Legt Jobs, Gruppen oder Berechtigungsstufen für {name} fest.", integration = "Wählt Framework oder Anbieter für {name} aus.", path = "Legt den Speicher- oder Ressourcenpfad für {name} fest.", color = "Legt die Oberflächenfarbe für {name} fest.", displayText = "Legt den Spielern angezeigten Text für {name} fest.", phoneNumber = "Legt die Telefon- oder Servicenummer für {name} fest.", routing = "Steuert die Verteilung eingehender Anfragen für {name}.", command = "Legt den Chat-Befehl zum Öffnen oder Ausführen von {name} fest.", locale = "Wählt die Sprache für {name} aus.", debug = "Steuert ausführliche Diagnoseausgaben für {name}.", mediaQuality = "Legt Medienqualität oder Lautstärke für {name} fest.", amount = "Legt die maximale oder angezeigte Menge für {name} fest." }, table = { list = "Liste", table = "Schlüsseltabelle", vector = "Vektor", entry = "Eintrag", general = "Allgemein", addRow = "Zeile hinzufügen", addField = "Feld hinzufügen", remove = "Entfernen", emptyList = "Noch keine Zeilen. Füge die erste Zeile mit Plus hinzu.", emptyTable = "Noch keine Felder. Füge unten den ersten Schlüssel hinzu.", keyPlaceholder = "Neuer Schlüssel", convertToList = "Als Liste nutzen", convertToMap = "Als typisierte Schlüsseltabelle nutzen", convertToTable = "Als Schlüsseltabelle nutzen", types = { string = "Text", number = "Zahl", boolean = "Schalter", list = "Liste", table = "Tabelle" } } },
|
||||
players = { eyebrow = "Aktive Sitzungen", title = "Online-Spieler", online = "Jetzt online", empty = "Keine Spieler gefunden", emptyBody = "Passe die Suche an oder aktualisiere die Spielerliste." },
|
||||
search = { players = "Name, ID, Job oder Nummer suchen", apps = "Apps suchen", clear = "Suche leeren" },
|
||||
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" },
|
||||
@@ -218,9 +220,9 @@ Locales["de"] = {
|
||||
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" } },
|
||||
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-Ä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 = "Das Handy wurde zwischenzeitlich geändert. Aktualisiere und versuche es erneut.", 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." },
|
||||
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 = {
|
||||
@@ -635,7 +637,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",
|
||||
@@ -651,7 +653,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 = {
|
||||
@@ -1098,7 +1100,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 3–20 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 8–72 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 3–20 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 8–72 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",
|
||||
@@ -1236,7 +1238,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",
|
||||
@@ -1270,7 +1272,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.",
|
||||
@@ -1455,7 +1457,9 @@ Locales["de"] = {
|
||||
errors = {
|
||||
cancelled = "Die Aufnahme ist abgesagt.", capture_failed = "Die Spielansicht kann nicht erfasst werden.",
|
||||
invalid_media_type = "Der hochgeladene Medientyp ist ungültig.", invalid_upload = "Der Upload konnte nicht überprüft werden.",
|
||||
invalid_upload_token = "Die Upload-Sitzung ist nicht mehr gültig.", missing_config = "Kamera-Uploads sind nicht konfiguriert.",
|
||||
invalid_upload_token = "Die Upload-Sitzung ist nicht mehr gültig.", media_provider_failed = "Der Kamera-Upload-Dienst ist nicht verfügbar.",
|
||||
media_provider_rate_limited = "Der Kamera-Upload-Dienst ist ausgelastet. Versuch es gleich erneut.", media_provider_unauthorized = "Der konfigurierte FiveManage API-Schlüssel wurde abgelehnt.",
|
||||
missing_config = "Kamera-Uploads sind nicht konfiguriert.",
|
||||
microphone_unavailable = "Erlaube den Mikrofonzugriff oder schalte das Mikrofon vor der Aufnahme stumm.",
|
||||
not_found = "Das Medienobjekt existiert nicht mehr.", owner_changed = "Das aktive Telefonkonto hat sich beim Upload geändert.",
|
||||
operation_in_progress = "Eine weitere Medienoperation ist bereits im Gange.",
|
||||
@@ -2134,3 +2138,41 @@ 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",
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
Locales["en"] = {
|
||||
CommandDescription = "Open your phone.",
|
||||
AdminCommand = {
|
||||
CommandDescription = "Open the protected phone admin panel.",
|
||||
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.",
|
||||
@@ -43,6 +43,7 @@ Locales["en"] = {
|
||||
invalid_sim = "This SIM card has invalid metadata.",
|
||||
phone_required = "You need a phone in your inventory.",
|
||||
operation_in_progress = "Another phone operation is already in progress.",
|
||||
rate_limited = "The phone was opened too often. Try again in a moment.",
|
||||
voice_unavailable = "The configured phone voice service is unavailable.",
|
||||
default = "The phone could not be opened.",
|
||||
},
|
||||
@@ -203,9 +204,10 @@ Locales["en"] = {
|
||||
},
|
||||
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" },
|
||||
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" },
|
||||
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" } },
|
||||
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" },
|
||||
statistics = { eyebrow = "Live data", title = "Phone statistics", body = "Current usage and device status, refreshed every 15 seconds.", today = "Activity today", todayBody = "Phone and admin activity since midnight.", messagesToday = "Messages", callsToday = "Calls", auditToday = "Admin actions", coverage = "Device coverage", coverageBody = "Current share of all stored phones.", linkedDevices = "Account linked", simDevices = "SIM assigned", activeDevices = "Updated in 24 hours", ofDevices = "{count} of {total} devices" },
|
||||
configurator = { context = "Runtime configuration", eyebrow = "System tool", sections = "Configuration", search = "Search settings or paths", configScope = "config.lua", mediaScope = "media.lua", noResults = "No matching settings", loading = "Loading SQL configuration...", title = "Phone configurator", body = "Manage phone and media settings from the protected admin workspace.", disabledTitle = "SQL configuration is not active", disabledBody = "Enable the configurator at the beginning of config.lua and restart sky_phone. Until then, file values remain active and editing is locked.", manualSave = "Manual save", refreshNotice = "Nothing is written automatically. The green check verifies config.lua and media.lua in SQL and refreshes the active server, client, media and UI configuration immediately.", fieldCount = "{count} fields", secretConfigured = "Secret configured · enter a replacement", invalidValue = "Check the highlighted table or number value.", saved = "SQL configuration saved and applied.", descriptions = { featureToggle = "Turns {name} on or off.", boolean = "Controls whether {name} is allowed.", number = "Sets the numeric value for {name}.", text = "Sets the text value used for {name}.", optionalText = "Sets the optional value for {name}; switch it off to disable it.", list = "Manages all entries used for {name}.", table = "Groups the related settings for {name}.", credential = "Stores the protected credential used by {name}.", url = "Sets the URL or endpoint used by {name}.", hosts = "Defines which domains are allowed for {name}.", milliseconds = "Sets the timing for {name} in milliseconds.", seconds = "Sets the timing for {name} in seconds.", rateLimit = "Limits how many {name} actions are allowed per minute.", byteLimit = "Sets the maximum data size allowed for {name}.", textLimit = "Sets the maximum text length allowed for {name}.", distance = "Sets the world distance used for {name}.", coordinates = "Sets the world coordinates or orientation for {name}.", gameAsset = "Sets the GTA model or prop used for {name}.", animation = "Sets the animation asset used for {name}.", access = "Defines the jobs, groups or permission level for {name}.", integration = "Selects the connected framework or provider for {name}.", path = "Sets the storage or resource path used for {name}.", color = "Sets the interface color used for {name}.", displayText = "Sets the text shown to players for {name}.", phoneNumber = "Sets the phone or service number used for {name}.", routing = "Controls how incoming requests are routed for {name}.", command = "Sets the chat command used to open or run {name}.", locale = "Selects the language used for {name}.", debug = "Controls detailed diagnostic output for {name}.", mediaQuality = "Sets the media quality or volume used for {name}.", amount = "Sets the maximum or displayed amount for {name}." }, table = { list = "List", table = "Key table", vector = "Vector", entry = "Entry", general = "General", addRow = "Add row", addField = "Add field", remove = "Remove", emptyList = "No rows yet. Add the first row with plus.", emptyTable = "No fields yet. Add the first key below.", keyPlaceholder = "New key", convertToList = "Use list", convertToMap = "Use typed key table", convertToTable = "Use key table", types = { string = "Text", number = "Number", boolean = "Switch", list = "List", table = "Table" } } },
|
||||
players = { eyebrow = "Active sessions", title = "Online players", online = "Online now", empty = "No players found", emptyBody = "Adjust the search or refresh the live player list." },
|
||||
search = { players = "Search name, ID, job, or number", apps = "Search apps", clear = "Clear search" },
|
||||
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" },
|
||||
@@ -218,9 +220,9 @@ Locales["en"] = {
|
||||
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" } },
|
||||
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 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 phone changed in the meantime. Refresh and try again.", 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." },
|
||||
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 = {
|
||||
@@ -635,7 +637,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",
|
||||
@@ -1098,7 +1100,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 3–20 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 8–72 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 3–20 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 8–72 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",
|
||||
@@ -1236,7 +1238,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",
|
||||
@@ -1270,7 +1272,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.",
|
||||
@@ -1455,7 +1457,9 @@ Locales["en"] = {
|
||||
errors = {
|
||||
cancelled = "Capture cancelled.", capture_failed = "Unable to capture the game view.",
|
||||
invalid_media_type = "The uploaded media type is invalid.", invalid_upload = "The upload could not be verified.",
|
||||
invalid_upload_token = "The upload session is no longer valid.", missing_config = "Camera uploads are not configured.",
|
||||
invalid_upload_token = "The upload session is no longer valid.", media_provider_failed = "The camera upload service is unavailable.",
|
||||
media_provider_rate_limited = "The camera upload service is busy. Try again shortly.", media_provider_unauthorized = "The configured FiveManage API key was rejected.",
|
||||
missing_config = "Camera uploads are not configured.",
|
||||
microphone_unavailable = "Allow microphone access or mute the microphone before recording.",
|
||||
not_found = "The media item no longer exists.", owner_changed = "The active phone account changed during upload.",
|
||||
operation_in_progress = "Another media operation is already in progress.",
|
||||
@@ -2134,3 +2138,41 @@ 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",
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -6,7 +6,7 @@ use_experimental_fxv2_oal 'yes'
|
||||
|
||||
author 'Sky-Systems'
|
||||
description 'Sky Phone'
|
||||
version '0.1.0'
|
||||
version '0.2.8'
|
||||
|
||||
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',
|
||||
@@ -107,6 +115,7 @@ server_scripts {
|
||||
'source/server/notifications.lua',
|
||||
'source/shared/public_api.lua',
|
||||
'source/server/public_api.lua',
|
||||
'source/server/media_provider_config.lua',
|
||||
'source/server/media_import.lua',
|
||||
'source/server/media_import/fivemanage.lua',
|
||||
'source/server/media_import/manifest.lua',
|
||||
@@ -150,6 +159,7 @@ files {
|
||||
'source/html/index.html',
|
||||
'source/html/assets/**',
|
||||
'source/html/img/**',
|
||||
'source/html/sounds/**',
|
||||
'config/music/**',
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,15 @@ local provider_aliases = {
|
||||
|
||||
local function resolve_provider()
|
||||
local configured = tostring(Config.Calls.VoiceProvider or "")
|
||||
if configured == "auto" then
|
||||
for _, candidate in ipairs({ "yaca", "pma", "saltychat" }) do
|
||||
if GetResourceState(provider_resources[candidate]) == "started" then
|
||||
return candidate
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local selected = provider_aliases[configured] or configured
|
||||
local resource_name = provider_resources[selected]
|
||||
if resource_name and GetResourceState(resource_name) == "started" then
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 _, slot_id in ipairs(inventory:GetSlotIdsWithItem(source, item_name, metadata) or {}) do
|
||||
local normalized = Bridge.Inventory.GetSlot(source, slot_id)
|
||||
if normalized and normalized.name == item_name
|
||||
and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then
|
||||
matches[#matches + 1] = normalized
|
||||
end
|
||||
end
|
||||
return matches
|
||||
end
|
||||
|
||||
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
|
||||
local slot = Bridge.Inventory.GetSlot(source, slot_id)
|
||||
if not slot then
|
||||
return false
|
||||
end
|
||||
|
||||
local requested_metadata = type(metadata) == "table" and metadata or {}
|
||||
if inventory:SetItemMetadata(source, slot.slot, requested_metadata) == false then
|
||||
return false
|
||||
end
|
||||
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
|
||||
return updated and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata) or false
|
||||
end
|
||||
|
||||
function Bridge.Inventory.CanCarryItem(source, item_name, count)
|
||||
return inventory:CanCarryItem(source, item_name, count) == true
|
||||
end
|
||||
|
||||
function Bridge.Inventory.AddItem(source, item_name, count, slot, metadata)
|
||||
if not Bridge.Inventory.CanCarryItem(source, item_name, count, metadata) then
|
||||
return false
|
||||
end
|
||||
return inventory:AddItem(source, item_name, count, metadata or {}, slot) == true
|
||||
end
|
||||
|
||||
function Bridge.Inventory.RemoveItem(source, item_name, count, slot, metadata)
|
||||
if metadata and not slot then
|
||||
local slots = Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
|
||||
slot = slots[1] and slots[1].slot or nil
|
||||
if not slot then
|
||||
return 0
|
||||
end
|
||||
end
|
||||
return inventory:RemoveItem(source, item_name, count, metadata, slot) == true and count or 0
|
||||
end
|
||||
|
||||
function Bridge.Inventory.RegisterUsableItem(item_name, callback)
|
||||
return Bridge.Framework.RegisterUsableItem(item_name, function(source, ...)
|
||||
callback(source, normalize(Bridge.Inventory.ResolveUsableItem(...)))
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,88 @@
|
||||
if Bridge.Inventory.Name ~= "origen" then
|
||||
return
|
||||
end
|
||||
|
||||
local inventory = exports.origen_inventory
|
||||
|
||||
local function normalize(item)
|
||||
local normalized = Bridge.Inventory.NormalizeItem(item)
|
||||
if not normalized or not normalized.name or normalized.amount < 1 then
|
||||
return nil
|
||||
end
|
||||
return normalized
|
||||
end
|
||||
|
||||
local function get_inventory(source)
|
||||
local player_inventory = inventory:GetInventory(source)
|
||||
return type(player_inventory) == "table" and type(player_inventory.inventory) == "table"
|
||||
and player_inventory.inventory or {}
|
||||
end
|
||||
|
||||
function Bridge.Inventory.GetResourceName()
|
||||
return "origen_inventory"
|
||||
end
|
||||
|
||||
function Bridge.Inventory.GetSlot(source, slot_id)
|
||||
for index, item in pairs(get_inventory(source)) do
|
||||
local normalized = normalize(item)
|
||||
local item_slot = normalized and (normalized.slot or tonumber(index)) or nil
|
||||
if tostring(item_slot) == tostring(slot_id) then
|
||||
normalized.slot = item_slot
|
||||
return normalized
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
|
||||
local matches = {}
|
||||
for index, item in pairs(get_inventory(source)) do
|
||||
local normalized = normalize(item)
|
||||
if normalized and normalized.name == item_name
|
||||
and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then
|
||||
normalized.slot = normalized.slot or tonumber(index) or index
|
||||
matches[#matches + 1] = normalized
|
||||
end
|
||||
end
|
||||
return matches
|
||||
end
|
||||
|
||||
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
|
||||
local slot = Bridge.Inventory.GetSlot(source, slot_id)
|
||||
if not slot then
|
||||
return false
|
||||
end
|
||||
|
||||
local requested_metadata = type(metadata) == "table" and metadata or {}
|
||||
inventory:setMetadata(source, slot.slot, requested_metadata)
|
||||
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
|
||||
return updated and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata) or false
|
||||
end
|
||||
|
||||
function Bridge.Inventory.CanCarryItem(source, item_name, count)
|
||||
return inventory:canCarryItem(source, item_name, count) == true
|
||||
end
|
||||
|
||||
function Bridge.Inventory.AddItem(source, item_name, count, slot, metadata)
|
||||
if not Bridge.Inventory.CanCarryItem(source, item_name, count, metadata) then
|
||||
return false
|
||||
end
|
||||
return inventory:addItem(source, item_name, count, metadata or {}, slot, false) == true
|
||||
end
|
||||
|
||||
function Bridge.Inventory.RemoveItem(source, item_name, count, slot, metadata)
|
||||
if metadata and not slot then
|
||||
local slots = Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
|
||||
slot = slots[1] and slots[1].slot or nil
|
||||
if not slot then
|
||||
return 0
|
||||
end
|
||||
end
|
||||
return inventory:removeItem(source, item_name, count, nil, slot, false) == true and count or 0
|
||||
end
|
||||
|
||||
function Bridge.Inventory.RegisterUsableItem(item_name, callback)
|
||||
return Bridge.Framework.RegisterUsableItem(item_name, function(source, ...)
|
||||
callback(source, normalize(Bridge.Inventory.ResolveUsableItem(...)))
|
||||
end)
|
||||
end
|
||||
@@ -44,8 +44,10 @@ function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
|
||||
return false
|
||||
end
|
||||
|
||||
inventory:SetMetadata(source, tonumber(slot_id), metadata or {})
|
||||
return true
|
||||
local requested_metadata = type(metadata) == "table" and metadata or {}
|
||||
inventory:SetMetadata(source, tonumber(slot_id), requested_metadata)
|
||||
local updated = Bridge.Inventory.GetSlot(source, slot_id)
|
||||
return updated and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata) or false
|
||||
end
|
||||
|
||||
function Bridge.Inventory.CanCarryItem(source, item_name, count, metadata)
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
if Bridge.Inventory.Name ~= "ps" then
|
||||
return
|
||||
end
|
||||
|
||||
if Bridge.Framework.GetName() ~= "qb" then
|
||||
error("[sky_phone] ps-inventory is only supported with QBCore.")
|
||||
end
|
||||
|
||||
local inventory = exports["ps-inventory"]
|
||||
local QBCore = exports["qb-core"]:GetCoreObject()
|
||||
|
||||
local function normalize(item, fallback_slot)
|
||||
local normalized = Bridge.Inventory.NormalizeItem(item, "info", fallback_slot)
|
||||
if not normalized or not normalized.name or normalized.amount < 1 then
|
||||
return nil
|
||||
end
|
||||
return normalized
|
||||
end
|
||||
|
||||
local function get_inventory(source)
|
||||
local player = QBCore.Functions.GetPlayer(tonumber(source))
|
||||
return player and player.PlayerData.items or {}
|
||||
end
|
||||
|
||||
local function restore_slot(source, slot, metadata)
|
||||
local restored = inventory:AddItem(source, slot.name, slot.amount, slot.slot, metadata)
|
||||
if not restored then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] ps-inventory could not restore item '%s' in slot %s after a metadata update failed.",
|
||||
tostring(slot.name),
|
||||
tostring(slot.slot)
|
||||
)
|
||||
end
|
||||
return restored == true
|
||||
end
|
||||
|
||||
function Bridge.Inventory.GetResourceName()
|
||||
return "ps-inventory"
|
||||
end
|
||||
|
||||
function Bridge.Inventory.GetSlot(source, slot_id)
|
||||
local numeric_slot = tonumber(slot_id)
|
||||
return numeric_slot and normalize(inventory:GetItemBySlot(source, numeric_slot), numeric_slot) or nil
|
||||
end
|
||||
|
||||
function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
|
||||
local matches = {}
|
||||
for index, item in pairs(get_inventory(source)) do
|
||||
local normalized = normalize(item, tonumber(index) or index)
|
||||
if normalized and normalized.name == item_name
|
||||
and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then
|
||||
normalized.slot = normalized.slot or tonumber(index) or index
|
||||
matches[#matches + 1] = normalized
|
||||
end
|
||||
end
|
||||
return matches
|
||||
end
|
||||
|
||||
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
|
||||
local slot = Bridge.Inventory.GetSlot(source, slot_id)
|
||||
if not slot or slot.amount < 1 then
|
||||
return false
|
||||
end
|
||||
|
||||
local previous_metadata = slot.metadata
|
||||
local requested_metadata = type(metadata) == "table" and metadata or {}
|
||||
if inventory:RemoveItem(source, slot.name, slot.amount, slot.slot) ~= true then
|
||||
return false
|
||||
end
|
||||
if inventory:AddItem(source, slot.name, slot.amount, slot.slot, requested_metadata) ~= true then
|
||||
restore_slot(source, slot, previous_metadata)
|
||||
return false
|
||||
end
|
||||
|
||||
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
|
||||
if updated and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata) then
|
||||
return true
|
||||
end
|
||||
|
||||
inventory:RemoveItem(source, slot.name, slot.amount, slot.slot)
|
||||
restore_slot(source, slot, previous_metadata)
|
||||
return false
|
||||
end
|
||||
|
||||
function Bridge.Inventory.CanCarryItem()
|
||||
return true
|
||||
end
|
||||
|
||||
function Bridge.Inventory.AddItem(source, item_name, count, slot, metadata)
|
||||
return inventory:AddItem(source, item_name, count, slot, metadata or {}) == true
|
||||
end
|
||||
|
||||
function Bridge.Inventory.RemoveItem(source, item_name, count, slot, metadata)
|
||||
if metadata and not slot then
|
||||
local slots = Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
|
||||
slot = slots[1] and slots[1].slot or nil
|
||||
if not slot then
|
||||
return 0
|
||||
end
|
||||
end
|
||||
return inventory:RemoveItem(source, item_name, count, slot) == true and count or 0
|
||||
end
|
||||
|
||||
function Bridge.Inventory.RegisterUsableItem(item_name, callback)
|
||||
return Bridge.Framework.RegisterUsableItem(item_name, function(source, ...)
|
||||
callback(source, normalize(Bridge.Inventory.ResolveUsableItem(...)))
|
||||
end)
|
||||
end
|
||||
@@ -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
|
||||
@@ -41,6 +41,15 @@ end
|
||||
|
||||
local function resolve_call_provider()
|
||||
local configured = tostring(Config.Calls.VoiceProvider or "")
|
||||
if configured == "auto" then
|
||||
for _, candidate in ipairs({ "yaca", "pma", "saltychat" }) do
|
||||
if GetResourceState(call_provider_resources[candidate]) == "started" then
|
||||
return candidate
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local selected = call_provider_aliases[configured] or configured
|
||||
local resource_name = call_provider_resources[selected]
|
||||
if resource_name and GetResourceState(resource_name) == "started" then
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -448,6 +448,13 @@ RegisterNUICallback("media:requestUpload", function(data, cb)
|
||||
cb({ success = false, error = "invalid_request" })
|
||||
return
|
||||
end
|
||||
Bridge.Debug(
|
||||
"debug",
|
||||
"[sky_phone][media-debug] NUI requested an upload (correlation=%s, type=%s).",
|
||||
tostring(data.correlationId),
|
||||
tostring(data.mediaType),
|
||||
{ notice = true }
|
||||
)
|
||||
TriggerServerEvent("sky_phone:media:request-upload", data)
|
||||
cb({ success = true })
|
||||
end)
|
||||
@@ -457,6 +464,15 @@ RegisterNUICallback("media:completeUpload", function(data, cb)
|
||||
cb({ success = false, error = "invalid_request" })
|
||||
return
|
||||
end
|
||||
Bridge.Debug(
|
||||
"debug",
|
||||
"[sky_phone][media-debug] NUI completed the provider upload (correlation=%s, remote-id=%s, url=%s, original-url=%s).",
|
||||
tostring(data.correlationId),
|
||||
type(data.remoteId) == "string" and "present" or "missing",
|
||||
type(data.url) == "string" and "present" or "missing",
|
||||
type(data.originalUrl) == "string" and "present" or "missing",
|
||||
{ notice = true }
|
||||
)
|
||||
TriggerServerEvent("sky_phone:media:complete-upload", data)
|
||||
cb({ success = true })
|
||||
end)
|
||||
@@ -475,6 +491,16 @@ RegisterNUICallback("media:failUpload", function(data, cb)
|
||||
cb({ success = false, error = "invalid_request" })
|
||||
return
|
||||
end
|
||||
local debug_message = tostring(data.debugMessage or "unknown"):gsub("[\r\n]", " "):sub(1, 240)
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone][media-debug] NUI reported an upload failure (correlation=%s, error=%s, stage=%s, status=%s, detail=%s).",
|
||||
tostring(data.correlationId),
|
||||
tostring(data.error),
|
||||
tostring(data.debugStage),
|
||||
tostring(data.debugStatus),
|
||||
debug_message
|
||||
)
|
||||
TriggerServerEvent("sky_phone:media:fail-upload", data)
|
||||
cb({ success = true })
|
||||
end)
|
||||
@@ -534,10 +560,26 @@ RegisterNUICallback("memos:failUpload", function(data, cb)
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:media:upload-ready", function(data)
|
||||
Bridge.Debug(
|
||||
"debug",
|
||||
"[sky_phone][media-debug] Client received upload-ready (correlation=%s, type=%s, presigned-url=%s).",
|
||||
tostring(type(data) == "table" and data.correlationId),
|
||||
tostring(type(data) == "table" and data.mediaType),
|
||||
type(data) == "table" and type(data.presignedUrl) == "string" and "present" or "missing",
|
||||
{ notice = true }
|
||||
)
|
||||
SendNUIMessage({ type = "media:uploadReady", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:media:upload-result", function(data)
|
||||
Bridge.Debug(
|
||||
"debug",
|
||||
"[sky_phone][media-debug] Client received upload-result (correlation=%s, success=%s, error=%s).",
|
||||
tostring(type(data) == "table" and data.correlationId),
|
||||
tostring(type(data) == "table" and data.success),
|
||||
tostring(type(data) == "table" and data.error),
|
||||
{ notice = true }
|
||||
)
|
||||
SendNUIMessage({ type = "media:uploadResult", data = data })
|
||||
end)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,10 +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,
|
||||
@@ -14,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,
|
||||
@@ -23,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)
|
||||
@@ -61,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,
|
||||
@@ -83,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)
|
||||
@@ -100,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
|
||||
|
||||
@@ -117,6 +162,7 @@ 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()
|
||||
@@ -143,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
|
||||
|
||||
@@ -170,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
|
||||
@@ -184,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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -9,6 +9,16 @@ 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
|
||||
@@ -65,6 +75,21 @@ Bridge.Debug("debug", "[sky_phone] Client script initialized.", { always = true
|
||||
|
||||
local locale, locale_name = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
|
||||
|
||||
local function apply_disabled_apps(payload)
|
||||
if type(payload) ~= "table" then
|
||||
return
|
||||
end
|
||||
|
||||
local disabled = {}
|
||||
for app_id, enabled in pairs(Config.Apps or {}) do
|
||||
if type(app_id) == "string" and enabled == false then
|
||||
disabled[#disabled + 1] = app_id
|
||||
end
|
||||
end
|
||||
table.sort(disabled)
|
||||
payload.disabledApps = disabled
|
||||
end
|
||||
|
||||
local function send_admin_panel_open()
|
||||
SendNUIMessage({
|
||||
type = "admin:open",
|
||||
@@ -103,6 +128,40 @@ 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
|
||||
apply_disabled_apps(device_payload)
|
||||
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
|
||||
@@ -132,6 +191,22 @@ local function close_phone(close_device_session)
|
||||
end
|
||||
end
|
||||
|
||||
local function request_phone_open(callback_name)
|
||||
if is_open or open_requested then
|
||||
return true
|
||||
end
|
||||
|
||||
open_requested = true
|
||||
local result = Bridge.Callbacks.Trigger(callback_name, {})
|
||||
if type(result) == "table" and result.success == true then
|
||||
return true
|
||||
end
|
||||
|
||||
open_requested = false
|
||||
open_without_focus = false
|
||||
return false
|
||||
end
|
||||
|
||||
local function toggle_phone(open, no_focus)
|
||||
if open ~= nil and type(open) ~= "boolean" then
|
||||
Bridge.Debug("error", "[sky_phone] Rejected invalid phone toggle state.")
|
||||
@@ -147,6 +222,9 @@ local function toggle_phone(open, no_focus)
|
||||
should_open = not (is_open or open_requested)
|
||||
end
|
||||
if not should_open then
|
||||
if open == nil and open_requested and not is_open then
|
||||
return true
|
||||
end
|
||||
close_phone()
|
||||
return true
|
||||
end
|
||||
@@ -159,12 +237,7 @@ local function toggle_phone(open, no_focus)
|
||||
return true
|
||||
end
|
||||
|
||||
local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
|
||||
if type(result) ~= "table" or result.success ~= true then
|
||||
open_without_focus = false
|
||||
return false
|
||||
end
|
||||
return true
|
||||
return request_phone_open("sky_phone:device:open-request")
|
||||
end
|
||||
|
||||
SkyPhoneClient.Toggle = toggle_phone
|
||||
@@ -173,26 +246,60 @@ 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)
|
||||
end
|
||||
|
||||
RegisterCommand("sky_phone_toggle", function()
|
||||
if is_open or open_requested then
|
||||
local function run_development_command()
|
||||
if is_open then
|
||||
close_phone()
|
||||
return
|
||||
end
|
||||
if open_requested then
|
||||
return
|
||||
end
|
||||
|
||||
open_without_focus = false
|
||||
Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
|
||||
end, false)
|
||||
request_phone_open("sky_phone:device:development-open")
|
||||
end
|
||||
|
||||
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 then
|
||||
close_phone()
|
||||
return
|
||||
end
|
||||
if open_requested then
|
||||
return
|
||||
end
|
||||
|
||||
open_without_focus = false
|
||||
request_phone_open("sky_phone:device:open-request")
|
||||
end
|
||||
|
||||
RegisterCommand("sky_phone_live_activity_open", function()
|
||||
if not live_activity_active or is_open or open_requested then
|
||||
@@ -200,12 +307,41 @@ RegisterCommand("sky_phone_live_activity_open", function()
|
||||
end
|
||||
|
||||
open_home_requested = true
|
||||
local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
|
||||
if not result or not result.success then
|
||||
if not request_phone_open("sky_phone:device:open-request") then
|
||||
open_home_requested = false
|
||||
end
|
||||
end, false)
|
||||
|
||||
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,
|
||||
@@ -213,14 +349,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
|
||||
@@ -353,6 +494,10 @@ end)
|
||||
RegisterNetEvent("sky_phone:device:open", function(data)
|
||||
if type(data) ~= "table" or type(data.device) ~= "table" or type(data.device.imei) ~= "string" then
|
||||
Bridge.Debug("error", "[sky_phone] Rejected invalid device open data.")
|
||||
if not is_open then
|
||||
open_requested = false
|
||||
open_without_focus = false
|
||||
end
|
||||
return
|
||||
end
|
||||
Bridge.Debug(
|
||||
@@ -362,6 +507,7 @@ RegisterNetEvent("sky_phone:device:open", function(data)
|
||||
tostring(data.account ~= nil),
|
||||
{ always = true }
|
||||
)
|
||||
apply_disabled_apps(data)
|
||||
device_payload = data
|
||||
update_equipped_phone_number(data)
|
||||
open_requested = true
|
||||
@@ -378,6 +524,7 @@ RegisterNetEvent("sky_phone:device:updated", function(data)
|
||||
Bridge.Debug("error", "[sky_phone] Rejected invalid device update data.")
|
||||
return
|
||||
end
|
||||
apply_disabled_apps(data)
|
||||
device_payload = data
|
||||
update_equipped_phone_number(data)
|
||||
SendNUIMessage({ type = "device:updated", data = data })
|
||||
@@ -393,8 +540,10 @@ RegisterNetEvent("sky_phone:device:invalidated", function()
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:device:error", function(error_code)
|
||||
if not is_open and not open_requested then
|
||||
if not is_open then
|
||||
open_requested = false
|
||||
open_without_focus = false
|
||||
open_home_requested = false
|
||||
end
|
||||
Bridge.Debug(
|
||||
"debug",
|
||||
@@ -407,19 +556,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
|
||||
if Config.AdminPanel.Enabled then
|
||||
TriggerEvent(
|
||||
"chat:addSuggestion",
|
||||
"/" .. Config.AdminPanel.Command,
|
||||
locale.AdminCommand.CommandDescription
|
||||
)
|
||||
end
|
||||
refresh_admin_command_suggestion()
|
||||
end)
|
||||
|
||||
AddEventHandler("onResourceStop", function(resource_name)
|
||||
@@ -437,13 +574,13 @@ AddEventHandler("onResourceStop", function(resource_name)
|
||||
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 Config.AdminPanel.Enabled then
|
||||
TriggerEvent("chat:removeSuggestion", "/" .. Config.AdminPanel.Command)
|
||||
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
|
||||
|
||||
@@ -2,7 +2,7 @@ local callback_groups = {
|
||||
account = [[login register logout devices remove-device]],
|
||||
admin = [[
|
||||
bootstrap player save-apps reveal-password activity
|
||||
reset-passcode change-number factory-reset
|
||||
reset-passcode change-number factory-reset configurator save-configurator
|
||||
]],
|
||||
banking = [[overview transfer]],
|
||||
billing = [[overview list detail markRead pay dispute]],
|
||||
|
||||
@@ -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)
|
||||
@@ -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.")
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -98,7 +98,7 @@ end
|
||||
|
||||
local function require_admin(source, operation, maximum)
|
||||
if not Config.AdminPanel.Enabled
|
||||
or not Bridge.Framework.HasAdminGroup(source, Config.AdminPanel.AdminGroups)
|
||||
or not Bridge.Framework.HasPermission(source, "phonepanel")
|
||||
then
|
||||
Bridge.Debug("warn", "[sky_phone] Rejected admin panel access from source %s.", tostring(source))
|
||||
return nil, { success = false, error = "not_authorized" }
|
||||
@@ -109,11 +109,9 @@ local function require_admin(source, operation, maximum)
|
||||
return true
|
||||
end
|
||||
|
||||
if type(Config.AdminPanel.Command) ~= "string" or Config.AdminPanel.Command == "" then
|
||||
error("[sky_phone] Config.AdminPanel.Command must be a non-empty command name.")
|
||||
end
|
||||
local active_admin_command
|
||||
|
||||
RegisterCommand(Config.AdminPanel.Command, function(command_source)
|
||||
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.")
|
||||
@@ -123,7 +121,7 @@ RegisterCommand(Config.AdminPanel.Command, function(command_source)
|
||||
TriggerClientEvent("sky_phone:admin:command-error", player_source, "disabled")
|
||||
return
|
||||
end
|
||||
if not Bridge.Framework.HasAdminGroup(player_source, Config.AdminPanel.AdminGroups) then
|
||||
if not Bridge.Framework.HasPermission(player_source, "phonepanel") then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] Rejected admin panel command from source %s.",
|
||||
@@ -134,7 +132,29 @@ RegisterCommand(Config.AdminPanel.Command, function(command_source)
|
||||
end
|
||||
|
||||
TriggerClientEvent("sky_phone:admin:launch", player_source)
|
||||
end, false)
|
||||
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)
|
||||
@@ -353,6 +373,9 @@ end
|
||||
|
||||
local function app_metadata(app_id)
|
||||
if BUILTIN_APPS[app_id] then
|
||||
if not SkyPhone.IsAppEnabled(app_id) then
|
||||
return nil
|
||||
end
|
||||
return {
|
||||
defaultInstalled = DEFAULT_INSTALLED_APPS[app_id] == true,
|
||||
removable = PROTECTED_APPS[app_id] ~= true,
|
||||
@@ -443,16 +466,36 @@ Bridge.Callbacks.Register("sky_phone:admin:bootstrap", function(source)
|
||||
local totals = Bridge.Database.Query([[
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM `sky_phone_devices`) AS `devices`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_accounts`) AS `accounts`
|
||||
(SELECT COUNT(*) FROM `sky_phone_accounts`) AS `accounts`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_devices` WHERE `account_id` IS NOT NULL) AS `linked_devices`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_devices` WHERE `sim_id` IS NOT NULL) AS `sim_devices`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_devices`
|
||||
WHERE `updated_at` >= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 24 HOUR)) AS `active_devices`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_sms_messages`
|
||||
WHERE `created_at` >= CURRENT_DATE) AS `messages_today`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_calls`
|
||||
WHERE `started_at` >= CURRENT_DATE) AS `calls_today`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_admin_audit`) AS `audit_entries`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_admin_audit`
|
||||
WHERE `created_at` >= CURRENT_DATE) AS `audit_today`
|
||||
]], {})
|
||||
local stats = totals[1] or {}
|
||||
return {
|
||||
success = true,
|
||||
data = {
|
||||
players = players,
|
||||
disabledApps = SkyPhone.GetDisabledApps(),
|
||||
stats = {
|
||||
online = #players,
|
||||
devices = tonumber(totals[1] and totals[1].devices) or 0,
|
||||
accounts = tonumber(totals[1] and totals[1].accounts) or 0,
|
||||
devices = tonumber(stats.devices) or 0,
|
||||
accounts = tonumber(stats.accounts) or 0,
|
||||
linkedDevices = tonumber(stats.linked_devices) or 0,
|
||||
simDevices = tonumber(stats.sim_devices) or 0,
|
||||
activeDevices = tonumber(stats.active_devices) or 0,
|
||||
messagesToday = tonumber(stats.messages_today) or 0,
|
||||
callsToday = tonumber(stats.calls_today) or 0,
|
||||
auditEntries = tonumber(stats.audit_entries) or 0,
|
||||
auditToday = tonumber(stats.audit_today) or 0,
|
||||
},
|
||||
audit = load_audit(),
|
||||
},
|
||||
@@ -861,4 +904,50 @@ Bridge.Callbacks.Register("sky_phone:admin:factory-reset", function(source, data
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
+157
-122
@@ -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)
|
||||
@@ -1403,8 +1414,6 @@ local function reconcile_settlements(include_recent)
|
||||
end
|
||||
end
|
||||
|
||||
reconcile_settlements(true)
|
||||
|
||||
local function crypto_random_int(minimum, maximum, failure_message)
|
||||
local value = exports[GetCurrentResourceName()]:CryptoRandomInt(minimum, maximum)
|
||||
if type(value) ~= "number" then
|
||||
@@ -1487,134 +1496,160 @@ local function advance_market_cycle(config, dynamics)
|
||||
return dynamics.cycle_bias
|
||||
end
|
||||
|
||||
CreateThread(function()
|
||||
while true do
|
||||
Wait(5 * 60 * 1000)
|
||||
reconcile_settlements(false)
|
||||
local scheduler_generation = 0
|
||||
|
||||
local function start_crypto_schedulers()
|
||||
scheduler_generation = scheduler_generation + 1
|
||||
if Config.Crypto.Enabled ~= true then
|
||||
return
|
||||
end
|
||||
end)
|
||||
|
||||
CreateThread(function()
|
||||
while true do
|
||||
local tick_seconds = crypto_random_int(
|
||||
Config.Crypto.PriceTickMinimumSeconds,
|
||||
Config.Crypto.PriceTickMaximumSeconds + 1,
|
||||
"[sky_phone] Crypto entropy provider did not return a market tick interval."
|
||||
)
|
||||
Wait(tick_seconds * 1000)
|
||||
with_exchange_lock(function()
|
||||
local market_count = crypto_random_int(
|
||||
Config.Crypto.MarketsPerTickMinimum,
|
||||
Config.Crypto.MarketsPerTickMaximum + 1,
|
||||
"[sky_phone] Crypto entropy provider did not return a market count."
|
||||
local generation = scheduler_generation
|
||||
reconcile_settlements(true)
|
||||
|
||||
CreateThread(function()
|
||||
while scheduler_generation == generation and Config.Crypto.Enabled == true do
|
||||
Wait(5 * 60 * 1000)
|
||||
if scheduler_generation ~= generation or Config.Crypto.Enabled ~= true then
|
||||
break
|
||||
end
|
||||
reconcile_settlements(false)
|
||||
end
|
||||
end)
|
||||
|
||||
CreateThread(function()
|
||||
while scheduler_generation == generation and Config.Crypto.Enabled == true do
|
||||
local tick_seconds = crypto_random_int(
|
||||
Config.Crypto.PriceTickMinimumSeconds,
|
||||
Config.Crypto.PriceTickMaximumSeconds + 1,
|
||||
"[sky_phone] Crypto entropy provider did not return a market tick interval."
|
||||
)
|
||||
advance_global_market_cycle()
|
||||
local changed_markets = {}
|
||||
market_count = math.min(market_count, #market_order)
|
||||
Wait(tick_seconds * 1000)
|
||||
if scheduler_generation ~= generation or Config.Crypto.Enabled ~= true then
|
||||
break
|
||||
end
|
||||
with_exchange_lock(function()
|
||||
local market_count = crypto_random_int(
|
||||
Config.Crypto.MarketsPerTickMinimum,
|
||||
Config.Crypto.MarketsPerTickMaximum + 1,
|
||||
"[sky_phone] Crypto entropy provider did not return a market count."
|
||||
)
|
||||
advance_global_market_cycle()
|
||||
local changed_markets = {}
|
||||
market_count = math.min(market_count, #market_order)
|
||||
|
||||
for offset = 0, market_count - 1 do
|
||||
local order_index = ((market_cursor + offset - 1) % #market_order) + 1
|
||||
local market_id = market_order[order_index]
|
||||
local config = markets[market_id]
|
||||
local row = Bridge.Database.Query(
|
||||
"SELECT `price`,`version`,`status` FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1",
|
||||
{ market_id }
|
||||
)[1]
|
||||
if row and row.status == "active" then
|
||||
local price = tonumber(row.price) or config.InitialPrice
|
||||
local impulse = crypto_random_int(
|
||||
-config.VolatilityBasisPoints,
|
||||
config.VolatilityBasisPoints + 1,
|
||||
"[sky_phone] Crypto entropy provider did not return a market movement."
|
||||
)
|
||||
if impulse > 0 then
|
||||
impulse = math.floor(impulse / Config.Crypto.RandomImpulseDivisor)
|
||||
elseif impulse < 0 then
|
||||
impulse = math.ceil(impulse / Config.Crypto.RandomImpulseDivisor)
|
||||
end
|
||||
local shock_roll = crypto_random_int(
|
||||
0,
|
||||
10000,
|
||||
"[sky_phone] Crypto entropy provider did not return a market shock roll."
|
||||
)
|
||||
local dynamics = market_dynamics[market_id] or {
|
||||
momentum = 0,
|
||||
cycle_bias = 0,
|
||||
cycle_direction = 0,
|
||||
cycle_remaining_ticks = 0,
|
||||
cycle_target = 0,
|
||||
}
|
||||
dynamics.momentum = truncate_integer((
|
||||
dynamics.momentum * Config.Crypto.MomentumDecayBasisPoints
|
||||
+ impulse * Config.Crypto.MomentumImpulseBasisPoints
|
||||
) / 10000)
|
||||
local cycle_bias = advance_market_cycle(config, dynamics)
|
||||
market_dynamics[market_id] = dynamics
|
||||
|
||||
local deviation = math.floor(
|
||||
(config.InitialPrice - price) * 10000 / config.InitialPrice
|
||||
)
|
||||
local reversion = truncate_integer(
|
||||
deviation * Config.Crypto.MeanReversionBasisPoints / 10000
|
||||
)
|
||||
local shock = 0
|
||||
if shock_roll < Config.Crypto.MarketShockChanceBasisPoints then
|
||||
local multiplier = crypto_random_int(
|
||||
Config.Crypto.MarketShockMinimumMultiplier,
|
||||
Config.Crypto.MarketShockMaximumMultiplier + 1,
|
||||
"[sky_phone] Crypto entropy provider did not return a market shock multiplier."
|
||||
for offset = 0, market_count - 1 do
|
||||
local order_index = ((market_cursor + offset - 1) % #market_order) + 1
|
||||
local market_id = market_order[order_index]
|
||||
local config = markets[market_id]
|
||||
local row = Bridge.Database.Query(
|
||||
"SELECT `price`,`version`,`status` FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1",
|
||||
{ market_id }
|
||||
)[1]
|
||||
if row and row.status == "active" then
|
||||
local price = tonumber(row.price) or config.InitialPrice
|
||||
local impulse = crypto_random_int(
|
||||
-config.VolatilityBasisPoints,
|
||||
config.VolatilityBasisPoints + 1,
|
||||
"[sky_phone] Crypto entropy provider did not return a market movement."
|
||||
)
|
||||
local direction_roll = crypto_random_int(
|
||||
if impulse > 0 then
|
||||
impulse = math.floor(impulse / Config.Crypto.RandomImpulseDivisor)
|
||||
elseif impulse < 0 then
|
||||
impulse = math.ceil(impulse / Config.Crypto.RandomImpulseDivisor)
|
||||
end
|
||||
local shock_roll = crypto_random_int(
|
||||
0,
|
||||
2,
|
||||
"[sky_phone] Crypto entropy provider did not return a market shock direction."
|
||||
10000,
|
||||
"[sky_phone] Crypto entropy provider did not return a market shock roll."
|
||||
)
|
||||
local direction = direction_roll == 0 and -1 or 1
|
||||
shock = direction * config.VolatilityBasisPoints * multiplier
|
||||
end
|
||||
local dynamics = market_dynamics[market_id] or {
|
||||
momentum = 0,
|
||||
cycle_bias = 0,
|
||||
cycle_direction = 0,
|
||||
cycle_remaining_ticks = 0,
|
||||
cycle_target = 0,
|
||||
}
|
||||
dynamics.momentum = truncate_integer((
|
||||
dynamics.momentum * Config.Crypto.MomentumDecayBasisPoints
|
||||
+ impulse * Config.Crypto.MomentumImpulseBasisPoints
|
||||
) / 10000)
|
||||
local cycle_bias = advance_market_cycle(config, dynamics)
|
||||
market_dynamics[market_id] = dynamics
|
||||
|
||||
local maximum_movement = config.VolatilityBasisPoints
|
||||
* Config.Crypto.MaximumMovementMultiplier
|
||||
local movement = impulse + dynamics.momentum + cycle_bias
|
||||
+ global_market_trend + reversion + shock
|
||||
movement = math.max(-maximum_movement, math.min(maximum_movement, movement))
|
||||
if movement > 0 then
|
||||
movement = math.floor(movement / Config.Crypto.TickMovementDivisor)
|
||||
elseif movement < 0 then
|
||||
movement = math.ceil(movement / Config.Crypto.TickMovementDivisor)
|
||||
end
|
||||
local next_price = math.floor(price * (10000 + movement) / 10000)
|
||||
if next_price == price and movement ~= 0 then
|
||||
next_price = price + (movement > 0 and 1 or -1)
|
||||
end
|
||||
next_price = math.max(config.MinimumPrice, math.min(config.MaximumPrice, next_price))
|
||||
local next_version = (tonumber(row.version) or 0) + 1
|
||||
if Bridge.Database.Transaction({
|
||||
{ query = [[UPDATE `sky_phone_crypto_markets` SET `price` = ?, `version` = ? WHERE `id` = ? AND `version` = ?]], params = { next_price, next_version, market_id, row.version } },
|
||||
{ query = [[INSERT INTO `sky_phone_crypto_market_ticks` (`market_id`,`version`,`price`) VALUES (?, ?, ?)]], params = { market_id, next_version, next_price } },
|
||||
}) then
|
||||
changed_markets[#changed_markets + 1] = market_id
|
||||
Bridge.Database.Query([[
|
||||
DELETE FROM `sky_phone_crypto_market_ticks`
|
||||
WHERE `market_id` = ? AND `id` NOT IN (
|
||||
SELECT `id` FROM (
|
||||
SELECT `id` FROM `sky_phone_crypto_market_ticks`
|
||||
WHERE `market_id` = ? ORDER BY `id` DESC LIMIT ?
|
||||
) retained
|
||||
local deviation = math.floor(
|
||||
(config.InitialPrice - price) * 10000 / config.InitialPrice
|
||||
)
|
||||
local reversion = truncate_integer(
|
||||
deviation * Config.Crypto.MeanReversionBasisPoints / 10000
|
||||
)
|
||||
local shock = 0
|
||||
if shock_roll < Config.Crypto.MarketShockChanceBasisPoints then
|
||||
local multiplier = crypto_random_int(
|
||||
Config.Crypto.MarketShockMinimumMultiplier,
|
||||
Config.Crypto.MarketShockMaximumMultiplier + 1,
|
||||
"[sky_phone] Crypto entropy provider did not return a market shock multiplier."
|
||||
)
|
||||
]], { market_id, market_id, Config.Crypto.HistoryRetentionTicks })
|
||||
local direction_roll = crypto_random_int(
|
||||
0,
|
||||
2,
|
||||
"[sky_phone] Crypto entropy provider did not return a market shock direction."
|
||||
)
|
||||
local direction = direction_roll == 0 and -1 or 1
|
||||
shock = direction * config.VolatilityBasisPoints * multiplier
|
||||
end
|
||||
|
||||
local maximum_movement = config.VolatilityBasisPoints
|
||||
* Config.Crypto.MaximumMovementMultiplier
|
||||
local movement = impulse + dynamics.momentum + cycle_bias
|
||||
+ global_market_trend + reversion + shock
|
||||
movement = math.max(-maximum_movement, math.min(maximum_movement, movement))
|
||||
if movement > 0 then
|
||||
movement = math.floor(movement / Config.Crypto.TickMovementDivisor)
|
||||
elseif movement < 0 then
|
||||
movement = math.ceil(movement / Config.Crypto.TickMovementDivisor)
|
||||
end
|
||||
local next_price = math.floor(price * (10000 + movement) / 10000)
|
||||
if next_price == price and movement ~= 0 then
|
||||
next_price = price + (movement > 0 and 1 or -1)
|
||||
end
|
||||
next_price = math.max(config.MinimumPrice, math.min(config.MaximumPrice, next_price))
|
||||
local next_version = (tonumber(row.version) or 0) + 1
|
||||
if Bridge.Database.Transaction({
|
||||
{ query = [[UPDATE `sky_phone_crypto_markets` SET `price` = ?, `version` = ? WHERE `id` = ? AND `version` = ?]], params = { next_price, next_version, market_id, row.version } },
|
||||
{ query = [[INSERT INTO `sky_phone_crypto_market_ticks` (`market_id`,`version`,`price`) VALUES (?, ?, ?)]], params = { market_id, next_version, next_price } },
|
||||
}) then
|
||||
changed_markets[#changed_markets + 1] = market_id
|
||||
Bridge.Database.Query([[
|
||||
DELETE FROM `sky_phone_crypto_market_ticks`
|
||||
WHERE `market_id` = ? AND `id` NOT IN (
|
||||
SELECT `id` FROM (
|
||||
SELECT `id` FROM `sky_phone_crypto_market_ticks`
|
||||
WHERE `market_id` = ? ORDER BY `id` DESC LIMIT ?
|
||||
) retained
|
||||
)
|
||||
]], { market_id, market_id, Config.Crypto.HistoryRetentionTicks })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
market_cursor = ((market_cursor + market_count - 1) % #market_order) + 1
|
||||
if #changed_markets > 0 then
|
||||
TriggerClientEvent("sky_phone:crypto:changed", -1, {
|
||||
markets = market_dtos(changed_markets),
|
||||
updatedAt = os.time() * 1000,
|
||||
})
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
market_cursor = ((market_cursor + market_count - 1) % #market_order) + 1
|
||||
if #changed_markets > 0 then
|
||||
TriggerClientEvent("sky_phone:crypto:changed", -1, {
|
||||
markets = market_dtos(changed_markets),
|
||||
updatedAt = os.time() * 1000,
|
||||
})
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
local function refresh_crypto_runtime()
|
||||
initialize_markets()
|
||||
start_crypto_schedulers()
|
||||
end
|
||||
|
||||
AddEventHandler("sky_phone:configurator:serverUpdated", refresh_crypto_runtime)
|
||||
start_crypto_schedulers()
|
||||
|
||||
end)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -931,10 +931,16 @@ end)
|
||||
CreateThread(function()
|
||||
while true do
|
||||
Wait(Config.DarkChat.CleanupIntervalSeconds * 1000)
|
||||
local batch_size = math.max(
|
||||
1,
|
||||
math.floor(tonumber(Config.DarkChat.CleanupBatchSize) or 250)
|
||||
)
|
||||
Bridge.Database.Query([[
|
||||
DELETE FROM `sky_phone_darkchat_messages`
|
||||
WHERE `expires_at` IS NOT NULL AND `expires_at` <= CURRENT_TIMESTAMP
|
||||
]], {})
|
||||
ORDER BY `expires_at`, `id`
|
||||
LIMIT ?
|
||||
]], { batch_size })
|
||||
end
|
||||
end)
|
||||
|
||||
|
||||
@@ -739,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" },
|
||||
@@ -1041,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" },
|
||||
@@ -2196,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" },
|
||||
@@ -2949,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`)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user