mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 01:03:25 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af1ae8510d | ||
|
|
197dce8767 | ||
|
|
1bd0adbea3 | ||
|
|
f70b1cc37d | ||
|
|
7df62153a2 | ||
|
|
1949a37006 | ||
|
|
219d523edf | ||
|
|
20faedc1a2 | ||
|
|
87fa234c49 | ||
|
|
7bc4eb6993 | ||
|
|
740bdda0db | ||
|
|
7f97072c75 | ||
|
|
42f5e8f493 | ||
|
|
d8a7721917 | ||
|
|
15e32799d4 | ||
|
|
d6b03ac2b7 | ||
|
|
9dd330da93 | ||
|
|
a9b0c4e0f4 | ||
|
|
8e2523d555 | ||
|
|
f5b28c7fda | ||
|
|
3a45aa159a | ||
|
|
22165d64df |
@@ -119,5 +119,5 @@ jobs:
|
||||
"${archive}.sha256" \
|
||||
--verify-tag \
|
||||
--generate-notes \
|
||||
--title "$GITHUB_REF_NAME Beta"
|
||||
--title "Sky Phone $GITHUB_REF_NAME"
|
||||
fi
|
||||
|
||||
@@ -86,10 +86,10 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
|
||||
| Layer | Supported options |
|
||||
| --- | --- |
|
||||
| **Frameworks** | ESX Legacy, QBCore, Qbox |
|
||||
| **Inventories** | ak47_inventory, codem-inventory, core_inventory, jaksam_inventory, jpr-inventory, lj-inventory, mf-inventory, one_inventory, origen_inventory, ox_inventory, ps-inventory, qb-inventory, qs-inventory, smx-inventory, tgiann-inventory, hex_4_inventory, and native ESX inventory |
|
||||
| **Inventories** | ox_inventory, qb-inventory, lj-inventory, qs-inventory, codem-inventory, core_inventory, mf-inventory, smx-inventory, hex_4_inventory, and native ESX inventory |
|
||||
| **Calls** | YACA, PMA Voice, SaltyChat |
|
||||
| **Radio** | YACA, PMA Voice, SaltyChat |
|
||||
| **Housing** | RTX Housing, Quasar Housing, TGIANN House, VMS Housing, RX Housing, NoLag Properties, SN Properties, ESX Property, qbx_properties |
|
||||
| **Housing** | RTX Housing, Quasar Housing, 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 |
|
||||
@@ -121,33 +121,20 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
|
||||
| **Framework** | ESX Legacy (`es_extended`), QBCore (`qb-core`), or Qbox (`qbx_core`) |
|
||||
| **Inventory** | Choose one supported adapter from the table below |
|
||||
|
||||
| Inventory (configuration value) | Metadata support | Unique Phones | Notes |
|
||||
| Inventory | Metadata support | Unique Phones | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `jaksam_inventory` (`jaksam`) | Yes | Yes | Direct per-slot metadata; usable items are registered through jaksam_inventory |
|
||||
| `qs-inventory` (`qs`) | Yes | Yes | Full per-slot metadata |
|
||||
| `ps-inventory` (`ps`) | Yes | Yes | QBCore only; uses item `info` metadata |
|
||||
| `codem-inventory` (`codem`) | Yes | Yes | Full per-slot metadata |
|
||||
| `tgiann-inventory` (`tgiann`) | Yes | Yes | Per-slot metadata; item definitions must enable `hasMetadata` |
|
||||
| `core_inventory` (`core`) | Yes | Yes | Full per-slot metadata |
|
||||
| `jpr-inventory` (`jpr`) | Yes | Yes | QBCore only; uses item `info` metadata |
|
||||
| `origen_inventory` (`origen`) | Yes | Yes | Full per-slot metadata |
|
||||
| `ak47_inventory` (`ak47`) | Yes | Yes | Uses per-slot item `info` metadata |
|
||||
| `one_inventory` (`one`) | Yes | Yes | Full per-slot metadata |
|
||||
| `ox_inventory` (`ox`) | Yes | Yes | Full per-item phone and physical SIM metadata |
|
||||
| `mf-inventory` (`mf`) | Yes | Yes | ESX only |
|
||||
| `smx-inventory` (`smx`) | Yes | Yes | ESX only; one metadata record per configured item name through the player metadata bridge |
|
||||
| `lj-inventory` (`lj`) | Yes | Yes | QBCore inventory with item `info` metadata |
|
||||
| `qb-inventory` (`qb`) | Yes | Yes | Uses item `info` metadata |
|
||||
| `hex_4_inventory` (`hex`) | **No metadata support** | **No, Unique Phones are not possible** | ESX only; Sky Phone automatically disables unique phones and physical SIM cards |
|
||||
| Native ESX inventory (`esx`) | **No metadata support** | **No, Unique Phones are not possible** | Count-based items; Sky Phone automatically disables unique phones and physical SIM cards |
|
||||
| `ox_inventory` | Yes | Yes | Full per-item phone and physical SIM metadata |
|
||||
| `qb-inventory` | Yes | Yes | Uses item `info` metadata |
|
||||
| `lj-inventory` | Yes | Yes | QBCore inventory with item `info` metadata |
|
||||
| `qs-inventory` | Yes | Yes | Full per-slot metadata |
|
||||
| `codem-inventory` | Yes | Yes | Full per-slot metadata |
|
||||
| `core_inventory` | Yes | Yes | Full per-slot metadata |
|
||||
| `mf-inventory` | Yes | Yes | Supported with ESX |
|
||||
| `smx-inventory` | Yes | Yes | Supported with ESX through the player metadata bridge |
|
||||
| `hex_4_inventory` | **No metadata support** | **No, Unique Phones are not possible** | ESX only; set `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` |
|
||||
| Native ESX inventory | **No metadata support** | **No, Unique Phones are not possible** | Count-based items; set `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` |
|
||||
|
||||
`hex_4_inventory` and native ESX inventory cannot persist per-item metadata. Sky Phone therefore forces `Config.Phone.Unique` and `Config.Sim.Enabled` to `false` at runtime whenever either adapter is active.
|
||||
|
||||
`Config.Bridge.Inventory = "auto"` detects framework-compatible adapters in the table order. This deliberately matches the Sky inventory priority so a dedicated inventory is selected before a compatibility resource it may run beside. You may configure either the short value shown in parentheses or the exact resource name.
|
||||
|
||||
The adapters shared with `sky_base` are implemented locally inside Sky Phone. Installing or starting `sky_base` is not required; Sky Phone remains a standalone resource.
|
||||
|
||||
For configuration parity with `sky_base`, `qb-inv` is accepted as an alias for `qb`, while `qbox` selects the Qbox-native `ox_inventory` adapter.
|
||||
`hex_4_inventory` and native ESX inventory cannot persist per-item metadata. Unique Phones and physical SIM cards are therefore unavailable with these adapters.
|
||||
|
||||
### Voice
|
||||
|
||||
@@ -216,7 +203,6 @@ The files contain clearly separated sections for:
|
||||
| Section | Purpose |
|
||||
| --- | --- |
|
||||
| `Config.Bridge` | Framework, inventory, language, callback timeout, and debug mode |
|
||||
| `Config.CommandPermissions` | Fixed groups for the admin panel, test data, verification commands, and social moderation |
|
||||
| `Config.Phone` | Phone item, movement, unique-device mode, and development command |
|
||||
| `Config.Sim` | Physical or virtual SIM behavior and number formatting |
|
||||
| `Config.Calls` / `Config.Radio` | Voice providers, call behavior, radio limits, and permissions |
|
||||
@@ -242,31 +228,21 @@ Config.PhoneConfigurator = {
|
||||
}
|
||||
```
|
||||
|
||||
When enabled, the generated `source/shared/config_default.lua` is the shipped first-run baseline.
|
||||
The frontend build recreates this file from `config.lua` and the server-only `media.lua`; do not edit
|
||||
the generated snapshot directly. Sky Phone creates the `sky_phone_configurator` table automatically,
|
||||
loads its saved values before framework and phone
|
||||
When enabled, `config.lua` and the server-only `media.lua` are first-run defaults. Sky Phone creates
|
||||
the `sky_phone_configurator` table automatically, loads its saved values before framework and phone
|
||||
modules initialize, and exposes the editor through `/phonepanel`. Nothing autosaves: stage changes
|
||||
in the Phone Configurator tool and press the green check. Saving verifies both SQL payloads and then
|
||||
applies the new server, client, media, app, item, command, provider, animation, and UI values through
|
||||
Sky Phone's internal runtime refresh. It does not execute a resource restart command.
|
||||
|
||||
Every configurable `Config.*` value from `config.lua`, including server-only sections, and every
|
||||
value from `Config.Media` is discovered automatically. The bootstrap switch and
|
||||
`Config.CommandPermissions` intentionally remain file-owned: the switch decides whether SQL
|
||||
configuration is loaded, while permissions must remain authoritative outside the panel. The fixed
|
||||
permission table is never displayed or overwritten by the Phone Configurator, and its stable keys do
|
||||
not change when their commands are renamed in the panel. Lists, nested objects, vectors,
|
||||
Every `Config.*` value from `config.lua`, including server-only sections, and every value from
|
||||
`Config.Media` is discovered automatically. The bootstrap switch above intentionally remains
|
||||
file-owned because it decides whether SQL configuration is loaded. Lists, nested objects, vectors,
|
||||
and numeric-keyed Lua tables use structured editors instead of raw JSON. Shipped schema rows stay
|
||||
editable but cannot be renamed, converted, or removed. Every list and table still accepts any number
|
||||
of additional rows; administrator-added rows remain removable. Company job keys are intentionally
|
||||
fully removable because `Config.Companies.Definitions` is a freely managed job collection.
|
||||
|
||||
ESX and QBCore use the groups listed in `Config.CommandPermissions`. Qbox checks the configured ACE
|
||||
objects first and then its framework groups. The standard Qbox `permissions.cfg` grants the `admin`
|
||||
ACE to `group.admin`, so an identifier assigned to `group.admin` can open `/phonepanel` with the
|
||||
shipped `phonepanel` permission list. Restart `sky_phone` after changing fixed permissions.
|
||||
|
||||
Media API keys and server peppers are never returned in plaintext to the NUI. Existing secrets are
|
||||
shown only as configured and are replaced only when an administrator enters a new value.
|
||||
|
||||
@@ -374,9 +350,9 @@ Default entries for unique phones with physical SIM cards:
|
||||
|
||||
Do not configure an LB Phone client event or client export. Sky Phone registers the usable items through its server-side inventory adapter.
|
||||
|
||||
The server registers `Config.Phone.Item` as usable for every supported inventory adapter: `ak47`, `codem`, `core`, `jaksam`, `jpr`, `lj`, `mf`, `one`, `origen`, `ox`, `ps`, `qb`, `qs`, `smx`, `tgiann`, `hex`, and `esx`. Resource startup fails visibly if the selected adapter or its resource is unavailable.
|
||||
The server registers `Config.Phone.Item` as usable for every supported inventory adapter: `ox`, `qb`, `lj`, `qs`, `codem`, `core`, `mf`, `smx`, `hex`, and `esx`. Resource startup fails visibly if the selected adapter cannot complete that registration.
|
||||
|
||||
The `hex` and `esx` adapters use ESX's count-based item API, which cannot persist per-item phone or physical SIM metadata. Sky Phone automatically forces `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` while either adapter is active. `auto` selects `hex` when `hex_4_inventory` is started and otherwise falls back to `esx` on an ESX server when no metadata-capable inventory is detected.
|
||||
The `hex` and `esx` adapters use ESX's count-based item API. They require both `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` because this API cannot persist per-item phone or physical SIM metadata. `auto` selects `hex` when `hex_4_inventory` is started and otherwise falls back to `esx` on an ESX server when no metadata-capable inventory is detected.
|
||||
|
||||
### QBCore-style item tables
|
||||
|
||||
@@ -385,67 +361,6 @@ The `hex` and `esx` adapters use ESX's count-based item API, which cannot persis
|
||||
- Physical SIM items must always be unique.
|
||||
- SIM items are not required when `Config.Sim.Enabled = false`.
|
||||
|
||||
Example for `qb-inventory`, `lj-inventory`, `ps-inventory`, and `jpr-inventory`:
|
||||
|
||||
```lua
|
||||
phone = {
|
||||
name = "phone",
|
||||
label = "iFruit Phone",
|
||||
weight = 200,
|
||||
type = "item",
|
||||
image = "phone.png",
|
||||
unique = true,
|
||||
useable = true,
|
||||
shouldClose = true,
|
||||
description = "A personal mobile phone",
|
||||
},
|
||||
|
||||
sky_phone_sim_registered = {
|
||||
name = "sky_phone_sim_registered",
|
||||
label = "Registered SIM",
|
||||
weight = 5,
|
||||
type = "item",
|
||||
image = "sky_phone_sim_registered.png",
|
||||
unique = true,
|
||||
useable = true,
|
||||
shouldClose = true,
|
||||
},
|
||||
|
||||
sky_phone_sim_anonymous = {
|
||||
name = "sky_phone_sim_anonymous",
|
||||
label = "Anonymous SIM",
|
||||
weight = 5,
|
||||
type = "item",
|
||||
image = "sky_phone_sim_anonymous.png",
|
||||
unique = true,
|
||||
useable = true,
|
||||
shouldClose = true,
|
||||
},
|
||||
```
|
||||
|
||||
For `tgiann-inventory`, set `hasMetadata = true`, `useable = true`, and `shouldClose = true` on all three item definitions. Follow the inventory's own item schema for the remaining adapters; the required behavior is always the same: a unique phone or physical SIM must occupy its own slot and its metadata table must survive moving, dropping, storing, and trading the item.
|
||||
|
||||
### Unique Phones and metadata
|
||||
|
||||
Sky Phone owns the metadata values and writes them server-side. Do not pre-generate IMEIs or phone numbers in item definitions:
|
||||
|
||||
| Item | Metadata written by Sky Phone |
|
||||
| --- | --- |
|
||||
| Phone | `imei`; when a SIM is inserted, also `sim_id`, `phone_number`, and `formatted_number` |
|
||||
| Physical SIM | `sim_metadata_version`, `sim_id`, `phone_number`, `formatted_number`, `sim_type`, and registration details where applicable |
|
||||
|
||||
When a metadata-capable phone item is used for the first time, Sky Phone reserves an IMEI and writes it back to that exact slot. Existing metadata is preserved. The adapter then reads the slot again and rejects the operation if the inventory did not persist the requested values.
|
||||
|
||||
For reliable Unique Phones:
|
||||
|
||||
- Set `Config.Phone.Unique = true`.
|
||||
- Make the phone item non-stackable/unique. Every phone slot must contain exactly one item.
|
||||
- If `Config.Sim.Enabled = true`, make both physical SIM items non-stackable/unique and metadata-capable too.
|
||||
- Do not use inventory conversion, admin, crafting, or shop scripts that strip item metadata. Copying an item with its metadata also copies its IMEI; duplicated IMEIs are reported in the server console.
|
||||
- When changing inventory systems, migrate the complete item metadata table. Without the old `imei`, the next use creates a new device identity and does not automatically attach the old handset data.
|
||||
|
||||
With `Config.Phone.Unique = false`, the handset identity is stored once per framework character instead of on each phone item. The phone item may stack. Physical SIMs still require per-item metadata, so `Config.Sim.Enabled` must be `false` on `hex` and native `esx`.
|
||||
|
||||
## Phone and SIM modes
|
||||
|
||||
The two mode switches are independent:
|
||||
@@ -650,7 +565,7 @@ Select the provider under `Config.Garage.System`. Vehicle images use the configu
|
||||
|
||||
### Housing
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### Companies
|
||||
|
||||
|
||||
+5
-40
@@ -2,49 +2,15 @@ const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const frontendRoot = __dirname
|
||||
const resourceDirectory = path.join(frontendRoot, '..', 'sky_phone')
|
||||
const sourceDirectory = path.join(frontendRoot, 'dist')
|
||||
const targetDirectory = path.join(resourceDirectory, 'source', 'html')
|
||||
const configDefaultPath = path.join(
|
||||
resourceDirectory,
|
||||
const targetDirectory = path.join(
|
||||
frontendRoot,
|
||||
'..',
|
||||
'sky_phone',
|
||||
'source',
|
||||
'shared',
|
||||
'config_default.lua',
|
||||
'html',
|
||||
)
|
||||
|
||||
function readLuaSource(...segments) {
|
||||
return fs
|
||||
.readFileSync(path.join(resourceDirectory, ...segments), 'utf8')
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.trimEnd()
|
||||
}
|
||||
|
||||
function generateConfigDefault() {
|
||||
const configSource = readLuaSource('config', 'config.lua').replace(
|
||||
/\n?-- CONFIG_DEFAULT_EXCLUDE_START[\s\S]*?-- CONFIG_DEFAULT_EXCLUDE_END\n?/g,
|
||||
'\n',
|
||||
)
|
||||
const mediaSource = readLuaSource('config', 'media.lua')
|
||||
const generatedSource = [
|
||||
'-- GENERATED by frontend/build.cjs from config/config.lua and config/media.lua - do not edit.',
|
||||
'-- Escrowed snapshot of the shipped defaults used by the Phone Configurator.',
|
||||
'local realConfig = Config',
|
||||
'Config = {}',
|
||||
'',
|
||||
configSource,
|
||||
'',
|
||||
mediaSource,
|
||||
'',
|
||||
'ConfigDefaults = Config',
|
||||
'Config = realConfig',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
fs.mkdirSync(path.dirname(configDefaultPath), { recursive: true })
|
||||
fs.writeFileSync(configDefaultPath, generatedSource, 'utf8')
|
||||
console.log(`Generated Phone Configurator defaults at ${configDefaultPath}`)
|
||||
}
|
||||
|
||||
if (!fs.existsSync(sourceDirectory)) {
|
||||
throw new Error(
|
||||
'Missing frontend/dist. Run vite build before publishing the NUI.',
|
||||
@@ -62,5 +28,4 @@ const normalizedIndex = fs
|
||||
.replace(/\n[ \t]*\n([ \t]*<\/body>)/g, '\n$1')
|
||||
fs.writeFileSync(targetIndex, normalizedIndex)
|
||||
|
||||
generateConfigDefault()
|
||||
console.log(`Published NUI to ${targetDirectory}`)
|
||||
|
||||
+11
-24
@@ -86,8 +86,6 @@ 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'
|
||||
|
||||
@@ -119,7 +117,6 @@ type AppMessage = {
|
||||
| CustomAppEventData
|
||||
| NavigationEventData
|
||||
| AdminPanelOpenPayload
|
||||
| CustomPhoneToneCatalog
|
||||
}
|
||||
|
||||
type AdminPanelOpenPayload = Required<
|
||||
@@ -533,17 +530,8 @@ function hydratePhone(payload: PhoneOpenPayload): void {
|
||||
clock.hydrate(payload.device?.data.alarms?.payload)
|
||||
games.hydrate(payload.device?.data.games?.payload)
|
||||
media.hydrate(payload.device?.data.media?.payload)
|
||||
appStore.hydrate(payload.device?.data.apps?.payload, payload.disabledApps)
|
||||
appStore.hydrate(payload.device?.data.apps?.payload)
|
||||
widgets.hydrate(payload.device?.data.widgets?.payload)
|
||||
|
||||
const currentAppId = route.params.appId
|
||||
if (
|
||||
typeof currentAppId === 'string' &&
|
||||
isPhoneAppId(currentAppId) &&
|
||||
!appStore.isInstalled(currentAppId)
|
||||
) {
|
||||
void router.push('/')
|
||||
}
|
||||
}
|
||||
|
||||
function getInstalledNavigationAppIds(): string[] {
|
||||
@@ -752,8 +740,6 @@ 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
|
||||
@@ -1320,17 +1306,20 @@ async function closePhone(): Promise<void> {
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (event.key !== 'Escape') return
|
||||
if (simPicker.value) {
|
||||
if (!consumeEscape(event)) return
|
||||
event.preventDefault()
|
||||
void closeSimPicker()
|
||||
return
|
||||
}
|
||||
|
||||
if (!phone.isOpen || activitySuspended.value || !consumeEscape(event)) return
|
||||
if (controlCenterOpened.value) {
|
||||
controlCenterOpened.value = false
|
||||
return
|
||||
}
|
||||
void closePhone()
|
||||
queueMicrotask(() => {
|
||||
if (event.defaultPrevented || !phone.isOpen || activitySuspended.value)
|
||||
return
|
||||
if (controlCenterOpened.value) {
|
||||
controlCenterOpened.value = false
|
||||
return
|
||||
}
|
||||
void closePhone()
|
||||
})
|
||||
}
|
||||
|
||||
function onSystemColorSchemeChange(event: MediaQueryListEvent): void {
|
||||
@@ -1887,8 +1876,6 @@ onBeforeUnmount(() => {
|
||||
class="phone-screen"
|
||||
:class="{
|
||||
'phone-screen--app': isAppRoute || isDevelopmentRoute,
|
||||
'phone-screen--camera-landscape':
|
||||
activeAppId === 'camera' && phone.cameraLandscape,
|
||||
'phone-app--light': !displayedDarkMode,
|
||||
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
|
||||
}"
|
||||
|
||||
@@ -70,43 +70,6 @@ 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,27 +380,9 @@ button {
|
||||
.phone-hardware-button:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
.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:focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: -6px;
|
||||
}
|
||||
.phone-hardware-button--action {
|
||||
top: 176px;
|
||||
@@ -478,8 +460,6 @@ button {
|
||||
}
|
||||
}
|
||||
.phone-screen {
|
||||
--phone-screen-radius: 40px;
|
||||
--phone-screen-portrait-ratio: 2.30951;
|
||||
position: relative;
|
||||
container-type: size;
|
||||
margin: auto;
|
||||
@@ -488,14 +468,7 @@ button {
|
||||
height: 98%;
|
||||
overflow: hidden;
|
||||
background: #08080a;
|
||||
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;
|
||||
border-radius: 40px;
|
||||
}
|
||||
.phone-app {
|
||||
position: absolute !important;
|
||||
@@ -876,14 +849,6 @@ 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;
|
||||
}
|
||||
@@ -3274,9 +3239,6 @@ button {
|
||||
backface-visibility: hidden;
|
||||
will-change: transform, border-radius, opacity;
|
||||
}
|
||||
.app-window--camera-landscape {
|
||||
background: transparent;
|
||||
}
|
||||
.app-window--citywarn {
|
||||
backface-visibility: visible;
|
||||
filter: none;
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const config = readFileSync(
|
||||
new URL('../../sky_phone/config/config.lua', import.meta.url),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
const companiesServer = readFileSync(
|
||||
new URL('../../sky_phone/source/server/companies.lua', import.meta.url),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
const configuratorServer = readFileSync(
|
||||
new URL(
|
||||
'../../sky_phone/source/server/phone_configurator.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
const testServer = readFileSync(
|
||||
new URL('../testserver/index.cjs', import.meta.url),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
|
||||
function sourceBlock(source: string, startMarker: string, endMarker: string) {
|
||||
const start = source.indexOf(startMarker)
|
||||
const end = source.indexOf(endMarker, start)
|
||||
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
return source.slice(start, end)
|
||||
}
|
||||
|
||||
describe('Companies emergency request contract', () => {
|
||||
it('ships non-emergency police assistance as a requestable service', () => {
|
||||
const police = sourceBlock(
|
||||
config,
|
||||
' police = {',
|
||||
' ambulance = {',
|
||||
)
|
||||
const mockPolice = sourceBlock(
|
||||
testServer,
|
||||
'const companyProfiles = [',
|
||||
' {\n acceptsRequests: false,\n announcement: null,',
|
||||
)
|
||||
|
||||
expect(police).toContain('Emergency = true')
|
||||
expect(police).toContain('AcceptsRequests = true')
|
||||
expect(police).toContain('Id = "police-assistance"')
|
||||
expect(police).toContain('RequestsEnabled = true')
|
||||
expect(mockPolice).toContain('acceptsRequests: true')
|
||||
expect(mockPolice).toContain("name: 'Los Santos Police Department'")
|
||||
expect(mockPolice).toContain("id: 'police-assistance'")
|
||||
})
|
||||
|
||||
it('authorizes configured emergency companies through the normal request gates', () => {
|
||||
const validation = sourceBlock(
|
||||
companiesServer,
|
||||
'local function validate_configuration(configuration)',
|
||||
'local function seed_companies()',
|
||||
)
|
||||
const payload = sourceBlock(
|
||||
companiesServer,
|
||||
'local function company_payload(',
|
||||
'local function public_company(',
|
||||
)
|
||||
const createRequest = sourceBlock(
|
||||
companiesServer,
|
||||
'Bridge.Callbacks.Register("sky_phone:companies:create-request"',
|
||||
'Bridge.Callbacks.Register("sky_phone:companies:cancel-request"',
|
||||
)
|
||||
const updateProfile = sourceBlock(
|
||||
companiesServer,
|
||||
'Bridge.Callbacks.Register("sky_phone:companies:update-profile"',
|
||||
'Bridge.Callbacks.Register("sky_phone:companies:update-hours"',
|
||||
)
|
||||
|
||||
expect(validation).not.toContain(
|
||||
'definition.Emergency and definition.AcceptsRequests',
|
||||
)
|
||||
expect(payload).toContain(
|
||||
'acceptsRequests = tonumber(row.accepts_requests) == 1,',
|
||||
)
|
||||
expect(payload).not.toContain('not definition.Emergency')
|
||||
expect(createRequest).toContain(
|
||||
'if not definition or not definition.Public then',
|
||||
)
|
||||
expect(createRequest).not.toContain('definition.Emergency')
|
||||
expect(createRequest).toContain('SELECT `accepts_requests`')
|
||||
expect(createRequest).toContain('AND `requests_enabled` = 1')
|
||||
expect(updateProfile).not.toContain('member.definition.Emergency')
|
||||
})
|
||||
|
||||
it('migrates existing requestable emergency profiles exactly once', () => {
|
||||
const migration = sourceBlock(
|
||||
companiesServer,
|
||||
'local function migrate_requestable_emergency_companies()',
|
||||
'local function tombstone_removed_companies()',
|
||||
)
|
||||
const refresh = sourceBlock(
|
||||
companiesServer,
|
||||
'local function refresh_runtime_configuration()',
|
||||
'\n\nrefresh_runtime_configuration()',
|
||||
)
|
||||
|
||||
expect(migration).toContain('sky-phone:companies:requestable-emergency:v1')
|
||||
expect(migration).toContain(
|
||||
'if definition.Emergency and definition.AcceptsRequests then',
|
||||
)
|
||||
expect(migration).toContain('SET `accepts_requests` = 1')
|
||||
expect(migration).toContain('INSERT IGNORE INTO `sky_phone_migrations`')
|
||||
expect(migration).toContain('Bridge.Database.Transaction(statements)')
|
||||
expect(refresh.indexOf('seed_companies()')).toBeLessThan(
|
||||
refresh.indexOf('migrate_requestable_emergency_companies()'),
|
||||
)
|
||||
expect(
|
||||
refresh.indexOf('migrate_requestable_emergency_companies()'),
|
||||
).toBeLessThan(refresh.indexOf('tombstone_removed_companies()'))
|
||||
})
|
||||
|
||||
it('migrates the existing Phone Configurator police defaults', () => {
|
||||
const migration = sourceBlock(
|
||||
configuratorServer,
|
||||
'local function migrate_police_request_defaults()',
|
||||
'\n\ndefault_config = {}',
|
||||
)
|
||||
|
||||
expect(migration).toContain('sky-phone:configurator:police-requests:v1')
|
||||
expect(migration).toContain('police.AcceptsRequests == false')
|
||||
expect(migration).toContain('next(police.Services) == nil')
|
||||
expect(migration).toContain(
|
||||
'police.AcceptsRequests = defaults.AcceptsRequests',
|
||||
)
|
||||
expect(migration).toContain(
|
||||
'police.Services = copy_value(defaults.Services)',
|
||||
)
|
||||
expect(migration).toContain('SET `config_payload` = ?')
|
||||
expect(migration).toContain('`revision` = `revision` + 1')
|
||||
expect(migration).toContain('INSERT IGNORE INTO `sky_phone_migrations`')
|
||||
expect(migration).toContain('Bridge.Database.Transaction(statements)')
|
||||
expect(migration).toContain('apply_stored_row(read_stored_row())')
|
||||
expect(migration).toContain('apply_runtime_configuration()')
|
||||
expect(configuratorServer).toContain(
|
||||
'Bridge.Database.AfterMigration("sky_phone", migrate_police_request_defaults)',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,133 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
function source(path: string): string {
|
||||
return readFileSync(new URL(path, import.meta.url), 'utf8').replace(
|
||||
/\r\n/g,
|
||||
'\n',
|
||||
)
|
||||
}
|
||||
|
||||
function sourceBlock(
|
||||
value: string,
|
||||
startMarker: string,
|
||||
endMarker: string,
|
||||
): string {
|
||||
const start = value.indexOf(startMarker)
|
||||
const end = value.indexOf(endMarker, start)
|
||||
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
return value.slice(start, end)
|
||||
}
|
||||
|
||||
const companiesServer = source('../../sky_phone/source/server/companies.lua')
|
||||
const messagesServer = source('../../sky_phone/source/server/messages.lua')
|
||||
const databaseMigration = source('../../sky_phone/source/server/db_migrate.lua')
|
||||
const configDefaults = source(
|
||||
'../../sky_phone/source/shared/config_default.lua',
|
||||
)
|
||||
const messagesApp = source('./views/apps/MessagesApp.vue')
|
||||
const phoneFallbacks = source('./stores/phone.ts')
|
||||
const locales = ['en', 'de', 'es'].map((locale) =>
|
||||
source(`../../sky_phone/config/locales/${locale}.lua`),
|
||||
)
|
||||
|
||||
describe('Companies service-line message routing contract', () => {
|
||||
it('enables the police service line and persists an indexed route channel', () => {
|
||||
const police = sourceBlock(
|
||||
configDefaults,
|
||||
' police = {',
|
||||
' ambulance = {',
|
||||
)
|
||||
const schema = sourceBlock(
|
||||
databaseMigration,
|
||||
' name = "sky_phone_company_requests",',
|
||||
' name = "sky_phone_company_request_reads",',
|
||||
)
|
||||
|
||||
expect(police).toContain('Number = "911"')
|
||||
expect(police).toContain('CanMessage = true')
|
||||
expect(schema).toContain(
|
||||
"name = \"channel\", type = \"ENUM('app','service_line') NOT NULL DEFAULT 'app'\"",
|
||||
)
|
||||
expect(schema).toContain(
|
||||
'name = "idx_sky_phone_company_requests_service_line"',
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves short service numbers before the normal SIM-number path', () => {
|
||||
const resolver = sourceBlock(
|
||||
messagesServer,
|
||||
'local function resolve_message_recipient(value)',
|
||||
'\n\nlocal function shared_contact',
|
||||
)
|
||||
const send = sourceBlock(
|
||||
messagesServer,
|
||||
'Bridge.Callbacks.Register("sky_phone:messages:send"',
|
||||
'\n\nend)',
|
||||
)
|
||||
|
||||
expect(resolver).toContain('SkyPhoneCompanies.GetServiceLine(value)')
|
||||
expect(resolver).toContain('return service_line.number, service_line')
|
||||
expect(messagesServer.match(/resolve_message_recipient\(/g)?.length).toBe(4)
|
||||
expect(send).toContain('service_line_text_only')
|
||||
expect(send).toContain('SkyPhoneCompanies.RouteServiceLineMessage')
|
||||
expect(send.indexOf('RouteServiceLineMessage')).toBeLessThan(
|
||||
send.indexOf('SELECT s.`id`, s.`phone_number`'),
|
||||
)
|
||||
})
|
||||
|
||||
it('atomically mirrors inbound SMS into one active company request', () => {
|
||||
const route = sourceBlock(
|
||||
companiesServer,
|
||||
'function SkyPhoneCompanies.RouteServiceLineMessage(source, data)',
|
||||
'Bridge.Callbacks.Register("sky_phone:companies:create-request"',
|
||||
)
|
||||
|
||||
expect(route).toContain('current_device(source, false)')
|
||||
expect(route).toContain('service_line.canMessage')
|
||||
expect(route).toContain('UPDATE `sky_phone_sims`')
|
||||
expect(route).toContain("`channel` = 'service_line'")
|
||||
expect(route).toContain('AND NOT EXISTS (')
|
||||
expect(route).toContain('INSERT INTO `sky_phone_company_request_messages`')
|
||||
expect(route).toContain('INSERT INTO `sky_phone_sms_messages`')
|
||||
expect(route).toContain('Bridge.Database.Transaction(statements)')
|
||||
expect(route).toContain('emit_request_change(row, true, true, source)')
|
||||
expect(route).toContain('"sky_phone:companies:notification"')
|
||||
})
|
||||
|
||||
it('mirrors authorized company replies back to the customer SMS thread', () => {
|
||||
const sendMessage = sourceBlock(
|
||||
companiesServer,
|
||||
'Bridge.Callbacks.Register("sky_phone:companies:send-message"',
|
||||
'Bridge.Callbacks.Register("sky_phone:companies:claim-request"',
|
||||
)
|
||||
|
||||
expect(sendMessage).toContain('access.row.channel == "service_line"')
|
||||
expect(
|
||||
sendMessage.match(/INSERT INTO `sky_phone_sms_messages`/g)?.length,
|
||||
).toBe(2)
|
||||
expect(sendMessage).toContain("message.`sender_type` = 'customer'")
|
||||
expect(sendMessage).toContain("message.`sender_type` = 'company'")
|
||||
expect(sendMessage).toContain('"sky_phone:messages:changed"')
|
||||
expect(sendMessage).toContain('"sky_phone:messages:new"')
|
||||
})
|
||||
|
||||
it('keeps service-line composition text-only in every shipped locale', () => {
|
||||
expect(messagesApp).toContain(
|
||||
"() => activeContact.value?.source === 'company'",
|
||||
)
|
||||
expect(messagesApp).toContain(
|
||||
'activeCanMessage && !activeServiceLine && attachmentMenuOpen',
|
||||
)
|
||||
expect(messagesApp).toContain('v-if="!activeServiceLine"')
|
||||
expect(messagesApp).toContain('v-else-if="!activeServiceLine"')
|
||||
expect(messagesApp).toContain("'service_line_text_only'")
|
||||
expect(phoneFallbacks).toContain('service_line_text_only:')
|
||||
for (const locale of locales) {
|
||||
expect(locale).toContain('service_line_text_only =')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,139 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const companiesServer = readFileSync(
|
||||
new URL('../../sky_phone/source/server/companies.lua', import.meta.url),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
const configuratorServer = readFileSync(
|
||||
new URL(
|
||||
'../../sky_phone/source/server/phone_configurator.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
const englishLocale = readFileSync(
|
||||
new URL('../../sky_phone/config/locales/en.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const germanLocale = readFileSync(
|
||||
new URL('../../sky_phone/config/locales/de.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const spanishLocale = readFileSync(
|
||||
new URL('../../sky_phone/config/locales/es.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('company configurator creation contract', () => {
|
||||
it('publishes complete defaults for newly added company definitions', () => {
|
||||
expect(configuratorServer).toContain(
|
||||
'local function company_definition_entry_default(company_id, configuration)',
|
||||
)
|
||||
expect(configuratorServer).toContain(
|
||||
'local function next_available_company_service_number(configuration)',
|
||||
)
|
||||
expect(configuratorServer).toContain(
|
||||
'entryDefault = company_definition_entry_default()',
|
||||
)
|
||||
expect(configuratorServer).toContain('Routing = "round_robin"')
|
||||
expect(configuratorServer).toContain('RequestsEnabled = true')
|
||||
})
|
||||
|
||||
it('validates the candidate runtime config before encoding or writing SQL', () => {
|
||||
const save = configuratorServer.slice(
|
||||
configuratorServer.indexOf('function SkyPhoneConfigurator.Save('),
|
||||
)
|
||||
const validation = save.indexOf(
|
||||
'SkyPhoneCompanies.ValidateConfiguration(candidate_config)',
|
||||
)
|
||||
const encoding = save.indexOf(
|
||||
'local config_encoded = encode_payload(next_config, "config")',
|
||||
)
|
||||
const update = save.indexOf('UPDATE `%s`')
|
||||
|
||||
expect(validation).toBeGreaterThanOrEqual(0)
|
||||
expect(validation).toBeLessThan(encoding)
|
||||
expect(validation).toBeLessThan(update)
|
||||
expect(save).toContain(
|
||||
'return { success = false, error = "invalid_company_configuration" }',
|
||||
)
|
||||
})
|
||||
|
||||
it('validates into isolated registries before replacing the live company state', () => {
|
||||
expect(companiesServer).toContain(
|
||||
'function SkyPhoneCompanies.ValidateConfiguration(configuration)',
|
||||
)
|
||||
expect(companiesServer).toContain(
|
||||
'local validated, validation_error = validate_configuration(configuration)',
|
||||
)
|
||||
expect(companiesServer).toContain('definitions = validated.definitions')
|
||||
expect(companiesServer).toContain(
|
||||
'definition_ids = validated.definition_ids',
|
||||
)
|
||||
})
|
||||
|
||||
it('localizes rejected company configurations in every shipped locale', () => {
|
||||
for (const locale of [englishLocale, germanLocale, spanishLocale]) {
|
||||
expect(locale).toContain('invalid_company_configuration =')
|
||||
}
|
||||
})
|
||||
|
||||
it('repairs company rows created by the legacy blank schema before Companies starts', () => {
|
||||
const migration = configuratorServer.slice(
|
||||
configuratorServer.indexOf(
|
||||
'local function migrate_blank_company_definitions()',
|
||||
),
|
||||
configuratorServer.indexOf(
|
||||
'local function migrate_police_request_defaults()',
|
||||
),
|
||||
)
|
||||
const blankRegistration = configuratorServer.indexOf(
|
||||
'Bridge.Database.AfterMigration("sky_phone", migrate_blank_company_definitions)',
|
||||
)
|
||||
const policeRegistration = configuratorServer.indexOf(
|
||||
'Bridge.Database.AfterMigration("sky_phone", migrate_police_request_defaults)',
|
||||
)
|
||||
|
||||
expect(migration).toContain(
|
||||
'sky-phone:configurator:company-definition-defaults:v1',
|
||||
)
|
||||
expect(migration).toContain('company_definition_entry_default(')
|
||||
expect(migration).toContain('Bridge.Database.Transaction(statements)')
|
||||
expect(migration).toContain('SET `config_payload` = ?')
|
||||
expect(migration).toContain('`revision` = `revision` + 1')
|
||||
expect(blankRegistration).toBeGreaterThanOrEqual(0)
|
||||
expect(blankRegistration).toBeLessThan(policeRegistration)
|
||||
})
|
||||
|
||||
it('enables persisted police service-line messaging before Companies starts', () => {
|
||||
const migration = configuratorServer.slice(
|
||||
configuratorServer.indexOf(
|
||||
'local function migrate_police_service_line_messaging()',
|
||||
),
|
||||
configuratorServer.indexOf('\n\ndefault_config = {}'),
|
||||
)
|
||||
const policeRegistration = configuratorServer.indexOf(
|
||||
'Bridge.Database.AfterMigration("sky_phone", migrate_police_request_defaults)',
|
||||
)
|
||||
const messageRegistration = configuratorServer.indexOf(
|
||||
'Bridge.Database.AfterMigration("sky_phone", migrate_police_service_line_messaging)',
|
||||
)
|
||||
|
||||
expect(migration).toContain(
|
||||
'sky-phone:configurator:service-line-messaging:v2',
|
||||
)
|
||||
expect(migration).toContain('line.CanMessage ~= true')
|
||||
expect(migration).toContain('line.CanMessage = true')
|
||||
expect(migration).toContain('Bridge.Database.Transaction(statements)')
|
||||
expect(migration).toContain('SET `config_payload` = ?')
|
||||
expect(migration).toContain('`revision` = `revision` + 1')
|
||||
expect(migration).toContain('apply_stored_row(read_stored_row())')
|
||||
expect(migration).toContain('apply_runtime_configuration()')
|
||||
expect(messageRegistration).toBeGreaterThan(policeRegistration)
|
||||
expect(companiesServer).not.toContain(
|
||||
'enables messaging without a virtual service-line message router',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -10,15 +10,10 @@ 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
|
||||
@@ -28,7 +23,6 @@ export type AdminConfigEditorLabels = {
|
||||
emptyTable: string
|
||||
entry: string
|
||||
general: string
|
||||
jobPlaceholder: string
|
||||
keyPlaceholder: string
|
||||
list: string
|
||||
remove: string
|
||||
@@ -151,11 +145,6 @@ 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,
|
||||
@@ -353,6 +342,40 @@ function mapValuePath(entry: SerializedMapEntry): string {
|
||||
return props.path ? `${props.path}.${entry.key}` : String(entry.key)
|
||||
}
|
||||
|
||||
function blankFromStructure(structure: AdminConfiguratorStructure): unknown {
|
||||
if (structure.kind === 'optionalString') return ''
|
||||
if (structure.kind === 'value') return blankValue(structure.valueType)
|
||||
if (structure.kind === 'vector') {
|
||||
const axes = ['x', 'y', 'z', 'w'].slice(
|
||||
0,
|
||||
Number(structure.vectorType.slice(-1)),
|
||||
)
|
||||
return Object.fromEntries([
|
||||
['__skyType', structure.vectorType],
|
||||
...axes.map((axis) => [axis, 0]),
|
||||
])
|
||||
}
|
||||
if (structure.kind === 'list') {
|
||||
return structure.items.map(blankFromStructure)
|
||||
}
|
||||
if (structure.kind === 'map') {
|
||||
return {
|
||||
__skyType: 'map',
|
||||
entries: structure.entries.map((entry) => ({
|
||||
key: entry.key,
|
||||
keyType: entry.keyType,
|
||||
value: blankFromStructure(entry.structure),
|
||||
})),
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(structure.fields).map(([key, field]) => [
|
||||
key,
|
||||
blankFromStructure(field),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
function updateScalar(event: Event): void {
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLInputElement)) return
|
||||
@@ -378,19 +401,6 @@ 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
|
||||
@@ -400,10 +410,10 @@ function toggleOptionalString(event: Event): void {
|
||||
function addListRow(): void {
|
||||
const rows = Array.isArray(props.modelValue) ? [...props.modelValue] : []
|
||||
const index = rows.length
|
||||
const value = listTemplate.value
|
||||
? blankFromConfiguratorStructure(listTemplate.value)
|
||||
: rows.length
|
||||
? blankLike(rows[0])
|
||||
const value = rows.length
|
||||
? blankLike(rows[0])
|
||||
: listTemplate.value
|
||||
? blankFromStructure(listTemplate.value)
|
||||
: blankValue(newArrayKind.value)
|
||||
rows.push(value)
|
||||
emit('update:modelValue', rows)
|
||||
@@ -466,21 +476,12 @@ function addTableField(): void {
|
||||
const template = tableStructure.value?.mutableKeys
|
||||
? tableStructure.value.template
|
||||
: undefined
|
||||
const structuredValue = tableStructure.value?.mutableKeys
|
||||
? createMutableTableEntry(
|
||||
tableStructure.value,
|
||||
props.path,
|
||||
key,
|
||||
tableValue.value,
|
||||
const value = template
|
||||
? blankFromStructure(template)
|
||||
: blankCollectionValue(
|
||||
newObjectKind.value,
|
||||
Object.values(tableValue.value).filter((_, index) => index < 50),
|
||||
)
|
||||
: undefined
|
||||
const value =
|
||||
structuredValue !== undefined
|
||||
? structuredValue
|
||||
: blankCollectionValue(
|
||||
newObjectKind.value,
|
||||
Object.values(tableValue.value).filter((_, index) => index < 50),
|
||||
)
|
||||
emit('update:modelValue', {
|
||||
...tableValue.value,
|
||||
[key]: value,
|
||||
@@ -555,7 +556,7 @@ function addMapEntry(): void {
|
||||
if (!canAddMapEntry.value || parsedNewMapKey.value === null) return
|
||||
const key = parsedNewMapKey.value
|
||||
const value = mapTemplate.value
|
||||
? blankFromConfiguratorStructure(mapTemplate.value)
|
||||
? blankFromStructure(mapTemplate.value)
|
||||
: blankCollectionValue(
|
||||
newMapValueKind.value,
|
||||
mapEntries.value.slice(0, 50).map((entry) => entry.value),
|
||||
@@ -1035,15 +1036,11 @@ function mapEntryStructure(
|
||||
@submit.prevent="addTableField"
|
||||
>
|
||||
<input
|
||||
:value="newObjectKey"
|
||||
v-model="newObjectKey"
|
||||
type="text"
|
||||
:disabled="disabled"
|
||||
:placeholder="
|
||||
isJobTable ? labels.jobPlaceholder : labels.keyPlaceholder
|
||||
"
|
||||
:aria-label="isJobTable ? labels.jobPlaceholder : labels.keyPlaceholder"
|
||||
autocomplete="off"
|
||||
@input="updateNewObjectKey"
|
||||
:placeholder="labels.keyPlaceholder"
|
||||
:aria-label="labels.keyPlaceholder"
|
||||
/>
|
||||
<select
|
||||
v-if="!tableStructure"
|
||||
@@ -1063,7 +1060,7 @@ function mapEntryStructure(
|
||||
{{ structureTypeLabel(tableStructure.template) }}
|
||||
</span>
|
||||
<button type="submit" :disabled="disabled || !canAddTableField">
|
||||
<Plus :size="13" />{{ isJobTable ? labels.addJob : labels.addField }}
|
||||
<Plus :size="13" />{{ labels.addField }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,809 +0,0 @@
|
||||
<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,14 +54,6 @@ const config = readFileSync(
|
||||
new URL('../../../sky_phone/config/config.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const configDefaultsSource = config.replace(
|
||||
/\r?\n?-- CONFIG_DEFAULT_EXCLUDE_START[\s\S]*?-- CONFIG_DEFAULT_EXCLUDE_END\r?\n?/g,
|
||||
'\n',
|
||||
)
|
||||
const mediaConfig = readFileSync(
|
||||
new URL('../../../sky_phone/config/media.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const schema = readFileSync(
|
||||
new URL('../../../sky_phone/sql/install.sql', import.meta.url),
|
||||
'utf8',
|
||||
@@ -77,17 +69,6 @@ const configuratorServer = readFileSync(
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
const configDefault = readFileSync(
|
||||
new URL(
|
||||
'../../../sky_phone/source/shared/config_default.lua',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
const frontendBuild = readFileSync(
|
||||
new URL('../../build.cjs', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const configuratorClient = readFileSync(
|
||||
new URL(
|
||||
'../../../sky_phone/source/client/phone_configurator.lua',
|
||||
@@ -144,7 +125,6 @@ describe('standalone admin panel contracts', () => {
|
||||
'messages',
|
||||
'calls',
|
||||
'moderation',
|
||||
'tones',
|
||||
'audit',
|
||||
'configurator',
|
||||
]) {
|
||||
@@ -153,23 +133,24 @@ describe('standalone admin panel contracts', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('uses a fixed blue accent and replaces appearance controls with live statistics', () => {
|
||||
it('removes manual reload and applies a persistent global accent choice', () => {
|
||||
expect(source).not.toContain('<RefreshCw')
|
||||
expect(source).not.toContain("kind: 'refresh'")
|
||||
expect(source).not.toContain("'sky-phone-admin-accent'")
|
||||
expect(source).not.toContain("'sky-phone-admin-font-family'")
|
||||
expect(source).not.toContain("'sky-phone-admin-font-size'")
|
||||
expect(source).not.toContain("t('appearance.")
|
||||
expect(source).toContain('--admin-accent: #00b8e4')
|
||||
expect(source).toContain('class="admin-panel-statistics-layout"')
|
||||
expect(source).toContain('class="admin-panel-activity-grid"')
|
||||
expect(source).toContain('class="admin-panel-coverage-list"')
|
||||
expect(source).toContain('statisticsTimer = window.setInterval')
|
||||
expect(source).toContain('15_000')
|
||||
expect(server).toContain('AS `messages_today`')
|
||||
expect(server).toContain('AS `calls_today`')
|
||||
expect(server).toContain('AS `active_devices`')
|
||||
expect(server).toContain('auditEntries = tonumber(stats.audit_entries)')
|
||||
expect(source).toContain("'sky-phone-admin-accent'")
|
||||
expect(source).toContain("'sky-phone-admin-font-family'")
|
||||
expect(source).toContain("'sky-phone-admin-font-size'")
|
||||
expect(source).toContain("'--admin-accent': accentColor")
|
||||
expect(source).toContain("'--admin-font-family': activeAdminFontFamily")
|
||||
expect(source).toContain(
|
||||
"'--admin-font-scale': String(adminFontSize / 100)",
|
||||
)
|
||||
expect(source).toContain('class="admin-panel-color-value"')
|
||||
expect(source).toContain('class="admin-panel-rgb-fields"')
|
||||
expect(source).toContain('class="admin-panel-rgb-range"')
|
||||
expect(source).toContain('class="admin-panel-font-select"')
|
||||
expect(source).toContain('class="admin-panel-font-select__menu"')
|
||||
expect(source).toContain('class="admin-panel-font-size-control"')
|
||||
expect(source).not.toContain('type="color"')
|
||||
expect(source).toContain('color-mix(in srgb, var(--admin-green)')
|
||||
expect(source).toContain('--admin-toggle-on: #63d471')
|
||||
expect(source).toContain('background: var(--admin-toggle-on)')
|
||||
@@ -189,22 +170,6 @@ describe('standalone admin panel contracts', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('applies global bundled-app availability to phones and admin management', () => {
|
||||
expect(config).toContain('Config.Apps = {')
|
||||
expect(config).toContain('memory = true,')
|
||||
expect(config).toContain('weather = true,')
|
||||
expect(configDefault).toContain('Config.Apps = {')
|
||||
expect(configuratorServer).toContain('Apps = true,')
|
||||
expect(phoneServer).toContain('function SkyPhone.IsAppEnabled(app_id)')
|
||||
expect(phoneServer).toContain('function SkyPhone.GetDisabledApps()')
|
||||
expect(phoneServer).toContain('disabledApps = SkyPhone.GetDisabledApps()')
|
||||
expect(server).toContain('if not SkyPhone.IsAppEnabled(app_id) then')
|
||||
expect(server).toContain('disabledApps = SkyPhone.GetDisabledApps()')
|
||||
expect(store).toContain('disabledApps: [] as string[]')
|
||||
expect(store).toContain('disabledAppsFromConfigurator(response.data)')
|
||||
expect(source).toContain('!admin.disabledApps.includes(app.id)')
|
||||
})
|
||||
|
||||
it('connects every operation through the standard NUI callback bridge', () => {
|
||||
expect(bridge).toContain('admin = [[')
|
||||
for (const endpoint of [
|
||||
@@ -251,14 +216,12 @@ describe('standalone admin panel contracts', () => {
|
||||
it('authorizes every server request without requiring a phone session', () => {
|
||||
expect(server).not.toContain('SkyPhone.RequireSession(source)')
|
||||
expect(server).toContain(
|
||||
'Bridge.Framework.HasPermission(source, "phonepanel")',
|
||||
'Bridge.Framework.HasAdminGroup(source, Config.AdminPanel.AdminGroups)',
|
||||
)
|
||||
expect(server).toContain('Config.AdminPanel.ReadRequestsPerMinute')
|
||||
expect(server).toContain('Config.AdminPanel.ActionRequestsPerMinute')
|
||||
expect(server).toContain('Config.AdminPanel.CredentialRevealsPerMinute')
|
||||
expect(config).toContain('Config.AdminPanel = {')
|
||||
expect(config).toContain('Config.CommandPermissions = {')
|
||||
expect(config).not.toContain('AdminGroups =')
|
||||
})
|
||||
|
||||
it('opens directly from the configurable command with dedicated focus', () => {
|
||||
@@ -309,12 +272,6 @@ describe('standalone admin panel contracts', () => {
|
||||
expect(schema).toContain(
|
||||
'CREATE TABLE IF NOT EXISTS `sky_phone_configurator`',
|
||||
)
|
||||
expect(
|
||||
manifest.indexOf("'source/shared/config_default.lua'"),
|
||||
).toBeGreaterThan(manifest.indexOf("'config/media.lua'"))
|
||||
expect(manifest.indexOf("'source/shared/config_default.lua'")).toBeLessThan(
|
||||
manifest.indexOf("'source/server/phone_configurator.lua'"),
|
||||
)
|
||||
expect(
|
||||
manifest.indexOf("'source/server/phone_configurator.lua'"),
|
||||
).toBeLessThan(manifest.indexOf("'source/bridge/server/framework.lua'"))
|
||||
@@ -324,36 +281,11 @@ describe('standalone admin panel contracts', () => {
|
||||
expect(configuratorServer).toContain('AND `revision` = ?')
|
||||
expect(configuratorServer).toContain('configurator_enabled')
|
||||
expect(configuratorServer).toMatch(
|
||||
/for key, value in pairs\(ConfigDefaults\)[\s\S]*?key ~= "Media"[\s\S]*?key ~= "PhoneConfigurator"/,
|
||||
/for key, value in pairs\(Config\)[\s\S]*?key ~= "Media"[\s\S]*?key ~= "PhoneConfigurator"/,
|
||||
)
|
||||
expect(configuratorServer).toContain(
|
||||
'default_media = serialize_value(ConfigDefaults.Media)',
|
||||
'default_media = serialize_value(Config.Media)',
|
||||
)
|
||||
expect(configDefault).toContain(
|
||||
'GENERATED by frontend/build.cjs from config/config.lua and config/media.lua',
|
||||
)
|
||||
expect(configDefault).toContain('local realConfig = Config')
|
||||
expect(configDefault).toContain('ConfigDefaults = Config')
|
||||
expect(configDefault.replace(/\r\n?/g, '\n').trimEnd()).toBe(
|
||||
[
|
||||
'-- GENERATED by frontend/build.cjs from config/config.lua and config/media.lua - do not edit.',
|
||||
'-- Escrowed snapshot of the shipped defaults used by the Phone Configurator.',
|
||||
'local realConfig = Config',
|
||||
'Config = {}',
|
||||
'',
|
||||
configDefaultsSource.replace(/\r\n?/g, '\n').trimEnd(),
|
||||
'',
|
||||
mediaConfig.replace(/\r\n?/g, '\n').trimEnd(),
|
||||
'',
|
||||
'ConfigDefaults = Config',
|
||||
'Config = realConfig',
|
||||
].join('\n'),
|
||||
)
|
||||
expect(frontendBuild).toContain("readLuaSource('config', 'config.lua')")
|
||||
expect(frontendBuild).toContain("readLuaSource('config', 'media.lua')")
|
||||
expect(frontendBuild).toContain("'config_default.lua'")
|
||||
expect(configDefault).not.toContain('Config.PhoneConfigurator')
|
||||
expect(configDefault).not.toContain('Config.CommandPermissions')
|
||||
expect(configuratorServer).toContain(
|
||||
'build_sections("config", stored_config',
|
||||
)
|
||||
@@ -441,9 +373,6 @@ 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(',
|
||||
@@ -501,8 +430,7 @@ describe('standalone admin panel contracts', () => {
|
||||
expect(configuratorValueEditor).toContain('function tableFieldStructure(')
|
||||
expect(configuratorValueEditor).toContain('function isFixedTableField(')
|
||||
expect(configuratorValueEditor).toContain('function blankCollectionValue(')
|
||||
expect(configuratorValueEditor).toContain('blankFromConfiguratorStructure')
|
||||
expect(configuratorValueEditor).toContain('createMutableTableEntry(')
|
||||
expect(configuratorValueEditor).toContain('function blankFromStructure(')
|
||||
expect(source).toContain("'is-structured': field.type === 'json'")
|
||||
expect(configuratorValueEditor).toContain('function isStructuredValue(')
|
||||
expect(configuratorValueEditor).toContain(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,13 +24,4 @@ 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.isInstalled('flare')) {
|
||||
if (!appStore.homeLayout.hidden.includes('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.isInstalled('darkchat')) {
|
||||
if (!appStore.homeLayout.hidden.includes('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.isInstalled(id))
|
||||
.filter((id) => !appStore.homeLayout.hidden.includes(id))
|
||||
.flatMap((id) => {
|
||||
const app = getPhoneApp(id)
|
||||
return app ? [{ app, id }] : []
|
||||
@@ -195,21 +195,18 @@ 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,10 +26,6 @@ const mainCss = readFileSync(
|
||||
new URL('../assets/main.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const openedFolderAppBlocks =
|
||||
overlaySource.match(/<AppIcon\b[\s\S]*?\/>/g) ?? []
|
||||
const homeFolderIconBlocks =
|
||||
springboardSource.match(/<HomeFolderIcon\b[\s\S]*?\/>/g) ?? []
|
||||
|
||||
describe('Home folder interaction contract', () => {
|
||||
it('opens folders from edit mode and starts dragging only after movement', () => {
|
||||
@@ -40,7 +36,7 @@ describe('Home folder interaction contract', () => {
|
||||
expect(iconSource).not.toContain('props.editMode || suppressClick.value')
|
||||
})
|
||||
|
||||
it('keeps whole-folder dragging aligned at every phone scale', () => {
|
||||
it('keeps folder app dragging aligned at every phone scale', () => {
|
||||
expect(dragSource).toContain("'.springboard-page, .home-folder-panel'")
|
||||
expect(dragSource).toContain('viewportWidth: bounds.width')
|
||||
expect(dragSource).not.toContain("getPropertyValue('zoom')")
|
||||
@@ -51,60 +47,6 @@ describe('Home folder interaction contract', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('routes whole folders from both the grid and dock through the shared drag portal', () => {
|
||||
expect(homeFolderIconBlocks).toHaveLength(2)
|
||||
for (const area of ['grid', 'dock'] as const) {
|
||||
const block = homeFolderIconBlocks.find((candidate) =>
|
||||
candidate.includes(`data-home-area="${area}"`),
|
||||
)
|
||||
|
||||
expect(block).toBeDefined()
|
||||
expect(block).toContain(':external-drag-visual="homeDragVisualActive"')
|
||||
expect(block).toContain('@dragcancel="stopHomeDrag"')
|
||||
expect(block).toContain('@dragend="finishHomeDrag"')
|
||||
expect(block).toContain('@dragmove="moveHomeDrag"')
|
||||
expect(block).toContain(`@dragstart="startHomeDrag('${area}',`)
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the external viewport visual for apps dragged from an opened folder', () => {
|
||||
expect(openedFolderAppBlocks).toHaveLength(1)
|
||||
expect(overlaySource).toMatch(/externalDragVisual\??:\s*boolean/)
|
||||
expect(openedFolderAppBlocks[0]).toContain(
|
||||
':external-drag-visual="externalDragVisual"',
|
||||
)
|
||||
expect(appIconSource).toContain('externalDragVisual?: boolean')
|
||||
expect(appIconSource).toContain('app-icon-item--drag-source')
|
||||
})
|
||||
|
||||
it('forwards opened-folder pointer start, move, and cancellation to the parent session', () => {
|
||||
expect(overlaySource).toMatch(
|
||||
/function startFolderAppDrag\(index: number, event: PointerEvent\)/,
|
||||
)
|
||||
expect(overlaySource).toContain("emit('dragstart', index, event)")
|
||||
expect(overlaySource).toContain("emit('dragmove', event)")
|
||||
expect(overlaySource).toContain("emit('dragcancel')")
|
||||
expect(openedFolderAppBlocks[0]).toContain(
|
||||
'@dragstart="startFolderAppDrag(entry.index, $event)"',
|
||||
)
|
||||
expect(openedFolderAppBlocks[0]).toContain('@dragmove="moveFolderAppDrag"')
|
||||
expect(openedFolderAppBlocks[0]).toContain(
|
||||
'@dragcancel="stopFolderAppDrag"',
|
||||
)
|
||||
})
|
||||
|
||||
it('hit-tests the opened folder panel and app targets in normalized viewport coordinates', () => {
|
||||
expect(overlaySource).toContain('readPhoneViewportGeometry')
|
||||
expect(overlaySource).toContain('phoneViewportRectContainsPoint')
|
||||
expect(overlaySource).toContain('folderDragViewportRect(panel)')
|
||||
expect(overlaySource).toContain('folderDragViewportRect(element)')
|
||||
expect(overlaySource).toMatch(/geometry\?\.rect\(element\)/)
|
||||
expect(overlaySource).not.toContain(
|
||||
'panelElement.value?.getBoundingClientRect()',
|
||||
)
|
||||
expect(overlaySource).not.toContain('document.elementsFromPoint')
|
||||
})
|
||||
|
||||
it('edits the folder name inline with Sky UI controls', () => {
|
||||
expect(overlaySource).toContain('<SkyField')
|
||||
expect(overlaySource).toContain('home-folder-heading--editing')
|
||||
@@ -121,9 +63,8 @@ describe('Home folder interaction contract', () => {
|
||||
expect(springboardSource).toContain(
|
||||
'@drag-outside-change="folderDraggingOutside = $event"',
|
||||
)
|
||||
expect(overlaySource).toContain('if (draggingIndex.value === null) return')
|
||||
expect(overlaySource).toContain(
|
||||
'if (!draggingOutside.value && !isPointerInsidePanel(event))',
|
||||
'if (draggingIndex.value === null || draggingOutside.value) return',
|
||||
)
|
||||
expect(overlaySource).toContain(
|
||||
'draggingOutside.value || !isPointerInsidePanel(event)',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, Pencil, X } from 'lucide-vue-next'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
@@ -11,35 +11,20 @@ import {
|
||||
HOME_FOLDER_PAGE_SIZE,
|
||||
type HomeFolder,
|
||||
} from '@/utils/homeLayout'
|
||||
import {
|
||||
phoneViewportRectContainsPoint,
|
||||
readPhoneViewportGeometry,
|
||||
type PhoneViewportGeometry,
|
||||
type PhoneViewportRect,
|
||||
} from '@/utils/phoneViewportGeometry'
|
||||
|
||||
type FolderAppEntry = {
|
||||
app: PhoneAppDefinition
|
||||
index: number
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
apps: FolderAppEntry[]
|
||||
editMode: boolean
|
||||
externalDragVisual?: boolean
|
||||
folder: HomeFolder
|
||||
renameOnOpen: boolean
|
||||
}>(),
|
||||
{
|
||||
externalDragVisual: false,
|
||||
},
|
||||
)
|
||||
const props = defineProps<{
|
||||
apps: FolderAppEntry[]
|
||||
editMode: boolean
|
||||
folder: HomeFolder
|
||||
renameOnOpen: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
dragcancel: []
|
||||
dragmove: [event: PointerEvent]
|
||||
dragstart: [index: number, event: PointerEvent]
|
||||
'drag-outside-change': [outside: boolean]
|
||||
edit: []
|
||||
extract: [index: number, event: PointerEvent]
|
||||
@@ -58,7 +43,6 @@ const renameOpened = ref(false)
|
||||
const renameDraft = ref('')
|
||||
let pagePointerStart = 0
|
||||
let pagePointerId: number | null = null
|
||||
let dragGeometry: PhoneViewportGeometry | null = null
|
||||
|
||||
const folderName = computed(
|
||||
() => props.folder.name || phone.t('Home.folders.defaultName'),
|
||||
@@ -129,48 +113,25 @@ function saveRename(): void {
|
||||
renameOpened.value = false
|
||||
}
|
||||
|
||||
function startFolderAppDrag(index: number, event: PointerEvent): void {
|
||||
function startFolderAppDrag(index: number): void {
|
||||
draggingIndex.value = index
|
||||
dragGeometry = readPhoneViewportGeometry(panelElement.value)
|
||||
setDraggingOutside(false)
|
||||
emit('dragstart', index, event)
|
||||
}
|
||||
|
||||
function fallbackViewportRect(element: Element): PhoneViewportRect {
|
||||
const bounds = element.getBoundingClientRect()
|
||||
return {
|
||||
bottom: bounds.bottom,
|
||||
height: bounds.height,
|
||||
left: bounds.left,
|
||||
right: bounds.right,
|
||||
top: bounds.top,
|
||||
width: bounds.width,
|
||||
}
|
||||
}
|
||||
|
||||
function folderDragViewportRect(element: Element): PhoneViewportRect {
|
||||
const geometry = dragGeometry ?? readPhoneViewportGeometry(element)
|
||||
return geometry?.rect(element) ?? fallbackViewportRect(element)
|
||||
}
|
||||
|
||||
function isPointerInsidePanel(event: PointerEvent): boolean {
|
||||
const panel = panelElement.value
|
||||
const panelBounds = panelElement.value?.getBoundingClientRect()
|
||||
if (!panelBounds) return false
|
||||
return (
|
||||
panel !== null &&
|
||||
phoneViewportRectContainsPoint(
|
||||
folderDragViewportRect(panel),
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
)
|
||||
event.clientX >= panelBounds.left &&
|
||||
event.clientX <= panelBounds.right &&
|
||||
event.clientY >= panelBounds.top &&
|
||||
event.clientY <= panelBounds.bottom
|
||||
)
|
||||
}
|
||||
|
||||
function moveFolderAppDrag(event: PointerEvent): void {
|
||||
if (draggingIndex.value === null) return
|
||||
if (!draggingOutside.value && !isPointerInsidePanel(event)) {
|
||||
setDraggingOutside(true)
|
||||
}
|
||||
emit('dragmove', event)
|
||||
if (draggingIndex.value === null || draggingOutside.value) return
|
||||
if (!isPointerInsidePanel(event)) setDraggingOutside(true)
|
||||
}
|
||||
|
||||
function setDraggingOutside(outside: boolean): void {
|
||||
@@ -184,52 +145,31 @@ function finishFolderAppDrag(event: PointerEvent): void {
|
||||
const shouldExtract = draggingOutside.value || !isPointerInsidePanel(event)
|
||||
draggingIndex.value = null
|
||||
if (sourceIndex === null) {
|
||||
dragGeometry = null
|
||||
setDraggingOutside(false)
|
||||
emit('dragcancel')
|
||||
return
|
||||
}
|
||||
if (shouldExtract) {
|
||||
emit('extract', sourceIndex, event)
|
||||
dragGeometry = null
|
||||
setDraggingOutside(false)
|
||||
return
|
||||
}
|
||||
setDraggingOutside(false)
|
||||
|
||||
const target = Array.from(
|
||||
panelElement.value?.querySelectorAll<HTMLElement>(
|
||||
'[data-folder-app-index]',
|
||||
) ?? [],
|
||||
).find(
|
||||
(element) =>
|
||||
Number(element.dataset.folderAppIndex) !== sourceIndex &&
|
||||
phoneViewportRectContainsPoint(
|
||||
folderDragViewportRect(element),
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
),
|
||||
)
|
||||
if (!target) {
|
||||
dragGeometry = null
|
||||
emit('dragcancel')
|
||||
return
|
||||
}
|
||||
const target = document
|
||||
.elementsFromPoint(event.clientX, event.clientY)
|
||||
.map((element) => element.closest<HTMLElement>('[data-folder-app-index]'))
|
||||
.find(
|
||||
(element) =>
|
||||
element && Number(element.dataset.folderAppIndex) !== sourceIndex,
|
||||
)
|
||||
if (!target) return
|
||||
const targetIndex = Number(target.dataset.folderAppIndex)
|
||||
dragGeometry = null
|
||||
if (!Number.isInteger(targetIndex)) {
|
||||
emit('dragcancel')
|
||||
return
|
||||
}
|
||||
emit('move', sourceIndex, targetIndex)
|
||||
if (Number.isInteger(targetIndex)) emit('move', sourceIndex, targetIndex)
|
||||
}
|
||||
|
||||
function stopFolderAppDrag(): void {
|
||||
const wasDragging = draggingIndex.value !== null
|
||||
draggingIndex.value = null
|
||||
dragGeometry = null
|
||||
setDraggingOutside(false)
|
||||
if (wasDragging) emit('dragcancel')
|
||||
}
|
||||
|
||||
function goToPage(page: number): void {
|
||||
@@ -255,11 +195,6 @@ function finishPageSwipe(event: PointerEvent): void {
|
||||
}
|
||||
pagePointerId = null
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopFolderAppDrag()
|
||||
pagePointerId = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -355,11 +290,10 @@ onBeforeUnmount(() => {
|
||||
:app="entry.app"
|
||||
:data-folder-app-index="entry.index"
|
||||
:edit-mode="editMode"
|
||||
:external-drag-visual="externalDragVisual"
|
||||
@dragcancel="stopFolderAppDrag"
|
||||
@dragend="finishFolderAppDrag"
|
||||
@dragmove="moveFolderAppDrag"
|
||||
@dragstart="startFolderAppDrag(entry.index, $event)"
|
||||
@dragstart="startFolderAppDrag(entry.index)"
|
||||
@edit="emit('edit')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -61,22 +61,16 @@ const passcodeError = ref('')
|
||||
const passcodeLength = ref<4 | 6>(phone.security.length === 4 ? 4 : 6)
|
||||
const notificationsEnabled = ref(true)
|
||||
const notificationSounds = ref(true)
|
||||
const selectedApps = ref<BuiltinPhoneAppId[]>(
|
||||
(['banking', 'garage', 'skyride'] as const).filter((id) =>
|
||||
appStore.isAvailable(id),
|
||||
),
|
||||
)
|
||||
const selectedApps = ref<BuiltinPhoneAppId[]>(['banking', 'garage', 'skyride'])
|
||||
const setupCompleteBusy = ref(false)
|
||||
const setupCompleteError = ref('')
|
||||
|
||||
const setupApps = computed(() =>
|
||||
(
|
||||
['banking', 'garage', 'skyride', 'citymarkt', 'picstagram', 'snake'] as const
|
||||
).flatMap((id) => {
|
||||
const app = getPhoneApp(id)
|
||||
return app && appStore.isAvailable(id) ? [app] : []
|
||||
}),
|
||||
)
|
||||
const setupApps = (
|
||||
['banking', 'garage', 'skyride', 'citymarkt', 'picstagram', 'snake'] as const
|
||||
).flatMap((id) => {
|
||||
const app = getPhoneApp(id)
|
||||
return app ? [app] : []
|
||||
})
|
||||
const wallpaperChoices = WALLPAPER_IDS
|
||||
const progress = computed(() => `${((step.value + 1) / 10) * 100}%`)
|
||||
const displayName = computed(() => {
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
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"',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,63 +0,0 @@
|
||||
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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,93 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
function source(path: string): string {
|
||||
return readFileSync(new URL(path, import.meta.url), 'utf8')
|
||||
}
|
||||
|
||||
const config = source('../../sky_phone/config/config.lua')
|
||||
const configDefault = source('../../sky_phone/source/shared/config_default.lua')
|
||||
const framework = source('../../sky_phone/source/bridge/server/framework.lua')
|
||||
const qbox = source('../../sky_phone/source/bridge/server/frameworks/qbox.lua')
|
||||
const configurator = source(
|
||||
'../../sky_phone/source/server/phone_configurator.lua',
|
||||
)
|
||||
const configuratorFixture = source('../testserver/configurator-fixture.cjs')
|
||||
|
||||
describe('fixed server permissions', () => {
|
||||
it('defines every protected phone capability only in config.lua', () => {
|
||||
expect(config).toContain('Config.CommandPermissions = {')
|
||||
for (const permission of [
|
||||
'phonepanel',
|
||||
'phonetestdata',
|
||||
'fliptokverify',
|
||||
'picstagramverify',
|
||||
'picstagramadmin',
|
||||
]) {
|
||||
expect(config).toMatch(new RegExp(`\\s${permission} = \\{`))
|
||||
}
|
||||
expect(config).not.toContain('AdminGroups =')
|
||||
expect(configDefault).not.toContain('Config.PhoneConfigurator')
|
||||
expect(configDefault).not.toContain('Config.CommandPermissions')
|
||||
expect(configDefault).not.toContain('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}")`,
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -3,27 +3,17 @@ import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const readResourceFile = (path: string) =>
|
||||
readFileSync(
|
||||
new URL(`../../sky_phone/${path}`, import.meta.url),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8').replace(/\r\n/g, '\n')
|
||||
const readFrontendFile = (path: string) =>
|
||||
readFileSync(new URL(path, import.meta.url), 'utf8')
|
||||
|
||||
const inventoryAdapters = [
|
||||
['jaksam', 'source/bridge/server/inventory/jaksam.lua'],
|
||||
['ox', 'source/bridge/server/inventory/ox.lua'],
|
||||
['qb', 'source/bridge/server/inventory/qb.lua'],
|
||||
['lj', 'source/bridge/server/inventory/qb.lua'],
|
||||
['qs', 'source/bridge/server/inventory/qs.lua'],
|
||||
['ps', 'source/bridge/server/inventory/ps.lua'],
|
||||
['codem', 'source/bridge/server/inventory/codem.lua'],
|
||||
['tgiann', 'source/bridge/server/inventory/tgiann.lua'],
|
||||
['core', 'source/bridge/server/inventory/core.lua'],
|
||||
['jpr', 'source/bridge/server/inventory/jpr.lua'],
|
||||
['origen', 'source/bridge/server/inventory/origen.lua'],
|
||||
['ak47', 'source/bridge/server/inventory/ak47.lua'],
|
||||
['one', 'source/bridge/server/inventory/one.lua'],
|
||||
['mf', 'source/bridge/server/inventory/mf.lua'],
|
||||
['smx', 'source/bridge/server/inventory/smx.lua'],
|
||||
['hex', 'source/bridge/server/inventory/esx.lua'],
|
||||
@@ -52,65 +42,17 @@ describe('phone inventory contracts', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses One Inventory slot ids and authoritative slot reads', () => {
|
||||
const adapter = readResourceFile(
|
||||
'source/bridge/server/inventory/one.lua',
|
||||
)
|
||||
|
||||
expect(adapter).toContain('inventory:GetSlotIdsWithItem(')
|
||||
expect(adapter).toContain(
|
||||
'local normalized = Bridge.Inventory.GetSlot(source, slot_id)',
|
||||
)
|
||||
expect(adapter).not.toContain('inventory:SearchInventory(')
|
||||
expect(adapter).toContain(
|
||||
'inventory:SetItemMetadata(source, slot.slot, requested_metadata) == false',
|
||||
)
|
||||
})
|
||||
|
||||
it('serializes and rate-limits phone bootstrap requests on both sides', () => {
|
||||
const phoneClient = readResourceFile('source/client/main.lua')
|
||||
const phoneServer = readResourceFile('source/server/phone.lua')
|
||||
|
||||
expect(phoneClient).toContain('local function request_phone_open(')
|
||||
expect(phoneClient).toMatch(
|
||||
/request_phone_open\(callback_name\)[\s\S]*?open_requested = true[\s\S]*?Bridge\.Callbacks\.Trigger\(callback_name, \{\}\)/,
|
||||
)
|
||||
expect(phoneClient).toMatch(
|
||||
/RegisterNetEvent\("sky_phone:device:error"[\s\S]*?if not is_open then[\s\S]*?open_requested = false/,
|
||||
)
|
||||
expect(phoneServer).toContain('local phone_open_in_progress = {}')
|
||||
expect(phoneServer).toContain(
|
||||
'SkyPhone.AllowOperation(source, "phone_open", request_limit, 60)',
|
||||
)
|
||||
expect(phoneServer).toContain(
|
||||
'pcall(perform_phone_open, source, used_item)',
|
||||
)
|
||||
})
|
||||
|
||||
it('auto-detects registered inventories and forces metadata-free adapters into compatible modes', () => {
|
||||
it('auto-detects HEX and limits count-based ESX inventories to metadata-free modes', () => {
|
||||
const inventoryBridge = readResourceFile(
|
||||
'source/bridge/server/inventory.lua',
|
||||
)
|
||||
|
||||
expect(inventoryBridge).toContain(
|
||||
'{ name = "hex", resource = "hex_4_inventory", framework = "esx", metadata = false },',
|
||||
)
|
||||
expect(inventoryBridge).toContain(
|
||||
'GetResourceState(adapter.resource) == "started"',
|
||||
)
|
||||
expect(inventoryBridge).toContain('configured_inventory = adapter.name')
|
||||
expect(inventoryBridge).toContain('selected_adapter.metadata == false')
|
||||
expect(inventoryBridge).toContain('Config.Phone.Unique = false')
|
||||
expect(inventoryBridge).toContain('Config.Sim.Enabled = false')
|
||||
expect(inventoryBridge).toContain(
|
||||
'does not support item metadata; unique phones and physical SIM cards were disabled automatically',
|
||||
)
|
||||
expect(inventoryBridge).toContain(
|
||||
'AddEventHandler("sky_phone:configurator:serverUpdated"',
|
||||
)
|
||||
expect(inventoryBridge).not.toContain(
|
||||
'cannot store unique phone or physical SIM metadata',
|
||||
'GetResourceState("hex_4_inventory") == "started"',
|
||||
)
|
||||
expect(inventoryBridge).toContain('configured_inventory = "hex"')
|
||||
expect(inventoryBridge).toContain('Config.Phone.Unique ~= false')
|
||||
expect(inventoryBridge).toContain('Config.Sim.Enabled ~= false')
|
||||
})
|
||||
|
||||
it('provides the LB IsOpen export alias from the authoritative client state', () => {
|
||||
@@ -161,15 +103,9 @@ describe('phone inventory contracts', () => {
|
||||
expect(equippedNumberExport).toBeDefined()
|
||||
expect(equippedNumberResolver).toBeDefined()
|
||||
expect(equippedNumberExport).toContain('type(player) == "number"')
|
||||
expect(equippedNumberExport).toContain(
|
||||
'online_source_for_identifier(player)',
|
||||
)
|
||||
expect(phoneServer).toContain(
|
||||
'Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)',
|
||||
)
|
||||
expect(phoneServer).toContain(
|
||||
'return resolve_equipped_phone_number(player)',
|
||||
)
|
||||
expect(equippedNumberExport).toContain('online_source_for_identifier(player)')
|
||||
expect(phoneServer).toContain('Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)')
|
||||
expect(phoneServer).toContain('return resolve_equipped_phone_number(player)')
|
||||
expect(equippedNumberExport).not.toContain('tonumber(player)')
|
||||
expect(equippedNumberExport).not.toContain('equipped_phone_numbers[player]')
|
||||
expect(equippedNumberResolver).not.toContain('return cached_number')
|
||||
@@ -201,10 +137,10 @@ describe('phone inventory contracts', () => {
|
||||
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "ToggleOpen"',
|
||||
)
|
||||
expect(phoneClient).toContain(
|
||||
'local result = Bridge.Callbacks.Trigger(callback_name, {})',
|
||||
'local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})',
|
||||
)
|
||||
expect(phoneClient).toMatch(
|
||||
/result\.success == true[\s\S]*open_requested = false[\s\S]*open_without_focus = false[\s\S]*return false/,
|
||||
/result\.success ~= true[\s\S]*open_without_focus = false[\s\S]*return false/,
|
||||
)
|
||||
expect(phoneBridge).toContain(
|
||||
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "IsPhoneOnScreen"',
|
||||
@@ -224,9 +160,7 @@ describe('phone inventory contracts', () => {
|
||||
expect(phoneClient).toContain(
|
||||
'TriggerEvent("sky_phone:client:phoneToggled", false)',
|
||||
)
|
||||
expect(phoneBridge).toContain(
|
||||
'TriggerEvent("lb-phone:numberChanged", phone_number)',
|
||||
)
|
||||
expect(phoneBridge).toContain('TriggerEvent("lb-phone:numberChanged", phone_number)')
|
||||
expect(phoneBridge).toContain('TriggerEvent("lb-phone:phoneToggled", open)')
|
||||
})
|
||||
|
||||
@@ -279,28 +213,26 @@ describe('phone inventory contracts', () => {
|
||||
expect(mediaServer).toContain(
|
||||
'TriggerEvent("sky_phone:server:galleryMediaDeleted", src, phone_number, deleted_link)',
|
||||
)
|
||||
expect(phoneBridge).toContain(
|
||||
'TriggerEvent("lb-phone:phoneNumberGenerated"',
|
||||
)
|
||||
expect(phoneBridge).toContain('TriggerEvent("lb-phone:phoneNumberGenerated"')
|
||||
expect(phoneBridge).toContain('TriggerEvent("lb-phone:factoryReset"')
|
||||
expect(phoneBridge).toContain('TriggerEvent("lb-phone:deletedFromGallery"')
|
||||
})
|
||||
|
||||
it('keeps the phone key mapping command stable so FiveM user rebindings persist', () => {
|
||||
it('opens from a configurable F1 mapping without client-provided device identity', () => {
|
||||
const config = readResourceFile('config/config.lua')
|
||||
const phoneClient = readResourceFile('source/client/main.lua')
|
||||
const phoneServer = readResourceFile('source/server/phone.lua')
|
||||
|
||||
expect(config).toContain('Keybind = "F1"')
|
||||
expect(phoneClient).toContain('local phone_key_mapping_registered = false')
|
||||
expect(phoneClient).toContain('refresh_phone_key_mapping = function()')
|
||||
expect(phoneClient).toMatch(
|
||||
/RegisterKeyMapping\(\s*"sky_phone_toggle",\s*locale\.Controls\.OpenPhone,\s*"keyboard",\s*key_name\s*\)/,
|
||||
)
|
||||
expect(phoneClient).not.toContain('sky_phone_toggle_config_')
|
||||
expect(phoneClient).not.toContain('key_mapping_revision')
|
||||
expect(phoneClient).toContain(
|
||||
'request_phone_open("sky_phone:device:open-request")',
|
||||
'RegisterKeyMapping(command_name, locale.Controls.OpenPhone, "keyboard", key_name)',
|
||||
)
|
||||
expect(phoneClient).toContain(
|
||||
'if active_key_mapping_command == command_name then',
|
||||
)
|
||||
expect(phoneClient).toContain(
|
||||
'Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})',
|
||||
)
|
||||
expect(phoneServer).toContain(
|
||||
'Bridge.Callbacks.Register("sky_phone:device:open-request", function(source)',
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
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,45 +8,14 @@ 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,
|
||||
activeDevices: 0,
|
||||
auditEntries: 0,
|
||||
auditToday: 0,
|
||||
callsToday: 0,
|
||||
devices: 0,
|
||||
linkedDevices: 0,
|
||||
messagesToday: 0,
|
||||
online: 0,
|
||||
simDevices: 0,
|
||||
}
|
||||
|
||||
function disabledAppsFromConfigurator(
|
||||
configurator: AdminConfigurator,
|
||||
): string[] {
|
||||
const apps = configurator.sections
|
||||
.flatMap((section) => section.fields)
|
||||
.find((field) => field.scope === 'config' && field.path === 'Apps')?.value
|
||||
if (!apps || typeof apps !== 'object' || Array.isArray(apps)) return []
|
||||
|
||||
return Object.entries(apps)
|
||||
.filter(([, enabled]) => enabled === false)
|
||||
.map(([appId]) => appId)
|
||||
.sort()
|
||||
}
|
||||
const EMPTY_STATS: AdminStats = { accounts: 0, devices: 0, online: 0 }
|
||||
|
||||
export const useAdminStore = defineStore('admin', {
|
||||
state: () => ({
|
||||
@@ -55,10 +24,7 @@ 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,
|
||||
@@ -81,9 +47,6 @@ export const useAdminStore = defineStore('admin', {
|
||||
return false
|
||||
}
|
||||
this.players = response.data.players
|
||||
this.disabledApps = Array.isArray(response.data.disabledApps)
|
||||
? response.data.disabledApps
|
||||
: []
|
||||
this.stats = response.data.stats
|
||||
this.audit = response.data.audit
|
||||
this.error = ''
|
||||
@@ -184,7 +147,6 @@ export const useAdminStore = defineStore('admin', {
|
||||
return false
|
||||
}
|
||||
this.configurator = response.data
|
||||
this.disabledApps = disabledAppsFromConfigurator(response.data)
|
||||
this.error = ''
|
||||
return true
|
||||
},
|
||||
@@ -205,7 +167,6 @@ export const useAdminStore = defineStore('admin', {
|
||||
this.actionKey = ''
|
||||
if (response.success && response.data) {
|
||||
this.configurator = response.data
|
||||
this.disabledApps = disabledAppsFromConfigurator(response.data)
|
||||
this.error = ''
|
||||
} else {
|
||||
if (response.data) this.configurator = response.data
|
||||
@@ -213,99 +174,6 @@ 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,21 +75,6 @@ describe('app store', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('removes server-disabled apps without forgetting device claims', () => {
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
apps.hydrate({ claimedApps: ['weather', 'snake'] }, ['weather'])
|
||||
|
||||
expect(apps.isAvailable('weather')).toBe(false)
|
||||
expect(apps.isInstalled('weather')).toBe(false)
|
||||
expect(apps.homeLayout.grid).not.toContain('weather')
|
||||
expect(apps.claimedApps).toContain('weather')
|
||||
expect(apps.isInstalled('snake')).toBe(true)
|
||||
|
||||
apps.hydrate({ claimedApps: apps.claimedApps }, [])
|
||||
expect(apps.isInstalled('weather')).toBe(true)
|
||||
})
|
||||
|
||||
it('drops the retired admin app from persisted phone layouts', () => {
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
@@ -264,23 +249,6 @@ 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()
|
||||
|
||||
@@ -491,41 +459,6 @@ describe('app store', () => {
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('materializes a trailing page only for a successful folder extraction', () => {
|
||||
const apps = useAppStoreStore()
|
||||
apps.hydrate(null)
|
||||
|
||||
const notesIndex = apps.homeLayout.grid.indexOf('notes')
|
||||
const settingsIndex = apps.homeLayout.grid.indexOf('settings')
|
||||
const folderId = apps.createHomeFolder(
|
||||
'grid',
|
||||
notesIndex,
|
||||
'grid',
|
||||
settingsIndex,
|
||||
'Utilities',
|
||||
)
|
||||
expect(folderId).toBeTruthy()
|
||||
|
||||
const originalPageCount = apps.homeLayout.pageCount
|
||||
const targetPage = originalPageCount + 1
|
||||
const targetIndex = originalPageCount * HOME_GRID_PAGE_SIZE
|
||||
expect(apps.homeLayout.grid[targetIndex]).toBeUndefined()
|
||||
|
||||
expect(
|
||||
apps.extractHomeFolderApp('missing-folder', 0, 'grid', targetIndex),
|
||||
).toBe(false)
|
||||
expect(apps.homeLayout.pageCount).toBe(originalPageCount)
|
||||
expect(apps.homeLayout.grid[targetIndex]).toBeUndefined()
|
||||
|
||||
mocks.phone.saveDeviceNamespace.mockClear()
|
||||
expect(apps.extractHomeFolderApp(folderId!, 0, 'grid', targetIndex)).toBe(
|
||||
true,
|
||||
)
|
||||
expect(apps.homeLayout.pageCount).toBe(targetPage)
|
||||
expect(apps.homeLayout.grid[targetIndex]).toBe('settings')
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not commit an installation to a different phone', () => {
|
||||
vi.useFakeTimers()
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
createDefaultHomeLayout,
|
||||
deleteHomePage,
|
||||
extractHomeFolderApp,
|
||||
HOME_GRID_PAGE_SIZE,
|
||||
moveHomeFolderApp,
|
||||
moveHomeApp,
|
||||
moveHomeAppToGridPage,
|
||||
@@ -46,49 +45,28 @@ const pendingInstallations = new WeakMap<
|
||||
Map<LaunchablePhoneAppId, PendingInstallation>
|
||||
>()
|
||||
|
||||
function isAppDisabled(
|
||||
appId: LaunchablePhoneAppId,
|
||||
disabledApps: readonly LaunchablePhoneAppId[],
|
||||
): boolean {
|
||||
return disabledApps.includes(appId)
|
||||
}
|
||||
|
||||
function getDefaultGridIds(
|
||||
disabledApps: readonly LaunchablePhoneAppId[] = [],
|
||||
): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter(
|
||||
(app) => app.dockOrder === null && !isAppDisabled(app.id, disabledApps),
|
||||
)
|
||||
function getDefaultGridIds(): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter((app) => app.dockOrder === null)
|
||||
.sort((a, b) => a.gridOrder - b.gridOrder)
|
||||
.map((app) => app.id)
|
||||
}
|
||||
|
||||
function getDefaultDockIds(
|
||||
disabledApps: readonly LaunchablePhoneAppId[] = [],
|
||||
): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter(
|
||||
(app) => app.dockOrder !== null && !isAppDisabled(app.id, disabledApps),
|
||||
)
|
||||
function getDefaultDockIds(): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter((app) => app.dockOrder !== null)
|
||||
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
|
||||
.map((app) => app.id)
|
||||
}
|
||||
|
||||
function getDefaultInstalledIds(
|
||||
disabledApps: readonly LaunchablePhoneAppId[] = [],
|
||||
): LaunchablePhoneAppId[] {
|
||||
function getDefaultInstalledIds(): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter((app) => {
|
||||
if (app.adminOnly || isAppDisabled(app.id, disabledApps)) return false
|
||||
if (app.adminOnly) return false
|
||||
return isExternalPhoneApp(app)
|
||||
? app.defaultInstalled
|
||||
: DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id)
|
||||
}).map((app) => app.id)
|
||||
}
|
||||
|
||||
function isProtectedHomeApp(
|
||||
appId: LaunchablePhoneAppId,
|
||||
disabledApps: readonly LaunchablePhoneAppId[],
|
||||
): boolean {
|
||||
if (isAppDisabled(appId, disabledApps)) return false
|
||||
function isProtectedHomeApp(appId: LaunchablePhoneAppId): boolean {
|
||||
const app = getPhoneApp(appId)
|
||||
return app
|
||||
? !isPhoneAppRemovable(app)
|
||||
@@ -126,7 +104,6 @@ function hasUninstalledBuiltinApp(
|
||||
export const useAppStoreStore = defineStore('app-store', {
|
||||
state: () => ({
|
||||
claimedApps: [] as LaunchablePhoneAppId[],
|
||||
disabledApps: [] as LaunchablePhoneAppId[],
|
||||
uninstalledApps: [] as LaunchablePhoneAppId[],
|
||||
homeLayout: createDefaultHomeLayout(
|
||||
getDefaultInstalledIds(),
|
||||
@@ -153,7 +130,6 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
return true
|
||||
},
|
||||
claimApp(id: LaunchablePhoneAppId): void {
|
||||
if (!this.isAvailable(id)) return
|
||||
this.uninstalledApps = this.uninstalledApps.filter(
|
||||
(appId) => appId !== id,
|
||||
)
|
||||
@@ -174,7 +150,6 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
this.installingApps = {}
|
||||
},
|
||||
installApp(id: LaunchablePhoneAppId): void {
|
||||
if (!this.isAvailable(id)) return
|
||||
const installed = this.isInstalled(id)
|
||||
if (
|
||||
this.installingApps[id] ||
|
||||
@@ -216,10 +191,6 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!this.isAvailable(id)) {
|
||||
delete this.installingApps[id]
|
||||
return
|
||||
}
|
||||
if (reportInstall && !isExternalPhoneApp(getPhoneApp(id))) {
|
||||
delete this.installingApps[id]
|
||||
console.error(
|
||||
@@ -249,14 +220,8 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
installations.set(id, { deviceImei, timer, token })
|
||||
pendingInstallations.set(this, installations)
|
||||
},
|
||||
hydrate(payload: unknown, disabledApps: unknown = []): void {
|
||||
hydrate(payload: unknown): void {
|
||||
this.cancelPendingInstalls()
|
||||
this.disabledApps = Array.isArray(disabledApps)
|
||||
? disabledApps.filter(
|
||||
(id): id is LaunchablePhoneAppId =>
|
||||
typeof id === 'string' && isPhoneAppId(id),
|
||||
)
|
||||
: []
|
||||
const data = payload as {
|
||||
claimedApps?: unknown
|
||||
homeLayout?: unknown
|
||||
@@ -291,22 +256,16 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
})
|
||||
: []
|
||||
const installedIds = [
|
||||
...new Set([
|
||||
...getDefaultInstalledIds(this.disabledApps),
|
||||
...this.claimedApps,
|
||||
]),
|
||||
].filter(
|
||||
(id) =>
|
||||
!this.uninstalledApps.includes(id) && this.isAvailable(id),
|
||||
)
|
||||
...new Set([...getDefaultInstalledIds(), ...this.claimedApps]),
|
||||
].filter((id) => !this.uninstalledApps.includes(id))
|
||||
const removedLegacyDefaults = hasUninstalledBuiltinApp(
|
||||
data?.homeLayout,
|
||||
installedIds,
|
||||
)
|
||||
const defaults = createDefaultHomeLayout(
|
||||
installedIds,
|
||||
getDefaultGridIds(this.disabledApps),
|
||||
getDefaultDockIds(this.disabledApps),
|
||||
getDefaultGridIds(),
|
||||
getDefaultDockIds(),
|
||||
)
|
||||
const parsedHomeLayout = parseHomeLayout(
|
||||
data?.homeLayout,
|
||||
@@ -318,9 +277,8 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
const removedDockGridDuplicates =
|
||||
normalizedHomeLayout !== parsedHomeLayout
|
||||
this.homeLayout = normalizedHomeLayout
|
||||
const protectedHiddenAppIds = this.homeLayout.hidden.filter((appId) =>
|
||||
isProtectedHomeApp(appId, this.disabledApps),
|
||||
)
|
||||
const protectedHiddenAppIds =
|
||||
this.homeLayout.hidden.filter(isProtectedHomeApp)
|
||||
for (const appId of protectedHiddenAppIds) {
|
||||
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
|
||||
}
|
||||
@@ -352,11 +310,7 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
this.persist()
|
||||
}
|
||||
},
|
||||
isAvailable(appId: LaunchablePhoneAppId): boolean {
|
||||
return !isAppDisabled(appId, this.disabledApps)
|
||||
},
|
||||
isInstalled(appId: LaunchablePhoneAppId): boolean {
|
||||
if (!this.isAvailable(appId)) return false
|
||||
const app = getPhoneApp(appId)
|
||||
if (app?.adminOnly) return false
|
||||
if (this.uninstalledApps.includes(appId)) return false
|
||||
@@ -368,18 +322,12 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
},
|
||||
reconcileCatalog(): void {
|
||||
const installedIds = [
|
||||
...new Set([
|
||||
...getDefaultInstalledIds(this.disabledApps),
|
||||
...this.claimedApps,
|
||||
]),
|
||||
].filter(
|
||||
(id) =>
|
||||
!this.uninstalledApps.includes(id) && this.isAvailable(id),
|
||||
)
|
||||
...new Set([...getDefaultInstalledIds(), ...this.claimedApps]),
|
||||
].filter((id) => !this.uninstalledApps.includes(id))
|
||||
const defaults = createDefaultHomeLayout(
|
||||
installedIds,
|
||||
getDefaultGridIds(this.disabledApps),
|
||||
getDefaultDockIds(this.disabledApps),
|
||||
getDefaultGridIds(),
|
||||
getDefaultDockIds(),
|
||||
)
|
||||
const previous = JSON.stringify(this.homeLayout)
|
||||
this.homeLayout = removeDockGridDuplicates(
|
||||
@@ -387,7 +335,7 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
)
|
||||
|
||||
for (const appId of [...this.homeLayout.hidden]) {
|
||||
if (isProtectedHomeApp(appId, this.disabledApps)) {
|
||||
if (isProtectedHomeApp(appId)) {
|
||||
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
|
||||
}
|
||||
}
|
||||
@@ -397,7 +345,6 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
}
|
||||
},
|
||||
recordLaunch(appId: LaunchablePhoneAppId): void {
|
||||
if (!this.isAvailable(appId)) return
|
||||
this.launchCounts[appId] = (this.launchCounts[appId] ?? 0) + 1
|
||||
this.persist()
|
||||
},
|
||||
@@ -520,35 +467,25 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
to: HomeArea,
|
||||
targetIndex: number,
|
||||
): boolean {
|
||||
let sourceLayout = this.homeLayout
|
||||
if (to === 'grid' && Number.isInteger(targetIndex) && targetIndex >= 0) {
|
||||
const targetPage = Math.floor(targetIndex / HOME_GRID_PAGE_SIZE) + 1
|
||||
while (sourceLayout.pageCount < targetPage) {
|
||||
const expanded = addHomePage(sourceLayout)
|
||||
if (expanded === sourceLayout) return false
|
||||
sourceLayout = expanded
|
||||
}
|
||||
}
|
||||
const next = extractHomeFolderApp(
|
||||
sourceLayout,
|
||||
this.homeLayout,
|
||||
folderId,
|
||||
sourceIndex,
|
||||
to,
|
||||
targetIndex,
|
||||
)
|
||||
if (next === sourceLayout) return false
|
||||
if (next === this.homeLayout) return false
|
||||
this.homeLayout = next
|
||||
this.persist()
|
||||
return true
|
||||
},
|
||||
removeHomeApp(appId: LaunchablePhoneAppId): void {
|
||||
if (isProtectedHomeApp(appId, this.disabledApps)) return
|
||||
if (isProtectedHomeApp(appId)) return
|
||||
|
||||
this.homeLayout = removeHomeApp(this.homeLayout, appId)
|
||||
this.persist()
|
||||
},
|
||||
restoreHomeApp(appId: LaunchablePhoneAppId): void {
|
||||
if (!this.isAvailable(appId)) return
|
||||
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
|
||||
this.persist()
|
||||
},
|
||||
|
||||
@@ -4,18 +4,14 @@ 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 { findCustomPhoneTone, playCustomPhoneTone } from '@/utils/customTones'
|
||||
import {
|
||||
isBuiltInRingtoneId,
|
||||
type BuiltInRingtoneId,
|
||||
} from '@/utils/preferences'
|
||||
import type { RingtoneId } from '@/utils/preferences'
|
||||
import {
|
||||
playPhoneTone,
|
||||
playPhoneVibration,
|
||||
type PhoneToneId,
|
||||
} from '@/utils/tones'
|
||||
|
||||
const RINGTONE_TONES: Record<BuiltInRingtoneId, PhoneToneId> = {
|
||||
const RINGTONE_TONES: Record<RingtoneId, PhoneToneId> = {
|
||||
horizon: 'aurora',
|
||||
pulse: 'signal',
|
||||
skyline: 'apex',
|
||||
@@ -28,21 +24,6 @@ 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()])
|
||||
}
|
||||
@@ -192,7 +173,11 @@ export const useCallsStore = defineStore('calls', () => {
|
||||
phone.preferences.settings.ringtoneVolume === 0
|
||||
stopRingtone = alertsMuted
|
||||
? playPhoneVibration('call', true)
|
||||
: playSelectedRingtone(phone.preferences.settings.ringtoneVolume)
|
||||
: playPhoneTone(
|
||||
RINGTONE_TONES[phone.preferences.settings.ringtone],
|
||||
phone.preferences.settings.ringtoneVolume,
|
||||
true,
|
||||
)
|
||||
}
|
||||
if (!['ringing', 'connected'].includes(call.state)) {
|
||||
window.setTimeout(() => {
|
||||
|
||||
@@ -5,10 +5,8 @@ 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 {
|
||||
@@ -192,34 +190,18 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
||||
const alertsMuted =
|
||||
preferences.settings.notificationVolume === 0 &&
|
||||
preferences.settings.ringtoneVolume === 0
|
||||
const selectedSound = preferences.settings.notificationSound
|
||||
const customSound = notification.sound
|
||||
? undefined
|
||||
: findCustomPhoneTone(
|
||||
phone.customTones.notificationSounds,
|
||||
selectedSound,
|
||||
)
|
||||
const sound = notification.sound ?? preferences.settings.notificationSound
|
||||
const volume = notification.critical
|
||||
? preferences.settings.ringtoneVolume
|
||||
: preferences.settings.notificationVolume
|
||||
stopToneHandles.set(
|
||||
notification.id,
|
||||
alertsMuted
|
||||
? playPhoneVibration('notification', !!notification.persistent)
|
||||
: customSound
|
||||
? playCustomPhoneTone(
|
||||
customSound,
|
||||
volume,
|
||||
!!notification.persistent,
|
||||
)
|
||||
: playPhoneTone(
|
||||
notification.sound ??
|
||||
(isBuiltInNotificationSoundId(selectedSound)
|
||||
? selectedSound
|
||||
: 'chime'),
|
||||
volume,
|
||||
!!notification.persistent,
|
||||
),
|
||||
? playPhoneVibration(
|
||||
'notification',
|
||||
!!notification.persistent,
|
||||
)
|
||||
: playPhoneTone(sound, volume, !!notification.persistent),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,22 +10,14 @@ type LuaToken = {
|
||||
}
|
||||
|
||||
const frontendSourceDirectory = fileURLToPath(new URL('../', import.meta.url))
|
||||
const localeDirectory = fileURLToPath(
|
||||
new URL('../../../sky_phone/config/locales/', import.meta.url),
|
||||
const englishLocaleSource = readFileSync(
|
||||
new URL('../../../sky_phone/config/locales/en.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const localeSources = new Map(
|
||||
readdirSync(localeDirectory, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.lua'))
|
||||
.map((entry) => [
|
||||
entry.name.replace(/\.lua$/, ''),
|
||||
readFileSync(join(localeDirectory, entry.name), 'utf8'),
|
||||
]),
|
||||
const germanLocaleSource = readFileSync(
|
||||
new URL('../../../sky_phone/config/locales/de.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const englishLocaleSource = localeSources.get('en')
|
||||
const germanLocaleSource = localeSources.get('de')
|
||||
if (!englishLocaleSource || !germanLocaleSource) {
|
||||
throw new Error('The bundled English and German phone locales are required.')
|
||||
}
|
||||
const phoneStoreSource = readFileSync(
|
||||
new URL('./phone.ts', import.meta.url),
|
||||
'utf8',
|
||||
@@ -156,10 +148,6 @@ function collectPlaceholders(value: string): string[] {
|
||||
return [...new Set(value.match(/\{[A-Za-z0-9_]+\}/g) ?? [])].sort()
|
||||
}
|
||||
|
||||
function collectNumberTokens(value: string): string[] {
|
||||
return value.match(/\d+/g) ?? []
|
||||
}
|
||||
|
||||
function collectDefaultLocalePaths(source: string): Set<string> {
|
||||
const ast = ts.createSourceFile(
|
||||
'phone.ts',
|
||||
@@ -235,26 +223,10 @@ function collectFrontendFiles(directory: string): string[] {
|
||||
}
|
||||
|
||||
describe('phone locale contract', () => {
|
||||
const localeValues = new Map(
|
||||
[...localeSources].map(([locale, source]) => [
|
||||
locale,
|
||||
collectLuaLocaleValues(source),
|
||||
]),
|
||||
)
|
||||
const englishValues = localeValues.get('en')!
|
||||
const germanValues = localeValues.get('de')!
|
||||
const translatedLocaleValues = [...localeValues].filter(
|
||||
([locale]) => locale !== 'en',
|
||||
)
|
||||
const englishValues = collectLuaLocaleValues(englishLocaleSource)
|
||||
const germanValues = collectLuaLocaleValues(germanLocaleSource)
|
||||
const englishPaths = new Set(englishValues.keys())
|
||||
|
||||
it.each([...localeSources])(
|
||||
'registers the %s locale under its file name',
|
||||
(locale, source) => {
|
||||
expect(source.match(/^Locales\["([^"]+)"\]/)?.[1]).toBe(locale)
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps every bundled frontend fallback in en.lua', () => {
|
||||
const missing = [...collectDefaultLocalePaths(phoneStoreSource)].filter(
|
||||
(path) => !englishPaths.has(`Nui.${path}`),
|
||||
@@ -299,52 +271,26 @@ describe('phone locale contract', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.each(translatedLocaleValues)(
|
||||
'keeps %s structurally aligned with English',
|
||||
(_locale, values) => {
|
||||
expect([...values.keys()].sort()).toEqual([...englishPaths].sort())
|
||||
},
|
||||
)
|
||||
it('keeps German structurally aligned with English', () => {
|
||||
const germanPaths = new Set(germanValues.keys())
|
||||
|
||||
it.each(translatedLocaleValues)(
|
||||
'keeps %s interpolation placeholders aligned with English',
|
||||
(_locale, values) => {
|
||||
const mismatches = [...englishValues].flatMap(
|
||||
([path, englishValue]) => {
|
||||
const translatedValue = values.get(path)
|
||||
return translatedValue !== undefined &&
|
||||
JSON.stringify(collectPlaceholders(translatedValue)) !==
|
||||
JSON.stringify(collectPlaceholders(englishValue))
|
||||
? [
|
||||
`${path}: ${collectPlaceholders(englishValue).join(', ')} != ${collectPlaceholders(translatedValue).join(', ')}`,
|
||||
]
|
||||
: []
|
||||
},
|
||||
)
|
||||
expect([...germanPaths].sort()).toEqual([...englishPaths].sort())
|
||||
})
|
||||
|
||||
expect(mismatches).toEqual([])
|
||||
},
|
||||
)
|
||||
it('keeps German interpolation placeholders aligned with English', () => {
|
||||
const mismatches = [...englishValues].flatMap(([path, englishValue]) => {
|
||||
const germanValue = germanValues.get(path)
|
||||
return germanValue !== undefined &&
|
||||
JSON.stringify(collectPlaceholders(germanValue)) !==
|
||||
JSON.stringify(collectPlaceholders(englishValue))
|
||||
? [
|
||||
`${path}: ${collectPlaceholders(englishValue).join(', ')} != ${collectPlaceholders(germanValue).join(', ')}`,
|
||||
]
|
||||
: []
|
||||
})
|
||||
|
||||
it.each(translatedLocaleValues)(
|
||||
'keeps %s numeric source values intact',
|
||||
(_locale, values) => {
|
||||
const mismatches = [...englishValues].flatMap(([path, englishValue]) => {
|
||||
const expected = collectNumberTokens(englishValue)
|
||||
if (!expected.length) return []
|
||||
|
||||
const remaining = collectNumberTokens(values.get(path) ?? '')
|
||||
for (const token of expected) {
|
||||
const index = remaining.indexOf(token)
|
||||
if (index >= 0) remaining.splice(index, 1)
|
||||
else return [`${path}: missing numeric token ${token}`]
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
expect(mismatches).toEqual([])
|
||||
},
|
||||
)
|
||||
expect(mismatches).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps standard app names German and custom game names unchanged', () => {
|
||||
const standardAppNames = {
|
||||
|
||||
+37
-108
@@ -8,11 +8,6 @@ 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 {
|
||||
@@ -38,7 +33,6 @@ export type PasscodeResponseData = {
|
||||
export type PhoneOpenPayload = {
|
||||
account?: DeviceBootstrap['account']
|
||||
device?: PhoneDevice
|
||||
disabledApps?: string[]
|
||||
fallbackLocales?: LocaleTree
|
||||
lang?: string
|
||||
locales?: LocaleTree
|
||||
@@ -828,7 +822,6 @@ const adminPanelFallbackLocales = {
|
||||
messages: 'Messages',
|
||||
calls: 'Calls',
|
||||
moderation: 'Moderation',
|
||||
tones: 'Sounds',
|
||||
audit: 'Audit',
|
||||
configurator: 'Phone configurator',
|
||||
},
|
||||
@@ -852,25 +845,43 @@ 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',
|
||||
},
|
||||
statistics: {
|
||||
eyebrow: 'Live data',
|
||||
title: 'Phone statistics',
|
||||
body: 'Current usage and device status, refreshed every 15 seconds.',
|
||||
today: 'Activity today',
|
||||
todayBody: 'Phone and admin activity since midnight.',
|
||||
messagesToday: 'Messages',
|
||||
callsToday: 'Calls',
|
||||
auditToday: 'Admin actions',
|
||||
coverage: 'Device coverage',
|
||||
coverageBody: 'Current share of all stored phones.',
|
||||
linkedDevices: 'Account linked',
|
||||
simDevices: 'SIM assigned',
|
||||
activeDevices: 'Updated in 24 hours',
|
||||
ofDevices: '{count} of {total} devices',
|
||||
appearance: {
|
||||
eyebrow: 'Appearance',
|
||||
title: 'Interface',
|
||||
body: 'Personalize colors and typography across the admin workspace.',
|
||||
colors: {
|
||||
emerald: 'Emerald',
|
||||
blue: 'Blue',
|
||||
violet: 'Violet',
|
||||
orange: 'Orange',
|
||||
red: 'Red',
|
||||
},
|
||||
controls: {
|
||||
customColor: 'Custom color',
|
||||
customColorBody: 'Choose any RGB accent or enter its channel values.',
|
||||
red: 'R',
|
||||
green: 'G',
|
||||
blue: 'B',
|
||||
hex: 'HEX color',
|
||||
value: 'value',
|
||||
fontFamily: 'Font family',
|
||||
fontFamilyBody: 'Choose the typeface used by the complete admin panel.',
|
||||
fontSize: 'Font size',
|
||||
fontSizeBody: 'Scale text and controls for comfortable readability.',
|
||||
fonts: {
|
||||
inter: 'Inter',
|
||||
system: 'System',
|
||||
classic: 'Classic',
|
||||
verdana: 'Verdana',
|
||||
tahoma: 'Tahoma',
|
||||
trebuchet: 'Trebuchet',
|
||||
georgia: 'Georgia',
|
||||
mono: 'Monospace',
|
||||
},
|
||||
},
|
||||
},
|
||||
configurator: {
|
||||
context: 'Runtime configuration',
|
||||
@@ -893,43 +904,6 @@ 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.',
|
||||
@@ -971,6 +945,7 @@ const adminPanelFallbackLocales = {
|
||||
entry: 'Entry',
|
||||
general: 'General',
|
||||
subtabs: {
|
||||
AdminGroups: 'Admin Groups',
|
||||
Dictionaries: 'Dictionaries',
|
||||
Clips: 'Clips',
|
||||
Transforms: 'Transforms',
|
||||
@@ -1009,12 +984,10 @@ 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',
|
||||
@@ -1201,8 +1174,6 @@ 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: {
|
||||
@@ -1217,12 +1188,6 @@ 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.',
|
||||
@@ -2766,8 +2731,6 @@ const defaultLocales: LocaleTree = {
|
||||
recipient_not_found: 'That number is unavailable.',
|
||||
blocked: 'This contact has blocked calls and messages from your SIM.',
|
||||
messaging_unavailable: 'This company contact does not accept messages.',
|
||||
service_line_text_only:
|
||||
'Company service lines currently accept text messages only.',
|
||||
no_sim: 'This phone has no SIM card.',
|
||||
rate_limited: 'Too many messages. Try again in a minute.',
|
||||
request_failed: 'Messages are temporarily unavailable.',
|
||||
@@ -3081,7 +3044,6 @@ 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',
|
||||
@@ -4580,8 +4542,8 @@ const defaultLocales: LocaleTree = {
|
||||
video: 'Video',
|
||||
microphoneOn: 'Microphone on',
|
||||
microphoneOff: 'Microphone muted',
|
||||
focusHelp: 'Hold the look key to look around',
|
||||
returnHelp: 'Release the look key for controls',
|
||||
focusHelp: 'Space for movement',
|
||||
returnHelp: 'Space to return',
|
||||
uploading: '{count} uploading',
|
||||
saving: 'Saving video...',
|
||||
openGallery: 'Open Photos',
|
||||
@@ -5738,8 +5700,6 @@ 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,
|
||||
@@ -5783,36 +5743,6 @@ 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
|
||||
@@ -5827,7 +5757,6 @@ 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,14 +1,7 @@
|
||||
export type AdminStats = {
|
||||
accounts: number
|
||||
activeDevices: number
|
||||
auditEntries: number
|
||||
auditToday: number
|
||||
callsToday: number
|
||||
devices: number
|
||||
linkedDevices: number
|
||||
messagesToday: number
|
||||
online: number
|
||||
simDevices: number
|
||||
}
|
||||
|
||||
export type AdminPlayerSummary = {
|
||||
@@ -85,7 +78,6 @@ export type AdminAuditEntry = {
|
||||
|
||||
export type AdminBootstrap = {
|
||||
audit: AdminAuditEntry[]
|
||||
disabledApps: string[]
|
||||
players: AdminPlayerSummary[]
|
||||
stats: AdminStats
|
||||
}
|
||||
@@ -138,7 +130,6 @@ export type AdminConfiguratorStructure =
|
||||
template?: AdminConfiguratorStructure
|
||||
}
|
||||
| {
|
||||
entryDefault?: unknown
|
||||
fields: Record<string, AdminConfiguratorStructure>
|
||||
kind: 'table'
|
||||
mutableKeys?: boolean
|
||||
@@ -186,23 +177,3 @@ 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,17 +43,6 @@ describe('SkyNavbar', () => {
|
||||
expect(html).toContain('Account')
|
||||
})
|
||||
|
||||
it('constrains long compact titles to the center column', () => {
|
||||
const titleRule = foundationStyles.match(
|
||||
/\.sky-navbar__title\s*\{([^}]*)\}/s,
|
||||
)?.[1]
|
||||
|
||||
expect(titleRule).toContain('max-width: 100%')
|
||||
expect(titleRule).toContain('overflow: hidden')
|
||||
expect(titleRule).toContain('text-overflow: ellipsis')
|
||||
expect(titleRule).toContain('white-space: nowrap')
|
||||
})
|
||||
|
||||
it('exposes the large-title header without changing heading semantics', async () => {
|
||||
const html = await renderToString(
|
||||
createSSRApp(SkyNavbar, {
|
||||
|
||||
@@ -1265,9 +1265,8 @@ label.sky-list-item__row {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.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__select option {
|
||||
color: var(--sky-text, #000000);
|
||||
}
|
||||
|
||||
.sky-field--floating-label.sky-field--outline:not(
|
||||
|
||||
@@ -164,23 +164,6 @@ 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,7 +168,6 @@
|
||||
|
||||
.sky-navbar__title {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
padding: 0 var(--sky-space-1);
|
||||
overflow: hidden;
|
||||
|
||||
@@ -188,8 +188,6 @@
|
||||
--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);
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
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],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,142 +0,0 @@
|
||||
import type { AdminConfiguratorStructure } from '@/types/admin'
|
||||
|
||||
function cloneConfiguratorValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(cloneConfiguratorValue)
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, child]) => [
|
||||
key,
|
||||
cloneConfiguratorValue(child),
|
||||
]),
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function blankScalar(valueType: 'boolean' | 'number' | 'string'): unknown {
|
||||
if (valueType === 'boolean') return false
|
||||
if (valueType === 'number') return 0
|
||||
return ''
|
||||
}
|
||||
|
||||
export function blankFromConfiguratorStructure(
|
||||
structure: AdminConfiguratorStructure,
|
||||
): unknown {
|
||||
if (structure.kind === 'optionalString') return ''
|
||||
if (structure.kind === 'value') return blankScalar(structure.valueType)
|
||||
if (structure.kind === 'vector') {
|
||||
const axes = ['x', 'y', 'z', 'w'].slice(
|
||||
0,
|
||||
Number(structure.vectorType.slice(-1)),
|
||||
)
|
||||
return Object.fromEntries([
|
||||
['__skyType', structure.vectorType],
|
||||
...axes.map((axis) => [axis, 0]),
|
||||
])
|
||||
}
|
||||
if (structure.kind === 'list') {
|
||||
return structure.items.map(blankFromConfiguratorStructure)
|
||||
}
|
||||
if (structure.kind === 'map') {
|
||||
return {
|
||||
__skyType: 'map',
|
||||
entries: structure.entries.map((entry) => ({
|
||||
key: entry.key,
|
||||
keyType: entry.keyType,
|
||||
value: blankFromConfiguratorStructure(entry.structure),
|
||||
})),
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(structure.fields).map(([key, field]) => [
|
||||
key,
|
||||
blankFromConfiguratorStructure(field),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
export function createMutableTableEntry(
|
||||
structure: Extract<AdminConfiguratorStructure, { kind: 'table' }>,
|
||||
path: string,
|
||||
key: string,
|
||||
entries: Record<string, unknown> = {},
|
||||
): unknown {
|
||||
const value =
|
||||
structure.entryDefault !== undefined
|
||||
? cloneConfiguratorValue(structure.entryDefault)
|
||||
: structure.template
|
||||
? blankFromConfiguratorStructure(structure.template)
|
||||
: undefined
|
||||
|
||||
if (
|
||||
path !== 'Companies.Definitions' ||
|
||||
value === null ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value)
|
||||
) {
|
||||
return value
|
||||
}
|
||||
|
||||
const company = value as Record<string, unknown>
|
||||
company.Job = key
|
||||
company.Name = key
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\b\w/g, (character) => character.toUpperCase())
|
||||
company.LogoUrl = `https://picsum.photos/seed/companies-${key}-logo/180/180`
|
||||
if (Array.isArray(company.Services)) {
|
||||
if (company.Services.length === 0) {
|
||||
company.Services.push({
|
||||
Description: '',
|
||||
Id: key,
|
||||
Price: '',
|
||||
RequestsEnabled: true,
|
||||
Title: company.Name,
|
||||
})
|
||||
} else {
|
||||
const service = company.Services[0]
|
||||
if (
|
||||
service !== null &&
|
||||
typeof service === 'object' &&
|
||||
!Array.isArray(service) &&
|
||||
!(service as Record<string, unknown>).Id
|
||||
) {
|
||||
const mutableService = service as Record<string, unknown>
|
||||
mutableService.Id = key
|
||||
mutableService.Title = company.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
const serviceLine = company.ServiceLine
|
||||
if (
|
||||
serviceLine !== null &&
|
||||
typeof serviceLine === 'object' &&
|
||||
!Array.isArray(serviceLine)
|
||||
) {
|
||||
const mutableServiceLine = serviceLine as Record<string, unknown>
|
||||
const number = String(mutableServiceLine.Number ?? '')
|
||||
const numeric = Number(number)
|
||||
if (/^\d+$/.test(number) && Number.isSafeInteger(numeric) && numeric > 0) {
|
||||
const used = new Set(
|
||||
Object.values(entries).map((entry) => {
|
||||
if (entry === null || typeof entry !== 'object') return ''
|
||||
const line = (entry as Record<string, unknown>).ServiceLine
|
||||
if (line === null || typeof line !== 'object') return ''
|
||||
return String((line as Record<string, unknown>).Number ?? '').replace(
|
||||
/\D/g,
|
||||
'',
|
||||
)
|
||||
}),
|
||||
)
|
||||
const maximum = 10 ** number.length - 1
|
||||
for (let offset = 0; offset < maximum; offset += 1) {
|
||||
const candidate = ((numeric - 1 + offset) % maximum) + 1
|
||||
const formatted = String(candidate).padStart(number.length, '0')
|
||||
if (!used.has(formatted)) {
|
||||
mutableServiceLine.Number = formatted
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return company
|
||||
}
|
||||
@@ -16,7 +16,9 @@ describe('admin configurator descriptions', () => {
|
||||
expect(configuratorDescriptionKey('Media.RequestTimeoutMs', 10000)).toBe(
|
||||
'milliseconds',
|
||||
)
|
||||
expect(configuratorDescriptionKey('Radio.AllowedJobs', [])).toBe('access')
|
||||
expect(configuratorDescriptionKey('AdminPanel.AdminGroups', [])).toBe(
|
||||
'access',
|
||||
)
|
||||
expect(configuratorDescriptionKey('FiveManage.ApiKey', '')).toBe(
|
||||
'credential',
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -41,21 +41,24 @@ describe('App Store preview catalog', () => {
|
||||
expect(visualSignatures.size).toBe(storeAppIds.length)
|
||||
})
|
||||
|
||||
it('localizes every specialized preview in the fallback and every configured locale', () => {
|
||||
const localeDirectory = fileURLToPath(
|
||||
new URL('../../../sky_phone/config/locales/', import.meta.url),
|
||||
)
|
||||
it('localizes every specialized preview in the fallback, English and German locales', () => {
|
||||
const localeSources = [
|
||||
readFileSync(
|
||||
fileURLToPath(new URL('../stores/phone.ts', import.meta.url)),
|
||||
'utf8',
|
||||
),
|
||||
...readdirSync(localeDirectory)
|
||||
.filter((fileName) => fileName.endsWith('.lua'))
|
||||
.sort()
|
||||
.map((fileName) =>
|
||||
readFileSync(`${localeDirectory}/${fileName}`, 'utf8'),
|
||||
readFileSync(
|
||||
fileURLToPath(
|
||||
new URL('../../../sky_phone/config/locales/en.lua', import.meta.url),
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
readFileSync(
|
||||
fileURLToPath(
|
||||
new URL('../../../sky_phone/config/locales/de.lua', import.meta.url),
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
]
|
||||
|
||||
for (const appId of PREVIEWABLE_BUILTIN_APP_IDS) {
|
||||
|
||||
@@ -5,10 +5,10 @@ import type {
|
||||
SkyPhoneAppCapability,
|
||||
} from '@/types/apps'
|
||||
import type { NuiResponse } from '@/utils/nui'
|
||||
import type { BuiltInNotificationSoundId } from '@/utils/preferences'
|
||||
import type { NotificationSoundId } from '@/utils/preferences'
|
||||
|
||||
const STORAGE_KEY_PATTERN = /^[A-Za-z0-9._-]{1,64}$/
|
||||
const NOTIFICATION_SOUNDS: ReadonlySet<BuiltInNotificationSoundId> = new Set([
|
||||
const NOTIFICATION_SOUNDS: ReadonlySet<NotificationSoundId> = 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?: BuiltInNotificationSoundId
|
||||
sound?: NotificationSoundId
|
||||
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 BuiltInNotificationSoundId))
|
||||
? (payload.sound as BuiltInNotificationSoundId | undefined)
|
||||
NOTIFICATION_SOUNDS.has(payload.sound as NotificationSoundId))
|
||||
? (payload.sound as NotificationSoundId | undefined)
|
||||
: null
|
||||
if (sound === null) return null
|
||||
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,240 +0,0 @@
|
||||
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,12 +75,6 @@ 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,7 +2,6 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
normalizePhoneViewportRect,
|
||||
phoneViewportRectContainsPoint,
|
||||
readPhoneViewportGeometry,
|
||||
type PhoneViewportRect,
|
||||
} from '@/utils/phoneViewportGeometry'
|
||||
@@ -64,78 +63,7 @@ function createGeometryFixture(options: {
|
||||
}
|
||||
}
|
||||
|
||||
const PHONE_BASE_ZOOM = 0.69 * 1.2
|
||||
const PHONE_HEIGHT = 844
|
||||
const PHONE_WIDTH = 390
|
||||
const REFERENCE_VIEWPORT_HEIGHT = 1080
|
||||
const REFERENCE_VIEWPORT_WIDTH = 1920
|
||||
|
||||
const displayCases = [
|
||||
{ height: 1080, label: '1080p 16:9', width: 1920 },
|
||||
{ height: 2160, label: '4K 16:9', width: 3840 },
|
||||
{ height: 1440, label: '21:9', width: 3440 },
|
||||
{ height: 1440, label: '32:9', width: 5120 },
|
||||
{ height: 1600, label: '16:10', width: 2560 },
|
||||
] as const
|
||||
|
||||
const phoneScales = [80, 100, 120] as const
|
||||
|
||||
function productionPhoneZoom(
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
phoneScale: number,
|
||||
): number {
|
||||
const viewportScale = Math.min(
|
||||
viewportWidth / REFERENCE_VIEWPORT_WIDTH,
|
||||
viewportHeight / REFERENCE_VIEWPORT_HEIGHT,
|
||||
)
|
||||
const preferred = PHONE_BASE_ZOOM * viewportScale * (phoneScale / 100)
|
||||
const edgeGap = 24 * viewportScale
|
||||
const viewportMaximum = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
(viewportWidth - edgeGap) / PHONE_WIDTH,
|
||||
(viewportHeight - edgeGap) / PHONE_HEIGHT,
|
||||
),
|
||||
)
|
||||
|
||||
return Math.min(viewportMaximum, Math.max(260 / PHONE_WIDTH, preferred))
|
||||
}
|
||||
|
||||
const cefDisplayMatrix = displayCases.flatMap((display) =>
|
||||
phoneScales.map((phoneScale) => ({ display, phoneScale })),
|
||||
)
|
||||
|
||||
describe('phone viewport geometry', () => {
|
||||
it('treats every rectangle boundary as inside and rejects points beyond it', () => {
|
||||
const rect: PhoneViewportRect = {
|
||||
bottom: 260,
|
||||
height: 160,
|
||||
left: 120,
|
||||
right: 360,
|
||||
top: 100,
|
||||
width: 240,
|
||||
}
|
||||
|
||||
expect(phoneViewportRectContainsPoint(rect, 240, 180)).toBe(true)
|
||||
expect(phoneViewportRectContainsPoint(rect, rect.left, rect.top)).toBe(true)
|
||||
expect(phoneViewportRectContainsPoint(rect, rect.right, rect.bottom)).toBe(
|
||||
true,
|
||||
)
|
||||
expect(phoneViewportRectContainsPoint(rect, rect.left - 0.001, 180)).toBe(
|
||||
false,
|
||||
)
|
||||
expect(phoneViewportRectContainsPoint(rect, rect.right + 0.001, 180)).toBe(
|
||||
false,
|
||||
)
|
||||
expect(phoneViewportRectContainsPoint(rect, 240, rect.top - 0.001)).toBe(
|
||||
false,
|
||||
)
|
||||
expect(phoneViewportRectContainsPoint(rect, 240, rect.bottom + 0.001)).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves modern Chrome measurements unchanged when the canvas BCR is already rendered', () => {
|
||||
const wrapper = measuredRect(1573.09, 126.25, 322.92, 698.832)
|
||||
const layer = measuredRect(1589.783336, 177.75, 289.533328, 603.2)
|
||||
@@ -196,76 +124,6 @@ describe('phone viewport geometry', () => {
|
||||
},
|
||||
)
|
||||
|
||||
it.each(cefDisplayMatrix)(
|
||||
'keeps an opened folder panel hit-test exact at $phoneScale% on $display.label',
|
||||
({ display, phoneScale }) => {
|
||||
const viewportScale = Math.min(
|
||||
display.width / REFERENCE_VIEWPORT_WIDTH,
|
||||
display.height / REFERENCE_VIEWPORT_HEIGHT,
|
||||
)
|
||||
const zoom = productionPhoneZoom(
|
||||
display.width,
|
||||
display.height,
|
||||
phoneScale,
|
||||
)
|
||||
const edgeGap = 24 * viewportScale
|
||||
const wrapper = measuredRect(
|
||||
display.width - edgeGap - PHONE_WIDTH * zoom,
|
||||
display.height - edgeGap - PHONE_HEIGHT * zoom,
|
||||
PHONE_WIDTH * zoom,
|
||||
PHONE_HEIGHT * zoom,
|
||||
)
|
||||
// Live CEF 103 can report the zoomed canvas in a different coordinate
|
||||
// space from pointer events. Preserve that mismatch in this fixture.
|
||||
const rawCanvas = measuredRect(
|
||||
display.width + 117.375,
|
||||
83.625,
|
||||
PHONE_WIDTH,
|
||||
PHONE_HEIGHT,
|
||||
)
|
||||
const rawPanel = measuredRect(
|
||||
rawCanvas.left + 23.25,
|
||||
rawCanvas.top + 246.75,
|
||||
343.5,
|
||||
324.25,
|
||||
)
|
||||
const panel = normalizePhoneViewportRect(rawPanel, rawCanvas, wrapper)
|
||||
const expected = {
|
||||
bottom: wrapper.top + (246.75 + 324.25) * zoom,
|
||||
height: 324.25 * zoom,
|
||||
left: wrapper.left + 23.25 * zoom,
|
||||
right: wrapper.left + (23.25 + 343.5) * zoom,
|
||||
top: wrapper.top + 246.75 * zoom,
|
||||
width: 343.5 * zoom,
|
||||
}
|
||||
|
||||
expectRectClose(panel, expected)
|
||||
expect(
|
||||
phoneViewportRectContainsPoint(
|
||||
panel,
|
||||
panel.left + panel.width / 2,
|
||||
panel.top + panel.height / 2,
|
||||
),
|
||||
).toBe(true)
|
||||
expect(phoneViewportRectContainsPoint(panel, panel.left, panel.top)).toBe(
|
||||
true,
|
||||
)
|
||||
expect(
|
||||
phoneViewportRectContainsPoint(panel, panel.right, panel.bottom),
|
||||
).toBe(true)
|
||||
expect(
|
||||
phoneViewportRectContainsPoint(panel, panel.left - 0.25, panel.top),
|
||||
).toBe(false)
|
||||
expect(
|
||||
phoneViewportRectContainsPoint(panel, panel.right + 0.25, panel.bottom),
|
||||
).toBe(false)
|
||||
expect(panel.left).toBeGreaterThanOrEqual(0)
|
||||
expect(panel.right).toBeLessThanOrEqual(display.width)
|
||||
expect(panel.top).toBeGreaterThanOrEqual(0)
|
||||
expect(panel.bottom).toBeLessThanOrEqual(display.height)
|
||||
},
|
||||
)
|
||||
|
||||
it('reads visual scale and normalized element rects from a canvas anchor', () => {
|
||||
const fixture = createGeometryFixture({
|
||||
canvasOffsetHeight: 844,
|
||||
|
||||
@@ -7,14 +7,6 @@ export type PhoneViewportRect = {
|
||||
width: number
|
||||
}
|
||||
|
||||
export function phoneViewportRectContainsPoint(
|
||||
rect: PhoneViewportRect,
|
||||
x: number,
|
||||
y: number,
|
||||
): boolean {
|
||||
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom
|
||||
}
|
||||
|
||||
type RectMeasurement = Pick<
|
||||
DOMRectReadOnly,
|
||||
'height' | 'left' | 'top' | 'width'
|
||||
|
||||
@@ -2,16 +2,11 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { LaunchablePhoneAppId } from '@/types/apps'
|
||||
import {
|
||||
DEFAULT_PHONE_PREFERENCES,
|
||||
PHONE_SCALE_STEP,
|
||||
PHONE_SETUP_LAST_STEP,
|
||||
parsePhonePreferences,
|
||||
WALLPAPER_IDS,
|
||||
} from './preferences'
|
||||
describe('preferences', () => {
|
||||
it('allows one-percent phone scale adjustments', () => {
|
||||
expect(PHONE_SCALE_STEP).toBe(1)
|
||||
})
|
||||
|
||||
it('starts Setup Assistant for a phone without saved settings', () => {
|
||||
const value = parsePhonePreferences(null)
|
||||
|
||||
@@ -123,38 +118,6 @@ 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,9 +1,5 @@
|
||||
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
|
||||
@@ -42,18 +38,14 @@ export const WALLPAPER_IDS = [
|
||||
] as const
|
||||
export const PHONE_SCALE_MIN = 75
|
||||
export const PHONE_SCALE_MAX = 150
|
||||
export const PHONE_SCALE_STEP = 1
|
||||
export const PHONE_SCALE_STEP = 5
|
||||
export const PHONE_SETUP_LAST_STEP = 9
|
||||
|
||||
export type AppearanceMode = (typeof APPEARANCE_MODE_IDS)[number]
|
||||
export type GraphicsMode = (typeof GRAPHICS_MODE_IDS)[number]
|
||||
export type PhoneFrameId = (typeof PHONE_FRAME_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 RingtoneId = (typeof RINGTONE_IDS)[number]
|
||||
export type NotificationSoundId = (typeof NOTIFICATION_SOUND_IDS)[number]
|
||||
export type BuiltInWallpaperId = (typeof WALLPAPER_IDS)[number]
|
||||
export type WallpaperId = BuiltInWallpaperId | 'custom'
|
||||
export type WallpaperTarget = 'home' | 'lock'
|
||||
@@ -204,33 +196,6 @@ 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()
|
||||
@@ -363,7 +328,7 @@ export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 {
|
||||
),
|
||||
lockWallpaper,
|
||||
lockWallpaperImageUrl,
|
||||
notificationSound: readToneChoice(
|
||||
notificationSound: readChoice(
|
||||
settings.notificationSound,
|
||||
NOTIFICATION_SOUND_IDS,
|
||||
defaults.notificationSound,
|
||||
@@ -389,7 +354,7 @@ export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 {
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
),
|
||||
),
|
||||
ringtone: readToneChoice(
|
||||
ringtone: readChoice(
|
||||
settings.ringtone,
|
||||
RINGTONE_IDS,
|
||||
defaults.ringtone,
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ALARM_SOUND_IDS } from './alarms'
|
||||
import {
|
||||
phoneToneDuration,
|
||||
playPhoneMediaTone,
|
||||
playPhoneVibration,
|
||||
} from './tones'
|
||||
import { phoneToneDuration, playPhoneVibration } from './tones'
|
||||
|
||||
describe('phone tones', () => {
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
@@ -31,8 +27,6 @@ describe('phone tones', () => {
|
||||
pause: () => void
|
||||
play: () => Promise<void>
|
||||
preload: string
|
||||
load: () => void
|
||||
removeAttribute: (name: string) => void
|
||||
src: string
|
||||
volume: number
|
||||
}> = []
|
||||
@@ -47,12 +41,6 @@ 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
|
||||
@@ -75,46 +63,4 @@ 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,11 +1,8 @@
|
||||
import {
|
||||
registerPhoneMediaElement,
|
||||
unregisterPhoneMediaElement,
|
||||
} from '@/utils/phoneAudio'
|
||||
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
|
||||
import type { AlarmSoundId } from '@/utils/alarms'
|
||||
import type { BuiltInNotificationSoundId } from '@/utils/preferences'
|
||||
import type { NotificationSoundId } from '@/utils/preferences'
|
||||
|
||||
export type PhoneToneId = AlarmSoundId | BuiltInNotificationSoundId
|
||||
export type PhoneToneId = AlarmSoundId | NotificationSoundId
|
||||
export type PhoneVibrationKind = 'call' | 'notification'
|
||||
|
||||
type ToneVoice = {
|
||||
@@ -495,44 +492,6 @@ 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,
|
||||
@@ -551,12 +510,8 @@ export function playPhoneVibration(
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
player.pause()
|
||||
player.currentTime = 0
|
||||
player.removeAttribute('src')
|
||||
player.load()
|
||||
unregisterPhoneMediaElement(player)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,7 @@ const launchStyle = computed(() => {
|
||||
<div
|
||||
v-if="app && !app.adminOnly"
|
||||
class="app-window"
|
||||
:class="{
|
||||
'app-window--camera-landscape':
|
||||
app.id === 'camera' && phone.cameraLandscape,
|
||||
'app-window--citywarn': app.id === 'citywarn',
|
||||
}"
|
||||
:class="{ 'app-window--citywarn': app.id === 'citywarn' }"
|
||||
:style="launchStyle"
|
||||
>
|
||||
<CustomAppFrame
|
||||
|
||||
@@ -31,22 +31,6 @@ const builtInWallpaperCss = mainCss.slice(
|
||||
mainCss.indexOf('.wallpaper--midnight'),
|
||||
mainCss.indexOf('.wallpaper--custom'),
|
||||
)
|
||||
const folderOverlayBlock =
|
||||
viewSource.match(/<HomeFolderOverlay\b[\s\S]*?\/>/)?.[0] ?? ''
|
||||
|
||||
function handlerName(block: string, event: string): string {
|
||||
return (
|
||||
block.match(new RegExp(`@${event}="([A-Za-z][A-Za-z0-9_]*)"`))?.[1] ?? ''
|
||||
)
|
||||
}
|
||||
|
||||
function functionSource(name: string): string {
|
||||
if (!name) return ''
|
||||
const start = viewSource.indexOf(`function ${name}(`)
|
||||
if (start < 0) return ''
|
||||
const next = viewSource.indexOf('\nfunction ', start + 1)
|
||||
return viewSource.slice(start, next < 0 ? viewSource.length : next)
|
||||
}
|
||||
|
||||
describe('Springboard page swipe contract', () => {
|
||||
it('keeps app and widget labels on the larger shared home typography', () => {
|
||||
@@ -152,7 +136,7 @@ describe('Springboard page swipe contract', () => {
|
||||
)
|
||||
expect(
|
||||
viewSource.match(/:external-drag-visual="homeDragVisualActive"/g),
|
||||
).toHaveLength(5)
|
||||
).toHaveLength(4)
|
||||
expect(viewSource).toContain('updateHomeDragGhost(event)')
|
||||
expect(viewSource).toContain('const dropGhost = homeDragGhost')
|
||||
expect(viewSource).toContain('const dropOrigin = homeDragPreviewBounds')
|
||||
@@ -176,65 +160,6 @@ describe('Springboard page swipe contract', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('owns one external drag session for an app leaving an opened folder', () => {
|
||||
expect(folderOverlayBlock).toContain(
|
||||
':external-drag-visual="homeDragVisualActive"',
|
||||
)
|
||||
|
||||
const startSource = functionSource(
|
||||
handlerName(folderOverlayBlock, 'dragstart'),
|
||||
)
|
||||
const moveSource = functionSource(
|
||||
handlerName(folderOverlayBlock, 'dragmove'),
|
||||
)
|
||||
const cancelSource = functionSource(
|
||||
handlerName(folderOverlayBlock, 'dragcancel'),
|
||||
)
|
||||
|
||||
expect(startSource).toContain('createHomeDragGhost(event)')
|
||||
expect(startSource).toMatch(/\.value\s*=\s*\{[\s\S]*?sourceIndex/)
|
||||
expect(moveSource).toContain('updateHomeDragGhost(event)')
|
||||
expect(moveSource).toContain('folderDraggingOutside.value')
|
||||
expect(moveSource).toContain('resolveHomeEdgeTurn(event)')
|
||||
expect(moveSource).toContain("queueEdgePageTurn(event, 'app')")
|
||||
expect(viewSource).toContain(
|
||||
'(draggingHomeApp.value || draggingFolderApp.value)',
|
||||
)
|
||||
expect(cancelSource).toContain('clearHomeDragGhost()')
|
||||
expect(cancelSource).toContain('clearTemporaryHomePage()')
|
||||
})
|
||||
|
||||
it('settles an extracted folder app from the tracked viewport preview', () => {
|
||||
const extractSource = functionSource('extractOpenedFolderApp')
|
||||
const targetSource = functionSource('folderExtractionDropTarget')
|
||||
|
||||
expect(extractSource).toContain('const dropGhost = homeDragGhost')
|
||||
expect(extractSource).toContain('homeDragPreviewBounds')
|
||||
expect(targetSource).toContain('page > appStore.homeLayout.pageCount')
|
||||
expect(extractSource).not.toContain('sourceElement')
|
||||
expect(extractSource).not.toContain('getBoundingClientRect()')
|
||||
expect(extractSource).toMatch(
|
||||
/animateHomeItemDrop\([\s\S]*?dropOrigin[\s\S]*?\)\.finally\(/,
|
||||
)
|
||||
expect(extractSource).toContain('clearHomeDragGhost(dropGhost)')
|
||||
expect(extractSource).toMatch(
|
||||
/if \(!target\) \{[\s\S]*?clearHomeDragGhost\(\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it('cleans the opened-folder drag preview after an internal move, cancellation, or unmount', () => {
|
||||
const moveSource = functionSource('moveOpenedFolderApp')
|
||||
const cancelSource = functionSource(
|
||||
handlerName(folderOverlayBlock, 'dragcancel'),
|
||||
)
|
||||
|
||||
expect(moveSource).toContain('stopOpenedFolderAppDrag()')
|
||||
expect(cancelSource).toContain('clearHomeDragGhost()')
|
||||
expect(viewSource).toMatch(
|
||||
/onBeforeUnmount\(\(\) => \{[\s\S]*?clearHomeDragGhost\(\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it('cleans up the external drag visual on every terminal path', () => {
|
||||
expect(
|
||||
viewSource.match(
|
||||
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
} from '@/utils/homeLayout'
|
||||
import type { ReorderDirection } from '@/utils/keyboard'
|
||||
import {
|
||||
phoneViewportRectContainsPoint,
|
||||
readPhoneViewportGeometry,
|
||||
type PhoneViewportGeometry,
|
||||
type PhoneViewportRect,
|
||||
@@ -112,11 +111,6 @@ const draggingHomeApp = ref<{
|
||||
area: HomeArea
|
||||
index: number
|
||||
} | null>(null)
|
||||
const draggingFolderApp = ref<{
|
||||
appId: LaunchablePhoneAppId
|
||||
folderId: string
|
||||
sourceIndex: number
|
||||
} | null>(null)
|
||||
const homeDragLayer = ref<HTMLElement | null>(null)
|
||||
const homeDragVisualActive = ref(false)
|
||||
const temporaryHomePage = ref<number | null>(null)
|
||||
@@ -616,11 +610,7 @@ function queueEdgePageTurn(
|
||||
edgePageTimer = undefined
|
||||
edgePageDirection = direction
|
||||
edgePageLocked = dragType === 'widget'
|
||||
if (
|
||||
dragType === 'app' &&
|
||||
lastHomePointer &&
|
||||
(draggingHomeApp.value || draggingFolderApp.value)
|
||||
) {
|
||||
if (dragType === 'app' && lastHomePointer && draggingHomeApp.value) {
|
||||
queueEdgePageTurn(lastHomePointer, 'app')
|
||||
}
|
||||
}, HOME_EDGE_PAGE_TURN_DELAY)
|
||||
@@ -824,7 +814,7 @@ function updateHomeDragGhost(event: {
|
||||
)
|
||||
}
|
||||
|
||||
function createHomeDragGhost(event: PointerEvent): HTMLElement | null {
|
||||
function createHomeDragGhost(event: PointerEvent): void {
|
||||
clearHomeDragGhost()
|
||||
const eventTarget =
|
||||
event.currentTarget instanceof Element
|
||||
@@ -837,7 +827,7 @@ function createHomeDragGhost(event: PointerEvent): HTMLElement | null {
|
||||
const springboard = source?.closest<HTMLElement>('.springboard')
|
||||
const portal = layer?.closest<HTMLElement>('.phone-home-drag-portal')
|
||||
const geometry = readPhoneViewportGeometry(source ?? null)
|
||||
if (!source || !layer || !springboard || !portal || !geometry) return null
|
||||
if (!source || !layer || !springboard || !portal || !geometry) return
|
||||
|
||||
const portalBounds = portal.getBoundingClientRect()
|
||||
const clipBounds = geometry.rect(springboard)
|
||||
@@ -848,7 +838,7 @@ function createHomeDragGhost(event: PointerEvent): HTMLElement | null {
|
||||
sourceBounds.width <= 0 ||
|
||||
sourceBounds.height <= 0
|
||||
) {
|
||||
return null
|
||||
return
|
||||
}
|
||||
|
||||
layer.style.left = `${clipBounds.left - portalBounds.left}px`
|
||||
@@ -875,12 +865,7 @@ function createHomeDragGhost(event: PointerEvent): HTMLElement | null {
|
||||
for (const element of [ghost, ...Array.from(ghost.querySelectorAll('*'))]) {
|
||||
element.removeAttribute('id')
|
||||
for (const attribute of element.getAttributeNames()) {
|
||||
if (
|
||||
attribute.startsWith('data-home-') ||
|
||||
attribute.startsWith('data-folder-')
|
||||
) {
|
||||
element.removeAttribute(attribute)
|
||||
}
|
||||
if (attribute.startsWith('data-home-')) element.removeAttribute(attribute)
|
||||
}
|
||||
}
|
||||
for (const control of ghost.querySelectorAll<HTMLElement>(
|
||||
@@ -909,7 +894,6 @@ function createHomeDragGhost(event: PointerEvent): HTMLElement | null {
|
||||
layer.appendChild(position)
|
||||
homeDragVisualActive.value = true
|
||||
updateHomeDragGhost(event)
|
||||
return position
|
||||
}
|
||||
|
||||
function startHomeDrag(
|
||||
@@ -1206,98 +1190,6 @@ function nearestDockDropTarget(event: {
|
||||
return closestIndex === null ? null : slots[closestIndex]
|
||||
}
|
||||
|
||||
function folderExtractionDropTarget(event: {
|
||||
clientX: number
|
||||
clientY: number
|
||||
}):
|
||||
| { area: 'dock'; index: number }
|
||||
| { area: 'grid'; index: number; page: number }
|
||||
| null {
|
||||
const geometry = homeDragGeometry
|
||||
if (!geometry) return null
|
||||
|
||||
const dock = document.querySelector<HTMLElement>('.app-dock')
|
||||
if (dock) {
|
||||
const dockBounds = homeDragViewportRect(dock, geometry)
|
||||
if (
|
||||
phoneViewportRectContainsPoint(dockBounds, event.clientX, event.clientY)
|
||||
) {
|
||||
const slots = Array.from(
|
||||
dock.querySelectorAll<HTMLElement>(
|
||||
'[data-home-area="dock"][data-home-index]',
|
||||
),
|
||||
).filter((slot) => {
|
||||
const index = Number(slot.dataset.homeIndex)
|
||||
return (
|
||||
Number.isInteger(index) && appStore.homeLayout.dock[index] === null
|
||||
)
|
||||
})
|
||||
const closestIndex = nearestSpringboardRectIndex(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
slots.map((slot) => homeDragViewportRect(slot, geometry)),
|
||||
)
|
||||
if (closestIndex === null) return null
|
||||
const index = Number(slots[closestIndex]?.dataset.homeIndex)
|
||||
return Number.isInteger(index) ? { area: 'dock', index } : null
|
||||
}
|
||||
}
|
||||
|
||||
const page = phone.currentPage
|
||||
if (page < 1 || page > appPages.value.length) return null
|
||||
const grid = document.querySelector<HTMLElement>(
|
||||
`.springboard-page--apps[data-home-page="${page}"] .app-grid`,
|
||||
)
|
||||
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
|
||||
const springboard = grid?.closest<HTMLElement>('.springboard')
|
||||
if (!grid || !pageElement || !springboard) return null
|
||||
|
||||
const pageBounds = homeDragViewportRect(pageElement, geometry)
|
||||
const springboardBounds = homeDragViewportRect(springboard, geometry)
|
||||
const rawGridBounds = homeDragViewportRect(grid, geometry)
|
||||
const gridBounds = viewportRectAt(
|
||||
springboardBounds.left + (rawGridBounds.left - pageBounds.left),
|
||||
springboardBounds.top + (rawGridBounds.top - pageBounds.top),
|
||||
rawGridBounds.width,
|
||||
rawGridBounds.height,
|
||||
)
|
||||
if (
|
||||
!phoneViewportRectContainsPoint(gridBounds, event.clientX, event.clientY)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const slots = Array.from(
|
||||
grid.querySelectorAll<HTMLElement>(
|
||||
'[data-home-area="grid"][data-home-index]',
|
||||
),
|
||||
).filter((slot) => {
|
||||
const index = Number(slot.dataset.homeIndex)
|
||||
const item = appStore.homeLayout.grid[index]
|
||||
return (
|
||||
Number.isInteger(index) &&
|
||||
(item === null ||
|
||||
(item === undefined && page > appStore.homeLayout.pageCount))
|
||||
)
|
||||
})
|
||||
const closestIndex = nearestSpringboardRectIndex(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
slots.map((slot) => {
|
||||
const bounds = homeDragViewportRect(slot, geometry)
|
||||
return {
|
||||
height: bounds.height,
|
||||
left: springboardBounds.left + (bounds.left - pageBounds.left),
|
||||
top: springboardBounds.top + (bounds.top - pageBounds.top),
|
||||
width: bounds.width,
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (closestIndex === null) return null
|
||||
const index = Number(slots[closestIndex]?.dataset.homeIndex)
|
||||
return Number.isInteger(index) ? { area: 'grid', index, page } : null
|
||||
}
|
||||
|
||||
function finishHomeDrag(event: PointerEvent): void {
|
||||
clearEdgePageTurn()
|
||||
clearFolderHover()
|
||||
@@ -1432,83 +1324,16 @@ function folderPreviewApps(folder: HomeFolder): PhoneAppDefinition[] {
|
||||
return apps
|
||||
}
|
||||
|
||||
function currentOpenedFolderDragSession(sourceIndex?: number) {
|
||||
const session = draggingFolderApp.value
|
||||
const folder = openedFolder.value
|
||||
if (
|
||||
!session ||
|
||||
!folder ||
|
||||
folder.id !== session.folderId ||
|
||||
(sourceIndex !== undefined && sourceIndex !== session.sourceIndex) ||
|
||||
folder.apps[session.sourceIndex] !== session.appId
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
function startOpenedFolderAppDrag(
|
||||
sourceIndex: number,
|
||||
event: PointerEvent,
|
||||
): void {
|
||||
stopOpenedFolderAppDrag()
|
||||
const folder = openedFolder.value
|
||||
if (
|
||||
!folder ||
|
||||
!Number.isInteger(sourceIndex) ||
|
||||
sourceIndex < 0 ||
|
||||
sourceIndex >= folder.apps.length
|
||||
) {
|
||||
return
|
||||
}
|
||||
const appId = folder.apps[sourceIndex]
|
||||
if (!appId || !installedAppsById.value.has(appId)) return
|
||||
if (!createHomeDragGhost(event)) {
|
||||
clearHomeDragGhost()
|
||||
return
|
||||
}
|
||||
draggingFolderApp.value = { appId, folderId: folder.id, sourceIndex }
|
||||
lastHomePointer = { clientX: event.clientX, clientY: event.clientY }
|
||||
}
|
||||
|
||||
function moveOpenedFolderAppDrag(event: PointerEvent): void {
|
||||
if (!currentOpenedFolderDragSession()) {
|
||||
stopOpenedFolderAppDrag()
|
||||
return
|
||||
}
|
||||
lastHomePointer = { clientX: event.clientX, clientY: event.clientY }
|
||||
updateHomeDragGhost(event)
|
||||
if (folderDraggingOutside.value && resolveHomeEdgeTurn(event)) {
|
||||
queueEdgePageTurn(event, 'app')
|
||||
return
|
||||
}
|
||||
clearEdgePageTurn()
|
||||
}
|
||||
|
||||
function stopOpenedFolderAppDrag(): void {
|
||||
clearEdgePageTurn()
|
||||
draggingFolderApp.value = null
|
||||
lastHomePointer = null
|
||||
clearHomeDragGhost()
|
||||
clearTemporaryHomePage()
|
||||
}
|
||||
|
||||
function resetOpenedFolderState(): void {
|
||||
folderDraggingOutside.value = false
|
||||
openedFolderId.value = null
|
||||
renameFolderOnOpenId.value = null
|
||||
}
|
||||
|
||||
function openFolder(folderId: string): void {
|
||||
stopOpenedFolderAppDrag()
|
||||
pageTransitioning.value = false
|
||||
folderDraggingOutside.value = false
|
||||
openedFolderId.value = folderId
|
||||
}
|
||||
|
||||
function closeFolder(): void {
|
||||
stopOpenedFolderAppDrag()
|
||||
resetOpenedFolderState()
|
||||
folderDraggingOutside.value = false
|
||||
openedFolderId.value = null
|
||||
renameFolderOnOpenId.value = null
|
||||
}
|
||||
|
||||
function renameOpenedFolder(name: string): void {
|
||||
@@ -1517,68 +1342,71 @@ function renameOpenedFolder(name: string): void {
|
||||
}
|
||||
|
||||
function moveOpenedFolderApp(sourceIndex: number, targetIndex: number): void {
|
||||
const session = currentOpenedFolderDragSession(sourceIndex)
|
||||
stopOpenedFolderAppDrag()
|
||||
if (!session) return
|
||||
appStore.moveHomeFolderApp(session.folderId, sourceIndex, targetIndex)
|
||||
if (!openedFolderId.value) return
|
||||
appStore.moveHomeFolderApp(openedFolderId.value, sourceIndex, targetIndex)
|
||||
}
|
||||
|
||||
function extractOpenedFolderApp(
|
||||
sourceIndex: number,
|
||||
event: PointerEvent,
|
||||
): void {
|
||||
const session = currentOpenedFolderDragSession(sourceIndex)
|
||||
if (!session) {
|
||||
closeFolder()
|
||||
return
|
||||
}
|
||||
updateHomeDragGhost(event)
|
||||
const dropGhost = homeDragGhost
|
||||
const dropOrigin = homeDragPreviewBounds ? { ...homeDragPreviewBounds } : null
|
||||
if (!dropGhost || !dropOrigin) {
|
||||
closeFolder()
|
||||
return
|
||||
}
|
||||
const target = folderExtractionDropTarget(event)
|
||||
const folder = openedFolder.value
|
||||
if (!folder) return
|
||||
const appId = folder.apps[sourceIndex]
|
||||
const sourceElement = document
|
||||
.querySelector<HTMLElement>(
|
||||
`.home-folder-panel [data-folder-app-index="${sourceIndex}"]`,
|
||||
)
|
||||
?.closest<HTMLElement>('.app-icon-item')
|
||||
const geometry = readPhoneViewportGeometry(sourceElement ?? null)
|
||||
const sourceBounds = sourceElement
|
||||
? homeDragViewportRect(sourceElement, geometry)
|
||||
: null
|
||||
const candidates = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-home-index][data-home-area]'),
|
||||
).filter((element) => {
|
||||
const area = element.dataset.homeArea as HomeArea
|
||||
const index = Number(element.dataset.homeIndex)
|
||||
return (
|
||||
(area === 'grid' || area === 'dock') &&
|
||||
Number.isInteger(index) &&
|
||||
appStore.homeLayout[area][index] === null
|
||||
)
|
||||
})
|
||||
const target = candidates.reduce<HTMLElement | null>((closest, candidate) => {
|
||||
if (!closest) return candidate
|
||||
const bounds = homeDragViewportRect(candidate, geometry)
|
||||
const closestBounds = homeDragViewportRect(closest, geometry)
|
||||
const distance = Math.hypot(
|
||||
event.clientX - (bounds.left + bounds.width / 2),
|
||||
event.clientY - (bounds.top + bounds.height / 2),
|
||||
)
|
||||
const closestDistance = Math.hypot(
|
||||
event.clientX - (closestBounds.left + closestBounds.width / 2),
|
||||
event.clientY - (closestBounds.top + closestBounds.height / 2),
|
||||
)
|
||||
return distance < closestDistance ? candidate : closest
|
||||
}, null)
|
||||
if (!target) {
|
||||
clearEdgePageTurn()
|
||||
draggingFolderApp.value = null
|
||||
lastHomePointer = null
|
||||
clearHomeDragGhost()
|
||||
clearTemporaryHomePage()
|
||||
resetOpenedFolderState()
|
||||
closeFolder()
|
||||
return
|
||||
}
|
||||
|
||||
const { area, index: targetIndex } = target
|
||||
const area = target.dataset.homeArea as HomeArea
|
||||
const targetIndex = Number(target.dataset.homeIndex)
|
||||
if (
|
||||
!appStore.extractHomeFolderApp(
|
||||
session.folderId,
|
||||
session.sourceIndex,
|
||||
area,
|
||||
targetIndex,
|
||||
)
|
||||
!appStore.extractHomeFolderApp(folder.id, sourceIndex, area, targetIndex)
|
||||
) {
|
||||
closeFolder()
|
||||
return
|
||||
}
|
||||
|
||||
clearEdgePageTurn()
|
||||
draggingFolderApp.value = null
|
||||
lastHomePointer = null
|
||||
clearTemporaryHomePage(area === 'grid')
|
||||
resetOpenedFolderState()
|
||||
const extractedIndex = findHomeItemIndex(session.appId, area, targetIndex)
|
||||
if (extractedIndex === null) {
|
||||
clearHomeDragGhost(dropGhost)
|
||||
return
|
||||
closeFolder()
|
||||
if (sourceBounds) {
|
||||
const extractedIndex = findHomeItemIndex(appId, area, targetIndex)
|
||||
if (extractedIndex !== null) {
|
||||
void animateHomeItemDrop(appId, area, extractedIndex, sourceBounds)
|
||||
}
|
||||
}
|
||||
void animateHomeItemDrop(
|
||||
session.appId,
|
||||
area,
|
||||
extractedIndex,
|
||||
dropOrigin,
|
||||
).finally(() => clearHomeDragGhost(dropGhost))
|
||||
}
|
||||
|
||||
function clearSearch(): void {
|
||||
@@ -1598,12 +1426,11 @@ watch(isEditablePage, (visible) => {
|
||||
watch(editMode, (editing) => {
|
||||
emit('editModeChange', editing)
|
||||
if (editing) return
|
||||
stopOpenedFolderAppDrag()
|
||||
stopHomeDrag()
|
||||
stopWidgetDrag()
|
||||
})
|
||||
watch(openedFolder, (folder) => {
|
||||
if (!folder && openedFolderId.value !== null) closeFolder()
|
||||
if (!folder) closeFolder()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
emit('editModeChange', false)
|
||||
@@ -1618,7 +1445,6 @@ onBeforeUnmount(() => {
|
||||
clearHomeDragGhost()
|
||||
temporaryHomePage.value = null
|
||||
draggingHomeApp.value = null
|
||||
draggingFolderApp.value = null
|
||||
draggingWidgetId.value = null
|
||||
})
|
||||
</script>
|
||||
@@ -2032,13 +1858,9 @@ onBeforeUnmount(() => {
|
||||
v-if="openedFolder"
|
||||
:apps="openedFolderApps"
|
||||
:edit-mode="editMode"
|
||||
:external-drag-visual="homeDragVisualActive"
|
||||
:folder="openedFolder"
|
||||
:rename-on-open="renameFolderOnOpenId === openedFolder.id"
|
||||
@close="closeFolder"
|
||||
@dragcancel="stopOpenedFolderAppDrag"
|
||||
@dragmove="moveOpenedFolderAppDrag"
|
||||
@dragstart="startOpenedFolderAppDrag"
|
||||
@drag-outside-change="folderDraggingOutside = $event"
|
||||
@edit="enterEditMode"
|
||||
@extract="extractOpenedFolderApp"
|
||||
|
||||
@@ -81,7 +81,6 @@ describe('AppStoreApp Sky navigation contract', () => {
|
||||
expect(source).toContain('@click="profileOpened = true"')
|
||||
expect(source).toContain('const installedApps = computed')
|
||||
expect(source).toContain('return !appStore.isInstalled(app.id)')
|
||||
expect(source).toContain('if (!appStore.isAvailable(app.id)) return false')
|
||||
expect(source).toContain(
|
||||
'class="store-account__apps phone-effect--expensive-shadow"',
|
||||
)
|
||||
@@ -101,10 +100,6 @@ 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,7 +63,6 @@ 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)
|
||||
@@ -123,7 +122,6 @@ const catalog = computed(() =>
|
||||
if (!isLaunchablePhoneApp(app) || app.id === 'app-store' || app.adminOnly) {
|
||||
return false
|
||||
}
|
||||
if (!appStore.isAvailable(app.id)) return false
|
||||
|
||||
return !appStore.isInstalled(app.id)
|
||||
}).sort((a, b) => a.gridOrder - b.gridOrder),
|
||||
@@ -131,9 +129,7 @@ const catalog = computed(() =>
|
||||
const installedApps = computed(() =>
|
||||
PHONE_APPS.filter(
|
||||
(app): app is LaunchablePhoneAppDefinition =>
|
||||
isLaunchablePhoneApp(app) &&
|
||||
appStore.isAvailable(app.id) &&
|
||||
appStore.isInstalled(app.id),
|
||||
isLaunchablePhoneApp(app) && appStore.isInstalled(app.id),
|
||||
).sort((a, b) => a.gridOrder - b.gridOrder),
|
||||
)
|
||||
const installedGameCount = computed(
|
||||
@@ -147,7 +143,6 @@ const dailyCandidates = computed(() =>
|
||||
!app.adminOnly &&
|
||||
!isExternalPhoneApp(app) &&
|
||||
app.id !== 'app-store' &&
|
||||
appStore.isAvailable(app.id) &&
|
||||
!DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) &&
|
||||
!appStore.isInstalled(app.id),
|
||||
),
|
||||
@@ -289,16 +284,6 @@ 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
|
||||
@@ -329,11 +314,8 @@ function endProfileDrag(event: PointerEvent): void {
|
||||
|
||||
function confirmUninstall(): void {
|
||||
if (!uninstallCandidate.value) return
|
||||
if (!appStore.uninstallApp(uninstallCandidate.value.id)) {
|
||||
uninstallError.value = phone.t('Apps.appStore.account.uninstallFailed')
|
||||
return
|
||||
}
|
||||
closeUninstallDialog()
|
||||
appStore.uninstallApp(uninstallCandidate.value.id)
|
||||
uninstallCandidate.value = null
|
||||
}
|
||||
|
||||
function highlightStyle(index: number): Record<string, string> {
|
||||
@@ -1121,7 +1103,7 @@ watch(
|
||||
app: getPhoneAppLabel(app, phone.t),
|
||||
})
|
||||
"
|
||||
@click.stop="requestUninstall(app)"
|
||||
@click="uninstallCandidate = app"
|
||||
>
|
||||
<Trash2 :size="17" :stroke-width="2" aria-hidden="true" />
|
||||
</button>
|
||||
@@ -1133,8 +1115,8 @@ watch(
|
||||
<SkyDialog
|
||||
:opened="Boolean(uninstallCandidate)"
|
||||
role="alertdialog"
|
||||
@backdropclick="closeUninstallDialog"
|
||||
@escape="closeUninstallDialog"
|
||||
@backdropclick="uninstallCandidate = null"
|
||||
@escape="uninstallCandidate = null"
|
||||
>
|
||||
<template #title>
|
||||
{{ phone.t('Apps.appStore.account.uninstallTitle') }}
|
||||
@@ -1146,15 +1128,8 @@ watch(
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<p
|
||||
v-if="uninstallError"
|
||||
class="store-account__uninstall-error"
|
||||
role="alert"
|
||||
>
|
||||
{{ uninstallError }}
|
||||
</p>
|
||||
<template #buttons>
|
||||
<SkyDialogButton @click="closeUninstallDialog">
|
||||
<SkyDialogButton @click="uninstallCandidate = null">
|
||||
{{ phone.t('Common.cancel') }}
|
||||
</SkyDialogButton>
|
||||
<SkyDialogButton strong @click="confirmUninstall">
|
||||
@@ -1497,11 +1472,6 @@ 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,15 +6,6 @@ const cameraView = readFileSync(
|
||||
new URL('./CameraApp.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const appShell = readFileSync(new URL('../../App.vue', import.meta.url), 'utf8')
|
||||
const appWindow = readFileSync(
|
||||
new URL('../PhoneAppWindow.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const shellStyles = readFileSync(
|
||||
new URL('../../assets/main.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const mediaCapture = readFileSync(
|
||||
new URL('../../components/PhoneMediaCapture.vue', import.meta.url),
|
||||
'utf8',
|
||||
@@ -56,99 +47,6 @@ describe('Camera app controls', () => {
|
||||
expect(cameraView).not.toContain('k-navbar')
|
||||
})
|
||||
|
||||
it('stays dark when the global phone appearance is light', () => {
|
||||
const cameraPageTag = cameraView.match(/<sky-app-page\b[^>]*>/s)?.[0]
|
||||
|
||||
expect(cameraPageTag).toBeDefined()
|
||||
expect(cameraPageTag).toMatch(/\bdark\b/)
|
||||
expect(cameraView).toMatch(
|
||||
/\.camera-page\s*\{[^}]*--sky-bg:\s*#000;[^}]*--sky-text:\s*#fff;[^}]*background:\s*var\(--sky-bg\);[^}]*color:\s*var\(--sky-text\);/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('fills the translucent landscape shell without letterbox gaps', () => {
|
||||
const landscapeViewport = cameraView.match(
|
||||
/\.camera-page--landscape \.camera-viewport\s*\{([^}]*)\}/s,
|
||||
)?.[1]
|
||||
|
||||
expect(cameraView).toMatch(
|
||||
/\.camera-page--landscape\s*\{[^}]*--sky-bg:\s*rgba\(0, 0, 0, 0\.42\);/s,
|
||||
)
|
||||
expect(landscapeViewport).toBeDefined()
|
||||
expect(landscapeViewport).toContain('top: 50%')
|
||||
expect(landscapeViewport).toContain(
|
||||
'width: calc(100% * var(--phone-screen-portrait-ratio))',
|
||||
)
|
||||
expect(landscapeViewport).toContain(
|
||||
'aspect-ratio: var(--phone-screen-portrait-ratio)',
|
||||
)
|
||||
expect(landscapeViewport).not.toContain('16 / 9')
|
||||
expect(cameraView).toMatch(
|
||||
/\.camera-page--landscape \.camera-shade\s*\{[^}]*linear-gradient\([^)]*90deg,[^)]*rgba\(0, 0, 0, 0\.42\) 0 18%,[^)]*transparent 18% 75%,[^)]*rgba\(0, 0, 0, 0\.42\) 75% 100%/s,
|
||||
)
|
||||
expect(appShell).toMatch(
|
||||
/'phone-screen--camera-landscape':\s*activeAppId === 'camera' && phone\.cameraLandscape/,
|
||||
)
|
||||
expect(appWindow).toMatch(
|
||||
/'app-window--camera-landscape':\s*app\.id === 'camera' && phone\.cameraLandscape/,
|
||||
)
|
||||
expect(shellStyles).toMatch(
|
||||
/\.phone-screen\s*\{[^}]*--phone-screen-portrait-ratio:\s*2\.30951;/s,
|
||||
)
|
||||
expect(shellStyles).toMatch(
|
||||
/\.phone-screen--camera-landscape\s*\{[^}]*background:\s*transparent;/s,
|
||||
)
|
||||
expect(shellStyles).toMatch(
|
||||
/\.phone-screen--camera-landscape \.springboard\s*\{[^}]*visibility:\s*hidden;/s,
|
||||
)
|
||||
expect(shellStyles).toMatch(
|
||||
/\.app-window--camera-landscape\s*\{[^}]*background:\s*transparent;/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('renders camera notices as plain text without a glass pill', () => {
|
||||
const noticeMarkup = cameraView.slice(
|
||||
cameraView.indexOf('<span v-if="noticeText"'),
|
||||
cameraView.indexOf('<span v-else-if="pendingCount"'),
|
||||
)
|
||||
const noticeStyles = cameraView.match(
|
||||
/\.camera-notice-text\s*,[\s\S]*?\{([^}]*)\}/,
|
||||
)?.[1]
|
||||
|
||||
expect(noticeMarkup).toContain('class="camera-notice-text"')
|
||||
expect(noticeMarkup).not.toContain('camera-focus-pill')
|
||||
expect(noticeStyles).toBeDefined()
|
||||
expect(noticeStyles).toContain('color: #ffd60a')
|
||||
expect(noticeStyles).not.toMatch(
|
||||
/(?:background|backdrop-filter|border-radius|padding)\s*:/,
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the current compact lens selector treatment', () => {
|
||||
const zoomControl = cameraView.slice(
|
||||
cameraView.indexOf('<div class="camera-zoom-control">'),
|
||||
cameraView.indexOf('<footer class="camera-controls">'),
|
||||
)
|
||||
|
||||
expect(zoomControl).toContain('variant="plain"')
|
||||
expect(zoomControl).not.toContain('glass')
|
||||
expect(zoomControl).toContain('{{ zoomPresetLabel(zoom) }}')
|
||||
expect(cameraView).toContain("return zoom === 0.5 ? '.5' : `${zoom}`")
|
||||
expect(cameraView).toMatch(
|
||||
/\.camera-zoom-control\s*\{[^}]*bottom:\s*216px;/s,
|
||||
)
|
||||
expect(cameraView).toMatch(/\.camera-zoom-row\s*\{[^}]*gap:\s*10px;/s)
|
||||
expect(cameraView).toMatch(
|
||||
/\.camera-zoom-pill\s*\{[^}]*width:\s*36px;[^}]*min-width:\s*36px;[^}]*height:\s*36px;[^}]*min-height:\s*36px;[^}]*padding:\s*0;/s,
|
||||
)
|
||||
expect(cameraView).toMatch(
|
||||
/\.camera-zoom-pill::before\s*\{[^}]*width:\s*var\(--sky-touch-target, 44px\);[^}]*inset-block:\s*-4px;/s,
|
||||
)
|
||||
expect(cameraView).toMatch(
|
||||
/\.camera-zoom-pill\.active\s*\{[^}]*color:\s*#ffd60a;[^}]*background:\s*rgba\(28, 28, 30, 0\.78\);/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps continuous wheel zoom without an extra slider bar', () => {
|
||||
expect(cameraView).not.toContain('camera-zoom-slider')
|
||||
expect(cameraView).not.toContain('type="range"')
|
||||
@@ -179,8 +77,7 @@ describe('Camera app controls', () => {
|
||||
it('locks look controls without changing the global gameplay camera', () => {
|
||||
expect(cameraView).toContain("nuiCall('camera:setLocked'")
|
||||
expect(cameraView).toContain('cameraLocked.value')
|
||||
expect(cameraView).toContain('Apps.camera.lookKey')
|
||||
expect(cameraView).not.toContain('Apps.camera.spaceKey')
|
||||
expect(cameraView).toContain('Apps.camera.spaceKey')
|
||||
expect(cameraClient).toContain('RegisterNUICallback("camera:setLocked"')
|
||||
expect(cameraClient).toContain('INPUT_LOOK_LR')
|
||||
expect(cameraClient).toContain('INPUT_LOOK_UD')
|
||||
@@ -198,26 +95,10 @@ describe('Camera app controls', () => {
|
||||
expect(cameraClient).not.toContain('ensure_ultrawide_camera')
|
||||
})
|
||||
|
||||
it('uses the configured HoldToLook control and Space in camera modes', () => {
|
||||
expect(cameraView).not.toContain("event.code !== 'Space'")
|
||||
expect(cameraView).not.toContain("window.addEventListener('keydown'")
|
||||
expect(focusClient).toContain(
|
||||
'function SkyPhoneFocus.IsHoldToLookPressed()',
|
||||
it('keeps the selfie camera stable while Space still allows movement', () => {
|
||||
expect(cameraView).toMatch(
|
||||
/event\.code !== 'Space'[\s\S]*cameraLocked\.value/,
|
||||
)
|
||||
expect(focusClient).toContain(
|
||||
'IsDisabledControlPressed(0, hold_to_look_control)',
|
||||
)
|
||||
expect(cameraClient).toContain('SkyPhoneFocus.IsHoldToLookPressed()')
|
||||
expect(cameraClient).toContain(
|
||||
'IsDisabledControlPressed(0, camera_passthrough_control)',
|
||||
)
|
||||
expect(cameraClient).toMatch(
|
||||
/if data\.active then\s+watch_camera_controls\(\)/,
|
||||
)
|
||||
expect(cameraClient).not.toContain('IsDisabledControlJustReleased(0, 22)')
|
||||
})
|
||||
|
||||
it('orbits the stable selfie camera while HoldToLook allows movement', () => {
|
||||
expect(cameraClient).toContain(
|
||||
'if camera_state.locked or camera_state.front_camera then',
|
||||
)
|
||||
@@ -225,22 +106,14 @@ describe('Camera app controls', () => {
|
||||
expect(cameraClient).toContain('local front_camera_view_mode = 0')
|
||||
expect(cameraClient).toContain('local front_camera_fov = 32.0')
|
||||
expect(cameraClient).toContain('local front_camera_distance = 1.05')
|
||||
expect(cameraClient).toContain('local front_camera_horizontal_limit = 75.0')
|
||||
expect(cameraClient).toContain('local front_camera_vertical_limit = 35.0')
|
||||
expect(cameraClient).toContain('local head_position = GetPedBoneCoords')
|
||||
expect(cameraClient).toContain('GetDisabledControlNormal(0, 1)')
|
||||
expect(cameraClient).toContain('GetDisabledControlNormal(0, 2)')
|
||||
expect(cameraClient).toContain('update_front_camera_orbit()')
|
||||
expect(cameraClient).toContain('camera_state.front_camera_yaw')
|
||||
expect(cameraClient).toContain('camera_state.front_camera_pitch')
|
||||
expect(cameraClient).toContain(
|
||||
'local dot = (to_camera.x * forward_vector.x)',
|
||||
)
|
||||
expect(cameraClient).toContain('front_camera_target_height')
|
||||
expect(focusClient).toContain(
|
||||
'return { block_game = false, block_look = false, cursor = false, focused = true, game_input = true, keep_input = true }',
|
||||
)
|
||||
expect(focusClient).toContain(
|
||||
'return { block_game = true, block_look = true, cursor = true, focused = true, game_input = false, keep_input = true }',
|
||||
)
|
||||
expect(focusClient).toContain('gameInput = focus.game_input')
|
||||
expect(cameraClient).toContain('SetCamCoord(')
|
||||
expect(cameraClient).toContain('PointCamAtCoord(')
|
||||
expect(cameraClient).not.toContain('SetCamRot(')
|
||||
@@ -248,6 +121,10 @@ describe('Camera app controls', () => {
|
||||
expect(cameraClient).not.toContain('front_camera_position')
|
||||
expect(cameraClient).not.toContain('AttachCamToEntity(')
|
||||
expect(cameraClient).not.toContain('PointCamAtEntity(')
|
||||
expect(cameraView).toContain("window.addEventListener('keyup', onKeyup)")
|
||||
expect(cameraView).toContain(
|
||||
"nuiCall('camera:setFocus', { focused: true })",
|
||||
)
|
||||
})
|
||||
|
||||
it('uses a looping camera-hold pose instead of the old selfie dance', () => {
|
||||
|
||||
@@ -56,6 +56,7 @@ const microphoneEnabled = ref(true)
|
||||
const frontCamera = ref(false)
|
||||
const shutterActive = ref(false)
|
||||
const cameraLocked = ref(false)
|
||||
const movementEnabled = ref(false)
|
||||
const recording = ref(false)
|
||||
const savingVideo = ref(false)
|
||||
const recordingStartedAt = ref(0)
|
||||
@@ -77,16 +78,6 @@ const pendingCount = computed(
|
||||
() =>
|
||||
captures.value.filter((capture) => capture.status === 'uploading').length,
|
||||
)
|
||||
|
||||
function zoomPresetIsActive(zoom: (typeof zoomLevels)[number]): boolean {
|
||||
return Math.abs(selectedZoom.value - zoom) < 0.03
|
||||
}
|
||||
|
||||
function zoomPresetLabel(zoom: (typeof zoomLevels)[number]): string {
|
||||
if (zoomPresetIsActive(zoom)) return `${zoom}x`
|
||||
return zoom === 0.5 ? '.5' : `${zoom}`
|
||||
}
|
||||
|
||||
function correlationId(): string {
|
||||
return `${Date.now()}-${crypto.randomUUID()}`
|
||||
}
|
||||
@@ -247,6 +238,10 @@ async function toggleFacing(): Promise<void> {
|
||||
|
||||
async function toggleCameraLock(): Promise<void> {
|
||||
cameraLocked.value = !cameraLocked.value
|
||||
if (cameraLocked.value && movementEnabled.value) {
|
||||
movementEnabled.value = false
|
||||
await nuiCall('camera:setFocus', { focused: true })
|
||||
}
|
||||
await nuiCall('camera:setLocked', { locked: cameraLocked.value })
|
||||
}
|
||||
|
||||
@@ -345,6 +340,26 @@ function updateRecordingTimer(): void {
|
||||
elapsed.value = formatRecordingDuration(Date.now() - recordingStartedAt.value)
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (
|
||||
event.code !== 'Space' ||
|
||||
event.repeat ||
|
||||
cameraLocked.value ||
|
||||
movementEnabled.value
|
||||
)
|
||||
return
|
||||
event.preventDefault()
|
||||
movementEnabled.value = true
|
||||
void nuiCall('camera:setFocus', { focused: false })
|
||||
}
|
||||
|
||||
function onKeyup(event: KeyboardEvent): void {
|
||||
if (event.code !== 'Space' || !movementEnabled.value) return
|
||||
event.preventDefault()
|
||||
movementEnabled.value = false
|
||||
void nuiCall('camera:setFocus', { focused: true })
|
||||
}
|
||||
|
||||
function onMessage(event: MessageEvent): void {
|
||||
if (!isTrustedRootMessageSource(event.source, window)) return
|
||||
const message = event.data as {
|
||||
@@ -427,6 +442,8 @@ onMounted(() => {
|
||||
{ data: { zoom: selectedZoom.value }, type: 'camera:zoom' },
|
||||
'*',
|
||||
)
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
window.addEventListener('keyup', onKeyup)
|
||||
window.addEventListener('message', onMessage)
|
||||
void nuiCall('camera:setActive', { active: true })
|
||||
void nuiCall<MediaConfig>('media:config').then((response) => {
|
||||
@@ -442,7 +459,13 @@ onBeforeUnmount(() => {
|
||||
if (shutterTimer !== undefined) window.clearTimeout(shutterTimer)
|
||||
if (noticeTimer !== undefined) window.clearTimeout(noticeTimer)
|
||||
if (recordingTimer !== undefined) window.clearInterval(recordingTimer)
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
window.removeEventListener('keyup', onKeyup)
|
||||
window.removeEventListener('message', onMessage)
|
||||
if (movementEnabled.value) {
|
||||
movementEnabled.value = false
|
||||
void nuiCall('camera:setFocus', { focused: true })
|
||||
}
|
||||
if (renderFrameId !== undefined) window.cancelAnimationFrame(renderFrameId)
|
||||
resizeObserver?.disconnect()
|
||||
gameView?.dispose()
|
||||
@@ -463,7 +486,6 @@ onBeforeUnmount(() => {
|
||||
class="camera-page"
|
||||
:class="{ 'camera-page--landscape': phone.cameraLandscape }"
|
||||
:aria-label="phone.t('Apps.camera.name')"
|
||||
dark
|
||||
>
|
||||
<div class="camera-viewport" @wheel.prevent.stop="zoomWithWheel">
|
||||
<canvas
|
||||
@@ -537,7 +559,10 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</sky-fab>
|
||||
</div>
|
||||
<span v-if="noticeText" class="camera-notice-text">
|
||||
<span
|
||||
v-if="noticeText"
|
||||
class="camera-focus-pill camera-focus-pill--notice"
|
||||
>
|
||||
{{ noticeText }}
|
||||
</span>
|
||||
<span v-else-if="pendingCount" class="camera-upload-pill">
|
||||
@@ -562,7 +587,7 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<LockKeyhole v-if="cameraLocked" :size="12" />
|
||||
<LockOpen v-else :size="12" />
|
||||
<kbd>{{ phone.t('Apps.camera.lookKey') }}</kbd>
|
||||
<kbd>{{ phone.t('Apps.camera.spaceKey') }}</kbd>
|
||||
</SkyButton>
|
||||
<sky-fab
|
||||
component="button"
|
||||
@@ -593,16 +618,16 @@ onBeforeUnmount(() => {
|
||||
<SkyButton
|
||||
v-for="zoom in zoomLevels"
|
||||
:key="zoom"
|
||||
glass
|
||||
rounded
|
||||
variant="plain"
|
||||
class="camera-zoom-pill"
|
||||
:class="{ active: zoomPresetIsActive(zoom) }"
|
||||
:class="{ active: Math.abs(selectedZoom - zoom) < 0.03 }"
|
||||
type="button"
|
||||
:aria-label="phone.t('Apps.camera.zoom', { zoom: `${zoom}x` })"
|
||||
:aria-pressed="zoomPresetIsActive(zoom)"
|
||||
:aria-pressed="Math.abs(selectedZoom - zoom) < 0.03"
|
||||
@click="setZoom(zoom)"
|
||||
>
|
||||
{{ zoomPresetLabel(zoom) }}
|
||||
{{ zoom }}x
|
||||
</SkyButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -693,15 +718,10 @@ onBeforeUnmount(() => {
|
||||
|
||||
<style scoped>
|
||||
.camera-page {
|
||||
--sky-bg: #000;
|
||||
--sky-text: #fff;
|
||||
position: relative;
|
||||
overflow: clip;
|
||||
background: var(--sky-bg);
|
||||
color: var(--sky-text);
|
||||
}
|
||||
.camera-page--landscape {
|
||||
--sky-bg: rgba(0, 0, 0, 0.42);
|
||||
background: #000;
|
||||
color: #fff;
|
||||
}
|
||||
.camera-viewport {
|
||||
position: absolute;
|
||||
@@ -713,11 +733,11 @@ onBeforeUnmount(() => {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.camera-page--landscape .camera-viewport {
|
||||
top: 50%;
|
||||
top: 46%;
|
||||
left: 50%;
|
||||
width: calc(100% * var(--phone-screen-portrait-ratio));
|
||||
width: calc(100% * 16 / 9);
|
||||
height: auto;
|
||||
aspect-ratio: var(--phone-screen-portrait-ratio);
|
||||
aspect-ratio: 16 / 9;
|
||||
transform: translate(-50%, -50%) rotate(90deg);
|
||||
}
|
||||
.camera-game-view,
|
||||
@@ -775,16 +795,6 @@ onBeforeUnmount(() => {
|
||||
pointer-events: none;
|
||||
background: linear-gradient(#0008, transparent 22%);
|
||||
}
|
||||
.camera-page--landscape .camera-shade {
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(0, 0, 0, 0.42) 0 18%,
|
||||
transparent 18% 75%,
|
||||
rgba(0, 0, 0, 0.42) 75% 100%
|
||||
),
|
||||
linear-gradient(#0008, transparent 22%);
|
||||
}
|
||||
.camera-flash {
|
||||
z-index: 8;
|
||||
pointer-events: none;
|
||||
@@ -845,22 +855,25 @@ onBeforeUnmount(() => {
|
||||
.camera-page--landscape .camera-latest svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.camera-upload-pill {
|
||||
padding: 7px 10px;
|
||||
border-radius: 999px;
|
||||
background: #0006;
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
}
|
||||
.camera-notice-text,
|
||||
.camera-focus-pill:not(.sky-button--glass),
|
||||
.camera-upload-pill {
|
||||
min-width: 0;
|
||||
color: #ffd60a;
|
||||
padding: 7px 10px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #0006;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
}
|
||||
.camera-upload-pill {
|
||||
color: #ffd60a;
|
||||
}
|
||||
.camera-focus-pill--notice {
|
||||
color: #ffd60a;
|
||||
}
|
||||
.camera-lock-control {
|
||||
min-height: 44px;
|
||||
@@ -911,7 +924,7 @@ onBeforeUnmount(() => {
|
||||
.camera-zoom-control {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
bottom: 216px;
|
||||
bottom: 196px;
|
||||
left: 50%;
|
||||
width: auto;
|
||||
transform: translateX(-50%);
|
||||
@@ -919,19 +932,16 @@ onBeforeUnmount(() => {
|
||||
.camera-zoom-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
.camera-zoom-pill {
|
||||
width: 36px;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
width: 44px;
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
@@ -939,20 +949,8 @@ onBeforeUnmount(() => {
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
.camera-zoom-pill::before {
|
||||
width: var(--sky-touch-target, 44px);
|
||||
inset-block: -4px;
|
||||
}
|
||||
.camera-zoom-pill.active {
|
||||
color: #ffd60a;
|
||||
background: rgba(28, 28, 30, 0.78);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.camera-zoom-pill:active:not(:disabled) {
|
||||
background: rgba(28, 28, 30, 0.42);
|
||||
}
|
||||
.camera-zoom-pill.active:active:not(:disabled) {
|
||||
background: rgba(28, 28, 30, 0.86);
|
||||
}
|
||||
.camera-controls {
|
||||
position: absolute;
|
||||
|
||||
@@ -40,10 +40,6 @@ describe('CityWarn product contract', () => {
|
||||
})
|
||||
|
||||
it('reserves the pill navigation, keeps sheets safe and avoids blur flicker', () => {
|
||||
expect(source).toMatch(
|
||||
/\.citywarn-scroll\s*\{[^}]*min-height:\s*0;[^}]*height:\s*auto;[^}]*flex:\s*1 1 0;[^}]*overflow-y:\s*auto;/s,
|
||||
)
|
||||
expect(source).not.toMatch(/\.citywarn-scroll\s*\{[^}]*height:\s*100%;/s)
|
||||
expect(source).toMatch(
|
||||
/\.citywarn-scroll\.sky-scroll-area--tabbar\s*\{[^}]*padding-bottom:\s*calc\(var\(--sky-safe-area-bottom\) \+ 84px\)/s,
|
||||
)
|
||||
@@ -60,51 +56,6 @@ describe('CityWarn product contract', () => {
|
||||
expect(source).not.toContain('<span>{{ alert.title }}</span>')
|
||||
})
|
||||
|
||||
it('uses one spacing and radius system throughout the current feed', () => {
|
||||
expect(source).toMatch(
|
||||
/\.citywarn-overview\s*\{[^}]*padding:\s*var\(--sky-space-3\);[^}]*gap:\s*var\(--sky-space-3\);[^}]*border-radius:\s*var\(--sky-radius-card\);/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.citywarn-overview \+ \.citywarn-feed\s*\{[^}]*margin-top:\s*var\(--sky-space-3\);[^}]*gap:\s*var\(--sky-space-3\);/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.citywarn-alert-card\s*\{[^}]*border-radius:\s*var\(--sky-radius-card\);/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.citywarn-publisher-card\s*\{[^}]*margin:\s*var\(--sky-space-3\) 0 0;[^}]*border-radius:\s*var\(--sky-radius-card\);/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.citywarn-publisher-card :deep\(\.sky-card__content\)\s*\{[^}]*padding:\s*var\(--sky-space-3\);[^}]*gap:\s*var\(--sky-space-3\);/s,
|
||||
)
|
||||
for (const selector of [
|
||||
'citywarn-overview-symbol',
|
||||
'citywarn-card-icon',
|
||||
'citywarn-publisher-icon',
|
||||
]) {
|
||||
expect(source).toMatch(
|
||||
new RegExp(
|
||||
`\\.${selector}\\s*\\{[^}]*border-radius:\\s*var\\(--sky-radius-control\\);`,
|
||||
's',
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps settings groups on one compact spacing rhythm', () => {
|
||||
expect(source).toMatch(
|
||||
/\.citywarn-settings\s*\{[^}]*gap:\s*var\(--sky-space-5\);/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.citywarn-settings :deep\(\.sky-settings-group\)\s*\{[^}]*margin:\s*0;/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.citywarn-settings :deep\(\.sky-settings-group__title\)\s*\{[^}]*margin:\s*0 var\(--sky-space-1\) var\(--sky-space-2\);/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.citywarn-settings :deep\(\.sky-settings-group__footer\)\s*\{[^}]*margin:\s*var\(--sky-space-2\) var\(--sky-space-1\) 0;/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps publishing authorization and validation on the server', () => {
|
||||
expect(server).toContain('Bridge.Framework.GetJob(source)')
|
||||
expect(server).toContain('SkyPhone.RequireSession(source)')
|
||||
|
||||
@@ -1026,9 +1026,7 @@ onMounted(async () => {
|
||||
height: 18px;
|
||||
}
|
||||
.citywarn-scroll {
|
||||
min-height: 0;
|
||||
height: auto;
|
||||
flex: 1 1 0;
|
||||
height: 100%;
|
||||
padding: 12px 13px calc(28px + env(safe-area-inset-bottom));
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -1047,11 +1045,11 @@ onMounted(async () => {
|
||||
}
|
||||
.citywarn-overview {
|
||||
display: flex;
|
||||
padding: var(--sky-space-3);
|
||||
padding: 15px;
|
||||
align-items: center;
|
||||
gap: var(--sky-space-3);
|
||||
gap: 12px;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: var(--sky-radius-card);
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(135deg, #f0fdf4, #ecfdf5);
|
||||
}
|
||||
.citywarn-overview--active {
|
||||
@@ -1064,7 +1062,7 @@ onMounted(async () => {
|
||||
height: 46px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: var(--sky-radius-control);
|
||||
border-radius: 15px;
|
||||
color: #15803d;
|
||||
background: #dcfce7;
|
||||
}
|
||||
@@ -1088,17 +1086,12 @@ onMounted(async () => {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.citywarn-overview + .citywarn-feed {
|
||||
margin-top: var(--sky-space-3);
|
||||
gap: var(--sky-space-3);
|
||||
}
|
||||
.citywarn-alert-card {
|
||||
position: relative;
|
||||
padding: var(--sky-space-3) var(--sky-space-3) var(--sky-space-3)
|
||||
var(--sky-space-4);
|
||||
padding: 13px 13px 12px 17px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: var(--sky-radius-card);
|
||||
border-radius: 17px;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 15px rgb(15 23 42 / 5%);
|
||||
cursor: pointer;
|
||||
@@ -1122,7 +1115,7 @@ onMounted(async () => {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
place-items: center;
|
||||
border-radius: var(--sky-radius-control);
|
||||
border-radius: 10px;
|
||||
color: var(--severity);
|
||||
background: color-mix(in srgb, var(--severity) 12%, white);
|
||||
}
|
||||
@@ -1146,7 +1139,7 @@ onMounted(async () => {
|
||||
color: #9ca3af;
|
||||
}
|
||||
.citywarn-alert-card h3 {
|
||||
margin: var(--sky-space-2) 0 var(--sky-space-1);
|
||||
margin: 10px 0 5px;
|
||||
font-size: 16px;
|
||||
line-height: 1.18;
|
||||
}
|
||||
@@ -1162,10 +1155,10 @@ onMounted(async () => {
|
||||
}
|
||||
.citywarn-alert-card footer {
|
||||
display: flex;
|
||||
margin-top: var(--sky-space-2);
|
||||
margin-top: 11px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sky-space-2);
|
||||
gap: 8px;
|
||||
color: #6b7280;
|
||||
font-size: 10.5px;
|
||||
}
|
||||
@@ -1179,13 +1172,12 @@ onMounted(async () => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.citywarn-publisher-card {
|
||||
margin: var(--sky-space-3) 0 0;
|
||||
border-radius: var(--sky-radius-card);
|
||||
margin-top: 12px;
|
||||
}
|
||||
.citywarn-publisher-card :deep(.sky-card__content) {
|
||||
display: flex;
|
||||
padding: var(--sky-space-3);
|
||||
gap: var(--sky-space-3);
|
||||
padding: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
.citywarn-publisher-icon {
|
||||
display: grid;
|
||||
@@ -1193,7 +1185,7 @@ onMounted(async () => {
|
||||
height: 36px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: var(--sky-radius-control);
|
||||
border-radius: 11px;
|
||||
color: #1d4ed8;
|
||||
background: #dbeafe;
|
||||
}
|
||||
@@ -1531,20 +1523,11 @@ onMounted(async () => {
|
||||
.citywarn-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sky-space-5);
|
||||
}
|
||||
.citywarn-settings :deep(.sky-settings-group) {
|
||||
margin: 0;
|
||||
}
|
||||
.citywarn-settings :deep(.sky-settings-group__title) {
|
||||
margin: 0 var(--sky-space-1) var(--sky-space-2);
|
||||
}
|
||||
.citywarn-settings :deep(.sky-settings-group__footer) {
|
||||
margin: var(--sky-space-2) var(--sky-space-1) 0;
|
||||
gap: 15px;
|
||||
}
|
||||
.citywarn-settings-hint {
|
||||
display: flex;
|
||||
margin: 0 var(--sky-space-1);
|
||||
margin: 0 8px;
|
||||
align-items: flex-start;
|
||||
gap: 7px;
|
||||
color: #6b7280;
|
||||
|
||||
@@ -369,22 +369,6 @@ describe('VaultX crypto app contracts', () => {
|
||||
expect(testServer).toContain('function advanceCryptoCycle(')
|
||||
})
|
||||
|
||||
it('does not keep market and settlement workers active while disabled', () => {
|
||||
expect(server).toContain('local function start_crypto_schedulers()')
|
||||
expect(server).toMatch(
|
||||
/start_crypto_schedulers\(\)[\s\S]*?if Config\.Crypto\.Enabled ~= true then\s+return/,
|
||||
)
|
||||
expect(server).toContain(
|
||||
'while scheduler_generation == generation and Config.Crypto.Enabled == true do',
|
||||
)
|
||||
expect(server).toContain(
|
||||
'AddEventHandler("sky_phone:configurator:serverUpdated", refresh_crypto_runtime)',
|
||||
)
|
||||
expect(server).not.toMatch(
|
||||
/CreateThread\(function\(\)\s+while true do\s+Wait\(5 \* 60 \* 1000\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it('stores cash in price-scale minor units throughout the ledger', () => {
|
||||
expect(server).toContain(
|
||||
'local ledger_amount = amount * Config.Crypto.PriceScale',
|
||||
|
||||
@@ -6,14 +6,6 @@ const source = readFileSync(
|
||||
new URL('./DarkChatApp.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const server = readFileSync(
|
||||
new URL('../../../../sky_phone/source/server/darkchat.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const config = readFileSync(
|
||||
new URL('../../../../sky_phone/config/config.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('DarkChatApp Sky UI contract', () => {
|
||||
it('uses first-party Sky UI without direct Konsta markup', () => {
|
||||
@@ -138,13 +130,4 @@ describe('DarkChatApp Sky UI contract', () => {
|
||||
/\.dc-inbox-navbar :deep\(\.sky-navbar__title-container > div\)\s*\{[^}]*translateY\(-14px\)/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('cleans expired messages in bounded indexed batches', () => {
|
||||
expect(config).toContain('CleanupBatchSize = 250')
|
||||
expect(server).toContain('Config.DarkChat.CleanupBatchSize')
|
||||
expect(server).toMatch(
|
||||
/DELETE FROM `sky_phone_darkchat_messages`[\s\S]*?ORDER BY `expires_at`, `id`[\s\S]*?LIMIT \?/,
|
||||
)
|
||||
expect(server).toContain(']], { batch_size })')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -203,7 +203,7 @@ describe('MessagesApp Sky UI contract', () => {
|
||||
expect(sheetStart).toBeGreaterThan(-1)
|
||||
expect(sheetEnd).toBeGreaterThan(sheetStart)
|
||||
expect(sheet).toContain(
|
||||
'activeCanMessage && !activeServiceLine && attachmentPicker !== null',
|
||||
':opened="activeCanMessage && attachmentPicker !== null"',
|
||||
)
|
||||
expect(sheet).toContain('swipe-to-close')
|
||||
expect(sheet).toContain('grabber-clickable')
|
||||
|
||||
@@ -215,9 +215,6 @@ const activeContact = computed(() =>
|
||||
const activeCanMessage = computed(
|
||||
() => activeContact.value?.canMessage !== false,
|
||||
)
|
||||
const activeServiceLine = computed(
|
||||
() => activeContact.value?.source === 'company',
|
||||
)
|
||||
const activeContactEmail = computed(() =>
|
||||
normalizeMailAddress(activeContact.value?.email ?? ''),
|
||||
)
|
||||
@@ -258,15 +255,12 @@ const inboxMenuItems = computed(() => [
|
||||
},
|
||||
])
|
||||
const attachmentPanelOpen = computed(
|
||||
() =>
|
||||
emojiOpen.value ||
|
||||
(!activeServiceLine.value && attachmentPicker.value !== null),
|
||||
() => emojiOpen.value || attachmentPicker.value !== null,
|
||||
)
|
||||
const composerHasContent = computed(
|
||||
() =>
|
||||
Boolean(draft.value.trim()) ||
|
||||
(!activeServiceLine.value &&
|
||||
(Boolean(shareDraft.value) || pendingAttachments.value.length > 0)),
|
||||
Boolean(draft.value.trim() || shareDraft.value) ||
|
||||
pendingAttachments.value.length > 0,
|
||||
)
|
||||
function contactName(number: string): string {
|
||||
return (
|
||||
@@ -423,8 +417,6 @@ function errorText(error?: string): string {
|
||||
'gif_provider_failed',
|
||||
'self_message',
|
||||
'recipient_not_found',
|
||||
'messaging_unavailable',
|
||||
'service_line_text_only',
|
||||
'no_sim',
|
||||
'rate_limited',
|
||||
'blocked',
|
||||
@@ -622,7 +614,6 @@ async function callActiveContact(): Promise<void> {
|
||||
}
|
||||
|
||||
function toggleAttachmentMenu(): void {
|
||||
if (activeServiceLine.value) return
|
||||
attachmentMenuOpen.value = !attachmentMenuOpen.value
|
||||
emojiOpen.value = false
|
||||
attachmentPicker.value = null
|
||||
@@ -655,7 +646,7 @@ function openMediaApp(
|
||||
app: 'camera' | 'photos',
|
||||
mediaType: 'photo' | 'video',
|
||||
): void {
|
||||
if (!messages.activeNumber || activeServiceLine.value) return
|
||||
if (!messages.activeNumber) return
|
||||
const remainingSlots =
|
||||
MAX_PENDING_ATTACHMENTS - pendingAttachments.value.length
|
||||
if (remainingSlots < 1) {
|
||||
@@ -716,14 +707,7 @@ async function sendAttachment(
|
||||
mediaAssetId: string,
|
||||
mediaDurationMs?: number,
|
||||
): Promise<void> {
|
||||
if (
|
||||
!messages.activeNumber ||
|
||||
!activeCanMessage.value ||
|
||||
activeServiceLine.value ||
|
||||
sending.value
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!messages.activeNumber || !activeCanMessage.value || sending.value) return
|
||||
attachmentMenuOpen.value = false
|
||||
attachmentPicker.value = null
|
||||
sending.value = true
|
||||
@@ -738,7 +722,7 @@ async function sendAttachment(
|
||||
}
|
||||
|
||||
async function sendContact(contact: PhoneContact): Promise<void> {
|
||||
if (!messages.activeNumber || activeServiceLine.value || sending.value) return
|
||||
if (!messages.activeNumber || sending.value) return
|
||||
attachmentMenuOpen.value = false
|
||||
attachmentPicker.value = null
|
||||
sending.value = true
|
||||
@@ -901,12 +885,7 @@ function sampleMicrophone(): void {
|
||||
}
|
||||
|
||||
async function startVoiceRecording(): Promise<void> {
|
||||
if (
|
||||
!activeCanMessage.value ||
|
||||
activeServiceLine.value ||
|
||||
recording.value ||
|
||||
recordingStarting.value
|
||||
) {
|
||||
if (!activeCanMessage.value || recording.value || recordingStarting.value) {
|
||||
return
|
||||
}
|
||||
emojiOpen.value = false
|
||||
@@ -1062,27 +1041,6 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
watch(activeServiceLine, (serviceLine) => {
|
||||
if (!serviceLine) return
|
||||
const discarded = Boolean(
|
||||
shareDraft.value ||
|
||||
pendingAttachments.value.length ||
|
||||
recording.value ||
|
||||
recordingStarting.value,
|
||||
)
|
||||
attachmentMenuOpen.value = false
|
||||
attachmentPicker.value = null
|
||||
shareDraft.value = null
|
||||
pendingAttachments.value = []
|
||||
if (recording.value) {
|
||||
discardRecording = true
|
||||
cancelVoiceRecording()
|
||||
} else if (recordingStarting.value) {
|
||||
cleanupRecorder()
|
||||
}
|
||||
if (discarded) showToast(errorText('service_line_text_only'))
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
discardRecording = true
|
||||
cleanupRecorder()
|
||||
@@ -1656,7 +1614,7 @@ onBeforeUnmount(() => {
|
||||
</SkyScrollArea>
|
||||
|
||||
<section
|
||||
v-if="activeCanMessage && !activeServiceLine && attachmentMenuOpen"
|
||||
v-if="activeCanMessage && attachmentMenuOpen"
|
||||
class="messages-attachment-menu"
|
||||
:aria-hidden="contactDetailsOpen"
|
||||
:inert="contactDetailsOpen || undefined"
|
||||
@@ -1701,9 +1659,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<SkySheet
|
||||
class="messages-media-picker-sheet"
|
||||
:opened="
|
||||
activeCanMessage && !activeServiceLine && attachmentPicker !== null
|
||||
"
|
||||
:opened="activeCanMessage && attachmentPicker !== null"
|
||||
:aria-label="
|
||||
phone.t(
|
||||
attachmentPicker === 'contacts'
|
||||
@@ -1838,7 +1794,7 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="activeCanMessage && !activeServiceLine && shareDraft && !recording"
|
||||
v-if="activeCanMessage && shareDraft && !recording"
|
||||
class="shared-composer-preview"
|
||||
:aria-hidden="contactDetailsOpen"
|
||||
:inert="contactDetailsOpen || undefined"
|
||||
@@ -1854,7 +1810,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<section
|
||||
v-if="activeCanMessage && !activeServiceLine && recording"
|
||||
v-if="activeCanMessage && recording"
|
||||
class="messages-recorder"
|
||||
:aria-hidden="contactDetailsOpen"
|
||||
:inert="contactDetailsOpen || undefined"
|
||||
@@ -1942,7 +1898,6 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div class="messages-sky-composer-row">
|
||||
<SkyGlass
|
||||
v-if="!activeServiceLine"
|
||||
component="button"
|
||||
type="button"
|
||||
class="messages-sky-messagebar__action messages-sky-messagebar__plus"
|
||||
@@ -1979,7 +1934,7 @@ onBeforeUnmount(() => {
|
||||
<ArrowUpCircle :size="29" :stroke-width="2.4" />
|
||||
</SkyLink>
|
||||
<SkyLink
|
||||
v-else-if="!activeServiceLine"
|
||||
v-else
|
||||
icon-only
|
||||
class="messages-sky-messagebar__send"
|
||||
:disabled="sending || recordingStarting"
|
||||
|
||||
@@ -16,12 +16,6 @@ describe('PhoneApp EasyShare contract', () => {
|
||||
it('uses the shared full-width Sky tab bar for phone sections', () => {
|
||||
expect(source).toContain('<sky-tab-bar')
|
||||
expect(source).toContain('<sky-tab-button')
|
||||
expect(source).toContain(
|
||||
'calc(var(--sky-tabbar-height) + var(--sky-safe-area-bottom) + 16px);',
|
||||
)
|
||||
expect(source).not.toMatch(
|
||||
/\.phone-contacts\s*\{[^}]*padding-bottom:\s*20px;/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('uses shared interactive liquid glass surfaces for phone controls', () => {
|
||||
@@ -63,21 +57,6 @@ describe('PhoneApp EasyShare contract', () => {
|
||||
expect(source).not.toContain('rgba(10, 132, 255')
|
||||
})
|
||||
|
||||
it('keeps contact profiles readable in light mode', () => {
|
||||
expect(source).toMatch(
|
||||
/\.phone-app--light\.phone-calls-app--profile\s*\{[^}]*color:\s*var\(--sky-text\);[^}]*background:\s*var\(--sky-bg\) !important;/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.phone-app--light \.phone-profile-action\s*\{[^}]*color:\s*var\(--sky-text\) !important;/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.phone-app--light \.phone-profile-action:disabled\s*\{[^}]*color:\s*var\(--sky-subtle\) !important;/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.phone-app--light \.phone-profile-card,[\s\S]*?\.phone-app--light \.phone-history-card\s*\{[^}]*color:\s*var\(--sky-text\);[^}]*background:\s*var\(--sky-glass\);/,
|
||||
)
|
||||
})
|
||||
|
||||
it('opens contact deep links only after contacts bootstrap and consumes the query', () => {
|
||||
const mounted = source.slice(
|
||||
source.indexOf('onMounted(async () => {'),
|
||||
|
||||
@@ -2250,6 +2250,7 @@ onBeforeUnmount(() => {
|
||||
position: relative;
|
||||
padding-top: 6px;
|
||||
padding-right: 24px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.phone-contacts-header {
|
||||
@@ -3858,63 +3859,6 @@ onBeforeUnmount(() => {
|
||||
background: var(--sky-glass);
|
||||
}
|
||||
|
||||
.phone-app--light.phone-calls-app--profile {
|
||||
color: var(--sky-text);
|
||||
background: var(--sky-bg) !important;
|
||||
}
|
||||
|
||||
.phone-app--light .phone-call-content--profile,
|
||||
.phone-app--light .phone-contact-hero h2,
|
||||
.phone-app--light .phone-detail-header-button,
|
||||
.phone-app--light .phone-detail-edit {
|
||||
color: var(--sky-text);
|
||||
}
|
||||
|
||||
.phone-app--light .phone-contact-hero .phone-contact-organization,
|
||||
.phone-app--light .phone-profile-notes > p,
|
||||
.phone-app--light .phone-history-copy small,
|
||||
.phone-app--light .phone-history-row time,
|
||||
.phone-app--light .phone-history-empty {
|
||||
color: var(--sky-muted);
|
||||
}
|
||||
|
||||
.phone-app--light .phone-profile-action {
|
||||
color: var(--sky-text) !important;
|
||||
}
|
||||
|
||||
.phone-app--light .phone-profile-action:disabled {
|
||||
color: var(--sky-subtle) !important;
|
||||
}
|
||||
|
||||
.phone-app--light .phone-profile-card,
|
||||
.phone-app--light .phone-own-profile-card,
|
||||
.phone-app--light .phone-history-card {
|
||||
border-color: var(--sky-hairline);
|
||||
color: var(--sky-text);
|
||||
background: var(--sky-glass);
|
||||
box-shadow: var(--sky-shadow-glass);
|
||||
}
|
||||
|
||||
.phone-app--light .phone-own-profile-card > div:not(:last-child),
|
||||
.phone-app--light .phone-profile-info-card > button,
|
||||
.phone-app--light .phone-profile-options-card > button,
|
||||
.phone-app--light .phone-profile-single-option > button,
|
||||
.phone-app--light .phone-profile-notes,
|
||||
.phone-app--light .phone-history-row {
|
||||
border-color: var(--sky-hairline);
|
||||
}
|
||||
|
||||
.phone-app--light .phone-own-profile-card > div > span {
|
||||
color: var(--sky-text);
|
||||
background: var(--sky-pressed);
|
||||
}
|
||||
|
||||
.phone-app--light .phone-profile-info-card > button > svg,
|
||||
.phone-app--light .phone-profile-notes > span,
|
||||
.phone-app--light .phone-history-card h3 {
|
||||
color: var(--sky-text);
|
||||
}
|
||||
|
||||
.phone-app--light .phone-bottom-tabbar {
|
||||
--sky-app-accent: #3a3a3c;
|
||||
}
|
||||
|
||||
@@ -96,11 +96,6 @@ import {
|
||||
type WallpaperTarget,
|
||||
} from '@/utils/preferences'
|
||||
|
||||
type ToneChoice<T extends string> = {
|
||||
id: T
|
||||
label: string
|
||||
}
|
||||
|
||||
type SettingsView =
|
||||
| 'root'
|
||||
| 'account'
|
||||
@@ -623,26 +618,6 @@ 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
|
||||
@@ -1301,25 +1276,23 @@ onBeforeUnmount(() => {
|
||||
|
||||
<SkySettingsGroup :title="phone.t('Apps.settings.ringtone')">
|
||||
<SkySettingsRow
|
||||
v-for="ringtone in ringtoneChoices"
|
||||
:key="ringtone.id"
|
||||
v-for="ringtone in RINGTONE_IDS"
|
||||
:key="ringtone"
|
||||
kind="choice"
|
||||
:selected="phone.preferences.settings.ringtone === ringtone.id"
|
||||
:title="ringtone.label"
|
||||
@activate="selectRingtone(ringtone.id)"
|
||||
:selected="phone.preferences.settings.ringtone === ringtone"
|
||||
:title="phone.t('Apps.settings.ringtones.' + ringtone)"
|
||||
@activate="selectRingtone(ringtone)"
|
||||
/>
|
||||
</SkySettingsGroup>
|
||||
|
||||
<SkySettingsGroup :title="phone.t('Apps.settings.notificationSound')">
|
||||
<SkySettingsRow
|
||||
v-for="sound in notificationSoundChoices"
|
||||
:key="sound.id"
|
||||
v-for="sound in NOTIFICATION_SOUND_IDS"
|
||||
:key="sound"
|
||||
kind="choice"
|
||||
:selected="
|
||||
phone.preferences.settings.notificationSound === sound.id
|
||||
"
|
||||
:title="sound.label"
|
||||
@activate="selectNotificationSound(sound.id)"
|
||||
:selected="phone.preferences.settings.notificationSound === sound"
|
||||
:title="phone.t('Apps.settings.notificationSoundsList.' + sound)"
|
||||
@activate="selectNotificationSound(sound)"
|
||||
/>
|
||||
</SkySettingsGroup>
|
||||
</template>
|
||||
|
||||
@@ -32,52 +32,6 @@ describe('SkyRide app layout', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('scrolls Ride and Drive away without full-width nav backgrounds', () => {
|
||||
expect(source).toMatch(
|
||||
/\.skyride-navbar :deep\(\.sky-navbar__blur\),\s*\.skyride-navbar :deep\(\.sky-navbar__background\)\s*\{[^}]*display:\s*none;/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/<div class="skyride-scroll">\s*<div class="skyride-mode">/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.skyride-scroll\s*\{[^}]*box-sizing:\s*border-box;[^}]*inset:\s*114px 0 0;[^}]*padding:\s*0 0 116px;/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.skyride-tabbar :deep\(\.sky-tabbar__blur\),\s*\.skyride-tabbar :deep\(\.sky-tabbar__background\)\s*\{[^}]*display:\s*none;/s,
|
||||
)
|
||||
expect(source).not.toMatch(
|
||||
/\.skyride-tabbar :deep\(\.sky-tabbar__pane\)\s*\{\s*border:/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps active-ride card spacing even and outline actions contrasted', () => {
|
||||
expect(source).toMatch(
|
||||
/\.skyride-ride-status-card\s*\{[^}]*margin-bottom:\s*12px;/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.skyride-person-card\s*\{[^}]*margin-bottom:\s*12px;/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.skyride-trip-card\s*\{[^}]*margin-bottom:\s*12px;/s,
|
||||
)
|
||||
expect(source).toContain('.sky-button--primary:not(.sky-button--outline)')
|
||||
expect(source).not.toMatch(
|
||||
/\.skyride-app :deep\(\.sky-button--primary\)\s*\{/,
|
||||
)
|
||||
})
|
||||
|
||||
it('aligns pickup and destination text to one timeline axis', () => {
|
||||
expect(source).toMatch(
|
||||
/\.skyride-route-stop\s*\{[^}]*grid-template-columns:\s*18px minmax\(0, 1fr\);/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.skyride-route-stop > \.skyride-dot\s*\{[^}]*justify-self:\s*center;/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.skyride-trip-card > i\s*\{[^}]*margin:\s*1px 0 1px 8px;/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('uses a compact swipeable profile editor with equal actions', () => {
|
||||
expect(source).toContain('class="skyride-profile-sheet"')
|
||||
expect(source).toContain('swipe-to-close')
|
||||
|
||||
@@ -622,26 +622,26 @@ onBeforeUnmount(() => {
|
||||
</k-card>
|
||||
|
||||
<template v-else>
|
||||
<div class="skyride-scroll">
|
||||
<div class="skyride-mode">
|
||||
<k-segmented v-if="skyride.driverEligible">
|
||||
<k-segmented-button
|
||||
:active="mode === 'rider'"
|
||||
:disabled="Boolean(skyride.activeRide)"
|
||||
@click="mode = 'rider'"
|
||||
>
|
||||
{{ phone.t('Apps.skyride.mode.rider') }}
|
||||
</k-segmented-button>
|
||||
<k-segmented-button
|
||||
:active="mode === 'driver'"
|
||||
:disabled="Boolean(skyride.activeRide)"
|
||||
@click="mode = 'driver'"
|
||||
>
|
||||
{{ phone.t('Apps.skyride.mode.driver') }}
|
||||
</k-segmented-button>
|
||||
</k-segmented>
|
||||
</div>
|
||||
<div class="skyride-mode">
|
||||
<k-segmented v-if="skyride.driverEligible">
|
||||
<k-segmented-button
|
||||
:active="mode === 'rider'"
|
||||
:disabled="Boolean(skyride.activeRide)"
|
||||
@click="mode = 'rider'"
|
||||
>
|
||||
{{ phone.t('Apps.skyride.mode.rider') }}
|
||||
</k-segmented-button>
|
||||
<k-segmented-button
|
||||
:active="mode === 'driver'"
|
||||
:disabled="Boolean(skyride.activeRide)"
|
||||
@click="mode = 'driver'"
|
||||
>
|
||||
{{ phone.t('Apps.skyride.mode.driver') }}
|
||||
</k-segmented-button>
|
||||
</k-segmented>
|
||||
</div>
|
||||
|
||||
<div class="skyride-scroll">
|
||||
<template v-if="activeTab === 'home'">
|
||||
<section v-if="mode === 'rider'" class="skyride-home-panel">
|
||||
<template v-if="!skyride.activeRide">
|
||||
@@ -1816,11 +1816,6 @@ onBeforeUnmount(() => {
|
||||
color: var(--ride-text);
|
||||
}
|
||||
|
||||
.skyride-navbar :deep(.sky-navbar__blur),
|
||||
.skyride-navbar :deep(.sky-navbar__background) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.skyride-navbar :deep(.sky-navbar__title) {
|
||||
color: var(--ride-text);
|
||||
font-size: 18px;
|
||||
@@ -1858,9 +1853,7 @@ onBeforeUnmount(() => {
|
||||
.skyride-scroll {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
box-sizing: border-box;
|
||||
inset: 114px 0 0;
|
||||
padding: 0 0 116px;
|
||||
inset: 148px 0 116px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
@@ -2260,10 +2253,6 @@ onBeforeUnmount(() => {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.skyride-person-card {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.skyride-avatar,
|
||||
.skyride-profile-avatar {
|
||||
display: grid;
|
||||
@@ -2340,19 +2329,13 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.skyride-route-stop {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: 11px;
|
||||
}
|
||||
|
||||
.skyride-route-stop > .skyride-dot {
|
||||
justify-self: center;
|
||||
gap: 11px;
|
||||
}
|
||||
|
||||
.skyride-route-stop > svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 18px;
|
||||
color: var(--ride-accent-strong);
|
||||
}
|
||||
|
||||
@@ -2383,7 +2366,7 @@ onBeforeUnmount(() => {
|
||||
display: block;
|
||||
width: 1px;
|
||||
height: 17px;
|
||||
margin: 1px 0 1px 8px;
|
||||
margin: 1px 0 1px 5px;
|
||||
border-left: 1px dashed var(--ride-muted);
|
||||
}
|
||||
|
||||
@@ -2708,11 +2691,6 @@ button.skyride-profile-avatar--editable {
|
||||
gap: 0 !important;
|
||||
}
|
||||
|
||||
.skyride-tabbar :deep(.sky-tabbar__blur),
|
||||
.skyride-tabbar :deep(.sky-tabbar__background) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.skyride-tab-pane {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
@@ -2991,7 +2969,7 @@ button.skyride-profile-avatar--editable {
|
||||
background-color: var(--ride-accent);
|
||||
}
|
||||
|
||||
.skyride-app :deep(.sky-button--primary:not(.sky-button--outline)) {
|
||||
.skyride-app :deep(.sky-button--primary) {
|
||||
color: #171719;
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('phone apps use Sky UI', () => {
|
||||
|
||||
it('uses shared liquid glass for remaining compact interaction controls', () => {
|
||||
const minimumGlassButtons: Record<string, number> = {
|
||||
'CameraApp.vue': 1,
|
||||
'CameraApp.vue': 2,
|
||||
'FeatherApp.vue': 2,
|
||||
'FlareApp.vue': 1,
|
||||
'FlipTokApp.vue': 2,
|
||||
|
||||
@@ -116,35 +116,6 @@ 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,89 +476,10 @@ 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' &&
|
||||
@@ -574,7 +495,6 @@ function buildStructure(value, scope, path) {
|
||||
]),
|
||||
)
|
||||
return {
|
||||
entryDefault: companyDefinitionEntryDefault(value),
|
||||
fields,
|
||||
kind: 'table',
|
||||
mutableKeys: true,
|
||||
@@ -741,8 +661,6 @@ 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,7 +12,6 @@ type ConfiguratorField = {
|
||||
}
|
||||
|
||||
type ConfiguratorStructure = {
|
||||
entryDefault?: unknown
|
||||
entries?: Array<{ structure: ConfiguratorStructure }>
|
||||
fields?: Record<string, ConfiguratorStructure>
|
||||
items?: ConfiguratorStructure[]
|
||||
@@ -62,22 +61,16 @@ function countStructure(structure: ConfiguratorStructure | undefined): number {
|
||||
}
|
||||
|
||||
describe('admin configurator fixture', () => {
|
||||
it('exposes every SQL-managed config.lua root through the full live preview', () => {
|
||||
it('exposes every config.lua root through the full live preview', () => {
|
||||
const sections = loadConfiguratorSections()
|
||||
const fields = sections.flatMap((section) => section.fields)
|
||||
const roots = [
|
||||
...configSource.matchAll(/^\s{0,4}Config\.([A-Za-z0-9_]+)\s*=/gm),
|
||||
]
|
||||
.map((match) => match[1])
|
||||
.filter(
|
||||
(root) =>
|
||||
root !== 'Media' &&
|
||||
root !== 'PhoneConfigurator' &&
|
||||
root !== 'CommandPermissions' &&
|
||||
root !== 'CustomTones',
|
||||
)
|
||||
.filter((root) => root !== 'Media' && root !== 'PhoneConfigurator')
|
||||
|
||||
expect(sections).toHaveLength(46)
|
||||
expect(sections).toHaveLength(45)
|
||||
expect(
|
||||
fields.reduce(
|
||||
(total, field) => total + countStructure(field.structure),
|
||||
@@ -124,22 +117,6 @@ describe('admin configurator fixture', () => {
|
||||
expect(phone?.structure?.fields?.Keybind.kind).toBe('optionalString')
|
||||
expect(companyJobs?.label).toBe('Jobs')
|
||||
expect(companyJobs?.structure).toMatchObject({
|
||||
entryDefault: {
|
||||
AcceptsRequests: true,
|
||||
Category: 'public_services',
|
||||
Job: '',
|
||||
ServiceLine: {
|
||||
Number: '500',
|
||||
Routing: 'round_robin',
|
||||
},
|
||||
Services: [
|
||||
{
|
||||
Id: '',
|
||||
RequestsEnabled: true,
|
||||
Title: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
fields: {
|
||||
ambulance: { kind: 'table' },
|
||||
police: { kind: 'table' },
|
||||
@@ -153,70 +130,6 @@ describe('admin configurator fixture', () => {
|
||||
).toBe('map')
|
||||
})
|
||||
|
||||
it('exposes the requestable police assistance defaults', () => {
|
||||
const companyJobs = loadConfiguratorSections()
|
||||
.flatMap((section) => section.fields)
|
||||
.find((field) => field.path === 'Companies.Definitions')
|
||||
const police = (
|
||||
companyJobs?.value as Record<string, Record<string, unknown>> | undefined
|
||||
)?.police
|
||||
|
||||
expect(police).toMatchObject({
|
||||
AcceptsRequests: true,
|
||||
Emergency: true,
|
||||
Services: [
|
||||
{
|
||||
Id: 'police-assistance',
|
||||
RequestsEnabled: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(
|
||||
companyJobs?.structure?.fields?.police.fields?.Services,
|
||||
).toMatchObject({
|
||||
items: [
|
||||
{
|
||||
fields: {
|
||||
Id: { kind: 'value', valueType: 'string' },
|
||||
RequestsEnabled: { kind: 'value', valueType: 'boolean' },
|
||||
},
|
||||
kind: 'table',
|
||||
},
|
||||
],
|
||||
kind: 'list',
|
||||
template: {
|
||||
fields: {
|
||||
Id: { kind: 'value', valueType: 'string' },
|
||||
RequestsEnabled: { kind: 'value', valueType: 'boolean' },
|
||||
},
|
||||
kind: 'table',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('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,
|
||||
@@ -265,7 +178,7 @@ describe('admin configurator fixture', () => {
|
||||
template: { kind: 'value', valueType: 'string' },
|
||||
})
|
||||
expect(
|
||||
root('Companies.Definitions')?.fields?.ambulance.fields?.Services,
|
||||
root('Companies.Definitions')?.fields?.police.fields?.Services,
|
||||
).toMatchObject({
|
||||
items: [],
|
||||
kind: 'list',
|
||||
|
||||
+18
-113
@@ -1978,7 +1978,7 @@ const contacts = [
|
||||
},
|
||||
{
|
||||
canCall: true,
|
||||
canMessage: true,
|
||||
canMessage: false,
|
||||
companyId: 'police',
|
||||
avatar_url: 'https://picsum.photos/seed/companies-police-logo/180/180',
|
||||
id: 'company:police',
|
||||
@@ -4178,7 +4178,7 @@ const companyCategories = [
|
||||
let companyCallAvailable = false
|
||||
const companyProfiles = [
|
||||
{
|
||||
acceptsRequests: true,
|
||||
acceptsRequests: false,
|
||||
announcement: {
|
||||
body: 'Community traffic unit active around Legion Square.',
|
||||
expiresAt: isoTime(6 * 60 * 60 * 1000),
|
||||
@@ -4187,7 +4187,7 @@ const companyProfiles = [
|
||||
availability: 'available',
|
||||
availabilityUpdatedAt: isoTime(-12 * 60 * 1000),
|
||||
canCall: true,
|
||||
canMessage: true,
|
||||
canMessage: false,
|
||||
categoryId: 'public_services',
|
||||
categoryName: 'Public Services',
|
||||
coverUrl: 'https://picsum.photos/seed/companies-police-cover/900/360',
|
||||
@@ -4202,20 +4202,28 @@ const companyProfiles = [
|
||||
label: 'Mission Row Police Station',
|
||||
},
|
||||
logoUrl: 'https://picsum.photos/seed/companies-police-logo/180/180',
|
||||
name: 'Los Santos Police Department',
|
||||
name: 'Los Santos Police',
|
||||
phoneNumber: '911',
|
||||
revision: 3,
|
||||
services: [
|
||||
{
|
||||
acceptsRequests: true,
|
||||
acceptsRequests: false,
|
||||
active: true,
|
||||
description: 'Request non-emergency police assistance.',
|
||||
id: 'police-assistance',
|
||||
description: 'Immediate police response through the service line.',
|
||||
id: 'emergency-response',
|
||||
priceText: null,
|
||||
title: 'Police Assistance',
|
||||
title: 'Emergency Response',
|
||||
},
|
||||
{
|
||||
acceptsRequests: false,
|
||||
active: true,
|
||||
description: 'General information and non-emergency assistance.',
|
||||
id: 'public-assistance',
|
||||
priceText: null,
|
||||
title: 'Public Assistance',
|
||||
},
|
||||
],
|
||||
serviceSummary: 'Non-emergency police assistance',
|
||||
serviceSummary: 'Emergency response and public assistance',
|
||||
verified: true,
|
||||
},
|
||||
{
|
||||
@@ -4718,7 +4726,6 @@ function adminMockBootstrap() {
|
||||
targetSource: 2,
|
||||
},
|
||||
],
|
||||
disabledApps: [],
|
||||
players: [1, 2].map((source) => {
|
||||
const player = adminMockPlayerDetail(source)
|
||||
return {
|
||||
@@ -4733,18 +4740,7 @@ function adminMockBootstrap() {
|
||||
source,
|
||||
}
|
||||
}),
|
||||
stats: {
|
||||
accounts: 24,
|
||||
activeDevices: 19,
|
||||
auditEntries: 37,
|
||||
auditToday: 4,
|
||||
callsToday: 18,
|
||||
devices: 31,
|
||||
linkedDevices: 22,
|
||||
messagesToday: 146,
|
||||
online: 2,
|
||||
simDevices: 27,
|
||||
},
|
||||
stats: { accounts: 24, devices: 31, online: 2 },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4933,13 +4929,6 @@ 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 }
|
||||
@@ -4961,9 +4950,6 @@ 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() })
|
||||
@@ -4977,87 +4963,6 @@ 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
|
||||
|
||||
+2
-15
@@ -134,7 +134,7 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
|
||||
- `hex_4_inventory`
|
||||
- Native ESX inventory
|
||||
|
||||
`mf-inventory` and `smx-inventory` are supported with ESX. The native ESX and HEX adapters use count-based items, so Sky Phone automatically disables unique phones and physical SIM cards while either adapter is active.
|
||||
`mf-inventory` and `smx-inventory` are supported with ESX. The native ESX and HEX adapters use count-based items and therefore require unique phones and physical SIM cards to be disabled.
|
||||
|
||||
### Voice
|
||||
|
||||
@@ -196,7 +196,6 @@ The files contain clearly separated sections for:
|
||||
| Section | Purpose |
|
||||
| --- | --- |
|
||||
| `Config.Bridge` | Framework, inventory, language, callback timeout, and debug mode |
|
||||
| `Config.CommandPermissions` | Fixed groups for the admin panel, test data, verification commands, and social moderation |
|
||||
| `Config.Phone` | Phone item, movement, unique-device mode, and development command |
|
||||
| `Config.Sim` | Physical or virtual SIM behavior and number formatting |
|
||||
| `Config.Calls` / `Config.Radio` | Voice providers, call behavior, radio limits, and permissions |
|
||||
@@ -212,18 +211,6 @@ The files contain clearly separated sections for:
|
||||
|
||||
Restart `sky_phone` after changing Lua configuration.
|
||||
|
||||
When `Config.PhoneConfigurator.Enabled` is enabled, the generated `source/shared/config_default.lua`
|
||||
provides the shipped SQL baseline. The frontend build recreates it from `config.lua` and the
|
||||
server-only `media.lua`; do not edit the generated snapshot directly.
|
||||
|
||||
`Config.PhoneConfigurator` and `Config.CommandPermissions` remain file-owned and are omitted from
|
||||
the generated default snapshot. Permissions are not displayed in the Phone Configurator and stay
|
||||
authoritative while SQL configuration is enabled. Their stable keys do not change when a command is
|
||||
renamed in the panel. ESX and QBCore use their framework permissions. Qbox checks the configured ACE
|
||||
objects first and then its framework groups, so the standard `permissions.cfg` mapping from
|
||||
`group.admin` to the `admin` ACE works with the shipped `phonepanel` permission list. Restart
|
||||
`sky_phone` after changing fixed permissions.
|
||||
|
||||
### Language
|
||||
|
||||
Available locales:
|
||||
@@ -325,7 +312,7 @@ Do not configure an LB Phone client event or client export. Sky Phone registers
|
||||
|
||||
The server registers `Config.Phone.Item` as usable for every supported inventory adapter: `ox`, `qb`, `lj`, `qs`, `codem`, `core`, `mf`, `smx`, `hex`, and `esx`. Resource startup fails visibly if the selected adapter cannot complete that registration.
|
||||
|
||||
The `hex` and `esx` adapters use ESX's count-based item API, which cannot persist per-item phone or physical SIM metadata. Sky Phone automatically forces `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` while either adapter is active. `auto` selects `hex` when `hex_4_inventory` is started and otherwise falls back to `esx` on an ESX server when no metadata-capable inventory is detected.
|
||||
The `hex` and `esx` adapters use ESX's count-based item API. They require both `Config.Phone.Unique = false` and `Config.Sim.Enabled = false` because this API cannot persist per-item phone or physical SIM metadata. `auto` selects `hex` when `hex_4_inventory` is started and otherwise falls back to `esx` on an ESX server when no metadata-capable inventory is detected.
|
||||
|
||||
### QBCore-style item tables
|
||||
|
||||
|
||||
+16
-113
@@ -10,36 +10,19 @@
|
||||
Keep option names unchanged. Restart sky_phone after editing this file.
|
||||
]]
|
||||
|
||||
-- CONFIG_DEFAULT_EXCLUDE_START
|
||||
-- When enabled, the active configuration is loaded from SQL and managed through
|
||||
-- /phonepanel. Frontend builds snapshot the shipped defaults from config.lua and
|
||||
-- media.lua into source/shared/config_default.lua.
|
||||
-- When enabled, config.lua and media.lua only provide first-run defaults.
|
||||
-- The active configuration is loaded from SQL and managed through /phonepanel.
|
||||
Config.PhoneConfigurator = {
|
||||
Enabled = true,
|
||||
}
|
||||
|
||||
-- Fixed server permissions. These values remain authoritative even while the
|
||||
-- Phone Configurator is enabled and are intentionally not shown in its panel.
|
||||
-- Group names use the active framework's permissions. On Qbox they also match
|
||||
-- ACE objects such as "admin" from the standard permissions.cfg.
|
||||
Config.CommandPermissions = {
|
||||
phonepanel = { "god", "superadmin", "admin" },
|
||||
phonetestdata = { "god", "superadmin", "admin" },
|
||||
fliptokverify = { "god", "superadmin", "admin" },
|
||||
picstagramverify = { "god", "superadmin", "admin" },
|
||||
picstagramadmin = { "god", "superadmin", "admin" },
|
||||
}
|
||||
-- CONFIG_DEFAULT_EXCLUDE_END
|
||||
|
||||
-- =============================================================================
|
||||
-- Core, framework and device
|
||||
-- =============================================================================
|
||||
|
||||
Config.Bridge = {
|
||||
Framework = "auto", -- auto, esx, qbox, qb
|
||||
-- auto, ak47, codem, core, jaksam, jpr, lj, mf, one, origen, ox, ps, qb, qs, smx, tgiann, hex, esx
|
||||
-- Compatibility aliases: qb-inv -> qb, qbox -> ox
|
||||
Inventory = "auto",
|
||||
Inventory = "auto", -- auto, ox, qb, lj, qs, codem, core, mf, smx, hex, esx
|
||||
Locale = "en",
|
||||
CallbackTimeout = 15000,
|
||||
Debug = false, -- true: show debug/info output; warnings and errors are always shown
|
||||
@@ -49,9 +32,8 @@ Config.Command = "phone"
|
||||
|
||||
Config.Phone = {
|
||||
Item = "phone",
|
||||
Unique = true, -- true: data follows each phone item; false: one persistent phone per character; forced false for metadata-free inventories
|
||||
Unique = true, -- true: data follows each phone item; false: one persistent phone per character; hex/esx require false
|
||||
Keybind = "F1", -- false disables the configurable phone key mapping
|
||||
OpenRequestsPerMinute = 20,
|
||||
AllowMovement = true, -- true: game input stays active while the mobile phone is open
|
||||
HoldToLook = {
|
||||
Enabled = true, -- hold the configured control to hide the cursor and look around; independent of AllowMovement
|
||||
@@ -61,83 +43,11 @@ 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 = {
|
||||
@@ -164,6 +74,7 @@ Config.Security = {
|
||||
Config.AdminPanel = {
|
||||
Enabled = true,
|
||||
Command = "phonepanel",
|
||||
AdminGroups = { "admin", "superadmin" },
|
||||
MaximumPlayers = 128,
|
||||
ReadRequestsPerMinute = 60,
|
||||
ActionRequestsPerMinute = 30,
|
||||
@@ -173,7 +84,7 @@ Config.AdminPanel = {
|
||||
}
|
||||
|
||||
Config.Sim = {
|
||||
Enabled = true, -- false: devices receive a persistent random number automatically; forced false for metadata-free inventories
|
||||
Enabled = true, -- false: devices receive a persistent random number automatically; hex/esx require false
|
||||
RegisteredItem = "sky_phone_sim_registered",
|
||||
AnonymousItem = "sky_phone_sim_anonymous",
|
||||
NumberLength = 10, -- total number of digits, including NumberPrefix
|
||||
@@ -190,7 +101,7 @@ Config.Speaker = {
|
||||
}
|
||||
|
||||
Config.Calls = {
|
||||
VoiceProvider = "pma", -- auto, yaca (alias: yaca-voice), pma (alias: pma-voice), saltychat (alias: salty)
|
||||
VoiceProvider = "pma", -- yaca (alias: yaca-voice), pma (alias: pma-voice), saltychat (alias: salty)
|
||||
RingSeconds = 30,
|
||||
ContactNameMaxLength = 80,
|
||||
ContactNotesMaxLength = 500,
|
||||
@@ -243,9 +154,8 @@ 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. Used when AllowEveryone is false.
|
||||
AllowedJobs = { -- Job name = minimum grade. Unlisted jobs cannot set a radio display name.
|
||||
police = 0,
|
||||
sheriff = 0,
|
||||
fib = 0,
|
||||
@@ -376,7 +286,6 @@ Config.DarkChat = {
|
||||
VoiceMaxBase64Length = 360000,
|
||||
VoiceWaveformSamples = 48,
|
||||
CleanupIntervalSeconds = 30,
|
||||
CleanupBatchSize = 250,
|
||||
AllowedDisappearTimers = {
|
||||
[0] = true,
|
||||
[-1] = true, -- after reading
|
||||
@@ -478,8 +387,8 @@ Config.Garage = {
|
||||
}
|
||||
|
||||
Config.Housing = {
|
||||
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" },
|
||||
System = "auto", -- auto, rtx, quasar, vms, rx, nolag, sn, esx_property, qbx_properties
|
||||
AutoPriority = { "esx_property", "qbx_properties", "rtx", "quasar", "vms", "rx", "nolag", "sn" },
|
||||
MaximumProperties = 50,
|
||||
OverviewRequestsPerMinute = 30,
|
||||
ActionsPerMinute = 12,
|
||||
@@ -574,6 +483,7 @@ Config.FlipTok = {
|
||||
MaxPostMedia = 10,
|
||||
MusicTracks = {},
|
||||
VerifyCommand = "fliptokverify",
|
||||
AdminGroups = { "admin" },
|
||||
ReportWebhookConvar = "sky_phone_fliptok_report_webhook",
|
||||
}
|
||||
|
||||
@@ -595,6 +505,7 @@ Config.Picstagram = {
|
||||
ReportDetailsMaxLength = 500,
|
||||
ReportReasons = { "spam", "harassment", "dangerous", "illegal", "other" },
|
||||
VerifyCommand = "picstagramverify",
|
||||
AdminGroups = { "admin" },
|
||||
}
|
||||
|
||||
Config.Feather = {
|
||||
@@ -1209,7 +1120,7 @@ if IsDuplicityVersion() then
|
||||
LogoUrl = "https://picsum.photos/seed/companies-police-logo/180/180",
|
||||
Description = "Public safety, emergency response, and police services.",
|
||||
DefaultAvailability = "closed",
|
||||
AcceptsRequests = true,
|
||||
AcceptsRequests = false,
|
||||
District = "Mission Row",
|
||||
LocationLabel = "Mission Row Police Station",
|
||||
Address = "Mission Row Police Station",
|
||||
@@ -1218,7 +1129,7 @@ if IsDuplicityVersion() then
|
||||
Number = "911",
|
||||
AutoContact = true,
|
||||
CanCall = true,
|
||||
CanMessage = true,
|
||||
CanMessage = false,
|
||||
Routing = "round_robin",
|
||||
MinimumGrade = 0,
|
||||
},
|
||||
@@ -1231,15 +1142,7 @@ if IsDuplicityVersion() then
|
||||
Services = 3,
|
||||
Announcement = 3,
|
||||
},
|
||||
Services = {
|
||||
{
|
||||
Id = "police-assistance",
|
||||
Title = "Police assistance",
|
||||
Description = "Request non-emergency police assistance.",
|
||||
Price = "",
|
||||
RequestsEnabled = true,
|
||||
},
|
||||
},
|
||||
Services = {},
|
||||
},
|
||||
ambulance = {
|
||||
Job = "ambulance",
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# 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,7 +43,6 @@ Locales["de"] = {
|
||||
invalid_sim = "Diese SIM-Karte besitzt ungültige Metadaten.",
|
||||
phone_required = "Du benötigst ein Handy in deinem Inventar.",
|
||||
operation_in_progress = "Eine andere Handy-Aktion wird bereits ausgeführt.",
|
||||
rate_limited = "Das Handy wurde zu oft geöffnet. Versuch es gleich erneut.",
|
||||
voice_unavailable = "Der konfigurierte Sprachdienst ist nicht verfügbar.",
|
||||
default = "Das Handy konnte nicht geöffnet werden.",
|
||||
},
|
||||
@@ -204,9 +203,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", 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" },
|
||||
tabs = { overview = "Übersicht", players = "Spieler", devices = "Geräte", apps = "Apps", accounts = "Accounts", messages = "Nachrichten", calls = "Anrufe", moderation = "Moderation", audit = "Audit", configurator = "Phone Configurator" },
|
||||
overview = { eyebrow = "Server", title = "Dashboard", body = "Spieler, Geräte, Apps und Handydaten.", stats = "Server-Handystatistik", online = "Online", devices = "Geräte", accounts = "Accounts", audit = "Audit-Einträge", control = "Navigation", features = "Module", featuresBody = "Öffne ein Verwaltungsmodul.", recent = "Letzte Aktivitäten", playerFeature = "Identität, Finanzen, Job und Dienst", deviceFeature = "IMEI, SIM, Nummer und Aktivität", appFeature = "Handy-Apps installieren oder entfernen", accountFeature = "Accountzugriff und geschützte Zugangsdaten", messageFeature = "Letzte SMS-Aktivitäten prüfen", callFeature = "Letzte Anrufaktivitäten prüfen", moderationFeature = "Zugriff, Nummer oder Gerätedaten zurücksetzen", auditFeature = "Sensible Admin-Aktionen prüfen", configuratorFeature = "config.lua und media.lua über SQL verwalten" },
|
||||
appearance = { eyebrow = "Darstellung", title = "Akzentfarbe", body = "Ändere den Akzent im gesamten Admin-Arbeitsbereich.", colors = { emerald = "Smaragd", blue = "Blau", violet = "Violett", orange = "Orange", red = "Rot" } },
|
||||
configurator = { context = "Runtime-Konfiguration", eyebrow = "Systemwerkzeug", sections = "Konfiguration", search = "Einstellungen oder Pfade suchen", configScope = "config.lua", mediaScope = "media.lua", noResults = "Keine passenden Einstellungen", loading = "SQL-Konfiguration wird geladen...", title = "Phone Configurator", body = "Verwalte Handy- und Media-Einstellungen im geschützten Admin-Bereich.", disabledTitle = "SQL-Konfiguration ist nicht aktiv", disabledBody = "Aktiviere den Configurator am Anfang der config.lua und starte sky_phone neu. Bis dahin bleiben die Dateiwerte aktiv und die Bearbeitung gesperrt.", manualSave = "Manuelles Speichern", refreshNotice = "Nichts wird automatisch gespeichert. Der grüne Haken prüft config.lua und media.lua in SQL und aktualisiert die aktive Server-, Client-, Media- und UI-Konfiguration sofort intern.", fieldCount = "{count} Felder", secretConfigured = "Secret gesetzt · Ersatzwert eingeben", invalidValue = "Prüfe den markierten Tabellen- oder Zahlenwert.", saved = "SQL-Konfiguration gespeichert und übernommen.", descriptions = { featureToggle = "Schaltet {name} ein oder aus.", boolean = "Legt fest, ob {name} erlaubt ist.", number = "Legt den Zahlenwert für {name} fest.", text = "Legt den Textwert für {name} fest.", optionalText = "Legt den optionalen Wert für {name} fest; der Schalter deaktiviert ihn.", list = "Verwaltet alle Einträge für {name}.", table = "Bündelt die zusammengehörigen Einstellungen für {name}.", credential = "Speichert den geschützten Zugangsschlüssel für {name}.", url = "Legt die URL oder den Endpunkt für {name} fest.", hosts = "Legt die erlaubten Domains für {name} fest.", milliseconds = "Legt die Zeit für {name} in Millisekunden fest.", seconds = "Legt die Zeit für {name} in Sekunden fest.", rateLimit = "Begrenzt die Aktionen für {name} pro Minute.", byteLimit = "Legt die maximal erlaubte Datengröße für {name} fest.", textLimit = "Legt die maximal erlaubte Textlänge für {name} fest.", distance = "Legt die Distanz in der Spielwelt für {name} fest.", coordinates = "Legt Weltkoordinaten oder Ausrichtung für {name} fest.", gameAsset = "Legt das GTA-Modell oder Prop für {name} fest.", animation = "Legt die Animationsdatei für {name} fest.", access = "Legt Jobs, Gruppen oder Berechtigungsstufen für {name} fest.", integration = "Wählt Framework oder Anbieter für {name} aus.", path = "Legt den Speicher- oder Ressourcenpfad für {name} fest.", color = "Legt die Oberflächenfarbe für {name} fest.", displayText = "Legt den Spielern angezeigten Text für {name} fest.", phoneNumber = "Legt die Telefon- oder Servicenummer für {name} fest.", routing = "Steuert die Verteilung eingehender Anfragen für {name}.", command = "Legt den Chat-Befehl zum Öffnen oder Ausführen von {name} fest.", locale = "Wählt die Sprache für {name} aus.", debug = "Steuert ausführliche Diagnoseausgaben für {name}.", mediaQuality = "Legt Medienqualität oder Lautstärke für {name} fest.", amount = "Legt die maximale oder angezeigte Menge für {name} fest." }, table = { list = "Liste", table = "Schlüsseltabelle", vector = "Vektor", entry = "Eintrag", general = "Allgemein", addRow = "Zeile hinzufügen", addField = "Feld hinzufügen", remove = "Entfernen", emptyList = "Noch keine Zeilen. Füge die erste Zeile mit Plus hinzu.", emptyTable = "Noch keine Felder. Füge unten den ersten Schlüssel hinzu.", keyPlaceholder = "Neuer Schlüssel", convertToList = "Als Liste nutzen", convertToMap = "Als typisierte Schlüsseltabelle nutzen", convertToTable = "Als Schlüsseltabelle nutzen", types = { string = "Text", number = "Zahl", boolean = "Schalter", list = "Liste", table = "Tabelle" } } },
|
||||
players = { eyebrow = "Aktive Sitzungen", title = "Online-Spieler", online = "Jetzt online", empty = "Keine Spieler gefunden", emptyBody = "Passe die Suche an oder aktualisiere die Spielerliste." },
|
||||
search = { players = "Name, ID, Job oder Nummer suchen", apps = "Apps suchen", clear = "Suche leeren" },
|
||||
@@ -222,7 +221,7 @@ Locales["de"] = {
|
||||
},
|
||||
audit = { eyebrow = "Nachvollziehbarkeit", title = "Audit-Verlauf", body = "Sensible Anzeigen und App-Änderungen werden hier protokolliert.", empty = "Noch keine Admin-Aktionen", emptyBody = "Geschützte Aktionen erscheinen hier, nachdem sie ausgeführt wurden.", by = "{actor} · Ziel-ID {target}", actions = { grant_app = "App installiert", revoke_app = "App entfernt", reveal_account_password = "Passwort angezeigt", view_messages = "Nachrichten angesehen", view_calls = "Anrufe angesehen", reset_passcode = "Gerätecode zurückgesetzt", change_number = "Telefonnummer geändert", factory_reset = "Werksreset ausgeführt", save_configuration = "Konfiguration gespeichert" } },
|
||||
editor = { brand = "SKY PHONE", workspace = "ADMIN", players = "Spielerverzeichnis", audit = "Audit-Protokoll", selectPlayer = "Wähle einen Spieler, um Identität, Geräte, Zugangsdaten und App-Zugriffe zu prüfen.", save = "Änderungen speichern", saveHint = "Offene Änderungen übernehmen", saved = "Änderungen gespeichert.", unsaved = "Ungespeicherte Änderungen", close = "Admin-Panel schließen", refresh = "Live-Daten aktualisieren", online = "LIVE", profile = "PROFIL", financial = "FINANZEN", device = "GERÄT", security = "SICHERHEIT", noAutoSave = "Manuelles Speichern", noAutoSaveBody = "Änderungen bleiben lokal, bis der grüne Haken gedrückt wird.", discardTitle = "Ungespeicherte Änderungen verwerfen?", discardBody = "Deine vorgemerkten App- oder Konfigurationsänderungen wurden noch nicht gespeichert.", keepEditing = "Weiter bearbeiten", discard = "Änderungen verwerfen", saveFailed = "Einige Änderungen konnten nicht gespeichert werden.", noSelection = "Kein Spieler ausgewählt" },
|
||||
errors = { not_authorized = "Du hast keinen Zugriff auf das Admin-Panel.", rate_limited = "Zu viele Admin-Anfragen. Bitte warte.", player_unavailable = "Dieser Spieler ist nicht mehr online.", device_not_owned = "Dieses Handy gehört nicht mehr zum ausgewählten Spieler.", invalid_app = "Diese App ist nicht auf dem Server registriert.", app_protected = "Diese System-App kann nicht entfernt werden.", revision_conflict = "Die Daten wurden zwischenzeitlich geändert. Öffne den Bereich neu und versuche es erneut.", configurator_disabled = "Aktiviere zuerst den Phone Configurator in der config.lua.", invalid_field = "Dieses Konfigurationsfeld ist nicht mehr verfügbar.", invalid_value = "Ein Konfigurationswert ist ungültig.", invalid_company_configuration = "Die Firmenkonfiguration ist unvollständig oder enthält einen doppelten Job, eine doppelte Servicenummer oder Service-ID.", account_not_found = "Mit diesem Handy ist kein iFruit-Account verknüpft.", invalid_phone_number = "Gib eine Telefonnummer im konfigurierten Serverformat ein.", phone_number_unchanged = "Diese SIM verwendet diese Telefonnummer bereits.", phone_number_taken = "Diese Telefonnummer ist bereits vergeben.", no_sim = "Dieses Handy besitzt keine änderbare SIM.", passcode_not_set = "Für dieses Handy ist kein Gerätecode eingerichtet.", device_not_found = "Dieses Handy existiert nicht mehr.", metadata_unsupported = "Die Inventar-Metadaten des Handys konnten nicht aktualisiert werden.", invalid_request = "Die Admin-Anfrage war ungültig.", request_failed = "Die Admin-Anfrage ist fehlgeschlagen.", default = "Das Admin-Panel ist vorübergehend nicht verfügbar." },
|
||||
errors = { not_authorized = "Du hast keinen Zugriff auf das Admin-Panel.", rate_limited = "Zu viele Admin-Anfragen. Bitte warte.", player_unavailable = "Dieser Spieler ist nicht mehr online.", device_not_owned = "Dieses Handy gehört nicht mehr zum ausgewählten Spieler.", invalid_app = "Diese App ist nicht auf dem Server registriert.", app_protected = "Diese System-App kann nicht entfernt werden.", revision_conflict = "Die Daten wurden zwischenzeitlich geändert. Öffne den Bereich neu und versuche es erneut.", configurator_disabled = "Aktiviere zuerst den Phone Configurator in der config.lua.", invalid_field = "Dieses Konfigurationsfeld ist nicht mehr verfügbar.", invalid_value = "Ein Konfigurationswert ist ungültig.", account_not_found = "Mit diesem Handy ist kein iFruit-Account verknüpft.", invalid_phone_number = "Gib eine Telefonnummer im konfigurierten Serverformat ein.", phone_number_unchanged = "Diese SIM verwendet diese Telefonnummer bereits.", phone_number_taken = "Diese Telefonnummer ist bereits vergeben.", no_sim = "Dieses Handy besitzt keine änderbare SIM.", passcode_not_set = "Für dieses Handy ist kein Gerätecode eingerichtet.", device_not_found = "Dieses Handy existiert nicht mehr.", metadata_unsupported = "Die Inventar-Metadaten des Handys konnten nicht aktualisiert werden.", invalid_request = "Die Admin-Anfrage war ungültig.", request_failed = "Die Admin-Anfrage ist fehlgeschlagen.", default = "Das Admin-Panel ist vorübergehend nicht verfügbar." },
|
||||
},
|
||||
Apps = {
|
||||
health = {
|
||||
@@ -695,7 +694,6 @@ Locales["de"] = {
|
||||
gif_provider_rate_limited = "GIPHY ist ausgelastet. Versuch es gleich erneut.", gif_provider_failed = "GIF-Suche ist vorübergehend nicht verfügbar.",
|
||||
self_message = "Du kannst deine eigene Nummer nicht melden.", recipient_not_found = "Diese Nummer ist nicht verfügbar.", blocked = "Dieser Kontakt hat Anrufe und Nachrichten von deinem SIM blockiert.",
|
||||
messaging_unavailable = "Dieser Firmenkontakt akzeptiert keine Nachrichten.",
|
||||
service_line_text_only = "Firmen-Serviceleitungen akzeptieren derzeit nur Textnachrichten.",
|
||||
no_sim = "Dieses Handy hat keine SIM-Karte.", rate_limited = "Zu viele Nachrichten. Versuch es in einer Minute erneut.",
|
||||
request_failed = "Nachrichten sind vorübergehend nicht verfügbar.", default = "Die Nachricht konnte nicht gesendet werden.",
|
||||
},
|
||||
@@ -1229,7 +1227,7 @@ Locales["de"] = {
|
||||
},
|
||||
skyride = {
|
||||
name = "SkyRide", loading = "Laden SkyRide", unavailable = "SkyRide nicht verfügbar", tryAgain = "Erneut versuchen",
|
||||
mode = { rider = "Passagier", driver = "Fahrer" },
|
||||
mode = { rider = "Reiten", driver = "Antrieb" },
|
||||
riderEyebrow = "Fahr irgendwohin.", whereTo = "Wohin?", pickup = "Abholung", destination = "Ziel",
|
||||
currentLocation = "Aktueller Standort", notSelected = "Nicht ausgewählt", location = "Wähle einen Ort",
|
||||
chooseLocation = { pickup = "Abholvorgang einstellen", destination = "Ziel auswählen" },
|
||||
@@ -1450,8 +1448,8 @@ Locales["de"] = {
|
||||
camera = {
|
||||
name = "Kamera", flash = "Blitz", flip = "Kamera wechseln", landscape = "Zum Querformat wechseln",
|
||||
portrait = "Zum Hochformat wechseln", photo = "Foto", video = "Video", microphoneOn = "Mikrofon eingeschaltet", microphoneOff = "Mikrofon stumm",
|
||||
focusHelp = "Halte die Umschautaste gedrückt, um dich umzusehen", returnHelp = "Umschautaste loslassen für Steuerung", lockCamera = "Kamerabewegung sperren",
|
||||
unlockCamera = "Kamerabewegung entsperren", lookKey = "Umschauen", uploading = "Upload: {count}",
|
||||
focusHelp = "Halte die Leertaste gedrückt, um dich umzusehen", returnHelp = "Leertaste für Steuerung", lockCamera = "Kamerabewegung sperren",
|
||||
unlockCamera = "Kamerabewegung entsperren", spaceKey = "Leertaste", uploading = "Upload: {count}",
|
||||
saving = "Video speichern...", openGallery = "Fotos öffnen", takePhoto = "Foto machen",
|
||||
startRecording = "Aufnahme starten", stopRecording = "Aufnahme beenden", saved = "Auf Fotos gespeichert.",
|
||||
zoom = "Kamerazoom auf {zoom} einstellen",
|
||||
@@ -2022,11 +2020,10 @@ Locales["de"] = {
|
||||
},
|
||||
account = {
|
||||
account = "Account", title = "App-Verwaltung", skyAccount = "Sky Phone Konto",
|
||||
apps = "Installierte Apps", games = "Spiele", library = "Deine Bibliothek.", myApps = "Meine Apps",
|
||||
apps = "Installation von 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}",
|
||||
@@ -2141,9 +2138,8 @@ 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",
|
||||
@@ -2180,46 +2176,29 @@ Locales["de"].Nui.AdminPanel.configurator.table.subtabs = {
|
||||
AllowedJobs = "Erlaubte Jobs",
|
||||
Websites = "Webseiten",
|
||||
}
|
||||
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.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.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,7 +43,6 @@ Locales["en"] = {
|
||||
invalid_sim = "This SIM card has invalid metadata.",
|
||||
phone_required = "You need a phone in your inventory.",
|
||||
operation_in_progress = "Another phone operation is already in progress.",
|
||||
rate_limited = "The phone was opened too often. Try again in a moment.",
|
||||
voice_unavailable = "The configured phone voice service is unavailable.",
|
||||
default = "The phone could not be opened.",
|
||||
},
|
||||
@@ -204,9 +203,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", 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" },
|
||||
tabs = { overview = "Overview", players = "Players", devices = "Devices", apps = "Apps", accounts = "Accounts", messages = "Messages", calls = "Calls", moderation = "Moderation", audit = "Audit", configurator = "Phone configurator" },
|
||||
overview = { eyebrow = "Server", title = "Dashboard", body = "Players, devices, apps, and phone data.", stats = "Server phone statistics", online = "Online", devices = "Devices", accounts = "Accounts", audit = "Audit entries", control = "Navigation", features = "Modules", featuresBody = "Open an administration module.", recent = "Recent activity", playerFeature = "Identity, finances, job, and duty", deviceFeature = "IMEI, SIM, number, and activity", appFeature = "Install or remove phone apps", accountFeature = "Account access and protected credentials", messageFeature = "Review recent SMS activity", callFeature = "Review recent call activity", moderationFeature = "Reset access, number, or device data", auditFeature = "Review sensitive admin actions", configuratorFeature = "Manage config.lua and media.lua through SQL" },
|
||||
appearance = { eyebrow = "Appearance", title = "Accent color", body = "Change the accent across the complete admin workspace.", colors = { emerald = "Emerald", blue = "Blue", violet = "Violet", orange = "Orange", red = "Red" } },
|
||||
configurator = { context = "Runtime configuration", eyebrow = "System tool", sections = "Configuration", search = "Search settings or paths", configScope = "config.lua", mediaScope = "media.lua", noResults = "No matching settings", loading = "Loading SQL configuration...", title = "Phone configurator", body = "Manage phone and media settings from the protected admin workspace.", disabledTitle = "SQL configuration is not active", disabledBody = "Enable the configurator at the beginning of config.lua and restart sky_phone. Until then, file values remain active and editing is locked.", manualSave = "Manual save", refreshNotice = "Nothing is written automatically. The green check verifies config.lua and media.lua in SQL and refreshes the active server, client, media and UI configuration immediately.", fieldCount = "{count} fields", secretConfigured = "Secret configured · enter a replacement", invalidValue = "Check the highlighted table or number value.", saved = "SQL configuration saved and applied.", descriptions = { featureToggle = "Turns {name} on or off.", boolean = "Controls whether {name} is allowed.", number = "Sets the numeric value for {name}.", text = "Sets the text value used for {name}.", optionalText = "Sets the optional value for {name}; switch it off to disable it.", list = "Manages all entries used for {name}.", table = "Groups the related settings for {name}.", credential = "Stores the protected credential used by {name}.", url = "Sets the URL or endpoint used by {name}.", hosts = "Defines which domains are allowed for {name}.", milliseconds = "Sets the timing for {name} in milliseconds.", seconds = "Sets the timing for {name} in seconds.", rateLimit = "Limits how many {name} actions are allowed per minute.", byteLimit = "Sets the maximum data size allowed for {name}.", textLimit = "Sets the maximum text length allowed for {name}.", distance = "Sets the world distance used for {name}.", coordinates = "Sets the world coordinates or orientation for {name}.", gameAsset = "Sets the GTA model or prop used for {name}.", animation = "Sets the animation asset used for {name}.", access = "Defines the jobs, groups or permission level for {name}.", integration = "Selects the connected framework or provider for {name}.", path = "Sets the storage or resource path used for {name}.", color = "Sets the interface color used for {name}.", displayText = "Sets the text shown to players for {name}.", phoneNumber = "Sets the phone or service number used for {name}.", routing = "Controls how incoming requests are routed for {name}.", command = "Sets the chat command used to open or run {name}.", locale = "Selects the language used for {name}.", debug = "Controls detailed diagnostic output for {name}.", mediaQuality = "Sets the media quality or volume used for {name}.", amount = "Sets the maximum or displayed amount for {name}." }, table = { list = "List", table = "Key table", vector = "Vector", entry = "Entry", general = "General", addRow = "Add row", addField = "Add field", remove = "Remove", emptyList = "No rows yet. Add the first row with plus.", emptyTable = "No fields yet. Add the first key below.", keyPlaceholder = "New key", convertToList = "Use list", convertToMap = "Use typed key table", convertToTable = "Use key table", types = { string = "Text", number = "Number", boolean = "Switch", list = "List", table = "Table" } } },
|
||||
players = { eyebrow = "Active sessions", title = "Online players", online = "Online now", empty = "No players found", emptyBody = "Adjust the search or refresh the live player list." },
|
||||
search = { players = "Search name, ID, job, or number", apps = "Search apps", clear = "Clear search" },
|
||||
@@ -222,7 +221,7 @@ Locales["en"] = {
|
||||
},
|
||||
audit = { eyebrow = "Accountability", title = "Audit trail", body = "Sensitive reveals and remote app changes are recorded here.", empty = "No admin actions yet", emptyBody = "Protected actions will appear here after they are performed.", by = "{actor} · target ID {target}", actions = { grant_app = "App installed", revoke_app = "App removed", reveal_account_password = "Password revealed", view_messages = "Messages viewed", view_calls = "Calls viewed", reset_passcode = "Passcode reset", change_number = "Phone number changed", factory_reset = "Phone factory reset", save_configuration = "Configuration saved" } },
|
||||
editor = { brand = "SKY PHONE", workspace = "ADMIN", players = "Player directory", audit = "Audit log", selectPlayer = "Select a player to inspect identity, devices, credentials, and app access.", save = "Save changes", saveHint = "Apply pending changes", saved = "Changes saved.", unsaved = "Unsaved changes", close = "Close admin panel", refresh = "Refresh live data", online = "LIVE", profile = "PROFILE", financial = "FINANCIAL", device = "DEVICE", security = "SECURITY", noAutoSave = "Manual save", noAutoSaveBody = "Changes stay local until the green check is pressed.", discardTitle = "Discard unsaved changes?", discardBody = "Your staged app or configuration changes have not been saved.", keepEditing = "Keep editing", discard = "Discard changes", saveFailed = "Some changes could not be saved.", noSelection = "No player selected" },
|
||||
errors = { not_authorized = "You do not have access to the admin panel.", rate_limited = "Too many admin requests. Please wait.", player_unavailable = "That player is no longer online.", device_not_owned = "That phone no longer belongs to the selected player.", invalid_app = "That app is not registered on the server.", app_protected = "This system app cannot be removed.", revision_conflict = "The data changed in the meantime. Reopen the section and try again.", configurator_disabled = "Enable the phone configurator in config.lua first.", invalid_field = "That configuration field is no longer available.", invalid_value = "A configuration value is invalid.", invalid_company_configuration = "The company configuration is incomplete or contains a duplicate job, service number, or service ID.", account_not_found = "No iFruit account is linked to this phone.", invalid_phone_number = "Enter a phone number in the configured server format.", phone_number_unchanged = "This SIM already uses that phone number.", phone_number_taken = "That phone number is already assigned.", no_sim = "This phone has no SIM that can be changed.", passcode_not_set = "This phone has no passcode configured.", device_not_found = "This phone no longer exists.", metadata_unsupported = "The phone inventory metadata could not be updated.", invalid_request = "The admin request was invalid.", request_failed = "The admin request failed.", default = "The admin panel is temporarily unavailable." },
|
||||
errors = { not_authorized = "You do not have access to the admin panel.", rate_limited = "Too many admin requests. Please wait.", player_unavailable = "That player is no longer online.", device_not_owned = "That phone no longer belongs to the selected player.", invalid_app = "That app is not registered on the server.", app_protected = "This system app cannot be removed.", revision_conflict = "The data changed in the meantime. Reopen the section and try again.", configurator_disabled = "Enable the phone configurator in config.lua first.", invalid_field = "That configuration field is no longer available.", invalid_value = "A configuration value is invalid.", account_not_found = "No iFruit account is linked to this phone.", invalid_phone_number = "Enter a phone number in the configured server format.", phone_number_unchanged = "This SIM already uses that phone number.", phone_number_taken = "That phone number is already assigned.", no_sim = "This phone has no SIM that can be changed.", passcode_not_set = "This phone has no passcode configured.", device_not_found = "This phone no longer exists.", metadata_unsupported = "The phone inventory metadata could not be updated.", invalid_request = "The admin request was invalid.", request_failed = "The admin request failed.", default = "The admin panel is temporarily unavailable." },
|
||||
},
|
||||
Apps = {
|
||||
health = {
|
||||
@@ -695,7 +694,6 @@ Locales["en"] = {
|
||||
gif_provider_rate_limited = "GIPHY is busy. Try again in a moment.", gif_provider_failed = "GIF search is temporarily unavailable.",
|
||||
self_message = "You cannot message your own number.", recipient_not_found = "That number is unavailable.", blocked = "This contact has blocked calls and messages from your SIM.",
|
||||
messaging_unavailable = "This company contact does not accept messages.",
|
||||
service_line_text_only = "Company service lines currently accept text messages only.",
|
||||
no_sim = "This phone has no SIM card.", rate_limited = "Too many messages. Try again in a minute.",
|
||||
request_failed = "Messages are temporarily unavailable.", default = "The message could not be sent.",
|
||||
},
|
||||
@@ -1450,8 +1448,8 @@ Locales["en"] = {
|
||||
camera = {
|
||||
name = "Camera", flash = "Flash", flip = "Flip camera", landscape = "Switch to landscape",
|
||||
portrait = "Switch to portrait", photo = "Photo", video = "Video", microphoneOn = "Microphone on", microphoneOff = "Microphone muted",
|
||||
focusHelp = "Hold the look key to look around", returnHelp = "Release the look key for controls", lockCamera = "Lock camera movement",
|
||||
unlockCamera = "Unlock camera movement", lookKey = "Look", uploading = "{count} uploading",
|
||||
focusHelp = "Hold Space to look around", returnHelp = "Release Space for controls", lockCamera = "Lock camera movement",
|
||||
unlockCamera = "Unlock camera movement", spaceKey = "Space", uploading = "{count} uploading",
|
||||
saving = "Saving video...", openGallery = "Open Photos", takePhoto = "Take photo",
|
||||
startRecording = "Start recording", stopRecording = "Stop recording", saved = "Saved to Photos.",
|
||||
zoom = "Set camera zoom to {zoom}",
|
||||
@@ -2026,7 +2024,6 @@ 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}",
|
||||
@@ -2141,9 +2138,8 @@ 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",
|
||||
@@ -2180,46 +2176,29 @@ Locales["en"].Nui.AdminPanel.configurator.table.subtabs = {
|
||||
AllowedJobs = "Allowed Jobs",
|
||||
Websites = "Websites",
|
||||
}
|
||||
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.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.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"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,25 +2,19 @@
|
||||
Sky Phone media configuration
|
||||
|
||||
This file is loaded on the server only. Keep API keys private. When the
|
||||
phone configurator is enabled in config.lua, the active media configuration
|
||||
is loaded from SQL and frontend builds copy the shipped values from this
|
||||
file into source/shared/config_default.lua.
|
||||
phone configurator is enabled in config.lua, these values are first-run
|
||||
defaults and the active media configuration is loaded from SQL.
|
||||
]]
|
||||
|
||||
Config.Media = {
|
||||
-- Create or sign in to an account at https://developers.giphy.com/, open
|
||||
-- the Developer Dashboard, choose "Create an API Key", and paste it here.
|
||||
GiphyApiKey = "",
|
||||
GiphyApiKey = "", -- Paste the GIPHY API key here.
|
||||
GifPageSize = 24,
|
||||
GifRating = "pg-13",
|
||||
UrlMaxLength = 2048,
|
||||
AllowedGifHosts = { "giphy.com" },
|
||||
|
||||
-- Create or sign in at https://fivemanage.com/, create or select your team,
|
||||
-- then open Dashboard > Tokens and create a token with Media access. Keep
|
||||
-- this server-only token private and paste it here.
|
||||
FiveManage = {
|
||||
ApiKey = "",
|
||||
ApiKey = "", -- Paste a newly generated FiveManage V3 Media API token here.
|
||||
BaseUrl = "https://api.fivemanage.com/api/v3/file",
|
||||
RequestTimeoutMs = 10000,
|
||||
UploadTimeoutMs = 25000,
|
||||
|
||||
@@ -6,7 +6,7 @@ use_experimental_fxv2_oal 'yes'
|
||||
|
||||
author 'Sky-Systems'
|
||||
description 'Sky Phone'
|
||||
version '0.3.5'
|
||||
version '0.2.5'
|
||||
|
||||
provide 'lb-phone'
|
||||
provide '17mov_Phone'
|
||||
@@ -30,7 +30,8 @@ shared_scripts {
|
||||
|
||||
client_scripts {
|
||||
'config/config.lua',
|
||||
'config/locales/*.lua',
|
||||
'config/locales/en.lua',
|
||||
'config/locales/de.lua',
|
||||
'source/bridge/client/callbacks.lua',
|
||||
'source/client/phone_configurator.lua',
|
||||
'source/bridge/client/framework.lua',
|
||||
@@ -52,7 +53,6 @@ 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',
|
||||
@@ -77,8 +77,6 @@ server_scripts {
|
||||
'config/media.lua',
|
||||
'config/locales/en.lua',
|
||||
'config/locales/de.lua',
|
||||
'config/locales/es.lua',
|
||||
'source/shared/config_default.lua',
|
||||
'source/server/nui_build_check.lua',
|
||||
'source/server/update_check.lua',
|
||||
'source/bridge/server/database.lua',
|
||||
@@ -106,7 +104,6 @@ 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,15 +11,6 @@ local provider_aliases = {
|
||||
|
||||
local function resolve_provider()
|
||||
local configured = tostring(Config.Calls.VoiceProvider or "")
|
||||
if configured == "auto" then
|
||||
for _, candidate in ipairs({ "yaca", "pma", "saltychat" }) do
|
||||
if GetResourceState(provider_resources[candidate]) == "started" then
|
||||
return candidate
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local selected = provider_aliases[configured] or configured
|
||||
local resource_name = provider_resources[selected]
|
||||
if resource_name and GetResourceState(resource_name) == "started" then
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
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,23 +19,3 @@ Bridge.Framework.Name = configured_framework
|
||||
function Bridge.Framework.GetName()
|
||||
return Bridge.Framework.Name
|
||||
end
|
||||
|
||||
function Bridge.Framework.HasPermission(source, permission)
|
||||
if type(permission) ~= "string" or permission == "" then
|
||||
error("[sky_phone] Permission identifiers must be non-empty strings.")
|
||||
end
|
||||
|
||||
local groups = Config.CommandPermissions[permission]
|
||||
if type(groups) ~= "table" or #groups == 0 then
|
||||
local message = "[sky_phone] Config.CommandPermissions.%s must contain at least one group."
|
||||
error(message:format(permission))
|
||||
end
|
||||
for index, group in ipairs(groups) do
|
||||
if type(group) ~= "string" or group == "" then
|
||||
local message = "[sky_phone] Config.CommandPermissions.%s[%s] must be a non-empty string."
|
||||
error(message:format(permission, index))
|
||||
end
|
||||
end
|
||||
|
||||
return Bridge.Framework.HasAdminGroup(source, groups)
|
||||
end
|
||||
|
||||
@@ -24,13 +24,6 @@ function Bridge.Framework.HasAdminGroup(source, groups)
|
||||
if not player then
|
||||
return false
|
||||
end
|
||||
|
||||
for _, group in ipairs(groups) do
|
||||
if IsPlayerAceAllowed(tostring(source), group) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
return exports.qbx_core:HasGroup(source, groups)
|
||||
end
|
||||
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
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,
|
||||
})
|
||||
@@ -1,92 +1,60 @@
|
||||
local inventory_adapters = {
|
||||
{ name = "jaksam", resource = "jaksam_inventory" },
|
||||
{ name = "qs", resource = "qs-inventory" },
|
||||
{ name = "ps", resource = "ps-inventory", framework = "qb" },
|
||||
{ name = "codem", resource = "codem-inventory" },
|
||||
{ name = "tgiann", resource = "tgiann-inventory" },
|
||||
{ name = "core", resource = "core_inventory" },
|
||||
{ name = "jpr", resource = "jpr-inventory", framework = "qb" },
|
||||
{ name = "origen", resource = "origen_inventory" },
|
||||
{ name = "ak47", resource = "ak47_inventory" },
|
||||
{ name = "one", resource = "one_inventory" },
|
||||
{ name = "ox", resource = "ox_inventory" },
|
||||
{ name = "mf", resource = "mf-inventory", framework = "esx" },
|
||||
{ name = "smx", resource = "smx-inventory", framework = "esx" },
|
||||
{ name = "lj", resource = "lj-inventory" },
|
||||
{ name = "qb", resource = "qb-inventory" },
|
||||
{ name = "hex", resource = "hex_4_inventory", framework = "esx", metadata = false },
|
||||
{ name = "esx", resource = "es_extended", framework = "esx", metadata = false },
|
||||
}
|
||||
|
||||
local inventory_aliases = {
|
||||
["qb-inv"] = "qb",
|
||||
qbox = "ox",
|
||||
}
|
||||
local supported_inventories = {}
|
||||
for _, adapter in ipairs(inventory_adapters) do
|
||||
inventory_aliases[adapter.resource] = adapter.name
|
||||
supported_inventories[adapter.name] = adapter
|
||||
end
|
||||
|
||||
local configured_inventory = Config.Bridge.Inventory
|
||||
configured_inventory = inventory_aliases[configured_inventory] or configured_inventory
|
||||
local framework_name = Bridge.Framework.GetName()
|
||||
|
||||
if configured_inventory == "auto" then
|
||||
for _, adapter in ipairs(inventory_adapters) do
|
||||
local compatible_framework = not adapter.framework or adapter.framework == framework_name
|
||||
if adapter.name ~= "esx" and compatible_framework and GetResourceState(adapter.resource) == "started" then
|
||||
configured_inventory = adapter.name
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if configured_inventory == "auto" and framework_name == "esx" then
|
||||
if GetResourceState("ox_inventory") == "started" then
|
||||
configured_inventory = "ox"
|
||||
elseif GetResourceState("qb-inventory") == "started" then
|
||||
configured_inventory = "qb"
|
||||
elseif GetResourceState("lj-inventory") == "started" then
|
||||
configured_inventory = "lj"
|
||||
elseif GetResourceState("qs-inventory") == "started" then
|
||||
configured_inventory = "qs"
|
||||
elseif GetResourceState("codem-inventory") == "started" then
|
||||
configured_inventory = "codem"
|
||||
elseif GetResourceState("core_inventory") == "started" then
|
||||
configured_inventory = "core"
|
||||
elseif GetResourceState("mf-inventory") == "started" then
|
||||
configured_inventory = "mf"
|
||||
elseif GetResourceState("smx-inventory") == "started" then
|
||||
configured_inventory = "smx"
|
||||
elseif GetResourceState("hex_4_inventory") == "started" then
|
||||
configured_inventory = "hex"
|
||||
elseif Bridge.Framework.GetName() == "esx" then
|
||||
configured_inventory = "esx"
|
||||
end
|
||||
end
|
||||
|
||||
local selected_adapter = supported_inventories[configured_inventory]
|
||||
if not selected_adapter then
|
||||
local supported_inventories = {
|
||||
ox = true,
|
||||
qb = true,
|
||||
lj = true,
|
||||
qs = true,
|
||||
codem = true,
|
||||
core = true,
|
||||
mf = true,
|
||||
smx = true,
|
||||
hex = true,
|
||||
esx = true,
|
||||
}
|
||||
|
||||
if not supported_inventories[configured_inventory] then
|
||||
error(("[sky_phone] Unsupported or unavailable inventory '%s'. Configure a supported inventory adapter."):format(tostring(configured_inventory)))
|
||||
end
|
||||
if GetResourceState(selected_adapter.resource) ~= "started" then
|
||||
error(("[sky_phone] Inventory '%s' is configured, but resource '%s' is not started.")
|
||||
:format(tostring(configured_inventory), selected_adapter.resource))
|
||||
end
|
||||
if selected_adapter.framework and selected_adapter.framework ~= framework_name then
|
||||
error(("[sky_phone] Inventory '%s' is only supported with framework '%s'.")
|
||||
:format(tostring(configured_inventory), selected_adapter.framework))
|
||||
end
|
||||
|
||||
Bridge.Inventory.Name = configured_inventory
|
||||
|
||||
local metadata_free_inventory = selected_adapter.metadata == false
|
||||
|
||||
local function enforce_metadata_compatibility()
|
||||
if not metadata_free_inventory then
|
||||
return
|
||||
end
|
||||
|
||||
local modes_changed = Config.Phone.Unique ~= false or Config.Sim.Enabled ~= false
|
||||
Config.Phone.Unique = false
|
||||
Config.Sim.Enabled = false
|
||||
|
||||
if modes_changed then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] Inventory '%s' does not support item metadata; unique phones and physical SIM cards were disabled automatically.",
|
||||
configured_inventory
|
||||
)
|
||||
if configured_inventory == "hex" or configured_inventory == "esx" then
|
||||
if Bridge.Framework.GetName() ~= "esx" then
|
||||
Bridge.Inventory.ConfigurationError =
|
||||
("[sky_phone] Inventory '%s' is only supported with ESX."):format(configured_inventory)
|
||||
elseif Config.Phone.Unique ~= false or Config.Sim.Enabled ~= false then
|
||||
Bridge.Inventory.ConfigurationError = ("[sky_phone] Inventory '%s' cannot store unique phone or physical SIM metadata. Set Config.Phone.Unique = false and Config.Sim.Enabled = false, or configure a metadata-capable inventory.")
|
||||
:format(configured_inventory)
|
||||
end
|
||||
end
|
||||
|
||||
enforce_metadata_compatibility()
|
||||
|
||||
AddEventHandler("sky_phone:configurator:serverUpdated", enforce_metadata_compatibility)
|
||||
|
||||
function Bridge.Inventory.NormalizeItem(item, metadata_field, fallback_slot)
|
||||
if type(item) ~= "table" then
|
||||
function Bridge.Inventory.NormalizeItem(item, metadata_field)
|
||||
if not item then
|
||||
return nil
|
||||
end
|
||||
|
||||
@@ -95,15 +63,11 @@ function Bridge.Inventory.NormalizeItem(item, metadata_field, fallback_slot)
|
||||
metadata = item.metadata or item.info
|
||||
end
|
||||
|
||||
local slot = item.slot or item.id or fallback_slot
|
||||
local numeric_slot = tonumber(slot)
|
||||
local count = tonumber(item.count or item.amount or item.quantity) or 0
|
||||
|
||||
return {
|
||||
name = item.name or item.item,
|
||||
slot = numeric_slot or slot,
|
||||
count = count,
|
||||
amount = count,
|
||||
name = item.name,
|
||||
slot = item.slot or item.id,
|
||||
count = tonumber(item.count or item.amount) or 0,
|
||||
amount = tonumber(item.amount or item.count) or 0,
|
||||
metadata = type(metadata) == "table" and metadata or {},
|
||||
}
|
||||
end
|
||||
@@ -120,13 +84,3 @@ function Bridge.Inventory.MetadataMatches(actual, expected)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function Bridge.Inventory.ResolveUsableItem(...)
|
||||
for index = select("#", ...), 1, -1 do
|
||||
local candidate = select(index, ...)
|
||||
if type(candidate) == "table" and (candidate.name or candidate.item) then
|
||||
return candidate
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
if Bridge.Inventory.Name ~= "ak47" then
|
||||
return
|
||||
end
|
||||
|
||||
local inventory = exports["ak47_inventory"]
|
||||
|
||||
local function normalize(item, fallback_slot)
|
||||
local normalized = Bridge.Inventory.NormalizeItem(item, "info", fallback_slot)
|
||||
if not normalized or not normalized.name or normalized.amount < 1 then
|
||||
return nil
|
||||
end
|
||||
return normalized
|
||||
end
|
||||
|
||||
function Bridge.Inventory.GetResourceName()
|
||||
return "ak47_inventory"
|
||||
end
|
||||
|
||||
function Bridge.Inventory.GetSlot(source, slot_id)
|
||||
local identifier = Bridge.Framework.GetIdentifier(source)
|
||||
local numeric_slot = tonumber(slot_id)
|
||||
if not identifier or not numeric_slot then
|
||||
return nil
|
||||
end
|
||||
return normalize(inventory:GetSlot(identifier, numeric_slot), numeric_slot)
|
||||
end
|
||||
|
||||
function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
|
||||
local identifier = Bridge.Framework.GetIdentifier(source)
|
||||
if not identifier then
|
||||
return {}
|
||||
end
|
||||
|
||||
local matches = {}
|
||||
local filter_metadata = type(metadata) == "table" and metadata or nil
|
||||
for index, item in pairs(inventory:GetSlotsWithItem(identifier, item_name, filter_metadata, filter_metadata ~= nil) or {}) do
|
||||
local normalized = normalize(item, tonumber(index) or index)
|
||||
if normalized and normalized.name == item_name
|
||||
and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then
|
||||
matches[#matches + 1] = normalized
|
||||
end
|
||||
end
|
||||
return matches
|
||||
end
|
||||
|
||||
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
|
||||
local identifier = Bridge.Framework.GetIdentifier(source)
|
||||
local slot = identifier and Bridge.Inventory.GetSlot(source, slot_id) or nil
|
||||
if not slot then
|
||||
return false
|
||||
end
|
||||
|
||||
local requested_metadata = type(metadata) == "table" and metadata or {}
|
||||
inventory:SetItemInfo(identifier, slot.slot, requested_metadata)
|
||||
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
|
||||
return updated and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata) or false
|
||||
end
|
||||
|
||||
function Bridge.Inventory.CanCarryItem(source, item_name, count)
|
||||
return inventory:CanAddItem(source, item_name, count) == true
|
||||
end
|
||||
|
||||
function Bridge.Inventory.AddItem(source, item_name, count, slot, metadata)
|
||||
if not Bridge.Inventory.CanCarryItem(source, item_name, count, metadata) then
|
||||
return false
|
||||
end
|
||||
return inventory:AddItem(source, item_name, count, slot, metadata or {}) == true
|
||||
end
|
||||
|
||||
function Bridge.Inventory.RemoveItem(source, item_name, count, slot, metadata)
|
||||
if metadata and not slot then
|
||||
local slots = Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
|
||||
slot = slots[1] and slots[1].slot or nil
|
||||
if not slot then
|
||||
return 0
|
||||
end
|
||||
end
|
||||
|
||||
local removed = inventory:RemoveItem(source, item_name, count, slot)
|
||||
return removed ~= false and count or 0
|
||||
end
|
||||
|
||||
function Bridge.Inventory.RegisterUsableItem(item_name, callback)
|
||||
return Bridge.Framework.RegisterUsableItem(item_name, function(source, ...)
|
||||
callback(source, normalize(Bridge.Inventory.ResolveUsableItem(...)))
|
||||
end)
|
||||
end
|
||||
@@ -9,8 +9,7 @@ local function normalize(item)
|
||||
end
|
||||
|
||||
local function get_inventory(source)
|
||||
local identifier = Bridge.Framework.GetIdentifier(source)
|
||||
return identifier and inventory:GetInventory(identifier, source) or {}
|
||||
return inventory:GetInventory(false, source) or {}
|
||||
end
|
||||
|
||||
function Bridge.Inventory.GetResourceName()
|
||||
@@ -28,14 +27,9 @@ end
|
||||
|
||||
function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
|
||||
local matches = {}
|
||||
local items = inventory:GetItemsByName(source, item_name) or {}
|
||||
if items.name then
|
||||
items = { items }
|
||||
end
|
||||
for _, item in pairs(items) do
|
||||
for _, item in pairs(get_inventory(source)) do
|
||||
local normalized = normalize(item)
|
||||
if normalized and normalized.name == item_name
|
||||
and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then
|
||||
if normalized.name == item_name and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then
|
||||
matches[#matches + 1] = normalized
|
||||
end
|
||||
end
|
||||
@@ -72,7 +66,7 @@ function Bridge.Inventory.RemoveItem(source, item_name, count, slot, metadata)
|
||||
end
|
||||
|
||||
function Bridge.Inventory.RegisterUsableItem(item_name, callback)
|
||||
return Bridge.Framework.RegisterUsableItem(item_name, function(source, ...)
|
||||
callback(source, normalize(Bridge.Inventory.ResolveUsableItem(...)))
|
||||
return Bridge.Framework.RegisterUsableItem(item_name, function(source, item)
|
||||
callback(source, normalize(item))
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -39,15 +39,11 @@ end
|
||||
|
||||
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
|
||||
local numeric_slot = tonumber(slot_id)
|
||||
local slot = numeric_slot and Bridge.Inventory.GetSlot(source, numeric_slot) or nil
|
||||
if not slot then
|
||||
if not numeric_slot then
|
||||
return false
|
||||
end
|
||||
|
||||
local requested_metadata = type(metadata) == "table" and metadata or {}
|
||||
inventory:setMetadata(source, numeric_slot, requested_metadata)
|
||||
local updated = Bridge.Inventory.GetSlot(source, numeric_slot)
|
||||
return updated and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata) or false
|
||||
inventory:setMetadata(source, numeric_slot, metadata or {})
|
||||
return true
|
||||
end
|
||||
|
||||
function Bridge.Inventory.CanCarryItem()
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
if Bridge.Inventory.Name ~= "jaksam" then
|
||||
return
|
||||
end
|
||||
|
||||
local inventory = exports["jaksam_inventory"]
|
||||
|
||||
local function normalize(item)
|
||||
local normalized = Bridge.Inventory.NormalizeItem(item)
|
||||
if not normalized or not normalized.name or normalized.amount < 1 then
|
||||
return nil
|
||||
end
|
||||
return normalized
|
||||
end
|
||||
|
||||
local function get_inventory(source)
|
||||
local player_inventory = inventory:getInventory(source)
|
||||
return type(player_inventory) == "table" and type(player_inventory.items) == "table"
|
||||
and player_inventory.items or {}
|
||||
end
|
||||
|
||||
function Bridge.Inventory.GetResourceName()
|
||||
return "jaksam_inventory"
|
||||
end
|
||||
|
||||
function Bridge.Inventory.GetSlot(source, slot_id)
|
||||
for index, item in pairs(get_inventory(source)) do
|
||||
local normalized = normalize(item)
|
||||
local item_slot = normalized and (normalized.slot or tonumber(index)) or nil
|
||||
if tostring(item_slot) == tostring(slot_id) then
|
||||
normalized.slot = item_slot
|
||||
return normalized
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
|
||||
local matches = {}
|
||||
for index, item in pairs(get_inventory(source)) do
|
||||
local normalized = normalize(item)
|
||||
if normalized and normalized.name == item_name
|
||||
and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then
|
||||
normalized.slot = normalized.slot or tonumber(index) or index
|
||||
matches[#matches + 1] = normalized
|
||||
end
|
||||
end
|
||||
return matches
|
||||
end
|
||||
|
||||
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
|
||||
local slot = Bridge.Inventory.GetSlot(source, slot_id)
|
||||
if not slot then
|
||||
return false
|
||||
end
|
||||
|
||||
local requested_metadata = type(metadata) == "table" and metadata or {}
|
||||
local success = inventory:setItemMetadataInSlot(source, slot.slot, requested_metadata)
|
||||
if success ~= true then
|
||||
return false
|
||||
end
|
||||
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
|
||||
return updated and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata) or false
|
||||
end
|
||||
|
||||
function Bridge.Inventory.CanCarryItem(source, item_name, count)
|
||||
return inventory:canCarryItem(source, item_name, count) == true
|
||||
end
|
||||
|
||||
function Bridge.Inventory.AddItem(source, item_name, count, slot, metadata)
|
||||
if not Bridge.Inventory.CanCarryItem(source, item_name, count, metadata) then
|
||||
return false
|
||||
end
|
||||
local success = inventory:addItem(source, item_name, count, metadata or {}, slot)
|
||||
return success == true
|
||||
end
|
||||
|
||||
function Bridge.Inventory.RemoveItem(source, item_name, count, slot, metadata)
|
||||
if metadata and not slot then
|
||||
local slots = Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
|
||||
slot = slots[1] and slots[1].slot or nil
|
||||
if not slot then
|
||||
return 0
|
||||
end
|
||||
end
|
||||
local success = inventory:removeItem(source, item_name, count, nil, slot)
|
||||
return success == true and count or 0
|
||||
end
|
||||
|
||||
function Bridge.Inventory.RegisterUsableItem(item_name, callback)
|
||||
inventory:registerUsableItem(item_name, function(source, ...)
|
||||
callback(source, normalize(Bridge.Inventory.ResolveUsableItem(...)))
|
||||
end)
|
||||
return true
|
||||
end
|
||||
@@ -1,109 +0,0 @@
|
||||
if Bridge.Inventory.Name ~= "jpr" then
|
||||
return
|
||||
end
|
||||
|
||||
if Bridge.Framework.GetName() ~= "qb" then
|
||||
error("[sky_phone] jpr-inventory is only supported with QBCore.")
|
||||
end
|
||||
|
||||
local inventory = exports["jpr-inventory"]
|
||||
local QBCore = exports["qb-core"]:GetCoreObject()
|
||||
|
||||
local function normalize(item, fallback_slot)
|
||||
local normalized = Bridge.Inventory.NormalizeItem(item, "info", fallback_slot)
|
||||
if not normalized or not normalized.name or normalized.amount < 1 then
|
||||
return nil
|
||||
end
|
||||
return normalized
|
||||
end
|
||||
|
||||
local function get_inventory(source)
|
||||
local player = QBCore.Functions.GetPlayer(tonumber(source))
|
||||
return player and player.PlayerData.items or {}
|
||||
end
|
||||
|
||||
local function restore_slot(source, slot, metadata)
|
||||
local restored = inventory:AddItem(source, slot.name, slot.amount, slot.slot, metadata, "sky_phone:metadata-rollback")
|
||||
if not restored then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] jpr-inventory could not restore item '%s' in slot %s after a metadata update failed.",
|
||||
tostring(slot.name),
|
||||
tostring(slot.slot)
|
||||
)
|
||||
end
|
||||
return restored == true
|
||||
end
|
||||
|
||||
function Bridge.Inventory.GetResourceName()
|
||||
return "jpr-inventory"
|
||||
end
|
||||
|
||||
function Bridge.Inventory.GetSlot(source, slot_id)
|
||||
local numeric_slot = tonumber(slot_id)
|
||||
return numeric_slot and normalize(inventory:GetItemBySlot(source, numeric_slot), numeric_slot) or nil
|
||||
end
|
||||
|
||||
function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
|
||||
local matches = {}
|
||||
for index, item in pairs(get_inventory(source)) do
|
||||
local normalized = normalize(item, tonumber(index) or index)
|
||||
if normalized and normalized.name == item_name
|
||||
and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then
|
||||
normalized.slot = normalized.slot or tonumber(index) or index
|
||||
matches[#matches + 1] = normalized
|
||||
end
|
||||
end
|
||||
return matches
|
||||
end
|
||||
|
||||
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
|
||||
local slot = Bridge.Inventory.GetSlot(source, slot_id)
|
||||
if not slot or slot.amount < 1 then
|
||||
return false
|
||||
end
|
||||
|
||||
local previous_metadata = slot.metadata
|
||||
local requested_metadata = type(metadata) == "table" and metadata or {}
|
||||
if inventory:RemoveItem(source, slot.name, slot.amount, slot.slot, "sky_phone:metadata-update") ~= true then
|
||||
return false
|
||||
end
|
||||
if inventory:AddItem(source, slot.name, slot.amount, slot.slot, requested_metadata, "sky_phone:metadata-update") ~= true then
|
||||
restore_slot(source, slot, previous_metadata)
|
||||
return false
|
||||
end
|
||||
|
||||
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
|
||||
if updated and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata) then
|
||||
return true
|
||||
end
|
||||
|
||||
inventory:RemoveItem(source, slot.name, slot.amount, slot.slot, "sky_phone:metadata-rollback")
|
||||
restore_slot(source, slot, previous_metadata)
|
||||
return false
|
||||
end
|
||||
|
||||
function Bridge.Inventory.CanCarryItem()
|
||||
return true
|
||||
end
|
||||
|
||||
function Bridge.Inventory.AddItem(source, item_name, count, slot, metadata)
|
||||
return inventory:AddItem(source, item_name, count, slot, metadata or {}, "sky_phone") == true
|
||||
end
|
||||
|
||||
function Bridge.Inventory.RemoveItem(source, item_name, count, slot, metadata)
|
||||
if metadata and not slot then
|
||||
local slots = Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
|
||||
slot = slots[1] and slots[1].slot or nil
|
||||
if not slot then
|
||||
return 0
|
||||
end
|
||||
end
|
||||
return inventory:RemoveItem(source, item_name, count, slot, "sky_phone") == true and count or 0
|
||||
end
|
||||
|
||||
function Bridge.Inventory.RegisterUsableItem(item_name, callback)
|
||||
return Bridge.Framework.RegisterUsableItem(item_name, function(source, ...)
|
||||
callback(source, normalize(Bridge.Inventory.ResolveUsableItem(...)))
|
||||
end)
|
||||
end
|
||||
@@ -1,76 +0,0 @@
|
||||
if Bridge.Inventory.Name ~= "one" then
|
||||
return
|
||||
end
|
||||
|
||||
local inventory = exports.one_inventory
|
||||
|
||||
local function normalize(item, fallback_slot)
|
||||
local normalized = Bridge.Inventory.NormalizeItem(item, nil, fallback_slot)
|
||||
if not normalized or not normalized.name or normalized.amount < 1 then
|
||||
return nil
|
||||
end
|
||||
return normalized
|
||||
end
|
||||
|
||||
function Bridge.Inventory.GetResourceName()
|
||||
return "one_inventory"
|
||||
end
|
||||
|
||||
function Bridge.Inventory.GetSlot(source, slot_id)
|
||||
local numeric_slot = tonumber(slot_id)
|
||||
return numeric_slot and normalize(inventory:GetSlot(source, numeric_slot), numeric_slot) or nil
|
||||
end
|
||||
|
||||
function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
|
||||
local matches = {}
|
||||
for _, slot_id in ipairs(inventory:GetSlotIdsWithItem(source, item_name, metadata) or {}) do
|
||||
local normalized = Bridge.Inventory.GetSlot(source, slot_id)
|
||||
if normalized and normalized.name == item_name
|
||||
and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then
|
||||
matches[#matches + 1] = normalized
|
||||
end
|
||||
end
|
||||
return matches
|
||||
end
|
||||
|
||||
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
|
||||
local slot = Bridge.Inventory.GetSlot(source, slot_id)
|
||||
if not slot then
|
||||
return false
|
||||
end
|
||||
|
||||
local requested_metadata = type(metadata) == "table" and metadata or {}
|
||||
if inventory:SetItemMetadata(source, slot.slot, requested_metadata) == false then
|
||||
return false
|
||||
end
|
||||
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
|
||||
return updated and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata) or false
|
||||
end
|
||||
|
||||
function Bridge.Inventory.CanCarryItem(source, item_name, count)
|
||||
return inventory:CanCarryItem(source, item_name, count) == true
|
||||
end
|
||||
|
||||
function Bridge.Inventory.AddItem(source, item_name, count, slot, metadata)
|
||||
if not Bridge.Inventory.CanCarryItem(source, item_name, count, metadata) then
|
||||
return false
|
||||
end
|
||||
return inventory:AddItem(source, item_name, count, metadata or {}, slot) == true
|
||||
end
|
||||
|
||||
function Bridge.Inventory.RemoveItem(source, item_name, count, slot, metadata)
|
||||
if metadata and not slot then
|
||||
local slots = Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
|
||||
slot = slots[1] and slots[1].slot or nil
|
||||
if not slot then
|
||||
return 0
|
||||
end
|
||||
end
|
||||
return inventory:RemoveItem(source, item_name, count, metadata, slot) == true and count or 0
|
||||
end
|
||||
|
||||
function Bridge.Inventory.RegisterUsableItem(item_name, callback)
|
||||
return Bridge.Framework.RegisterUsableItem(item_name, function(source, ...)
|
||||
callback(source, normalize(Bridge.Inventory.ResolveUsableItem(...)))
|
||||
end)
|
||||
end
|
||||
@@ -1,88 +0,0 @@
|
||||
if Bridge.Inventory.Name ~= "origen" then
|
||||
return
|
||||
end
|
||||
|
||||
local inventory = exports.origen_inventory
|
||||
|
||||
local function normalize(item)
|
||||
local normalized = Bridge.Inventory.NormalizeItem(item)
|
||||
if not normalized or not normalized.name or normalized.amount < 1 then
|
||||
return nil
|
||||
end
|
||||
return normalized
|
||||
end
|
||||
|
||||
local function get_inventory(source)
|
||||
local player_inventory = inventory:GetInventory(source)
|
||||
return type(player_inventory) == "table" and type(player_inventory.inventory) == "table"
|
||||
and player_inventory.inventory or {}
|
||||
end
|
||||
|
||||
function Bridge.Inventory.GetResourceName()
|
||||
return "origen_inventory"
|
||||
end
|
||||
|
||||
function Bridge.Inventory.GetSlot(source, slot_id)
|
||||
for index, item in pairs(get_inventory(source)) do
|
||||
local normalized = normalize(item)
|
||||
local item_slot = normalized and (normalized.slot or tonumber(index)) or nil
|
||||
if tostring(item_slot) == tostring(slot_id) then
|
||||
normalized.slot = item_slot
|
||||
return normalized
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
|
||||
local matches = {}
|
||||
for index, item in pairs(get_inventory(source)) do
|
||||
local normalized = normalize(item)
|
||||
if normalized and normalized.name == item_name
|
||||
and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then
|
||||
normalized.slot = normalized.slot or tonumber(index) or index
|
||||
matches[#matches + 1] = normalized
|
||||
end
|
||||
end
|
||||
return matches
|
||||
end
|
||||
|
||||
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
|
||||
local slot = Bridge.Inventory.GetSlot(source, slot_id)
|
||||
if not slot then
|
||||
return false
|
||||
end
|
||||
|
||||
local requested_metadata = type(metadata) == "table" and metadata or {}
|
||||
inventory:setMetadata(source, slot.slot, requested_metadata)
|
||||
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
|
||||
return updated and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata) or false
|
||||
end
|
||||
|
||||
function Bridge.Inventory.CanCarryItem(source, item_name, count)
|
||||
return inventory:canCarryItem(source, item_name, count) == true
|
||||
end
|
||||
|
||||
function Bridge.Inventory.AddItem(source, item_name, count, slot, metadata)
|
||||
if not Bridge.Inventory.CanCarryItem(source, item_name, count, metadata) then
|
||||
return false
|
||||
end
|
||||
return inventory:addItem(source, item_name, count, metadata or {}, slot, false) == true
|
||||
end
|
||||
|
||||
function Bridge.Inventory.RemoveItem(source, item_name, count, slot, metadata)
|
||||
if metadata and not slot then
|
||||
local slots = Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
|
||||
slot = slots[1] and slots[1].slot or nil
|
||||
if not slot then
|
||||
return 0
|
||||
end
|
||||
end
|
||||
return inventory:removeItem(source, item_name, count, nil, slot, false) == true and count or 0
|
||||
end
|
||||
|
||||
function Bridge.Inventory.RegisterUsableItem(item_name, callback)
|
||||
return Bridge.Framework.RegisterUsableItem(item_name, function(source, ...)
|
||||
callback(source, normalize(Bridge.Inventory.ResolveUsableItem(...)))
|
||||
end)
|
||||
end
|
||||
@@ -44,10 +44,8 @@ function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
|
||||
return false
|
||||
end
|
||||
|
||||
local requested_metadata = type(metadata) == "table" and metadata or {}
|
||||
inventory:SetMetadata(source, tonumber(slot_id), requested_metadata)
|
||||
local updated = Bridge.Inventory.GetSlot(source, slot_id)
|
||||
return updated and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata) or false
|
||||
inventory:SetMetadata(source, tonumber(slot_id), metadata or {})
|
||||
return true
|
||||
end
|
||||
|
||||
function Bridge.Inventory.CanCarryItem(source, item_name, count, metadata)
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
if Bridge.Inventory.Name ~= "ps" then
|
||||
return
|
||||
end
|
||||
|
||||
if Bridge.Framework.GetName() ~= "qb" then
|
||||
error("[sky_phone] ps-inventory is only supported with QBCore.")
|
||||
end
|
||||
|
||||
local inventory = exports["ps-inventory"]
|
||||
local QBCore = exports["qb-core"]:GetCoreObject()
|
||||
|
||||
local function normalize(item, fallback_slot)
|
||||
local normalized = Bridge.Inventory.NormalizeItem(item, "info", fallback_slot)
|
||||
if not normalized or not normalized.name or normalized.amount < 1 then
|
||||
return nil
|
||||
end
|
||||
return normalized
|
||||
end
|
||||
|
||||
local function get_inventory(source)
|
||||
local player = QBCore.Functions.GetPlayer(tonumber(source))
|
||||
return player and player.PlayerData.items or {}
|
||||
end
|
||||
|
||||
local function restore_slot(source, slot, metadata)
|
||||
local restored = inventory:AddItem(source, slot.name, slot.amount, slot.slot, metadata)
|
||||
if not restored then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] ps-inventory could not restore item '%s' in slot %s after a metadata update failed.",
|
||||
tostring(slot.name),
|
||||
tostring(slot.slot)
|
||||
)
|
||||
end
|
||||
return restored == true
|
||||
end
|
||||
|
||||
function Bridge.Inventory.GetResourceName()
|
||||
return "ps-inventory"
|
||||
end
|
||||
|
||||
function Bridge.Inventory.GetSlot(source, slot_id)
|
||||
local numeric_slot = tonumber(slot_id)
|
||||
return numeric_slot and normalize(inventory:GetItemBySlot(source, numeric_slot), numeric_slot) or nil
|
||||
end
|
||||
|
||||
function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
|
||||
local matches = {}
|
||||
for index, item in pairs(get_inventory(source)) do
|
||||
local normalized = normalize(item, tonumber(index) or index)
|
||||
if normalized and normalized.name == item_name
|
||||
and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then
|
||||
normalized.slot = normalized.slot or tonumber(index) or index
|
||||
matches[#matches + 1] = normalized
|
||||
end
|
||||
end
|
||||
return matches
|
||||
end
|
||||
|
||||
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
|
||||
local slot = Bridge.Inventory.GetSlot(source, slot_id)
|
||||
if not slot or slot.amount < 1 then
|
||||
return false
|
||||
end
|
||||
|
||||
local previous_metadata = slot.metadata
|
||||
local requested_metadata = type(metadata) == "table" and metadata or {}
|
||||
if inventory:RemoveItem(source, slot.name, slot.amount, slot.slot) ~= true then
|
||||
return false
|
||||
end
|
||||
if inventory:AddItem(source, slot.name, slot.amount, slot.slot, requested_metadata) ~= true then
|
||||
restore_slot(source, slot, previous_metadata)
|
||||
return false
|
||||
end
|
||||
|
||||
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
|
||||
if updated and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata) then
|
||||
return true
|
||||
end
|
||||
|
||||
inventory:RemoveItem(source, slot.name, slot.amount, slot.slot)
|
||||
restore_slot(source, slot, previous_metadata)
|
||||
return false
|
||||
end
|
||||
|
||||
function Bridge.Inventory.CanCarryItem()
|
||||
return true
|
||||
end
|
||||
|
||||
function Bridge.Inventory.AddItem(source, item_name, count, slot, metadata)
|
||||
return inventory:AddItem(source, item_name, count, slot, metadata or {}) == true
|
||||
end
|
||||
|
||||
function Bridge.Inventory.RemoveItem(source, item_name, count, slot, metadata)
|
||||
if metadata and not slot then
|
||||
local slots = Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
|
||||
slot = slots[1] and slots[1].slot or nil
|
||||
if not slot then
|
||||
return 0
|
||||
end
|
||||
end
|
||||
return inventory:RemoveItem(source, item_name, count, slot) == true and count or 0
|
||||
end
|
||||
|
||||
function Bridge.Inventory.RegisterUsableItem(item_name, callback)
|
||||
return Bridge.Framework.RegisterUsableItem(item_name, function(source, ...)
|
||||
callback(source, normalize(Bridge.Inventory.ResolveUsableItem(...)))
|
||||
end)
|
||||
end
|
||||
@@ -1,75 +0,0 @@
|
||||
if Bridge.Inventory.Name ~= "tgiann" then
|
||||
return
|
||||
end
|
||||
|
||||
local inventory = exports["tgiann-inventory"]
|
||||
|
||||
local function normalize(item, fallback_slot)
|
||||
local normalized = Bridge.Inventory.NormalizeItem(item, "info", fallback_slot)
|
||||
if not normalized or not normalized.name or normalized.amount < 1 then
|
||||
return nil
|
||||
end
|
||||
return normalized
|
||||
end
|
||||
|
||||
function Bridge.Inventory.GetResourceName()
|
||||
return "tgiann-inventory"
|
||||
end
|
||||
|
||||
function Bridge.Inventory.GetSlot(source, slot_id)
|
||||
local numeric_slot = tonumber(slot_id)
|
||||
return numeric_slot and normalize(inventory:GetItemBySlot(source, numeric_slot), numeric_slot) or nil
|
||||
end
|
||||
|
||||
function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
|
||||
local matches = {}
|
||||
for index, item in pairs(inventory:GetPlayerItems(source) or {}) do
|
||||
local normalized = normalize(item, tonumber(index) or index)
|
||||
if normalized and normalized.name == item_name
|
||||
and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then
|
||||
normalized.slot = normalized.slot or tonumber(index) or index
|
||||
matches[#matches + 1] = normalized
|
||||
end
|
||||
end
|
||||
return matches
|
||||
end
|
||||
|
||||
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
|
||||
local slot = Bridge.Inventory.GetSlot(source, slot_id)
|
||||
if not slot then
|
||||
return false
|
||||
end
|
||||
|
||||
local requested_metadata = type(metadata) == "table" and metadata or {}
|
||||
inventory:UpdateItemMetadata(source, slot.name, slot.slot, requested_metadata)
|
||||
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
|
||||
return updated and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata) or false
|
||||
end
|
||||
|
||||
function Bridge.Inventory.CanCarryItem(source, item_name, count)
|
||||
return inventory:CanCarryItem(source, item_name, count) == true
|
||||
end
|
||||
|
||||
function Bridge.Inventory.AddItem(source, item_name, count, slot, metadata)
|
||||
if not Bridge.Inventory.CanCarryItem(source, item_name, count, metadata) then
|
||||
return false
|
||||
end
|
||||
return inventory:AddItem(source, item_name, count, slot, metadata or {}) == true
|
||||
end
|
||||
|
||||
function Bridge.Inventory.RemoveItem(source, item_name, count, slot, metadata)
|
||||
if metadata and not slot then
|
||||
local slots = Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
|
||||
slot = slots[1] and slots[1].slot or nil
|
||||
if not slot then
|
||||
return 0
|
||||
end
|
||||
end
|
||||
return inventory:RemoveItem(source, item_name, count, slot, metadata) == true and count or 0
|
||||
end
|
||||
|
||||
function Bridge.Inventory.RegisterUsableItem(item_name, callback)
|
||||
return Bridge.Framework.RegisterUsableItem(item_name, function(source, ...)
|
||||
callback(source, normalize(Bridge.Inventory.ResolveUsableItem(...)))
|
||||
end)
|
||||
end
|
||||
@@ -65,117 +65,6 @@ local function query_or_error(query, parameters, context)
|
||||
return result
|
||||
end
|
||||
|
||||
local function column_key(table_name, column_name)
|
||||
return ("%s\0%s"):format(table_name:lower(), column_name:lower())
|
||||
end
|
||||
|
||||
local function quote_identifier(identifier)
|
||||
return ("`%s`"):format(tostring(identifier):gsub("`", "``"))
|
||||
end
|
||||
|
||||
local function read_foreign_keys()
|
||||
local rows = query_or_error([[
|
||||
SELECT
|
||||
kcu.CONSTRAINT_NAME AS `constraint_name`,
|
||||
kcu.TABLE_NAME AS `table_name`,
|
||||
kcu.COLUMN_NAME AS `column_name`,
|
||||
kcu.REFERENCED_TABLE_NAME AS `referenced_table_name`,
|
||||
kcu.REFERENCED_COLUMN_NAME AS `referenced_column_name`,
|
||||
rc.UPDATE_RULE AS `update_rule`,
|
||||
rc.DELETE_RULE AS `delete_rule`
|
||||
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
|
||||
INNER JOIN INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc
|
||||
ON rc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA
|
||||
AND rc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME
|
||||
AND rc.TABLE_NAME = kcu.TABLE_NAME
|
||||
WHERE kcu.CONSTRAINT_SCHEMA = DATABASE()
|
||||
AND kcu.REFERENCED_TABLE_SCHEMA = DATABASE()
|
||||
AND kcu.REFERENCED_TABLE_NAME IS NOT NULL
|
||||
ORDER BY kcu.TABLE_NAME, kcu.CONSTRAINT_NAME, kcu.ORDINAL_POSITION
|
||||
]], {}, "reading foreign key metadata")
|
||||
local foreign_keys = {}
|
||||
local foreign_keys_by_name = {}
|
||||
|
||||
for _, row in ipairs(rows) do
|
||||
local table_name = row.table_name or row.TABLE_NAME
|
||||
local constraint_name = row.constraint_name or row.CONSTRAINT_NAME
|
||||
local key = ("%s\0%s"):format(table_name:lower(), constraint_name:lower())
|
||||
local foreign_key = foreign_keys_by_name[key]
|
||||
if not foreign_key then
|
||||
foreign_key = {
|
||||
name = constraint_name,
|
||||
table_name = table_name,
|
||||
referenced_table_name = row.referenced_table_name or row.REFERENCED_TABLE_NAME,
|
||||
update_rule = row.update_rule or row.UPDATE_RULE,
|
||||
delete_rule = row.delete_rule or row.DELETE_RULE,
|
||||
columns = {},
|
||||
referenced_columns = {},
|
||||
}
|
||||
foreign_keys_by_name[key] = foreign_key
|
||||
foreign_keys[#foreign_keys + 1] = foreign_key
|
||||
end
|
||||
|
||||
foreign_key.columns[#foreign_key.columns + 1] = row.column_name or row.COLUMN_NAME
|
||||
foreign_key.referenced_columns[#foreign_key.referenced_columns + 1] =
|
||||
row.referenced_column_name or row.REFERENCED_COLUMN_NAME
|
||||
end
|
||||
|
||||
return foreign_keys
|
||||
end
|
||||
|
||||
local valid_foreign_key_rules = {
|
||||
CASCADE = true,
|
||||
["NO ACTION"] = true,
|
||||
RESTRICT = true,
|
||||
["SET DEFAULT"] = true,
|
||||
["SET NULL"] = true,
|
||||
}
|
||||
|
||||
local function build_foreign_key_definition(foreign_key)
|
||||
local columns = {}
|
||||
local referenced_columns = {}
|
||||
for index = 1, #foreign_key.columns do
|
||||
columns[index] = quote_identifier(foreign_key.columns[index])
|
||||
referenced_columns[index] = quote_identifier(foreign_key.referenced_columns[index])
|
||||
end
|
||||
|
||||
local update_rule = tostring(foreign_key.update_rule):upper()
|
||||
local delete_rule = tostring(foreign_key.delete_rule):upper()
|
||||
if not valid_foreign_key_rules[update_rule] or not valid_foreign_key_rules[delete_rule] then
|
||||
error(("[sky_phone] Cannot preserve foreign key '%s': unsupported referential action."):format(
|
||||
tostring(foreign_key.name)
|
||||
))
|
||||
end
|
||||
|
||||
return ("CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s (%s) ON DELETE %s ON UPDATE %s"):format(
|
||||
quote_identifier(foreign_key.name),
|
||||
table.concat(columns, ", "),
|
||||
quote_identifier(foreign_key.referenced_table_name),
|
||||
table.concat(referenced_columns, ", "),
|
||||
delete_rule,
|
||||
update_rule
|
||||
)
|
||||
end
|
||||
|
||||
local function foreign_key_touches_columns(foreign_key, changed_columns)
|
||||
for index = 1, #foreign_key.columns do
|
||||
if changed_columns[column_key(foreign_key.table_name, foreign_key.columns[index])]
|
||||
or changed_columns[column_key(foreign_key.referenced_table_name, foreign_key.referenced_columns[index])] then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function desired_foreign_key_target(references)
|
||||
local table_name, column_list = references:match("^%s*`([^`]+)`%s*%(([^%)]+)%)")
|
||||
local column_name = column_list and column_list:match("^%s*`([^`]+)`%s*$")
|
||||
if not table_name or not column_name then
|
||||
error(("[sky_phone] Unsupported foreign key reference definition: %s"):format(tostring(references)))
|
||||
end
|
||||
return table_name, column_name
|
||||
end
|
||||
|
||||
function Bridge.Database.EnsureIndex(table_name, index_name, columns, options)
|
||||
local table_count = Bridge.Database.Query([[
|
||||
SELECT COUNT(*) AS `count`
|
||||
@@ -206,11 +95,9 @@ end
|
||||
function Bridge.Database.Migrate(migration_name, schema)
|
||||
local table_names = {}
|
||||
local placeholders = {}
|
||||
local schema_tables = {}
|
||||
for index = 1, #schema do
|
||||
table_names[index] = schema[index].name
|
||||
placeholders[index] = "?"
|
||||
schema_tables[schema[index].name:lower()] = true
|
||||
end
|
||||
|
||||
local existing_tables = {}
|
||||
@@ -242,47 +129,6 @@ function Bridge.Database.Migrate(migration_name, schema)
|
||||
end
|
||||
end
|
||||
|
||||
local changed_columns = {}
|
||||
for _, table_definition in ipairs(schema) do
|
||||
local table_name = table_definition.name:lower()
|
||||
if existing_tables[table_name] then
|
||||
local columns = existing_columns[table_name] or {}
|
||||
for _, column in ipairs(table_definition.columns) do
|
||||
local current = columns[column.name:lower()]
|
||||
if current and ((column.characterSet and current.character_set ~= column.characterSet)
|
||||
or (column.collation and current.collation ~= column.collation)) then
|
||||
changed_columns[column_key(table_definition.name, column.name)] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local preserved_foreign_keys = {}
|
||||
if next(changed_columns) then
|
||||
for _, foreign_key in ipairs(read_foreign_keys()) do
|
||||
if foreign_key_touches_columns(foreign_key, changed_columns) then
|
||||
if not schema_tables[foreign_key.table_name:lower()]
|
||||
or not schema_tables[foreign_key.referenced_table_name:lower()] then
|
||||
error((
|
||||
"[sky_phone] Cannot safely update constrained columns because foreign key '%s.%s' is not fully owned by this migration."
|
||||
):format(foreign_key.table_name, foreign_key.name))
|
||||
end
|
||||
preserved_foreign_keys[#preserved_foreign_keys + 1] = foreign_key
|
||||
end
|
||||
end
|
||||
|
||||
for _, foreign_key in ipairs(preserved_foreign_keys) do
|
||||
query_or_error(
|
||||
("ALTER TABLE %s DROP FOREIGN KEY %s"):format(
|
||||
quote_identifier(foreign_key.table_name),
|
||||
quote_identifier(foreign_key.name)
|
||||
),
|
||||
{},
|
||||
("temporarily removing foreign key '%s.%s'"):format(foreign_key.table_name, foreign_key.name)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
for _, table_definition in ipairs(schema) do
|
||||
local table_name = table_definition.name:lower()
|
||||
if not existing_tables[table_name] then
|
||||
@@ -315,62 +161,6 @@ function Bridge.Database.Migrate(migration_name, schema)
|
||||
end
|
||||
end
|
||||
|
||||
for _, foreign_key in ipairs(preserved_foreign_keys) do
|
||||
query_or_error(
|
||||
("ALTER TABLE %s ADD %s"):format(
|
||||
quote_identifier(foreign_key.table_name),
|
||||
build_foreign_key_definition(foreign_key)
|
||||
),
|
||||
{},
|
||||
("restoring foreign key '%s.%s'"):format(foreign_key.table_name, foreign_key.name)
|
||||
)
|
||||
end
|
||||
|
||||
local existing_foreign_key_columns = {}
|
||||
local existing_foreign_key_targets = {}
|
||||
for _, foreign_key in ipairs(read_foreign_keys()) do
|
||||
for index = 1, #foreign_key.columns do
|
||||
local key = column_key(foreign_key.table_name, foreign_key.columns[index])
|
||||
existing_foreign_key_columns[key] = true
|
||||
existing_foreign_key_targets[("%s\0%s\0%s"):format(
|
||||
key,
|
||||
foreign_key.referenced_table_name:lower(),
|
||||
foreign_key.referenced_columns[index]:lower()
|
||||
)] = true
|
||||
end
|
||||
end
|
||||
|
||||
for _, table_definition in ipairs(schema) do
|
||||
for _, foreign_key in ipairs(table_definition.foreignKeys or {}) do
|
||||
local referenced_table_name, referenced_column_name = desired_foreign_key_target(foreign_key.references)
|
||||
local key = column_key(table_definition.name, foreign_key.column)
|
||||
local target_key = ("%s\0%s\0%s"):format(
|
||||
key,
|
||||
referenced_table_name:lower(),
|
||||
referenced_column_name:lower()
|
||||
)
|
||||
if not existing_foreign_key_targets[target_key] then
|
||||
if existing_foreign_key_columns[key] then
|
||||
error((
|
||||
"[sky_phone] Column '%s.%s' has a foreign key that does not match the migration schema."
|
||||
):format(table_definition.name, foreign_key.column))
|
||||
end
|
||||
|
||||
query_or_error(
|
||||
("ALTER TABLE %s ADD FOREIGN KEY (%s) REFERENCES %s"):format(
|
||||
quote_identifier(table_definition.name),
|
||||
quote_identifier(foreign_key.column),
|
||||
foreign_key.references
|
||||
),
|
||||
{},
|
||||
("restoring missing foreign key for '%s.%s'"):format(table_definition.name, foreign_key.column)
|
||||
)
|
||||
existing_foreign_key_columns[key] = true
|
||||
existing_foreign_key_targets[target_key] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
function Bridge.Database.CompleteMigration(migration_name)
|
||||
|
||||
@@ -18,13 +18,6 @@ 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
|
||||
@@ -34,41 +27,20 @@ local function yaca_is_enabled()
|
||||
local success, enabled = pcall(function()
|
||||
return exports["yaca-voice"]:isEnabled()
|
||||
end)
|
||||
if success then
|
||||
return enabled == true
|
||||
if not success then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] Yaca could not report its availability: %s",
|
||||
tostring(enabled),
|
||||
{ always = true }
|
||||
)
|
||||
return false
|
||||
end
|
||||
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
|
||||
return enabled == true
|
||||
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,13 +9,9 @@ local front_camera_fov = 32.0
|
||||
local front_camera_distance = 1.05
|
||||
local front_camera_height = 0.05
|
||||
local front_camera_target_height = 0.03
|
||||
local front_camera_horizontal_limit = 75.0
|
||||
local front_camera_vertical_limit = 35.0
|
||||
local front_camera_rotate_speed = 5.0
|
||||
local camera_passthrough_control = 22 -- INPUT_JUMP (Space by default)
|
||||
local blocked_camera_controls = {
|
||||
0, -- INPUT_NEXT_CAMERA
|
||||
camera_passthrough_control,
|
||||
22, -- INPUT_JUMP
|
||||
24, -- INPUT_ATTACK
|
||||
25, -- INPUT_AIM
|
||||
37, -- INPUT_SELECT_WEAPON
|
||||
@@ -49,8 +45,6 @@ local camera_state = {
|
||||
focus_watcher = false,
|
||||
front_camera = false,
|
||||
front_camera_handle = nil,
|
||||
front_camera_pitch = 0.0,
|
||||
front_camera_yaw = 0.0,
|
||||
game_input = false,
|
||||
landscape = false,
|
||||
locked = false,
|
||||
@@ -95,39 +89,20 @@ end
|
||||
local function get_front_camera_transform(ped)
|
||||
local head_position = GetPedBoneCoords(ped, 31086, 0.0, 0.0, 0.0)
|
||||
local forward = GetEntityForwardVector(ped)
|
||||
local yaw = math.rad(camera_state.front_camera_yaw)
|
||||
local pitch = math.rad(camera_state.front_camera_pitch)
|
||||
local orbit_direction = vector3(
|
||||
(forward.x * math.cos(yaw)) - (forward.y * math.sin(yaw)),
|
||||
(forward.x * math.sin(yaw)) + (forward.y * math.cos(yaw)),
|
||||
0.0
|
||||
)
|
||||
local camera_position = head_position
|
||||
+ (orbit_direction * (math.cos(pitch) * front_camera_distance))
|
||||
+ vector3(0.0, 0.0, front_camera_height + (math.sin(pitch) * front_camera_distance))
|
||||
local forward_vector = vector3(forward.x, forward.y, forward.z)
|
||||
local camera_offset = forward_vector * front_camera_distance
|
||||
local camera_position = head_position + camera_offset + vector3(0.0, 0.0, front_camera_height)
|
||||
local to_camera = camera_position - head_position
|
||||
local dot = (to_camera.x * forward_vector.x)
|
||||
+ (to_camera.y * forward_vector.y)
|
||||
+ (to_camera.z * forward_vector.z)
|
||||
if dot < 0.0 then
|
||||
camera_position = head_position - camera_offset + vector3(0.0, 0.0, front_camera_height)
|
||||
end
|
||||
local target_position = head_position + vector3(0.0, 0.0, front_camera_target_height)
|
||||
return camera_position, target_position
|
||||
end
|
||||
|
||||
local function update_front_camera_orbit()
|
||||
local horizontal_input = GetDisabledControlNormal(0, 1)
|
||||
local vertical_input = GetDisabledControlNormal(0, 2)
|
||||
camera_state.front_camera_yaw = math.max(
|
||||
-front_camera_horizontal_limit,
|
||||
math.min(
|
||||
front_camera_horizontal_limit,
|
||||
camera_state.front_camera_yaw - (horizontal_input * front_camera_rotate_speed)
|
||||
)
|
||||
)
|
||||
camera_state.front_camera_pitch = math.max(
|
||||
-front_camera_vertical_limit,
|
||||
math.min(
|
||||
front_camera_vertical_limit,
|
||||
camera_state.front_camera_pitch - (vertical_input * front_camera_rotate_speed)
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
local function apply_front_camera(ped)
|
||||
if not camera_state.front_camera_handle or not DoesCamExist(camera_state.front_camera_handle) then
|
||||
camera_state.front_camera_handle = CreateCam("DEFAULT_SCRIPTED_CAMERA", true)
|
||||
@@ -223,18 +198,7 @@ local function watch_camera_controls()
|
||||
CreateThread(function()
|
||||
while camera_state.active do
|
||||
apply_camera_controls()
|
||||
if not camera_state.walkable then
|
||||
local passthrough_pressed = SkyPhoneFocus.IsHoldToLookPressed()
|
||||
or IsDisabledControlPressed(0, camera_passthrough_control)
|
||||
local should_focus = camera_state.locked or not passthrough_pressed
|
||||
if camera_state.nui_focused ~= should_focus then
|
||||
set_camera_focus(should_focus)
|
||||
end
|
||||
end
|
||||
if camera_state.game_input then
|
||||
if camera_state.front_camera and not camera_state.locked then
|
||||
update_front_camera_orbit()
|
||||
end
|
||||
if
|
||||
IsDisabledControlJustPressed(0, 241)
|
||||
or IsDisabledControlJustPressed(0, 261)
|
||||
@@ -250,6 +214,9 @@ local function watch_camera_controls()
|
||||
math.max(minimum_zoom, camera_state.zoom - mouse_wheel_zoom_step)
|
||||
)
|
||||
end
|
||||
if IsDisabledControlJustReleased(0, 22) then
|
||||
set_camera_focus(true)
|
||||
end
|
||||
end
|
||||
Wait(0)
|
||||
end
|
||||
@@ -276,7 +243,7 @@ AddEventHandler("sky_phone:client:cameraFocusApplied", function(data)
|
||||
camera_state.applied_nui_focus = data.focused
|
||||
SendNUIMessage({ type = "camera:focus", data = { focused = data.focused } })
|
||||
end
|
||||
if data.active then
|
||||
if data.active and data.gameInput then
|
||||
watch_camera_controls()
|
||||
end
|
||||
end)
|
||||
@@ -289,8 +256,6 @@ local function set_camera_active(active)
|
||||
TriggerEvent("sky_phone:client:cameraActiveChanged", active)
|
||||
if active then
|
||||
camera_state.front_camera = false
|
||||
camera_state.front_camera_pitch = 0.0
|
||||
camera_state.front_camera_yaw = 0.0
|
||||
camera_state.landscape = false
|
||||
camera_state.locked = false
|
||||
camera_state.zoom = 1.0
|
||||
@@ -325,8 +290,6 @@ local function set_camera_active(active)
|
||||
end
|
||||
set_flash_enabled(false)
|
||||
camera_state.front_camera = false
|
||||
camera_state.front_camera_pitch = 0.0
|
||||
camera_state.front_camera_yaw = 0.0
|
||||
camera_state.game_input = false
|
||||
camera_state.landscape = false
|
||||
camera_state.locked = false
|
||||
@@ -351,8 +314,6 @@ local function set_front_camera(active)
|
||||
return
|
||||
end
|
||||
if active then
|
||||
camera_state.front_camera_pitch = 0.0
|
||||
camera_state.front_camera_yaw = 0.0
|
||||
apply_front_camera(PlayerPedId())
|
||||
else
|
||||
clear_front_camera()
|
||||
@@ -444,9 +405,6 @@ RegisterNUICallback("camera:setLocked", function(data, cb)
|
||||
return
|
||||
end
|
||||
camera_state.locked = data.locked == true
|
||||
if camera_state.locked and not camera_state.walkable then
|
||||
set_camera_focus(true)
|
||||
end
|
||||
cb({ success = true })
|
||||
end)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user