Compare commits

...
Author SHA1 Message Date
Alec Schitzkat 7ce34d5c25 BLD - Bump sky_phone version to 0.3.4 2026-09-01 16:31:24 +02:00
Dominik9906andDerEchteAlec bbc2cbe000 FIX - repair phone UI clipping and app lifecycle (#48)
* FIX - UI and Bug Fix

* FIX - remove hardware button focus box

* ENH - configure radio job permissions

* FIX - keep native dropdown options readable

---------

Co-authored-by: DerEchteAlec <alec.schitzkat@luwan.io>
2026-08-31 15:46:31 +02:00
Dominik9906 8efaa9f03b Feat/custom phone tones yaca (#49)
* ADD - manage custom phone tones

* ADD - provide phone database uninstall script

* FIX - support legacy YACA availability

* BLD - sync generated phone defaults
2026-08-31 15:43:18 +02:00
Alec Schitzkat efcc67334f BLD - Bump sky_phone version to 0.3.3 2026-08-26 18:47:33 +02:00
Leon.Schmidt 693c9bd6f9 FIX - allow configurable radio channel jobs (#46)
Mark locked-channel job maps mutable in the configurator schema and initialize new structured list rows from their schema template so required frequency ranges remain valid.
2026-08-26 18:46:51 +02:00
Leon.Schmidt a597e161ea ADD - integrate TGIANN House (#47)
Authorize owner properties from the documented TGIANN schema and enrich entrance waypoints through the published client export. Keep unsupported key, lock, CCTV, and garage capabilities disabled.
2026-08-26 18:46:39 +02:00
Alec Schitzkat b0b8cfc550 BLD - Bump sky_phone version to 0.3.2 2026-08-25 21:27:14 +02:00
Leon.Schmidt 76484cfe05 PERF - bound crypto market history queries (#45) 2026-08-25 21:26:18 +02:00
Dominik9906 b406fa9443 FIX - UI and Bug Fix (#44) 2026-08-25 21:26:05 +02:00
Alec Schitzkat 2388ba9b4e BLD - Bump sky_phone version to 0.3.1 2026-08-24 23:45:07 +02:00
Leon.Schmidt c6394fda66 PERF - streamline SkyRide refund recovery (#43) 2026-08-24 20:44:37 +02:00
Leon.Schmidt 4516469c33 PERF - cache crypto market ticks server-side (#42) 2026-08-24 15:20:13 +02:00
DerEchteAlec 7708ed725b FIX - support folder dragging across scaling (#41) 2026-08-24 01:39:43 +02:00
Alec Schitzkat 00067543cb BLD - release sky_phone 0.3.0 2026-08-24 01:14:08 +02:00
Alec Schitzkat fd24c8b272 FIX - route company service-line messages 2026-08-24 01:14:08 +02:00
Leon.Schmidt aac916efc6 Update fxmanifest.lua (#40) 2026-08-24 01:08:43 +02:00
DerEchteAlec 603f4de54e FIX - repair open Phone ticket regressions (#38)
* FIX - repair persisted company messaging defaults

* FIX - unify CityWarn spacing and scrolling
2026-08-24 00:50:01 +02:00
DerEchteAlecandLeon.Schmidt ef2d805079 FIX - unify CityWarn spacing and radii (#36)
Co-authored-by: Leon.Schmidt <159480018+leonw21342315@users.noreply.github.com>
2026-08-24 00:49:48 +02:00
DerEchteAlecandLeon.Schmidt 829f8da82a FIX - polish camera controls and landscape layout (#37)
Co-authored-by: Leon.Schmidt <159480018+leonw21342315@users.noreply.github.com>
2026-08-23 22:12:22 +02:00
DerEchteAlecandLeon.Schmidt 7ca39ef4b9 FIX - refine SkyRide navigation and trip layout (#35)
Co-authored-by: Leon.Schmidt <159480018+leonw21342315@users.noreply.github.com>
2026-08-23 22:09:47 +02:00
DerEchteAlec b673b15b97 FIX - restore contact profile light mode (#34) 2026-08-23 22:06:40 +02:00
Leon.Schmidt a12e43b624 FIX - allow configurator company creation (#33)
The generic mutable-table blank template persisted incomplete company definitions before runtime domain validation. Seed valid company defaults, validate before SQL writes, and repair legacy blank entries.
2026-08-23 19:20:37 +02:00
Leon.Schmidt 312ae8a3a3 FIX - repair company actions and key rebindings (#32)
* FIX - repair company detail actions

Constrain compact navbar titles to their grid column so long company names cannot overlap the back action. Allow configured public emergency companies to accept normal service requests, and migrate existing police profile and Phone Configurator defaults exactly once.

* FIX - preserve phone key rebindings

Use the stable sky_phone_toggle command as the RegisterKeyMapping identifier. The previous revisioned command names detached FiveM's persisted keyboard settings from the active handler whenever the mapping identity changed.
2026-08-23 18:23:09 +02:00
Leon.Schmidt 435320e8fd ENH - Fix SkyRide German mode labels (#31)
Correct the German SkyRide mode labels so rider and driver text matches the intended roles and improves clarity in the app.
2026-08-23 16:19:18 +02:00
Leon.Schmidt ea959f59f2 FIX - restore camera input and configurator warning (#30)
* FIX - honor configured camera look control

Camera focus was owned by hard-coded Space DOM handlers, bypassing Config.Phone.HoldToLook. Move focus input to the FiveM client and add a bounded scripted-camera orbit so selfie mode can rotate while the configured control is held.

* FIX - preserve camera movement passthrough

* FIX - restore Phone Configurator startup warning

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

* FIX - enforce fixed phone permissions
2026-08-22 05:02:14 +02:00
115 changed files with 10205 additions and 1530 deletions
+1 -1
View File
@@ -119,5 +119,5 @@ jobs:
"${archive}.sha256" \
--verify-tag \
--generate-notes \
--title "Sky Phone $GITHUB_REF_NAME"
--title "$GITHUB_REF_NAME Beta"
fi
+22 -11
View File
@@ -89,7 +89,7 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
| **Inventories** | ak47_inventory, codem-inventory, core_inventory, jaksam_inventory, jpr-inventory, lj-inventory, mf-inventory, one_inventory, origen_inventory, ox_inventory, ps-inventory, qb-inventory, qs-inventory, smx-inventory, tgiann-inventory, hex_4_inventory, and native ESX inventory |
| **Calls** | YACA, PMA Voice, SaltyChat |
| **Radio** | YACA, PMA Voice, SaltyChat |
| **Housing** | RTX Housing, Quasar Housing, VMS Housing, RX Housing, NoLag Properties, SN Properties, ESX Property, qbx_properties |
| **Housing** | RTX Housing, Quasar Housing, TGIANN House, VMS Housing, RX Housing, NoLag Properties, SN Properties, ESX Property, qbx_properties |
| **Garages** | Built-in/custom data and a broad set of popular garage providers configured through the bridge |
| **Custom app contracts** | Sky Phone, LB Phone, 17Movement, High Phone, Quasar Smartphone, YSeries |
| **Languages** | English, German |
@@ -138,10 +138,10 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
| `smx-inventory` (`smx`) | Yes | Yes | ESX only; one metadata record per configured item name through the player metadata bridge |
| `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; set `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` |
| Native ESX inventory (`esx`) | **No metadata support** | **No, Unique Phones are not possible** | Count-based items; set `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` |
| `hex_4_inventory` (`hex`) | **No metadata support** | **No, Unique Phones are not possible** | ESX only; Sky Phone automatically disables unique phones and physical SIM cards |
| Native ESX inventory (`esx`) | **No metadata support** | **No, Unique Phones are not possible** | Count-based items; Sky Phone automatically disables unique phones and physical SIM cards |
`hex_4_inventory` and native ESX inventory cannot persist per-item metadata. Unique Phones and physical SIM cards are therefore unavailable with these adapters.
`hex_4_inventory` and native ESX inventory cannot persist per-item metadata. Sky Phone therefore forces `Config.Phone.Unique` and `Config.Sim.Enabled` to `false` at runtime whenever either adapter is active.
`Config.Bridge.Inventory = "auto"` detects framework-compatible adapters in the table order. This deliberately matches the Sky inventory priority so a dedicated inventory is selected before a compatibility resource it may run beside. You may configure either the short value shown in parentheses or the exact resource name.
@@ -216,6 +216,7 @@ The files contain clearly separated sections for:
| Section | Purpose |
| --- | --- |
| `Config.Bridge` | Framework, inventory, language, callback timeout, and debug mode |
| `Config.CommandPermissions` | Fixed groups for the admin panel, test data, verification commands, and social moderation |
| `Config.Phone` | Phone item, movement, unique-device mode, and development command |
| `Config.Sim` | Physical or virtual SIM behavior and number formatting |
| `Config.Calls` / `Config.Radio` | Voice providers, call behavior, radio limits, and permissions |
@@ -241,21 +242,31 @@ Config.PhoneConfigurator = {
}
```
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
When enabled, the generated `source/shared/config_default.lua` is the shipped first-run baseline.
The frontend build recreates this file from `config.lua` and the server-only `media.lua`; do not edit
the generated snapshot directly. Sky Phone creates the `sky_phone_configurator` table automatically,
loads its saved values before framework and phone
modules initialize, and exposes the editor through `/phonepanel`. Nothing autosaves: stage changes
in the Phone Configurator tool and press the green check. Saving verifies both SQL payloads and then
applies the new server, client, media, app, item, command, provider, animation, and UI values through
Sky Phone's internal runtime refresh. It does not execute a resource restart command.
Every `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,
Every configurable `Config.*` value from `config.lua`, including server-only sections, and every
value from `Config.Media` is discovered automatically. The bootstrap switch and
`Config.CommandPermissions` intentionally remain file-owned: the switch decides whether SQL
configuration is loaded, while permissions must remain authoritative outside the panel. The fixed
permission table is never displayed or overwritten by the Phone Configurator, and its stable keys do
not change when their commands are renamed in the panel. Lists, nested objects, vectors,
and numeric-keyed Lua tables use structured editors instead of raw JSON. Shipped schema rows stay
editable but cannot be renamed, converted, or removed. Every list and table still accepts any number
of additional rows; administrator-added rows remain removable. Company job keys are intentionally
fully removable because `Config.Companies.Definitions` is a freely managed job collection.
ESX and QBCore use the groups listed in `Config.CommandPermissions`. Qbox checks the configured ACE
objects first and then its framework groups. The standard Qbox `permissions.cfg` grants the `admin`
ACE to `group.admin`, so an identifier assigned to `group.admin` can open `/phonepanel` with the
shipped `phonepanel` permission list. Restart `sky_phone` after changing fixed permissions.
Media API keys and server peppers are never returned in plaintext to the NUI. Existing secrets are
shown only as configured and are replaced only when an administrator enters a new value.
@@ -365,7 +376,7 @@ Do not configure an LB Phone client event or client export. Sky Phone registers
The server registers `Config.Phone.Item` as usable for every supported inventory adapter: `ak47`, `codem`, `core`, `jaksam`, `jpr`, `lj`, `mf`, `one`, `origen`, `ox`, `ps`, `qb`, `qs`, `smx`, `tgiann`, `hex`, and `esx`. Resource startup fails visibly if the selected adapter or its resource is unavailable.
The `hex` and `esx` adapters use ESX's count-based item API. They require both `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` because this API cannot persist per-item phone or physical SIM metadata. `auto` selects `hex` when `hex_4_inventory` is started and otherwise falls back to `esx` on an ESX server when no metadata-capable inventory is detected.
The `hex` and `esx` adapters use ESX's count-based item API, which cannot persist per-item phone or physical SIM metadata. Sky Phone automatically forces `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` while either adapter is active. `auto` selects `hex` when `hex_4_inventory` is started and otherwise falls back to `esx` on an ESX server when no metadata-capable inventory is detected.
### QBCore-style item tables
@@ -639,7 +650,7 @@ Select the provider under `Config.Garage.System`. Vehicle images use the configu
### Housing
Select `rtx`, `quasar`, `vms`, `rx`, `nolag`, `sn`, `esx_property`, or `qbx_properties` under `Config.Housing.System`. Automatic mode uses `Config.Housing.AutoPriority` and keeps the existing `esx_property` and `qbx_properties` defaults ahead of newly supported providers. Select a provider explicitly when multiple housing resources are running. Each bridge exposes only the capabilities supported by the documented provider API.
Select `rtx`, `quasar`, `tgiann`, `vms`, `rx`, `nolag`, `sn`, `esx_property`, or `qbx_properties` under `Config.Housing.System`. Automatic mode uses `Config.Housing.AutoPriority` and keeps the existing `esx_property` and `qbx_properties` defaults ahead of newly supported providers. Select a provider explicitly when multiple housing resources are running. Each bridge exposes only the capabilities supported by the documented provider API. The TGIANN bridge expects `tgiann-core` to start before `tgiann-house`, lists server-authorized owner properties, and supports entrance waypoints; TGIANN does not publish stable external contracts for keyholders, lock controls, CCTV, or live garage status.
### Companies
+40 -5
View File
@@ -2,15 +2,49 @@ const fs = require('node:fs')
const path = require('node:path')
const frontendRoot = __dirname
const resourceDirectory = path.join(frontendRoot, '..', 'sky_phone')
const sourceDirectory = path.join(frontendRoot, 'dist')
const targetDirectory = path.join(
frontendRoot,
'..',
'sky_phone',
const targetDirectory = path.join(resourceDirectory, 'source', 'html')
const configDefaultPath = path.join(
resourceDirectory,
'source',
'html',
'shared',
'config_default.lua',
)
function readLuaSource(...segments) {
return fs
.readFileSync(path.join(resourceDirectory, ...segments), 'utf8')
.replace(/\r\n?/g, '\n')
.trimEnd()
}
function generateConfigDefault() {
const configSource = readLuaSource('config', 'config.lua').replace(
/\n?-- CONFIG_DEFAULT_EXCLUDE_START[\s\S]*?-- CONFIG_DEFAULT_EXCLUDE_END\n?/g,
'\n',
)
const mediaSource = readLuaSource('config', 'media.lua')
const generatedSource = [
'-- GENERATED by frontend/build.cjs from config/config.lua and config/media.lua - do not edit.',
'-- Escrowed snapshot of the shipped defaults used by the Phone Configurator.',
'local realConfig = Config',
'Config = {}',
'',
configSource,
'',
mediaSource,
'',
'ConfigDefaults = Config',
'Config = realConfig',
'',
].join('\n')
fs.mkdirSync(path.dirname(configDefaultPath), { recursive: true })
fs.writeFileSync(configDefaultPath, generatedSource, 'utf8')
console.log(`Generated Phone Configurator defaults at ${configDefaultPath}`)
}
if (!fs.existsSync(sourceDirectory)) {
throw new Error(
'Missing frontend/dist. Run vite build before publishing the NUI.',
@@ -28,4 +62,5 @@ const normalizedIndex = fs
.replace(/\n[ \t]*\n([ \t]*<\/body>)/g, '\n$1')
fs.writeFileSync(targetIndex, normalizedIndex)
generateConfigDefault()
console.log(`Published NUI to ${targetDirectory}`)
+24 -11
View File
@@ -86,6 +86,8 @@ import { parsePhonePreferences } from '@/utils/preferences'
import { getHairlinePixelStyle } from '@/utils/rendering'
import { isTextInputElement } from '@/utils/textInputFocus'
import { configurePhoneNumberFormat } from '@/utils/phone'
import { consumeEscape } from '@/utils/keyboard'
import type { CustomPhoneToneCatalog } from '@/utils/customTones'
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
import SpringboardView from '@/views/SpringboardView.vue'
@@ -117,6 +119,7 @@ type AppMessage = {
| CustomAppEventData
| NavigationEventData
| AdminPanelOpenPayload
| CustomPhoneToneCatalog
}
type AdminPanelOpenPayload = Required<
@@ -530,8 +533,17 @@ function hydratePhone(payload: PhoneOpenPayload): void {
clock.hydrate(payload.device?.data.alarms?.payload)
games.hydrate(payload.device?.data.games?.payload)
media.hydrate(payload.device?.data.media?.payload)
appStore.hydrate(payload.device?.data.apps?.payload)
appStore.hydrate(payload.device?.data.apps?.payload, payload.disabledApps)
widgets.hydrate(payload.device?.data.widgets?.payload)
const currentAppId = route.params.appId
if (
typeof currentAppId === 'string' &&
isPhoneAppId(currentAppId) &&
!appStore.isInstalled(currentAppId)
) {
void router.push('/')
}
}
function getInstalledNavigationAppIds(): string[] {
@@ -740,6 +752,8 @@ function onMessage(event: MessageEvent<AppMessage>): void {
adminPanelOpen.value = true
} else if (event.data?.type === 'admin:close') {
adminPanelOpen.value = false
} else if (event.data?.type === 'phone:tones') {
phone.setCustomTones(event.data.data)
} else if (event.data?.type === 'custom-apps:catalog') {
appCatalog.replaceCatalog(event.data.data)
const catalogPayload = event.data.data as
@@ -1306,20 +1320,17 @@ async function closePhone(): Promise<void> {
function onKeydown(event: KeyboardEvent): void {
if (event.key !== 'Escape') return
if (simPicker.value) {
event.preventDefault()
if (!consumeEscape(event)) return
void closeSimPicker()
return
}
queueMicrotask(() => {
if (event.defaultPrevented || !phone.isOpen || activitySuspended.value)
return
if (controlCenterOpened.value) {
controlCenterOpened.value = false
return
}
void closePhone()
})
if (!phone.isOpen || activitySuspended.value || !consumeEscape(event)) return
if (controlCenterOpened.value) {
controlCenterOpened.value = false
return
}
void closePhone()
}
function onSystemColorSchemeChange(event: MediaQueryListEvent): void {
@@ -1876,6 +1887,8 @@ 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,
}"
@@ -70,6 +70,43 @@ describe('browser development preview contract', () => {
expect(source).toContain(':device-pixel-ratio="browserDevicePixelRatio"')
})
it('clips composited app and overlay layers to the curved display', () => {
expect(mainCss).toMatch(
/\.phone-screen\s*\{[^}]*--phone-screen-radius:\s*40px;[^}]*overflow:\s*hidden;[^}]*border-radius:\s*var\(--phone-screen-radius\);[^}]*clip-path:\s*inset\(0 round var\(--phone-screen-radius\)\);/s,
)
})
it('replaces the CEF button focus rectangle around the home indicator', () => {
expect(mainCss).toMatch(
/\.phone-home-indicator:focus\s*\{[^}]*outline:\s*none;/s,
)
expect(mainCss).toMatch(
/\.phone-home-indicator:focus-visible span\s*\{[^}]*0 0 0 2px #0a84ff,/s,
)
})
it('replaces rectangular CEF focus outlines on the side hardware controls', () => {
expect(mainCss).toMatch(
/\.phone-hardware-button:focus\s*\{[^}]*outline:\s*none;/s,
)
expect(mainCss).toMatch(
/\.phone-hardware-button:focus-visible::after\s*\{[^}]*width:\s*3px;[^}]*height:\s*24px;[^}]*border-radius:\s*999px;[^}]*background:\s*#0a84ff;/s,
)
expect(mainCss).not.toMatch(
/\.phone-hardware-button:focus-visible\s*\{[^}]*outline:/s,
)
})
it('consumes Escape synchronously before FiveM can open the pause menu', () => {
expect(source).toContain("import { consumeEscape } from '@/utils/keyboard'")
expect(source).toContain(
'if (!phone.isOpen || activitySuspended.value || !consumeEscape(event)) return',
)
expect(source).not.toMatch(
/function onKeydown\(event: KeyboardEvent\): void \{[\s\S]*?queueMicrotask/,
)
})
it('maps the visible device side controls to phone actions', () => {
expect(source).toContain('@click="toggleHardwareAlertMute"')
expect(source).toContain('@click="changeHardwareAlertVolume(10)"')
+42 -4
View File
@@ -380,9 +380,27 @@ button {
.phone-hardware-button:disabled {
cursor: default;
}
.phone-hardware-button:focus-visible {
outline: 2px solid #fff;
outline-offset: -6px;
.phone-hardware-button:focus {
outline: none;
}
.phone-hardware-button:focus-visible::after {
position: absolute;
top: 50%;
width: 3px;
height: 24px;
border-radius: 999px;
background: #0a84ff;
box-shadow: 0 0 0 1px rgb(255 255 255 / 90%);
content: '';
transform: translateY(-50%);
}
.phone-hardware-button--action:focus-visible::after,
.phone-hardware-button--volume-up:focus-visible::after,
.phone-hardware-button--volume-down:focus-visible::after {
right: 7px;
}
.phone-hardware-button--power:focus-visible::after {
left: 7px;
}
.phone-hardware-button--action {
top: 176px;
@@ -460,6 +478,8 @@ button {
}
}
.phone-screen {
--phone-screen-radius: 40px;
--phone-screen-portrait-ratio: 2.30951;
position: relative;
container-type: size;
margin: auto;
@@ -468,7 +488,14 @@ button {
height: 98%;
overflow: hidden;
background: #08080a;
border-radius: 40px;
border-radius: var(--phone-screen-radius);
clip-path: inset(0 round var(--phone-screen-radius));
}
.phone-screen--camera-landscape {
background: transparent;
}
.phone-screen--camera-landscape .springboard {
visibility: hidden;
}
.phone-app {
position: absolute !important;
@@ -849,6 +876,14 @@ button {
border-radius: 10px;
box-shadow: 0 1px 4px #0008;
}
.phone-home-indicator:focus {
outline: none;
}
.phone-home-indicator:focus-visible span {
box-shadow:
0 0 0 2px #0a84ff,
0 1px 4px #0008;
}
.phone-home-indicator--interactive {
cursor: pointer;
}
@@ -3239,6 +3274,9 @@ button {
backface-visibility: hidden;
will-change: transform, border-radius, opacity;
}
.app-window--camera-landscape {
background: transparent;
}
.app-window--citywarn {
backface-visibility: visible;
filter: none;
@@ -0,0 +1,147 @@
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)',
)
})
})
@@ -0,0 +1,133 @@
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 =')
}
})
})
@@ -0,0 +1,139 @@
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,15 @@ 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 = {
addField: string
addJob: string
addRow: string
configuredSecret: string
convertToList: string
@@ -23,6 +28,7 @@ export type AdminConfigEditorLabels = {
emptyTable: string
entry: string
general: string
jobPlaceholder: string
keyPlaceholder: string
list: string
remove: string
@@ -145,6 +151,11 @@ const canExtendTable = computed(
!vectorType.value &&
(!tableStructure.value || tableStructure.value.mutableKeys === true),
)
const isJobTable = computed(
() =>
props.path === 'Radio.DisplayName.AllowedJobs' ||
/^Radio\.LockedChannels\[\d+\]\.jobs$/.test(props.path),
)
const usesFixedTableLayout = computed(
() =>
Boolean(tableStructure.value) && tableStructure.value?.mutableKeys !== true,
@@ -342,40 +353,6 @@ 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
@@ -401,6 +378,19 @@ function updateOptionalString(event: Event): void {
}
}
function updateNewObjectKey(event: Event): void {
const target = event.target
if (!(target instanceof HTMLInputElement)) return
const value = isJobTable.value
? target.value
.toLowerCase()
.replace(/[^a-z0-9_-]/g, '')
.slice(0, 64)
: target.value
newObjectKey.value = value
target.value = value
}
function toggleOptionalString(event: Event): void {
const target = event.target
if (!(target instanceof HTMLInputElement)) return
@@ -410,10 +400,10 @@ function toggleOptionalString(event: Event): void {
function addListRow(): void {
const rows = Array.isArray(props.modelValue) ? [...props.modelValue] : []
const index = rows.length
const value = rows.length
? blankLike(rows[0])
: listTemplate.value
? blankFromStructure(listTemplate.value)
const value = listTemplate.value
? blankFromConfiguratorStructure(listTemplate.value)
: rows.length
? blankLike(rows[0])
: blankValue(newArrayKind.value)
rows.push(value)
emit('update:modelValue', rows)
@@ -476,12 +466,21 @@ function addTableField(): void {
const template = tableStructure.value?.mutableKeys
? tableStructure.value.template
: undefined
const value = template
? blankFromStructure(template)
: blankCollectionValue(
newObjectKind.value,
Object.values(tableValue.value).filter((_, index) => index < 50),
const structuredValue = tableStructure.value?.mutableKeys
? createMutableTableEntry(
tableStructure.value,
props.path,
key,
tableValue.value,
)
: undefined
const value =
structuredValue !== undefined
? structuredValue
: blankCollectionValue(
newObjectKind.value,
Object.values(tableValue.value).filter((_, index) => index < 50),
)
emit('update:modelValue', {
...tableValue.value,
[key]: value,
@@ -556,7 +555,7 @@ function addMapEntry(): void {
if (!canAddMapEntry.value || parsedNewMapKey.value === null) return
const key = parsedNewMapKey.value
const value = mapTemplate.value
? blankFromStructure(mapTemplate.value)
? blankFromConfiguratorStructure(mapTemplate.value)
: blankCollectionValue(
newMapValueKind.value,
mapEntries.value.slice(0, 50).map((entry) => entry.value),
@@ -1036,11 +1035,15 @@ function mapEntryStructure(
@submit.prevent="addTableField"
>
<input
v-model="newObjectKey"
:value="newObjectKey"
type="text"
:disabled="disabled"
:placeholder="labels.keyPlaceholder"
:aria-label="labels.keyPlaceholder"
:placeholder="
isJobTable ? labels.jobPlaceholder : labels.keyPlaceholder
"
:aria-label="isJobTable ? labels.jobPlaceholder : labels.keyPlaceholder"
autocomplete="off"
@input="updateNewObjectKey"
/>
<select
v-if="!tableStructure"
@@ -1060,7 +1063,7 @@ function mapEntryStructure(
{{ structureTypeLabel(tableStructure.template) }}
</span>
<button type="submit" :disabled="disabled || !canAddTableField">
<Plus :size="13" />{{ labels.addField }}
<Plus :size="13" />{{ isJobTable ? labels.addJob : labels.addField }}
</button>
</form>
</div>
@@ -0,0 +1,809 @@
<script setup lang="ts">
import {
FileAudio,
FileCode2,
LoaderCircle,
LockKeyhole,
Pause,
Play,
Plus,
Trash2,
Upload,
} from 'lucide-vue-next'
import { computed, onBeforeUnmount, ref } from 'vue'
import { useAdminStore } from '@/stores/admin'
import { usePhoneStore } from '@/stores/phone'
import type { AdminCustomTone } from '@/types/admin'
import { SkyButton } from '@/ui'
import { MAX_CUSTOM_TONE_BYTES, playCustomPhoneTone } from '@/utils/customTones'
const MAX_DURATION_MS = 30_000
const AUDIO_METADATA_TIMEOUT_MS = 8_000
const props = defineProps<{ disabled?: boolean }>()
const emit = defineEmits<{
toast: [message: string, tone: 'error' | 'success']
}>()
const admin = useAdminStore()
const phone = usePhoneStore()
const fileInput = ref<HTMLInputElement | null>(null)
const fileInputResetKey = ref(0)
const label = ref('')
const toneType = ref<'notification' | 'ringtone'>('ringtone')
const selectedFile = ref<File | null>(null)
const selectedMimeType = ref('')
const selectedDurationMs = ref(0)
const previewUrl = ref('')
const processingFile = ref(false)
const playingToneId = ref('')
const pendingDeleteId = ref('')
let stopPreview: (() => void) | null = null
let previewTimer: number | undefined
const ringtoneTones = computed(() =>
admin.customTones.filter((tone) => tone.toneType === 'ringtone'),
)
const notificationTones = computed(() =>
admin.customTones.filter((tone) => tone.toneType === 'notification'),
)
const saving = computed(() => admin.actionKey === 'custom-tone:create')
const canSave = computed(
() =>
!props.disabled &&
!processingFile.value &&
!saving.value &&
label.value.trim().length >= 1 &&
label.value.trim().length <= 64 &&
!!selectedFile.value &&
!!selectedMimeType.value &&
selectedDurationMs.value >= 250 &&
selectedDurationMs.value <= MAX_DURATION_MS,
)
function t(key: string, params?: Record<string, string>): string {
return phone.t(`AdminPanel.configurator.customTones.${key}`, params)
}
function errorText(error?: string): string {
const key = error || admin.error || 'request_failed'
const translated = phone.t(`AdminPanel.errors.${key}`)
return translated === `AdminPanel.errors.${key}`
? phone.t('AdminPanel.errors.default')
: translated
}
function normalizedMimeType(file: File): string {
const aliases: Record<string, string> = {
'audio/mp3': 'audio/mpeg',
'audio/x-wav': 'audio/wav',
}
const browserType = file.type.toLocaleLowerCase().split(';')[0]
if (aliases[browserType]) return aliases[browserType]
if (
['audio/mpeg', 'audio/ogg', 'audio/wav', 'audio/webm'].includes(browserType)
) {
return browserType
}
const extension = file.name.split('.').pop()?.toLocaleLowerCase()
return (
{
mp3: 'audio/mpeg',
ogg: 'audio/ogg',
wav: 'audio/wav',
webm: 'audio/webm',
}[extension ?? ''] ?? ''
)
}
function audioDuration(url: string): Promise<number> {
return new Promise((resolve, reject) => {
const audio = new Audio(url)
audio.preload = 'metadata'
let settled = false
const finish = (duration?: number): void => {
if (settled) return
settled = true
window.clearTimeout(timeout)
audio.removeEventListener('loadedmetadata', handleMetadata)
audio.removeEventListener('error', handleError)
audio.removeAttribute('src')
audio.load()
if (duration !== undefined && Number.isFinite(duration)) {
resolve(Math.round(duration * 1000))
} else {
reject(new Error('invalid_audio'))
}
}
const handleMetadata = (): void => finish(audio.duration)
const handleError = (): void => finish()
const timeout = window.setTimeout(finish, AUDIO_METADATA_TIMEOUT_MS)
audio.addEventListener('loadedmetadata', handleMetadata, { once: true })
audio.addEventListener('error', handleError, { once: true })
})
}
function resetFileInput(target = fileInput.value): void {
if (target) target.value = ''
fileInputResetKey.value += 1
}
function releaseSelectedFile(resetInput = true): void {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
selectedFile.value = null
selectedMimeType.value = ''
selectedDurationMs.value = 0
if (resetInput) resetFileInput()
}
async function chooseFile(event: Event): Promise<void> {
const target = event.target
if (!(target instanceof HTMLInputElement)) return
const file = target.files?.[0]
releaseSelectedFile(false)
resetFileInput(target)
if (!file) return
const mimeType = normalizedMimeType(file)
if (!mimeType) {
emit('toast', t('errors.type'), 'error')
return
}
if (file.size < 1 || file.size > MAX_CUSTOM_TONE_BYTES) {
emit('toast', t('errors.size'), 'error')
return
}
processingFile.value = true
const url = URL.createObjectURL(file)
try {
const durationMs = await audioDuration(url)
if (durationMs < 250 || durationMs > MAX_DURATION_MS) {
URL.revokeObjectURL(url)
emit('toast', t('errors.duration'), 'error')
return
}
selectedFile.value = file
selectedMimeType.value = mimeType
selectedDurationMs.value = durationMs
previewUrl.value = url
if (!label.value.trim()) {
label.value = file.name.replace(/\.[^.]+$/, '').slice(0, 64)
}
} catch (error) {
URL.revokeObjectURL(url)
console.error('[Phone admin] Could not read the selected tone.', error)
emit('toast', t('errors.invalid'), 'error')
} finally {
processingFile.value = false
}
}
function fileBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.addEventListener('load', () => {
if (typeof reader.result !== 'string') {
reject(new Error('invalid_audio'))
return
}
const delimiter = reader.result.indexOf(',')
if (delimiter < 0) {
reject(new Error('invalid_audio'))
return
}
resolve(reader.result.slice(delimiter + 1))
})
reader.addEventListener('error', () => reject(reader.error))
reader.readAsDataURL(file)
})
}
function stopActivePreview(): void {
stopPreview?.()
stopPreview = null
playingToneId.value = ''
if (previewTimer !== undefined) window.clearTimeout(previewTimer)
previewTimer = undefined
}
function previewSelected(): void {
if (!previewUrl.value) return
if (playingToneId.value === 'selected') {
stopActivePreview()
return
}
stopActivePreview()
const player = new Audio(previewUrl.value)
player.volume = 0.75
player.addEventListener('ended', stopActivePreview, { once: true })
void player.play().catch((error: unknown) => {
console.error('[Phone admin] Could not preview selected tone.', error)
emit('toast', t('errors.playback'), 'error')
})
playingToneId.value = 'selected'
stopPreview = () => {
player.pause()
player.currentTime = 0
}
}
function previewStored(tone: AdminCustomTone): void {
if (playingToneId.value === tone.id) {
stopActivePreview()
return
}
stopActivePreview()
playingToneId.value = tone.id
stopPreview = playCustomPhoneTone(
{
...tone,
id: `custom:${tone.id}`,
},
75,
false,
{
onError: () => {
if (playingToneId.value !== tone.id) return
stopActivePreview()
emit('toast', t('errors.playback'), 'error')
},
onStarted: () => {
if (playingToneId.value !== tone.id) return
if (previewTimer !== undefined) window.clearTimeout(previewTimer)
previewTimer = window.setTimeout(
stopActivePreview,
Math.min(MAX_DURATION_MS, tone.durationMs) + 750,
)
},
},
)
}
async function saveTone(): Promise<void> {
const file = selectedFile.value
const name = label.value.trim()
if (!canSave.value || !file) return
try {
const payload = await fileBase64(file)
const response = await admin.createCustomTone({
durationMs: selectedDurationMs.value,
label: name,
mimeType: selectedMimeType.value,
payload,
toneType: toneType.value,
})
if (!response.success) {
emit('toast', errorText(response.error), 'error')
return
}
label.value = ''
releaseSelectedFile()
emit('toast', t('saved'), 'success')
} catch (error) {
console.error('[Phone admin] Could not prepare the custom tone.', error)
emit('toast', t('errors.invalid'), 'error')
}
}
async function deleteTone(tone: AdminCustomTone): Promise<void> {
if (pendingDeleteId.value !== tone.id) {
pendingDeleteId.value = tone.id
return
}
stopActivePreview()
const response = await admin.deleteCustomTone(tone.id)
pendingDeleteId.value = ''
if (!response.success) {
emit('toast', errorText(response.error), 'error')
return
}
emit('toast', t('deleted'), 'success')
}
function formatDuration(durationMs: number): string {
return `${(durationMs / 1000).toLocaleString(phone.lang, {
maximumFractionDigits: 1,
})} s`
}
function formatBytes(byteSize: number): string {
return `${Math.ceil(byteSize / 1024).toLocaleString(phone.lang)} KB`
}
onBeforeUnmount(() => {
stopActivePreview()
releaseSelectedFile(false)
})
</script>
<template>
<section class="admin-custom-tones">
<article class="admin-custom-tones__config-note">
<FileCode2 :size="18" />
<div>
<strong>{{ t('configTitle') }}</strong>
<p>{{ t('configBody') }}</p>
<code>Config.CustomTones · config/custom_tones/</code>
</div>
</article>
<div class="admin-custom-tones__form">
<label>
<span>{{ t('name') }}</span>
<input
v-model="label"
type="text"
maxlength="64"
autocomplete="off"
:disabled="disabled || saving"
:placeholder="t('namePlaceholder')"
/>
</label>
<fieldset :disabled="disabled || saving">
<legend>{{ t('category') }}</legend>
<button
type="button"
:class="{ 'is-active': toneType === 'ringtone' }"
@click="toneType = 'ringtone'"
>
{{ t('ringtone') }}
</button>
<button
type="button"
:class="{ 'is-active': toneType === 'notification' }"
@click="toneType = 'notification'"
>
{{ t('notification') }}
</button>
</fieldset>
<label
class="admin-custom-tones__picker"
:class="{
'is-disabled': disabled || saving || processingFile,
'has-file': selectedFile,
}"
>
<input
:key="fileInputResetKey"
ref="fileInput"
class="admin-custom-tones__file-input"
type="file"
accept=".mp3,.ogg,.wav,.webm,audio/mpeg,audio/ogg,audio/wav,audio/webm"
:disabled="disabled || saving || processingFile"
@change="chooseFile"
/>
<LoaderCircle v-if="processingFile" :size="20" class="is-spinning" />
<Upload v-else :size="20" />
<span>
<strong>{{ selectedFile?.name ?? t('chooseFile') }}</strong>
<small>
{{
selectedFile
? `${formatDuration(selectedDurationMs)} · ${formatBytes(selectedFile.size)}`
: t('fileHint')
}}
</small>
</span>
</label>
<div class="admin-custom-tones__actions">
<SkyButton
small
variant="secondary"
:disabled="!previewUrl || saving"
@click="previewSelected"
>
<Pause v-if="playingToneId === 'selected'" :size="17" />
<Play v-else :size="17" />
{{ t('preview') }}
</SkyButton>
<SkyButton small :disabled="!canSave" @click="saveTone">
<LoaderCircle v-if="saving" :size="17" class="is-spinning" />
<Plus v-else :size="17" />
{{ t('add') }}
</SkyButton>
</div>
</div>
<div v-if="admin.customTonesLoading" class="admin-custom-tones__loading">
<LoaderCircle :size="22" class="is-spinning" />
{{ t('loading') }}
</div>
<div v-else class="admin-custom-tones__catalog">
<section
v-for="group in [
{ key: 'ringtone', label: t('ringtones'), tones: ringtoneTones },
{
key: 'notification',
label: t('notifications'),
tones: notificationTones,
},
]"
:key="group.key"
>
<header>
<strong>{{ group.label }}</strong>
<span>{{ group.tones.length }}/32</span>
</header>
<p v-if="!group.tones.length" class="admin-custom-tones__empty">
{{ t('empty') }}
</p>
<article v-for="tone in group.tones" :key="tone.id">
<span class="admin-custom-tones__tone-icon"
><FileAudio :size="18"
/></span>
<span class="admin-custom-tones__tone-copy">
<strong>{{ tone.label }}</strong>
<small>
{{ formatDuration(tone.durationMs) }} ·
{{ formatBytes(tone.byteSize) }} ·
{{
tone.source === 'config' ? t('configSource') : tone.createdBy
}}
</small>
</span>
<button
type="button"
:aria-label="t('preview')"
@click="previewStored(tone)"
>
<Pause v-if="playingToneId === tone.id" :size="17" />
<Play v-else :size="17" />
</button>
<button
v-if="tone.source === 'database'"
type="button"
class="is-danger"
:class="{ 'is-confirming': pendingDeleteId === tone.id }"
:aria-label="
pendingDeleteId === tone.id ? t('confirmDelete') : t('delete')
"
:disabled="admin.actionKey === `custom-tone:delete:${tone.id}`"
@click="deleteTone(tone)"
>
<LoaderCircle
v-if="admin.actionKey === `custom-tone:delete:${tone.id}`"
:size="17"
class="is-spinning"
/>
<Trash2 v-else :size="17" />
</button>
<span
v-else
class="admin-custom-tones__locked"
:title="t('configManaged')"
:aria-label="t('configManaged')"
>
<LockKeyhole :size="15" />
</span>
</article>
</section>
</div>
</section>
</template>
<style scoped>
.admin-custom-tones {
--sky-app-accent: var(--admin-green, #5ccb70);
--sky-button-text: #fff;
--sky-surface-muted: #1b1e1b;
--sky-text: #fff;
display: grid;
gap: 8px;
color: var(--admin-text, #f0f3f0);
}
.admin-custom-tones__config-note {
display: grid;
grid-template-columns: 26px minmax(0, 1fr);
align-items: start;
gap: 9px;
padding: 10px 11px;
border-radius: 3px;
background: linear-gradient(90deg, rgb(0 184 228 / 11%), transparent 82%);
}
.admin-custom-tones__config-note > svg {
color: var(--admin-accent, #00b8e4);
}
.admin-custom-tones__config-note div {
display: grid;
gap: 3px;
}
.admin-custom-tones__config-note strong {
font-size: 10px;
}
.admin-custom-tones__config-note p {
margin: 0;
color: var(--admin-muted, #818781);
font-size: 9px;
line-height: 1.45;
}
.admin-custom-tones__config-note code {
width: fit-content;
margin-top: 2px;
padding: 3px 5px;
border-radius: 3px;
color: #8edcf0;
background: rgb(0 0 0 / 25%);
font-size: 8px;
}
.admin-custom-tones__tone-icon {
display: grid;
place-items: center;
flex: 0 0 40px;
width: 40px;
height: 40px;
border-radius: 10px;
background: rgb(0 194 255 / 10%);
color: #14c9ff;
}
.admin-custom-tones__empty {
margin: 4px 0 0;
color: #8f9994;
font-size: 12px;
line-height: 1.5;
}
.admin-custom-tones__form {
display: grid;
grid-template-columns: minmax(220px, 1fr) minmax(230px, 0.8fr);
gap: 8px;
padding: 10px;
border: 1px solid var(--admin-border, rgb(255 255 255 / 5%));
border-radius: 3px;
background: var(--admin-panel-raised, #131514);
}
.admin-custom-tones__form label,
.admin-custom-tones__form fieldset {
display: grid;
gap: 7px;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.admin-custom-tones__form label > span,
.admin-custom-tones__form legend {
color: #aab3af;
font-size: 8px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.admin-custom-tones__form input[type='text'] {
min-height: 36px;
padding: 0 10px;
border: 1px solid var(--admin-border-strong, rgb(255 255 255 / 9%));
border-radius: 4px;
outline: none;
background: #1b1e1b;
color: #fff;
font-size: 10px;
}
.admin-custom-tones__form input[type='text']:focus {
border-color: #14c9ff;
box-shadow: 0 0 0 2px rgb(20 201 255 / 14%);
}
.admin-custom-tones__form fieldset {
grid-template-columns: 1fr 1fr;
}
.admin-custom-tones__form legend {
grid-column: 1 / -1;
}
.admin-custom-tones__form fieldset button {
min-height: 36px;
border: 1px solid var(--admin-border-strong, rgb(255 255 255 / 9%));
border-radius: 4px;
background: #1b1e1b;
color: #b7c0bc;
font-size: 10px;
font-weight: 700;
}
.admin-custom-tones__form fieldset button.is-active {
border-color: #14c9ff;
background: rgb(20 201 255 / 12%);
color: #fff;
}
.admin-custom-tones__file-input {
position: absolute;
z-index: 3;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
}
.admin-custom-tones__form .admin-custom-tones__picker {
position: relative;
display: flex;
grid-column: 1 / -1;
gap: 12px;
align-items: center;
min-height: 52px;
padding: 8px 11px;
border: 1px dashed var(--admin-border-strong, rgb(255 255 255 / 9%));
border-radius: 4px;
background: #111311;
color: #dfe7e3;
text-align: left;
}
.admin-custom-tones__picker:focus-within,
.admin-custom-tones__picker:hover:not(.is-disabled) {
border-color: var(--admin-accent, #00b8e4);
background: rgb(0 184 228 / 6%);
}
.admin-custom-tones__picker.is-disabled {
opacity: 0.45;
}
.admin-custom-tones__picker > span,
.admin-custom-tones__tone-copy {
display: grid;
gap: 3px;
min-width: 0;
}
.admin-custom-tones__picker strong,
.admin-custom-tones__tone-copy strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 10px;
}
.admin-custom-tones__picker small,
.admin-custom-tones__tone-copy small {
color: #89938e;
font-size: 8px;
}
.admin-custom-tones__actions {
display: flex;
grid-column: 1 / -1;
gap: 10px;
justify-content: flex-end;
}
.admin-custom-tones__actions :deep(.sky-button) {
flex: 1 1 0;
border-color: var(--admin-border-strong, rgb(255 255 255 / 9%));
font-size: 10px;
}
.admin-custom-tones__catalog {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.admin-custom-tones__catalog > section {
overflow: hidden;
border: 1px solid rgb(255 255 255 / 7%);
border-radius: 3px;
background: #111311;
}
.admin-custom-tones__catalog > section > header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 44px;
padding: 0 10px;
border-bottom: 1px solid rgb(255 255 255 / 7%);
}
.admin-custom-tones__catalog > section > header span {
color: #82908a;
font-size: 11px;
}
.admin-custom-tones__catalog > section > header strong {
font-size: 10px;
}
.admin-custom-tones__catalog article {
display: flex;
gap: 10px;
align-items: center;
min-height: 54px;
padding: 8px 10px;
border-bottom: 1px solid rgb(255 255 255 / 5%);
}
.admin-custom-tones__catalog article:last-child {
border-bottom: 0;
}
.admin-custom-tones__tone-icon {
flex-basis: 30px;
width: 30px;
height: 30px;
border-radius: 5px;
}
.admin-custom-tones__tone-copy {
flex: 1;
}
.admin-custom-tones__catalog article > button {
display: grid;
place-items: center;
flex: 0 0 32px;
width: 32px;
height: 32px;
border: 1px solid var(--admin-border-strong, rgb(255 255 255 / 9%));
border-radius: 4px;
background: #151a18;
color: #dfe7e3;
}
.admin-custom-tones__locked {
display: grid;
place-items: center;
flex: 0 0 32px;
width: 32px;
height: 32px;
color: var(--admin-dim, #555b55);
}
.admin-custom-tones__catalog article > button.is-danger {
color: #ff6875;
}
.admin-custom-tones__catalog article > button.is-confirming {
border-color: #ff5263;
background: rgb(255 82 99 / 14%);
}
.admin-custom-tones__empty,
.admin-custom-tones__loading {
padding: 18px 13px;
}
.admin-custom-tones__loading {
display: flex;
gap: 10px;
align-items: center;
color: #98a39e;
}
.is-spinning {
animation: admin-custom-tone-spin 0.8s linear infinite;
}
@keyframes admin-custom-tone-spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 900px) {
.admin-custom-tones__form,
.admin-custom-tones__catalog {
grid-template-columns: 1fr;
}
}
</style>
@@ -54,6 +54,14 @@ 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',
@@ -69,6 +77,17 @@ 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',
@@ -125,6 +144,7 @@ describe('standalone admin panel contracts', () => {
'messages',
'calls',
'moderation',
'tones',
'audit',
'configurator',
]) {
@@ -133,24 +153,23 @@ describe('standalone admin panel contracts', () => {
}
})
it('removes manual reload and applies a persistent global accent choice', () => {
it('uses a fixed blue accent and replaces appearance controls with live statistics', () => {
expect(source).not.toContain('<RefreshCw')
expect(source).not.toContain("kind: 'refresh'")
expect(source).toContain("'sky-phone-admin-accent'")
expect(source).toContain("'sky-phone-admin-font-family'")
expect(source).toContain("'sky-phone-admin-font-size'")
expect(source).toContain("'--admin-accent': accentColor")
expect(source).toContain("'--admin-font-family': activeAdminFontFamily")
expect(source).toContain(
"'--admin-font-scale': String(adminFontSize / 100)",
)
expect(source).toContain('class="admin-panel-color-value"')
expect(source).toContain('class="admin-panel-rgb-fields"')
expect(source).toContain('class="admin-panel-rgb-range"')
expect(source).toContain('class="admin-panel-font-select"')
expect(source).toContain('class="admin-panel-font-select__menu"')
expect(source).toContain('class="admin-panel-font-size-control"')
expect(source).not.toContain('type="color"')
expect(source).not.toContain("'sky-phone-admin-accent'")
expect(source).not.toContain("'sky-phone-admin-font-family'")
expect(source).not.toContain("'sky-phone-admin-font-size'")
expect(source).not.toContain("t('appearance.")
expect(source).toContain('--admin-accent: #00b8e4')
expect(source).toContain('class="admin-panel-statistics-layout"')
expect(source).toContain('class="admin-panel-activity-grid"')
expect(source).toContain('class="admin-panel-coverage-list"')
expect(source).toContain('statisticsTimer = window.setInterval')
expect(source).toContain('15_000')
expect(server).toContain('AS `messages_today`')
expect(server).toContain('AS `calls_today`')
expect(server).toContain('AS `active_devices`')
expect(server).toContain('auditEntries = tonumber(stats.audit_entries)')
expect(source).toContain('color-mix(in srgb, var(--admin-green)')
expect(source).toContain('--admin-toggle-on: #63d471')
expect(source).toContain('background: var(--admin-toggle-on)')
@@ -170,6 +189,22 @@ 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 [
@@ -216,12 +251,14 @@ describe('standalone admin panel contracts', () => {
it('authorizes every server request without requiring a phone session', () => {
expect(server).not.toContain('SkyPhone.RequireSession(source)')
expect(server).toContain(
'Bridge.Framework.HasAdminGroup(source, Config.AdminPanel.AdminGroups)',
'Bridge.Framework.HasPermission(source, "phonepanel")',
)
expect(server).toContain('Config.AdminPanel.ReadRequestsPerMinute')
expect(server).toContain('Config.AdminPanel.ActionRequestsPerMinute')
expect(server).toContain('Config.AdminPanel.CredentialRevealsPerMinute')
expect(config).toContain('Config.AdminPanel = {')
expect(config).toContain('Config.CommandPermissions = {')
expect(config).not.toContain('AdminGroups =')
})
it('opens directly from the configurable command with dedicated focus', () => {
@@ -272,6 +309,12 @@ 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'"))
@@ -281,11 +324,36 @@ describe('standalone admin panel contracts', () => {
expect(configuratorServer).toContain('AND `revision` = ?')
expect(configuratorServer).toContain('configurator_enabled')
expect(configuratorServer).toMatch(
/for key, value in pairs\(Config\)[\s\S]*?key ~= "Media"[\s\S]*?key ~= "PhoneConfigurator"/,
/for key, value in pairs\(ConfigDefaults\)[\s\S]*?key ~= "Media"[\s\S]*?key ~= "PhoneConfigurator"/,
)
expect(configuratorServer).toContain(
'default_media = serialize_value(Config.Media)',
'default_media = serialize_value(ConfigDefaults.Media)',
)
expect(configDefault).toContain(
'GENERATED by frontend/build.cjs from config/config.lua and config/media.lua',
)
expect(configDefault).toContain('local realConfig = Config')
expect(configDefault).toContain('ConfigDefaults = Config')
expect(configDefault.replace(/\r\n?/g, '\n').trimEnd()).toBe(
[
'-- GENERATED by frontend/build.cjs from config/config.lua and config/media.lua - do not edit.',
'-- Escrowed snapshot of the shipped defaults used by the Phone Configurator.',
'local realConfig = Config',
'Config = {}',
'',
configDefaultsSource.replace(/\r\n?/g, '\n').trimEnd(),
'',
mediaConfig.replace(/\r\n?/g, '\n').trimEnd(),
'',
'ConfigDefaults = Config',
'Config = realConfig',
].join('\n'),
)
expect(frontendBuild).toContain("readLuaSource('config', 'config.lua')")
expect(frontendBuild).toContain("readLuaSource('config', 'media.lua')")
expect(frontendBuild).toContain("'config_default.lua'")
expect(configDefault).not.toContain('Config.PhoneConfigurator')
expect(configDefault).not.toContain('Config.CommandPermissions')
expect(configuratorServer).toContain(
'build_sections("config", stored_config',
)
@@ -373,6 +441,9 @@ describe('standalone admin panel contracts', () => {
expect(source).toContain("selectConfiguratorScope('media')")
expect(source).not.toContain('class="admin-panel-config-meta"')
expect(configuratorValueEditor).toContain('function addListRow()')
expect(configuratorValueEditor).toMatch(
/const value = listTemplate\.value\s+\? blankFromConfiguratorStructure\(listTemplate\.value\)\s+: rows\.length\s+\? blankLike\(rows\[0\]\)/,
)
expect(configuratorValueEditor).toContain('function addTableField()')
expect(configuratorValueEditor).toContain(
'const canExtendTable = computed(',
@@ -430,7 +501,8 @@ describe('standalone admin panel contracts', () => {
expect(configuratorValueEditor).toContain('function tableFieldStructure(')
expect(configuratorValueEditor).toContain('function isFixedTableField(')
expect(configuratorValueEditor).toContain('function blankCollectionValue(')
expect(configuratorValueEditor).toContain('function blankFromStructure(')
expect(configuratorValueEditor).toContain('blankFromConfiguratorStructure')
expect(configuratorValueEditor).toContain('createMutableTableEntry(')
expect(source).toContain("'is-structured': field.type === 'json'")
expect(configuratorValueEditor).toContain('function isStructuredValue(')
expect(configuratorValueEditor).toContain(
File diff suppressed because it is too large Load Diff
@@ -24,4 +24,13 @@ describe('EasyShareSheet Sky UI contract', () => {
/\.easyshare-history\s*\{[^}]*overflow:\s*hidden[^}]*background:\s*var\(--easyshare-list-surface\)/s,
)
})
it('only exposes and opens installed share destinations', () => {
expect(source).toContain("if (appStore.isInstalled('flare'))")
expect(source).toContain("if (appStore.isInstalled('darkchat'))")
expect(source).toContain('.filter((id) => appStore.isInstalled(id))')
expect(source).toContain('if (!appStore.isInstalled(kind)) return')
expect(source).toContain('if (!appStore.isInstalled(appId)) return')
expect(source).not.toContain('appStore.homeLayout.hidden.includes')
})
})
+6 -3
View File
@@ -67,7 +67,7 @@ const sharePeople = computed(() => {
}> = []
const phoneNumbers = new Set<string>()
if (!appStore.homeLayout.hidden.includes('flare')) {
if (appStore.isInstalled('flare')) {
for (const match of flare.matches) {
people.push({
avatar: match.profile.photoUrls[0],
@@ -102,7 +102,7 @@ const sharePeople = computed(() => {
phoneNumbers.add(contact.phone_number)
}
if (!appStore.homeLayout.hidden.includes('darkchat')) {
if (appStore.isInstalled('darkchat')) {
for (const conversation of darkChat.conversations.slice(0, 8)) {
people.push({
kind: 'darkchat',
@@ -116,7 +116,7 @@ const sharePeople = computed(() => {
})
const shareApps = computed(() =>
(easyShare.payload ? easyShareDestinationAppIds(easyShare.payload) : [])
.filter((id) => !appStore.homeLayout.hidden.includes(id))
.filter((id) => appStore.isInstalled(id))
.flatMap((id) => {
const app = getPhoneApp(id)
return app ? [{ app, id }] : []
@@ -195,18 +195,21 @@ function endDrag(event: PointerEvent): void {
}
function shareToChat(kind: EasyShareChatApp, targetId: string): void {
if (!appStore.isInstalled(kind)) return
if (!easyShare.prepareChatDraft(kind, targetId)) return
close()
void router.push(`/apps/${kind}`)
}
function openChatApp(kind: EasyShareChatApp): void {
if (!appStore.isInstalled(kind)) return
if (!easyShare.prepareChatDraft(kind)) return
close()
void router.push(`/apps/${kind}`)
}
function openShareApp(appId: EasyShareDestinationApp): void {
if (!appStore.isInstalled(appId)) return
if (appId === 'messages' || appId === 'darkchat' || appId === 'flare') {
openChatApp(appId)
return
@@ -26,6 +26,10 @@ 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', () => {
@@ -36,7 +40,7 @@ describe('Home folder interaction contract', () => {
expect(iconSource).not.toContain('props.editMode || suppressClick.value')
})
it('keeps folder app dragging aligned at every phone scale', () => {
it('keeps whole-folder 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')")
@@ -47,6 +51,60 @@ 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')
@@ -63,8 +121,9 @@ 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 (draggingIndex.value === null || draggingOutside.value) return',
'if (!draggingOutside.value && !isPointerInsidePanel(event))',
)
expect(overlaySource).toContain(
'draggingOutside.value || !isPointerInsidePanel(event)',
+92 -26
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Check, Pencil, X } from 'lucide-vue-next'
import { computed, nextTick, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { usePhoneStore } from '@/stores/phone'
@@ -11,20 +11,35 @@ 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 = defineProps<{
apps: FolderAppEntry[]
editMode: boolean
folder: HomeFolder
renameOnOpen: boolean
}>()
const props = withDefaults(
defineProps<{
apps: FolderAppEntry[]
editMode: boolean
externalDragVisual?: boolean
folder: HomeFolder
renameOnOpen: boolean
}>(),
{
externalDragVisual: false,
},
)
const emit = defineEmits<{
close: []
dragcancel: []
dragmove: [event: PointerEvent]
dragstart: [index: number, event: PointerEvent]
'drag-outside-change': [outside: boolean]
edit: []
extract: [index: number, event: PointerEvent]
@@ -43,6 +58,7 @@ 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'),
@@ -113,25 +129,48 @@ function saveRename(): void {
renameOpened.value = false
}
function startFolderAppDrag(index: number): void {
function startFolderAppDrag(index: number, event: PointerEvent): 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 panelBounds = panelElement.value?.getBoundingClientRect()
if (!panelBounds) return false
const panel = panelElement.value
return (
event.clientX >= panelBounds.left &&
event.clientX <= panelBounds.right &&
event.clientY >= panelBounds.top &&
event.clientY <= panelBounds.bottom
panel !== null &&
phoneViewportRectContainsPoint(
folderDragViewportRect(panel),
event.clientX,
event.clientY,
)
)
}
function moveFolderAppDrag(event: PointerEvent): void {
if (draggingIndex.value === null || draggingOutside.value) return
if (!isPointerInsidePanel(event)) setDraggingOutside(true)
if (draggingIndex.value === null) return
if (!draggingOutside.value && !isPointerInsidePanel(event)) {
setDraggingOutside(true)
}
emit('dragmove', event)
}
function setDraggingOutside(outside: boolean): void {
@@ -145,31 +184,52 @@ 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 = 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 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 targetIndex = Number(target.dataset.folderAppIndex)
if (Number.isInteger(targetIndex)) emit('move', sourceIndex, targetIndex)
dragGeometry = null
if (!Number.isInteger(targetIndex)) {
emit('dragcancel')
return
}
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 {
@@ -195,6 +255,11 @@ function finishPageSwipe(event: PointerEvent): void {
}
pagePointerId = null
}
onBeforeUnmount(() => {
stopFolderAppDrag()
pagePointerId = null
})
</script>
<template>
@@ -290,10 +355,11 @@ function finishPageSwipe(event: PointerEvent): void {
: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)"
@dragstart="startFolderAppDrag(entry.index, $event)"
@edit="emit('edit')"
/>
</div>
@@ -61,16 +61,22 @@ const passcodeError = ref('')
const passcodeLength = ref<4 | 6>(phone.security.length === 4 ? 4 : 6)
const notificationsEnabled = ref(true)
const notificationSounds = ref(true)
const selectedApps = ref<BuiltinPhoneAppId[]>(['banking', 'garage', 'skyride'])
const selectedApps = ref<BuiltinPhoneAppId[]>(
(['banking', 'garage', 'skyride'] as const).filter((id) =>
appStore.isAvailable(id),
),
)
const setupCompleteBusy = ref(false)
const setupCompleteError = ref('')
const setupApps = (
['banking', 'garage', 'skyride', 'citymarkt', 'picstagram', 'snake'] as const
).flatMap((id) => {
const app = getPhoneApp(id)
return app ? [app] : []
})
const setupApps = computed(() =>
(
['banking', 'garage', 'skyride', 'citymarkt', 'picstagram', 'snake'] as const
).flatMap((id) => {
const app = getPhoneApp(id)
return app && appStore.isAvailable(id) ? [app] : []
}),
)
const wallpaperChoices = WALLPAPER_IDS
const progress = computed(() => `${((step.value + 1) / 10) * 100}%`)
const displayName = computed(() => {
+117
View File
@@ -0,0 +1,117 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const resource = (path: string) =>
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8')
const customTonesServer = resource('source/server/custom_tones.lua')
const databaseMigration = resource('source/server/db_migrate.lua')
const clientMain = resource('source/client/main.lua')
const clientTones = resource('source/client/custom_tones.lua')
const manifest = resource('fxmanifest.lua')
const nuiServerBridge = resource('source/client/nui_server_bridge.lua')
const settingsSource = readFileSync(
new URL('./views/apps/SettingsApp.vue', import.meta.url),
'utf8',
)
const adminManager = readFileSync(
new URL('./components/AdminCustomToneManager.vue', import.meta.url),
'utf8',
)
const adminStore = readFileSync(
new URL('./stores/admin.ts', import.meta.url),
'utf8',
)
const adminPanel = readFileSync(
new URL('./components/AdminPanel.vue', import.meta.url),
'utf8',
)
const config = resource('config/config.lua')
const configurator = resource('source/server/phone_configurator.lua')
describe('custom phone tone contract', () => {
it('stores bounded audio payloads in a phone-owned table', () => {
expect(databaseMigration).toContain('name = "sky_phone_custom_tones"')
expect(databaseMigration).toContain(
'{ name = "audio_payload", type = "MEDIUMTEXT NOT NULL"',
)
expect(customTonesServer).toContain('local MAX_TONES_PER_TYPE = 32')
expect(customTonesServer).toContain('local MAX_AUDIO_BYTES = 2000000')
expect(customTonesServer).toContain('local MAX_TRANSFER_CHUNK_CHARS = 8000')
expect(customTonesServer).toContain('local MAX_TRANSFER_CHUNKS = 334')
expect(customTonesServer).toContain('local MAX_DURATION_MS = 30000')
expect(customTonesServer).toContain('decoded_base64_size')
expect(customTonesServer).toContain('valid_audio_signature')
})
it('keeps creation and deletion behind server-side admin authorization', () => {
const adminServer = resource('source/server/admin.lua')
expect(adminServer).toMatch(
/^Bridge\.Database\.AfterMigration\("sky_phone", function\(\)/,
)
expect(adminServer.trimEnd()).toMatch(/end\)\r?\nend\)$/)
expect(adminServer).toContain('"sky_phone:admin:tone-upload-start"')
expect(adminServer).toContain('"sky_phone:admin:tone-upload-chunk"')
expect(adminServer).toContain(
'math.max(400, tonumber(Config.AdminPanel.ActionRequestsPerMinute) or 0)',
)
expect(adminServer).toContain('"sky_phone:admin:tone-upload-finish"')
expect(adminServer).toContain('"sky_phone:admin:delete-tone"')
expect(adminServer).toContain('require_admin(')
expect(adminServer).toContain('"create_custom_tone"')
expect(adminServer).toContain('"delete_custom_tone"')
})
it('loads metadata on readiness and audio only when it is played', () => {
expect(clientMain).toContain(
'Bridge.Callbacks.Trigger("sky_phone:tones:list"',
)
expect(clientMain).toContain('type = "phone:tones"')
expect(customTonesServer).toContain('TriggerLatentClientEvent(')
expect(customTonesServer).toContain('"sky_phone:tones:audio-request"')
expect(customTonesServer).toContain('AUDIO_TRANSFER_BYTES_PER_SECOND')
expect(clientTones).toContain('RegisterNUICallback("tones:audio"')
expect(clientTones).toContain('"sky_phone:tones:audio-response"')
expect(clientTones).toContain('AUDIO_TRANSFER_TIMEOUT_MS')
expect(manifest).toContain("'source/client/custom_tones.lua'")
expect(nuiServerBridge).not.toContain('audio-start')
expect(nuiServerBridge).not.toContain('audio-chunk')
expect(customTonesServer).toContain(
'SELECT `id`, `mime_type`, `audio_payload`',
)
})
it('offers direct local-file management without a URL field', () => {
expect(adminManager).toContain('type="file"')
expect(adminManager).toContain('readAsDataURL(file)')
expect(adminManager).not.toContain('type="url"')
expect(adminManager).not.toContain('https://')
expect(adminManager).not.toContain('fileInput.value?.click()')
expect(adminManager).toContain(':key="fileInputResetKey"')
expect(adminManager).toContain('AUDIO_METADATA_TIMEOUT_MS')
expect(adminManager).toContain('onStarted: () =>')
expect(adminManager).toContain("t('errors.playback')")
expect(adminStore).toContain('finally {')
expect(adminStore).toContain("this.actionKey = ''")
expect(adminStore).toContain('cacheCustomPhoneTonePayload(')
expect(adminPanel).toContain("| 'tones'")
expect(adminPanel).toContain("selectTab('tones')")
})
it('supports URL-free file configuration outside the phone panel', () => {
expect(config).toContain('Config.CustomTones = {')
expect(config).toContain('config/custom_tones/')
expect(customTonesServer).toContain('LoadResourceFile(')
expect(configurator).toContain('CustomTones = true')
expect(configurator).toContain('and key ~= "CustomTones"')
})
it('shows built-in and custom choices in both sound categories', () => {
expect(settingsSource).toContain('phone.customTones.ringtones.map')
expect(settingsSource).toContain('phone.customTones.notificationSounds.map')
expect(settingsSource).toContain('v-for="ringtone in ringtoneChoices"')
expect(settingsSource).toContain(
'v-for="sound in notificationSoundChoices"',
)
})
})
@@ -0,0 +1,63 @@
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')
describe('housing provider contracts', () => {
it('registers TGIANN House with server-authorized owner properties', () => {
const adapter = readResourceFile('source/bridge/server/housing/tgiann.lua')
expect(adapter).toContain('local resource_name = "tgiann-house"')
expect(adapter).toContain('FROM `tgiann_house`')
expect(adapter).toContain('WHERE `owner` = ?')
expect(adapter).toContain('Bridge.Framework.GetIdentifier(source)')
expect(adapter).toContain(
'Bridge.Housing.RegisterProvider(provider_name, {',
)
expect(adapter).toContain('return nil, "property_access_denied"')
expect(adapter).not.toContain('`houseKeys`')
expect(adapter).not.toContain('tgiann-house:getPlayerHouses')
})
it('uses the published client export for entrance waypoints only', () => {
const adapter = readResourceFile('source/bridge/client/housing/tgiann.lua')
expect(adapter).toContain(
'return exports[resource_name]:getHouseData(house)',
)
expect(adapter).toContain(
'Bridge.Normalize.Coordinates(house_data.doorCoord)',
)
expect(adapter).toContain(
'Bridge.Housing.RegisterClientProvider(provider_name, {',
)
expect(adapter).toContain('SetNewWaypoint(')
expect(adapter).not.toContain('enterHouse(')
expect(adapter).not.toContain('forceOpenDoorHouse')
})
it('advertises TGIANN in housing configuration and documentation', () => {
const config = readResourceFile('config/config.lua')
const readme = readFileSync(
new URL('../../README.md', import.meta.url),
'utf8',
)
expect(config).toContain(
'"rtx", "quasar", "tgiann", "vms", "rx", "nolag", "sn"',
)
expect(readme).toContain('Quasar Housing, TGIANN House, VMS Housing')
expect(readme).toContain('`quasar`, `tgiann`, `vms`')
expect(readme).toContain(
'expects `tgiann-core` to start before `tgiann-house`',
)
expect(readme).toContain(
'lists server-authorized owner properties, and supports entrance waypoints',
)
})
})
+93
View File
@@ -0,0 +1,93 @@
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('Config.CustomTones')
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" and key ~= "CustomTones" then',
)
expect(configuratorFixture).toContain('delete config.CommandPermissions')
expect(configuratorFixture).toContain('delete config.CustomTones')
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 Config.PhoneConfigurator, Config.CommandPermissions and Config.CustomTones 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}")`,
)
}
})
})
+56 -12
View File
@@ -52,7 +52,42 @@ describe('phone inventory contracts', () => {
)
})
it('auto-detects registered inventories and limits metadata-free adapters', () => {
it('uses One Inventory slot ids and authoritative slot reads', () => {
const adapter = readResourceFile(
'source/bridge/server/inventory/one.lua',
)
expect(adapter).toContain('inventory:GetSlotIdsWithItem(')
expect(adapter).toContain(
'local normalized = Bridge.Inventory.GetSlot(source, slot_id)',
)
expect(adapter).not.toContain('inventory:SearchInventory(')
expect(adapter).toContain(
'inventory:SetItemMetadata(source, slot.slot, requested_metadata) == false',
)
})
it('serializes and rate-limits phone bootstrap requests on both sides', () => {
const phoneClient = readResourceFile('source/client/main.lua')
const phoneServer = readResourceFile('source/server/phone.lua')
expect(phoneClient).toContain('local function request_phone_open(')
expect(phoneClient).toMatch(
/request_phone_open\(callback_name\)[\s\S]*?open_requested = true[\s\S]*?Bridge\.Callbacks\.Trigger\(callback_name, \{\}\)/,
)
expect(phoneClient).toMatch(
/RegisterNetEvent\("sky_phone:device:error"[\s\S]*?if not is_open then[\s\S]*?open_requested = false/,
)
expect(phoneServer).toContain('local phone_open_in_progress = {}')
expect(phoneServer).toContain(
'SkyPhone.AllowOperation(source, "phone_open", request_limit, 60)',
)
expect(phoneServer).toContain(
'pcall(perform_phone_open, source, used_item)',
)
})
it('auto-detects registered inventories and forces metadata-free adapters into compatible modes', () => {
const inventoryBridge = readResourceFile(
'source/bridge/server/inventory.lua',
)
@@ -65,8 +100,17 @@ describe('phone inventory contracts', () => {
)
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('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',
)
})
it('provides the LB IsOpen export alias from the authoritative client state', () => {
@@ -157,10 +201,10 @@ describe('phone inventory contracts', () => {
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "ToggleOpen"',
)
expect(phoneClient).toContain(
'local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})',
'local result = Bridge.Callbacks.Trigger(callback_name, {})',
)
expect(phoneClient).toMatch(
/result\.success ~= true[\s\S]*open_without_focus = false[\s\S]*return false/,
/result\.success == true[\s\S]*open_requested = false[\s\S]*open_without_focus = false[\s\S]*return false/,
)
expect(phoneBridge).toContain(
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "IsPhoneOnScreen"',
@@ -242,21 +286,21 @@ describe('phone inventory contracts', () => {
expect(phoneBridge).toContain('TriggerEvent("lb-phone:deletedFromGallery"')
})
it('opens from a configurable F1 mapping without client-provided device identity', () => {
it('keeps the phone key mapping command stable so FiveM user rebindings persist', () => {
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).toContain(
'RegisterKeyMapping(command_name, locale.Controls.OpenPhone, "keyboard", key_name)',
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(
'if active_key_mapping_command == command_name then',
)
expect(phoneClient).toContain(
'Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})',
'request_phone_open("sky_phone:device:open-request")',
)
expect(phoneServer).toContain(
'Bridge.Callbacks.Register("sky_phone:device:open-request", function(source)',
@@ -0,0 +1,85 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const resourceSource = (path: string): string =>
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8')
const configSource = resourceSource('config/config.lua')
const configuratorSource = resourceSource(
'source/server/phone_configurator.lua',
)
const radioSource = resourceSource('source/server/radio.lua')
const editorSource = readFileSync(
new URL('./components/AdminConfigValueEditor.vue', import.meta.url),
'utf8',
)
describe('radio configurator access contract', () => {
it('lets the server allow everyone or enforce configured job grades', () => {
expect(configSource).toMatch(
/DisplayName\s*=\s*\{[\s\S]*?AllowEveryone\s*=\s*false,[\s\S]*?AllowedJobs\s*=\s*\{/,
)
const start = radioSource.indexOf(
'local function can_set_display_name(source)',
)
const end = radioSource.indexOf(
'\nlocal function normalize_display_name',
start,
)
const permissionSource = radioSource.slice(start, end)
expect(permissionSource).toContain('if config.AllowEveryone == true then')
expect(permissionSource.indexOf('config.AllowEveryone')).toBeLessThan(
permissionSource.indexOf('Bridge.Framework.GetJob(source)'),
)
expect(permissionSource).toContain('config.AllowedJobs[job.name]')
expect(permissionSource).toContain('(tonumber(job.grade) or 0)')
})
it('keeps both radio job tables mutable and validates their value types', () => {
expect(configuratorSource).toContain(
'path == "Radio.DisplayName.AllowedJobs"',
)
expect(configuratorSource).toContain(
'path:match("^Radio%.LockedChannels%.%d+%.jobs$")',
)
expect(configuratorSource).toContain(
'path == "Companies.Definitions" or radio_job_entry_default(path) ~= nil',
)
expect(configuratorSource).toMatch(
/entryDefault = radio_job_default,[\s\S]*?mutableKeys = true,[\s\S]*?valueType = type\(radio_job_default\)/,
)
expect(configuratorSource).toContain('not key:match("^[a-z0-9_-]+$")')
})
it('shows a compact job-name input for both radio job tables', () => {
expect(editorSource).toContain(
"props.path === 'Radio.DisplayName.AllowedJobs'",
)
expect(editorSource).toContain(
String.raw`/^Radio\.LockedChannels\[\d+\]\.jobs$/`,
)
expect(editorSource).toContain(
'isJobTable ? labels.jobPlaceholder : labels.keyPlaceholder',
)
expect(editorSource).toContain(
'isJobTable ? labels.addJob : labels.addField',
)
expect(editorSource).toContain('function updateNewObjectKey(event: Event)')
expect(editorSource).toContain('.toLowerCase()')
expect(editorSource).toContain(".replace(/[^a-z0-9_-]/g, '')")
expect(editorSource).toContain('.slice(0, 64)')
for (const locale of ['en', 'de', 'es']) {
const localeSource = resourceSource(`config/locales/${locale}.lua`)
expect(localeSource).toContain(
`Locales["${locale}"].Nui.AdminPanel.configurator.table.addJob`,
)
expect(localeSource).toContain(
`Locales["${locale}"].Nui.AdminPanel.configurator.table.jobPlaceholder`,
)
}
})
})
+133 -1
View File
@@ -8,14 +8,45 @@ import type {
AdminConfigurator,
AdminConfiguratorChange,
AdminCredential,
AdminCustomTone,
AdminCustomToneCreate,
AdminMessageActivity,
AdminPlayerDetail,
AdminPlayerSummary,
AdminStats,
} from '@/types/admin'
import {
cacheCustomPhoneTonePayload,
CUSTOM_TONE_CHUNK_CHARS,
} from '@/utils/customTones'
import { nuiCall, type NuiResponse } from '@/utils/nui'
const EMPTY_STATS: AdminStats = { accounts: 0, devices: 0, online: 0 }
const EMPTY_STATS: AdminStats = {
accounts: 0,
activeDevices: 0,
auditEntries: 0,
auditToday: 0,
callsToday: 0,
devices: 0,
linkedDevices: 0,
messagesToday: 0,
online: 0,
simDevices: 0,
}
function disabledAppsFromConfigurator(
configurator: AdminConfigurator,
): string[] {
const apps = configurator.sections
.flatMap((section) => section.fields)
.find((field) => field.scope === 'config' && field.path === 'Apps')?.value
if (!apps || typeof apps !== 'object' || Array.isArray(apps)) return []
return Object.entries(apps)
.filter(([, enabled]) => enabled === false)
.map(([appId]) => appId)
.sort()
}
export const useAdminStore = defineStore('admin', {
state: () => ({
@@ -24,7 +55,10 @@ export const useAdminStore = defineStore('admin', {
audit: [] as AdminAuditEntry[],
configurator: null as AdminConfigurator | null,
configuratorLoading: false,
customTones: [] as AdminCustomTone[],
customTonesLoading: false,
detailLoading: false,
disabledApps: [] as string[],
error: '',
initialized: false,
loading: false,
@@ -47,6 +81,9 @@ export const useAdminStore = defineStore('admin', {
return false
}
this.players = response.data.players
this.disabledApps = Array.isArray(response.data.disabledApps)
? response.data.disabledApps
: []
this.stats = response.data.stats
this.audit = response.data.audit
this.error = ''
@@ -147,6 +184,7 @@ export const useAdminStore = defineStore('admin', {
return false
}
this.configurator = response.data
this.disabledApps = disabledAppsFromConfigurator(response.data)
this.error = ''
return true
},
@@ -167,6 +205,7 @@ 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
@@ -174,6 +213,99 @@ export const useAdminStore = defineStore('admin', {
}
return response
},
async loadCustomTones(): Promise<boolean> {
this.customTonesLoading = true
const response = await nuiCall<AdminCustomTone[]>('admin:tones')
this.customTonesLoading = false
if (!response.success || !response.data) {
this.error = response.error ?? 'request_failed'
return false
}
this.customTones = response.data
this.error = ''
return true
},
async createCustomTone(
tone: AdminCustomToneCreate,
): Promise<NuiResponse<AdminCustomTone[]>> {
this.actionKey = 'custom-tone:create'
const { payload, ...metadata } = tone
let uploadId = ''
try {
const started = await nuiCall<{ uploadId: string }>(
'admin:tone-upload-start',
{
...metadata,
payloadLength: payload.length,
},
)
uploadId = started.data?.uploadId ?? ''
if (!started.success || !uploadId) {
this.error = started.error ?? 'request_failed'
return { error: this.error, success: false }
}
for (let offset = 0, index = 1; offset < payload.length; index += 1) {
const response = await nuiCall('admin:tone-upload-chunk', {
chunk: payload.slice(offset, offset + CUSTOM_TONE_CHUNK_CHARS),
index,
uploadId,
})
if (!response.success) {
await nuiCall('admin:tone-upload-cancel', { uploadId })
this.error = response.error ?? 'request_failed'
return { error: this.error, success: false }
}
offset += CUSTOM_TONE_CHUNK_CHARS
}
const response: NuiResponse<AdminCustomTone[]> & {
toneId?: unknown
} = await nuiCall<AdminCustomTone[]>('admin:tone-upload-finish', {
uploadId,
})
if (response.success && response.data) {
this.customTones = response.data
if (typeof response.toneId === 'string') {
cacheCustomPhoneTonePayload(
`custom:${response.toneId}`,
tone.mimeType,
payload,
)
}
this.error = ''
} else {
await nuiCall('admin:tone-upload-cancel', { uploadId })
this.error = response.error ?? 'request_failed'
}
return response
} catch (error) {
if (uploadId) {
await nuiCall('admin:tone-upload-cancel', { uploadId })
}
console.error('[Phone admin] Custom tone upload failed.', error)
this.error = 'request_failed'
return { error: this.error, success: false }
} finally {
this.actionKey = ''
}
},
async deleteCustomTone(
id: string,
): Promise<NuiResponse<AdminCustomTone[]>> {
this.actionKey = `custom-tone:delete:${id}`
const response = await nuiCall<AdminCustomTone[]>('admin:delete-tone', {
id,
})
this.actionKey = ''
if (response.success && response.data) {
this.customTones = response.data
this.error = ''
} else {
this.error = response.error ?? 'request_failed'
}
return response
},
async resetPasscode(
source: number,
imei: string,
+67
View File
@@ -75,6 +75,21 @@ describe('app store', () => {
}
})
it('removes server-disabled apps without forgetting device claims', () => {
const apps = useAppStoreStore()
apps.hydrate({ claimedApps: ['weather', 'snake'] }, ['weather'])
expect(apps.isAvailable('weather')).toBe(false)
expect(apps.isInstalled('weather')).toBe(false)
expect(apps.homeLayout.grid).not.toContain('weather')
expect(apps.claimedApps).toContain('weather')
expect(apps.isInstalled('snake')).toBe(true)
apps.hydrate({ claimedApps: apps.claimedApps }, [])
expect(apps.isInstalled('weather')).toBe(true)
})
it('drops the retired admin app from persisted phone layouts', () => {
const apps = useAppStoreStore()
@@ -249,6 +264,23 @@ describe('app store', () => {
expect(apps.homeLayout.hidden).not.toContain('snake')
})
it('uninstalls claimed Banking and Picstagram apps from every app state', () => {
const apps = useAppStoreStore()
apps.hydrate({ claimedApps: ['banking', 'picstagram'] })
mocks.phone.saveDeviceNamespace.mockClear()
for (const appId of ['banking', 'picstagram'] as const) {
expect(apps.isInstalled(appId)).toBe(true)
expect(apps.uninstallApp(appId)).toBe(true)
expect(apps.isInstalled(appId)).toBe(false)
expect(apps.claimedApps).not.toContain(appId)
expect(apps.uninstalledApps).toContain(appId)
expect(apps.homeLayout.hidden).toContain(appId)
}
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(2)
})
it('hydrates persisted removals while rejecting protected and invalid ids', () => {
const apps = useAppStoreStore()
@@ -459,6 +491,41 @@ 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()
+85 -22
View File
@@ -19,6 +19,7 @@ import {
createDefaultHomeLayout,
deleteHomePage,
extractHomeFolderApp,
HOME_GRID_PAGE_SIZE,
moveHomeFolderApp,
moveHomeApp,
moveHomeAppToGridPage,
@@ -45,28 +46,49 @@ const pendingInstallations = new WeakMap<
Map<LaunchablePhoneAppId, PendingInstallation>
>()
function getDefaultGridIds(): LaunchablePhoneAppId[] {
return PHONE_APPS.filter((app) => app.dockOrder === null)
function isAppDisabled(
appId: LaunchablePhoneAppId,
disabledApps: readonly LaunchablePhoneAppId[],
): boolean {
return disabledApps.includes(appId)
}
function getDefaultGridIds(
disabledApps: readonly LaunchablePhoneAppId[] = [],
): LaunchablePhoneAppId[] {
return PHONE_APPS.filter(
(app) => app.dockOrder === null && !isAppDisabled(app.id, disabledApps),
)
.sort((a, b) => a.gridOrder - b.gridOrder)
.map((app) => app.id)
}
function getDefaultDockIds(): LaunchablePhoneAppId[] {
return PHONE_APPS.filter((app) => app.dockOrder !== null)
function getDefaultDockIds(
disabledApps: readonly LaunchablePhoneAppId[] = [],
): LaunchablePhoneAppId[] {
return PHONE_APPS.filter(
(app) => app.dockOrder !== null && !isAppDisabled(app.id, disabledApps),
)
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
.map((app) => app.id)
}
function getDefaultInstalledIds(): LaunchablePhoneAppId[] {
function getDefaultInstalledIds(
disabledApps: readonly LaunchablePhoneAppId[] = [],
): LaunchablePhoneAppId[] {
return PHONE_APPS.filter((app) => {
if (app.adminOnly) return false
if (app.adminOnly || isAppDisabled(app.id, disabledApps)) return false
return isExternalPhoneApp(app)
? app.defaultInstalled
: DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id)
}).map((app) => app.id)
}
function isProtectedHomeApp(appId: LaunchablePhoneAppId): boolean {
function isProtectedHomeApp(
appId: LaunchablePhoneAppId,
disabledApps: readonly LaunchablePhoneAppId[],
): boolean {
if (isAppDisabled(appId, disabledApps)) return false
const app = getPhoneApp(appId)
return app
? !isPhoneAppRemovable(app)
@@ -104,6 +126,7 @@ function hasUninstalledBuiltinApp(
export const useAppStoreStore = defineStore('app-store', {
state: () => ({
claimedApps: [] as LaunchablePhoneAppId[],
disabledApps: [] as LaunchablePhoneAppId[],
uninstalledApps: [] as LaunchablePhoneAppId[],
homeLayout: createDefaultHomeLayout(
getDefaultInstalledIds(),
@@ -130,6 +153,7 @@ export const useAppStoreStore = defineStore('app-store', {
return true
},
claimApp(id: LaunchablePhoneAppId): void {
if (!this.isAvailable(id)) return
this.uninstalledApps = this.uninstalledApps.filter(
(appId) => appId !== id,
)
@@ -150,6 +174,7 @@ export const useAppStoreStore = defineStore('app-store', {
this.installingApps = {}
},
installApp(id: LaunchablePhoneAppId): void {
if (!this.isAvailable(id)) return
const installed = this.isInstalled(id)
if (
this.installingApps[id] ||
@@ -191,6 +216,10 @@ export const useAppStoreStore = defineStore('app-store', {
)
return
}
if (!this.isAvailable(id)) {
delete this.installingApps[id]
return
}
if (reportInstall && !isExternalPhoneApp(getPhoneApp(id))) {
delete this.installingApps[id]
console.error(
@@ -220,8 +249,14 @@ export const useAppStoreStore = defineStore('app-store', {
installations.set(id, { deviceImei, timer, token })
pendingInstallations.set(this, installations)
},
hydrate(payload: unknown): void {
hydrate(payload: unknown, disabledApps: unknown = []): void {
this.cancelPendingInstalls()
this.disabledApps = Array.isArray(disabledApps)
? disabledApps.filter(
(id): id is LaunchablePhoneAppId =>
typeof id === 'string' && isPhoneAppId(id),
)
: []
const data = payload as {
claimedApps?: unknown
homeLayout?: unknown
@@ -256,16 +291,22 @@ export const useAppStoreStore = defineStore('app-store', {
})
: []
const installedIds = [
...new Set([...getDefaultInstalledIds(), ...this.claimedApps]),
].filter((id) => !this.uninstalledApps.includes(id))
...new Set([
...getDefaultInstalledIds(this.disabledApps),
...this.claimedApps,
]),
].filter(
(id) =>
!this.uninstalledApps.includes(id) && this.isAvailable(id),
)
const removedLegacyDefaults = hasUninstalledBuiltinApp(
data?.homeLayout,
installedIds,
)
const defaults = createDefaultHomeLayout(
installedIds,
getDefaultGridIds(),
getDefaultDockIds(),
getDefaultGridIds(this.disabledApps),
getDefaultDockIds(this.disabledApps),
)
const parsedHomeLayout = parseHomeLayout(
data?.homeLayout,
@@ -277,8 +318,9 @@ export const useAppStoreStore = defineStore('app-store', {
const removedDockGridDuplicates =
normalizedHomeLayout !== parsedHomeLayout
this.homeLayout = normalizedHomeLayout
const protectedHiddenAppIds =
this.homeLayout.hidden.filter(isProtectedHomeApp)
const protectedHiddenAppIds = this.homeLayout.hidden.filter((appId) =>
isProtectedHomeApp(appId, this.disabledApps),
)
for (const appId of protectedHiddenAppIds) {
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
}
@@ -310,7 +352,11 @@ export const useAppStoreStore = defineStore('app-store', {
this.persist()
}
},
isAvailable(appId: LaunchablePhoneAppId): boolean {
return !isAppDisabled(appId, this.disabledApps)
},
isInstalled(appId: LaunchablePhoneAppId): boolean {
if (!this.isAvailable(appId)) return false
const app = getPhoneApp(appId)
if (app?.adminOnly) return false
if (this.uninstalledApps.includes(appId)) return false
@@ -322,12 +368,18 @@ export const useAppStoreStore = defineStore('app-store', {
},
reconcileCatalog(): void {
const installedIds = [
...new Set([...getDefaultInstalledIds(), ...this.claimedApps]),
].filter((id) => !this.uninstalledApps.includes(id))
...new Set([
...getDefaultInstalledIds(this.disabledApps),
...this.claimedApps,
]),
].filter(
(id) =>
!this.uninstalledApps.includes(id) && this.isAvailable(id),
)
const defaults = createDefaultHomeLayout(
installedIds,
getDefaultGridIds(),
getDefaultDockIds(),
getDefaultGridIds(this.disabledApps),
getDefaultDockIds(this.disabledApps),
)
const previous = JSON.stringify(this.homeLayout)
this.homeLayout = removeDockGridDuplicates(
@@ -335,7 +387,7 @@ export const useAppStoreStore = defineStore('app-store', {
)
for (const appId of [...this.homeLayout.hidden]) {
if (isProtectedHomeApp(appId)) {
if (isProtectedHomeApp(appId, this.disabledApps)) {
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
}
}
@@ -345,6 +397,7 @@ export const useAppStoreStore = defineStore('app-store', {
}
},
recordLaunch(appId: LaunchablePhoneAppId): void {
if (!this.isAvailable(appId)) return
this.launchCounts[appId] = (this.launchCounts[appId] ?? 0) + 1
this.persist()
},
@@ -467,25 +520,35 @@ 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(
this.homeLayout,
sourceLayout,
folderId,
sourceIndex,
to,
targetIndex,
)
if (next === this.homeLayout) return false
if (next === sourceLayout) return false
this.homeLayout = next
this.persist()
return true
},
removeHomeApp(appId: LaunchablePhoneAppId): void {
if (isProtectedHomeApp(appId)) return
if (isProtectedHomeApp(appId, this.disabledApps)) return
this.homeLayout = removeHomeApp(this.homeLayout, appId)
this.persist()
},
restoreHomeApp(appId: LaunchablePhoneAppId): void {
if (!this.isAvailable(appId)) return
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
this.persist()
},
+22 -7
View File
@@ -4,14 +4,18 @@ import { ref } from 'vue'
import { usePhoneStore } from '@/stores/phone'
import type { PhoneCall, PhoneContact, RecentCall } from '@/types/phone'
import { nuiCall, type NuiResponse } from '@/utils/nui'
import type { RingtoneId } from '@/utils/preferences'
import { findCustomPhoneTone, playCustomPhoneTone } from '@/utils/customTones'
import {
isBuiltInRingtoneId,
type BuiltInRingtoneId,
} from '@/utils/preferences'
import {
playPhoneTone,
playPhoneVibration,
type PhoneToneId,
} from '@/utils/tones'
const RINGTONE_TONES: Record<RingtoneId, PhoneToneId> = {
const RINGTONE_TONES: Record<BuiltInRingtoneId, PhoneToneId> = {
horizon: 'aurora',
pulse: 'signal',
skyline: 'apex',
@@ -24,6 +28,21 @@ export const useCallsStore = defineStore('calls', () => {
const recents = ref<RecentCall[]>([])
let stopRingtone: (() => void) | null = null
function playSelectedRingtone(volume: number): () => void {
const selected = phone.preferences.settings.ringtone
const customTone = findCustomPhoneTone(
phone.customTones.ringtones,
selected,
)
if (customTone) return playCustomPhoneTone(customTone, volume, true)
return playPhoneTone(
isBuiltInRingtoneId(selected) ? RINGTONE_TONES[selected] : 'apex',
volume,
true,
)
}
async function bootstrap(): Promise<void> {
await Promise.all([loadContacts(), loadRecents()])
}
@@ -173,11 +192,7 @@ export const useCallsStore = defineStore('calls', () => {
phone.preferences.settings.ringtoneVolume === 0
stopRingtone = alertsMuted
? playPhoneVibration('call', true)
: playPhoneTone(
RINGTONE_TONES[phone.preferences.settings.ringtone],
phone.preferences.settings.ringtoneVolume,
true,
)
: playSelectedRingtone(phone.preferences.settings.ringtoneVolume)
}
if (!['ringing', 'connected'].includes(call.state)) {
window.setTimeout(() => {
+24 -6
View File
@@ -5,8 +5,10 @@ import { isPhoneAppId } from '@/config/apps'
import { usePhoneStore } from '@/stores/phone'
import type { LaunchablePhoneAppId } from '@/types/apps'
import { nuiCall } from '@/utils/nui'
import { findCustomPhoneTone, playCustomPhoneTone } from '@/utils/customTones'
import {
DEFAULT_APP_NOTIFICATION_PREFERENCES,
isBuiltInNotificationSoundId,
type PhonePreferencesV1,
} from '@/utils/preferences'
import {
@@ -190,18 +192,34 @@ export const useNotificationsStore = defineStore('notifications', () => {
const alertsMuted =
preferences.settings.notificationVolume === 0 &&
preferences.settings.ringtoneVolume === 0
const sound = notification.sound ?? preferences.settings.notificationSound
const selectedSound = preferences.settings.notificationSound
const customSound = notification.sound
? undefined
: findCustomPhoneTone(
phone.customTones.notificationSounds,
selectedSound,
)
const volume = notification.critical
? preferences.settings.ringtoneVolume
: preferences.settings.notificationVolume
stopToneHandles.set(
notification.id,
alertsMuted
? playPhoneVibration(
'notification',
!!notification.persistent,
)
: playPhoneTone(sound, volume, !!notification.persistent),
? playPhoneVibration('notification', !!notification.persistent)
: customSound
? playCustomPhoneTone(
customSound,
volume,
!!notification.persistent,
)
: playPhoneTone(
notification.sound ??
(isBuiltInNotificationSoundId(selectedSound)
? selectedSound
: 'chime'),
volume,
!!notification.persistent,
),
)
}
+108 -37
View File
@@ -8,6 +8,11 @@ import type {
} from '@/types/device'
import { clampPage } from '@/utils/pages'
import { cloneJsonData } from '@/utils/clone'
import {
EMPTY_CUSTOM_PHONE_TONES,
isCustomTonePreferenceId,
parseCustomPhoneToneCatalog,
} from '@/utils/customTones'
import { nuiCall } from '@/utils/nui'
import type { NuiResponse } from '@/utils/nui'
import {
@@ -33,6 +38,7 @@ export type PasscodeResponseData = {
export type PhoneOpenPayload = {
account?: DeviceBootstrap['account']
device?: PhoneDevice
disabledApps?: string[]
fallbackLocales?: LocaleTree
lang?: string
locales?: LocaleTree
@@ -822,6 +828,7 @@ const adminPanelFallbackLocales = {
messages: 'Messages',
calls: 'Calls',
moderation: 'Moderation',
tones: 'Sounds',
audit: 'Audit',
configurator: 'Phone configurator',
},
@@ -845,43 +852,25 @@ const adminPanelFallbackLocales = {
messageFeature: 'Review recent SMS activity',
callFeature: 'Review recent call activity',
moderationFeature: 'Reset access, number, or device data',
tonesFeature: 'Manage ringtones and notification sounds',
auditFeature: 'Review sensitive admin actions',
configuratorFeature: 'Manage config.lua and media.lua through SQL',
},
appearance: {
eyebrow: 'Appearance',
title: 'Interface',
body: 'Personalize colors and typography across the admin workspace.',
colors: {
emerald: 'Emerald',
blue: 'Blue',
violet: 'Violet',
orange: 'Orange',
red: 'Red',
},
controls: {
customColor: 'Custom color',
customColorBody: 'Choose any RGB accent or enter its channel values.',
red: 'R',
green: 'G',
blue: 'B',
hex: 'HEX color',
value: 'value',
fontFamily: 'Font family',
fontFamilyBody: 'Choose the typeface used by the complete admin panel.',
fontSize: 'Font size',
fontSizeBody: 'Scale text and controls for comfortable readability.',
fonts: {
inter: 'Inter',
system: 'System',
classic: 'Classic',
verdana: 'Verdana',
tahoma: 'Tahoma',
trebuchet: 'Trebuchet',
georgia: 'Georgia',
mono: 'Monospace',
},
},
statistics: {
eyebrow: 'Live data',
title: 'Phone statistics',
body: 'Current usage and device status, refreshed every 15 seconds.',
today: 'Activity today',
todayBody: 'Phone and admin activity since midnight.',
messagesToday: 'Messages',
callsToday: 'Calls',
auditToday: 'Admin actions',
coverage: 'Device coverage',
coverageBody: 'Current share of all stored phones.',
linkedDevices: 'Account linked',
simDevices: 'SIM assigned',
activeDevices: 'Updated in 24 hours',
ofDevices: '{count} of {total} devices',
},
configurator: {
context: 'Runtime configuration',
@@ -904,6 +893,43 @@ const adminPanelFallbackLocales = {
secretConfigured: 'Secret configured · enter a replacement',
invalidValue: 'Check the highlighted table or number value.',
saved: 'SQL configuration saved and applied.',
customTones: {
context: 'Sound library',
eyebrow: 'Audio management',
library: 'Library',
title: 'Custom ringtones and notification sounds',
body: 'Manage local audio files from the database or config.lua without external URLs.',
configTitle: 'File-based alternative',
configBody:
'If the FiveM client does not open a file dialog, place the file in the resource folder and register it in config.lua.',
configSource: 'config.lua',
configManaged:
'This tone is managed through config.lua and can only be previewed here.',
name: 'Display name',
namePlaceholder: 'For example Dispatch',
category: 'Use as',
ringtone: 'Ringtone',
notification: 'Notification sound',
chooseFile: 'Choose audio file',
fileHint: 'MP3, OGG, WAV, or WebM · up to 2 MB and 30 seconds',
preview: 'Preview',
add: 'Add tone',
loading: 'Loading tone library...',
ringtones: 'Ringtones',
notifications: 'Notification sounds',
empty: 'No custom tones in this category yet.',
delete: 'Delete tone',
confirmDelete: 'Click again to confirm',
saved: 'The tone was saved and is immediately available on every phone.',
deleted: 'The tone was deleted.',
errors: {
type: 'Choose an MP3, OGG, WAV, or WebM audio file.',
size: 'The audio file may not exceed 2 MB.',
duration: 'The tone must be between 0.25 and 30 seconds long.',
invalid: 'The audio file could not be read.',
playback: 'The tone could not be played.',
},
},
descriptions: {
featureToggle: 'Turns {name} on or off.',
boolean: 'Controls whether {name} is allowed.',
@@ -945,7 +971,6 @@ const adminPanelFallbackLocales = {
entry: 'Entry',
general: 'General',
subtabs: {
AdminGroups: 'Admin Groups',
Dictionaries: 'Dictionaries',
Clips: 'Clips',
Transforms: 'Transforms',
@@ -984,10 +1009,12 @@ const adminPanelFallbackLocales = {
},
addRow: 'Add row',
addField: 'Add field',
addJob: 'Add job',
remove: 'Remove',
emptyList: 'No rows yet. Add the first row with plus.',
emptyTable: 'No fields yet. Add the first key below.',
keyPlaceholder: 'New key',
jobPlaceholder: 'Job name',
convertToList: 'Use list',
convertToMap: 'Use typed key table',
convertToTable: 'Use key table',
@@ -1174,6 +1201,8 @@ const adminPanelFallbackLocales = {
change_number: 'Phone number changed',
factory_reset: 'Phone factory reset',
save_configuration: 'Configuration saved',
create_custom_tone: 'Custom tone added',
delete_custom_tone: 'Custom tone deleted',
},
},
errors: {
@@ -1188,6 +1217,12 @@ const adminPanelFallbackLocales = {
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_tone: 'Check the tone name, file type, file size, and duration.',
tone_name_taken: 'A tone with this name already exists in this category.',
tone_limit: 'This category already contains 32 custom tones.',
tone_not_found: 'That tone no longer exists.',
invalid_upload: 'The tone upload is incomplete or invalid.',
operation_in_progress: 'Another tone upload is already in progress.',
account_not_found: 'No iFruit account is linked to this phone.',
invalid_phone_number:
'Enter a phone number in the configured server format.',
@@ -2731,6 +2766,8 @@ 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.',
@@ -3044,6 +3081,7 @@ const defaultLocales: LocaleTree = {
uninstallTitle: 'Uninstall this app?',
uninstallBody:
'{app} will be removed from this phone. You can download it again from the App Store.',
uninstallFailed: 'The app could not be uninstalled. Please try again.',
},
details: {
skyStudios: 'Sky Studios',
@@ -4542,8 +4580,8 @@ const defaultLocales: LocaleTree = {
video: 'Video',
microphoneOn: 'Microphone on',
microphoneOff: 'Microphone muted',
focusHelp: 'Space for movement',
returnHelp: 'Space to return',
focusHelp: 'Hold the look key to look around',
returnHelp: 'Release the look key for controls',
uploading: '{count} uploading',
saving: 'Saving video...',
openGallery: 'Open Photos',
@@ -5700,6 +5738,8 @@ export const usePhoneStore = defineStore('phone', {
state: () => ({
cameraLandscape: false,
currentPage: 1,
customTones: cloneJsonData(EMPTY_CUSTOM_PHONE_TONES),
customTonesLoaded: false,
device: null as PhoneDevice | null,
deviceRevisions: {} as Record<string, number>,
deviceSessionToken: null as string | null,
@@ -5743,6 +5783,36 @@ export const usePhoneStore = defineStore('phone', {
this.locales = locales
this.fallbackLocales = fallbackLocales
},
setCustomTones(payload: unknown): void {
this.customTones = parseCustomPhoneToneCatalog(payload)
this.customTonesLoaded = true
this.reconcileCustomTonePreferences()
},
reconcileCustomTonePreferences(): void {
if (!this.customTonesLoaded) return
let changed = false
const ringtone = this.preferences.settings.ringtone
if (
isCustomTonePreferenceId(ringtone) &&
!this.customTones.ringtones.some((tone) => tone.id === ringtone)
) {
this.preferences.settings.ringtone = 'skyline'
changed = true
}
const notificationSound = this.preferences.settings.notificationSound
if (
isCustomTonePreferenceId(notificationSound) &&
!this.customTones.notificationSounds.some(
(tone) => tone.id === notificationSound,
)
) {
this.preferences.settings.notificationSound = 'chime'
changed = true
}
if (changed && this.device) {
this.saveDeviceNamespace('settings', this.preferences)
}
},
open(payload: PhoneOpenPayload = {}): void {
const nextImei = payload.device?.imei ?? this.device?.imei ?? null
const nextToken = payload.token ?? this.deviceSessionToken
@@ -5757,6 +5827,7 @@ export const usePhoneStore = defineStore('phone', {
this.fallbackLocales = payload.fallbackLocales ?? defaultLocales
this.locales = payload.locales ?? this.fallbackLocales
if (payload.device) this.hydrateDevice(payload.device)
this.reconcileCustomTonePreferences()
if (payload.player) this.player = payload.player
this.security = payload.security ?? {
enabled: false,
+29
View File
@@ -1,7 +1,14 @@
export type AdminStats = {
accounts: number
activeDevices: number
auditEntries: number
auditToday: number
callsToday: number
devices: number
linkedDevices: number
messagesToday: number
online: number
simDevices: number
}
export type AdminPlayerSummary = {
@@ -78,6 +85,7 @@ export type AdminAuditEntry = {
export type AdminBootstrap = {
audit: AdminAuditEntry[]
disabledApps: string[]
players: AdminPlayerSummary[]
stats: AdminStats
}
@@ -130,6 +138,7 @@ export type AdminConfiguratorStructure =
template?: AdminConfiguratorStructure
}
| {
entryDefault?: unknown
fields: Record<string, AdminConfiguratorStructure>
kind: 'table'
mutableKeys?: boolean
@@ -177,3 +186,23 @@ export type AdminConfiguratorChange = {
scope: 'config' | 'media'
value: unknown
}
export type AdminCustomTone = {
byteSize: number
createdAt: string
createdBy: string
durationMs: number
id: string
label: string
mimeType: string
source: 'config' | 'database'
toneType: 'notification' | 'ringtone'
}
export type AdminCustomToneCreate = {
durationMs: number
label: string
mimeType: string
payload: string
toneType: 'notification' | 'ringtone'
}
+11
View File
@@ -43,6 +43,17 @@ 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, {
+3 -2
View File
@@ -1265,8 +1265,9 @@ label.sky-list-item__row {
opacity: 0;
}
.sky-field--floating-label .sky-field__select option {
color: var(--sky-text, #000000);
.sky-field__select option {
background: var(--sky-native-select-option-background, #ffffff);
color: var(--sky-native-select-option-text, #000000);
}
.sky-field--floating-label.sky-field--outline:not(
+17
View File
@@ -164,6 +164,23 @@ describe('SkyField', () => {
)
})
it('keeps native select options readable on the CEF popup surface', () => {
const controls = readFileSync(
new URL('../controls.css', import.meta.url),
'utf8',
)
const tokens = readFileSync(
new URL('../tokens.css', import.meta.url),
'utf8',
)
expect(controls).toMatch(
/\.sky-field__select option\s*\{[^}]*background:\s*var\(--sky-native-select-option-background, #ffffff\);[^}]*color:\s*var\(--sky-native-select-option-text, #000000\);/s,
)
expect(tokens).toContain('--sky-native-select-option-background: #ffffff;')
expect(tokens).toContain('--sky-native-select-option-text: #000000;')
})
it('raises floating labels only after the field has a value', async () => {
const emptyApp = createSSRApp(SkyField, {
floatingLabel: true,
+1
View File
@@ -168,6 +168,7 @@
.sky-navbar__title {
min-width: 0;
max-width: 100%;
margin: 0;
padding: 0 var(--sky-space-1);
overflow: hidden;
+2
View File
@@ -188,6 +188,8 @@
--sky-hairline: rgba(0, 0, 0, 0.2);
--sky-field-outline: rgba(0, 0, 0, 0.3);
--sky-field-placeholder: rgba(0, 0, 0, 0.3);
--sky-native-select-option-background: #ffffff;
--sky-native-select-option-text: #000000;
--sky-pressed: rgba(0, 0, 0, 0.1);
--sky-tabbar-highlight-background: rgba(0, 0, 0, 0.1);
--sky-tabbar-thumb-background: rgba(0, 0, 0, 0.05);
@@ -0,0 +1,178 @@
import { describe, expect, it } from 'vitest'
import type { AdminConfiguratorStructure } from '@/types/admin'
import {
blankFromConfiguratorStructure,
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,
)
})
it('uses the configured access value when adding radio jobs', () => {
const channelJobs: AdminConfiguratorStructure = {
entryDefault: true,
fields: {},
kind: 'table',
mutableKeys: true,
template: { kind: 'value', valueType: 'boolean' },
}
const displayNameJobs: AdminConfiguratorStructure = {
entryDefault: 0,
fields: {},
kind: 'table',
mutableKeys: true,
template: { kind: 'value', valueType: 'number' },
}
expect(
createMutableTableEntry(
channelJobs,
'Radio.LockedChannels[1].jobs',
'mechanic',
),
).toBe(true)
expect(
createMutableTableEntry(
displayNameJobs,
'Radio.DisplayName.AllowedJobs',
'mechanic',
),
).toBe(0)
})
it('preserves required nested list fields in schema-derived rows', () => {
const structure: AdminConfiguratorStructure = {
fields: {
jobs: {
fields: {
police: { kind: 'value', valueType: 'boolean' },
},
kind: 'table',
mutableKeys: true,
template: { kind: 'value', valueType: 'boolean' },
},
range: {
items: [
{ kind: 'value', valueType: 'number' },
{ kind: 'value', valueType: 'number' },
],
kind: 'list',
template: { kind: 'value', valueType: 'number' },
},
},
kind: 'table',
}
expect(blankFromConfiguratorStructure(structure)).toEqual({
jobs: { police: false },
range: [0, 0],
})
})
})
@@ -0,0 +1,142 @@
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,9 +16,7 @@ describe('admin configurator descriptions', () => {
expect(configuratorDescriptionKey('Media.RequestTimeoutMs', 10000)).toBe(
'milliseconds',
)
expect(configuratorDescriptionKey('AdminPanel.AdminGroups', [])).toBe(
'access',
)
expect(configuratorDescriptionKey('Radio.AllowedJobs', [])).toBe('access')
expect(configuratorDescriptionKey('FiveManage.ApiKey', '')).toBe(
'credential',
)
+5 -5
View File
@@ -5,10 +5,10 @@ import type {
SkyPhoneAppCapability,
} from '@/types/apps'
import type { NuiResponse } from '@/utils/nui'
import type { NotificationSoundId } from '@/utils/preferences'
import type { BuiltInNotificationSoundId } from '@/utils/preferences'
const STORAGE_KEY_PATTERN = /^[A-Za-z0-9._-]{1,64}$/
const NOTIFICATION_SOUNDS: ReadonlySet<NotificationSoundId> = new Set([
const NOTIFICATION_SOUNDS: ReadonlySet<BuiltInNotificationSoundId> = new Set([
'chime',
'signal',
'soft',
@@ -24,7 +24,7 @@ type JsonResult = { success: true; value: unknown } | { success: false }
export type CustomAppBridgeNotification = {
appId: string
route: string
sound?: NotificationSoundId
sound?: BuiltInNotificationSoundId
subtitle?: string
text: string
title: string
@@ -210,8 +210,8 @@ function normalizeNotificationPayload(
const sound =
payload.sound === undefined ||
(typeof payload.sound === 'string' &&
NOTIFICATION_SOUNDS.has(payload.sound as NotificationSoundId))
? (payload.sound as NotificationSoundId | undefined)
NOTIFICATION_SOUNDS.has(payload.sound as BuiltInNotificationSoundId))
? (payload.sound as BuiltInNotificationSoundId | undefined)
: null
if (sound === null) return null
+156
View File
@@ -0,0 +1,156 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nuiCall } from '@/utils/nui'
import { playPhoneMediaTone } from '@/utils/tones'
import {
CUSTOM_TONE_CHUNK_CHARS,
MAX_CUSTOM_TONE_PAYLOAD_CHARS,
findCustomPhoneTone,
isCustomTonePreferenceId,
parseCustomPhoneToneCatalog,
playCustomPhoneTone,
} from './customTones'
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
vi.mock('@/utils/tones', () => ({ playPhoneMediaTone: vi.fn() }))
const DISPATCH_ID = '1fbd07b4-d231-4a76-b661-c9e5d0ab19a8'
const RETRO_ID = 'a1e8db72-bc56-41c8-84e2-521180e14db1'
function tone(
id: string,
label: string,
toneType: 'notification' | 'ringtone',
byteSize = 120_000,
) {
return {
byteSize,
createdAt: '2026-08-27 12:00:00',
durationMs: 8_500,
id,
label,
mimeType: 'audio/mpeg',
toneType,
}
}
describe('custom phone tones', () => {
beforeEach(() => vi.clearAllMocks())
it('keeps transfer chunks below the reliable event ceiling and Base64-aligned', () => {
expect(CUSTOM_TONE_CHUNK_CHARS).toBe(8_000)
expect(CUSTOM_TONE_CHUNK_CHARS % 4).toBe(0)
expect(
Math.ceil(MAX_CUSTOM_TONE_PAYLOAD_CHARS / CUSTOM_TONE_CHUNK_CHARS),
).toBe(334)
})
it('accepts valid stored-audio catalog entries and creates namespaced preference ids', () => {
const catalog = parseCustomPhoneToneCatalog({
notificationSounds: [tone(DISPATCH_ID, 'Dispatch Alert', 'notification')],
ringtones: [tone(RETRO_ID, 'Retro Ring', 'ringtone')],
})
expect(catalog.ringtones[0]).toMatchObject({
id: `custom:${RETRO_ID}`,
label: 'Retro Ring',
mimeType: 'audio/mpeg',
})
expect(
findCustomPhoneTone(catalog.notificationSounds, `custom:${DISPATCH_ID}`)
?.label,
).toBe('Dispatch Alert')
})
it('rejects malformed ids, mismatched categories, duplicates and blank labels', () => {
const catalog = parseCustomPhoneToneCatalog({
notificationSounds: [],
ringtones: [
tone('../escape', 'Escape', 'ringtone'),
tone(DISPATCH_ID, 'Wrong category', 'notification'),
tone(RETRO_ID, ' ', 'ringtone'),
tone(DISPATCH_ID, 'Safe', 'ringtone'),
tone(DISPATCH_ID, 'Duplicate', 'ringtone'),
],
})
expect(catalog.ringtones).toHaveLength(1)
expect(catalog.ringtones[0]).toMatchObject({
id: `custom:${DISPATCH_ID}`,
label: 'Safe',
})
expect(isCustomTonePreferenceId(`custom:${DISPATCH_ID}`)).toBe(true)
expect(isCustomTonePreferenceId('custom:../escape')).toBe(false)
})
it('accepts safe config-file tone ids in their matching category', () => {
const catalog = parseCustomPhoneToneCatalog({
notificationSounds: [],
ringtones: [
{
...tone('config:ringtone:dispatch_call', 'Dispatch Call', 'ringtone'),
source: 'config',
},
],
})
expect(catalog.ringtones[0]).toMatchObject({
id: 'custom:config:ringtone:dispatch_call',
source: 'config',
})
expect(
isCustomTonePreferenceId('custom:config:ringtone:dispatch_call'),
).toBe(true)
})
it('accepts tones above the old 270 KB limit up to 2 MB', () => {
const catalog = parseCustomPhoneToneCatalog({
notificationSounds: [],
ringtones: [
tone(DISPATCH_ID, 'Large valid tone', 'ringtone', 1_500_000),
tone(RETRO_ID, 'Too large', 'ringtone', 2_000_001),
],
})
expect(catalog.ringtones).toHaveLength(1)
expect(catalog.ringtones[0]?.byteSize).toBe(1_500_000)
})
it('loads preview audio in one NUI request before starting playback', async () => {
const customTone = parseCustomPhoneToneCatalog({
notificationSounds: [],
ringtones: [tone(DISPATCH_ID, 'Fast preview', 'ringtone')],
}).ringtones[0]!
const onStarted = vi.fn()
const stopPlayback = vi.fn()
vi.mocked(nuiCall).mockResolvedValue({
data: {
id: DISPATCH_ID,
mimeType: 'audio/ogg',
payload: 'T2dnUw==',
},
success: true,
})
vi.mocked(playPhoneMediaTone).mockImplementation(
(_url, _volume, _loop, callbacks) => {
callbacks?.onStarted?.()
return stopPlayback
},
)
const stop = playCustomPhoneTone(customTone, 70, false, { onStarted })
await vi.waitFor(() => expect(onStarted).toHaveBeenCalledOnce())
expect(nuiCall).toHaveBeenCalledOnce()
expect(nuiCall).toHaveBeenCalledWith('tones:audio', { id: DISPATCH_ID })
expect(playPhoneMediaTone).toHaveBeenCalledWith(
'data:audio/ogg;base64,T2dnUw==',
70,
false,
{ onStarted },
)
stop()
expect(stopPlayback).toHaveBeenCalledOnce()
})
})
+240
View File
@@ -0,0 +1,240 @@
import { nuiCall } from '@/utils/nui'
import { playPhoneMediaTone } from '@/utils/tones'
export type CustomTonePreferenceId = `custom:${string}`
export type CustomToneType = 'notification' | 'ringtone'
export type CustomPhoneTone = {
byteSize: number
createdAt: string
durationMs: number
id: CustomTonePreferenceId
label: string
mimeType: string
source: 'config' | 'database'
toneType: CustomToneType
}
export type CustomPhoneToneCatalog = {
notificationSounds: CustomPhoneTone[]
ringtones: CustomPhoneTone[]
}
type CustomToneAudio = {
id: string
mimeType: string
payload: string
}
export const EMPTY_CUSTOM_PHONE_TONES: CustomPhoneToneCatalog = {
notificationSounds: [],
ringtones: [],
}
export const MAX_CUSTOM_TONE_BYTES = 2_000_000
export const MAX_CUSTOM_TONE_PAYLOAD_CHARS = 2_666_668
export const CUSTOM_TONE_CHUNK_CHARS = 8_000
const CUSTOM_TONE_ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
const CONFIG_TONE_ID_PATTERN =
/^config:(ringtone|notification):[a-z0-9][a-z0-9_-]{0,47}$/
const MAX_CUSTOM_TONES_PER_TYPE = 32
const MAX_CACHED_TONES = 2
const ALLOWED_MIME_TYPES = new Set([
'audio/mpeg',
'audio/ogg',
'audio/wav',
'audio/webm',
])
const cachedSources = new Map<string, string>()
export function isCustomTonePreferenceId(
value: unknown,
): value is CustomTonePreferenceId {
return (
typeof value === 'string' &&
value.startsWith('custom:') &&
(CUSTOM_TONE_ID_PATTERN.test(value.slice('custom:'.length)) ||
CONFIG_TONE_ID_PATTERN.test(value.slice('custom:'.length)))
)
}
function parseToneList(
value: unknown,
expectedType: CustomToneType,
): CustomPhoneTone[] {
if (!Array.isArray(value)) return []
const tones: CustomPhoneTone[] = []
const acceptedIds = new Set<string>()
for (const entry of value.slice(0, MAX_CUSTOM_TONES_PER_TYPE)) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue
const candidate = entry as Record<string, unknown>
const source = candidate.source === 'config' ? 'config' : 'database'
const validId =
typeof candidate.id === 'string' &&
(source === 'config'
? CONFIG_TONE_ID_PATTERN.test(candidate.id) &&
candidate.id.startsWith(`config:${expectedType}:`)
: CUSTOM_TONE_ID_PATTERN.test(candidate.id))
if (
!validId ||
typeof candidate.id !== 'string' ||
acceptedIds.has(candidate.id) ||
typeof candidate.label !== 'string' ||
candidate.label.length < 1 ||
candidate.label.length > 64 ||
candidate.label.trim().length < 1 ||
candidate.toneType !== expectedType ||
typeof candidate.mimeType !== 'string' ||
!ALLOWED_MIME_TYPES.has(candidate.mimeType) ||
typeof candidate.durationMs !== 'number' ||
!Number.isInteger(candidate.durationMs) ||
candidate.durationMs < 250 ||
candidate.durationMs > 30_000 ||
typeof candidate.byteSize !== 'number' ||
!Number.isInteger(candidate.byteSize) ||
candidate.byteSize < 1 ||
candidate.byteSize > MAX_CUSTOM_TONE_BYTES ||
typeof candidate.createdAt !== 'string'
) {
continue
}
acceptedIds.add(candidate.id)
tones.push({
byteSize: candidate.byteSize,
createdAt: candidate.createdAt,
durationMs: candidate.durationMs,
id: `custom:${candidate.id}`,
label: candidate.label,
mimeType: candidate.mimeType,
source,
toneType: expectedType,
})
}
return tones
}
export function parseCustomPhoneToneCatalog(
value: unknown,
): CustomPhoneToneCatalog {
const source =
value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
return {
notificationSounds: parseToneList(
source.notificationSounds,
'notification',
),
ringtones: parseToneList(source.ringtones, 'ringtone'),
}
}
export function findCustomPhoneTone(
tones: CustomPhoneTone[],
id: string,
): CustomPhoneTone | undefined {
return isCustomTonePreferenceId(id)
? tones.find((tone) => tone.id === id)
: undefined
}
function cacheSource(id: string, source: string): void {
cachedSources.delete(id)
cachedSources.set(id, source)
while (cachedSources.size > MAX_CACHED_TONES) {
const oldest = cachedSources.keys().next().value
if (typeof oldest !== 'string') break
cachedSources.delete(oldest)
}
}
export function cacheCustomPhoneTonePayload(
id: CustomTonePreferenceId,
mimeType: string,
payload: string,
): boolean {
if (
!isCustomTonePreferenceId(id) ||
!ALLOWED_MIME_TYPES.has(mimeType) ||
payload.length < 1 ||
payload.length > MAX_CUSTOM_TONE_PAYLOAD_CHARS ||
payload.length % 4 !== 0 ||
!/^[A-Za-z0-9+/]*={0,2}$/.test(payload)
) {
return false
}
cacheSource(id.slice('custom:'.length), `data:${mimeType};base64,${payload}`)
return true
}
async function loadToneSource(tone: CustomPhoneTone): Promise<string> {
const rawId = tone.id.slice('custom:'.length)
const cached = cachedSources.get(rawId)
if (cached) {
cacheSource(rawId, cached)
return cached
}
const response = await nuiCall<CustomToneAudio>('tones:audio', {
id: rawId,
})
const audio = response.data
if (
!response.success ||
!audio ||
audio.id !== rawId ||
!ALLOWED_MIME_TYPES.has(audio.mimeType) ||
typeof audio.payload !== 'string' ||
audio.payload.length < 1 ||
audio.payload.length > MAX_CUSTOM_TONE_PAYLOAD_CHARS ||
audio.payload.length % 4 !== 0 ||
!/^[A-Za-z0-9+/]*={0,2}$/.test(audio.payload)
) {
throw new Error(response.error ?? 'invalid_custom_tone')
}
const source = `data:${audio.mimeType};base64,${audio.payload}`
cacheSource(rawId, source)
return source
}
export function playCustomPhoneTone(
tone: CustomPhoneTone,
volumePercent: number,
loop: boolean,
callbacks: {
onError?: (error: unknown) => void
onStarted?: () => void
} = {},
): () => void {
let stopped = false
let stopPlayback: (() => void) | null = null
void loadToneSource(tone)
.then((source) => {
if (!stopped) {
stopPlayback = playPhoneMediaTone(
source,
volumePercent,
loop,
callbacks,
)
}
})
.catch((error: unknown) => {
if (!stopped) {
console.error('[Phone audio] Failed to load custom tone', error)
callbacks.onError?.(error)
}
})
return () => {
stopped = true
stopPlayback?.()
stopPlayback = null
}
}
+6
View File
@@ -75,6 +75,12 @@ export function registerPhoneMediaElement<T extends HTMLMediaElement>(
return trackPhoneMediaElement(element, true)
}
export function unregisterPhoneMediaElement(element: HTMLMediaElement): void {
element.removeEventListener('volumechange', onMediaVolumeChange)
mediaElements.delete(element)
mediaLocalVolumes.delete(element)
}
export function setPhoneOutputVolume(volume: number): void {
outputVolume = clampVolume(volume)
for (const [element, persistent] of mediaElements) {
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
normalizePhoneViewportRect,
phoneViewportRectContainsPoint,
readPhoneViewportGeometry,
type PhoneViewportRect,
} from '@/utils/phoneViewportGeometry'
@@ -63,7 +64,78 @@ 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)
@@ -124,6 +196,76 @@ 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,6 +7,14 @@ 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'
+37
View File
@@ -2,11 +2,16 @@ import { describe, expect, it } from 'vitest'
import type { LaunchablePhoneAppId } from '@/types/apps'
import {
DEFAULT_PHONE_PREFERENCES,
PHONE_SCALE_STEP,
PHONE_SETUP_LAST_STEP,
parsePhonePreferences,
WALLPAPER_IDS,
} from './preferences'
describe('preferences', () => {
it('allows one-percent phone scale adjustments', () => {
expect(PHONE_SCALE_STEP).toBe(1)
})
it('starts Setup Assistant for a phone without saved settings', () => {
const value = parsePhonePreferences(null)
@@ -118,6 +123,38 @@ describe('preferences', () => {
expect(value.settings.screenBrightness).toBe(10)
})
it('preserves valid custom tone ids without persisting their URLs', () => {
const notificationId = 'custom:1fbd07b4-d231-4a76-b661-c9e5d0ab19a8'
const ringtoneId = 'custom:a1e8db72-bc56-41c8-84e2-521180e14db1'
const value = parsePhonePreferences(
JSON.stringify({
version: 1,
settings: {
notificationSound: notificationId,
ringtone: ringtoneId,
},
}),
)
expect(value.settings.notificationSound).toBe(notificationId)
expect(value.settings.ringtone).toBe(ringtoneId)
})
it('rejects malformed custom tone ids', () => {
const value = parsePhonePreferences(
JSON.stringify({
version: 1,
settings: {
notificationSound: 'custom:../unsafe',
ringtone: 'custom:',
},
}),
)
expect(value.settings.notificationSound).toBe('chime')
expect(value.settings.ringtone).toBe('skyline')
})
it('keeps the phone above the minimum usable scale', () => {
const value = parsePhonePreferences(
JSON.stringify({
+40 -5
View File
@@ -1,5 +1,9 @@
import type { LaunchablePhoneAppId } from '@/types/apps'
import { cloneJsonData } from '@/utils/clone'
import {
isCustomTonePreferenceId,
type CustomTonePreferenceId,
} from '@/utils/customTones'
export const APPEARANCE_MODE_IDS = ['automatic', 'light', 'dark'] as const
export const GRAPHICS_MODE_IDS = ['performance', 'ultimate'] as const
@@ -38,14 +42,18 @@ export const WALLPAPER_IDS = [
] as const
export const PHONE_SCALE_MIN = 75
export const PHONE_SCALE_MAX = 150
export const PHONE_SCALE_STEP = 5
export const PHONE_SCALE_STEP = 1
export const PHONE_SETUP_LAST_STEP = 9
export type AppearanceMode = (typeof APPEARANCE_MODE_IDS)[number]
export type GraphicsMode = (typeof GRAPHICS_MODE_IDS)[number]
export type PhoneFrameId = (typeof PHONE_FRAME_IDS)[number]
export type RingtoneId = (typeof RINGTONE_IDS)[number]
export type NotificationSoundId = (typeof NOTIFICATION_SOUND_IDS)[number]
export type BuiltInRingtoneId = (typeof RINGTONE_IDS)[number]
export type BuiltInNotificationSoundId = (typeof NOTIFICATION_SOUND_IDS)[number]
export type RingtoneId = BuiltInRingtoneId | CustomTonePreferenceId
export type NotificationSoundId =
| BuiltInNotificationSoundId
| CustomTonePreferenceId
export type BuiltInWallpaperId = (typeof WALLPAPER_IDS)[number]
export type WallpaperId = BuiltInWallpaperId | 'custom'
export type WallpaperTarget = 'home' | 'lock'
@@ -196,6 +204,33 @@ function readChoice<T extends string>(
: fallback
}
export function isBuiltInRingtoneId(
value: unknown,
): value is BuiltInRingtoneId {
return (
typeof value === 'string' &&
RINGTONE_IDS.includes(value as BuiltInRingtoneId)
)
}
export function isBuiltInNotificationSoundId(
value: unknown,
): value is BuiltInNotificationSoundId {
return (
typeof value === 'string' &&
NOTIFICATION_SOUND_IDS.includes(value as BuiltInNotificationSoundId)
)
}
function readToneChoice<T extends string>(
value: unknown,
choices: readonly T[],
fallback: T,
): T | CustomTonePreferenceId {
if (isCustomTonePreferenceId(value)) return value
return readChoice(value, choices, fallback)
}
function readWallpaperImageUrl(value: unknown): string | null {
if (typeof value !== 'string') return null
const imageUrl = value.trim()
@@ -328,7 +363,7 @@ export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 {
),
lockWallpaper,
lockWallpaperImageUrl,
notificationSound: readChoice(
notificationSound: readToneChoice(
settings.notificationSound,
NOTIFICATION_SOUND_IDS,
defaults.notificationSound,
@@ -354,7 +389,7 @@ export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 {
Number.MAX_SAFE_INTEGER,
),
),
ringtone: readChoice(
ringtone: readToneChoice(
settings.ringtone,
RINGTONE_IDS,
defaults.ringtone,
+55 -1
View File
@@ -1,7 +1,11 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ALARM_SOUND_IDS } from './alarms'
import { phoneToneDuration, playPhoneVibration } from './tones'
import {
phoneToneDuration,
playPhoneMediaTone,
playPhoneVibration,
} from './tones'
describe('phone tones', () => {
afterEach(() => vi.unstubAllGlobals())
@@ -27,6 +31,8 @@ describe('phone tones', () => {
pause: () => void
play: () => Promise<void>
preload: string
load: () => void
removeAttribute: (name: string) => void
src: string
volume: number
}> = []
@@ -41,6 +47,12 @@ describe('phone tones', () => {
src: string
volume = 0
load(): void {}
removeAttribute(name: string): void {
if (name === 'src') this.src = ''
}
constructor(src: string) {
super()
this.src = src
@@ -63,4 +75,46 @@ describe('phone tones', () => {
expect(players[0].currentTime).toBe(0)
},
)
it('reports media playback only after play has actually started', async () => {
let resolvePlay: (() => void) | undefined
const onError = vi.fn()
const onStarted = vi.fn()
vi.stubGlobal(
'Audio',
class extends EventTarget {
loop = false
preload = ''
volume = 0
load(): void {}
pause(): void {}
play(): Promise<void> {
return new Promise((resolve) => {
resolvePlay = resolve
})
}
removeAttribute(): void {}
},
)
const stop = playPhoneMediaTone(
'data:audio/ogg;base64,T2dnUw==',
75,
false,
{
onError,
onStarted,
},
)
expect(onStarted).not.toHaveBeenCalled()
resolvePlay?.()
await vi.waitFor(() => expect(onStarted).toHaveBeenCalledOnce())
expect(onError).not.toHaveBeenCalled()
stop()
})
})
+48 -3
View File
@@ -1,8 +1,11 @@
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
import {
registerPhoneMediaElement,
unregisterPhoneMediaElement,
} from '@/utils/phoneAudio'
import type { AlarmSoundId } from '@/utils/alarms'
import type { NotificationSoundId } from '@/utils/preferences'
import type { BuiltInNotificationSoundId } from '@/utils/preferences'
export type PhoneToneId = AlarmSoundId | NotificationSoundId
export type PhoneToneId = AlarmSoundId | BuiltInNotificationSoundId
export type PhoneVibrationKind = 'call' | 'notification'
type ToneVoice = {
@@ -492,6 +495,44 @@ export function playPhoneTone(
}
}
export function playPhoneMediaTone(
url: string,
volumePercent: number,
loop: boolean,
callbacks: {
onError?: (error: unknown) => void
onStarted?: () => void
} = {},
): () => void {
const player = new Audio(url)
player.loop = loop
player.preload = 'auto'
player.volume = Math.max(0, Math.min(1, volumePercent / 100))
registerPhoneMediaElement(player)
let stopped = false
void player
.play()
.then(() => {
if (!stopped) callbacks.onStarted?.()
})
.catch((error: unknown) => {
if (!stopped) {
console.error('[Phone audio] Failed to start custom tone', error)
callbacks.onError?.(error)
}
})
return () => {
if (stopped) return
stopped = true
player.pause()
player.removeAttribute('src')
player.load()
unregisterPhoneMediaElement(player)
}
}
export function playPhoneVibration(
kind: PhoneVibrationKind,
loop: boolean,
@@ -510,8 +551,12 @@ export function playPhoneVibration(
})
return () => {
if (stopped) return
stopped = true
player.pause()
player.currentTime = 0
player.removeAttribute('src')
player.load()
unregisterPhoneMediaElement(player)
}
}
+5 -1
View File
@@ -30,7 +30,11 @@ const launchStyle = computed(() => {
<div
v-if="app && !app.adminOnly"
class="app-window"
:class="{ 'app-window--citywarn': app.id === 'citywarn' }"
:class="{
'app-window--camera-landscape':
app.id === 'camera' && phone.cameraLandscape,
'app-window--citywarn': app.id === 'citywarn',
}"
:style="launchStyle"
>
<CustomAppFrame
@@ -31,6 +31,22 @@ 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', () => {
@@ -136,7 +152,7 @@ describe('Springboard page swipe contract', () => {
)
expect(
viewSource.match(/:external-drag-visual="homeDragVisualActive"/g),
).toHaveLength(4)
).toHaveLength(5)
expect(viewSource).toContain('updateHomeDragGhost(event)')
expect(viewSource).toContain('const dropGhost = homeDragGhost')
expect(viewSource).toContain('const dropOrigin = homeDragPreviewBounds')
@@ -160,6 +176,65 @@ 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(
+236 -58
View File
@@ -42,6 +42,7 @@ import {
} from '@/utils/homeLayout'
import type { ReorderDirection } from '@/utils/keyboard'
import {
phoneViewportRectContainsPoint,
readPhoneViewportGeometry,
type PhoneViewportGeometry,
type PhoneViewportRect,
@@ -111,6 +112,11 @@ 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)
@@ -610,7 +616,11 @@ function queueEdgePageTurn(
edgePageTimer = undefined
edgePageDirection = direction
edgePageLocked = dragType === 'widget'
if (dragType === 'app' && lastHomePointer && draggingHomeApp.value) {
if (
dragType === 'app' &&
lastHomePointer &&
(draggingHomeApp.value || draggingFolderApp.value)
) {
queueEdgePageTurn(lastHomePointer, 'app')
}
}, HOME_EDGE_PAGE_TURN_DELAY)
@@ -814,7 +824,7 @@ function updateHomeDragGhost(event: {
)
}
function createHomeDragGhost(event: PointerEvent): void {
function createHomeDragGhost(event: PointerEvent): HTMLElement | null {
clearHomeDragGhost()
const eventTarget =
event.currentTarget instanceof Element
@@ -827,7 +837,7 @@ function createHomeDragGhost(event: PointerEvent): void {
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
if (!source || !layer || !springboard || !portal || !geometry) return null
const portalBounds = portal.getBoundingClientRect()
const clipBounds = geometry.rect(springboard)
@@ -838,7 +848,7 @@ function createHomeDragGhost(event: PointerEvent): void {
sourceBounds.width <= 0 ||
sourceBounds.height <= 0
) {
return
return null
}
layer.style.left = `${clipBounds.left - portalBounds.left}px`
@@ -865,7 +875,12 @@ function createHomeDragGhost(event: PointerEvent): void {
for (const element of [ghost, ...Array.from(ghost.querySelectorAll('*'))]) {
element.removeAttribute('id')
for (const attribute of element.getAttributeNames()) {
if (attribute.startsWith('data-home-')) element.removeAttribute(attribute)
if (
attribute.startsWith('data-home-') ||
attribute.startsWith('data-folder-')
) {
element.removeAttribute(attribute)
}
}
}
for (const control of ghost.querySelectorAll<HTMLElement>(
@@ -894,6 +909,7 @@ function createHomeDragGhost(event: PointerEvent): void {
layer.appendChild(position)
homeDragVisualActive.value = true
updateHomeDragGhost(event)
return position
}
function startHomeDrag(
@@ -1190,6 +1206,98 @@ 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()
@@ -1324,16 +1432,83 @@ 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 {
folderDraggingOutside.value = false
openedFolderId.value = null
renameFolderOnOpenId.value = null
stopOpenedFolderAppDrag()
resetOpenedFolderState()
}
function renameOpenedFolder(name: string): void {
@@ -1342,71 +1517,68 @@ function renameOpenedFolder(name: string): void {
}
function moveOpenedFolderApp(sourceIndex: number, targetIndex: number): void {
if (!openedFolderId.value) return
appStore.moveHomeFolderApp(openedFolderId.value, sourceIndex, targetIndex)
const session = currentOpenedFolderDragSession(sourceIndex)
stopOpenedFolderAppDrag()
if (!session) return
appStore.moveHomeFolderApp(session.folderId, sourceIndex, targetIndex)
}
function extractOpenedFolderApp(
sourceIndex: number,
event: PointerEvent,
): void {
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) {
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)
if (!target) {
clearEdgePageTurn()
draggingFolderApp.value = null
lastHomePointer = null
clearHomeDragGhost()
clearTemporaryHomePage()
resetOpenedFolderState()
return
}
const area = target.dataset.homeArea as HomeArea
const targetIndex = Number(target.dataset.homeIndex)
const { area, index: targetIndex } = target
if (
!appStore.extractHomeFolderApp(folder.id, sourceIndex, area, targetIndex)
!appStore.extractHomeFolderApp(
session.folderId,
session.sourceIndex,
area,
targetIndex,
)
) {
closeFolder()
return
}
closeFolder()
if (sourceBounds) {
const extractedIndex = findHomeItemIndex(appId, area, targetIndex)
if (extractedIndex !== null) {
void animateHomeItemDrop(appId, area, extractedIndex, sourceBounds)
}
clearEdgePageTurn()
draggingFolderApp.value = null
lastHomePointer = null
clearTemporaryHomePage(area === 'grid')
resetOpenedFolderState()
const extractedIndex = findHomeItemIndex(session.appId, area, targetIndex)
if (extractedIndex === null) {
clearHomeDragGhost(dropGhost)
return
}
void animateHomeItemDrop(
session.appId,
area,
extractedIndex,
dropOrigin,
).finally(() => clearHomeDragGhost(dropGhost))
}
function clearSearch(): void {
@@ -1426,11 +1598,12 @@ watch(isEditablePage, (visible) => {
watch(editMode, (editing) => {
emit('editModeChange', editing)
if (editing) return
stopOpenedFolderAppDrag()
stopHomeDrag()
stopWidgetDrag()
})
watch(openedFolder, (folder) => {
if (!folder) closeFolder()
if (!folder && openedFolderId.value !== null) closeFolder()
})
onBeforeUnmount(() => {
emit('editModeChange', false)
@@ -1445,6 +1618,7 @@ onBeforeUnmount(() => {
clearHomeDragGhost()
temporaryHomePage.value = null
draggingHomeApp.value = null
draggingFolderApp.value = null
draggingWidgetId.value = null
})
</script>
@@ -1858,9 +2032,13 @@ 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,6 +81,7 @@ describe('AppStoreApp Sky navigation contract', () => {
expect(source).toContain('@click="profileOpened = true"')
expect(source).toContain('const installedApps = computed')
expect(source).toContain('return !appStore.isInstalled(app.id)')
expect(source).toContain('if (!appStore.isAvailable(app.id)) return false')
expect(source).toContain(
'class="store-account__apps phone-effect--expensive-shadow"',
)
@@ -100,6 +101,10 @@ describe('AppStoreApp Sky navigation contract', () => {
expect(source).toContain(
'appStore.uninstallApp(uninstallCandidate.value.id)',
)
expect(source).toContain('@click.stop="requestUninstall(app)"')
expect(source).toContain('if (!appStore.uninstallApp(')
expect(source).toContain('Apps.appStore.account.uninstallFailed')
expect(source).toContain('role="alert"')
expect(source).toContain('v-if="isPhoneAppRemovable(app)"')
expect(source).toContain(':opened="Boolean(uninstallCandidate)"')
expect(source).toContain('class="store-account__grabber"')
+37 -7
View File
@@ -63,6 +63,7 @@ const openedAt = new Date()
const featuredSlide = ref(0)
const profileOpened = ref(false)
const uninstallCandidate = ref<LaunchablePhoneAppDefinition | null>(null)
const uninstallError = ref('')
const selectedApp = ref<LaunchablePhoneAppDefinition | null>(null)
const storeScroll = ref<ComponentPublicInstance | null>(null)
const featuredScroller = ref<HTMLElement | null>(null)
@@ -122,6 +123,7 @@ const catalog = computed(() =>
if (!isLaunchablePhoneApp(app) || app.id === 'app-store' || app.adminOnly) {
return false
}
if (!appStore.isAvailable(app.id)) return false
return !appStore.isInstalled(app.id)
}).sort((a, b) => a.gridOrder - b.gridOrder),
@@ -129,7 +131,9 @@ const catalog = computed(() =>
const installedApps = computed(() =>
PHONE_APPS.filter(
(app): app is LaunchablePhoneAppDefinition =>
isLaunchablePhoneApp(app) && appStore.isInstalled(app.id),
isLaunchablePhoneApp(app) &&
appStore.isAvailable(app.id) &&
appStore.isInstalled(app.id),
).sort((a, b) => a.gridOrder - b.gridOrder),
)
const installedGameCount = computed(
@@ -143,6 +147,7 @@ const dailyCandidates = computed(() =>
!app.adminOnly &&
!isExternalPhoneApp(app) &&
app.id !== 'app-store' &&
appStore.isAvailable(app.id) &&
!DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) &&
!appStore.isInstalled(app.id),
),
@@ -284,6 +289,16 @@ function closeProfile(): void {
profileDragOffset.value = 0
}
function requestUninstall(app: LaunchablePhoneAppDefinition): void {
uninstallError.value = ''
uninstallCandidate.value = app
}
function closeUninstallDialog(): void {
uninstallError.value = ''
uninstallCandidate.value = null
}
function beginProfileDrag(event: PointerEvent): void {
if (!profileOpened.value || event.button !== 0) return
profileDragPointerId = event.pointerId
@@ -314,8 +329,11 @@ function endProfileDrag(event: PointerEvent): void {
function confirmUninstall(): void {
if (!uninstallCandidate.value) return
appStore.uninstallApp(uninstallCandidate.value.id)
uninstallCandidate.value = null
if (!appStore.uninstallApp(uninstallCandidate.value.id)) {
uninstallError.value = phone.t('Apps.appStore.account.uninstallFailed')
return
}
closeUninstallDialog()
}
function highlightStyle(index: number): Record<string, string> {
@@ -1103,7 +1121,7 @@ watch(
app: getPhoneAppLabel(app, phone.t),
})
"
@click="uninstallCandidate = app"
@click.stop="requestUninstall(app)"
>
<Trash2 :size="17" :stroke-width="2" aria-hidden="true" />
</button>
@@ -1115,8 +1133,8 @@ watch(
<SkyDialog
:opened="Boolean(uninstallCandidate)"
role="alertdialog"
@backdropclick="uninstallCandidate = null"
@escape="uninstallCandidate = null"
@backdropclick="closeUninstallDialog"
@escape="closeUninstallDialog"
>
<template #title>
{{ phone.t('Apps.appStore.account.uninstallTitle') }}
@@ -1128,8 +1146,15 @@ watch(
})
}}
</p>
<p
v-if="uninstallError"
class="store-account__uninstall-error"
role="alert"
>
{{ uninstallError }}
</p>
<template #buttons>
<SkyDialogButton @click="uninstallCandidate = null">
<SkyDialogButton @click="closeUninstallDialog">
{{ phone.t('Common.cancel') }}
</SkyDialogButton>
<SkyDialogButton strong @click="confirmUninstall">
@@ -1472,6 +1497,11 @@ watch(
background: var(--sky-danger-soft);
}
.store-account__uninstall-error {
color: var(--sky-danger);
font-size: 12px;
}
.store-scroll {
min-height: 0;
flex: 1 1 auto;
@@ -6,6 +6,15 @@ 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',
@@ -47,6 +56,99 @@ 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"')
@@ -77,7 +179,8 @@ 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.spaceKey')
expect(cameraView).toContain('Apps.camera.lookKey')
expect(cameraView).not.toContain('Apps.camera.spaceKey')
expect(cameraClient).toContain('RegisterNUICallback("camera:setLocked"')
expect(cameraClient).toContain('INPUT_LOOK_LR')
expect(cameraClient).toContain('INPUT_LOOK_UD')
@@ -95,10 +198,26 @@ describe('Camera app controls', () => {
expect(cameraClient).not.toContain('ensure_ultrawide_camera')
})
it('keeps the selfie camera stable while Space still allows movement', () => {
expect(cameraView).toMatch(
/event\.code !== 'Space'[\s\S]*cameraLocked\.value/,
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()',
)
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',
)
@@ -106,14 +225,22 @@ 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(
'local dot = (to_camera.x * forward_vector.x)',
)
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('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(')
@@ -121,10 +248,6 @@ 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', () => {
+67 -65
View File
@@ -56,7 +56,6 @@ 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)
@@ -78,6 +77,16 @@ 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()}`
}
@@ -238,10 +247,6 @@ 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 })
}
@@ -340,26 +345,6 @@ 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 {
@@ -442,8 +427,6 @@ 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) => {
@@ -459,13 +442,7 @@ 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()
@@ -486,6 +463,7 @@ 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
@@ -559,10 +537,7 @@ onBeforeUnmount(() => {
</template>
</sky-fab>
</div>
<span
v-if="noticeText"
class="camera-focus-pill camera-focus-pill--notice"
>
<span v-if="noticeText" class="camera-notice-text">
{{ noticeText }}
</span>
<span v-else-if="pendingCount" class="camera-upload-pill">
@@ -587,7 +562,7 @@ onBeforeUnmount(() => {
>
<LockKeyhole v-if="cameraLocked" :size="12" />
<LockOpen v-else :size="12" />
<kbd>{{ phone.t('Apps.camera.spaceKey') }}</kbd>
<kbd>{{ phone.t('Apps.camera.lookKey') }}</kbd>
</SkyButton>
<sky-fab
component="button"
@@ -618,16 +593,16 @@ onBeforeUnmount(() => {
<SkyButton
v-for="zoom in zoomLevels"
:key="zoom"
glass
rounded
variant="plain"
class="camera-zoom-pill"
:class="{ active: Math.abs(selectedZoom - zoom) < 0.03 }"
:class="{ active: zoomPresetIsActive(zoom) }"
type="button"
:aria-label="phone.t('Apps.camera.zoom', { zoom: `${zoom}x` })"
:aria-pressed="Math.abs(selectedZoom - zoom) < 0.03"
:aria-pressed="zoomPresetIsActive(zoom)"
@click="setZoom(zoom)"
>
{{ zoom }}x
{{ zoomPresetLabel(zoom) }}
</SkyButton>
</div>
</div>
@@ -718,10 +693,15 @@ onBeforeUnmount(() => {
<style scoped>
.camera-page {
--sky-bg: #000;
--sky-text: #fff;
position: relative;
overflow: clip;
background: #000;
color: #fff;
background: var(--sky-bg);
color: var(--sky-text);
}
.camera-page--landscape {
--sky-bg: rgba(0, 0, 0, 0.42);
}
.camera-viewport {
position: absolute;
@@ -733,11 +713,11 @@ onBeforeUnmount(() => {
transform: translateY(-50%);
}
.camera-page--landscape .camera-viewport {
top: 46%;
top: 50%;
left: 50%;
width: calc(100% * 16 / 9);
width: calc(100% * var(--phone-screen-portrait-ratio));
height: auto;
aspect-ratio: 16 / 9;
aspect-ratio: var(--phone-screen-portrait-ratio);
transform: translate(-50%, -50%) rotate(90deg);
}
.camera-game-view,
@@ -795,6 +775,16 @@ 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;
@@ -855,25 +845,22 @@ onBeforeUnmount(() => {
.camera-page--landscape .camera-latest svg {
transform: rotate(90deg);
}
.camera-focus-pill:not(.sky-button--glass),
.camera-upload-pill {
min-width: 0;
padding: 7px 10px;
overflow: hidden;
border-radius: 999px;
background: #0006;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.camera-notice-text,
.camera-upload-pill {
min-width: 0;
color: #ffd60a;
overflow: hidden;
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;
@@ -924,7 +911,7 @@ onBeforeUnmount(() => {
.camera-zoom-control {
position: absolute;
z-index: 4;
bottom: 196px;
bottom: 216px;
left: 50%;
width: auto;
transform: translateX(-50%);
@@ -932,16 +919,19 @@ onBeforeUnmount(() => {
.camera-zoom-row {
display: flex;
justify-content: space-between;
gap: 8px;
gap: 10px;
}
.camera-zoom-pill {
width: 44px;
min-width: 44px;
height: 44px;
min-height: 44px;
width: 36px;
min-width: 36px;
height: 36px;
min-height: 36px;
padding: 0;
border: 0;
color: #fff;
font-size: 10px;
background: transparent;
font-size: 12px;
font-weight: 500;
text-align: center;
transition:
background-color 0.2s ease,
@@ -949,8 +939,20 @@ 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,6 +40,10 @@ 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,
)
@@ -56,6 +60,51 @@ 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)')
+34 -17
View File
@@ -1026,7 +1026,9 @@ onMounted(async () => {
height: 18px;
}
.citywarn-scroll {
height: 100%;
min-height: 0;
height: auto;
flex: 1 1 0;
padding: 12px 13px calc(28px + env(safe-area-inset-bottom));
overflow-y: auto;
}
@@ -1045,11 +1047,11 @@ onMounted(async () => {
}
.citywarn-overview {
display: flex;
padding: 15px;
padding: var(--sky-space-3);
align-items: center;
gap: 12px;
gap: var(--sky-space-3);
border: 1px solid #bbf7d0;
border-radius: 18px;
border-radius: var(--sky-radius-card);
background: linear-gradient(135deg, #f0fdf4, #ecfdf5);
}
.citywarn-overview--active {
@@ -1062,7 +1064,7 @@ onMounted(async () => {
height: 46px;
flex: 0 0 auto;
place-items: center;
border-radius: 15px;
border-radius: var(--sky-radius-control);
color: #15803d;
background: #dcfce7;
}
@@ -1086,12 +1088,17 @@ 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: 13px 13px 12px 17px;
padding: var(--sky-space-3) var(--sky-space-3) var(--sky-space-3)
var(--sky-space-4);
overflow: hidden;
border: 1px solid #e5e7eb;
border-radius: 17px;
border-radius: var(--sky-radius-card);
background: #fff;
box-shadow: 0 4px 15px rgb(15 23 42 / 5%);
cursor: pointer;
@@ -1115,7 +1122,7 @@ onMounted(async () => {
width: 32px;
height: 32px;
place-items: center;
border-radius: 10px;
border-radius: var(--sky-radius-control);
color: var(--severity);
background: color-mix(in srgb, var(--severity) 12%, white);
}
@@ -1139,7 +1146,7 @@ onMounted(async () => {
color: #9ca3af;
}
.citywarn-alert-card h3 {
margin: 10px 0 5px;
margin: var(--sky-space-2) 0 var(--sky-space-1);
font-size: 16px;
line-height: 1.18;
}
@@ -1155,10 +1162,10 @@ onMounted(async () => {
}
.citywarn-alert-card footer {
display: flex;
margin-top: 11px;
margin-top: var(--sky-space-2);
align-items: center;
justify-content: space-between;
gap: 8px;
gap: var(--sky-space-2);
color: #6b7280;
font-size: 10.5px;
}
@@ -1172,12 +1179,13 @@ onMounted(async () => {
white-space: nowrap;
}
.citywarn-publisher-card {
margin-top: 12px;
margin: var(--sky-space-3) 0 0;
border-radius: var(--sky-radius-card);
}
.citywarn-publisher-card :deep(.sky-card__content) {
display: flex;
padding: 12px;
gap: 10px;
padding: var(--sky-space-3);
gap: var(--sky-space-3);
}
.citywarn-publisher-icon {
display: grid;
@@ -1185,7 +1193,7 @@ onMounted(async () => {
height: 36px;
flex: 0 0 auto;
place-items: center;
border-radius: 11px;
border-radius: var(--sky-radius-control);
color: #1d4ed8;
background: #dbeafe;
}
@@ -1523,11 +1531,20 @@ onMounted(async () => {
.citywarn-settings {
display: flex;
flex-direction: column;
gap: 15px;
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;
}
.citywarn-settings-hint {
display: flex;
margin: 0 8px;
margin: 0 var(--sky-space-1);
align-items: flex-start;
gap: 7px;
color: #6b7280;
@@ -369,6 +369,22 @@ describe('VaultX crypto app contracts', () => {
expect(testServer).toContain('function advanceCryptoCycle(')
})
it('does not keep market and settlement workers active while disabled', () => {
expect(server).toContain('local function start_crypto_schedulers()')
expect(server).toMatch(
/start_crypto_schedulers\(\)[\s\S]*?if Config\.Crypto\.Enabled ~= true then\s+return/,
)
expect(server).toContain(
'while scheduler_generation == generation and Config.Crypto.Enabled == true do',
)
expect(server).toContain(
'AddEventHandler("sky_phone:configurator:serverUpdated", refresh_crypto_runtime)',
)
expect(server).not.toMatch(
/CreateThread\(function\(\)\s+while true do\s+Wait\(5 \* 60 \* 1000\)/,
)
})
it('stores cash in price-scale minor units throughout the ledger', () => {
expect(server).toContain(
'local ledger_amount = amount * Config.Crypto.PriceScale',
@@ -6,6 +6,14 @@ const source = readFileSync(
new URL('./DarkChatApp.vue', import.meta.url),
'utf8',
)
const server = readFileSync(
new URL('../../../../sky_phone/source/server/darkchat.lua', import.meta.url),
'utf8',
)
const config = readFileSync(
new URL('../../../../sky_phone/config/config.lua', import.meta.url),
'utf8',
)
describe('DarkChatApp Sky UI contract', () => {
it('uses first-party Sky UI without direct Konsta markup', () => {
@@ -130,4 +138,13 @@ describe('DarkChatApp Sky UI contract', () => {
/\.dc-inbox-navbar :deep\(\.sky-navbar__title-container > div\)\s*\{[^}]*translateY\(-14px\)/s,
)
})
it('cleans expired messages in bounded indexed batches', () => {
expect(config).toContain('CleanupBatchSize = 250')
expect(server).toContain('Config.DarkChat.CleanupBatchSize')
expect(server).toMatch(
/DELETE FROM `sky_phone_darkchat_messages`[\s\S]*?ORDER BY `expires_at`, `id`[\s\S]*?LIMIT \?/,
)
expect(server).toContain(']], { batch_size })')
})
})
@@ -203,7 +203,7 @@ describe('MessagesApp Sky UI contract', () => {
expect(sheetStart).toBeGreaterThan(-1)
expect(sheetEnd).toBeGreaterThan(sheetStart)
expect(sheet).toContain(
':opened="activeCanMessage && attachmentPicker !== null"',
'activeCanMessage && !activeServiceLine && attachmentPicker !== null',
)
expect(sheet).toContain('swipe-to-close')
expect(sheet).toContain('grabber-clickable')
+57 -12
View File
@@ -215,6 +215,9 @@ const activeContact = computed(() =>
const activeCanMessage = computed(
() => activeContact.value?.canMessage !== false,
)
const activeServiceLine = computed(
() => activeContact.value?.source === 'company',
)
const activeContactEmail = computed(() =>
normalizeMailAddress(activeContact.value?.email ?? ''),
)
@@ -255,12 +258,15 @@ const inboxMenuItems = computed(() => [
},
])
const attachmentPanelOpen = computed(
() => emojiOpen.value || attachmentPicker.value !== null,
() =>
emojiOpen.value ||
(!activeServiceLine.value && attachmentPicker.value !== null),
)
const composerHasContent = computed(
() =>
Boolean(draft.value.trim() || shareDraft.value) ||
pendingAttachments.value.length > 0,
Boolean(draft.value.trim()) ||
(!activeServiceLine.value &&
(Boolean(shareDraft.value) || pendingAttachments.value.length > 0)),
)
function contactName(number: string): string {
return (
@@ -417,6 +423,8 @@ function errorText(error?: string): string {
'gif_provider_failed',
'self_message',
'recipient_not_found',
'messaging_unavailable',
'service_line_text_only',
'no_sim',
'rate_limited',
'blocked',
@@ -614,6 +622,7 @@ async function callActiveContact(): Promise<void> {
}
function toggleAttachmentMenu(): void {
if (activeServiceLine.value) return
attachmentMenuOpen.value = !attachmentMenuOpen.value
emojiOpen.value = false
attachmentPicker.value = null
@@ -646,7 +655,7 @@ function openMediaApp(
app: 'camera' | 'photos',
mediaType: 'photo' | 'video',
): void {
if (!messages.activeNumber) return
if (!messages.activeNumber || activeServiceLine.value) return
const remainingSlots =
MAX_PENDING_ATTACHMENTS - pendingAttachments.value.length
if (remainingSlots < 1) {
@@ -707,7 +716,14 @@ async function sendAttachment(
mediaAssetId: string,
mediaDurationMs?: number,
): Promise<void> {
if (!messages.activeNumber || !activeCanMessage.value || sending.value) return
if (
!messages.activeNumber ||
!activeCanMessage.value ||
activeServiceLine.value ||
sending.value
) {
return
}
attachmentMenuOpen.value = false
attachmentPicker.value = null
sending.value = true
@@ -722,7 +738,7 @@ async function sendAttachment(
}
async function sendContact(contact: PhoneContact): Promise<void> {
if (!messages.activeNumber || sending.value) return
if (!messages.activeNumber || activeServiceLine.value || sending.value) return
attachmentMenuOpen.value = false
attachmentPicker.value = null
sending.value = true
@@ -885,7 +901,12 @@ function sampleMicrophone(): void {
}
async function startVoiceRecording(): Promise<void> {
if (!activeCanMessage.value || recording.value || recordingStarting.value) {
if (
!activeCanMessage.value ||
activeServiceLine.value ||
recording.value ||
recordingStarting.value
) {
return
}
emojiOpen.value = false
@@ -1041,6 +1062,27 @@ 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()
@@ -1614,7 +1656,7 @@ onBeforeUnmount(() => {
</SkyScrollArea>
<section
v-if="activeCanMessage && attachmentMenuOpen"
v-if="activeCanMessage && !activeServiceLine && attachmentMenuOpen"
class="messages-attachment-menu"
:aria-hidden="contactDetailsOpen"
:inert="contactDetailsOpen || undefined"
@@ -1659,7 +1701,9 @@ onBeforeUnmount(() => {
<SkySheet
class="messages-media-picker-sheet"
:opened="activeCanMessage && attachmentPicker !== null"
:opened="
activeCanMessage && !activeServiceLine && attachmentPicker !== null
"
:aria-label="
phone.t(
attachmentPicker === 'contacts'
@@ -1794,7 +1838,7 @@ onBeforeUnmount(() => {
/>
<div
v-if="activeCanMessage && shareDraft && !recording"
v-if="activeCanMessage && !activeServiceLine && shareDraft && !recording"
class="shared-composer-preview"
:aria-hidden="contactDetailsOpen"
:inert="contactDetailsOpen || undefined"
@@ -1810,7 +1854,7 @@ onBeforeUnmount(() => {
</div>
<section
v-if="activeCanMessage && recording"
v-if="activeCanMessage && !activeServiceLine && recording"
class="messages-recorder"
:aria-hidden="contactDetailsOpen"
:inert="contactDetailsOpen || undefined"
@@ -1898,6 +1942,7 @@ onBeforeUnmount(() => {
<div class="messages-sky-composer-row">
<SkyGlass
v-if="!activeServiceLine"
component="button"
type="button"
class="messages-sky-messagebar__action messages-sky-messagebar__plus"
@@ -1934,7 +1979,7 @@ onBeforeUnmount(() => {
<ArrowUpCircle :size="29" :stroke-width="2.4" />
</SkyLink>
<SkyLink
v-else
v-else-if="!activeServiceLine"
icon-only
class="messages-sky-messagebar__send"
:disabled="sending || recordingStarting"
@@ -16,6 +16,12 @@ describe('PhoneApp EasyShare contract', () => {
it('uses the shared full-width Sky tab bar for phone sections', () => {
expect(source).toContain('<sky-tab-bar')
expect(source).toContain('<sky-tab-button')
expect(source).toContain(
'calc(var(--sky-tabbar-height) + var(--sky-safe-area-bottom) + 16px);',
)
expect(source).not.toMatch(
/\.phone-contacts\s*\{[^}]*padding-bottom:\s*20px;/s,
)
})
it('uses shared interactive liquid glass surfaces for phone controls', () => {
@@ -57,6 +63,21 @@ 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 () => {'),
+57 -1
View File
@@ -2250,7 +2250,6 @@ onBeforeUnmount(() => {
position: relative;
padding-top: 6px;
padding-right: 24px;
padding-bottom: 20px;
}
.phone-contacts-header {
@@ -3859,6 +3858,63 @@ 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;
}
+37 -10
View File
@@ -96,6 +96,11 @@ import {
type WallpaperTarget,
} from '@/utils/preferences'
type ToneChoice<T extends string> = {
id: T
label: string
}
type SettingsView =
| 'root'
| 'account'
@@ -618,6 +623,26 @@ function selectNotificationSound(sound: NotificationSoundId): void {
phone.setPreference('notificationSound', sound)
}
const ringtoneChoices = computed<ToneChoice<RingtoneId>[]>(() => [
...RINGTONE_IDS.map((id) => ({
id,
label: phone.t('Apps.settings.ringtones.' + id),
})),
...phone.customTones.ringtones.map(({ id, label }) => ({ id, label })),
])
const notificationSoundChoices = computed<ToneChoice<NotificationSoundId>[]>(
() => [
...NOTIFICATION_SOUND_IDS.map((id) => ({
id,
label: phone.t('Apps.settings.notificationSoundsList.' + id),
})),
...phone.customTones.notificationSounds.map(({ id, label }) => ({
id,
label,
})),
],
)
function updateAccountEmail(event: Event): void {
const input = event.target as HTMLInputElement
const original = input.value
@@ -1276,23 +1301,25 @@ onBeforeUnmount(() => {
<SkySettingsGroup :title="phone.t('Apps.settings.ringtone')">
<SkySettingsRow
v-for="ringtone in RINGTONE_IDS"
:key="ringtone"
v-for="ringtone in ringtoneChoices"
:key="ringtone.id"
kind="choice"
:selected="phone.preferences.settings.ringtone === ringtone"
:title="phone.t('Apps.settings.ringtones.' + ringtone)"
@activate="selectRingtone(ringtone)"
:selected="phone.preferences.settings.ringtone === ringtone.id"
:title="ringtone.label"
@activate="selectRingtone(ringtone.id)"
/>
</SkySettingsGroup>
<SkySettingsGroup :title="phone.t('Apps.settings.notificationSound')">
<SkySettingsRow
v-for="sound in NOTIFICATION_SOUND_IDS"
:key="sound"
v-for="sound in notificationSoundChoices"
:key="sound.id"
kind="choice"
:selected="phone.preferences.settings.notificationSound === sound"
:title="phone.t('Apps.settings.notificationSoundsList.' + sound)"
@activate="selectNotificationSound(sound)"
:selected="
phone.preferences.settings.notificationSound === sound.id
"
:title="sound.label"
@activate="selectNotificationSound(sound.id)"
/>
</SkySettingsGroup>
</template>
@@ -32,6 +32,52 @@ 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')
+47 -25
View File
@@ -622,26 +622,26 @@ onBeforeUnmount(() => {
</k-card>
<template v-else>
<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">
<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>
<template v-if="activeTab === 'home'">
<section v-if="mode === 'rider'" class="skyride-home-panel">
<template v-if="!skyride.activeRide">
@@ -1816,6 +1816,11 @@ 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;
@@ -1853,7 +1858,9 @@ onBeforeUnmount(() => {
.skyride-scroll {
position: absolute;
z-index: 2;
inset: 148px 0 116px;
box-sizing: border-box;
inset: 114px 0 0;
padding: 0 0 116px;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
@@ -2253,6 +2260,10 @@ onBeforeUnmount(() => {
padding: 16px;
}
.skyride-person-card {
margin-bottom: 12px;
}
.skyride-avatar,
.skyride-profile-avatar {
display: grid;
@@ -2329,13 +2340,19 @@ onBeforeUnmount(() => {
}
.skyride-route-stop {
display: flex;
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
align-items: center;
gap: 11px;
column-gap: 11px;
}
.skyride-route-stop > .skyride-dot {
justify-self: center;
}
.skyride-route-stop > svg {
flex: 0 0 18px;
width: 18px;
height: 18px;
color: var(--ride-accent-strong);
}
@@ -2366,7 +2383,7 @@ onBeforeUnmount(() => {
display: block;
width: 1px;
height: 17px;
margin: 1px 0 1px 5px;
margin: 1px 0 1px 8px;
border-left: 1px dashed var(--ride-muted);
}
@@ -2691,6 +2708,11 @@ 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;
@@ -2969,7 +2991,7 @@ button.skyride-profile-avatar--editable {
background-color: var(--ride-accent);
}
.skyride-app :deep(.sky-button--primary) {
.skyride-app :deep(.sky-button--primary:not(.sky-button--outline)) {
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': 2,
'CameraApp.vue': 1,
'FeatherApp.vue': 2,
'FlareApp.vue': 1,
'FlipTokApp.vue': 2,
@@ -116,6 +116,35 @@ describe('voice provider contracts', () => {
expect(phoneApp).not.toContain('callMuted = !callMuted')
})
it('keeps calls compatible with Yaca releases before the server status export', () => {
expect(serverVoice).toContain('is_missing_yaca_status_export')
expect(serverVoice).toContain('normalized:find("no such export", 1, true)')
expect(serverVoice).toContain('warned_about_legacy_yaca_status')
expect(serverVoice).toContain(
'using legacy compatibility because yaca-voice is started',
)
expect(serverVoice).toMatch(
/if is_missing_yaca_status_export\(enabled\) then[\s\S]*?return true/,
)
expect(serverVoice).toMatch(
/if success then\s+return enabled == true\s+end/,
)
})
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,10 +476,89 @@ 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' }
}
if (
scope === 'config' &&
/^Radio\.LockedChannels\.\d+\.jobs$/.test(path) &&
value !== null &&
typeof value === 'object' &&
!Array.isArray(value)
) {
return {
fields: Object.fromEntries(
Object.entries(value).map(([key, child]) => [
key,
buildStructure(child, scope, `${path}.${key}`),
]),
),
kind: 'table',
mutableKeys: true,
template: { kind: 'value', valueType: 'boolean' },
}
}
if (
scope === 'config' &&
path === 'Companies.Definitions' &&
@@ -495,6 +574,7 @@ function buildStructure(value, scope, path) {
]),
)
return {
entryDefault: companyDefinitionEntryDefault(value),
fields,
kind: 'table',
mutableKeys: true,
@@ -661,6 +741,8 @@ function loadConfiguratorSections() {
)
const media = mediaRoot.Media
delete config.PhoneConfigurator
delete config.CommandPermissions
delete config.CustomTones
delete config.Media
return [...buildSections('config', config), ...buildSections('media', media)]
}
@@ -12,6 +12,7 @@ type ConfiguratorField = {
}
type ConfiguratorStructure = {
entryDefault?: unknown
entries?: Array<{ structure: ConfiguratorStructure }>
fields?: Record<string, ConfiguratorStructure>
items?: ConfiguratorStructure[]
@@ -61,16 +62,22 @@ function countStructure(structure: ConfiguratorStructure | undefined): number {
}
describe('admin configurator fixture', () => {
it('exposes every config.lua root through the full live preview', () => {
it('exposes every SQL-managed config.lua root through the full live preview', () => {
const sections = loadConfiguratorSections()
const 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')
.filter(
(root) =>
root !== 'Media' &&
root !== 'PhoneConfigurator' &&
root !== 'CommandPermissions' &&
root !== 'CustomTones',
)
expect(sections).toHaveLength(45)
expect(sections).toHaveLength(46)
expect(
fields.reduce(
(total, field) => total + countStructure(field.structure),
@@ -117,6 +124,22 @@ 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' },
@@ -130,6 +153,70 @@ 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('allows custom jobs in locked radio channel entries', () => {
const radio = loadConfiguratorSections()
.flatMap((section) => section.fields)
.find((field) => field.path === 'Radio')
const lockedChannels = radio?.structure?.fields?.LockedChannels
const jobs = lockedChannels?.items?.[0]?.fields?.jobs
expect(jobs).toMatchObject({
fields: {
ambulance: { kind: 'value', valueType: 'boolean' },
police: { kind: 'value', valueType: 'boolean' },
},
kind: 'table',
mutableKeys: true,
template: { kind: 'value', valueType: 'boolean' },
})
expect(lockedChannels?.template?.fields?.jobs).toMatchObject({
kind: 'table',
mutableKeys: true,
template: { kind: 'value', valueType: 'boolean' },
})
})
it('publishes fixed schemas for every empty configurable collection', () => {
const fields = loadConfiguratorSections().flatMap(
(section) => section.fields,
@@ -178,7 +265,7 @@ describe('admin configurator fixture', () => {
template: { kind: 'value', valueType: 'string' },
})
expect(
root('Companies.Definitions')?.fields?.police.fields?.Services,
root('Companies.Definitions')?.fields?.ambulance.fields?.Services,
).toMatchObject({
items: [],
kind: 'list',
+113 -18
View File
@@ -1978,7 +1978,7 @@ const contacts = [
},
{
canCall: true,
canMessage: false,
canMessage: true,
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: false,
acceptsRequests: true,
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: false,
canMessage: true,
categoryId: 'public_services',
categoryName: 'Public Services',
coverUrl: 'https://picsum.photos/seed/companies-police-cover/900/360',
@@ -4202,28 +4202,20 @@ const companyProfiles = [
label: 'Mission Row Police Station',
},
logoUrl: 'https://picsum.photos/seed/companies-police-logo/180/180',
name: 'Los Santos Police',
name: 'Los Santos Police Department',
phoneNumber: '911',
revision: 3,
services: [
{
acceptsRequests: false,
acceptsRequests: true,
active: true,
description: 'Immediate police response through the service line.',
id: 'emergency-response',
description: 'Request non-emergency police assistance.',
id: 'police-assistance',
priceText: null,
title: 'Emergency Response',
},
{
acceptsRequests: false,
active: true,
description: 'General information and non-emergency assistance.',
id: 'public-assistance',
priceText: null,
title: 'Public Assistance',
title: 'Police Assistance',
},
],
serviceSummary: 'Emergency response and public assistance',
serviceSummary: 'Non-emergency police assistance',
verified: true,
},
{
@@ -4726,6 +4718,7 @@ function adminMockBootstrap() {
targetSource: 2,
},
],
disabledApps: [],
players: [1, 2].map((source) => {
const player = adminMockPlayerDetail(source)
return {
@@ -4740,7 +4733,18 @@ function adminMockBootstrap() {
source,
}
}),
stats: { accounts: 24, devices: 31, online: 2 },
stats: {
accounts: 24,
activeDevices: 19,
auditEntries: 37,
auditToday: 4,
callsToday: 18,
devices: 31,
linkedDevices: 22,
messagesToday: 146,
online: 2,
simDevices: 27,
},
}
}
@@ -4929,6 +4933,13 @@ const adminMockConfigurator = {
sections: loadConfiguratorSections(),
}
const adminMockCustomTones = []
const adminMockToneUploads = new Map()
function adminCustomToneList() {
return adminMockCustomTones.map(({ payload: _payload, ...tone }) => tone)
}
app.post('/api/:endpoint', async (request, response, next) => {
const endpoint = request.params.endpoint
const loggedBody = { ...request.body }
@@ -4950,6 +4961,9 @@ app.post('/api/:endpoint', async (request, response, next) => {
if (endpoint === 'memos:devCapture') {
loggedBody.audioDataUrl = `<${String(request.body.audioDataUrl ?? '').length} characters>`
}
if (endpoint === 'admin:tone-upload-chunk') {
loggedBody.chunk = `<${String(request.body.chunk ?? '').length} characters>`
}
console.log('[NUI]', endpoint, loggedBody)
if (endpoint === 'music:bootstrap') {
response.json({ success: true, data: musicBootstrap() })
@@ -4963,6 +4977,87 @@ app.post('/api/:endpoint', async (request, response, next) => {
response.json({ success: true, data: adminMockConfigurator })
return
}
if (endpoint === 'admin:tones') {
response.json({ success: true, data: adminCustomToneList() })
return
}
if (endpoint === 'admin:tone-upload-start') {
const uploadId = randomUUID()
adminMockToneUploads.set(uploadId, {
chunks: [],
durationMs: Number(request.body.durationMs) || 1000,
label: String(request.body.label ?? 'Custom tone'),
mimeType: String(request.body.mimeType ?? 'audio/mpeg'),
toneType:
request.body.toneType === 'notification' ? 'notification' : 'ringtone',
})
response.json({ success: true, data: { uploadId } })
return
}
if (endpoint === 'admin:tone-upload-chunk') {
const upload = adminMockToneUploads.get(request.body.uploadId)
if (!upload) {
response.json({ success: false, error: 'invalid_upload' })
return
}
upload.chunks.push(String(request.body.chunk ?? ''))
response.json({ success: true })
return
}
if (endpoint === 'admin:tone-upload-finish') {
const upload = adminMockToneUploads.get(request.body.uploadId)
if (!upload) {
response.json({ success: false, error: 'invalid_upload' })
return
}
adminMockToneUploads.delete(request.body.uploadId)
const payload = upload.chunks.join('')
adminMockCustomTones.push({
byteSize: Buffer.from(payload, 'base64').byteLength,
createdAt: new Date().toISOString(),
createdBy: 'Development Admin',
durationMs: upload.durationMs,
id: randomUUID(),
label: upload.label,
mimeType: upload.mimeType,
payload,
source: 'database',
toneType: upload.toneType,
})
response.json({ success: true, data: adminCustomToneList() })
return
}
if (endpoint === 'admin:tone-upload-cancel') {
adminMockToneUploads.delete(request.body.uploadId)
response.json({ success: true })
return
}
if (endpoint === 'admin:delete-tone') {
const index = adminMockCustomTones.findIndex(
(tone) => tone.id === request.body.id,
)
if (index >= 0) adminMockCustomTones.splice(index, 1)
response.json({ success: true, data: adminCustomToneList() })
return
}
if (endpoint === 'tones:audio') {
const tone = adminMockCustomTones.find(
(candidate) => candidate.id === request.body.id,
)
if (!tone) {
response.json({ success: false, error: 'tone_not_found' })
return
}
response.json({
success: true,
data: {
id: tone.id,
mimeType: tone.mimeType,
payload: tone.payload,
},
})
return
}
if (endpoint === 'admin:save-configurator') {
const changes = Array.isArray(request.body.changes)
? request.body.changes
+15 -2
View File
@@ -134,7 +134,7 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
- `hex_4_inventory`
- Native ESX inventory
`mf-inventory` and `smx-inventory` are supported with ESX. The native ESX and HEX adapters use count-based items and therefore require unique phones and physical SIM cards to be disabled.
`mf-inventory` and `smx-inventory` are supported with ESX. The native ESX and HEX adapters use count-based items, so Sky Phone automatically disables unique phones and physical SIM cards while either adapter is active.
### Voice
@@ -196,6 +196,7 @@ The files contain clearly separated sections for:
| Section | Purpose |
| --- | --- |
| `Config.Bridge` | Framework, inventory, language, callback timeout, and debug mode |
| `Config.CommandPermissions` | Fixed groups for the admin panel, test data, verification commands, and social moderation |
| `Config.Phone` | Phone item, movement, unique-device mode, and development command |
| `Config.Sim` | Physical or virtual SIM behavior and number formatting |
| `Config.Calls` / `Config.Radio` | Voice providers, call behavior, radio limits, and permissions |
@@ -211,6 +212,18 @@ The files contain clearly separated sections for:
Restart `sky_phone` after changing Lua configuration.
When `Config.PhoneConfigurator.Enabled` is enabled, the generated `source/shared/config_default.lua`
provides the shipped SQL baseline. The frontend build recreates it from `config.lua` and the
server-only `media.lua`; do not edit the generated snapshot directly.
`Config.PhoneConfigurator` and `Config.CommandPermissions` remain file-owned and are omitted from
the generated default snapshot. Permissions are not displayed in the Phone Configurator and stay
authoritative while SQL configuration is enabled. Their stable keys do not change when a command is
renamed in the panel. ESX and QBCore use their framework permissions. Qbox checks the configured ACE
objects first and then its framework groups, so the standard `permissions.cfg` mapping from
`group.admin` to the `admin` ACE works with the shipped `phonepanel` permission list. Restart
`sky_phone` after changing fixed permissions.
### Language
Available locales:
@@ -312,7 +325,7 @@ Do not configure an LB Phone client event or client export. Sky Phone registers
The server registers `Config.Phone.Item` as usable for every supported inventory adapter: `ox`, `qb`, `lj`, `qs`, `codem`, `core`, `mf`, `smx`, `hex`, and `esx`. Resource startup fails visibly if the selected adapter cannot complete that registration.
The `hex` and `esx` adapters use ESX's count-based item API. They require both `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` because this API cannot persist per-item phone or physical SIM metadata. `auto` selects `hex` when `hex_4_inventory` is started and otherwise falls back to `esx` on an ESX server when no metadata-capable inventory is detected.
The `hex` and `esx` adapters use ESX's count-based item API, which cannot persist per-item phone or physical SIM metadata. Sky Phone automatically forces `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` while either adapter is active. `auto` selects `hex` when `hex_4_inventory` is started and otherwise falls back to `esx` on an ESX server when no metadata-capable inventory is detected.
### QBCore-style item tables
+110 -15
View File
@@ -10,12 +10,27 @@
Keep option names unchanged. Restart sky_phone after editing this file.
]]
-- When enabled, config.lua and media.lua only provide first-run defaults.
-- The active configuration is loaded from SQL and managed through /phonepanel.
-- CONFIG_DEFAULT_EXCLUDE_START
-- When enabled, the active configuration is loaded from SQL and managed through
-- /phonepanel. Frontend builds snapshot the shipped defaults from config.lua and
-- media.lua into source/shared/config_default.lua.
Config.PhoneConfigurator = {
Enabled = true,
}
-- Fixed server permissions. These values remain authoritative even while the
-- Phone Configurator is enabled and are intentionally not shown in its panel.
-- Group names use the active framework's permissions. On Qbox they also match
-- ACE objects such as "admin" from the standard permissions.cfg.
Config.CommandPermissions = {
phonepanel = { "god", "superadmin", "admin" },
phonetestdata = { "god", "superadmin", "admin" },
fliptokverify = { "god", "superadmin", "admin" },
picstagramverify = { "god", "superadmin", "admin" },
picstagramadmin = { "god", "superadmin", "admin" },
}
-- CONFIG_DEFAULT_EXCLUDE_END
-- =============================================================================
-- Core, framework and device
-- =============================================================================
@@ -34,8 +49,9 @@ Config.Command = "phone"
Config.Phone = {
Item = "phone",
Unique = true, -- true: data follows each phone item; false: one persistent phone per character; hex/esx require false
Unique = true, -- true: data follows each phone item; false: one persistent phone per character; forced false for metadata-free inventories
Keybind = "F1", -- false disables the configurable phone key mapping
OpenRequestsPerMinute = 20,
AllowMovement = true, -- true: game input stays active while the mobile phone is open
HoldToLook = {
Enabled = true, -- hold the configured control to hide the cursor and look around; independent of AllowMovement
@@ -45,11 +61,83 @@ Config.Phone = {
DeviceName = "iFruit Phone",
}
-- CONFIG_DEFAULT_EXCLUDE_START
-- Local custom sounds remain file-based even when the Phone Configurator is
-- enabled. Copy each audio file into config/custom_tones, add one entry below,
-- and restart sky_phone. Files are read and served by sky_phone itself; no URL
-- or external website is required. Supported: mp3, ogg, wav, webm.
-- Limits per file: 2 MB and 250-30000 ms. Id values must be unique and use
-- only lowercase letters, numbers, underscores, or hyphens.
Config.CustomTones = {
Ringtones = {
-- {
-- Id = "dispatch_call",
-- Label = "Dispatch Call",
-- File = "config/custom_tones/dispatch_call.ogg",
-- DurationMs = 8500,
-- },
},
NotificationSounds = {
-- {
-- Id = "dispatch_ping",
-- Label = "Dispatch Ping",
-- File = "config/custom_tones/dispatch_ping.ogg",
-- DurationMs = 900,
-- },
},
}
-- CONFIG_DEFAULT_EXCLUDE_END
-- 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 = {
@@ -76,7 +164,6 @@ Config.Security = {
Config.AdminPanel = {
Enabled = true,
Command = "phonepanel",
AdminGroups = { "admin", "superadmin" },
MaximumPlayers = 128,
ReadRequestsPerMinute = 60,
ActionRequestsPerMinute = 30,
@@ -86,7 +173,7 @@ Config.AdminPanel = {
}
Config.Sim = {
Enabled = true, -- false: devices receive a persistent random number automatically; hex/esx require false
Enabled = true, -- false: devices receive a persistent random number automatically; forced false for metadata-free inventories
RegisteredItem = "sky_phone_sim_registered",
AnonymousItem = "sky_phone_sim_anonymous",
NumberLength = 10, -- total number of digits, including NumberPrefix
@@ -103,7 +190,7 @@ Config.Speaker = {
}
Config.Calls = {
VoiceProvider = "pma", -- yaca (alias: yaca-voice), pma (alias: pma-voice), saltychat (alias: salty)
VoiceProvider = "pma", -- auto, yaca (alias: yaca-voice), pma (alias: pma-voice), saltychat (alias: salty)
RingSeconds = 30,
ContactNameMaxLength = 80,
ContactNotesMaxLength = 500,
@@ -156,8 +243,9 @@ Config.Radio = {
AutoRejoin = false,
DisplayName = {
Enabled = true,
AllowEveryone = false, -- true allows every job; false uses AllowedJobs and its minimum grades
MaxLength = 32,
AllowedJobs = { -- Job name = minimum grade. Unlisted jobs cannot set a radio display name.
AllowedJobs = { -- Job name = minimum grade. Used when AllowEveryone is false.
police = 0,
sheriff = 0,
fib = 0,
@@ -288,6 +376,7 @@ Config.DarkChat = {
VoiceMaxBase64Length = 360000,
VoiceWaveformSamples = 48,
CleanupIntervalSeconds = 30,
CleanupBatchSize = 250,
AllowedDisappearTimers = {
[0] = true,
[-1] = true, -- after reading
@@ -389,8 +478,8 @@ Config.Garage = {
}
Config.Housing = {
System = "auto", -- auto, rtx, quasar, vms, rx, nolag, sn, esx_property, qbx_properties
AutoPriority = { "esx_property", "qbx_properties", "rtx", "quasar", "vms", "rx", "nolag", "sn" },
System = "auto", -- auto, rtx, quasar, tgiann, vms, rx, nolag, sn, esx_property, qbx_properties
AutoPriority = { "esx_property", "qbx_properties", "rtx", "quasar", "tgiann", "vms", "rx", "nolag", "sn" },
MaximumProperties = 50,
OverviewRequestsPerMinute = 30,
ActionsPerMinute = 12,
@@ -485,7 +574,6 @@ Config.FlipTok = {
MaxPostMedia = 10,
MusicTracks = {},
VerifyCommand = "fliptokverify",
AdminGroups = { "admin" },
ReportWebhookConvar = "sky_phone_fliptok_report_webhook",
}
@@ -507,7 +595,6 @@ Config.Picstagram = {
ReportDetailsMaxLength = 500,
ReportReasons = { "spam", "harassment", "dangerous", "illegal", "other" },
VerifyCommand = "picstagramverify",
AdminGroups = { "admin" },
}
Config.Feather = {
@@ -1122,7 +1209,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 = false,
AcceptsRequests = true,
District = "Mission Row",
LocationLabel = "Mission Row Police Station",
Address = "Mission Row Police Station",
@@ -1131,7 +1218,7 @@ if IsDuplicityVersion() then
Number = "911",
AutoContact = true,
CanCall = true,
CanMessage = false,
CanMessage = true,
Routing = "round_robin",
MinimumGrade = 0,
},
@@ -1144,7 +1231,15 @@ if IsDuplicityVersion() then
Services = 3,
Announcement = 3,
},
Services = {},
Services = {
{
Id = "police-assistance",
Title = "Police assistance",
Description = "Request non-emergency police assistance.",
Price = "",
RequestsEnabled = true,
},
},
},
ambulance = {
Job = "ambulance",
+7
View File
@@ -0,0 +1,7 @@
# Local custom phone tones
Place local `.mp3`, `.ogg`, `.wav`, or `.webm` files in this directory and register them in
`Config.CustomTones` inside `config/config.lua`. Restart `sky_phone` after changing the files or the
configuration. The phone reads these files directly; no external URL or website is required.
Each file may be at most 2 MB and between 250 milliseconds and 30 seconds long.
+54 -33
View File
@@ -43,6 +43,7 @@ Locales["de"] = {
invalid_sim = "Diese SIM-Karte besitzt ungültige Metadaten.",
phone_required = "Du benötigst ein Handy in deinem Inventar.",
operation_in_progress = "Eine andere Handy-Aktion wird bereits ausgeführt.",
rate_limited = "Das Handy wurde zu oft geöffnet. Versuch es gleich erneut.",
voice_unavailable = "Der konfigurierte Sprachdienst ist nicht verfügbar.",
default = "Das Handy konnte nicht geöffnet werden.",
},
@@ -203,9 +204,9 @@ Locales["de"] = {
},
AdminPanel = {
name = "Phone Admin", subtitle = "Administration", navigation = "Admin-Navigation", refresh = "Admin-Daten aktualisieren", loading = "Geschützte Daten werden geladen...",
tabs = { overview = "Übersicht", players = "Spieler", devices = "Geräte", apps = "Apps", accounts = "Accounts", messages = "Nachrichten", calls = "Anrufe", moderation = "Moderation", audit = "Audit", configurator = "Phone Configurator" },
overview = { eyebrow = "Server", title = "Dashboard", body = "Spieler, Geräte, Apps und Handydaten.", stats = "Server-Handystatistik", online = "Online", devices = "Geräte", accounts = "Accounts", audit = "Audit-Einträge", control = "Navigation", features = "Module", featuresBody = "Öffne ein Verwaltungsmodul.", recent = "Letzte Aktivitäten", playerFeature = "Identität, Finanzen, Job und Dienst", deviceFeature = "IMEI, SIM, Nummer und Aktivität", appFeature = "Handy-Apps installieren oder entfernen", accountFeature = "Accountzugriff und geschützte Zugangsdaten", messageFeature = "Letzte SMS-Aktivitäten prüfen", callFeature = "Letzte Anrufaktivitäten prüfen", moderationFeature = "Zugriff, Nummer oder Gerätedaten zurücksetzen", auditFeature = "Sensible Admin-Aktionen prüfen", configuratorFeature = "config.lua und media.lua über SQL verwalten" },
appearance = { eyebrow = "Darstellung", title = "Akzentfarbe", body = "Ändere den Akzent im gesamten Admin-Arbeitsbereich.", colors = { emerald = "Smaragd", blue = "Blau", violet = "Violett", orange = "Orange", red = "Rot" } },
tabs = { overview = "Übersicht", players = "Spieler", devices = "Geräte", apps = "Apps", accounts = "Accounts", messages = "Nachrichten", calls = "Anrufe", moderation = "Moderation", tones = "Töne", 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", tonesFeature = "Klingel- und Nachrichtentöne verwalten", auditFeature = "Sensible Admin-Aktionen prüfen", configuratorFeature = "config.lua und media.lua über SQL verwalten" },
statistics = { eyebrow = "Live-Daten", title = "Handy-Statistiken", body = "Aktuelle Nutzung und Gerätestatus, automatisch alle 15 Sekunden aktualisiert.", today = "Aktivität heute", todayBody = "Handy- und Admin-Aktivität seit Mitternacht.", messagesToday = "Nachrichten", callsToday = "Anrufe", auditToday = "Admin-Aktionen", coverage = "Geräteabdeckung", coverageBody = "Aktueller Anteil aller gespeicherten Handys.", linkedDevices = "Account verknüpft", simDevices = "SIM zugewiesen", activeDevices = "In 24 Stunden aktualisiert", ofDevices = "{count} von {total} Geräten" },
configurator = { context = "Runtime-Konfiguration", eyebrow = "Systemwerkzeug", sections = "Konfiguration", search = "Einstellungen oder Pfade suchen", configScope = "config.lua", mediaScope = "media.lua", noResults = "Keine passenden Einstellungen", loading = "SQL-Konfiguration wird geladen...", title = "Phone Configurator", body = "Verwalte Handy- und Media-Einstellungen im geschützten Admin-Bereich.", disabledTitle = "SQL-Konfiguration ist nicht aktiv", disabledBody = "Aktiviere den Configurator am Anfang der config.lua und starte sky_phone neu. Bis dahin bleiben die Dateiwerte aktiv und die Bearbeitung gesperrt.", manualSave = "Manuelles Speichern", refreshNotice = "Nichts wird automatisch gespeichert. Der grüne Haken prüft config.lua und media.lua in SQL und aktualisiert die aktive Server-, Client-, Media- und UI-Konfiguration sofort intern.", fieldCount = "{count} Felder", secretConfigured = "Secret gesetzt · Ersatzwert eingeben", invalidValue = "Prüfe den markierten Tabellen- oder Zahlenwert.", saved = "SQL-Konfiguration gespeichert und übernommen.", descriptions = { featureToggle = "Schaltet {name} ein oder aus.", boolean = "Legt fest, ob {name} erlaubt ist.", number = "Legt den Zahlenwert für {name} fest.", text = "Legt den Textwert für {name} fest.", optionalText = "Legt den optionalen Wert für {name} fest; der Schalter deaktiviert ihn.", list = "Verwaltet alle Einträge für {name}.", table = "Bündelt die zusammengehörigen Einstellungen für {name}.", credential = "Speichert den geschützten Zugangsschlüssel für {name}.", url = "Legt die URL oder den Endpunkt für {name} fest.", hosts = "Legt die erlaubten Domains für {name} fest.", milliseconds = "Legt die Zeit für {name} in Millisekunden fest.", seconds = "Legt die Zeit für {name} in Sekunden fest.", rateLimit = "Begrenzt die Aktionen für {name} pro Minute.", byteLimit = "Legt die maximal erlaubte Datengröße für {name} fest.", textLimit = "Legt die maximal erlaubte Textlänge für {name} fest.", distance = "Legt die Distanz in der Spielwelt für {name} fest.", coordinates = "Legt Weltkoordinaten oder Ausrichtung für {name} fest.", gameAsset = "Legt das GTA-Modell oder Prop für {name} fest.", animation = "Legt die Animationsdatei für {name} fest.", access = "Legt Jobs, Gruppen oder Berechtigungsstufen für {name} fest.", integration = "Wählt Framework oder Anbieter für {name} aus.", path = "Legt den Speicher- oder Ressourcenpfad für {name} fest.", color = "Legt die Oberflächenfarbe für {name} fest.", displayText = "Legt den Spielern angezeigten Text für {name} fest.", phoneNumber = "Legt die Telefon- oder Servicenummer für {name} fest.", routing = "Steuert die Verteilung eingehender Anfragen für {name}.", command = "Legt den Chat-Befehl zum Öffnen oder Ausführen von {name} fest.", locale = "Wählt die Sprache für {name} aus.", debug = "Steuert ausführliche Diagnoseausgaben für {name}.", mediaQuality = "Legt Medienqualität oder Lautstärke für {name} fest.", amount = "Legt die maximale oder angezeigte Menge für {name} fest." }, table = { list = "Liste", table = "Schlüsseltabelle", vector = "Vektor", entry = "Eintrag", general = "Allgemein", addRow = "Zeile hinzufügen", addField = "Feld hinzufügen", remove = "Entfernen", emptyList = "Noch keine Zeilen. Füge die erste Zeile mit Plus hinzu.", emptyTable = "Noch keine Felder. Füge unten den ersten Schlüssel hinzu.", keyPlaceholder = "Neuer Schlüssel", convertToList = "Als Liste nutzen", convertToMap = "Als typisierte Schlüsseltabelle nutzen", convertToTable = "Als Schlüsseltabelle nutzen", types = { string = "Text", number = "Zahl", boolean = "Schalter", list = "Liste", table = "Tabelle" } } },
players = { eyebrow = "Aktive Sitzungen", title = "Online-Spieler", online = "Jetzt online", empty = "Keine Spieler gefunden", emptyBody = "Passe die Suche an oder aktualisiere die Spielerliste." },
search = { players = "Name, ID, Job oder Nummer suchen", apps = "Apps suchen", clear = "Suche leeren" },
@@ -221,7 +222,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.", 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.", 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." },
},
Apps = {
health = {
@@ -694,6 +695,7 @@ 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.",
},
@@ -1227,7 +1229,7 @@ Locales["de"] = {
},
skyride = {
name = "SkyRide", loading = "Laden SkyRide", unavailable = "SkyRide nicht verfügbar", tryAgain = "Erneut versuchen",
mode = { rider = "Reiten", driver = "Antrieb" },
mode = { rider = "Passagier", driver = "Fahrer" },
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" },
@@ -1448,8 +1450,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 Leertaste gedrückt, um dich umzusehen", returnHelp = "Leertaste für Steuerung", lockCamera = "Kamerabewegung sperren",
unlockCamera = "Kamerabewegung entsperren", spaceKey = "Leertaste", uploading = "Upload: {count}",
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}",
saving = "Video speichern...", openGallery = "Fotos öffnen", takePhoto = "Foto machen",
startRecording = "Aufnahme starten", stopRecording = "Aufnahme beenden", saved = "Auf Fotos gespeichert.",
zoom = "Kamerazoom auf {zoom} einstellen",
@@ -2020,10 +2022,11 @@ Locales["de"] = {
},
account = {
account = "Account", title = "App-Verwaltung", skyAccount = "Sky Phone Konto",
apps = "Installation von Apps", games = "Spiele", library = "Deine Bibliothek.", myApps = "Meine Apps",
apps = "Installierte Apps", games = "Spiele", library = "Deine Bibliothek.", myApps = "Meine Apps",
downloadedOn = "Gespeichert {date}", uninstall = "Deinstallieren", uninstallApp = "Deinstallieren {app}",
uninstallTitle = "Diese App deinstallieren?",
uninstallBody = "{app} wird von diesem Handy entfernt. Du kannst die App erneut aus dem App Store laden.",
uninstallFailed = "Die App konnte nicht deinstalliert werden. Bitte versuche es erneut.",
},
details = {
skyStudios = "Sky Studios", share = "App teilen", openDetails = "Ansicht {app}",
@@ -2138,8 +2141,9 @@ Locales["de"] = {
},
}
Locales["de"].Nui.AdminPanel.configurator.table.addJob = "Job hinzufügen"
Locales["de"].Nui.AdminPanel.configurator.table.jobPlaceholder = "Jobname"
Locales["de"].Nui.AdminPanel.configurator.table.subtabs = {
AdminGroups = "Admin-Gruppen",
Dictionaries = "Animationsdateien",
Clips = "Clips",
Transforms = "Transformationen",
@@ -2176,29 +2180,46 @@ 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",
Locales["de"].Nui.AdminPanel.configurator.customTones = {
context = "Tonbibliothek",
eyebrow = "Audioverwaltung",
library = "Bibliothek",
title = "Eigene Klingel- und Nachrichtentöne",
body = "Verwalte lokale Audiodateien aus der Datenbank oder der config.lua ohne externe URLs.",
configTitle = "Dateibasierte Alternative",
configBody = "Wenn der FiveM-Client keinen Dateidialog öffnet, lege die Datei im Resource-Ordner ab und registriere sie in der config.lua.",
configSource = "config.lua",
configManaged = "Dieser Ton wird über die config.lua verwaltet und kann hier nur angehört werden.",
name = "Anzeigename",
namePlaceholder = "Zum Beispiel Leitstelle",
category = "Verwendung",
ringtone = "Klingelton",
notification = "Nachrichtenton",
chooseFile = "Audiodatei auswählen",
fileHint = "MP3, OGG, WAV oder WebM · maximal 2 MB und 30 Sekunden",
preview = "Anhören",
add = "Ton hinzufügen",
loading = "Tonbibliothek wird geladen...",
ringtones = "Klingeltöne",
notifications = "Nachrichtentöne",
empty = "Noch keine eigenen Töne in dieser Kategorie.",
delete = "Ton löschen",
confirmDelete = "Zum Bestätigen erneut klicken",
saved = "Der Ton wurde gespeichert und ist sofort auf allen Handys verfügbar.",
deleted = "Der Ton wurde gelöscht.",
errors = {
type = "Wähle eine MP3-, OGG-, WAV- oder WebM-Audiodatei.",
size = "Die Audiodatei darf höchstens 2 MB groß sein.",
duration = "Der Ton muss zwischen 0,25 und 30 Sekunden lang sein.",
invalid = "Die Audiodatei konnte nicht gelesen werden.",
playback = "Der Ton konnte nicht wiedergegeben werden.",
},
}
Locales["de"].Nui.AdminPanel.errors.invalid_tone = "Prüfe Name, Dateityp, Dateigröße und Länge des Tons."
Locales["de"].Nui.AdminPanel.errors.tone_name_taken = "In dieser Kategorie existiert bereits ein Ton mit diesem Namen."
Locales["de"].Nui.AdminPanel.errors.tone_limit = "In dieser Kategorie sind bereits 32 eigene Töne gespeichert."
Locales["de"].Nui.AdminPanel.errors.tone_not_found = "Dieser Ton existiert nicht mehr."
Locales["de"].Nui.AdminPanel.errors.invalid_upload = "Der Ton-Upload ist unvollständig oder ungültig."
Locales["de"].Nui.AdminPanel.errors.operation_in_progress = "Ein anderer Ton-Upload läuft bereits."
Locales["de"].Nui.AdminPanel.audit.actions.create_custom_tone = "Eigenen Ton hinzugefügt"
Locales["de"].Nui.AdminPanel.audit.actions.delete_custom_tone = "Eigenen Ton gelöscht"
+52 -31
View File
@@ -43,6 +43,7 @@ Locales["en"] = {
invalid_sim = "This SIM card has invalid metadata.",
phone_required = "You need a phone in your inventory.",
operation_in_progress = "Another phone operation is already in progress.",
rate_limited = "The phone was opened too often. Try again in a moment.",
voice_unavailable = "The configured phone voice service is unavailable.",
default = "The phone could not be opened.",
},
@@ -203,9 +204,9 @@ Locales["en"] = {
},
AdminPanel = {
name = "Phone Admin", subtitle = "Administration", navigation = "Admin navigation", refresh = "Refresh admin data", loading = "Loading protected data...",
tabs = { overview = "Overview", players = "Players", devices = "Devices", apps = "Apps", accounts = "Accounts", messages = "Messages", calls = "Calls", moderation = "Moderation", audit = "Audit", configurator = "Phone configurator" },
overview = { eyebrow = "Server", title = "Dashboard", body = "Players, devices, apps, and phone data.", stats = "Server phone statistics", online = "Online", devices = "Devices", accounts = "Accounts", audit = "Audit entries", control = "Navigation", features = "Modules", featuresBody = "Open an administration module.", recent = "Recent activity", playerFeature = "Identity, finances, job, and duty", deviceFeature = "IMEI, SIM, number, and activity", appFeature = "Install or remove phone apps", accountFeature = "Account access and protected credentials", messageFeature = "Review recent SMS activity", callFeature = "Review recent call activity", moderationFeature = "Reset access, number, or device data", auditFeature = "Review sensitive admin actions", configuratorFeature = "Manage config.lua and media.lua through SQL" },
appearance = { eyebrow = "Appearance", title = "Accent color", body = "Change the accent across the complete admin workspace.", colors = { emerald = "Emerald", blue = "Blue", violet = "Violet", orange = "Orange", red = "Red" } },
tabs = { overview = "Overview", players = "Players", devices = "Devices", apps = "Apps", accounts = "Accounts", messages = "Messages", calls = "Calls", moderation = "Moderation", tones = "Sounds", 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", tonesFeature = "Manage ringtones and notification sounds", auditFeature = "Review sensitive admin actions", configuratorFeature = "Manage config.lua and media.lua through SQL" },
statistics = { eyebrow = "Live data", title = "Phone statistics", body = "Current usage and device status, refreshed every 15 seconds.", today = "Activity today", todayBody = "Phone and admin activity since midnight.", messagesToday = "Messages", callsToday = "Calls", auditToday = "Admin actions", coverage = "Device coverage", coverageBody = "Current share of all stored phones.", linkedDevices = "Account linked", simDevices = "SIM assigned", activeDevices = "Updated in 24 hours", ofDevices = "{count} of {total} devices" },
configurator = { context = "Runtime configuration", eyebrow = "System tool", sections = "Configuration", search = "Search settings or paths", configScope = "config.lua", mediaScope = "media.lua", noResults = "No matching settings", loading = "Loading SQL configuration...", title = "Phone configurator", body = "Manage phone and media settings from the protected admin workspace.", disabledTitle = "SQL configuration is not active", disabledBody = "Enable the configurator at the beginning of config.lua and restart sky_phone. Until then, file values remain active and editing is locked.", manualSave = "Manual save", refreshNotice = "Nothing is written automatically. The green check verifies config.lua and media.lua in SQL and refreshes the active server, client, media and UI configuration immediately.", fieldCount = "{count} fields", secretConfigured = "Secret configured · enter a replacement", invalidValue = "Check the highlighted table or number value.", saved = "SQL configuration saved and applied.", descriptions = { featureToggle = "Turns {name} on or off.", boolean = "Controls whether {name} is allowed.", number = "Sets the numeric value for {name}.", text = "Sets the text value used for {name}.", optionalText = "Sets the optional value for {name}; switch it off to disable it.", list = "Manages all entries used for {name}.", table = "Groups the related settings for {name}.", credential = "Stores the protected credential used by {name}.", url = "Sets the URL or endpoint used by {name}.", hosts = "Defines which domains are allowed for {name}.", milliseconds = "Sets the timing for {name} in milliseconds.", seconds = "Sets the timing for {name} in seconds.", rateLimit = "Limits how many {name} actions are allowed per minute.", byteLimit = "Sets the maximum data size allowed for {name}.", textLimit = "Sets the maximum text length allowed for {name}.", distance = "Sets the world distance used for {name}.", coordinates = "Sets the world coordinates or orientation for {name}.", gameAsset = "Sets the GTA model or prop used for {name}.", animation = "Sets the animation asset used for {name}.", access = "Defines the jobs, groups or permission level for {name}.", integration = "Selects the connected framework or provider for {name}.", path = "Sets the storage or resource path used for {name}.", color = "Sets the interface color used for {name}.", displayText = "Sets the text shown to players for {name}.", phoneNumber = "Sets the phone or service number used for {name}.", routing = "Controls how incoming requests are routed for {name}.", command = "Sets the chat command used to open or run {name}.", locale = "Selects the language used for {name}.", debug = "Controls detailed diagnostic output for {name}.", mediaQuality = "Sets the media quality or volume used for {name}.", amount = "Sets the maximum or displayed amount for {name}." }, table = { list = "List", table = "Key table", vector = "Vector", entry = "Entry", general = "General", addRow = "Add row", addField = "Add field", remove = "Remove", emptyList = "No rows yet. Add the first row with plus.", emptyTable = "No fields yet. Add the first key below.", keyPlaceholder = "New key", convertToList = "Use list", convertToMap = "Use typed key table", convertToTable = "Use key table", types = { string = "Text", number = "Number", boolean = "Switch", list = "List", table = "Table" } } },
players = { eyebrow = "Active sessions", title = "Online players", online = "Online now", empty = "No players found", emptyBody = "Adjust the search or refresh the live player list." },
search = { players = "Search name, ID, job, or number", apps = "Search apps", clear = "Clear search" },
@@ -221,7 +222,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.", 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.", 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." },
},
Apps = {
health = {
@@ -694,6 +695,7 @@ 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.",
},
@@ -1448,8 +1450,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 Space to look around", returnHelp = "Release Space for controls", lockCamera = "Lock camera movement",
unlockCamera = "Unlock camera movement", spaceKey = "Space", uploading = "{count} uploading",
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",
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}",
@@ -2024,6 +2026,7 @@ Locales["en"] = {
downloadedOn = "Downloaded {date}", uninstall = "Uninstall", uninstallApp = "Uninstall {app}",
uninstallTitle = "Uninstall this app?",
uninstallBody = "{app} will be removed from this phone. You can download it again from the App Store.",
uninstallFailed = "The app could not be uninstalled. Please try again.",
},
details = {
skyStudios = "Sky Studios", share = "Share app", openDetails = "View {app}",
@@ -2138,8 +2141,9 @@ Locales["en"] = {
},
}
Locales["en"].Nui.AdminPanel.configurator.table.addJob = "Add job"
Locales["en"].Nui.AdminPanel.configurator.table.jobPlaceholder = "Job name"
Locales["en"].Nui.AdminPanel.configurator.table.subtabs = {
AdminGroups = "Admin Groups",
Dictionaries = "Dictionaries",
Clips = "Clips",
Transforms = "Transforms",
@@ -2176,29 +2180,46 @@ 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",
Locales["en"].Nui.AdminPanel.configurator.customTones = {
context = "Sound library",
eyebrow = "Audio management",
library = "Library",
title = "Custom ringtones and notification sounds",
body = "Manage local audio files from the database or config.lua without external URLs.",
configTitle = "File-based alternative",
configBody = "If the FiveM client does not open a file dialog, place the file in the resource folder and register it in config.lua.",
configSource = "config.lua",
configManaged = "This tone is managed through config.lua and can only be previewed here.",
name = "Display name",
namePlaceholder = "For example Dispatch",
category = "Use as",
ringtone = "Ringtone",
notification = "Notification sound",
chooseFile = "Choose audio file",
fileHint = "MP3, OGG, WAV, or WebM · up to 2 MB and 30 seconds",
preview = "Preview",
add = "Add tone",
loading = "Loading tone library...",
ringtones = "Ringtones",
notifications = "Notification sounds",
empty = "No custom tones in this category yet.",
delete = "Delete tone",
confirmDelete = "Click again to confirm",
saved = "The tone was saved and is immediately available on every phone.",
deleted = "The tone was deleted.",
errors = {
type = "Choose an MP3, OGG, WAV, or WebM audio file.",
size = "The audio file may not exceed 2 MB.",
duration = "The tone must be between 0.25 and 30 seconds long.",
invalid = "The audio file could not be read.",
playback = "The tone could not be played.",
},
}
Locales["en"].Nui.AdminPanel.errors.invalid_tone = "Check the tone name, file type, file size, and duration."
Locales["en"].Nui.AdminPanel.errors.tone_name_taken = "A tone with this name already exists in this category."
Locales["en"].Nui.AdminPanel.errors.tone_limit = "This category already contains 32 custom tones."
Locales["en"].Nui.AdminPanel.errors.tone_not_found = "That tone no longer exists."
Locales["en"].Nui.AdminPanel.errors.invalid_upload = "The tone upload is incomplete or invalid."
Locales["en"].Nui.AdminPanel.errors.operation_in_progress = "Another tone upload is already in progress."
Locales["en"].Nui.AdminPanel.audit.actions.create_custom_tone = "Custom tone added"
Locales["en"].Nui.AdminPanel.audit.actions.delete_custom_tone = "Custom tone deleted"
+52 -32
View File
@@ -43,6 +43,7 @@ Locales["es"] = {
invalid_sim = "Esta tarjeta SIM tiene metadatos inválidos.",
phone_required = "Necesitas un teléfono en tu inventario.",
operation_in_progress = "Otra operación de teléfono ya está en progreso.",
rate_limited = "El teléfono se abrió demasiadas veces. Inténtalo de nuevo en un momento.",
voice_unavailable = "El servicio de voz configurado para el teléfono no está disponible.",
default = "No se pudo abrir el teléfono.",
},
@@ -203,9 +204,9 @@ Locales["es"] = {
},
AdminPanel = {
name = "Administrador de teléfono", subtitle = "Administración", navigation = "Navegación del administrador", refresh = "Actualizar los datos del administrador", loading = "Cargar datos protegidos...",
tabs = { overview = "Sinopsis", players = "Los jugadores", devices = "Los dispositivos", apps = "Aplicaciones", accounts = "Cuentas", messages = "Mensajes", calls = "Las llamadas", moderation = "Moderación", audit = "Auditoría", configurator = "Configuración de teléfono" },
overview = { eyebrow = "El servidor", title = "Tablero de control", body = "Jugadores, dispositivos, aplicaciones y datos telefónicos.", stats = "Estadísticas telefónicas del servidor", online = "En línea", devices = "Los dispositivos", accounts = "Cuentas", audit = "Entradas de auditoría", control = "Navegación", features = "Los módulos", featuresBody = "Abre un módulo de administración.", recent = "Actividad reciente", playerFeature = "Identidad, finanzas, trabajo y deber", deviceFeature = "IMEI, SIM, número y actividad", appFeature = "Instalar o eliminar aplicaciones telefónicas", accountFeature = "Acceso a la cuenta y credenciales protegidas", messageFeature = "Revisar la actividad reciente de SMS", callFeature = "Revisar la actividad reciente de las llamadas", moderationFeature = "Resetear datos de acceso, número o dispositivo", auditFeature = "Revisar las acciones del administrador sensibles", configuratorFeature = "Administrar config.lua y media.lua a través de SQL" },
appearance = { eyebrow = "Apariencia", title = "Color de acento", body = "Cambia el acento en todo el espacio de trabajo del administrador.", colors = { emerald = "Esmeralda", blue = "Azul", violet = "Violeta", orange = "Naranja", red = "Rojo" } },
tabs = { overview = "Sinopsis", players = "Los jugadores", devices = "Los dispositivos", apps = "Aplicaciones", accounts = "Cuentas", messages = "Mensajes", calls = "Las llamadas", moderation = "Moderación", tones = "Sonidos", audit = "Auditoría", configurator = "Configuración de teléfono" },
overview = { eyebrow = "El servidor", title = "Tablero de control", body = "Jugadores, dispositivos, aplicaciones y datos telefónicos.", stats = "Estadísticas telefónicas del servidor", online = "En línea", devices = "Los dispositivos", accounts = "Cuentas", audit = "Entradas de auditoría", control = "Navegación", features = "Los módulos", featuresBody = "Abre un módulo de administración.", recent = "Actividad reciente", playerFeature = "Identidad, finanzas, trabajo y deber", deviceFeature = "IMEI, SIM, número y actividad", appFeature = "Instalar o eliminar aplicaciones telefónicas", accountFeature = "Acceso a la cuenta y credenciales protegidas", messageFeature = "Revisar la actividad reciente de SMS", callFeature = "Revisar la actividad reciente de las llamadas", moderationFeature = "Resetear datos de acceso, número o dispositivo", tonesFeature = "Administrar tonos de llamada y notificación", auditFeature = "Revisar las acciones del administrador sensibles", configuratorFeature = "Administrar config.lua y media.lua a través de SQL" },
statistics = { eyebrow = "Datos en vivo", title = "Estadísticas del teléfono", body = "Uso actual y estado de los dispositivos, actualizado cada 15 segundos.", today = "Actividad de hoy", todayBody = "Actividad telefónica y administrativa desde medianoche.", messagesToday = "Mensajes", callsToday = "Llamadas", auditToday = "Acciones de administrador", coverage = "Cobertura de dispositivos", coverageBody = "Proporción actual de todos los teléfonos guardados.", linkedDevices = "Cuenta vinculada", simDevices = "SIM asignada", activeDevices = "Actualizado en 24 horas", ofDevices = "{count} de {total} dispositivos" },
configurator = { context = "Configuración de tiempo de ejecución", eyebrow = "Herramienta del sistema", sections = "Configuración", search = "Configuración de búsqueda o caminos", configScope = "config.lua", mediaScope = "media.lua", noResults = "Ninguna configuración correspondiente", loading = "Cargar la configuración de SQL...", title = "Configuración de teléfono", body = "Gestionar la configuración de teléfono y medios desde el espacio de trabajo de administrador protegido.", disabledTitle = "Configuración SQL no está activa", disabledBody = "Habilitar el configurador al principio de config.lua y reiniciar sky_phone. Hasta entonces, los valores del archivo permanecen activos y la edición está bloqueada.", manualSave = "Salvamiento manual", refreshNotice = "Nada se escribe automáticamente. La verificación verde verifica config.lua y media.lua en SQL y actualiza inmediatamente la configuración del servidor activo, el cliente, los medios y la interfaz de usuario.", fieldCount = "Campos {count}", secretConfigured = "Configuración secreta · introducir un reemplazo", invalidValue = "Comprueba el valor de la tabla o número resaltado.", saved = "Configuración SQL guardada y aplicada.", descriptions = { featureToggle = "Enciende o apaga {name}.", boolean = "Controla si {name} está permitido.", number = "Establece el valor numérico para {name}.", text = "Establece el valor de texto utilizado para {name}.", optionalText = "Establece el valor opcional para {name}; apague para desactivarlo.", list = "Gestiona todas las entradas utilizadas para {name}.", table = "Agrupa las configuraciones relacionadas para {name}.", credential = "Almacena la credencial protegida utilizada por {name}.", url = "Establece la URL o el punto final utilizado por {name}.", hosts = "Define qué dominios están permitidos para {name}.", milliseconds = "Establece el tiempo de {name} en milisegundos.", seconds = "Establece el tiempo de {name} en segundos.", rateLimit = "Limita la cantidad de acciones {name} permitidas por minuto.", byteLimit = "Establece el tamaño máximo de datos permitido para {name}.", textLimit = "Establece la longitud máxima de texto permitida para {name}.", distance = "Establece la distancia mundial utilizada para {name}.", coordinates = "Establece las coordenadas del mundo o la orientación para {name}.", gameAsset = "Establece el modelo GTA o el accesorio utilizado para {name}.", animation = "Establece el activo de animación utilizado para {name}.", access = "Define los puestos de trabajo, los grupos o el nivel de permiso para {name}.", integration = "Selecciona el marco o proveedor conectado para {name}.", path = "Establece la ruta de almacenamiento o recurso utilizada para {name}.", color = "Establece el color de interfaz utilizado para {name}.", displayText = "Establece el texto que se muestra a los jugadores para {name}.", phoneNumber = "Establece el número de teléfono o servicio utilizado para {name}.", routing = "Controla cómo se envía las solicitudes entrantes para {name}.", command = "Establece el comando de chat utilizado para abrir o ejecutar {name}.", locale = "Selecciona el idioma utilizado para {name}.", debug = "Controla el diagnóstico detallado de {name}.", mediaQuality = "Establece la calidad o volumen de los medios utilizados para {name}.", amount = "Establece la cantidad máxima o mostrada para {name}." }, table = { list = "Lista", table = "Mesa de llaves", vector = "Vector", entry = "Entrada", general = "Generales", addRow = "Añadir fila", addField = "Añadir campo", remove = "Eliminar", emptyList = "Aún no hay filas. Agregue la primera fila con más.", emptyTable = "Aún no hay campos. Añade la primera llave de abajo.", keyPlaceholder = "Nueva llave", convertToList = "Lista de uso", convertToMap = "Usa la tabla de teclas digitalizada", convertToTable = "Usa la tabla de teclas", types = { string = "El texto", number = "Número", boolean = "Cambiador", list = "Lista", table = "Cuadro" } } },
players = { eyebrow = "sesiones activas", title = "Jugadores en línea", online = "En línea ahora", empty = "No se encontraron jugadores", emptyBody = "Ajusta la búsqueda o actualiza la lista de jugadores en vivo." },
search = { players = "Nombre, identificación, trabajo o número de búsqueda", apps = "Aplicaciones de búsqueda", clear = "Limpiar búsqueda" },
@@ -221,7 +222,7 @@ Locales["es"] = {
},
audit = { eyebrow = "Responsabilidad", title = "Trayectoria de auditoría", body = "Las revelaciones sensibles y los cambios remotos de la aplicación se registran aquí.", empty = "No hay acciones de administración todavía", emptyBody = "Las acciones protegidas aparecerán aquí después de que se realicen.", by = "{actor} · Identificación del objetivo {target}", actions = { grant_app = "Aplicación instalada", revoke_app = "Aplicación eliminada", reveal_account_password = "Se reveló la contraseña", view_messages = "Los mensajes vistos", view_calls = "Las llamadas vistas", reset_passcode = "Reset de código de acceso", change_number = "Cambió el número de teléfono", factory_reset = "Reinicio de fábrica de teléfono", save_configuration = "Configuración guardada" } },
editor = { brand = "SKY PHONE", workspace = "ADMINISTRACIÓN", players = "Directorio de jugadores", audit = "Registro de auditoría", selectPlayer = "Selecciona un jugador para inspeccionar la identidad, los dispositivos, las credenciales y el acceso a la aplicación.", save = "Guardar cambios", saveHint = "Aplicar los cambios pendientes", saved = "Cambios guardados.", unsaved = "Cambios no guardados", close = "Cerrar el panel de administración", refresh = "Actualizar los datos en vivo", online = "VIVIENDO", profile = "PROFILES", financial = "FINANCIERO", device = "DISPOSITIVO", security = "LA SEGURIDAD", noAutoSave = "Salvamiento manual", noAutoSaveBody = "Los cambios permanecen locales hasta que se presione el cheque verde.", discardTitle = "¿Deshacerse de los cambios no salvos?", discardBody = "No se han guardado los cambios de configuración de su aplicación en etapas.", keepEditing = "Sigue editando", discard = "Rechazar los cambios", saveFailed = "Algunos cambios no se pudieron salvar.", noSelection = "Ningún jugador seleccionado" },
errors = { not_authorized = "No tiene acceso al panel de administración.", rate_limited = "Demasiadas solicitudes de administración. Por favor, espera.", player_unavailable = "Ese jugador ya no está en línea.", device_not_owned = "Ese teléfono ya no pertenece al jugador seleccionado.", invalid_app = "Esa aplicación no está registrada en el servidor.", app_protected = "Esta aplicación del sistema no se puede eliminar.", revision_conflict = "Los datos han cambiado mientras tanto. Vuelve a abrir la sección y vuelve a intentarlo.", configurator_disabled = "Habilitar el configurador de teléfono en config.lua primero.", invalid_field = "Ese campo de configuración ya no está disponible.", invalid_value = "Un valor de configuración es inválido.", account_not_found = "Ninguna cuenta de iFruit está vinculada a este teléfono.", invalid_phone_number = "Ingresa un número de teléfono en el formato de servidor configurado.", phone_number_unchanged = "Este SIM ya usa ese número de teléfono.", phone_number_taken = "Ese número de teléfono ya está asignado.", no_sim = "Este teléfono no tiene tarjeta SIM que se pueda cambiar.", passcode_not_set = "Este teléfono no tiene un código de acceso configurado.", device_not_found = "Este teléfono ya no existe.", metadata_unsupported = "Los metadatos del inventario telefónico no pudieron actualizarse.", invalid_request = "La solicitud del administrador fue inválida.", request_failed = "La solicitud del administrador falló.", default = "El panel de administración no está disponible temporalmente." },
errors = { not_authorized = "No tiene acceso al panel de administración.", rate_limited = "Demasiadas solicitudes de administración. Por favor, espera.", player_unavailable = "Ese jugador ya no está en línea.", device_not_owned = "Ese teléfono ya no pertenece al jugador seleccionado.", invalid_app = "Esa aplicación no está registrada en el servidor.", app_protected = "Esta aplicación del sistema no se puede eliminar.", revision_conflict = "Los datos han cambiado mientras tanto. Vuelve a abrir la sección y vuelve a intentarlo.", configurator_disabled = "Habilitar el configurador de teléfono en config.lua primero.", invalid_field = "Ese campo de configuración ya no está disponible.", invalid_value = "Un valor de configuración es inválido.", invalid_company_configuration = "La configuración de la empresa está incompleta o contiene un trabajo, número de servicio o ID de servicio duplicado.", account_not_found = "Ninguna cuenta de iFruit está vinculada a este teléfono.", invalid_phone_number = "Ingresa un número de teléfono en el formato de servidor configurado.", phone_number_unchanged = "Este SIM ya usa ese número de teléfono.", phone_number_taken = "Ese número de teléfono ya está asignado.", no_sim = "Este teléfono no tiene tarjeta SIM que se pueda cambiar.", passcode_not_set = "Este teléfono no tiene un código de acceso configurado.", device_not_found = "Este teléfono ya no existe.", metadata_unsupported = "Los metadatos del inventario telefónico no pudieron actualizarse.", invalid_request = "La solicitud del administrador fue inválida.", request_failed = "La solicitud del administrador falló.", default = "El panel de administración no está disponible temporalmente." },
},
Apps = {
health = {
@@ -694,6 +695,7 @@ Locales["es"] = {
gif_provider_rate_limited = "GIPHY está ocupado. Inténtalo de nuevo en un momento.", gif_provider_failed = "La búsqueda de GIFs está temporalmente inaccesible.",
self_message = "No puedes enviar mensajes a tu propio número.", recipient_not_found = "Ese número no está disponible.", blocked = "Este contacto ha bloqueado las llamadas y los mensajes de esta SIM.",
messaging_unavailable = "Este contacto de empresa no acepta mensajes.",
service_line_text_only = "Las líneas de servicio de empresa solo aceptan mensajes de texto por ahora.",
no_sim = "Este teléfono no tiene tarjeta SIM.", rate_limited = "Demasiados mensajes, inténtalo de nuevo en un minuto.",
request_failed = "Los mensajes están temporalmente inaccesibles.", default = "El mensaje no se pudo enviar.",
},
@@ -1448,8 +1450,8 @@ Locales["es"] = {
camera = {
name = "Cámara", flash = "Linterna", flip = "Cambiar de cámara", landscape = "Cambiar a paisaje",
portrait = "Cambiar a vertical", photo = "Foto", video = "Vídeo", microphoneOn = "Micrófono encendido", microphoneOff = "Micrófono silenciado",
focusHelp = "Presionar Espacio para mirar alrededor", returnHelp = "Soltar espacio para controles", lockCamera = "Bloquear movimiento de la cámara",
unlockCamera = "Desbloquear movimiento de la cámara", spaceKey = "Espacio", uploading = "Subiendo {count}",
focusHelp = "Mantén pulsada la tecla de vista para mirar alrededor", returnHelp = "Suelta la tecla de vista para usar los controles", lockCamera = "Bloquear movimiento de la cámara",
unlockCamera = "Desbloquear movimiento de la cámara", lookKey = "Mirar", uploading = "Subiendo {count}",
saving = "Guardando video...", openGallery = "Abrir fotos", takePhoto = "Tomar foto",
startRecording = "Iniciar grabación", stopRecording = "Detener grabación", saved = "Guardado en fotos.",
zoom = "Establecer zoom de la cámara en {zoom}",
@@ -2024,6 +2026,7 @@ Locales["es"] = {
downloadedOn = "Descargado {date}", uninstall = "Desinstalar", uninstallApp = "Desinstalar {app}",
uninstallTitle = "¿Desinstalar esta aplicación?",
uninstallBody = "{app} será eliminado de este teléfono. Puedes descargarlo de nuevo de la App Store.",
uninstallFailed = "No se ha podido desinstalar la aplicación. Inténtalo de nuevo.",
},
details = {
skyStudios = "Sky Studios", share = "Compartir aplicación", openDetails = "Ver {app}",
@@ -2138,8 +2141,9 @@ Locales["es"] = {
},
}
Locales["es"].Nui.AdminPanel.configurator.table.addJob = "Añadir trabajo"
Locales["es"].Nui.AdminPanel.configurator.table.jobPlaceholder = "Nombre del trabajo"
Locales["es"].Nui.AdminPanel.configurator.table.subtabs = {
AdminGroups = "Grupos de administradores",
Dictionaries = "Diccionarios",
Clips = "Los clips",
Transforms = "Transformaciones",
@@ -2176,30 +2180,46 @@ Locales["es"].Nui.AdminPanel.configurator.table.subtabs = {
AllowedJobs = "Trabajos permitidos",
Websites = "Las páginas web",
}
Locales["es"].Nui.AdminPanel.appearance.title = "Interfaz"
Locales["es"].Nui.AdminPanel.appearance.body = "Personaliza los colores y la tipografía en el espacio de trabajo del administrador."
Locales["es"].Nui.AdminPanel.appearance.controls = {
customColor = "Color personalizado",
customColorBody = "Seleccione cualquier acento RGB o ingrese sus valores de canal.",
red = "R",
green = "G",
blue = "EN EL CASO DE B",
hex = "El color HEX",
value = "el valor",
fontFamily = "Familia de fuentes",
fontFamilyBody = "Seleccione la tipografía utilizada por el panel de administración completo.",
fontSize = "Tamaño de fuente",
fontSizeBody = "Escala el texto y los controles para una fácil legibilidad.",
fonts = {
inter = "Inter",
system = "El sistema",
classic = "Clásico",
verdana = "Verdana",
tahoma = "Tahoma",
trebuchet = "Arbúsqueda",
georgia = "Georgia",
mono = "Monospacio",
Locales["es"].Nui.AdminPanel.configurator.customTones = {
context = "Biblioteca de sonidos",
eyebrow = "Gestión de audio",
library = "Biblioteca",
title = "Tonos de llamada y notificación personalizados",
body = "Administra archivos de audio locales desde la base de datos o config.lua sin URL externas.",
configTitle = "Alternativa basada en archivos",
configBody = "Si el cliente de FiveM no abre el selector, coloca el archivo en la carpeta del recurso y regístralo en config.lua.",
configSource = "config.lua",
configManaged = "Este tono se administra mediante config.lua y aquí solo puede escucharse.",
name = "Nombre para mostrar",
namePlaceholder = "Por ejemplo Central",
category = "Usar como",
ringtone = "Tono de llamada",
notification = "Tono de notificación",
chooseFile = "Elegir archivo de audio",
fileHint = "MP3, OGG, WAV o WebM · máximo 2 MB y 30 segundos",
preview = "Escuchar",
add = "Añadir tono",
loading = "Cargando biblioteca de tonos...",
ringtones = "Tonos de llamada",
notifications = "Tonos de notificación",
empty = "Todavía no hay tonos personalizados en esta categoría.",
delete = "Eliminar tono",
confirmDelete = "Haz clic de nuevo para confirmar",
saved = "El tono se guardó y está disponible inmediatamente en todos los teléfonos.",
deleted = "El tono fue eliminado.",
errors = {
type = "Elige un archivo de audio MP3, OGG, WAV o WebM.",
size = "El archivo de audio no puede superar los 2 MB.",
duration = "El tono debe durar entre 0,25 y 30 segundos.",
invalid = "No se pudo leer el archivo de audio.",
playback = "No se pudo reproducir el tono.",
},
}
Locales["es"].Nui.AdminPanel.errors.invalid_tone = "Comprueba el nombre, tipo, tamaño y duración del tono."
Locales["es"].Nui.AdminPanel.errors.tone_name_taken = "Ya existe un tono con este nombre en esta categoría."
Locales["es"].Nui.AdminPanel.errors.tone_limit = "Esta categoría ya contiene 32 tonos personalizados."
Locales["es"].Nui.AdminPanel.errors.tone_not_found = "Ese tono ya no existe."
Locales["es"].Nui.AdminPanel.errors.invalid_upload = "La carga del tono está incompleta o no es válida."
Locales["es"].Nui.AdminPanel.errors.operation_in_progress = "Ya hay otra carga de tono en curso."
Locales["es"].Nui.AdminPanel.audit.actions.create_custom_tone = "Tono personalizado añadido"
Locales["es"].Nui.AdminPanel.audit.actions.delete_custom_tone = "Tono personalizado eliminado"
+10 -4
View File
@@ -2,19 +2,25 @@
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, these values are first-run
defaults and the active media configuration is loaded from SQL.
phone configurator is enabled in config.lua, the active media configuration
is loaded from SQL and frontend builds copy the shipped values from this
file into source/shared/config_default.lua.
]]
Config.Media = {
GiphyApiKey = "", -- Paste the GIPHY API key here.
-- Create or sign in to an account at https://developers.giphy.com/, open
-- the Developer Dashboard, choose "Create an API Key", and paste it here.
GiphyApiKey = "",
GifPageSize = 24,
GifRating = "pg-13",
UrlMaxLength = 2048,
AllowedGifHosts = { "giphy.com" },
-- Create or sign in at https://fivemanage.com/, create or select your team,
-- then open Dashboard > Tokens and create a token with Media access. Keep
-- this server-only token private and paste it here.
FiveManage = {
ApiKey = "", -- Paste a newly generated FiveManage V3 Media API token here.
ApiKey = "",
BaseUrl = "https://api.fivemanage.com/api/v3/file",
RequestTimeoutMs = 10000,
UploadTimeoutMs = 25000,
+5 -4
View File
@@ -6,7 +6,7 @@ use_experimental_fxv2_oal 'yes'
author 'Sky-Systems'
description 'Sky Phone'
version '0.2.6'
version '0.3.4'
provide 'lb-phone'
provide '17mov_Phone'
@@ -30,9 +30,7 @@ shared_scripts {
client_scripts {
'config/config.lua',
'config/locales/en.lua',
'config/locales/de.lua',
'config/locales/es.lua',
'config/locales/*.lua',
'source/bridge/client/callbacks.lua',
'source/client/phone_configurator.lua',
'source/bridge/client/framework.lua',
@@ -54,6 +52,7 @@ client_scripts {
'source/bridge/client/radio.lua',
'source/client/payphones.lua',
'source/client/custom_apps.lua',
'source/client/custom_tones.lua',
'source/client/nui_server_bridge.lua',
'source/client/nui_events.lua',
'source/client/notifications.lua',
@@ -79,6 +78,7 @@ server_scripts {
'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',
@@ -106,6 +106,7 @@ server_scripts {
'source/server/phone.lua',
'source/server/device_directory.lua',
'source/server/db_migrate.lua',
'source/server/custom_tones.lua',
'source/server/admin.lua',
'source/server/lb_phone_migration.lua',
'source/server/custom_app_storage.lua',
+9
View File
@@ -11,6 +11,15 @@ local provider_aliases = {
local function resolve_provider()
local configured = tostring(Config.Calls.VoiceProvider or "")
if configured == "auto" then
for _, candidate in ipairs({ "yaca", "pma", "saltychat" }) do
if GetResourceState(provider_resources[candidate]) == "started" then
return candidate
end
end
return nil
end
local selected = provider_aliases[configured] or configured
local resource_name = provider_resources[selected]
if resource_name and GetResourceState(resource_name) == "started" then
@@ -0,0 +1,73 @@
local provider_name = "tgiann"
local resource_name = "tgiann-house"
local function house_reference(value)
if type(value) ~= "string" then
return nil
end
return value ~= "" and value or nil
end
local function house_entrance(house)
local success, house_data = pcall(function()
return exports[resource_name]:getHouseData(house)
end)
if not success then
Bridge.Debug(
"error",
"[sky_phone] tgiann-house:getHouseData failed for '%s': %s",
house,
tostring(house_data)
)
return nil, "provider_error"
end
local entrance = type(house_data) == "table"
and Bridge.Normalize.Coordinates(house_data.doorCoord) or nil
if not entrance then
return nil, "invalid_coordinates"
end
return entrance
end
local function enrich_overview(properties)
local result = {}
if GetResourceState(resource_name) ~= "started" or type(properties) ~= "table" then
return result
end
for _, property in ipairs(properties) do
local house = type(property) == "table" and house_reference(property.providerId) or nil
if house and property.id == provider_name .. ":" .. house then
local entrance = house_entrance(house)
if entrance then
result[#result + 1] = {
id = property.id,
entrance = entrance,
}
end
end
end
return result
end
Bridge.Housing.RegisterClientProvider(provider_name, {
enrich_overview = enrich_overview,
execute = function(action, data)
if GetResourceState(resource_name) ~= "started" then
return false, "provider_unavailable"
end
if action ~= "set_waypoint" then
return false, "capability_unavailable"
end
local house = type(data) == "table" and house_reference(data.providerId) or nil
if not house then
return false, "invalid_property"
end
local entrance, error_code = house_entrance(house)
if not entrance then
return false, error_code
end
SetNewWaypoint(entrance.x + 0.0, entrance.y + 0.0)
return true
end,
})
@@ -19,3 +19,23 @@ Bridge.Framework.Name = configured_framework
function Bridge.Framework.GetName()
return Bridge.Framework.Name
end
function Bridge.Framework.HasPermission(source, permission)
if type(permission) ~= "string" or permission == "" then
error("[sky_phone] Permission identifiers must be non-empty strings.")
end
local groups = Config.CommandPermissions[permission]
if type(groups) ~= "table" or #groups == 0 then
local message = "[sky_phone] Config.CommandPermissions.%s must contain at least one group."
error(message:format(permission))
end
for index, group in ipairs(groups) do
if type(group) ~= "string" or group == "" then
local message = "[sky_phone] Config.CommandPermissions.%s[%s] must be a non-empty string."
error(message:format(permission, index))
end
end
return Bridge.Framework.HasAdminGroup(source, groups)
end
@@ -24,6 +24,13 @@ function Bridge.Framework.HasAdminGroup(source, groups)
if not player then
return false
end
for _, group in ipairs(groups) do
if IsPlayerAceAllowed(tostring(source), group) then
return true
end
end
return exports.qbx_core:HasGroup(source, groups)
end
@@ -0,0 +1,124 @@
local provider_name = "tgiann"
local resource_name = "tgiann-house"
local function query_owned_houses(identifier)
local success, properties = pcall(function()
return MySQL.query.await([[
SELECT `name`
FROM `tgiann_house`
WHERE `owner` = ?
ORDER BY `name` ASC
]], { identifier })
end)
if not success or type(properties) ~= "table" then
Bridge.Debug("error", "[sky_phone] tgiann-house overview query failed: %s", tostring(properties))
return nil
end
return properties
end
local function owns_house(identifier, house)
local success, property = pcall(function()
return MySQL.single.await([[
SELECT `name`
FROM `tgiann_house`
WHERE `name` = ? AND `owner` = ?
LIMIT 1
]], { house, identifier })
end)
if not success then
Bridge.Debug("error", "[sky_phone] tgiann-house ownership query failed: %s", tostring(property))
return nil, "provider_error"
end
return type(property) == "table"
end
local function house_from_property_id(value)
if type(value) ~= "string" then
return nil
end
local house = value:match("^tgiann:(.+)$")
return house and house ~= "" and house or nil
end
local function normalized_property(house)
return {
id = provider_name .. ":" .. house,
providerId = house,
name = house,
access = "owner",
locked = false,
capabilities = {
lock = false,
keys = false,
waypoint = true,
cctv = false,
garageStatus = false,
},
cctv = { enabled = false },
garage = nil,
keys = nil,
}
end
Bridge.Housing.RegisterProvider(provider_name, {
resource_name = resource_name,
is_available = function()
return GetResourceState(resource_name) == "started"
end,
get_overview = function(source)
local identifier = Bridge.Framework.GetIdentifier(source)
if not identifier then
return nil, "housing_unavailable"
end
local properties = query_owned_houses(identifier)
if not properties then
return nil, "provider_error"
end
local result = {}
local seen = {}
local maximum = math.max(0, math.floor(tonumber(Config.Housing.MaximumProperties) or 0))
for _, property in ipairs(properties) do
if #result >= maximum then
break
end
local house = type(property.name) == "string" and property.name or nil
if house and house ~= "" and not seen[house] then
seen[house] = true
result[#result + 1] = normalized_property(house)
end
end
return result
end,
prepare = function(source, action, data)
if action == "toggle_lock" or action == "grant_key" or action == "revoke_key"
or action == "key_candidates"
then
return nil, "capability_unavailable"
end
if action == "open_cctv" then
return nil, "cctv_unavailable"
end
if action ~= "set_waypoint" then
return nil, "invalid_action"
end
local house = house_from_property_id(data and data.propertyId)
if not house then
return nil, "invalid_property"
end
local identifier = Bridge.Framework.GetIdentifier(source)
if not identifier then
return nil, "housing_unavailable"
end
local owned, error_code = owns_house(identifier, house)
if owned == nil then
return nil, error_code
end
if not owned then
return nil, "property_access_denied"
end
return { providerId = house }
end,
})
+21 -4
View File
@@ -61,13 +61,30 @@ end
Bridge.Inventory.Name = configured_inventory
if selected_adapter.metadata == false then
if Config.Phone.Unique ~= false or Config.Sim.Enabled ~= false then
Bridge.Inventory.ConfigurationError = ("[sky_phone] Inventory '%s' cannot store unique phone or physical SIM metadata. Set Config.Phone.Unique = false and Config.Sim.Enabled = false, or configure a metadata-capable inventory.")
:format(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
)
end
end
enforce_metadata_compatibility()
AddEventHandler("sky_phone:configurator:serverUpdated", enforce_metadata_compatibility)
function Bridge.Inventory.NormalizeItem(item, metadata_field, fallback_slot)
if type(item) ~= "table" then
return nil
@@ -23,8 +23,8 @@ end
function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
local matches = {}
for index, item in pairs(inventory:SearchInventory(source, item_name, metadata) or {}) do
local normalized = normalize(item, tonumber(index) or index)
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
@@ -40,7 +40,7 @@ function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
end
local requested_metadata = type(metadata) == "table" and metadata or {}
if inventory:SetItemMetadata(source, slot.slot, requested_metadata) ~= true then
if inventory:SetItemMetadata(source, slot.slot, requested_metadata) == false then
return false
end
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
+37 -9
View File
@@ -18,6 +18,13 @@ local radio_provider_aliases = {
["pma-voice"] = "pma",
salty = "saltychat",
}
local warned_about_legacy_yaca_status = false
local function is_missing_yaca_status_export(error_message)
local normalized = tostring(error_message):lower()
return normalized:find("isenabled", 1, true) ~= nil
and normalized:find("no such export", 1, true) ~= nil
end
local function yaca_is_enabled()
if GetResourceState("yaca-voice") ~= "started" then
@@ -27,20 +34,41 @@ local function yaca_is_enabled()
local success, enabled = pcall(function()
return exports["yaca-voice"]:isEnabled()
end)
if not success then
Bridge.Debug(
"error",
"[sky_phone] Yaca could not report its availability: %s",
tostring(enabled),
{ always = true }
)
return false
if success then
return enabled == true
end
return enabled == true
if is_missing_yaca_status_export(enabled) then
if not warned_about_legacy_yaca_status then
warned_about_legacy_yaca_status = true
Bridge.Debug(
"warn",
"[sky_phone] Yaca does not expose the server isEnabled status; using legacy compatibility because yaca-voice is started.",
{ always = true }
)
end
return true
end
Bridge.Debug(
"error",
"[sky_phone] Yaca could not report its availability: %s",
tostring(enabled),
{ always = true }
)
return false
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
+57 -15
View File
@@ -9,9 +9,13 @@ 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
22, -- INPUT_JUMP
camera_passthrough_control,
24, -- INPUT_ATTACK
25, -- INPUT_AIM
37, -- INPUT_SELECT_WEAPON
@@ -45,6 +49,8 @@ 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,
@@ -89,20 +95,39 @@ 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 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 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 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)
@@ -198,7 +223,18 @@ 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)
@@ -214,9 +250,6 @@ 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
@@ -243,7 +276,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 and data.gameInput then
if data.active then
watch_camera_controls()
end
end)
@@ -256,6 +289,8 @@ 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
@@ -290,6 +325,8 @@ 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
@@ -314,6 +351,8 @@ 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()
@@ -405,6 +444,9 @@ 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)
+76
View File
@@ -0,0 +1,76 @@
local AUDIO_TRANSFER_TIMEOUT_MS = math.max(15000, tonumber(Config.Bridge.CallbackTimeout) or 0)
local MAX_AUDIO_REQUEST_ID = 2147483646
local MAX_PENDING_AUDIO_REQUESTS = 2
local next_audio_request_id = 0
local pending_audio_requests = {}
local pending_audio_request_count = 0
RegisterNetEvent("sky_phone:tones:audio-response", function(request_id, result)
local request = pending_audio_requests[request_id]
if not request then
return
end
pending_audio_requests[request_id] = nil
pending_audio_request_count = pending_audio_request_count - 1
if type(result) == "table" then
request:resolve(result)
return
end
request:resolve({ success = false, error = "request_failed" })
end)
RegisterNUICallback("tones:audio", function(data, cb)
local tone_id = type(data) == "table" and data.id or nil
if type(tone_id) ~= "string" or tone_id == "" or #tone_id > 128 then
cb({ success = false, error = "invalid_request" })
return
end
if pending_audio_request_count >= MAX_PENDING_AUDIO_REQUESTS then
cb({ success = false, error = "request_in_progress" })
return
end
next_audio_request_id = next_audio_request_id % MAX_AUDIO_REQUEST_ID + 1
local request_id = next_audio_request_id
local request = promise.new()
pending_audio_requests[request_id] = request
pending_audio_request_count = pending_audio_request_count + 1
TriggerServerEvent("sky_phone:tones:audio-request", request_id, tone_id)
SetTimeout(AUDIO_TRANSFER_TIMEOUT_MS, function()
if pending_audio_requests[request_id] ~= request then
return
end
pending_audio_requests[request_id] = nil
pending_audio_request_count = pending_audio_request_count - 1
Bridge.Debug(
"error",
"[sky_phone] Custom tone audio request '%s' timed out.",
tostring(tone_id)
)
request:resolve({ success = false, error = "request_timeout" })
end)
local result = Citizen.Await(request)
if type(result) == "table" then
cb(result)
return
end
cb({ success = false, error = "request_failed" })
end)
AddEventHandler("onResourceStop", function(resource_name)
if resource_name ~= GetCurrentResourceName() then
return
end
for request_id, request in pairs(pending_audio_requests) do
pending_audio_requests[request_id] = nil
request:resolve({ success = false, error = "resource_stopped" })
end
pending_audio_request_count = 0
end)
+18 -5
View File
@@ -1,6 +1,6 @@
SkyPhoneFocus = {}
local blocked_phone_controls = { 24, 140, 141, 142, 257, 263, 264 }
local blocked_phone_controls = { 24, 140, 141, 142, 199, 200, 257, 263, 264 }
local blocked_phone_look_controls = { 1, 2, 3, 4, 5, 6 }
local focused_control_groups = { 0, 1, 2 }
local hold_to_look_enabled = false
@@ -58,6 +58,16 @@ 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)
@@ -96,6 +106,10 @@ 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
@@ -137,7 +151,7 @@ function SkyPhoneFocus.Reapply()
active = state.camera_active,
cursor = focus.cursor,
focused = focus.focused,
gameInput = focus.keep_input,
gameInput = focus.game_input,
})
end
@@ -234,10 +248,9 @@ end
CreateThread(function()
while true do
if game_input or block_game then
local look_passthrough = hold_to_look_enabled
and game_input
local look_passthrough = game_input
and not state.cursor_disabled
and IsControlPressed(0, hold_to_look_control)
and SkyPhoneFocus.IsHoldToLookPressed()
if look_passthrough ~= state.look_passthrough then
state.look_passthrough = look_passthrough
SkyPhoneFocus.Reapply()
+90 -33
View File
@@ -13,9 +13,7 @@ local suggested_admin_command = nil
local suggested_test_data_command = nil
local active_development_command = nil
local registered_development_commands = {}
local active_key_mapping_command = nil
local active_key_mapping_key = nil
local key_mapping_revision = 0
local phone_key_mapping_registered = false
local refresh_development_command
local refresh_phone_key_mapping
local refresh_test_data_command_suggestion
@@ -75,6 +73,21 @@ Bridge.Debug("debug", "[sky_phone] Client script initialized.", { always = true
local locale, locale_name = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
local function apply_disabled_apps(payload)
if type(payload) ~= "table" then
return
end
local disabled = {}
for app_id, enabled in pairs(Config.Apps or {}) do
if type(app_id) == "string" and enabled == false then
disabled[#disabled + 1] = app_id
end
end
table.sort(disabled)
payload.disabledApps = disabled
end
local function send_admin_panel_open()
SendNUIMessage({
type = "admin:open",
@@ -86,6 +99,22 @@ local function send_admin_panel_open()
})
end
local function send_phone_tone_catalog()
local response = Bridge.Callbacks.Trigger("sky_phone:tones:list", {})
if not response or not response.success then
Bridge.Debug("warn", "[sky_phone] Could not load the custom tone catalog.")
return
end
SendNUIMessage({
type = "phone:tones",
data = response.data,
})
end
RegisterNetEvent("sky_phone:tones:changed", function()
send_phone_tone_catalog()
end)
local function close_admin_panel()
if not admin_panel_open then
return
@@ -134,8 +163,10 @@ AddEventHandler("sky_phone:configurator:updated", function()
refresh_phone_key_mapping()
refresh_test_data_command_suggestion()
refresh_admin_command_suggestion()
send_phone_tone_catalog()
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
@@ -175,6 +206,22 @@ local function close_phone(close_device_session)
end
end
local function request_phone_open(callback_name)
if is_open or open_requested then
return true
end
open_requested = true
local result = Bridge.Callbacks.Trigger(callback_name, {})
if type(result) == "table" and result.success == true then
return true
end
open_requested = false
open_without_focus = false
return false
end
local function toggle_phone(open, no_focus)
if open ~= nil and type(open) ~= "boolean" then
Bridge.Debug("error", "[sky_phone] Rejected invalid phone toggle state.")
@@ -190,6 +237,9 @@ local function toggle_phone(open, no_focus)
should_open = not (is_open or open_requested)
end
if not should_open then
if open == nil and open_requested and not is_open then
return true
end
close_phone()
return true
end
@@ -202,12 +252,7 @@ local function toggle_phone(open, no_focus)
return true
end
local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
if type(result) ~= "table" or result.success ~= true then
open_without_focus = false
return false
end
return true
return request_phone_open("sky_phone:device:open-request")
end
SkyPhoneClient.Toggle = toggle_phone
@@ -217,13 +262,16 @@ AddEventHandler("sky_phone:client:forceClose", function()
end)
local function run_development_command()
if is_open or open_requested then
if is_open then
close_phone()
return
end
if open_requested then
return
end
open_without_focus = false
Bridge.Callbacks.Trigger("sky_phone:device:development-open", {})
request_phone_open("sky_phone:device:development-open")
end
refresh_development_command = function()
@@ -256,13 +304,16 @@ end
refresh_development_command()
local function run_phone_toggle()
if is_open or open_requested then
if is_open then
close_phone()
return
end
if open_requested then
return
end
open_without_focus = false
Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
request_phone_open("sky_phone:device:open-request")
end
RegisterCommand("sky_phone_live_activity_open", function()
@@ -271,38 +322,35 @@ RegisterCommand("sky_phone_live_activity_open", function()
end
open_home_requested = true
local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
if not result or not result.success then
if not request_phone_open("sky_phone:device:open-request") then
open_home_requested = false
end
end, false)
RegisterCommand("sky_phone_toggle", run_phone_toggle, false)
RegisterCommand("sky_phone_toggle", function()
if not Config.Phone.Keybind then
return
end
run_phone_toggle()
end, false)
refresh_phone_key_mapping = function()
local key_name = Config.Phone.Keybind
if key_name ~= false and key_name ~= nil and (type(key_name) ~= "string" or key_name == "") then
error("[sky_phone] Config.Phone.Keybind must be a non-empty keyboard key name or false.")
end
if key_name == active_key_mapping_key then
if phone_key_mapping_registered or not key_name then
return
end
active_key_mapping_key = key_name
active_key_mapping_command = nil
if not key_name then
return
end
key_mapping_revision = key_mapping_revision + 1
local command_name = "sky_phone_toggle_config_" .. key_mapping_revision
active_key_mapping_command = command_name
RegisterCommand(command_name, function()
if active_key_mapping_command == command_name then
run_phone_toggle()
end
end, false)
RegisterKeyMapping(command_name, locale.Controls.OpenPhone, "keyboard", key_name)
-- 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
)
end
refresh_phone_key_mapping()
@@ -347,6 +395,7 @@ RegisterNUICallback("ui:ready", function(data, cb)
-- cannot survive unless their UI is replayed as part of this handshake.
SkyPhoneFocus.BeginNuiHydration()
Bridge.Debug("debug", "[sky_phone] NUI reported ready.", { always = true })
send_phone_tone_catalog()
SkyPhoneApps.SendCatalog()
if open_requested and device_payload then
send_open_message()
@@ -459,6 +508,10 @@ end)
RegisterNetEvent("sky_phone:device:open", function(data)
if type(data) ~= "table" or type(data.device) ~= "table" or type(data.device.imei) ~= "string" then
Bridge.Debug("error", "[sky_phone] Rejected invalid device open data.")
if not is_open then
open_requested = false
open_without_focus = false
end
return
end
Bridge.Debug(
@@ -468,6 +521,7 @@ RegisterNetEvent("sky_phone:device:open", function(data)
tostring(data.account ~= nil),
{ always = true }
)
apply_disabled_apps(data)
device_payload = data
update_equipped_phone_number(data)
open_requested = true
@@ -484,6 +538,7 @@ RegisterNetEvent("sky_phone:device:updated", function(data)
Bridge.Debug("error", "[sky_phone] Rejected invalid device update data.")
return
end
apply_disabled_apps(data)
device_payload = data
update_equipped_phone_number(data)
SendNUIMessage({ type = "device:updated", data = data })
@@ -499,8 +554,10 @@ RegisterNetEvent("sky_phone:device:invalidated", function()
end)
RegisterNetEvent("sky_phone:device:error", function(error_code)
if not is_open and not open_requested then
if not is_open then
open_requested = false
open_without_focus = false
open_home_requested = false
end
Bridge.Debug(
"debug",
@@ -3,6 +3,7 @@ local callback_groups = {
admin = [[
bootstrap player save-apps reveal-password activity
reset-passcode change-number factory-reset configurator save-configurator
tones tone-upload-start tone-upload-chunk tone-upload-finish tone-upload-cancel delete-tone
]],
banking = [[overview transfer]],
billing = [[overview list detail markRead pay dispute]],
@@ -71,6 +72,7 @@ local callback_groups = {
]],
security = [[unlock set-passcode change-passcode disable-passcode]],
sim = [[insert eject]],
tones = [[list]],
["weazel-news"] = [[context list get manage-list create update delete]],
}
+138 -5
View File
@@ -98,7 +98,7 @@ end
local function require_admin(source, operation, maximum)
if not Config.AdminPanel.Enabled
or not Bridge.Framework.HasAdminGroup(source, Config.AdminPanel.AdminGroups)
or not Bridge.Framework.HasPermission(source, "phonepanel")
then
Bridge.Debug("warn", "[sky_phone] Rejected admin panel access from source %s.", tostring(source))
return nil, { success = false, error = "not_authorized" }
@@ -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.HasAdminGroup(player_source, Config.AdminPanel.AdminGroups) then
if not Bridge.Framework.HasPermission(player_source, "phonepanel") then
Bridge.Debug(
"warn",
"[sky_phone] Rejected admin panel command from source %s.",
@@ -373,6 +373,9 @@ end
local function app_metadata(app_id)
if BUILTIN_APPS[app_id] then
if not SkyPhone.IsAppEnabled(app_id) then
return nil
end
return {
defaultInstalled = DEFAULT_INSTALLED_APPS[app_id] == true,
removable = PROTECTED_APPS[app_id] ~= true,
@@ -463,16 +466,36 @@ Bridge.Callbacks.Register("sky_phone:admin:bootstrap", function(source)
local totals = Bridge.Database.Query([[
SELECT
(SELECT COUNT(*) FROM `sky_phone_devices`) AS `devices`,
(SELECT COUNT(*) FROM `sky_phone_accounts`) AS `accounts`
(SELECT COUNT(*) FROM `sky_phone_accounts`) AS `accounts`,
(SELECT COUNT(*) FROM `sky_phone_devices` WHERE `account_id` IS NOT NULL) AS `linked_devices`,
(SELECT COUNT(*) FROM `sky_phone_devices` WHERE `sim_id` IS NOT NULL) AS `sim_devices`,
(SELECT COUNT(*) FROM `sky_phone_devices`
WHERE `updated_at` >= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 24 HOUR)) AS `active_devices`,
(SELECT COUNT(*) FROM `sky_phone_sms_messages`
WHERE `created_at` >= CURRENT_DATE) AS `messages_today`,
(SELECT COUNT(*) FROM `sky_phone_calls`
WHERE `started_at` >= CURRENT_DATE) AS `calls_today`,
(SELECT COUNT(*) FROM `sky_phone_admin_audit`) AS `audit_entries`,
(SELECT COUNT(*) FROM `sky_phone_admin_audit`
WHERE `created_at` >= CURRENT_DATE) AS `audit_today`
]], {})
local stats = totals[1] or {}
return {
success = true,
data = {
players = players,
disabledApps = SkyPhone.GetDisabledApps(),
stats = {
online = #players,
devices = tonumber(totals[1] and totals[1].devices) or 0,
accounts = tonumber(totals[1] and totals[1].accounts) or 0,
devices = tonumber(stats.devices) or 0,
accounts = tonumber(stats.accounts) or 0,
linkedDevices = tonumber(stats.linked_devices) or 0,
simDevices = tonumber(stats.sim_devices) or 0,
activeDevices = tonumber(stats.active_devices) or 0,
messagesToday = tonumber(stats.messages_today) or 0,
callsToday = tonumber(stats.calls_today) or 0,
auditEntries = tonumber(stats.audit_entries) or 0,
auditToday = tonumber(stats.audit_today) or 0,
},
audit = load_audit(),
},
@@ -927,4 +950,114 @@ Bridge.Callbacks.Register("sky_phone:admin:save-configurator", function(source,
end
return response
end)
Bridge.Callbacks.Register("sky_phone:admin:tones", function(source)
local authorized, error_response = require_admin(
source,
"tones_read",
Config.AdminPanel.ReadRequestsPerMinute
)
if not authorized then
return error_response
end
return { success = true, data = SkyPhoneTones.GetAdminList() }
end)
Bridge.Callbacks.Register("sky_phone:admin:tone-upload-start", function(source, data)
local authorized, error_response = require_admin(
source,
"tone_upload_start",
Config.AdminPanel.ActionRequestsPerMinute
)
if not authorized then
return error_response
end
local actor_identifier = Bridge.Framework.GetIdentifier(source)
if not actor_identifier then
return { success = false, error = "player_unavailable" }
end
local actor_name = player_name(source)
return SkyPhoneTones.BeginUpload(source, data, actor_identifier, actor_name)
end)
Bridge.Callbacks.Register("sky_phone:admin:tone-upload-chunk", function(source, data)
local authorized, error_response = require_admin(
source,
"tone_upload_chunk",
math.max(400, tonumber(Config.AdminPanel.ActionRequestsPerMinute) or 0)
)
if not authorized then
return error_response
end
return SkyPhoneTones.AppendUploadChunk(source, data)
end)
Bridge.Callbacks.Register("sky_phone:admin:tone-upload-finish", function(source, data)
local authorized, error_response = require_admin(
source,
"tone_upload_finish",
Config.AdminPanel.ActionRequestsPerMinute
)
if not authorized then
return error_response
end
local actor_identifier = Bridge.Framework.GetIdentifier(source)
if not actor_identifier then
return { success = false, error = "player_unavailable" }
end
local response = SkyPhoneTones.CompleteUpload(source, data)
if response.success then
local upload = response.upload or {}
write_audit(source, source, actor_identifier, nil, "create_custom_tone", {
durationMs = tonumber(upload.durationMs),
label = trim(upload.label),
toneId = response.toneId,
toneType = upload.toneType,
})
end
response.upload = nil
return response
end)
Bridge.Callbacks.Register("sky_phone:admin:tone-upload-cancel", function(source, data)
local authorized, error_response = require_admin(
source,
"tone_upload_cancel",
Config.AdminPanel.ActionRequestsPerMinute
)
if not authorized then
return error_response
end
return SkyPhoneTones.CancelUpload(source, data)
end)
Bridge.Callbacks.Register("sky_phone:admin:delete-tone", function(source, data)
local authorized, error_response = require_admin(
source,
"tone_delete",
Config.AdminPanel.ActionRequestsPerMinute
)
if not authorized then
return error_response
end
if type(data) ~= "table" then
return { success = false, error = "invalid_request" }
end
local actor_identifier = Bridge.Framework.GetIdentifier(source)
if not actor_identifier then
return { success = false, error = "player_unavailable" }
end
local response = SkyPhoneTones.Delete(data.id)
if response.success then
write_audit(source, source, actor_identifier, nil, "delete_custom_tone", {
label = response.tone.label,
toneId = response.tone.id,
toneType = response.tone.tone_type,
})
end
return response
end)
end)
+377 -86
View File
@@ -156,12 +156,18 @@ local function permission_grade(definition, permission)
return grade and math.max(0, math.floor(grade)) or nil
end
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.")
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."
end
if type(Config.Companies.Enabled) ~= "boolean" then
error("[sky_phone] Config.Companies.Enabled must be a boolean.")
if type(company_config.Enabled) ~= "boolean" then
return nil, "[sky_phone] Config.Companies.Enabled must be a boolean."
end
for _, field in ipairs({
{ "PageSize", 1000 },
@@ -184,26 +190,26 @@ local function validate_configuration()
{ "AnnouncementMaximumSeconds", 31536000 },
{ "RetentionDays", 36500 },
}) do
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]))
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])
end
end
if Config.Companies.PageSize > Config.Companies.MaximumPageSize then
error("[sky_phone] Companies PageSize cannot exceed MaximumPageSize.")
if company_config.PageSize > company_config.MaximumPageSize then
return nil, "[sky_phone] Companies PageSize cannot exceed MaximumPageSize."
end
if type(Config.Companies.RateLimits) ~= "table" then
error("[sky_phone] Config.Companies.RateLimits must be configured.")
if type(company_config.RateLimits) ~= "table" then
return nil, "[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(Config.Companies.RateLimits[name], 1, 100000) then
error(("[sky_phone] Companies rate limit '%s' is invalid."):format(name))
if not valid_integer(company_config.RateLimits[name], 1, 100000) then
return nil, ("[sky_phone] Companies rate limit '%s' is invalid."):format(name)
end
end
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)
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)
then
error("[sky_phone] Config.Companies.CallRouting is invalid.")
return nil, "[sky_phone] Config.Companies.CallRouting is invalid."
end
local configured_statuses = {
new = true,
@@ -213,48 +219,48 @@ local function validate_configuration()
completed = true,
cancelled = true,
}
if type(Config.Companies.Statuses) ~= "table" then
error("[sky_phone] Config.Companies.Statuses must be configured.")
if type(company_config.Statuses) ~= "table" then
return nil, "[sky_phone] Config.Companies.Statuses must be configured."
end
for status in pairs(configured_statuses) do
if Config.Companies.Statuses[status] ~= true then
error(("[sky_phone] Companies status '%s' must be enabled."):format(status))
if company_config.Statuses[status] ~= true then
return nil, ("[sky_phone] Companies status '%s' must be enabled."):format(status)
end
end
for status, enabled in pairs(Config.Companies.Statuses) do
for status, enabled in pairs(company_config.Statuses) do
if not configured_statuses[status] or enabled ~= true then
error(("[sky_phone] Companies status '%s' is unsupported."):format(tostring(status)))
return nil, ("[sky_phone] Companies status '%s' is unsupported."):format(tostring(status))
end
end
if type(Config.Companies.AvailabilityStatuses) ~= "table" then
error("[sky_phone] Config.Companies.AvailabilityStatuses must be configured.")
if type(company_config.AvailabilityStatuses) ~= "table" then
return nil, "[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 Config.Companies.AvailabilityStatuses[status] ~= true then
error(("[sky_phone] Companies availability status '%s' must be enabled."):format(status))
if company_config.AvailabilityStatuses[status] ~= true then
return nil, ("[sky_phone] Companies availability status '%s' must be enabled."):format(status)
end
end
for status, enabled in pairs(Config.Companies.AvailabilityStatuses) do
for status, enabled in pairs(company_config.AvailabilityStatuses) do
if not configured_availability[status] or enabled ~= true then
error(("[sky_phone] Companies availability status '%s' is unsupported."):format(tostring(status)))
return nil, ("[sky_phone] Companies availability status '%s' is unsupported."):format(tostring(status))
end
end
if not valid_array(Config.Companies.Categories, 100) then
error("[sky_phone] Config.Companies.Categories must be a bounded array.")
if not valid_array(company_config.Categories, 100) then
return nil, "[sky_phone] Config.Companies.Categories must be a bounded array."
end
local category_ids = {}
local configured_service_ids = {}
for _, category_id in ipairs(Config.Companies.Categories) do
for _, category_id in ipairs(company_config.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
error("[sky_phone] Companies contains an invalid category ID.")
return nil, "[sky_phone] Companies contains an invalid category ID."
end
category_ids[category_id] = true
end
for company_id, definition in pairs(Config.Companies.Definitions) do
for company_id, definition in pairs(company_config.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
@@ -262,26 +268,25 @@ local function validate_configuration()
or not valid_text(definition.Name, 120, false)
or not category_ids[definition.Category]
then
error(("[sky_phone] Company definition '%s' is invalid."):format(tostring(company_id)))
return nil, ("[sky_phone] Company definition '%s' is invalid."):format(tostring(company_id))
end
local description = valid_text(definition.Description or "", Config.Companies.ProfileDescriptionMaxLength, true)
local district = valid_text(definition.District or "", Config.Companies.DistrictMaxLength, true)
local description = valid_text(definition.Description or "", company_config.ProfileDescriptionMaxLength, true)
local district = valid_text(definition.District or "", company_config.DistrictMaxLength, true)
local location_label = valid_text(
definition.LocationLabel or definition.Address or "",
Config.Companies.DistrictMaxLength,
company_config.DistrictMaxLength,
true
)
local address = valid_text(definition.Address or "", Config.Companies.AddressMaxLength, true)
local address = valid_text(definition.Address or "", company_config.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 Config.Companies.AvailabilityStatuses[definition.DefaultAvailability]
or not company_config.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
error(("[sky_phone] Company definition '%s' has invalid public profile defaults."):format(company_id))
return nil, ("[sky_phone] Company definition '%s' has invalid public profile defaults."):format(company_id)
end
definition.Name = trim(definition.Name)
definition.Description = description
@@ -292,7 +297,7 @@ local function validate_configuration()
if definition.Location ~= nil then
local location_type = type(definition.Location)
if location_type ~= "table" and location_type ~= "vector3" then
error(("[sky_phone] Company definition '%s' has invalid location coordinates."):format(company_id))
return nil, ("[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)
@@ -300,46 +305,46 @@ local function validate_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
error(("[sky_phone] Company definition '%s' has invalid location coordinates."):format(company_id))
return nil, ("[sky_phone] Company definition '%s' has invalid location coordinates."):format(company_id)
end
end
if definitions_by_job[definition.Job] then
error(("[sky_phone] Framework job '%s' is assigned to more than one company."):format(definition.Job))
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)
end
local line = definition.ServiceLine
if type(line) ~= "table" then
error(("[sky_phone] Company '%s' has no service line configuration."):format(company_id))
return nil, ("[sky_phone] Company '%s' has no service line configuration."):format(company_id)
end
local number = SkyPhoneSimNumber.NormalizeService(line.Number, Config.Sim.NumberLength)
local number = SkyPhoneSimNumber.NormalizeService(line.Number, configuration.Sim.NumberLength)
if not number then
error(("[sky_phone] Company '%s' has an invalid service number."):format(company_id))
return nil, ("[sky_phone] Company '%s' has an invalid service number."):format(company_id)
end
if service_lines_by_number[number] then
error(("[sky_phone] Service number '%s' is assigned more than once."):format(number))
if validated_service_lines_by_number[number] then
return nil, ("[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
error(("[sky_phone] Company '%s' has invalid service line flags or grade."):format(company_id))
return nil, ("[sky_phone] Company '%s' has invalid service line flags or grade."):format(company_id)
end
if line.AutoContact and not definition.Public then
error(("[sky_phone] Private company '%s' cannot create a public system contact."):format(company_id))
return nil, ("[sky_phone] Private company '%s' cannot create a public system contact."):format(company_id)
end
if line.Routing ~= "round_robin" then
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))
return nil, ("[sky_phone] Company '%s' uses unsupported call routing '%s'."):format(
company_id,
tostring(line.Routing)
)
end
line.Number = number
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
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
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
error(("[sky_phone] Company '%s' has no valid '%s' grade."):format(company_id, permission))
return nil, ("[sky_phone] Company '%s' has no valid '%s' grade."):format(company_id, permission)
end
end
local default_services = definition.Services
@@ -347,40 +352,54 @@ local function validate_configuration()
default_services = {}
definition.Services = default_services
end
if not valid_array(default_services, Config.Companies.MaximumServices) then
error(("[sky_phone] Company '%s' has an invalid default service list."):format(company_id))
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)
end
for _, service in ipairs(default_services) do
if type(service) ~= "table" then
error(("[sky_phone] Company '%s' has an invalid default service."):format(company_id))
return nil, ("[sky_phone] Company '%s' has an invalid default service."):format(company_id)
end
local title = valid_text(service.Title, Config.Companies.ServiceTitleMaxLength, false)
local title = valid_text(service.Title, company_config.ServiceTitleMaxLength, false)
local service_description = valid_text(
service.Description or "",
Config.Companies.ServiceDescriptionMaxLength,
company_config.ServiceDescriptionMaxLength,
true
)
local price = valid_text(service.Price or "", Config.Companies.ServicePriceMaxLength, true)
local price = valid_text(service.Price or "", company_config.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
error(("[sky_phone] Company '%s' has an invalid default service."):format(company_id))
return nil, ("[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
error(("[sky_phone] Default company service ID '%s' is configured more than once."):format(service.Id))
return nil, ("[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(definition_ids, function(left, right)
local left_name = definitions[left].Name:lower()
local right_name = definitions[right].Name:lower()
table.sort(validated_definition_ids, function(left, right)
local left_name = validated_definitions[left].Name:lower()
local right_name = validated_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()
@@ -466,6 +485,48 @@ 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`
@@ -826,7 +887,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 and not definition.Emergency,
acceptsRequests = tonumber(row.accepts_requests) == 1,
phoneNumber = line and line.Number or nil,
canCall = line and line.CanCall == true or false,
canMessage = line and line.CanMessage == true or false,
@@ -906,12 +967,16 @@ local function cleanup_retained_data()
end
local function refresh_runtime_configuration()
definitions = {}
definition_ids = {}
definitions_by_job = {}
service_lines_by_number = {}
validate_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
seed_companies()
migrate_requestable_emergency_companies()
tombstone_removed_companies()
end
@@ -1123,7 +1188,7 @@ end
local function request_row(request_id)
local rows = Bridge.Database.Query([[
SELECT r.`id`, r.`company_id`, r.`service_id`, r.`customer_sim_id`, r.`subject`, r.`description`,
SELECT r.`id`, r.`company_id`, r.`service_id`, r.`channel`, 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`,
@@ -1825,6 +1890,175 @@ 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
@@ -1839,7 +2073,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 or definition.Emergency then
if not definition or not definition.Public then
return { success = false, error = "company_not_found" }
end
local subject = valid_text(data.subject, Config.Companies.SubjectMaxLength, false)
@@ -2090,6 +2324,11 @@ 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"
@@ -2129,6 +2368,37 @@ 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 = [[
@@ -2143,16 +2413,32 @@ 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 `id` FROM `sky_phone_company_request_messages` WHERE `id` = ? LIMIT 1",
{ message_id }
)
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 })
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(
@@ -2162,6 +2448,12 @@ 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",
@@ -2636,7 +2928,6 @@ 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
+326 -163
View File
@@ -6,7 +6,12 @@ local exchange_lock = false
local markets = {}
local market_order = {}
local market_dynamics = {}
local market_state = {}
local market_history = {}
local market_daily_buckets = {}
local market_cursor = 1
local market_daily_bucket_seconds = 5 * 60
local market_persistence_interval = market_daily_bucket_seconds * 1000
local global_market_trend = 0
local global_market_cycle = {
direction = 0,
@@ -370,6 +375,144 @@ local function initialize_markets()
market_cursor = math.min(market_cursor, math.max(#market_order, 1))
end
local function add_market_daily_price(buckets, price, timestamp)
local bucket_id = math.floor(timestamp / market_daily_bucket_seconds)
local bucket = buckets[#buckets]
if bucket and bucket.bucket_id == bucket_id then
bucket.low = math.min(bucket.low, price)
bucket.high = math.max(bucket.high, price)
return
end
buckets[#buckets + 1] = {
bucket_id = bucket_id,
low = price,
high = price,
}
end
local function market_daily_range(market_id, price, timestamp)
local buckets = market_daily_buckets[market_id]
local cutoff_bucket = math.floor((timestamp - 24 * 60 * 60) / market_daily_bucket_seconds)
while buckets[1] and buckets[1].bucket_id < cutoff_bucket do
table.remove(buckets, 1)
end
local low = price
local high = price
for index = 1, #buckets do
local bucket = buckets[index]
low = math.min(low, bucket.low)
high = math.max(high, bucket.high)
end
return low, high
end
local function load_market_cache()
local rows = Bridge.Database.Query([[
SELECT `id`,`price`,`version`,`status`, UNIX_TIMESTAMP(`updated_at`) AS `updated_at`
FROM `sky_phone_crypto_markets`
]], {})
local next_market_state = {}
local next_market_history = {}
local next_market_daily_buckets = {}
local history_limit = math.min(Config.Crypto.HistoryRetentionTicks, Config.Crypto.SparklinePoints)
local timestamp = os.time()
for _, row in ipairs(rows) do
if markets[row.id] then
next_market_state[row.id] = {
price = tonumber(row.price) or markets[row.id].InitialPrice,
version = tonumber(row.version) or 1,
status = row.status,
updated_at = tonumber(row.updated_at) or timestamp,
dirty = false,
}
end
end
for _, market_id in ipairs(market_order) do
local state = next_market_state[market_id]
if not state then
error(("[sky_phone] Crypto market state is missing after initialization: %s"):format(market_id))
end
local rows_for_market = Bridge.Database.Query([[
SELECT `price`
FROM `sky_phone_crypto_market_ticks`
WHERE `market_id` = ? ORDER BY `created_at` DESC, `id` DESC LIMIT ?
]], { market_id, history_limit })
local history = {}
for index = #rows_for_market, 1, -1 do
history[#history + 1] = tonumber(rows_for_market[index].price) or state.price
end
if #history == 0 then
history[1] = state.price
end
next_market_history[market_id] = history
local daily_rows = Bridge.Database.Query([[
SELECT FLOOR(UNIX_TIMESTAMP(`created_at`) / ?) AS `bucket_id`,
MIN(`price`) AS `low_price`, MAX(`price`) AS `high_price`
FROM `sky_phone_crypto_market_ticks`
WHERE `market_id` = ?
AND `created_at` >= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 24 HOUR)
GROUP BY `bucket_id` ORDER BY `bucket_id`
]], { market_daily_bucket_seconds, market_id })
local daily_buckets = {}
for _, daily_row in ipairs(daily_rows) do
daily_buckets[#daily_buckets + 1] = {
bucket_id = tonumber(daily_row.bucket_id),
low = tonumber(daily_row.low_price) or state.price,
high = tonumber(daily_row.high_price) or state.price,
}
end
add_market_daily_price(daily_buckets, state.price, timestamp)
next_market_daily_buckets[market_id] = daily_buckets
end
market_state = next_market_state
market_history = next_market_history
market_daily_buckets = next_market_daily_buckets
end
local function persist_market_cache()
local queries = {}
local persisted_markets = {}
for _, market_id in ipairs(market_order) do
local state = market_state[market_id]
if state and state.dirty then
queries[#queries + 1] = {
query = [[
UPDATE `sky_phone_crypto_markets`
SET `price` = ?, `version` = ?, `status` = ?, `updated_at` = FROM_UNIXTIME(?)
WHERE `id` = ?
]],
params = { state.price, state.version, state.status, state.updated_at, market_id },
}
queries[#queries + 1] = {
query = [[
INSERT INTO `sky_phone_crypto_market_ticks`
(`market_id`,`version`,`price`,`created_at`) VALUES (?, ?, ?, FROM_UNIXTIME(?))
]],
params = { market_id, state.version, state.price, state.updated_at },
}
persisted_markets[#persisted_markets + 1] = market_id
end
end
if #queries == 0 then
return true
end
queries[#queries + 1] = {
query = [[
DELETE FROM `sky_phone_crypto_market_ticks`
WHERE `created_at` < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 24 HOUR)
]],
params = {},
}
if not Bridge.Database.Transaction(queries) then
print("[sky_phone] Failed to persist the crypto market cache.")
return false
end
for _, market_id in ipairs(persisted_markets) do
market_state[market_id].dirty = false
end
return true
end
local function require_phone(source)
local phone_session, error_response = SkyPhone.RequireSession(source)
if not phone_session then
@@ -451,20 +594,7 @@ local function balance(account, asset)
row and (tonumber(row.version) or 0) or 0
end
local function market_rows()
local rows = Bridge.Database.Query([[
SELECT `id`,`price`,`version`,`status`, UNIX_TIMESTAMP(`updated_at`) AS `updated_at`
FROM `sky_phone_crypto_markets`
]], {})
local indexed = {}
for _, row in ipairs(rows) do
indexed[row.id] = row
end
return indexed
end
local function market_dtos(selected_market_ids)
local current = market_rows()
local selected = nil
if selected_market_ids then
selected = {}
@@ -472,28 +602,17 @@ local function market_dtos(selected_market_ids)
selected[market_id] = true
end
end
local daily_rows = Bridge.Database.Query([[
SELECT `market_id`, MIN(`price`) AS `low`, MAX(`price`) AS `high`
FROM `sky_phone_crypto_market_ticks`
WHERE `created_at` >= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 24 HOUR)
GROUP BY `market_id`
]], {})
local daily = {}
for _, daily_row in ipairs(daily_rows) do
daily[daily_row.market_id] = daily_row
end
local result = {}
local timestamp = os.time()
for _, market_id in ipairs(market_order) do
if not selected or selected[market_id] then
local config = markets[market_id]
local row = current[market_id]
local ticks = Bridge.Database.Query([[
SELECT `price` FROM `sky_phone_crypto_market_ticks`
WHERE `market_id` = ? ORDER BY `id` DESC LIMIT ?
]], { market_id, Config.Crypto.SparklinePoints })
local row = market_state[market_id]
local history = market_history[market_id]
local prices = {}
for index = #ticks, 1, -1 do
prices[#prices + 1] = tonumber(ticks[index].price) or tonumber(row.price)
local first_history_index = math.max(1, #history - Config.Crypto.SparklinePoints + 1)
for index = first_history_index, #history do
prices[#prices + 1] = history[index]
end
if #prices == 0 then
prices[1] = tonumber(row.price)
@@ -509,7 +628,7 @@ local function market_dtos(selected_market_ids)
end
local first = prices[1]
local price = tonumber(row.price) or config.InitialPrice
local daily_range = daily[market_id]
local daily_low, daily_high = market_daily_range(market_id, price, timestamp)
result[#result + 1] = {
id = market_id,
symbol = config.Symbol,
@@ -519,13 +638,13 @@ local function market_dtos(selected_market_ids)
price = decimal_string(price, Config.Crypto.PriceScale),
changePercent = first > 0 and ((price - first) / first) * 100 or 0,
enabled = row.status == "active",
high24h = decimal_string(daily_range and daily_range.high or maximum, Config.Crypto.PriceScale),
low24h = decimal_string(daily_range and daily_range.low or minimum, Config.Crypto.PriceScale),
high24h = decimal_string(daily_high, Config.Crypto.PriceScale),
low24h = decimal_string(daily_low, Config.Crypto.PriceScale),
issuedSupply = decimal_string(config.IssuedSupply * Config.Crypto.AssetScale, Config.Crypto.AssetScale),
treasuryAvailable = decimal_string(balance("treasury", market_id), Config.Crypto.AssetScale),
priceHistory = price_history,
sparkline = sparkline,
updatedAt = (tonumber(row.updated_at) or os.time()) * 1000,
updatedAt = (tonumber(row.updated_at) or timestamp) * 1000,
}
end
end
@@ -567,13 +686,12 @@ end
local function bootstrap(profile)
local cash = balance(account_id(profile.id), "CASH")
local current_markets = market_rows()
local holdings = {}
local portfolio = cash
for _, market_id in ipairs(market_order) do
local available = balance(account_id(profile.id), market_id)
if available > 0 then
local price = tonumber(current_markets[market_id].price) or 0
local price = tonumber(market_state[market_id].price) or 0
local value = math.floor(available * price / Config.Crypto.AssetScale)
local fill = Bridge.Database.Query([[
SELECT FLOOR(SUM(fill.`gross`) * ? / NULLIF(SUM(fill.`quantity`), 0)) AS `price`
@@ -1044,10 +1162,7 @@ Bridge.Callbacks.Register("sky_phone:crypto:quote", function(source, data)
if not config or not side or not quantity then
return { success = false, error = "invalid_quantity" }
end
local market = Bridge.Database.Query(
"SELECT `price`,`version`,`status` FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1",
{ config.Id }
)[1]
local market = market_state[config.Id]
if not market or market.status ~= "active" then
return { success = false, error = "market_unavailable" }
end
@@ -1127,9 +1242,8 @@ local function execute_trade(profile, data)
or { success = false, error = "duplicate_request" }
end
local quote = Bridge.Database.Query([[
SELECT quote.*, market.`status` AS `market_status`, market.`version` AS `current_version`
SELECT quote.*
FROM `sky_phone_crypto_quotes` quote
JOIN `sky_phone_crypto_markets` market ON market.`id` = quote.`market_id`
WHERE quote.`id` = ? AND quote.`profile_id` = ? LIMIT 1
]], { data.quoteId, profile.id })[1]
if not quote or quote.consumed_operation_id then
@@ -1142,7 +1256,10 @@ local function execute_trade(profile, data)
if not expiry or tonumber(expiry.expires_at) < os.time() then
return { success = false, error = "quote_expired" }
end
if quote.market_status ~= "active" or tonumber(quote.current_version) ~= tonumber(quote.market_version) then
local current_market = market_state[quote.market_id]
if not current_market or current_market.status ~= "active"
or current_market.version ~= tonumber(quote.market_version)
then
return { success = false, error = "quote_expired" }
end
local quantity = tonumber(quote.quantity)
@@ -1367,8 +1484,7 @@ end)
ensure_schema()
migrate_crypto_keys()
initialize_markets()
AddEventHandler("sky_phone:configurator:serverUpdated", initialize_markets)
load_market_cache()
local function reconcile_settlements(include_recent)
local age_clause = include_recent and "" or " AND settlement.`updated_at` < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)"
@@ -1416,8 +1532,6 @@ local function reconcile_settlements(include_recent)
end
end
reconcile_settlements(true)
local function crypto_random_int(minimum, maximum, failure_message)
local value = exports[GetCurrentResourceName()]:CryptoRandomInt(minimum, maximum)
if type(value) ~= "number" then
@@ -1500,134 +1614,183 @@ local function advance_market_cycle(config, dynamics)
return dynamics.cycle_bias
end
CreateThread(function()
while true do
Wait(5 * 60 * 1000)
reconcile_settlements(false)
local scheduler_generation = 0
local function start_crypto_schedulers()
scheduler_generation = scheduler_generation + 1
if Config.Crypto.Enabled ~= true then
return
end
end)
CreateThread(function()
while true do
local tick_seconds = crypto_random_int(
Config.Crypto.PriceTickMinimumSeconds,
Config.Crypto.PriceTickMaximumSeconds + 1,
"[sky_phone] Crypto entropy provider did not return a market tick interval."
)
Wait(tick_seconds * 1000)
with_exchange_lock(function()
local market_count = crypto_random_int(
Config.Crypto.MarketsPerTickMinimum,
Config.Crypto.MarketsPerTickMaximum + 1,
"[sky_phone] Crypto entropy provider did not return a market count."
local generation = scheduler_generation
reconcile_settlements(true)
CreateThread(function()
while scheduler_generation == generation and Config.Crypto.Enabled == true do
Wait(market_persistence_interval)
if scheduler_generation ~= generation or Config.Crypto.Enabled ~= true then
break
end
with_exchange_lock(persist_market_cache)
end
end)
CreateThread(function()
while scheduler_generation == generation and Config.Crypto.Enabled == true do
Wait(5 * 60 * 1000)
if scheduler_generation ~= generation or Config.Crypto.Enabled ~= true then
break
end
reconcile_settlements(false)
end
end)
CreateThread(function()
while scheduler_generation == generation and Config.Crypto.Enabled == true do
local tick_seconds = crypto_random_int(
Config.Crypto.PriceTickMinimumSeconds,
Config.Crypto.PriceTickMaximumSeconds + 1,
"[sky_phone] Crypto entropy provider did not return a market tick interval."
)
advance_global_market_cycle()
local changed_markets = {}
market_count = math.min(market_count, #market_order)
Wait(tick_seconds * 1000)
if scheduler_generation ~= generation or Config.Crypto.Enabled ~= true then
break
end
with_exchange_lock(function()
local market_count = crypto_random_int(
Config.Crypto.MarketsPerTickMinimum,
Config.Crypto.MarketsPerTickMaximum + 1,
"[sky_phone] Crypto entropy provider did not return a market count."
)
advance_global_market_cycle()
local changed_markets = {}
local history_limit = math.min(
Config.Crypto.HistoryRetentionTicks,
Config.Crypto.SparklinePoints
)
market_count = math.min(market_count, #market_order)
for offset = 0, market_count - 1 do
local order_index = ((market_cursor + offset - 1) % #market_order) + 1
local market_id = market_order[order_index]
local config = markets[market_id]
local row = Bridge.Database.Query(
"SELECT `price`,`version`,`status` FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1",
{ market_id }
)[1]
if row and row.status == "active" then
local price = tonumber(row.price) or config.InitialPrice
local impulse = crypto_random_int(
-config.VolatilityBasisPoints,
config.VolatilityBasisPoints + 1,
"[sky_phone] Crypto entropy provider did not return a market movement."
)
if impulse > 0 then
impulse = math.floor(impulse / Config.Crypto.RandomImpulseDivisor)
elseif impulse < 0 then
impulse = math.ceil(impulse / Config.Crypto.RandomImpulseDivisor)
end
local shock_roll = crypto_random_int(
0,
10000,
"[sky_phone] Crypto entropy provider did not return a market shock roll."
)
local dynamics = market_dynamics[market_id] or {
momentum = 0,
cycle_bias = 0,
cycle_direction = 0,
cycle_remaining_ticks = 0,
cycle_target = 0,
}
dynamics.momentum = truncate_integer((
dynamics.momentum * Config.Crypto.MomentumDecayBasisPoints
+ impulse * Config.Crypto.MomentumImpulseBasisPoints
) / 10000)
local cycle_bias = advance_market_cycle(config, dynamics)
market_dynamics[market_id] = dynamics
local deviation = math.floor(
(config.InitialPrice - price) * 10000 / config.InitialPrice
)
local reversion = truncate_integer(
deviation * Config.Crypto.MeanReversionBasisPoints / 10000
)
local shock = 0
if shock_roll < Config.Crypto.MarketShockChanceBasisPoints then
local multiplier = crypto_random_int(
Config.Crypto.MarketShockMinimumMultiplier,
Config.Crypto.MarketShockMaximumMultiplier + 1,
"[sky_phone] Crypto entropy provider did not return a market shock multiplier."
for offset = 0, market_count - 1 do
local order_index = ((market_cursor + offset - 1) % #market_order) + 1
local market_id = market_order[order_index]
local config = markets[market_id]
local row = market_state[market_id]
if row and row.status == "active" then
local price = tonumber(row.price) or config.InitialPrice
local impulse = crypto_random_int(
-config.VolatilityBasisPoints,
config.VolatilityBasisPoints + 1,
"[sky_phone] Crypto entropy provider did not return a market movement."
)
local direction_roll = crypto_random_int(
if impulse > 0 then
impulse = math.floor(impulse / Config.Crypto.RandomImpulseDivisor)
elseif impulse < 0 then
impulse = math.ceil(impulse / Config.Crypto.RandomImpulseDivisor)
end
local shock_roll = crypto_random_int(
0,
2,
"[sky_phone] Crypto entropy provider did not return a market shock direction."
10000,
"[sky_phone] Crypto entropy provider did not return a market shock roll."
)
local direction = direction_roll == 0 and -1 or 1
shock = direction * config.VolatilityBasisPoints * multiplier
end
local dynamics = market_dynamics[market_id] or {
momentum = 0,
cycle_bias = 0,
cycle_direction = 0,
cycle_remaining_ticks = 0,
cycle_target = 0,
}
dynamics.momentum = truncate_integer((
dynamics.momentum * Config.Crypto.MomentumDecayBasisPoints
+ impulse * Config.Crypto.MomentumImpulseBasisPoints
) / 10000)
local cycle_bias = advance_market_cycle(config, dynamics)
market_dynamics[market_id] = dynamics
local maximum_movement = config.VolatilityBasisPoints
* Config.Crypto.MaximumMovementMultiplier
local movement = impulse + dynamics.momentum + cycle_bias
+ global_market_trend + reversion + shock
movement = math.max(-maximum_movement, math.min(maximum_movement, movement))
if movement > 0 then
movement = math.floor(movement / Config.Crypto.TickMovementDivisor)
elseif movement < 0 then
movement = math.ceil(movement / Config.Crypto.TickMovementDivisor)
end
local next_price = math.floor(price * (10000 + movement) / 10000)
if next_price == price and movement ~= 0 then
next_price = price + (movement > 0 and 1 or -1)
end
next_price = math.max(config.MinimumPrice, math.min(config.MaximumPrice, next_price))
local next_version = (tonumber(row.version) or 0) + 1
if Bridge.Database.Transaction({
{ query = [[UPDATE `sky_phone_crypto_markets` SET `price` = ?, `version` = ? WHERE `id` = ? AND `version` = ?]], params = { next_price, next_version, market_id, row.version } },
{ query = [[INSERT INTO `sky_phone_crypto_market_ticks` (`market_id`,`version`,`price`) VALUES (?, ?, ?)]], params = { market_id, next_version, next_price } },
}) then
changed_markets[#changed_markets + 1] = market_id
Bridge.Database.Query([[
DELETE FROM `sky_phone_crypto_market_ticks`
WHERE `market_id` = ? AND `id` NOT IN (
SELECT `id` FROM (
SELECT `id` FROM `sky_phone_crypto_market_ticks`
WHERE `market_id` = ? ORDER BY `id` DESC LIMIT ?
) retained
local deviation = math.floor(
(config.InitialPrice - price) * 10000 / config.InitialPrice
)
local reversion = truncate_integer(
deviation * Config.Crypto.MeanReversionBasisPoints / 10000
)
local shock = 0
if shock_roll < Config.Crypto.MarketShockChanceBasisPoints then
local multiplier = crypto_random_int(
Config.Crypto.MarketShockMinimumMultiplier,
Config.Crypto.MarketShockMaximumMultiplier + 1,
"[sky_phone] Crypto entropy provider did not return a market shock multiplier."
)
]], { market_id, market_id, Config.Crypto.HistoryRetentionTicks })
local direction_roll = crypto_random_int(
0,
2,
"[sky_phone] Crypto entropy provider did not return a market shock direction."
)
local direction = direction_roll == 0 and -1 or 1
shock = direction * config.VolatilityBasisPoints * multiplier
end
local maximum_movement = config.VolatilityBasisPoints
* Config.Crypto.MaximumMovementMultiplier
local movement = impulse + dynamics.momentum + cycle_bias
+ global_market_trend + reversion + shock
movement = math.max(-maximum_movement, math.min(maximum_movement, movement))
if movement > 0 then
movement = math.floor(movement / Config.Crypto.TickMovementDivisor)
elseif movement < 0 then
movement = math.ceil(movement / Config.Crypto.TickMovementDivisor)
end
local next_price = math.floor(price * (10000 + movement) / 10000)
if next_price == price and movement ~= 0 then
next_price = price + (movement > 0 and 1 or -1)
end
next_price = math.max(config.MinimumPrice, math.min(config.MaximumPrice, next_price))
local next_version = row.version + 1
local updated_at = os.time()
row.price = next_price
row.version = next_version
row.updated_at = updated_at
row.dirty = true
local history = market_history[market_id]
history[#history + 1] = next_price
if #history > history_limit then
table.remove(history, 1)
end
add_market_daily_price(market_daily_buckets[market_id], next_price, updated_at)
changed_markets[#changed_markets + 1] = market_id
end
end
end
market_cursor = ((market_cursor + market_count - 1) % #market_order) + 1
if #changed_markets > 0 then
TriggerClientEvent("sky_phone:crypto:changed", -1, {
markets = market_dtos(changed_markets),
updatedAt = os.time() * 1000,
})
end
end)
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()
if exchange_lock then
print("[sky_phone] Crypto runtime refresh skipped because the exchange is busy.")
return
end
with_exchange_lock(function()
if not persist_market_cache() then
return
end
initialize_markets()
load_market_cache()
start_crypto_schedulers()
end)
end
AddEventHandler("sky_phone:configurator:serverUpdated", refresh_crypto_runtime)
AddEventHandler("onResourceStop", function(resource_name)
if resource_name == GetCurrentResourceName() then
persist_market_cache()
end
end)
start_crypto_schedulers()
end)
+698
View File
@@ -0,0 +1,698 @@
SkyPhoneTones = SkyPhoneTones or {}
local MAX_TONES_PER_TYPE = 32
local MAX_AUDIO_BYTES = 2000000
local MAX_DURATION_MS = 30000
local MAX_PAYLOAD_CHARS = 2666668
local MAX_LABEL_LENGTH = 64
local MAX_TRANSFER_CHUNK_CHARS = 8000
local MAX_TRANSFER_CHUNKS = 334
local TRANSFER_TIMEOUT_MS = 120000
local AUDIO_TRANSFER_BYTES_PER_SECOND = 3000000
local MAX_AUDIO_REQUEST_ID = 2147483646
local PUBLIC_READS_PER_MINUTE = 90
local PUBLIC_AUDIO_READS_PER_MINUTE = 24
local pending_uploads = {}
local configured_rows = {}
local configured_audio = {}
local allowed_mime_types = {
["audio/mpeg"] = true,
["audio/ogg"] = true,
["audio/wav"] = true,
["audio/webm"] = true,
}
local mime_types_by_extension = {
mp3 = "audio/mpeg",
ogg = "audio/ogg",
wav = "audio/wav",
webm = "audio/webm",
}
local base64_alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local function affected_rows(result)
if type(result) == "number" then
return result
end
return type(result) == "table" and tonumber(result.affectedRows) or 0
end
local function trim(value)
if type(value) ~= "string" then
return ""
end
return value:match("^%s*(.-)%s*$")
end
local function valid_uuid(value)
return type(value) == "string"
and value:match("^[0-9a-fA-F]+%-[0-9a-fA-F]+%-[0-9a-fA-F]+%-[0-9a-fA-F]+%-[0-9a-fA-F]+$") ~= nil
and #value == 36
end
local function decoded_base64_size(payload)
if type(payload) ~= "string"
or payload == ""
or #payload > MAX_PAYLOAD_CHARS
or #payload % 4 ~= 0
then
return nil
end
local body, padding = payload:match("^([A-Za-z0-9+/]*)(=*)$")
if not body or #padding > 2 then
return nil
end
return math.floor(#payload * 3 / 4) - #padding
end
local function decode_base64_prefix(payload, max_bytes)
local decoded = {}
local output_length = 0
for index = 1, #payload, 4 do
local first = base64_alphabet:find(payload:sub(index, index), 1, true)
local second = base64_alphabet:find(payload:sub(index + 1, index + 1), 1, true)
local third_character = payload:sub(index + 2, index + 2)
local fourth_character = payload:sub(index + 3, index + 3)
local third = third_character == "=" and 1
or base64_alphabet:find(third_character, 1, true)
local fourth = fourth_character == "=" and 1
or base64_alphabet:find(fourth_character, 1, true)
if not first or not second or not third or not fourth then
return nil
end
local combined = (first - 1) * 262144
+ (second - 1) * 4096
+ (third - 1) * 64
+ (fourth - 1)
decoded[#decoded + 1] = string.char(math.floor(combined / 65536) % 256)
output_length = output_length + 1
if third_character ~= "=" and output_length < max_bytes then
decoded[#decoded + 1] = string.char(math.floor(combined / 256) % 256)
output_length = output_length + 1
end
if fourth_character ~= "=" and output_length < max_bytes then
decoded[#decoded + 1] = string.char(combined % 256)
output_length = output_length + 1
end
if output_length >= max_bytes then
break
end
end
return table.concat(decoded)
end
local function valid_audio_signature(mime_type, raw)
if type(raw) ~= "string" then
return false
end
if mime_type == "audio/mpeg" then
local first, second = raw:byte(1, 2)
return raw:sub(1, 3) == "ID3"
or (first == 0xFF and second ~= nil and (second & 0xE0) == 0xE0)
end
if mime_type == "audio/ogg" then
return raw:sub(1, 4) == "OggS"
end
if mime_type == "audio/wav" then
return raw:sub(1, 4) == "RIFF" and raw:sub(9, 12) == "WAVE"
end
if mime_type == "audio/webm" then
return raw:sub(1, 4) == string.char(0x1A, 0x45, 0xDF, 0xA3)
end
return false
end
local function encode_base64(value)
local encoded = {}
local output_index = 1
for index = 1, #value, 3 do
local first, second, third = value:byte(index, index + 2)
second = second or 0
third = third or 0
local combined = first * 65536 + second * 256 + third
local first_index = math.floor(combined / 262144) % 64 + 1
local second_index = math.floor(combined / 4096) % 64 + 1
local third_index = math.floor(combined / 64) % 64 + 1
local fourth_index = combined % 64 + 1
encoded[output_index] = base64_alphabet:sub(first_index, first_index)
encoded[output_index + 1] = base64_alphabet:sub(second_index, second_index)
encoded[output_index + 2] = index + 1 <= #value
and base64_alphabet:sub(third_index, third_index)
or "="
encoded[output_index + 3] = index + 2 <= #value
and base64_alphabet:sub(fourth_index, fourth_index)
or "="
output_index = output_index + 4
end
return table.concat(encoded)
end
local function normalize_create_payload(data)
if type(data) ~= "table" then
return nil
end
local tone_type = data.toneType
local label = trim(data.label)
local mime_type = type(data.mimeType) == "string" and data.mimeType:lower() or ""
local duration_ms = tonumber(data.durationMs)
local byte_size = decoded_base64_size(data.payload)
local prefix = byte_size and decode_base64_prefix(data.payload, 16) or nil
if (tone_type ~= "ringtone" and tone_type ~= "notification")
or label == ""
or #label > MAX_LABEL_LENGTH
or label:find("[%c]")
or not allowed_mime_types[mime_type]
or not byte_size
or not valid_audio_signature(mime_type, prefix)
or byte_size < 1
or byte_size > MAX_AUDIO_BYTES
or not duration_ms
or duration_ms ~= math.floor(duration_ms)
or duration_ms < 250
or duration_ms > MAX_DURATION_MS
then
return nil
end
return {
audioPayload = data.payload,
byteSize = byte_size,
durationMs = duration_ms,
label = label,
mimeType = mime_type,
toneType = tone_type,
}
end
local function map_tone(row, include_admin_fields)
local tone = {
byteSize = tonumber(row.byte_size) or 0,
createdAt = row.created_at,
durationMs = tonumber(row.duration_ms) or 0,
id = row.id,
label = row.label,
mimeType = row.mime_type,
source = row.source == "config" and "config" or "database",
toneType = row.tone_type,
}
if include_admin_fields then
tone.createdBy = row.created_by_name
end
return tone
end
local function load_database_rows()
return Bridge.Database.Query([[
SELECT
`id`, `tone_type`, `label`, `mime_type`, `byte_size`, `duration_ms`,
`created_by_name`, `created_at`
FROM `sky_phone_custom_tones`
ORDER BY `tone_type` ASC, `label` ASC, `id` ASC
]], {})
end
local function load_rows()
local rows = {}
for _, row in ipairs(configured_rows) do
rows[#rows + 1] = row
end
for _, row in ipairs(load_database_rows()) do
row.source = "database"
rows[#rows + 1] = row
end
table.sort(rows, function(left, right)
if left.tone_type ~= right.tone_type then
return left.tone_type < right.tone_type
end
if left.label ~= right.label then
return left.label < right.label
end
return left.id < right.id
end)
return rows
end
function SkyPhoneTones.GetAdminList()
local tones = {}
for _, row in ipairs(load_rows()) do
tones[#tones + 1] = map_tone(row, true)
end
return tones
end
function SkyPhoneTones.GetCatalog()
local catalog = {
notificationSounds = {},
ringtones = {},
}
local counts = { notification = 0, ringtone = 0 }
for _, row in ipairs(load_rows()) do
if counts[row.tone_type] < MAX_TONES_PER_TYPE then
counts[row.tone_type] = counts[row.tone_type] + 1
local target = row.tone_type == "ringtone" and catalog.ringtones or catalog.notificationSounds
target[#target + 1] = map_tone(row, false)
end
end
return catalog
end
function SkyPhoneTones.GetAudio(id)
local configured = type(id) == "string" and configured_audio[id] or nil
if configured then
local raw = LoadResourceFile(GetCurrentResourceName(), configured.file)
if type(raw) ~= "string"
or #raw < 1
or #raw > MAX_AUDIO_BYTES
or not valid_audio_signature(configured.mime_type, raw:sub(1, 16))
then
Bridge.Debug(
"error",
"[sky_phone] Configured custom tone '%s' could not be read safely.",
tostring(id),
{ always = true }
)
return nil
end
return {
id = id,
mimeType = configured.mime_type,
payload = encode_base64(raw),
}
end
if not valid_uuid(id) then
return nil
end
local rows = Bridge.Database.Query([[
SELECT `id`, `mime_type`, `audio_payload`
FROM `sky_phone_custom_tones`
WHERE `id` = ?
LIMIT 1
]], { id })
local row = rows[1]
if not row then
return nil
end
return {
id = row.id,
mimeType = row.mime_type,
payload = row.audio_payload,
}
end
local function configured_type_count(tone_type)
local count = 0
for _, row in ipairs(configured_rows) do
if row.tone_type == tone_type then
count = count + 1
end
end
return count
end
local function configured_label_exists(tone_type, label)
for _, row in ipairs(configured_rows) do
if row.tone_type == tone_type and row.label == label then
return true
end
end
return false
end
function SkyPhoneTones.Create(data, actor_identifier, actor_name)
local tone = normalize_create_payload(data)
if not tone or type(actor_identifier) ~= "string" or type(actor_name) ~= "string" then
return { success = false, error = "invalid_tone" }
end
if configured_label_exists(tone.toneType, tone.label) then
return { success = false, error = "tone_name_taken" }
end
local duplicate = Bridge.Database.Query([[
SELECT 1
FROM `sky_phone_custom_tones`
WHERE `tone_type` = ? AND `label` = ?
LIMIT 1
]], { tone.toneType, tone.label })
if duplicate[1] then
return { success = false, error = "tone_name_taken" }
end
local count_rows = Bridge.Database.Query([[
SELECT COUNT(*) AS `count`
FROM `sky_phone_custom_tones`
WHERE `tone_type` = ?
]], { tone.toneType })
if (tonumber(count_rows[1] and count_rows[1].count) or 0) + configured_type_count(tone.toneType)
>= MAX_TONES_PER_TYPE
then
return { success = false, error = "tone_limit" }
end
local ids = Bridge.Database.Query("SELECT UUID() AS `id`", {})
local id = ids[1] and ids[1].id
if not valid_uuid(id) then
error("[sky_phone] Database did not generate a custom tone UUID.")
end
local result = Bridge.Database.Query([[
INSERT INTO `sky_phone_custom_tones` (
`id`, `tone_type`, `label`, `mime_type`, `audio_payload`, `byte_size`,
`duration_ms`, `created_by_identifier`, `created_by_name`
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
]], {
id,
tone.toneType,
tone.label,
tone.mimeType,
tone.audioPayload,
tone.byteSize,
tone.durationMs,
actor_identifier,
actor_name,
})
if affected_rows(result) ~= 1 then
return { success = false, error = "request_failed" }
end
TriggerClientEvent("sky_phone:tones:changed", -1)
return { success = true, data = SkyPhoneTones.GetAdminList(), toneId = id }
end
local function normalize_upload_metadata(data)
if type(data) ~= "table" then
return nil
end
local tone_type = data.toneType
local label = trim(data.label)
local mime_type = type(data.mimeType) == "string" and data.mimeType:lower() or ""
local duration_ms = tonumber(data.durationMs)
local payload_length = tonumber(data.payloadLength)
if (tone_type ~= "ringtone" and tone_type ~= "notification")
or label == ""
or #label > MAX_LABEL_LENGTH
or label:find("[%c]")
or not allowed_mime_types[mime_type]
or not duration_ms
or duration_ms ~= math.floor(duration_ms)
or duration_ms < 250
or duration_ms > MAX_DURATION_MS
or not payload_length
or payload_length ~= math.floor(payload_length)
or payload_length < 4
or payload_length > MAX_PAYLOAD_CHARS
or payload_length % 4 ~= 0
then
return nil
end
return {
durationMs = duration_ms,
label = label,
mimeType = mime_type,
payloadLength = payload_length,
toneType = tone_type,
}
end
function SkyPhoneTones.BeginUpload(source, data, actor_identifier, actor_name)
local metadata = normalize_upload_metadata(data)
if type(source) ~= "number"
or not metadata
or type(actor_identifier) ~= "string"
or type(actor_name) ~= "string"
then
return { success = false, error = "invalid_tone" }
end
pending_uploads[source] = nil
if configured_label_exists(metadata.toneType, metadata.label) then
return { success = false, error = "tone_name_taken" }
end
local ids = Bridge.Database.Query("SELECT UUID() AS `id`", {})
local upload_id = ids[1] and ids[1].id
if not valid_uuid(upload_id) then
error("[sky_phone] Database did not generate a custom tone upload UUID.")
end
local state = {
actor_identifier = actor_identifier,
actor_name = actor_name,
chunks = {},
length = 0,
metadata = metadata,
next_index = 1,
upload_id = upload_id,
}
pending_uploads[source] = state
SetTimeout(TRANSFER_TIMEOUT_MS, function()
if pending_uploads[source] == state then
pending_uploads[source] = nil
end
end)
return { success = true, data = { uploadId = upload_id } }
end
function SkyPhoneTones.AppendUploadChunk(source, data)
local state = pending_uploads[source]
local chunk = type(data) == "table" and data.chunk or nil
local index = type(data) == "table" and tonumber(data.index) or nil
if not state
or type(data) ~= "table"
or data.uploadId ~= state.upload_id
or not index
or index ~= math.floor(index)
or index ~= state.next_index
or index > MAX_TRANSFER_CHUNKS
or type(chunk) ~= "string"
or #chunk < 1
or #chunk > MAX_TRANSFER_CHUNK_CHARS
or not chunk:match("^[A-Za-z0-9+/=]+$")
or state.length + #chunk > state.metadata.payloadLength
then
return { success = false, error = "invalid_upload" }
end
state.chunks[index] = chunk
state.length = state.length + #chunk
state.next_index = index + 1
return { success = true }
end
function SkyPhoneTones.CompleteUpload(source, data)
local state = pending_uploads[source]
if not state
or type(data) ~= "table"
or data.uploadId ~= state.upload_id
or state.length ~= state.metadata.payloadLength
then
return { success = false, error = "invalid_upload" }
end
pending_uploads[source] = nil
local payload = table.concat(state.chunks)
local response = SkyPhoneTones.Create({
durationMs = state.metadata.durationMs,
label = state.metadata.label,
mimeType = state.metadata.mimeType,
payload = payload,
toneType = state.metadata.toneType,
}, state.actor_identifier, state.actor_name)
if response.success then
response.upload = state.metadata
end
return response
end
function SkyPhoneTones.CancelUpload(source, data)
local state = pending_uploads[source]
if state and type(data) == "table" and data.uploadId == state.upload_id then
pending_uploads[source] = nil
end
return { success = true }
end
function SkyPhoneTones.Delete(id)
if not valid_uuid(id) then
return { success = false, error = "invalid_request" }
end
local rows = Bridge.Database.Query([[
SELECT `id`, `tone_type`, `label`
FROM `sky_phone_custom_tones`
WHERE `id` = ?
LIMIT 1
]], { id })
local tone = rows[1]
if not tone then
return { success = false, error = "tone_not_found" }
end
local result = Bridge.Database.Query(
"DELETE FROM `sky_phone_custom_tones` WHERE `id` = ?",
{ id }
)
if affected_rows(result) ~= 1 then
return { success = false, error = "tone_not_found" }
end
TriggerClientEvent("sky_phone:tones:changed", -1)
return { success = true, data = SkyPhoneTones.GetAdminList(), tone = tone }
end
local function load_configured_tones()
local groups = {
{ entries = Config.CustomTones and Config.CustomTones.Ringtones, name = "Ringtones", tone_type = "ringtone" },
{
entries = Config.CustomTones and Config.CustomTones.NotificationSounds,
name = "NotificationSounds",
tone_type = "notification",
},
}
local seen_ids = {}
local seen_labels = {}
for _, group in ipairs(groups) do
local entries = type(group.entries) == "table" and group.entries or {}
for index, entry in ipairs(entries) do
local id = type(entry) == "table" and trim(entry.Id):lower() or ""
local label = type(entry) == "table" and trim(entry.Label) or ""
local file = type(entry) == "table" and trim(entry.File):gsub("\\", "/") or ""
local duration_ms = type(entry) == "table" and tonumber(entry.DurationMs) or nil
local extension = file:lower():match("%.([a-z0-9]+)$")
local mime_type = extension and mime_types_by_extension[extension] or nil
local row_id = ("config:%s:%s"):format(group.tone_type, id)
local label_key = group.tone_type .. ":" .. label
local invalid_reason
if type(entry) ~= "table" then
invalid_reason = "entry must be a table"
elseif not id:match("^[a-z0-9][a-z0-9_-]*$") or #id > 48 then
invalid_reason = "Id must contain 1-48 lowercase letters, numbers, underscores, or hyphens"
elseif label == "" or #label > MAX_LABEL_LENGTH or label:find("[%c]") then
invalid_reason = "Label must contain 1-64 visible characters"
elseif file:sub(1, 20) ~= "config/custom_tones/" or file:find("..", 1, true) then
invalid_reason = "File must stay inside config/custom_tones"
elseif not mime_type then
invalid_reason = "File must use mp3, ogg, wav, or webm"
elseif not duration_ms
or duration_ms ~= math.floor(duration_ms)
or duration_ms < 250
or duration_ms > MAX_DURATION_MS
then
invalid_reason = "DurationMs must be an integer between 250 and 30000"
elseif seen_ids[row_id] then
invalid_reason = "Id is duplicated"
elseif seen_labels[label_key] then
invalid_reason = "Label is duplicated in this category"
elseif configured_type_count(group.tone_type) >= MAX_TONES_PER_TYPE then
invalid_reason = "the category contains more than 32 configured tones"
end
local raw
if not invalid_reason then
raw = LoadResourceFile(GetCurrentResourceName(), file)
if type(raw) ~= "string" or #raw < 1 then
invalid_reason = "File could not be read"
elseif #raw > MAX_AUDIO_BYTES then
invalid_reason = "File exceeds 2 MB"
elseif not valid_audio_signature(mime_type, raw:sub(1, 16)) then
invalid_reason = "File content does not match its audio extension"
end
end
if invalid_reason then
Bridge.Debug(
"error",
"[sky_phone] Ignored Config.CustomTones.%s[%s]: %s.",
group.name,
tostring(index),
invalid_reason,
{ always = true }
)
else
seen_ids[row_id] = true
seen_labels[label_key] = true
configured_rows[#configured_rows + 1] = {
byte_size = #raw,
created_at = "",
created_by_name = "config.lua",
duration_ms = duration_ms,
id = row_id,
label = label,
mime_type = mime_type,
source = "config",
tone_type = group.tone_type,
}
configured_audio[row_id] = { file = file, mime_type = mime_type }
end
end
end
end
load_configured_tones()
AddEventHandler("playerDropped", function()
pending_uploads[source] = nil
end)
Bridge.Database.AfterMigration("sky_phone", function()
Bridge.Callbacks.Register("sky_phone:tones:list", function(source)
if not SkyPhone.AllowOperation(source, "custom_tones_list", PUBLIC_READS_PER_MINUTE, 60) then
return { success = false, error = "rate_limited" }
end
return { success = true, data = SkyPhoneTones.GetCatalog() }
end)
RegisterNetEvent("sky_phone:tones:audio-request", function(request_id, tone_id)
local player_source = source
if type(player_source) ~= "number"
or player_source < 1
or type(request_id) ~= "number"
or request_id ~= math.floor(request_id)
or request_id < 1
or request_id > MAX_AUDIO_REQUEST_ID
or type(tone_id) ~= "string"
or tone_id == ""
or #tone_id > 128
then
return
end
if not SkyPhone.AllowOperation(
player_source,
"custom_tones_audio",
PUBLIC_AUDIO_READS_PER_MINUTE,
60
) then
TriggerClientEvent(
"sky_phone:tones:audio-response",
player_source,
request_id,
{ success = false, error = "rate_limited" }
)
return
end
local audio = SkyPhoneTones.GetAudio(tone_id)
local audio_size = audio and decoded_base64_size(audio.payload) or nil
local signature = audio_size and decode_base64_prefix(audio.payload, 16) or nil
if not audio
or audio.id ~= tone_id
or not allowed_mime_types[audio.mimeType]
or not audio_size
or audio_size < 1
or audio_size > MAX_AUDIO_BYTES
or not valid_audio_signature(audio.mimeType, signature)
then
TriggerClientEvent(
"sky_phone:tones:audio-response",
player_source,
request_id,
{ success = false, error = "tone_not_found" }
)
return
end
TriggerLatentClientEvent(
"sky_phone:tones:audio-response",
player_source,
AUDIO_TRANSFER_BYTES_PER_SECOND,
request_id,
{ success = true, data = audio }
)
end)
end)
+7 -1
View File
@@ -931,10 +931,16 @@ end)
CreateThread(function()
while true do
Wait(Config.DarkChat.CleanupIntervalSeconds * 1000)
local batch_size = math.max(
1,
math.floor(tonumber(Config.DarkChat.CleanupBatchSize) or 250)
)
Bridge.Database.Query([[
DELETE FROM `sky_phone_darkchat_messages`
WHERE `expires_at` IS NOT NULL AND `expires_at` <= CURRENT_TIMESTAMP
]], {})
ORDER BY `expires_at`, `id`
LIMIT ?
]], { batch_size })
end
end)
+25
View File
@@ -456,6 +456,29 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_custom_tones",
columns = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "tone_type", type = "ENUM('ringtone','notification') NOT NULL" },
{ name = "label", type = "VARCHAR(64) NOT NULL" },
{ name = "mime_type", type = "VARCHAR(40) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "audio_payload", type = "MEDIUMTEXT NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "byte_size", type = "INT UNSIGNED NOT NULL" },
{ name = "duration_ms", type = "INT UNSIGNED NOT NULL" },
{ name = "created_by_identifier", type = "VARCHAR(80) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "created_by_name", type = "VARCHAR(120) NOT NULL" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
primaryKey = "id",
uniqueKeys = {
{ name = "uniq_sky_phone_custom_tone_label", columns = "(`tone_type`, `label`)" },
},
indexes = {
{ name = "idx_sky_phone_custom_tones_type", columns = "(`tone_type`, `created_at`, `id`)" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_notes",
columns = {
@@ -2710,6 +2733,7 @@ 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" },
@@ -2729,6 +2753,7 @@ 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.HasAdminGroup(source, Config.FlipTok.AdminGroups) then
if source ~= 0 and not Bridge.Framework.HasPermission(source, "fliptokverify") then
send_command_feedback(command_locale.noPermission, "error")
Bridge.Debug(
"warn",
+47 -7
View File
@@ -97,6 +97,14 @@ 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"
@@ -366,7 +374,7 @@ Bridge.Callbacks.Register("sky_phone:messages:thread", function(source, data)
if not device then
return error_response
end
local number = SkyPhoneSimNumber.Normalize(data.phoneNumber, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
local number = resolve_message_recipient(data.phoneNumber)
if not number then
return { success = false, error = "invalid_number" }
end
@@ -407,11 +415,7 @@ Bridge.Callbacks.Register("sky_phone:messages:delete", function(source, data)
local numbers = {}
local seen = {}
for index = 1, #data.phoneNumbers do
local number = SkyPhoneSimNumber.Normalize(
data.phoneNumbers[index],
Config.Sim.NumberLength,
Config.Sim.NumberPrefix
)
local number = resolve_message_recipient(data.phoneNumbers[index])
if not number then
return { success = false, error = "invalid_number" }
end
@@ -483,7 +487,7 @@ Bridge.Callbacks.Register("sky_phone:messages:send", function(source, data)
if not device then
return error_response
end
local number = SkyPhoneSimNumber.Normalize(data.phoneNumber, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
local number, service_line = resolve_message_recipient(data.phoneNumber)
if not number then
return { success = false, error = "invalid_number" }
end
@@ -536,6 +540,42 @@ 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

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