Compare commits

..
Author SHA1 Message Date
DerEchteAlec af1ae8510d Merge branch 'dev' into feat/phone-configurator 2026-08-21 21:50:21 +02:00
Leon.Schmidt 197dce8767 CLN - merge dev into phone configurator
# Conflicts:
#	sky_phone/source/client/focus.lua
#	sky_phone/source/server/sim.lua
2026-08-21 17:25:17 +02:00
Leon.Schmidt 1bd0adbea3 FIX - close live activity command registration 2026-08-21 17:03:05 +02:00
Leon.Schmidt f70b1cc37d CLN - merge dev into phone configurator 2026-08-21 16:49:01 +02:00
Leon.Schmidt 7df62153a2 FIX - apply configurator settings instantly 2026-08-21 16:48:54 +02:00
Leon.Schmidt 1949a37006 ENH - refine admin configurator controls 2026-08-21 04:35:00 +02:00
Leon.Schmidt 219d523edf ENH - refine configurator editing experience 2026-08-21 03:59:19 +02:00
Leon.Schmidt 20faedc1a2 FIX - hide fixed configurator add controls 2026-08-21 03:25:10 +02:00
Leon.Schmidt 87fa234c49 ENH - add configurator field descriptions 2026-08-21 03:05:44 +02:00
Leon.Schmidt 7bc4eb6993 ENH - organize nested configurator sections 2026-08-21 02:46:01 +02:00
Leon.Schmidt 740bdda0db ENH - organize configurator collection controls 2026-08-21 02:37:12 +02:00
Leon.Schmidt 7f97072c75 FIX - align configurator tables to left 2026-08-21 02:28:23 +02:00
Leon.Schmidt 42f5e8f493 FIX - make company jobs freely configurable 2026-08-21 02:17:28 +02:00
Leon.Schmidt d8a7721917 FIX - expose complete phone configuration 2026-08-21 02:01:39 +02:00
Leon.Schmidt 15e32799d4 ADD - add SQL phone configurator 2026-08-21 01:22:53 +02:00
Leon.Schmidt d6b03ac2b7 ENH - refine admin panel navigation style 2026-08-20 21:55:12 +02:00
Leon.Schmidt 9dd330da93 ENH - expand admin device controls 2026-08-20 21:36:16 +02:00
Leon.Schmidt a9b0c4e0f4 ENH - compact admin workspace navigation 2026-08-20 21:06:53 +02:00
Leon.Schmidt 8e2523d555 ENH - rebuild admin panel as standalone editor 2026-08-20 20:52:36 +02:00
Leon.Schmidt f5b28c7fda CLN - merge dev into admin panel branch 2026-08-20 20:11:24 +02:00
Leon.Schmidt 3a45aa159a ADD - open admin panel by command 2026-08-20 20:10:31 +02:00
Leon.Schmidt 22165d64df ADD - build in-game admin command center 2026-08-20 19:51:26 +02:00
97 changed files with 1450 additions and 9365 deletions
+1 -1
View File
@@ -119,5 +119,5 @@ jobs:
"${archive}.sha256" \
--verify-tag \
--generate-notes \
--title "$GITHUB_REF_NAME Beta"
--title "Sky Phone $GITHUB_REF_NAME"
fi
+20 -105
View File
@@ -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** | 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 |
| **Inventories** | ox_inventory, qb-inventory, lj-inventory, qs-inventory, codem-inventory, core_inventory, mf-inventory, smx-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,33 +121,20 @@ 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 (configuration value) | Metadata support | Unique Phones | Notes |
| Inventory | Metadata support | Unique Phones | Notes |
| --- | --- | --- | --- |
| `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 |
| `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` |
`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.
`hex_4_inventory` and native ESX inventory cannot persist per-item metadata. Unique Phones and physical SIM cards are therefore unavailable with these adapters.
### Voice
@@ -216,7 +203,6 @@ 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 |
@@ -242,31 +228,21 @@ Config.PhoneConfigurator = {
}
```
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
When enabled, `config.lua` and the server-only `media.lua` are first-run defaults. Sky Phone creates
the `sky_phone_configurator` table automatically, loads its saved values before framework and phone
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,
Every `Config.*` value from `config.lua`, including server-only sections, and every value from
`Config.Media` is discovered automatically. The bootstrap switch above intentionally remains
file-owned because it decides whether SQL configuration is loaded. Lists, nested objects, vectors,
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.
@@ -374,9 +350,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: `ak47`, `codem`, `core`, `jaksam`, `jpr`, `lj`, `mf`, `one`, `origen`, `ox`, `ps`, `qb`, `qs`, `smx`, `tgiann`, `hex`, and `esx`. Resource startup fails visibly if the selected adapter or its resource is unavailable.
The server registers `Config.Phone.Item` as usable for every supported inventory adapter: `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, 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.
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.
### QBCore-style item tables
@@ -385,67 +361,6 @@ The `hex` and `esx` adapters use ESX's count-based item API, which cannot persis
- 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:
+5 -40
View File
@@ -2,49 +2,15 @@ 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(resourceDirectory, 'source', 'html')
const configDefaultPath = path.join(
resourceDirectory,
const targetDirectory = path.join(
frontendRoot,
'..',
'sky_phone',
'source',
'shared',
'config_default.lua',
'html',
)
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.',
@@ -62,5 +28,4 @@ const normalizedIndex = fs
.replace(/\n[ \t]*\n([ \t]*<\/body>)/g, '\n$1')
fs.writeFileSync(targetIndex, normalizedIndex)
generateConfigDefault()
console.log(`Published NUI to ${targetDirectory}`)
+1 -12
View File
@@ -530,17 +530,8 @@ 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, payload.disabledApps)
appStore.hydrate(payload.device?.data.apps?.payload)
widgets.hydrate(payload.device?.data.widgets?.payload)
const currentAppId = route.params.appId
if (
typeof currentAppId === 'string' &&
isPhoneAppId(currentAppId) &&
!appStore.isInstalled(currentAppId)
) {
void router.push('/')
}
}
function getInstalledNavigationAppIds(): string[] {
@@ -1885,8 +1876,6 @@ onBeforeUnmount(() => {
class="phone-screen"
:class="{
'phone-screen--app': isAppRoute || isDevelopmentRoute,
'phone-screen--camera-landscape':
activeAppId === 'camera' && phone.cameraLandscape,
'phone-app--light': !displayedDarkMode,
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
}"
-10
View File
@@ -460,7 +460,6 @@ button {
}
}
.phone-screen {
--phone-screen-portrait-ratio: 2.30951;
position: relative;
container-type: size;
margin: auto;
@@ -471,12 +470,6 @@ button {
background: #08080a;
border-radius: 40px;
}
.phone-screen--camera-landscape {
background: transparent;
}
.phone-screen--camera-landscape .springboard {
visibility: hidden;
}
.phone-app {
position: absolute !important;
inset: 0 !important;
@@ -3246,9 +3239,6 @@ button {
backface-visibility: hidden;
will-change: transform, border-radius, opacity;
}
.app-window--camera-landscape {
background: transparent;
}
.app-window--citywarn {
backface-visibility: visible;
filter: none;
@@ -1,147 +0,0 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const config = readFileSync(
new URL('../../sky_phone/config/config.lua', import.meta.url),
'utf8',
).replace(/\r\n/g, '\n')
const companiesServer = readFileSync(
new URL('../../sky_phone/source/server/companies.lua', import.meta.url),
'utf8',
).replace(/\r\n/g, '\n')
const configuratorServer = readFileSync(
new URL(
'../../sky_phone/source/server/phone_configurator.lua',
import.meta.url,
),
'utf8',
).replace(/\r\n/g, '\n')
const testServer = readFileSync(
new URL('../testserver/index.cjs', import.meta.url),
'utf8',
).replace(/\r\n/g, '\n')
function sourceBlock(source: string, startMarker: string, endMarker: string) {
const start = source.indexOf(startMarker)
const end = source.indexOf(endMarker, start)
expect(start).toBeGreaterThanOrEqual(0)
expect(end).toBeGreaterThan(start)
return source.slice(start, end)
}
describe('Companies emergency request contract', () => {
it('ships non-emergency police assistance as a requestable service', () => {
const police = sourceBlock(
config,
' police = {',
' ambulance = {',
)
const mockPolice = sourceBlock(
testServer,
'const companyProfiles = [',
' {\n acceptsRequests: false,\n announcement: null,',
)
expect(police).toContain('Emergency = true')
expect(police).toContain('AcceptsRequests = true')
expect(police).toContain('Id = "police-assistance"')
expect(police).toContain('RequestsEnabled = true')
expect(mockPolice).toContain('acceptsRequests: true')
expect(mockPolice).toContain("name: 'Los Santos Police Department'")
expect(mockPolice).toContain("id: 'police-assistance'")
})
it('authorizes configured emergency companies through the normal request gates', () => {
const validation = sourceBlock(
companiesServer,
'local function validate_configuration(configuration)',
'local function seed_companies()',
)
const payload = sourceBlock(
companiesServer,
'local function company_payload(',
'local function public_company(',
)
const createRequest = sourceBlock(
companiesServer,
'Bridge.Callbacks.Register("sky_phone:companies:create-request"',
'Bridge.Callbacks.Register("sky_phone:companies:cancel-request"',
)
const updateProfile = sourceBlock(
companiesServer,
'Bridge.Callbacks.Register("sky_phone:companies:update-profile"',
'Bridge.Callbacks.Register("sky_phone:companies:update-hours"',
)
expect(validation).not.toContain(
'definition.Emergency and definition.AcceptsRequests',
)
expect(payload).toContain(
'acceptsRequests = tonumber(row.accepts_requests) == 1,',
)
expect(payload).not.toContain('not definition.Emergency')
expect(createRequest).toContain(
'if not definition or not definition.Public then',
)
expect(createRequest).not.toContain('definition.Emergency')
expect(createRequest).toContain('SELECT `accepts_requests`')
expect(createRequest).toContain('AND `requests_enabled` = 1')
expect(updateProfile).not.toContain('member.definition.Emergency')
})
it('migrates existing requestable emergency profiles exactly once', () => {
const migration = sourceBlock(
companiesServer,
'local function migrate_requestable_emergency_companies()',
'local function tombstone_removed_companies()',
)
const refresh = sourceBlock(
companiesServer,
'local function refresh_runtime_configuration()',
'\n\nrefresh_runtime_configuration()',
)
expect(migration).toContain('sky-phone:companies:requestable-emergency:v1')
expect(migration).toContain(
'if definition.Emergency and definition.AcceptsRequests then',
)
expect(migration).toContain('SET `accepts_requests` = 1')
expect(migration).toContain('INSERT IGNORE INTO `sky_phone_migrations`')
expect(migration).toContain('Bridge.Database.Transaction(statements)')
expect(refresh.indexOf('seed_companies()')).toBeLessThan(
refresh.indexOf('migrate_requestable_emergency_companies()'),
)
expect(
refresh.indexOf('migrate_requestable_emergency_companies()'),
).toBeLessThan(refresh.indexOf('tombstone_removed_companies()'))
})
it('migrates the existing Phone Configurator police defaults', () => {
const migration = sourceBlock(
configuratorServer,
'local function migrate_police_request_defaults()',
'\n\ndefault_config = {}',
)
expect(migration).toContain('sky-phone:configurator:police-requests:v1')
expect(migration).toContain('police.AcceptsRequests == false')
expect(migration).toContain('next(police.Services) == nil')
expect(migration).toContain(
'police.AcceptsRequests = defaults.AcceptsRequests',
)
expect(migration).toContain(
'police.Services = copy_value(defaults.Services)',
)
expect(migration).toContain('SET `config_payload` = ?')
expect(migration).toContain('`revision` = `revision` + 1')
expect(migration).toContain('INSERT IGNORE INTO `sky_phone_migrations`')
expect(migration).toContain('Bridge.Database.Transaction(statements)')
expect(migration).toContain('apply_stored_row(read_stored_row())')
expect(migration).toContain('apply_runtime_configuration()')
expect(configuratorServer).toContain(
'Bridge.Database.AfterMigration("sky_phone", migrate_police_request_defaults)',
)
})
})
@@ -1,133 +0,0 @@
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').replace(
/\r\n/g,
'\n',
)
}
function sourceBlock(
value: string,
startMarker: string,
endMarker: string,
): string {
const start = value.indexOf(startMarker)
const end = value.indexOf(endMarker, start)
expect(start).toBeGreaterThanOrEqual(0)
expect(end).toBeGreaterThan(start)
return value.slice(start, end)
}
const companiesServer = source('../../sky_phone/source/server/companies.lua')
const messagesServer = source('../../sky_phone/source/server/messages.lua')
const databaseMigration = source('../../sky_phone/source/server/db_migrate.lua')
const configDefaults = source(
'../../sky_phone/source/shared/config_default.lua',
)
const messagesApp = source('./views/apps/MessagesApp.vue')
const phoneFallbacks = source('./stores/phone.ts')
const locales = ['en', 'de', 'es'].map((locale) =>
source(`../../sky_phone/config/locales/${locale}.lua`),
)
describe('Companies service-line message routing contract', () => {
it('enables the police service line and persists an indexed route channel', () => {
const police = sourceBlock(
configDefaults,
' police = {',
' ambulance = {',
)
const schema = sourceBlock(
databaseMigration,
' name = "sky_phone_company_requests",',
' name = "sky_phone_company_request_reads",',
)
expect(police).toContain('Number = "911"')
expect(police).toContain('CanMessage = true')
expect(schema).toContain(
"name = \"channel\", type = \"ENUM('app','service_line') NOT NULL DEFAULT 'app'\"",
)
expect(schema).toContain(
'name = "idx_sky_phone_company_requests_service_line"',
)
})
it('resolves short service numbers before the normal SIM-number path', () => {
const resolver = sourceBlock(
messagesServer,
'local function resolve_message_recipient(value)',
'\n\nlocal function shared_contact',
)
const send = sourceBlock(
messagesServer,
'Bridge.Callbacks.Register("sky_phone:messages:send"',
'\n\nend)',
)
expect(resolver).toContain('SkyPhoneCompanies.GetServiceLine(value)')
expect(resolver).toContain('return service_line.number, service_line')
expect(messagesServer.match(/resolve_message_recipient\(/g)?.length).toBe(4)
expect(send).toContain('service_line_text_only')
expect(send).toContain('SkyPhoneCompanies.RouteServiceLineMessage')
expect(send.indexOf('RouteServiceLineMessage')).toBeLessThan(
send.indexOf('SELECT s.`id`, s.`phone_number`'),
)
})
it('atomically mirrors inbound SMS into one active company request', () => {
const route = sourceBlock(
companiesServer,
'function SkyPhoneCompanies.RouteServiceLineMessage(source, data)',
'Bridge.Callbacks.Register("sky_phone:companies:create-request"',
)
expect(route).toContain('current_device(source, false)')
expect(route).toContain('service_line.canMessage')
expect(route).toContain('UPDATE `sky_phone_sims`')
expect(route).toContain("`channel` = 'service_line'")
expect(route).toContain('AND NOT EXISTS (')
expect(route).toContain('INSERT INTO `sky_phone_company_request_messages`')
expect(route).toContain('INSERT INTO `sky_phone_sms_messages`')
expect(route).toContain('Bridge.Database.Transaction(statements)')
expect(route).toContain('emit_request_change(row, true, true, source)')
expect(route).toContain('"sky_phone:companies:notification"')
})
it('mirrors authorized company replies back to the customer SMS thread', () => {
const sendMessage = sourceBlock(
companiesServer,
'Bridge.Callbacks.Register("sky_phone:companies:send-message"',
'Bridge.Callbacks.Register("sky_phone:companies:claim-request"',
)
expect(sendMessage).toContain('access.row.channel == "service_line"')
expect(
sendMessage.match(/INSERT INTO `sky_phone_sms_messages`/g)?.length,
).toBe(2)
expect(sendMessage).toContain("message.`sender_type` = 'customer'")
expect(sendMessage).toContain("message.`sender_type` = 'company'")
expect(sendMessage).toContain('"sky_phone:messages:changed"')
expect(sendMessage).toContain('"sky_phone:messages:new"')
})
it('keeps service-line composition text-only in every shipped locale', () => {
expect(messagesApp).toContain(
"() => activeContact.value?.source === 'company'",
)
expect(messagesApp).toContain(
'activeCanMessage && !activeServiceLine && attachmentMenuOpen',
)
expect(messagesApp).toContain('v-if="!activeServiceLine"')
expect(messagesApp).toContain('v-else-if="!activeServiceLine"')
expect(messagesApp).toContain("'service_line_text_only'")
expect(phoneFallbacks).toContain('service_line_text_only:')
for (const locale of locales) {
expect(locale).toContain('service_line_text_only =')
}
})
})
@@ -1,139 +0,0 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const companiesServer = readFileSync(
new URL('../../sky_phone/source/server/companies.lua', import.meta.url),
'utf8',
).replace(/\r\n/g, '\n')
const configuratorServer = readFileSync(
new URL(
'../../sky_phone/source/server/phone_configurator.lua',
import.meta.url,
),
'utf8',
).replace(/\r\n/g, '\n')
const englishLocale = readFileSync(
new URL('../../sky_phone/config/locales/en.lua', import.meta.url),
'utf8',
)
const germanLocale = readFileSync(
new URL('../../sky_phone/config/locales/de.lua', import.meta.url),
'utf8',
)
const spanishLocale = readFileSync(
new URL('../../sky_phone/config/locales/es.lua', import.meta.url),
'utf8',
)
describe('company configurator creation contract', () => {
it('publishes complete defaults for newly added company definitions', () => {
expect(configuratorServer).toContain(
'local function company_definition_entry_default(company_id, configuration)',
)
expect(configuratorServer).toContain(
'local function next_available_company_service_number(configuration)',
)
expect(configuratorServer).toContain(
'entryDefault = company_definition_entry_default()',
)
expect(configuratorServer).toContain('Routing = "round_robin"')
expect(configuratorServer).toContain('RequestsEnabled = true')
})
it('validates the candidate runtime config before encoding or writing SQL', () => {
const save = configuratorServer.slice(
configuratorServer.indexOf('function SkyPhoneConfigurator.Save('),
)
const validation = save.indexOf(
'SkyPhoneCompanies.ValidateConfiguration(candidate_config)',
)
const encoding = save.indexOf(
'local config_encoded = encode_payload(next_config, "config")',
)
const update = save.indexOf('UPDATE `%s`')
expect(validation).toBeGreaterThanOrEqual(0)
expect(validation).toBeLessThan(encoding)
expect(validation).toBeLessThan(update)
expect(save).toContain(
'return { success = false, error = "invalid_company_configuration" }',
)
})
it('validates into isolated registries before replacing the live company state', () => {
expect(companiesServer).toContain(
'function SkyPhoneCompanies.ValidateConfiguration(configuration)',
)
expect(companiesServer).toContain(
'local validated, validation_error = validate_configuration(configuration)',
)
expect(companiesServer).toContain('definitions = validated.definitions')
expect(companiesServer).toContain(
'definition_ids = validated.definition_ids',
)
})
it('localizes rejected company configurations in every shipped locale', () => {
for (const locale of [englishLocale, germanLocale, spanishLocale]) {
expect(locale).toContain('invalid_company_configuration =')
}
})
it('repairs company rows created by the legacy blank schema before Companies starts', () => {
const migration = configuratorServer.slice(
configuratorServer.indexOf(
'local function migrate_blank_company_definitions()',
),
configuratorServer.indexOf(
'local function migrate_police_request_defaults()',
),
)
const blankRegistration = configuratorServer.indexOf(
'Bridge.Database.AfterMigration("sky_phone", migrate_blank_company_definitions)',
)
const policeRegistration = configuratorServer.indexOf(
'Bridge.Database.AfterMigration("sky_phone", migrate_police_request_defaults)',
)
expect(migration).toContain(
'sky-phone:configurator:company-definition-defaults:v1',
)
expect(migration).toContain('company_definition_entry_default(')
expect(migration).toContain('Bridge.Database.Transaction(statements)')
expect(migration).toContain('SET `config_payload` = ?')
expect(migration).toContain('`revision` = `revision` + 1')
expect(blankRegistration).toBeGreaterThanOrEqual(0)
expect(blankRegistration).toBeLessThan(policeRegistration)
})
it('enables persisted police service-line messaging before Companies starts', () => {
const migration = configuratorServer.slice(
configuratorServer.indexOf(
'local function migrate_police_service_line_messaging()',
),
configuratorServer.indexOf('\n\ndefault_config = {}'),
)
const policeRegistration = configuratorServer.indexOf(
'Bridge.Database.AfterMigration("sky_phone", migrate_police_request_defaults)',
)
const messageRegistration = configuratorServer.indexOf(
'Bridge.Database.AfterMigration("sky_phone", migrate_police_service_line_messaging)',
)
expect(migration).toContain(
'sky-phone:configurator:service-line-messaging:v2',
)
expect(migration).toContain('line.CanMessage ~= true')
expect(migration).toContain('line.CanMessage = true')
expect(migration).toContain('Bridge.Database.Transaction(statements)')
expect(migration).toContain('SET `config_payload` = ?')
expect(migration).toContain('`revision` = `revision` + 1')
expect(migration).toContain('apply_stored_row(read_stored_row())')
expect(migration).toContain('apply_runtime_configuration()')
expect(messageRegistration).toBeGreaterThan(policeRegistration)
expect(companiesServer).not.toContain(
'enables messaging without a virtual service-line message router',
)
})
})
@@ -10,10 +10,6 @@ import { computed, ref } from 'vue'
import { vConfigInputWidth } from '@/directives/configInputWidth'
import type { AdminConfiguratorStructure } from '@/types/admin'
import {
blankFromConfiguratorStructure,
createMutableTableEntry,
} from '@/utils/adminConfiguratorDefaults'
import type { AdminConfiguratorDescribe } from '@/utils/adminConfiguratorDescription'
export type AdminConfigEditorLabels = {
@@ -346,6 +342,40 @@ function mapValuePath(entry: SerializedMapEntry): string {
return props.path ? `${props.path}.${entry.key}` : String(entry.key)
}
function blankFromStructure(structure: AdminConfiguratorStructure): unknown {
if (structure.kind === 'optionalString') return ''
if (structure.kind === 'value') return blankValue(structure.valueType)
if (structure.kind === 'vector') {
const axes = ['x', 'y', 'z', 'w'].slice(
0,
Number(structure.vectorType.slice(-1)),
)
return Object.fromEntries([
['__skyType', structure.vectorType],
...axes.map((axis) => [axis, 0]),
])
}
if (structure.kind === 'list') {
return structure.items.map(blankFromStructure)
}
if (structure.kind === 'map') {
return {
__skyType: 'map',
entries: structure.entries.map((entry) => ({
key: entry.key,
keyType: entry.keyType,
value: blankFromStructure(entry.structure),
})),
}
}
return Object.fromEntries(
Object.entries(structure.fields).map(([key, field]) => [
key,
blankFromStructure(field),
]),
)
}
function updateScalar(event: Event): void {
const target = event.target
if (!(target instanceof HTMLInputElement)) return
@@ -383,7 +413,7 @@ function addListRow(): void {
const value = rows.length
? blankLike(rows[0])
: listTemplate.value
? blankFromConfiguratorStructure(listTemplate.value)
? blankFromStructure(listTemplate.value)
: blankValue(newArrayKind.value)
rows.push(value)
emit('update:modelValue', rows)
@@ -446,21 +476,12 @@ function addTableField(): void {
const template = tableStructure.value?.mutableKeys
? tableStructure.value.template
: undefined
const structuredValue = tableStructure.value?.mutableKeys
? createMutableTableEntry(
tableStructure.value,
props.path,
key,
tableValue.value,
const value = template
? blankFromStructure(template)
: blankCollectionValue(
newObjectKind.value,
Object.values(tableValue.value).filter((_, index) => index < 50),
)
: undefined
const value =
structuredValue !== undefined
? structuredValue
: blankCollectionValue(
newObjectKind.value,
Object.values(tableValue.value).filter((_, index) => index < 50),
)
emit('update:modelValue', {
...tableValue.value,
[key]: value,
@@ -535,7 +556,7 @@ function addMapEntry(): void {
if (!canAddMapEntry.value || parsedNewMapKey.value === null) return
const key = parsedNewMapKey.value
const value = mapTemplate.value
? blankFromConfiguratorStructure(mapTemplate.value)
? blankFromStructure(mapTemplate.value)
: blankCollectionValue(
newMapValueKind.value,
mapEntries.value.slice(0, 50).map((entry) => entry.value),
@@ -54,14 +54,6 @@ 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',
@@ -77,17 +69,6 @@ const configuratorServer = readFileSync(
),
'utf8',
)
const configDefault = readFileSync(
new URL(
'../../../sky_phone/source/shared/config_default.lua',
import.meta.url,
),
'utf8',
)
const frontendBuild = readFileSync(
new URL('../../build.cjs', import.meta.url),
'utf8',
)
const configuratorClient = readFileSync(
new URL(
'../../../sky_phone/source/client/phone_configurator.lua',
@@ -152,23 +133,24 @@ describe('standalone admin panel contracts', () => {
}
})
it('uses a fixed blue accent and replaces appearance controls with live statistics', () => {
it('removes manual reload and applies a persistent global accent choice', () => {
expect(source).not.toContain('<RefreshCw')
expect(source).not.toContain("kind: 'refresh'")
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("'sky-phone-admin-accent'")
expect(source).toContain("'sky-phone-admin-font-family'")
expect(source).toContain("'sky-phone-admin-font-size'")
expect(source).toContain("'--admin-accent': accentColor")
expect(source).toContain("'--admin-font-family': activeAdminFontFamily")
expect(source).toContain(
"'--admin-font-scale': String(adminFontSize / 100)",
)
expect(source).toContain('class="admin-panel-color-value"')
expect(source).toContain('class="admin-panel-rgb-fields"')
expect(source).toContain('class="admin-panel-rgb-range"')
expect(source).toContain('class="admin-panel-font-select"')
expect(source).toContain('class="admin-panel-font-select__menu"')
expect(source).toContain('class="admin-panel-font-size-control"')
expect(source).not.toContain('type="color"')
expect(source).toContain('color-mix(in srgb, var(--admin-green)')
expect(source).toContain('--admin-toggle-on: #63d471')
expect(source).toContain('background: var(--admin-toggle-on)')
@@ -188,22 +170,6 @@ 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 [
@@ -250,14 +216,12 @@ 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.HasPermission(source, "phonepanel")',
'Bridge.Framework.HasAdminGroup(source, Config.AdminPanel.AdminGroups)',
)
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', () => {
@@ -308,12 +272,6 @@ describe('standalone admin panel contracts', () => {
expect(schema).toContain(
'CREATE TABLE IF NOT EXISTS `sky_phone_configurator`',
)
expect(
manifest.indexOf("'source/shared/config_default.lua'"),
).toBeGreaterThan(manifest.indexOf("'config/media.lua'"))
expect(manifest.indexOf("'source/shared/config_default.lua'")).toBeLessThan(
manifest.indexOf("'source/server/phone_configurator.lua'"),
)
expect(
manifest.indexOf("'source/server/phone_configurator.lua'"),
).toBeLessThan(manifest.indexOf("'source/bridge/server/framework.lua'"))
@@ -323,36 +281,11 @@ describe('standalone admin panel contracts', () => {
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"/,
/for key, value in pairs\(Config\)[\s\S]*?key ~= "Media"[\s\S]*?key ~= "PhoneConfigurator"/,
)
expect(configuratorServer).toContain(
'default_media = serialize_value(ConfigDefaults.Media)',
'default_media = serialize_value(Config.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',
)
@@ -497,8 +430,7 @@ describe('standalone admin panel contracts', () => {
expect(configuratorValueEditor).toContain('function tableFieldStructure(')
expect(configuratorValueEditor).toContain('function isFixedTableField(')
expect(configuratorValueEditor).toContain('function blankCollectionValue(')
expect(configuratorValueEditor).toContain('blankFromConfiguratorStructure')
expect(configuratorValueEditor).toContain('createMutableTableEntry(')
expect(configuratorValueEditor).toContain('function blankFromStructure(')
expect(source).toContain("'is-structured': field.type === 'json'")
expect(configuratorValueEditor).toContain('function isStructuredValue(')
expect(configuratorValueEditor).toContain(
+537 -191
View File
@@ -1,9 +1,9 @@
<script setup lang="ts">
import {
ChartNoAxesCombined,
BadgeDollarSign,
BriefcaseBusiness,
Check,
ChevronDown,
ChevronRight,
CircleUserRound,
Clipboard,
@@ -16,6 +16,7 @@ import {
LoaderCircle,
LockKeyhole,
MessageSquare,
Palette,
PhoneCall,
PhoneForwarded,
Save,
@@ -80,6 +81,17 @@ type AdminTab =
type ConfiguratorScope = 'config' | 'media'
type DeviceAction = 'reset-passcode' | 'change-number' | 'factory-reset'
type PendingAction = { kind: 'close' } | { kind: 'player'; source: number }
type AccentChannel = 'blue' | 'green' | 'red'
type AdminFontFamily =
| 'classic'
| 'georgia'
| 'inter'
| 'mono'
| 'system'
| 'tahoma'
| 'trebuchet'
| 'verdana'
const emit = defineEmits<{ close: [] }>()
const admin = useAdminStore()
const phone = usePhoneStore()
@@ -97,11 +109,61 @@ const revealDialogImei = ref('')
const deviceAction = ref<DeviceAction | null>(null)
const deviceActionInput = ref('')
const discardDialog = ref(false)
const fontMenuOpen = ref(false)
const pendingAction = ref<PendingAction | null>(null)
const toast = ref('')
const toastTone = ref<'error' | 'success'>('success')
let toastTimer: number | undefined
let statisticsTimer: number | undefined
const accentOptions = [
{ color: '#74d66f', key: 'emerald' },
{ color: '#4f9cff', key: 'blue' },
{ color: '#a875ff', key: 'violet' },
{ color: '#f0a24b', key: 'orange' },
{ color: '#ef6969', key: 'red' },
] as const
const accentChannels = ['red', 'green', 'blue'] as const
const fontFamilyOptions: Array<{
key: AdminFontFamily
value: string
}> = [
{ key: 'inter', value: 'var(--sky-font-family, Inter, sans-serif)' },
{
key: 'system',
value:
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
},
{ key: 'classic', value: 'Arial, Helvetica, sans-serif' },
{ key: 'verdana', value: 'Verdana, Geneva, sans-serif' },
{ key: 'tahoma', value: 'Tahoma, Geneva, sans-serif' },
{
key: 'trebuchet',
value: '"Trebuchet MS", "Lucida Grande", sans-serif',
},
{ key: 'georgia', value: 'Georgia, "Times New Roman", serif' },
{
key: 'mono',
value:
'ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace',
},
]
const accentColor = ref('#74d66f')
const adminFontFamily = ref<AdminFontFamily>('inter')
const adminFontSize = ref(100)
const adminFontSizePreview = ref(100)
const activeAdminFontFamily = computed(
() =>
fontFamilyOptions.find((option) => option.key === adminFontFamily.value)
?.value ?? fontFamilyOptions[0].value,
)
const accentRgb = computed(() => {
const color = accentColor.value.replace('#', '')
return {
red: Number.parseInt(color.slice(0, 2), 16),
green: Number.parseInt(color.slice(2, 4), 16),
blue: Number.parseInt(color.slice(4, 6), 16),
}
})
const filteredPlayers = computed(() => {
const needle = playerQuery.value.trim().toLocaleLowerCase(phone.lang)
@@ -134,9 +196,7 @@ const manageableApps = computed(() => {
const needle = appQuery.value.trim().toLocaleLowerCase(phone.lang)
return PHONE_APPS.filter(
(app): app is LaunchablePhoneAppDefinition =>
isLaunchablePhoneApp(app) &&
!app.adminOnly &&
!admin.disabledApps.includes(app.id),
isLaunchablePhoneApp(app) && !app.adminOnly,
)
.filter(
(app) =>
@@ -535,9 +595,7 @@ function queueAction(action: PendingAction): void {
function selectTab(nextTab: AdminTab): void {
tab.value = nextTab
if (nextTab === 'overview' && admin.initialized && !admin.loading) {
void admin.load()
}
fontMenuOpen.value = false
if (nextTab === 'configurator' && !admin.configurator) {
void admin.loadConfigurator().then((loaded) => {
if (!loaded) showToast(errorText(), 'error')
@@ -545,13 +603,61 @@ function selectTab(nextTab: AdminTab): void {
}
}
function formatStatistic(value: number): string {
return value.toLocaleString(phone.lang)
function selectAccent(color: string): void {
if (!/^#[0-9a-f]{6}$/i.test(color)) return
accentColor.value = color.toLowerCase()
window.localStorage.setItem('sky-phone-admin-accent', accentColor.value)
}
function statisticPercentage(value: number, total: number): number {
if (total <= 0) return 0
return Math.min(100, Math.round((value / total) * 100))
function updateAccent(event: Event): void {
const input = event.currentTarget as HTMLInputElement
if (/^#[0-9a-f]{6}$/i.test(input.value)) selectAccent(input.value)
else input.value = accentColor.value.toUpperCase()
}
function updateAccentChannel(channel: AccentChannel, event: Event): void {
const input = event.currentTarget as HTMLInputElement
const value = Math.min(255, Math.max(0, Number(input.value) || 0))
const channels = { ...accentRgb.value, [channel]: value }
selectAccent(
`#${channels.red.toString(16).padStart(2, '0')}${channels.green
.toString(16)
.padStart(2, '0')}${channels.blue.toString(16).padStart(2, '0')}`,
)
}
function accentChannelGradient(channel: AccentChannel): string {
const { red, green, blue } = accentRgb.value
if (channel === 'red') {
return `linear-gradient(90deg, rgb(0, ${green}, ${blue}), rgb(255, ${green}, ${blue}))`
}
if (channel === 'green') {
return `linear-gradient(90deg, rgb(${red}, 0, ${blue}), rgb(${red}, 255, ${blue}))`
}
return `linear-gradient(90deg, rgb(${red}, ${green}, 0), rgb(${red}, ${green}, 255))`
}
function selectAdminFontFamily(family: AdminFontFamily): void {
adminFontFamily.value = family
fontMenuOpen.value = false
window.localStorage.setItem('sky-phone-admin-font-family', family)
}
function closeFontMenu(event: FocusEvent): void {
const menu = event.currentTarget as HTMLElement
if (!menu.contains(event.relatedTarget as Node | null)) {
fontMenuOpen.value = false
}
}
function updateAdminFontSize(event: Event): void {
const size = Number((event.currentTarget as HTMLInputElement).value)
adminFontSize.value = Math.min(115, Math.max(90, size))
adminFontSizePreview.value = adminFontSize.value
window.localStorage.setItem(
'sky-phone-admin-font-size',
String(adminFontSize.value),
)
}
async function runAction(action: PendingAction): Promise<void> {
@@ -812,6 +918,10 @@ function auditDescription(entry: AdminAuditEntry): string {
function onKeydown(event: KeyboardEvent): void {
if (event.key !== 'Escape') return
event.preventDefault()
if (fontMenuOpen.value) {
fontMenuOpen.value = false
return
}
if (revealDialogImei.value) {
revealDialogImei.value = ''
return
@@ -831,21 +941,46 @@ function onKeydown(event: KeyboardEvent): void {
onMounted(() => {
document.addEventListener('keydown', onKeydown)
const storedAccent = window.localStorage.getItem('sky-phone-admin-accent')
if (storedAccent && /^#[0-9a-f]{6}$/i.test(storedAccent)) {
accentColor.value = storedAccent.toLowerCase()
}
const storedFontFamily = window.localStorage.getItem(
'sky-phone-admin-font-family',
)
if (
storedFontFamily &&
fontFamilyOptions.some((option) => option.key === storedFontFamily)
) {
adminFontFamily.value = storedFontFamily as AdminFontFamily
}
const storedFontSize = Number(
window.localStorage.getItem('sky-phone-admin-font-size'),
)
if (storedFontSize >= 90 && storedFontSize <= 115) {
adminFontSize.value = storedFontSize
adminFontSizePreview.value = storedFontSize
}
void refreshData()
statisticsTimer = window.setInterval(() => {
if (tab.value === 'overview' && !admin.loading) void admin.load()
}, 15_000)
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKeydown)
if (toastTimer) window.clearTimeout(toastTimer)
if (statisticsTimer) window.clearInterval(statisticsTimer)
})
</script>
<template>
<div class="admin-panel-overlay" role="dialog" :aria-label="t('name')">
<div
class="admin-panel-overlay"
role="dialog"
:aria-label="t('name')"
:style="{
'--admin-accent': accentColor,
'--admin-font-family': activeAdminFontFamily,
'--admin-font-scale': String(adminFontSize / 100),
}"
>
<div class="admin-panel-window">
<header class="admin-panel-header">
<div class="admin-panel-brand">
@@ -1249,22 +1384,22 @@ onBeforeUnmount(() => {
<article>
<UsersRound :size="18" />
<span>{{ t('overview.online') }}</span>
<strong>{{ formatStatistic(admin.stats.online) }}</strong>
<strong>{{ admin.stats.online }}</strong>
</article>
<article>
<Smartphone :size="18" />
<span>{{ t('overview.devices') }}</span>
<strong>{{ formatStatistic(admin.stats.devices) }}</strong>
<strong>{{ admin.stats.devices }}</strong>
</article>
<article>
<Database :size="18" />
<span>{{ t('overview.accounts') }}</span>
<strong>{{ formatStatistic(admin.stats.accounts) }}</strong>
<strong>{{ admin.stats.accounts }}</strong>
</article>
<article>
<ScrollText :size="18" />
<span>{{ t('overview.audit') }}</span>
<strong>{{ formatStatistic(admin.stats.auditEntries) }}</strong>
<strong>{{ admin.audit.length }}</strong>
</article>
</div>
@@ -1273,100 +1408,146 @@ onBeforeUnmount(() => {
>
<div class="admin-panel-section-card__heading">
<div>
<span>{{ t('statistics.eyebrow') }}</span>
<h2>{{ t('statistics.title') }}</h2>
<p>{{ t('statistics.body') }}</p>
<span>{{ t('appearance.eyebrow') }}</span>
<h2>{{ t('appearance.title') }}</h2>
<p>{{ t('appearance.body') }}</p>
</div>
<ChartNoAxesCombined :size="20" />
<Palette :size="20" />
</div>
<div class="admin-panel-statistics-layout">
<section class="admin-panel-activity-statistics">
<div class="admin-panel-statistics-heading">
<div>
<strong>{{ t('statistics.today') }}</strong>
<small>{{ t('statistics.todayBody') }}</small>
</div>
<div class="admin-panel-accent-picker">
<button
v-for="option in accentOptions"
:key="option.color"
type="button"
:class="{ 'is-active': accentColor === option.color }"
:aria-label="t('appearance.colors.' + option.key)"
:title="t('appearance.colors.' + option.key)"
@click="selectAccent(option.color)"
>
<span :style="{ backgroundColor: option.color }"></span>
<strong>{{ t('appearance.colors.' + option.key) }}</strong>
<Check v-if="accentColor === option.color" :size="15" />
</button>
</div>
<div class="admin-panel-appearance-controls">
<section class="admin-panel-appearance-control is-color">
<div>
<strong>{{ t('appearance.controls.customColor') }}</strong>
<small>{{
t('appearance.controls.customColorBody')
}}</small>
</div>
<div class="admin-panel-activity-grid">
<article>
<MessageSquare :size="17" />
<strong>{{
formatStatistic(admin.stats.messagesToday)
}}</strong>
<span>{{ t('statistics.messagesToday') }}</span>
</article>
<article>
<PhoneCall :size="17" />
<strong>{{
formatStatistic(admin.stats.callsToday)
}}</strong>
<span>{{ t('statistics.callsToday') }}</span>
</article>
<article>
<ScrollText :size="17" />
<strong>{{
formatStatistic(admin.stats.auditToday)
}}</strong>
<span>{{ t('statistics.auditToday') }}</span>
</article>
<div class="admin-panel-rgb-picker">
<label class="admin-panel-color-value">
<span :style="{ backgroundColor: accentColor }"></span>
<input
v-config-input-width
type="text"
maxlength="7"
:value="accentColor.toUpperCase()"
:aria-label="t('appearance.controls.hex')"
@change="updateAccent"
/>
</label>
<div class="admin-panel-rgb-fields">
<label v-for="channel in accentChannels" :key="channel">
<span>{{ t(`appearance.controls.${channel}`) }}</span>
<input
class="admin-panel-rgb-range"
type="range"
min="0"
max="255"
:value="accentRgb[channel]"
:style="{
background: accentChannelGradient(channel),
}"
:aria-label="t(`appearance.controls.${channel}`)"
@input="updateAccentChannel(channel, $event)"
/>
<input
v-config-input-width
type="number"
min="0"
max="255"
:value="accentRgb[channel]"
:aria-label="`${t(`appearance.controls.${channel}`)} ${t('appearance.controls.value')}`"
@input="updateAccentChannel(channel, $event)"
/>
</label>
</div>
</div>
</section>
<section class="admin-panel-coverage-statistics">
<div class="admin-panel-statistics-heading">
<div>
<strong>{{ t('statistics.coverage') }}</strong>
<small>{{ t('statistics.coverageBody') }}</small>
<section class="admin-panel-appearance-control">
<span>
<strong>{{ t('appearance.controls.fontFamily') }}</strong>
<small>{{ t('appearance.controls.fontFamilyBody') }}</small>
</span>
<div
class="admin-panel-font-select"
@focusout="closeFontMenu"
>
<button
type="button"
:aria-label="t('appearance.controls.fontFamily')"
aria-haspopup="listbox"
:aria-expanded="fontMenuOpen"
@click="fontMenuOpen = !fontMenuOpen"
>
<span :style="{ fontFamily: activeAdminFontFamily }">
{{ t(`appearance.controls.fonts.${adminFontFamily}`) }}
</span>
<ChevronDown :size="14" />
</button>
<div
v-if="fontMenuOpen"
class="admin-panel-font-select__menu"
role="listbox"
:aria-label="t('appearance.controls.fontFamily')"
>
<button
v-for="option in fontFamilyOptions"
:key="option.key"
type="button"
role="option"
:aria-selected="adminFontFamily === option.key"
:class="{ 'is-active': adminFontFamily === option.key }"
:style="{ fontFamily: option.value }"
@click="selectAdminFontFamily(option.key)"
>
<span>{{
t(`appearance.controls.fonts.${option.key}`)
}}</span>
<Check
v-if="adminFontFamily === option.key"
:size="13"
/>
</button>
</div>
</div>
<div class="admin-panel-coverage-list">
<article
v-for="metric in [
{
key: 'linkedDevices',
value: admin.stats.linkedDevices,
},
{
key: 'simDevices',
value: admin.stats.simDevices,
},
{
key: 'activeDevices',
value: admin.stats.activeDevices,
},
]"
:key="metric.key"
>
<div>
<strong>{{ t(`statistics.${metric.key}`) }}</strong>
<span>{{
t('statistics.ofDevices', {
count: formatStatistic(metric.value),
total: formatStatistic(admin.stats.devices),
})
}}</span>
</div>
<div
class="admin-panel-statistics-progress"
role="progressbar"
:aria-label="t(`statistics.${metric.key}`)"
:aria-valuemin="0"
:aria-valuemax="100"
:aria-valuenow="
statisticPercentage(metric.value, admin.stats.devices)
"
>
<span
:style="{
width: `${statisticPercentage(metric.value, admin.stats.devices)}%`,
}"
></span>
</div>
</article>
</div>
</section>
<label class="admin-panel-appearance-control">
<span>
<strong>{{ t('appearance.controls.fontSize') }}</strong>
<small>{{ t('appearance.controls.fontSizeBody') }}</small>
</span>
<div class="admin-panel-font-size-control">
<input
v-model.number="adminFontSizePreview"
type="range"
min="90"
max="115"
step="5"
:aria-label="t('appearance.controls.fontSize')"
@change="updateAdminFontSize"
/>
<strong>{{ adminFontSizePreview }}%</strong>
</div>
</label>
</div>
</article>
</section>
<section
@@ -2307,8 +2488,7 @@ onBeforeUnmount(() => {
--admin-text: #f0f3f0;
--admin-muted: #818781;
--admin-dim: #555b55;
--admin-accent: #00b8e4;
--admin-green: var(--admin-accent);
--admin-green: var(--admin-accent, #74d66f);
--admin-green-soft: color-mix(in srgb, var(--admin-green) 9%, transparent);
--admin-toggle-on: #63d471;
--admin-row-hover: linear-gradient(
@@ -2332,7 +2512,10 @@ onBeforeUnmount(() => {
padding: 2.5vh 2.5vw;
color: var(--admin-text);
background: transparent;
font-family: var(--sky-font-family, Inter, sans-serif);
font-family: var(
--admin-font-family,
var(--sky-font-family, Inter, sans-serif)
);
pointer-events: auto;
}
@@ -2346,6 +2529,7 @@ onBeforeUnmount(() => {
box-shadow:
0 20px 56px rgba(0, 0, 0, 0.72),
inset 0 1px rgba(255, 255, 255, 0.018);
zoom: var(--admin-font-scale, 1);
}
.admin-panel-header {
@@ -2469,19 +2653,15 @@ onBeforeUnmount(() => {
}
.admin-panel-save.is-ready {
color: var(--admin-green);
color: #74d66f;
background: transparent;
filter: drop-shadow(
0 0 5px color-mix(in srgb, var(--admin-green) 42%, transparent)
);
filter: drop-shadow(0 0 5px rgba(116, 214, 111, 0.42));
}
.admin-panel-save.is-ready:hover:not(:disabled) {
color: color-mix(in srgb, var(--admin-green) 82%, white);
color: color-mix(in srgb, #74d66f 82%, white);
background: transparent;
filter: drop-shadow(
0 0 7px color-mix(in srgb, var(--admin-green) 65%, transparent)
);
filter: drop-shadow(0 0 7px rgba(116, 214, 111, 0.65));
}
.admin-panel-close:hover {
@@ -3172,127 +3352,293 @@ button:disabled {
gap: 8px;
}
.admin-panel-statistics-layout {
.admin-panel-accent-picker {
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
gap: 8px;
}
.admin-panel-activity-statistics,
.admin-panel-coverage-statistics {
min-width: 0;
padding: 10px;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 3px;
padding: 3px;
border: 0;
border-radius: 3px;
background: #0b0d0c;
}
.admin-panel-statistics-heading {
margin-bottom: 9px;
}
.admin-panel-statistics-heading > div {
.admin-panel-accent-picker button {
min-height: 38px;
display: grid;
gap: 2px;
grid-template-columns: 18px minmax(0, 1fr) 16px;
align-items: center;
gap: 7px;
padding: 7px 9px;
border: 1px solid transparent;
border-radius: 2px;
color: var(--admin-muted);
background: transparent;
text-align: left;
cursor: pointer;
}
.admin-panel-statistics-heading strong {
.admin-panel-accent-picker button:hover,
.admin-panel-accent-picker button.is-active {
color: var(--admin-text);
background: var(--admin-row-active);
}
.admin-panel-accent-picker button > span {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.2);
border-radius: 50%;
}
.admin-panel-accent-picker strong {
overflow: hidden;
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-panel-accent-picker svg {
color: var(--admin-green);
}
.admin-panel-appearance-controls {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
margin-top: 8px;
background: #0a0c0b;
}
.admin-panel-appearance-control {
min-height: 62px;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(120px, 0.8fr);
align-items: center;
gap: 12px;
padding: 10px 12px;
background: #121412;
}
.admin-panel-appearance-control.is-color {
grid-column: 1 / -1;
grid-template-columns: minmax(170px, 0.7fr) minmax(320px, 1.3fr);
}
.admin-panel-appearance-control > div:first-child,
.admin-panel-appearance-control > span:first-child {
min-width: 0;
display: grid;
gap: 3px;
}
.admin-panel-appearance-control strong {
color: #d9ddd9;
font-size: 9px;
font-weight: 600;
}
.admin-panel-statistics-heading small {
.admin-panel-appearance-control small {
color: var(--admin-muted);
font-size: 8px;
line-height: 1.35;
line-height: 1.3;
}
.admin-panel-activity-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 3px;
}
.admin-panel-activity-grid article {
.admin-panel-rgb-picker {
min-width: 0;
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
grid-template-columns: auto minmax(230px, 1fr);
align-items: center;
gap: 2px 6px;
padding: 10px;
background: #121412;
gap: 14px;
}
.admin-panel-activity-grid svg {
color: var(--admin-green);
.admin-panel-color-value {
min-width: 96px;
height: 32px;
display: flex;
align-items: center;
gap: 7px;
padding: 0 8px;
border-radius: 3px;
outline: 1px solid var(--admin-border-strong);
background: #1a1d1b;
}
.admin-panel-activity-grid strong {
overflow: hidden;
font-size: 13px;
font-weight: 620;
text-overflow: ellipsis;
white-space: nowrap;
.admin-panel-color-value > span {
width: 16px;
height: 16px;
flex: 0 0 auto;
border-radius: 3px;
box-shadow: 0 0 8px color-mix(in srgb, var(--admin-green) 48%, transparent);
}
.admin-panel-activity-grid span {
grid-column: 1 / -1;
overflow: hidden;
color: var(--admin-muted);
font-size: 7px;
text-overflow: ellipsis;
.admin-panel-color-value input {
min-width: 0;
height: 24px;
padding: 0;
border: 0;
outline: 0;
color: #cbd0cb;
background: transparent;
font: inherit;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 8px;
text-transform: uppercase;
white-space: nowrap;
}
.admin-panel-coverage-list {
.admin-panel-rgb-fields {
display: grid;
gap: 8px;
gap: 6px;
}
.admin-panel-coverage-list article {
.admin-panel-rgb-fields label {
min-width: 0;
display: grid;
gap: 5px;
grid-template-columns: 12px minmax(90px, 1fr) 42px;
align-items: center;
gap: 7px;
}
.admin-panel-coverage-list article > div:first-child {
.admin-panel-rgb-fields span {
color: var(--admin-muted);
font-size: 8px;
font-weight: 650;
text-transform: uppercase;
}
.admin-panel-rgb-fields input[type='number'] {
width: 42px !important;
max-width: 42px;
height: 24px;
padding: 0 6px;
border: 0;
border-radius: 3px;
outline: 1px solid var(--admin-border-strong);
color: var(--admin-text);
background: #1a1d1b;
font: inherit;
font-size: 8px;
appearance: textfield;
}
.admin-panel-rgb-fields input[type='number']::-webkit-inner-spin-button,
.admin-panel-rgb-fields input[type='number']::-webkit-outer-spin-button {
appearance: none;
}
.admin-panel-rgb-range {
width: 100%;
height: 4px;
padding: 0;
border: 0;
border-radius: 999px;
outline: 0;
appearance: none;
cursor: pointer;
}
.admin-panel-rgb-range::-webkit-slider-thumb {
width: 13px;
height: 13px;
border: 2px solid #f3f5f3;
border-radius: 50%;
background: var(--admin-green);
box-shadow: 0 0 0 2px #171a18;
appearance: none;
}
.admin-panel-font-select {
position: relative;
min-width: 0;
}
.admin-panel-font-select > button {
width: 100%;
height: 32px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
gap: 8px;
padding: 0 9px;
border: 0;
border-radius: 3px;
outline: 1px solid var(--admin-border-strong);
color: var(--admin-text);
background: #1a1d1b;
font: inherit;
font-size: 9px;
text-align: left;
cursor: pointer;
}
.admin-panel-coverage-list strong,
.admin-panel-coverage-list span {
overflow: hidden;
font-size: 8px;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-panel-coverage-list strong {
color: #d9ddd9;
font-weight: 560;
}
.admin-panel-coverage-list span {
.admin-panel-font-select > button svg {
color: var(--admin-muted);
transition: transform 150ms ease;
}
.admin-panel-statistics-progress {
.admin-panel-font-select > button[aria-expanded='true'] svg {
transform: rotate(180deg);
}
.admin-panel-font-select__menu {
position: absolute;
z-index: 12;
top: calc(100% + 5px);
right: 0;
left: 0;
max-height: 210px;
overflow-y: auto;
display: grid;
gap: 1px;
padding: 4px;
border-radius: 4px;
outline: 1px solid rgba(255, 255, 255, 0.09);
background: #101211;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.58);
}
.admin-panel-font-select__menu button {
min-height: 30px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 8px;
border: 0;
border-radius: 2px;
color: var(--admin-muted);
background: transparent;
font-size: 9px;
text-align: left;
cursor: pointer;
}
.admin-panel-font-select__menu button:hover,
.admin-panel-font-select__menu button.is-active {
color: var(--admin-text);
background: var(--admin-row-active);
}
.admin-panel-font-select__menu svg {
color: var(--admin-green);
}
.admin-panel-font-size-control {
display: grid;
grid-template-columns: minmax(80px, 1fr) 38px;
align-items: center;
gap: 9px;
}
.admin-panel-font-size-control input {
width: 100%;
height: 3px;
overflow: hidden;
border-radius: 999px;
background: #1d201e;
accent-color: var(--admin-green);
cursor: pointer;
}
.admin-panel-statistics-progress > span {
height: 100%;
display: block;
border-radius: inherit;
background: var(--admin-green);
box-shadow: 0 0 8px color-mix(in srgb, var(--admin-green) 52%, transparent);
transition: width 180ms ease;
.admin-panel-font-size-control > strong {
color: var(--admin-green);
text-align: right;
}
.admin-panel-section-card__heading {
@@ -26,10 +26,6 @@ const mainCss = readFileSync(
new URL('../assets/main.css', import.meta.url),
'utf8',
)
const openedFolderAppBlocks =
overlaySource.match(/<AppIcon\b[\s\S]*?\/>/g) ?? []
const homeFolderIconBlocks =
springboardSource.match(/<HomeFolderIcon\b[\s\S]*?\/>/g) ?? []
describe('Home folder interaction contract', () => {
it('opens folders from edit mode and starts dragging only after movement', () => {
@@ -40,7 +36,7 @@ describe('Home folder interaction contract', () => {
expect(iconSource).not.toContain('props.editMode || suppressClick.value')
})
it('keeps whole-folder dragging aligned at every phone scale', () => {
it('keeps folder app dragging aligned at every phone scale', () => {
expect(dragSource).toContain("'.springboard-page, .home-folder-panel'")
expect(dragSource).toContain('viewportWidth: bounds.width')
expect(dragSource).not.toContain("getPropertyValue('zoom')")
@@ -51,60 +47,6 @@ describe('Home folder interaction contract', () => {
)
})
it('routes whole folders from both the grid and dock through the shared drag portal', () => {
expect(homeFolderIconBlocks).toHaveLength(2)
for (const area of ['grid', 'dock'] as const) {
const block = homeFolderIconBlocks.find((candidate) =>
candidate.includes(`data-home-area="${area}"`),
)
expect(block).toBeDefined()
expect(block).toContain(':external-drag-visual="homeDragVisualActive"')
expect(block).toContain('@dragcancel="stopHomeDrag"')
expect(block).toContain('@dragend="finishHomeDrag"')
expect(block).toContain('@dragmove="moveHomeDrag"')
expect(block).toContain(`@dragstart="startHomeDrag('${area}',`)
}
})
it('uses the external viewport visual for apps dragged from an opened folder', () => {
expect(openedFolderAppBlocks).toHaveLength(1)
expect(overlaySource).toMatch(/externalDragVisual\??:\s*boolean/)
expect(openedFolderAppBlocks[0]).toContain(
':external-drag-visual="externalDragVisual"',
)
expect(appIconSource).toContain('externalDragVisual?: boolean')
expect(appIconSource).toContain('app-icon-item--drag-source')
})
it('forwards opened-folder pointer start, move, and cancellation to the parent session', () => {
expect(overlaySource).toMatch(
/function startFolderAppDrag\(index: number, event: PointerEvent\)/,
)
expect(overlaySource).toContain("emit('dragstart', index, event)")
expect(overlaySource).toContain("emit('dragmove', event)")
expect(overlaySource).toContain("emit('dragcancel')")
expect(openedFolderAppBlocks[0]).toContain(
'@dragstart="startFolderAppDrag(entry.index, $event)"',
)
expect(openedFolderAppBlocks[0]).toContain('@dragmove="moveFolderAppDrag"')
expect(openedFolderAppBlocks[0]).toContain(
'@dragcancel="stopFolderAppDrag"',
)
})
it('hit-tests the opened folder panel and app targets in normalized viewport coordinates', () => {
expect(overlaySource).toContain('readPhoneViewportGeometry')
expect(overlaySource).toContain('phoneViewportRectContainsPoint')
expect(overlaySource).toContain('folderDragViewportRect(panel)')
expect(overlaySource).toContain('folderDragViewportRect(element)')
expect(overlaySource).toMatch(/geometry\?\.rect\(element\)/)
expect(overlaySource).not.toContain(
'panelElement.value?.getBoundingClientRect()',
)
expect(overlaySource).not.toContain('document.elementsFromPoint')
})
it('edits the folder name inline with Sky UI controls', () => {
expect(overlaySource).toContain('<SkyField')
expect(overlaySource).toContain('home-folder-heading--editing')
@@ -121,9 +63,8 @@ describe('Home folder interaction contract', () => {
expect(springboardSource).toContain(
'@drag-outside-change="folderDraggingOutside = $event"',
)
expect(overlaySource).toContain('if (draggingIndex.value === null) return')
expect(overlaySource).toContain(
'if (!draggingOutside.value && !isPointerInsidePanel(event))',
'if (draggingIndex.value === null || draggingOutside.value) return',
)
expect(overlaySource).toContain(
'draggingOutside.value || !isPointerInsidePanel(event)',
+26 -92
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Check, Pencil, X } from 'lucide-vue-next'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { usePhoneStore } from '@/stores/phone'
@@ -11,35 +11,20 @@ import {
HOME_FOLDER_PAGE_SIZE,
type HomeFolder,
} from '@/utils/homeLayout'
import {
phoneViewportRectContainsPoint,
readPhoneViewportGeometry,
type PhoneViewportGeometry,
type PhoneViewportRect,
} from '@/utils/phoneViewportGeometry'
type FolderAppEntry = {
app: PhoneAppDefinition
index: number
}
const props = withDefaults(
defineProps<{
apps: FolderAppEntry[]
editMode: boolean
externalDragVisual?: boolean
folder: HomeFolder
renameOnOpen: boolean
}>(),
{
externalDragVisual: false,
},
)
const props = defineProps<{
apps: FolderAppEntry[]
editMode: boolean
folder: HomeFolder
renameOnOpen: boolean
}>()
const emit = defineEmits<{
close: []
dragcancel: []
dragmove: [event: PointerEvent]
dragstart: [index: number, event: PointerEvent]
'drag-outside-change': [outside: boolean]
edit: []
extract: [index: number, event: PointerEvent]
@@ -58,7 +43,6 @@ const renameOpened = ref(false)
const renameDraft = ref('')
let pagePointerStart = 0
let pagePointerId: number | null = null
let dragGeometry: PhoneViewportGeometry | null = null
const folderName = computed(
() => props.folder.name || phone.t('Home.folders.defaultName'),
@@ -129,48 +113,25 @@ function saveRename(): void {
renameOpened.value = false
}
function startFolderAppDrag(index: number, event: PointerEvent): void {
function startFolderAppDrag(index: number): void {
draggingIndex.value = index
dragGeometry = readPhoneViewportGeometry(panelElement.value)
setDraggingOutside(false)
emit('dragstart', index, event)
}
function fallbackViewportRect(element: Element): PhoneViewportRect {
const bounds = element.getBoundingClientRect()
return {
bottom: bounds.bottom,
height: bounds.height,
left: bounds.left,
right: bounds.right,
top: bounds.top,
width: bounds.width,
}
}
function folderDragViewportRect(element: Element): PhoneViewportRect {
const geometry = dragGeometry ?? readPhoneViewportGeometry(element)
return geometry?.rect(element) ?? fallbackViewportRect(element)
}
function isPointerInsidePanel(event: PointerEvent): boolean {
const panel = panelElement.value
const panelBounds = panelElement.value?.getBoundingClientRect()
if (!panelBounds) return false
return (
panel !== null &&
phoneViewportRectContainsPoint(
folderDragViewportRect(panel),
event.clientX,
event.clientY,
)
event.clientX >= panelBounds.left &&
event.clientX <= panelBounds.right &&
event.clientY >= panelBounds.top &&
event.clientY <= panelBounds.bottom
)
}
function moveFolderAppDrag(event: PointerEvent): void {
if (draggingIndex.value === null) return
if (!draggingOutside.value && !isPointerInsidePanel(event)) {
setDraggingOutside(true)
}
emit('dragmove', event)
if (draggingIndex.value === null || draggingOutside.value) return
if (!isPointerInsidePanel(event)) setDraggingOutside(true)
}
function setDraggingOutside(outside: boolean): void {
@@ -184,52 +145,31 @@ function finishFolderAppDrag(event: PointerEvent): void {
const shouldExtract = draggingOutside.value || !isPointerInsidePanel(event)
draggingIndex.value = null
if (sourceIndex === null) {
dragGeometry = null
setDraggingOutside(false)
emit('dragcancel')
return
}
if (shouldExtract) {
emit('extract', sourceIndex, event)
dragGeometry = null
setDraggingOutside(false)
return
}
setDraggingOutside(false)
const target = Array.from(
panelElement.value?.querySelectorAll<HTMLElement>(
'[data-folder-app-index]',
) ?? [],
).find(
(element) =>
Number(element.dataset.folderAppIndex) !== sourceIndex &&
phoneViewportRectContainsPoint(
folderDragViewportRect(element),
event.clientX,
event.clientY,
),
)
if (!target) {
dragGeometry = null
emit('dragcancel')
return
}
const target = document
.elementsFromPoint(event.clientX, event.clientY)
.map((element) => element.closest<HTMLElement>('[data-folder-app-index]'))
.find(
(element) =>
element && Number(element.dataset.folderAppIndex) !== sourceIndex,
)
if (!target) return
const targetIndex = Number(target.dataset.folderAppIndex)
dragGeometry = null
if (!Number.isInteger(targetIndex)) {
emit('dragcancel')
return
}
emit('move', sourceIndex, targetIndex)
if (Number.isInteger(targetIndex)) emit('move', sourceIndex, targetIndex)
}
function stopFolderAppDrag(): void {
const wasDragging = draggingIndex.value !== null
draggingIndex.value = null
dragGeometry = null
setDraggingOutside(false)
if (wasDragging) emit('dragcancel')
}
function goToPage(page: number): void {
@@ -255,11 +195,6 @@ function finishPageSwipe(event: PointerEvent): void {
}
pagePointerId = null
}
onBeforeUnmount(() => {
stopFolderAppDrag()
pagePointerId = null
})
</script>
<template>
@@ -355,11 +290,10 @@ onBeforeUnmount(() => {
:app="entry.app"
:data-folder-app-index="entry.index"
:edit-mode="editMode"
:external-drag-visual="externalDragVisual"
@dragcancel="stopFolderAppDrag"
@dragend="finishFolderAppDrag"
@dragmove="moveFolderAppDrag"
@dragstart="startFolderAppDrag(entry.index, $event)"
@dragstart="startFolderAppDrag(entry.index)"
@edit="emit('edit')"
/>
</div>
@@ -61,22 +61,16 @@ 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'] as const).filter((id) =>
appStore.isAvailable(id),
),
)
const selectedApps = ref<BuiltinPhoneAppId[]>(['banking', 'garage', 'skyride'])
const setupCompleteBusy = ref(false)
const setupCompleteError = ref('')
const setupApps = computed(() =>
(
['banking', 'garage', 'skyride', 'citymarkt', 'picstagram', 'snake'] as const
).flatMap((id) => {
const app = getPhoneApp(id)
return app && appStore.isAvailable(id) ? [app] : []
}),
)
const setupApps = (
['banking', 'garage', 'skyride', 'citymarkt', 'picstagram', 'snake'] as const
).flatMap((id) => {
const app = getPhoneApp(id)
return app ? [app] : []
})
const wallpaperChoices = WALLPAPER_IDS
const progress = computed(() => `${((step.value + 1) / 10) * 100}%`)
const displayName = computed(() => {
-89
View File
@@ -1,89 +0,0 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
function source(path: string): string {
return readFileSync(new URL(path, import.meta.url), 'utf8')
}
const config = source('../../sky_phone/config/config.lua')
const configDefault = source('../../sky_phone/source/shared/config_default.lua')
const framework = source('../../sky_phone/source/bridge/server/framework.lua')
const qbox = source('../../sky_phone/source/bridge/server/frameworks/qbox.lua')
const configurator = source(
'../../sky_phone/source/server/phone_configurator.lua',
)
const configuratorFixture = source('../testserver/configurator-fixture.cjs')
describe('fixed server permissions', () => {
it('defines every protected phone capability only in config.lua', () => {
expect(config).toContain('Config.CommandPermissions = {')
for (const permission of [
'phonepanel',
'phonetestdata',
'fliptokverify',
'picstagramverify',
'picstagramadmin',
]) {
expect(config).toMatch(new RegExp(`\\s${permission} = \\{`))
}
expect(config).not.toContain('AdminGroups =')
expect(configDefault).not.toContain('Config.PhoneConfigurator')
expect(configDefault).not.toContain('Config.CommandPermissions')
expect(configDefault).not.toContain('AdminGroups =')
})
it('keeps fixed permissions outside SQL and removes legacy group fields', () => {
expect(configurator).toContain('key ~= "CommandPermissions"')
expect(configurator).toContain('if key ~= "CommandPermissions" then')
expect(configuratorFixture).toContain('delete config.CommandPermissions')
for (const path of [
'AdminPanel.AdminGroups',
'TestData.AdminGroups',
'FlipTok.AdminGroups',
'Picstagram.AdminGroups',
]) {
expect(configurator).toContain(`["${path}"] = true`)
}
expect(configurator).toContain(
'SKY PHONE CONFIGURATION FILES ARE DISABLED',
)
expect(configurator).toContain(
'^1 Runtime settings from config.lua and media.lua are DISABLED.^0',
)
expect(configurator).toContain(
'^1 Configure all phone and media settings IN GAME through /phonepanel.^0',
)
expect(configurator).toContain(
'^1 Only Config.PhoneConfigurator.Enabled and Config.CommandPermissions remain file-based.^0',
)
})
it('authorizes Qbox through ACE before retaining framework group support', () => {
expect(framework).toContain(
'local groups = Config.CommandPermissions[permission]',
)
expect(qbox).toContain(
'if IsPlayerAceAllowed(tostring(source), group) then',
)
expect(qbox.indexOf('IsPlayerAceAllowed')).toBeLessThan(
qbox.indexOf('exports.qbx_core:HasGroup'),
)
})
it('uses stable permission identifiers for every protected operation', () => {
const expectations = [
['../../sky_phone/source/server/admin.lua', 'phonepanel'],
['../../sky_phone/source/server/testdata.lua', 'phonetestdata'],
['../../sky_phone/source/server/fliptok.lua', 'fliptokverify'],
['../../sky_phone/source/server/picstagram.lua', 'picstagramverify'],
['../../sky_phone/source/server/picstagram.lua', 'picstagramadmin'],
] as const
for (const [path, permission] of expectations) {
expect(source(path)).toContain(
`Bridge.Framework.HasPermission(source, "${permission}")`,
)
}
})
})
+21 -89
View File
@@ -3,27 +3,17 @@ 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'],
@@ -52,65 +42,17 @@ describe('phone inventory contracts', () => {
)
})
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', () => {
it('auto-detects HEX and limits count-based ESX inventories to metadata-free modes', () => {
const inventoryBridge = readResourceFile(
'source/bridge/server/inventory.lua',
)
expect(inventoryBridge).toContain(
'{ 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',
'GetResourceState("hex_4_inventory") == "started"',
)
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', () => {
@@ -161,15 +103,9 @@ 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')
@@ -201,10 +137,10 @@ describe('phone inventory contracts', () => {
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "ToggleOpen"',
)
expect(phoneClient).toContain(
'local result = Bridge.Callbacks.Trigger(callback_name, {})',
'local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})',
)
expect(phoneClient).toMatch(
/result\.success == true[\s\S]*open_requested = false[\s\S]*open_without_focus = false[\s\S]*return false/,
/result\.success ~= true[\s\S]*open_without_focus = false[\s\S]*return false/,
)
expect(phoneBridge).toContain(
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "IsPhoneOnScreen"',
@@ -224,9 +160,7 @@ 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)')
})
@@ -279,28 +213,26 @@ 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"')
})
it('keeps the phone key mapping command stable so FiveM user rebindings persist', () => {
it('opens from a configurable F1 mapping without client-provided device identity', () => {
const config = readResourceFile('config/config.lua')
const phoneClient = readResourceFile('source/client/main.lua')
const phoneServer = readResourceFile('source/server/phone.lua')
expect(config).toContain('Keybind = "F1"')
expect(phoneClient).toContain('local phone_key_mapping_registered = false')
expect(phoneClient).toContain('refresh_phone_key_mapping = function()')
expect(phoneClient).toMatch(
/RegisterKeyMapping\(\s*"sky_phone_toggle",\s*locale\.Controls\.OpenPhone,\s*"keyboard",\s*key_name\s*\)/,
)
expect(phoneClient).not.toContain('sky_phone_toggle_config_')
expect(phoneClient).not.toContain('key_mapping_revision')
expect(phoneClient).toContain(
'request_phone_open("sky_phone:device:open-request")',
'RegisterKeyMapping(command_name, locale.Controls.OpenPhone, "keyboard", key_name)',
)
expect(phoneClient).toContain(
'if active_key_mapping_command == command_name then',
)
expect(phoneClient).toContain(
'Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})',
)
expect(phoneServer).toContain(
'Bridge.Callbacks.Register("sky_phone:device:open-request", function(source)',
+1 -32
View File
@@ -15,32 +15,7 @@ import type {
} from '@/types/admin'
import { nuiCall, type NuiResponse } from '@/utils/nui'
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()
}
const EMPTY_STATS: AdminStats = { accounts: 0, devices: 0, online: 0 }
export const useAdminStore = defineStore('admin', {
state: () => ({
@@ -50,7 +25,6 @@ export const useAdminStore = defineStore('admin', {
configurator: null as AdminConfigurator | null,
configuratorLoading: false,
detailLoading: false,
disabledApps: [] as string[],
error: '',
initialized: false,
loading: false,
@@ -73,9 +47,6 @@ 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 = ''
@@ -176,7 +147,6 @@ export const useAdminStore = defineStore('admin', {
return false
}
this.configurator = response.data
this.disabledApps = disabledAppsFromConfigurator(response.data)
this.error = ''
return true
},
@@ -197,7 +167,6 @@ export const useAdminStore = defineStore('admin', {
this.actionKey = ''
if (response.success && response.data) {
this.configurator = response.data
this.disabledApps = disabledAppsFromConfigurator(response.data)
this.error = ''
} else {
if (response.data) this.configurator = response.data
-50
View File
@@ -75,21 +75,6 @@ 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()
@@ -474,41 +459,6 @@ describe('app store', () => {
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(5)
})
it('materializes a trailing page only for a successful folder extraction', () => {
const apps = useAppStoreStore()
apps.hydrate(null)
const notesIndex = apps.homeLayout.grid.indexOf('notes')
const settingsIndex = apps.homeLayout.grid.indexOf('settings')
const folderId = apps.createHomeFolder(
'grid',
notesIndex,
'grid',
settingsIndex,
'Utilities',
)
expect(folderId).toBeTruthy()
const originalPageCount = apps.homeLayout.pageCount
const targetPage = originalPageCount + 1
const targetIndex = originalPageCount * HOME_GRID_PAGE_SIZE
expect(apps.homeLayout.grid[targetIndex]).toBeUndefined()
expect(
apps.extractHomeFolderApp('missing-folder', 0, 'grid', targetIndex),
).toBe(false)
expect(apps.homeLayout.pageCount).toBe(originalPageCount)
expect(apps.homeLayout.grid[targetIndex]).toBeUndefined()
mocks.phone.saveDeviceNamespace.mockClear()
expect(apps.extractHomeFolderApp(folderId!, 0, 'grid', targetIndex)).toBe(
true,
)
expect(apps.homeLayout.pageCount).toBe(targetPage)
expect(apps.homeLayout.grid[targetIndex]).toBe('settings')
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
})
it('does not commit an installation to a different phone', () => {
vi.useFakeTimers()
const apps = useAppStoreStore()
+22 -85
View File
@@ -19,7 +19,6 @@ import {
createDefaultHomeLayout,
deleteHomePage,
extractHomeFolderApp,
HOME_GRID_PAGE_SIZE,
moveHomeFolderApp,
moveHomeApp,
moveHomeAppToGridPage,
@@ -46,49 +45,28 @@ const pendingInstallations = new WeakMap<
Map<LaunchablePhoneAppId, PendingInstallation>
>()
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),
)
function getDefaultGridIds(): LaunchablePhoneAppId[] {
return PHONE_APPS.filter((app) => app.dockOrder === null)
.sort((a, b) => a.gridOrder - b.gridOrder)
.map((app) => app.id)
}
function getDefaultDockIds(
disabledApps: readonly LaunchablePhoneAppId[] = [],
): LaunchablePhoneAppId[] {
return PHONE_APPS.filter(
(app) => app.dockOrder !== null && !isAppDisabled(app.id, disabledApps),
)
function getDefaultDockIds(): LaunchablePhoneAppId[] {
return PHONE_APPS.filter((app) => app.dockOrder !== null)
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
.map((app) => app.id)
}
function getDefaultInstalledIds(
disabledApps: readonly LaunchablePhoneAppId[] = [],
): LaunchablePhoneAppId[] {
function getDefaultInstalledIds(): LaunchablePhoneAppId[] {
return PHONE_APPS.filter((app) => {
if (app.adminOnly || isAppDisabled(app.id, disabledApps)) return false
if (app.adminOnly) return false
return isExternalPhoneApp(app)
? app.defaultInstalled
: DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id)
}).map((app) => app.id)
}
function isProtectedHomeApp(
appId: LaunchablePhoneAppId,
disabledApps: readonly LaunchablePhoneAppId[],
): boolean {
if (isAppDisabled(appId, disabledApps)) return false
function isProtectedHomeApp(appId: LaunchablePhoneAppId): boolean {
const app = getPhoneApp(appId)
return app
? !isPhoneAppRemovable(app)
@@ -126,7 +104,6 @@ function hasUninstalledBuiltinApp(
export const useAppStoreStore = defineStore('app-store', {
state: () => ({
claimedApps: [] as LaunchablePhoneAppId[],
disabledApps: [] as LaunchablePhoneAppId[],
uninstalledApps: [] as LaunchablePhoneAppId[],
homeLayout: createDefaultHomeLayout(
getDefaultInstalledIds(),
@@ -153,7 +130,6 @@ 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,
)
@@ -174,7 +150,6 @@ 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] ||
@@ -216,10 +191,6 @@ 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(
@@ -249,14 +220,8 @@ export const useAppStoreStore = defineStore('app-store', {
installations.set(id, { deviceImei, timer, token })
pendingInstallations.set(this, installations)
},
hydrate(payload: unknown, disabledApps: unknown = []): void {
hydrate(payload: 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
@@ -291,22 +256,16 @@ export const useAppStoreStore = defineStore('app-store', {
})
: []
const installedIds = [
...new Set([
...getDefaultInstalledIds(this.disabledApps),
...this.claimedApps,
]),
].filter(
(id) =>
!this.uninstalledApps.includes(id) && this.isAvailable(id),
)
...new Set([...getDefaultInstalledIds(), ...this.claimedApps]),
].filter((id) => !this.uninstalledApps.includes(id))
const removedLegacyDefaults = hasUninstalledBuiltinApp(
data?.homeLayout,
installedIds,
)
const defaults = createDefaultHomeLayout(
installedIds,
getDefaultGridIds(this.disabledApps),
getDefaultDockIds(this.disabledApps),
getDefaultGridIds(),
getDefaultDockIds(),
)
const parsedHomeLayout = parseHomeLayout(
data?.homeLayout,
@@ -318,9 +277,8 @@ export const useAppStoreStore = defineStore('app-store', {
const removedDockGridDuplicates =
normalizedHomeLayout !== parsedHomeLayout
this.homeLayout = normalizedHomeLayout
const protectedHiddenAppIds = this.homeLayout.hidden.filter((appId) =>
isProtectedHomeApp(appId, this.disabledApps),
)
const protectedHiddenAppIds =
this.homeLayout.hidden.filter(isProtectedHomeApp)
for (const appId of protectedHiddenAppIds) {
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
}
@@ -352,11 +310,7 @@ 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
@@ -368,18 +322,12 @@ export const useAppStoreStore = defineStore('app-store', {
},
reconcileCatalog(): void {
const installedIds = [
...new Set([
...getDefaultInstalledIds(this.disabledApps),
...this.claimedApps,
]),
].filter(
(id) =>
!this.uninstalledApps.includes(id) && this.isAvailable(id),
)
...new Set([...getDefaultInstalledIds(), ...this.claimedApps]),
].filter((id) => !this.uninstalledApps.includes(id))
const defaults = createDefaultHomeLayout(
installedIds,
getDefaultGridIds(this.disabledApps),
getDefaultDockIds(this.disabledApps),
getDefaultGridIds(),
getDefaultDockIds(),
)
const previous = JSON.stringify(this.homeLayout)
this.homeLayout = removeDockGridDuplicates(
@@ -387,7 +335,7 @@ export const useAppStoreStore = defineStore('app-store', {
)
for (const appId of [...this.homeLayout.hidden]) {
if (isProtectedHomeApp(appId, this.disabledApps)) {
if (isProtectedHomeApp(appId)) {
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
}
}
@@ -397,7 +345,6 @@ 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()
},
@@ -520,35 +467,25 @@ export const useAppStoreStore = defineStore('app-store', {
to: HomeArea,
targetIndex: number,
): boolean {
let sourceLayout = this.homeLayout
if (to === 'grid' && Number.isInteger(targetIndex) && targetIndex >= 0) {
const targetPage = Math.floor(targetIndex / HOME_GRID_PAGE_SIZE) + 1
while (sourceLayout.pageCount < targetPage) {
const expanded = addHomePage(sourceLayout)
if (expanded === sourceLayout) return false
sourceLayout = expanded
}
}
const next = extractHomeFolderApp(
sourceLayout,
this.homeLayout,
folderId,
sourceIndex,
to,
targetIndex,
)
if (next === sourceLayout) return false
if (next === this.homeLayout) return false
this.homeLayout = next
this.persist()
return true
},
removeHomeApp(appId: LaunchablePhoneAppId): void {
if (isProtectedHomeApp(appId, this.disabledApps)) return
if (isProtectedHomeApp(appId)) 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,22 +10,14 @@ type LuaToken = {
}
const frontendSourceDirectory = fileURLToPath(new URL('../', import.meta.url))
const localeDirectory = fileURLToPath(
new URL('../../../sky_phone/config/locales/', import.meta.url),
const englishLocaleSource = readFileSync(
new URL('../../../sky_phone/config/locales/en.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 germanLocaleSource = readFileSync(
new URL('../../../sky_phone/config/locales/de.lua', import.meta.url),
'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',
@@ -156,10 +148,6 @@ 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',
@@ -235,26 +223,10 @@ function collectFrontendFiles(directory: string): string[] {
}
describe('phone locale contract', () => {
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 englishValues = collectLuaLocaleValues(englishLocaleSource)
const germanValues = collectLuaLocaleValues(germanLocaleSource)
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}`),
@@ -299,52 +271,26 @@ describe('phone locale contract', () => {
}
})
it.each(translatedLocaleValues)(
'keeps %s structurally aligned with English',
(_locale, values) => {
expect([...values.keys()].sort()).toEqual([...englishPaths].sort())
},
)
it('keeps German structurally aligned with English', () => {
const germanPaths = new Set(germanValues.keys())
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(', ')}`,
]
: []
},
)
expect([...germanPaths].sort()).toEqual([...englishPaths].sort())
})
expect(mismatches).toEqual([])
},
)
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(', ')}`,
]
: []
})
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([])
},
)
expect(mismatches).toEqual([])
})
it('keeps standard app names German and custom game names unchanged', () => {
const standardAppNames = {
+37 -20
View File
@@ -33,7 +33,6 @@ export type PasscodeResponseData = {
export type PhoneOpenPayload = {
account?: DeviceBootstrap['account']
device?: PhoneDevice
disabledApps?: string[]
fallbackLocales?: LocaleTree
lang?: string
locales?: LocaleTree
@@ -849,21 +848,40 @@ const adminPanelFallbackLocales = {
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',
appearance: {
eyebrow: 'Appearance',
title: 'Interface',
body: 'Personalize colors and typography across the admin workspace.',
colors: {
emerald: 'Emerald',
blue: 'Blue',
violet: 'Violet',
orange: 'Orange',
red: 'Red',
},
controls: {
customColor: 'Custom color',
customColorBody: 'Choose any RGB accent or enter its channel values.',
red: 'R',
green: 'G',
blue: 'B',
hex: 'HEX color',
value: 'value',
fontFamily: 'Font family',
fontFamilyBody: 'Choose the typeface used by the complete admin panel.',
fontSize: 'Font size',
fontSizeBody: 'Scale text and controls for comfortable readability.',
fonts: {
inter: 'Inter',
system: 'System',
classic: 'Classic',
verdana: 'Verdana',
tahoma: 'Tahoma',
trebuchet: 'Trebuchet',
georgia: 'Georgia',
mono: 'Monospace',
},
},
},
configurator: {
context: 'Runtime configuration',
@@ -927,6 +945,7 @@ const adminPanelFallbackLocales = {
entry: 'Entry',
general: 'General',
subtabs: {
AdminGroups: 'Admin Groups',
Dictionaries: 'Dictionaries',
Clips: 'Clips',
Transforms: 'Transforms',
@@ -2712,8 +2731,6 @@ const defaultLocales: LocaleTree = {
recipient_not_found: 'That number is unavailable.',
blocked: 'This contact has blocked calls and messages from your SIM.',
messaging_unavailable: 'This company contact does not accept messages.',
service_line_text_only:
'Company service lines currently accept text messages only.',
no_sim: 'This phone has no SIM card.',
rate_limited: 'Too many messages. Try again in a minute.',
request_failed: 'Messages are temporarily unavailable.',
@@ -4525,8 +4542,8 @@ const defaultLocales: LocaleTree = {
video: 'Video',
microphoneOn: 'Microphone on',
microphoneOff: 'Microphone muted',
focusHelp: 'Hold the look key to look around',
returnHelp: 'Release the look key for controls',
focusHelp: 'Space for movement',
returnHelp: 'Space to return',
uploading: '{count} uploading',
saving: 'Saving video...',
openGallery: 'Open Photos',
-9
View File
@@ -1,14 +1,7 @@
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 = {
@@ -85,7 +78,6 @@ export type AdminAuditEntry = {
export type AdminBootstrap = {
audit: AdminAuditEntry[]
disabledApps: string[]
players: AdminPlayerSummary[]
stats: AdminStats
}
@@ -138,7 +130,6 @@ export type AdminConfiguratorStructure =
template?: AdminConfiguratorStructure
}
| {
entryDefault?: unknown
fields: Record<string, AdminConfiguratorStructure>
kind: 'table'
mutableKeys?: boolean
-11
View File
@@ -43,17 +43,6 @@ describe('SkyNavbar', () => {
expect(html).toContain('Account')
})
it('constrains long compact titles to the center column', () => {
const titleRule = foundationStyles.match(
/\.sky-navbar__title\s*\{([^}]*)\}/s,
)?.[1]
expect(titleRule).toContain('max-width: 100%')
expect(titleRule).toContain('overflow: hidden')
expect(titleRule).toContain('text-overflow: ellipsis')
expect(titleRule).toContain('white-space: nowrap')
})
it('exposes the large-title header without changing heading semantics', async () => {
const html = await renderToString(
createSSRApp(SkyNavbar, {
-1
View File
@@ -168,7 +168,6 @@
.sky-navbar__title {
min-width: 0;
max-width: 100%;
margin: 0;
padding: 0 var(--sky-space-1);
overflow: hidden;
@@ -1,114 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { AdminConfiguratorStructure } from '@/types/admin'
import { createMutableTableEntry } from '@/utils/adminConfiguratorDefaults'
describe('admin configurator defaults', () => {
it('creates a usable company draft from its key and the server defaults', () => {
const entryDefault = {
AcceptsRequests: true,
Category: 'public_services',
Job: '',
LogoUrl: 'https://picsum.photos/seed/companies-new-logo/180/180',
Name: '',
ServiceLine: {
Number: '500',
Routing: 'round_robin',
},
Services: [
{
Description: '',
Id: '',
Price: '',
RequestsEnabled: true,
Title: '',
},
],
}
const structure: AdminConfiguratorStructure = {
entryDefault,
fields: {},
kind: 'table',
mutableKeys: true,
template: {
fields: {
Job: { kind: 'value', valueType: 'string' },
Name: { kind: 'value', valueType: 'string' },
},
kind: 'table',
},
}
expect(
createMutableTableEntry(
structure,
'Companies.Definitions',
'pizza_palace',
),
).toEqual({
AcceptsRequests: true,
Category: 'public_services',
Job: 'pizza_palace',
LogoUrl: 'https://picsum.photos/seed/companies-pizza_palace-logo/180/180',
Name: 'Pizza Palace',
ServiceLine: {
Number: '500',
Routing: 'round_robin',
},
Services: [
{
Description: '',
Id: 'pizza_palace',
Price: '',
RequestsEnabled: true,
Title: 'Pizza Palace',
},
],
})
expect(entryDefault).toMatchObject({
Job: '',
Name: '',
Services: [{ Id: '', Title: '' }],
})
})
it('allocates distinct service numbers for multiple unsaved companies', () => {
const structure: AdminConfiguratorStructure = {
entryDefault: {
Job: '',
LogoUrl: '',
Name: '',
ServiceLine: { Number: '500' },
},
fields: {},
kind: 'table',
mutableKeys: true,
}
const existing = {
pizzeria: { ServiceLine: { Number: '500' } },
restaurant: { ServiceLine: { Number: '501' } },
}
expect(
createMutableTableEntry(
structure,
'Companies.Definitions',
'bakery',
existing,
),
).toMatchObject({ ServiceLine: { Number: '502' } })
})
it('keeps generic mutable tables on their schema-derived blank value', () => {
const structure: AdminConfiguratorStructure = {
fields: {},
kind: 'table',
mutableKeys: true,
template: { kind: 'value', valueType: 'boolean' },
}
expect(createMutableTableEntry(structure, 'FeatureFlags', 'example')).toBe(
false,
)
})
})
@@ -1,142 +0,0 @@
import type { AdminConfiguratorStructure } from '@/types/admin'
function cloneConfiguratorValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(cloneConfiguratorValue)
if (value !== null && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, child]) => [
key,
cloneConfiguratorValue(child),
]),
)
}
return value
}
function blankScalar(valueType: 'boolean' | 'number' | 'string'): unknown {
if (valueType === 'boolean') return false
if (valueType === 'number') return 0
return ''
}
export function blankFromConfiguratorStructure(
structure: AdminConfiguratorStructure,
): unknown {
if (structure.kind === 'optionalString') return ''
if (structure.kind === 'value') return blankScalar(structure.valueType)
if (structure.kind === 'vector') {
const axes = ['x', 'y', 'z', 'w'].slice(
0,
Number(structure.vectorType.slice(-1)),
)
return Object.fromEntries([
['__skyType', structure.vectorType],
...axes.map((axis) => [axis, 0]),
])
}
if (structure.kind === 'list') {
return structure.items.map(blankFromConfiguratorStructure)
}
if (structure.kind === 'map') {
return {
__skyType: 'map',
entries: structure.entries.map((entry) => ({
key: entry.key,
keyType: entry.keyType,
value: blankFromConfiguratorStructure(entry.structure),
})),
}
}
return Object.fromEntries(
Object.entries(structure.fields).map(([key, field]) => [
key,
blankFromConfiguratorStructure(field),
]),
)
}
export function createMutableTableEntry(
structure: Extract<AdminConfiguratorStructure, { kind: 'table' }>,
path: string,
key: string,
entries: Record<string, unknown> = {},
): unknown {
const value =
structure.entryDefault !== undefined
? cloneConfiguratorValue(structure.entryDefault)
: structure.template
? blankFromConfiguratorStructure(structure.template)
: undefined
if (
path !== 'Companies.Definitions' ||
value === null ||
typeof value !== 'object' ||
Array.isArray(value)
) {
return value
}
const company = value as Record<string, unknown>
company.Job = key
company.Name = key
.replace(/[_-]+/g, ' ')
.replace(/\b\w/g, (character) => character.toUpperCase())
company.LogoUrl = `https://picsum.photos/seed/companies-${key}-logo/180/180`
if (Array.isArray(company.Services)) {
if (company.Services.length === 0) {
company.Services.push({
Description: '',
Id: key,
Price: '',
RequestsEnabled: true,
Title: company.Name,
})
} else {
const service = company.Services[0]
if (
service !== null &&
typeof service === 'object' &&
!Array.isArray(service) &&
!(service as Record<string, unknown>).Id
) {
const mutableService = service as Record<string, unknown>
mutableService.Id = key
mutableService.Title = company.Name
}
}
}
const serviceLine = company.ServiceLine
if (
serviceLine !== null &&
typeof serviceLine === 'object' &&
!Array.isArray(serviceLine)
) {
const mutableServiceLine = serviceLine as Record<string, unknown>
const number = String(mutableServiceLine.Number ?? '')
const numeric = Number(number)
if (/^\d+$/.test(number) && Number.isSafeInteger(numeric) && numeric > 0) {
const used = new Set(
Object.values(entries).map((entry) => {
if (entry === null || typeof entry !== 'object') return ''
const line = (entry as Record<string, unknown>).ServiceLine
if (line === null || typeof line !== 'object') return ''
return String((line as Record<string, unknown>).Number ?? '').replace(
/\D/g,
'',
)
}),
)
const maximum = 10 ** number.length - 1
for (let offset = 0; offset < maximum; offset += 1) {
const candidate = ((numeric - 1 + offset) % maximum) + 1
const formatted = String(candidate).padStart(number.length, '0')
if (!used.has(formatted)) {
mutableServiceLine.Number = formatted
break
}
}
}
}
return company
}
@@ -16,7 +16,9 @@ describe('admin configurator descriptions', () => {
expect(configuratorDescriptionKey('Media.RequestTimeoutMs', 10000)).toBe(
'milliseconds',
)
expect(configuratorDescriptionKey('Radio.AllowedJobs', [])).toBe('access')
expect(configuratorDescriptionKey('AdminPanel.AdminGroups', [])).toBe(
'access',
)
expect(configuratorDescriptionKey('FiveManage.ApiKey', '')).toBe(
'credential',
)
+13 -10
View File
@@ -1,4 +1,4 @@
import { readdirSync, readFileSync } from 'node:fs'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
@@ -41,21 +41,24 @@ describe('App Store preview catalog', () => {
expect(visualSignatures.size).toBe(storeAppIds.length)
})
it('localizes every specialized preview in the fallback and every configured locale', () => {
const localeDirectory = fileURLToPath(
new URL('../../../sky_phone/config/locales/', import.meta.url),
)
it('localizes every specialized preview in the fallback, English and German locales', () => {
const localeSources = [
readFileSync(
fileURLToPath(new URL('../stores/phone.ts', import.meta.url)),
'utf8',
),
...readdirSync(localeDirectory)
.filter((fileName) => fileName.endsWith('.lua'))
.sort()
.map((fileName) =>
readFileSync(`${localeDirectory}/${fileName}`, 'utf8'),
readFileSync(
fileURLToPath(
new URL('../../../sky_phone/config/locales/en.lua', import.meta.url),
),
'utf8',
),
readFileSync(
fileURLToPath(
new URL('../../../sky_phone/config/locales/de.lua', import.meta.url),
),
'utf8',
),
]
for (const appId of PREVIEWABLE_BUILTIN_APP_IDS) {
@@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest'
import {
normalizePhoneViewportRect,
phoneViewportRectContainsPoint,
readPhoneViewportGeometry,
type PhoneViewportRect,
} from '@/utils/phoneViewportGeometry'
@@ -64,78 +63,7 @@ function createGeometryFixture(options: {
}
}
const PHONE_BASE_ZOOM = 0.69 * 1.2
const PHONE_HEIGHT = 844
const PHONE_WIDTH = 390
const REFERENCE_VIEWPORT_HEIGHT = 1080
const REFERENCE_VIEWPORT_WIDTH = 1920
const displayCases = [
{ height: 1080, label: '1080p 16:9', width: 1920 },
{ height: 2160, label: '4K 16:9', width: 3840 },
{ height: 1440, label: '21:9', width: 3440 },
{ height: 1440, label: '32:9', width: 5120 },
{ height: 1600, label: '16:10', width: 2560 },
] as const
const phoneScales = [80, 100, 120] as const
function productionPhoneZoom(
viewportWidth: number,
viewportHeight: number,
phoneScale: number,
): number {
const viewportScale = Math.min(
viewportWidth / REFERENCE_VIEWPORT_WIDTH,
viewportHeight / REFERENCE_VIEWPORT_HEIGHT,
)
const preferred = PHONE_BASE_ZOOM * viewportScale * (phoneScale / 100)
const edgeGap = 24 * viewportScale
const viewportMaximum = Math.max(
0,
Math.min(
(viewportWidth - edgeGap) / PHONE_WIDTH,
(viewportHeight - edgeGap) / PHONE_HEIGHT,
),
)
return Math.min(viewportMaximum, Math.max(260 / PHONE_WIDTH, preferred))
}
const cefDisplayMatrix = displayCases.flatMap((display) =>
phoneScales.map((phoneScale) => ({ display, phoneScale })),
)
describe('phone viewport geometry', () => {
it('treats every rectangle boundary as inside and rejects points beyond it', () => {
const rect: PhoneViewportRect = {
bottom: 260,
height: 160,
left: 120,
right: 360,
top: 100,
width: 240,
}
expect(phoneViewportRectContainsPoint(rect, 240, 180)).toBe(true)
expect(phoneViewportRectContainsPoint(rect, rect.left, rect.top)).toBe(true)
expect(phoneViewportRectContainsPoint(rect, rect.right, rect.bottom)).toBe(
true,
)
expect(phoneViewportRectContainsPoint(rect, rect.left - 0.001, 180)).toBe(
false,
)
expect(phoneViewportRectContainsPoint(rect, rect.right + 0.001, 180)).toBe(
false,
)
expect(phoneViewportRectContainsPoint(rect, 240, rect.top - 0.001)).toBe(
false,
)
expect(phoneViewportRectContainsPoint(rect, 240, rect.bottom + 0.001)).toBe(
false,
)
})
it('leaves modern Chrome measurements unchanged when the canvas BCR is already rendered', () => {
const wrapper = measuredRect(1573.09, 126.25, 322.92, 698.832)
const layer = measuredRect(1589.783336, 177.75, 289.533328, 603.2)
@@ -196,76 +124,6 @@ describe('phone viewport geometry', () => {
},
)
it.each(cefDisplayMatrix)(
'keeps an opened folder panel hit-test exact at $phoneScale% on $display.label',
({ display, phoneScale }) => {
const viewportScale = Math.min(
display.width / REFERENCE_VIEWPORT_WIDTH,
display.height / REFERENCE_VIEWPORT_HEIGHT,
)
const zoom = productionPhoneZoom(
display.width,
display.height,
phoneScale,
)
const edgeGap = 24 * viewportScale
const wrapper = measuredRect(
display.width - edgeGap - PHONE_WIDTH * zoom,
display.height - edgeGap - PHONE_HEIGHT * zoom,
PHONE_WIDTH * zoom,
PHONE_HEIGHT * zoom,
)
// Live CEF 103 can report the zoomed canvas in a different coordinate
// space from pointer events. Preserve that mismatch in this fixture.
const rawCanvas = measuredRect(
display.width + 117.375,
83.625,
PHONE_WIDTH,
PHONE_HEIGHT,
)
const rawPanel = measuredRect(
rawCanvas.left + 23.25,
rawCanvas.top + 246.75,
343.5,
324.25,
)
const panel = normalizePhoneViewportRect(rawPanel, rawCanvas, wrapper)
const expected = {
bottom: wrapper.top + (246.75 + 324.25) * zoom,
height: 324.25 * zoom,
left: wrapper.left + 23.25 * zoom,
right: wrapper.left + (23.25 + 343.5) * zoom,
top: wrapper.top + 246.75 * zoom,
width: 343.5 * zoom,
}
expectRectClose(panel, expected)
expect(
phoneViewportRectContainsPoint(
panel,
panel.left + panel.width / 2,
panel.top + panel.height / 2,
),
).toBe(true)
expect(phoneViewportRectContainsPoint(panel, panel.left, panel.top)).toBe(
true,
)
expect(
phoneViewportRectContainsPoint(panel, panel.right, panel.bottom),
).toBe(true)
expect(
phoneViewportRectContainsPoint(panel, panel.left - 0.25, panel.top),
).toBe(false)
expect(
phoneViewportRectContainsPoint(panel, panel.right + 0.25, panel.bottom),
).toBe(false)
expect(panel.left).toBeGreaterThanOrEqual(0)
expect(panel.right).toBeLessThanOrEqual(display.width)
expect(panel.top).toBeGreaterThanOrEqual(0)
expect(panel.bottom).toBeLessThanOrEqual(display.height)
},
)
it('reads visual scale and normalized element rects from a canvas anchor', () => {
const fixture = createGeometryFixture({
canvasOffsetHeight: 844,
@@ -7,14 +7,6 @@ export type PhoneViewportRect = {
width: number
}
export function phoneViewportRectContainsPoint(
rect: PhoneViewportRect,
x: number,
y: number,
): boolean {
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom
}
type RectMeasurement = Pick<
DOMRectReadOnly,
'height' | 'left' | 'top' | 'width'
-5
View File
@@ -2,16 +2,11 @@ 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)
+1 -1
View File
@@ -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 = 1
export const PHONE_SCALE_STEP = 5
export const PHONE_SETUP_LAST_STEP = 9
export type AppearanceMode = (typeof APPEARANCE_MODE_IDS)[number]
+1 -5
View File
@@ -30,11 +30,7 @@ const launchStyle = computed(() => {
<div
v-if="app && !app.adminOnly"
class="app-window"
:class="{
'app-window--camera-landscape':
app.id === 'camera' && phone.cameraLandscape,
'app-window--citywarn': app.id === 'citywarn',
}"
:class="{ 'app-window--citywarn': app.id === 'citywarn' }"
:style="launchStyle"
>
<CustomAppFrame
@@ -31,22 +31,6 @@ const builtInWallpaperCss = mainCss.slice(
mainCss.indexOf('.wallpaper--midnight'),
mainCss.indexOf('.wallpaper--custom'),
)
const folderOverlayBlock =
viewSource.match(/<HomeFolderOverlay\b[\s\S]*?\/>/)?.[0] ?? ''
function handlerName(block: string, event: string): string {
return (
block.match(new RegExp(`@${event}="([A-Za-z][A-Za-z0-9_]*)"`))?.[1] ?? ''
)
}
function functionSource(name: string): string {
if (!name) return ''
const start = viewSource.indexOf(`function ${name}(`)
if (start < 0) return ''
const next = viewSource.indexOf('\nfunction ', start + 1)
return viewSource.slice(start, next < 0 ? viewSource.length : next)
}
describe('Springboard page swipe contract', () => {
it('keeps app and widget labels on the larger shared home typography', () => {
@@ -152,7 +136,7 @@ describe('Springboard page swipe contract', () => {
)
expect(
viewSource.match(/:external-drag-visual="homeDragVisualActive"/g),
).toHaveLength(5)
).toHaveLength(4)
expect(viewSource).toContain('updateHomeDragGhost(event)')
expect(viewSource).toContain('const dropGhost = homeDragGhost')
expect(viewSource).toContain('const dropOrigin = homeDragPreviewBounds')
@@ -176,65 +160,6 @@ describe('Springboard page swipe contract', () => {
)
})
it('owns one external drag session for an app leaving an opened folder', () => {
expect(folderOverlayBlock).toContain(
':external-drag-visual="homeDragVisualActive"',
)
const startSource = functionSource(
handlerName(folderOverlayBlock, 'dragstart'),
)
const moveSource = functionSource(
handlerName(folderOverlayBlock, 'dragmove'),
)
const cancelSource = functionSource(
handlerName(folderOverlayBlock, 'dragcancel'),
)
expect(startSource).toContain('createHomeDragGhost(event)')
expect(startSource).toMatch(/\.value\s*=\s*\{[\s\S]*?sourceIndex/)
expect(moveSource).toContain('updateHomeDragGhost(event)')
expect(moveSource).toContain('folderDraggingOutside.value')
expect(moveSource).toContain('resolveHomeEdgeTurn(event)')
expect(moveSource).toContain("queueEdgePageTurn(event, 'app')")
expect(viewSource).toContain(
'(draggingHomeApp.value || draggingFolderApp.value)',
)
expect(cancelSource).toContain('clearHomeDragGhost()')
expect(cancelSource).toContain('clearTemporaryHomePage()')
})
it('settles an extracted folder app from the tracked viewport preview', () => {
const extractSource = functionSource('extractOpenedFolderApp')
const targetSource = functionSource('folderExtractionDropTarget')
expect(extractSource).toContain('const dropGhost = homeDragGhost')
expect(extractSource).toContain('homeDragPreviewBounds')
expect(targetSource).toContain('page > appStore.homeLayout.pageCount')
expect(extractSource).not.toContain('sourceElement')
expect(extractSource).not.toContain('getBoundingClientRect()')
expect(extractSource).toMatch(
/animateHomeItemDrop\([\s\S]*?dropOrigin[\s\S]*?\)\.finally\(/,
)
expect(extractSource).toContain('clearHomeDragGhost(dropGhost)')
expect(extractSource).toMatch(
/if \(!target\) \{[\s\S]*?clearHomeDragGhost\(\)/,
)
})
it('cleans the opened-folder drag preview after an internal move, cancellation, or unmount', () => {
const moveSource = functionSource('moveOpenedFolderApp')
const cancelSource = functionSource(
handlerName(folderOverlayBlock, 'dragcancel'),
)
expect(moveSource).toContain('stopOpenedFolderAppDrag()')
expect(cancelSource).toContain('clearHomeDragGhost()')
expect(viewSource).toMatch(
/onBeforeUnmount\(\(\) => \{[\s\S]*?clearHomeDragGhost\(\)/,
)
})
it('cleans up the external drag visual on every terminal path', () => {
expect(
viewSource.match(
+58 -236
View File
@@ -42,7 +42,6 @@ import {
} from '@/utils/homeLayout'
import type { ReorderDirection } from '@/utils/keyboard'
import {
phoneViewportRectContainsPoint,
readPhoneViewportGeometry,
type PhoneViewportGeometry,
type PhoneViewportRect,
@@ -112,11 +111,6 @@ const draggingHomeApp = ref<{
area: HomeArea
index: number
} | null>(null)
const draggingFolderApp = ref<{
appId: LaunchablePhoneAppId
folderId: string
sourceIndex: number
} | null>(null)
const homeDragLayer = ref<HTMLElement | null>(null)
const homeDragVisualActive = ref(false)
const temporaryHomePage = ref<number | null>(null)
@@ -616,11 +610,7 @@ function queueEdgePageTurn(
edgePageTimer = undefined
edgePageDirection = direction
edgePageLocked = dragType === 'widget'
if (
dragType === 'app' &&
lastHomePointer &&
(draggingHomeApp.value || draggingFolderApp.value)
) {
if (dragType === 'app' && lastHomePointer && draggingHomeApp.value) {
queueEdgePageTurn(lastHomePointer, 'app')
}
}, HOME_EDGE_PAGE_TURN_DELAY)
@@ -824,7 +814,7 @@ function updateHomeDragGhost(event: {
)
}
function createHomeDragGhost(event: PointerEvent): HTMLElement | null {
function createHomeDragGhost(event: PointerEvent): void {
clearHomeDragGhost()
const eventTarget =
event.currentTarget instanceof Element
@@ -837,7 +827,7 @@ function createHomeDragGhost(event: PointerEvent): HTMLElement | null {
const springboard = source?.closest<HTMLElement>('.springboard')
const portal = layer?.closest<HTMLElement>('.phone-home-drag-portal')
const geometry = readPhoneViewportGeometry(source ?? null)
if (!source || !layer || !springboard || !portal || !geometry) return null
if (!source || !layer || !springboard || !portal || !geometry) return
const portalBounds = portal.getBoundingClientRect()
const clipBounds = geometry.rect(springboard)
@@ -848,7 +838,7 @@ function createHomeDragGhost(event: PointerEvent): HTMLElement | null {
sourceBounds.width <= 0 ||
sourceBounds.height <= 0
) {
return null
return
}
layer.style.left = `${clipBounds.left - portalBounds.left}px`
@@ -875,12 +865,7 @@ function createHomeDragGhost(event: PointerEvent): HTMLElement | null {
for (const element of [ghost, ...Array.from(ghost.querySelectorAll('*'))]) {
element.removeAttribute('id')
for (const attribute of element.getAttributeNames()) {
if (
attribute.startsWith('data-home-') ||
attribute.startsWith('data-folder-')
) {
element.removeAttribute(attribute)
}
if (attribute.startsWith('data-home-')) element.removeAttribute(attribute)
}
}
for (const control of ghost.querySelectorAll<HTMLElement>(
@@ -909,7 +894,6 @@ function createHomeDragGhost(event: PointerEvent): HTMLElement | null {
layer.appendChild(position)
homeDragVisualActive.value = true
updateHomeDragGhost(event)
return position
}
function startHomeDrag(
@@ -1206,98 +1190,6 @@ function nearestDockDropTarget(event: {
return closestIndex === null ? null : slots[closestIndex]
}
function folderExtractionDropTarget(event: {
clientX: number
clientY: number
}):
| { area: 'dock'; index: number }
| { area: 'grid'; index: number; page: number }
| null {
const geometry = homeDragGeometry
if (!geometry) return null
const dock = document.querySelector<HTMLElement>('.app-dock')
if (dock) {
const dockBounds = homeDragViewportRect(dock, geometry)
if (
phoneViewportRectContainsPoint(dockBounds, event.clientX, event.clientY)
) {
const slots = Array.from(
dock.querySelectorAll<HTMLElement>(
'[data-home-area="dock"][data-home-index]',
),
).filter((slot) => {
const index = Number(slot.dataset.homeIndex)
return (
Number.isInteger(index) && appStore.homeLayout.dock[index] === null
)
})
const closestIndex = nearestSpringboardRectIndex(
event.clientX,
event.clientY,
slots.map((slot) => homeDragViewportRect(slot, geometry)),
)
if (closestIndex === null) return null
const index = Number(slots[closestIndex]?.dataset.homeIndex)
return Number.isInteger(index) ? { area: 'dock', index } : null
}
}
const page = phone.currentPage
if (page < 1 || page > appPages.value.length) return null
const grid = document.querySelector<HTMLElement>(
`.springboard-page--apps[data-home-page="${page}"] .app-grid`,
)
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
const springboard = grid?.closest<HTMLElement>('.springboard')
if (!grid || !pageElement || !springboard) return null
const pageBounds = homeDragViewportRect(pageElement, geometry)
const springboardBounds = homeDragViewportRect(springboard, geometry)
const rawGridBounds = homeDragViewportRect(grid, geometry)
const gridBounds = viewportRectAt(
springboardBounds.left + (rawGridBounds.left - pageBounds.left),
springboardBounds.top + (rawGridBounds.top - pageBounds.top),
rawGridBounds.width,
rawGridBounds.height,
)
if (
!phoneViewportRectContainsPoint(gridBounds, event.clientX, event.clientY)
) {
return null
}
const slots = Array.from(
grid.querySelectorAll<HTMLElement>(
'[data-home-area="grid"][data-home-index]',
),
).filter((slot) => {
const index = Number(slot.dataset.homeIndex)
const item = appStore.homeLayout.grid[index]
return (
Number.isInteger(index) &&
(item === null ||
(item === undefined && page > appStore.homeLayout.pageCount))
)
})
const closestIndex = nearestSpringboardRectIndex(
event.clientX,
event.clientY,
slots.map((slot) => {
const bounds = homeDragViewportRect(slot, geometry)
return {
height: bounds.height,
left: springboardBounds.left + (bounds.left - pageBounds.left),
top: springboardBounds.top + (bounds.top - pageBounds.top),
width: bounds.width,
}
}),
)
if (closestIndex === null) return null
const index = Number(slots[closestIndex]?.dataset.homeIndex)
return Number.isInteger(index) ? { area: 'grid', index, page } : null
}
function finishHomeDrag(event: PointerEvent): void {
clearEdgePageTurn()
clearFolderHover()
@@ -1432,83 +1324,16 @@ function folderPreviewApps(folder: HomeFolder): PhoneAppDefinition[] {
return apps
}
function currentOpenedFolderDragSession(sourceIndex?: number) {
const session = draggingFolderApp.value
const folder = openedFolder.value
if (
!session ||
!folder ||
folder.id !== session.folderId ||
(sourceIndex !== undefined && sourceIndex !== session.sourceIndex) ||
folder.apps[session.sourceIndex] !== session.appId
) {
return null
}
return session
}
function startOpenedFolderAppDrag(
sourceIndex: number,
event: PointerEvent,
): void {
stopOpenedFolderAppDrag()
const folder = openedFolder.value
if (
!folder ||
!Number.isInteger(sourceIndex) ||
sourceIndex < 0 ||
sourceIndex >= folder.apps.length
) {
return
}
const appId = folder.apps[sourceIndex]
if (!appId || !installedAppsById.value.has(appId)) return
if (!createHomeDragGhost(event)) {
clearHomeDragGhost()
return
}
draggingFolderApp.value = { appId, folderId: folder.id, sourceIndex }
lastHomePointer = { clientX: event.clientX, clientY: event.clientY }
}
function moveOpenedFolderAppDrag(event: PointerEvent): void {
if (!currentOpenedFolderDragSession()) {
stopOpenedFolderAppDrag()
return
}
lastHomePointer = { clientX: event.clientX, clientY: event.clientY }
updateHomeDragGhost(event)
if (folderDraggingOutside.value && resolveHomeEdgeTurn(event)) {
queueEdgePageTurn(event, 'app')
return
}
clearEdgePageTurn()
}
function stopOpenedFolderAppDrag(): void {
clearEdgePageTurn()
draggingFolderApp.value = null
lastHomePointer = null
clearHomeDragGhost()
clearTemporaryHomePage()
}
function resetOpenedFolderState(): void {
folderDraggingOutside.value = false
openedFolderId.value = null
renameFolderOnOpenId.value = null
}
function openFolder(folderId: string): void {
stopOpenedFolderAppDrag()
pageTransitioning.value = false
folderDraggingOutside.value = false
openedFolderId.value = folderId
}
function closeFolder(): void {
stopOpenedFolderAppDrag()
resetOpenedFolderState()
folderDraggingOutside.value = false
openedFolderId.value = null
renameFolderOnOpenId.value = null
}
function renameOpenedFolder(name: string): void {
@@ -1517,68 +1342,71 @@ function renameOpenedFolder(name: string): void {
}
function moveOpenedFolderApp(sourceIndex: number, targetIndex: number): void {
const session = currentOpenedFolderDragSession(sourceIndex)
stopOpenedFolderAppDrag()
if (!session) return
appStore.moveHomeFolderApp(session.folderId, sourceIndex, targetIndex)
if (!openedFolderId.value) return
appStore.moveHomeFolderApp(openedFolderId.value, sourceIndex, targetIndex)
}
function extractOpenedFolderApp(
sourceIndex: number,
event: PointerEvent,
): void {
const session = currentOpenedFolderDragSession(sourceIndex)
if (!session) {
closeFolder()
return
}
updateHomeDragGhost(event)
const dropGhost = homeDragGhost
const dropOrigin = homeDragPreviewBounds ? { ...homeDragPreviewBounds } : null
if (!dropGhost || !dropOrigin) {
closeFolder()
return
}
const target = folderExtractionDropTarget(event)
const folder = openedFolder.value
if (!folder) return
const appId = folder.apps[sourceIndex]
const sourceElement = document
.querySelector<HTMLElement>(
`.home-folder-panel [data-folder-app-index="${sourceIndex}"]`,
)
?.closest<HTMLElement>('.app-icon-item')
const geometry = readPhoneViewportGeometry(sourceElement ?? null)
const sourceBounds = sourceElement
? homeDragViewportRect(sourceElement, geometry)
: null
const candidates = Array.from(
document.querySelectorAll<HTMLElement>('[data-home-index][data-home-area]'),
).filter((element) => {
const area = element.dataset.homeArea as HomeArea
const index = Number(element.dataset.homeIndex)
return (
(area === 'grid' || area === 'dock') &&
Number.isInteger(index) &&
appStore.homeLayout[area][index] === null
)
})
const target = candidates.reduce<HTMLElement | null>((closest, candidate) => {
if (!closest) return candidate
const bounds = homeDragViewportRect(candidate, geometry)
const closestBounds = homeDragViewportRect(closest, geometry)
const distance = Math.hypot(
event.clientX - (bounds.left + bounds.width / 2),
event.clientY - (bounds.top + bounds.height / 2),
)
const closestDistance = Math.hypot(
event.clientX - (closestBounds.left + closestBounds.width / 2),
event.clientY - (closestBounds.top + closestBounds.height / 2),
)
return distance < closestDistance ? candidate : closest
}, null)
if (!target) {
clearEdgePageTurn()
draggingFolderApp.value = null
lastHomePointer = null
clearHomeDragGhost()
clearTemporaryHomePage()
resetOpenedFolderState()
closeFolder()
return
}
const { area, index: targetIndex } = target
const area = target.dataset.homeArea as HomeArea
const targetIndex = Number(target.dataset.homeIndex)
if (
!appStore.extractHomeFolderApp(
session.folderId,
session.sourceIndex,
area,
targetIndex,
)
!appStore.extractHomeFolderApp(folder.id, sourceIndex, area, targetIndex)
) {
closeFolder()
return
}
clearEdgePageTurn()
draggingFolderApp.value = null
lastHomePointer = null
clearTemporaryHomePage(area === 'grid')
resetOpenedFolderState()
const extractedIndex = findHomeItemIndex(session.appId, area, targetIndex)
if (extractedIndex === null) {
clearHomeDragGhost(dropGhost)
return
closeFolder()
if (sourceBounds) {
const extractedIndex = findHomeItemIndex(appId, area, targetIndex)
if (extractedIndex !== null) {
void animateHomeItemDrop(appId, area, extractedIndex, sourceBounds)
}
}
void animateHomeItemDrop(
session.appId,
area,
extractedIndex,
dropOrigin,
).finally(() => clearHomeDragGhost(dropGhost))
}
function clearSearch(): void {
@@ -1598,12 +1426,11 @@ watch(isEditablePage, (visible) => {
watch(editMode, (editing) => {
emit('editModeChange', editing)
if (editing) return
stopOpenedFolderAppDrag()
stopHomeDrag()
stopWidgetDrag()
})
watch(openedFolder, (folder) => {
if (!folder && openedFolderId.value !== null) closeFolder()
if (!folder) closeFolder()
})
onBeforeUnmount(() => {
emit('editModeChange', false)
@@ -1618,7 +1445,6 @@ onBeforeUnmount(() => {
clearHomeDragGhost()
temporaryHomePage.value = null
draggingHomeApp.value = null
draggingFolderApp.value = null
draggingWidgetId.value = null
})
</script>
@@ -2032,13 +1858,9 @@ onBeforeUnmount(() => {
v-if="openedFolder"
:apps="openedFolderApps"
:edit-mode="editMode"
:external-drag-visual="homeDragVisualActive"
:folder="openedFolder"
:rename-on-open="renameFolderOnOpenId === openedFolder.id"
@close="closeFolder"
@dragcancel="stopOpenedFolderAppDrag"
@dragmove="moveOpenedFolderAppDrag"
@dragstart="startOpenedFolderAppDrag"
@drag-outside-change="folderDraggingOutside = $event"
@edit="enterEditMode"
@extract="extractOpenedFolderApp"
@@ -81,7 +81,6 @@ 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"',
)
+1 -5
View File
@@ -122,7 +122,6 @@ 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),
@@ -130,9 +129,7 @@ const catalog = computed(() =>
const installedApps = computed(() =>
PHONE_APPS.filter(
(app): app is LaunchablePhoneAppDefinition =>
isLaunchablePhoneApp(app) &&
appStore.isAvailable(app.id) &&
appStore.isInstalled(app.id),
isLaunchablePhoneApp(app) && appStore.isInstalled(app.id),
).sort((a, b) => a.gridOrder - b.gridOrder),
)
const installedGameCount = computed(
@@ -146,7 +143,6 @@ 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),
),
@@ -6,15 +6,6 @@ const cameraView = readFileSync(
new URL('./CameraApp.vue', import.meta.url),
'utf8',
)
const appShell = readFileSync(new URL('../../App.vue', import.meta.url), 'utf8')
const appWindow = readFileSync(
new URL('../PhoneAppWindow.vue', import.meta.url),
'utf8',
)
const shellStyles = readFileSync(
new URL('../../assets/main.css', import.meta.url),
'utf8',
)
const mediaCapture = readFileSync(
new URL('../../components/PhoneMediaCapture.vue', import.meta.url),
'utf8',
@@ -56,99 +47,6 @@ describe('Camera app controls', () => {
expect(cameraView).not.toContain('k-navbar')
})
it('stays dark when the global phone appearance is light', () => {
const cameraPageTag = cameraView.match(/<sky-app-page\b[^>]*>/s)?.[0]
expect(cameraPageTag).toBeDefined()
expect(cameraPageTag).toMatch(/\bdark\b/)
expect(cameraView).toMatch(
/\.camera-page\s*\{[^}]*--sky-bg:\s*#000;[^}]*--sky-text:\s*#fff;[^}]*background:\s*var\(--sky-bg\);[^}]*color:\s*var\(--sky-text\);/s,
)
})
it('fills the translucent landscape shell without letterbox gaps', () => {
const landscapeViewport = cameraView.match(
/\.camera-page--landscape \.camera-viewport\s*\{([^}]*)\}/s,
)?.[1]
expect(cameraView).toMatch(
/\.camera-page--landscape\s*\{[^}]*--sky-bg:\s*rgba\(0, 0, 0, 0\.42\);/s,
)
expect(landscapeViewport).toBeDefined()
expect(landscapeViewport).toContain('top: 50%')
expect(landscapeViewport).toContain(
'width: calc(100% * var(--phone-screen-portrait-ratio))',
)
expect(landscapeViewport).toContain(
'aspect-ratio: var(--phone-screen-portrait-ratio)',
)
expect(landscapeViewport).not.toContain('16 / 9')
expect(cameraView).toMatch(
/\.camera-page--landscape \.camera-shade\s*\{[^}]*linear-gradient\([^)]*90deg,[^)]*rgba\(0, 0, 0, 0\.42\) 0 18%,[^)]*transparent 18% 75%,[^)]*rgba\(0, 0, 0, 0\.42\) 75% 100%/s,
)
expect(appShell).toMatch(
/'phone-screen--camera-landscape':\s*activeAppId === 'camera' && phone\.cameraLandscape/,
)
expect(appWindow).toMatch(
/'app-window--camera-landscape':\s*app\.id === 'camera' && phone\.cameraLandscape/,
)
expect(shellStyles).toMatch(
/\.phone-screen\s*\{[^}]*--phone-screen-portrait-ratio:\s*2\.30951;/s,
)
expect(shellStyles).toMatch(
/\.phone-screen--camera-landscape\s*\{[^}]*background:\s*transparent;/s,
)
expect(shellStyles).toMatch(
/\.phone-screen--camera-landscape \.springboard\s*\{[^}]*visibility:\s*hidden;/s,
)
expect(shellStyles).toMatch(
/\.app-window--camera-landscape\s*\{[^}]*background:\s*transparent;/s,
)
})
it('renders camera notices as plain text without a glass pill', () => {
const noticeMarkup = cameraView.slice(
cameraView.indexOf('<span v-if="noticeText"'),
cameraView.indexOf('<span v-else-if="pendingCount"'),
)
const noticeStyles = cameraView.match(
/\.camera-notice-text\s*,[\s\S]*?\{([^}]*)\}/,
)?.[1]
expect(noticeMarkup).toContain('class="camera-notice-text"')
expect(noticeMarkup).not.toContain('camera-focus-pill')
expect(noticeStyles).toBeDefined()
expect(noticeStyles).toContain('color: #ffd60a')
expect(noticeStyles).not.toMatch(
/(?:background|backdrop-filter|border-radius|padding)\s*:/,
)
})
it('uses the current compact lens selector treatment', () => {
const zoomControl = cameraView.slice(
cameraView.indexOf('<div class="camera-zoom-control">'),
cameraView.indexOf('<footer class="camera-controls">'),
)
expect(zoomControl).toContain('variant="plain"')
expect(zoomControl).not.toContain('glass')
expect(zoomControl).toContain('{{ zoomPresetLabel(zoom) }}')
expect(cameraView).toContain("return zoom === 0.5 ? '.5' : `${zoom}`")
expect(cameraView).toMatch(
/\.camera-zoom-control\s*\{[^}]*bottom:\s*216px;/s,
)
expect(cameraView).toMatch(/\.camera-zoom-row\s*\{[^}]*gap:\s*10px;/s)
expect(cameraView).toMatch(
/\.camera-zoom-pill\s*\{[^}]*width:\s*36px;[^}]*min-width:\s*36px;[^}]*height:\s*36px;[^}]*min-height:\s*36px;[^}]*padding:\s*0;/s,
)
expect(cameraView).toMatch(
/\.camera-zoom-pill::before\s*\{[^}]*width:\s*var\(--sky-touch-target, 44px\);[^}]*inset-block:\s*-4px;/s,
)
expect(cameraView).toMatch(
/\.camera-zoom-pill\.active\s*\{[^}]*color:\s*#ffd60a;[^}]*background:\s*rgba\(28, 28, 30, 0\.78\);/s,
)
})
it('keeps continuous wheel zoom without an extra slider bar', () => {
expect(cameraView).not.toContain('camera-zoom-slider')
expect(cameraView).not.toContain('type="range"')
@@ -179,8 +77,7 @@ describe('Camera app controls', () => {
it('locks look controls without changing the global gameplay camera', () => {
expect(cameraView).toContain("nuiCall('camera:setLocked'")
expect(cameraView).toContain('cameraLocked.value')
expect(cameraView).toContain('Apps.camera.lookKey')
expect(cameraView).not.toContain('Apps.camera.spaceKey')
expect(cameraView).toContain('Apps.camera.spaceKey')
expect(cameraClient).toContain('RegisterNUICallback("camera:setLocked"')
expect(cameraClient).toContain('INPUT_LOOK_LR')
expect(cameraClient).toContain('INPUT_LOOK_UD')
@@ -198,26 +95,10 @@ describe('Camera app controls', () => {
expect(cameraClient).not.toContain('ensure_ultrawide_camera')
})
it('uses the configured HoldToLook control and Space in camera modes', () => {
expect(cameraView).not.toContain("event.code !== 'Space'")
expect(cameraView).not.toContain("window.addEventListener('keydown'")
expect(focusClient).toContain(
'function SkyPhoneFocus.IsHoldToLookPressed()',
it('keeps the selfie camera stable while Space still allows movement', () => {
expect(cameraView).toMatch(
/event\.code !== 'Space'[\s\S]*cameraLocked\.value/,
)
expect(focusClient).toContain(
'IsDisabledControlPressed(0, hold_to_look_control)',
)
expect(cameraClient).toContain('SkyPhoneFocus.IsHoldToLookPressed()')
expect(cameraClient).toContain(
'IsDisabledControlPressed(0, camera_passthrough_control)',
)
expect(cameraClient).toMatch(
/if data\.active then\s+watch_camera_controls\(\)/,
)
expect(cameraClient).not.toContain('IsDisabledControlJustReleased(0, 22)')
})
it('orbits the stable selfie camera while HoldToLook allows movement', () => {
expect(cameraClient).toContain(
'if camera_state.locked or camera_state.front_camera then',
)
@@ -225,22 +106,14 @@ describe('Camera app controls', () => {
expect(cameraClient).toContain('local front_camera_view_mode = 0')
expect(cameraClient).toContain('local front_camera_fov = 32.0')
expect(cameraClient).toContain('local front_camera_distance = 1.05')
expect(cameraClient).toContain('local front_camera_horizontal_limit = 75.0')
expect(cameraClient).toContain('local front_camera_vertical_limit = 35.0')
expect(cameraClient).toContain('local head_position = GetPedBoneCoords')
expect(cameraClient).toContain('GetDisabledControlNormal(0, 1)')
expect(cameraClient).toContain('GetDisabledControlNormal(0, 2)')
expect(cameraClient).toContain('update_front_camera_orbit()')
expect(cameraClient).toContain('camera_state.front_camera_yaw')
expect(cameraClient).toContain('camera_state.front_camera_pitch')
expect(cameraClient).toContain(
'local dot = (to_camera.x * forward_vector.x)',
)
expect(cameraClient).toContain('front_camera_target_height')
expect(focusClient).toContain(
'return { block_game = false, block_look = false, cursor = false, focused = true, game_input = true, keep_input = true }',
)
expect(focusClient).toContain(
'return { block_game = true, block_look = true, cursor = true, focused = true, game_input = false, keep_input = true }',
)
expect(focusClient).toContain('gameInput = focus.game_input')
expect(cameraClient).toContain('SetCamCoord(')
expect(cameraClient).toContain('PointCamAtCoord(')
expect(cameraClient).not.toContain('SetCamRot(')
@@ -248,6 +121,10 @@ describe('Camera app controls', () => {
expect(cameraClient).not.toContain('front_camera_position')
expect(cameraClient).not.toContain('AttachCamToEntity(')
expect(cameraClient).not.toContain('PointCamAtEntity(')
expect(cameraView).toContain("window.addEventListener('keyup', onKeyup)")
expect(cameraView).toContain(
"nuiCall('camera:setFocus', { focused: true })",
)
})
it('uses a looping camera-hold pose instead of the old selfie dance', () => {
+66 -68
View File
@@ -56,6 +56,7 @@ const microphoneEnabled = ref(true)
const frontCamera = ref(false)
const shutterActive = ref(false)
const cameraLocked = ref(false)
const movementEnabled = ref(false)
const recording = ref(false)
const savingVideo = ref(false)
const recordingStartedAt = ref(0)
@@ -77,16 +78,6 @@ const pendingCount = computed(
() =>
captures.value.filter((capture) => capture.status === 'uploading').length,
)
function zoomPresetIsActive(zoom: (typeof zoomLevels)[number]): boolean {
return Math.abs(selectedZoom.value - zoom) < 0.03
}
function zoomPresetLabel(zoom: (typeof zoomLevels)[number]): string {
if (zoomPresetIsActive(zoom)) return `${zoom}x`
return zoom === 0.5 ? '.5' : `${zoom}`
}
function correlationId(): string {
return `${Date.now()}-${crypto.randomUUID()}`
}
@@ -247,6 +238,10 @@ async function toggleFacing(): Promise<void> {
async function toggleCameraLock(): Promise<void> {
cameraLocked.value = !cameraLocked.value
if (cameraLocked.value && movementEnabled.value) {
movementEnabled.value = false
await nuiCall('camera:setFocus', { focused: true })
}
await nuiCall('camera:setLocked', { locked: cameraLocked.value })
}
@@ -345,6 +340,26 @@ function updateRecordingTimer(): void {
elapsed.value = formatRecordingDuration(Date.now() - recordingStartedAt.value)
}
function onKeydown(event: KeyboardEvent): void {
if (
event.code !== 'Space' ||
event.repeat ||
cameraLocked.value ||
movementEnabled.value
)
return
event.preventDefault()
movementEnabled.value = true
void nuiCall('camera:setFocus', { focused: false })
}
function onKeyup(event: KeyboardEvent): void {
if (event.code !== 'Space' || !movementEnabled.value) return
event.preventDefault()
movementEnabled.value = false
void nuiCall('camera:setFocus', { focused: true })
}
function onMessage(event: MessageEvent): void {
if (!isTrustedRootMessageSource(event.source, window)) return
const message = event.data as {
@@ -427,6 +442,8 @@ onMounted(() => {
{ data: { zoom: selectedZoom.value }, type: 'camera:zoom' },
'*',
)
window.addEventListener('keydown', onKeydown)
window.addEventListener('keyup', onKeyup)
window.addEventListener('message', onMessage)
void nuiCall('camera:setActive', { active: true })
void nuiCall<MediaConfig>('media:config').then((response) => {
@@ -442,7 +459,13 @@ onBeforeUnmount(() => {
if (shutterTimer !== undefined) window.clearTimeout(shutterTimer)
if (noticeTimer !== undefined) window.clearTimeout(noticeTimer)
if (recordingTimer !== undefined) window.clearInterval(recordingTimer)
window.removeEventListener('keydown', onKeydown)
window.removeEventListener('keyup', onKeyup)
window.removeEventListener('message', onMessage)
if (movementEnabled.value) {
movementEnabled.value = false
void nuiCall('camera:setFocus', { focused: true })
}
if (renderFrameId !== undefined) window.cancelAnimationFrame(renderFrameId)
resizeObserver?.disconnect()
gameView?.dispose()
@@ -463,7 +486,6 @@ onBeforeUnmount(() => {
class="camera-page"
:class="{ 'camera-page--landscape': phone.cameraLandscape }"
:aria-label="phone.t('Apps.camera.name')"
dark
>
<div class="camera-viewport" @wheel.prevent.stop="zoomWithWheel">
<canvas
@@ -537,7 +559,10 @@ onBeforeUnmount(() => {
</template>
</sky-fab>
</div>
<span v-if="noticeText" class="camera-notice-text">
<span
v-if="noticeText"
class="camera-focus-pill camera-focus-pill--notice"
>
{{ noticeText }}
</span>
<span v-else-if="pendingCount" class="camera-upload-pill">
@@ -562,7 +587,7 @@ onBeforeUnmount(() => {
>
<LockKeyhole v-if="cameraLocked" :size="12" />
<LockOpen v-else :size="12" />
<kbd>{{ phone.t('Apps.camera.lookKey') }}</kbd>
<kbd>{{ phone.t('Apps.camera.spaceKey') }}</kbd>
</SkyButton>
<sky-fab
component="button"
@@ -593,16 +618,16 @@ onBeforeUnmount(() => {
<SkyButton
v-for="zoom in zoomLevels"
:key="zoom"
glass
rounded
variant="plain"
class="camera-zoom-pill"
:class="{ active: zoomPresetIsActive(zoom) }"
:class="{ active: Math.abs(selectedZoom - zoom) < 0.03 }"
type="button"
:aria-label="phone.t('Apps.camera.zoom', { zoom: `${zoom}x` })"
:aria-pressed="zoomPresetIsActive(zoom)"
:aria-pressed="Math.abs(selectedZoom - zoom) < 0.03"
@click="setZoom(zoom)"
>
{{ zoomPresetLabel(zoom) }}
{{ zoom }}x
</SkyButton>
</div>
</div>
@@ -693,15 +718,10 @@ onBeforeUnmount(() => {
<style scoped>
.camera-page {
--sky-bg: #000;
--sky-text: #fff;
position: relative;
overflow: clip;
background: var(--sky-bg);
color: var(--sky-text);
}
.camera-page--landscape {
--sky-bg: rgba(0, 0, 0, 0.42);
background: #000;
color: #fff;
}
.camera-viewport {
position: absolute;
@@ -713,11 +733,11 @@ onBeforeUnmount(() => {
transform: translateY(-50%);
}
.camera-page--landscape .camera-viewport {
top: 50%;
top: 46%;
left: 50%;
width: calc(100% * var(--phone-screen-portrait-ratio));
width: calc(100% * 16 / 9);
height: auto;
aspect-ratio: var(--phone-screen-portrait-ratio);
aspect-ratio: 16 / 9;
transform: translate(-50%, -50%) rotate(90deg);
}
.camera-game-view,
@@ -775,16 +795,6 @@ onBeforeUnmount(() => {
pointer-events: none;
background: linear-gradient(#0008, transparent 22%);
}
.camera-page--landscape .camera-shade {
background:
linear-gradient(
90deg,
rgba(0, 0, 0, 0.42) 0 18%,
transparent 18% 75%,
rgba(0, 0, 0, 0.42) 75% 100%
),
linear-gradient(#0008, transparent 22%);
}
.camera-flash {
z-index: 8;
pointer-events: none;
@@ -845,22 +855,25 @@ onBeforeUnmount(() => {
.camera-page--landscape .camera-latest svg {
transform: rotate(90deg);
}
.camera-upload-pill {
padding: 7px 10px;
border-radius: 999px;
background: #0006;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.camera-notice-text,
.camera-focus-pill:not(.sky-button--glass),
.camera-upload-pill {
min-width: 0;
color: #ffd60a;
padding: 7px 10px;
overflow: hidden;
border-radius: 999px;
background: #0006;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.camera-upload-pill {
color: #ffd60a;
}
.camera-focus-pill--notice {
color: #ffd60a;
}
.camera-lock-control {
min-height: 44px;
@@ -911,7 +924,7 @@ onBeforeUnmount(() => {
.camera-zoom-control {
position: absolute;
z-index: 4;
bottom: 216px;
bottom: 196px;
left: 50%;
width: auto;
transform: translateX(-50%);
@@ -919,19 +932,16 @@ onBeforeUnmount(() => {
.camera-zoom-row {
display: flex;
justify-content: space-between;
gap: 10px;
gap: 8px;
}
.camera-zoom-pill {
width: 36px;
min-width: 36px;
height: 36px;
min-height: 36px;
width: 44px;
min-width: 44px;
height: 44px;
min-height: 44px;
padding: 0;
border: 0;
color: #fff;
background: transparent;
font-size: 12px;
font-weight: 500;
font-size: 10px;
text-align: center;
transition:
background-color 0.2s ease,
@@ -939,20 +949,8 @@ onBeforeUnmount(() => {
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.camera-zoom-pill::before {
width: var(--sky-touch-target, 44px);
inset-block: -4px;
}
.camera-zoom-pill.active {
color: #ffd60a;
background: rgba(28, 28, 30, 0.78);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12);
}
.camera-zoom-pill:active:not(:disabled) {
background: rgba(28, 28, 30, 0.42);
}
.camera-zoom-pill.active:active:not(:disabled) {
background: rgba(28, 28, 30, 0.86);
}
.camera-controls {
position: absolute;
@@ -40,10 +40,6 @@ describe('CityWarn product contract', () => {
})
it('reserves the pill navigation, keeps sheets safe and avoids blur flicker', () => {
expect(source).toMatch(
/\.citywarn-scroll\s*\{[^}]*min-height:\s*0;[^}]*height:\s*auto;[^}]*flex:\s*1 1 0;[^}]*overflow-y:\s*auto;/s,
)
expect(source).not.toMatch(/\.citywarn-scroll\s*\{[^}]*height:\s*100%;/s)
expect(source).toMatch(
/\.citywarn-scroll\.sky-scroll-area--tabbar\s*\{[^}]*padding-bottom:\s*calc\(var\(--sky-safe-area-bottom\) \+ 84px\)/s,
)
@@ -60,51 +56,6 @@ describe('CityWarn product contract', () => {
expect(source).not.toContain('<span>{{ alert.title }}</span>')
})
it('uses one spacing and radius system throughout the current feed', () => {
expect(source).toMatch(
/\.citywarn-overview\s*\{[^}]*padding:\s*var\(--sky-space-3\);[^}]*gap:\s*var\(--sky-space-3\);[^}]*border-radius:\s*var\(--sky-radius-card\);/s,
)
expect(source).toMatch(
/\.citywarn-overview \+ \.citywarn-feed\s*\{[^}]*margin-top:\s*var\(--sky-space-3\);[^}]*gap:\s*var\(--sky-space-3\);/s,
)
expect(source).toMatch(
/\.citywarn-alert-card\s*\{[^}]*border-radius:\s*var\(--sky-radius-card\);/s,
)
expect(source).toMatch(
/\.citywarn-publisher-card\s*\{[^}]*margin:\s*var\(--sky-space-3\) 0 0;[^}]*border-radius:\s*var\(--sky-radius-card\);/s,
)
expect(source).toMatch(
/\.citywarn-publisher-card :deep\(\.sky-card__content\)\s*\{[^}]*padding:\s*var\(--sky-space-3\);[^}]*gap:\s*var\(--sky-space-3\);/s,
)
for (const selector of [
'citywarn-overview-symbol',
'citywarn-card-icon',
'citywarn-publisher-icon',
]) {
expect(source).toMatch(
new RegExp(
`\\.${selector}\\s*\\{[^}]*border-radius:\\s*var\\(--sky-radius-control\\);`,
's',
),
)
}
})
it('keeps settings groups on one compact spacing rhythm', () => {
expect(source).toMatch(
/\.citywarn-settings\s*\{[^}]*gap:\s*var\(--sky-space-5\);/s,
)
expect(source).toMatch(
/\.citywarn-settings :deep\(\.sky-settings-group\)\s*\{[^}]*margin:\s*0;/s,
)
expect(source).toMatch(
/\.citywarn-settings :deep\(\.sky-settings-group__title\)\s*\{[^}]*margin:\s*0 var\(--sky-space-1\) var\(--sky-space-2\);/s,
)
expect(source).toMatch(
/\.citywarn-settings :deep\(\.sky-settings-group__footer\)\s*\{[^}]*margin:\s*var\(--sky-space-2\) var\(--sky-space-1\) 0;/s,
)
})
it('keeps publishing authorization and validation on the server', () => {
expect(server).toContain('Bridge.Framework.GetJob(source)')
expect(server).toContain('SkyPhone.RequireSession(source)')
+17 -34
View File
@@ -1026,9 +1026,7 @@ onMounted(async () => {
height: 18px;
}
.citywarn-scroll {
min-height: 0;
height: auto;
flex: 1 1 0;
height: 100%;
padding: 12px 13px calc(28px + env(safe-area-inset-bottom));
overflow-y: auto;
}
@@ -1047,11 +1045,11 @@ onMounted(async () => {
}
.citywarn-overview {
display: flex;
padding: var(--sky-space-3);
padding: 15px;
align-items: center;
gap: var(--sky-space-3);
gap: 12px;
border: 1px solid #bbf7d0;
border-radius: var(--sky-radius-card);
border-radius: 18px;
background: linear-gradient(135deg, #f0fdf4, #ecfdf5);
}
.citywarn-overview--active {
@@ -1064,7 +1062,7 @@ onMounted(async () => {
height: 46px;
flex: 0 0 auto;
place-items: center;
border-radius: var(--sky-radius-control);
border-radius: 15px;
color: #15803d;
background: #dcfce7;
}
@@ -1088,17 +1086,12 @@ onMounted(async () => {
flex-direction: column;
gap: 10px;
}
.citywarn-overview + .citywarn-feed {
margin-top: var(--sky-space-3);
gap: var(--sky-space-3);
}
.citywarn-alert-card {
position: relative;
padding: var(--sky-space-3) var(--sky-space-3) var(--sky-space-3)
var(--sky-space-4);
padding: 13px 13px 12px 17px;
overflow: hidden;
border: 1px solid #e5e7eb;
border-radius: var(--sky-radius-card);
border-radius: 17px;
background: #fff;
box-shadow: 0 4px 15px rgb(15 23 42 / 5%);
cursor: pointer;
@@ -1122,7 +1115,7 @@ onMounted(async () => {
width: 32px;
height: 32px;
place-items: center;
border-radius: var(--sky-radius-control);
border-radius: 10px;
color: var(--severity);
background: color-mix(in srgb, var(--severity) 12%, white);
}
@@ -1146,7 +1139,7 @@ onMounted(async () => {
color: #9ca3af;
}
.citywarn-alert-card h3 {
margin: var(--sky-space-2) 0 var(--sky-space-1);
margin: 10px 0 5px;
font-size: 16px;
line-height: 1.18;
}
@@ -1162,10 +1155,10 @@ onMounted(async () => {
}
.citywarn-alert-card footer {
display: flex;
margin-top: var(--sky-space-2);
margin-top: 11px;
align-items: center;
justify-content: space-between;
gap: var(--sky-space-2);
gap: 8px;
color: #6b7280;
font-size: 10.5px;
}
@@ -1179,13 +1172,12 @@ onMounted(async () => {
white-space: nowrap;
}
.citywarn-publisher-card {
margin: var(--sky-space-3) 0 0;
border-radius: var(--sky-radius-card);
margin-top: 12px;
}
.citywarn-publisher-card :deep(.sky-card__content) {
display: flex;
padding: var(--sky-space-3);
gap: var(--sky-space-3);
padding: 12px;
gap: 10px;
}
.citywarn-publisher-icon {
display: grid;
@@ -1193,7 +1185,7 @@ onMounted(async () => {
height: 36px;
flex: 0 0 auto;
place-items: center;
border-radius: var(--sky-radius-control);
border-radius: 11px;
color: #1d4ed8;
background: #dbeafe;
}
@@ -1531,20 +1523,11 @@ onMounted(async () => {
.citywarn-settings {
display: flex;
flex-direction: column;
gap: var(--sky-space-5);
}
.citywarn-settings :deep(.sky-settings-group) {
margin: 0;
}
.citywarn-settings :deep(.sky-settings-group__title) {
margin: 0 var(--sky-space-1) var(--sky-space-2);
}
.citywarn-settings :deep(.sky-settings-group__footer) {
margin: var(--sky-space-2) var(--sky-space-1) 0;
gap: 15px;
}
.citywarn-settings-hint {
display: flex;
margin: 0 var(--sky-space-1);
margin: 0 8px;
align-items: flex-start;
gap: 7px;
color: #6b7280;
@@ -369,22 +369,6 @@ describe('VaultX crypto app contracts', () => {
expect(testServer).toContain('function advanceCryptoCycle(')
})
it('does not keep market and settlement workers active while disabled', () => {
expect(server).toContain('local function start_crypto_schedulers()')
expect(server).toMatch(
/start_crypto_schedulers\(\)[\s\S]*?if Config\.Crypto\.Enabled ~= true then\s+return/,
)
expect(server).toContain(
'while scheduler_generation == generation and Config.Crypto.Enabled == true do',
)
expect(server).toContain(
'AddEventHandler("sky_phone:configurator:serverUpdated", refresh_crypto_runtime)',
)
expect(server).not.toMatch(
/CreateThread\(function\(\)\s+while true do\s+Wait\(5 \* 60 \* 1000\)/,
)
})
it('stores cash in price-scale minor units throughout the ledger', () => {
expect(server).toContain(
'local ledger_amount = amount * Config.Crypto.PriceScale',
@@ -6,14 +6,6 @@ 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', () => {
@@ -138,13 +130,4 @@ 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 })')
})
})
@@ -203,7 +203,7 @@ describe('MessagesApp Sky UI contract', () => {
expect(sheetStart).toBeGreaterThan(-1)
expect(sheetEnd).toBeGreaterThan(sheetStart)
expect(sheet).toContain(
'activeCanMessage && !activeServiceLine && attachmentPicker !== null',
':opened="activeCanMessage && attachmentPicker !== null"',
)
expect(sheet).toContain('swipe-to-close')
expect(sheet).toContain('grabber-clickable')
+12 -57
View File
@@ -215,9 +215,6 @@ const activeContact = computed(() =>
const activeCanMessage = computed(
() => activeContact.value?.canMessage !== false,
)
const activeServiceLine = computed(
() => activeContact.value?.source === 'company',
)
const activeContactEmail = computed(() =>
normalizeMailAddress(activeContact.value?.email ?? ''),
)
@@ -258,15 +255,12 @@ const inboxMenuItems = computed(() => [
},
])
const attachmentPanelOpen = computed(
() =>
emojiOpen.value ||
(!activeServiceLine.value && attachmentPicker.value !== null),
() => emojiOpen.value || attachmentPicker.value !== null,
)
const composerHasContent = computed(
() =>
Boolean(draft.value.trim()) ||
(!activeServiceLine.value &&
(Boolean(shareDraft.value) || pendingAttachments.value.length > 0)),
Boolean(draft.value.trim() || shareDraft.value) ||
pendingAttachments.value.length > 0,
)
function contactName(number: string): string {
return (
@@ -423,8 +417,6 @@ function errorText(error?: string): string {
'gif_provider_failed',
'self_message',
'recipient_not_found',
'messaging_unavailable',
'service_line_text_only',
'no_sim',
'rate_limited',
'blocked',
@@ -622,7 +614,6 @@ async function callActiveContact(): Promise<void> {
}
function toggleAttachmentMenu(): void {
if (activeServiceLine.value) return
attachmentMenuOpen.value = !attachmentMenuOpen.value
emojiOpen.value = false
attachmentPicker.value = null
@@ -655,7 +646,7 @@ function openMediaApp(
app: 'camera' | 'photos',
mediaType: 'photo' | 'video',
): void {
if (!messages.activeNumber || activeServiceLine.value) return
if (!messages.activeNumber) return
const remainingSlots =
MAX_PENDING_ATTACHMENTS - pendingAttachments.value.length
if (remainingSlots < 1) {
@@ -716,14 +707,7 @@ async function sendAttachment(
mediaAssetId: string,
mediaDurationMs?: number,
): Promise<void> {
if (
!messages.activeNumber ||
!activeCanMessage.value ||
activeServiceLine.value ||
sending.value
) {
return
}
if (!messages.activeNumber || !activeCanMessage.value || sending.value) return
attachmentMenuOpen.value = false
attachmentPicker.value = null
sending.value = true
@@ -738,7 +722,7 @@ async function sendAttachment(
}
async function sendContact(contact: PhoneContact): Promise<void> {
if (!messages.activeNumber || activeServiceLine.value || sending.value) return
if (!messages.activeNumber || sending.value) return
attachmentMenuOpen.value = false
attachmentPicker.value = null
sending.value = true
@@ -901,12 +885,7 @@ function sampleMicrophone(): void {
}
async function startVoiceRecording(): Promise<void> {
if (
!activeCanMessage.value ||
activeServiceLine.value ||
recording.value ||
recordingStarting.value
) {
if (!activeCanMessage.value || recording.value || recordingStarting.value) {
return
}
emojiOpen.value = false
@@ -1062,27 +1041,6 @@ watch(
},
)
watch(activeServiceLine, (serviceLine) => {
if (!serviceLine) return
const discarded = Boolean(
shareDraft.value ||
pendingAttachments.value.length ||
recording.value ||
recordingStarting.value,
)
attachmentMenuOpen.value = false
attachmentPicker.value = null
shareDraft.value = null
pendingAttachments.value = []
if (recording.value) {
discardRecording = true
cancelVoiceRecording()
} else if (recordingStarting.value) {
cleanupRecorder()
}
if (discarded) showToast(errorText('service_line_text_only'))
})
onBeforeUnmount(() => {
discardRecording = true
cleanupRecorder()
@@ -1656,7 +1614,7 @@ onBeforeUnmount(() => {
</SkyScrollArea>
<section
v-if="activeCanMessage && !activeServiceLine && attachmentMenuOpen"
v-if="activeCanMessage && attachmentMenuOpen"
class="messages-attachment-menu"
:aria-hidden="contactDetailsOpen"
:inert="contactDetailsOpen || undefined"
@@ -1701,9 +1659,7 @@ onBeforeUnmount(() => {
<SkySheet
class="messages-media-picker-sheet"
:opened="
activeCanMessage && !activeServiceLine && attachmentPicker !== null
"
:opened="activeCanMessage && attachmentPicker !== null"
:aria-label="
phone.t(
attachmentPicker === 'contacts'
@@ -1838,7 +1794,7 @@ onBeforeUnmount(() => {
/>
<div
v-if="activeCanMessage && !activeServiceLine && shareDraft && !recording"
v-if="activeCanMessage && shareDraft && !recording"
class="shared-composer-preview"
:aria-hidden="contactDetailsOpen"
:inert="contactDetailsOpen || undefined"
@@ -1854,7 +1810,7 @@ onBeforeUnmount(() => {
</div>
<section
v-if="activeCanMessage && !activeServiceLine && recording"
v-if="activeCanMessage && recording"
class="messages-recorder"
:aria-hidden="contactDetailsOpen"
:inert="contactDetailsOpen || undefined"
@@ -1942,7 +1898,6 @@ onBeforeUnmount(() => {
<div class="messages-sky-composer-row">
<SkyGlass
v-if="!activeServiceLine"
component="button"
type="button"
class="messages-sky-messagebar__action messages-sky-messagebar__plus"
@@ -1979,7 +1934,7 @@ onBeforeUnmount(() => {
<ArrowUpCircle :size="29" :stroke-width="2.4" />
</SkyLink>
<SkyLink
v-else-if="!activeServiceLine"
v-else
icon-only
class="messages-sky-messagebar__send"
:disabled="sending || recordingStarting"
@@ -16,12 +16,6 @@ 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', () => {
@@ -63,21 +57,6 @@ describe('PhoneApp EasyShare contract', () => {
expect(source).not.toContain('rgba(10, 132, 255')
})
it('keeps contact profiles readable in light mode', () => {
expect(source).toMatch(
/\.phone-app--light\.phone-calls-app--profile\s*\{[^}]*color:\s*var\(--sky-text\);[^}]*background:\s*var\(--sky-bg\) !important;/s,
)
expect(source).toMatch(
/\.phone-app--light \.phone-profile-action\s*\{[^}]*color:\s*var\(--sky-text\) !important;/s,
)
expect(source).toMatch(
/\.phone-app--light \.phone-profile-action:disabled\s*\{[^}]*color:\s*var\(--sky-subtle\) !important;/s,
)
expect(source).toMatch(
/\.phone-app--light \.phone-profile-card,[\s\S]*?\.phone-app--light \.phone-history-card\s*\{[^}]*color:\s*var\(--sky-text\);[^}]*background:\s*var\(--sky-glass\);/,
)
})
it('opens contact deep links only after contacts bootstrap and consumes the query', () => {
const mounted = source.slice(
source.indexOf('onMounted(async () => {'),
+1 -57
View File
@@ -2250,6 +2250,7 @@ onBeforeUnmount(() => {
position: relative;
padding-top: 6px;
padding-right: 24px;
padding-bottom: 20px;
}
.phone-contacts-header {
@@ -3858,63 +3859,6 @@ onBeforeUnmount(() => {
background: var(--sky-glass);
}
.phone-app--light.phone-calls-app--profile {
color: var(--sky-text);
background: var(--sky-bg) !important;
}
.phone-app--light .phone-call-content--profile,
.phone-app--light .phone-contact-hero h2,
.phone-app--light .phone-detail-header-button,
.phone-app--light .phone-detail-edit {
color: var(--sky-text);
}
.phone-app--light .phone-contact-hero .phone-contact-organization,
.phone-app--light .phone-profile-notes > p,
.phone-app--light .phone-history-copy small,
.phone-app--light .phone-history-row time,
.phone-app--light .phone-history-empty {
color: var(--sky-muted);
}
.phone-app--light .phone-profile-action {
color: var(--sky-text) !important;
}
.phone-app--light .phone-profile-action:disabled {
color: var(--sky-subtle) !important;
}
.phone-app--light .phone-profile-card,
.phone-app--light .phone-own-profile-card,
.phone-app--light .phone-history-card {
border-color: var(--sky-hairline);
color: var(--sky-text);
background: var(--sky-glass);
box-shadow: var(--sky-shadow-glass);
}
.phone-app--light .phone-own-profile-card > div:not(:last-child),
.phone-app--light .phone-profile-info-card > button,
.phone-app--light .phone-profile-options-card > button,
.phone-app--light .phone-profile-single-option > button,
.phone-app--light .phone-profile-notes,
.phone-app--light .phone-history-row {
border-color: var(--sky-hairline);
}
.phone-app--light .phone-own-profile-card > div > span {
color: var(--sky-text);
background: var(--sky-pressed);
}
.phone-app--light .phone-profile-info-card > button > svg,
.phone-app--light .phone-profile-notes > span,
.phone-app--light .phone-history-card h3 {
color: var(--sky-text);
}
.phone-app--light .phone-bottom-tabbar {
--sky-app-accent: #3a3a3c;
}
@@ -32,52 +32,6 @@ describe('SkyRide app layout', () => {
)
})
it('scrolls Ride and Drive away without full-width nav backgrounds', () => {
expect(source).toMatch(
/\.skyride-navbar :deep\(\.sky-navbar__blur\),\s*\.skyride-navbar :deep\(\.sky-navbar__background\)\s*\{[^}]*display:\s*none;/s,
)
expect(source).toMatch(
/<div class="skyride-scroll">\s*<div class="skyride-mode">/s,
)
expect(source).toMatch(
/\.skyride-scroll\s*\{[^}]*box-sizing:\s*border-box;[^}]*inset:\s*114px 0 0;[^}]*padding:\s*0 0 116px;/s,
)
expect(source).toMatch(
/\.skyride-tabbar :deep\(\.sky-tabbar__blur\),\s*\.skyride-tabbar :deep\(\.sky-tabbar__background\)\s*\{[^}]*display:\s*none;/s,
)
expect(source).not.toMatch(
/\.skyride-tabbar :deep\(\.sky-tabbar__pane\)\s*\{\s*border:/s,
)
})
it('keeps active-ride card spacing even and outline actions contrasted', () => {
expect(source).toMatch(
/\.skyride-ride-status-card\s*\{[^}]*margin-bottom:\s*12px;/s,
)
expect(source).toMatch(
/\.skyride-person-card\s*\{[^}]*margin-bottom:\s*12px;/s,
)
expect(source).toMatch(
/\.skyride-trip-card\s*\{[^}]*margin-bottom:\s*12px;/s,
)
expect(source).toContain('.sky-button--primary:not(.sky-button--outline)')
expect(source).not.toMatch(
/\.skyride-app :deep\(\.sky-button--primary\)\s*\{/,
)
})
it('aligns pickup and destination text to one timeline axis', () => {
expect(source).toMatch(
/\.skyride-route-stop\s*\{[^}]*grid-template-columns:\s*18px minmax\(0, 1fr\);/s,
)
expect(source).toMatch(
/\.skyride-route-stop > \.skyride-dot\s*\{[^}]*justify-self:\s*center;/s,
)
expect(source).toMatch(
/\.skyride-trip-card > i\s*\{[^}]*margin:\s*1px 0 1px 8px;/s,
)
})
it('uses a compact swipeable profile editor with equal actions', () => {
expect(source).toContain('class="skyride-profile-sheet"')
expect(source).toContain('swipe-to-close')
+25 -47
View File
@@ -622,26 +622,26 @@ onBeforeUnmount(() => {
</k-card>
<template v-else>
<div class="skyride-scroll">
<div class="skyride-mode">
<k-segmented v-if="skyride.driverEligible">
<k-segmented-button
:active="mode === 'rider'"
:disabled="Boolean(skyride.activeRide)"
@click="mode = 'rider'"
>
{{ phone.t('Apps.skyride.mode.rider') }}
</k-segmented-button>
<k-segmented-button
:active="mode === 'driver'"
:disabled="Boolean(skyride.activeRide)"
@click="mode = 'driver'"
>
{{ phone.t('Apps.skyride.mode.driver') }}
</k-segmented-button>
</k-segmented>
</div>
<div class="skyride-mode">
<k-segmented v-if="skyride.driverEligible">
<k-segmented-button
:active="mode === 'rider'"
:disabled="Boolean(skyride.activeRide)"
@click="mode = 'rider'"
>
{{ phone.t('Apps.skyride.mode.rider') }}
</k-segmented-button>
<k-segmented-button
:active="mode === 'driver'"
:disabled="Boolean(skyride.activeRide)"
@click="mode = 'driver'"
>
{{ phone.t('Apps.skyride.mode.driver') }}
</k-segmented-button>
</k-segmented>
</div>
<div class="skyride-scroll">
<template v-if="activeTab === 'home'">
<section v-if="mode === 'rider'" class="skyride-home-panel">
<template v-if="!skyride.activeRide">
@@ -1816,11 +1816,6 @@ onBeforeUnmount(() => {
color: var(--ride-text);
}
.skyride-navbar :deep(.sky-navbar__blur),
.skyride-navbar :deep(.sky-navbar__background) {
display: none;
}
.skyride-navbar :deep(.sky-navbar__title) {
color: var(--ride-text);
font-size: 18px;
@@ -1858,9 +1853,7 @@ onBeforeUnmount(() => {
.skyride-scroll {
position: absolute;
z-index: 2;
box-sizing: border-box;
inset: 114px 0 0;
padding: 0 0 116px;
inset: 148px 0 116px;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
@@ -2260,10 +2253,6 @@ onBeforeUnmount(() => {
padding: 16px;
}
.skyride-person-card {
margin-bottom: 12px;
}
.skyride-avatar,
.skyride-profile-avatar {
display: grid;
@@ -2340,19 +2329,13 @@ onBeforeUnmount(() => {
}
.skyride-route-stop {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
display: flex;
align-items: center;
column-gap: 11px;
}
.skyride-route-stop > .skyride-dot {
justify-self: center;
gap: 11px;
}
.skyride-route-stop > svg {
width: 18px;
height: 18px;
flex: 0 0 18px;
color: var(--ride-accent-strong);
}
@@ -2383,7 +2366,7 @@ onBeforeUnmount(() => {
display: block;
width: 1px;
height: 17px;
margin: 1px 0 1px 8px;
margin: 1px 0 1px 5px;
border-left: 1px dashed var(--ride-muted);
}
@@ -2708,11 +2691,6 @@ button.skyride-profile-avatar--editable {
gap: 0 !important;
}
.skyride-tabbar :deep(.sky-tabbar__blur),
.skyride-tabbar :deep(.sky-tabbar__background) {
display: none;
}
.skyride-tab-pane {
width: 100% !important;
max-width: none !important;
@@ -2991,7 +2969,7 @@ button.skyride-profile-avatar--editable {
background-color: var(--ride-accent);
}
.skyride-app :deep(.sky-button--primary:not(.sky-button--outline)) {
.skyride-app :deep(.sky-button--primary) {
color: #171719;
}
@@ -93,7 +93,7 @@ describe('phone apps use Sky UI', () => {
it('uses shared liquid glass for remaining compact interaction controls', () => {
const minimumGlassButtons: Record<string, number> = {
'CameraApp.vue': 1,
'CameraApp.vue': 2,
'FeatherApp.vue': 2,
'FlareApp.vue': 1,
'FlipTokApp.vue': 2,
@@ -116,20 +116,6 @@ 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)',
@@ -476,66 +476,6 @@ function emptyStructure(scope, path) {
return undefined
}
function companyDefinitionEntryDefault(definitions) {
const firstDefinition = definitions[Object.keys(definitions).sort()[0]]
const usedNumbers = new Set(
Object.values(definitions).map((definition) =>
String(definition.ServiceLine.Number).replace(/\D/g, ''),
),
)
let serviceNumber = ''
for (let offset = 0; offset < 999; offset += 1) {
const candidate = String(((499 + offset) % 999) + 1).padStart(3, '0')
if (!usedNumbers.has(candidate)) {
serviceNumber = candidate
break
}
}
return {
AcceptsRequests: true,
Address: '',
Category: firstDefinition.Category,
DefaultAvailability: 'closed',
Description: '',
District: '',
Emergency: false,
Icon: 'building',
Job: '',
Location: { __skyType: 'vector3', x: 0, y: 0, z: 0 },
LocationLabel: '',
LogoUrl: 'https://picsum.photos/seed/companies-new-logo/180/180',
Name: '',
Permissions: {
Announcement: 0,
Assign: 0,
Availability: 0,
Hours: 0,
Profile: 0,
Services: 0,
WorkQueue: 0,
},
Public: true,
ServiceLine: {
AutoContact: true,
CanCall: true,
CanMessage: false,
MinimumGrade: 0,
Number: serviceNumber,
Routing: 'round_robin',
},
Services: [
{
Description: '',
Id: '',
Price: '',
RequestsEnabled: true,
Title: '',
},
],
Verified: false,
}
}
function buildStructure(value, scope, path) {
if (scope === 'config' && path === 'Phone.Keybind') {
return { kind: 'optionalString' }
@@ -555,7 +495,6 @@ function buildStructure(value, scope, path) {
]),
)
return {
entryDefault: companyDefinitionEntryDefault(value),
fields,
kind: 'table',
mutableKeys: true,
@@ -722,7 +661,6 @@ function loadConfiguratorSections() {
)
const media = mediaRoot.Media
delete config.PhoneConfigurator
delete config.CommandPermissions
delete config.Media
return [...buildSections('config', config), ...buildSections('media', media)]
}
@@ -12,7 +12,6 @@ type ConfiguratorField = {
}
type ConfiguratorStructure = {
entryDefault?: unknown
entries?: Array<{ structure: ConfiguratorStructure }>
fields?: Record<string, ConfiguratorStructure>
items?: ConfiguratorStructure[]
@@ -62,21 +61,16 @@ function countStructure(structure: ConfiguratorStructure | undefined): number {
}
describe('admin configurator fixture', () => {
it('exposes every SQL-managed config.lua root through the full live preview', () => {
it('exposes every 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',
)
.filter((root) => root !== 'Media' && root !== 'PhoneConfigurator')
expect(sections).toHaveLength(46)
expect(sections).toHaveLength(45)
expect(
fields.reduce(
(total, field) => total + countStructure(field.structure),
@@ -123,22 +117,6 @@ describe('admin configurator fixture', () => {
expect(phone?.structure?.fields?.Keybind.kind).toBe('optionalString')
expect(companyJobs?.label).toBe('Jobs')
expect(companyJobs?.structure).toMatchObject({
entryDefault: {
AcceptsRequests: true,
Category: 'public_services',
Job: '',
ServiceLine: {
Number: '500',
Routing: 'round_robin',
},
Services: [
{
Id: '',
RequestsEnabled: true,
Title: '',
},
],
},
fields: {
ambulance: { kind: 'table' },
police: { kind: 'table' },
@@ -152,47 +130,6 @@ describe('admin configurator fixture', () => {
).toBe('map')
})
it('exposes the requestable police assistance defaults', () => {
const companyJobs = loadConfiguratorSections()
.flatMap((section) => section.fields)
.find((field) => field.path === 'Companies.Definitions')
const police = (
companyJobs?.value as Record<string, Record<string, unknown>> | undefined
)?.police
expect(police).toMatchObject({
AcceptsRequests: true,
Emergency: true,
Services: [
{
Id: 'police-assistance',
RequestsEnabled: true,
},
],
})
expect(
companyJobs?.structure?.fields?.police.fields?.Services,
).toMatchObject({
items: [
{
fields: {
Id: { kind: 'value', valueType: 'string' },
RequestsEnabled: { kind: 'value', valueType: 'boolean' },
},
kind: 'table',
},
],
kind: 'list',
template: {
fields: {
Id: { kind: 'value', valueType: 'string' },
RequestsEnabled: { kind: 'value', valueType: 'boolean' },
},
kind: 'table',
},
})
})
it('publishes fixed schemas for every empty configurable collection', () => {
const fields = loadConfiguratorSections().flatMap(
(section) => section.fields,
@@ -241,7 +178,7 @@ describe('admin configurator fixture', () => {
template: { kind: 'value', valueType: 'string' },
})
expect(
root('Companies.Definitions')?.fields?.ambulance.fields?.Services,
root('Companies.Definitions')?.fields?.police.fields?.Services,
).toMatchObject({
items: [],
kind: 'list',
+18 -22
View File
@@ -1978,7 +1978,7 @@ const contacts = [
},
{
canCall: true,
canMessage: true,
canMessage: false,
companyId: 'police',
avatar_url: 'https://picsum.photos/seed/companies-police-logo/180/180',
id: 'company:police',
@@ -4178,7 +4178,7 @@ const companyCategories = [
let companyCallAvailable = false
const companyProfiles = [
{
acceptsRequests: true,
acceptsRequests: false,
announcement: {
body: 'Community traffic unit active around Legion Square.',
expiresAt: isoTime(6 * 60 * 60 * 1000),
@@ -4187,7 +4187,7 @@ const companyProfiles = [
availability: 'available',
availabilityUpdatedAt: isoTime(-12 * 60 * 1000),
canCall: true,
canMessage: true,
canMessage: false,
categoryId: 'public_services',
categoryName: 'Public Services',
coverUrl: 'https://picsum.photos/seed/companies-police-cover/900/360',
@@ -4202,20 +4202,28 @@ const companyProfiles = [
label: 'Mission Row Police Station',
},
logoUrl: 'https://picsum.photos/seed/companies-police-logo/180/180',
name: 'Los Santos Police Department',
name: 'Los Santos Police',
phoneNumber: '911',
revision: 3,
services: [
{
acceptsRequests: true,
acceptsRequests: false,
active: true,
description: 'Request non-emergency police assistance.',
id: 'police-assistance',
description: 'Immediate police response through the service line.',
id: 'emergency-response',
priceText: null,
title: 'Police Assistance',
title: 'Emergency Response',
},
{
acceptsRequests: false,
active: true,
description: 'General information and non-emergency assistance.',
id: 'public-assistance',
priceText: null,
title: 'Public Assistance',
},
],
serviceSummary: 'Non-emergency police assistance',
serviceSummary: 'Emergency response and public assistance',
verified: true,
},
{
@@ -4718,7 +4726,6 @@ function adminMockBootstrap() {
targetSource: 2,
},
],
disabledApps: [],
players: [1, 2].map((source) => {
const player = adminMockPlayerDetail(source)
return {
@@ -4733,18 +4740,7 @@ function adminMockBootstrap() {
source,
}
}),
stats: {
accounts: 24,
activeDevices: 19,
auditEntries: 37,
auditToday: 4,
callsToday: 18,
devices: 31,
linkedDevices: 22,
messagesToday: 146,
online: 2,
simDevices: 27,
},
stats: { accounts: 24, devices: 31, online: 2 },
}
}
+2 -15
View File
@@ -134,7 +134,7 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
- `hex_4_inventory`
- Native ESX inventory
`mf-inventory` and `smx-inventory` are supported with ESX. The native ESX and HEX adapters use count-based items, so Sky Phone automatically disables unique phones and physical SIM cards while either adapter is active.
`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.
### Voice
@@ -196,7 +196,6 @@ 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 |
@@ -212,18 +211,6 @@ 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:
@@ -325,7 +312,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, 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.
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.
### QBCore-style item tables
+13 -82
View File
@@ -10,36 +10,19 @@
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.
-- When enabled, config.lua and media.lua only provide first-run defaults.
-- The active configuration is loaded from SQL and managed through /phonepanel.
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
-- 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",
Inventory = "auto", -- auto, ox, qb, lj, qs, codem, core, mf, smx, hex, esx
Locale = "en",
CallbackTimeout = 15000,
Debug = false, -- true: show debug/info output; warnings and errors are always shown
@@ -49,9 +32,8 @@ Config.Command = "phone"
Config.Phone = {
Item = "phone",
Unique = true, -- true: data follows each phone item; false: one persistent phone per character; forced false for metadata-free inventories
Unique = true, -- true: data follows each phone item; false: one persistent phone per character; hex/esx require false
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
@@ -61,56 +43,11 @@ Config.Phone = {
DeviceName = "iFruit Phone",
}
-- Server-wide availability for bundled apps. Set an entry to false to hide it
-- from every phone, the App Store and per-device app management.
Config.Apps = {
["app-store"] = true,
banking = true,
billing = true,
calculator = true,
calendar = true,
camera = true,
citymarkt = true,
citywarn = true,
clock = true,
companies = true,
crewlink = true,
crypto = true,
darkchat = true,
feather = true,
flare = true,
fliptok = true,
garage = true,
health = true,
house = true,
["local-pages"] = true,
mail = true,
map = true,
memory = true,
memos = true,
messages = true,
minesweeper = true,
music = true,
["neon-drop"] = true,
notes = true,
["number-merge"] = true,
phone = true,
photos = true,
picstagram = true,
radio = true,
settings = true,
["sky-flappy"] = true,
skyride = true,
snake = true,
["tower-stack"] = true,
weather = true,
["weazel-news"] = true,
}
Config.TestData = {
Enabled = false, -- development/test servers only; keep disabled in production
Command = "phonetestdata",
AdminOnly = false, -- enable only on development servers; every run is scoped to the executing player's phone
AdminGroups = { "admin", "superadmin" },
}
Config.CustomApps = {
@@ -137,6 +74,7 @@ Config.Security = {
Config.AdminPanel = {
Enabled = true,
Command = "phonepanel",
AdminGroups = { "admin", "superadmin" },
MaximumPlayers = 128,
ReadRequestsPerMinute = 60,
ActionRequestsPerMinute = 30,
@@ -146,7 +84,7 @@ Config.AdminPanel = {
}
Config.Sim = {
Enabled = true, -- false: devices receive a persistent random number automatically; forced false for metadata-free inventories
Enabled = true, -- false: devices receive a persistent random number automatically; hex/esx require false
RegisteredItem = "sky_phone_sim_registered",
AnonymousItem = "sky_phone_sim_anonymous",
NumberLength = 10, -- total number of digits, including NumberPrefix
@@ -163,7 +101,7 @@ Config.Speaker = {
}
Config.Calls = {
VoiceProvider = "pma", -- auto, yaca (alias: yaca-voice), pma (alias: pma-voice), saltychat (alias: salty)
VoiceProvider = "pma", -- yaca (alias: yaca-voice), pma (alias: pma-voice), saltychat (alias: salty)
RingSeconds = 30,
ContactNameMaxLength = 80,
ContactNotesMaxLength = 500,
@@ -348,7 +286,6 @@ Config.DarkChat = {
VoiceMaxBase64Length = 360000,
VoiceWaveformSamples = 48,
CleanupIntervalSeconds = 30,
CleanupBatchSize = 250,
AllowedDisappearTimers = {
[0] = true,
[-1] = true, -- after reading
@@ -546,6 +483,7 @@ Config.FlipTok = {
MaxPostMedia = 10,
MusicTracks = {},
VerifyCommand = "fliptokverify",
AdminGroups = { "admin" },
ReportWebhookConvar = "sky_phone_fliptok_report_webhook",
}
@@ -567,6 +505,7 @@ Config.Picstagram = {
ReportDetailsMaxLength = 500,
ReportReasons = { "spam", "harassment", "dangerous", "illegal", "other" },
VerifyCommand = "picstagramverify",
AdminGroups = { "admin" },
}
Config.Feather = {
@@ -1181,7 +1120,7 @@ if IsDuplicityVersion() then
LogoUrl = "https://picsum.photos/seed/companies-police-logo/180/180",
Description = "Public safety, emergency response, and police services.",
DefaultAvailability = "closed",
AcceptsRequests = true,
AcceptsRequests = false,
District = "Mission Row",
LocationLabel = "Mission Row Police Station",
Address = "Mission Row Police Station",
@@ -1190,7 +1129,7 @@ if IsDuplicityVersion() then
Number = "911",
AutoContact = true,
CanCall = true,
CanMessage = true,
CanMessage = false,
Routing = "round_robin",
MinimumGrade = 0,
},
@@ -1203,15 +1142,7 @@ if IsDuplicityVersion() then
Services = 3,
Announcement = 3,
},
Services = {
{
Id = "police-assistance",
Title = "Police assistance",
Description = "Request non-emergency police assistance.",
Price = "",
RequestsEnabled = true,
},
},
Services = {},
},
ambulance = {
Job = "ambulance",
+32 -7
View File
@@ -43,7 +43,6 @@ 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.",
},
@@ -206,7 +205,7 @@ Locales["de"] = {
name = "Phone Admin", subtitle = "Administration", navigation = "Admin-Navigation", refresh = "Admin-Daten aktualisieren", loading = "Geschützte Daten werden geladen...",
tabs = { overview = "Übersicht", players = "Spieler", devices = "Geräte", apps = "Apps", accounts = "Accounts", messages = "Nachrichten", calls = "Anrufe", moderation = "Moderation", audit = "Audit", configurator = "Phone Configurator" },
overview = { eyebrow = "Server", title = "Dashboard", body = "Spieler, Geräte, Apps und Handydaten.", stats = "Server-Handystatistik", online = "Online", devices = "Geräte", accounts = "Accounts", audit = "Audit-Einträge", control = "Navigation", features = "Module", featuresBody = "Öffne ein Verwaltungsmodul.", recent = "Letzte Aktivitäten", playerFeature = "Identität, Finanzen, Job und Dienst", deviceFeature = "IMEI, SIM, Nummer und Aktivität", appFeature = "Handy-Apps installieren oder entfernen", accountFeature = "Accountzugriff und geschützte Zugangsdaten", messageFeature = "Letzte SMS-Aktivitäten prüfen", callFeature = "Letzte Anrufaktivitäten prüfen", moderationFeature = "Zugriff, Nummer oder Gerätedaten zurücksetzen", auditFeature = "Sensible Admin-Aktionen prüfen", configuratorFeature = "config.lua und media.lua über SQL verwalten" },
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" },
appearance = { eyebrow = "Darstellung", title = "Akzentfarbe", body = "Ändere den Akzent im gesamten Admin-Arbeitsbereich.", colors = { emerald = "Smaragd", blue = "Blau", violet = "Violett", orange = "Orange", red = "Rot" } },
configurator = { context = "Runtime-Konfiguration", eyebrow = "Systemwerkzeug", sections = "Konfiguration", search = "Einstellungen oder Pfade suchen", configScope = "config.lua", mediaScope = "media.lua", noResults = "Keine passenden Einstellungen", loading = "SQL-Konfiguration wird geladen...", title = "Phone Configurator", body = "Verwalte Handy- und Media-Einstellungen im geschützten Admin-Bereich.", disabledTitle = "SQL-Konfiguration ist nicht aktiv", disabledBody = "Aktiviere den Configurator am Anfang der config.lua und starte sky_phone neu. Bis dahin bleiben die Dateiwerte aktiv und die Bearbeitung gesperrt.", manualSave = "Manuelles Speichern", refreshNotice = "Nichts wird automatisch gespeichert. Der grüne Haken prüft config.lua und media.lua in SQL und aktualisiert die aktive Server-, Client-, Media- und UI-Konfiguration sofort intern.", fieldCount = "{count} Felder", secretConfigured = "Secret gesetzt · Ersatzwert eingeben", invalidValue = "Prüfe den markierten Tabellen- oder Zahlenwert.", saved = "SQL-Konfiguration gespeichert und übernommen.", descriptions = { featureToggle = "Schaltet {name} ein oder aus.", boolean = "Legt fest, ob {name} erlaubt ist.", number = "Legt den Zahlenwert für {name} fest.", text = "Legt den Textwert für {name} fest.", optionalText = "Legt den optionalen Wert für {name} fest; der Schalter deaktiviert ihn.", list = "Verwaltet alle Einträge für {name}.", table = "Bündelt die zusammengehörigen Einstellungen für {name}.", credential = "Speichert den geschützten Zugangsschlüssel für {name}.", url = "Legt die URL oder den Endpunkt für {name} fest.", hosts = "Legt die erlaubten Domains für {name} fest.", milliseconds = "Legt die Zeit für {name} in Millisekunden fest.", seconds = "Legt die Zeit für {name} in Sekunden fest.", rateLimit = "Begrenzt die Aktionen für {name} pro Minute.", byteLimit = "Legt die maximal erlaubte Datengröße für {name} fest.", textLimit = "Legt die maximal erlaubte Textlänge für {name} fest.", distance = "Legt die Distanz in der Spielwelt für {name} fest.", coordinates = "Legt Weltkoordinaten oder Ausrichtung für {name} fest.", gameAsset = "Legt das GTA-Modell oder Prop für {name} fest.", animation = "Legt die Animationsdatei für {name} fest.", access = "Legt Jobs, Gruppen oder Berechtigungsstufen für {name} fest.", integration = "Wählt Framework oder Anbieter für {name} aus.", path = "Legt den Speicher- oder Ressourcenpfad für {name} fest.", color = "Legt die Oberflächenfarbe für {name} fest.", displayText = "Legt den Spielern angezeigten Text für {name} fest.", phoneNumber = "Legt die Telefon- oder Servicenummer für {name} fest.", routing = "Steuert die Verteilung eingehender Anfragen für {name}.", command = "Legt den Chat-Befehl zum Öffnen oder Ausführen von {name} fest.", locale = "Wählt die Sprache für {name} aus.", debug = "Steuert ausführliche Diagnoseausgaben für {name}.", mediaQuality = "Legt Medienqualität oder Lautstärke für {name} fest.", amount = "Legt die maximale oder angezeigte Menge für {name} fest." }, table = { list = "Liste", table = "Schlüsseltabelle", vector = "Vektor", entry = "Eintrag", general = "Allgemein", addRow = "Zeile hinzufügen", addField = "Feld hinzufügen", remove = "Entfernen", emptyList = "Noch keine Zeilen. Füge die erste Zeile mit Plus hinzu.", emptyTable = "Noch keine Felder. Füge unten den ersten Schlüssel hinzu.", keyPlaceholder = "Neuer Schlüssel", convertToList = "Als Liste nutzen", convertToMap = "Als typisierte Schlüsseltabelle nutzen", convertToTable = "Als Schlüsseltabelle nutzen", types = { string = "Text", number = "Zahl", boolean = "Schalter", list = "Liste", table = "Tabelle" } } },
players = { eyebrow = "Aktive Sitzungen", title = "Online-Spieler", online = "Jetzt online", empty = "Keine Spieler gefunden", emptyBody = "Passe die Suche an oder aktualisiere die Spielerliste." },
search = { players = "Name, ID, Job oder Nummer suchen", apps = "Apps suchen", clear = "Suche leeren" },
@@ -222,7 +221,7 @@ Locales["de"] = {
},
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.", invalid_company_configuration = "Die Firmenkonfiguration ist unvollständig oder enthält einen doppelten Job, eine doppelte Servicenummer oder Service-ID.", 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." },
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 = {
@@ -695,7 +694,6 @@ Locales["de"] = {
gif_provider_rate_limited = "GIPHY ist ausgelastet. Versuch es gleich erneut.", gif_provider_failed = "GIF-Suche ist vorübergehend nicht verfügbar.",
self_message = "Du kannst deine eigene Nummer nicht melden.", recipient_not_found = "Diese Nummer ist nicht verfügbar.", blocked = "Dieser Kontakt hat Anrufe und Nachrichten von deinem SIM blockiert.",
messaging_unavailable = "Dieser Firmenkontakt akzeptiert keine Nachrichten.",
service_line_text_only = "Firmen-Serviceleitungen akzeptieren derzeit nur Textnachrichten.",
no_sim = "Dieses Handy hat keine SIM-Karte.", rate_limited = "Zu viele Nachrichten. Versuch es in einer Minute erneut.",
request_failed = "Nachrichten sind vorübergehend nicht verfügbar.", default = "Die Nachricht konnte nicht gesendet werden.",
},
@@ -1229,7 +1227,7 @@ Locales["de"] = {
},
skyride = {
name = "SkyRide", loading = "Laden SkyRide", unavailable = "SkyRide nicht verfügbar", tryAgain = "Erneut versuchen",
mode = { rider = "Passagier", driver = "Fahrer" },
mode = { rider = "Reiten", driver = "Antrieb" },
riderEyebrow = "Fahr irgendwohin.", whereTo = "Wohin?", pickup = "Abholung", destination = "Ziel",
currentLocation = "Aktueller Standort", notSelected = "Nicht ausgewählt", location = "Wähle einen Ort",
chooseLocation = { pickup = "Abholvorgang einstellen", destination = "Ziel auswählen" },
@@ -1450,8 +1448,8 @@ Locales["de"] = {
camera = {
name = "Kamera", flash = "Blitz", flip = "Kamera wechseln", landscape = "Zum Querformat wechseln",
portrait = "Zum Hochformat wechseln", photo = "Foto", video = "Video", microphoneOn = "Mikrofon eingeschaltet", microphoneOff = "Mikrofon stumm",
focusHelp = "Halte die Umschautaste gedrückt, um dich umzusehen", returnHelp = "Umschautaste loslassen für Steuerung", lockCamera = "Kamerabewegung sperren",
unlockCamera = "Kamerabewegung entsperren", lookKey = "Umschauen", uploading = "Upload: {count}",
focusHelp = "Halte die Leertaste gedrückt, um dich umzusehen", returnHelp = "Leertaste für Steuerung", lockCamera = "Kamerabewegung sperren",
unlockCamera = "Kamerabewegung entsperren", spaceKey = "Leertaste", uploading = "Upload: {count}",
saving = "Video speichern...", openGallery = "Fotos öffnen", takePhoto = "Foto machen",
startRecording = "Aufnahme starten", stopRecording = "Aufnahme beenden", saved = "Auf Fotos gespeichert.",
zoom = "Kamerazoom auf {zoom} einstellen",
@@ -2141,6 +2139,7 @@ Locales["de"] = {
}
Locales["de"].Nui.AdminPanel.configurator.table.subtabs = {
AdminGroups = "Admin-Gruppen",
Dictionaries = "Animationsdateien",
Clips = "Clips",
Transforms = "Transformationen",
@@ -2177,3 +2176,29 @@ Locales["de"].Nui.AdminPanel.configurator.table.subtabs = {
AllowedJobs = "Erlaubte Jobs",
Websites = "Webseiten",
}
Locales["de"].Nui.AdminPanel.appearance.title = "Oberfläche"
Locales["de"].Nui.AdminPanel.appearance.body = "Passe Farben und Typografie im gesamten Admin-Bereich an."
Locales["de"].Nui.AdminPanel.appearance.controls = {
customColor = "Eigene Farbe",
customColorBody = "Wähle einen beliebigen RGB-Akzent oder trage die Farbwerte ein.",
red = "R",
green = "G",
blue = "B",
hex = "HEX-Farbe",
value = "Wert",
fontFamily = "Schriftart",
fontFamilyBody = "Wähle die Schrift für das gesamte Admin-Panel.",
fontSize = "Schriftgröße",
fontSizeBody = "Skaliere Text und Bedienelemente für eine angenehme Lesbarkeit.",
fonts = {
inter = "Inter",
system = "System",
classic = "Klassisch",
verdana = "Verdana",
tahoma = "Tahoma",
trebuchet = "Trebuchet",
georgia = "Georgia",
mono = "Monospace",
},
}
+31 -6
View File
@@ -43,7 +43,6 @@ 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.",
},
@@ -206,7 +205,7 @@ Locales["en"] = {
name = "Phone Admin", subtitle = "Administration", navigation = "Admin navigation", refresh = "Refresh admin data", loading = "Loading protected data...",
tabs = { overview = "Overview", players = "Players", devices = "Devices", apps = "Apps", accounts = "Accounts", messages = "Messages", calls = "Calls", moderation = "Moderation", audit = "Audit", configurator = "Phone configurator" },
overview = { eyebrow = "Server", title = "Dashboard", body = "Players, devices, apps, and phone data.", stats = "Server phone statistics", online = "Online", devices = "Devices", accounts = "Accounts", audit = "Audit entries", control = "Navigation", features = "Modules", featuresBody = "Open an administration module.", recent = "Recent activity", playerFeature = "Identity, finances, job, and duty", deviceFeature = "IMEI, SIM, number, and activity", appFeature = "Install or remove phone apps", accountFeature = "Account access and protected credentials", messageFeature = "Review recent SMS activity", callFeature = "Review recent call activity", moderationFeature = "Reset access, number, or device data", auditFeature = "Review sensitive admin actions", configuratorFeature = "Manage config.lua and media.lua through SQL" },
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" },
appearance = { eyebrow = "Appearance", title = "Accent color", body = "Change the accent across the complete admin workspace.", colors = { emerald = "Emerald", blue = "Blue", violet = "Violet", orange = "Orange", red = "Red" } },
configurator = { context = "Runtime configuration", eyebrow = "System tool", sections = "Configuration", search = "Search settings or paths", configScope = "config.lua", mediaScope = "media.lua", noResults = "No matching settings", loading = "Loading SQL configuration...", title = "Phone configurator", body = "Manage phone and media settings from the protected admin workspace.", disabledTitle = "SQL configuration is not active", disabledBody = "Enable the configurator at the beginning of config.lua and restart sky_phone. Until then, file values remain active and editing is locked.", manualSave = "Manual save", refreshNotice = "Nothing is written automatically. The green check verifies config.lua and media.lua in SQL and refreshes the active server, client, media and UI configuration immediately.", fieldCount = "{count} fields", secretConfigured = "Secret configured · enter a replacement", invalidValue = "Check the highlighted table or number value.", saved = "SQL configuration saved and applied.", descriptions = { featureToggle = "Turns {name} on or off.", boolean = "Controls whether {name} is allowed.", number = "Sets the numeric value for {name}.", text = "Sets the text value used for {name}.", optionalText = "Sets the optional value for {name}; switch it off to disable it.", list = "Manages all entries used for {name}.", table = "Groups the related settings for {name}.", credential = "Stores the protected credential used by {name}.", url = "Sets the URL or endpoint used by {name}.", hosts = "Defines which domains are allowed for {name}.", milliseconds = "Sets the timing for {name} in milliseconds.", seconds = "Sets the timing for {name} in seconds.", rateLimit = "Limits how many {name} actions are allowed per minute.", byteLimit = "Sets the maximum data size allowed for {name}.", textLimit = "Sets the maximum text length allowed for {name}.", distance = "Sets the world distance used for {name}.", coordinates = "Sets the world coordinates or orientation for {name}.", gameAsset = "Sets the GTA model or prop used for {name}.", animation = "Sets the animation asset used for {name}.", access = "Defines the jobs, groups or permission level for {name}.", integration = "Selects the connected framework or provider for {name}.", path = "Sets the storage or resource path used for {name}.", color = "Sets the interface color used for {name}.", displayText = "Sets the text shown to players for {name}.", phoneNumber = "Sets the phone or service number used for {name}.", routing = "Controls how incoming requests are routed for {name}.", command = "Sets the chat command used to open or run {name}.", locale = "Selects the language used for {name}.", debug = "Controls detailed diagnostic output for {name}.", mediaQuality = "Sets the media quality or volume used for {name}.", amount = "Sets the maximum or displayed amount for {name}." }, table = { list = "List", table = "Key table", vector = "Vector", entry = "Entry", general = "General", addRow = "Add row", addField = "Add field", remove = "Remove", emptyList = "No rows yet. Add the first row with plus.", emptyTable = "No fields yet. Add the first key below.", keyPlaceholder = "New key", convertToList = "Use list", convertToMap = "Use typed key table", convertToTable = "Use key table", types = { string = "Text", number = "Number", boolean = "Switch", list = "List", table = "Table" } } },
players = { eyebrow = "Active sessions", title = "Online players", online = "Online now", empty = "No players found", emptyBody = "Adjust the search or refresh the live player list." },
search = { players = "Search name, ID, job, or number", apps = "Search apps", clear = "Clear search" },
@@ -222,7 +221,7 @@ Locales["en"] = {
},
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.", invalid_company_configuration = "The company configuration is incomplete or contains a duplicate job, service number, or service ID.", 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." },
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 = {
@@ -695,7 +694,6 @@ Locales["en"] = {
gif_provider_rate_limited = "GIPHY is busy. Try again in a moment.", gif_provider_failed = "GIF search is temporarily unavailable.",
self_message = "You cannot message your own number.", recipient_not_found = "That number is unavailable.", blocked = "This contact has blocked calls and messages from your SIM.",
messaging_unavailable = "This company contact does not accept messages.",
service_line_text_only = "Company service lines currently accept text messages only.",
no_sim = "This phone has no SIM card.", rate_limited = "Too many messages. Try again in a minute.",
request_failed = "Messages are temporarily unavailable.", default = "The message could not be sent.",
},
@@ -1450,8 +1448,8 @@ Locales["en"] = {
camera = {
name = "Camera", flash = "Flash", flip = "Flip camera", landscape = "Switch to landscape",
portrait = "Switch to portrait", photo = "Photo", video = "Video", microphoneOn = "Microphone on", microphoneOff = "Microphone muted",
focusHelp = "Hold the look key to look around", returnHelp = "Release the look key for controls", lockCamera = "Lock camera movement",
unlockCamera = "Unlock camera movement", lookKey = "Look", uploading = "{count} uploading",
focusHelp = "Hold Space to look around", returnHelp = "Release Space for controls", lockCamera = "Lock camera movement",
unlockCamera = "Unlock camera movement", spaceKey = "Space", uploading = "{count} uploading",
saving = "Saving video...", openGallery = "Open Photos", takePhoto = "Take photo",
startRecording = "Start recording", stopRecording = "Stop recording", saved = "Saved to Photos.",
zoom = "Set camera zoom to {zoom}",
@@ -2141,6 +2139,7 @@ Locales["en"] = {
}
Locales["en"].Nui.AdminPanel.configurator.table.subtabs = {
AdminGroups = "Admin Groups",
Dictionaries = "Dictionaries",
Clips = "Clips",
Transforms = "Transforms",
@@ -2177,3 +2176,29 @@ Locales["en"].Nui.AdminPanel.configurator.table.subtabs = {
AllowedJobs = "Allowed Jobs",
Websites = "Websites",
}
Locales["en"].Nui.AdminPanel.appearance.title = "Interface"
Locales["en"].Nui.AdminPanel.appearance.body = "Personalize colors and typography across the admin workspace."
Locales["en"].Nui.AdminPanel.appearance.controls = {
customColor = "Custom color",
customColorBody = "Choose any RGB accent or enter its channel values.",
red = "R",
green = "G",
blue = "B",
hex = "HEX color",
value = "value",
fontFamily = "Font family",
fontFamilyBody = "Choose the typeface used by the complete admin panel.",
fontSize = "Font size",
fontSizeBody = "Scale text and controls for comfortable readability.",
fonts = {
inter = "Inter",
system = "System",
classic = "Classic",
verdana = "Verdana",
tahoma = "Tahoma",
trebuchet = "Trebuchet",
georgia = "Georgia",
mono = "Monospace",
},
}
File diff suppressed because it is too large Load Diff
+4 -10
View File
@@ -2,25 +2,19 @@
Sky Phone media configuration
This file is loaded on the server only. Keep API keys private. When the
phone configurator is enabled in config.lua, the active media configuration
is loaded from SQL and frontend builds copy the shipped values from this
file into source/shared/config_default.lua.
phone configurator is enabled in config.lua, these values are first-run
defaults and the active media configuration is loaded from SQL.
]]
Config.Media = {
-- 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 = "",
GiphyApiKey = "", -- Paste the GIPHY API key here.
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 = "",
ApiKey = "", -- Paste a newly generated FiveManage V3 Media API token here.
BaseUrl = "https://api.fivemanage.com/api/v3/file",
RequestTimeoutMs = 10000,
UploadTimeoutMs = 25000,
+3 -4
View File
@@ -6,7 +6,7 @@ use_experimental_fxv2_oal 'yes'
author 'Sky-Systems'
description 'Sky Phone'
version '0.3.0'
version '0.2.5'
provide 'lb-phone'
provide '17mov_Phone'
@@ -30,7 +30,8 @@ shared_scripts {
client_scripts {
'config/config.lua',
'config/locales/*.lua',
'config/locales/en.lua',
'config/locales/de.lua',
'source/bridge/client/callbacks.lua',
'source/client/phone_configurator.lua',
'source/bridge/client/framework.lua',
@@ -76,8 +77,6 @@ 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',
-9
View File
@@ -11,15 +11,6 @@ local provider_aliases = {
local function resolve_provider()
local configured = tostring(Config.Calls.VoiceProvider or "")
if configured == "auto" then
for _, candidate in ipairs({ "yaca", "pma", "saltychat" }) do
if GetResourceState(provider_resources[candidate]) == "started" then
return candidate
end
end
return nil
end
local selected = provider_aliases[configured] or configured
local resource_name = provider_resources[selected]
if resource_name and GetResourceState(resource_name) == "started" then
@@ -19,23 +19,3 @@ 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,13 +24,6 @@ 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
+46 -92
View File
@@ -1,92 +1,60 @@
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
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
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
configured_inventory = "esx"
end
end
local selected_adapter = supported_inventories[configured_inventory]
if not selected_adapter then
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
error(("[sky_phone] Unsupported or unavailable inventory '%s'. Configure a supported inventory adapter."):format(tostring(configured_inventory)))
end
if GetResourceState(selected_adapter.resource) ~= "started" then
error(("[sky_phone] Inventory '%s' is configured, but resource '%s' is not started.")
:format(tostring(configured_inventory), selected_adapter.resource))
end
if selected_adapter.framework and selected_adapter.framework ~= framework_name then
error(("[sky_phone] Inventory '%s' is only supported with framework '%s'.")
:format(tostring(configured_inventory), selected_adapter.framework))
end
Bridge.Inventory.Name = configured_inventory
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
)
if configured_inventory == "hex" or configured_inventory == "esx" then
if Bridge.Framework.GetName() ~= "esx" then
Bridge.Inventory.ConfigurationError =
("[sky_phone] Inventory '%s' is only supported with ESX."):format(configured_inventory)
elseif Config.Phone.Unique ~= false or Config.Sim.Enabled ~= false then
Bridge.Inventory.ConfigurationError = ("[sky_phone] Inventory '%s' cannot store unique phone or physical SIM metadata. Set Config.Phone.Unique = false and Config.Sim.Enabled = false, or configure a metadata-capable inventory.")
:format(configured_inventory)
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
function Bridge.Inventory.NormalizeItem(item, metadata_field)
if not item then
return nil
end
@@ -95,15 +63,11 @@ function Bridge.Inventory.NormalizeItem(item, metadata_field, fallback_slot)
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 or item.item,
slot = numeric_slot or slot,
count = count,
amount = count,
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,
metadata = type(metadata) == "table" and metadata or {},
}
end
@@ -120,13 +84,3 @@ 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
@@ -1,87 +0,0 @@
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,8 +9,7 @@ local function normalize(item)
end
local function get_inventory(source)
local identifier = Bridge.Framework.GetIdentifier(source)
return identifier and inventory:GetInventory(identifier, source) or {}
return inventory:GetInventory(false, source) or {}
end
function Bridge.Inventory.GetResourceName()
@@ -28,14 +27,9 @@ end
function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
local matches = {}
local items = inventory:GetItemsByName(source, item_name) or {}
if items.name then
items = { items }
end
for _, item in pairs(items) do
for _, 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
if normalized.name == item_name and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then
matches[#matches + 1] = normalized
end
end
@@ -72,7 +66,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, ...)
callback(source, normalize(Bridge.Inventory.ResolveUsableItem(...)))
return Bridge.Framework.RegisterUsableItem(item_name, function(source, item)
callback(source, normalize(item))
end)
end
@@ -39,15 +39,11 @@ end
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
local numeric_slot = tonumber(slot_id)
local slot = numeric_slot and Bridge.Inventory.GetSlot(source, numeric_slot) or nil
if not slot then
if not numeric_slot then
return false
end
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
inventory:setMetadata(source, numeric_slot, metadata or {})
return true
end
function Bridge.Inventory.CanCarryItem()
@@ -1,94 +0,0 @@
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
@@ -1,109 +0,0 @@
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
@@ -1,76 +0,0 @@
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
@@ -1,88 +0,0 @@
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,10 +44,8 @@ function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
return false
end
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
inventory:SetMetadata(source, tonumber(slot_id), metadata or {})
return true
end
function Bridge.Inventory.CanCarryItem(source, item_name, count, metadata)
@@ -1,109 +0,0 @@
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
@@ -1,75 +0,0 @@
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
-9
View File
@@ -41,15 +41,6 @@ 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
+15 -57
View File
@@ -9,13 +9,9 @@ local front_camera_fov = 32.0
local front_camera_distance = 1.05
local front_camera_height = 0.05
local front_camera_target_height = 0.03
local front_camera_horizontal_limit = 75.0
local front_camera_vertical_limit = 35.0
local front_camera_rotate_speed = 5.0
local camera_passthrough_control = 22 -- INPUT_JUMP (Space by default)
local blocked_camera_controls = {
0, -- INPUT_NEXT_CAMERA
camera_passthrough_control,
22, -- INPUT_JUMP
24, -- INPUT_ATTACK
25, -- INPUT_AIM
37, -- INPUT_SELECT_WEAPON
@@ -49,8 +45,6 @@ local camera_state = {
focus_watcher = false,
front_camera = false,
front_camera_handle = nil,
front_camera_pitch = 0.0,
front_camera_yaw = 0.0,
game_input = false,
landscape = false,
locked = false,
@@ -95,39 +89,20 @@ end
local function get_front_camera_transform(ped)
local head_position = GetPedBoneCoords(ped, 31086, 0.0, 0.0, 0.0)
local forward = GetEntityForwardVector(ped)
local yaw = math.rad(camera_state.front_camera_yaw)
local pitch = math.rad(camera_state.front_camera_pitch)
local orbit_direction = vector3(
(forward.x * math.cos(yaw)) - (forward.y * math.sin(yaw)),
(forward.x * math.sin(yaw)) + (forward.y * math.cos(yaw)),
0.0
)
local camera_position = head_position
+ (orbit_direction * (math.cos(pitch) * front_camera_distance))
+ vector3(0.0, 0.0, front_camera_height + (math.sin(pitch) * front_camera_distance))
local forward_vector = vector3(forward.x, forward.y, forward.z)
local camera_offset = forward_vector * front_camera_distance
local camera_position = head_position + camera_offset + vector3(0.0, 0.0, front_camera_height)
local to_camera = camera_position - head_position
local dot = (to_camera.x * forward_vector.x)
+ (to_camera.y * forward_vector.y)
+ (to_camera.z * forward_vector.z)
if dot < 0.0 then
camera_position = head_position - camera_offset + vector3(0.0, 0.0, front_camera_height)
end
local target_position = head_position + vector3(0.0, 0.0, front_camera_target_height)
return camera_position, target_position
end
local function update_front_camera_orbit()
local horizontal_input = GetDisabledControlNormal(0, 1)
local vertical_input = GetDisabledControlNormal(0, 2)
camera_state.front_camera_yaw = math.max(
-front_camera_horizontal_limit,
math.min(
front_camera_horizontal_limit,
camera_state.front_camera_yaw - (horizontal_input * front_camera_rotate_speed)
)
)
camera_state.front_camera_pitch = math.max(
-front_camera_vertical_limit,
math.min(
front_camera_vertical_limit,
camera_state.front_camera_pitch - (vertical_input * front_camera_rotate_speed)
)
)
end
local function apply_front_camera(ped)
if not camera_state.front_camera_handle or not DoesCamExist(camera_state.front_camera_handle) then
camera_state.front_camera_handle = CreateCam("DEFAULT_SCRIPTED_CAMERA", true)
@@ -223,18 +198,7 @@ local function watch_camera_controls()
CreateThread(function()
while camera_state.active do
apply_camera_controls()
if not camera_state.walkable then
local passthrough_pressed = SkyPhoneFocus.IsHoldToLookPressed()
or IsDisabledControlPressed(0, camera_passthrough_control)
local should_focus = camera_state.locked or not passthrough_pressed
if camera_state.nui_focused ~= should_focus then
set_camera_focus(should_focus)
end
end
if camera_state.game_input then
if camera_state.front_camera and not camera_state.locked then
update_front_camera_orbit()
end
if
IsDisabledControlJustPressed(0, 241)
or IsDisabledControlJustPressed(0, 261)
@@ -250,6 +214,9 @@ local function watch_camera_controls()
math.max(minimum_zoom, camera_state.zoom - mouse_wheel_zoom_step)
)
end
if IsDisabledControlJustReleased(0, 22) then
set_camera_focus(true)
end
end
Wait(0)
end
@@ -276,7 +243,7 @@ AddEventHandler("sky_phone:client:cameraFocusApplied", function(data)
camera_state.applied_nui_focus = data.focused
SendNUIMessage({ type = "camera:focus", data = { focused = data.focused } })
end
if data.active then
if data.active and data.gameInput then
watch_camera_controls()
end
end)
@@ -289,8 +256,6 @@ local function set_camera_active(active)
TriggerEvent("sky_phone:client:cameraActiveChanged", active)
if active then
camera_state.front_camera = false
camera_state.front_camera_pitch = 0.0
camera_state.front_camera_yaw = 0.0
camera_state.landscape = false
camera_state.locked = false
camera_state.zoom = 1.0
@@ -325,8 +290,6 @@ local function set_camera_active(active)
end
set_flash_enabled(false)
camera_state.front_camera = false
camera_state.front_camera_pitch = 0.0
camera_state.front_camera_yaw = 0.0
camera_state.game_input = false
camera_state.landscape = false
camera_state.locked = false
@@ -351,8 +314,6 @@ local function set_front_camera(active)
return
end
if active then
camera_state.front_camera_pitch = 0.0
camera_state.front_camera_yaw = 0.0
apply_front_camera(PlayerPedId())
else
clear_front_camera()
@@ -444,9 +405,6 @@ RegisterNUICallback("camera:setLocked", function(data, cb)
return
end
camera_state.locked = data.locked == true
if camera_state.locked and not camera_state.walkable then
set_camera_focus(true)
end
cb({ success = true })
end)
+4 -17
View File
@@ -58,16 +58,6 @@ AddEventHandler("sky_phone:configurator:updated", function()
SkyPhoneFocus.Reapply()
end)
function SkyPhoneFocus.IsHoldToLookPressed()
if not hold_to_look_enabled then
return false
end
if IsControlPressed(0, hold_to_look_control) then
return true
end
return IsDisabledControlPressed(0, hold_to_look_control)
end
function SkyPhoneFocus.ApplyFocusedControls()
for _, group in ipairs(focused_control_groups) do
DisableAllControlActions(group)
@@ -106,10 +96,6 @@ 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
if state.camera_active then
-- Forward controls so disabled inputs remain readable while the NUI cursor owns focus.
return { block_game = true, block_look = true, cursor = true, focused = true, game_input = false, keep_input = true }
end
local game_input = state.is_open
and allows_game_input(state)
and not state.camera_active
@@ -151,7 +137,7 @@ function SkyPhoneFocus.Reapply()
active = state.camera_active,
cursor = focus.cursor,
focused = focus.focused,
gameInput = focus.game_input,
gameInput = focus.keep_input,
})
end
@@ -248,9 +234,10 @@ end
CreateThread(function()
while true do
if game_input or block_game then
local look_passthrough = game_input
local look_passthrough = hold_to_look_enabled
and game_input
and not state.cursor_disabled
and SkyPhoneFocus.IsHoldToLookPressed()
and IsControlPressed(0, hold_to_look_control)
if look_passthrough ~= state.look_passthrough then
state.look_passthrough = look_passthrough
SkyPhoneFocus.Reapply()
+33 -72
View File
@@ -13,7 +13,9 @@ local suggested_admin_command = nil
local suggested_test_data_command = nil
local active_development_command = nil
local registered_development_commands = {}
local phone_key_mapping_registered = false
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
@@ -73,21 +75,6 @@ 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",
@@ -149,7 +136,6 @@ AddEventHandler("sky_phone:configurator:updated", function()
refresh_admin_command_suggestion()
SkyPhoneApps.SendCatalog()
if is_open and device_payload then
apply_disabled_apps(device_payload)
device_payload.lang = locale_name
device_payload.locales = locale.Nui
device_payload.fallbackLocales = Locales.en.Nui
@@ -189,22 +175,6 @@ 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.")
@@ -220,9 +190,6 @@ 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
@@ -235,7 +202,12 @@ local function toggle_phone(open, no_focus)
return true
end
return request_phone_open("sky_phone:device:open-request")
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
end
SkyPhoneClient.Toggle = toggle_phone
@@ -245,16 +217,13 @@ AddEventHandler("sky_phone:client:forceClose", function()
end)
local function run_development_command()
if is_open then
if is_open or open_requested then
close_phone()
return
end
if open_requested then
return
end
open_without_focus = false
request_phone_open("sky_phone:device:development-open")
Bridge.Callbacks.Trigger("sky_phone:device:development-open", {})
end
refresh_development_command = function()
@@ -287,16 +256,13 @@ end
refresh_development_command()
local function run_phone_toggle()
if is_open then
if is_open or open_requested then
close_phone()
return
end
if open_requested then
return
end
open_without_focus = false
request_phone_open("sky_phone:device:open-request")
Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
end
RegisterCommand("sky_phone_live_activity_open", function()
@@ -305,35 +271,38 @@ RegisterCommand("sky_phone_live_activity_open", function()
end
open_home_requested = true
if not request_phone_open("sky_phone:device:open-request") then
local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
if not result or not result.success then
open_home_requested = false
end
end, false)
RegisterCommand("sky_phone_toggle", function()
if not Config.Phone.Keybind then
return
end
run_phone_toggle()
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 phone_key_mapping_registered or not key_name then
if key_name == active_key_mapping_key then
return
end
-- FiveM persists player rebindings by command name, so this identifier must remain stable.
phone_key_mapping_registered = true
RegisterKeyMapping(
"sky_phone_toggle",
locale.Controls.OpenPhone,
"keyboard",
key_name
)
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()
@@ -490,10 +459,6 @@ 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(
@@ -503,7 +468,6 @@ 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
@@ -520,7 +484,6 @@ 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 })
@@ -536,10 +499,8 @@ RegisterNetEvent("sky_phone:device:invalidated", function()
end)
RegisterNetEvent("sky_phone:device:error", function(error_code)
if not is_open then
open_requested = false
if not is_open and not open_requested then
open_without_focus = false
open_home_requested = false
end
Bridge.Debug(
"debug",
+5 -28
View File
@@ -98,7 +98,7 @@ end
local function require_admin(source, operation, maximum)
if not Config.AdminPanel.Enabled
or not Bridge.Framework.HasPermission(source, "phonepanel")
or not Bridge.Framework.HasAdminGroup(source, Config.AdminPanel.AdminGroups)
then
Bridge.Debug("warn", "[sky_phone] Rejected admin panel access from source %s.", tostring(source))
return nil, { success = false, error = "not_authorized" }
@@ -121,7 +121,7 @@ local function run_admin_command(command_source)
TriggerClientEvent("sky_phone:admin:command-error", player_source, "disabled")
return
end
if not Bridge.Framework.HasPermission(player_source, "phonepanel") then
if not Bridge.Framework.HasAdminGroup(player_source, Config.AdminPanel.AdminGroups) then
Bridge.Debug(
"warn",
"[sky_phone] Rejected admin panel command from source %s.",
@@ -373,9 +373,6 @@ 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,
@@ -466,36 +463,16 @@ 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_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`
(SELECT COUNT(*) FROM `sky_phone_accounts`) AS `accounts`
]], {})
local stats = totals[1] or {}
return {
success = true,
data = {
players = players,
disabledApps = SkyPhone.GetDisabledApps(),
stats = {
online = #players,
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,
devices = tonumber(totals[1] and totals[1].devices) or 0,
accounts = tonumber(totals[1] and totals[1].accounts) or 0,
},
audit = load_audit(),
},
+86 -377
View File
@@ -156,18 +156,12 @@ local function permission_grade(definition, permission)
return grade and math.max(0, math.floor(grade)) or nil
end
local function validate_configuration(configuration)
local company_config = configuration.Companies
local validated_definitions = {}
local validated_definition_ids = {}
local validated_definitions_by_job = {}
local validated_service_lines_by_number = {}
if type(company_config) ~= "table" or type(company_config.Definitions) ~= "table" then
return nil, "[sky_phone] Config.Companies.Definitions must be configured."
local function validate_configuration()
if type(Config.Companies) ~= "table" or type(Config.Companies.Definitions) ~= "table" then
error("[sky_phone] Config.Companies.Definitions must be configured.")
end
if type(company_config.Enabled) ~= "boolean" then
return nil, "[sky_phone] Config.Companies.Enabled must be a boolean."
if type(Config.Companies.Enabled) ~= "boolean" then
error("[sky_phone] Config.Companies.Enabled must be a boolean.")
end
for _, field in ipairs({
{ "PageSize", 1000 },
@@ -190,26 +184,26 @@ local function validate_configuration(configuration)
{ "AnnouncementMaximumSeconds", 31536000 },
{ "RetentionDays", 36500 },
}) do
if not valid_integer(company_config[field[1]], 1, field[2]) then
return nil, ("[sky_phone] Config.Companies.%s is outside its supported range."):format(field[1])
if not valid_integer(Config.Companies[field[1]], 1, field[2]) then
error(("[sky_phone] Config.Companies.%s is outside its supported range."):format(field[1]))
end
end
if company_config.PageSize > company_config.MaximumPageSize then
return nil, "[sky_phone] Companies PageSize cannot exceed MaximumPageSize."
if Config.Companies.PageSize > Config.Companies.MaximumPageSize then
error("[sky_phone] Companies PageSize cannot exceed MaximumPageSize.")
end
if type(company_config.RateLimits) ~= "table" then
return nil, "[sky_phone] Config.Companies.RateLimits must be configured."
if type(Config.Companies.RateLimits) ~= "table" then
error("[sky_phone] Config.Companies.RateLimits must be configured.")
end
for _, name in ipairs({ "Read", "Search", "CreateRequest", "Message", "RequestAction", "Profile", "CallAvailability" }) do
if not valid_integer(company_config.RateLimits[name], 1, 100000) then
return nil, ("[sky_phone] Companies rate limit '%s' is invalid."):format(name)
if not valid_integer(Config.Companies.RateLimits[name], 1, 100000) then
error(("[sky_phone] Companies rate limit '%s' is invalid."):format(name))
end
end
if type(company_config.CallRouting) ~= "table"
or not valid_integer(company_config.CallRouting.MaxAttempts, 1, 20)
or not valid_integer(company_config.CallRouting.RingSeconds, 1, 120)
if type(Config.Companies.CallRouting) ~= "table"
or not valid_integer(Config.Companies.CallRouting.MaxAttempts, 1, 20)
or not valid_integer(Config.Companies.CallRouting.RingSeconds, 1, 120)
then
return nil, "[sky_phone] Config.Companies.CallRouting is invalid."
error("[sky_phone] Config.Companies.CallRouting is invalid.")
end
local configured_statuses = {
new = true,
@@ -219,48 +213,48 @@ local function validate_configuration(configuration)
completed = true,
cancelled = true,
}
if type(company_config.Statuses) ~= "table" then
return nil, "[sky_phone] Config.Companies.Statuses must be configured."
if type(Config.Companies.Statuses) ~= "table" then
error("[sky_phone] Config.Companies.Statuses must be configured.")
end
for status in pairs(configured_statuses) do
if company_config.Statuses[status] ~= true then
return nil, ("[sky_phone] Companies status '%s' must be enabled."):format(status)
if Config.Companies.Statuses[status] ~= true then
error(("[sky_phone] Companies status '%s' must be enabled."):format(status))
end
end
for status, enabled in pairs(company_config.Statuses) do
for status, enabled in pairs(Config.Companies.Statuses) do
if not configured_statuses[status] or enabled ~= true then
return nil, ("[sky_phone] Companies status '%s' is unsupported."):format(tostring(status))
error(("[sky_phone] Companies status '%s' is unsupported."):format(tostring(status)))
end
end
if type(company_config.AvailabilityStatuses) ~= "table" then
return nil, "[sky_phone] Config.Companies.AvailabilityStatuses must be configured."
if type(Config.Companies.AvailabilityStatuses) ~= "table" then
error("[sky_phone] Config.Companies.AvailabilityStatuses must be configured.")
end
local configured_availability = { available = true, busy = true, closed = true }
for _, status in ipairs({ "available", "busy", "closed" }) do
if company_config.AvailabilityStatuses[status] ~= true then
return nil, ("[sky_phone] Companies availability status '%s' must be enabled."):format(status)
if Config.Companies.AvailabilityStatuses[status] ~= true then
error(("[sky_phone] Companies availability status '%s' must be enabled."):format(status))
end
end
for status, enabled in pairs(company_config.AvailabilityStatuses) do
for status, enabled in pairs(Config.Companies.AvailabilityStatuses) do
if not configured_availability[status] or enabled ~= true then
return nil, ("[sky_phone] Companies availability status '%s' is unsupported."):format(tostring(status))
error(("[sky_phone] Companies availability status '%s' is unsupported."):format(tostring(status)))
end
end
if not valid_array(company_config.Categories, 100) then
return nil, "[sky_phone] Config.Companies.Categories must be a bounded array."
if not valid_array(Config.Companies.Categories, 100) then
error("[sky_phone] Config.Companies.Categories must be a bounded array.")
end
local category_ids = {}
local configured_service_ids = {}
for _, category_id in ipairs(company_config.Categories) do
for _, category_id in ipairs(Config.Companies.Categories) do
if type(category_id) ~= "string" or #category_id > 64
or not category_id:match("^[a-z0-9_-]+$") or category_ids[category_id]
then
return nil, "[sky_phone] Companies contains an invalid category ID."
error("[sky_phone] Companies contains an invalid category ID.")
end
category_ids[category_id] = true
end
for company_id, definition in pairs(company_config.Definitions) do
for company_id, definition in pairs(Config.Companies.Definitions) do
if type(company_id) ~= "string" or #company_id > 64 or not company_id:match("^[a-z0-9_-]+$")
or type(definition) ~= "table"
or type(definition.Job) ~= "string" or #definition.Job > 64
@@ -268,25 +262,26 @@ local function validate_configuration(configuration)
or not valid_text(definition.Name, 120, false)
or not category_ids[definition.Category]
then
return nil, ("[sky_phone] Company definition '%s' is invalid."):format(tostring(company_id))
error(("[sky_phone] Company definition '%s' is invalid."):format(tostring(company_id)))
end
local description = valid_text(definition.Description or "", company_config.ProfileDescriptionMaxLength, true)
local district = valid_text(definition.District or "", company_config.DistrictMaxLength, true)
local description = valid_text(definition.Description or "", Config.Companies.ProfileDescriptionMaxLength, true)
local district = valid_text(definition.District or "", Config.Companies.DistrictMaxLength, true)
local location_label = valid_text(
definition.LocationLabel or definition.Address or "",
company_config.DistrictMaxLength,
Config.Companies.DistrictMaxLength,
true
)
local address = valid_text(definition.Address or "", company_config.AddressMaxLength, true)
local address = valid_text(definition.Address or "", Config.Companies.AddressMaxLength, true)
local logo_url = valid_text(definition.LogoUrl, 2048, false)
if not description or not district or not location_label or not address
or type(definition.Public) ~= "boolean" or type(definition.Emergency) ~= "boolean"
or type(definition.Verified) ~= "boolean" or type(definition.AcceptsRequests) ~= "boolean"
or not company_config.AvailabilityStatuses[definition.DefaultAvailability]
or not Config.Companies.AvailabilityStatuses[definition.DefaultAvailability]
or not valid_text(definition.Icon, 64, false)
or not logo_url or not logo_url:match("^https://[^%s]+$")
or (definition.Emergency and definition.AcceptsRequests)
then
return nil, ("[sky_phone] Company definition '%s' has invalid public profile defaults."):format(company_id)
error(("[sky_phone] Company definition '%s' has invalid public profile defaults."):format(company_id))
end
definition.Name = trim(definition.Name)
definition.Description = description
@@ -297,7 +292,7 @@ local function validate_configuration(configuration)
if definition.Location ~= nil then
local location_type = type(definition.Location)
if location_type ~= "table" and location_type ~= "vector3" then
return nil, ("[sky_phone] Company definition '%s' has invalid location coordinates."):format(company_id)
error(("[sky_phone] Company definition '%s' has invalid location coordinates."):format(company_id))
end
local x = tonumber(definition.Location.x)
local y = tonumber(definition.Location.y)
@@ -305,46 +300,46 @@ local function validate_configuration(configuration)
if not x or not y or not z or x ~= x or y ~= y or z ~= z
or math.abs(x) > 10000 or math.abs(y) > 10000 or math.abs(z) > 2000
then
return nil, ("[sky_phone] Company definition '%s' has invalid location coordinates."):format(company_id)
error(("[sky_phone] Company definition '%s' has invalid location coordinates."):format(company_id))
end
end
if validated_definitions_by_job[definition.Job] then
return nil, ("[sky_phone] Framework job '%s' is assigned to more than one company."):format(definition.Job)
if definitions_by_job[definition.Job] then
error(("[sky_phone] Framework job '%s' is assigned to more than one company."):format(definition.Job))
end
local line = definition.ServiceLine
if type(line) ~= "table" then
return nil, ("[sky_phone] Company '%s' has no service line configuration."):format(company_id)
error(("[sky_phone] Company '%s' has no service line configuration."):format(company_id))
end
local number = SkyPhoneSimNumber.NormalizeService(line.Number, configuration.Sim.NumberLength)
local number = SkyPhoneSimNumber.NormalizeService(line.Number, Config.Sim.NumberLength)
if not number then
return nil, ("[sky_phone] Company '%s' has an invalid service number."):format(company_id)
error(("[sky_phone] Company '%s' has an invalid service number."):format(company_id))
end
if validated_service_lines_by_number[number] then
return nil, ("[sky_phone] Service number '%s' is assigned more than once."):format(number)
if service_lines_by_number[number] then
error(("[sky_phone] Service number '%s' is assigned more than once."):format(number))
end
if type(line.AutoContact) ~= "boolean" or type(line.CanCall) ~= "boolean"
or type(line.CanMessage) ~= "boolean"
or not valid_integer(line.MinimumGrade, 0, 10000)
then
return nil, ("[sky_phone] Company '%s' has invalid service line flags or grade."):format(company_id)
error(("[sky_phone] Company '%s' has invalid service line flags or grade."):format(company_id))
end
if line.AutoContact and not definition.Public then
return nil, ("[sky_phone] Private company '%s' cannot create a public system contact."):format(company_id)
error(("[sky_phone] Private company '%s' cannot create a public system contact."):format(company_id))
end
if line.Routing ~= "round_robin" then
return nil, ("[sky_phone] Company '%s' uses unsupported call routing '%s'."):format(
company_id,
tostring(line.Routing)
)
error(("[sky_phone] Company '%s' uses unsupported call routing '%s'."):format(company_id, tostring(line.Routing)))
end
if line.CanMessage then
error(("[sky_phone] Company '%s' enables messaging without a virtual service-line message router."):format(company_id))
end
line.Number = number
validated_definitions[company_id] = definition
validated_definition_ids[#validated_definition_ids + 1] = company_id
validated_definitions_by_job[definition.Job] = company_id
validated_service_lines_by_number[number] = company_id
definitions[company_id] = definition
definition_ids[#definition_ids + 1] = company_id
definitions_by_job[definition.Job] = company_id
service_lines_by_number[number] = company_id
for _, permission in ipairs({ "WorkQueue", "Availability", "Assign", "Profile", "Hours", "Services", "Announcement" }) do
if not definition.Permissions or not valid_integer(definition.Permissions[permission], 0, 10000) then
return nil, ("[sky_phone] Company '%s' has no valid '%s' grade."):format(company_id, permission)
error(("[sky_phone] Company '%s' has no valid '%s' grade."):format(company_id, permission))
end
end
local default_services = definition.Services
@@ -352,54 +347,40 @@ local function validate_configuration(configuration)
default_services = {}
definition.Services = default_services
end
if not valid_array(default_services, company_config.MaximumServices) then
return nil, ("[sky_phone] Company '%s' has an invalid default service list."):format(company_id)
if not valid_array(default_services, Config.Companies.MaximumServices) then
error(("[sky_phone] Company '%s' has an invalid default service list."):format(company_id))
end
for _, service in ipairs(default_services) do
if type(service) ~= "table" then
return nil, ("[sky_phone] Company '%s' has an invalid default service."):format(company_id)
error(("[sky_phone] Company '%s' has an invalid default service."):format(company_id))
end
local title = valid_text(service.Title, company_config.ServiceTitleMaxLength, false)
local title = valid_text(service.Title, Config.Companies.ServiceTitleMaxLength, false)
local service_description = valid_text(
service.Description or "",
company_config.ServiceDescriptionMaxLength,
Config.Companies.ServiceDescriptionMaxLength,
true
)
local price = valid_text(service.Price or "", company_config.ServicePriceMaxLength, true)
local price = valid_text(service.Price or "", Config.Companies.ServicePriceMaxLength, true)
if not valid_service_id(service.Id) or not title or not service_description or not price
or type(service.RequestsEnabled) ~= "boolean"
then
return nil, ("[sky_phone] Company '%s' has an invalid default service."):format(company_id)
error(("[sky_phone] Company '%s' has an invalid default service."):format(company_id))
end
service.Title = title
service.Description = service_description
service.Price = price
if configured_service_ids[service.Id] then
return nil, ("[sky_phone] Default company service ID '%s' is configured more than once."):format(service.Id)
error(("[sky_phone] Default company service ID '%s' is configured more than once."):format(service.Id))
end
configured_service_ids[service.Id] = true
end
end
table.sort(validated_definition_ids, function(left, right)
local left_name = validated_definitions[left].Name:lower()
local right_name = validated_definitions[right].Name:lower()
table.sort(definition_ids, function(left, right)
local left_name = definitions[left].Name:lower()
local right_name = definitions[right].Name:lower()
return left_name == right_name and left < right or left_name < right_name
end)
return {
definition_ids = validated_definition_ids,
definitions = validated_definitions,
definitions_by_job = validated_definitions_by_job,
service_lines_by_number = validated_service_lines_by_number,
}
end
function SkyPhoneCompanies.ValidateConfiguration(configuration)
local validated, validation_error = validate_configuration(configuration)
if not validated then
return false, validation_error
end
return true
end
local function seed_companies()
@@ -485,48 +466,6 @@ local function seed_companies()
end
local function migrate_requestable_emergency_companies()
local migration_name = "sky-phone:companies:requestable-emergency:v1"
local completed = Bridge.Database.Query(
"SELECT 1 FROM `sky_phone_migrations` WHERE `name` = ? LIMIT 1",
{ migration_name }
)
if completed[1] then
return
end
local statements = {}
local migrated_companies = {}
for _, company_id in ipairs(definition_ids) do
local definition = definitions[company_id]
if definition.Emergency and definition.AcceptsRequests then
statements[#statements + 1] = {
query = [[
UPDATE `sky_phone_company_profiles`
SET `accepts_requests` = 1, `revision` = `revision` + 1
WHERE `company_id` = ? AND `accepts_requests` = 0
]],
params = { company_id },
}
migrated_companies[#migrated_companies + 1] = company_id
end
end
statements[#statements + 1] = {
query = [[
INSERT IGNORE INTO `sky_phone_migrations` (`name`, `source`, `stats`)
VALUES (?, ?, ?)
]],
params = {
migration_name,
"sky-phone",
json.encode({ companies = migrated_companies }),
},
}
if not Bridge.Database.Transaction(statements) then
error("[sky_phone] Could not migrate requestable emergency company profiles.")
end
end
local function tombstone_removed_companies()
local profiles = Bridge.Database.Query([[
SELECT DISTINCT profile.`company_id`
@@ -887,7 +826,7 @@ local function company_payload(company_id, include_inactive_services)
availability = availability,
availabilityUpdatedAt = iso_time(row.availability_updated_at_unix)
or iso_time(row.updated_at_unix),
acceptsRequests = tonumber(row.accepts_requests) == 1,
acceptsRequests = tonumber(row.accepts_requests) == 1 and not definition.Emergency,
phoneNumber = line and line.Number or nil,
canCall = line and line.CanCall == true or false,
canMessage = line and line.CanMessage == true or false,
@@ -967,16 +906,12 @@ local function cleanup_retained_data()
end
local function refresh_runtime_configuration()
local validated, validation_error = validate_configuration(Config)
if not validated then
error(validation_error)
end
definitions = validated.definitions
definition_ids = validated.definition_ids
definitions_by_job = validated.definitions_by_job
service_lines_by_number = validated.service_lines_by_number
definitions = {}
definition_ids = {}
definitions_by_job = {}
service_lines_by_number = {}
validate_configuration()
seed_companies()
migrate_requestable_emergency_companies()
tombstone_removed_companies()
end
@@ -1188,7 +1123,7 @@ end
local function request_row(request_id)
local rows = Bridge.Database.Query([[
SELECT r.`id`, r.`company_id`, r.`service_id`, r.`channel`, r.`customer_sim_id`, r.`subject`, r.`description`,
SELECT r.`id`, r.`company_id`, r.`service_id`, r.`customer_sim_id`, r.`subject`, r.`description`,
r.`status`, r.`assigned_identifier`, r.`customer_unread`,
r.`company_activity_revision`, r.`revision`,
UNIX_TIMESTAMP(r.`created_at`) AS `created_at_unix`,
@@ -1890,175 +1825,6 @@ local function notification_payload(kind, area, row)
}
end
function SkyPhoneCompanies.RouteServiceLineMessage(source, data)
if not Config.Companies.Enabled or type(data) ~= "table"
or data.messageType ~= "text" or not valid_uuid(data.id)
then
return { success = false, error = "invalid_request" }
end
local device, device_error = current_device(source, false)
if not device then
return device_error
end
local service_line = SkyPhoneCompanies.GetServiceLine(data.phoneNumber)
if not service_line or not service_line.canMessage then
return { success = false, error = "messaging_unavailable" }
end
local body = valid_text(
data.body,
math.min(Config.Messages.BodyMaxLength, Config.Companies.MessageMaxLength),
false
)
if not body then
return { success = false, error = "invalid_message" }
end
local request_id = uuid()
local created_event_id = uuid()
local status_event_id = uuid()
local mutation_token = uuid()
local statements = {
{
query = "UPDATE `sky_phone_sims` SET `updated_at` = `updated_at` WHERE `id` = ?",
params = { device.sim_id },
},
{
query = [[
INSERT INTO `sky_phone_company_requests`
(`id`, `company_id`, `channel`, `customer_sim_id`, `subject`, `description`,
`company_activity_revision`)
SELECT ?, profile.`company_id`, 'service_line', sim.`id`, sim.`phone_number`, ?, 0
FROM `sky_phone_sims` sim
INNER JOIN `sky_phone_company_profiles` profile ON profile.`company_id` = ?
WHERE sim.`id` = ? AND NOT EXISTS (
SELECT 1 FROM `sky_phone_company_requests` existing
WHERE existing.`company_id` = profile.`company_id`
AND existing.`customer_sim_id` = sim.`id`
AND existing.`channel` = 'service_line'
AND existing.`status` NOT IN ('completed', 'cancelled')
)
]],
params = {
request_id,
body,
service_line.companyId,
device.sim_id,
},
},
{
query = [[
INSERT INTO `sky_phone_company_request_events`
(`id`, `request_id`, `event_type`, `actor_type`, `to_status`, `detail`)
SELECT ?, `id`, 'created', 'customer', 'new', 'service_line'
FROM `sky_phone_company_requests` WHERE `id` = ?
]],
params = { created_event_id, request_id },
},
{
query = [[
INSERT INTO `sky_phone_company_request_events`
(`id`, `request_id`, `event_type`, `actor_type`, `from_status`, `to_status`, `detail`)
SELECT ?, `id`, 'status', 'customer', 'waiting_customer', 'in_progress', 'service_line_message'
FROM `sky_phone_company_requests`
WHERE `company_id` = ? AND `customer_sim_id` = ?
AND `channel` = 'service_line' AND `status` = 'waiting_customer'
ORDER BY `updated_at` DESC, `id` DESC LIMIT 1
]],
params = { status_event_id, service_line.companyId, device.sim_id },
},
{
query = [[
UPDATE `sky_phone_company_requests`
SET `status` = IF(`status` = 'waiting_customer', 'in_progress', `status`),
`company_activity_revision` = `company_activity_revision` + 1,
`revision` = `revision` + 1, `mutation_token` = ?
WHERE `company_id` = ? AND `customer_sim_id` = ?
AND `channel` = 'service_line'
AND `status` NOT IN ('completed', 'cancelled')
ORDER BY `updated_at` DESC, `id` DESC LIMIT 1
]],
params = { mutation_token, service_line.companyId, device.sim_id },
},
{
query = [[
INSERT INTO `sky_phone_company_request_messages`
(`id`, `request_id`, `sender_type`, `sender_sim_id`, `body`)
SELECT ?, `id`, 'customer', `customer_sim_id`, ?
FROM `sky_phone_company_requests`
WHERE `mutation_token` = ? LIMIT 1
]],
params = { data.id, body, mutation_token },
},
{
query = [[
INSERT INTO `sky_phone_sms_messages`
(`id`, `sender_sim_id`, `recipient_sim_id`, `sender_number`, `recipient_number`,
`message_type`, `body`)
SELECT ?, sim.`id`, NULL, sim.`phone_number`, ?, 'text', ?
FROM `sky_phone_sims` sim
INNER JOIN `sky_phone_company_request_messages` message ON message.`id` = ?
WHERE sim.`id` = ?
]],
params = {
data.id,
service_line.number,
body,
data.id,
device.sim_id,
},
},
}
if not Bridge.Database.Transaction(statements) then
return { success = false, error = "request_failed" }
end
local routed = Bridge.Database.Query([[
SELECT request.`id` AS `request_id`, created.`id` AS `created_event_id`
FROM `sky_phone_sms_messages` sms
INNER JOIN `sky_phone_company_request_messages` message ON message.`id` = sms.`id`
INNER JOIN `sky_phone_company_requests` request ON request.`id` = message.`request_id`
LEFT JOIN `sky_phone_company_request_events` created
ON created.`id` = ? AND created.`request_id` = request.`id`
WHERE sms.`id` = ? LIMIT 1
]], { created_event_id, data.id })
if not routed[1] then
Bridge.Debug(
"error",
"[sky_phone] Service-line message %s committed without a complete route.",
tostring(data.id),
{ always = true }
)
return { success = false, error = "request_failed" }
end
local row = request_row(routed[1].request_id)
if not row then
return { success = false, error = "request_failed" }
end
emit_request_change(row, true, true, source)
if routed[1].created_event_id then
notify_company(row.company_id, "sky_phone:companies:notification", notification_payload(
"newRequest",
"work",
row
), source)
elseif row.assigned_identifier then
notify_identifier(
row.assigned_identifier,
row.company_id,
"sky_phone:companies:notification",
notification_payload("newMessage", "work", row)
)
end
return {
success = true,
data = {
messageId = data.id,
requestId = row.id,
},
}
end
Bridge.Callbacks.Register("sky_phone:companies:create-request", function(source, data)
local allowed, rate_error = allow_mutation(source, "create_request", "CreateRequest")
if not allowed then
@@ -2073,7 +1839,7 @@ Bridge.Callbacks.Register("sky_phone:companies:create-request", function(source,
end
local company_id = data.companyId
local definition = type(company_id) == "string" and definitions[company_id] or nil
if not definition or not definition.Public then
if not definition or not definition.Public or definition.Emergency then
return { success = false, error = "company_not_found" }
end
local subject = valid_text(data.subject, Config.Companies.SubjectMaxLength, false)
@@ -2324,11 +2090,6 @@ Bridge.Callbacks.Register("sky_phone:companies:send-message", function(source, d
then
return { success = false, error = "invalid_status" }
end
local service_line = access.row.channel == "service_line"
and SkyPhoneCompanies.GetServiceLineForCompany(access.row.company_id) or nil
if access.row.channel == "service_line" and (not service_line or not service_line.canMessage) then
return { success = false, error = "messaging_unavailable" }
end
local message_id = uuid()
local mutation_token = uuid()
local new_status = access.audience == "customer" and access.row.status == "waiting_customer"
@@ -2368,37 +2129,6 @@ Bridge.Callbacks.Register("sky_phone:companies:send-message", function(source, d
params = { message_id, access.member.identifier, body, request_id, revision + 1, mutation_token },
}
end
if service_line then
if access.audience == "customer" then
statements[#statements + 1] = {
query = [[
INSERT INTO `sky_phone_sms_messages`
(`id`, `sender_sim_id`, `recipient_sim_id`, `sender_number`, `recipient_number`,
`message_type`, `body`)
SELECT message.`id`, request.`customer_sim_id`, NULL, sim.`phone_number`, ?, 'text', message.`body`
FROM `sky_phone_company_request_messages` message
INNER JOIN `sky_phone_company_requests` request ON request.`id` = message.`request_id`
INNER JOIN `sky_phone_sims` sim ON sim.`id` = request.`customer_sim_id`
WHERE message.`id` = ? AND message.`sender_type` = 'customer'
]],
params = { service_line.number, message_id },
}
else
statements[#statements + 1] = {
query = [[
INSERT INTO `sky_phone_sms_messages`
(`id`, `sender_sim_id`, `recipient_sim_id`, `sender_number`, `recipient_number`,
`message_type`, `body`)
SELECT message.`id`, NULL, request.`customer_sim_id`, ?, sim.`phone_number`, 'text', message.`body`
FROM `sky_phone_company_request_messages` message
INNER JOIN `sky_phone_company_requests` request ON request.`id` = message.`request_id`
INNER JOIN `sky_phone_sims` sim ON sim.`id` = request.`customer_sim_id`
WHERE message.`id` = ? AND message.`sender_type` = 'company'
]],
params = { service_line.number, message_id },
}
end
end
if new_status ~= access.row.status then
statements[#statements + 1] = {
query = [[
@@ -2413,32 +2143,16 @@ Bridge.Callbacks.Register("sky_phone:companies:send-message", function(source, d
if not Bridge.Database.Transaction(statements) then
return { success = false, error = "request_failed" }
end
local inserted = Bridge.Database.Query([[
SELECT message.`id`, sms.`id` AS `sms_id`
FROM `sky_phone_company_request_messages` message
LEFT JOIN `sky_phone_sms_messages` sms ON sms.`id` = message.`id`
WHERE message.`id` = ? LIMIT 1
]], { message_id })
local inserted = Bridge.Database.Query(
"SELECT `id` FROM `sky_phone_company_request_messages` WHERE `id` = ? LIMIT 1",
{ message_id }
)
if not inserted[1] then
return { success = false, error = "revision_conflict" }
end
if service_line and not inserted[1].sms_id then
Bridge.Debug(
"error",
"[sky_phone] Company request message %s committed without its service-line SMS.",
tostring(message_id),
{ always = true }
)
return { success = false, error = "request_failed" }
end
local row = request_row(request_id)
emit_request_change(row, true, true, source)
if access.audience == "customer" then
if service_line then
TriggerClientEvent("sky_phone:messages:changed", source, {
phoneNumber = service_line.number,
})
end
local notification = notification_payload("newMessage", "work", row)
if row.assigned_identifier then
notify_identifier(
@@ -2448,12 +2162,6 @@ Bridge.Callbacks.Register("sky_phone:companies:send-message", function(source, d
notification
)
end
elseif service_line then
notify_sim(row.customer_sim_id, "sky_phone:messages:new", {
phoneNumber = service_line.number,
sender = service_line.name,
voice = false,
})
else
notify_sim(row.customer_sim_id, "sky_phone:companies:notification", notification_payload(
"newMessage",
@@ -2928,6 +2636,7 @@ Bridge.Callbacks.Register("sky_phone:companies:update-profile", function(source,
local address = valid_text(data.address, Config.Companies.AddressMaxLength, true)
if not revision or not description or not district or not location_label or not address
or type(data.acceptsRequests) ~= "boolean"
or (member.definition.Emergency and data.acceptsRequests)
then
return { success = false, error = "invalid_profile" }
end
+122 -144
View File
@@ -1368,6 +1368,8 @@ ensure_schema()
migrate_crypto_keys()
initialize_markets()
AddEventHandler("sky_phone:configurator:serverUpdated", initialize_markets)
local function reconcile_settlements(include_recent)
local age_clause = include_recent and "" or " AND settlement.`updated_at` < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)"
local rows = Bridge.Database.Query([[
@@ -1414,6 +1416,8 @@ 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
@@ -1496,160 +1500,134 @@ local function advance_market_cycle(config, dynamics)
return dynamics.cycle_bias
end
local scheduler_generation = 0
local function start_crypto_schedulers()
scheduler_generation = scheduler_generation + 1
if Config.Crypto.Enabled ~= true then
return
CreateThread(function()
while true do
Wait(5 * 60 * 1000)
reconcile_settlements(false)
end
end)
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."
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."
)
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)
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."
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."
)
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(
local direction_roll = crypto_random_int(
0,
10000,
"[sky_phone] Crypto entropy provider did not return a market shock roll."
2,
"[sky_phone] Crypto entropy provider did not return a market shock direction."
)
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 direction = direction_roll == 0 and -1 or 1
shock = direction * config.VolatilityBasisPoints * multiplier
end
local deviation = math.floor(
(config.InitialPrice - price) * 10000 / config.InitialPrice
)
local reversion = truncate_integer(
deviation * Config.Crypto.MeanReversionBasisPoints / 10000
)
local shock = 0
if shock_roll < Config.Crypto.MarketShockChanceBasisPoints then
local multiplier = crypto_random_int(
Config.Crypto.MarketShockMinimumMultiplier,
Config.Crypto.MarketShockMaximumMultiplier + 1,
"[sky_phone] Crypto entropy provider did not return a market shock multiplier."
local 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 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
]], { market_id, market_id, Config.Crypto.HistoryRetentionTicks })
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
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)
+1 -7
View File
@@ -931,16 +931,10 @@ 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)
-2
View File
@@ -2710,7 +2710,6 @@ local schema = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "company_id", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "service_id", type = "VARCHAR(64) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "channel", type = "ENUM('app','service_line') NOT NULL DEFAULT 'app'" },
{ name = "customer_sim_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "subject", type = "VARCHAR(120) NOT NULL" },
{ name = "description", type = "VARCHAR(2000) NOT NULL" },
@@ -2730,7 +2729,6 @@ local schema = {
{ name = "idx_sky_phone_company_requests_customer", columns = "(`customer_sim_id`, `updated_at`, `id`)" },
{ name = "idx_sky_phone_company_requests_queue", columns = "(`company_id`, `status`, `updated_at`, `id`)" },
{ name = "idx_sky_phone_company_requests_assignee", columns = "(`company_id`, `assigned_identifier`, `status`)" },
{ name = "idx_sky_phone_company_requests_service_line", columns = "(`company_id`, `customer_sim_id`, `channel`, `status`, `updated_at`, `id`)" },
},
foreignKeys = {
{ column = "company_id", references = "`sky_phone_company_profiles` (`company_id`) ON DELETE CASCADE" },
+1 -1
View File
@@ -943,7 +943,7 @@ local function run_verify_command(source, arguments)
notificationType = notification_type,
})
end
if source ~= 0 and not Bridge.Framework.HasPermission(source, "fliptokverify") then
if source ~= 0 and not Bridge.Framework.HasAdminGroup(source, Config.FlipTok.AdminGroups) then
send_command_feedback(command_locale.noPermission, "error")
Bridge.Debug(
"warn",
+7 -47
View File
@@ -97,14 +97,6 @@ local function current_device(source)
return device
end
local function resolve_message_recipient(value)
local service_line = SkyPhoneCompanies.GetServiceLine(value)
if service_line then
return service_line.number, service_line
end
return SkyPhoneSimNumber.Normalize(value, Config.Sim.NumberLength, Config.Sim.NumberPrefix), nil
end
local function shared_contact(device, contact_id)
if type(contact_id) ~= "string" or contact_id == "" or #contact_id > 36 then
return nil, "invalid_contact"
@@ -374,7 +366,7 @@ Bridge.Callbacks.Register("sky_phone:messages:thread", function(source, data)
if not device then
return error_response
end
local number = resolve_message_recipient(data.phoneNumber)
local number = SkyPhoneSimNumber.Normalize(data.phoneNumber, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
if not number then
return { success = false, error = "invalid_number" }
end
@@ -415,7 +407,11 @@ Bridge.Callbacks.Register("sky_phone:messages:delete", function(source, data)
local numbers = {}
local seen = {}
for index = 1, #data.phoneNumbers do
local number = resolve_message_recipient(data.phoneNumbers[index])
local number = SkyPhoneSimNumber.Normalize(
data.phoneNumbers[index],
Config.Sim.NumberLength,
Config.Sim.NumberPrefix
)
if not number then
return { success = false, error = "invalid_number" }
end
@@ -487,7 +483,7 @@ Bridge.Callbacks.Register("sky_phone:messages:send", function(source, data)
if not device then
return error_response
end
local number, service_line = resolve_message_recipient(data.phoneNumber)
local number = SkyPhoneSimNumber.Normalize(data.phoneNumber, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
if not number then
return { success = false, error = "invalid_number" }
end
@@ -540,42 +536,6 @@ Bridge.Callbacks.Register("sky_phone:messages:send", function(source, data)
else
return { success = false, error = "invalid_request" }
end
if service_line then
if not service_line.canMessage then
return { success = false, error = "messaging_unavailable" }
end
if message_type ~= "text" then
return { success = false, error = "service_line_text_only" }
end
local id = uuid()
local routed = SkyPhoneCompanies.RouteServiceLineMessage(source, {
id = id,
body = body,
messageType = message_type,
phoneNumber = number,
})
if not routed or not routed.success then
return routed or { success = false, error = "request_failed" }
end
local rows = Bridge.Database.Query([[
SELECT `id`, `sender_number`, `recipient_number`, `message_type`, `body`,
`media_payload`, `media_mime`, `media_duration_ms`, `media_waveform`,
`read_at`, `created_at`, 'sent' AS `direction`
FROM `sky_phone_sms_messages` WHERE `id` = ? LIMIT 1
]], { id })
if not rows[1] then
Bridge.Debug(
"error",
"[sky_phone] Routed service-line SMS %s could not be read back.",
tostring(id),
{ always = true }
)
return { success = false, error = "request_failed" }
end
local message = format_message(rows[1])
TriggerClientEvent("sky_phone:messages:changed", source, { phoneNumber = number })
return { success = true, data = message }
end
local recipients = Bridge.Database.Query([[
SELECT s.`id`, s.`phone_number`
FROM `sky_phone_sims` s
+1 -52
View File
@@ -54,28 +54,12 @@ Bridge.Debug("debug", "[sky_phone] Server initialization started after database
SkyPhone = {}
function SkyPhone.IsAppEnabled(app_id)
return type(Config.Apps) ~= "table" or Config.Apps[app_id] ~= false
end
function SkyPhone.GetDisabledApps()
local disabled = {}
for app_id, enabled in pairs(Config.Apps or {}) do
if type(app_id) == "string" and enabled == false then
disabled[#disabled + 1] = app_id
end
end
table.sort(disabled)
return disabled
end
local sessions = {}
local preferred_device_imeis = {}
local equipped_phone_numbers = {}
local equipped_phone_identifiers = {}
local equipped_phone_sources = {}
local operation_attempts = {}
local phone_open_in_progress = {}
local character_device_cache = {}
local function trim(value)
@@ -564,7 +548,6 @@ local function bootstrap(source, security, security_loaded)
return {
token = session.token,
disabledApps = SkyPhone.GetDisabledApps(),
phoneNumberFormat = {
length = Config.Sim.NumberLength,
groups = Config.Sim.NumberGroups,
@@ -856,7 +839,7 @@ function SkyPhone.RefreshDevice(imei)
end
end
local function perform_phone_open(source, used_item)
local function open_phone(source, used_item)
local opened_at = GetGameTimer()
Bridge.Debug(
"debug",
@@ -943,38 +926,6 @@ local function perform_phone_open(source, used_item)
return true
end
local function open_phone(source, used_item)
if phone_open_in_progress[source] then
TriggerClientEvent("sky_phone:device:error", source, "operation_in_progress")
return false, "operation_in_progress"
end
local request_limit = math.max(
1,
math.floor(tonumber(Config.Phone.OpenRequestsPerMinute) or 20)
)
if not SkyPhone.AllowOperation(source, "phone_open", request_limit, 60) then
TriggerClientEvent("sky_phone:device:error", source, "rate_limited")
return false, "rate_limited"
end
phone_open_in_progress[source] = true
local completed, success, error_code = pcall(perform_phone_open, source, used_item)
phone_open_in_progress[source] = nil
if not completed then
Bridge.Debug(
"error",
"[sky_phone] Phone open failed unexpectedly for source %s: %s",
tostring(source),
tostring(success),
{ always = true }
)
TriggerClientEvent("sky_phone:device:error", source, "request_failed")
return false, "request_failed"
end
return success, error_code
end
phone_open_handler = open_phone
flush_pending_phone_opens()
@@ -1077,7 +1028,6 @@ AddEventHandler("playerDropped", function()
sessions[source] = nil
discard_equipped_phone_number(source)
operation_attempts[source] = nil
phone_open_in_progress[source] = nil
character_device_cache[source] = nil
preferred_device_imeis[source] = nil
end)
@@ -1086,7 +1036,6 @@ AddEventHandler("onResourceStop", function(resource_name)
if resource_name == GetCurrentResourceName() then
sessions = {}
operation_attempts = {}
phone_open_in_progress = {}
character_device_cache = {}
preferred_device_imeis = {}
equipped_phone_numbers = {}
+16 -342
View File
@@ -17,31 +17,9 @@ local updated_at
local updated_by_name
local is_sequence
local FIXED_CONFIG_PATHS = {
CommandPermissions = true,
["AdminPanel.AdminGroups"] = true,
["TestData.AdminGroups"] = true,
["FlipTok.AdminGroups"] = true,
["Picstagram.AdminGroups"] = true,
}
if configurator_enabled then
local border = "======================================================================"
print(([[
^1%s^0
^1 SKY PHONE CONFIGURATION FILES ARE DISABLED ^0
^1%s^0
^1 The Phone Configurator is ENABLED.^0
^1 Runtime settings from config.lua and media.lua are DISABLED.^0
^1 Configure all phone and media settings IN GAME through /phonepanel.^0
^1 Only Config.PhoneConfigurator.Enabled and Config.CommandPermissions remain file-based.^0
^1%s^0]]):format(border, border, border))
end
local CLIENT_CONFIG_KEYS = {
AdminPanel = true,
Animations = true,
Apps = true,
Banking = true,
Billing = true,
Bridge = true,
@@ -274,7 +252,7 @@ local function upgrade_legacy_map(defaults, saved)
return { __skyType = "map", entries = entries }
end
local function merge_values(defaults, saved, path, excluded_paths)
local function merge_values(defaults, saved, path)
path = path or ""
if type(defaults) ~= "table" or type(saved) ~= "table" then
return copy_value(saved)
@@ -300,12 +278,7 @@ local function merge_values(defaults, saved, path, excluded_paths)
key = entry.key,
keyType = entry.keyType,
value = saved_entry
and merge_values(
entry.value,
saved_entry.value,
path .. "." .. tostring(entry.key),
excluded_paths
)
and merge_values(entry.value, saved_entry.value, path .. "." .. tostring(entry.key))
or copy_value(entry.value),
}
included[identity] = true
@@ -325,7 +298,7 @@ local function merge_values(defaults, saved, path, excluded_paths)
local merged = copy_value(saved)
for index, child in ipairs(defaults) do
merged[index] = saved[index] ~= nil
and merge_values(child, saved[index], path .. "." .. tostring(index), excluded_paths)
and merge_values(child, saved[index], path .. "." .. tostring(index))
or copy_value(child)
end
return merged
@@ -336,13 +309,11 @@ local function merge_values(defaults, saved, path, excluded_paths)
local merged = copy_value(defaults)
for key, child in pairs(saved) do
local child_path = path == "" and tostring(key) or (path .. "." .. tostring(key))
if not excluded_paths or not excluded_paths[child_path] then
if merged[key] ~= nil then
merged[key] = merge_values(merged[key], child, child_path, excluded_paths)
else
merged[key] = copy_value(child)
end
if merged[key] ~= nil then
local child_path = path == "" and tostring(key) or (path .. "." .. tostring(key))
merged[key] = merge_values(merged[key], child, child_path)
else
merged[key] = copy_value(child)
end
end
return merged
@@ -385,12 +356,10 @@ local function apply_runtime_configuration()
local runtime_config = deserialize_value(stored_config)
for key, value in pairs(runtime_config) do
if key ~= "CommandPermissions" then
if type(Config[key]) == "table" and type(value) == "table" then
apply_runtime_table(Config[key], value)
else
Config[key] = value
end
if type(Config[key]) == "table" and type(value) == "table" then
apply_runtime_table(Config[key], value)
else
Config[key] = value
end
end
local runtime_media = deserialize_value(stored_media)
@@ -563,75 +532,6 @@ local function empty_structure(scope, path)
return nil
end
local function next_available_company_service_number(configuration)
configuration = configuration or stored_config
local maximum_length = configuration.Sim.NumberLength
local service_length = math.max(1, math.min(3, maximum_length - 1))
local first_candidate = service_length == 1 and 5 or 5 * (10 ^ (service_length - 1))
local last_candidate = (10 ^ service_length) - 1
local used = {}
for _, definition in pairs(configuration.Companies.Definitions) do
used[tostring(definition.ServiceLine.Number):gsub("%D", "")] = true
end
for offset = 0, last_candidate - 1 do
local candidate = ((first_candidate - 1 + offset) % last_candidate) + 1
local number = ("%0" .. tostring(service_length) .. "d"):format(candidate)
if not used[number] then
return number
end
end
return ""
end
local function company_definition_entry_default(company_id, configuration)
configuration = configuration or stored_config
local name = company_id and humanize(company_id) or ""
local logo_seed = company_id or "new"
return {
AcceptsRequests = true,
Address = "",
Category = configuration.Companies.Categories[1] or default_config.Companies.Categories[1],
DefaultAvailability = "closed",
Description = "",
District = "",
Emergency = false,
Icon = "building",
Job = company_id or "",
Location = { __skyType = "vector3", x = 0.0, y = 0.0, z = 0.0 },
LocationLabel = "",
LogoUrl = ("https://picsum.photos/seed/companies-%s-logo/180/180"):format(logo_seed),
Name = name,
Permissions = {
Announcement = 0,
Assign = 0,
Availability = 0,
Hours = 0,
Profile = 0,
Services = 0,
WorkQueue = 0,
},
Public = true,
ServiceLine = {
AutoContact = true,
CanCall = true,
CanMessage = false,
MinimumGrade = 0,
Number = next_available_company_service_number(configuration),
Routing = "round_robin",
},
Services = {
{
Description = "",
Id = company_id or "",
Price = "",
RequestsEnabled = true,
Title = name,
},
},
Verified = false,
}
end
local function build_structure(value, scope, path)
local value_type = type(value)
if scope == "config" and path == "Phone.Keybind" then
@@ -650,7 +550,6 @@ local function build_structure(value, scope, path)
fields[key] = build_structure(value[key], scope, path .. "." .. tostring(key))
end
return {
entryDefault = company_definition_entry_default(),
fields = fields,
kind = "table",
mutableKeys = true,
@@ -1133,230 +1032,20 @@ local function read_stored_row()
end
local function apply_stored_row(row)
stored_config = merge_values(
default_config,
decode_payload(row.config_payload, "config"),
"",
FIXED_CONFIG_PATHS
)
stored_config = merge_values(default_config, decode_payload(row.config_payload, "config"), "")
stored_media = merge_values(default_media, decode_payload(row.media_payload, "media"), "")
revision = tonumber(row.revision) or 1
updated_at = row.updated_at
updated_by_name = row.updated_by_name
end
local function migrate_blank_company_definitions()
local migration_name = "sky-phone:configurator:company-definition-defaults:v1"
local completed = Bridge.Database.Query(
"SELECT 1 FROM `sky_phone_migrations` WHERE `name` = ? LIMIT 1",
{ migration_name }
)
if completed[1] then
return
end
local row = read_stored_row()
local config_payload = decode_payload(row.config_payload, "config")
local next_config = merge_values(default_config, config_payload, "", FIXED_CONFIG_PATHS)
local migrated_companies = {}
for company_id, definition in pairs(next_config.Companies.Definitions) do
local line = type(definition) == "table" and definition.ServiceLine or nil
if type(company_id) == "string"
and company_id:match("^[a-z0-9_-]+$")
and type(definition) == "table"
and (definition.Job == "" or definition.Job == company_id)
and definition.Name == ""
and definition.Category == ""
and definition.Icon == ""
and definition.LogoUrl == ""
and definition.DefaultAvailability == ""
and type(line) == "table"
and line.Number == ""
and line.Routing == ""
and type(definition.Services) == "table"
and next(definition.Services) == nil
then
next_config.Companies.Definitions[company_id] = company_definition_entry_default(
company_id,
next_config
)
migrated_companies[#migrated_companies + 1] = company_id
end
end
table.sort(migrated_companies)
local statements = {}
if #migrated_companies > 0 then
statements[#statements + 1] = {
query = ([[
UPDATE `%s`
SET `config_payload` = ?, `revision` = `revision` + 1
WHERE `id` = ?
]]):format(TABLE_NAME),
params = { encode_payload(next_config, "config"), CONFIG_ROW_ID },
}
end
statements[#statements + 1] = {
query = [[
INSERT IGNORE INTO `sky_phone_migrations` (`name`, `source`, `stats`)
VALUES (?, ?, ?)
]],
params = {
migration_name,
"sky-phone",
json.encode({ companies = migrated_companies }),
},
}
if not Bridge.Database.Transaction(statements) then
error("[sky_phone] Could not migrate blank Phone Configurator company definitions.")
end
if #migrated_companies == 0 then
return
end
apply_stored_row(read_stored_row())
apply_runtime_configuration()
TriggerEvent("sky_phone:configurator:serverUpdated", revision)
SkyPhoneConfigurator.Broadcast(-1)
Bridge.Debug(
"info",
"[sky_phone] Migrated blank Phone Configurator company definitions: %s",
table.concat(migrated_companies, ", "),
{ always = true }
)
end
local function migrate_police_request_defaults()
local migration_name = "sky-phone:configurator:police-requests:v1"
local completed = Bridge.Database.Query(
"SELECT 1 FROM `sky_phone_migrations` WHERE `name` = ? LIMIT 1",
{ migration_name }
)
if completed[1] then
return
end
local row = read_stored_row()
local config_payload = decode_payload(row.config_payload, "config")
local police = config_payload.Companies
and config_payload.Companies.Definitions
and config_payload.Companies.Definitions.police
local migrated = type(police) == "table"
and police.Emergency == true
and police.AcceptsRequests == false
and type(police.Services) == "table"
and next(police.Services) == nil
local statements = {}
if migrated then
local defaults = default_config.Companies.Definitions.police
police.AcceptsRequests = defaults.AcceptsRequests
police.Services = copy_value(defaults.Services)
statements[#statements + 1] = {
query = ([[
UPDATE `%s`
SET `config_payload` = ?, `revision` = `revision` + 1
WHERE `id` = ?
]]):format(TABLE_NAME),
params = { encode_payload(config_payload, "config"), CONFIG_ROW_ID },
}
end
statements[#statements + 1] = {
query = [[
INSERT IGNORE INTO `sky_phone_migrations` (`name`, `source`, `stats`)
VALUES (?, ?, ?)
]],
params = {
migration_name,
"sky-phone",
json.encode({ migrated = migrated }),
},
}
if not Bridge.Database.Transaction(statements) then
error("[sky_phone] Could not migrate Phone Configurator police request defaults.")
end
if not migrated then
return
end
apply_stored_row(read_stored_row())
apply_runtime_configuration()
TriggerEvent("sky_phone:configurator:serverUpdated", revision)
SkyPhoneConfigurator.Broadcast(-1)
Bridge.Debug(
"info",
"[sky_phone] Migrated Phone Configurator police request defaults.",
{ always = true }
)
end
local function migrate_police_service_line_messaging()
local migration_name = "sky-phone:configurator:service-line-messaging:v2"
local completed = Bridge.Database.Query(
"SELECT 1 FROM `sky_phone_migrations` WHERE `name` = ? LIMIT 1",
{ migration_name }
)
if completed[1] then
return
end
local row = read_stored_row()
local config_payload = decode_payload(row.config_payload, "config")
local police = config_payload.Companies
and config_payload.Companies.Definitions
and config_payload.Companies.Definitions.police
local line = type(police) == "table" and police.ServiceLine or nil
local migrated = type(line) == "table" and line.CanMessage ~= true
if migrated then
line.CanMessage = true
end
local statements = {}
if migrated then
statements[#statements + 1] = {
query = ([[
UPDATE `%s`
SET `config_payload` = ?, `revision` = `revision` + 1
WHERE `id` = ?
]]):format(TABLE_NAME),
params = { encode_payload(config_payload, "config"), CONFIG_ROW_ID },
}
end
statements[#statements + 1] = {
query = [[
INSERT IGNORE INTO `sky_phone_migrations` (`name`, `source`, `stats`)
VALUES (?, ?, ?)
]],
params = {
migration_name,
"sky-phone",
json.encode({ police = migrated }),
},
}
if not Bridge.Database.Transaction(statements) then
error("[sky_phone] Could not enable Phone Configurator police service-line messaging.")
end
if not migrated then
return
end
apply_stored_row(read_stored_row())
apply_runtime_configuration()
TriggerEvent("sky_phone:configurator:serverUpdated", revision)
SkyPhoneConfigurator.Broadcast(-1)
Bridge.Debug(
"info",
"[sky_phone] Enabled Phone Configurator police service-line messaging.",
{ always = true }
)
end
default_config = {}
for key, value in pairs(ConfigDefaults) do
if key ~= "Media" and key ~= "PhoneConfigurator" and key ~= "CommandPermissions" then
for key, value in pairs(Config) do
if key ~= "Media" and key ~= "PhoneConfigurator" then
default_config[key] = serialize_value(value)
end
end
default_media = serialize_value(ConfigDefaults.Media)
default_media = serialize_value(Config.Media)
Bridge.Database.Migrate("sky_phone_configurator", { SkyPhoneConfiguratorSchema })
Bridge.Database.Query(([[
@@ -1370,9 +1059,6 @@ Bridge.Database.Query(([[
apply_stored_row(read_stored_row())
apply_runtime_configuration()
Bridge.Database.AfterMigration("sky_phone", migrate_blank_company_definitions)
Bridge.Database.AfterMigration("sky_phone", migrate_police_request_defaults)
Bridge.Database.AfterMigration("sky_phone", migrate_police_service_line_messaging)
function SkyPhoneConfigurator.GetAdminData()
local data = build_admin_data()
@@ -1422,18 +1108,6 @@ function SkyPhoneConfigurator.Save(expected_revision, changes, actor_identifier,
end
end
local candidate_config = deserialize_value(next_config)
local companies_valid, validation_error = SkyPhoneCompanies.ValidateConfiguration(candidate_config)
if not companies_valid then
Bridge.Debug(
"warn",
"[sky_phone] Rejected invalid Phone Configurator Companies configuration: %s",
validation_error,
{ always = true }
)
return { success = false, error = "invalid_company_configuration" }
end
local config_encoded = encode_payload(next_config, "config")
local media_encoded = encode_payload(next_media, "media")
local result = Bridge.Database.Query(([[
+2 -2
View File
@@ -319,7 +319,7 @@ local function create_activity(recipient_id, actor_id, kind, post_id)
end
local function is_admin(source)
return Bridge.Framework.HasPermission(source, "picstagramadmin")
return Bridge.Framework.HasAdminGroup(source, Config.Picstagram.AdminGroups)
end
Bridge.Callbacks.Register("sky_phone:picstagram:register", function(source, data)
@@ -1464,7 +1464,7 @@ local function run_verify_command(source, args)
notificationType = notification_type,
})
end
if source ~= 0 and not Bridge.Framework.HasPermission(source, "picstagramverify") then
if source ~= 0 and not Bridge.Framework.HasAdminGroup(source, Config.Picstagram.AdminGroups) then
send_command_feedback(command_locale.noPermission, "error")
Bridge.Debug(
"warn",
+1 -1
View File
@@ -1045,7 +1045,7 @@ local function run_test_data_command(source)
Bridge.Debug("warn", "[sky_phone] The test data command must be run by an in-game player.")
return
end
if Config.TestData.AdminOnly and not Bridge.Framework.HasPermission(source, "phonetestdata") then
if Config.TestData.AdminOnly and not Bridge.Framework.HasAdminGroup(source, Config.TestData.AdminGroups) then
Bridge.Debug("warn", "[sky_phone] Source %s attempted to run the restricted test data command.", tostring(source))
TriggerClientEvent("sky_phone:testdata:feedback", source, false)
return
File diff suppressed because it is too large Load Diff
+5 -63
View File
@@ -4,20 +4,6 @@ local camera_target = nil
local camera_created = false
local camera_destroyed = false
local scripted_camera_rendering = false
local event_handlers = {}
local threads = {}
local hold_to_look_pressed = false
local disabled_controls = {}
local disabled_pressed_controls = {}
local disabled_control_normals = {}
local triggered_events = {}
local thread_stop = {}
SkyPhoneFocus = {
IsHoldToLookPressed = function()
return hold_to_look_pressed
end,
}
local vector_meta = {}
vector_meta.__index = vector_meta
@@ -46,20 +32,12 @@ function RegisterNUICallback(name, callback)
end
function RegisterNetEvent() end
function AddEventHandler(name, callback)
event_handlers[name] = callback
end
function TriggerEvent(name, data)
triggered_events[#triggered_events + 1] = { name = name, data = data }
end
function AddEventHandler() end
function TriggerEvent() end
function TriggerServerEvent() end
function SendNUIMessage() end
function CreateThread(callback)
threads[#threads + 1] = callback
end
function Wait()
error(thread_stop, 0)
end
function CreateThread() end
function Wait() end
function PlayerPedId() return 7 end
function PlayerId() return 8 end
function GetFollowPedCamViewMode() return 1 end
@@ -69,19 +47,10 @@ function DisplayRadar() end
function SetFollowPedCamViewMode() end
function SetFollowVehicleCamViewMode() end
function IsPedInAnyVehicle() return false end
function DisableControlAction(group, control, disabled)
assert(group == 0 and disabled, "camera controls must be disabled in the primary input group")
disabled_controls[control] = true
end
function DisableControlAction() end
function DisablePlayerFiring() end
function HideHudAndRadarThisFrame() end
function GetCurrentResourceName() return "sky_phone" end
function IsDisabledControlJustPressed() return false end
function IsDisabledControlPressed(group, control)
assert(group == 0, "camera passthrough must read the primary input group")
return disabled_pressed_controls[control] == true
end
function GetDisabledControlNormal(_, control) return disabled_control_normals[control] or 0.0 end
function GetEntityCoords()
return vector3(10.0, 20.0, 1.0)
@@ -151,33 +120,6 @@ assert(close_enough(camera_target.x, 10.0))
assert(close_enough(camera_target.y, 20.0))
assert(close_enough(camera_target.z, 2.73))
event_handlers["sky_phone:client:cameraFocusApplied"]({
active = true,
cursor = false,
focused = true,
gameInput = true,
})
disabled_pressed_controls[22] = true
disabled_control_normals[1] = 0.2
local watcher_ok, watcher_error = pcall(threads[2])
assert(not watcher_ok and watcher_error == thread_stop, "camera watcher must run one controlled frame")
local camera_focus_event = triggered_events[#triggered_events]
assert(
camera_focus_event.name == "sky_phone:client:setCameraFocus"
and camera_focus_event.data.active
and not camera_focus_event.data.nuiFocused,
"holding Space must release the camera cursor for simultaneous look and movement"
)
for _, control in ipairs({ 30, 31, 32, 33, 34, 35 }) do
assert(not disabled_controls[control], ("camera passthrough must preserve movement control %d"):format(control))
end
local camera_ok, camera_error = pcall(threads[1])
assert(not camera_ok and camera_error == thread_stop, "selfie camera must render one controlled frame")
assert(not close_enough(camera_coord.x, 10.0), "horizontal look input must orbit the selfie camera around the player")
assert(camera_coord.y < 21.05, "selfie orbit must retain its configured distance from the player")
disabled_pressed_controls[22] = false
disabled_control_normals[1] = 0.0
assert(response_from("camera:setFacing", { front = false }).success)
assert(camera_destroyed and not scripted_camera_rendering, "rear mode must release the selfie camera")
+3 -57
View File
@@ -5,9 +5,6 @@ local event_handlers = {}
local nui_callbacks = {}
local nui_focus = nil
local nui_keep_input = nil
local pressed_controls = {}
local disabled_pressed_controls = {}
local triggered_events = {}
Config = {
Phone = {
@@ -37,19 +34,7 @@ function SetNuiFocusKeepInput(keep_input)
nui_keep_input = keep_input
end
function TriggerEvent(name, data)
triggered_events[#triggered_events + 1] = { name = name, data = data }
end
function IsControlPressed(group, control)
assert(group == 0, "HoldToLook must read the primary input group")
return pressed_controls[control] == true
end
function IsDisabledControlPressed(group, control)
assert(group == 0, "HoldToLook must read disabled controls from the primary input group")
return disabled_pressed_controls[control] == true
end
function TriggerEvent() end
function DisableControlAction(group, control, disabled)
assert(group == 0 and disabled, "phone controls must be disabled in the primary input group")
@@ -71,25 +56,6 @@ end
dofile("sky_phone/source/client/focus.lua")
assert(not SkyPhoneFocus.IsHoldToLookPressed(), "HoldToLook must be idle until its configured control is held")
pressed_controls[19] = true
assert(SkyPhoneFocus.IsHoldToLookPressed(), "HoldToLook must read its configured active control")
pressed_controls[19] = false
Config.Phone.HoldToLook.Control = 38
event_handlers["sky_phone:configurator:updated"]()
disabled_pressed_controls[38] = true
assert(
SkyPhoneFocus.IsHoldToLookPressed(),
"HoldToLook must read its configured control while NUI focus has disabled GTA input"
)
disabled_pressed_controls[38] = false
Config.Phone.HoldToLook.Enabled = false
event_handlers["sky_phone:configurator:updated"]()
assert(not SkyPhoneFocus.IsHoldToLookPressed(), "disabled HoldToLook must reject every control state")
Config.Phone.HoldToLook.Enabled = true
Config.Phone.HoldToLook.Control = 19
event_handlers["sky_phone:configurator:updated"]()
local function resolve(overrides)
local state = {
activity_suspended = false,
@@ -267,9 +233,6 @@ disabled_controls = {}
firing_disabled = false
SkyPhoneFocus.ApplyGameInputControls(false)
assert(not disabled_controls[1] and not disabled_controls[2], "camera passthrough must preserve camera look")
for _, control in ipairs({ 30, 31, 32, 33, 34, 35 }) do
assert(not disabled_controls[control], ("camera passthrough must preserve movement control %d"):format(control))
end
assert(firing_disabled, "player attacks must remain disabled during camera passthrough")
local movable_notification = resolve({ allow_movement = true, notification_focus = true })
@@ -301,28 +264,11 @@ local focused_camera = resolve({
assert(
focused_camera.cursor
and focused_camera.focused
and focused_camera.keep_input
and not focused_camera.keep_input
and not focused_camera.game_input,
"focused camera must forward readable input while blocking game actions until passthrough is held"
"focused camera must override movement configuration until Space enables passthrough"
)
event_handlers["sky_phone:client:setCameraFocus"]({ active = true, nuiFocused = true })
local focused_camera_event = triggered_events[#triggered_events]
assert(
nui_focus.focused and nui_focus.cursor and nui_keep_input
and focused_camera_event.name == "sky_phone:client:cameraFocusApplied"
and not focused_camera_event.data.gameInput,
"focused camera must keep controls readable without reporting movement passthrough"
)
event_handlers["sky_phone:client:setCameraFocus"]({ active = true, nuiFocused = false })
local passthrough_camera_event = triggered_events[#triggered_events]
assert(
nui_focus.focused and not nui_focus.cursor and nui_keep_input
and passthrough_camera_event.data.gameInput,
"camera passthrough must apply keyboard focus without a cursor and keep GTA input enabled"
)
event_handlers["sky_phone:client:setCameraFocus"]({ active = false, nuiFocused = true })
local camera_interrupted_by_call = resolve({
call_focus = true,
camera_active = true,
-64
View File
@@ -1,64 +0,0 @@
Config = {
Bridge = {
Framework = "qbox",
},
CommandPermissions = {
phonepanel = { "god", "superadmin", "admin" },
},
}
Bridge = {
Framework = {},
}
function GetResourceState(resource)
assert(resource == "qbx_core")
return "started"
end
function GetPlayers()
return { "1" }
end
local ace_permissions = {}
local framework_permission = false
function IsPlayerAceAllowed(source, permission)
assert(source == "1")
return ace_permissions[permission] == true
end
exports = {
qbx_core = {
GetPlayer = function(_, source)
if tonumber(source) ~= 1 then
return nil
end
return {
PlayerData = {
citizenid = "test-citizen",
},
}
end,
HasGroup = function(_, source, groups)
assert(source == 1)
assert(groups == Config.CommandPermissions.phonepanel)
return framework_permission
end,
},
}
dofile("sky_phone/source/bridge/server/framework.lua")
dofile("sky_phone/source/bridge/server/frameworks/qbox.lua")
ace_permissions.admin = true
assert(Bridge.Framework.HasPermission(1, "phonepanel"), "Qbox ACE permissions must grant access")
ace_permissions.admin = false
framework_permission = true
assert(Bridge.Framework.HasPermission(1, "phonepanel"), "Qbox framework groups must remain supported")
local success = pcall(Bridge.Framework.HasPermission, 1, "missing")
assert(not success, "Missing fixed permission definitions must fail loudly")
print("framework permission tests passed")
+5 -18
View File
@@ -1,9 +1,3 @@
local event_handlers = {}
AddEventHandler = function(event_name, callback)
event_handlers[event_name] = callback
end
local function reset_bridge(inventory_name, unique_phones, sim_cards_enabled)
Config = {
Bridge = {
@@ -199,18 +193,11 @@ exports = {
}
load_inventory_contract("sky_phone/source/bridge/server/inventory/esx.lua")
assert(Config.Phone.Unique == false)
assert(Config.Sim.Enabled == false)
assert(Bridge.Inventory.ConfigurationError == nil)
Config.Phone.Unique = true
Config.Sim.Enabled = true
event_handlers["sky_phone:configurator:serverUpdated"]()
assert(Config.Phone.Unique == false)
assert(Config.Sim.Enabled == false)
assert(Bridge.Inventory.RegisterUsableItem("phone", function()
end))
local ok, configuration_error = pcall(Bridge.Inventory.RegisterUsableItem, "phone", function()
end)
assert(not ok)
assert(configuration_error:find("Config.Phone.Unique = false", 1, true))
assert(configuration_error:find("Config.Sim.Enabled = false", 1, true))
local manifest_file = assert(io.open("sky_phone/fxmanifest.lua", "rb"))
local manifest = manifest_file:read("*a")
-324
View File
@@ -1,324 +0,0 @@
AddEventHandler = function()
end
local function reset_bridge(inventory_name, resource_name, inventory_export, framework_name, extra_exports)
Config = {
Bridge = { Inventory = inventory_name },
Phone = { Unique = true },
Sim = { Enabled = true },
}
local usable_items = {}
Bridge = {
Debug = function()
end,
Framework = {
GetName = function()
return framework_name or "qb"
end,
GetIdentifier = function(source)
return ("player:%s"):format(source)
end,
RegisterUsableItem = function(item_name, callback)
usable_items[item_name] = callback
return true
end,
},
Inventory = {},
}
exports = extra_exports or {}
exports[resource_name] = inventory_export
GetResourceState = function(candidate)
return candidate == resource_name and "started" or "missing"
end
return usable_items
end
local function load_adapter(adapter_path)
dofile("sky_phone/source/bridge/server/inventory.lua")
dofile(adapter_path)
dofile("sky_phone/source/bridge/server/inventory_contract.lua")
end
local function assert_metadata(metadata, imei)
assert(type(metadata) == "table" and metadata.imei == imei)
end
Config = {
Bridge = { Inventory = "auto" },
Phone = { Unique = true },
Sim = { Enabled = true },
}
Bridge = {
Framework = {
GetName = function()
return "esx"
end,
},
Inventory = {},
}
GetResourceState = function(resource_name)
if resource_name == "ps-inventory" or resource_name == "tgiann-inventory" or resource_name == "ox_inventory" then
return "started"
end
return "missing"
end
dofile("sky_phone/source/bridge/server/inventory.lua")
assert(Bridge.Inventory.Name == "tgiann", "auto detection must follow the shared Sky inventory priority")
local ak47_items = {
[3] = { name = "phone", slot = 3, amount = 1, info = { owner = "kept" } },
}
local ak47 = {}
function ak47:GetSlot(identifier, slot)
assert(identifier == "player:21")
return ak47_items[slot]
end
function ak47:GetSlotsWithItem(identifier, item_name)
assert(identifier == "player:21" and item_name == "phone")
return ak47_items
end
function ak47:SetItemInfo(identifier, slot, metadata)
assert(identifier == "player:21")
ak47_items[slot].info = metadata
end
function ak47:CanAddItem()
return true
end
function ak47:AddItem()
return true
end
function ak47:RemoveItem()
return true
end
local ak47_usable = reset_bridge("ak47_inventory", "ak47_inventory", ak47)
load_adapter("sky_phone/source/bridge/server/inventory/ak47.lua")
assert(Bridge.Inventory.Name == "ak47", "resource-name aliases must resolve to adapter names")
assert(Bridge.Inventory.SetSlotMetadata(21, 3, { owner = "kept", imei = "111111111111111" }))
assert_metadata(Bridge.Inventory.GetSlot(21, 3).metadata, "111111111111111")
assert(Bridge.Inventory.RegisterUsableItem("phone", function(source, item)
assert(source == 21 and item.slot == 3)
end))
ak47_usable.phone(21, "phone", ak47_items[3])
local jaksam_items = {
[5] = { name = "phone", slot = 5, amount = 1, metadata = {} },
}
local jaksam_usable = {}
local jaksam = {}
function jaksam:getInventory()
return { items = jaksam_items }
end
function jaksam:setItemMetadataInSlot(_, slot, metadata)
jaksam_items[slot].metadata = metadata
return true
end
function jaksam:canCarryItem()
return true
end
function jaksam:addItem()
return true
end
function jaksam:removeItem()
return true
end
function jaksam:registerUsableItem(item_name, callback)
jaksam_usable[item_name] = callback
end
reset_bridge("jaksam", "jaksam_inventory", jaksam)
load_adapter("sky_phone/source/bridge/server/inventory/jaksam.lua")
assert(Bridge.Inventory.SetSlotMetadata(22, 5, { imei = "222222222222222" }))
assert_metadata(Bridge.Inventory.GetSlot(22, 5).metadata, "222222222222222")
assert(Bridge.Inventory.RegisterUsableItem("phone", function(source, item)
assert(source == 22 and item.slot == 5)
end))
jaksam_usable.phone(22, jaksam_items[5])
local one_items = {
[7] = { name = "phone", slot = 7, count = 1, metadata = {} },
}
local one = {}
function one:GetSlot(_, slot)
return one_items[slot]
end
function one:GetSlotIdsWithItem(_, item_name, metadata)
assert(item_name == "phone")
if metadata and metadata.imei ~= one_items[7].metadata.imei then
return {}
end
return { 7 }
end
function one:SetItemMetadata(_, slot, metadata)
one_items[slot].metadata = metadata
end
function one:CanCarryItem()
return true
end
function one:AddItem()
return true
end
function one:RemoveItem()
return true
end
reset_bridge("one", "one_inventory", one)
load_adapter("sky_phone/source/bridge/server/inventory/one.lua")
assert(Bridge.Inventory.SetSlotMetadata(23, 7, { imei = "333333333333333" }))
assert_metadata(Bridge.Inventory.GetSlotsWithItem(23, "phone")[1].metadata, "333333333333333")
local origen_items = {
[9] = { name = "phone", slot = 9, amount = 1, metadata = {} },
}
local origen = {}
function origen:GetInventory()
return { inventory = origen_items }
end
function origen:setMetadata(_, slot, metadata)
origen_items[slot].metadata = metadata
end
function origen:canCarryItem()
return true
end
function origen:addItem()
return true
end
function origen:removeItem()
return true
end
reset_bridge("origen", "origen_inventory", origen)
load_adapter("sky_phone/source/bridge/server/inventory/origen.lua")
assert(Bridge.Inventory.SetSlotMetadata(24, 9, { imei = "444444444444444" }))
assert_metadata(Bridge.Inventory.GetSlot(24, 9).metadata, "444444444444444")
local tgiann_items = {
[11] = { name = "phone", slot = 11, amount = 1, info = {} },
}
local tgiann = {}
function tgiann:GetPlayerItems()
return tgiann_items
end
function tgiann:GetItemBySlot(_, slot)
return tgiann_items[slot]
end
function tgiann:UpdateItemMetadata(_, _, slot, metadata)
tgiann_items[slot].info = metadata
end
function tgiann:CanCarryItem()
return true
end
function tgiann:AddItem()
return true
end
function tgiann:RemoveItem()
return true
end
reset_bridge("tgiann", "tgiann-inventory", tgiann)
load_adapter("sky_phone/source/bridge/server/inventory/tgiann.lua")
assert(Bridge.Inventory.SetSlotMetadata(25, 11, { imei = "555555555555555" }))
assert_metadata(Bridge.Inventory.GetSlot(25, 11).metadata, "555555555555555")
local qb_items = {
[13] = { name = "phone", slot = 13, amount = 1, info = { owner = "kept" } },
}
local qb_player = { PlayerData = { items = qb_items } }
local qb_core = {
Functions = {
GetPlayer = function()
return qb_player
end,
},
}
local qb_export = {
GetCoreObject = function()
return qb_core
end,
}
local jpr_drop_next_metadata = true
local jpr = {}
function jpr:GetItemBySlot(_, slot)
return qb_items[slot]
end
function jpr:RemoveItem(_, _, _, slot)
qb_items[slot] = nil
return true
end
function jpr:AddItem(_, item_name, amount, slot, metadata)
qb_items[slot] = {
name = item_name,
slot = slot,
amount = amount,
info = jpr_drop_next_metadata and {} or metadata,
}
jpr_drop_next_metadata = false
return true
end
reset_bridge("jpr", "jpr-inventory", jpr, "qb", { ["qb-core"] = qb_export })
load_adapter("sky_phone/source/bridge/server/inventory/jpr.lua")
assert(not Bridge.Inventory.SetSlotMetadata(26, 13, { owner = "kept", imei = "666666666666666" }))
assert(qb_items[13] and qb_items[13].info.owner == "kept", "jpr metadata failure must restore the original item")
assert(Bridge.Inventory.SetSlotMetadata(26, 13, { owner = "kept", imei = "666666666666666" }))
assert_metadata(Bridge.Inventory.GetSlot(26, 13).metadata, "666666666666666")
qb_items[15] = { name = "phone", slot = 15, amount = 1, info = {} }
local ps = {}
function ps:GetItemBySlot(_, slot)
return qb_items[slot]
end
function ps:RemoveItem(_, _, _, slot)
qb_items[slot] = nil
return true
end
function ps:AddItem(_, item_name, amount, slot, metadata)
qb_items[slot] = { name = item_name, slot = slot, amount = amount, info = metadata }
return true
end
reset_bridge("ps", "ps-inventory", ps, "qb", { ["qb-core"] = qb_export })
load_adapter("sky_phone/source/bridge/server/inventory/ps.lua")
assert(Bridge.Inventory.SetSlotMetadata(27, 15, { imei = "777777777777777" }))
assert_metadata(Bridge.Inventory.GetSlot(27, 15).metadata, "777777777777777")
print("extended inventory bridge regression checks passed")
+2 -1
View File
@@ -25,7 +25,7 @@ Bridge = {
Debug = function()
end,
Framework = {
HasPermission = function()
HasAdminGroup = function()
return true
end,
},
@@ -42,6 +42,7 @@ Bridge = {
Config = {
AdminPanel = {
AdminGroups = { "admin" },
Command = "phoneadmin",
Enabled = true,
},