mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-03 17:00:53 +00:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ce34d5c25 | ||
|
|
bbc2cbe000 | ||
|
|
8efaa9f03b | ||
|
|
efcc67334f | ||
|
|
693c9bd6f9 | ||
|
|
a597e161ea | ||
|
|
b0b8cfc550 | ||
|
|
76484cfe05 | ||
|
|
b406fa9443 | ||
|
|
2388ba9b4e | ||
|
|
c6394fda66 | ||
|
|
4516469c33 | ||
|
|
7708ed725b | ||
|
|
00067543cb | ||
|
|
fd24c8b272 | ||
|
|
aac916efc6 | ||
|
|
603f4de54e | ||
|
|
ef2d805079 | ||
|
|
829f8da82a | ||
|
|
7ca39ef4b9 | ||
|
|
b673b15b97 | ||
|
|
a12e43b624 | ||
|
|
312ae8a3a3 | ||
|
|
435320e8fd | ||
|
|
ea959f59f2 | ||
|
|
8b4a32bfab | ||
|
|
cb6c94a7d8 | ||
|
|
2059e19e1c | ||
|
|
99e943dbc5 | ||
|
|
bd9f4c2695 | ||
|
|
4fa4bf3c78 |
@@ -119,5 +119,5 @@ jobs:
|
||||
"${archive}.sha256" \
|
||||
--verify-tag \
|
||||
--generate-notes \
|
||||
--title "Sky Phone $GITHUB_REF_NAME"
|
||||
--title "$GITHUB_REF_NAME Beta"
|
||||
fi
|
||||
|
||||
@@ -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
@@ -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
@@ -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)"')
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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}")`,
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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`,
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+1
@@ -168,6 +168,7 @@
|
||||
|
||||
.sky-navbar__title {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
padding: 0 var(--sky-space-1);
|
||||
overflow: hidden;
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"')
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 () => {'),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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]],
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user