mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 01:03:25 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21913b585c | ||
|
|
e056d0f05c | ||
|
|
edd9677f43 | ||
|
|
97db163cd0 | ||
|
|
698420584d | ||
|
|
56901621a0 | ||
|
|
7b4e445394 |
@@ -17,6 +17,8 @@ for (const requiredFragment of [
|
|||||||
"fx_version 'cerulean'",
|
"fx_version 'cerulean'",
|
||||||
"node_version '22'",
|
"node_version '22'",
|
||||||
"use_experimental_fxv2_oal 'yes'",
|
"use_experimental_fxv2_oal 'yes'",
|
||||||
|
"'source/server/nui_build_check.lua'",
|
||||||
|
"'source/html/sounds/**'",
|
||||||
"ui_page 'source/html/index.html'",
|
"ui_page 'source/html/index.html'",
|
||||||
]) {
|
]) {
|
||||||
if (!manifest.includes(requiredFragment)) {
|
if (!manifest.includes(requiredFragment)) {
|
||||||
|
|||||||
@@ -92,6 +92,23 @@ as project instructions, not as optional documentation.
|
|||||||
proof.
|
proof.
|
||||||
- Do not hand-edit generated frontend output.
|
- Do not hand-edit generated frontend output.
|
||||||
|
|
||||||
|
## Phone Configurator parity (mandatory)
|
||||||
|
|
||||||
|
- Every new, changed, renamed, moved, or removed configurable value in
|
||||||
|
`sky_phone/config/config.lua` or `sky_phone/config/media.lua` **must** be
|
||||||
|
reflected in the in-game Phone Configurator in the same change. Config-only
|
||||||
|
changes without matching Configurator support are incomplete and must not be
|
||||||
|
committed or merged.
|
||||||
|
- Keep the Configurator's runtime schema, defaults, value types, fixed versus
|
||||||
|
extensible collection rules, labels, descriptions, English and German
|
||||||
|
locales, SQL persistence, validation, and save/load roundtrip aligned with
|
||||||
|
the Lua configuration.
|
||||||
|
- The Configurator must remain complete when file-based configuration is
|
||||||
|
disabled. Do not introduce a setting that can only be managed by editing Lua
|
||||||
|
after `Config.PhoneConfigurator.Enabled` is enabled.
|
||||||
|
- Add or update contract/fixture coverage so configuration parity regressions
|
||||||
|
fail automated checks.
|
||||||
|
|
||||||
## Working method and verification
|
## Working method and verification
|
||||||
|
|
||||||
1. Trace the relevant client, server, NUI, configuration, persistence, and event
|
1. Trace the relevant client, server, NUI, configuration, persistence, and event
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://www.sky-systems.net/shop/phone#live-demo"><strong>Live demo</strong></a>
|
<a href="https://www.sky-systems.net/shop/phone#live-demo"><strong>Live demo</strong></a>
|
||||||
•
|
•
|
||||||
<a href="https://github.com/sky-systems/sky_phone"><strong>Download for free</strong></a>
|
<a href="https://github.com/sky-systems/sky_phone/releases/latest"><strong>Download for free</strong></a>
|
||||||
•
|
•
|
||||||
<a href="https://discord.gg/sky-systems"><strong>Discord support</strong></a>
|
<a href="https://discord.gg/sky-systems"><strong>Discord support</strong></a>
|
||||||
</p>
|
</p>
|
||||||
@@ -44,7 +44,7 @@ Sky Phone is a **free and open-source FiveM phone script** built to give serious
|
|||||||
|
|
||||||
This is not a cut-down free alternative. Sky Phone includes the core experience server owners and players expect from a leading paid FiveM phone, plus full source access, no purchase price, no feature paywalls, and no forced ecosystem lock-in.
|
This is not a cut-down free alternative. Sky Phone includes the core experience server owners and players expect from a leading paid FiveM phone, plus full source access, no purchase price, no feature paywalls, and no forced ecosystem lock-in.
|
||||||
|
|
||||||
The production frontend is included, so a normal server installation does not require Node.js or pnpm.
|
The production frontend is included in the published release package, so a normal server installation does not require Node.js or pnpm. GitHub's automatically generated source archives do not contain that build.
|
||||||
|
|
||||||
## Why Sky Phone stands out
|
## Why Sky Phone stands out
|
||||||
|
|
||||||
@@ -160,8 +160,8 @@ Start the selected voice resource before Sky Phone.
|
|||||||
|
|
||||||
## Quick installation
|
## Quick installation
|
||||||
|
|
||||||
1. Copy the resource into your FiveM resources directory.
|
1. Download and extract the latest published [Sky Phone release](https://github.com/sky-systems/sky_phone/releases/latest). Do not use GitHub's automatically generated "Source code" archives for a server installation because they do not contain the built frontend.
|
||||||
2. Keep the resource folder name `sky_phone`.
|
2. Copy the included resource into your FiveM resources directory and keep its folder name `sky_phone`.
|
||||||
3. Start `oxmysql`, your framework, inventory, and voice resource before Sky Phone.
|
3. Start `oxmysql`, your framework, inventory, and voice resource before Sky Phone.
|
||||||
4. Review `sky_phone/config/config.lua` and `sky_phone/config/media.lua`.
|
4. Review `sky_phone/config/config.lua` and `sky_phone/config/media.lua`.
|
||||||
5. Add the required inventory items.
|
5. Add the required inventory items.
|
||||||
@@ -182,6 +182,13 @@ Replace the example framework, inventory, and voice resources with the providers
|
|||||||
|
|
||||||
Sky Phone creates and upgrades its database tables automatically. A manual SQL import is normally not required.
|
Sky Phone creates and upgrades its database tables automatically. A manual SQL import is normally not required.
|
||||||
|
|
||||||
|
### How players open the phone
|
||||||
|
|
||||||
|
- Give the player the item configured in `Config.Phone.Item` (default: `phone`).
|
||||||
|
- Players can use that inventory item or press the configured keybind (default: `F1`).
|
||||||
|
- The keybind still verifies server-side that the player owns a configured phone item; it does not bypass inventory ownership.
|
||||||
|
- A SIM card is **not required to open or use the phone itself**. With `Config.Sim.Enabled = true`, only cellular features such as calls and messages require an inserted SIM.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Customer settings are organized in:
|
Customer settings are organized in:
|
||||||
@@ -211,6 +218,34 @@ The files contain clearly separated sections for:
|
|||||||
|
|
||||||
Restart `sky_phone` after changing Lua configuration.
|
Restart `sky_phone` after changing Lua configuration.
|
||||||
|
|
||||||
|
### In-game phone configurator
|
||||||
|
|
||||||
|
Set the switch at the beginning of `config/config.lua` to use SQL-backed configuration:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
Config.PhoneConfigurator = {
|
||||||
|
Enabled = true,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When enabled, `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 `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.
|
||||||
|
|
||||||
|
Media API keys and server peppers are never returned in plaintext to the NUI. Existing secrets are
|
||||||
|
shown only as configured and are replaced only when an administrator enters a new value.
|
||||||
|
|
||||||
### Language
|
### Language
|
||||||
|
|
||||||
Available locales:
|
Available locales:
|
||||||
@@ -344,7 +379,7 @@ With unique phones, using an inventory item selects that exact handset whenever
|
|||||||
|
|
||||||
| SIM mode | Behavior |
|
| SIM mode | Behavior |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `Enabled = true` | A registered or anonymous physical SIM item is required for cellular service. |
|
| `Enabled = true` | The phone opens with or without a SIM. A registered or anonymous physical SIM item is required only for cellular service such as calls and messages. |
|
||||||
| `Enabled = false` | Sky Phone creates a persistent automatic number for devices without a SIM. Physical SIM items are not required. |
|
| `Enabled = false` | Sky Phone creates a persistent automatic number for devices without a SIM. Physical SIM items are not required. |
|
||||||
|
|
||||||
When changing these modes on an existing production server, restart the resource and test with a copy of the database first. The first phone used after switching to non-unique mode may adopt an existing valid IMEI so its local data is preserved.
|
When changing these modes on an existing production server, restart the resource and test with a copy of the database first. The first phone used after switching to non-unique mode may adopt an existing valid IMEI so its local data is preserved.
|
||||||
@@ -534,7 +569,11 @@ Select `rtx`, `quasar`, `vms`, `rx`, `nolag`, `sn`, `esx_property`, or `qbx_prop
|
|||||||
|
|
||||||
### Companies
|
### Companies
|
||||||
|
|
||||||
Company jobs, public profiles, service numbers, services, permissions, locations, and default availability are configured under `Config.Companies.Definitions`.
|
Company jobs, public profiles, service numbers, services, permissions, locations, and default
|
||||||
|
availability are configured under `Config.Companies.Definitions`. Definitions are not limited to the
|
||||||
|
shipped jobs: add any number of company IDs in the in-game configurator and fill the freely
|
||||||
|
configurable `Job` value in the automatically generated full company template. Existing job keys can
|
||||||
|
also be removed; the remaining Companies settings stay available as normal individual fields.
|
||||||
|
|
||||||
### Weazel News
|
### Weazel News
|
||||||
|
|
||||||
@@ -602,12 +641,20 @@ pnpm build
|
|||||||
|
|
||||||
### The phone item does nothing
|
### The phone item does nothing
|
||||||
|
|
||||||
|
- A warning that the inventory returned no configured phone item means an item definition, `Config.Phone.Item`, inventory selection, or player ownership problem. It is not caused by a missing SIM card.
|
||||||
- Confirm the framework and inventory are supported and started first.
|
- Confirm the framework and inventory are supported and started first.
|
||||||
- Confirm the item name matches `Config.Phone.Item`.
|
- Confirm the item name matches `Config.Phone.Item`.
|
||||||
- Confirm the item is usable.
|
- Confirm the item is usable.
|
||||||
- In unique mode, confirm the phone is non-stackable.
|
- In unique mode, confirm the phone is non-stackable.
|
||||||
- Check the server console for inventory adapter warnings.
|
- Check the server console for inventory adapter warnings.
|
||||||
|
|
||||||
|
### The resource starts but the phone UI is missing
|
||||||
|
|
||||||
|
- On startup, the server console prints `SKY PHONE UI BUILD IS MISSING OR INCOMPLETE`, lists the missing or invalid packaged files, and shows repository-native build commands.
|
||||||
|
- Install the latest published release package rather than GitHub's automatically generated source archive.
|
||||||
|
- Confirm `sky_phone/source/html/index.html`, `assets`, `img`, and `sounds` exist.
|
||||||
|
- Developers working from source must run the frontend production build before starting the resource.
|
||||||
|
|
||||||
### Calls connect without audio
|
### Calls connect without audio
|
||||||
|
|
||||||
- Confirm the configured voice resource is running.
|
- Confirm the configured voice resource is running.
|
||||||
|
|||||||
+43
-5
@@ -11,6 +11,7 @@ import {
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { SkyProvider } from '@/ui'
|
import { SkyProvider } from '@/ui'
|
||||||
|
import AdminPanel from '@/components/AdminPanel.vue'
|
||||||
import PhoneHomeIndicator from '@/components/PhoneHomeIndicator.vue'
|
import PhoneHomeIndicator from '@/components/PhoneHomeIndicator.vue'
|
||||||
import PhoneControlCenter from '@/components/PhoneControlCenter.vue'
|
import PhoneControlCenter from '@/components/PhoneControlCenter.vue'
|
||||||
import PhoneDynamicIsland from '@/components/PhoneDynamicIsland.vue'
|
import PhoneDynamicIsland from '@/components/PhoneDynamicIsland.vue'
|
||||||
@@ -70,7 +71,7 @@ import type {
|
|||||||
CompanyChangedPayload,
|
CompanyChangedPayload,
|
||||||
CompanyUnreadCounts,
|
CompanyUnreadCounts,
|
||||||
} from '@/types/companies'
|
} from '@/types/companies'
|
||||||
import type { PhoneCall } from '@/types/phone'
|
import type { PhoneCall, PhoneNumberFormat } from '@/types/phone'
|
||||||
import type { DynamicIslandActivity } from '@/types/dynamicIsland'
|
import type { DynamicIslandActivity } from '@/types/dynamicIsland'
|
||||||
import type { EasyShareEvent } from '@/types/easyshare'
|
import type { EasyShareEvent } from '@/types/easyshare'
|
||||||
import type { CryptoMarketChangedData } from '@/types/crypto'
|
import type { CryptoMarketChangedData } from '@/types/crypto'
|
||||||
@@ -84,6 +85,7 @@ import { formatTimer } from '@/utils/clock'
|
|||||||
import { parsePhonePreferences } from '@/utils/preferences'
|
import { parsePhonePreferences } from '@/utils/preferences'
|
||||||
import { getHairlinePixelStyle } from '@/utils/rendering'
|
import { getHairlinePixelStyle } from '@/utils/rendering'
|
||||||
import { isTextInputElement } from '@/utils/textInputFocus'
|
import { isTextInputElement } from '@/utils/textInputFocus'
|
||||||
|
import { configurePhoneNumberFormat } from '@/utils/phone'
|
||||||
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
||||||
import SpringboardView from '@/views/SpringboardView.vue'
|
import SpringboardView from '@/views/SpringboardView.vue'
|
||||||
|
|
||||||
@@ -114,8 +116,13 @@ type AppMessage = {
|
|||||||
| CustomAppCatalogEventData
|
| CustomAppCatalogEventData
|
||||||
| CustomAppEventData
|
| CustomAppEventData
|
||||||
| NavigationEventData
|
| NavigationEventData
|
||||||
|
| AdminPanelOpenPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AdminPanelOpenPayload = Required<
|
||||||
|
Pick<PhoneOpenPayload, 'fallbackLocales' | 'lang' | 'locales'>
|
||||||
|
>
|
||||||
|
|
||||||
type CustomAppCatalogEventData = {
|
type CustomAppCatalogEventData = {
|
||||||
apps?: unknown
|
apps?: unknown
|
||||||
}
|
}
|
||||||
@@ -133,6 +140,7 @@ type NavigationEventData = {
|
|||||||
type SimPickerPayload = {
|
type SimPickerPayload = {
|
||||||
choices: SimPhoneChoice[]
|
choices: SimPhoneChoice[]
|
||||||
number: string
|
number: string
|
||||||
|
phoneNumberFormat?: PhoneNumberFormat
|
||||||
}
|
}
|
||||||
|
|
||||||
type NotificationEventData = Omit<PhoneNotificationInput, 'device'> & {
|
type NotificationEventData = Omit<PhoneNotificationInput, 'device'> & {
|
||||||
@@ -355,6 +363,9 @@ const appTransitionName = computed(() =>
|
|||||||
route.query.transition === 'app-switch' ? 'app-switch' : 'app-window',
|
route.query.transition === 'app-switch' ? 'app-switch' : 'app-window',
|
||||||
)
|
)
|
||||||
const isLocked = ref(false)
|
const isLocked = ref(false)
|
||||||
|
const adminPanelOpen = ref(
|
||||||
|
isDevelopment && developmentParameters.has('adminPanel'),
|
||||||
|
)
|
||||||
const springboardEditing = ref(false)
|
const springboardEditing = ref(false)
|
||||||
const isUnlocking = ref(false)
|
const isUnlocking = ref(false)
|
||||||
const passcodeBusy = ref(false)
|
const passcodeBusy = ref(false)
|
||||||
@@ -491,6 +502,7 @@ function getViewportScale(): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hydratePhone(payload: PhoneOpenPayload): void {
|
function hydratePhone(payload: PhoneOpenPayload): void {
|
||||||
|
configurePhoneNumberFormat(payload.phoneNumberFormat)
|
||||||
if (payload.device?.imei) {
|
if (payload.device?.imei) {
|
||||||
companies.bindDeviceScope(
|
companies.bindDeviceScope(
|
||||||
payload.device.imei,
|
payload.device.imei,
|
||||||
@@ -627,6 +639,8 @@ function loadUnlockedPhoneData(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function completePhoneSetup(): void {
|
function completePhoneSetup(): void {
|
||||||
|
const requestedRoute = pendingUnlockRoute.value
|
||||||
|
pendingUnlockRoute.value = null
|
||||||
setupPreviewDismissed.value = true
|
setupPreviewDismissed.value = true
|
||||||
setupAppearanceSelected.value = false
|
setupAppearanceSelected.value = false
|
||||||
isLocked.value = false
|
isLocked.value = false
|
||||||
@@ -634,7 +648,7 @@ function completePhoneSetup(): void {
|
|||||||
passcodeVisible.value = false
|
passcodeVisible.value = false
|
||||||
passcodeRequired.value = false
|
passcodeRequired.value = false
|
||||||
controlCenterOpened.value = false
|
controlCenterOpened.value = false
|
||||||
void router.replace('/')
|
void router.replace(requestedRoute ?? '/')
|
||||||
loadUnlockedPhoneData()
|
loadUnlockedPhoneData()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -718,7 +732,15 @@ function openDevelopmentPayphonePreview(): void {
|
|||||||
function onMessage(event: MessageEvent<AppMessage>): void {
|
function onMessage(event: MessageEvent<AppMessage>): void {
|
||||||
if (!isTrustedRootMessageSource(event.source, window)) return
|
if (!isTrustedRootMessageSource(event.source, window)) return
|
||||||
|
|
||||||
if (event.data?.type === 'custom-apps:catalog') {
|
if (event.data?.type === 'admin:open') {
|
||||||
|
const data = event.data.data as AdminPanelOpenPayload | undefined
|
||||||
|
if (data?.lang && data.locales && data.fallbackLocales) {
|
||||||
|
phone.setLocale(data.lang, data.locales, data.fallbackLocales)
|
||||||
|
}
|
||||||
|
adminPanelOpen.value = true
|
||||||
|
} else if (event.data?.type === 'admin:close') {
|
||||||
|
adminPanelOpen.value = false
|
||||||
|
} else if (event.data?.type === 'custom-apps:catalog') {
|
||||||
appCatalog.replaceCatalog(event.data.data)
|
appCatalog.replaceCatalog(event.data.data)
|
||||||
const catalogPayload = event.data.data as
|
const catalogPayload = event.data.data as
|
||||||
| { apps?: unknown; debug?: unknown }
|
| { apps?: unknown; debug?: unknown }
|
||||||
@@ -763,7 +785,12 @@ function onMessage(event: MessageEvent<AppMessage>): void {
|
|||||||
isPhoneAppId(data.appId) &&
|
isPhoneAppId(data.appId) &&
|
||||||
appStore.isInstalled(data.appId)
|
appStore.isInstalled(data.appId)
|
||||||
) {
|
) {
|
||||||
void router.push(`/apps/${data.appId}`)
|
const requestedRoute = `/apps/${data.appId}`
|
||||||
|
if (setupRequired.value || isLocked.value) {
|
||||||
|
pendingUnlockRoute.value = requestedRoute
|
||||||
|
} else {
|
||||||
|
void router.push(requestedRoute)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error('[Navigation] Ignored an unavailable app target.')
|
console.error('[Navigation] Ignored an unavailable app target.')
|
||||||
}
|
}
|
||||||
@@ -1222,7 +1249,9 @@ function onMessage(event: MessageEvent<AppMessage>): void {
|
|||||||
loadUnlockedPhoneData()
|
loadUnlockedPhoneData()
|
||||||
}
|
}
|
||||||
} else if (event.data?.type === 'sim:picker' && event.data.data) {
|
} else if (event.data?.type === 'sim:picker' && event.data.data) {
|
||||||
simPicker.value = event.data.data as unknown as SimPickerPayload
|
const payload = event.data.data as unknown as SimPickerPayload
|
||||||
|
configurePhoneNumberFormat(payload.phoneNumberFormat)
|
||||||
|
simPicker.value = payload
|
||||||
} else if (event.data?.type === 'sim:picker-close') {
|
} else if (event.data?.type === 'sim:picker-close') {
|
||||||
simPicker.value = null
|
simPicker.value = null
|
||||||
}
|
}
|
||||||
@@ -1726,6 +1755,15 @@ onBeforeUnmount(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<SkyProvider
|
||||||
|
v-if="adminPanelOpen"
|
||||||
|
dark
|
||||||
|
:safe-areas="false"
|
||||||
|
accent="#74d66f"
|
||||||
|
accent-soft="rgba(116, 214, 111, 0.14)"
|
||||||
|
>
|
||||||
|
<AdminPanel @close="adminPanelOpen = false" />
|
||||||
|
</SkyProvider>
|
||||||
<PhoneMediaCapture />
|
<PhoneMediaCapture />
|
||||||
<PhoneMemoRecorder />
|
<PhoneMemoRecorder />
|
||||||
<RadioHud />
|
<RadioHud />
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,455 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const source = readFileSync(
|
||||||
|
new URL('./AdminPanel.vue', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const agentInstructions = readFileSync(
|
||||||
|
new URL('../../../AGENTS.md', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const app = readFileSync(new URL('../App.vue', import.meta.url), 'utf8')
|
||||||
|
const apps = readFileSync(new URL('../config/apps.ts', import.meta.url), 'utf8')
|
||||||
|
const store = readFileSync(
|
||||||
|
new URL('../stores/admin.ts', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const server = readFileSync(
|
||||||
|
new URL('../../../sky_phone/source/server/admin.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const phoneServer = readFileSync(
|
||||||
|
new URL('../../../sky_phone/source/server/phone.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const persistenceServer = readFileSync(
|
||||||
|
new URL(
|
||||||
|
'../../../sky_phone/source/server/phone_persistence.lua',
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const simServer = readFileSync(
|
||||||
|
new URL('../../../sky_phone/source/server/sim.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const phoneClient = readFileSync(
|
||||||
|
new URL('../../../sky_phone/source/client/main.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const focusClient = readFileSync(
|
||||||
|
new URL('../../../sky_phone/source/client/focus.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const bridge = readFileSync(
|
||||||
|
new URL(
|
||||||
|
'../../../sky_phone/source/client/nui_server_bridge.lua',
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const config = readFileSync(
|
||||||
|
new URL('../../../sky_phone/config/config.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const schema = readFileSync(
|
||||||
|
new URL('../../../sky_phone/sql/install.sql', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const manifest = readFileSync(
|
||||||
|
new URL('../../../sky_phone/fxmanifest.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const configuratorServer = readFileSync(
|
||||||
|
new URL(
|
||||||
|
'../../../sky_phone/source/server/phone_configurator.lua',
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const configuratorClient = readFileSync(
|
||||||
|
new URL(
|
||||||
|
'../../../sky_phone/source/client/phone_configurator.lua',
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const mediaImportServer = readFileSync(
|
||||||
|
new URL('../../../sky_phone/source/server/media_import.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const configuratorValueEditor = readFileSync(
|
||||||
|
new URL('./AdminConfigValueEditor.vue', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const configInputWidth = readFileSync(
|
||||||
|
new URL('../directives/configInputWidth.ts', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
|
describe('standalone admin panel contracts', () => {
|
||||||
|
it('renders as a dedicated full-screen editor outside the phone shell', () => {
|
||||||
|
expect(apps).not.toContain("id: 'admin'")
|
||||||
|
expect(app).toContain("event.data?.type === 'admin:open'")
|
||||||
|
expect(app).toContain('v-if="adminPanelOpen"')
|
||||||
|
expect(app).toContain('<AdminPanel')
|
||||||
|
expect(source).toContain("import { SkyButton } from '@/ui'")
|
||||||
|
expect(source).toContain('class="admin-panel-overlay"')
|
||||||
|
expect(source).toContain('class="admin-panel-rail"')
|
||||||
|
expect(source).toContain('class="admin-panel-directory"')
|
||||||
|
expect(source).toContain('class="admin-panel-editor"')
|
||||||
|
expect(source).toContain('pointer-events: auto')
|
||||||
|
expect(source).toContain('@media (prefers-reduced-motion: reduce)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses a compact transparent shell with dedicated admin workspaces', () => {
|
||||||
|
expect(source).toContain('background: transparent')
|
||||||
|
expect(source).toContain('width: min(76vw, 1220px)')
|
||||||
|
expect(source).toContain('height: min(74vh, 700px)')
|
||||||
|
expect(source).toContain('--admin-row-hover: linear-gradient')
|
||||||
|
expect(source).toContain('--admin-row-active: linear-gradient')
|
||||||
|
expect(source).toContain('background: var(--admin-nav-active)')
|
||||||
|
expect(source).not.toContain('admin-panel-brand__mark')
|
||||||
|
expect(source).not.toContain('admin-panel-profile-heading__status')
|
||||||
|
expect(source).not.toContain('backdrop-filter: blur(2px)')
|
||||||
|
expect(source).not.toContain("t('overview.recent')")
|
||||||
|
|
||||||
|
for (const tab of [
|
||||||
|
'overview',
|
||||||
|
'players',
|
||||||
|
'devices',
|
||||||
|
'apps',
|
||||||
|
'accounts',
|
||||||
|
'messages',
|
||||||
|
'calls',
|
||||||
|
'moderation',
|
||||||
|
'audit',
|
||||||
|
'configurator',
|
||||||
|
]) {
|
||||||
|
expect(source).toContain(`selectTab('${tab}')`)
|
||||||
|
expect(source).toContain(`t('tabs.${tab}')`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes manual reload and applies a persistent global accent choice', () => {
|
||||||
|
expect(source).not.toContain('<RefreshCw')
|
||||||
|
expect(source).not.toContain("kind: 'refresh'")
|
||||||
|
expect(source).toContain("'sky-phone-admin-accent'")
|
||||||
|
expect(source).toContain("'sky-phone-admin-font-family'")
|
||||||
|
expect(source).toContain("'sky-phone-admin-font-size'")
|
||||||
|
expect(source).toContain("'--admin-accent': accentColor")
|
||||||
|
expect(source).toContain("'--admin-font-family': activeAdminFontFamily")
|
||||||
|
expect(source).toContain(
|
||||||
|
"'--admin-font-scale': String(adminFontSize / 100)",
|
||||||
|
)
|
||||||
|
expect(source).toContain('class="admin-panel-color-value"')
|
||||||
|
expect(source).toContain('class="admin-panel-rgb-fields"')
|
||||||
|
expect(source).toContain('class="admin-panel-rgb-range"')
|
||||||
|
expect(source).toContain('class="admin-panel-font-select"')
|
||||||
|
expect(source).toContain('class="admin-panel-font-select__menu"')
|
||||||
|
expect(source).toContain('class="admin-panel-font-size-control"')
|
||||||
|
expect(source).not.toContain('type="color"')
|
||||||
|
expect(source).toContain('color-mix(in srgb, var(--admin-green)')
|
||||||
|
expect(source).toContain('--admin-toggle-on: #63d471')
|
||||||
|
expect(source).toContain('background: var(--admin-toggle-on)')
|
||||||
|
expect(configuratorValueEditor).toContain(
|
||||||
|
'background: var(--admin-toggle-on)',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stages app changes locally and saves them only from the toolbar action', () => {
|
||||||
|
expect(source).toContain('const drafts = ref<')
|
||||||
|
expect(source).toContain('@click="saveChanges"')
|
||||||
|
expect(source).toContain("t('editor.noAutoSave')")
|
||||||
|
expect(store).toContain("'admin:save-apps'")
|
||||||
|
expect(store).not.toContain("'admin:set-app'")
|
||||||
|
expect(server).toContain(
|
||||||
|
'Bridge.Callbacks.Register("sky_phone:admin:save-apps"',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('connects every operation through the standard NUI callback bridge', () => {
|
||||||
|
expect(bridge).toContain('admin = [[')
|
||||||
|
for (const endpoint of [
|
||||||
|
'admin:bootstrap',
|
||||||
|
'admin:player',
|
||||||
|
'admin:save-apps',
|
||||||
|
'admin:reveal-password',
|
||||||
|
'admin:activity',
|
||||||
|
'admin:reset-passcode',
|
||||||
|
'admin:change-number',
|
||||||
|
'admin:factory-reset',
|
||||||
|
'admin:configurator',
|
||||||
|
'admin:save-configurator',
|
||||||
|
]) {
|
||||||
|
expect(store).toContain(endpoint)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('protects activity views and device moderation with ownership and audit checks', () => {
|
||||||
|
for (const endpoint of [
|
||||||
|
'activity',
|
||||||
|
'reset-passcode',
|
||||||
|
'change-number',
|
||||||
|
'factory-reset',
|
||||||
|
]) {
|
||||||
|
expect(server).toContain(
|
||||||
|
`Bridge.Callbacks.Register("sky_phone:admin:${endpoint}"`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
expect(server).toContain('data.kind ~= "messages"')
|
||||||
|
expect(server).toContain('data.kind ~= "calls"')
|
||||||
|
expect(server).toContain('"view_messages"')
|
||||||
|
expect(server).toContain('"view_calls"')
|
||||||
|
expect(server).toContain('"reset_passcode"')
|
||||||
|
expect(server).toContain('"change_number"')
|
||||||
|
expect(server).toContain('"factory_reset"')
|
||||||
|
expect(simServer).toContain('function SkyPhoneSim.ChangeNumber(')
|
||||||
|
expect(simServer).toContain('UPDATE IGNORE `sky_phone_sims`')
|
||||||
|
expect(persistenceServer).toContain(
|
||||||
|
'function SkyPhonePersistence.FactoryReset(imei)',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('authorizes every server request without requiring a phone session', () => {
|
||||||
|
expect(server).not.toContain('SkyPhone.RequireSession(source)')
|
||||||
|
expect(server).toContain(
|
||||||
|
'Bridge.Framework.HasAdminGroup(source, Config.AdminPanel.AdminGroups)',
|
||||||
|
)
|
||||||
|
expect(server).toContain('Config.AdminPanel.ReadRequestsPerMinute')
|
||||||
|
expect(server).toContain('Config.AdminPanel.ActionRequestsPerMinute')
|
||||||
|
expect(server).toContain('Config.AdminPanel.CredentialRevealsPerMinute')
|
||||||
|
expect(config).toContain('Config.AdminPanel = {')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens directly from the configurable command with dedicated focus', () => {
|
||||||
|
expect(config).toContain('Command = "phonepanel"')
|
||||||
|
expect(server).toContain('local command_name = Config.AdminPanel.Command')
|
||||||
|
expect(server).toContain(
|
||||||
|
'RegisterCommand(command_name, function(command_source)',
|
||||||
|
)
|
||||||
|
expect(server).toContain(
|
||||||
|
'TriggerClientEvent("sky_phone:admin:launch", player_source)',
|
||||||
|
)
|
||||||
|
expect(phoneServer).not.toContain(
|
||||||
|
'RegisterCommand(Config.AdminPanel.Command',
|
||||||
|
)
|
||||||
|
expect(phoneClient).toContain('RegisterNetEvent("sky_phone:admin:launch"')
|
||||||
|
expect(phoneClient).toContain('SkyPhoneFocus.SetAdminPanel(true)')
|
||||||
|
expect(focusClient).toContain('function SkyPhoneFocus.SetAdminPanel(open)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validates ownership, app policy, and revision before a batch mutation', () => {
|
||||||
|
expect(server).toContain('find_owned_device(target_source, data.imei)')
|
||||||
|
expect(server).toContain('app_metadata(change.appId)')
|
||||||
|
expect(server).toContain('error = "device_not_owned"')
|
||||||
|
expect(server).toContain('error = "app_protected"')
|
||||||
|
expect(server).toContain('AND `revision` = ?')
|
||||||
|
expect(server).toContain('SkyPhone.RefreshDevice(data.imei)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gates plaintext account reveals behind confirmation and audit logging', () => {
|
||||||
|
expect(source).toContain('revealDialogImei')
|
||||||
|
expect(source).toContain("t('credentials.revealTitle')")
|
||||||
|
expect(server).toContain('"reveal_account_password"')
|
||||||
|
expect(server).toContain('write_audit(')
|
||||||
|
expect(server).not.toContain('passcode_hash` AS')
|
||||||
|
expect(schema).toContain(
|
||||||
|
'CREATE TABLE IF NOT EXISTS `sky_phone_admin_audit`',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads the SQL phone configurator before framework-owned configuration is read', () => {
|
||||||
|
expect(agentInstructions).toContain('Phone Configurator parity (mandatory)')
|
||||||
|
expect(agentInstructions).toContain(
|
||||||
|
'changes without matching Configurator support are incomplete',
|
||||||
|
)
|
||||||
|
expect(config).toMatch(
|
||||||
|
/Config\.PhoneConfigurator\s*=\s*\{[\s\S]*?Enabled\s*=\s*true[\s\S]*?Config\.Bridge\s*=/,
|
||||||
|
)
|
||||||
|
expect(schema).toContain(
|
||||||
|
'CREATE TABLE IF NOT EXISTS `sky_phone_configurator`',
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
manifest.indexOf("'source/server/phone_configurator.lua'"),
|
||||||
|
).toBeLessThan(manifest.indexOf("'source/bridge/server/framework.lua'"))
|
||||||
|
expect(
|
||||||
|
manifest.indexOf("'source/client/phone_configurator.lua'"),
|
||||||
|
).toBeLessThan(manifest.indexOf("'source/bridge/client/framework.lua'"))
|
||||||
|
expect(configuratorServer).toContain('AND `revision` = ?')
|
||||||
|
expect(configuratorServer).toContain('configurator_enabled')
|
||||||
|
expect(configuratorServer).toMatch(
|
||||||
|
/for key, value in pairs\(Config\)[\s\S]*?key ~= "Media"[\s\S]*?key ~= "PhoneConfigurator"/,
|
||||||
|
)
|
||||||
|
expect(configuratorServer).toContain(
|
||||||
|
'default_media = serialize_value(Config.Media)',
|
||||||
|
)
|
||||||
|
expect(configuratorServer).toContain(
|
||||||
|
'build_sections("config", stored_config',
|
||||||
|
)
|
||||||
|
expect(configuratorServer).toContain('build_sections("media", stored_media')
|
||||||
|
expect(configuratorServer).toContain('sensitive_path')
|
||||||
|
expect(configuratorServer).toContain('restore_redacted_values')
|
||||||
|
expect(configuratorServer).toContain(
|
||||||
|
'{ __skyType = "map", entries = entries }',
|
||||||
|
)
|
||||||
|
expect(configuratorServer).toContain('validate_structured_value')
|
||||||
|
expect(configuratorServer).toContain('validate_locked_structure')
|
||||||
|
expect(configuratorServer).toContain('build_structure(default_value')
|
||||||
|
expect(configuratorServer).toContain('path == "Companies.Definitions"')
|
||||||
|
expect(configuratorServer).toContain('flatten_company_fields')
|
||||||
|
expect(configuratorServer).toContain('structure.mutableKeys')
|
||||||
|
expect(configuratorServer).toContain('local function empty_structure(')
|
||||||
|
expect(configuratorServer).toContain('template = items[1]')
|
||||||
|
expect(configuratorServer).toContain('structure.template')
|
||||||
|
expect(configuratorServer).toContain('if not structure.fields[key] then')
|
||||||
|
expect(configuratorServer).toContain('field.type == "stringOrFalse"')
|
||||||
|
expect(configuratorServer).not.toContain('Config.Media = client_payload')
|
||||||
|
expect(configuratorServer).toContain(
|
||||||
|
'apply_runtime_table(Config[key], value)',
|
||||||
|
)
|
||||||
|
expect(configuratorServer).toContain(
|
||||||
|
'apply_runtime_table(Config.Media, runtime_media)',
|
||||||
|
)
|
||||||
|
expect(configuratorServer).toContain('local function read_stored_row()')
|
||||||
|
expect(configuratorServer).toContain('local function apply_stored_row(row)')
|
||||||
|
expect(configuratorServer).toContain(
|
||||||
|
'persisted_row.media_payload ~= media_encoded',
|
||||||
|
)
|
||||||
|
expect(configuratorServer).toContain(
|
||||||
|
'Phone configurator SQL verification failed',
|
||||||
|
)
|
||||||
|
expect(server).not.toContain('ExecuteCommand(("restart %s")')
|
||||||
|
expect(configuratorServer).not.toContain('clear_runtime_table')
|
||||||
|
expect(configuratorServer).toContain(
|
||||||
|
'TriggerEvent("sky_phone:configurator:serverUpdated", revision)',
|
||||||
|
)
|
||||||
|
expect(configuratorServer).toContain(
|
||||||
|
'function SkyPhoneConfigurator.Broadcast(target)',
|
||||||
|
)
|
||||||
|
expect(configuratorServer).toContain('SkyPhoneConfigurator.Broadcast(-1)')
|
||||||
|
expect(configuratorServer).toContain('through the internal runtime refresh')
|
||||||
|
expect(configuratorClient).toContain(
|
||||||
|
'Bridge.Callbacks.Trigger("sky_phone:configurator:runtime"',
|
||||||
|
)
|
||||||
|
expect(configuratorClient).toContain(
|
||||||
|
'apply_runtime_table(Config[key], value)',
|
||||||
|
)
|
||||||
|
expect(configuratorClient).not.toContain('clear_runtime_table')
|
||||||
|
expect(configuratorClient).toContain(
|
||||||
|
'TriggerEvent("sky_phone:configurator:updated"',
|
||||||
|
)
|
||||||
|
expect(phoneClient).toMatch(
|
||||||
|
/AddEventHandler\("sky_phone:configurator:updated"[\s\S]*?SkyPhoneLocales\.Resolve\(Config\.Bridge\.Locale\)[\s\S]*?SkyPhoneApps\.SendCatalog\(\)[\s\S]*?type = "device:updated"/,
|
||||||
|
)
|
||||||
|
expect(server).toContain(
|
||||||
|
'AddEventHandler("sky_phone:configurator:serverUpdated"',
|
||||||
|
)
|
||||||
|
expect(phoneServer).toContain('register_configured_phone_item()')
|
||||||
|
expect(phoneServer).toContain(
|
||||||
|
'AddEventHandler("sky_phone:configurator:serverUpdated"',
|
||||||
|
)
|
||||||
|
expect(simServer).toContain('refresh_sim_types()')
|
||||||
|
expect(simServer).toContain(
|
||||||
|
'AddEventHandler("sky_phone:configurator:serverUpdated"',
|
||||||
|
)
|
||||||
|
expect(mediaImportServer).toContain('local website = {}')
|
||||||
|
expect(mediaImportServer).toContain('website._adapter = adapter')
|
||||||
|
expect(mediaImportServer).not.toContain('definition._adapter = adapter')
|
||||||
|
expect(mediaImportServer).toMatch(
|
||||||
|
/AddEventHandler\("sky_phone:configurator:serverUpdated"[\s\S]*?if initialized then[\s\S]*?build_registry\(\)/,
|
||||||
|
)
|
||||||
|
expect(source).toContain('class="admin-panel-rail__configurator"')
|
||||||
|
expect(source).toContain(
|
||||||
|
'.admin-panel-rail .admin-panel-rail__configurator',
|
||||||
|
)
|
||||||
|
expect(source).toContain('<AdminConfigValueEditor')
|
||||||
|
expect(source).toContain('function configuratorFieldRepeatsSection(')
|
||||||
|
expect(source).toContain('v-if="!configuratorFieldRepeatsSection(field)"')
|
||||||
|
expect(source).toContain('class="admin-panel-config-scopes"')
|
||||||
|
expect(source).toContain("selectConfiguratorScope('config')")
|
||||||
|
expect(source).toContain("selectConfiguratorScope('media')")
|
||||||
|
expect(source).not.toContain('class="admin-panel-config-meta"')
|
||||||
|
expect(configuratorValueEditor).toContain('function addListRow()')
|
||||||
|
expect(configuratorValueEditor).toContain('function addTableField()')
|
||||||
|
expect(configuratorValueEditor).toContain(
|
||||||
|
'const canExtendTable = computed(',
|
||||||
|
)
|
||||||
|
expect(configuratorValueEditor).toContain(
|
||||||
|
'tableStructure.value.mutableKeys === true',
|
||||||
|
)
|
||||||
|
expect(configuratorValueEditor).toContain(
|
||||||
|
'const usesFixedTableLayout = computed(',
|
||||||
|
)
|
||||||
|
expect(configuratorValueEditor).toContain(
|
||||||
|
"'is-fixed-table': usesFixedTableLayout",
|
||||||
|
)
|
||||||
|
expect(configuratorValueEditor).toContain('v-if="!usesFixedTableLayout"')
|
||||||
|
expect(configuratorValueEditor).toContain(
|
||||||
|
'.config-structured-editor.is-fixed-table',
|
||||||
|
)
|
||||||
|
expect(configuratorValueEditor).toContain('v-else-if="canExtendTable"')
|
||||||
|
expect(configuratorValueEditor).toContain('function removeListRow(')
|
||||||
|
expect(configuratorValueEditor).toContain('function removeTableField(')
|
||||||
|
expect(configuratorValueEditor).toContain('function addMapEntry()')
|
||||||
|
expect(configuratorValueEditor).toContain('function updateMapKey(')
|
||||||
|
expect(configuratorValueEditor).toContain('fixedMapEntryStructure(current)')
|
||||||
|
expect(configuratorValueEditor).toContain('listStructure?.items[index]')
|
||||||
|
expect(configuratorValueEditor).toContain('function listItemStructure(')
|
||||||
|
expect(configuratorValueEditor).toContain('const listTemplate = computed(')
|
||||||
|
expect(configuratorValueEditor).toContain('function structureTypeLabel(')
|
||||||
|
expect(configuratorValueEditor).toContain(
|
||||||
|
'class="config-structured-editor__fixed-type"',
|
||||||
|
)
|
||||||
|
expect(configuratorValueEditor).toContain(
|
||||||
|
'const rootTableTabs = computed<RootTableTab[]>(',
|
||||||
|
)
|
||||||
|
expect(configuratorValueEditor).toContain(
|
||||||
|
'class="config-structured-editor__tabs"',
|
||||||
|
)
|
||||||
|
expect(configuratorValueEditor).toContain(
|
||||||
|
'class="config-structured-editor__tab-panel"',
|
||||||
|
)
|
||||||
|
expect(source).toContain(':tab-label="configuratorSubtabLabel"')
|
||||||
|
expect(source).toContain('configurator.table.subtabs.${key}')
|
||||||
|
expect(configuratorValueEditor).toContain(
|
||||||
|
'props.tabLabel?.(key, tableValue.value[key]) ?? key',
|
||||||
|
)
|
||||||
|
expect(source).toContain('v-config-input-width')
|
||||||
|
expect(configuratorValueEditor).toContain('v-config-input-width')
|
||||||
|
expect(configInputWidth).toContain("input.addEventListener('input'")
|
||||||
|
expect(configInputWidth).toContain('input.scrollWidth')
|
||||||
|
expect(source).toContain('filter: drop-shadow')
|
||||||
|
expect(source).toContain("input[type='number']::-webkit-inner-spin-button")
|
||||||
|
expect(configuratorValueEditor).toContain(
|
||||||
|
"input[type='number']::-webkit-inner-spin-button",
|
||||||
|
)
|
||||||
|
expect(source).toContain('justify-self: start')
|
||||||
|
expect(configuratorValueEditor).toContain('function tableFieldStructure(')
|
||||||
|
expect(configuratorValueEditor).toContain('function isFixedTableField(')
|
||||||
|
expect(configuratorValueEditor).toContain('function blankCollectionValue(')
|
||||||
|
expect(configuratorValueEditor).toContain('function blankFromStructure(')
|
||||||
|
expect(source).toContain("'is-structured': field.type === 'json'")
|
||||||
|
expect(configuratorValueEditor).toContain('function isStructuredValue(')
|
||||||
|
expect(configuratorValueEditor).toContain(
|
||||||
|
'class="config-structured-editor__add-field is-list"',
|
||||||
|
)
|
||||||
|
expect(configuratorValueEditor).toContain(
|
||||||
|
'class="config-structured-editor__actions"',
|
||||||
|
)
|
||||||
|
expect(configuratorValueEditor).toContain('function toggleStructuredEntry(')
|
||||||
|
expect(configuratorValueEditor).toContain(
|
||||||
|
'class="config-structured-editor__section-toggle"',
|
||||||
|
)
|
||||||
|
expect(configuratorValueEditor).toContain(':aria-expanded=')
|
||||||
|
expect(configuratorValueEditor).toContain(
|
||||||
|
'@media (prefers-reduced-motion: reduce)',
|
||||||
|
)
|
||||||
|
expect(configuratorValueEditor).toContain(
|
||||||
|
'.config-structured-editor__property.has-structured-value',
|
||||||
|
)
|
||||||
|
expect(configuratorValueEditor).not.toContain('<textarea')
|
||||||
|
})
|
||||||
|
})
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -328,7 +328,10 @@ onBeforeUnmount(() => {
|
|||||||
class="app-icon"
|
class="app-icon"
|
||||||
:class="[
|
:class="[
|
||||||
app.iconClass,
|
app.iconClass,
|
||||||
{ 'app-icon--image': !iconFailed && app.id !== 'calendar' },
|
{
|
||||||
|
'app-icon--image':
|
||||||
|
!iconFailed && Boolean(app.iconImage) && app.id !== 'calendar',
|
||||||
|
},
|
||||||
]"
|
]"
|
||||||
:style="iconStyle"
|
:style="iconStyle"
|
||||||
>
|
>
|
||||||
@@ -337,7 +340,7 @@ onBeforeUnmount(() => {
|
|||||||
<b>{{ calendarDay }}</b>
|
<b>{{ calendarDay }}</b>
|
||||||
</span>
|
</span>
|
||||||
<img
|
<img
|
||||||
v-else-if="!iconFailed"
|
v-else-if="!iconFailed && app.iconImage"
|
||||||
:src="app.iconImage"
|
:src="app.iconImage"
|
||||||
alt=""
|
alt=""
|
||||||
draggable="false"
|
draggable="false"
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
|||||||
|
|
||||||
type RecordingChunk = { blob: Blob; durationMs: number }
|
type RecordingChunk = { blob: Blob; durationMs: number }
|
||||||
type PendingVideo = { blob: Blob; fileName: string }
|
type PendingVideo = { blob: Blob; fileName: string }
|
||||||
|
type UploadFailureDebug = {
|
||||||
|
correlationId: string
|
||||||
|
message: string
|
||||||
|
stage: string
|
||||||
|
status?: number
|
||||||
|
}
|
||||||
|
|
||||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||||
const pendingVideos = new Map<string, PendingVideo>()
|
const pendingVideos = new Map<string, PendingVideo>()
|
||||||
@@ -130,7 +136,10 @@ function cleanupRecording(): void {
|
|||||||
try {
|
try {
|
||||||
activeRecorder.stop()
|
activeRecorder.stop()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Camera] Could not stop the failed media recorder.', error)
|
console.error(
|
||||||
|
'[Camera] Could not stop the failed media recorder.',
|
||||||
|
error,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,8 +254,7 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
|
|||||||
const activeRecorder = recorder
|
const activeRecorder = recorder
|
||||||
removeRecorderErrorListener = bindMediaRecorderError(
|
removeRecorderErrorListener = bindMediaRecorderError(
|
||||||
activeRecorder,
|
activeRecorder,
|
||||||
() =>
|
() => generation === recordingGeneration && recorder === activeRecorder,
|
||||||
generation === recordingGeneration && recorder === activeRecorder,
|
|
||||||
(event) => {
|
(event) => {
|
||||||
console.error('[Camera] Media recorder failed while recording.', event)
|
console.error('[Camera] Media recorder failed while recording.', event)
|
||||||
cleanupRecording()
|
cleanupRecording()
|
||||||
@@ -417,8 +425,32 @@ async function capturePhotoBlob(ready: UploadReady): Promise<Blob> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function failUpload(requestId: string, error: string): Promise<void> {
|
async function failUpload(
|
||||||
await nuiCall('media:failUpload', { error, requestId })
|
requestId: string,
|
||||||
|
error: string,
|
||||||
|
debug: UploadFailureDebug,
|
||||||
|
): Promise<void> {
|
||||||
|
console.error('[Sky Phone Media] Upload failed.', {
|
||||||
|
correlationId: debug.correlationId,
|
||||||
|
detail: debug.message,
|
||||||
|
error,
|
||||||
|
stage: debug.stage,
|
||||||
|
status: debug.status,
|
||||||
|
})
|
||||||
|
const response = await nuiCall('media:failUpload', {
|
||||||
|
correlationId: debug.correlationId,
|
||||||
|
debugMessage: debug.message,
|
||||||
|
debugStage: debug.stage,
|
||||||
|
debugStatus: debug.status,
|
||||||
|
error,
|
||||||
|
requestId,
|
||||||
|
})
|
||||||
|
if (!response.success) {
|
||||||
|
console.error('[Sky Phone Media] Could not forward upload diagnostics.', {
|
||||||
|
correlationId: debug.correlationId,
|
||||||
|
error: response.error,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadReady(ready: UploadReady): Promise<void> {
|
async function uploadReady(ready: UploadReady): Promise<void> {
|
||||||
@@ -435,49 +467,87 @@ async function uploadReady(ready: UploadReady): Promise<void> {
|
|||||||
blob = await capturePhotoBlob(ready)
|
blob = await capturePhotoBlob(ready)
|
||||||
fileName = `camera-${ready.correlationId}.${ready.photo?.Encoding ?? 'jpg'}`
|
fileName = `camera-${ready.correlationId}.${ready.photo?.Encoding ?? 'jpg'}`
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
await failUpload(ready.requestId, 'capture_failed')
|
await failUpload(ready.requestId, 'capture_failed', {
|
||||||
|
correlationId: ready.correlationId,
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
stage: 'capture',
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.info('[Sky Phone Media] Capture prepared for upload.', {
|
||||||
|
bytes: blob.size,
|
||||||
|
correlationId: ready.correlationId,
|
||||||
|
mimeType: blob.type,
|
||||||
|
type: ready.mediaType,
|
||||||
|
})
|
||||||
|
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', blob, fileName)
|
form.append('file', blob, fileName)
|
||||||
form.append(
|
|
||||||
'metadata',
|
|
||||||
JSON.stringify({ captureToken: ready.captureToken, source: 'sky_phone' }),
|
|
||||||
)
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timeout = window.setTimeout(
|
const timeout = window.setTimeout(
|
||||||
() => controller.abort(),
|
() => controller.abort(),
|
||||||
ready.uploadTimeoutMs ?? 25000,
|
ready.uploadTimeoutMs ?? 25000,
|
||||||
)
|
)
|
||||||
|
let debugStage = 'provider_request'
|
||||||
|
let debugStatus: number | undefined
|
||||||
try {
|
try {
|
||||||
const response = await fetch(ready.presignedUrl, {
|
const response = await fetch(ready.presignedUrl, {
|
||||||
body: form,
|
body: form,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
|
debugStatus = response.status
|
||||||
|
debugStage = 'provider_response'
|
||||||
|
console.info('[Sky Phone Media] FiveManage upload responded.', {
|
||||||
|
correlationId: ready.correlationId,
|
||||||
|
status: response.status,
|
||||||
|
})
|
||||||
const text = await response.text()
|
const text = await response.text()
|
||||||
const body = JSON.parse(text) as {
|
const body = JSON.parse(text) as {
|
||||||
data?: { id?: string; url?: string }
|
data?: { id?: string; url?: string }
|
||||||
|
error?: string
|
||||||
id?: string
|
id?: string
|
||||||
|
message?: string
|
||||||
url?: string
|
url?: string
|
||||||
}
|
}
|
||||||
const uploaded = body.data ?? body
|
const uploaded = body.data ?? body
|
||||||
if (!response.ok || !uploaded.id || !uploaded.url) {
|
if (!response.ok || !uploaded.id || !uploaded.url) {
|
||||||
throw new Error('upload_failed')
|
throw new Error(
|
||||||
|
(typeof body.error === 'string' && body.error) ||
|
||||||
|
(typeof body.message === 'string' && body.message) ||
|
||||||
|
'upload_failed',
|
||||||
|
)
|
||||||
}
|
}
|
||||||
await nuiCall('media:completeUpload', {
|
debugStage = 'completion_callback'
|
||||||
|
const completion = await nuiCall('media:completeUpload', {
|
||||||
|
correlationId: ready.correlationId,
|
||||||
remoteId: uploaded.id,
|
remoteId: uploaded.id,
|
||||||
requestId: ready.requestId,
|
requestId: ready.requestId,
|
||||||
url: uploaded.url,
|
url: uploaded.url,
|
||||||
})
|
})
|
||||||
|
if (!completion.success) {
|
||||||
|
throw new Error(completion.error ?? 'completion_callback_failed')
|
||||||
|
}
|
||||||
|
console.info(
|
||||||
|
'[Sky Phone Media] Upload completion forwarded to the server.',
|
||||||
|
{
|
||||||
|
correlationId: ready.correlationId,
|
||||||
|
},
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await failUpload(
|
await failUpload(
|
||||||
ready.requestId,
|
ready.requestId,
|
||||||
error instanceof DOMException && error.name === 'AbortError'
|
error instanceof DOMException && error.name === 'AbortError'
|
||||||
? 'upload_timeout'
|
? 'upload_timeout'
|
||||||
: 'upload_failed',
|
: 'upload_failed',
|
||||||
|
{
|
||||||
|
correlationId: ready.correlationId,
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
stage: debugStage,
|
||||||
|
status: debugStatus,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
window.clearTimeout(timeout)
|
window.clearTimeout(timeout)
|
||||||
@@ -523,7 +593,12 @@ function onMessage(event: MessageEvent): void {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if (message.type === 'media:uploadReady') {
|
} else if (message.type === 'media:uploadReady') {
|
||||||
void uploadReady(message.data as UploadReady)
|
const ready = message.data as UploadReady
|
||||||
|
console.info('[Sky Phone Media] Upload-ready received.', {
|
||||||
|
correlationId: ready.correlationId,
|
||||||
|
type: ready.mediaType,
|
||||||
|
})
|
||||||
|
void uploadReady(ready)
|
||||||
} else if (message.type === 'media:uploadResult') {
|
} else if (message.type === 'media:uploadResult') {
|
||||||
const correlationId = String(message.data?.correlationId ?? '')
|
const correlationId = String(message.data?.correlationId ?? '')
|
||||||
if (correlationId) pendingVideos.delete(correlationId)
|
if (correlationId) pendingVideos.delete(correlationId)
|
||||||
|
|||||||
@@ -365,6 +365,7 @@ async function stopRecording(data: Record<string, unknown>): Promise<void> {
|
|||||||
mimeType,
|
mimeType,
|
||||||
note: finalMetadata.note,
|
note: finalMetadata.note,
|
||||||
pinned: finalMetadata.pinned,
|
pinned: finalMetadata.pinned,
|
||||||
|
sizeBytes: blob.size,
|
||||||
title: finalMetadata.title,
|
title: finalMetadata.title,
|
||||||
waveform,
|
waveform,
|
||||||
}
|
}
|
||||||
@@ -458,14 +459,6 @@ async function uploadReady(ready: MemoUploadReady): Promise<void> {
|
|||||||
pending.requestId = ready.requestId
|
pending.requestId = ready.requestId
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', pending.blob, pending.fileName)
|
form.append('file', pending.blob, pending.fileName)
|
||||||
form.append(
|
|
||||||
'metadata',
|
|
||||||
JSON.stringify({
|
|
||||||
captureToken: ready.captureToken,
|
|
||||||
purpose: 'memo',
|
|
||||||
source: 'sky_phone',
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
pending.abortController = controller
|
pending.abortController = controller
|
||||||
const timeout = window.setTimeout(
|
const timeout = window.setTimeout(
|
||||||
|
|||||||
@@ -748,6 +748,7 @@ export function getPhoneAppLabel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isPhoneAppRemovable(app: PhoneAppDefinition): boolean {
|
export function isPhoneAppRemovable(app: PhoneAppDefinition): boolean {
|
||||||
|
if (app.adminOnly) return false
|
||||||
return app.kind === 'external'
|
return app.kind === 'external'
|
||||||
? app.removable && !app.defaultInstalled
|
? app.removable && !app.defaultInstalled
|
||||||
: !DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) &&
|
: !DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) &&
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Directive } from 'vue'
|
||||||
|
|
||||||
|
const inputListeners = new WeakMap<HTMLInputElement, () => void>()
|
||||||
|
const MINIMUM_INPUT_WIDTH = 42
|
||||||
|
|
||||||
|
function syncInputWidth(input: HTMLInputElement): void {
|
||||||
|
input.style.width = '1px'
|
||||||
|
input.style.width = `${Math.max(MINIMUM_INPUT_WIDTH, Math.ceil(input.scrollWidth) + 2)}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const vConfigInputWidth: Directive<HTMLInputElement> = {
|
||||||
|
mounted(input) {
|
||||||
|
const listener = () => syncInputWidth(input)
|
||||||
|
inputListeners.set(input, listener)
|
||||||
|
input.addEventListener('input', listener)
|
||||||
|
syncInputWidth(input)
|
||||||
|
},
|
||||||
|
updated(input) {
|
||||||
|
syncInputWidth(input)
|
||||||
|
},
|
||||||
|
beforeUnmount(input) {
|
||||||
|
const listener = inputListeners.get(input)
|
||||||
|
if (listener) input.removeEventListener('input', listener)
|
||||||
|
inputListeners.delete(input)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const mediaConfig = readFileSync(
|
||||||
|
new URL('../../sky_phone/config/media.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const manifest = readFileSync(
|
||||||
|
new URL('../../sky_phone/fxmanifest.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const mediaProviderConfig = readFileSync(
|
||||||
|
new URL(
|
||||||
|
'../../sky_phone/source/server/media_provider_config.lua',
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const mediaImportAdapter = readFileSync(
|
||||||
|
new URL(
|
||||||
|
'../../sky_phone/source/server/media_import/fivemanage.lua',
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const mediaServer = readFileSync(
|
||||||
|
new URL('../../sky_phone/source/server/media.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const memoServer = readFileSync(
|
||||||
|
new URL('../../sky_phone/source/server/memos.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const mediaCapture = readFileSync(
|
||||||
|
new URL('./components/PhoneMediaCapture.vue', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const memoRecorder = readFileSync(
|
||||||
|
new URL('./components/PhoneMemoRecorder.vue', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
|
describe('FiveManage server configuration contract', () => {
|
||||||
|
it('keeps the provider token in the server-only media config', () => {
|
||||||
|
expect(mediaConfig).toMatch(/FiveManage\s*=\s*{\s*ApiKey\s*=/)
|
||||||
|
expect(mediaProviderConfig).toContain(
|
||||||
|
'return trim_key(Config.Media.FiveManage.ApiKey)',
|
||||||
|
)
|
||||||
|
expect(mediaProviderConfig).not.toContain('GetConvar')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses one resolver for Camera uploads and FiveManage imports', () => {
|
||||||
|
expect(
|
||||||
|
manifest.indexOf("'source/server/media_provider_config.lua'"),
|
||||||
|
).toBeLessThan(manifest.indexOf("'source/server/media_import.lua'"))
|
||||||
|
expect(mediaServer).toContain(
|
||||||
|
'SkyPhoneMediaProviderConfig.FiveManageApiKey()',
|
||||||
|
)
|
||||||
|
expect(mediaImportAdapter).toContain(
|
||||||
|
'SkyPhoneMediaProviderConfig.FiveManageApiKey(website.ApiKey)',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the direct FiveManage upload response flow for Camera and voice memos', () => {
|
||||||
|
expect(mediaConfig).not.toContain('VerificationRetryDelaysMs')
|
||||||
|
expect(mediaCapture).toContain("form.append('file', blob, fileName)")
|
||||||
|
expect(mediaCapture).not.toContain("form.append('path'")
|
||||||
|
expect(mediaCapture).not.toContain("form.append(\n 'metadata'")
|
||||||
|
expect(memoRecorder).toContain(
|
||||||
|
"form.append('file', pending.blob, pending.fileName)",
|
||||||
|
)
|
||||||
|
expect(memoRecorder).not.toContain("form.append('path'")
|
||||||
|
expect(memoRecorder).not.toContain("form.append(\n 'metadata'")
|
||||||
|
expect(mediaServer).toContain(
|
||||||
|
'Accepting the direct FiveManage upload response',
|
||||||
|
)
|
||||||
|
expect(mediaServer).toContain('remote_id = remote_id')
|
||||||
|
expect(mediaServer).toContain('url = uploaded_url')
|
||||||
|
expect(mediaServer).not.toContain('"HEAD"')
|
||||||
|
expect(mediaServer).not.toContain('authenticated upload-path lookup')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allowlists the FiveManage API and media hosts', () => {
|
||||||
|
expect(mediaServer).toContain('["api.fivemanage.com"] = true')
|
||||||
|
expect(mediaServer).toContain('["fmapi.net"] = true')
|
||||||
|
expect(mediaServer).toContain(
|
||||||
|
'uploaded_host:lower() ~= "r2.fivemanage.com"',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validates and preserves the recorded memo size before upload', () => {
|
||||||
|
expect(memoRecorder).toContain('sizeBytes: blob.size')
|
||||||
|
expect(memoServer).toContain(
|
||||||
|
'local size_bytes = tonumber(data.sizeBytes)',
|
||||||
|
)
|
||||||
|
expect(memoServer).toContain(
|
||||||
|
'size_bytes < 1 or size_bytes > Config.Memos.MaximumBytes',
|
||||||
|
)
|
||||||
|
expect(memoServer).toContain('size_bytes = memo.size_bytes')
|
||||||
|
expect(mediaServer).toContain('size = state.size_bytes')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const mediaCapture = readFileSync(
|
||||||
|
new URL('./components/PhoneMediaCapture.vue', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const mediaServer = readFileSync(
|
||||||
|
new URL('../../sky_phone/source/server/media.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
|
describe('media upload diagnostics contracts', () => {
|
||||||
|
it('forwards browser upload failure context to the server log', () => {
|
||||||
|
expect(mediaCapture).toContain("let debugStage = 'provider_request'")
|
||||||
|
expect(mediaCapture).toContain('debugStatus: debug.status')
|
||||||
|
expect(mediaServer).toContain('Client-reported upload failure')
|
||||||
|
expect(mediaServer).toContain('diagnostic_text(data.debugMessage, 240)')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -34,9 +34,12 @@ describe('phone inventory contracts', () => {
|
|||||||
const phoneServer = readResourceFile('source/server/phone.lua')
|
const phoneServer = readResourceFile('source/server/phone.lua')
|
||||||
|
|
||||||
expect(phoneServer).toContain(
|
expect(phoneServer).toContain(
|
||||||
'Bridge.Inventory.RegisterUsableItem(Config.Phone.Item, open_phone)',
|
'Bridge.Inventory.RegisterUsableItem(item_name, function(...)',
|
||||||
|
)
|
||||||
|
expect(phoneServer).toContain('if Config.Phone.Item == item_name then')
|
||||||
|
expect(phoneServer).toContain(
|
||||||
|
'if not Bridge.Inventory.RegisterUsableItem(item_name, function(...)',
|
||||||
)
|
)
|
||||||
expect(phoneServer).toContain('if not usable_registered then')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('auto-detects HEX and limits count-based ESX inventories to metadata-free modes', () => {
|
it('auto-detects HEX and limits count-based ESX inventories to metadata-free modes', () => {
|
||||||
@@ -221,8 +224,12 @@ describe('phone inventory contracts', () => {
|
|||||||
const phoneServer = readResourceFile('source/server/phone.lua')
|
const phoneServer = readResourceFile('source/server/phone.lua')
|
||||||
|
|
||||||
expect(config).toContain('Keybind = "F1"')
|
expect(config).toContain('Keybind = "F1"')
|
||||||
|
expect(phoneClient).toContain('refresh_phone_key_mapping = function()')
|
||||||
expect(phoneClient).toContain(
|
expect(phoneClient).toContain(
|
||||||
'RegisterKeyMapping("sky_phone_toggle", locale.Controls.OpenPhone, "keyboard", Config.Phone.Keybind)',
|
'RegisterKeyMapping(command_name, locale.Controls.OpenPhone, "keyboard", key_name)',
|
||||||
|
)
|
||||||
|
expect(phoneClient).toContain(
|
||||||
|
'if active_key_mapping_command == command_name then',
|
||||||
)
|
)
|
||||||
expect(phoneClient).toContain(
|
expect(phoneClient).toContain(
|
||||||
'Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})',
|
'Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})',
|
||||||
@@ -232,6 +239,26 @@ describe('phone inventory contracts', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('applies development command changes immediately without a resource restart', () => {
|
||||||
|
const phoneClient = readResourceFile('source/client/main.lua')
|
||||||
|
|
||||||
|
expect(phoneClient).toContain('local active_development_command = nil')
|
||||||
|
expect(phoneClient).toContain('refresh_development_command = function()')
|
||||||
|
expect(phoneClient).toContain(
|
||||||
|
'local command_name = Config.Phone.DevelopmentCommand and Config.Command or nil',
|
||||||
|
)
|
||||||
|
expect(phoneClient).toContain('RegisterCommand(command_name, function()')
|
||||||
|
expect(phoneClient).toContain(
|
||||||
|
'if active_development_command == command_name and Config.Phone.DevelopmentCommand then',
|
||||||
|
)
|
||||||
|
expect(phoneClient).toContain(
|
||||||
|
'TriggerEvent("chat:removeSuggestion", "/" .. active_development_command)',
|
||||||
|
)
|
||||||
|
expect(phoneClient).toMatch(
|
||||||
|
/AddEventHandler\("sky_phone:configurator:updated", function\(\)[\s\S]*?refresh_development_command\(\)/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('opens a running live activity with Space without affecting normal gameplay', () => {
|
it('opens a running live activity with Space without affecting normal gameplay', () => {
|
||||||
const phoneClient = readResourceFile('source/client/main.lua')
|
const phoneClient = readResourceFile('source/client/main.lua')
|
||||||
|
|
||||||
@@ -262,7 +289,7 @@ describe('phone inventory contracts', () => {
|
|||||||
const phoneServer = readResourceFile('source/server/phone.lua')
|
const phoneServer = readResourceFile('source/server/phone.lua')
|
||||||
const migration = readResourceFile('source/server/db_migrate.lua')
|
const migration = readResourceFile('source/server/db_migrate.lua')
|
||||||
|
|
||||||
expect(phoneServer).toContain('if not unique_phones then')
|
expect(phoneServer).toContain('if Config.Phone.Unique == false then')
|
||||||
expect(phoneServer).toContain('return map_character_device(source, slot)')
|
expect(phoneServer).toContain('return map_character_device(source, slot)')
|
||||||
expect(phoneServer).toContain('FROM `sky_phone_character_devices`')
|
expect(phoneServer).toContain('FROM `sky_phone_character_devices`')
|
||||||
expect(phoneServer).toContain('WHERE `owner_identifier` = ?')
|
expect(phoneServer).toContain('WHERE `owner_identifier` = ?')
|
||||||
|
|||||||
@@ -29,4 +29,10 @@ describe('neutral phone navigation contract', () => {
|
|||||||
expect(navigationSource).toContain('if not installed_apps[normalized_app_id] then')
|
expect(navigationSource).toContain('if not installed_apps[normalized_app_id] then')
|
||||||
expect(navigationSource).toContain('if current_app_id ~= normalized_app_id then')
|
expect(navigationSource).toContain('if current_app_id ~= normalized_app_id then')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('defers command-driven app routes until setup or device unlock completes', () => {
|
||||||
|
expect(appSource).toContain('if (setupRequired.value || isLocked.value)')
|
||||||
|
expect(appSource).toContain('pendingUnlockRoute.value = requestedRoute')
|
||||||
|
expect(appSource).toContain("void router.replace(requestedRoute ?? '/')")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AdminAuditEntry,
|
||||||
|
AdminActivityResponse,
|
||||||
|
AdminBootstrap,
|
||||||
|
AdminCallActivity,
|
||||||
|
AdminConfigurator,
|
||||||
|
AdminConfiguratorChange,
|
||||||
|
AdminCredential,
|
||||||
|
AdminMessageActivity,
|
||||||
|
AdminPlayerDetail,
|
||||||
|
AdminPlayerSummary,
|
||||||
|
AdminStats,
|
||||||
|
} from '@/types/admin'
|
||||||
|
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||||
|
|
||||||
|
const EMPTY_STATS: AdminStats = { accounts: 0, devices: 0, online: 0 }
|
||||||
|
|
||||||
|
export const useAdminStore = defineStore('admin', {
|
||||||
|
state: () => ({
|
||||||
|
actionKey: '',
|
||||||
|
activityKey: '',
|
||||||
|
audit: [] as AdminAuditEntry[],
|
||||||
|
configurator: null as AdminConfigurator | null,
|
||||||
|
configuratorLoading: false,
|
||||||
|
detailLoading: false,
|
||||||
|
error: '',
|
||||||
|
initialized: false,
|
||||||
|
loading: false,
|
||||||
|
players: [] as AdminPlayerSummary[],
|
||||||
|
revealedCredentials: {} as Record<string, AdminCredential>,
|
||||||
|
deviceActivity: {} as Record<
|
||||||
|
string,
|
||||||
|
{ calls?: AdminCallActivity[]; messages?: AdminMessageActivity[] }
|
||||||
|
>,
|
||||||
|
selectedPlayer: null as AdminPlayerDetail | null,
|
||||||
|
stats: { ...EMPTY_STATS },
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
async load(): Promise<boolean> {
|
||||||
|
this.loading = true
|
||||||
|
const response = await nuiCall<AdminBootstrap>('admin:bootstrap')
|
||||||
|
this.loading = false
|
||||||
|
if (!response.success || !response.data) {
|
||||||
|
this.error = response.error ?? 'request_failed'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
this.players = response.data.players
|
||||||
|
this.stats = response.data.stats
|
||||||
|
this.audit = response.data.audit
|
||||||
|
this.error = ''
|
||||||
|
this.initialized = true
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
async openPlayer(source: number): Promise<boolean> {
|
||||||
|
this.detailLoading = true
|
||||||
|
this.revealedCredentials = {}
|
||||||
|
const response = await nuiCall<AdminPlayerDetail>('admin:player', {
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
this.detailLoading = false
|
||||||
|
if (!response.success || !response.data) {
|
||||||
|
this.error = response.error ?? 'request_failed'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
this.selectedPlayer = response.data
|
||||||
|
this.error = ''
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
closePlayer(): void {
|
||||||
|
this.selectedPlayer = null
|
||||||
|
this.revealedCredentials = {}
|
||||||
|
},
|
||||||
|
async saveApps(
|
||||||
|
source: number,
|
||||||
|
imei: string,
|
||||||
|
revision: number,
|
||||||
|
changes: Array<{ appId: string; installed: boolean }>,
|
||||||
|
): Promise<NuiResponse<AdminPlayerDetail>> {
|
||||||
|
this.actionKey = `${imei}:save`
|
||||||
|
const response = await nuiCall<AdminPlayerDetail>('admin:save-apps', {
|
||||||
|
changes,
|
||||||
|
imei,
|
||||||
|
revision,
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
this.actionKey = ''
|
||||||
|
if (response.success && response.data) {
|
||||||
|
this.selectedPlayer = response.data
|
||||||
|
this.error = ''
|
||||||
|
} else {
|
||||||
|
this.error = response.error ?? 'request_failed'
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
async revealPassword(
|
||||||
|
source: number,
|
||||||
|
imei: string,
|
||||||
|
): Promise<NuiResponse<AdminCredential>> {
|
||||||
|
this.actionKey = `${imei}:password`
|
||||||
|
const response = await nuiCall<AdminCredential>('admin:reveal-password', {
|
||||||
|
imei,
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
this.actionKey = ''
|
||||||
|
if (response.success && response.data) {
|
||||||
|
this.revealedCredentials[imei] = response.data
|
||||||
|
this.error = ''
|
||||||
|
} else {
|
||||||
|
this.error = response.error ?? 'request_failed'
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
async loadActivity(
|
||||||
|
source: number,
|
||||||
|
imei: string,
|
||||||
|
kind: 'messages' | 'calls',
|
||||||
|
): Promise<boolean> {
|
||||||
|
this.activityKey = `${imei}:${kind}`
|
||||||
|
const response = await nuiCall<AdminActivityResponse>('admin:activity', {
|
||||||
|
imei,
|
||||||
|
kind,
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
this.activityKey = ''
|
||||||
|
if (!response.success || !response.data) {
|
||||||
|
this.error = response.error ?? 'request_failed'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const activity = this.deviceActivity[imei] ?? {}
|
||||||
|
if (response.data.kind === 'messages') {
|
||||||
|
activity.messages = response.data.entries
|
||||||
|
} else {
|
||||||
|
activity.calls = response.data.entries
|
||||||
|
}
|
||||||
|
this.deviceActivity[imei] = activity
|
||||||
|
this.error = ''
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
async loadConfigurator(): Promise<boolean> {
|
||||||
|
this.configuratorLoading = true
|
||||||
|
const response = await nuiCall<AdminConfigurator>('admin:configurator')
|
||||||
|
this.configuratorLoading = false
|
||||||
|
if (!response.success || !response.data) {
|
||||||
|
this.error = response.error ?? 'request_failed'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
this.configurator = response.data
|
||||||
|
this.error = ''
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
async saveConfigurator(
|
||||||
|
changes: AdminConfiguratorChange[],
|
||||||
|
): Promise<NuiResponse<AdminConfigurator>> {
|
||||||
|
const current = this.configurator
|
||||||
|
if (!current) return { error: 'request_failed', success: false }
|
||||||
|
|
||||||
|
this.actionKey = 'configurator:save'
|
||||||
|
const response = await nuiCall<AdminConfigurator>(
|
||||||
|
'admin:save-configurator',
|
||||||
|
{
|
||||||
|
changes,
|
||||||
|
revision: current.revision,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
this.actionKey = ''
|
||||||
|
if (response.success && response.data) {
|
||||||
|
this.configurator = response.data
|
||||||
|
this.error = ''
|
||||||
|
} else {
|
||||||
|
if (response.data) this.configurator = response.data
|
||||||
|
this.error = response.error ?? 'request_failed'
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
async resetPasscode(
|
||||||
|
source: number,
|
||||||
|
imei: string,
|
||||||
|
): Promise<NuiResponse<AdminPlayerDetail>> {
|
||||||
|
this.actionKey = `${imei}:reset-passcode`
|
||||||
|
const response = await nuiCall<AdminPlayerDetail>(
|
||||||
|
'admin:reset-passcode',
|
||||||
|
{ imei, source },
|
||||||
|
)
|
||||||
|
this.actionKey = ''
|
||||||
|
if (response.success && response.data) {
|
||||||
|
this.selectedPlayer = response.data
|
||||||
|
delete this.revealedCredentials[imei]
|
||||||
|
this.error = ''
|
||||||
|
} else {
|
||||||
|
this.error = response.error ?? 'request_failed'
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
async changeNumber(
|
||||||
|
source: number,
|
||||||
|
imei: string,
|
||||||
|
phoneNumber: string,
|
||||||
|
): Promise<NuiResponse<AdminPlayerDetail>> {
|
||||||
|
this.actionKey = `${imei}:change-number`
|
||||||
|
const response = await nuiCall<AdminPlayerDetail>('admin:change-number', {
|
||||||
|
imei,
|
||||||
|
phoneNumber,
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
this.actionKey = ''
|
||||||
|
if (response.success && response.data) {
|
||||||
|
this.selectedPlayer = response.data
|
||||||
|
delete this.deviceActivity[imei]
|
||||||
|
this.error = ''
|
||||||
|
} else {
|
||||||
|
this.error = response.error ?? 'request_failed'
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
async factoryReset(
|
||||||
|
source: number,
|
||||||
|
imei: string,
|
||||||
|
): Promise<NuiResponse<AdminPlayerDetail>> {
|
||||||
|
this.actionKey = `${imei}:factory-reset`
|
||||||
|
const response = await nuiCall<AdminPlayerDetail>('admin:factory-reset', {
|
||||||
|
imei,
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
this.actionKey = ''
|
||||||
|
if (response.success && response.data) {
|
||||||
|
this.selectedPlayer = response.data
|
||||||
|
delete this.deviceActivity[imei]
|
||||||
|
delete this.revealedCredentials[imei]
|
||||||
|
this.error = ''
|
||||||
|
} else {
|
||||||
|
this.error = response.error ?? 'request_failed'
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -75,6 +75,13 @@ describe('app store', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('drops the retired admin app from persisted phone layouts', () => {
|
||||||
|
const apps = useAppStoreStore()
|
||||||
|
|
||||||
|
apps.hydrate({ claimedApps: ['admin'] })
|
||||||
|
expect(apps.homeLayout.grid).not.toContain('admin')
|
||||||
|
})
|
||||||
|
|
||||||
it('migrates current layouts so dock apps are not repeated in the grid', () => {
|
it('migrates current layouts so dock apps are not repeated in the grid', () => {
|
||||||
const apps = useAppStoreStore()
|
const apps = useAppStoreStore()
|
||||||
|
|
||||||
|
|||||||
@@ -58,11 +58,12 @@ function getDefaultDockIds(): LaunchablePhoneAppId[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultInstalledIds(): LaunchablePhoneAppId[] {
|
function getDefaultInstalledIds(): LaunchablePhoneAppId[] {
|
||||||
return PHONE_APPS.filter((app) =>
|
return PHONE_APPS.filter((app) => {
|
||||||
isExternalPhoneApp(app)
|
if (app.adminOnly) return false
|
||||||
|
return isExternalPhoneApp(app)
|
||||||
? app.defaultInstalled
|
? app.defaultInstalled
|
||||||
: DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id),
|
: DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id)
|
||||||
).map((app) => app.id)
|
}).map((app) => app.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isProtectedHomeApp(appId: LaunchablePhoneAppId): boolean {
|
function isProtectedHomeApp(appId: LaunchablePhoneAppId): boolean {
|
||||||
@@ -237,13 +238,15 @@ export const useAppStoreStore = defineStore('app-store', {
|
|||||||
layoutVersion === 5 ||
|
layoutVersion === 5 ||
|
||||||
layoutVersion === 6
|
layoutVersion === 6
|
||||||
this.claimedApps = Array.isArray(data?.claimedApps)
|
this.claimedApps = Array.isArray(data?.claimedApps)
|
||||||
? data.claimedApps.filter(
|
? data.claimedApps.filter((id): id is LaunchablePhoneAppId => {
|
||||||
(id): id is LaunchablePhoneAppId =>
|
if (typeof id !== 'string') return false
|
||||||
typeof id === 'string' &&
|
const app = getPhoneApp(id)
|
||||||
(isPhoneAppId(id) ||
|
if (app?.adminOnly) return false
|
||||||
(supportsPersistedExternalApps &&
|
return (
|
||||||
isValidExternalPhoneAppId(id))),
|
isPhoneAppId(id) ||
|
||||||
)
|
(supportsPersistedExternalApps && isValidExternalPhoneAppId(id))
|
||||||
|
)
|
||||||
|
})
|
||||||
: []
|
: []
|
||||||
this.uninstalledApps = Array.isArray(data?.uninstalledApps)
|
this.uninstalledApps = Array.isArray(data?.uninstalledApps)
|
||||||
? data.uninstalledApps.filter((id): id is LaunchablePhoneAppId => {
|
? data.uninstalledApps.filter((id): id is LaunchablePhoneAppId => {
|
||||||
@@ -308,9 +311,10 @@ export const useAppStoreStore = defineStore('app-store', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
isInstalled(appId: LaunchablePhoneAppId): boolean {
|
isInstalled(appId: LaunchablePhoneAppId): boolean {
|
||||||
|
const app = getPhoneApp(appId)
|
||||||
|
if (app?.adminOnly) return false
|
||||||
if (this.uninstalledApps.includes(appId)) return false
|
if (this.uninstalledApps.includes(appId)) return false
|
||||||
if (this.claimedApps.includes(appId)) return true
|
if (this.claimedApps.includes(appId)) return true
|
||||||
const app = getPhoneApp(appId)
|
|
||||||
if (!app) return false
|
if (!app) return false
|
||||||
return isExternalPhoneApp(app)
|
return isExternalPhoneApp(app)
|
||||||
? app.defaultInstalled
|
? app.defaultInstalled
|
||||||
|
|||||||
@@ -118,6 +118,29 @@ function collectLuaLocaleValues(source: string): Map<string, string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
parseTable([])
|
parseTable([])
|
||||||
|
while (position < tokens.length) {
|
||||||
|
const assignment = tokens.findIndex(
|
||||||
|
(token, index) => index >= position && token.kind === '=',
|
||||||
|
)
|
||||||
|
if (assignment < 0) break
|
||||||
|
const nui = tokens.findIndex(
|
||||||
|
(token, index) =>
|
||||||
|
index >= position && index < assignment && token.value === 'Nui',
|
||||||
|
)
|
||||||
|
if (nui < 0) {
|
||||||
|
position = assignment + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const path = [
|
||||||
|
'Nui',
|
||||||
|
...tokens
|
||||||
|
.slice(nui + 1, assignment)
|
||||||
|
.filter((token) => token.kind === 'word')
|
||||||
|
.map((token) => token.value),
|
||||||
|
]
|
||||||
|
position = assignment + 1
|
||||||
|
parseValue(path)
|
||||||
|
}
|
||||||
return values
|
return values
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+599
-39
@@ -38,6 +38,7 @@ export type PhoneOpenPayload = {
|
|||||||
locales?: LocaleTree
|
locales?: LocaleTree
|
||||||
memos?: DeviceBootstrap['memos']
|
memos?: DeviceBootstrap['memos']
|
||||||
notes?: DeviceBootstrap['notes']
|
notes?: DeviceBootstrap['notes']
|
||||||
|
phoneNumberFormat?: DeviceBootstrap['phoneNumberFormat']
|
||||||
player?: DeviceBootstrap['player']
|
player?: DeviceBootstrap['player']
|
||||||
security?: DeviceSecurity
|
security?: DeviceSecurity
|
||||||
token?: string
|
token?: string
|
||||||
@@ -638,7 +639,8 @@ const cryptoFallbackLocales = {
|
|||||||
invalid_handle: 'Use 3–20 letters, numbers, dots or underscores.',
|
invalid_handle: 'Use 3–20 letters, numbers, dots or underscores.',
|
||||||
handle_taken: 'That VaultX handle is already taken.',
|
handle_taken: 'That VaultX handle is already taken.',
|
||||||
profile_exists: 'This character already owns a VaultX profile.',
|
profile_exists: 'This character already owns a VaultX profile.',
|
||||||
invalid_password: 'Password must be 8–72 characters.',
|
invalid_password:
|
||||||
|
'Use 8–72 characters with uppercase, lowercase, a number, and a special character.',
|
||||||
password_mismatch: 'The passwords do not match.',
|
password_mismatch: 'The passwords do not match.',
|
||||||
accept_terms: 'Confirm that this is a fictional in-game wallet.',
|
accept_terms: 'Confirm that this is a fictional in-game wallet.',
|
||||||
invalid_credentials: 'The password is incorrect.',
|
invalid_credentials: 'The password is incorrect.',
|
||||||
@@ -805,7 +807,404 @@ const citywarnFallbackLocales = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const adminPanelFallbackLocales = {
|
||||||
|
name: 'Phone Admin',
|
||||||
|
subtitle: 'Administration',
|
||||||
|
navigation: 'Admin navigation',
|
||||||
|
refresh: 'Refresh admin data',
|
||||||
|
loading: 'Loading protected data...',
|
||||||
|
tabs: {
|
||||||
|
overview: 'Overview',
|
||||||
|
players: 'Players',
|
||||||
|
devices: 'Devices',
|
||||||
|
apps: 'Apps',
|
||||||
|
accounts: 'Accounts',
|
||||||
|
messages: 'Messages',
|
||||||
|
calls: 'Calls',
|
||||||
|
moderation: 'Moderation',
|
||||||
|
audit: 'Audit',
|
||||||
|
configurator: 'Phone configurator',
|
||||||
|
},
|
||||||
|
overview: {
|
||||||
|
eyebrow: 'Server',
|
||||||
|
title: 'Dashboard',
|
||||||
|
body: 'Players, devices, apps, and phone data.',
|
||||||
|
stats: 'Server phone statistics',
|
||||||
|
online: 'Online',
|
||||||
|
devices: 'Devices',
|
||||||
|
accounts: 'Accounts',
|
||||||
|
audit: 'Audit entries',
|
||||||
|
control: 'Navigation',
|
||||||
|
features: 'Modules',
|
||||||
|
featuresBody: 'Open an administration module.',
|
||||||
|
recent: 'Recent activity',
|
||||||
|
playerFeature: 'Identity, finances, job, and duty',
|
||||||
|
deviceFeature: 'IMEI, SIM, number, and activity',
|
||||||
|
appFeature: 'Install or remove phone apps',
|
||||||
|
accountFeature: 'Account access and protected credentials',
|
||||||
|
messageFeature: 'Review recent SMS activity',
|
||||||
|
callFeature: 'Review recent call activity',
|
||||||
|
moderationFeature: 'Reset access, number, or device data',
|
||||||
|
auditFeature: 'Review sensitive admin actions',
|
||||||
|
configuratorFeature: 'Manage config.lua and media.lua through SQL',
|
||||||
|
},
|
||||||
|
appearance: {
|
||||||
|
eyebrow: 'Appearance',
|
||||||
|
title: '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',
|
||||||
|
eyebrow: 'System tool',
|
||||||
|
sections: 'Configuration',
|
||||||
|
search: 'Search settings or paths',
|
||||||
|
configScope: 'config.lua',
|
||||||
|
mediaScope: 'media.lua',
|
||||||
|
noResults: 'No matching settings',
|
||||||
|
loading: 'Loading SQL configuration...',
|
||||||
|
title: 'Phone configurator',
|
||||||
|
body: 'Manage phone and media settings from the protected admin workspace.',
|
||||||
|
disabledTitle: 'SQL configuration is not active',
|
||||||
|
disabledBody:
|
||||||
|
'Enable the configurator at the beginning of config.lua and restart sky_phone. Until then, file values remain active and editing is locked.',
|
||||||
|
manualSave: 'Manual save',
|
||||||
|
refreshNotice:
|
||||||
|
'Nothing is written automatically. The green check verifies config.lua and media.lua in SQL and refreshes the active server, client, media and UI configuration immediately.',
|
||||||
|
fieldCount: '{count} fields',
|
||||||
|
secretConfigured: 'Secret configured · enter a replacement',
|
||||||
|
invalidValue: 'Check the highlighted table or number value.',
|
||||||
|
saved: 'SQL configuration saved and applied.',
|
||||||
|
descriptions: {
|
||||||
|
featureToggle: 'Turns {name} on or off.',
|
||||||
|
boolean: 'Controls whether {name} is allowed.',
|
||||||
|
number: 'Sets the numeric value for {name}.',
|
||||||
|
text: 'Sets the text value used for {name}.',
|
||||||
|
optionalText:
|
||||||
|
'Sets the optional value for {name}; switch it off to disable it.',
|
||||||
|
list: 'Manages all entries used for {name}.',
|
||||||
|
table: 'Groups the related settings for {name}.',
|
||||||
|
credential: 'Stores the protected credential used by {name}.',
|
||||||
|
url: 'Sets the URL or endpoint used by {name}.',
|
||||||
|
hosts: 'Defines which domains are allowed for {name}.',
|
||||||
|
milliseconds: 'Sets the timing for {name} in milliseconds.',
|
||||||
|
seconds: 'Sets the timing for {name} in seconds.',
|
||||||
|
rateLimit: 'Limits how many {name} actions are allowed per minute.',
|
||||||
|
byteLimit: 'Sets the maximum data size allowed for {name}.',
|
||||||
|
textLimit: 'Sets the maximum text length allowed for {name}.',
|
||||||
|
distance: 'Sets the world distance used for {name}.',
|
||||||
|
coordinates: 'Sets the world coordinates or orientation for {name}.',
|
||||||
|
gameAsset: 'Sets the GTA model or prop used for {name}.',
|
||||||
|
animation: 'Sets the animation asset used for {name}.',
|
||||||
|
access: 'Defines the jobs, groups or permission level for {name}.',
|
||||||
|
integration: 'Selects the connected framework or provider for {name}.',
|
||||||
|
path: 'Sets the storage or resource path used for {name}.',
|
||||||
|
color: 'Sets the interface color used for {name}.',
|
||||||
|
displayText: 'Sets the text shown to players for {name}.',
|
||||||
|
phoneNumber: 'Sets the phone or service number used for {name}.',
|
||||||
|
routing: 'Controls how incoming requests are routed for {name}.',
|
||||||
|
command: 'Sets the chat command used to open or run {name}.',
|
||||||
|
locale: 'Selects the language used for {name}.',
|
||||||
|
debug: 'Controls detailed diagnostic output for {name}.',
|
||||||
|
mediaQuality: 'Sets the media quality or volume used for {name}.',
|
||||||
|
amount: 'Sets the maximum or displayed amount for {name}.',
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
list: 'List',
|
||||||
|
table: 'Key table',
|
||||||
|
vector: 'Vector',
|
||||||
|
entry: 'Entry',
|
||||||
|
general: 'General',
|
||||||
|
subtabs: {
|
||||||
|
AdminGroups: 'Admin Groups',
|
||||||
|
Dictionaries: 'Dictionaries',
|
||||||
|
Clips: 'Clips',
|
||||||
|
Transforms: 'Transforms',
|
||||||
|
RateLimits: 'Rate Limits',
|
||||||
|
Publishers: 'Publishers',
|
||||||
|
ExternalPingResources: 'External Ping Resources',
|
||||||
|
Markets: 'Markets',
|
||||||
|
TrustedAdapters: 'Trusted Adapters',
|
||||||
|
AllowedDisappearTimers: 'Allowed Disappear Timers',
|
||||||
|
MusicTracks: 'Music Tracks',
|
||||||
|
VehicleImages: 'Vehicle Images',
|
||||||
|
Custom: 'Custom',
|
||||||
|
Valet: 'Valet',
|
||||||
|
AutoPriority: 'Automatic Priority',
|
||||||
|
Camera: 'Camera',
|
||||||
|
Categories: 'Categories',
|
||||||
|
Districts: 'Districts',
|
||||||
|
PhotoGradients: 'Photo Gradients',
|
||||||
|
LbPhone: 'LB Phone',
|
||||||
|
Tracks: 'Tracks',
|
||||||
|
Props: 'Props',
|
||||||
|
CustomLocations: 'Custom Locations',
|
||||||
|
Animation: 'Animation',
|
||||||
|
ReportReasons: 'Report Reasons',
|
||||||
|
DisplayName: 'Display Name',
|
||||||
|
Hud: 'HUD',
|
||||||
|
Badge: 'Badge',
|
||||||
|
LockedChannels: 'Locked Channels',
|
||||||
|
NumberGroups: 'Number Groups',
|
||||||
|
CustomFare: 'Custom Fare',
|
||||||
|
DriverJobs: 'Driver Jobs',
|
||||||
|
Services: 'Services',
|
||||||
|
QuickLocations: 'Quick Locations',
|
||||||
|
AllowedJobs: 'Allowed Jobs',
|
||||||
|
Websites: 'Websites',
|
||||||
|
},
|
||||||
|
addRow: 'Add row',
|
||||||
|
addField: 'Add field',
|
||||||
|
remove: 'Remove',
|
||||||
|
emptyList: 'No rows yet. Add the first row with plus.',
|
||||||
|
emptyTable: 'No fields yet. Add the first key below.',
|
||||||
|
keyPlaceholder: 'New key',
|
||||||
|
convertToList: 'Use list',
|
||||||
|
convertToMap: 'Use typed key table',
|
||||||
|
convertToTable: 'Use key table',
|
||||||
|
types: {
|
||||||
|
string: 'Text',
|
||||||
|
number: 'Number',
|
||||||
|
boolean: 'Switch',
|
||||||
|
list: 'List',
|
||||||
|
table: 'Table',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
players: {
|
||||||
|
eyebrow: 'Active sessions',
|
||||||
|
title: 'Online players',
|
||||||
|
online: 'Online now',
|
||||||
|
empty: 'No players found',
|
||||||
|
emptyBody: 'Adjust the search or refresh the live player list.',
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
players: 'Search name, ID, job, or number',
|
||||||
|
apps: 'Search apps',
|
||||||
|
clear: 'Clear search',
|
||||||
|
},
|
||||||
|
detail: {
|
||||||
|
character: 'Character profile',
|
||||||
|
data: 'Player data overview',
|
||||||
|
cash: 'Cash',
|
||||||
|
bank: 'Bank',
|
||||||
|
job: 'Job',
|
||||||
|
duty: 'Duty',
|
||||||
|
onDuty: 'On duty',
|
||||||
|
offDuty: 'Off duty',
|
||||||
|
identity: 'Identity',
|
||||||
|
playerData: 'Player data',
|
||||||
|
identifier: 'Character identifier',
|
||||||
|
birthdate: 'Birthdate',
|
||||||
|
grade: 'Job grade',
|
||||||
|
unknown: 'Unknown',
|
||||||
|
},
|
||||||
|
devices: {
|
||||||
|
eyebrow: 'Device control',
|
||||||
|
title: 'Phones',
|
||||||
|
body: 'Inspect every phone assigned to the selected player.',
|
||||||
|
choose: 'Choose phone',
|
||||||
|
empty: 'No phone found',
|
||||||
|
emptyBody: 'This player currently has no phone device that can be managed.',
|
||||||
|
noNumber: 'No phone number',
|
||||||
|
noSim: 'No SIM',
|
||||||
|
imei: 'IMEI',
|
||||||
|
updated: 'Last activity',
|
||||||
|
apps: 'Claimed apps',
|
||||||
|
account: 'Linked account',
|
||||||
|
},
|
||||||
|
credentials: {
|
||||||
|
eyebrow: 'Protected data',
|
||||||
|
title: 'Credentials',
|
||||||
|
email: 'iFruit email',
|
||||||
|
password: 'iFruit password',
|
||||||
|
reveal: 'Reveal',
|
||||||
|
copy: 'Copy password',
|
||||||
|
copied: 'Password copied.',
|
||||||
|
noAccount: 'No iFruit account is linked to this phone.',
|
||||||
|
passcode: 'Device passcode',
|
||||||
|
passcodeHashed: '{length}-digit PIN · securely hashed and not recoverable',
|
||||||
|
passcodeDisabled: 'No passcode configured',
|
||||||
|
revealTitle: 'Reveal protected password?',
|
||||||
|
revealBody:
|
||||||
|
'This action is server-authorized, rate-limited, and written to the admin audit log.',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
confirmReveal: 'Reveal password',
|
||||||
|
},
|
||||||
|
apps: {
|
||||||
|
eyebrow: 'Remote management',
|
||||||
|
title: 'App access',
|
||||||
|
description:
|
||||||
|
'Stage app access for this device. Nothing changes until you save.',
|
||||||
|
installed: 'Installed',
|
||||||
|
available: 'Available',
|
||||||
|
protected: 'System app',
|
||||||
|
changes: '{count} pending changes',
|
||||||
|
},
|
||||||
|
activity: {
|
||||||
|
protected: 'Protected activity',
|
||||||
|
messagesTitle: 'Messages',
|
||||||
|
messagesBody: 'Recent SMS activity for the selected SIM.',
|
||||||
|
callsTitle: 'Calls',
|
||||||
|
callsBody: 'Recent call activity for the selected SIM.',
|
||||||
|
loading: 'Loading activity...',
|
||||||
|
incoming: 'Incoming',
|
||||||
|
outgoing: 'Outgoing',
|
||||||
|
mediaMessage: '{type} message',
|
||||||
|
noMessages: 'No message activity found.',
|
||||||
|
noCalls: 'No call activity found.',
|
||||||
|
status: {
|
||||||
|
completed: 'Completed',
|
||||||
|
missed: 'Missed',
|
||||||
|
rejected: 'Rejected',
|
||||||
|
busy: 'Busy',
|
||||||
|
unanswered: 'Unanswered',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
|
failed: 'Failed',
|
||||||
|
ringing: 'Ringing',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
moderation: {
|
||||||
|
eyebrow: 'Device administration',
|
||||||
|
title: 'Moderation actions',
|
||||||
|
body: 'Every action is server-authorized, rate-limited, and audited.',
|
||||||
|
resetPasscode: 'Reset passcode',
|
||||||
|
resetPasscodeBody: 'Remove the device PIN and clear failed attempts.',
|
||||||
|
changeNumber: 'Change number',
|
||||||
|
changeNumberBody: 'Assign a new unique number to the current SIM.',
|
||||||
|
factoryReset: 'Factory reset',
|
||||||
|
factoryResetBody: 'Clear local device data and disconnect the account.',
|
||||||
|
saveFirst: 'Save or discard pending app changes first.',
|
||||||
|
phoneNumber: 'New phone number',
|
||||||
|
phoneNumberPlaceholder: 'Enter the full configured number',
|
||||||
|
typeToConfirm: 'Type {word} to confirm the factory reset.',
|
||||||
|
confirmWord: 'RESET',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
'reset-passcodeSuccess': 'Passcode reset.',
|
||||||
|
'change-numberSuccess': 'Phone number changed.',
|
||||||
|
'factory-resetSuccess': 'Phone factory reset completed.',
|
||||||
|
dialogs: {
|
||||||
|
'reset-passcodeTitle': 'Reset device passcode?',
|
||||||
|
'reset-passcodeBody':
|
||||||
|
'The player can unlock this phone without the previous PIN afterward.',
|
||||||
|
'change-numberTitle': 'Change phone number?',
|
||||||
|
'change-numberBody':
|
||||||
|
'The new number must match the configured server number format and be unique.',
|
||||||
|
'factory-resetTitle': 'Factory reset this phone?',
|
||||||
|
'factory-resetBody':
|
||||||
|
'This clears local device data, app settings, security, and the linked account. This cannot be undone.',
|
||||||
|
},
|
||||||
|
confirm: {
|
||||||
|
'reset-passcode': 'Reset passcode',
|
||||||
|
'change-number': 'Change number',
|
||||||
|
'factory-reset': 'Factory reset',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
const defaultLocales: LocaleTree = {
|
const defaultLocales: LocaleTree = {
|
||||||
|
AdminPanel: adminPanelFallbackLocales,
|
||||||
Apps: {
|
Apps: {
|
||||||
citywarn: citywarnFallbackLocales,
|
citywarn: citywarnFallbackLocales,
|
||||||
crypto: cryptoFallbackLocales,
|
crypto: cryptoFallbackLocales,
|
||||||
@@ -2086,7 +2485,7 @@ const defaultLocales: LocaleTree = {
|
|||||||
to: 'To:',
|
to: 'To:',
|
||||||
connectPrivately: 'Connect privately',
|
connectPrivately: 'Connect privately',
|
||||||
newChatBody:
|
newChatBody:
|
||||||
'Enter an exact Dark-ID or invitation code. Unknown identities require confirmation.',
|
"Enter another person's Dark-ID or invitation code. You can share your own ID from your profile.",
|
||||||
darkIdOrInvite: 'Dark-ID or invitation code',
|
darkIdOrInvite: 'Dark-ID or invitation code',
|
||||||
continue: 'Continue',
|
continue: 'Continue',
|
||||||
contacts: 'DarkChat Contacts',
|
contacts: 'DarkChat Contacts',
|
||||||
@@ -2432,46 +2831,190 @@ const defaultLocales: LocaleTree = {
|
|||||||
'neon-drop': 'Neon block-dropping puzzle',
|
'neon-drop': 'Neon block-dropping puzzle',
|
||||||
},
|
},
|
||||||
previews: {
|
previews: {
|
||||||
citywarn: { first: 'Live alerts', second: 'Safety zones', third: 'Incident updates' },
|
citywarn: {
|
||||||
crypto: { first: 'Synthetic markets', second: 'Portfolio', third: 'Wallet transfers' },
|
first: 'Live alerts',
|
||||||
health: { first: 'Activity rings', second: 'Medical ID', third: 'Health records' },
|
second: 'Safety zones',
|
||||||
'weazel-news': { first: 'Top stories', second: 'Local reports', third: 'Breaking news' },
|
third: 'Incident updates',
|
||||||
companies: { first: 'Business directory', second: 'Job requests', third: 'Services' },
|
},
|
||||||
music: { first: 'Now playing', second: 'Playlists', third: 'Music library' },
|
crypto: {
|
||||||
picstagram: { first: 'Photo feed', second: 'Stories', third: 'Profiles' },
|
first: 'Synthetic markets',
|
||||||
feather: { first: 'Short posts', second: 'Following feed', third: 'Conversations' },
|
second: 'Portfolio',
|
||||||
fliptok: { first: 'Video feed', second: 'Creator tools', third: 'Trends' },
|
third: 'Wallet transfers',
|
||||||
flare: { first: 'Discover people', second: 'Matches', third: 'Live moments' },
|
},
|
||||||
calendar: { first: 'Upcoming events', second: 'Day planner', third: 'Reminders' },
|
health: {
|
||||||
radio: { first: 'Live channels', second: 'Team radio', third: 'Favorites' },
|
first: 'Activity rings',
|
||||||
'local-pages': { first: 'Local pages', second: 'Reviews', third: 'City discovery' },
|
second: 'Medical ID',
|
||||||
crewlink: { first: 'Crew roster', second: 'Shared locations', third: 'Coordination' },
|
third: 'Health records',
|
||||||
phone: { first: 'Recent calls', second: 'Contacts', third: 'Voicemail' },
|
},
|
||||||
messages: { first: 'Conversations', second: 'Media sharing', third: 'Quick replies' },
|
'weazel-news': {
|
||||||
darkchat: { first: 'Private chats', second: 'Secure groups', third: 'Invitations' },
|
first: 'Top stories',
|
||||||
garage: { first: 'Vehicle list', second: 'Parking locations', third: 'Valet' },
|
second: 'Local reports',
|
||||||
house: { first: 'Property access', second: 'Residents', third: 'Management' },
|
third: 'Breaking news',
|
||||||
map: { first: 'Live navigation', second: 'Nearby places', third: 'Route guidance' },
|
},
|
||||||
skyride: { first: 'Ride booking', second: 'Driver tracking', third: 'Trip history' },
|
companies: {
|
||||||
banking: { first: 'Account balance', second: 'Transfers', third: 'Transactions' },
|
first: 'Business directory',
|
||||||
billing: { first: 'Open invoices', second: 'Payment requests', third: 'Payment history' },
|
second: 'Job requests',
|
||||||
|
third: 'Services',
|
||||||
|
},
|
||||||
|
music: {
|
||||||
|
first: 'Now playing',
|
||||||
|
second: 'Playlists',
|
||||||
|
third: 'Music library',
|
||||||
|
},
|
||||||
|
picstagram: {
|
||||||
|
first: 'Photo feed',
|
||||||
|
second: 'Stories',
|
||||||
|
third: 'Profiles',
|
||||||
|
},
|
||||||
|
feather: {
|
||||||
|
first: 'Short posts',
|
||||||
|
second: 'Following feed',
|
||||||
|
third: 'Conversations',
|
||||||
|
},
|
||||||
|
fliptok: {
|
||||||
|
first: 'Video feed',
|
||||||
|
second: 'Creator tools',
|
||||||
|
third: 'Trends',
|
||||||
|
},
|
||||||
|
flare: {
|
||||||
|
first: 'Discover people',
|
||||||
|
second: 'Matches',
|
||||||
|
third: 'Live moments',
|
||||||
|
},
|
||||||
|
calendar: {
|
||||||
|
first: 'Upcoming events',
|
||||||
|
second: 'Day planner',
|
||||||
|
third: 'Reminders',
|
||||||
|
},
|
||||||
|
radio: {
|
||||||
|
first: 'Live channels',
|
||||||
|
second: 'Team radio',
|
||||||
|
third: 'Favorites',
|
||||||
|
},
|
||||||
|
'local-pages': {
|
||||||
|
first: 'Local pages',
|
||||||
|
second: 'Reviews',
|
||||||
|
third: 'City discovery',
|
||||||
|
},
|
||||||
|
crewlink: {
|
||||||
|
first: 'Crew roster',
|
||||||
|
second: 'Shared locations',
|
||||||
|
third: 'Coordination',
|
||||||
|
},
|
||||||
|
phone: {
|
||||||
|
first: 'Recent calls',
|
||||||
|
second: 'Contacts',
|
||||||
|
third: 'Voicemail',
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
first: 'Conversations',
|
||||||
|
second: 'Media sharing',
|
||||||
|
third: 'Quick replies',
|
||||||
|
},
|
||||||
|
darkchat: {
|
||||||
|
first: 'Private chats',
|
||||||
|
second: 'Secure groups',
|
||||||
|
third: 'Invitations',
|
||||||
|
},
|
||||||
|
garage: {
|
||||||
|
first: 'Vehicle list',
|
||||||
|
second: 'Parking locations',
|
||||||
|
third: 'Valet',
|
||||||
|
},
|
||||||
|
house: {
|
||||||
|
first: 'Property access',
|
||||||
|
second: 'Residents',
|
||||||
|
third: 'Management',
|
||||||
|
},
|
||||||
|
map: {
|
||||||
|
first: 'Live navigation',
|
||||||
|
second: 'Nearby places',
|
||||||
|
third: 'Route guidance',
|
||||||
|
},
|
||||||
|
skyride: {
|
||||||
|
first: 'Ride booking',
|
||||||
|
second: 'Driver tracking',
|
||||||
|
third: 'Trip history',
|
||||||
|
},
|
||||||
|
banking: {
|
||||||
|
first: 'Account balance',
|
||||||
|
second: 'Transfers',
|
||||||
|
third: 'Transactions',
|
||||||
|
},
|
||||||
|
billing: {
|
||||||
|
first: 'Open invoices',
|
||||||
|
second: 'Payment requests',
|
||||||
|
third: 'Payment history',
|
||||||
|
},
|
||||||
mail: { first: 'Inbox', second: 'Attachments', third: 'Mailboxes' },
|
mail: { first: 'Inbox', second: 'Attachments', third: 'Mailboxes' },
|
||||||
notes: { first: 'Notes', second: 'Checklists', third: 'Pinned ideas' },
|
notes: { first: 'Notes', second: 'Checklists', third: 'Pinned ideas' },
|
||||||
memos: { first: 'Voice recordings', second: 'Playback', third: 'Favorites' },
|
memos: {
|
||||||
calculator: { first: 'Basic calculation', second: 'Scientific tools', third: 'History' },
|
first: 'Voice recordings',
|
||||||
camera: { first: 'Photo mode', second: 'Video capture', third: 'Zoom controls' },
|
second: 'Playback',
|
||||||
|
third: 'Favorites',
|
||||||
|
},
|
||||||
|
calculator: {
|
||||||
|
first: 'Basic calculation',
|
||||||
|
second: 'Scientific tools',
|
||||||
|
third: 'History',
|
||||||
|
},
|
||||||
|
camera: {
|
||||||
|
first: 'Photo mode',
|
||||||
|
second: 'Video capture',
|
||||||
|
third: 'Zoom controls',
|
||||||
|
},
|
||||||
clock: { first: 'World clock', second: 'Alarms', third: 'Timers' },
|
clock: { first: 'World clock', second: 'Alarms', third: 'Timers' },
|
||||||
weather: { first: 'Current weather', second: 'Hourly forecast', third: 'Seven-day outlook' },
|
weather: {
|
||||||
photos: { first: 'Media library', second: 'Albums', third: 'Shared media' },
|
first: 'Current weather',
|
||||||
settings: { first: 'Device controls', second: 'Privacy', third: 'Personalization' },
|
second: 'Hourly forecast',
|
||||||
|
third: 'Seven-day outlook',
|
||||||
|
},
|
||||||
|
photos: {
|
||||||
|
first: 'Media library',
|
||||||
|
second: 'Albums',
|
||||||
|
third: 'Shared media',
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
first: 'Device controls',
|
||||||
|
second: 'Privacy',
|
||||||
|
third: 'Personalization',
|
||||||
|
},
|
||||||
snake: { first: 'High score', second: 'Speed', third: 'Classic grid' },
|
snake: { first: 'High score', second: 'Speed', third: 'Classic grid' },
|
||||||
memory: { first: 'Matched pairs', second: 'Best time', third: 'Card themes' },
|
memory: {
|
||||||
'number-merge': { first: 'Highest tile', second: 'Score', third: 'Strategy grid' },
|
first: 'Matched pairs',
|
||||||
minesweeper: { first: 'Mine counter', second: 'Best time', third: 'Difficulty' },
|
second: 'Best time',
|
||||||
'tower-stack': { first: 'Tower height', second: 'Perfect drops', third: 'High score' },
|
third: 'Card themes',
|
||||||
'sky-flappy': { first: 'Flight score', second: 'Best run', third: 'Obstacles' },
|
},
|
||||||
citymarkt: { first: 'Listings', second: 'Categories', third: 'Saved offers' },
|
'number-merge': {
|
||||||
'neon-drop': { first: 'Lines cleared', second: 'Level', third: 'Neon pieces' },
|
first: 'Highest tile',
|
||||||
|
second: 'Score',
|
||||||
|
third: 'Strategy grid',
|
||||||
|
},
|
||||||
|
minesweeper: {
|
||||||
|
first: 'Mine counter',
|
||||||
|
second: 'Best time',
|
||||||
|
third: 'Difficulty',
|
||||||
|
},
|
||||||
|
'tower-stack': {
|
||||||
|
first: 'Tower height',
|
||||||
|
second: 'Perfect drops',
|
||||||
|
third: 'High score',
|
||||||
|
},
|
||||||
|
'sky-flappy': {
|
||||||
|
first: 'Flight score',
|
||||||
|
second: 'Best run',
|
||||||
|
third: 'Obstacles',
|
||||||
|
},
|
||||||
|
citymarkt: {
|
||||||
|
first: 'Listings',
|
||||||
|
second: 'Categories',
|
||||||
|
third: 'Saved offers',
|
||||||
|
},
|
||||||
|
'neon-drop': {
|
||||||
|
first: 'Lines cleared',
|
||||||
|
second: 'Level',
|
||||||
|
third: 'Neon pieces',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
recommended: 'Recommended',
|
recommended: 'Recommended',
|
||||||
@@ -3036,6 +3579,8 @@ const defaultLocales: LocaleTree = {
|
|||||||
viewRides: 'View Ride Options',
|
viewRides: 'View Ride Options',
|
||||||
change: 'Change',
|
change: 'Change',
|
||||||
requestRide: 'Request SkyRide',
|
requestRide: 'Request SkyRide',
|
||||||
|
playerDriverNotice:
|
||||||
|
'SkyRide matches you with real player drivers. A driver must be online and accept your request.',
|
||||||
serviceMeta: '{eta} min away · {seats} seats',
|
serviceMeta: '{eta} min away · {seats} seats',
|
||||||
distanceMeters: '{distance} m',
|
distanceMeters: '{distance} m',
|
||||||
distanceKilometers: '{distance} km',
|
distanceKilometers: '{distance} km',
|
||||||
@@ -3106,7 +3651,8 @@ const defaultLocales: LocaleTree = {
|
|||||||
cancelled: 'Cancelled',
|
cancelled: 'Cancelled',
|
||||||
},
|
},
|
||||||
statusBody: {
|
statusBody: {
|
||||||
searching: 'We are matching you with a nearby driver.',
|
searching:
|
||||||
|
'Your request is waiting for an available player driver to accept it.',
|
||||||
accepted: 'Your driver is preparing to pick you up.',
|
accepted: 'Your driver is preparing to pick you up.',
|
||||||
driver_arriving: 'Your driver is on the way to your pickup.',
|
driver_arriving: 'Your driver is on the way to your pickup.',
|
||||||
arrived: 'Your driver is waiting at the pickup point.',
|
arrived: 'Your driver is waiting at the pickup point.',
|
||||||
@@ -4012,6 +4558,11 @@ const defaultLocales: LocaleTree = {
|
|||||||
invalid_media_type: 'The uploaded media type is invalid.',
|
invalid_media_type: 'The uploaded media type is invalid.',
|
||||||
invalid_upload: 'The upload could not be verified.',
|
invalid_upload: 'The upload could not be verified.',
|
||||||
invalid_upload_token: 'The upload session is no longer valid.',
|
invalid_upload_token: 'The upload session is no longer valid.',
|
||||||
|
media_provider_failed: 'The camera upload service is unavailable.',
|
||||||
|
media_provider_rate_limited:
|
||||||
|
'The camera upload service is busy. Try again shortly.',
|
||||||
|
media_provider_unauthorized:
|
||||||
|
'The configured FiveManage API key was rejected.',
|
||||||
missing_config: 'Camera uploads are not configured.',
|
missing_config: 'Camera uploads are not configured.',
|
||||||
microphone_unavailable:
|
microphone_unavailable:
|
||||||
'Allow microphone access or mute the microphone before recording.',
|
'Allow microphone access or mute the microphone before recording.',
|
||||||
@@ -5183,6 +5734,15 @@ export const usePhoneStore = defineStore('phone', {
|
|||||||
this.cameraLandscape = false
|
this.cameraLandscape = false
|
||||||
this.isOpen = false
|
this.isOpen = false
|
||||||
},
|
},
|
||||||
|
setLocale(
|
||||||
|
lang: string,
|
||||||
|
locales: LocaleTree,
|
||||||
|
fallbackLocales: LocaleTree,
|
||||||
|
): void {
|
||||||
|
this.lang = lang
|
||||||
|
this.locales = locales
|
||||||
|
this.fallbackLocales = fallbackLocales
|
||||||
|
},
|
||||||
open(payload: PhoneOpenPayload = {}): void {
|
open(payload: PhoneOpenPayload = {}): void {
|
||||||
const nextImei = payload.device?.imei ?? this.device?.imei ?? null
|
const nextImei = payload.device?.imei ?? this.device?.imei ?? null
|
||||||
const nextToken = payload.token ?? this.deviceSessionToken
|
const nextToken = payload.token ?? this.deviceSessionToken
|
||||||
|
|||||||
@@ -16,10 +16,15 @@ describe('test data seeding contracts', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns before registering the test-data command when disabled', () => {
|
it('refreshes the test-data command when its runtime configuration changes', () => {
|
||||||
expect(
|
expect(testData).toContain('local function refresh_test_data_command()')
|
||||||
testData.indexOf('if not Config.TestData.Enabled then'),
|
expect(testData).toContain(
|
||||||
).toBeLessThan(testData.indexOf('RegisterCommand(Config.TestData.Command'))
|
'active_test_data_command = Config.TestData.Enabled and Config.TestData.Command or nil',
|
||||||
|
)
|
||||||
|
expect(testData).toContain(
|
||||||
|
'AddEventHandler("sky_phone:configurator:serverUpdated", refresh_test_data_command)',
|
||||||
|
)
|
||||||
|
expect(testData).not.toContain('RegisterCommand(Config.TestData.Command')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('moves an existing player SIM before attaching it to the selected phone', () => {
|
it('moves an existing player SIM before attaching it to the selected phone', () => {
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
export type AdminStats = {
|
||||||
|
accounts: number
|
||||||
|
devices: number
|
||||||
|
online: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminPlayerSummary = {
|
||||||
|
deviceCount: number
|
||||||
|
grade: number
|
||||||
|
identifier: string
|
||||||
|
job: string
|
||||||
|
name: string
|
||||||
|
onDuty: boolean
|
||||||
|
phoneNumber: string | null
|
||||||
|
serverName: string
|
||||||
|
source: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminDevice = {
|
||||||
|
account: {
|
||||||
|
email: string
|
||||||
|
id: number
|
||||||
|
passwordAvailable: boolean
|
||||||
|
} | null
|
||||||
|
apps: {
|
||||||
|
claimed: string[]
|
||||||
|
revision: number
|
||||||
|
uninstalled: string[]
|
||||||
|
}
|
||||||
|
createdAt: string
|
||||||
|
imei: string
|
||||||
|
name: string
|
||||||
|
number: string | null
|
||||||
|
security: {
|
||||||
|
enabled: boolean
|
||||||
|
failedAttempts: number
|
||||||
|
length: number | null
|
||||||
|
lockedUntil: number
|
||||||
|
}
|
||||||
|
simRegistered: boolean
|
||||||
|
simType: string | null
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminPlayerDetail = {
|
||||||
|
birthdate: string
|
||||||
|
devices: AdminDevice[]
|
||||||
|
firstName: string
|
||||||
|
identifier: string
|
||||||
|
job: {
|
||||||
|
grade: number
|
||||||
|
gradeLabel: string
|
||||||
|
label: string
|
||||||
|
name: string
|
||||||
|
onDuty: boolean
|
||||||
|
}
|
||||||
|
lastName: string
|
||||||
|
money: {
|
||||||
|
bank: number
|
||||||
|
cash: number
|
||||||
|
currency: string
|
||||||
|
}
|
||||||
|
name: string
|
||||||
|
serverName: string
|
||||||
|
source: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminAuditEntry = {
|
||||||
|
action: string
|
||||||
|
actorName: string
|
||||||
|
createdAt: string
|
||||||
|
details: Record<string, unknown>
|
||||||
|
deviceImei: string | null
|
||||||
|
id: number
|
||||||
|
targetIdentifier: string
|
||||||
|
targetSource: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminBootstrap = {
|
||||||
|
audit: AdminAuditEntry[]
|
||||||
|
players: AdminPlayerSummary[]
|
||||||
|
stats: AdminStats
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminCredential = {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminMessageActivity = {
|
||||||
|
body: string
|
||||||
|
createdAt: string
|
||||||
|
direction: 'incoming' | 'outgoing'
|
||||||
|
id: string
|
||||||
|
messageType: string
|
||||||
|
otherNumber: string
|
||||||
|
readAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminCallActivity = {
|
||||||
|
answeredAt: string | null
|
||||||
|
direction: 'incoming' | 'outgoing'
|
||||||
|
durationSeconds: number
|
||||||
|
endedAt: string | null
|
||||||
|
id: string
|
||||||
|
otherNumber: string
|
||||||
|
startedAt: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminActivityResponse =
|
||||||
|
| { entries: AdminMessageActivity[]; kind: 'messages' }
|
||||||
|
| { entries: AdminCallActivity[]; kind: 'calls' }
|
||||||
|
|
||||||
|
export type AdminConfiguratorField = {
|
||||||
|
configured?: boolean
|
||||||
|
label: string
|
||||||
|
path: string
|
||||||
|
structure?: AdminConfiguratorStructure
|
||||||
|
scope: 'config' | 'media'
|
||||||
|
sensitive: boolean
|
||||||
|
type: 'boolean' | 'json' | 'number' | 'string' | 'stringOrFalse'
|
||||||
|
value: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminConfiguratorStructure =
|
||||||
|
| {
|
||||||
|
kind: 'list'
|
||||||
|
items: AdminConfiguratorStructure[]
|
||||||
|
template?: AdminConfiguratorStructure
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
fields: Record<string, AdminConfiguratorStructure>
|
||||||
|
kind: 'table'
|
||||||
|
mutableKeys?: boolean
|
||||||
|
template?: AdminConfiguratorStructure
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
entries: Array<{
|
||||||
|
key: number | string
|
||||||
|
keyType: 'number' | 'string'
|
||||||
|
structure: AdminConfiguratorStructure
|
||||||
|
}>
|
||||||
|
keyType?: 'number' | 'string'
|
||||||
|
kind: 'map'
|
||||||
|
template?: AdminConfiguratorStructure
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'value'
|
||||||
|
valueType: 'boolean' | 'number' | 'string'
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'optionalString'
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'vector'
|
||||||
|
vectorType: 'vector2' | 'vector3' | 'vector4'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminConfiguratorSection = {
|
||||||
|
fields: AdminConfiguratorField[]
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
scope: 'config' | 'media'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminConfigurator = {
|
||||||
|
enabled: boolean
|
||||||
|
revision: number
|
||||||
|
sections: AdminConfiguratorSection[]
|
||||||
|
updatedAt: string | null
|
||||||
|
updatedBy: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminConfiguratorChange = {
|
||||||
|
path: string
|
||||||
|
scope: 'config' | 'media'
|
||||||
|
value: unknown
|
||||||
|
}
|
||||||
@@ -68,6 +68,7 @@ export type AppLaunchOrigin = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PhoneAppDefinitionBase = {
|
type PhoneAppDefinitionBase = {
|
||||||
|
adminOnly?: boolean
|
||||||
category: PhoneAppCategory
|
category: PhoneAppCategory
|
||||||
dockOrder: number | null
|
dockOrder: number | null
|
||||||
gridOrder: number
|
gridOrder: number
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Note } from '@/utils/notes'
|
import type { Note } from '@/utils/notes'
|
||||||
import type { MemoDto } from '@/types/memos'
|
import type { MemoDto } from '@/types/memos'
|
||||||
import type { PhoneSim } from '@/types/phone'
|
import type { PhoneNumberFormat, PhoneSim } from '@/types/phone'
|
||||||
|
|
||||||
export type DeviceDataEntry<T = unknown> = {
|
export type DeviceDataEntry<T = unknown> = {
|
||||||
payload: T
|
payload: T
|
||||||
@@ -50,6 +50,7 @@ export type DeviceBootstrap = {
|
|||||||
device: PhoneDevice
|
device: PhoneDevice
|
||||||
memos: MemoDto[]
|
memos: MemoDto[]
|
||||||
notes: Note[]
|
notes: Note[]
|
||||||
|
phoneNumberFormat: PhoneNumberFormat
|
||||||
player: PhonePlayerIdentity
|
player: PhonePlayerIdentity
|
||||||
security: DeviceSecurity
|
security: DeviceSecurity
|
||||||
token: string
|
token: string
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ export type MediaImportResult = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type UploadReady = {
|
export type UploadReady = {
|
||||||
captureToken: string
|
|
||||||
correlationId: string
|
correlationId: string
|
||||||
mediaType: MediaType
|
mediaType: MediaType
|
||||||
photo?: {
|
photo?: {
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ export type MemoRecordingMetadata = {
|
|||||||
export type MemoUploadReady = {
|
export type MemoUploadReady = {
|
||||||
requestId: string
|
requestId: string
|
||||||
correlationId: string
|
correlationId: string
|
||||||
captureToken: string
|
|
||||||
presignedUrl: string
|
presignedUrl: string
|
||||||
uploadTimeoutMs?: number
|
uploadTimeoutMs?: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
export type SimType = 'registered' | 'anonymous'
|
export type SimType = 'registered' | 'anonymous'
|
||||||
|
|
||||||
|
export type PhoneNumberFormat = {
|
||||||
|
groups: number[]
|
||||||
|
length: number
|
||||||
|
}
|
||||||
|
|
||||||
export type PhoneSim = {
|
export type PhoneSim = {
|
||||||
id: string
|
id: string
|
||||||
number: string
|
number: string
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import type { AdminConfiguratorStructure } from '@/types/admin'
|
||||||
|
|
||||||
|
import {
|
||||||
|
configuratorDescriptionKey,
|
||||||
|
configuratorPathName,
|
||||||
|
describeConfiguratorValue,
|
||||||
|
} from './adminConfiguratorDescription'
|
||||||
|
|
||||||
|
describe('admin configurator descriptions', () => {
|
||||||
|
it('selects specific descriptions before generic value descriptions', () => {
|
||||||
|
expect(configuratorDescriptionKey('Bridge.CallbackTimeout', 15000)).toBe(
|
||||||
|
'milliseconds',
|
||||||
|
)
|
||||||
|
expect(configuratorDescriptionKey('Media.RequestTimeoutMs', 10000)).toBe(
|
||||||
|
'milliseconds',
|
||||||
|
)
|
||||||
|
expect(configuratorDescriptionKey('AdminPanel.AdminGroups', [])).toBe(
|
||||||
|
'access',
|
||||||
|
)
|
||||||
|
expect(configuratorDescriptionKey('FiveManage.ApiKey', '')).toBe(
|
||||||
|
'credential',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('describes structured values from their schema', () => {
|
||||||
|
const vector: AdminConfiguratorStructure = {
|
||||||
|
kind: 'vector',
|
||||||
|
vectorType: 'vector3',
|
||||||
|
}
|
||||||
|
const table: AdminConfiguratorStructure = {
|
||||||
|
fields: {},
|
||||||
|
kind: 'table',
|
||||||
|
}
|
||||||
|
expect(configuratorDescriptionKey('Location', {}, vector)).toBe(
|
||||||
|
'coordinates',
|
||||||
|
)
|
||||||
|
expect(configuratorDescriptionKey('Settings', {}, table)).toBe('table')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes a readable field name to the localized template', () => {
|
||||||
|
const translate = (key: string, params?: Record<string, string>) =>
|
||||||
|
`${key}:${params?.name}`
|
||||||
|
expect(
|
||||||
|
describeConfiguratorValue(
|
||||||
|
translate,
|
||||||
|
'CustomApps.MaximumStorageBytesPerApp',
|
||||||
|
262144,
|
||||||
|
),
|
||||||
|
).toBe('configurator.descriptions.byteLimit:Maximum Storage Bytes Per App')
|
||||||
|
expect(
|
||||||
|
describeConfiguratorValue(translate, 'Radio.AllowedJobs[2]', 'police'),
|
||||||
|
).toBe('configurator.descriptions.access:Allowed Jobs #2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('humanizes subtab keys', () => {
|
||||||
|
expect(configuratorPathName('ExternalPingResources')).toBe(
|
||||||
|
'External Ping Resources',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import type { AdminConfiguratorStructure } from '@/types/admin'
|
||||||
|
|
||||||
|
type ConfiguratorDescriptionTranslator = (
|
||||||
|
key: string,
|
||||||
|
params?: Record<string, string>,
|
||||||
|
) => string
|
||||||
|
|
||||||
|
export type AdminConfiguratorDescribe = (
|
||||||
|
path: string,
|
||||||
|
value: unknown,
|
||||||
|
structure?: AdminConfiguratorStructure,
|
||||||
|
label?: string,
|
||||||
|
) => string
|
||||||
|
|
||||||
|
const DESCRIPTION_RULES: Array<[RegExp, string]> = [
|
||||||
|
[/(?:^|\.)(?:apikey|token|password|secret)$/i, 'credential'],
|
||||||
|
[/(?:base|manifest|image|icon)?url$/i, 'url'],
|
||||||
|
[/(?:allowed)?(?:gif|media)?hosts?$/i, 'hosts'],
|
||||||
|
[
|
||||||
|
/(?:timeout|timeoutms|milliseconds|durationms|intervalms|pollms)$/i,
|
||||||
|
'milliseconds',
|
||||||
|
],
|
||||||
|
[/(?:timeoutseconds|seconds)$/i, 'seconds'],
|
||||||
|
[/perminute$/i, 'rateLimit'],
|
||||||
|
[/(?:maximum|max).*bytes/i, 'byteLimit'],
|
||||||
|
[/(?:maximum|max).*length$|length$/i, 'textLimit'],
|
||||||
|
[/(?:distance)$/i, 'distance'],
|
||||||
|
[/(?:location|position|rotation|coords|coordinates)$/i, 'coordinates'],
|
||||||
|
[/(?:model|prop|propmodel|customprop|replacementprop)$/i, 'gameAsset'],
|
||||||
|
[/(?:dictionary|dictionaries|clip|clips|pedclip|propclip)$/i, 'animation'],
|
||||||
|
[
|
||||||
|
/(?:permissions?|admingroups?|allowedjobs?|jobs?|minimumgrade|requiredace)$/i,
|
||||||
|
'access',
|
||||||
|
],
|
||||||
|
[/(?:framework|inventory|provider|voiceprovider|adapter)$/i, 'integration'],
|
||||||
|
[/(?:path)$/i, 'path'],
|
||||||
|
[/(?:color|colour|accent)$/i, 'color'],
|
||||||
|
[
|
||||||
|
/(?:label|name|title|description|address|district|locationlabel|devicename)$/i,
|
||||||
|
'displayText',
|
||||||
|
],
|
||||||
|
[/(?:number|callernumber|numberprefix)$/i, 'phoneNumber'],
|
||||||
|
[/(?:routing)$/i, 'routing'],
|
||||||
|
[/(?:command)$/i, 'command'],
|
||||||
|
[/(?:locale)$/i, 'locale'],
|
||||||
|
[/(?:debug)$/i, 'debug'],
|
||||||
|
[/(?:enabled|active|public|verified)$/i, 'featureToggle'],
|
||||||
|
[/(?:quality|bitratekbps|volume)$/i, 'mediaQuality'],
|
||||||
|
[
|
||||||
|
/(?:pagesize|batchsize|limit|count|maxselection|maximumplayers|samples|decimals)$/i,
|
||||||
|
'amount',
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
|
export function configuratorPathName(path: string): string {
|
||||||
|
const listEntry = path.match(/^(.*)\[(\d+)\]$/)
|
||||||
|
const source = listEntry?.[1] ?? path
|
||||||
|
const segment = source.split('.').at(-1) ?? source
|
||||||
|
const name = segment
|
||||||
|
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
||||||
|
.replace(/([a-z\d])([A-Z])/g, '$1 $2')
|
||||||
|
.replace(/[_-]+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
return listEntry ? `${name} #${listEntry[2]}` : name
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configuratorDescriptionKey(
|
||||||
|
path: string,
|
||||||
|
value: unknown,
|
||||||
|
structure?: AdminConfiguratorStructure,
|
||||||
|
): string {
|
||||||
|
const segment =
|
||||||
|
path
|
||||||
|
.replace(/\[\d+\]$/, '')
|
||||||
|
.split('.')
|
||||||
|
.at(-1) ?? path
|
||||||
|
const semanticRule = DESCRIPTION_RULES.find(([pattern]) =>
|
||||||
|
pattern.test(segment),
|
||||||
|
)
|
||||||
|
if (semanticRule) return semanticRule[1]
|
||||||
|
|
||||||
|
if (structure?.kind === 'vector') return 'coordinates'
|
||||||
|
if (structure?.kind === 'list' || Array.isArray(value)) return 'list'
|
||||||
|
if (structure?.kind === 'map' || structure?.kind === 'table') return 'table'
|
||||||
|
if (structure?.kind === 'optionalString') return 'optionalText'
|
||||||
|
if (value !== null && typeof value === 'object') return 'table'
|
||||||
|
if (typeof value === 'boolean') return 'boolean'
|
||||||
|
if (typeof value === 'number') return 'number'
|
||||||
|
return 'text'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeConfiguratorValue(
|
||||||
|
translate: ConfiguratorDescriptionTranslator,
|
||||||
|
path: string,
|
||||||
|
value: unknown,
|
||||||
|
structure?: AdminConfiguratorStructure,
|
||||||
|
label?: string,
|
||||||
|
): string {
|
||||||
|
return translate(
|
||||||
|
`configurator.descriptions.${configuratorDescriptionKey(path, value, structure)}`,
|
||||||
|
{ name: label?.trim() || configuratorPathName(path) },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,7 +13,10 @@ const previewModules = import.meta.glob<string>(
|
|||||||
|
|
||||||
const APP_STORE_PREVIEW_IMAGES = Object.fromEntries(
|
const APP_STORE_PREVIEW_IMAGES = Object.fromEntries(
|
||||||
Object.entries(previewModules).map(([path, imageUrl]) => {
|
Object.entries(previewModules).map(([path, imageUrl]) => {
|
||||||
const appId = path.split('/').at(-1)?.replace(/\.jpg$/, '')
|
const appId = path
|
||||||
|
.split('/')
|
||||||
|
.at(-1)
|
||||||
|
?.replace(/\.jpg$/, '')
|
||||||
if (!appId) throw new Error(`Invalid App Store preview path: ${path}`)
|
if (!appId) throw new Error(`Invalid App Store preview path: ${path}`)
|
||||||
return [appId, imageUrl]
|
return [appId, imageUrl]
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ function escapeRegExp(value: string): string {
|
|||||||
describe('App Store preview catalog', () => {
|
describe('App Store preview catalog', () => {
|
||||||
it('contains a real captured screenshot for every built-in store app', () => {
|
it('contains a real captured screenshot for every built-in store app', () => {
|
||||||
const storeAppIds = PHONE_APPS.filter(
|
const storeAppIds = PHONE_APPS.filter(
|
||||||
(app) => app.kind !== 'external' && app.id !== 'app-store',
|
(app) =>
|
||||||
|
app.kind !== 'external' && app.id !== 'app-store' && !app.adminOnly,
|
||||||
).map((app) => app.id)
|
).map((app) => app.id)
|
||||||
|
|
||||||
expect([...APP_STORE_PREVIEW_IMAGE_IDS].sort()).toEqual(storeAppIds.sort())
|
expect([...APP_STORE_PREVIEW_IMAGE_IDS].sort()).toEqual(storeAppIds.sort())
|
||||||
@@ -25,7 +26,8 @@ describe('App Store preview catalog', () => {
|
|||||||
|
|
||||||
it('provides specialized preview data for every built-in store app', () => {
|
it('provides specialized preview data for every built-in store app', () => {
|
||||||
const storeAppIds = PHONE_APPS.filter(
|
const storeAppIds = PHONE_APPS.filter(
|
||||||
(app) => app.kind !== 'external' && app.id !== 'app-store',
|
(app) =>
|
||||||
|
app.kind !== 'external' && app.id !== 'app-store' && !app.adminOnly,
|
||||||
).map((app) => app.id)
|
).map((app) => app.id)
|
||||||
|
|
||||||
expect([...PREVIEWABLE_BUILTIN_APP_IDS].sort()).toEqual(
|
expect([...PREVIEWABLE_BUILTIN_APP_IDS].sort()).toEqual(
|
||||||
|
|||||||
@@ -158,6 +158,13 @@ describe('media utilities', () => {
|
|||||||
expect(mediaErrorKey('profile_photo_required')).toBe(
|
expect(mediaErrorKey('profile_photo_required')).toBe(
|
||||||
'profile_photo_required',
|
'profile_photo_required',
|
||||||
)
|
)
|
||||||
|
expect(mediaErrorKey('media_provider_failed')).toBe('media_provider_failed')
|
||||||
|
expect(mediaErrorKey('media_provider_rate_limited')).toBe(
|
||||||
|
'media_provider_rate_limited',
|
||||||
|
)
|
||||||
|
expect(mediaErrorKey('media_provider_unauthorized')).toBe(
|
||||||
|
'media_provider_unauthorized',
|
||||||
|
)
|
||||||
expect(mediaErrorKey('private_provider_error')).toBe('request_failed')
|
expect(mediaErrorKey('private_provider_error')).toBe('request_failed')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ export function mediaErrorKey(error?: string): string {
|
|||||||
'invalid_import_url',
|
'invalid_import_url',
|
||||||
'invalid_upload',
|
'invalid_upload',
|
||||||
'invalid_upload_token',
|
'invalid_upload_token',
|
||||||
|
'media_provider_failed',
|
||||||
|
'media_provider_rate_limited',
|
||||||
|
'media_provider_unauthorized',
|
||||||
'missing_config',
|
'missing_config',
|
||||||
'import_media_not_allowed',
|
'import_media_not_allowed',
|
||||||
'import_media_too_large',
|
'import_media_too_large',
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
import { formatPhoneNumber, normalizePhoneNumber } from './phone'
|
import {
|
||||||
|
configurePhoneNumberFormat,
|
||||||
|
formatPhoneNumber,
|
||||||
|
normalizePhoneNumber,
|
||||||
|
} from './phone'
|
||||||
|
|
||||||
describe('phone numbers', () => {
|
describe('phone numbers', () => {
|
||||||
it('normalizes formatted ten digit values', () => {
|
it('normalizes formatted ten digit values', () => {
|
||||||
@@ -13,4 +17,13 @@ describe('phone numbers', () => {
|
|||||||
expect(formatPhoneNumber('5551')).toBe('555 1')
|
expect(formatPhoneNumber('5551')).toBe('555 1')
|
||||||
expect(formatPhoneNumber(5551234567)).toBe('555 123 4567')
|
expect(formatPhoneNumber(5551234567)).toBe('555 123 4567')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses the server-provided number length and display groups', () => {
|
||||||
|
configurePhoneNumberFormat({ groups: [4, 3, 3], length: 10 })
|
||||||
|
|
||||||
|
expect(formatPhoneNumber('0171234567')).toBe('0171 234 567')
|
||||||
|
expect(normalizePhoneNumber('0171 234 567')).toBe('0171234567')
|
||||||
|
|
||||||
|
configurePhoneNumberFormat()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,53 @@
|
|||||||
|
import type { PhoneNumberFormat } from '@/types/phone'
|
||||||
|
|
||||||
export const PHONE_NUMBER_LENGTH = 10
|
export const PHONE_NUMBER_LENGTH = 10
|
||||||
|
const DEFAULT_PHONE_NUMBER_FORMAT: PhoneNumberFormat = {
|
||||||
|
groups: [3, 3, 4],
|
||||||
|
length: PHONE_NUMBER_LENGTH,
|
||||||
|
}
|
||||||
|
let phoneNumberFormat: PhoneNumberFormat = DEFAULT_PHONE_NUMBER_FORMAT
|
||||||
|
|
||||||
|
export function configurePhoneNumberFormat(value?: PhoneNumberFormat): void {
|
||||||
|
const length = value?.length
|
||||||
|
const groups = value?.groups
|
||||||
|
if (
|
||||||
|
typeof length !== 'number' ||
|
||||||
|
!Number.isInteger(length) ||
|
||||||
|
length < 1 ||
|
||||||
|
length > 24 ||
|
||||||
|
!Array.isArray(groups) ||
|
||||||
|
groups.length === 0 ||
|
||||||
|
groups.some((group) => !Number.isInteger(group) || group < 1)
|
||||||
|
) {
|
||||||
|
phoneNumberFormat = DEFAULT_PHONE_NUMBER_FORMAT
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
phoneNumberFormat = {
|
||||||
|
groups: [...groups],
|
||||||
|
length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizePhoneNumber(value: string): string | null {
|
export function normalizePhoneNumber(value: string): string | null {
|
||||||
const digits = value.replace(/\D/g, '')
|
const digits = value.replace(/\D/g, '')
|
||||||
return digits.length === PHONE_NUMBER_LENGTH ? digits : null
|
return digits.length === phoneNumberFormat.length ? digits : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatPhoneNumber(value: string | number): string {
|
export function formatPhoneNumber(value: string | number): string {
|
||||||
const digits = String(value).replace(/\D/g, '').slice(0, PHONE_NUMBER_LENGTH)
|
const digits = String(value)
|
||||||
const groups = [digits.slice(0, 3), digits.slice(3, 6), digits.slice(6, 10)]
|
.replace(/\D/g, '')
|
||||||
return groups.filter(Boolean).join(' ')
|
.slice(0, phoneNumberFormat.length)
|
||||||
|
const formatted: string[] = []
|
||||||
|
let offset = 0
|
||||||
|
|
||||||
|
for (const size of phoneNumberFormat.groups) {
|
||||||
|
const group = digits.slice(offset, offset + size)
|
||||||
|
if (!group) break
|
||||||
|
formatted.push(group)
|
||||||
|
offset += size
|
||||||
|
}
|
||||||
|
if (offset < digits.length) formatted.push(digits.slice(offset))
|
||||||
|
|
||||||
|
return formatted.join(' ')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const launchStyle = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="app"
|
v-if="app && !app.adminOnly"
|
||||||
class="app-window"
|
class="app-window"
|
||||||
:class="{ 'app-window--citywarn': app.id === 'citywarn' }"
|
:class="{ 'app-window--citywarn': app.id === 'citywarn' }"
|
||||||
:style="launchStyle"
|
:style="launchStyle"
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ const downloadDateDescription = computed(() =>
|
|||||||
)
|
)
|
||||||
const catalog = computed(() =>
|
const catalog = computed(() =>
|
||||||
PHONE_APPS.filter((app): app is LaunchablePhoneAppDefinition => {
|
PHONE_APPS.filter((app): app is LaunchablePhoneAppDefinition => {
|
||||||
if (!isLaunchablePhoneApp(app) || app.id === 'app-store') {
|
if (!isLaunchablePhoneApp(app) || app.id === 'app-store' || app.adminOnly) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +140,7 @@ const dailyCandidates = computed(() =>
|
|||||||
PHONE_APPS.filter(
|
PHONE_APPS.filter(
|
||||||
(app): app is LaunchablePhoneAppDefinition =>
|
(app): app is LaunchablePhoneAppDefinition =>
|
||||||
isLaunchablePhoneApp(app) &&
|
isLaunchablePhoneApp(app) &&
|
||||||
|
!app.adminOnly &&
|
||||||
!isExternalPhoneApp(app) &&
|
!isExternalPhoneApp(app) &&
|
||||||
app.id !== 'app-store' &&
|
app.id !== 'app-store' &&
|
||||||
!DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) &&
|
!DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) &&
|
||||||
|
|||||||
@@ -134,10 +134,30 @@ async function requestPhoto(): Promise<void> {
|
|||||||
window.setTimeout(() => void completeDevelopmentCapture(id, 'photo'), 700)
|
window.setTimeout(() => void completeDevelopmentCapture(id, 'photo'), 700)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await nuiCall('media:requestUpload', {
|
console.info('[Sky Phone Media] Camera requested a photo upload.', {
|
||||||
|
correlationId: id,
|
||||||
|
})
|
||||||
|
const response = await nuiCall('media:requestUpload', {
|
||||||
correlationId: id,
|
correlationId: id,
|
||||||
mediaType: 'photo',
|
mediaType: 'photo',
|
||||||
})
|
})
|
||||||
|
if (!response.success) {
|
||||||
|
console.error('[Sky Phone Media] Photo upload request was rejected.', {
|
||||||
|
correlationId: id,
|
||||||
|
error: response.error,
|
||||||
|
})
|
||||||
|
window.postMessage(
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
correlationId: id,
|
||||||
|
error: response.error ?? 'request_failed',
|
||||||
|
success: false,
|
||||||
|
},
|
||||||
|
type: 'media:uploadResult',
|
||||||
|
},
|
||||||
|
'*',
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startRecording(): void {
|
function startRecording(): void {
|
||||||
@@ -374,6 +394,11 @@ function onMessage(event: MessageEvent): void {
|
|||||||
} else if (message.type === 'media:uploadResult') {
|
} else if (message.type === 'media:uploadResult') {
|
||||||
const result = message.data as UploadResult
|
const result = message.data as UploadResult
|
||||||
if (!result?.correlationId) return
|
if (!result?.correlationId) return
|
||||||
|
console.info('[Sky Phone Media] Camera received an upload result.', {
|
||||||
|
correlationId: result.correlationId,
|
||||||
|
error: result.error,
|
||||||
|
success: result.success,
|
||||||
|
})
|
||||||
savingVideo.value = false
|
savingVideo.value = false
|
||||||
if (result.success && result.media) {
|
if (result.success && result.media) {
|
||||||
latestMedia.value = result.media
|
latestMedia.value = result.media
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ describe('VaultX crypto app contracts', () => {
|
|||||||
expect(source).not.toContain('class="auth-panel__heading"')
|
expect(source).not.toContain('class="auth-panel__heading"')
|
||||||
expect(source).not.toContain('class="auth-action-dock"')
|
expect(source).not.toContain('class="auth-action-dock"')
|
||||||
expect(source).not.toContain('class="password-rules"')
|
expect(source).not.toContain('class="password-rules"')
|
||||||
|
expect(source).toContain('class="auth-password-hint"')
|
||||||
|
expect(source).toContain("t('auth.ruleSpecial')")
|
||||||
expect(source).toMatch(
|
expect(source).toMatch(
|
||||||
/\.auth-shell\s*\{[^}]*width:\s*100%;[^}]*max-width:\s*338px;/s,
|
/\.auth-shell\s*\{[^}]*width:\s*100%;[^}]*max-width:\s*338px;/s,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -824,6 +824,10 @@ onUnmounted(() => {
|
|||||||
:size="18"
|
:size="18"
|
||||||
/></button></template
|
/></button></template
|
||||||
></SkyField>
|
></SkyField>
|
||||||
|
<p v-if="authMode === 'register'" class="auth-password-hint">
|
||||||
|
{{ t('auth.ruleLength') }} · {{ t('auth.ruleMixed') }} ·
|
||||||
|
{{ t('auth.ruleNumber') }} · {{ t('auth.ruleSpecial') }}
|
||||||
|
</p>
|
||||||
<p v-if="formError" class="error">{{ formError }}</p>
|
<p v-if="formError" class="error">{{ formError }}</p>
|
||||||
<SkyButton block large class="auth-submit" type="submit">{{
|
<SkyButton block large class="auth-submit" type="submit">{{
|
||||||
t(
|
t(
|
||||||
@@ -2060,6 +2064,12 @@ onUnmounted(() => {
|
|||||||
.auth-form {
|
.auth-form {
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
.auth-password-hint {
|
||||||
|
margin: -3px 4px 0;
|
||||||
|
color: rgba(255, 255, 255, 0.58);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 15px;
|
||||||
|
}
|
||||||
.auth-account {
|
.auth-account {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 40px minmax(0, 1fr) 18px;
|
grid-template-columns: 40px minmax(0, 1fr) 18px;
|
||||||
|
|||||||
@@ -37,10 +37,17 @@ describe('DarkChatApp Sky UI contract', () => {
|
|||||||
expect(newChat).toContain('class="dc-recipient-field"')
|
expect(newChat).toContain('class="dc-recipient-field"')
|
||||||
expect(newChat).toContain('layout="inline"')
|
expect(newChat).toContain('layout="inline"')
|
||||||
expect(newChat).toContain('class="dc-new-chat-contacts"')
|
expect(newChat).toContain('class="dc-new-chat-contacts"')
|
||||||
|
expect(newChat).toContain("{{ t('newChatBody') }}")
|
||||||
expect(newChat).toContain("{{ phone.t('Common.cancel') }}")
|
expect(newChat).toContain("{{ phone.t('Common.cancel') }}")
|
||||||
expect(newChat).not.toContain('class="dc-hero"')
|
expect(newChat).not.toContain('class="dc-hero"')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('rejects the current profile identifiers before opening confirmation', () => {
|
||||||
|
expect(source).toContain('darkchat.profile?.darkId')
|
||||||
|
expect(source).toContain('darkchat.profile?.inviteCode')
|
||||||
|
expect(source).toContain("showToast(errorText('self_chat'))")
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps the search visually compact without shrinking its wrapper', () => {
|
it('keeps the search visually compact without shrinking its wrapper', () => {
|
||||||
expect(source).toMatch(
|
expect(source).toMatch(
|
||||||
/\.dc-search :deep\(\.sky-searchbar__control\),[\s\S]*?height:\s*38px;[\s\S]*?min-height:\s*38px;/,
|
/\.dc-search :deep\(\.sky-searchbar__control\),[\s\S]*?height:\s*38px;[\s\S]*?min-height:\s*38px;/,
|
||||||
|
|||||||
@@ -427,6 +427,20 @@ function closeReport(): void {
|
|||||||
function requestStart(value = identifier.value): void {
|
function requestStart(value = identifier.value): void {
|
||||||
const clean = value.trim()
|
const clean = value.trim()
|
||||||
if (!clean) return
|
if (!clean) return
|
||||||
|
const ownIdentifiers = [
|
||||||
|
darkchat.profile?.darkId,
|
||||||
|
darkchat.profile?.inviteCode,
|
||||||
|
]
|
||||||
|
if (
|
||||||
|
ownIdentifiers.some(
|
||||||
|
(ownIdentifier) =>
|
||||||
|
ownIdentifier?.toLocaleLowerCase(phone.lang) ===
|
||||||
|
clean.toLocaleLowerCase(phone.lang),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
showToast(errorText('self_chat'))
|
||||||
|
return
|
||||||
|
}
|
||||||
pendingIdentifier.value = clean
|
pendingIdentifier.value = clean
|
||||||
safetyOpen.value = true
|
safetyOpen.value = true
|
||||||
}
|
}
|
||||||
@@ -1262,6 +1276,13 @@ onBeforeUnmount(() => {
|
|||||||
padding-top: 2px;
|
padding-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dc-new-chat-intro {
|
||||||
|
margin: 2px 2px 12px;
|
||||||
|
color: var(--dc-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.dc-recipient-field,
|
.dc-recipient-field,
|
||||||
.dc-new-chat-contacts {
|
.dc-new-chat-contacts {
|
||||||
margin: 0 calc(var(--sky-page-gutter) * -1) 12px;
|
margin: 0 calc(var(--sky-page-gutter) * -1) 12px;
|
||||||
@@ -2071,6 +2092,7 @@ onBeforeUnmount(() => {
|
|||||||
</template>
|
</template>
|
||||||
</SkyNavbar>
|
</SkyNavbar>
|
||||||
<SkyScrollArea padded class="dc-new-chat-scroll">
|
<SkyScrollArea padded class="dc-new-chat-scroll">
|
||||||
|
<p class="dc-new-chat-intro">{{ t('newChatBody') }}</p>
|
||||||
<SkyList class="dc-recipient-field" density="compact" flush>
|
<SkyList class="dc-recipient-field" density="compact" flush>
|
||||||
<SkyField
|
<SkyField
|
||||||
:label="t('to')"
|
:label="t('to')"
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ describe('SkyRide app layout', () => {
|
|||||||
expect(source).toContain('skyride-history-card__distance')
|
expect(source).toContain('skyride-history-card__distance')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('explains that requests require an online player driver', () => {
|
||||||
|
expect(source).toContain("phone.t('Apps.skyride.playerDriverNotice')")
|
||||||
|
expect(source).toContain('class="skyride-player-driver-notice"')
|
||||||
|
})
|
||||||
|
|
||||||
it('aligns rider and activity surfaces to one content width', () => {
|
it('aligns rider and activity surfaces to one content width', () => {
|
||||||
expect(source).toMatch(
|
expect(source).toMatch(
|
||||||
/\.skyride-location-list,\s*\.skyride-activity-list\s*\{[^}]*margin-inline:\s*2px !important;/s,
|
/\.skyride-location-list,\s*\.skyride-activity-list\s*\{[^}]*margin-inline:\s*2px !important;/s,
|
||||||
|
|||||||
@@ -848,6 +848,9 @@ onBeforeUnmount(() => {
|
|||||||
</k-button>
|
</k-button>
|
||||||
</div>
|
</div>
|
||||||
</k-card>
|
</k-card>
|
||||||
|
<p class="skyride-player-driver-notice">
|
||||||
|
{{ phone.t('Apps.skyride.playerDriverNotice') }}
|
||||||
|
</p>
|
||||||
<k-button
|
<k-button
|
||||||
large
|
large
|
||||||
rounded
|
rounded
|
||||||
@@ -2109,6 +2112,13 @@ onBeforeUnmount(() => {
|
|||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.skyride-player-driver-notice {
|
||||||
|
margin: 2px 7px 8px;
|
||||||
|
color: var(--ride-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
.skyride-primary :deep(svg) {
|
.skyride-primary :deep(svg) {
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,668 @@
|
|||||||
|
const { readFileSync } = require('node:fs')
|
||||||
|
const { resolve } = require('node:path')
|
||||||
|
|
||||||
|
class LuaTable {
|
||||||
|
constructor() {
|
||||||
|
this.entries = []
|
||||||
|
this.nextArrayIndex = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key) {
|
||||||
|
return this.entries.find((entry) => entry.key === key)?.value
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key, value) {
|
||||||
|
const entry = this.entries.find((candidate) => candidate.key === key)
|
||||||
|
if (entry) entry.value = value
|
||||||
|
else this.entries.push({ key, value })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenize(source) {
|
||||||
|
const tokens = []
|
||||||
|
let index = 0
|
||||||
|
|
||||||
|
while (index < source.length) {
|
||||||
|
const character = source[index]
|
||||||
|
if (/\s/.test(character)) {
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (source.startsWith('--[[', index)) {
|
||||||
|
const end = source.indexOf(']]', index + 4)
|
||||||
|
index = end < 0 ? source.length : end + 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (source.startsWith('--', index)) {
|
||||||
|
const end = source.indexOf('\n', index + 2)
|
||||||
|
index = end < 0 ? source.length : end + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (character === '"' || character === "'") {
|
||||||
|
const quote = character
|
||||||
|
let value = ''
|
||||||
|
index += 1
|
||||||
|
while (index < source.length && source[index] !== quote) {
|
||||||
|
if (source[index] !== '\\') {
|
||||||
|
value += source[index]
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
index += 1
|
||||||
|
const escaped = source[index]
|
||||||
|
const escapeValues = {
|
||||||
|
a: '\u0007',
|
||||||
|
b: '\b',
|
||||||
|
f: '\f',
|
||||||
|
n: '\n',
|
||||||
|
r: '\r',
|
||||||
|
t: '\t',
|
||||||
|
v: '\u000b',
|
||||||
|
}
|
||||||
|
value += escapeValues[escaped] ?? escaped
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
if (source[index] !== quote)
|
||||||
|
throw new Error('Unterminated Lua string in configurator fixture.')
|
||||||
|
index += 1
|
||||||
|
tokens.push({ type: 'string', value })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (/[A-Za-z_]/.test(character)) {
|
||||||
|
const match = source.slice(index).match(/^[A-Za-z_][A-Za-z0-9_]*/)
|
||||||
|
tokens.push({ type: 'name', value: match[0] })
|
||||||
|
index += match[0].length
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
/\d/.test(character) ||
|
||||||
|
(character === '.' && /\d/.test(source[index + 1]))
|
||||||
|
) {
|
||||||
|
const match = source
|
||||||
|
.slice(index)
|
||||||
|
.match(/^(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/)
|
||||||
|
tokens.push({ type: 'number', value: Number(match[0]) })
|
||||||
|
index += match[0].length
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens.push({ type: 'symbol', value: character })
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
class LuaConfigParser {
|
||||||
|
constructor(source) {
|
||||||
|
this.tokens = tokenize(source)
|
||||||
|
this.index = 0
|
||||||
|
this.config = new LuaTable()
|
||||||
|
}
|
||||||
|
|
||||||
|
current(offset = 0) {
|
||||||
|
return this.tokens[this.index + offset]
|
||||||
|
}
|
||||||
|
|
||||||
|
matches(value, offset = 0) {
|
||||||
|
return this.current(offset)?.value === value
|
||||||
|
}
|
||||||
|
|
||||||
|
take(value) {
|
||||||
|
const token = this.current()
|
||||||
|
if (!token || (value !== undefined && token.value !== value)) {
|
||||||
|
throw new Error(
|
||||||
|
`Expected '${value}', received '${token?.value ?? 'end of file'}' in configurator fixture.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
this.index += 1
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
parse() {
|
||||||
|
while (this.current()) {
|
||||||
|
if (this.matches('Config')) {
|
||||||
|
const start = this.index
|
||||||
|
const path = this.parsePath()
|
||||||
|
if (path.length && this.matches('=')) {
|
||||||
|
this.take('=')
|
||||||
|
this.setPath(path, this.parseExpression())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
this.index = start + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
this.index += 1
|
||||||
|
}
|
||||||
|
return this.config
|
||||||
|
}
|
||||||
|
|
||||||
|
parsePath() {
|
||||||
|
this.take('Config')
|
||||||
|
const path = []
|
||||||
|
while (this.matches('.') && this.current(1)?.type === 'name') {
|
||||||
|
this.take('.')
|
||||||
|
path.push(this.take().value)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
getPath(path) {
|
||||||
|
let value = this.config
|
||||||
|
for (const key of path) {
|
||||||
|
if (!(value instanceof LuaTable)) return undefined
|
||||||
|
value = value.get(key)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
setPath(path, value) {
|
||||||
|
let parent = this.config
|
||||||
|
for (let index = 0; index < path.length - 1; index += 1) {
|
||||||
|
const key = path[index]
|
||||||
|
let child = parent.get(key)
|
||||||
|
if (!(child instanceof LuaTable)) {
|
||||||
|
child = new LuaTable()
|
||||||
|
parent.set(key, child)
|
||||||
|
}
|
||||||
|
parent = child
|
||||||
|
}
|
||||||
|
parent.set(path.at(-1), value)
|
||||||
|
}
|
||||||
|
|
||||||
|
parseExpression() {
|
||||||
|
return this.parseOr()
|
||||||
|
}
|
||||||
|
|
||||||
|
parseOr() {
|
||||||
|
let value = this.parseAdditive()
|
||||||
|
while (this.matches('or')) {
|
||||||
|
this.take('or')
|
||||||
|
const fallback = this.parseAdditive()
|
||||||
|
value =
|
||||||
|
value !== false && value !== null && value !== undefined
|
||||||
|
? value
|
||||||
|
: fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
parseAdditive() {
|
||||||
|
let value = this.parseMultiplicative()
|
||||||
|
while (this.matches('+') || this.matches('-')) {
|
||||||
|
const operator = this.take().value
|
||||||
|
const right = this.parseMultiplicative()
|
||||||
|
value =
|
||||||
|
operator === '+'
|
||||||
|
? Number(value) + Number(right)
|
||||||
|
: Number(value) - Number(right)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
parseMultiplicative() {
|
||||||
|
let value = this.parseUnary()
|
||||||
|
while (this.matches('*') || this.matches('/')) {
|
||||||
|
const operator = this.take().value
|
||||||
|
const right = this.parseUnary()
|
||||||
|
value =
|
||||||
|
operator === '*'
|
||||||
|
? Number(value) * Number(right)
|
||||||
|
: Number(value) / Number(right)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
parseUnary() {
|
||||||
|
if (this.matches('-')) {
|
||||||
|
this.take('-')
|
||||||
|
return -Number(this.parseUnary())
|
||||||
|
}
|
||||||
|
return this.parsePrimary()
|
||||||
|
}
|
||||||
|
|
||||||
|
parsePrimary() {
|
||||||
|
const token = this.current()
|
||||||
|
if (!token)
|
||||||
|
throw new Error(
|
||||||
|
'Unexpected end of Lua configuration in configurator fixture.',
|
||||||
|
)
|
||||||
|
if (token.type === 'number' || token.type === 'string') {
|
||||||
|
this.index += 1
|
||||||
|
return token.value
|
||||||
|
}
|
||||||
|
if (this.matches('true') || this.matches('false') || this.matches('nil')) {
|
||||||
|
this.index += 1
|
||||||
|
return token.value === 'true'
|
||||||
|
? true
|
||||||
|
: token.value === 'false'
|
||||||
|
? false
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
if (this.matches('{')) return this.parseTable()
|
||||||
|
if (this.matches('(')) {
|
||||||
|
this.take('(')
|
||||||
|
const value = this.parseExpression()
|
||||||
|
this.take(')')
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if (this.matches('Config')) return this.getPath(this.parsePath())
|
||||||
|
if (token.type === 'name' && this.current(1)?.value === '(') {
|
||||||
|
const name = this.take().value
|
||||||
|
this.take('(')
|
||||||
|
const argumentsList = []
|
||||||
|
while (!this.matches(')')) {
|
||||||
|
argumentsList.push(this.parseExpression())
|
||||||
|
if (!this.matches(',')) break
|
||||||
|
this.take(',')
|
||||||
|
}
|
||||||
|
this.take(')')
|
||||||
|
if (/^vector[234]$/.test(name)) {
|
||||||
|
const axes = ['x', 'y', 'z', 'w']
|
||||||
|
return Object.fromEntries([
|
||||||
|
['__skyType', name],
|
||||||
|
...argumentsList.map((value, index) => [axes[index], value]),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
throw new Error(`Unsupported Lua call '${name}' in configurator fixture.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported Lua token '${token.value}' in configurator fixture.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
parseTable() {
|
||||||
|
const table = new LuaTable()
|
||||||
|
this.take('{')
|
||||||
|
while (!this.matches('}')) {
|
||||||
|
if (this.matches('[')) {
|
||||||
|
this.take('[')
|
||||||
|
const key = this.parseExpression()
|
||||||
|
this.take(']')
|
||||||
|
this.take('=')
|
||||||
|
table.set(key, this.parseExpression())
|
||||||
|
} else if (
|
||||||
|
this.current()?.type === 'name' &&
|
||||||
|
this.current(1)?.value === '='
|
||||||
|
) {
|
||||||
|
const key = this.take().value
|
||||||
|
this.take('=')
|
||||||
|
table.set(key, this.parseExpression())
|
||||||
|
} else {
|
||||||
|
table.set(table.nextArrayIndex, this.parseExpression())
|
||||||
|
table.nextArrayIndex += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.matches(',') || this.matches(';')) this.index += 1
|
||||||
|
else if (!this.matches('}')) {
|
||||||
|
throw new Error(
|
||||||
|
`Expected a Lua table separator, received '${this.current()?.value ?? 'end of file'}'.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.take('}')
|
||||||
|
return table
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeLuaValue(value) {
|
||||||
|
if (!(value instanceof LuaTable)) {
|
||||||
|
if (Array.isArray(value)) return value.map(serializeLuaValue)
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value).map(([key, child]) => [
|
||||||
|
key,
|
||||||
|
serializeLuaValue(child),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericKeys = value.entries
|
||||||
|
.filter((entry) => typeof entry.key === 'number')
|
||||||
|
.map((entry) => entry.key)
|
||||||
|
.sort((left, right) => left - right)
|
||||||
|
const isSequence =
|
||||||
|
numericKeys.length === value.entries.length &&
|
||||||
|
numericKeys.every((key, index) => key === index + 1)
|
||||||
|
if (isSequence) {
|
||||||
|
return [...value.entries]
|
||||||
|
.sort((left, right) => left.key - right.key)
|
||||||
|
.map((entry) => serializeLuaValue(entry.value))
|
||||||
|
}
|
||||||
|
if (value.entries.every((entry) => typeof entry.key === 'string')) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
value.entries.map((entry) => [entry.key, serializeLuaValue(entry.value)]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
__skyType: 'map',
|
||||||
|
entries: value.entries.map((entry) => ({
|
||||||
|
key: entry.key,
|
||||||
|
keyType: typeof entry.key,
|
||||||
|
value: serializeLuaValue(entry.value),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanize(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replace(/[_-]+/g, ' ')
|
||||||
|
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||||
|
.replace(/([A-Za-z])(\d)/g, '$1 $2')
|
||||||
|
.replace(/^./, (character) => character.toUpperCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
function sensitivePath(path) {
|
||||||
|
const leaf = path.split('.').at(-1) ?? path
|
||||||
|
const normalized = leaf.toLowerCase().replace(/[^a-z0-9]/g, '')
|
||||||
|
return (
|
||||||
|
normalized.includes('apikey') ||
|
||||||
|
normalized.includes('secret') ||
|
||||||
|
normalized.includes('pepper') ||
|
||||||
|
[
|
||||||
|
'password',
|
||||||
|
'token',
|
||||||
|
'authorization',
|
||||||
|
'credential',
|
||||||
|
'connectionstring',
|
||||||
|
].includes(normalized)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskValue(value, path) {
|
||||||
|
if (Array.isArray(value))
|
||||||
|
return value.map((child, index) => maskValue(child, `${path}.${index + 1}`))
|
||||||
|
if (value?.__skyType === 'map' && Array.isArray(value.entries)) {
|
||||||
|
return {
|
||||||
|
__skyType: 'map',
|
||||||
|
entries: value.entries.map((entry) => ({
|
||||||
|
key: entry.key,
|
||||||
|
keyType: entry.keyType,
|
||||||
|
value: maskValue(entry.value, `${path}.${entry.key}`),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value).map(([key, child]) => [
|
||||||
|
key,
|
||||||
|
maskValue(child, `${path}.${key}`),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return typeof value === 'string' && sensitivePath(path)
|
||||||
|
? '***REDACTED***'
|
||||||
|
: value
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyStructure(scope, path) {
|
||||||
|
if (scope !== 'config') return undefined
|
||||||
|
if (path === 'Garage.VehicleImages.ModelNames') {
|
||||||
|
return {
|
||||||
|
entries: [],
|
||||||
|
keyType: 'number',
|
||||||
|
kind: 'map',
|
||||||
|
template: { kind: 'value', valueType: 'string' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
path === 'CrewLink.ExternalPingResources' ||
|
||||||
|
path === 'CustomApps.TrustedAdapters'
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
fields: {},
|
||||||
|
kind: 'table',
|
||||||
|
mutableKeys: true,
|
||||||
|
template: { kind: 'value', valueType: 'boolean' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (path === 'FlipTok.MusicTracks') {
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
kind: 'list',
|
||||||
|
template: {
|
||||||
|
fields: {
|
||||||
|
Artist: { kind: 'value', valueType: 'string' },
|
||||||
|
Id: { kind: 'value', valueType: 'string' },
|
||||||
|
Title: { kind: 'value', valueType: 'string' },
|
||||||
|
Url: { kind: 'value', valueType: 'string' },
|
||||||
|
},
|
||||||
|
kind: 'table',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (path === 'Music.Tracks') {
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
kind: 'list',
|
||||||
|
template: {
|
||||||
|
fields: {
|
||||||
|
Artist: { kind: 'value', valueType: 'string' },
|
||||||
|
Id: { kind: 'value', valueType: 'string' },
|
||||||
|
Title: { kind: 'value', valueType: 'string' },
|
||||||
|
},
|
||||||
|
kind: 'table',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (path === 'Payphones.CustomLocations') {
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
kind: 'list',
|
||||||
|
template: { kind: 'vector', vectorType: 'vector4' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (/^Companies\.Definitions\.[^.]+\.Services$/.test(path)) {
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
kind: 'list',
|
||||||
|
template: {
|
||||||
|
fields: {
|
||||||
|
Description: { kind: 'value', valueType: 'string' },
|
||||||
|
Id: { kind: 'value', valueType: 'string' },
|
||||||
|
Price: { kind: 'value', valueType: 'string' },
|
||||||
|
RequestsEnabled: { kind: 'value', valueType: 'boolean' },
|
||||||
|
Title: { kind: 'value', valueType: 'string' },
|
||||||
|
},
|
||||||
|
kind: 'table',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStructure(value, scope, path) {
|
||||||
|
if (scope === 'config' && path === 'Phone.Keybind') {
|
||||||
|
return { kind: 'optionalString' }
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
scope === 'config' &&
|
||||||
|
path === 'Companies.Definitions' &&
|
||||||
|
value !== null &&
|
||||||
|
typeof value === 'object' &&
|
||||||
|
!Array.isArray(value)
|
||||||
|
) {
|
||||||
|
const keys = Object.keys(value).sort()
|
||||||
|
const fields = Object.fromEntries(
|
||||||
|
keys.map((key) => [
|
||||||
|
key,
|
||||||
|
buildStructure(value[key], scope, `${path}.${key}`),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
fields,
|
||||||
|
kind: 'table',
|
||||||
|
mutableKeys: true,
|
||||||
|
template: keys[0] ? fields[keys[0]] : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const configuredEmptyStructure =
|
||||||
|
Array.isArray(value) && value.length === 0
|
||||||
|
? emptyStructure(scope, path)
|
||||||
|
: undefined
|
||||||
|
if (configuredEmptyStructure) {
|
||||||
|
return configuredEmptyStructure
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const items = value.map((child, index) =>
|
||||||
|
buildStructure(child, scope, `${path}.${index + 1}`),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
kind: 'list',
|
||||||
|
items,
|
||||||
|
template: items[0],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value?.__skyType === 'map' && Array.isArray(value.entries)) {
|
||||||
|
const entries = value.entries.map((entry) => ({
|
||||||
|
key: entry.key,
|
||||||
|
keyType: entry.keyType,
|
||||||
|
structure: buildStructure(entry.value, scope, `${path}.${entry.key}`),
|
||||||
|
}))
|
||||||
|
const keyTypes = new Set(entries.map((entry) => entry.keyType))
|
||||||
|
return {
|
||||||
|
kind: 'map',
|
||||||
|
entries,
|
||||||
|
keyType: keyTypes.size === 1 ? entries[0]?.keyType : undefined,
|
||||||
|
template: entries[0]?.structure,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (/^vector[234]$/.test(value?.__skyType ?? '')) {
|
||||||
|
return { kind: 'vector', vectorType: value.__skyType }
|
||||||
|
}
|
||||||
|
if (value !== null && typeof value === 'object') {
|
||||||
|
return {
|
||||||
|
kind: 'table',
|
||||||
|
fields: Object.fromEntries(
|
||||||
|
Object.entries(value).map(([key, child]) => [
|
||||||
|
key,
|
||||||
|
buildStructure(child, scope, `${path}.${key}`),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { kind: 'value', valueType: typeof value }
|
||||||
|
}
|
||||||
|
|
||||||
|
function addField(fields, scope, path, value) {
|
||||||
|
const valueType = Array.isArray(value) ? 'json' : typeof value
|
||||||
|
const sensitive = typeof value === 'string' && sensitivePath(path)
|
||||||
|
fields.push({
|
||||||
|
configured: sensitive ? value !== '' : undefined,
|
||||||
|
label:
|
||||||
|
scope === 'config' && path === 'Companies.Definitions'
|
||||||
|
? 'Jobs'
|
||||||
|
: humanize(path.split('.').at(-1)),
|
||||||
|
path,
|
||||||
|
scope,
|
||||||
|
sensitive,
|
||||||
|
structure:
|
||||||
|
value !== null && typeof value === 'object'
|
||||||
|
? buildStructure(value, scope, path)
|
||||||
|
: undefined,
|
||||||
|
type:
|
||||||
|
scope === 'config' && path === 'Phone.Keybind'
|
||||||
|
? 'stringOrFalse'
|
||||||
|
: value !== null && typeof value === 'object'
|
||||||
|
? 'json'
|
||||||
|
: valueType,
|
||||||
|
value: sensitive ? '' : maskValue(value, path),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenCompanyFields(fields, path, value) {
|
||||||
|
if (
|
||||||
|
path === 'Companies.Definitions' ||
|
||||||
|
value === null ||
|
||||||
|
typeof value !== 'object' ||
|
||||||
|
Array.isArray(value) ||
|
||||||
|
value.__skyType ||
|
||||||
|
Object.keys(value).length === 0
|
||||||
|
) {
|
||||||
|
addField(fields, 'config', path, value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of Object.keys(value).sort()) {
|
||||||
|
flattenCompanyFields(fields, `${path}.${key}`, value[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSections(scope, payload) {
|
||||||
|
const sections = []
|
||||||
|
const generalFields = []
|
||||||
|
for (const key of Object.keys(payload).sort()) {
|
||||||
|
const value = payload[key]
|
||||||
|
if (scope === 'config' && key === 'Companies') {
|
||||||
|
const fields = []
|
||||||
|
flattenCompanyFields(fields, 'Companies', value)
|
||||||
|
fields.sort((left, right) => {
|
||||||
|
if (left.path === 'Companies.Definitions') return 1
|
||||||
|
if (right.path === 'Companies.Definitions') return -1
|
||||||
|
return left.path.localeCompare(right.path)
|
||||||
|
})
|
||||||
|
sections.push({
|
||||||
|
fields,
|
||||||
|
id: 'config:Companies',
|
||||||
|
label: humanize(key),
|
||||||
|
scope,
|
||||||
|
})
|
||||||
|
} else if (
|
||||||
|
value !== null &&
|
||||||
|
typeof value === 'object' &&
|
||||||
|
!Array.isArray(value) &&
|
||||||
|
!value.__skyType &&
|
||||||
|
Object.keys(value).length > 0
|
||||||
|
) {
|
||||||
|
const fields = []
|
||||||
|
addField(fields, scope, key, value)
|
||||||
|
sections.push({
|
||||||
|
fields,
|
||||||
|
id: `${scope}:${key}`,
|
||||||
|
label: humanize(key),
|
||||||
|
scope,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
addField(generalFields, scope, key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (generalFields.length) {
|
||||||
|
const general = {
|
||||||
|
fields: generalFields,
|
||||||
|
id: `${scope}:general`,
|
||||||
|
label: 'General',
|
||||||
|
scope,
|
||||||
|
}
|
||||||
|
if (scope === 'config') sections.unshift(general)
|
||||||
|
else sections.push(general)
|
||||||
|
}
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadConfiguratorSections() {
|
||||||
|
const resourceRoot = resolve(__dirname, '../..')
|
||||||
|
const config = serializeLuaValue(
|
||||||
|
new LuaConfigParser(
|
||||||
|
readFileSync(
|
||||||
|
resolve(resourceRoot, 'sky_phone/config/config.lua'),
|
||||||
|
'utf8',
|
||||||
|
),
|
||||||
|
).parse(),
|
||||||
|
)
|
||||||
|
const mediaRoot = serializeLuaValue(
|
||||||
|
new LuaConfigParser(
|
||||||
|
readFileSync(resolve(resourceRoot, 'sky_phone/config/media.lua'), 'utf8'),
|
||||||
|
).parse(),
|
||||||
|
)
|
||||||
|
const media = mediaRoot.Media
|
||||||
|
delete config.PhoneConfigurator
|
||||||
|
delete config.Media
|
||||||
|
return [...buildSections('config', config), ...buildSections('media', media)]
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { loadConfiguratorSections }
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { createRequire } from 'node:module'
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
type ConfiguratorField = {
|
||||||
|
label: string
|
||||||
|
path: string
|
||||||
|
structure?: ConfiguratorStructure
|
||||||
|
type: string
|
||||||
|
value: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfiguratorStructure = {
|
||||||
|
entries?: Array<{ structure: ConfiguratorStructure }>
|
||||||
|
fields?: Record<string, ConfiguratorStructure>
|
||||||
|
items?: ConfiguratorStructure[]
|
||||||
|
keyType?: 'number' | 'string'
|
||||||
|
kind: string
|
||||||
|
mutableKeys?: boolean
|
||||||
|
template?: ConfiguratorStructure
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfiguratorSection = {
|
||||||
|
fields: ConfiguratorField[]
|
||||||
|
id: string
|
||||||
|
scope: 'config' | 'media'
|
||||||
|
}
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url)
|
||||||
|
const { loadConfiguratorSections } = require('./configurator-fixture.cjs') as {
|
||||||
|
loadConfiguratorSections: () => ConfiguratorSection[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const configSource = readFileSync(
|
||||||
|
new URL('../../sky_phone/config/config.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
|
function countStructure(structure: ConfiguratorStructure | undefined): number {
|
||||||
|
if (!structure) return 1
|
||||||
|
if (structure.fields) {
|
||||||
|
return Object.values(structure.fields).reduce(
|
||||||
|
(total, field) => total + countStructure(field),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (structure.items) {
|
||||||
|
return structure.items.reduce(
|
||||||
|
(total, item) => total + countStructure(item),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (structure.entries) {
|
||||||
|
return structure.entries.reduce(
|
||||||
|
(total, entry) => total + countStructure(entry.structure),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('admin configurator fixture', () => {
|
||||||
|
it('exposes every 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')
|
||||||
|
|
||||||
|
expect(sections).toHaveLength(45)
|
||||||
|
expect(
|
||||||
|
fields.reduce(
|
||||||
|
(total, field) => total + countStructure(field.structure),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
).toBeGreaterThan(700)
|
||||||
|
for (const root of new Set(roots)) {
|
||||||
|
expect(
|
||||||
|
sections.some(
|
||||||
|
(section) =>
|
||||||
|
section.id === `config:${root}` ||
|
||||||
|
section.fields.some(
|
||||||
|
(field) =>
|
||||||
|
field.path === root || field.path.startsWith(`${root}.`),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
`Missing Config.${root}`,
|
||||||
|
).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves numeric Lua map keys and the false keybind option', () => {
|
||||||
|
const fields = loadConfiguratorSections().flatMap(
|
||||||
|
(section) => section.fields,
|
||||||
|
)
|
||||||
|
const darkChat = fields.find((field) => field.path === 'DarkChat')
|
||||||
|
const phone = fields.find((field) => field.path === 'Phone')
|
||||||
|
const companyJobs = fields.find(
|
||||||
|
(field) => field.path === 'Companies.Definitions',
|
||||||
|
)
|
||||||
|
const garage = fields.find((field) => field.path === 'Garage')
|
||||||
|
const timers = (darkChat?.value as Record<string, unknown> | undefined)
|
||||||
|
?.AllowedDisappearTimers
|
||||||
|
|
||||||
|
expect(timers).toMatchObject({
|
||||||
|
__skyType: 'map',
|
||||||
|
entries: expect.arrayContaining([
|
||||||
|
{ key: -1, keyType: 'number', value: true },
|
||||||
|
{ key: 0, keyType: 'number', value: true },
|
||||||
|
{ key: 604800, keyType: 'number', value: true },
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
expect(darkChat?.structure?.fields?.AllowedDisappearTimers.kind).toBe('map')
|
||||||
|
expect(phone?.structure?.fields?.Keybind.kind).toBe('optionalString')
|
||||||
|
expect(companyJobs?.label).toBe('Jobs')
|
||||||
|
expect(companyJobs?.structure).toMatchObject({
|
||||||
|
fields: {
|
||||||
|
ambulance: { kind: 'table' },
|
||||||
|
police: { kind: 'table' },
|
||||||
|
},
|
||||||
|
kind: 'table',
|
||||||
|
mutableKeys: true,
|
||||||
|
template: { kind: 'table' },
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
garage?.structure?.fields?.VehicleImages.fields?.ModelNames.kind,
|
||||||
|
).toBe('map')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('publishes fixed schemas for every empty configurable collection', () => {
|
||||||
|
const fields = loadConfiguratorSections().flatMap(
|
||||||
|
(section) => section.fields,
|
||||||
|
)
|
||||||
|
const root = (path: string) =>
|
||||||
|
fields.find((field) => field.path === path)?.structure
|
||||||
|
|
||||||
|
expect(root('Music')?.fields?.Tracks).toMatchObject({
|
||||||
|
items: [],
|
||||||
|
kind: 'list',
|
||||||
|
template: {
|
||||||
|
fields: {
|
||||||
|
Artist: { kind: 'value', valueType: 'string' },
|
||||||
|
Id: { kind: 'value', valueType: 'string' },
|
||||||
|
Title: { kind: 'value', valueType: 'string' },
|
||||||
|
},
|
||||||
|
kind: 'table',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(root('FlipTok')?.fields?.MusicTracks.template).toMatchObject({
|
||||||
|
fields: { Url: { kind: 'value', valueType: 'string' } },
|
||||||
|
kind: 'table',
|
||||||
|
})
|
||||||
|
expect(root('Payphones')?.fields?.CustomLocations.template).toEqual({
|
||||||
|
kind: 'vector',
|
||||||
|
vectorType: 'vector4',
|
||||||
|
})
|
||||||
|
expect(root('CrewLink')?.fields?.ExternalPingResources).toMatchObject({
|
||||||
|
fields: {},
|
||||||
|
kind: 'table',
|
||||||
|
mutableKeys: true,
|
||||||
|
template: { kind: 'value', valueType: 'boolean' },
|
||||||
|
})
|
||||||
|
expect(root('CustomApps')?.fields?.TrustedAdapters).toMatchObject({
|
||||||
|
fields: {},
|
||||||
|
kind: 'table',
|
||||||
|
mutableKeys: true,
|
||||||
|
template: { kind: 'value', valueType: 'boolean' },
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
root('Garage')?.fields?.VehicleImages.fields?.ModelNames,
|
||||||
|
).toMatchObject({
|
||||||
|
entries: [],
|
||||||
|
keyType: 'number',
|
||||||
|
kind: 'map',
|
||||||
|
template: { kind: 'value', valueType: 'string' },
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
root('Companies.Definitions')?.fields?.police.fields?.Services,
|
||||||
|
).toMatchObject({
|
||||||
|
items: [],
|
||||||
|
kind: 'list',
|
||||||
|
template: {
|
||||||
|
fields: {
|
||||||
|
Id: { kind: 'value', valueType: 'string' },
|
||||||
|
RequestsEnabled: { kind: 'value', valueType: 'boolean' },
|
||||||
|
},
|
||||||
|
kind: 'table',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,6 +3,8 @@ const { randomUUID } = require('node:crypto')
|
|||||||
const cors = require('cors')
|
const cors = require('cors')
|
||||||
const express = require('express')
|
const express = require('express')
|
||||||
|
|
||||||
|
const { loadConfiguratorSections } = require('./configurator-fixture.cjs')
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
const port = Number(process.argv[2]) || 3001
|
const port = Number(process.argv[2]) || 3001
|
||||||
|
|
||||||
@@ -3098,9 +3100,7 @@ const deviceData = {
|
|||||||
ringtoneVolume: 80,
|
ringtoneVolume: 80,
|
||||||
streamerMode: false,
|
streamerMode: false,
|
||||||
wallpaper: 'custom',
|
wallpaper: 'custom',
|
||||||
wallpaperHistory: [
|
wallpaperHistory: [{ imageUrl: demoWallpaperUrl, wallpaper: 'custom' }],
|
||||||
{ imageUrl: demoWallpaperUrl, wallpaper: 'custom' },
|
|
||||||
],
|
|
||||||
wallpaperImageUrl: demoWallpaperUrl,
|
wallpaperImageUrl: demoWallpaperUrl,
|
||||||
},
|
},
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -4650,11 +4650,303 @@ function companyWorkContext(testScenario = '') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const adminMockApps = {
|
||||||
|
claimed: ['citymarkt', 'darkchat', 'feather', 'local-pages'],
|
||||||
|
revision: 3,
|
||||||
|
uninstalled: ['crypto', 'skyride'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminMockDevices = {
|
||||||
|
1: { account: true, number: '555-0101', security: true },
|
||||||
|
2: { account: true, number: '555-0102', security: true },
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminMockPlayerDetail(source = 1) {
|
||||||
|
const primary = source === 1
|
||||||
|
const deviceState = adminMockDevices[source] ?? adminMockDevices[1]
|
||||||
|
return {
|
||||||
|
birthdate: primary ? '1994-04-16' : '1998-11-03',
|
||||||
|
devices: [
|
||||||
|
{
|
||||||
|
account: deviceState.account
|
||||||
|
? {
|
||||||
|
email: primary ? 'demo@ifruit.com' : 'jordan@ifruit.com',
|
||||||
|
id: primary ? 1 : 2,
|
||||||
|
passwordAvailable: true,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
apps: { ...adminMockApps },
|
||||||
|
createdAt: '2026-08-15 18:42:00',
|
||||||
|
imei: primary ? '356938035643809' : '356938035643810',
|
||||||
|
name: primary ? 'Personal iFruit Phone' : 'Service iFruit Phone',
|
||||||
|
number: deviceState.number,
|
||||||
|
security: {
|
||||||
|
enabled: deviceState.security,
|
||||||
|
failedAttempts: 0,
|
||||||
|
length: deviceState.security ? 6 : null,
|
||||||
|
lockedUntil: 0,
|
||||||
|
},
|
||||||
|
simRegistered: true,
|
||||||
|
simType: 'standard',
|
||||||
|
updatedAt: '2026-08-20 19:04:00',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
firstName: primary ? 'Alex' : 'Jordan',
|
||||||
|
identifier: primary ? 'char1:demo' : 'char1:jordan',
|
||||||
|
job: {
|
||||||
|
grade: primary ? 4 : 1,
|
||||||
|
gradeLabel: primary ? 'Chief' : 'Officer',
|
||||||
|
label: 'Los Santos Police Department',
|
||||||
|
name: 'police',
|
||||||
|
onDuty: true,
|
||||||
|
},
|
||||||
|
lastName: primary ? 'Morgan' : 'Blake',
|
||||||
|
money: {
|
||||||
|
bank: primary ? 182450 : 28450,
|
||||||
|
cash: primary ? 2740 : 950,
|
||||||
|
currency: '$',
|
||||||
|
},
|
||||||
|
name: primary ? 'Alex Morgan' : 'Jordan Blake',
|
||||||
|
serverName: primary ? 'Skyline' : 'JordanB',
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminMockBootstrap() {
|
||||||
|
return {
|
||||||
|
audit: [
|
||||||
|
{
|
||||||
|
action: 'grant_app',
|
||||||
|
actorName: 'Skyline',
|
||||||
|
createdAt: '2026-08-20 19:04:00',
|
||||||
|
details: { appId: 'darkchat' },
|
||||||
|
deviceImei: '356938035643810',
|
||||||
|
id: 1,
|
||||||
|
targetIdentifier: 'char1:jordan',
|
||||||
|
targetSource: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
players: [1, 2].map((source) => {
|
||||||
|
const player = adminMockPlayerDetail(source)
|
||||||
|
return {
|
||||||
|
deviceCount: player.devices.length,
|
||||||
|
grade: player.job.grade,
|
||||||
|
identifier: player.identifier,
|
||||||
|
job: player.job.name,
|
||||||
|
name: player.name,
|
||||||
|
onDuty: player.job.onDuty,
|
||||||
|
phoneNumber: player.devices[0]?.number ?? null,
|
||||||
|
serverName: player.serverName,
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
stats: { accounts: 24, devices: 31, online: 2 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminMockConfiguratorBase = {
|
||||||
|
enabled: true,
|
||||||
|
revision: 4,
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: 'config:Bridge',
|
||||||
|
label: 'Bridge',
|
||||||
|
scope: 'config',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Framework',
|
||||||
|
path: 'Bridge.Framework',
|
||||||
|
scope: 'config',
|
||||||
|
sensitive: false,
|
||||||
|
type: 'string',
|
||||||
|
value: 'auto',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Inventory',
|
||||||
|
path: 'Bridge.Inventory',
|
||||||
|
scope: 'config',
|
||||||
|
sensitive: false,
|
||||||
|
type: 'string',
|
||||||
|
value: 'auto',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Locale',
|
||||||
|
path: 'Bridge.Locale',
|
||||||
|
scope: 'config',
|
||||||
|
sensitive: false,
|
||||||
|
type: 'string',
|
||||||
|
value: 'de',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Callback Timeout',
|
||||||
|
path: 'Bridge.CallbackTimeout',
|
||||||
|
scope: 'config',
|
||||||
|
sensitive: false,
|
||||||
|
type: 'number',
|
||||||
|
value: 15000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Debug',
|
||||||
|
path: 'Bridge.Debug',
|
||||||
|
scope: 'config',
|
||||||
|
sensitive: false,
|
||||||
|
type: 'boolean',
|
||||||
|
value: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'config:Phone',
|
||||||
|
label: 'Phone',
|
||||||
|
scope: 'config',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Item',
|
||||||
|
path: 'Phone.Item',
|
||||||
|
scope: 'config',
|
||||||
|
sensitive: false,
|
||||||
|
type: 'string',
|
||||||
|
value: 'phone',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Unique',
|
||||||
|
path: 'Phone.Unique',
|
||||||
|
scope: 'config',
|
||||||
|
sensitive: false,
|
||||||
|
type: 'boolean',
|
||||||
|
value: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Keybind',
|
||||||
|
path: 'Phone.Keybind',
|
||||||
|
scope: 'config',
|
||||||
|
sensitive: false,
|
||||||
|
type: 'string',
|
||||||
|
value: 'F1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Device Name',
|
||||||
|
path: 'Phone.DeviceName',
|
||||||
|
scope: 'config',
|
||||||
|
sensitive: false,
|
||||||
|
type: 'string',
|
||||||
|
value: 'iFruit Phone',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'config:Companies',
|
||||||
|
label: 'Companies',
|
||||||
|
scope: 'config',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Enabled',
|
||||||
|
path: 'Companies.Enabled',
|
||||||
|
scope: 'config',
|
||||||
|
sensitive: false,
|
||||||
|
type: 'boolean',
|
||||||
|
value: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Categories',
|
||||||
|
path: 'Companies.Categories',
|
||||||
|
scope: 'config',
|
||||||
|
sensitive: false,
|
||||||
|
type: 'json',
|
||||||
|
value: ['public_services', 'vehicles', 'transport'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'media:FiveManage',
|
||||||
|
label: 'Five Manage',
|
||||||
|
scope: 'media',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
configured: true,
|
||||||
|
label: 'Api Key',
|
||||||
|
path: 'FiveManage.ApiKey',
|
||||||
|
scope: 'media',
|
||||||
|
sensitive: true,
|
||||||
|
type: 'string',
|
||||||
|
value: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Base Url',
|
||||||
|
path: 'FiveManage.BaseUrl',
|
||||||
|
scope: 'media',
|
||||||
|
sensitive: false,
|
||||||
|
type: 'string',
|
||||||
|
value: 'https://api.fivemanage.com/api/v3/file',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Upload Timeout Ms',
|
||||||
|
path: 'FiveManage.UploadTimeoutMs',
|
||||||
|
scope: 'media',
|
||||||
|
sensitive: false,
|
||||||
|
type: 'number',
|
||||||
|
value: 25000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'media:Import',
|
||||||
|
label: 'Import',
|
||||||
|
scope: 'media',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Enabled',
|
||||||
|
path: 'Import.Enabled',
|
||||||
|
scope: 'media',
|
||||||
|
sensitive: false,
|
||||||
|
type: 'boolean',
|
||||||
|
value: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Websites',
|
||||||
|
path: 'Import.Websites',
|
||||||
|
scope: 'media',
|
||||||
|
sensitive: false,
|
||||||
|
type: 'json',
|
||||||
|
value: [
|
||||||
|
{
|
||||||
|
Adapter: 'fivemanage',
|
||||||
|
Enabled: true,
|
||||||
|
Id: 'fivemanage',
|
||||||
|
Label: 'FiveManage',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
updatedAt: '2026-08-20 20:15:00',
|
||||||
|
updatedBy: 'Alex Morgan',
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminMockConfigurator = {
|
||||||
|
...adminMockConfiguratorBase,
|
||||||
|
sections: loadConfiguratorSections(),
|
||||||
|
}
|
||||||
|
|
||||||
app.post('/api/:endpoint', async (request, response, next) => {
|
app.post('/api/:endpoint', async (request, response, next) => {
|
||||||
const endpoint = request.params.endpoint
|
const endpoint = request.params.endpoint
|
||||||
const loggedBody = { ...request.body }
|
const loggedBody = { ...request.body }
|
||||||
if (typeof loggedBody.password === 'string')
|
if (typeof loggedBody.password === 'string')
|
||||||
loggedBody.password = '<redacted>'
|
loggedBody.password = '<redacted>'
|
||||||
|
if (
|
||||||
|
endpoint === 'admin:save-configurator' &&
|
||||||
|
Array.isArray(loggedBody.changes)
|
||||||
|
) {
|
||||||
|
loggedBody.changes = loggedBody.changes.map((change) => ({
|
||||||
|
...change,
|
||||||
|
value: /api.?key|pepper|secret|token|password/i.test(
|
||||||
|
String(change.path ?? ''),
|
||||||
|
)
|
||||||
|
? '<redacted>'
|
||||||
|
: change.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
if (endpoint === 'memos:devCapture') {
|
if (endpoint === 'memos:devCapture') {
|
||||||
loggedBody.audioDataUrl = `<${String(request.body.audioDataUrl ?? '').length} characters>`
|
loggedBody.audioDataUrl = `<${String(request.body.audioDataUrl ?? '').length} characters>`
|
||||||
}
|
}
|
||||||
@@ -4663,6 +4955,153 @@ app.post('/api/:endpoint', async (request, response, next) => {
|
|||||||
response.json({ success: true, data: musicBootstrap() })
|
response.json({ success: true, data: musicBootstrap() })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (endpoint === 'admin:bootstrap') {
|
||||||
|
response.json({ success: true, data: adminMockBootstrap() })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (endpoint === 'admin:configurator') {
|
||||||
|
response.json({ success: true, data: adminMockConfigurator })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (endpoint === 'admin:save-configurator') {
|
||||||
|
const changes = Array.isArray(request.body.changes)
|
||||||
|
? request.body.changes
|
||||||
|
: []
|
||||||
|
for (const change of changes) {
|
||||||
|
for (const section of adminMockConfigurator.sections) {
|
||||||
|
const field = section.fields.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.scope === change.scope && candidate.path === change.path,
|
||||||
|
)
|
||||||
|
if (!field) continue
|
||||||
|
if (field.sensitive)
|
||||||
|
field.configured = String(change.value ?? '') !== ''
|
||||||
|
else field.value = change.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
adminMockConfigurator.revision += 1
|
||||||
|
adminMockConfigurator.updatedAt = new Date().toISOString()
|
||||||
|
response.json({ success: true, data: adminMockConfigurator })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (endpoint === 'admin:player') {
|
||||||
|
response.json({
|
||||||
|
success: true,
|
||||||
|
data: adminMockPlayerDetail(Number(request.body.source) || 1),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (endpoint === 'admin:save-apps') {
|
||||||
|
const changes = Array.isArray(request.body.changes)
|
||||||
|
? request.body.changes
|
||||||
|
: []
|
||||||
|
for (const change of changes) {
|
||||||
|
const appId = String(change.appId ?? '')
|
||||||
|
const installed = change.installed === true
|
||||||
|
adminMockApps.claimed = adminMockApps.claimed.filter((id) => id !== appId)
|
||||||
|
adminMockApps.uninstalled = adminMockApps.uninstalled.filter(
|
||||||
|
(id) => id !== appId,
|
||||||
|
)
|
||||||
|
if (installed) adminMockApps.claimed.push(appId)
|
||||||
|
else adminMockApps.uninstalled.push(appId)
|
||||||
|
}
|
||||||
|
adminMockApps.revision += 1
|
||||||
|
response.json({
|
||||||
|
success: true,
|
||||||
|
data: adminMockPlayerDetail(Number(request.body.source) || 1),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (endpoint === 'admin:close') {
|
||||||
|
response.json({ success: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (endpoint === 'admin:reveal-password') {
|
||||||
|
response.json({
|
||||||
|
success: true,
|
||||||
|
data: { email: 'demo@ifruit.com', password: 'mock-only-password' },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (endpoint === 'admin:activity') {
|
||||||
|
if (request.body.kind === 'messages') {
|
||||||
|
response.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
kind: 'messages',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
body: 'Meet at Mission Row in ten minutes.',
|
||||||
|
createdAt: '2026-08-20 19:03:00',
|
||||||
|
direction: 'outgoing',
|
||||||
|
id: 'admin-message-1',
|
||||||
|
messageType: 'text',
|
||||||
|
otherNumber: '555-0144',
|
||||||
|
readAt: '2026-08-20 19:03:30',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: 'Copy, I am on my way.',
|
||||||
|
createdAt: '2026-08-20 18:58:00',
|
||||||
|
direction: 'incoming',
|
||||||
|
id: 'admin-message-2',
|
||||||
|
messageType: 'text',
|
||||||
|
otherNumber: '555-0199',
|
||||||
|
readAt: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
kind: 'calls',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
answeredAt: '2026-08-20 18:49:05',
|
||||||
|
direction: 'incoming',
|
||||||
|
durationSeconds: 184,
|
||||||
|
endedAt: '2026-08-20 18:52:09',
|
||||||
|
id: 'admin-call-1',
|
||||||
|
otherNumber: '555-0177',
|
||||||
|
startedAt: '2026-08-20 18:49:00',
|
||||||
|
status: 'completed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
answeredAt: null,
|
||||||
|
direction: 'outgoing',
|
||||||
|
durationSeconds: 0,
|
||||||
|
endedAt: '2026-08-20 17:13:18',
|
||||||
|
id: 'admin-call-2',
|
||||||
|
otherNumber: '555-0112',
|
||||||
|
startedAt: '2026-08-20 17:13:00',
|
||||||
|
status: 'missed',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (endpoint === 'admin:reset-passcode') {
|
||||||
|
const source = Number(request.body.source) || 1
|
||||||
|
adminMockDevices[source].security = false
|
||||||
|
response.json({ success: true, data: adminMockPlayerDetail(source) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (endpoint === 'admin:change-number') {
|
||||||
|
const source = Number(request.body.source) || 1
|
||||||
|
adminMockDevices[source].number = String(request.body.phoneNumber ?? '')
|
||||||
|
response.json({ success: true, data: adminMockPlayerDetail(source) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (endpoint === 'admin:factory-reset') {
|
||||||
|
const source = Number(request.body.source) || 1
|
||||||
|
adminMockDevices[source].account = false
|
||||||
|
adminMockDevices[source].security = false
|
||||||
|
response.json({ success: true, data: adminMockPlayerDetail(source) })
|
||||||
|
return
|
||||||
|
}
|
||||||
if (endpoint === 'music:add-youtube') {
|
if (endpoint === 'music:add-youtube') {
|
||||||
const value = String(request.body.url ?? '')
|
const value = String(request.body.url ?? '')
|
||||||
const customTitle = String(request.body.title ?? '').trim()
|
const customTitle = String(request.body.title ?? '').trim()
|
||||||
|
|||||||
@@ -10,6 +10,12 @@
|
|||||||
Keep option names unchanged. Restart sky_phone after editing this file.
|
Keep option names unchanged. Restart sky_phone after editing this file.
|
||||||
]]
|
]]
|
||||||
|
|
||||||
|
-- When enabled, config.lua and media.lua only provide first-run defaults.
|
||||||
|
-- The active configuration is loaded from SQL and managed through /phonepanel.
|
||||||
|
Config.PhoneConfigurator = {
|
||||||
|
Enabled = true,
|
||||||
|
}
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- Core, framework and device
|
-- Core, framework and device
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
@@ -29,6 +35,10 @@ Config.Phone = {
|
|||||||
Unique = true, -- true: data follows each phone item; false: one persistent phone per character; hex/esx require false
|
Unique = true, -- true: data follows each phone item; false: one persistent phone per character; hex/esx require false
|
||||||
Keybind = "F1", -- false disables the configurable phone key mapping
|
Keybind = "F1", -- false disables the configurable phone key mapping
|
||||||
AllowMovement = true, -- true: game input stays active while the mobile phone is open
|
AllowMovement = true, -- true: game input stays active while the mobile phone is open
|
||||||
|
HoldToLook = {
|
||||||
|
Enabled = true, -- hold the configured control to hide the cursor and look around; independent of AllowMovement
|
||||||
|
Control = 19, -- INPUT_CHARACTER_WHEEL (Left Alt by default)
|
||||||
|
},
|
||||||
DevelopmentCommand = true,
|
DevelopmentCommand = true,
|
||||||
DeviceName = "iFruit Phone",
|
DeviceName = "iFruit Phone",
|
||||||
}
|
}
|
||||||
@@ -44,7 +54,7 @@ Config.CustomApps = {
|
|||||||
Enabled = true,
|
Enabled = true,
|
||||||
BundledApps = true,
|
BundledApps = true,
|
||||||
ExternalApps = true,
|
ExternalApps = true,
|
||||||
Debug = true, -- detailed client traces for exports, registration, catalog sync and lifecycle events
|
Debug = false, -- detailed client traces for exports, registration, catalog sync and lifecycle events
|
||||||
ReadyTimeoutMs = 8000,
|
ReadyTimeoutMs = 8000,
|
||||||
MaximumMessageBytes = 65536,
|
MaximumMessageBytes = 65536,
|
||||||
MaximumStorageBytesPerApp = 262144,
|
MaximumStorageBytesPerApp = 262144,
|
||||||
@@ -61,13 +71,25 @@ Config.Security = {
|
|||||||
AttemptsPerMinute = 12,
|
AttemptsPerMinute = 12,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Config.AdminPanel = {
|
||||||
|
Enabled = true,
|
||||||
|
Command = "phonepanel",
|
||||||
|
AdminGroups = { "admin", "superadmin" },
|
||||||
|
MaximumPlayers = 128,
|
||||||
|
ReadRequestsPerMinute = 60,
|
||||||
|
ActionRequestsPerMinute = 30,
|
||||||
|
CredentialRevealsPerMinute = 6,
|
||||||
|
AuditLimit = 40,
|
||||||
|
ActivityLimit = 40,
|
||||||
|
}
|
||||||
|
|
||||||
Config.Sim = {
|
Config.Sim = {
|
||||||
Enabled = true, -- false: devices receive a persistent random number automatically; hex/esx require false
|
Enabled = true, -- false: devices receive a persistent random number automatically; hex/esx require false
|
||||||
RegisteredItem = "sky_phone_sim_registered",
|
RegisteredItem = "sky_phone_sim_registered",
|
||||||
AnonymousItem = "sky_phone_sim_anonymous",
|
AnonymousItem = "sky_phone_sim_anonymous",
|
||||||
NumberLength = 10,
|
NumberLength = 10, -- total number of digits, including NumberPrefix
|
||||||
NumberPrefix = "",
|
NumberPrefix = "", -- digits only; use "555", not "555-"
|
||||||
NumberGroups = { 3, 3, 4 },
|
NumberGroups = { 3, 3, 4 }, -- display groups separated by spaces
|
||||||
}
|
}
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
Locales["de"] = {
|
Locales["de"] = {
|
||||||
CommandDescription = "Öffne dein Handy.",
|
CommandDescription = "Öffne dein Handy.",
|
||||||
|
AdminCommand = {
|
||||||
|
CommandDescription = "Öffne Handy-Administration und -Konfiguration.",
|
||||||
|
Errors = {
|
||||||
|
disabled = "Das Handy-Admin-Panel ist deaktiviert.",
|
||||||
|
not_authorized = "Du hast keinen Zugriff auf das Handy-Admin-Panel.",
|
||||||
|
default = "Das Handy-Admin-Panel konnte nicht geöffnet werden.",
|
||||||
|
},
|
||||||
|
},
|
||||||
Controls = {
|
Controls = {
|
||||||
OpenPhone = "Handy öffnen",
|
OpenPhone = "Handy öffnen",
|
||||||
},
|
},
|
||||||
@@ -193,6 +201,28 @@ Locales["de"] = {
|
|||||||
contacts = { name = "Favoriten", description = "Rufe deine Lieblingskontakte an oder schreibe ihnen.", choose = "Lieblingskontakte" },
|
contacts = { name = "Favoriten", description = "Rufe deine Lieblingskontakte an oder schreibe ihnen.", choose = "Lieblingskontakte" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
AdminPanel = {
|
||||||
|
name = "Phone Admin", subtitle = "Administration", navigation = "Admin-Navigation", refresh = "Admin-Daten aktualisieren", loading = "Geschützte Daten werden geladen...",
|
||||||
|
tabs = { overview = "Übersicht", players = "Spieler", devices = "Geräte", apps = "Apps", accounts = "Accounts", messages = "Nachrichten", calls = "Anrufe", moderation = "Moderation", audit = "Audit", configurator = "Phone Configurator" },
|
||||||
|
overview = { eyebrow = "Server", title = "Dashboard", body = "Spieler, Geräte, Apps und Handydaten.", stats = "Server-Handystatistik", online = "Online", devices = "Geräte", accounts = "Accounts", audit = "Audit-Einträge", control = "Navigation", features = "Module", featuresBody = "Öffne ein Verwaltungsmodul.", recent = "Letzte Aktivitäten", playerFeature = "Identität, Finanzen, Job und Dienst", deviceFeature = "IMEI, SIM, Nummer und Aktivität", appFeature = "Handy-Apps installieren oder entfernen", accountFeature = "Accountzugriff und geschützte Zugangsdaten", messageFeature = "Letzte SMS-Aktivitäten prüfen", callFeature = "Letzte Anrufaktivitäten prüfen", moderationFeature = "Zugriff, Nummer oder Gerätedaten zurücksetzen", auditFeature = "Sensible Admin-Aktionen prüfen", configuratorFeature = "config.lua und media.lua über SQL verwalten" },
|
||||||
|
appearance = { eyebrow = "Darstellung", title = "Akzentfarbe", body = "Ändere den Akzent im gesamten Admin-Arbeitsbereich.", colors = { emerald = "Smaragd", blue = "Blau", violet = "Violett", orange = "Orange", red = "Rot" } },
|
||||||
|
configurator = { context = "Runtime-Konfiguration", eyebrow = "Systemwerkzeug", sections = "Konfiguration", search = "Einstellungen oder Pfade suchen", configScope = "config.lua", mediaScope = "media.lua", noResults = "Keine passenden Einstellungen", loading = "SQL-Konfiguration wird geladen...", title = "Phone Configurator", body = "Verwalte Handy- und Media-Einstellungen im geschützten Admin-Bereich.", disabledTitle = "SQL-Konfiguration ist nicht aktiv", disabledBody = "Aktiviere den Configurator am Anfang der config.lua und starte sky_phone neu. Bis dahin bleiben die Dateiwerte aktiv und die Bearbeitung gesperrt.", manualSave = "Manuelles Speichern", refreshNotice = "Nichts wird automatisch gespeichert. Der grüne Haken prüft config.lua und media.lua in SQL und aktualisiert die aktive Server-, Client-, Media- und UI-Konfiguration sofort intern.", fieldCount = "{count} Felder", secretConfigured = "Secret gesetzt · Ersatzwert eingeben", invalidValue = "Prüfe den markierten Tabellen- oder Zahlenwert.", saved = "SQL-Konfiguration gespeichert und übernommen.", descriptions = { featureToggle = "Schaltet {name} ein oder aus.", boolean = "Legt fest, ob {name} erlaubt ist.", number = "Legt den Zahlenwert für {name} fest.", text = "Legt den Textwert für {name} fest.", optionalText = "Legt den optionalen Wert für {name} fest; der Schalter deaktiviert ihn.", list = "Verwaltet alle Einträge für {name}.", table = "Bündelt die zusammengehörigen Einstellungen für {name}.", credential = "Speichert den geschützten Zugangsschlüssel für {name}.", url = "Legt die URL oder den Endpunkt für {name} fest.", hosts = "Legt die erlaubten Domains für {name} fest.", milliseconds = "Legt die Zeit für {name} in Millisekunden fest.", seconds = "Legt die Zeit für {name} in Sekunden fest.", rateLimit = "Begrenzt die Aktionen für {name} pro Minute.", byteLimit = "Legt die maximal erlaubte Datengröße für {name} fest.", textLimit = "Legt die maximal erlaubte Textlänge für {name} fest.", distance = "Legt die Distanz in der Spielwelt für {name} fest.", coordinates = "Legt Weltkoordinaten oder Ausrichtung für {name} fest.", gameAsset = "Legt das GTA-Modell oder Prop für {name} fest.", animation = "Legt die Animationsdatei für {name} fest.", access = "Legt Jobs, Gruppen oder Berechtigungsstufen für {name} fest.", integration = "Wählt Framework oder Anbieter für {name} aus.", path = "Legt den Speicher- oder Ressourcenpfad für {name} fest.", color = "Legt die Oberflächenfarbe für {name} fest.", displayText = "Legt den Spielern angezeigten Text für {name} fest.", phoneNumber = "Legt die Telefon- oder Servicenummer für {name} fest.", routing = "Steuert die Verteilung eingehender Anfragen für {name}.", command = "Legt den Chat-Befehl zum Öffnen oder Ausführen von {name} fest.", locale = "Wählt die Sprache für {name} aus.", debug = "Steuert ausführliche Diagnoseausgaben für {name}.", mediaQuality = "Legt Medienqualität oder Lautstärke für {name} fest.", amount = "Legt die maximale oder angezeigte Menge für {name} fest." }, table = { list = "Liste", table = "Schlüsseltabelle", vector = "Vektor", entry = "Eintrag", general = "Allgemein", addRow = "Zeile hinzufügen", addField = "Feld hinzufügen", remove = "Entfernen", emptyList = "Noch keine Zeilen. Füge die erste Zeile mit Plus hinzu.", emptyTable = "Noch keine Felder. Füge unten den ersten Schlüssel hinzu.", keyPlaceholder = "Neuer Schlüssel", convertToList = "Als Liste nutzen", convertToMap = "Als typisierte Schlüsseltabelle nutzen", convertToTable = "Als Schlüsseltabelle nutzen", types = { string = "Text", number = "Zahl", boolean = "Schalter", list = "Liste", table = "Tabelle" } } },
|
||||||
|
players = { eyebrow = "Aktive Sitzungen", title = "Online-Spieler", online = "Jetzt online", empty = "Keine Spieler gefunden", emptyBody = "Passe die Suche an oder aktualisiere die Spielerliste." },
|
||||||
|
search = { players = "Name, ID, Job oder Nummer suchen", apps = "Apps suchen", clear = "Suche leeren" },
|
||||||
|
detail = { character = "Charakterprofil", data = "Spielerdatenübersicht", cash = "Bargeld", bank = "Bank", job = "Job", duty = "Dienst", onDuty = "Im Dienst", offDuty = "Außer Dienst", identity = "Identität", playerData = "Spielerdaten", identifier = "Charakter-Identifier", birthdate = "Geburtsdatum", grade = "Job-Rang", unknown = "Unbekannt" },
|
||||||
|
devices = { eyebrow = "Gerätesteuerung", title = "Handys", body = "Prüfe alle Handys des ausgewählten Spielers.", choose = "Handy auswählen", empty = "Kein Handy gefunden", emptyBody = "Dieser Spieler besitzt aktuell kein verwaltbares Handy.", noNumber = "Keine Telefonnummer", noSim = "Keine SIM", imei = "IMEI", updated = "Letzte Aktivität", apps = "Zugewiesene Apps", account = "Verknüpfter Account" },
|
||||||
|
credentials = { eyebrow = "Geschützte Daten", title = "Zugangsdaten", email = "iFruit-E-Mail", password = "iFruit-Passwort", reveal = "Anzeigen", copy = "Passwort kopieren", copied = "Passwort kopiert.", noAccount = "Mit diesem Handy ist kein iFruit-Account verknüpft.", passcode = "Gerätecode", passcodeHashed = "{length}-stellige PIN · sicher gehasht und nicht wiederherstellbar", passcodeDisabled = "Kein Gerätecode eingerichtet", revealTitle = "Geschütztes Passwort anzeigen?", revealBody = "Diese Aktion wird serverseitig autorisiert, rate-limitiert und im Admin-Audit protokolliert.", cancel = "Abbrechen", confirmReveal = "Passwort anzeigen" },
|
||||||
|
apps = { eyebrow = "Fernverwaltung", title = "App-Zugriff", description = "Lege App-Zugriffe für dieses Gerät als Entwurf fest. Erst Speichern übernimmt die Änderungen.", installed = "Installiert", available = "Verfügbar", protected = "System-App", changes = "{count} offene Änderungen" },
|
||||||
|
activity = { protected = "Geschützte Aktivität", messagesTitle = "Nachrichten", messagesBody = "Letzte SMS-Aktivitäten der ausgewählten SIM.", callsTitle = "Anrufe", callsBody = "Letzte Anrufaktivitäten der ausgewählten SIM.", loading = "Aktivitäten werden geladen...", incoming = "Eingehend", outgoing = "Ausgehend", mediaMessage = "{type}-Nachricht", noMessages = "Keine Nachrichtenaktivität gefunden.", noCalls = "Keine Anrufaktivität gefunden.", status = { completed = "Abgeschlossen", missed = "Verpasst", rejected = "Abgelehnt", busy = "Besetzt", unanswered = "Unbeantwortet", cancelled = "Abgebrochen", failed = "Fehlgeschlagen", ringing = "Klingelt" } },
|
||||||
|
moderation = {
|
||||||
|
eyebrow = "Geräteverwaltung", title = "Moderationsaktionen", body = "Jede Aktion wird serverseitig autorisiert, rate-limitiert und protokolliert.", resetPasscode = "Gerätecode zurücksetzen", resetPasscodeBody = "Entfernt die Geräte-PIN und fehlgeschlagene Versuche.", changeNumber = "Nummer ändern", changeNumberBody = "Weist der aktuellen SIM eine neue eindeutige Nummer zu.", factoryReset = "Werksreset", factoryResetBody = "Löscht lokale Gerätedaten und trennt den Account.", saveFirst = "Speichere oder verwirf zuerst offene App-Änderungen.", phoneNumber = "Neue Telefonnummer", phoneNumberPlaceholder = "Vollständige konfigurierte Nummer eingeben", typeToConfirm = "Tippe {word}, um den Werksreset zu bestätigen.", confirmWord = "RESET", cancel = "Abbrechen", ["reset-passcodeSuccess"] = "Gerätecode zurückgesetzt.", ["change-numberSuccess"] = "Telefonnummer geändert.", ["factory-resetSuccess"] = "Werksreset abgeschlossen.",
|
||||||
|
dialogs = { ["reset-passcodeTitle"] = "Gerätecode zurücksetzen?", ["reset-passcodeBody"] = "Der Spieler kann dieses Handy danach ohne die bisherige PIN entsperren.", ["change-numberTitle"] = "Telefonnummer ändern?", ["change-numberBody"] = "Die neue Nummer muss dem Serverformat entsprechen und eindeutig sein.", ["factory-resetTitle"] = "Werksreset für dieses Handy?", ["factory-resetBody"] = "Lokale Gerätedaten, App-Einstellungen, Sicherheit und der verknüpfte Account werden gelöscht. Das kann nicht rückgängig gemacht werden." },
|
||||||
|
confirm = { ["reset-passcode"] = "Gerätecode zurücksetzen", ["change-number"] = "Nummer ändern", ["factory-reset"] = "Werksreset" },
|
||||||
|
},
|
||||||
|
audit = { eyebrow = "Nachvollziehbarkeit", title = "Audit-Verlauf", body = "Sensible Anzeigen und App-Änderungen werden hier protokolliert.", empty = "Noch keine Admin-Aktionen", emptyBody = "Geschützte Aktionen erscheinen hier, nachdem sie ausgeführt wurden.", by = "{actor} · Ziel-ID {target}", actions = { grant_app = "App installiert", revoke_app = "App entfernt", reveal_account_password = "Passwort angezeigt", view_messages = "Nachrichten angesehen", view_calls = "Anrufe angesehen", reset_passcode = "Gerätecode zurückgesetzt", change_number = "Telefonnummer geändert", factory_reset = "Werksreset ausgeführt", save_configuration = "Konfiguration gespeichert" } },
|
||||||
|
editor = { brand = "SKY PHONE", workspace = "ADMIN", players = "Spielerverzeichnis", audit = "Audit-Protokoll", selectPlayer = "Wähle einen Spieler, um Identität, Geräte, Zugangsdaten und App-Zugriffe zu prüfen.", save = "Änderungen speichern", saveHint = "Offene Änderungen übernehmen", saved = "Änderungen gespeichert.", unsaved = "Ungespeicherte Änderungen", close = "Admin-Panel schließen", refresh = "Live-Daten aktualisieren", online = "LIVE", profile = "PROFIL", financial = "FINANZEN", device = "GERÄT", security = "SICHERHEIT", noAutoSave = "Manuelles Speichern", noAutoSaveBody = "Änderungen bleiben lokal, bis der grüne Haken gedrückt wird.", discardTitle = "Ungespeicherte Änderungen verwerfen?", discardBody = "Deine vorgemerkten App- oder Konfigurationsänderungen wurden noch nicht gespeichert.", keepEditing = "Weiter bearbeiten", discard = "Änderungen verwerfen", saveFailed = "Einige Änderungen konnten nicht gespeichert werden.", noSelection = "Kein Spieler ausgewählt" },
|
||||||
|
errors = { not_authorized = "Du hast keinen Zugriff auf das Admin-Panel.", rate_limited = "Zu viele Admin-Anfragen. Bitte warte.", player_unavailable = "Dieser Spieler ist nicht mehr online.", device_not_owned = "Dieses Handy gehört nicht mehr zum ausgewählten Spieler.", invalid_app = "Diese App ist nicht auf dem Server registriert.", app_protected = "Diese System-App kann nicht entfernt werden.", revision_conflict = "Die Daten wurden zwischenzeitlich geändert. Öffne den Bereich neu und versuche es erneut.", configurator_disabled = "Aktiviere zuerst den Phone Configurator in der config.lua.", invalid_field = "Dieses Konfigurationsfeld ist nicht mehr verfügbar.", invalid_value = "Ein Konfigurationswert ist ungültig.", account_not_found = "Mit diesem Handy ist kein iFruit-Account verknüpft.", invalid_phone_number = "Gib eine Telefonnummer im konfigurierten Serverformat ein.", phone_number_unchanged = "Diese SIM verwendet diese Telefonnummer bereits.", phone_number_taken = "Diese Telefonnummer ist bereits vergeben.", no_sim = "Dieses Handy besitzt keine änderbare SIM.", passcode_not_set = "Für dieses Handy ist kein Gerätecode eingerichtet.", device_not_found = "Dieses Handy existiert nicht mehr.", metadata_unsupported = "Die Inventar-Metadaten des Handys konnten nicht aktualisiert werden.", invalid_request = "Die Admin-Anfrage war ungültig.", request_failed = "Die Admin-Anfrage ist fehlgeschlagen.", default = "Das Admin-Panel ist vorübergehend nicht verfügbar." },
|
||||||
|
},
|
||||||
Apps = {
|
Apps = {
|
||||||
health = {
|
health = {
|
||||||
name = "Gesundheit",
|
name = "Gesundheit",
|
||||||
@@ -606,7 +636,7 @@ Locales["de"] = {
|
|||||||
createIdentity = "Dunkle Identität erstellen", createIdentityBody = "Erstell eine private Identität, bevor du ein DarkChat-Gespräch anfängst.",
|
createIdentity = "Dunkle Identität erstellen", createIdentityBody = "Erstell eine private Identität, bevor du ein DarkChat-Gespräch anfängst.",
|
||||||
security = "Sicherheit", privateNetwork = "Privates Einladungs-Nur-Netzwerk", noResults = "Keine Ergebnisse", noResultsBody = "Versucht es mit einem anderen Alias oder Dark-ID.",
|
security = "Sicherheit", privateNetwork = "Privates Einladungs-Nur-Netzwerk", noResults = "Keine Ergebnisse", noResultsBody = "Versucht es mit einem anderen Alias oder Dark-ID.",
|
||||||
noChats = "Keine privaten Chats", noChatsBody = "Verbinde dich über eine Dark-ID oder einen Einladungscode. Es gibt keine öffentliche Suche.", newChat = "Neuer Chat", to = "An:",
|
noChats = "Keine privaten Chats", noChatsBody = "Verbinde dich über eine Dark-ID oder einen Einladungscode. Es gibt keine öffentliche Suche.", newChat = "Neuer Chat", to = "An:",
|
||||||
connectPrivately = "Privat verbinden", newChatBody = "Gib einen genauen Dark-ID oder Einladungscode ein. Unbekannte Identitäten erfordern eine Bestätigung.", darkIdOrInvite = "Dark-ID oder Einladungscode", continue = "Weiter", contacts = "DarkChat Kontakte", contactsHint = "Gespeicherte private Identitäten erscheinen nur auf deinem DarkChat-Konto.", noContacts = "Keine gespeicherten DarkChat Kontakte", private = "Privat", shareIdentity = "Tippe auf deine private Identität",
|
connectPrivately = "Privat verbinden", newChatBody = "Gib die Dark-ID oder den Einladungscode einer anderen Person ein. Deine eigene ID kannst du im Profil teilen.", darkIdOrInvite = "Dark-ID oder Einladungscode", continue = "Weiter", contacts = "DarkChat Kontakte", contactsHint = "Gespeicherte private Identitäten erscheinen nur auf deinem DarkChat-Konto.", noContacts = "Keine gespeicherten DarkChat Kontakte", private = "Privat", shareIdentity = "Tippe auf deine private Identität",
|
||||||
message = "Dunkle Botschaft", activeNow = "Anteil der Tätigkeiten", encryptedSession = "Privatsitzung", serverPrivate = "Private server-gespeicherte Konversation",
|
message = "Dunkle Botschaft", activeNow = "Anteil der Tätigkeiten", encryptedSession = "Privatsitzung", serverPrivate = "Private server-gespeicherte Konversation",
|
||||||
emoji = "Emoji", gif = "GIF", gifs = "GIF", photo = "Foto", video = "Video", attachPhoto = "Foto anhängen", takePhoto = "Foto machen", attachGif = "GIF-Anlage", attachVideo = "Video anhängen", searchGifs = "GIF durchsuchen", loadMore = "Mehr laden",
|
emoji = "Emoji", gif = "GIF", gifs = "GIF", photo = "Foto", video = "Video", attachPhoto = "Foto anhängen", takePhoto = "Foto machen", attachGif = "GIF-Anlage", attachVideo = "Video anhängen", searchGifs = "GIF durchsuchen", loadMore = "Mehr laden",
|
||||||
voiceMessage = "Sprachnachricht", sending = "Senden", failed = "Nicht geliefert", delivered = "Lieferung", read = "Lesen", replying = "Antwort auf",
|
voiceMessage = "Sprachnachricht", sending = "Senden", failed = "Nicht geliefert", delivered = "Lieferung", read = "Lesen", replying = "Antwort auf",
|
||||||
@@ -622,7 +652,7 @@ Locales["de"] = {
|
|||||||
microphoneUnavailable = "Das Mikrofon ist nicht verfügbar.", recordingTooLarge = "Die Sprachbotschaft ist zu groß.",
|
microphoneUnavailable = "Das Mikrofon ist nicht verfügbar.", recordingTooLarge = "Die Sprachbotschaft ist zu groß.",
|
||||||
errors = {
|
errors = {
|
||||||
sign_out_failed = "Sky Cloud konnte sich nicht ausmelden.",
|
sign_out_failed = "Sky Cloud konnte sich nicht ausmelden.",
|
||||||
not_authenticated = "Melde dich zuerst bei deinem Sky Cloud-Konto an.", invalid_dark_id = "Gib einen gültigen Dark-ID oder Einladungscode ein.", profile_not_found = "Diese private Identität wurde nicht gefunden.", self_chat = "Du kannst deine Identität nicht selbst sagen.", conversation_not_found = "Diese Unterhaltung ist nicht verfügbar.", blocked = "Nachrichten werden in diesem Gespräch blockiert.", invalid_message = "Gib eine gültige Nachricht ein.", invalid_gif = "Diese GIF ist ungültig.", invalid_voice = "Diese Sprachnachricht ist ungültig.", invalid_attachment = "Dieses Foto oder Video ist nicht verfügbar.", invalid_profile = "Prüfe deinen Alias und deine Privatsphäre.", rate_limited = "Zu viele Anfragen. Versuch es gleich erneut.", gif_provider_unconfigured = "GIF-Suche ist nicht konfiguriert.", gif_provider_unauthorized = "Der GIF-Anbieterschlüssel ist ungültig.", gif_provider_rate_limited = "Die GIF-Suche ist ausgelastet. Versuch es gleich erneut.", gif_provider_failed = "GIFs sind vorübergehend nicht verfügbar.", default = "DarkChat konnte die Anfrage nicht abschließen.",
|
not_authenticated = "Melde dich zuerst bei deinem Sky Cloud-Konto an.", invalid_dark_id = "Gib eine gültige Dark-ID oder einen Einladungscode ein.", profile_not_found = "Diese private Identität wurde nicht gefunden.", self_chat = "Du kannst deiner eigenen Identität keine Nachricht senden.", conversation_not_found = "Diese Unterhaltung ist nicht verfügbar.", blocked = "Nachrichten werden in diesem Gespräch blockiert.", invalid_message = "Gib eine gültige Nachricht ein.", invalid_gif = "Dieses GIF ist ungültig.", invalid_voice = "Diese Sprachnachricht ist ungültig.", invalid_attachment = "Dieses Foto oder Video ist nicht verfügbar.", invalid_profile = "Prüfe deinen Alias und deine Privatsphäre.", rate_limited = "Zu viele Anfragen. Versuch es gleich erneut.", gif_provider_unconfigured = "GIF-Suche ist nicht konfiguriert.", gif_provider_unauthorized = "Der GIF-Anbieterschlüssel ist ungültig.", gif_provider_rate_limited = "Die GIF-Suche ist ausgelastet. Versuch es gleich erneut.", gif_provider_failed = "GIFs sind vorübergehend nicht verfügbar.", default = "DarkChat konnte die Anfrage nicht abschließen.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
messages = {
|
messages = {
|
||||||
@@ -1069,7 +1099,7 @@ Locales["de"] = {
|
|||||||
profile = { verified = "Charakterbesitz verifiziert", walletKey = "Öffentlicher Crypto-Key", walletKeyBody = "Teile diesen Key, um Krypto zu empfangen. Er gewährt keinen Kontozugriff.", copyKey = "Kopieren", copied = "Kopiert", shareKey = "Teilen", shareTitle = "Mein VaultX Crypto-Key", shareText = "Sende Krypto an meinen öffentlichen VaultX-Key:", trades = "Trades", volume = "Volumen", memberSince = "Mitglied seit", preferences = "Einstellungen", priceAlerts = "Kurswarnungen", priceAlertsBody = "Bei auffälligen Marktbewegungen benachrichtigen.", confirmations = "Handelsbestätigung", confirmationsBody = "Zusätzliche Bestätigung vor der Ausführung behalten.", hideBalances = "Privatsphäre-Modus", hideBalancesBody = "Beträge in VaultX verbergen.", identity = "Profilidentität", edit = "Bearbeiten", account = "Konto & Sicherheit", editTitle = "Profil bearbeiten", editBody = "Ändere deinen Benutzernamen oder lege ein neues Passwort fest. Dein aktuelles Passwort bestätigt Identitätsänderungen.", currentPassword = "Aktuelles Passwort", currentPasswordPlaceholder = "Für Namens- oder Passwortänderungen erforderlich", newPassword = "Neues Passwort", newPasswordPlaceholder = "Leer lassen, um das aktuelle Passwort zu behalten", passwordSecurity = "Passwörter werden speicherintensiv gehasht und nie wieder angezeigt.", saved = "Profil sicher gespeichert.", save = "Profil speichern", saveChanges = "Änderungen speichern", securityTitle = "Geschütztes Profil", securityBody = "Dein Profil bleibt an diesen Framework-Charakter und die Finanzsitzung gebunden." },
|
profile = { verified = "Charakterbesitz verifiziert", walletKey = "Öffentlicher Crypto-Key", walletKeyBody = "Teile diesen Key, um Krypto zu empfangen. Er gewährt keinen Kontozugriff.", copyKey = "Kopieren", copied = "Kopiert", shareKey = "Teilen", shareTitle = "Mein VaultX Crypto-Key", shareText = "Sende Krypto an meinen öffentlichen VaultX-Key:", trades = "Trades", volume = "Volumen", memberSince = "Mitglied seit", preferences = "Einstellungen", priceAlerts = "Kurswarnungen", priceAlertsBody = "Bei auffälligen Marktbewegungen benachrichtigen.", confirmations = "Handelsbestätigung", confirmationsBody = "Zusätzliche Bestätigung vor der Ausführung behalten.", hideBalances = "Privatsphäre-Modus", hideBalancesBody = "Beträge in VaultX verbergen.", identity = "Profilidentität", edit = "Bearbeiten", account = "Konto & Sicherheit", editTitle = "Profil bearbeiten", editBody = "Ändere deinen Benutzernamen oder lege ein neues Passwort fest. Dein aktuelles Passwort bestätigt Identitätsänderungen.", currentPassword = "Aktuelles Passwort", currentPasswordPlaceholder = "Für Namens- oder Passwortänderungen erforderlich", newPassword = "Neues Passwort", newPasswordPlaceholder = "Leer lassen, um das aktuelle Passwort zu behalten", passwordSecurity = "Passwörter werden speicherintensiv gehasht und nie wieder angezeigt.", saved = "Profil sicher gespeichert.", save = "Profil speichern", saveChanges = "Änderungen speichern", securityTitle = "Geschütztes Profil", securityBody = "Dein Profil bleibt an diesen Framework-Charakter und die Finanzsitzung gebunden." },
|
||||||
activityTypes = { buy = "Asset-Kauf", sell = "Asset-Verkauf", deposit = "Bankeinzahlung", withdrawal = "Bankauszahlung", transfer_in = "Krypto empfangen", transfer_out = "Krypto gesendet" },
|
activityTypes = { buy = "Asset-Kauf", sell = "Asset-Verkauf", deposit = "Bankeinzahlung", withdrawal = "Bankauszahlung", transfer_in = "Krypto empfangen", transfer_out = "Krypto gesendet" },
|
||||||
statuses = { completed = "Abgeschlossen", pending = "Prüfung ausstehend", failed = "Fehlgeschlagen", manual_review = "Manuelle Prüfung" },
|
statuses = { completed = "Abgeschlossen", pending = "Prüfung ausstehend", failed = "Fehlgeschlagen", manual_review = "Manuelle Prüfung" },
|
||||||
errors = { invalid_profile = "Prüfe deine VaultX-Profileinstellungen.", invalid_handle = "Nutze 3–20 Buchstaben, Zahlen, Punkte oder Unterstriche.", handle_taken = "Dieser VaultX-Benutzername ist bereits vergeben.", profile_exists = "Dieser Charakter besitzt bereits ein VaultX-Profil.", invalid_password = "Das Passwort muss 8–72 Zeichen lang sein.", password_mismatch = "Die Passwörter stimmen nicht überein.", accept_terms = "Bestätige, dass dies ein fiktives Ingame-Wallet ist.", invalid_credentials = "Das Passwort ist falsch.", locked = "Zu viele Versuche. Versuche es später erneut.", not_authenticated = "Logge dich zuerst bei VaultX ein.", invalid_amount = "Gib einen positiven ganzen Betrag ein.", invalid_quantity = "Gib eine Menge mit bis zu sechs Nachkommastellen ein.", invalid_wallet_key = "Gib einen vollständigen VX-Empfangsschlüssel ein.", recipient_not_found = "Zu diesem Key wurde kein aktives VaultX-Konto gefunden.", self_transfer = "Du kannst keine Krypto an deinen eigenen Key senden.", invalid_transfer = "Prüfe Empfänger, Asset und Krypto-Menge.", recipient_limit_exceeded = "Das Empfänger-Wallet kann diese Menge nicht aufnehmen.", insufficient_funds = "Dein verfügbarer Bestand reicht nicht aus.", insufficient_liquidity = "Die Börse kann diese Menge derzeit nicht ausführen.", quote_expired = "Das Angebot ist abgelaufen. Fordere ein neues an.", quote_unavailable = "Das Angebot ist nicht mehr verfügbar.", market_unavailable = "Dieser Markt ist vorübergehend pausiert.", limit_exceeded = "Die Anfrage überschreitet ein Börsenlimit.", duplicate_request = "Diese Anfrage wurde bereits verarbeitet.", rate_limited = "Zu viele Anfragen. Versuche es gleich erneut.", settlement_pending = "Eine Geldbewegung wartet bereits auf Prüfung.", service_unavailable = "VaultX ist vorübergehend nicht verfügbar.", request_failed = "Die sichere Börsenanfrage ist fehlgeschlagen.", default = "VaultX konnte die Anfrage nicht abschließen." },
|
errors = { invalid_profile = "Prüfe deine VaultX-Profileinstellungen.", invalid_handle = "Nutze 3–20 Buchstaben, Zahlen, Punkte oder Unterstriche.", handle_taken = "Dieser VaultX-Benutzername ist bereits vergeben.", profile_exists = "Dieser Charakter besitzt bereits ein VaultX-Profil.", invalid_password = "Nutze 8–72 Zeichen mit Groß- und Kleinbuchstaben, einer Zahl und einem Sonderzeichen.", password_mismatch = "Die Passwörter stimmen nicht überein.", accept_terms = "Bestätige, dass dies ein fiktives Ingame-Wallet ist.", invalid_credentials = "Das Passwort ist falsch.", locked = "Zu viele Versuche. Versuche es später erneut.", not_authenticated = "Logge dich zuerst bei VaultX ein.", invalid_amount = "Gib einen positiven ganzen Betrag ein.", invalid_quantity = "Gib eine Menge mit bis zu sechs Nachkommastellen ein.", invalid_wallet_key = "Gib einen vollständigen VX-Empfangsschlüssel ein.", recipient_not_found = "Zu diesem Key wurde kein aktives VaultX-Konto gefunden.", self_transfer = "Du kannst keine Krypto an deinen eigenen Key senden.", invalid_transfer = "Prüfe Empfänger, Asset und Krypto-Menge.", recipient_limit_exceeded = "Das Empfänger-Wallet kann diese Menge nicht aufnehmen.", insufficient_funds = "Dein verfügbarer Bestand reicht nicht aus.", insufficient_liquidity = "Die Börse kann diese Menge derzeit nicht ausführen.", quote_expired = "Das Angebot ist abgelaufen. Fordere ein neues an.", quote_unavailable = "Das Angebot ist nicht mehr verfügbar.", market_unavailable = "Dieser Markt ist vorübergehend pausiert.", limit_exceeded = "Die Anfrage überschreitet ein Börsenlimit.", duplicate_request = "Diese Anfrage wurde bereits verarbeitet.", rate_limited = "Zu viele Anfragen. Versuche es gleich erneut.", settlement_pending = "Eine Geldbewegung wartet bereits auf Prüfung.", service_unavailable = "VaultX ist vorübergehend nicht verfügbar.", request_failed = "Die sichere Börsenanfrage ist fehlgeschlagen.", default = "VaultX konnte die Anfrage nicht abschließen." },
|
||||||
},
|
},
|
||||||
banking = {
|
banking = {
|
||||||
name = "Banking", welcome = "Willkommen zurück", totalBalance = "Gesamtsaldo", recentPeriod = "in jüngster Zeit",
|
name = "Banking", welcome = "Willkommen zurück", totalBalance = "Gesamtsaldo", recentPeriod = "in jüngster Zeit",
|
||||||
@@ -1207,7 +1237,7 @@ Locales["de"] = {
|
|||||||
zoomIn = "Vergrößern", zoomOut = "Auszoomen", resetMap = "Kartenansicht zurücksetzen",
|
zoomIn = "Vergrößern", zoomOut = "Auszoomen", resetMap = "Kartenansicht zurücksetzen",
|
||||||
savedPlaces = "Gespeicherte Plätze", savedPlace = "Gespeicherter Ort", quickDestinations = "Schnelle Reiseziele",
|
savedPlaces = "Gespeicherte Plätze", savedPlace = "Gespeicherter Ort", quickDestinations = "Schnelle Reiseziele",
|
||||||
quickLocations = { ["legion-square"] = "Legionsplatz", ["diamond-casino"] = "Diamant Casino", airport = "Flughafen Los Santos", vinewood = "Weinholz" },
|
quickLocations = { ["legion-square"] = "Legionsplatz", ["diamond-casino"] = "Diamant Casino", airport = "Flughafen Los Santos", vinewood = "Weinholz" },
|
||||||
viewRides = "Ansicht Einstellungen für die Fahrt", change = "Veränderung", requestRide = "Anfrage SkyRide",
|
viewRides = "Fahrtoptionen anzeigen", change = "Ändern", requestRide = "SkyRide anfragen", playerDriverNotice = "SkyRide vermittelt an echte Spieler-Fahrer. Ein Fahrer muss online sein und deine Anfrage annehmen.",
|
||||||
serviceMeta = "{eta} min entfernt · {seats} Sitze", distanceMeters = "{distance} m",
|
serviceMeta = "{eta} min entfernt · {seats} Sitze", distanceMeters = "{distance} m",
|
||||||
distanceKilometers = "{distance} km", distanceMiles = "{distance} mi", durationMinutes = "{minutes} min",
|
distanceKilometers = "{distance} km", distanceMiles = "{distance} mi", durationMinutes = "{minutes} min",
|
||||||
fare = "Fahrpreis", calculatedFare = "Berechnet", customFare = "Eigener Preis",
|
fare = "Fahrpreis", calculatedFare = "Berechnet", customFare = "Eigener Preis",
|
||||||
@@ -1241,7 +1271,7 @@ Locales["de"] = {
|
|||||||
arrived = "Fahrer angekommen", in_progress = "Auf dem Weg", completed = "Abgeschlossen", cancelled = "Abgebrochen",
|
arrived = "Fahrer angekommen", in_progress = "Auf dem Weg", completed = "Abgeschlossen", cancelled = "Abgebrochen",
|
||||||
},
|
},
|
||||||
statusBody = {
|
statusBody = {
|
||||||
searching = "Wir passen zu dir mit einem Fahrer in der Nähe.", accepted = "Dein Fahrer will dich abholen.",
|
searching = "Deine Anfrage wartet darauf, dass ein verfügbarer Spieler-Fahrer sie annimmt.", accepted = "Dein Fahrer bereitet die Abholung vor.",
|
||||||
driver_arriving = "Dein Fahrer ist auf dem Weg zu deinem Pickup.", arrived = "Dein Fahrer wartet am Abholpunkt.",
|
driver_arriving = "Dein Fahrer ist auf dem Weg zu deinem Pickup.", arrived = "Dein Fahrer wartet am Abholpunkt.",
|
||||||
in_progress = "Du bist auf dem Weg zu deinem Ziel.", completed = "Du bist am Ziel angekommen.",
|
in_progress = "Du bist auf dem Weg zu deinem Ziel.", completed = "Du bist am Ziel angekommen.",
|
||||||
cancelled = "Diese Fahrt ist nicht mehr aktiv.",
|
cancelled = "Diese Fahrt ist nicht mehr aktiv.",
|
||||||
@@ -1426,7 +1456,9 @@ Locales["de"] = {
|
|||||||
errors = {
|
errors = {
|
||||||
cancelled = "Die Aufnahme ist abgesagt.", capture_failed = "Die Spielansicht kann nicht erfasst werden.",
|
cancelled = "Die Aufnahme ist abgesagt.", capture_failed = "Die Spielansicht kann nicht erfasst werden.",
|
||||||
invalid_media_type = "Der hochgeladene Medientyp ist ungültig.", invalid_upload = "Der Upload konnte nicht überprüft werden.",
|
invalid_media_type = "Der hochgeladene Medientyp ist ungültig.", invalid_upload = "Der Upload konnte nicht überprüft werden.",
|
||||||
invalid_upload_token = "Die Upload-Sitzung ist nicht mehr gültig.", missing_config = "Kamera-Uploads sind nicht konfiguriert.",
|
invalid_upload_token = "Die Upload-Sitzung ist nicht mehr gültig.", media_provider_failed = "Der Kamera-Upload-Dienst ist nicht verfügbar.",
|
||||||
|
media_provider_rate_limited = "Der Kamera-Upload-Dienst ist ausgelastet. Versuch es gleich erneut.", media_provider_unauthorized = "Der konfigurierte FiveManage API-Schlüssel wurde abgelehnt.",
|
||||||
|
missing_config = "Kamera-Uploads sind nicht konfiguriert.",
|
||||||
microphone_unavailable = "Erlaube den Mikrofonzugriff oder schalte das Mikrofon vor der Aufnahme stumm.",
|
microphone_unavailable = "Erlaube den Mikrofonzugriff oder schalte das Mikrofon vor der Aufnahme stumm.",
|
||||||
not_found = "Das Medienobjekt existiert nicht mehr.", owner_changed = "Das aktive Telefonkonto hat sich beim Upload geändert.",
|
not_found = "Das Medienobjekt existiert nicht mehr.", owner_changed = "Das aktive Telefonkonto hat sich beim Upload geändert.",
|
||||||
operation_in_progress = "Eine weitere Medienoperation ist bereits im Gange.",
|
operation_in_progress = "Eine weitere Medienoperation ist bereits im Gange.",
|
||||||
@@ -2105,3 +2137,68 @@ Locales["de"] = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Locales["de"].Nui.AdminPanel.configurator.table.subtabs = {
|
||||||
|
AdminGroups = "Admin-Gruppen",
|
||||||
|
Dictionaries = "Animationsdateien",
|
||||||
|
Clips = "Clips",
|
||||||
|
Transforms = "Transformationen",
|
||||||
|
RateLimits = "Ratenlimits",
|
||||||
|
Publishers = "Herausgeber",
|
||||||
|
ExternalPingResources = "Externe Ping-Ressourcen",
|
||||||
|
Markets = "Märkte",
|
||||||
|
TrustedAdapters = "Vertrauenswürdige Adapter",
|
||||||
|
AllowedDisappearTimers = "Erlaubte Löschzeiten",
|
||||||
|
MusicTracks = "Musiktitel",
|
||||||
|
VehicleImages = "Fahrzeugbilder",
|
||||||
|
Custom = "Benutzerdefiniert",
|
||||||
|
Valet = "Parkservice",
|
||||||
|
AutoPriority = "Automatische Priorität",
|
||||||
|
Camera = "Kamera",
|
||||||
|
Categories = "Kategorien",
|
||||||
|
Districts = "Bezirke",
|
||||||
|
PhotoGradients = "Fotoverläufe",
|
||||||
|
LbPhone = "LB Phone",
|
||||||
|
Tracks = "Titel",
|
||||||
|
Props = "Props",
|
||||||
|
CustomLocations = "Benutzerdefinierte Standorte",
|
||||||
|
Animation = "Animation",
|
||||||
|
ReportReasons = "Meldegründe",
|
||||||
|
DisplayName = "Anzeigename",
|
||||||
|
Hud = "HUD",
|
||||||
|
Badge = "Abzeichen",
|
||||||
|
LockedChannels = "Gesperrte Kanäle",
|
||||||
|
NumberGroups = "Nummerngruppen",
|
||||||
|
CustomFare = "Benutzerdefinierter Tarif",
|
||||||
|
DriverJobs = "Fahrerjobs",
|
||||||
|
Services = "Dienstleistungen",
|
||||||
|
QuickLocations = "Schnellziele",
|
||||||
|
AllowedJobs = "Erlaubte Jobs",
|
||||||
|
Websites = "Webseiten",
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
Locales["en"] = {
|
Locales["en"] = {
|
||||||
CommandDescription = "Open your phone.",
|
CommandDescription = "Open your phone.",
|
||||||
|
AdminCommand = {
|
||||||
|
CommandDescription = "Open phone administration and configuration.",
|
||||||
|
Errors = {
|
||||||
|
disabled = "The phone admin panel is disabled.",
|
||||||
|
not_authorized = "You do not have access to the phone admin panel.",
|
||||||
|
default = "The phone admin panel could not be opened.",
|
||||||
|
},
|
||||||
|
},
|
||||||
Controls = {
|
Controls = {
|
||||||
OpenPhone = "Open phone",
|
OpenPhone = "Open phone",
|
||||||
},
|
},
|
||||||
@@ -193,6 +201,28 @@ Locales["en"] = {
|
|||||||
contacts = { name = "Favorites", description = "Call or message your favorite contacts.", choose = "Favorite Contacts" },
|
contacts = { name = "Favorites", description = "Call or message your favorite contacts.", choose = "Favorite Contacts" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
AdminPanel = {
|
||||||
|
name = "Phone Admin", subtitle = "Administration", navigation = "Admin navigation", refresh = "Refresh admin data", loading = "Loading protected data...",
|
||||||
|
tabs = { overview = "Overview", players = "Players", devices = "Devices", apps = "Apps", accounts = "Accounts", messages = "Messages", calls = "Calls", moderation = "Moderation", audit = "Audit", configurator = "Phone configurator" },
|
||||||
|
overview = { eyebrow = "Server", title = "Dashboard", body = "Players, devices, apps, and phone data.", stats = "Server phone statistics", online = "Online", devices = "Devices", accounts = "Accounts", audit = "Audit entries", control = "Navigation", features = "Modules", featuresBody = "Open an administration module.", recent = "Recent activity", playerFeature = "Identity, finances, job, and duty", deviceFeature = "IMEI, SIM, number, and activity", appFeature = "Install or remove phone apps", accountFeature = "Account access and protected credentials", messageFeature = "Review recent SMS activity", callFeature = "Review recent call activity", moderationFeature = "Reset access, number, or device data", auditFeature = "Review sensitive admin actions", configuratorFeature = "Manage config.lua and media.lua through SQL" },
|
||||||
|
appearance = { eyebrow = "Appearance", title = "Accent color", body = "Change the accent across the complete admin workspace.", colors = { emerald = "Emerald", blue = "Blue", violet = "Violet", orange = "Orange", red = "Red" } },
|
||||||
|
configurator = { context = "Runtime configuration", eyebrow = "System tool", sections = "Configuration", search = "Search settings or paths", configScope = "config.lua", mediaScope = "media.lua", noResults = "No matching settings", loading = "Loading SQL configuration...", title = "Phone configurator", body = "Manage phone and media settings from the protected admin workspace.", disabledTitle = "SQL configuration is not active", disabledBody = "Enable the configurator at the beginning of config.lua and restart sky_phone. Until then, file values remain active and editing is locked.", manualSave = "Manual save", refreshNotice = "Nothing is written automatically. The green check verifies config.lua and media.lua in SQL and refreshes the active server, client, media and UI configuration immediately.", fieldCount = "{count} fields", secretConfigured = "Secret configured · enter a replacement", invalidValue = "Check the highlighted table or number value.", saved = "SQL configuration saved and applied.", descriptions = { featureToggle = "Turns {name} on or off.", boolean = "Controls whether {name} is allowed.", number = "Sets the numeric value for {name}.", text = "Sets the text value used for {name}.", optionalText = "Sets the optional value for {name}; switch it off to disable it.", list = "Manages all entries used for {name}.", table = "Groups the related settings for {name}.", credential = "Stores the protected credential used by {name}.", url = "Sets the URL or endpoint used by {name}.", hosts = "Defines which domains are allowed for {name}.", milliseconds = "Sets the timing for {name} in milliseconds.", seconds = "Sets the timing for {name} in seconds.", rateLimit = "Limits how many {name} actions are allowed per minute.", byteLimit = "Sets the maximum data size allowed for {name}.", textLimit = "Sets the maximum text length allowed for {name}.", distance = "Sets the world distance used for {name}.", coordinates = "Sets the world coordinates or orientation for {name}.", gameAsset = "Sets the GTA model or prop used for {name}.", animation = "Sets the animation asset used for {name}.", access = "Defines the jobs, groups or permission level for {name}.", integration = "Selects the connected framework or provider for {name}.", path = "Sets the storage or resource path used for {name}.", color = "Sets the interface color used for {name}.", displayText = "Sets the text shown to players for {name}.", phoneNumber = "Sets the phone or service number used for {name}.", routing = "Controls how incoming requests are routed for {name}.", command = "Sets the chat command used to open or run {name}.", locale = "Selects the language used for {name}.", debug = "Controls detailed diagnostic output for {name}.", mediaQuality = "Sets the media quality or volume used for {name}.", amount = "Sets the maximum or displayed amount for {name}." }, table = { list = "List", table = "Key table", vector = "Vector", entry = "Entry", general = "General", addRow = "Add row", addField = "Add field", remove = "Remove", emptyList = "No rows yet. Add the first row with plus.", emptyTable = "No fields yet. Add the first key below.", keyPlaceholder = "New key", convertToList = "Use list", convertToMap = "Use typed key table", convertToTable = "Use key table", types = { string = "Text", number = "Number", boolean = "Switch", list = "List", table = "Table" } } },
|
||||||
|
players = { eyebrow = "Active sessions", title = "Online players", online = "Online now", empty = "No players found", emptyBody = "Adjust the search or refresh the live player list." },
|
||||||
|
search = { players = "Search name, ID, job, or number", apps = "Search apps", clear = "Clear search" },
|
||||||
|
detail = { character = "Character profile", data = "Player data overview", cash = "Cash", bank = "Bank", job = "Job", duty = "Duty", onDuty = "On duty", offDuty = "Off duty", identity = "Identity", playerData = "Player data", identifier = "Character identifier", birthdate = "Birthdate", grade = "Job grade", unknown = "Unknown" },
|
||||||
|
devices = { eyebrow = "Device control", title = "Phones", body = "Inspect every phone assigned to the selected player.", choose = "Choose phone", empty = "No phone found", emptyBody = "This player currently has no phone device that can be managed.", noNumber = "No phone number", noSim = "No SIM", imei = "IMEI", updated = "Last activity", apps = "Claimed apps", account = "Linked account" },
|
||||||
|
credentials = { eyebrow = "Protected data", title = "Credentials", email = "iFruit email", password = "iFruit password", reveal = "Reveal", copy = "Copy password", copied = "Password copied.", noAccount = "No iFruit account is linked to this phone.", passcode = "Device passcode", passcodeHashed = "{length}-digit PIN · securely hashed and not recoverable", passcodeDisabled = "No passcode configured", revealTitle = "Reveal protected password?", revealBody = "This action is server-authorized, rate-limited, and written to the admin audit log.", cancel = "Cancel", confirmReveal = "Reveal password" },
|
||||||
|
apps = { eyebrow = "Remote management", title = "App access", description = "Stage app access for this device. Nothing changes until you save.", installed = "Installed", available = "Available", protected = "System app", changes = "{count} pending changes" },
|
||||||
|
activity = { protected = "Protected activity", messagesTitle = "Messages", messagesBody = "Recent SMS activity for the selected SIM.", callsTitle = "Calls", callsBody = "Recent call activity for the selected SIM.", loading = "Loading activity...", incoming = "Incoming", outgoing = "Outgoing", mediaMessage = "{type} message", noMessages = "No message activity found.", noCalls = "No call activity found.", status = { completed = "Completed", missed = "Missed", rejected = "Rejected", busy = "Busy", unanswered = "Unanswered", cancelled = "Cancelled", failed = "Failed", ringing = "Ringing" } },
|
||||||
|
moderation = {
|
||||||
|
eyebrow = "Device administration", title = "Moderation actions", body = "Every action is server-authorized, rate-limited, and audited.", resetPasscode = "Reset passcode", resetPasscodeBody = "Remove the device PIN and clear failed attempts.", changeNumber = "Change number", changeNumberBody = "Assign a new unique number to the current SIM.", factoryReset = "Factory reset", factoryResetBody = "Clear local device data and disconnect the account.", saveFirst = "Save or discard pending app changes first.", phoneNumber = "New phone number", phoneNumberPlaceholder = "Enter the full configured number", typeToConfirm = "Type {word} to confirm the factory reset.", confirmWord = "RESET", cancel = "Cancel", ["reset-passcodeSuccess"] = "Passcode reset.", ["change-numberSuccess"] = "Phone number changed.", ["factory-resetSuccess"] = "Phone factory reset completed.",
|
||||||
|
dialogs = { ["reset-passcodeTitle"] = "Reset device passcode?", ["reset-passcodeBody"] = "The player can unlock this phone without the previous PIN afterward.", ["change-numberTitle"] = "Change phone number?", ["change-numberBody"] = "The new number must match the configured server number format and be unique.", ["factory-resetTitle"] = "Factory reset this phone?", ["factory-resetBody"] = "This clears local device data, app settings, security, and the linked account. This cannot be undone." },
|
||||||
|
confirm = { ["reset-passcode"] = "Reset passcode", ["change-number"] = "Change number", ["factory-reset"] = "Factory reset" },
|
||||||
|
},
|
||||||
|
audit = { eyebrow = "Accountability", title = "Audit trail", body = "Sensitive reveals and remote app changes are recorded here.", empty = "No admin actions yet", emptyBody = "Protected actions will appear here after they are performed.", by = "{actor} · target ID {target}", actions = { grant_app = "App installed", revoke_app = "App removed", reveal_account_password = "Password revealed", view_messages = "Messages viewed", view_calls = "Calls viewed", reset_passcode = "Passcode reset", change_number = "Phone number changed", factory_reset = "Phone factory reset", save_configuration = "Configuration saved" } },
|
||||||
|
editor = { brand = "SKY PHONE", workspace = "ADMIN", players = "Player directory", audit = "Audit log", selectPlayer = "Select a player to inspect identity, devices, credentials, and app access.", save = "Save changes", saveHint = "Apply pending changes", saved = "Changes saved.", unsaved = "Unsaved changes", close = "Close admin panel", refresh = "Refresh live data", online = "LIVE", profile = "PROFILE", financial = "FINANCIAL", device = "DEVICE", security = "SECURITY", noAutoSave = "Manual save", noAutoSaveBody = "Changes stay local until the green check is pressed.", discardTitle = "Discard unsaved changes?", discardBody = "Your staged app or configuration changes have not been saved.", keepEditing = "Keep editing", discard = "Discard changes", saveFailed = "Some changes could not be saved.", noSelection = "No player selected" },
|
||||||
|
errors = { not_authorized = "You do not have access to the admin panel.", rate_limited = "Too many admin requests. Please wait.", player_unavailable = "That player is no longer online.", device_not_owned = "That phone no longer belongs to the selected player.", invalid_app = "That app is not registered on the server.", app_protected = "This system app cannot be removed.", revision_conflict = "The data changed in the meantime. Reopen the section and try again.", configurator_disabled = "Enable the phone configurator in config.lua first.", invalid_field = "That configuration field is no longer available.", invalid_value = "A configuration value is invalid.", account_not_found = "No iFruit account is linked to this phone.", invalid_phone_number = "Enter a phone number in the configured server format.", phone_number_unchanged = "This SIM already uses that phone number.", phone_number_taken = "That phone number is already assigned.", no_sim = "This phone has no SIM that can be changed.", passcode_not_set = "This phone has no passcode configured.", device_not_found = "This phone no longer exists.", metadata_unsupported = "The phone inventory metadata could not be updated.", invalid_request = "The admin request was invalid.", request_failed = "The admin request failed.", default = "The admin panel is temporarily unavailable." },
|
||||||
|
},
|
||||||
Apps = {
|
Apps = {
|
||||||
health = {
|
health = {
|
||||||
name = "Health",
|
name = "Health",
|
||||||
@@ -606,7 +636,7 @@ Locales["en"] = {
|
|||||||
createIdentity = "Create Dark Identity", createIdentityBody = "Create a private identity before starting a DarkChat conversation.",
|
createIdentity = "Create Dark Identity", createIdentityBody = "Create a private identity before starting a DarkChat conversation.",
|
||||||
security = "Security", privateNetwork = "Private invitation-only network", noResults = "No Results", noResultsBody = "Try another alias or Dark-ID.",
|
security = "Security", privateNetwork = "Private invitation-only network", noResults = "No Results", noResultsBody = "Try another alias or Dark-ID.",
|
||||||
noChats = "No Private Chats", noChatsBody = "Connect with a Dark-ID or invitation code. There is no public search.", newChat = "New Chat", to = "To:",
|
noChats = "No Private Chats", noChatsBody = "Connect with a Dark-ID or invitation code. There is no public search.", newChat = "New Chat", to = "To:",
|
||||||
connectPrivately = "Connect privately", newChatBody = "Enter an exact Dark-ID or invitation code. Unknown identities require confirmation.", darkIdOrInvite = "Dark-ID or invitation code", continue = "Continue", contacts = "DarkChat Contacts", contactsHint = "Saved private identities appear only on your DarkChat account.", noContacts = "No saved DarkChat contacts", private = "Private", shareIdentity = "Tap to share your private identity",
|
connectPrivately = "Connect privately", newChatBody = "Enter another person's Dark-ID or invitation code. You can share your own ID from your profile.", darkIdOrInvite = "Dark-ID or invitation code", continue = "Continue", contacts = "DarkChat Contacts", contactsHint = "Saved private identities appear only on your DarkChat account.", noContacts = "No saved DarkChat contacts", private = "Private", shareIdentity = "Tap to share your private identity",
|
||||||
message = "Dark message", activeNow = "Activity shared", encryptedSession = "Private session", serverPrivate = "Private server-stored conversation",
|
message = "Dark message", activeNow = "Activity shared", encryptedSession = "Private session", serverPrivate = "Private server-stored conversation",
|
||||||
emoji = "Emoji", gif = "GIF", gifs = "GIFs", photo = "Photo", video = "Video", attachPhoto = "Attach Photo", takePhoto = "Take Photo", attachGif = "Attach GIF", attachVideo = "Attach Video", searchGifs = "Search GIFs", loadMore = "Load More",
|
emoji = "Emoji", gif = "GIF", gifs = "GIFs", photo = "Photo", video = "Video", attachPhoto = "Attach Photo", takePhoto = "Take Photo", attachGif = "Attach GIF", attachVideo = "Attach Video", searchGifs = "Search GIFs", loadMore = "Load More",
|
||||||
voiceMessage = "Voice message", sending = "Sending", failed = "Not delivered", delivered = "Delivered", read = "Read", replying = "Replying to",
|
voiceMessage = "Voice message", sending = "Sending", failed = "Not delivered", delivered = "Delivered", read = "Read", replying = "Replying to",
|
||||||
@@ -1069,7 +1099,7 @@ Locales["en"] = {
|
|||||||
profile = { verified = "Character ownership verified", walletKey = "Public crypto key", walletKeyBody = "Share this key to receive crypto. It cannot access your account.", copyKey = "Copy", copied = "Copied", shareKey = "Share", shareTitle = "My VaultX crypto key", shareText = "Send crypto to my public VaultX key:", trades = "Trades", volume = "Volume", memberSince = "Member since", preferences = "Preferences", priceAlerts = "Price alerts", priceAlertsBody = "Notify me about notable market moves.", confirmations = "Trade confirmations", confirmationsBody = "Keep an extra confirmation before execution.", hideBalances = "Privacy mode", hideBalancesBody = "Hide balances across VaultX.", identity = "Profile identity", edit = "Edit", account = "Account & security", editTitle = "Edit profile", editBody = "Update your handle or set a new password. Your current password confirms identity changes.", currentPassword = "Current password", currentPasswordPlaceholder = "Required for handle or password changes", newPassword = "New password", newPasswordPlaceholder = "Leave blank to keep your current password", passwordSecurity = "Passwords use memory-hard hashing and are never shown again.", saved = "Profile saved securely.", save = "Save profile", saveChanges = "Save changes", securityTitle = "Protected profile", securityBody = "Your profile stays bound to this framework character and financial session." },
|
profile = { verified = "Character ownership verified", walletKey = "Public crypto key", walletKeyBody = "Share this key to receive crypto. It cannot access your account.", copyKey = "Copy", copied = "Copied", shareKey = "Share", shareTitle = "My VaultX crypto key", shareText = "Send crypto to my public VaultX key:", trades = "Trades", volume = "Volume", memberSince = "Member since", preferences = "Preferences", priceAlerts = "Price alerts", priceAlertsBody = "Notify me about notable market moves.", confirmations = "Trade confirmations", confirmationsBody = "Keep an extra confirmation before execution.", hideBalances = "Privacy mode", hideBalancesBody = "Hide balances across VaultX.", identity = "Profile identity", edit = "Edit", account = "Account & security", editTitle = "Edit profile", editBody = "Update your handle or set a new password. Your current password confirms identity changes.", currentPassword = "Current password", currentPasswordPlaceholder = "Required for handle or password changes", newPassword = "New password", newPasswordPlaceholder = "Leave blank to keep your current password", passwordSecurity = "Passwords use memory-hard hashing and are never shown again.", saved = "Profile saved securely.", save = "Save profile", saveChanges = "Save changes", securityTitle = "Protected profile", securityBody = "Your profile stays bound to this framework character and financial session." },
|
||||||
activityTypes = { buy = "Asset purchase", sell = "Asset sale", deposit = "Bank deposit", withdrawal = "Bank withdrawal", transfer_in = "Crypto received", transfer_out = "Crypto sent" },
|
activityTypes = { buy = "Asset purchase", sell = "Asset sale", deposit = "Bank deposit", withdrawal = "Bank withdrawal", transfer_in = "Crypto received", transfer_out = "Crypto sent" },
|
||||||
statuses = { completed = "Completed", pending = "Pending review", failed = "Failed", manual_review = "Manual review" },
|
statuses = { completed = "Completed", pending = "Pending review", failed = "Failed", manual_review = "Manual review" },
|
||||||
errors = { invalid_profile = "Check your VaultX profile settings.", invalid_handle = "Use 3–20 letters, numbers, dots or underscores.", handle_taken = "That VaultX handle is already taken.", profile_exists = "This character already owns a VaultX profile.", invalid_password = "Password must be 8–72 characters.", password_mismatch = "The passwords do not match.", accept_terms = "Confirm that this is a fictional in-game wallet.", invalid_credentials = "The password is incorrect.", locked = "Too many attempts. Try again later.", not_authenticated = "Sign in to VaultX first.", invalid_amount = "Enter a positive whole amount.", invalid_quantity = "Enter a quantity with up to six decimals.", invalid_wallet_key = "Enter a complete VX receiving key.", recipient_not_found = "No active VaultX account uses this key.", self_transfer = "You cannot send crypto to your own key.", invalid_transfer = "Verify a recipient, asset and valid crypto amount.", recipient_limit_exceeded = "The recipient wallet cannot hold that amount.", insufficient_funds = "Your available balance is too low.", insufficient_liquidity = "The exchange cannot fill that amount right now.", quote_expired = "This quote expired. Request a new one.", quote_unavailable = "The quote is no longer available.", market_unavailable = "This market is temporarily paused.", limit_exceeded = "This request exceeds an exchange limit.", duplicate_request = "This request was already processed.", rate_limited = "Too many requests. Try again shortly.", settlement_pending = "A money transfer is already pending review.", service_unavailable = "VaultX is temporarily unavailable.", request_failed = "The secure exchange request failed.", default = "VaultX could not complete the request." },
|
errors = { invalid_profile = "Check your VaultX profile settings.", invalid_handle = "Use 3–20 letters, numbers, dots or underscores.", handle_taken = "That VaultX handle is already taken.", profile_exists = "This character already owns a VaultX profile.", invalid_password = "Use 8–72 characters with uppercase, lowercase, a number, and a special character.", password_mismatch = "The passwords do not match.", accept_terms = "Confirm that this is a fictional in-game wallet.", invalid_credentials = "The password is incorrect.", locked = "Too many attempts. Try again later.", not_authenticated = "Sign in to VaultX first.", invalid_amount = "Enter a positive whole amount.", invalid_quantity = "Enter a quantity with up to six decimals.", invalid_wallet_key = "Enter a complete VX receiving key.", recipient_not_found = "No active VaultX account uses this key.", self_transfer = "You cannot send crypto to your own key.", invalid_transfer = "Verify a recipient, asset and valid crypto amount.", recipient_limit_exceeded = "The recipient wallet cannot hold that amount.", insufficient_funds = "Your available balance is too low.", insufficient_liquidity = "The exchange cannot fill that amount right now.", quote_expired = "This quote expired. Request a new one.", quote_unavailable = "The quote is no longer available.", market_unavailable = "This market is temporarily paused.", limit_exceeded = "This request exceeds an exchange limit.", duplicate_request = "This request was already processed.", rate_limited = "Too many requests. Try again shortly.", settlement_pending = "A money transfer is already pending review.", service_unavailable = "VaultX is temporarily unavailable.", request_failed = "The secure exchange request failed.", default = "VaultX could not complete the request." },
|
||||||
},
|
},
|
||||||
banking = {
|
banking = {
|
||||||
name = "Banking", welcome = "Welcome back", totalBalance = "Total Balance", recentPeriod = "in recent activity",
|
name = "Banking", welcome = "Welcome back", totalBalance = "Total Balance", recentPeriod = "in recent activity",
|
||||||
@@ -1207,7 +1237,7 @@ Locales["en"] = {
|
|||||||
zoomIn = "Zoom in", zoomOut = "Zoom out", resetMap = "Reset map view",
|
zoomIn = "Zoom in", zoomOut = "Zoom out", resetMap = "Reset map view",
|
||||||
savedPlaces = "Saved places", savedPlace = "Saved place", quickDestinations = "Quick destinations",
|
savedPlaces = "Saved places", savedPlace = "Saved place", quickDestinations = "Quick destinations",
|
||||||
quickLocations = { ["legion-square"] = "Legion Square", ["diamond-casino"] = "Diamond Casino", airport = "Los Santos Airport", vinewood = "Vinewood" },
|
quickLocations = { ["legion-square"] = "Legion Square", ["diamond-casino"] = "Diamond Casino", airport = "Los Santos Airport", vinewood = "Vinewood" },
|
||||||
viewRides = "View Ride Options", change = "Change", requestRide = "Request SkyRide",
|
viewRides = "View Ride Options", change = "Change", requestRide = "Request SkyRide", playerDriverNotice = "SkyRide matches you with real player drivers. A driver must be online and accept your request.",
|
||||||
serviceMeta = "{eta} min away · {seats} seats", distanceMeters = "{distance} m",
|
serviceMeta = "{eta} min away · {seats} seats", distanceMeters = "{distance} m",
|
||||||
distanceKilometers = "{distance} km", distanceMiles = "{distance} mi", durationMinutes = "{minutes} min",
|
distanceKilometers = "{distance} km", distanceMiles = "{distance} mi", durationMinutes = "{minutes} min",
|
||||||
fare = "Trip fare", calculatedFare = "Calculated", customFare = "Own price",
|
fare = "Trip fare", calculatedFare = "Calculated", customFare = "Own price",
|
||||||
@@ -1241,7 +1271,7 @@ Locales["en"] = {
|
|||||||
arrived = "Driver arrived", in_progress = "On the way", completed = "Completed", cancelled = "Cancelled",
|
arrived = "Driver arrived", in_progress = "On the way", completed = "Completed", cancelled = "Cancelled",
|
||||||
},
|
},
|
||||||
statusBody = {
|
statusBody = {
|
||||||
searching = "We are matching you with a nearby driver.", accepted = "Your driver is preparing to pick you up.",
|
searching = "Your request is waiting for an available player driver to accept it.", accepted = "Your driver is preparing to pick you up.",
|
||||||
driver_arriving = "Your driver is on the way to your pickup.", arrived = "Your driver is waiting at the pickup point.",
|
driver_arriving = "Your driver is on the way to your pickup.", arrived = "Your driver is waiting at the pickup point.",
|
||||||
in_progress = "You are on the way to your destination.", completed = "You have arrived at your destination.",
|
in_progress = "You are on the way to your destination.", completed = "You have arrived at your destination.",
|
||||||
cancelled = "This ride is no longer active.",
|
cancelled = "This ride is no longer active.",
|
||||||
@@ -1426,7 +1456,9 @@ Locales["en"] = {
|
|||||||
errors = {
|
errors = {
|
||||||
cancelled = "Capture cancelled.", capture_failed = "Unable to capture the game view.",
|
cancelled = "Capture cancelled.", capture_failed = "Unable to capture the game view.",
|
||||||
invalid_media_type = "The uploaded media type is invalid.", invalid_upload = "The upload could not be verified.",
|
invalid_media_type = "The uploaded media type is invalid.", invalid_upload = "The upload could not be verified.",
|
||||||
invalid_upload_token = "The upload session is no longer valid.", missing_config = "Camera uploads are not configured.",
|
invalid_upload_token = "The upload session is no longer valid.", media_provider_failed = "The camera upload service is unavailable.",
|
||||||
|
media_provider_rate_limited = "The camera upload service is busy. Try again shortly.", media_provider_unauthorized = "The configured FiveManage API key was rejected.",
|
||||||
|
missing_config = "Camera uploads are not configured.",
|
||||||
microphone_unavailable = "Allow microphone access or mute the microphone before recording.",
|
microphone_unavailable = "Allow microphone access or mute the microphone before recording.",
|
||||||
not_found = "The media item no longer exists.", owner_changed = "The active phone account changed during upload.",
|
not_found = "The media item no longer exists.", owner_changed = "The active phone account changed during upload.",
|
||||||
operation_in_progress = "Another media operation is already in progress.",
|
operation_in_progress = "Another media operation is already in progress.",
|
||||||
@@ -2105,3 +2137,68 @@ Locales["en"] = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Locales["en"].Nui.AdminPanel.configurator.table.subtabs = {
|
||||||
|
AdminGroups = "Admin Groups",
|
||||||
|
Dictionaries = "Dictionaries",
|
||||||
|
Clips = "Clips",
|
||||||
|
Transforms = "Transforms",
|
||||||
|
RateLimits = "Rate Limits",
|
||||||
|
Publishers = "Publishers",
|
||||||
|
ExternalPingResources = "External Ping Resources",
|
||||||
|
Markets = "Markets",
|
||||||
|
TrustedAdapters = "Trusted Adapters",
|
||||||
|
AllowedDisappearTimers = "Allowed Disappear Timers",
|
||||||
|
MusicTracks = "Music Tracks",
|
||||||
|
VehicleImages = "Vehicle Images",
|
||||||
|
Custom = "Custom",
|
||||||
|
Valet = "Valet",
|
||||||
|
AutoPriority = "Automatic Priority",
|
||||||
|
Camera = "Camera",
|
||||||
|
Categories = "Categories",
|
||||||
|
Districts = "Districts",
|
||||||
|
PhotoGradients = "Photo Gradients",
|
||||||
|
LbPhone = "LB Phone",
|
||||||
|
Tracks = "Tracks",
|
||||||
|
Props = "Props",
|
||||||
|
CustomLocations = "Custom Locations",
|
||||||
|
Animation = "Animation",
|
||||||
|
ReportReasons = "Report Reasons",
|
||||||
|
DisplayName = "Display Name",
|
||||||
|
Hud = "HUD",
|
||||||
|
Badge = "Badge",
|
||||||
|
LockedChannels = "Locked Channels",
|
||||||
|
NumberGroups = "Number Groups",
|
||||||
|
CustomFare = "Custom Fare",
|
||||||
|
DriverJobs = "Driver Jobs",
|
||||||
|
Services = "Services",
|
||||||
|
QuickLocations = "Quick Locations",
|
||||||
|
AllowedJobs = "Allowed Jobs",
|
||||||
|
Websites = "Websites",
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
--[[
|
--[[
|
||||||
Sky Phone media configuration
|
Sky Phone media configuration
|
||||||
|
|
||||||
This file is loaded on the server only. Keep API keys private and restart
|
This file is loaded on the server only. Keep API keys private. When the
|
||||||
sky_phone after changing providers, upload limits, or import sources.
|
phone configurator is enabled in config.lua, these values are first-run
|
||||||
|
defaults and the active media configuration is loaded from SQL.
|
||||||
]]
|
]]
|
||||||
|
|
||||||
Config.Media = {
|
Config.Media = {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use_experimental_fxv2_oal 'yes'
|
|||||||
|
|
||||||
author 'Sky-Systems'
|
author 'Sky-Systems'
|
||||||
description 'Sky Phone'
|
description 'Sky Phone'
|
||||||
version '0.2.0'
|
version '0.2.5'
|
||||||
|
|
||||||
provide 'lb-phone'
|
provide 'lb-phone'
|
||||||
provide '17mov_Phone'
|
provide '17mov_Phone'
|
||||||
@@ -32,8 +32,9 @@ client_scripts {
|
|||||||
'config/config.lua',
|
'config/config.lua',
|
||||||
'config/locales/en.lua',
|
'config/locales/en.lua',
|
||||||
'config/locales/de.lua',
|
'config/locales/de.lua',
|
||||||
'source/bridge/client/framework.lua',
|
|
||||||
'source/bridge/client/callbacks.lua',
|
'source/bridge/client/callbacks.lua',
|
||||||
|
'source/client/phone_configurator.lua',
|
||||||
|
'source/bridge/client/framework.lua',
|
||||||
'source/bridge/client/housing.lua',
|
'source/bridge/client/housing.lua',
|
||||||
'source/bridge/client/housing/*.lua',
|
'source/bridge/client/housing/*.lua',
|
||||||
'source/bridge/client/calls.lua',
|
'source/bridge/client/calls.lua',
|
||||||
@@ -76,16 +77,20 @@ server_scripts {
|
|||||||
'config/media.lua',
|
'config/media.lua',
|
||||||
'config/locales/en.lua',
|
'config/locales/en.lua',
|
||||||
'config/locales/de.lua',
|
'config/locales/de.lua',
|
||||||
|
'source/server/nui_build_check.lua',
|
||||||
'source/server/update_check.lua',
|
'source/server/update_check.lua',
|
||||||
'source/bridge/server/database.lua',
|
'source/bridge/server/database.lua',
|
||||||
'source/bridge/server/migrations.lua',
|
'source/bridge/server/migrations.lua',
|
||||||
|
'source/server/phone_configurator_schema.lua',
|
||||||
'source/bridge/server/callbacks.lua',
|
'source/bridge/server/callbacks.lua',
|
||||||
|
'source/server/phone_configurator.lua',
|
||||||
'source/bridge/server/framework.lua',
|
'source/bridge/server/framework.lua',
|
||||||
'source/bridge/server/frameworks/*.lua',
|
'source/bridge/server/frameworks/*.lua',
|
||||||
'source/bridge/server/housing.lua',
|
'source/bridge/server/housing.lua',
|
||||||
'source/bridge/server/housing/*.lua',
|
'source/bridge/server/housing/*.lua',
|
||||||
'source/bridge/server/inventory.lua',
|
'source/bridge/server/inventory.lua',
|
||||||
'source/bridge/server/inventory/*.lua',
|
'source/bridge/server/inventory/*.lua',
|
||||||
|
'source/bridge/server/inventory_contract.lua',
|
||||||
'source/bridge/server/voice.lua',
|
'source/bridge/server/voice.lua',
|
||||||
'source/server/custom_apps.lua',
|
'source/server/custom_apps.lua',
|
||||||
'source/server/media_metadata.lua',
|
'source/server/media_metadata.lua',
|
||||||
@@ -99,6 +104,7 @@ server_scripts {
|
|||||||
'source/server/phone.lua',
|
'source/server/phone.lua',
|
||||||
'source/server/device_directory.lua',
|
'source/server/device_directory.lua',
|
||||||
'source/server/db_migrate.lua',
|
'source/server/db_migrate.lua',
|
||||||
|
'source/server/admin.lua',
|
||||||
'source/server/lb_phone_migration.lua',
|
'source/server/lb_phone_migration.lua',
|
||||||
'source/server/custom_app_storage.lua',
|
'source/server/custom_app_storage.lua',
|
||||||
'source/server/payphones.lua',
|
'source/server/payphones.lua',
|
||||||
@@ -106,6 +112,7 @@ server_scripts {
|
|||||||
'source/server/notifications.lua',
|
'source/server/notifications.lua',
|
||||||
'source/shared/public_api.lua',
|
'source/shared/public_api.lua',
|
||||||
'source/server/public_api.lua',
|
'source/server/public_api.lua',
|
||||||
|
'source/server/media_provider_config.lua',
|
||||||
'source/server/media_import.lua',
|
'source/server/media_import.lua',
|
||||||
'source/server/media_import/fivemanage.lua',
|
'source/server/media_import/fivemanage.lua',
|
||||||
'source/server/media_import/manifest.lua',
|
'source/server/media_import/manifest.lua',
|
||||||
@@ -149,6 +156,7 @@ files {
|
|||||||
'source/html/index.html',
|
'source/html/index.html',
|
||||||
'source/html/assets/**',
|
'source/html/assets/**',
|
||||||
'source/html/img/**',
|
'source/html/img/**',
|
||||||
|
'source/html/sounds/**',
|
||||||
'config/music/**',
|
'config/music/**',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,11 +21,24 @@ local function resolve_framework()
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
framework_name = resolve_framework()
|
local function refresh_framework()
|
||||||
if not framework_name then
|
local resolved = resolve_framework()
|
||||||
error("[sky_phone] No supported framework is running. Configure Config.Bridge.Framework.")
|
if resolved ~= "esx" and resolved ~= "qbox" and resolved ~= "qb" then
|
||||||
|
error("[sky_phone] No supported framework is running. Configure Config.Bridge.Framework.")
|
||||||
|
end
|
||||||
|
if resolved == framework_name then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
framework_name = resolved
|
||||||
|
esx = nil
|
||||||
|
qb = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
refresh_framework()
|
||||||
|
|
||||||
|
AddEventHandler("sky_phone:configurator:updated", refresh_framework)
|
||||||
|
|
||||||
function Bridge.Framework.GetName()
|
function Bridge.Framework.GetName()
|
||||||
return framework_name
|
return framework_name
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
local provider
|
local provider
|
||||||
|
local configured_voice_provider = Config.Radio.VoiceProvider
|
||||||
local provider_resources = {
|
local provider_resources = {
|
||||||
yaca = "yaca-voice",
|
yaca = "yaca-voice",
|
||||||
pma = "pma-voice",
|
pma = "pma-voice",
|
||||||
@@ -138,8 +139,7 @@ function Bridge.Radio.Join(primary, secondary)
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
function Bridge.Radio.Leave()
|
local function leave_provider(selected)
|
||||||
local selected = resolve_provider()
|
|
||||||
if selected == "yaca" then
|
if selected == "yaca" then
|
||||||
exports["yaca-voice"]:enableRadio(false)
|
exports["yaca-voice"]:enableRadio(false)
|
||||||
elseif selected == "pma" then
|
elseif selected == "pma" then
|
||||||
@@ -151,6 +151,10 @@ function Bridge.Radio.Leave()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function Bridge.Radio.Leave()
|
||||||
|
leave_provider(resolve_provider())
|
||||||
|
end
|
||||||
|
|
||||||
function Bridge.Radio.SetVolume(volume)
|
function Bridge.Radio.SetVolume(volume)
|
||||||
local selected = resolve_provider()
|
local selected = resolve_provider()
|
||||||
if selected == "yaca" then
|
if selected == "yaca" then
|
||||||
@@ -165,6 +169,17 @@ function Bridge.Radio.SetVolume(volume)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
AddEventHandler("sky_phone:configurator:updated", function()
|
||||||
|
if configured_voice_provider == Config.Radio.VoiceProvider then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
leave_provider(provider)
|
||||||
|
provider = nil
|
||||||
|
configured_voice_provider = Config.Radio.VoiceProvider
|
||||||
|
TriggerEvent("sky_phone:client:radioProviderUpdated")
|
||||||
|
end)
|
||||||
|
|
||||||
AddEventHandler("onResourceStop", function(resource_name)
|
AddEventHandler("onResourceStop", function(resource_name)
|
||||||
if resource_name == GetCurrentResourceName() then
|
if resource_name == GetCurrentResourceName() then
|
||||||
Bridge.Radio.Leave()
|
Bridge.Radio.Leave()
|
||||||
|
|||||||
@@ -111,3 +111,8 @@ end
|
|||||||
|
|
||||||
AddEventHandler("onResourceStart", reset_provider)
|
AddEventHandler("onResourceStart", reset_provider)
|
||||||
AddEventHandler("onResourceStop", reset_provider)
|
AddEventHandler("onResourceStop", reset_provider)
|
||||||
|
|
||||||
|
AddEventHandler("sky_phone:configurator:serverUpdated", function()
|
||||||
|
selected_provider_name = nil
|
||||||
|
last_provider_state = nil
|
||||||
|
end)
|
||||||
|
|||||||
@@ -41,20 +41,18 @@ if not supported_inventories[configured_inventory] then
|
|||||||
error(("[sky_phone] Unsupported or unavailable inventory '%s'. Configure a supported inventory adapter."):format(tostring(configured_inventory)))
|
error(("[sky_phone] Unsupported or unavailable inventory '%s'. Configure a supported inventory adapter."):format(tostring(configured_inventory)))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Bridge.Inventory.Name = configured_inventory
|
||||||
|
|
||||||
if configured_inventory == "hex" or configured_inventory == "esx" then
|
if configured_inventory == "hex" or configured_inventory == "esx" then
|
||||||
if Bridge.Framework.GetName() ~= "esx" then
|
if Bridge.Framework.GetName() ~= "esx" then
|
||||||
error(("[sky_phone] Inventory '%s' is only supported with ESX."):format(configured_inventory))
|
Bridge.Inventory.ConfigurationError =
|
||||||
end
|
("[sky_phone] Inventory '%s' is only supported with ESX."):format(configured_inventory)
|
||||||
if Config.Phone.Unique ~= false then
|
elseif Config.Phone.Unique ~= false or Config.Sim.Enabled ~= false then
|
||||||
error(("[sky_phone] Inventory '%s' cannot store unique phone metadata. Set Config.Phone.Unique = false or configure a metadata-capable inventory."):format(configured_inventory))
|
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.")
|
||||||
end
|
:format(configured_inventory)
|
||||||
if Config.Sim.Enabled ~= false then
|
|
||||||
error(("[sky_phone] Inventory '%s' cannot store physical SIM metadata. Set Config.Sim.Enabled = false or configure a metadata-capable inventory."):format(configured_inventory))
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Bridge.Inventory.Name = configured_inventory
|
|
||||||
|
|
||||||
function Bridge.Inventory.NormalizeItem(item, metadata_field)
|
function Bridge.Inventory.NormalizeItem(item, metadata_field)
|
||||||
if not item then
|
if not item then
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -38,13 +38,12 @@ function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
|
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
|
||||||
local slot = Bridge.Inventory.GetSlot(source, slot_id)
|
local numeric_slot = tonumber(slot_id)
|
||||||
if not slot then
|
if not numeric_slot then
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
inventory:updateMetadata(source, slot.slot, metadata or {})
|
inventory:setMetadata(source, numeric_slot, metadata or {})
|
||||||
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
|
return true
|
||||||
return updated and Bridge.Inventory.MetadataMatches(updated.metadata, metadata or {}) or false
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function Bridge.Inventory.CanCarryItem()
|
function Bridge.Inventory.CanCarryItem()
|
||||||
|
|||||||
@@ -15,12 +15,16 @@ local function normalize(item)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local metadata = {}
|
||||||
|
for key, value in pairs(type(item.info) == "table" and item.info or {}) do
|
||||||
|
metadata[key] = value
|
||||||
|
end
|
||||||
return {
|
return {
|
||||||
name = item.name,
|
name = item.name,
|
||||||
slot = tonumber(item.slot),
|
slot = tonumber(item.slot),
|
||||||
count = tonumber(item.amount) or 0,
|
count = tonumber(item.amount) or 0,
|
||||||
amount = tonumber(item.amount) or 0,
|
amount = tonumber(item.amount) or 0,
|
||||||
metadata = type(item.info) == "table" and item.info or {},
|
metadata = metadata,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -77,11 +81,33 @@ function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
|
|||||||
if not player then
|
if not player then
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
player.PlayerData.items[slot.slot].info = metadata or {}
|
local requested_metadata = type(metadata) == "table" and metadata or {}
|
||||||
|
player.PlayerData.items[slot.slot].info = requested_metadata
|
||||||
player.Functions.SetInventory(player.PlayerData.items, true)
|
player.Functions.SetInventory(player.PlayerData.items, true)
|
||||||
return true
|
local updated = player.PlayerData.items[slot.slot]
|
||||||
|
return updated ~= nil and Bridge.Inventory.MetadataMatches(updated.info, requested_metadata)
|
||||||
end
|
end
|
||||||
return inventory:SetItemData(source, slot.name, "info", metadata or {}, slot.slot) == true
|
|
||||||
|
local amount = tonumber(slot.amount or slot.count) or 0
|
||||||
|
if amount <= 0 then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local requested_metadata = type(metadata) == "table" and metadata or {}
|
||||||
|
if inventory:RemoveItem(source, slot.name, amount, slot.slot, "sky_phone:metadata-update") ~= true then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
if inventory:AddItem(source, slot.name, amount, slot.slot, requested_metadata, "sky_phone:metadata-update") ~= true then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
|
||||||
|
return updated
|
||||||
|
and updated.name == slot.name
|
||||||
|
and updated.amount == amount
|
||||||
|
and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata)
|
||||||
|
or false
|
||||||
end
|
end
|
||||||
|
|
||||||
function Bridge.Inventory.CanCarryItem(source, item_name, count)
|
function Bridge.Inventory.CanCarryItem(source, item_name, count)
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
local required_methods = {
|
||||||
|
"GetResourceName",
|
||||||
|
"GetSlot",
|
||||||
|
"GetSlotsWithItem",
|
||||||
|
"SetSlotMetadata",
|
||||||
|
"CanCarryItem",
|
||||||
|
"AddItem",
|
||||||
|
"RemoveItem",
|
||||||
|
"RegisterUsableItem",
|
||||||
|
}
|
||||||
|
|
||||||
|
local configuration_error = Bridge.Inventory.ConfigurationError
|
||||||
|
|
||||||
|
for _, method_name in ipairs(required_methods) do
|
||||||
|
if configuration_error then
|
||||||
|
Bridge.Inventory[method_name] = function()
|
||||||
|
error(configuration_error, 2)
|
||||||
|
end
|
||||||
|
elseif type(Bridge.Inventory[method_name]) ~= "function" then
|
||||||
|
local contract_error = ("[sky_phone] Inventory adapter '%s' is missing required method '%s'.")
|
||||||
|
:format(tostring(Bridge.Inventory.Name), method_name)
|
||||||
|
Bridge.Inventory[method_name] = function()
|
||||||
|
error(contract_error, 2)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -384,6 +384,12 @@ reevaluate = function(force)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
AddEventHandler("sky_phone:configurator:updated", function()
|
||||||
|
animation_state.revision = animation_state.revision + 1
|
||||||
|
cleanup_phone()
|
||||||
|
reevaluate(true)
|
||||||
|
end)
|
||||||
|
|
||||||
local function reset_animation_state()
|
local function reset_animation_state()
|
||||||
animation_state.phone_open = false
|
animation_state.phone_open = false
|
||||||
animation_state.call_state = nil
|
animation_state.call_state = nil
|
||||||
|
|||||||
@@ -448,6 +448,13 @@ RegisterNUICallback("media:requestUpload", function(data, cb)
|
|||||||
cb({ success = false, error = "invalid_request" })
|
cb({ success = false, error = "invalid_request" })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
Bridge.Debug(
|
||||||
|
"debug",
|
||||||
|
"[sky_phone][media-debug] NUI requested an upload (correlation=%s, type=%s).",
|
||||||
|
tostring(data.correlationId),
|
||||||
|
tostring(data.mediaType),
|
||||||
|
{ notice = true }
|
||||||
|
)
|
||||||
TriggerServerEvent("sky_phone:media:request-upload", data)
|
TriggerServerEvent("sky_phone:media:request-upload", data)
|
||||||
cb({ success = true })
|
cb({ success = true })
|
||||||
end)
|
end)
|
||||||
@@ -457,6 +464,15 @@ RegisterNUICallback("media:completeUpload", function(data, cb)
|
|||||||
cb({ success = false, error = "invalid_request" })
|
cb({ success = false, error = "invalid_request" })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
Bridge.Debug(
|
||||||
|
"debug",
|
||||||
|
"[sky_phone][media-debug] NUI completed the provider upload (correlation=%s, remote-id=%s, url=%s, original-url=%s).",
|
||||||
|
tostring(data.correlationId),
|
||||||
|
type(data.remoteId) == "string" and "present" or "missing",
|
||||||
|
type(data.url) == "string" and "present" or "missing",
|
||||||
|
type(data.originalUrl) == "string" and "present" or "missing",
|
||||||
|
{ notice = true }
|
||||||
|
)
|
||||||
TriggerServerEvent("sky_phone:media:complete-upload", data)
|
TriggerServerEvent("sky_phone:media:complete-upload", data)
|
||||||
cb({ success = true })
|
cb({ success = true })
|
||||||
end)
|
end)
|
||||||
@@ -475,6 +491,16 @@ RegisterNUICallback("media:failUpload", function(data, cb)
|
|||||||
cb({ success = false, error = "invalid_request" })
|
cb({ success = false, error = "invalid_request" })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
local debug_message = tostring(data.debugMessage or "unknown"):gsub("[\r\n]", " "):sub(1, 240)
|
||||||
|
Bridge.Debug(
|
||||||
|
"error",
|
||||||
|
"[sky_phone][media-debug] NUI reported an upload failure (correlation=%s, error=%s, stage=%s, status=%s, detail=%s).",
|
||||||
|
tostring(data.correlationId),
|
||||||
|
tostring(data.error),
|
||||||
|
tostring(data.debugStage),
|
||||||
|
tostring(data.debugStatus),
|
||||||
|
debug_message
|
||||||
|
)
|
||||||
TriggerServerEvent("sky_phone:media:fail-upload", data)
|
TriggerServerEvent("sky_phone:media:fail-upload", data)
|
||||||
cb({ success = true })
|
cb({ success = true })
|
||||||
end)
|
end)
|
||||||
@@ -534,10 +560,26 @@ RegisterNUICallback("memos:failUpload", function(data, cb)
|
|||||||
end)
|
end)
|
||||||
|
|
||||||
RegisterNetEvent("sky_phone:media:upload-ready", function(data)
|
RegisterNetEvent("sky_phone:media:upload-ready", function(data)
|
||||||
|
Bridge.Debug(
|
||||||
|
"debug",
|
||||||
|
"[sky_phone][media-debug] Client received upload-ready (correlation=%s, type=%s, presigned-url=%s).",
|
||||||
|
tostring(type(data) == "table" and data.correlationId),
|
||||||
|
tostring(type(data) == "table" and data.mediaType),
|
||||||
|
type(data) == "table" and type(data.presignedUrl) == "string" and "present" or "missing",
|
||||||
|
{ notice = true }
|
||||||
|
)
|
||||||
SendNUIMessage({ type = "media:uploadReady", data = data })
|
SendNUIMessage({ type = "media:uploadReady", data = data })
|
||||||
end)
|
end)
|
||||||
|
|
||||||
RegisterNetEvent("sky_phone:media:upload-result", function(data)
|
RegisterNetEvent("sky_phone:media:upload-result", function(data)
|
||||||
|
Bridge.Debug(
|
||||||
|
"debug",
|
||||||
|
"[sky_phone][media-debug] Client received upload-result (correlation=%s, success=%s, error=%s).",
|
||||||
|
tostring(type(data) == "table" and data.correlationId),
|
||||||
|
tostring(type(data) == "table" and data.success),
|
||||||
|
tostring(type(data) == "table" and data.error),
|
||||||
|
{ notice = true }
|
||||||
|
)
|
||||||
SendNUIMessage({ type = "media:uploadResult", data = data })
|
SendNUIMessage({ type = "media:uploadResult", data = data })
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|||||||
@@ -1567,19 +1567,49 @@ SkyPhoneApps.SendCatalog = send_catalog
|
|||||||
SkyPhoneApps.SetPhoneOpen = set_phone_open
|
SkyPhoneApps.SetPhoneOpen = set_phone_open
|
||||||
SkyPhoneApps.RegisterClientHooks = register_bundled_client_hooks
|
SkyPhoneApps.RegisterClientHooks = register_bundled_client_hooks
|
||||||
|
|
||||||
if Config.CustomApps.Enabled and Config.CustomApps.BundledApps then
|
local function refresh_bundled_apps()
|
||||||
local bundled = SkyPhoneApps.GetBundledManifests()
|
local manifests = Config.CustomApps.Enabled and Config.CustomApps.BundledApps
|
||||||
for index = 1, #bundled do
|
and SkyPhoneApps.GetBundledManifests()
|
||||||
local registered, register_error = register_app(normalize_bundled_manifest(bundled[index]))
|
or {}
|
||||||
if not registered then
|
local desired_ids = {}
|
||||||
error(("[sky_phone] Could not register bundled custom app '%s': %s"):format(
|
for index = 1, #manifests do
|
||||||
bundled[index].id,
|
desired_ids[manifests[index].id] = true
|
||||||
register_error
|
end
|
||||||
))
|
|
||||||
|
local removals = {}
|
||||||
|
for app_id, app in pairs(apps_by_id) do
|
||||||
|
if app.catalog.bundled and not desired_ids[app_id] then
|
||||||
|
removals[#removals + 1] = app_id
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
for index = 1, #removals do
|
||||||
|
remove_registered_app(removals[index], true, false)
|
||||||
|
end
|
||||||
|
|
||||||
|
for index = 1, #manifests do
|
||||||
|
local manifest = manifests[index]
|
||||||
|
local existing = apps_by_id[manifest.id]
|
||||||
|
local normalized = normalize_bundled_manifest(manifest)
|
||||||
|
if existing and existing.catalog.bundled then
|
||||||
|
normalized.hooks = existing.hooks
|
||||||
|
apps_by_id[manifest.id] = normalized
|
||||||
|
elseif not existing then
|
||||||
|
local registered, register_error = register_app(normalized)
|
||||||
|
if not registered then
|
||||||
|
error(("[sky_phone] Could not register bundled custom app '%s': %s"):format(
|
||||||
|
manifest.id,
|
||||||
|
register_error
|
||||||
|
))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
sync_catalog()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
refresh_bundled_apps()
|
||||||
|
|
||||||
|
AddEventHandler("sky_phone:configurator:updated", refresh_bundled_apps)
|
||||||
|
|
||||||
SkyPhoneApps.CompatibilityCore = {
|
SkyPhoneApps.CompatibilityCore = {
|
||||||
Add = add_compatibility_app,
|
Add = add_compatibility_app,
|
||||||
AddServerAuthorized = add_server_authorized_compatibility_app,
|
AddServerAuthorized = add_server_authorized_compatibility_app,
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ SkyPhoneFocus = {}
|
|||||||
local blocked_phone_controls = { 24, 140, 141, 142, 257, 263, 264 }
|
local blocked_phone_controls = { 24, 140, 141, 142, 257, 263, 264 }
|
||||||
local blocked_phone_look_controls = { 1, 2, 3, 4, 5, 6 }
|
local blocked_phone_look_controls = { 1, 2, 3, 4, 5, 6 }
|
||||||
local focused_control_groups = { 0, 1, 2 }
|
local focused_control_groups = { 0, 1, 2 }
|
||||||
|
local hold_to_look_enabled = false
|
||||||
|
local hold_to_look_control
|
||||||
local state = {
|
local state = {
|
||||||
|
admin_panel_open = false,
|
||||||
activity_suspended = false,
|
activity_suspended = false,
|
||||||
allow_movement = Config.Phone.AllowMovement,
|
allow_movement = false,
|
||||||
call_focus = false,
|
call_focus = false,
|
||||||
camera_active = false,
|
camera_active = false,
|
||||||
camera_nui_focused = true,
|
camera_nui_focused = true,
|
||||||
@@ -13,6 +16,7 @@ local state = {
|
|||||||
external_game_input = nil,
|
external_game_input = nil,
|
||||||
external_game_input_owner = nil,
|
external_game_input_owner = nil,
|
||||||
is_open = false,
|
is_open = false,
|
||||||
|
look_passthrough = false,
|
||||||
notification_focus = false,
|
notification_focus = false,
|
||||||
payphone_focus = false,
|
payphone_focus = false,
|
||||||
sim_picker_open = false,
|
sim_picker_open = false,
|
||||||
@@ -22,6 +26,38 @@ local block_game = false
|
|||||||
local block_look = false
|
local block_look = false
|
||||||
local game_input = false
|
local game_input = false
|
||||||
|
|
||||||
|
local function refresh_focus_configuration()
|
||||||
|
local hold_to_look_config = Config.Phone.HoldToLook
|
||||||
|
local enabled = type(hold_to_look_config) == "table" and hold_to_look_config.Enabled == true
|
||||||
|
local control = enabled and tonumber(hold_to_look_config.Control) or nil
|
||||||
|
if enabled and (
|
||||||
|
not control
|
||||||
|
or control ~= math.floor(control)
|
||||||
|
or control < 0
|
||||||
|
) then
|
||||||
|
error("[sky_phone] Config.Phone.HoldToLook.Control must be a non-negative whole control index.")
|
||||||
|
end
|
||||||
|
|
||||||
|
hold_to_look_enabled = enabled
|
||||||
|
hold_to_look_control = control
|
||||||
|
state.allow_movement = Config.Phone.AllowMovement == true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function allows_game_input(value)
|
||||||
|
local allowed = value.external_game_input
|
||||||
|
if allowed == nil then
|
||||||
|
allowed = value.allow_movement
|
||||||
|
end
|
||||||
|
return allowed == true
|
||||||
|
end
|
||||||
|
|
||||||
|
refresh_focus_configuration()
|
||||||
|
|
||||||
|
AddEventHandler("sky_phone:configurator:updated", function()
|
||||||
|
refresh_focus_configuration()
|
||||||
|
SkyPhoneFocus.Reapply()
|
||||||
|
end)
|
||||||
|
|
||||||
function SkyPhoneFocus.ApplyFocusedControls()
|
function SkyPhoneFocus.ApplyFocusedControls()
|
||||||
for _, group in ipairs(focused_control_groups) do
|
for _, group in ipairs(focused_control_groups) do
|
||||||
DisableAllControlActions(group)
|
DisableAllControlActions(group)
|
||||||
@@ -42,6 +78,15 @@ function SkyPhoneFocus.ApplyGameInputControls(block_look)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function SkyPhoneFocus.Resolve(state)
|
function SkyPhoneFocus.Resolve(state)
|
||||||
|
if state.admin_panel_open then
|
||||||
|
return {
|
||||||
|
block_game = true,
|
||||||
|
cursor = true,
|
||||||
|
focused = true,
|
||||||
|
game_input = false,
|
||||||
|
keep_input = false,
|
||||||
|
}
|
||||||
|
end
|
||||||
if state.activity_suspended then
|
if state.activity_suspended then
|
||||||
return { block_game = false, cursor = false, focused = false, game_input = false, keep_input = false }
|
return { block_game = false, cursor = false, focused = false, game_input = false, keep_input = false }
|
||||||
end
|
end
|
||||||
@@ -51,20 +96,19 @@ function SkyPhoneFocus.Resolve(state)
|
|||||||
if state.camera_active and not state.camera_nui_focused then
|
if state.camera_active and not state.camera_nui_focused then
|
||||||
return { block_game = false, block_look = false, cursor = false, focused = true, game_input = true, keep_input = true }
|
return { block_game = false, block_look = false, cursor = false, focused = true, game_input = true, keep_input = true }
|
||||||
end
|
end
|
||||||
local allow_game_input = state.external_game_input
|
local game_input = state.is_open
|
||||||
if allow_game_input == nil then
|
and allows_game_input(state)
|
||||||
allow_game_input = state.allow_movement
|
and not state.camera_active
|
||||||
end
|
and not state.text_input_focused
|
||||||
local game_input = state.is_open and allow_game_input and not state.camera_active
|
|
||||||
local focused = state.is_open
|
local focused = state.is_open
|
||||||
or state.notification_focus
|
or state.notification_focus
|
||||||
or state.payphone_focus
|
or state.payphone_focus
|
||||||
or state.sim_picker_open
|
or state.sim_picker_open
|
||||||
or (state.camera_active and state.camera_nui_focused)
|
or (state.camera_active and state.camera_nui_focused)
|
||||||
local cursor = focused and not (state.is_open and state.cursor_disabled)
|
local cursor = focused and not (state.is_open and (state.cursor_disabled or state.look_passthrough))
|
||||||
return {
|
return {
|
||||||
block_game = cursor and (not game_input or state.text_input_focused),
|
block_game = cursor and not game_input,
|
||||||
block_look = game_input and not state.cursor_disabled,
|
block_look = game_input and cursor,
|
||||||
cursor = cursor,
|
cursor = cursor,
|
||||||
focused = focused,
|
focused = focused,
|
||||||
game_input = game_input,
|
game_input = game_input,
|
||||||
@@ -73,6 +117,16 @@ function SkyPhoneFocus.Resolve(state)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function SkyPhoneFocus.Reapply()
|
function SkyPhoneFocus.Reapply()
|
||||||
|
if state.look_passthrough and (
|
||||||
|
not hold_to_look_enabled
|
||||||
|
or not state.is_open
|
||||||
|
or state.cursor_disabled
|
||||||
|
or state.text_input_focused
|
||||||
|
or state.camera_active
|
||||||
|
or not allows_game_input(state)
|
||||||
|
) then
|
||||||
|
state.look_passthrough = false
|
||||||
|
end
|
||||||
local focus = SkyPhoneFocus.Resolve(state)
|
local focus = SkyPhoneFocus.Resolve(state)
|
||||||
SetNuiFocus(focus.focused, focus.cursor)
|
SetNuiFocus(focus.focused, focus.cursor)
|
||||||
SetNuiFocusKeepInput(focus.keep_input)
|
SetNuiFocusKeepInput(focus.keep_input)
|
||||||
@@ -90,6 +144,7 @@ end
|
|||||||
function SkyPhoneFocus.BeginNuiHydration()
|
function SkyPhoneFocus.BeginNuiHydration()
|
||||||
-- Browser-owned focus claims cannot survive a CEF reload.
|
-- Browser-owned focus claims cannot survive a CEF reload.
|
||||||
state.notification_focus = false
|
state.notification_focus = false
|
||||||
|
state.look_passthrough = false
|
||||||
state.text_input_focused = false
|
state.text_input_focused = false
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -107,6 +162,16 @@ function SkyPhoneFocus.SetPhone(open, cursor_disabled)
|
|||||||
state.cursor_disabled = false
|
state.cursor_disabled = false
|
||||||
state.external_game_input = nil
|
state.external_game_input = nil
|
||||||
state.external_game_input_owner = nil
|
state.external_game_input_owner = nil
|
||||||
|
state.look_passthrough = false
|
||||||
|
state.text_input_focused = false
|
||||||
|
end
|
||||||
|
SkyPhoneFocus.Reapply()
|
||||||
|
end
|
||||||
|
|
||||||
|
function SkyPhoneFocus.SetAdminPanel(open)
|
||||||
|
state.admin_panel_open = open == true
|
||||||
|
if state.admin_panel_open then
|
||||||
|
state.notification_focus = false
|
||||||
state.text_input_focused = false
|
state.text_input_focused = false
|
||||||
end
|
end
|
||||||
SkyPhoneFocus.Reapply()
|
SkyPhoneFocus.Reapply()
|
||||||
@@ -124,6 +189,9 @@ end
|
|||||||
|
|
||||||
function SkyPhoneFocus.SetTextInputFocused(active)
|
function SkyPhoneFocus.SetTextInputFocused(active)
|
||||||
state.text_input_focused = active == true
|
state.text_input_focused = active == true
|
||||||
|
if state.text_input_focused then
|
||||||
|
state.look_passthrough = false
|
||||||
|
end
|
||||||
SkyPhoneFocus.Reapply()
|
SkyPhoneFocus.Reapply()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -142,6 +210,7 @@ function SkyPhoneFocus.SetExternalGameInput(owner_resource, allow_game_input)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function SkyPhoneFocus.Reset()
|
function SkyPhoneFocus.Reset()
|
||||||
|
state.admin_panel_open = false
|
||||||
state.activity_suspended = false
|
state.activity_suspended = false
|
||||||
state.call_focus = false
|
state.call_focus = false
|
||||||
state.camera_active = false
|
state.camera_active = false
|
||||||
@@ -150,6 +219,7 @@ function SkyPhoneFocus.Reset()
|
|||||||
state.external_game_input = nil
|
state.external_game_input = nil
|
||||||
state.external_game_input_owner = nil
|
state.external_game_input_owner = nil
|
||||||
state.is_open = false
|
state.is_open = false
|
||||||
|
state.look_passthrough = false
|
||||||
state.notification_focus = false
|
state.notification_focus = false
|
||||||
state.payphone_focus = false
|
state.payphone_focus = false
|
||||||
state.sim_picker_open = false
|
state.sim_picker_open = false
|
||||||
@@ -164,6 +234,14 @@ end
|
|||||||
CreateThread(function()
|
CreateThread(function()
|
||||||
while true do
|
while true do
|
||||||
if game_input or block_game then
|
if game_input or block_game then
|
||||||
|
local look_passthrough = hold_to_look_enabled
|
||||||
|
and game_input
|
||||||
|
and not state.cursor_disabled
|
||||||
|
and IsControlPressed(0, hold_to_look_control)
|
||||||
|
if look_passthrough ~= state.look_passthrough then
|
||||||
|
state.look_passthrough = look_passthrough
|
||||||
|
SkyPhoneFocus.Reapply()
|
||||||
|
end
|
||||||
if block_game then
|
if block_game then
|
||||||
SkyPhoneFocus.ApplyFocusedControls()
|
SkyPhoneFocus.ApplyFocusedControls()
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ local vehicle_mods = {
|
|||||||
local phone_locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
|
local phone_locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
|
||||||
local garage_locale = phone_locale.Nui.Apps.garage
|
local garage_locale = phone_locale.Nui.Apps.garage
|
||||||
|
|
||||||
|
AddEventHandler("sky_phone:configurator:updated", function()
|
||||||
|
phone_locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
|
||||||
|
garage_locale = phone_locale.Nui.Apps.garage
|
||||||
|
end)
|
||||||
|
|
||||||
local function vehicle_kind(model_hash, fallback)
|
local function vehicle_kind(model_hash, fallback)
|
||||||
if IsThisModelABoat(model_hash) then
|
if IsThisModelABoat(model_hash) then
|
||||||
return "boat"
|
return "boat"
|
||||||
|
|||||||
@@ -8,6 +8,17 @@ local equipped_phone_number = nil
|
|||||||
local nui_generation = 0
|
local nui_generation = 0
|
||||||
local live_activity_active = false
|
local live_activity_active = false
|
||||||
local open_home_requested = false
|
local open_home_requested = false
|
||||||
|
local admin_panel_open = false
|
||||||
|
local suggested_admin_command = nil
|
||||||
|
local suggested_test_data_command = nil
|
||||||
|
local active_development_command = nil
|
||||||
|
local registered_development_commands = {}
|
||||||
|
local active_key_mapping_command = nil
|
||||||
|
local active_key_mapping_key = nil
|
||||||
|
local key_mapping_revision = 0
|
||||||
|
local refresh_development_command
|
||||||
|
local refresh_phone_key_mapping
|
||||||
|
local refresh_test_data_command_suggestion
|
||||||
|
|
||||||
local function get_equipped_phone_number()
|
local function get_equipped_phone_number()
|
||||||
if not device_payload or not device_payload.device.sim then
|
if not device_payload or not device_payload.device.sim then
|
||||||
@@ -64,6 +75,26 @@ Bridge.Debug("debug", "[sky_phone] Client script initialized.", { always = true
|
|||||||
|
|
||||||
local locale, locale_name = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
|
local locale, locale_name = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
|
||||||
|
|
||||||
|
local function send_admin_panel_open()
|
||||||
|
SendNUIMessage({
|
||||||
|
type = "admin:open",
|
||||||
|
data = {
|
||||||
|
lang = locale_name,
|
||||||
|
locales = locale.Nui,
|
||||||
|
fallbackLocales = Locales.en.Nui,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
local function close_admin_panel()
|
||||||
|
if not admin_panel_open then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
admin_panel_open = false
|
||||||
|
SkyPhoneFocus.SetAdminPanel(false)
|
||||||
|
SendNUIMessage({ type = "admin:close" })
|
||||||
|
end
|
||||||
|
|
||||||
local function send_open_message()
|
local function send_open_message()
|
||||||
if not device_payload then
|
if not device_payload then
|
||||||
return
|
return
|
||||||
@@ -82,6 +113,39 @@ local function send_open_message()
|
|||||||
open_home_requested = false
|
open_home_requested = false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function refresh_admin_command_suggestion()
|
||||||
|
if suggested_admin_command then
|
||||||
|
TriggerEvent("chat:removeSuggestion", "/" .. suggested_admin_command)
|
||||||
|
suggested_admin_command = nil
|
||||||
|
end
|
||||||
|
if Config.AdminPanel.Enabled then
|
||||||
|
suggested_admin_command = Config.AdminPanel.Command
|
||||||
|
TriggerEvent(
|
||||||
|
"chat:addSuggestion",
|
||||||
|
"/" .. suggested_admin_command,
|
||||||
|
locale.AdminCommand.CommandDescription
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
AddEventHandler("sky_phone:configurator:updated", function()
|
||||||
|
locale, locale_name = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
|
||||||
|
refresh_development_command()
|
||||||
|
refresh_phone_key_mapping()
|
||||||
|
refresh_test_data_command_suggestion()
|
||||||
|
refresh_admin_command_suggestion()
|
||||||
|
SkyPhoneApps.SendCatalog()
|
||||||
|
if is_open and device_payload then
|
||||||
|
device_payload.lang = locale_name
|
||||||
|
device_payload.locales = locale.Nui
|
||||||
|
device_payload.fallbackLocales = Locales.en.Nui
|
||||||
|
SendNUIMessage({ type = "device:updated", data = device_payload })
|
||||||
|
end
|
||||||
|
if admin_panel_open then
|
||||||
|
send_admin_panel_open()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
local function open_phone()
|
local function open_phone()
|
||||||
if is_open or not device_payload then
|
if is_open or not device_payload then
|
||||||
return
|
return
|
||||||
@@ -152,18 +216,46 @@ AddEventHandler("sky_phone:client:forceClose", function()
|
|||||||
close_phone()
|
close_phone()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
if Config.Phone.DevelopmentCommand then
|
local function run_development_command()
|
||||||
RegisterCommand(Config.Command, function()
|
if is_open or open_requested then
|
||||||
if is_open or open_requested then
|
close_phone()
|
||||||
close_phone()
|
return
|
||||||
return
|
end
|
||||||
end
|
|
||||||
open_without_focus = false
|
open_without_focus = false
|
||||||
Bridge.Callbacks.Trigger("sky_phone:device:development-open", {})
|
Bridge.Callbacks.Trigger("sky_phone:device:development-open", {})
|
||||||
end, false)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
RegisterCommand("sky_phone_toggle", function()
|
refresh_development_command = function()
|
||||||
|
local command_name = Config.Phone.DevelopmentCommand and Config.Command or nil
|
||||||
|
if command_name ~= nil and (type(command_name) ~= "string" or command_name == "") then
|
||||||
|
error("[sky_phone] Config.Command must be a non-empty command name.")
|
||||||
|
end
|
||||||
|
|
||||||
|
if active_development_command then
|
||||||
|
TriggerEvent("chat:removeSuggestion", "/" .. active_development_command)
|
||||||
|
end
|
||||||
|
|
||||||
|
active_development_command = command_name
|
||||||
|
if not command_name then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if not registered_development_commands[command_name] then
|
||||||
|
registered_development_commands[command_name] = true
|
||||||
|
RegisterCommand(command_name, function()
|
||||||
|
if active_development_command == command_name and Config.Phone.DevelopmentCommand then
|
||||||
|
run_development_command()
|
||||||
|
end
|
||||||
|
end, false)
|
||||||
|
end
|
||||||
|
|
||||||
|
TriggerEvent("chat:addSuggestion", "/" .. command_name, locale.CommandDescription)
|
||||||
|
end
|
||||||
|
|
||||||
|
refresh_development_command()
|
||||||
|
|
||||||
|
local function run_phone_toggle()
|
||||||
if is_open or open_requested then
|
if is_open or open_requested then
|
||||||
close_phone()
|
close_phone()
|
||||||
return
|
return
|
||||||
@@ -171,7 +263,7 @@ RegisterCommand("sky_phone_toggle", function()
|
|||||||
|
|
||||||
open_without_focus = false
|
open_without_focus = false
|
||||||
Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
|
Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
|
||||||
end, false)
|
end
|
||||||
|
|
||||||
RegisterCommand("sky_phone_live_activity_open", function()
|
RegisterCommand("sky_phone_live_activity_open", function()
|
||||||
if not live_activity_active or is_open or open_requested then
|
if not live_activity_active or is_open or open_requested then
|
||||||
@@ -185,6 +277,36 @@ RegisterCommand("sky_phone_live_activity_open", function()
|
|||||||
end
|
end
|
||||||
end, false)
|
end, false)
|
||||||
|
|
||||||
|
RegisterCommand("sky_phone_toggle", run_phone_toggle, false)
|
||||||
|
|
||||||
|
refresh_phone_key_mapping = function()
|
||||||
|
local key_name = Config.Phone.Keybind
|
||||||
|
if key_name ~= false and key_name ~= nil and (type(key_name) ~= "string" or key_name == "") then
|
||||||
|
error("[sky_phone] Config.Phone.Keybind must be a non-empty keyboard key name or false.")
|
||||||
|
end
|
||||||
|
if key_name == active_key_mapping_key then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
active_key_mapping_key = key_name
|
||||||
|
active_key_mapping_command = nil
|
||||||
|
if not key_name then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
key_mapping_revision = key_mapping_revision + 1
|
||||||
|
local command_name = "sky_phone_toggle_config_" .. key_mapping_revision
|
||||||
|
active_key_mapping_command = command_name
|
||||||
|
RegisterCommand(command_name, function()
|
||||||
|
if active_key_mapping_command == command_name then
|
||||||
|
run_phone_toggle()
|
||||||
|
end
|
||||||
|
end, false)
|
||||||
|
RegisterKeyMapping(command_name, locale.Controls.OpenPhone, "keyboard", key_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
refresh_phone_key_mapping()
|
||||||
|
|
||||||
RegisterKeyMapping(
|
RegisterKeyMapping(
|
||||||
"sky_phone_live_activity_open",
|
"sky_phone_live_activity_open",
|
||||||
locale.Controls.OpenPhone,
|
locale.Controls.OpenPhone,
|
||||||
@@ -192,14 +314,19 @@ RegisterKeyMapping(
|
|||||||
"SPACE"
|
"SPACE"
|
||||||
)
|
)
|
||||||
|
|
||||||
if Config.Phone.Keybind then
|
refresh_test_data_command_suggestion = function()
|
||||||
if type(Config.Phone.Keybind) ~= "string" or Config.Phone.Keybind == "" then
|
if suggested_test_data_command then
|
||||||
error("[sky_phone] Config.Phone.Keybind must be a non-empty keyboard key name or false.")
|
TriggerEvent("chat:removeSuggestion", "/" .. suggested_test_data_command)
|
||||||
|
suggested_test_data_command = nil
|
||||||
|
end
|
||||||
|
if Config.TestData.Enabled then
|
||||||
|
suggested_test_data_command = Config.TestData.Command
|
||||||
|
TriggerEvent("chat:addSuggestion", "/" .. suggested_test_data_command, locale.TestData.CommandDescription)
|
||||||
end
|
end
|
||||||
|
|
||||||
RegisterKeyMapping("sky_phone_toggle", locale.Controls.OpenPhone, "keyboard", Config.Phone.Keybind)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
refresh_test_data_command_suggestion()
|
||||||
|
|
||||||
RegisterNetEvent("sky_phone:testdata:feedback", function(success, detail)
|
RegisterNetEvent("sky_phone:testdata:feedback", function(success, detail)
|
||||||
local test_data_locale = locale.TestData
|
local test_data_locale = locale.TestData
|
||||||
local message = success and test_data_locale.Success or test_data_locale.Failed
|
local message = success and test_data_locale.Success or test_data_locale.Failed
|
||||||
@@ -224,6 +351,9 @@ RegisterNUICallback("ui:ready", function(data, cb)
|
|||||||
if open_requested and device_payload then
|
if open_requested and device_payload then
|
||||||
send_open_message()
|
send_open_message()
|
||||||
end
|
end
|
||||||
|
if admin_panel_open then
|
||||||
|
send_admin_panel_open()
|
||||||
|
end
|
||||||
SkyPhoneCalls.ReplayNui()
|
SkyPhoneCalls.ReplayNui()
|
||||||
SkyPhoneSimPicker.ReplayNui()
|
SkyPhoneSimPicker.ReplayNui()
|
||||||
SkyPhoneFocus.Reapply()
|
SkyPhoneFocus.Reapply()
|
||||||
@@ -269,12 +399,42 @@ RegisterNUICallback("ui:opened", function(data, cb)
|
|||||||
cb({ success = true })
|
cb({ success = true })
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
RegisterNetEvent("sky_phone:admin:launch", function()
|
||||||
|
if is_open or open_requested then
|
||||||
|
close_phone()
|
||||||
|
end
|
||||||
|
admin_panel_open = true
|
||||||
|
SkyPhoneFocus.SetAdminPanel(true)
|
||||||
|
send_admin_panel_open()
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNetEvent("sky_phone:admin:command-error", function(error_code)
|
||||||
|
local messages = locale.AdminCommand.Errors
|
||||||
|
Bridge.Framework.Notify(
|
||||||
|
"iFruit",
|
||||||
|
messages[error_code] or messages.default,
|
||||||
|
"error",
|
||||||
|
5000
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
RegisterNUICallback("admin:close", function(data, cb)
|
||||||
|
if type(data) ~= "table" then
|
||||||
|
cb({ success = false, error = "invalid_request" })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
close_admin_panel()
|
||||||
|
cb({ success = true })
|
||||||
|
end)
|
||||||
|
|
||||||
RegisterNUICallback("ui:input-focus", function(data, cb)
|
RegisterNUICallback("ui:input-focus", function(data, cb)
|
||||||
if type(data) ~= "table" or type(data.active) ~= "boolean" then
|
if type(data) ~= "table" or type(data.active) ~= "boolean" then
|
||||||
cb({ success = false, error = "invalid_request" })
|
cb({ success = false, error = "invalid_request" })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
SkyPhoneFocus.SetTextInputFocused(data.active and (is_open or open_requested))
|
SkyPhoneFocus.SetTextInputFocused(
|
||||||
|
data.active and (is_open or open_requested or admin_panel_open)
|
||||||
|
)
|
||||||
cb({ success = true })
|
cb({ success = true })
|
||||||
end)
|
end)
|
||||||
|
|
||||||
@@ -353,12 +513,7 @@ RegisterNetEvent("sky_phone:device:error", function(error_code)
|
|||||||
end)
|
end)
|
||||||
|
|
||||||
CreateThread(function()
|
CreateThread(function()
|
||||||
if Config.Phone.DevelopmentCommand then
|
refresh_admin_command_suggestion()
|
||||||
TriggerEvent("chat:addSuggestion", "/" .. Config.Command, locale.CommandDescription)
|
|
||||||
end
|
|
||||||
if Config.TestData.Enabled then
|
|
||||||
TriggerEvent("chat:addSuggestion", "/" .. Config.TestData.Command, locale.TestData.CommandDescription)
|
|
||||||
end
|
|
||||||
end)
|
end)
|
||||||
|
|
||||||
AddEventHandler("onResourceStop", function(resource_name)
|
AddEventHandler("onResourceStop", function(resource_name)
|
||||||
@@ -369,16 +524,20 @@ AddEventHandler("onResourceStop", function(resource_name)
|
|||||||
is_open = false
|
is_open = false
|
||||||
open_requested = false
|
open_requested = false
|
||||||
open_without_focus = false
|
open_without_focus = false
|
||||||
|
admin_panel_open = false
|
||||||
|
|
||||||
TriggerEvent("sky_phone:animation:reset")
|
TriggerEvent("sky_phone:animation:reset")
|
||||||
SkyPhoneCalls.Reset()
|
SkyPhoneCalls.Reset()
|
||||||
SkyPhoneSimPicker.Reset()
|
SkyPhoneSimPicker.Reset()
|
||||||
SkyPhoneFocus.Reset()
|
SkyPhoneFocus.Reset()
|
||||||
|
|
||||||
if Config.Phone.DevelopmentCommand then
|
if active_development_command then
|
||||||
TriggerEvent("chat:removeSuggestion", "/" .. Config.Command)
|
TriggerEvent("chat:removeSuggestion", "/" .. active_development_command)
|
||||||
end
|
end
|
||||||
if Config.TestData.Enabled then
|
if suggested_test_data_command then
|
||||||
TriggerEvent("chat:removeSuggestion", "/" .. Config.TestData.Command)
|
TriggerEvent("chat:removeSuggestion", "/" .. suggested_test_data_command)
|
||||||
|
end
|
||||||
|
if suggested_admin_command then
|
||||||
|
TriggerEvent("chat:removeSuggestion", "/" .. suggested_admin_command)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|||||||
@@ -159,6 +159,11 @@ end)
|
|||||||
local phone_locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
|
local phone_locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
|
||||||
local app_locales = phone_locale.Nui.Apps
|
local app_locales = phone_locale.Nui.Apps
|
||||||
|
|
||||||
|
AddEventHandler("sky_phone:configurator:updated", function()
|
||||||
|
phone_locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
|
||||||
|
app_locales = phone_locale.Nui.Apps
|
||||||
|
end)
|
||||||
|
|
||||||
RegisterNetEvent("sky_phone:mail:new", function(data)
|
RegisterNetEvent("sky_phone:mail:new", function(data)
|
||||||
local locale = app_locales.mail
|
local locale = app_locales.mail
|
||||||
data.title = locale.name
|
data.title = locale.name
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
local callback_groups = {
|
local callback_groups = {
|
||||||
account = [[login register logout devices remove-device]],
|
account = [[login register logout devices remove-device]],
|
||||||
|
admin = [[
|
||||||
|
bootstrap player save-apps reveal-password activity
|
||||||
|
reset-passcode change-number factory-reset configurator save-configurator
|
||||||
|
]],
|
||||||
banking = [[overview transfer]],
|
banking = [[overview transfer]],
|
||||||
billing = [[overview list detail markRead pay dispute]],
|
billing = [[overview list detail markRead pay dispute]],
|
||||||
calendar = [[list create update delete]],
|
calendar = [[list create update delete]],
|
||||||
|
|||||||
@@ -21,10 +21,16 @@ local remote_visuals = {}
|
|||||||
local custom_payphone_props = {}
|
local custom_payphone_props = {}
|
||||||
|
|
||||||
local configured_models = {}
|
local configured_models = {}
|
||||||
for _, model_name in ipairs(Config.Payphones.Props or {}) do
|
|
||||||
configured_models[joaat(model_name)] = model_name
|
local function refresh_configured_models()
|
||||||
|
configured_models = {}
|
||||||
|
for _, model_name in ipairs(Config.Payphones.Props or {}) do
|
||||||
|
configured_models[joaat(model_name)] = model_name
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
refresh_configured_models()
|
||||||
|
|
||||||
local function valid_visual_number(value, maximum)
|
local function valid_visual_number(value, maximum)
|
||||||
local number = tonumber(value)
|
local number = tonumber(value)
|
||||||
if not number or number ~= number or math.abs(number) > maximum then
|
if not number or number ~= number or math.abs(number) > maximum then
|
||||||
@@ -156,6 +162,23 @@ end
|
|||||||
|
|
||||||
CreateThread(spawn_custom_payphones)
|
CreateThread(spawn_custom_payphones)
|
||||||
|
|
||||||
|
local function clear_custom_payphones()
|
||||||
|
for _, entity in ipairs(custom_payphone_props) do
|
||||||
|
if DoesEntityExist(entity) then
|
||||||
|
SetEntityAsMissionEntity(entity, true, true)
|
||||||
|
DeleteEntity(entity)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
custom_payphone_props = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
AddEventHandler("sky_phone:configurator:updated", function()
|
||||||
|
locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
|
||||||
|
refresh_configured_models()
|
||||||
|
clear_custom_payphones()
|
||||||
|
CreateThread(spawn_custom_payphones)
|
||||||
|
end)
|
||||||
|
|
||||||
local function load_animation(dictionary)
|
local function load_animation(dictionary)
|
||||||
if HasAnimDictLoaded(dictionary) then
|
if HasAnimDictLoaded(dictionary) then
|
||||||
return true
|
return true
|
||||||
@@ -726,10 +749,5 @@ AddEventHandler("onResourceStop", function(resource_name)
|
|||||||
for _, id in ipairs(visual_ids) do
|
for _, id in ipairs(visual_ids) do
|
||||||
restore_remote_visual(id)
|
restore_remote_visual(id)
|
||||||
end
|
end
|
||||||
for _, entity in ipairs(custom_payphone_props) do
|
clear_custom_payphones()
|
||||||
if DoesEntityExist(entity) then
|
|
||||||
SetEntityAsMissionEntity(entity, true, true)
|
|
||||||
DeleteEntity(entity)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end)
|
end)
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
local function deserialize_value(value)
|
||||||
|
if type(value) ~= "table" then
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
|
||||||
|
if value.__skyType == "vector2" then
|
||||||
|
return vector2(tonumber(value.x) or 0.0, tonumber(value.y) or 0.0)
|
||||||
|
end
|
||||||
|
if value.__skyType == "vector3" then
|
||||||
|
return vector3(tonumber(value.x) or 0.0, tonumber(value.y) or 0.0, tonumber(value.z) or 0.0)
|
||||||
|
end
|
||||||
|
if value.__skyType == "vector4" then
|
||||||
|
return vector4(
|
||||||
|
tonumber(value.x) or 0.0,
|
||||||
|
tonumber(value.y) or 0.0,
|
||||||
|
tonumber(value.z) or 0.0,
|
||||||
|
tonumber(value.w) or 0.0
|
||||||
|
)
|
||||||
|
end
|
||||||
|
if value.__skyType == "map" then
|
||||||
|
local decoded = {}
|
||||||
|
for _, entry in ipairs(value.entries or {}) do
|
||||||
|
local key = entry.keyType == "number" and tonumber(entry.key) or entry.key
|
||||||
|
decoded[key] = deserialize_value(entry.value)
|
||||||
|
end
|
||||||
|
return decoded
|
||||||
|
end
|
||||||
|
|
||||||
|
local decoded = {}
|
||||||
|
for key, child in pairs(value) do
|
||||||
|
decoded[key] = deserialize_value(child)
|
||||||
|
end
|
||||||
|
return decoded
|
||||||
|
end
|
||||||
|
|
||||||
|
local function apply_runtime_table(current, replacement)
|
||||||
|
for key in pairs(current) do
|
||||||
|
if replacement[key] == nil then
|
||||||
|
current[key] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for key, value in pairs(replacement) do
|
||||||
|
local current_value = current[key]
|
||||||
|
if type(current_value) == "table" and type(value) == "table" then
|
||||||
|
apply_runtime_table(current_value, value)
|
||||||
|
else
|
||||||
|
current[key] = value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function apply_runtime_config(payload)
|
||||||
|
if type(payload) ~= "table" or payload.enabled ~= true then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if type(payload.config) ~= "table" then
|
||||||
|
error("[sky_phone] Phone configurator received an invalid client configuration payload.")
|
||||||
|
end
|
||||||
|
|
||||||
|
local runtime_config = deserialize_value(payload.config)
|
||||||
|
for key, value in pairs(runtime_config) do
|
||||||
|
if type(Config[key]) == "table" and type(value) == "table" then
|
||||||
|
apply_runtime_table(Config[key], value)
|
||||||
|
else
|
||||||
|
Config[key] = value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
TriggerEvent("sky_phone:configurator:updated", tonumber(payload.revision) or 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
RegisterNetEvent("sky_phone:configurator:sync", function(payload)
|
||||||
|
apply_runtime_config(payload)
|
||||||
|
end)
|
||||||
|
|
||||||
|
local response = Bridge.Callbacks.Trigger("sky_phone:configurator:runtime", {})
|
||||||
|
if not response or not response.success or type(response.data) ~= "table" then
|
||||||
|
if Config.PhoneConfigurator.Enabled then
|
||||||
|
error("[sky_phone] Phone configurator failed to load the client runtime configuration.")
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
apply_runtime_config(response.data)
|
||||||
@@ -44,6 +44,18 @@ local CLIENT_CAPABILITIES = {
|
|||||||
side = "client",
|
side = "client",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
local function refresh_configured_capabilities()
|
||||||
|
local enabled = Config.CustomApps.Enabled == true
|
||||||
|
local external = enabled and Config.CustomApps.ExternalApps == true
|
||||||
|
CLIENT_CAPABILITIES.features.customApps.enabled = enabled
|
||||||
|
CLIENT_CAPABILITIES.features.customApps.external = external
|
||||||
|
CLIENT_CAPABILITIES.features.notifications.customApps = external
|
||||||
|
end
|
||||||
|
|
||||||
|
refresh_configured_capabilities()
|
||||||
|
|
||||||
|
AddEventHandler("sky_phone:configurator:updated", refresh_configured_capabilities)
|
||||||
|
|
||||||
local custom_app_api = SkyPhoneApps.ClientPublicApi
|
local custom_app_api = SkyPhoneApps.ClientPublicApi
|
||||||
if type(custom_app_api) ~= "table" then
|
if type(custom_app_api) ~= "table" then
|
||||||
error("[sky_phone] Client public API initialized before the custom app API.")
|
error("[sky_phone] Client public API initialized before the custom app API.")
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ local function send_hud_config()
|
|||||||
SendNUIMessage({ type = "radio:hud-config", data = get_hud_config() })
|
SendNUIMessage({ type = "radio:hud-config", data = get_hud_config() })
|
||||||
end
|
end
|
||||||
|
|
||||||
|
AddEventHandler("sky_phone:configurator:updated", function()
|
||||||
|
send_hud_config()
|
||||||
|
end)
|
||||||
|
|
||||||
local function send_hud_members()
|
local function send_hud_members()
|
||||||
local combined = {}
|
local combined = {}
|
||||||
for channel_id = 1, 2 do
|
for channel_id = 1, 2 do
|
||||||
@@ -156,6 +160,21 @@ local function leave_radio()
|
|||||||
return request("disconnect")
|
return request("disconnect")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
AddEventHandler("sky_phone:client:radioProviderUpdated", function()
|
||||||
|
if current_primary <= 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not Bridge.Radio.Join(current_primary, current_secondary) then
|
||||||
|
request("disconnect")
|
||||||
|
current_primary = 0
|
||||||
|
current_secondary = 0
|
||||||
|
clear_hud_members()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
Bridge.Radio.SetVolume(current_volume)
|
||||||
|
end)
|
||||||
|
|
||||||
RegisterNUICallback("radio:get", function(data, cb)
|
RegisterNUICallback("radio:get", function(data, cb)
|
||||||
if type(data) ~= "table" then
|
if type(data) ~= "table" then
|
||||||
cb({ success = false, error = "invalid_request" })
|
cb({ success = false, error = "invalid_request" })
|
||||||
|
|||||||
@@ -0,0 +1,930 @@
|
|||||||
|
Bridge.Database.AfterMigration("sky_phone", function()
|
||||||
|
local BUILTIN_APPS = {
|
||||||
|
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,
|
||||||
|
["app-store"] = true,
|
||||||
|
["local-pages"] = true,
|
||||||
|
mail = true,
|
||||||
|
map = true,
|
||||||
|
memos = true,
|
||||||
|
memory = 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
local DEFAULT_INSTALLED_APPS = {
|
||||||
|
["app-store"] = true,
|
||||||
|
calculator = true,
|
||||||
|
calendar = true,
|
||||||
|
camera = true,
|
||||||
|
citywarn = true,
|
||||||
|
clock = true,
|
||||||
|
health = true,
|
||||||
|
mail = true,
|
||||||
|
map = true,
|
||||||
|
memos = true,
|
||||||
|
messages = true,
|
||||||
|
notes = true,
|
||||||
|
phone = true,
|
||||||
|
photos = true,
|
||||||
|
settings = true,
|
||||||
|
weather = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
local PROTECTED_APPS = {
|
||||||
|
["app-store"] = true,
|
||||||
|
camera = true,
|
||||||
|
citywarn = true,
|
||||||
|
health = true,
|
||||||
|
mail = true,
|
||||||
|
messages = true,
|
||||||
|
phone = true,
|
||||||
|
photos = true,
|
||||||
|
settings = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
local function affected_rows(result)
|
||||||
|
if type(result) == "number" then
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
return type(result) == "table" and tonumber(result.affectedRows) or 0
|
||||||
|
end
|
||||||
|
|
||||||
|
local function trim(value)
|
||||||
|
if type(value) ~= "string" then
|
||||||
|
return ""
|
||||||
|
end
|
||||||
|
return value:match("^%s*(.-)%s*$")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function player_name(source)
|
||||||
|
local first_name = trim(Bridge.Framework.GetFirstname(source))
|
||||||
|
local last_name = trim(Bridge.Framework.GetLastname(source))
|
||||||
|
local character_name = trim((first_name .. " " .. last_name))
|
||||||
|
return character_name ~= "" and character_name or GetPlayerName(source) or ("Player %s"):format(source)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function require_admin(source, operation, maximum)
|
||||||
|
if not Config.AdminPanel.Enabled
|
||||||
|
or not Bridge.Framework.HasAdminGroup(source, Config.AdminPanel.AdminGroups)
|
||||||
|
then
|
||||||
|
Bridge.Debug("warn", "[sky_phone] Rejected admin panel access from source %s.", tostring(source))
|
||||||
|
return nil, { success = false, error = "not_authorized" }
|
||||||
|
end
|
||||||
|
if not SkyPhone.AllowOperation(source, "admin_" .. operation, maximum, 60) then
|
||||||
|
return nil, { success = false, error = "rate_limited" }
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local active_admin_command
|
||||||
|
|
||||||
|
local function run_admin_command(command_source)
|
||||||
|
local player_source = tonumber(command_source)
|
||||||
|
if not player_source or player_source < 1 then
|
||||||
|
Bridge.Debug("warn", "[sky_phone] The admin panel command can only be used by a player.")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not Config.AdminPanel.Enabled then
|
||||||
|
TriggerClientEvent("sky_phone:admin:command-error", player_source, "disabled")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not Bridge.Framework.HasAdminGroup(player_source, Config.AdminPanel.AdminGroups) then
|
||||||
|
Bridge.Debug(
|
||||||
|
"warn",
|
||||||
|
"[sky_phone] Rejected admin panel command from source %s.",
|
||||||
|
tostring(player_source)
|
||||||
|
)
|
||||||
|
TriggerClientEvent("sky_phone:admin:command-error", player_source, "not_authorized")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
TriggerClientEvent("sky_phone:admin:launch", player_source)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function register_admin_command()
|
||||||
|
local command_name = Config.AdminPanel.Command
|
||||||
|
if type(command_name) ~= "string" or command_name == "" then
|
||||||
|
error("[sky_phone] Config.AdminPanel.Command must be a non-empty command name.")
|
||||||
|
end
|
||||||
|
if command_name == active_admin_command then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
active_admin_command = command_name
|
||||||
|
RegisterCommand(command_name, function(command_source)
|
||||||
|
if active_admin_command == command_name then
|
||||||
|
run_admin_command(command_source)
|
||||||
|
end
|
||||||
|
end, false)
|
||||||
|
end
|
||||||
|
|
||||||
|
register_admin_command()
|
||||||
|
|
||||||
|
AddEventHandler("sky_phone:configurator:serverUpdated", function()
|
||||||
|
register_admin_command()
|
||||||
|
end)
|
||||||
|
|
||||||
|
local function normalize_source(value)
|
||||||
|
local player_source = tonumber(value)
|
||||||
|
if not player_source or player_source < 1 or player_source ~= math.floor(player_source) then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if not Bridge.Framework.GetIdentifier(player_source) then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return player_source
|
||||||
|
end
|
||||||
|
|
||||||
|
local function collect_device_imeis(source, identifier)
|
||||||
|
local imeis = {}
|
||||||
|
local seen = {}
|
||||||
|
local function add_imei(imei)
|
||||||
|
if SkyPhoneImei.IsValid(imei) and not seen[imei] then
|
||||||
|
seen[imei] = true
|
||||||
|
imeis[#imeis + 1] = imei
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, item in ipairs(Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)) do
|
||||||
|
add_imei(item.metadata and item.metadata.imei)
|
||||||
|
end
|
||||||
|
|
||||||
|
local rows = Bridge.Database.Query([[
|
||||||
|
SELECT `device_imei` AS `imei`
|
||||||
|
FROM `sky_phone_character_devices`
|
||||||
|
WHERE `owner_identifier` = ?
|
||||||
|
UNION
|
||||||
|
SELECT device.`imei`
|
||||||
|
FROM `sky_phone_devices` device
|
||||||
|
JOIN `sky_phone_sims` sim ON sim.`id` = device.`sim_id`
|
||||||
|
WHERE sim.`owner_identifier` = ?
|
||||||
|
]], { identifier, identifier })
|
||||||
|
for _, row in ipairs(rows) do
|
||||||
|
add_imei(row.imei)
|
||||||
|
end
|
||||||
|
|
||||||
|
table.sort(imeis)
|
||||||
|
return imeis
|
||||||
|
end
|
||||||
|
|
||||||
|
local function normalize_app_ids(value)
|
||||||
|
local normalized = {}
|
||||||
|
local seen = {}
|
||||||
|
if type(value) ~= "table" then
|
||||||
|
return normalized
|
||||||
|
end
|
||||||
|
|
||||||
|
for index = 1, #value do
|
||||||
|
local app_id = value[index]
|
||||||
|
if type(app_id) == "string"
|
||||||
|
and #app_id > 0
|
||||||
|
and #app_id <= 64
|
||||||
|
and not seen[app_id]
|
||||||
|
then
|
||||||
|
seen[app_id] = true
|
||||||
|
normalized[#normalized + 1] = app_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return normalized
|
||||||
|
end
|
||||||
|
|
||||||
|
local function load_app_payload(encoded)
|
||||||
|
if type(encoded) ~= "string" or encoded == "" then
|
||||||
|
return {}, {}, {}
|
||||||
|
end
|
||||||
|
local payload = json.decode(encoded)
|
||||||
|
if type(payload) ~= "table" then
|
||||||
|
error("[sky_phone] Stored admin target app payload is not a JSON object.")
|
||||||
|
end
|
||||||
|
return payload, normalize_app_ids(payload.claimedApps), normalize_app_ids(payload.uninstalledApps)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function load_player_devices(source, identifier)
|
||||||
|
local imeis = collect_device_imeis(source, identifier)
|
||||||
|
if #imeis == 0 then
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
|
||||||
|
local placeholders = {}
|
||||||
|
for index = 1, #imeis do
|
||||||
|
placeholders[index] = "?"
|
||||||
|
end
|
||||||
|
local rows = Bridge.Database.Query(([[
|
||||||
|
SELECT device.`imei`, device.`device_name`, device.`created_at`, device.`updated_at`,
|
||||||
|
sim.`phone_number`, sim.`sim_type`, sim.`registered_at`,
|
||||||
|
account.`id` AS `account_id`, account.`email` AS `account_email`,
|
||||||
|
security.`passcode_length`, security.`failed_attempts`, security.`locked_until`,
|
||||||
|
app_data.`payload` AS `apps_payload`, app_data.`revision` AS `apps_revision`
|
||||||
|
FROM `sky_phone_devices` device
|
||||||
|
LEFT JOIN `sky_phone_sims` sim ON sim.`id` = device.`sim_id`
|
||||||
|
LEFT JOIN `sky_phone_accounts` account ON account.`id` = device.`account_id`
|
||||||
|
LEFT JOIN `sky_phone_device_security` security ON security.`device_imei` = device.`imei`
|
||||||
|
LEFT JOIN `sky_phone_device_data` app_data
|
||||||
|
ON app_data.`device_imei` = device.`imei` AND app_data.`namespace` = 'apps'
|
||||||
|
WHERE device.`imei` IN (%s)
|
||||||
|
ORDER BY device.`updated_at` DESC, device.`imei` ASC
|
||||||
|
]]):format(table.concat(placeholders, ", ")), imeis)
|
||||||
|
|
||||||
|
local devices = {}
|
||||||
|
for _, row in ipairs(rows) do
|
||||||
|
local _, claimed_apps, uninstalled_apps = load_app_payload(row.apps_payload)
|
||||||
|
devices[#devices + 1] = {
|
||||||
|
imei = row.imei,
|
||||||
|
name = row.device_name,
|
||||||
|
createdAt = row.created_at,
|
||||||
|
updatedAt = row.updated_at,
|
||||||
|
number = row.phone_number,
|
||||||
|
simType = row.sim_type,
|
||||||
|
simRegistered = row.registered_at ~= nil,
|
||||||
|
account = row.account_id and {
|
||||||
|
id = tonumber(row.account_id),
|
||||||
|
email = row.account_email,
|
||||||
|
passwordAvailable = true,
|
||||||
|
} or nil,
|
||||||
|
security = {
|
||||||
|
enabled = row.passcode_length ~= nil,
|
||||||
|
length = row.passcode_length and tonumber(row.passcode_length) or nil,
|
||||||
|
failedAttempts = tonumber(row.failed_attempts) or 0,
|
||||||
|
lockedUntil = tonumber(row.locked_until) or 0,
|
||||||
|
},
|
||||||
|
apps = {
|
||||||
|
claimed = claimed_apps,
|
||||||
|
uninstalled = uninstalled_apps,
|
||||||
|
revision = tonumber(row.apps_revision) or 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
end
|
||||||
|
return devices
|
||||||
|
end
|
||||||
|
|
||||||
|
local function build_player_summary(source)
|
||||||
|
local identifier = Bridge.Framework.GetIdentifier(source)
|
||||||
|
local devices = load_player_devices(source, identifier)
|
||||||
|
local job = Bridge.Framework.GetJob(source)
|
||||||
|
return {
|
||||||
|
source = source,
|
||||||
|
identifier = identifier,
|
||||||
|
name = player_name(source),
|
||||||
|
serverName = GetPlayerName(source) or "",
|
||||||
|
job = job.label ~= "" and job.label or job.name,
|
||||||
|
grade = job.grade,
|
||||||
|
onDuty = job.onDuty,
|
||||||
|
deviceCount = #devices,
|
||||||
|
phoneNumber = devices[1] and devices[1].number or nil,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function list_players()
|
||||||
|
local sources = Bridge.Framework.GetPlayers()
|
||||||
|
table.sort(sources, function(left, right)
|
||||||
|
return tonumber(left) < tonumber(right)
|
||||||
|
end)
|
||||||
|
|
||||||
|
local players = {}
|
||||||
|
local maximum = math.max(1, math.floor(tonumber(Config.AdminPanel.MaximumPlayers) or 128))
|
||||||
|
for index = 1, math.min(#sources, maximum) do
|
||||||
|
local player_source = tonumber(sources[index])
|
||||||
|
if player_source and Bridge.Framework.GetIdentifier(player_source) then
|
||||||
|
players[#players + 1] = build_player_summary(player_source)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return players
|
||||||
|
end
|
||||||
|
|
||||||
|
local function load_player_detail(source)
|
||||||
|
local identifier = Bridge.Framework.GetIdentifier(source)
|
||||||
|
local job = Bridge.Framework.GetJob(source)
|
||||||
|
return {
|
||||||
|
source = source,
|
||||||
|
identifier = identifier,
|
||||||
|
name = player_name(source),
|
||||||
|
serverName = GetPlayerName(source) or "",
|
||||||
|
firstName = trim(Bridge.Framework.GetFirstname(source)),
|
||||||
|
lastName = trim(Bridge.Framework.GetLastname(source)),
|
||||||
|
birthdate = trim(Bridge.Framework.GetBirthdate(source)),
|
||||||
|
job = {
|
||||||
|
name = job.name,
|
||||||
|
label = job.label,
|
||||||
|
grade = job.grade,
|
||||||
|
gradeLabel = job.gradeLabel,
|
||||||
|
onDuty = job.onDuty,
|
||||||
|
},
|
||||||
|
money = {
|
||||||
|
bank = tonumber(Bridge.Framework.GetMoney(source, "bank")) or 0,
|
||||||
|
cash = tonumber(Bridge.Framework.GetMoney(source, "cash")) or 0,
|
||||||
|
currency = Config.Banking.Currency,
|
||||||
|
},
|
||||||
|
devices = load_player_devices(source, identifier),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function find_owned_device(source, imei)
|
||||||
|
local identifier = Bridge.Framework.GetIdentifier(source)
|
||||||
|
for _, device in ipairs(load_player_devices(source, identifier)) do
|
||||||
|
if device.imei == imei then
|
||||||
|
return device, identifier
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil, identifier
|
||||||
|
end
|
||||||
|
|
||||||
|
local function load_device_sim(imei)
|
||||||
|
local rows = Bridge.Database.Query([[
|
||||||
|
SELECT sim.`id`, sim.`phone_number`
|
||||||
|
FROM `sky_phone_devices` device
|
||||||
|
JOIN `sky_phone_sims` sim ON sim.`id` = device.`sim_id`
|
||||||
|
WHERE device.`imei` = ?
|
||||||
|
LIMIT 1
|
||||||
|
]], { imei })
|
||||||
|
return rows[1]
|
||||||
|
end
|
||||||
|
|
||||||
|
local function app_metadata(app_id)
|
||||||
|
if BUILTIN_APPS[app_id] then
|
||||||
|
return {
|
||||||
|
defaultInstalled = DEFAULT_INSTALLED_APPS[app_id] == true,
|
||||||
|
removable = PROTECTED_APPS[app_id] ~= true,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
if SkyPhoneApps.GetPolicy(app_id) then
|
||||||
|
return { defaultInstalled = false, removable = true }
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function remove_app_id(values, app_id)
|
||||||
|
local next_values = {}
|
||||||
|
for index = 1, #values do
|
||||||
|
if values[index] ~= app_id then
|
||||||
|
next_values[#next_values + 1] = values[index]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return next_values
|
||||||
|
end
|
||||||
|
|
||||||
|
local function add_app_id(values, app_id)
|
||||||
|
for index = 1, #values do
|
||||||
|
if values[index] == app_id then
|
||||||
|
return values
|
||||||
|
end
|
||||||
|
end
|
||||||
|
values[#values + 1] = app_id
|
||||||
|
return values
|
||||||
|
end
|
||||||
|
|
||||||
|
local function write_audit(actor_source, target_source, target_identifier, imei, action, details)
|
||||||
|
local actor_identifier = Bridge.Framework.GetIdentifier(actor_source)
|
||||||
|
local result = Bridge.Database.Query([[
|
||||||
|
INSERT INTO `sky_phone_admin_audit`
|
||||||
|
(`actor_identifier`, `actor_name`, `target_identifier`, `target_source`, `device_imei`, `action`, `details`)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
]], {
|
||||||
|
actor_identifier,
|
||||||
|
player_name(actor_source),
|
||||||
|
target_identifier,
|
||||||
|
target_source,
|
||||||
|
imei,
|
||||||
|
action,
|
||||||
|
json.encode(details or {}),
|
||||||
|
})
|
||||||
|
if affected_rows(result) ~= 1 then
|
||||||
|
error("[sky_phone] Admin audit insert did not affect exactly one row.")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function load_audit()
|
||||||
|
local limit = math.max(1, math.min(100, math.floor(tonumber(Config.AdminPanel.AuditLimit) or 40)))
|
||||||
|
local rows = Bridge.Database.Query(([[
|
||||||
|
SELECT `id`, `actor_name`, `target_identifier`, `target_source`, `device_imei`,
|
||||||
|
`action`, `details`, `created_at`
|
||||||
|
FROM `sky_phone_admin_audit`
|
||||||
|
ORDER BY `id` DESC
|
||||||
|
LIMIT %s
|
||||||
|
]]):format(limit), {})
|
||||||
|
local audit = {}
|
||||||
|
for _, row in ipairs(rows) do
|
||||||
|
audit[#audit + 1] = {
|
||||||
|
id = tonumber(row.id),
|
||||||
|
actorName = row.actor_name,
|
||||||
|
targetIdentifier = row.target_identifier,
|
||||||
|
targetSource = row.target_source and tonumber(row.target_source) or nil,
|
||||||
|
deviceImei = row.device_imei,
|
||||||
|
action = row.action,
|
||||||
|
details = json.decode(row.details),
|
||||||
|
createdAt = row.created_at,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
return audit
|
||||||
|
end
|
||||||
|
|
||||||
|
Bridge.Callbacks.Register("sky_phone:admin:bootstrap", function(source)
|
||||||
|
local authorized, error_response = require_admin(
|
||||||
|
source,
|
||||||
|
"bootstrap",
|
||||||
|
Config.AdminPanel.ReadRequestsPerMinute
|
||||||
|
)
|
||||||
|
if not authorized then
|
||||||
|
return error_response
|
||||||
|
end
|
||||||
|
|
||||||
|
local players = list_players()
|
||||||
|
local totals = Bridge.Database.Query([[
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM `sky_phone_devices`) AS `devices`,
|
||||||
|
(SELECT COUNT(*) FROM `sky_phone_accounts`) AS `accounts`
|
||||||
|
]], {})
|
||||||
|
return {
|
||||||
|
success = true,
|
||||||
|
data = {
|
||||||
|
players = players,
|
||||||
|
stats = {
|
||||||
|
online = #players,
|
||||||
|
devices = tonumber(totals[1] and totals[1].devices) or 0,
|
||||||
|
accounts = tonumber(totals[1] and totals[1].accounts) or 0,
|
||||||
|
},
|
||||||
|
audit = load_audit(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
Bridge.Callbacks.Register("sky_phone:admin:player", function(source, data)
|
||||||
|
local authorized, error_response = require_admin(
|
||||||
|
source,
|
||||||
|
"player",
|
||||||
|
Config.AdminPanel.ReadRequestsPerMinute
|
||||||
|
)
|
||||||
|
if not authorized then
|
||||||
|
return error_response
|
||||||
|
end
|
||||||
|
local target_source = normalize_source(data and data.source)
|
||||||
|
if not target_source then
|
||||||
|
return { success = false, error = "player_unavailable" }
|
||||||
|
end
|
||||||
|
return { success = true, data = load_player_detail(target_source) }
|
||||||
|
end)
|
||||||
|
|
||||||
|
Bridge.Callbacks.Register("sky_phone:admin:save-apps", function(source, data)
|
||||||
|
local authorized, error_response = require_admin(
|
||||||
|
source,
|
||||||
|
"save_apps",
|
||||||
|
Config.AdminPanel.ActionRequestsPerMinute
|
||||||
|
)
|
||||||
|
if not authorized then
|
||||||
|
return error_response
|
||||||
|
end
|
||||||
|
if type(data) ~= "table"
|
||||||
|
or not SkyPhoneImei.IsValid(data.imei)
|
||||||
|
or type(data.revision) ~= "number"
|
||||||
|
or data.revision < 0
|
||||||
|
or data.revision ~= math.floor(data.revision)
|
||||||
|
or type(data.changes) ~= "table"
|
||||||
|
or #data.changes < 1
|
||||||
|
or #data.changes > 128
|
||||||
|
then
|
||||||
|
return { success = false, error = "invalid_request" }
|
||||||
|
end
|
||||||
|
|
||||||
|
local target_source = normalize_source(data.source)
|
||||||
|
if not target_source then
|
||||||
|
return { success = false, error = "player_unavailable" }
|
||||||
|
end
|
||||||
|
local target_device, target_identifier = find_owned_device(target_source, data.imei)
|
||||||
|
if not target_device then
|
||||||
|
return { success = false, error = "device_not_owned" }
|
||||||
|
end
|
||||||
|
local normalized_changes = {}
|
||||||
|
local seen_apps = {}
|
||||||
|
for index = 1, #data.changes do
|
||||||
|
local change = data.changes[index]
|
||||||
|
if type(change) ~= "table"
|
||||||
|
or type(change.appId) ~= "string"
|
||||||
|
or #change.appId < 1
|
||||||
|
or #change.appId > 64
|
||||||
|
or type(change.installed) ~= "boolean"
|
||||||
|
or seen_apps[change.appId]
|
||||||
|
then
|
||||||
|
return { success = false, error = "invalid_request" }
|
||||||
|
end
|
||||||
|
local metadata = app_metadata(change.appId)
|
||||||
|
if not metadata then
|
||||||
|
return { success = false, error = "invalid_app" }
|
||||||
|
end
|
||||||
|
if not change.installed and not metadata.removable then
|
||||||
|
return { success = false, error = "app_protected" }
|
||||||
|
end
|
||||||
|
seen_apps[change.appId] = true
|
||||||
|
normalized_changes[#normalized_changes + 1] = {
|
||||||
|
appId = change.appId,
|
||||||
|
installed = change.installed,
|
||||||
|
metadata = metadata,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local rows = Bridge.Database.Query([[
|
||||||
|
SELECT `payload`, `revision`
|
||||||
|
FROM `sky_phone_device_data`
|
||||||
|
WHERE `device_imei` = ? AND `namespace` = 'apps'
|
||||||
|
LIMIT 1
|
||||||
|
]], { data.imei })
|
||||||
|
local current_revision = tonumber(rows[1] and rows[1].revision) or 0
|
||||||
|
if current_revision ~= data.revision then
|
||||||
|
return { success = false, error = "revision_conflict" }
|
||||||
|
end
|
||||||
|
|
||||||
|
local payload, claimed_apps, uninstalled_apps = load_app_payload(rows[1] and rows[1].payload)
|
||||||
|
for index = 1, #normalized_changes do
|
||||||
|
local change = normalized_changes[index]
|
||||||
|
if change.installed then
|
||||||
|
uninstalled_apps = remove_app_id(uninstalled_apps, change.appId)
|
||||||
|
if not change.metadata.defaultInstalled then
|
||||||
|
claimed_apps = add_app_id(claimed_apps, change.appId)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
claimed_apps = remove_app_id(claimed_apps, change.appId)
|
||||||
|
uninstalled_apps = add_app_id(uninstalled_apps, change.appId)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
payload.claimedApps = claimed_apps
|
||||||
|
payload.uninstalledApps = #uninstalled_apps > 0 and uninstalled_apps or nil
|
||||||
|
|
||||||
|
local encoded = json.encode(payload)
|
||||||
|
if #encoded > 100000 then
|
||||||
|
return { success = false, error = "payload_too_large" }
|
||||||
|
end
|
||||||
|
|
||||||
|
if rows[1] then
|
||||||
|
local result = Bridge.Database.Query([[
|
||||||
|
UPDATE `sky_phone_device_data`
|
||||||
|
SET `payload` = ?, `revision` = `revision` + 1
|
||||||
|
WHERE `device_imei` = ? AND `namespace` = 'apps' AND `revision` = ?
|
||||||
|
]], { encoded, data.imei, data.revision })
|
||||||
|
if affected_rows(result) ~= 1 then
|
||||||
|
return { success = false, error = "revision_conflict" }
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local result = Bridge.Database.Query([[
|
||||||
|
INSERT IGNORE INTO `sky_phone_device_data` (`device_imei`, `namespace`, `payload`)
|
||||||
|
VALUES (?, 'apps', ?)
|
||||||
|
]], { data.imei, encoded })
|
||||||
|
if affected_rows(result) ~= 1 then
|
||||||
|
return { success = false, error = "revision_conflict" }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for index = 1, #normalized_changes do
|
||||||
|
local change = normalized_changes[index]
|
||||||
|
write_audit(
|
||||||
|
source,
|
||||||
|
target_source,
|
||||||
|
target_identifier,
|
||||||
|
data.imei,
|
||||||
|
change.installed and "grant_app" or "revoke_app",
|
||||||
|
{ appId = change.appId }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
SkyPhone.RefreshDevice(data.imei)
|
||||||
|
return { success = true, data = load_player_detail(target_source) }
|
||||||
|
end)
|
||||||
|
|
||||||
|
Bridge.Callbacks.Register("sky_phone:admin:reveal-password", function(source, data)
|
||||||
|
local authorized, error_response = require_admin(
|
||||||
|
source,
|
||||||
|
"reveal_password",
|
||||||
|
Config.AdminPanel.CredentialRevealsPerMinute
|
||||||
|
)
|
||||||
|
if not authorized then
|
||||||
|
return error_response
|
||||||
|
end
|
||||||
|
if type(data) ~= "table" or not SkyPhoneImei.IsValid(data.imei) then
|
||||||
|
return { success = false, error = "invalid_request" }
|
||||||
|
end
|
||||||
|
|
||||||
|
local target_source = normalize_source(data.source)
|
||||||
|
if not target_source then
|
||||||
|
return { success = false, error = "player_unavailable" }
|
||||||
|
end
|
||||||
|
local device, target_identifier = find_owned_device(target_source, data.imei)
|
||||||
|
if not device then
|
||||||
|
return { success = false, error = "device_not_owned" }
|
||||||
|
end
|
||||||
|
|
||||||
|
local accounts = Bridge.Database.Query([[
|
||||||
|
SELECT account.`email`, account.`password`
|
||||||
|
FROM `sky_phone_devices` device
|
||||||
|
JOIN `sky_phone_accounts` account ON account.`id` = device.`account_id`
|
||||||
|
WHERE device.`imei` = ?
|
||||||
|
LIMIT 1
|
||||||
|
]], { data.imei })
|
||||||
|
if not accounts[1] then
|
||||||
|
return { success = false, error = "account_not_found" }
|
||||||
|
end
|
||||||
|
|
||||||
|
write_audit(
|
||||||
|
source,
|
||||||
|
target_source,
|
||||||
|
target_identifier,
|
||||||
|
data.imei,
|
||||||
|
"reveal_account_password",
|
||||||
|
{ email = accounts[1].email }
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
success = true,
|
||||||
|
data = {
|
||||||
|
email = accounts[1].email,
|
||||||
|
password = accounts[1].password,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
Bridge.Callbacks.Register("sky_phone:admin:activity", function(source, data)
|
||||||
|
local authorized, error_response = require_admin(
|
||||||
|
source,
|
||||||
|
"activity",
|
||||||
|
Config.AdminPanel.ReadRequestsPerMinute
|
||||||
|
)
|
||||||
|
if not authorized then
|
||||||
|
return error_response
|
||||||
|
end
|
||||||
|
if type(data) ~= "table"
|
||||||
|
or not SkyPhoneImei.IsValid(data.imei)
|
||||||
|
or (data.kind ~= "messages" and data.kind ~= "calls")
|
||||||
|
then
|
||||||
|
return { success = false, error = "invalid_request" }
|
||||||
|
end
|
||||||
|
|
||||||
|
local target_source = normalize_source(data.source)
|
||||||
|
if not target_source then
|
||||||
|
return { success = false, error = "player_unavailable" }
|
||||||
|
end
|
||||||
|
local device, target_identifier = find_owned_device(target_source, data.imei)
|
||||||
|
if not device then
|
||||||
|
return { success = false, error = "device_not_owned" }
|
||||||
|
end
|
||||||
|
local audit_action = data.kind == "messages" and "view_messages" or "view_calls"
|
||||||
|
local sim = load_device_sim(data.imei)
|
||||||
|
if not sim then
|
||||||
|
write_audit(
|
||||||
|
source,
|
||||||
|
target_source,
|
||||||
|
target_identifier,
|
||||||
|
data.imei,
|
||||||
|
audit_action,
|
||||||
|
{ count = 0 }
|
||||||
|
)
|
||||||
|
return { success = true, data = { kind = data.kind, entries = {} } }
|
||||||
|
end
|
||||||
|
|
||||||
|
local limit = math.max(1, math.min(100, math.floor(tonumber(Config.AdminPanel.ActivityLimit) or 40)))
|
||||||
|
local entries = {}
|
||||||
|
if data.kind == "messages" then
|
||||||
|
local rows = Bridge.Database.Query(([[
|
||||||
|
SELECT `id`, `sender_sim_id`, `sender_number`, `recipient_number`, `message_type`,
|
||||||
|
`body`, `read_at`, `created_at`
|
||||||
|
FROM `sky_phone_sms_messages`
|
||||||
|
WHERE `sender_sim_id` = ? OR `recipient_sim_id` = ?
|
||||||
|
ORDER BY `created_at` DESC
|
||||||
|
LIMIT %s
|
||||||
|
]]):format(limit), { sim.id, sim.id })
|
||||||
|
for _, row in ipairs(rows) do
|
||||||
|
local outgoing = row.sender_sim_id == sim.id
|
||||||
|
entries[#entries + 1] = {
|
||||||
|
id = row.id,
|
||||||
|
direction = outgoing and "outgoing" or "incoming",
|
||||||
|
otherNumber = outgoing and row.recipient_number or row.sender_number,
|
||||||
|
messageType = row.message_type,
|
||||||
|
body = row.body,
|
||||||
|
readAt = row.read_at,
|
||||||
|
createdAt = row.created_at,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local rows = Bridge.Database.Query(([[
|
||||||
|
SELECT `id`, `caller_sim_id`, `caller_number`, `callee_number`, `status`,
|
||||||
|
`started_at`, `answered_at`, `ended_at`, `duration_seconds`
|
||||||
|
FROM `sky_phone_calls`
|
||||||
|
WHERE `caller_sim_id` = ? OR `callee_sim_id` = ?
|
||||||
|
ORDER BY `started_at` DESC
|
||||||
|
LIMIT %s
|
||||||
|
]]):format(limit), { sim.id, sim.id })
|
||||||
|
for _, row in ipairs(rows) do
|
||||||
|
local outgoing = row.caller_sim_id == sim.id
|
||||||
|
entries[#entries + 1] = {
|
||||||
|
id = row.id,
|
||||||
|
direction = outgoing and "outgoing" or "incoming",
|
||||||
|
otherNumber = outgoing and row.callee_number or row.caller_number,
|
||||||
|
status = row.status,
|
||||||
|
startedAt = row.started_at,
|
||||||
|
answeredAt = row.answered_at,
|
||||||
|
endedAt = row.ended_at,
|
||||||
|
durationSeconds = tonumber(row.duration_seconds) or 0,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
write_audit(
|
||||||
|
source,
|
||||||
|
target_source,
|
||||||
|
target_identifier,
|
||||||
|
data.imei,
|
||||||
|
audit_action,
|
||||||
|
{ count = #entries }
|
||||||
|
)
|
||||||
|
return { success = true, data = { kind = data.kind, entries = entries } }
|
||||||
|
end)
|
||||||
|
|
||||||
|
Bridge.Callbacks.Register("sky_phone:admin:reset-passcode", function(source, data)
|
||||||
|
local authorized, error_response = require_admin(
|
||||||
|
source,
|
||||||
|
"reset_passcode",
|
||||||
|
Config.AdminPanel.ActionRequestsPerMinute
|
||||||
|
)
|
||||||
|
if not authorized then
|
||||||
|
return error_response
|
||||||
|
end
|
||||||
|
if type(data) ~= "table" or not SkyPhoneImei.IsValid(data.imei) then
|
||||||
|
return { success = false, error = "invalid_request" }
|
||||||
|
end
|
||||||
|
|
||||||
|
local target_source = normalize_source(data.source)
|
||||||
|
if not target_source then
|
||||||
|
return { success = false, error = "player_unavailable" }
|
||||||
|
end
|
||||||
|
local device, target_identifier = find_owned_device(target_source, data.imei)
|
||||||
|
if not device then
|
||||||
|
return { success = false, error = "device_not_owned" }
|
||||||
|
end
|
||||||
|
local result = Bridge.Database.Query(
|
||||||
|
"DELETE FROM `sky_phone_device_security` WHERE `device_imei` = ?",
|
||||||
|
{ data.imei }
|
||||||
|
)
|
||||||
|
if affected_rows(result) ~= 1 then
|
||||||
|
return { success = false, error = "passcode_not_set" }
|
||||||
|
end
|
||||||
|
|
||||||
|
write_audit(source, target_source, target_identifier, data.imei, "reset_passcode", {})
|
||||||
|
SkyPhone.RefreshDevice(data.imei)
|
||||||
|
return { success = true, data = load_player_detail(target_source) }
|
||||||
|
end)
|
||||||
|
|
||||||
|
Bridge.Callbacks.Register("sky_phone:admin:change-number", function(source, data)
|
||||||
|
local authorized, error_response = require_admin(
|
||||||
|
source,
|
||||||
|
"change_number",
|
||||||
|
Config.AdminPanel.ActionRequestsPerMinute
|
||||||
|
)
|
||||||
|
if not authorized then
|
||||||
|
return error_response
|
||||||
|
end
|
||||||
|
if type(data) ~= "table"
|
||||||
|
or not SkyPhoneImei.IsValid(data.imei)
|
||||||
|
or (type(data.phoneNumber) ~= "string" and type(data.phoneNumber) ~= "number")
|
||||||
|
then
|
||||||
|
return { success = false, error = "invalid_request" }
|
||||||
|
end
|
||||||
|
|
||||||
|
local target_source = normalize_source(data.source)
|
||||||
|
if not target_source then
|
||||||
|
return { success = false, error = "player_unavailable" }
|
||||||
|
end
|
||||||
|
local device, target_identifier = find_owned_device(target_source, data.imei)
|
||||||
|
if not device then
|
||||||
|
return { success = false, error = "device_not_owned" }
|
||||||
|
end
|
||||||
|
local sim = load_device_sim(data.imei)
|
||||||
|
if not sim then
|
||||||
|
return { success = false, error = "no_sim" }
|
||||||
|
end
|
||||||
|
|
||||||
|
local changed, number_or_error = SkyPhoneSim.ChangeNumber(
|
||||||
|
target_source,
|
||||||
|
data.imei,
|
||||||
|
sim.id,
|
||||||
|
data.phoneNumber
|
||||||
|
)
|
||||||
|
if not changed then
|
||||||
|
return { success = false, error = number_or_error }
|
||||||
|
end
|
||||||
|
|
||||||
|
SkyPhoneCompanies.ClearCallAvailability(target_source)
|
||||||
|
SkyPhoneCalls.EndForSim(sim.id, "number_changed")
|
||||||
|
write_audit(source, target_source, target_identifier, data.imei, "change_number", {
|
||||||
|
previousNumber = sim.phone_number,
|
||||||
|
phoneNumber = number_or_error,
|
||||||
|
})
|
||||||
|
SkyPhone.RefreshDevice(data.imei)
|
||||||
|
return { success = true, data = load_player_detail(target_source) }
|
||||||
|
end)
|
||||||
|
|
||||||
|
Bridge.Callbacks.Register("sky_phone:admin:factory-reset", function(source, data)
|
||||||
|
local authorized, error_response = require_admin(
|
||||||
|
source,
|
||||||
|
"factory_reset",
|
||||||
|
Config.AdminPanel.ActionRequestsPerMinute
|
||||||
|
)
|
||||||
|
if not authorized then
|
||||||
|
return error_response
|
||||||
|
end
|
||||||
|
if type(data) ~= "table" or not SkyPhoneImei.IsValid(data.imei) then
|
||||||
|
return { success = false, error = "invalid_request" }
|
||||||
|
end
|
||||||
|
|
||||||
|
local target_source = normalize_source(data.source)
|
||||||
|
if not target_source then
|
||||||
|
return { success = false, error = "player_unavailable" }
|
||||||
|
end
|
||||||
|
local device, target_identifier = find_owned_device(target_source, data.imei)
|
||||||
|
if not device then
|
||||||
|
return { success = false, error = "device_not_owned" }
|
||||||
|
end
|
||||||
|
|
||||||
|
local reset, phone_number_or_error = SkyPhonePersistence.FactoryReset(data.imei)
|
||||||
|
if not reset then
|
||||||
|
return { success = false, error = phone_number_or_error }
|
||||||
|
end
|
||||||
|
|
||||||
|
write_audit(source, target_source, target_identifier, data.imei, "factory_reset", {})
|
||||||
|
if phone_number_or_error then
|
||||||
|
TriggerEvent("sky_phone:server:factoryReset", target_source, phone_number_or_error)
|
||||||
|
end
|
||||||
|
SkyPhone.RefreshDevice(data.imei)
|
||||||
|
return { success = true, data = load_player_detail(target_source) }
|
||||||
|
end)
|
||||||
|
|
||||||
|
Bridge.Callbacks.Register("sky_phone:admin:configurator", function(source)
|
||||||
|
local authorized, error_response = require_admin(
|
||||||
|
source,
|
||||||
|
"configurator_read",
|
||||||
|
Config.AdminPanel.ReadRequestsPerMinute
|
||||||
|
)
|
||||||
|
if not authorized then
|
||||||
|
return error_response
|
||||||
|
end
|
||||||
|
|
||||||
|
return { success = true, data = SkyPhoneConfigurator.GetAdminData() }
|
||||||
|
end)
|
||||||
|
|
||||||
|
Bridge.Callbacks.Register("sky_phone:admin:save-configurator", function(source, data)
|
||||||
|
local authorized, error_response = require_admin(
|
||||||
|
source,
|
||||||
|
"configurator_save",
|
||||||
|
Config.AdminPanel.ActionRequestsPerMinute
|
||||||
|
)
|
||||||
|
if not authorized then
|
||||||
|
return error_response
|
||||||
|
end
|
||||||
|
if type(data) ~= "table" or type(data.changes) ~= "table" then
|
||||||
|
return { success = false, error = "invalid_request" }
|
||||||
|
end
|
||||||
|
|
||||||
|
local actor_identifier = Bridge.Framework.GetIdentifier(source)
|
||||||
|
if not actor_identifier then
|
||||||
|
return { success = false, error = "player_unavailable" }
|
||||||
|
end
|
||||||
|
local actor_name = player_name(source)
|
||||||
|
local response = SkyPhoneConfigurator.Save(
|
||||||
|
data.revision,
|
||||||
|
data.changes,
|
||||||
|
actor_identifier,
|
||||||
|
actor_name
|
||||||
|
)
|
||||||
|
if response.success then
|
||||||
|
write_audit(source, source, actor_identifier, nil, "save_configuration", {
|
||||||
|
changeCount = #data.changes,
|
||||||
|
revision = response.data.revision,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
return response
|
||||||
|
end)
|
||||||
|
end)
|
||||||
@@ -1206,12 +1206,18 @@ Bridge.Callbacks.Register("sky_phone:calls:dial", function(source, data)
|
|||||||
end)
|
end)
|
||||||
|
|
||||||
local payphone_models = {}
|
local payphone_models = {}
|
||||||
for _, model_name in ipairs(Config.Payphones.Props or {}) do
|
|
||||||
if type(model_name) == "string" then
|
local function refresh_payphone_models()
|
||||||
payphone_models[model_name] = true
|
payphone_models = {}
|
||||||
|
for _, model_name in ipairs(Config.Payphones.Props or {}) do
|
||||||
|
if type(model_name) == "string" then
|
||||||
|
payphone_models[model_name] = true
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
refresh_payphone_models()
|
||||||
|
|
||||||
if Config.Payphones.Enabled and not next(payphone_models) then
|
if Config.Payphones.Enabled and not next(payphone_models) then
|
||||||
Bridge.Debug(
|
Bridge.Debug(
|
||||||
"error",
|
"error",
|
||||||
@@ -1220,6 +1226,10 @@ if Config.Payphones.Enabled and not next(payphone_models) then
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
AddEventHandler("sky_phone:configurator:serverUpdated", function()
|
||||||
|
refresh_payphone_models()
|
||||||
|
end)
|
||||||
|
|
||||||
local function valid_payphone_position(source, detected_booth)
|
local function valid_payphone_position(source, detected_booth)
|
||||||
local ped = GetPlayerPed(source)
|
local ped = GetPlayerPed(source)
|
||||||
if not ped or ped == 0 then
|
if not ped or ped == 0 then
|
||||||
|
|||||||
@@ -905,9 +905,22 @@ local function cleanup_retained_data()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
validate_configuration()
|
local function refresh_runtime_configuration()
|
||||||
seed_companies()
|
definitions = {}
|
||||||
tombstone_removed_companies()
|
definition_ids = {}
|
||||||
|
definitions_by_job = {}
|
||||||
|
service_lines_by_number = {}
|
||||||
|
validate_configuration()
|
||||||
|
seed_companies()
|
||||||
|
tombstone_removed_companies()
|
||||||
|
end
|
||||||
|
|
||||||
|
refresh_runtime_configuration()
|
||||||
|
|
||||||
|
AddEventHandler("sky_phone:configurator:serverUpdated", function()
|
||||||
|
refresh_runtime_configuration()
|
||||||
|
end)
|
||||||
|
|
||||||
cleanup_retained_data()
|
cleanup_retained_data()
|
||||||
|
|
||||||
CreateThread(function()
|
CreateThread(function()
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
Bridge.Database.AfterMigration("sky_phone", function()
|
Bridge.Database.AfterMigration("sky_phone", function()
|
||||||
local password_pepper = tostring(Config.Server.CrewLinkPasswordPepper or "")
|
local password_pepper = tostring(Config.Server.CrewLinkPasswordPepper or "")
|
||||||
|
AddEventHandler("sky_phone:configurator:serverUpdated", function()
|
||||||
|
password_pepper = tostring(Config.Server.CrewLinkPasswordPepper or "")
|
||||||
|
end)
|
||||||
|
|
||||||
local role_levels = {
|
local role_levels = {
|
||||||
guest = 1,
|
guest = 1,
|
||||||
member = 2,
|
member = 2,
|
||||||
|
|||||||
@@ -316,9 +316,16 @@ local function ceil_div(value, divisor)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function initialize_markets()
|
local function initialize_markets()
|
||||||
|
local next_markets = {}
|
||||||
|
local next_market_order = {}
|
||||||
|
local next_market_dynamics = {}
|
||||||
for _, config in ipairs(Config.Crypto.Markets) do
|
for _, config in ipairs(Config.Crypto.Markets) do
|
||||||
markets[config.Id] = config
|
if next_markets[config.Id] then
|
||||||
market_order[#market_order + 1] = config.Id
|
error(("[sky_phone] Config.Crypto.Markets contains duplicate id '%s'."):format(config.Id))
|
||||||
|
end
|
||||||
|
next_markets[config.Id] = config
|
||||||
|
next_market_order[#next_market_order + 1] = config.Id
|
||||||
|
next_market_dynamics[config.Id] = market_dynamics[config.Id]
|
||||||
Bridge.Database.Query([[
|
Bridge.Database.Query([[
|
||||||
INSERT INTO `sky_phone_crypto_markets`
|
INSERT INTO `sky_phone_crypto_markets`
|
||||||
(`id`,`asset_scale`,`price_scale`,`issued_supply`,`price`,`version`,`status`)
|
(`id`,`asset_scale`,`price_scale`,`issued_supply`,`price`,`version`,`status`)
|
||||||
@@ -357,6 +364,10 @@ local function initialize_markets()
|
|||||||
INSERT INTO `sky_phone_crypto_balances` (`account_id`,`asset_id`,`available`)
|
INSERT INTO `sky_phone_crypto_balances` (`account_id`,`asset_id`,`available`)
|
||||||
VALUES ('treasury', 'CASH', ?) ON DUPLICATE KEY UPDATE `account_id` = VALUES(`account_id`)
|
VALUES ('treasury', 'CASH', ?) ON DUPLICATE KEY UPDATE `account_id` = VALUES(`account_id`)
|
||||||
]], { Config.Crypto.TreasuryCash * Config.Crypto.PriceScale })
|
]], { Config.Crypto.TreasuryCash * Config.Crypto.PriceScale })
|
||||||
|
markets = next_markets
|
||||||
|
market_order = next_market_order
|
||||||
|
market_dynamics = next_market_dynamics
|
||||||
|
market_cursor = math.min(market_cursor, math.max(#market_order, 1))
|
||||||
end
|
end
|
||||||
|
|
||||||
local function require_phone(source)
|
local function require_phone(source)
|
||||||
@@ -1357,6 +1368,8 @@ ensure_schema()
|
|||||||
migrate_crypto_keys()
|
migrate_crypto_keys()
|
||||||
initialize_markets()
|
initialize_markets()
|
||||||
|
|
||||||
|
AddEventHandler("sky_phone:configurator:serverUpdated", initialize_markets)
|
||||||
|
|
||||||
local function reconcile_settlements(include_recent)
|
local function reconcile_settlements(include_recent)
|
||||||
local age_clause = include_recent and "" or " AND settlement.`updated_at` < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)"
|
local age_clause = include_recent and "" or " AND settlement.`updated_at` < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)"
|
||||||
local rows = Bridge.Database.Query([[
|
local rows = Bridge.Database.Query([[
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
Bridge.Database.AfterMigration("sky_phone", function()
|
Bridge.Database.AfterMigration("sky_phone", function()
|
||||||
local STORAGE_JSON_MAX_DEPTH = 8
|
local STORAGE_JSON_MAX_DEPTH = 8
|
||||||
local STORAGE_JSON_MAX_NODES = 512
|
local STORAGE_JSON_MAX_NODES = 512
|
||||||
local STORAGE_KEY_MAX_LENGTH = math.min(Config.CustomApps.MaximumStorageKeyLength, 64)
|
|
||||||
local STORAGE_VALUE_MAX_BYTES = math.min(Config.CustomApps.MaximumStorageValueBytes, 65536)
|
|
||||||
local storage_write_locks = {}
|
local storage_write_locks = {}
|
||||||
|
|
||||||
local function affected_rows(result)
|
local function affected_rows(result)
|
||||||
@@ -27,7 +25,7 @@ local function validate_storage_json(value, depth, state)
|
|||||||
return value == value and value ~= math.huge and value ~= -math.huge
|
return value == value and value ~= math.huge and value ~= -math.huge
|
||||||
end
|
end
|
||||||
if value_type == "string" then
|
if value_type == "string" then
|
||||||
return #value <= STORAGE_VALUE_MAX_BYTES
|
return #value <= math.min(Config.CustomApps.MaximumStorageValueBytes, 65536)
|
||||||
end
|
end
|
||||||
if value_type ~= "table" or state.seen[value] then
|
if value_type ~= "table" or state.seen[value] then
|
||||||
return false
|
return false
|
||||||
@@ -120,7 +118,7 @@ local function validate_request(source, data, operation)
|
|||||||
end
|
end
|
||||||
if type(data.key) ~= "string"
|
if type(data.key) ~= "string"
|
||||||
or #data.key == 0
|
or #data.key == 0
|
||||||
or #data.key > STORAGE_KEY_MAX_LENGTH
|
or #data.key > math.min(Config.CustomApps.MaximumStorageKeyLength, 64)
|
||||||
or not data.key:match("^[%w._-]+$")
|
or not data.key:match("^[%w._-]+$")
|
||||||
then
|
then
|
||||||
return nil, nil, { success = false, error = "invalid_storage_key" }
|
return nil, nil, { success = false, error = "invalid_storage_key" }
|
||||||
@@ -205,7 +203,7 @@ Bridge.Callbacks.Register("sky_phone:custom-app:storage:set", function(source, d
|
|||||||
local encoded, payload = pcall(json.encode, request.value)
|
local encoded, payload = pcall(json.encode, request.value)
|
||||||
if not encoded
|
if not encoded
|
||||||
or type(payload) ~= "string"
|
or type(payload) ~= "string"
|
||||||
or #payload > STORAGE_VALUE_MAX_BYTES
|
or #payload > math.min(Config.CustomApps.MaximumStorageValueBytes, 65536)
|
||||||
then
|
then
|
||||||
return { success = false, error = "storage_value_too_large" }
|
return { success = false, error = "storage_value_too_large" }
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -474,26 +474,44 @@ SkyPhoneApps.ServerPublicApi = {
|
|||||||
UpdateCustomAppPolicyFromAdapter = update_custom_app_policy_from_adapter,
|
UpdateCustomAppPolicyFromAdapter = update_custom_app_policy_from_adapter,
|
||||||
}
|
}
|
||||||
|
|
||||||
if Config.CustomApps.Enabled and Config.CustomApps.BundledApps then
|
local function refresh_bundled_policies()
|
||||||
local bundled = SkyPhoneApps.GetBundledManifests()
|
local removals = {}
|
||||||
for index = 1, #bundled do
|
for app_id, policy in pairs(policies_by_id) do
|
||||||
local manifest = bundled[index]
|
if policy.bundled then
|
||||||
local registered, register_error = register_policy({
|
removals[#removals + 1] = app_id
|
||||||
adapterResource = nil,
|
end
|
||||||
bundled = true,
|
end
|
||||||
id = manifest.id,
|
for _, app_id in ipairs(removals) do
|
||||||
ownerResource = current_resource,
|
remove_policy(app_id)
|
||||||
permissions = build_permission_set(manifest.permissions),
|
end
|
||||||
})
|
|
||||||
if not registered then
|
if Config.CustomApps.Enabled and Config.CustomApps.BundledApps then
|
||||||
error(("[sky_phone] Could not register bundled custom app policy '%s': %s"):format(
|
local bundled = SkyPhoneApps.GetBundledManifests()
|
||||||
manifest.id,
|
for index = 1, #bundled do
|
||||||
register_error
|
local manifest = bundled[index]
|
||||||
))
|
local registered, register_error = register_policy({
|
||||||
|
adapterResource = nil,
|
||||||
|
bundled = true,
|
||||||
|
id = manifest.id,
|
||||||
|
ownerResource = current_resource,
|
||||||
|
permissions = build_permission_set(manifest.permissions),
|
||||||
|
})
|
||||||
|
if not registered then
|
||||||
|
error(("[sky_phone] Could not register bundled custom app policy '%s': %s"):format(
|
||||||
|
manifest.id,
|
||||||
|
register_error
|
||||||
|
))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
refresh_bundled_policies()
|
||||||
|
|
||||||
|
AddEventHandler("sky_phone:configurator:serverUpdated", function()
|
||||||
|
refresh_bundled_policies()
|
||||||
|
end)
|
||||||
|
|
||||||
AddEventHandler("onResourceStop", function(resource_name)
|
AddEventHandler("onResourceStop", function(resource_name)
|
||||||
if resource_name == current_resource then
|
if resource_name == current_resource then
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -416,6 +416,46 @@ local schema = {
|
|||||||
},
|
},
|
||||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name = "sky_phone_admin_audit",
|
||||||
|
columns = {
|
||||||
|
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
|
||||||
|
{
|
||||||
|
name = "actor_identifier",
|
||||||
|
type = "VARCHAR(80) NOT NULL",
|
||||||
|
characterSet = "ascii",
|
||||||
|
collation = "ascii_bin",
|
||||||
|
},
|
||||||
|
{ name = "actor_name", type = "VARCHAR(120) NOT NULL" },
|
||||||
|
{
|
||||||
|
name = "target_identifier",
|
||||||
|
type = "VARCHAR(80) NOT NULL",
|
||||||
|
characterSet = "ascii",
|
||||||
|
collation = "ascii_bin",
|
||||||
|
},
|
||||||
|
{ name = "target_source", type = "INT UNSIGNED NULL" },
|
||||||
|
{
|
||||||
|
name = "device_imei",
|
||||||
|
type = "CHAR(15) NULL",
|
||||||
|
characterSet = "ascii",
|
||||||
|
collation = "ascii_bin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name = "action",
|
||||||
|
type = "VARCHAR(48) NOT NULL",
|
||||||
|
characterSet = "ascii",
|
||||||
|
collation = "ascii_bin",
|
||||||
|
},
|
||||||
|
{ name = "details", type = "LONGTEXT NOT NULL" },
|
||||||
|
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||||
|
},
|
||||||
|
primaryKey = "id",
|
||||||
|
indexes = {
|
||||||
|
{ name = "idx_sky_phone_admin_audit_created", columns = "(`created_at`, `id`)" },
|
||||||
|
{ name = "idx_sky_phone_admin_audit_target", columns = "(`target_identifier`, `created_at`)" },
|
||||||
|
},
|
||||||
|
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name = "sky_phone_notes",
|
name = "sky_phone_notes",
|
||||||
columns = {
|
columns = {
|
||||||
@@ -699,7 +739,7 @@ local schema = {
|
|||||||
{ name = "title", type = "VARCHAR(160) NOT NULL" },
|
{ name = "title", type = "VARCHAR(160) NOT NULL" },
|
||||||
{ name = "description", type = "VARCHAR(1000) NOT NULL DEFAULT ''" },
|
{ name = "description", type = "VARCHAR(1000) NOT NULL DEFAULT ''" },
|
||||||
{ name = "amount", type = "BIGINT UNSIGNED NOT NULL" },
|
{ name = "amount", type = "BIGINT UNSIGNED NOT NULL" },
|
||||||
{ name = "currency", type = "VARCHAR(8) NOT NULL", characterSet = "ascii", collation = "ascii_general_ci" },
|
{ name = "currency", type = "VARCHAR(8) NOT NULL", characterSet = "utf8mb4", collation = "utf8mb4_unicode_ci" },
|
||||||
{ name = "status", type = "ENUM('open', 'processing', 'paid', 'disputed', 'cancelled', 'refunded') NOT NULL DEFAULT 'open'" },
|
{ name = "status", type = "ENUM('open', 'processing', 'paid', 'disputed', 'cancelled', 'refunded') NOT NULL DEFAULT 'open'" },
|
||||||
{ name = "read_at", type = "DATETIME NULL" },
|
{ name = "read_at", type = "DATETIME NULL" },
|
||||||
{ name = "issued_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
{ name = "issued_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||||
@@ -1001,8 +1041,8 @@ local schema = {
|
|||||||
{
|
{
|
||||||
name = "handle",
|
name = "handle",
|
||||||
type = "VARCHAR(24) NOT NULL",
|
type = "VARCHAR(24) NOT NULL",
|
||||||
characterSet = "ascii",
|
characterSet = "utf8mb4",
|
||||||
collation = "ascii_general_ci",
|
collation = "utf8mb4_unicode_ci",
|
||||||
},
|
},
|
||||||
{ name = "bio", type = "VARCHAR(160) NOT NULL DEFAULT ''" },
|
{ name = "bio", type = "VARCHAR(160) NOT NULL DEFAULT ''" },
|
||||||
{ name = "avatar_media_id", type = "BIGINT UNSIGNED NULL" },
|
{ name = "avatar_media_id", type = "BIGINT UNSIGNED NULL" },
|
||||||
@@ -2156,8 +2196,8 @@ local schema = {
|
|||||||
{
|
{
|
||||||
name = "currency",
|
name = "currency",
|
||||||
type = "VARCHAR(8) NOT NULL",
|
type = "VARCHAR(8) NOT NULL",
|
||||||
characterSet = "ascii",
|
characterSet = "utf8mb4",
|
||||||
collation = "ascii_general_ci",
|
collation = "utf8mb4_unicode_ci",
|
||||||
},
|
},
|
||||||
{ name = "driver_vehicle_model", type = "VARCHAR(64) NULL" },
|
{ name = "driver_vehicle_model", type = "VARCHAR(64) NULL" },
|
||||||
{ name = "driver_vehicle_color", type = "VARCHAR(64) NULL" },
|
{ name = "driver_vehicle_color", type = "VARCHAR(64) NULL" },
|
||||||
@@ -2909,6 +2949,8 @@ local schema = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
schema[#schema + 1] = SkyPhoneConfiguratorSchema
|
||||||
|
|
||||||
Bridge.Database.Migrate("sky_phone", schema)
|
Bridge.Database.Migrate("sky_phone", schema)
|
||||||
Bridge.Database.Query([[
|
Bridge.Database.Query([[
|
||||||
INSERT IGNORE INTO `sky_phone_fliptok_video_media` (`video_id`, `media_id`, `sort_order`)
|
INSERT IGNORE INTO `sky_phone_fliptok_video_media` (`video_id`, `media_id`, `sort_order`)
|
||||||
|
|||||||
@@ -14,7 +14,27 @@ local custom_music_extensions = {
|
|||||||
wav = true,
|
wav = true,
|
||||||
webm = true,
|
webm = true,
|
||||||
}
|
}
|
||||||
local password_pepper = tostring(Config.Server.FlipTokPasswordPepper or "")
|
local password_pepper = ""
|
||||||
|
|
||||||
|
local function refresh_runtime_configuration()
|
||||||
|
password_pepper = tostring(Config.Server.FlipTokPasswordPepper or "")
|
||||||
|
music_tracks = {}
|
||||||
|
music_track_list = {}
|
||||||
|
for _, track in ipairs(Config.FlipTok.MusicTracks) do
|
||||||
|
local id = tostring(track.Id or track.id or "")
|
||||||
|
local title = tostring(track.Title or track.title or "")
|
||||||
|
local artist = tostring(track.Artist or track.artist or "")
|
||||||
|
local url = tostring(track.Url or track.url or "")
|
||||||
|
if id == "" or title == "" or artist == "" or url == "" then
|
||||||
|
error("[sky_phone] Every configured FlipTok music track requires Id, Title, Artist, and Url.")
|
||||||
|
end
|
||||||
|
local item = { id = id, title = title, artist = artist, url = url }
|
||||||
|
music_tracks[id] = item
|
||||||
|
music_track_list[#music_track_list + 1] = item
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
refresh_runtime_configuration()
|
||||||
|
|
||||||
if password_pepper == "" then
|
if password_pepper == "" then
|
||||||
Bridge.Debug(
|
Bridge.Debug(
|
||||||
@@ -24,18 +44,9 @@ if password_pepper == "" then
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
for _, track in ipairs(Config.FlipTok.MusicTracks) do
|
AddEventHandler("sky_phone:configurator:serverUpdated", function()
|
||||||
local id = tostring(track.Id or track.id or "")
|
refresh_runtime_configuration()
|
||||||
local title = tostring(track.Title or track.title or "")
|
end)
|
||||||
local artist = tostring(track.Artist or track.artist or "")
|
|
||||||
local url = tostring(track.Url or track.url or "")
|
|
||||||
if id == "" or title == "" or artist == "" or url == "" then
|
|
||||||
error("[sky_phone] Every configured FlipTok music track requires Id, Title, Artist, and Url.")
|
|
||||||
end
|
|
||||||
local item = { id = id, title = title, artist = artist, url = url }
|
|
||||||
music_tracks[id] = item
|
|
||||||
music_track_list[#music_track_list + 1] = item
|
|
||||||
end
|
|
||||||
|
|
||||||
local function trim(value)
|
local function trim(value)
|
||||||
if type(value) ~= "string" then return nil end
|
if type(value) ~= "string" then return nil end
|
||||||
@@ -913,7 +924,10 @@ Bridge.Callbacks.Register("sky_phone:fliptok:delete", function(source, data)
|
|||||||
return affected > 0 and { success = true } or { success = false, error = "video_not_found" }
|
return affected > 0 and { success = true } or { success = false, error = "video_not_found" }
|
||||||
end)
|
end)
|
||||||
|
|
||||||
RegisterCommand(Config.FlipTok.VerifyCommand, function(source, arguments)
|
local active_verify_command = nil
|
||||||
|
local registered_verify_commands = {}
|
||||||
|
|
||||||
|
local function run_verify_command(source, arguments)
|
||||||
local command_locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale).FlipTokCommand
|
local command_locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale).FlipTokCommand
|
||||||
local function command_message(template, values)
|
local function command_message(template, values)
|
||||||
return template:gsub("{(%w+)}", function(key) return values[key] or "" end)
|
return template:gsub("{(%w+)}", function(key) return values[key] or "" end)
|
||||||
@@ -973,5 +987,26 @@ RegisterCommand(Config.FlipTok.VerifyCommand, function(source, arguments)
|
|||||||
state = verified and command_locale.verified or command_locale.unverified,
|
state = verified and command_locale.verified or command_locale.unverified,
|
||||||
})
|
})
|
||||||
send_command_feedback(message, "success")
|
send_command_feedback(message, "success")
|
||||||
end, false)
|
end
|
||||||
|
|
||||||
|
local function refresh_verify_command()
|
||||||
|
local command_name = Config.FlipTok.VerifyCommand
|
||||||
|
if type(command_name) ~= "string" or command_name == "" then
|
||||||
|
error("[sky_phone] Config.FlipTok.VerifyCommand must be a non-empty command name.")
|
||||||
|
end
|
||||||
|
active_verify_command = command_name
|
||||||
|
if registered_verify_commands[command_name] then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
registered_verify_commands[command_name] = true
|
||||||
|
RegisterCommand(command_name, function(source, arguments)
|
||||||
|
if active_verify_command == command_name then
|
||||||
|
run_verify_command(source, arguments)
|
||||||
|
end
|
||||||
|
end, false)
|
||||||
|
end
|
||||||
|
|
||||||
|
refresh_verify_command()
|
||||||
|
|
||||||
|
AddEventHandler("sky_phone:configurator:serverUpdated", refresh_verify_command)
|
||||||
end)
|
end)
|
||||||
|
|||||||
@@ -8,13 +8,23 @@ local offer_responses = { accepted = true, rejected = true }
|
|||||||
local public_statuses = { active = true, reserved = true }
|
local public_statuses = { active = true, reserved = true }
|
||||||
local seller_statuses = { active = true, reserved = true, sold = true, removed = true }
|
local seller_statuses = { active = true, reserved = true, sold = true, removed = true }
|
||||||
|
|
||||||
for _, category in ipairs(Config.Marketplace.Categories) do
|
local function refresh_runtime_configuration()
|
||||||
categories[category] = true
|
categories = {}
|
||||||
end
|
districts = {}
|
||||||
for _, district in ipairs(Config.Marketplace.Districts) do
|
for _, category in ipairs(Config.Marketplace.Categories) do
|
||||||
districts[district] = true
|
categories[category] = true
|
||||||
|
end
|
||||||
|
for _, district in ipairs(Config.Marketplace.Districts) do
|
||||||
|
districts[district] = true
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
refresh_runtime_configuration()
|
||||||
|
|
||||||
|
AddEventHandler("sky_phone:configurator:serverUpdated", function()
|
||||||
|
refresh_runtime_configuration()
|
||||||
|
end)
|
||||||
|
|
||||||
local function trim(value)
|
local function trim(value)
|
||||||
if type(value) ~= "string" then
|
if type(value) ~= "string" then
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+216
-113
@@ -4,36 +4,19 @@ SkyPhoneMediaImport.Initialize()
|
|||||||
|
|
||||||
local pending_uploads = {}
|
local pending_uploads = {}
|
||||||
local pending_deletes = {}
|
local pending_deletes = {}
|
||||||
local allowed_remote_mimes = {
|
local allowed_fivemanage_hosts = {
|
||||||
audio = {
|
["api.fivemanage.com"] = true,
|
||||||
["audio/ogg"] = true,
|
["fmapi.net"] = true,
|
||||||
["audio/webm"] = true,
|
|
||||||
},
|
|
||||||
photo = {
|
|
||||||
["image/jpeg"] = true,
|
|
||||||
["image/png"] = true,
|
|
||||||
["image/webp"] = true,
|
|
||||||
},
|
|
||||||
video = {
|
|
||||||
["video/mp4"] = true,
|
|
||||||
["video/webm"] = true,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
local function media_config()
|
local function diagnostic_text(value, maximum_length)
|
||||||
return Config.Media.FiveManage
|
return tostring(value or "unknown"):gsub("[\r\n]", " "):sub(1, maximum_length)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function media_api_key()
|
local function media_debug(message, ...)
|
||||||
local api_key = media_config().ApiKey
|
local arguments = { ... }
|
||||||
if type(api_key) ~= "string" then
|
arguments[#arguments + 1] = { notice = true }
|
||||||
return ""
|
Bridge.Debug("debug", "[sky_phone][media-debug] " .. message, table.unpack(arguments))
|
||||||
end
|
|
||||||
return api_key:match("^%s*(.-)%s*$")
|
|
||||||
end
|
|
||||||
|
|
||||||
local function api_configured()
|
|
||||||
return media_api_key() ~= ""
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function http_request(url, method, body, headers, timeout_ms)
|
local function http_request(url, method, body, headers, timeout_ms)
|
||||||
@@ -65,6 +48,9 @@ local function response_error_message(response)
|
|||||||
if type(response) ~= "table" then
|
if type(response) ~= "table" then
|
||||||
return "invalid response"
|
return "invalid response"
|
||||||
end
|
end
|
||||||
|
if type(response.status) == "number" and response.status >= 200 and response.status < 300 then
|
||||||
|
return "none"
|
||||||
|
end
|
||||||
if type(response.error) == "string" and response.error ~= "" then
|
if type(response.error) == "string" and response.error ~= "" then
|
||||||
return response.error:sub(1, 240)
|
return response.error:sub(1, 240)
|
||||||
end
|
end
|
||||||
@@ -75,6 +61,10 @@ local function response_error_message(response)
|
|||||||
return message:sub(1, 240)
|
return message:sub(1, 240)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
local response_body = tostring(response.body or ""):gsub("[\r\n]", " ")
|
||||||
|
if response_body ~= "" then
|
||||||
|
return response_body:sub(1, 240)
|
||||||
|
end
|
||||||
return "no provider error message"
|
return "no provider error message"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -95,8 +85,22 @@ local function decode_response(response)
|
|||||||
return decoded.data or decoded
|
return decoded.data or decoded
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function fivemanage_file_base(value)
|
||||||
|
if type(value) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local host = value:match("^https://([^/%?#]+)")
|
||||||
|
host = host and host:lower() or nil
|
||||||
|
if not host or not allowed_fivemanage_hosts[host] then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return ("https://%s/api/v3/file"):format(host), host
|
||||||
|
end
|
||||||
|
|
||||||
local function request_presigned_url()
|
local function request_presigned_url()
|
||||||
if not api_configured() then
|
local api_key = SkyPhoneMediaProviderConfig.FiveManageApiKey()
|
||||||
|
media_debug("Starting FiveManage presigned upload request (api-key=%s).", api_key ~= "" and "present" or "missing")
|
||||||
|
if api_key == "" then
|
||||||
Bridge.Debug(
|
Bridge.Debug(
|
||||||
"error",
|
"error",
|
||||||
"[sky_phone] FiveManage presigned upload request failed: Config.Media.FiveManage.ApiKey is empty or invalid.",
|
"[sky_phone] FiveManage presigned upload request failed: Config.Media.FiveManage.ApiKey is empty or invalid.",
|
||||||
@@ -109,9 +113,10 @@ local function request_presigned_url()
|
|||||||
tostring(config.BaseUrl):gsub("/+$", "") .. "/presigned-url",
|
tostring(config.BaseUrl):gsub("/+$", "") .. "/presigned-url",
|
||||||
"GET",
|
"GET",
|
||||||
"",
|
"",
|
||||||
{ ["Authorization"] = media_api_key() },
|
{ ["Authorization"] = api_key },
|
||||||
tonumber(config.RequestTimeoutMs) or 10000
|
tonumber(config.RequestTimeoutMs) or 10000
|
||||||
)
|
)
|
||||||
|
media_debug("FiveManage presigned upload request returned HTTP %s.", tostring(response.status))
|
||||||
if response.status == 401 or response.status == 403 then
|
if response.status == 401 or response.status == 403 then
|
||||||
Bridge.Debug(
|
Bridge.Debug(
|
||||||
"error",
|
"error",
|
||||||
@@ -150,40 +155,41 @@ local function request_presigned_url()
|
|||||||
)
|
)
|
||||||
return nil, "media_provider_failed"
|
return nil, "media_provider_failed"
|
||||||
end
|
end
|
||||||
|
local provider_base_url, provider_host = fivemanage_file_base(presigned_url)
|
||||||
|
if not provider_base_url then
|
||||||
|
Bridge.Debug(
|
||||||
|
"error",
|
||||||
|
"[sky_phone] FiveManage returned a presigned URL on an unexpected host.",
|
||||||
|
{ always = true }
|
||||||
|
)
|
||||||
|
return nil, "media_provider_failed"
|
||||||
|
end
|
||||||
|
media_debug("FiveManage returned a valid presigned upload URL (host=%s).", provider_host)
|
||||||
return presigned_url
|
return presigned_url
|
||||||
end
|
end
|
||||||
|
|
||||||
local function get_remote_file(remote_id)
|
local function encode_remote_path(value)
|
||||||
if not api_configured() then
|
local segments = {}
|
||||||
return nil, "missing_config"
|
for segment in tostring(value):gmatch("[^/]+") do
|
||||||
|
segments[#segments + 1] = SkyPhoneMediaImport.UrlEncode(segment)
|
||||||
end
|
end
|
||||||
local config = Config.Media.FiveManage
|
return table.concat(segments, "/")
|
||||||
local response = http_request(
|
|
||||||
("%s/%s"):format(
|
|
||||||
tostring(config.BaseUrl):gsub("/+$", ""),
|
|
||||||
SkyPhoneMediaImport.UrlEncode(remote_id)
|
|
||||||
),
|
|
||||||
"GET",
|
|
||||||
"",
|
|
||||||
{ ["Authorization"] = media_api_key() },
|
|
||||||
tonumber(config.RequestTimeoutMs) or 10000
|
|
||||||
)
|
|
||||||
return decode_response(response)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function delete_remote_file(remote_id)
|
local function delete_remote_file(remote_id)
|
||||||
if not api_configured() then
|
local api_key = SkyPhoneMediaProviderConfig.FiveManageApiKey()
|
||||||
|
if api_key == "" then
|
||||||
return false, "missing_config"
|
return false, "missing_config"
|
||||||
end
|
end
|
||||||
local config = Config.Media.FiveManage
|
local config = Config.Media.FiveManage
|
||||||
local response = http_request(
|
local response = http_request(
|
||||||
("%s/%s"):format(
|
("%s/%s"):format(
|
||||||
tostring(config.BaseUrl):gsub("/+$", ""),
|
tostring(config.BaseUrl):gsub("/+$", ""),
|
||||||
SkyPhoneMediaImport.UrlEncode(remote_id)
|
encode_remote_path(remote_id)
|
||||||
),
|
),
|
||||||
"DELETE",
|
"DELETE",
|
||||||
"",
|
"",
|
||||||
{ ["Authorization"] = media_api_key() },
|
{ ["Authorization"] = api_key },
|
||||||
tonumber(config.RequestTimeoutMs) or 10000
|
tonumber(config.RequestTimeoutMs) or 10000
|
||||||
)
|
)
|
||||||
if response.status < 200 or response.status >= 300 then
|
if response.status < 200 or response.status >= 300 then
|
||||||
@@ -289,6 +295,14 @@ function SkyPhoneMedia.ResolveOwnedMedia(source, media_id, media_type)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function upload_result(source, correlation_id, success, error_code, media)
|
local function upload_result(source, correlation_id, success, error_code, media)
|
||||||
|
media_debug(
|
||||||
|
"Sending upload result (source=%s, correlation=%s, success=%s, error=%s, media=%s).",
|
||||||
|
tostring(source),
|
||||||
|
diagnostic_text(correlation_id, 80),
|
||||||
|
tostring(success),
|
||||||
|
diagnostic_text(error_code, 80),
|
||||||
|
type(media) == "table" and "present" or "missing"
|
||||||
|
)
|
||||||
TriggerClientEvent("sky_phone:media:upload-result", source, {
|
TriggerClientEvent("sky_phone:media:upload-result", source, {
|
||||||
correlationId = correlation_id,
|
correlationId = correlation_id,
|
||||||
success = success,
|
success = success,
|
||||||
@@ -315,79 +329,42 @@ local function delete_many_result(source, correlation_id, success, error_code, d
|
|||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
local function parse_metadata(value)
|
|
||||||
if type(value) == "table" then
|
|
||||||
return value
|
|
||||||
end
|
|
||||||
if type(value) ~= "string" then
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
local success, decoded = pcall(json.decode, value)
|
|
||||||
return success and type(decoded) == "table" and decoded or nil
|
|
||||||
end
|
|
||||||
|
|
||||||
local function valid_remote_id(value)
|
local function valid_remote_id(value)
|
||||||
return type(value) == "string" and #value >= 4 and #value <= 128 and value:match("^[%w_%-]+$") ~= nil
|
return type(value) == "string" and #value >= 4 and #value <= 128 and value:match("^[%w_%-]+$") ~= nil
|
||||||
end
|
end
|
||||||
|
|
||||||
local function verify_remote_upload(state, remote_id, uploaded_url)
|
local function verify_remote_upload(state, remote_id, uploaded_url)
|
||||||
if not valid_remote_id(remote_id) or type(uploaded_url) ~= "string" or #uploaded_url > 2048
|
if not valid_remote_id(remote_id) or type(uploaded_url) ~= "string"
|
||||||
|
or #uploaded_url > Config.Media.UrlMaxLength
|
||||||
or not uploaded_url:match("^https://")
|
or not uploaded_url:match("^https://")
|
||||||
then
|
then
|
||||||
|
Bridge.Debug(
|
||||||
|
"error",
|
||||||
|
"[sky_phone][media-debug] Upload completion payload is invalid (remote-id=%s, url=%s).",
|
||||||
|
valid_remote_id(remote_id) and "valid" or "invalid",
|
||||||
|
type(uploaded_url) == "string" and uploaded_url:match("^https://") and "https" or "invalid"
|
||||||
|
)
|
||||||
return nil, "invalid_upload"
|
return nil, "invalid_upload"
|
||||||
end
|
end
|
||||||
local remote, remote_error = get_remote_file(remote_id)
|
|
||||||
if not remote then
|
local uploaded_host = uploaded_url:match("^https://([^/%?#]+)")
|
||||||
return nil, remote_error
|
if not uploaded_host or uploaded_host:lower() ~= "r2.fivemanage.com" then
|
||||||
end
|
Bridge.Debug(
|
||||||
if remote.id ~= remote_id then
|
"error",
|
||||||
|
"[sky_phone][media-debug] FiveManage upload returned an unexpected media host."
|
||||||
|
)
|
||||||
return nil, "invalid_upload"
|
return nil, "invalid_upload"
|
||||||
end
|
end
|
||||||
if remote.url ~= uploaded_url and remote.originalUrl ~= uploaded_url then
|
media_debug(
|
||||||
return nil, "invalid_upload"
|
"Accepting the direct FiveManage upload response (type=%s, size=%s).",
|
||||||
end
|
tostring(state.media_type),
|
||||||
local verified_url = remote.url or uploaded_url
|
tostring(state.size_bytes)
|
||||||
if type(verified_url) ~= "string" or #verified_url > Config.Media.UrlMaxLength
|
)
|
||||||
or not verified_url:match("^https://")
|
|
||||||
then
|
|
||||||
return nil, "invalid_upload"
|
|
||||||
end
|
|
||||||
local metadata = parse_metadata(remote.metadata)
|
|
||||||
if not metadata or metadata.captureToken ~= state.capture_token or metadata.source ~= "sky_phone" then
|
|
||||||
return nil, "invalid_upload_token"
|
|
||||||
end
|
|
||||||
if state.purpose and metadata.purpose ~= state.purpose then
|
|
||||||
return nil, "invalid_upload", true
|
|
||||||
end
|
|
||||||
local allowed_mimes = allowed_remote_mimes[state.media_type]
|
|
||||||
if not allowed_mimes then
|
|
||||||
return nil, "invalid_media_type", true
|
|
||||||
end
|
|
||||||
local remote_mime = tostring(remote.mimeType or ""):lower():match("^%s*([^;%s]+)") or ""
|
|
||||||
local remote_type = tostring(remote.type or ""):lower():match("^%s*([^;%s]+)") or ""
|
|
||||||
if remote_mime == "" and allowed_mimes[remote_type] then
|
|
||||||
remote_mime = remote_type
|
|
||||||
end
|
|
||||||
if remote_type == "" then
|
|
||||||
remote_type = remote_mime
|
|
||||||
end
|
|
||||||
if state.media_type == "photo" and remote_type ~= "" and not remote_type:find("image", 1, true) then
|
|
||||||
return nil, "invalid_media_type", true
|
|
||||||
end
|
|
||||||
if state.media_type == "video" and remote_type ~= "" and not remote_type:find("video", 1, true) then
|
|
||||||
return nil, "invalid_media_type", true
|
|
||||||
end
|
|
||||||
if state.media_type == "audio" and remote_type ~= "" and not remote_type:find("audio", 1, true) then
|
|
||||||
return nil, "invalid_media_type", true
|
|
||||||
end
|
|
||||||
if remote_mime ~= "" and not allowed_mimes[remote_mime] then
|
|
||||||
return nil, "invalid_media_type", true
|
|
||||||
end
|
|
||||||
return {
|
return {
|
||||||
mime_type = allowed_mimes[remote_mime] and remote_mime or state.mime_type,
|
mime_type = state.mime_type,
|
||||||
remote_id = remote_id,
|
remote_id = remote_id,
|
||||||
size = tonumber(remote.size),
|
size = state.size_bytes,
|
||||||
url = verified_url,
|
url = uploaded_url,
|
||||||
}, nil, true
|
}, nil, true
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -401,6 +378,12 @@ local function expire_upload(request_id)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
pending_uploads[request_id] = nil
|
pending_uploads[request_id] = nil
|
||||||
|
Bridge.Debug(
|
||||||
|
"error",
|
||||||
|
"[sky_phone][media-debug] Upload session expired before completion (source=%s, correlation=%s).",
|
||||||
|
tostring(state.source),
|
||||||
|
diagnostic_text(state.correlation_id, 80)
|
||||||
|
)
|
||||||
upload_result(state.source, state.correlation_id, false, "upload_timeout")
|
upload_result(state.source, state.correlation_id, false, "upload_timeout")
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -660,35 +643,81 @@ RegisterNetEvent("sky_phone:media:request-upload", function(data)
|
|||||||
data = type(data) == "table" and data or {}
|
data = type(data) == "table" and data or {}
|
||||||
local correlation_id = data.correlationId
|
local correlation_id = data.correlationId
|
||||||
local media_type = data.mediaType
|
local media_type = data.mediaType
|
||||||
|
media_debug(
|
||||||
|
"Server received upload request (source=%s, correlation=%s, type=%s).",
|
||||||
|
tostring(src),
|
||||||
|
diagnostic_text(correlation_id, 80),
|
||||||
|
diagnostic_text(media_type, 20)
|
||||||
|
)
|
||||||
if type(correlation_id) ~= "string" or #correlation_id > 80
|
if type(correlation_id) ~= "string" or #correlation_id > 80
|
||||||
or (media_type ~= "photo" and media_type ~= "video")
|
or (media_type ~= "photo" and media_type ~= "video")
|
||||||
then
|
then
|
||||||
|
Bridge.Debug(
|
||||||
|
"error",
|
||||||
|
"[sky_phone][media-debug] Upload request validation failed (source=%s, correlation-type=%s, correlation-length=%s, media-type=%s).",
|
||||||
|
tostring(src),
|
||||||
|
type(correlation_id),
|
||||||
|
type(correlation_id) == "string" and tostring(#correlation_id) or "invalid",
|
||||||
|
diagnostic_text(media_type, 20)
|
||||||
|
)
|
||||||
upload_result(src, correlation_id, false, "invalid_request")
|
upload_result(src, correlation_id, false, "invalid_request")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if not SkyPhone.AllowOperation(src, "media_write", 20, 60) then
|
if not SkyPhone.AllowOperation(src, "media_write", 20, 60) then
|
||||||
|
Bridge.Debug(
|
||||||
|
"warn",
|
||||||
|
"[sky_phone][media-debug] Upload request was rate limited (source=%s, correlation=%s).",
|
||||||
|
tostring(src),
|
||||||
|
diagnostic_text(correlation_id, 80)
|
||||||
|
)
|
||||||
upload_result(src, correlation_id, false, "rate_limited")
|
upload_result(src, correlation_id, false, "rate_limited")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local owner, error_response = session_owner(src)
|
local owner, error_response = session_owner(src)
|
||||||
if not owner then
|
if not owner then
|
||||||
|
Bridge.Debug(
|
||||||
|
"error",
|
||||||
|
"[sky_phone][media-debug] Upload request has no valid phone session (source=%s, correlation=%s, error=%s).",
|
||||||
|
tostring(src),
|
||||||
|
diagnostic_text(correlation_id, 80),
|
||||||
|
diagnostic_text(error_response and error_response.error, 80)
|
||||||
|
)
|
||||||
upload_result(src, correlation_id, false, error_response.error)
|
upload_result(src, correlation_id, false, error_response.error)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
media_debug(
|
||||||
|
"Upload request session resolved (source=%s, correlation=%s, owner=%s).",
|
||||||
|
tostring(src),
|
||||||
|
diagnostic_text(correlation_id, 80),
|
||||||
|
owner.account_id and "account" or "device"
|
||||||
|
)
|
||||||
local presigned_url, presigned_error = request_presigned_url()
|
local presigned_url, presigned_error = request_presigned_url()
|
||||||
if not presigned_url then
|
if not presigned_url then
|
||||||
|
Bridge.Debug(
|
||||||
|
"error",
|
||||||
|
"[sky_phone][media-debug] Presigned upload request failed (source=%s, correlation=%s, error=%s).",
|
||||||
|
tostring(src),
|
||||||
|
diagnostic_text(correlation_id, 80),
|
||||||
|
diagnostic_text(presigned_error, 80)
|
||||||
|
)
|
||||||
upload_result(src, correlation_id, false, presigned_error)
|
upload_result(src, correlation_id, false, presigned_error)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local ids = Bridge.Database.Query("SELECT UUID() AS `request_id`, UUID() AS `capture_token`", {})
|
local ids = Bridge.Database.Query("SELECT UUID() AS `request_id`", {})
|
||||||
local request_id = ids[1] and ids[1].request_id
|
local request_id = ids[1] and ids[1].request_id
|
||||||
local capture_token = ids[1] and ids[1].capture_token
|
if type(request_id) ~= "string" then
|
||||||
if type(request_id) ~= "string" or type(capture_token) ~= "string" then
|
Bridge.Debug(
|
||||||
|
"error",
|
||||||
|
"[sky_phone][media-debug] Database did not generate an upload request ID (source=%s, correlation=%s, rows=%s, request-id=%s).",
|
||||||
|
tostring(src),
|
||||||
|
diagnostic_text(correlation_id, 80),
|
||||||
|
tostring(type(ids) == "table" and #ids or 0),
|
||||||
|
type(request_id)
|
||||||
|
)
|
||||||
upload_result(src, correlation_id, false, "request_failed")
|
upload_result(src, correlation_id, false, "request_failed")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
pending_uploads[request_id] = {
|
pending_uploads[request_id] = {
|
||||||
capture_token = capture_token,
|
|
||||||
correlation_id = correlation_id,
|
correlation_id = correlation_id,
|
||||||
media_type = media_type,
|
media_type = media_type,
|
||||||
mime_type = media_type == "video" and "video/webm"
|
mime_type = media_type == "video" and "video/webm"
|
||||||
@@ -700,8 +729,13 @@ RegisterNetEvent("sky_phone:media:request-upload", function(data)
|
|||||||
SetTimeout(tonumber(Config.Media.UploadSessionTimeoutMs) or 60000, function()
|
SetTimeout(tonumber(Config.Media.UploadSessionTimeoutMs) or 60000, function()
|
||||||
expire_upload(request_id)
|
expire_upload(request_id)
|
||||||
end)
|
end)
|
||||||
|
media_debug(
|
||||||
|
"Sending upload-ready to client (source=%s, correlation=%s, type=%s).",
|
||||||
|
tostring(src),
|
||||||
|
diagnostic_text(correlation_id, 80),
|
||||||
|
tostring(media_type)
|
||||||
|
)
|
||||||
TriggerClientEvent("sky_phone:media:upload-ready", src, {
|
TriggerClientEvent("sky_phone:media:upload-ready", src, {
|
||||||
captureToken = capture_token,
|
|
||||||
correlationId = correlation_id,
|
correlationId = correlation_id,
|
||||||
mediaType = media_type,
|
mediaType = media_type,
|
||||||
photo = Config.Media.Photo,
|
photo = Config.Media.Photo,
|
||||||
@@ -718,18 +752,55 @@ RegisterNetEvent("sky_phone:media:complete-upload", function(data)
|
|||||||
local request_id = data.requestId
|
local request_id = data.requestId
|
||||||
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
|
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
|
||||||
if not state or state.source ~= src or state.completing then
|
if not state or state.source ~= src or state.completing then
|
||||||
|
Bridge.Debug(
|
||||||
|
"warn",
|
||||||
|
"[sky_phone][media-debug] Rejected upload completion (source=%s, request-id=%s, session=%s, owner-match=%s, completing=%s).",
|
||||||
|
tostring(src),
|
||||||
|
type(request_id) == "string" and "present" or "invalid",
|
||||||
|
state and "present" or "missing",
|
||||||
|
tostring(state and state.source == src),
|
||||||
|
tostring(state and state.completing)
|
||||||
|
)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
media_debug(
|
||||||
|
"Server received upload completion (source=%s, correlation=%s, remote-id=%s, url=%s).",
|
||||||
|
tostring(src),
|
||||||
|
diagnostic_text(state.correlation_id, 80),
|
||||||
|
type(data.remoteId) == "string" and "present" or "missing",
|
||||||
|
type(data.url) == "string" and "present" or "missing"
|
||||||
|
)
|
||||||
state.completing = true
|
state.completing = true
|
||||||
local owner, error_response = session_owner(src)
|
local owner, error_response = session_owner(src)
|
||||||
if not owner or not owners_match(owner, state.owner) then
|
if not owner or not owners_match(owner, state.owner) then
|
||||||
pending_uploads[request_id] = nil
|
pending_uploads[request_id] = nil
|
||||||
|
Bridge.Debug(
|
||||||
|
"error",
|
||||||
|
"[sky_phone][media-debug] Upload owner changed before completion (source=%s, correlation=%s, session=%s, owner-match=%s, error=%s).",
|
||||||
|
tostring(src),
|
||||||
|
diagnostic_text(state.correlation_id, 80),
|
||||||
|
owner and "present" or "missing",
|
||||||
|
tostring(owner and owners_match(owner, state.owner)),
|
||||||
|
diagnostic_text(error_response and error_response.error, 80)
|
||||||
|
)
|
||||||
upload_result(src, state.correlation_id, false, error_response and error_response.error or "owner_changed")
|
upload_result(src, state.correlation_id, false, error_response and error_response.error or "owner_changed")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local verified, verify_error, trusted_remote = verify_remote_upload(state, data.remoteId, data.url)
|
local verified, verify_error, trusted_remote = verify_remote_upload(
|
||||||
|
state,
|
||||||
|
data.remoteId,
|
||||||
|
data.url
|
||||||
|
)
|
||||||
if not verified then
|
if not verified then
|
||||||
pending_uploads[request_id] = nil
|
pending_uploads[request_id] = nil
|
||||||
|
Bridge.Debug(
|
||||||
|
"error",
|
||||||
|
"[sky_phone][media-debug] Uploaded file verification failed (source=%s, correlation=%s, error=%s, trusted-remote=%s).",
|
||||||
|
tostring(src),
|
||||||
|
diagnostic_text(state.correlation_id, 80),
|
||||||
|
diagnostic_text(verify_error, 80),
|
||||||
|
tostring(trusted_remote)
|
||||||
|
)
|
||||||
if trusted_remote then
|
if trusted_remote then
|
||||||
local deleted, delete_error = delete_remote_file(data.remoteId)
|
local deleted, delete_error = delete_remote_file(data.remoteId)
|
||||||
if not deleted then
|
if not deleted then
|
||||||
@@ -760,9 +831,22 @@ RegisterNetEvent("sky_phone:media:complete-upload", function(data)
|
|||||||
local media_id = type(result) == "number" and result or (type(result) == "table" and tonumber(result.insertId))
|
local media_id = type(result) == "number" and result or (type(result) == "table" and tonumber(result.insertId))
|
||||||
if not media_id then
|
if not media_id then
|
||||||
delete_remote_file(verified.remote_id)
|
delete_remote_file(verified.remote_id)
|
||||||
|
Bridge.Debug(
|
||||||
|
"error",
|
||||||
|
"[sky_phone][media-debug] Database insert did not return a media ID (source=%s, correlation=%s, result-type=%s).",
|
||||||
|
tostring(src),
|
||||||
|
diagnostic_text(state.correlation_id, 80),
|
||||||
|
type(result)
|
||||||
|
)
|
||||||
upload_result(src, state.correlation_id, false, "request_failed")
|
upload_result(src, state.correlation_id, false, "request_failed")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
media_debug(
|
||||||
|
"Media upload completed successfully (source=%s, correlation=%s, media-id=%s).",
|
||||||
|
tostring(src),
|
||||||
|
diagnostic_text(state.correlation_id, 80),
|
||||||
|
tostring(media_id)
|
||||||
|
)
|
||||||
upload_result(src, state.correlation_id, true, nil, {
|
upload_result(src, state.correlation_id, true, nil, {
|
||||||
id = media_id,
|
id = media_id,
|
||||||
url = verified.url,
|
url = verified.url,
|
||||||
@@ -787,6 +871,15 @@ RegisterNetEvent("sky_phone:media:fail-upload", function(data)
|
|||||||
local request_id = type(data) == "table" and data.requestId or nil
|
local request_id = type(data) == "table" and data.requestId or nil
|
||||||
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
|
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
|
||||||
if not state or state.source ~= src or state.completing then
|
if not state or state.source ~= src or state.completing then
|
||||||
|
Bridge.Debug(
|
||||||
|
"warn",
|
||||||
|
"[sky_phone][media-debug] Rejected client upload failure report (source=%s, request-id=%s, session=%s, owner-match=%s, completing=%s).",
|
||||||
|
tostring(src),
|
||||||
|
type(request_id) == "string" and "present" or "invalid",
|
||||||
|
state and "present" or "missing",
|
||||||
|
tostring(state and state.source == src),
|
||||||
|
tostring(state and state.completing)
|
||||||
|
)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local allowed_errors = {
|
local allowed_errors = {
|
||||||
@@ -797,6 +890,16 @@ RegisterNetEvent("sky_phone:media:fail-upload", function(data)
|
|||||||
}
|
}
|
||||||
pending_uploads[request_id] = nil
|
pending_uploads[request_id] = nil
|
||||||
local error_code = allowed_errors[data.error] and data.error or "upload_failed"
|
local error_code = allowed_errors[data.error] and data.error or "upload_failed"
|
||||||
|
Bridge.Debug(
|
||||||
|
"error",
|
||||||
|
"[sky_phone][media-debug] Client-reported upload failure (source=%s, correlation=%s, error=%s, stage=%s, status=%s, detail=%s).",
|
||||||
|
tostring(src),
|
||||||
|
diagnostic_text(state.correlation_id, 80),
|
||||||
|
diagnostic_text(error_code, 80),
|
||||||
|
diagnostic_text(data.debugStage, 40),
|
||||||
|
diagnostic_text(data.debugStatus, 20),
|
||||||
|
diagnostic_text(data.debugMessage, 240)
|
||||||
|
)
|
||||||
upload_result(src, state.correlation_id, false, error_code)
|
upload_result(src, state.correlation_id, false, error_code)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
@@ -981,7 +1084,7 @@ AddEventHandler("playerDropped", function()
|
|||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
if not api_configured() then
|
if SkyPhoneMediaProviderConfig.FiveManageApiKey() == "" then
|
||||||
Bridge.Debug(
|
Bridge.Debug(
|
||||||
"warn",
|
"warn",
|
||||||
"[sky_phone] FiveManage media integration is disabled because Config.Media.FiveManage.ApiKey is empty in config/media.lua. Camera photo and video uploads, Voice Memo uploads, remote Gallery deletion, and FiveManage imports are unavailable. Add a FiveManage V3 token with Media access and restart sky_phone.",
|
"[sky_phone] FiveManage media integration is disabled because Config.Media.FiveManage.ApiKey is empty in config/media.lua. Camera photo and video uploads, Voice Memo uploads, remote Gallery deletion, and FiveManage imports are unavailable. Add a FiveManage V3 token with Media access and restart sky_phone.",
|
||||||
|
|||||||
@@ -226,14 +226,18 @@ local function validate_website(definition)
|
|||||||
return nil, "invalid_required_ace"
|
return nil, "invalid_required_ace"
|
||||||
end
|
end
|
||||||
|
|
||||||
definition._adapter = adapter
|
local website = {}
|
||||||
definition._media_types = allowed_media_types
|
for key, value in pairs(definition) do
|
||||||
local valid, validation_error = adapter.Validate(definition)
|
website[key] = value
|
||||||
|
end
|
||||||
|
website._adapter = adapter
|
||||||
|
website._media_types = allowed_media_types
|
||||||
|
local valid, validation_error = adapter.Validate(website)
|
||||||
if not valid then
|
if not valid then
|
||||||
return nil, validation_error
|
return nil, validation_error
|
||||||
end
|
end
|
||||||
|
|
||||||
return definition
|
return website
|
||||||
end
|
end
|
||||||
|
|
||||||
local function build_registry()
|
local function build_registry()
|
||||||
@@ -255,7 +259,7 @@ local function build_registry()
|
|||||||
if website_error == "missing_api_key" then
|
if website_error == "missing_api_key" then
|
||||||
Bridge.Debug(
|
Bridge.Debug(
|
||||||
"warn",
|
"warn",
|
||||||
"[sky_phone] Media import source '%s' at index %s is disabled because Config.Media.FiveManage.ApiKey is empty in server-only config/media.lua. Add a FiveManage V3 token with Media access and restart sky_phone.",
|
"[sky_phone] Media import source '%s' at index %s is disabled because Config.Media.FiveManage.ApiKey is empty. Add a FiveManage V3 token with Media access in the Phone Configurator and save it.",
|
||||||
tostring(source_name or "unknown"),
|
tostring(source_name or "unknown"),
|
||||||
tostring(index)
|
tostring(index)
|
||||||
)
|
)
|
||||||
@@ -756,3 +760,9 @@ function SkyPhoneMediaImport.Initialize()
|
|||||||
import_candidates[source] = nil
|
import_candidates[source] = nil
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
AddEventHandler("sky_phone:configurator:serverUpdated", function()
|
||||||
|
if initialized then
|
||||||
|
build_registry()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
local function api_key(website)
|
local function api_key(website)
|
||||||
local configured_key = website.ApiKey or Config.Media.FiveManage.ApiKey
|
return SkyPhoneMediaProviderConfig.FiveManageApiKey(website.ApiKey)
|
||||||
if type(configured_key) ~= "string" then
|
|
||||||
return ""
|
|
||||||
end
|
|
||||||
return configured_key:match("^%s*(.-)%s*$")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function provider_error(response, not_found_error)
|
local function provider_error(response, not_found_error)
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
SkyPhoneMediaProviderConfig = {}
|
||||||
|
|
||||||
|
local function trim_key(value)
|
||||||
|
if type(value) ~= "string" then
|
||||||
|
return ""
|
||||||
|
end
|
||||||
|
return value:match("^%s*(.-)%s*$")
|
||||||
|
end
|
||||||
|
|
||||||
|
function SkyPhoneMediaProviderConfig.FiveManageApiKey(override_key)
|
||||||
|
local api_key = trim_key(override_key)
|
||||||
|
if api_key ~= "" then
|
||||||
|
return api_key
|
||||||
|
end
|
||||||
|
|
||||||
|
return trim_key(Config.Media.FiveManage.ApiKey)
|
||||||
|
end
|
||||||
@@ -170,6 +170,7 @@ local function validate_upload(data)
|
|||||||
local title_length = title and utf8.len(title) or nil
|
local title_length = title and utf8.len(title) or nil
|
||||||
local note_length = note and utf8.len(note) or nil
|
local note_length = note and utf8.len(note) or nil
|
||||||
local duration_ms = tonumber(data.durationMs)
|
local duration_ms = tonumber(data.durationMs)
|
||||||
|
local size_bytes = tonumber(data.sizeBytes)
|
||||||
local normalized_mime = allowed_audio_mimes[data.mimeType]
|
local normalized_mime = allowed_audio_mimes[data.mimeType]
|
||||||
local waveform = normalize_waveform(data.waveform)
|
local waveform = normalize_waveform(data.waveform)
|
||||||
if not title_length or title_length < 1 or title_length > Config.Memos.TitleMaxLength
|
if not title_length or title_length < 1 or title_length > Config.Memos.TitleMaxLength
|
||||||
@@ -177,6 +178,8 @@ local function validate_upload(data)
|
|||||||
or not duration_ms or duration_ms ~= duration_ms
|
or not duration_ms or duration_ms ~= duration_ms
|
||||||
or duration_ms == math.huge or duration_ms == -math.huge
|
or duration_ms == math.huge or duration_ms == -math.huge
|
||||||
or duration_ms < 300 or duration_ms > Config.Memos.MaximumDurationMs
|
or duration_ms < 300 or duration_ms > Config.Memos.MaximumDurationMs
|
||||||
|
or not size_bytes or size_bytes ~= math.floor(size_bytes)
|
||||||
|
or size_bytes < 1 or size_bytes > Config.Memos.MaximumBytes
|
||||||
or not normalized_mime or not waveform or type(data.pinned) ~= "boolean"
|
or not normalized_mime or not waveform or type(data.pinned) ~= "boolean"
|
||||||
then
|
then
|
||||||
return nil
|
return nil
|
||||||
@@ -188,6 +191,7 @@ local function validate_upload(data)
|
|||||||
mime_type = normalized_mime,
|
mime_type = normalized_mime,
|
||||||
note = note,
|
note = note,
|
||||||
pinned = data.pinned,
|
pinned = data.pinned,
|
||||||
|
size_bytes = size_bytes,
|
||||||
title = title,
|
title = title,
|
||||||
waveform = waveform,
|
waveform = waveform,
|
||||||
}
|
}
|
||||||
@@ -373,20 +377,18 @@ RegisterNetEvent("sky_phone:memos:request-upload", function(data)
|
|||||||
upload_result(src, memo.correlation_id, false, presigned_error)
|
upload_result(src, memo.correlation_id, false, presigned_error)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local ids = Bridge.Database.Query("SELECT UUID() AS `request_id`, UUID() AS `capture_token`", {})
|
local ids = Bridge.Database.Query("SELECT UUID() AS `request_id`", {})
|
||||||
local request_id = ids[1] and ids[1].request_id
|
local request_id = ids[1] and ids[1].request_id
|
||||||
local capture_token = ids[1] and ids[1].capture_token
|
if type(request_id) ~= "string" then
|
||||||
if type(request_id) ~= "string" or type(capture_token) ~= "string" then
|
error("[sky_phone] Database did not generate a voice memo upload request ID.")
|
||||||
error("[sky_phone] Database did not generate a voice memo upload token.")
|
|
||||||
end
|
end
|
||||||
pending_uploads[request_id] = {
|
pending_uploads[request_id] = {
|
||||||
capture_token = capture_token,
|
|
||||||
media_type = "audio",
|
media_type = "audio",
|
||||||
mime_type = memo.mime_type,
|
mime_type = memo.mime_type,
|
||||||
memo = memo,
|
memo = memo,
|
||||||
owner = owner,
|
owner = owner,
|
||||||
owner_key = pending_owner_key,
|
owner_key = pending_owner_key,
|
||||||
purpose = "memo",
|
size_bytes = memo.size_bytes,
|
||||||
source = src,
|
source = src,
|
||||||
}
|
}
|
||||||
SetTimeout(Config.Memos.UploadSessionTimeoutMs, function()
|
SetTimeout(Config.Memos.UploadSessionTimeoutMs, function()
|
||||||
@@ -395,7 +397,6 @@ RegisterNetEvent("sky_phone:memos:request-upload", function(data)
|
|||||||
TriggerClientEvent("sky_phone:memos:upload-ready", src, {
|
TriggerClientEvent("sky_phone:memos:upload-ready", src, {
|
||||||
requestId = request_id,
|
requestId = request_id,
|
||||||
correlationId = memo.correlation_id,
|
correlationId = memo.correlation_id,
|
||||||
captureToken = capture_token,
|
|
||||||
presignedUrl = presigned_url,
|
presignedUrl = presigned_url,
|
||||||
uploadTimeoutMs = Config.Media.FiveManage.UploadTimeoutMs,
|
uploadTimeoutMs = Config.Media.FiveManage.UploadTimeoutMs,
|
||||||
})
|
})
|
||||||
@@ -412,14 +413,22 @@ RegisterNetEvent("sky_phone:memos:complete-upload", function(data)
|
|||||||
local owner, error_response = device_owner(src, state.owner.imei)
|
local owner, error_response = device_owner(src, state.owner.imei)
|
||||||
if not owner or owner.imei ~= state.owner.imei or owner.account_id ~= state.owner.account_id then
|
if not owner or owner.imei ~= state.owner.imei or owner.account_id ~= state.owner.account_id then
|
||||||
pending_uploads[request_id] = nil
|
pending_uploads[request_id] = nil
|
||||||
local rejected, _, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload(state, data.remoteId, data.url)
|
local rejected, _, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload(
|
||||||
|
state,
|
||||||
|
data.remoteId,
|
||||||
|
data.url
|
||||||
|
)
|
||||||
if rejected or trusted_remote then
|
if rejected or trusted_remote then
|
||||||
discard_verified_upload(data.remoteId)
|
discard_verified_upload(data.remoteId)
|
||||||
end
|
end
|
||||||
upload_result(src, state.memo.correlation_id, false, error_response and error_response.error or "owner_changed")
|
upload_result(src, state.memo.correlation_id, false, error_response and error_response.error or "owner_changed")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local verified, verify_error, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload(state, data.remoteId, data.url)
|
local verified, verify_error, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload(
|
||||||
|
state,
|
||||||
|
data.remoteId,
|
||||||
|
data.url
|
||||||
|
)
|
||||||
if not verified then
|
if not verified then
|
||||||
pending_uploads[request_id] = nil
|
pending_uploads[request_id] = nil
|
||||||
if trusted_remote then
|
if trusted_remote then
|
||||||
|
|||||||
@@ -218,6 +218,10 @@ local function normalize_server_tracks()
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
server_tracks = {}
|
||||||
|
server_tracks_by_id = {}
|
||||||
|
configured_track_ids = {}
|
||||||
|
|
||||||
for index, configured in ipairs(Config.Music.Tracks or {}) do
|
for index, configured in ipairs(Config.Music.Tracks or {}) do
|
||||||
local id = type(configured) == "table" and trim(configured.Id) or nil
|
local id = type(configured) == "table" and trim(configured.Id) or nil
|
||||||
local title = type(configured) == "table" and trim(configured.Title) or nil
|
local title = type(configured) == "table" and trim(configured.Title) or nil
|
||||||
@@ -300,6 +304,8 @@ end
|
|||||||
|
|
||||||
normalize_server_tracks()
|
normalize_server_tracks()
|
||||||
|
|
||||||
|
AddEventHandler("sky_phone:configurator:serverUpdated", normalize_server_tracks)
|
||||||
|
|
||||||
local function session_owner(source)
|
local function session_owner(source)
|
||||||
local session, error_response = SkyPhone.RequireSession(source)
|
local session, error_response = SkyPhone.RequireSession(source)
|
||||||
if not session then
|
if not session then
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
local resource_name = GetCurrentResourceName()
|
||||||
|
local index_path = "source/html/index.html"
|
||||||
|
local required_static_files = {
|
||||||
|
"source/html/img/custom-app.svg",
|
||||||
|
"source/html/sounds/button.mp3",
|
||||||
|
}
|
||||||
|
|
||||||
|
local function load_non_empty_file(path)
|
||||||
|
local content = LoadResourceFile(resource_name, path)
|
||||||
|
if type(content) ~= "string" or content == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return content
|
||||||
|
end
|
||||||
|
|
||||||
|
local function add_missing_path(missing_paths, seen_paths, path)
|
||||||
|
if seen_paths[path] then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
seen_paths[path] = true
|
||||||
|
missing_paths[#missing_paths + 1] = path
|
||||||
|
end
|
||||||
|
|
||||||
|
local function normalize_entry_asset_path(url)
|
||||||
|
if type(url) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local path = url:gsub("[?#].*$", "")
|
||||||
|
while path:sub(1, 2) == "./" do
|
||||||
|
path = path:sub(3)
|
||||||
|
end
|
||||||
|
|
||||||
|
if path:sub(1, 7) ~= "assets/" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return "source/html/" .. path
|
||||||
|
end
|
||||||
|
|
||||||
|
local function collect_entry_asset_paths(index_html)
|
||||||
|
local paths = {}
|
||||||
|
local seen_paths = {}
|
||||||
|
|
||||||
|
for url in index_html:gmatch("src%s*=%s*[\"']([^\"']+)[\"']") do
|
||||||
|
local path = normalize_entry_asset_path(url)
|
||||||
|
if path and not seen_paths[path] then
|
||||||
|
seen_paths[path] = true
|
||||||
|
paths[#paths + 1] = path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for url in index_html:gmatch("href%s*=%s*[\"']([^\"']+)[\"']") do
|
||||||
|
local path = normalize_entry_asset_path(url)
|
||||||
|
if path and not seen_paths[path] then
|
||||||
|
seen_paths[path] = true
|
||||||
|
paths[#paths + 1] = path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return paths
|
||||||
|
end
|
||||||
|
|
||||||
|
local function find_missing_nui_files()
|
||||||
|
local missing_paths = {}
|
||||||
|
local seen_paths = {}
|
||||||
|
local index_html = load_non_empty_file(index_path)
|
||||||
|
|
||||||
|
if not index_html then
|
||||||
|
add_missing_path(missing_paths, seen_paths, index_path)
|
||||||
|
add_missing_path(missing_paths, seen_paths, "source/html/assets/* (generated entry assets)")
|
||||||
|
else
|
||||||
|
local entry_asset_paths = collect_entry_asset_paths(index_html)
|
||||||
|
if #entry_asset_paths == 0 then
|
||||||
|
add_missing_path(missing_paths, seen_paths, "source/html/assets/* (no entry asset referenced by index.html)")
|
||||||
|
else
|
||||||
|
for index = 1, #entry_asset_paths do
|
||||||
|
local path = entry_asset_paths[index]
|
||||||
|
if not load_non_empty_file(path) then
|
||||||
|
add_missing_path(missing_paths, seen_paths, path)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for index = 1, #required_static_files do
|
||||||
|
local path = required_static_files[index]
|
||||||
|
if not load_non_empty_file(path) then
|
||||||
|
add_missing_path(missing_paths, seen_paths, path)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return missing_paths
|
||||||
|
end
|
||||||
|
|
||||||
|
local function print_missing_nui_notice(missing_paths)
|
||||||
|
local border = "======================================================================"
|
||||||
|
local lines = {
|
||||||
|
("^1%s^0"):format(border),
|
||||||
|
"^1 SKY PHONE UI BUILD IS MISSING OR INCOMPLETE ^0",
|
||||||
|
("^1%s^0"):format(border),
|
||||||
|
"^3 The resource started, but the packaged phone UI cannot load.^0",
|
||||||
|
"",
|
||||||
|
"^3 Missing or invalid files:^0",
|
||||||
|
}
|
||||||
|
|
||||||
|
for index = 1, #missing_paths do
|
||||||
|
lines[#lines + 1] = ("^1 - %s^0"):format(missing_paths[index])
|
||||||
|
end
|
||||||
|
|
||||||
|
lines[#lines + 1] = ""
|
||||||
|
lines[#lines + 1] = "^3 Install the latest published release package, not GitHub's automatic source archive.^0"
|
||||||
|
lines[#lines + 1] = "^5 Release: https://github.com/sky-systems/sky_phone/releases/latest^0"
|
||||||
|
lines[#lines + 1] = "^3 Developers working from source:^0"
|
||||||
|
lines[#lines + 1] = "^5 cd frontend^0"
|
||||||
|
lines[#lines + 1] = "^5 pnpm install --frozen-lockfile^0"
|
||||||
|
lines[#lines + 1] = "^5 pnpm build^0"
|
||||||
|
lines[#lines + 1] = "^5 Build guide: https://github.com/sky-systems/sky_phone#frontend-development^0"
|
||||||
|
lines[#lines + 1] = ("^1%s^0"):format(border)
|
||||||
|
|
||||||
|
print(table.concat(lines, "\n"))
|
||||||
|
end
|
||||||
|
|
||||||
|
local missing_paths = find_missing_nui_files()
|
||||||
|
if #missing_paths > 0 then
|
||||||
|
print_missing_nui_notice(missing_paths)
|
||||||
|
end
|
||||||
@@ -1,8 +1,23 @@
|
|||||||
Bridge.Database.AfterMigration("sky_phone", function()
|
Bridge.Database.AfterMigration("sky_phone", function()
|
||||||
local categories = {}
|
local categories = {}
|
||||||
local districts = {}
|
local districts = {}
|
||||||
for _, value in ipairs(Config.LocalPages.Categories) do categories[value] = true end
|
|
||||||
for _, value in ipairs(Config.Marketplace.Districts) do districts[value] = true end
|
local function refresh_runtime_configuration()
|
||||||
|
categories = {}
|
||||||
|
districts = {}
|
||||||
|
for _, value in ipairs(Config.LocalPages.Categories) do
|
||||||
|
categories[value] = true
|
||||||
|
end
|
||||||
|
for _, value in ipairs(Config.Marketplace.Districts) do
|
||||||
|
districts[value] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
refresh_runtime_configuration()
|
||||||
|
|
||||||
|
AddEventHandler("sky_phone:configurator:serverUpdated", function()
|
||||||
|
refresh_runtime_configuration()
|
||||||
|
end)
|
||||||
|
|
||||||
local function trim(value)
|
local function trim(value)
|
||||||
if type(value) ~= "string" then return nil end
|
if type(value) ~= "string" then return nil end
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ Bridge.Callbacks.Register("sky_phone:device:open-request", function(source)
|
|||||||
return { success = true, data = { queued = true } }
|
return { success = true, data = { queued = true } }
|
||||||
end
|
end
|
||||||
|
|
||||||
return { success = phone_open_handler(source, nil) }
|
local success, error_code = phone_open_handler(source, nil)
|
||||||
|
return { success = success, error = error_code }
|
||||||
end)
|
end)
|
||||||
|
|
||||||
Bridge.Callbacks.Register("sky_phone:device:development-open", function(source)
|
Bridge.Callbacks.Register("sky_phone:device:development-open", function(source)
|
||||||
@@ -31,7 +32,8 @@ Bridge.Callbacks.Register("sky_phone:device:development-open", function(source)
|
|||||||
return { success = true, data = { queued = true } }
|
return { success = true, data = { queued = true } }
|
||||||
end
|
end
|
||||||
|
|
||||||
return { success = phone_open_handler(source, nil) }
|
local success, error_code = phone_open_handler(source, nil)
|
||||||
|
return { success = success, error = error_code }
|
||||||
end)
|
end)
|
||||||
|
|
||||||
AddEventHandler("playerDropped", function()
|
AddEventHandler("playerDropped", function()
|
||||||
@@ -52,8 +54,6 @@ Bridge.Debug("debug", "[sky_phone] Server initialization started after database
|
|||||||
|
|
||||||
SkyPhone = {}
|
SkyPhone = {}
|
||||||
|
|
||||||
local unique_phones = Config.Phone.Unique ~= false
|
|
||||||
local sim_cards_enabled = Config.Sim.Enabled ~= false
|
|
||||||
local sessions = {}
|
local sessions = {}
|
||||||
local preferred_device_imeis = {}
|
local preferred_device_imeis = {}
|
||||||
local equipped_phone_numbers = {}
|
local equipped_phone_numbers = {}
|
||||||
@@ -275,7 +275,7 @@ end
|
|||||||
local function find_device_slots(source, imei)
|
local function find_device_slots(source, imei)
|
||||||
local matches = {}
|
local matches = {}
|
||||||
local slots = Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)
|
local slots = Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)
|
||||||
if not unique_phones then
|
if Config.Phone.Unique == false then
|
||||||
if not slots[1] then
|
if not slots[1] then
|
||||||
return matches
|
return matches
|
||||||
end
|
end
|
||||||
@@ -366,7 +366,7 @@ local function resolve_used_slot(source, used_item)
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
if not unique_phones and #slots > 0 then
|
if Config.Phone.Unique == false and #slots > 0 then
|
||||||
return slots[1]
|
return slots[1]
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -394,9 +394,10 @@ local function resolve_used_slot(source, used_item)
|
|||||||
|
|
||||||
Bridge.Debug(
|
Bridge.Debug(
|
||||||
"warn",
|
"warn",
|
||||||
"[sky_phone] No phone item slot was available for source %s (%s candidates).",
|
"[sky_phone] Inventory '%s' returned no '%s' phone item for source %s. This is an item/configuration problem, not a missing SIM card.",
|
||||||
tostring(source),
|
tostring(Bridge.Inventory.GetResourceName()),
|
||||||
tostring(#slots)
|
tostring(Config.Phone.Item),
|
||||||
|
tostring(source)
|
||||||
)
|
)
|
||||||
return nil, "phone_required"
|
return nil, "phone_required"
|
||||||
end
|
end
|
||||||
@@ -413,7 +414,7 @@ local function ensure_device(source, slot)
|
|||||||
tostring(Bridge.Inventory.GetResourceName()),
|
tostring(Bridge.Inventory.GetResourceName()),
|
||||||
{ always = true }
|
{ always = true }
|
||||||
)
|
)
|
||||||
if not unique_phones then
|
if Config.Phone.Unique == false then
|
||||||
if amount < 1 then
|
if amount < 1 then
|
||||||
return nil, "phone_slot_missing"
|
return nil, "phone_slot_missing"
|
||||||
end
|
end
|
||||||
@@ -547,6 +548,10 @@ local function bootstrap(source, security, security_loaded)
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
token = session.token,
|
token = session.token,
|
||||||
|
phoneNumberFormat = {
|
||||||
|
length = Config.Sim.NumberLength,
|
||||||
|
groups = Config.Sim.NumberGroups,
|
||||||
|
},
|
||||||
security = SkyPhoneSecurity.Status(device.imei, security, security_loaded),
|
security = SkyPhoneSecurity.Status(device.imei, security, security_loaded),
|
||||||
device = {
|
device = {
|
||||||
imei = device.imei,
|
imei = device.imei,
|
||||||
@@ -554,7 +559,7 @@ local function bootstrap(source, security, security_loaded)
|
|||||||
sim = device.sim_id and {
|
sim = device.sim_id and {
|
||||||
id = device.sim_id,
|
id = device.sim_id,
|
||||||
number = device.phone_number,
|
number = device.phone_number,
|
||||||
removable = sim_cards_enabled and tonumber(device.sim_is_virtual) ~= 1,
|
removable = Config.Sim.Enabled ~= false and tonumber(device.sim_is_virtual) ~= 1,
|
||||||
type = device.sim_type,
|
type = device.sim_type,
|
||||||
registered = device.registered_at ~= nil,
|
registered = device.registered_at ~= nil,
|
||||||
} or nil,
|
} or nil,
|
||||||
@@ -609,7 +614,7 @@ local function resolve_equipped_phone_number(source)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local imei
|
local imei
|
||||||
if unique_phones then
|
if Config.Phone.Unique ~= false then
|
||||||
table.sort(slots, function(left, right)
|
table.sort(slots, function(left, right)
|
||||||
local left_slot = tonumber(left.slot)
|
local left_slot = tonumber(left.slot)
|
||||||
local right_slot = tonumber(right.slot)
|
local right_slot = tonumber(right.slot)
|
||||||
@@ -795,7 +800,7 @@ function SkyPhone.NotifyAccountDevices(account_id, event_name, data)
|
|||||||
|
|
||||||
for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do
|
for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do
|
||||||
local source = tonumber(player_source) or player_source
|
local source = tonumber(player_source) or player_source
|
||||||
if not unique_phones then
|
if Config.Phone.Unique == false then
|
||||||
if Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)[1] then
|
if Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)[1] then
|
||||||
local imei = load_character_device(source)
|
local imei = load_character_device(source)
|
||||||
local device = imei and devices[imei] or nil
|
local device = imei and devices[imei] or nil
|
||||||
@@ -852,7 +857,7 @@ local function open_phone(source, used_item)
|
|||||||
{ always = true }
|
{ always = true }
|
||||||
)
|
)
|
||||||
TriggerClientEvent("sky_phone:device:error", source, slot_error)
|
TriggerClientEvent("sky_phone:device:error", source, slot_error)
|
||||||
return false
|
return false, slot_error
|
||||||
end
|
end
|
||||||
|
|
||||||
local imei, error_code = ensure_device(source, slot)
|
local imei, error_code = ensure_device(source, slot)
|
||||||
@@ -866,7 +871,7 @@ local function open_phone(source, used_item)
|
|||||||
{ always = true }
|
{ always = true }
|
||||||
)
|
)
|
||||||
TriggerClientEvent("sky_phone:device:error", source, error_code)
|
TriggerClientEvent("sky_phone:device:error", source, error_code)
|
||||||
return false
|
return false, error_code
|
||||||
end
|
end
|
||||||
local prepared, prepare_error = SkyPhoneSim.PrepareDevice(source, slot, imei)
|
local prepared, prepare_error = SkyPhoneSim.PrepareDevice(source, slot, imei)
|
||||||
if not prepared then
|
if not prepared then
|
||||||
@@ -877,8 +882,9 @@ local function open_phone(source, used_item)
|
|||||||
imei,
|
imei,
|
||||||
tostring(prepare_error)
|
tostring(prepare_error)
|
||||||
)
|
)
|
||||||
TriggerClientEvent("sky_phone:device:error", source, prepare_error or "request_failed")
|
local error_code = prepare_error or "request_failed"
|
||||||
return false
|
TriggerClientEvent("sky_phone:device:error", source, error_code)
|
||||||
|
return false, error_code
|
||||||
end
|
end
|
||||||
|
|
||||||
local security = SkyPhoneSecurity.Load(imei)
|
local security = SkyPhoneSecurity.Load(imei)
|
||||||
@@ -892,7 +898,20 @@ local function open_phone(source, used_item)
|
|||||||
unlocked = security == nil,
|
unlocked = security == nil,
|
||||||
}
|
}
|
||||||
preferred_device_imeis[source] = imei
|
preferred_device_imeis[source] = imei
|
||||||
local payload = bootstrap(source, security, true)
|
local payload, bootstrap_error = bootstrap(source, security, true)
|
||||||
|
if not payload then
|
||||||
|
local error_code = type(bootstrap_error) == "table" and bootstrap_error.error or "request_failed"
|
||||||
|
Bridge.Debug(
|
||||||
|
"error",
|
||||||
|
"[sky_phone] Phone bootstrap failed for source %s slot %s IMEI %s: %s.",
|
||||||
|
tostring(source),
|
||||||
|
tostring(slot.slot),
|
||||||
|
imei,
|
||||||
|
tostring(error_code)
|
||||||
|
)
|
||||||
|
TriggerClientEvent("sky_phone:device:error", source, error_code)
|
||||||
|
return false, error_code
|
||||||
|
end
|
||||||
Bridge.Debug(
|
Bridge.Debug(
|
||||||
"debug",
|
"debug",
|
||||||
"[sky_phone] Triggering client open for source %s slot %s IMEI %s account_linked=%s after %sms.",
|
"[sky_phone] Triggering client open for source %s slot %s IMEI %s account_linked=%s after %sms.",
|
||||||
@@ -932,30 +951,53 @@ function SkyPhone.OpenDeviceForCall(source, imei)
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
preferred_device_imeis[source] = imei
|
preferred_device_imeis[source] = imei
|
||||||
TriggerClientEvent("sky_phone:device:open", source, bootstrap(source))
|
local payload, bootstrap_error = bootstrap(source)
|
||||||
|
if not payload then
|
||||||
|
Bridge.Debug(
|
||||||
|
"error",
|
||||||
|
"[sky_phone] Call notification bootstrap failed for source %s IMEI %s: %s.",
|
||||||
|
tostring(source),
|
||||||
|
tostring(imei),
|
||||||
|
tostring(type(bootstrap_error) == "table" and bootstrap_error.error or "request_failed")
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
TriggerClientEvent("sky_phone:device:open", source, payload)
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
Bridge.Debug(
|
local registered_usable_items = {}
|
||||||
"debug",
|
|
||||||
"[sky_phone] Registering usable item '%s' through inventory '%s'.",
|
local function register_configured_phone_item()
|
||||||
Config.Phone.Item,
|
local item_name = Config.Phone.Item
|
||||||
tostring(Bridge.Inventory.GetResourceName()),
|
if registered_usable_items[item_name] then
|
||||||
{ always = true }
|
return
|
||||||
)
|
end
|
||||||
local usable_registered = Bridge.Inventory.RegisterUsableItem(Config.Phone.Item, open_phone)
|
Bridge.Debug(
|
||||||
if not usable_registered then
|
"debug",
|
||||||
error(("[sky_phone] Inventory '%s' did not register phone item '%s' as usable."):format(
|
"[sky_phone] Registering usable item '%s' through inventory '%s'.",
|
||||||
|
item_name,
|
||||||
tostring(Bridge.Inventory.GetResourceName()),
|
tostring(Bridge.Inventory.GetResourceName()),
|
||||||
tostring(Config.Phone.Item)
|
{ always = true }
|
||||||
))
|
)
|
||||||
|
if not Bridge.Inventory.RegisterUsableItem(item_name, function(...)
|
||||||
|
if Config.Phone.Item == item_name then
|
||||||
|
open_phone(...)
|
||||||
|
end
|
||||||
|
end) then
|
||||||
|
error(("[sky_phone] Inventory '%s' did not register phone item '%s' as usable."):format(
|
||||||
|
tostring(Bridge.Inventory.GetResourceName()),
|
||||||
|
tostring(item_name)
|
||||||
|
))
|
||||||
|
end
|
||||||
|
registered_usable_items[item_name] = true
|
||||||
end
|
end
|
||||||
Bridge.Debug(
|
|
||||||
"debug",
|
register_configured_phone_item()
|
||||||
"[sky_phone] Usable item registration returned: %s.",
|
|
||||||
tostring(usable_registered),
|
AddEventHandler("sky_phone:configurator:serverUpdated", function()
|
||||||
{ always = true }
|
register_configured_phone_item()
|
||||||
)
|
end)
|
||||||
|
|
||||||
Bridge.Callbacks.Register("sky_phone:device:close", function(source)
|
Bridge.Callbacks.Register("sky_phone:device:close", function(source)
|
||||||
sessions[source] = nil
|
sessions[source] = nil
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
|||||||
|
SkyPhoneConfiguratorSchema = {
|
||||||
|
name = "sky_phone_configurator",
|
||||||
|
columns = {
|
||||||
|
{ name = "id", type = "TINYINT UNSIGNED NOT NULL" },
|
||||||
|
{ name = "config_payload", type = "LONGTEXT NOT NULL" },
|
||||||
|
{ name = "media_payload", type = "LONGTEXT NOT NULL" },
|
||||||
|
{ name = "revision", type = "INT UNSIGNED NOT NULL DEFAULT 1" },
|
||||||
|
{
|
||||||
|
name = "updated_by_identifier",
|
||||||
|
type = "VARCHAR(80) NULL",
|
||||||
|
characterSet = "ascii",
|
||||||
|
collation = "ascii_bin",
|
||||||
|
},
|
||||||
|
{ name = "updated_by_name", type = "VARCHAR(120) NULL" },
|
||||||
|
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||||
|
{
|
||||||
|
name = "updated_at",
|
||||||
|
type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
primaryKey = "id",
|
||||||
|
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
Bridge.Database.AfterMigration("sky_phone", function()
|
Bridge.Database.AfterMigration("sky_phone", function()
|
||||||
|
SkyPhonePersistence = {}
|
||||||
|
|
||||||
local max_device_data_bytes = 100000
|
local max_device_data_bytes = 100000
|
||||||
local allowed_device_namespaces = {
|
local allowed_device_namespaces = {
|
||||||
settings = true,
|
settings = true,
|
||||||
@@ -150,6 +152,69 @@ Bridge.Callbacks.Register("sky_phone:notifications:save", function(source, data)
|
|||||||
return { success = true }
|
return { success = true }
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
function SkyPhonePersistence.FactoryReset(imei)
|
||||||
|
if not SkyPhoneImei.IsValid(imei) then
|
||||||
|
return false, "invalid_request"
|
||||||
|
end
|
||||||
|
|
||||||
|
local device = SkyPhone.LoadDevice(imei)
|
||||||
|
if not device then
|
||||||
|
return false, "device_not_found"
|
||||||
|
end
|
||||||
|
local phone_number = device and device.phone_number or nil
|
||||||
|
local media_remote_ids = SkyPhoneMedia.GetDeviceRemoteIds(imei)
|
||||||
|
if not Bridge.Database.Transaction({
|
||||||
|
{
|
||||||
|
query = "DELETE FROM `sky_phone_device_security` WHERE `device_imei` = ?",
|
||||||
|
params = { imei },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query = "DELETE FROM `sky_phone_device_data` WHERE `device_imei` = ?",
|
||||||
|
params = { imei },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query = "DELETE FROM `sky_phone_custom_app_data` WHERE `device_imei` = ?",
|
||||||
|
params = { imei },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query = "DELETE FROM `sky_phone_notes` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
||||||
|
params = { imei },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query = "DELETE FROM `sky_phone_media` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
||||||
|
params = { imei },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query = "DELETE FROM `sky_phone_music_playlists` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
||||||
|
params = { imei },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query = "DELETE FROM `sky_phone_music_youtube_songs` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
||||||
|
params = { imei },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query = "DELETE FROM `sky_phone_contacts` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
||||||
|
params = { imei },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query = "DELETE FROM `sky_phone_call_entries` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
||||||
|
params = { imei },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query = "DELETE FROM `sky_phone_fliptok_sessions` WHERE `device_imei` = ?",
|
||||||
|
params = { imei },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query = "UPDATE `sky_phone_devices` SET `account_id` = NULL, `device_name` = ? WHERE `imei` = ?",
|
||||||
|
params = { Config.Phone.DeviceName, imei },
|
||||||
|
},
|
||||||
|
}) then
|
||||||
|
return false, "request_failed"
|
||||||
|
end
|
||||||
|
SkyPhoneMedia.CleanupRemoteFiles(media_remote_ids)
|
||||||
|
return true, phone_number
|
||||||
|
end
|
||||||
|
|
||||||
Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source)
|
Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source)
|
||||||
if not SkyPhone.AllowOperation(source, "factory_reset", 3, 60) then
|
if not SkyPhone.AllowOperation(source, "factory_reset", 3, 60) then
|
||||||
return { success = false, error = "rate_limited" }
|
return { success = false, error = "rate_limited" }
|
||||||
@@ -158,58 +223,11 @@ Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source)
|
|||||||
if not session then
|
if not session then
|
||||||
return error_response
|
return error_response
|
||||||
end
|
end
|
||||||
local device = SkyPhone.LoadDevice(session.imei)
|
|
||||||
local phone_number = device and device.phone_number or nil
|
local reset, phone_number = SkyPhonePersistence.FactoryReset(session.imei)
|
||||||
local media_remote_ids = SkyPhoneMedia.GetDeviceRemoteIds(session.imei)
|
if not reset then
|
||||||
if not Bridge.Database.Transaction({
|
return { success = false, error = phone_number }
|
||||||
{
|
|
||||||
query = "DELETE FROM `sky_phone_device_security` WHERE `device_imei` = ?",
|
|
||||||
params = { session.imei },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
query = "DELETE FROM `sky_phone_device_data` WHERE `device_imei` = ?",
|
|
||||||
params = { session.imei },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
query = "DELETE FROM `sky_phone_custom_app_data` WHERE `device_imei` = ?",
|
|
||||||
params = { session.imei },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
query = "DELETE FROM `sky_phone_notes` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
|
||||||
params = { session.imei },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
query = "DELETE FROM `sky_phone_media` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
|
||||||
params = { session.imei },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
query = "DELETE FROM `sky_phone_music_playlists` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
|
||||||
params = { session.imei },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
query = "DELETE FROM `sky_phone_music_youtube_songs` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
|
||||||
params = { session.imei },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
query = "DELETE FROM `sky_phone_contacts` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
|
||||||
params = { session.imei },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
query = "DELETE FROM `sky_phone_call_entries` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
|
||||||
params = { session.imei },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
query = "DELETE FROM `sky_phone_fliptok_sessions` WHERE `device_imei` = ?",
|
|
||||||
params = { session.imei },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
query = "UPDATE `sky_phone_devices` SET `account_id` = NULL, `device_name` = ? WHERE `imei` = ?",
|
|
||||||
params = { Config.Phone.DeviceName, session.imei },
|
|
||||||
},
|
|
||||||
}) then
|
|
||||||
return { success = false, error = "request_failed" }
|
|
||||||
end
|
end
|
||||||
SkyPhoneMedia.CleanupRemoteFiles(media_remote_ids)
|
|
||||||
session.unlocked = true
|
session.unlocked = true
|
||||||
if phone_number then
|
if phone_number then
|
||||||
TriggerEvent("sky_phone:server:factoryReset", source, phone_number)
|
TriggerEvent("sky_phone:server:factoryReset", source, phone_number)
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ Bridge.Database.AfterMigration("sky_phone", function()
|
|||||||
SkyPhoneSecurity = {}
|
SkyPhoneSecurity = {}
|
||||||
|
|
||||||
local passcode_pepper = tostring(Config.Server.PasscodePepper or "")
|
local passcode_pepper = tostring(Config.Server.PasscodePepper or "")
|
||||||
|
AddEventHandler("sky_phone:configurator:serverUpdated", function()
|
||||||
|
passcode_pepper = tostring(Config.Server.PasscodePepper or "")
|
||||||
|
end)
|
||||||
|
|
||||||
if passcode_pepper == "" then
|
if passcode_pepper == "" then
|
||||||
Bridge.Debug(
|
Bridge.Debug(
|
||||||
"warn",
|
"warn",
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
Bridge.Database.AfterMigration("sky_phone", function()
|
Bridge.Database.AfterMigration("sky_phone", function()
|
||||||
local report_reasons = {}
|
local report_reasons = {}
|
||||||
local password_pepper = tostring(Config.Server.PicstagramPasswordPepper or "")
|
local password_pepper = ""
|
||||||
|
|
||||||
|
local function refresh_runtime_configuration()
|
||||||
|
password_pepper = tostring(Config.Server.PicstagramPasswordPepper or "")
|
||||||
|
report_reasons = {}
|
||||||
|
for index = 1, #Config.Picstagram.ReportReasons do
|
||||||
|
report_reasons[Config.Picstagram.ReportReasons[index]] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
refresh_runtime_configuration()
|
||||||
|
|
||||||
if password_pepper == "" then
|
if password_pepper == "" then
|
||||||
Bridge.Debug(
|
Bridge.Debug(
|
||||||
@@ -10,9 +20,9 @@ if password_pepper == "" then
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
for index = 1, #Config.Picstagram.ReportReasons do
|
AddEventHandler("sky_phone:configurator:serverUpdated", function()
|
||||||
report_reasons[Config.Picstagram.ReportReasons[index]] = true
|
refresh_runtime_configuration()
|
||||||
end
|
end)
|
||||||
|
|
||||||
local function trim(value)
|
local function trim(value)
|
||||||
if type(value) ~= "string" then
|
if type(value) ~= "string" then
|
||||||
@@ -1434,7 +1444,10 @@ Bridge.Callbacks.Register("sky_phone:picstagram:admin-resolve-report", function(
|
|||||||
return { success = true }
|
return { success = true }
|
||||||
end)
|
end)
|
||||||
|
|
||||||
RegisterCommand(Config.Picstagram.VerifyCommand, function(source, args)
|
local active_verify_command = nil
|
||||||
|
local registered_verify_commands = {}
|
||||||
|
|
||||||
|
local function run_verify_command(source, args)
|
||||||
local command_locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale).PicstagramCommand
|
local command_locale = SkyPhoneLocales.Resolve(Config.Bridge.Locale).PicstagramCommand
|
||||||
local function command_message(template, values)
|
local function command_message(template, values)
|
||||||
return template:gsub("{(%w+)}", function(key)
|
return template:gsub("{(%w+)}", function(key)
|
||||||
@@ -1489,5 +1502,26 @@ RegisterCommand(Config.Picstagram.VerifyCommand, function(source, args)
|
|||||||
handle = handle,
|
handle = handle,
|
||||||
state = state == 1 and command_locale.verified or command_locale.unverified,
|
state = state == 1 and command_locale.verified or command_locale.unverified,
|
||||||
}), "success")
|
}), "success")
|
||||||
end, false)
|
end
|
||||||
|
|
||||||
|
local function refresh_verify_command()
|
||||||
|
local command_name = Config.Picstagram.VerifyCommand
|
||||||
|
if type(command_name) ~= "string" or command_name == "" then
|
||||||
|
error("[sky_phone] Config.Picstagram.VerifyCommand must be a non-empty command name.")
|
||||||
|
end
|
||||||
|
active_verify_command = command_name
|
||||||
|
if registered_verify_commands[command_name] then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
registered_verify_commands[command_name] = true
|
||||||
|
RegisterCommand(command_name, function(source, args)
|
||||||
|
if active_verify_command == command_name then
|
||||||
|
run_verify_command(source, args)
|
||||||
|
end
|
||||||
|
end, false)
|
||||||
|
end
|
||||||
|
|
||||||
|
refresh_verify_command()
|
||||||
|
|
||||||
|
AddEventHandler("sky_phone:configurator:serverUpdated", refresh_verify_command)
|
||||||
end)
|
end)
|
||||||
|
|||||||
@@ -25,6 +25,18 @@ local SERVER_CAPABILITIES = {
|
|||||||
side = "server",
|
side = "server",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
local function refresh_configured_capabilities()
|
||||||
|
local enabled = Config.CustomApps.Enabled == true
|
||||||
|
local external = enabled and Config.CustomApps.ExternalApps == true
|
||||||
|
SERVER_CAPABILITIES.features.customApps.enabled = enabled
|
||||||
|
SERVER_CAPABILITIES.features.customApps.external = external
|
||||||
|
SERVER_CAPABILITIES.features.notifications.customApps = external
|
||||||
|
end
|
||||||
|
|
||||||
|
refresh_configured_capabilities()
|
||||||
|
|
||||||
|
AddEventHandler("sky_phone:configurator:serverUpdated", refresh_configured_capabilities)
|
||||||
|
|
||||||
local custom_app_api = SkyPhoneApps.ServerPublicApi
|
local custom_app_api = SkyPhoneApps.ServerPublicApi
|
||||||
if type(custom_app_api) ~= "table" then
|
if type(custom_app_api) ~= "table" then
|
||||||
error("[sky_phone] Server public API initialized before the custom app policy API.")
|
error("[sky_phone] Server public API initialized before the custom app policy API.")
|
||||||
|
|||||||
+111
-15
@@ -2,14 +2,29 @@ Bridge.Database.AfterMigration("sky_phone", function()
|
|||||||
|
|
||||||
SkyPhoneSim = {}
|
SkyPhoneSim = {}
|
||||||
|
|
||||||
local unique_phones = Config.Phone.Unique ~= false
|
|
||||||
local sim_cards_enabled = Config.Sim.Enabled ~= false
|
|
||||||
local pending_insertions = {}
|
local pending_insertions = {}
|
||||||
local operation_locks = {}
|
local operation_locks = {}
|
||||||
local sim_types = {
|
local sim_types = {}
|
||||||
[Config.Sim.RegisteredItem] = "registered",
|
|
||||||
[Config.Sim.AnonymousItem] = "anonymous",
|
local function validate_number_configuration()
|
||||||
}
|
local valid, validation_error = SkyPhoneSimNumber.ValidateConfiguration(
|
||||||
|
Config.Sim.NumberLength,
|
||||||
|
Config.Sim.NumberPrefix
|
||||||
|
)
|
||||||
|
if not valid then
|
||||||
|
error(("[sky_phone] Invalid SIM number configuration: %s."):format(validation_error))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function refresh_sim_types()
|
||||||
|
sim_types = {
|
||||||
|
[Config.Sim.RegisteredItem] = "registered",
|
||||||
|
[Config.Sim.AnonymousItem] = "anonymous",
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
validate_number_configuration()
|
||||||
|
refresh_sim_types()
|
||||||
|
|
||||||
local function affected_rows(result)
|
local function affected_rows(result)
|
||||||
if type(result) == "number" then
|
if type(result) == "number" then
|
||||||
@@ -68,7 +83,7 @@ local function sim_metadata(sim)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function set_phone_sim_metadata(source, phone_slot, sim)
|
local function set_phone_sim_metadata(source, phone_slot, sim)
|
||||||
if not unique_phones then
|
if Config.Phone.Unique == false then
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
if not phone_slot then
|
if not phone_slot then
|
||||||
@@ -94,7 +109,7 @@ local function prepare_device(source, phone_slot, imei)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local sim = device.sim_id and load_sim(device.sim_id) or nil
|
local sim = device.sim_id and load_sim(device.sim_id) or nil
|
||||||
if sim_cards_enabled then
|
if Config.Sim.Enabled ~= false then
|
||||||
if sim and tonumber(sim.is_virtual) == 1 then
|
if sim and tonumber(sim.is_virtual) == 1 then
|
||||||
local result = Bridge.Database.Query([[
|
local result = Bridge.Database.Query([[
|
||||||
UPDATE `sky_phone_devices`
|
UPDATE `sky_phone_devices`
|
||||||
@@ -153,6 +168,62 @@ end
|
|||||||
|
|
||||||
SkyPhoneSim.PrepareDevice = prepare_device
|
SkyPhoneSim.PrepareDevice = prepare_device
|
||||||
|
|
||||||
|
function SkyPhoneSim.ChangeNumber(source, imei, sim_id, value)
|
||||||
|
local number = SkyPhoneSimNumber.Normalize(value, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
|
||||||
|
if not number or SkyPhoneCompanies.IsServiceNumber(number) then
|
||||||
|
return false, "invalid_phone_number"
|
||||||
|
end
|
||||||
|
|
||||||
|
local sim = load_sim(sim_id)
|
||||||
|
if not sim then
|
||||||
|
return false, "no_sim"
|
||||||
|
end
|
||||||
|
if sim.phone_number == number then
|
||||||
|
return false, "phone_number_unchanged"
|
||||||
|
end
|
||||||
|
|
||||||
|
local existing = Bridge.Database.Query(
|
||||||
|
"SELECT `id` FROM `sky_phone_sims` WHERE `phone_number` = ? AND `id` <> ? LIMIT 1",
|
||||||
|
{ number, sim_id }
|
||||||
|
)
|
||||||
|
if existing[1] then
|
||||||
|
return false, "phone_number_taken"
|
||||||
|
end
|
||||||
|
|
||||||
|
local phone_slot
|
||||||
|
if Config.Phone.Unique ~= false then
|
||||||
|
for _, slot in ipairs(Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)) do
|
||||||
|
if slot.metadata and slot.metadata.imei == imei then
|
||||||
|
phone_slot = slot
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local previous_number = sim.phone_number
|
||||||
|
sim.phone_number = number
|
||||||
|
if phone_slot and not set_phone_sim_metadata(source, phone_slot, sim) then
|
||||||
|
return false, "metadata_unsupported"
|
||||||
|
end
|
||||||
|
|
||||||
|
local result = Bridge.Database.Query([[
|
||||||
|
UPDATE IGNORE `sky_phone_sims`
|
||||||
|
SET `phone_number` = ?
|
||||||
|
WHERE `id` = ? AND `phone_number` = ?
|
||||||
|
]], { number, sim_id, previous_number })
|
||||||
|
if affected_rows(result) ~= 1 then
|
||||||
|
if phone_slot then
|
||||||
|
sim.phone_number = previous_number
|
||||||
|
if not set_phone_sim_metadata(source, phone_slot, sim) then
|
||||||
|
error("[sky_phone] Could not restore SIM metadata after a failed admin number change.")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false, "phone_number_taken"
|
||||||
|
end
|
||||||
|
|
||||||
|
return true, number
|
||||||
|
end
|
||||||
|
|
||||||
local function resolve_used_sim(source, used_item, item_name)
|
local function resolve_used_sim(source, used_item, item_name)
|
||||||
local slot_id = used_item and (used_item.slot or used_item.id)
|
local slot_id = used_item and (used_item.slot or used_item.id)
|
||||||
local slot = slot_id and Bridge.Inventory.GetSlot(source, slot_id) or nil
|
local slot = slot_id and Bridge.Inventory.GetSlot(source, slot_id) or nil
|
||||||
@@ -244,7 +315,7 @@ local function rollback_phone_metadata(source, phone_slot, old_sim)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function insert_sim(source, phone_imei, confirmed)
|
local function insert_sim(source, phone_imei, confirmed)
|
||||||
if not sim_cards_enabled then
|
if Config.Sim.Enabled == false then
|
||||||
return { success = false, error = "disabled" }
|
return { success = false, error = "disabled" }
|
||||||
end
|
end
|
||||||
if operation_locks[source] then
|
if operation_locks[source] then
|
||||||
@@ -348,7 +419,7 @@ local function insert_sim(source, phone_imei, confirmed)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function use_sim(source, used_item)
|
local function use_sim(source, used_item)
|
||||||
if not sim_cards_enabled then
|
if Config.Sim.Enabled == false then
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
local item_name = used_item and used_item.name
|
local item_name = used_item and used_item.name
|
||||||
@@ -393,17 +464,36 @@ local function use_sim(source, used_item)
|
|||||||
TriggerClientEvent("sky_phone:sim:picker", source, {
|
TriggerClientEvent("sky_phone:sim:picker", source, {
|
||||||
choices = choices,
|
choices = choices,
|
||||||
number = sim.phone_number,
|
number = sim.phone_number,
|
||||||
|
phoneNumberFormat = {
|
||||||
|
length = Config.Sim.NumberLength,
|
||||||
|
groups = Config.Sim.NumberGroups,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Register the guarded callbacks in both modes so a resource-only restart can
|
-- Register the guarded callbacks in both modes so a resource-only restart can
|
||||||
-- replace registrations left behind in framework-owned usable-item tables.
|
-- replace registrations left behind in framework-owned usable-item tables.
|
||||||
Bridge.Inventory.RegisterUsableItem(Config.Sim.RegisteredItem, use_sim)
|
local registered_sim_items = {}
|
||||||
Bridge.Inventory.RegisterUsableItem(Config.Sim.AnonymousItem, use_sim)
|
|
||||||
|
local function register_configured_sim_items()
|
||||||
|
for item_name in pairs(sim_types) do
|
||||||
|
if not registered_sim_items[item_name] then
|
||||||
|
local registered_item_name = item_name
|
||||||
|
Bridge.Inventory.RegisterUsableItem(registered_item_name, function(...)
|
||||||
|
if Config.Sim.Enabled ~= false and sim_types[registered_item_name] then
|
||||||
|
use_sim(...)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
registered_sim_items[registered_item_name] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
register_configured_sim_items()
|
||||||
|
|
||||||
Bridge.Callbacks.Register("sky_phone:sim:insert", function(source, data)
|
Bridge.Callbacks.Register("sky_phone:sim:insert", function(source, data)
|
||||||
if not sim_cards_enabled then
|
if Config.Sim.Enabled == false then
|
||||||
return { success = false, error = "disabled" }
|
return { success = false, error = "disabled" }
|
||||||
end
|
end
|
||||||
if type(data) ~= "table" or not SkyPhoneImei.IsValid(data.imei) then
|
if type(data) ~= "table" or not SkyPhoneImei.IsValid(data.imei) then
|
||||||
@@ -421,7 +511,7 @@ Bridge.Callbacks.Register("sky_phone:sim:picker-close", function(source)
|
|||||||
end)
|
end)
|
||||||
|
|
||||||
Bridge.Callbacks.Register("sky_phone:sim:eject", function(source)
|
Bridge.Callbacks.Register("sky_phone:sim:eject", function(source)
|
||||||
if not sim_cards_enabled then
|
if Config.Sim.Enabled == false then
|
||||||
return { success = false, error = "disabled" }
|
return { success = false, error = "disabled" }
|
||||||
end
|
end
|
||||||
if operation_locks[source] then
|
if operation_locks[source] then
|
||||||
@@ -474,7 +564,13 @@ AddEventHandler("playerDropped", function()
|
|||||||
operation_locks[source] = nil
|
operation_locks[source] = nil
|
||||||
end)
|
end)
|
||||||
|
|
||||||
if sim_cards_enabled then
|
AddEventHandler("sky_phone:configurator:serverUpdated", function()
|
||||||
|
validate_number_configuration()
|
||||||
|
refresh_sim_types()
|
||||||
|
register_configured_sim_items()
|
||||||
|
end)
|
||||||
|
|
||||||
|
if Config.Sim.Enabled ~= false then
|
||||||
Bridge.Database.Query([[
|
Bridge.Database.Query([[
|
||||||
UPDATE `sky_phone_devices` d
|
UPDATE `sky_phone_devices` d
|
||||||
INNER JOIN `sky_phone_sims` s ON s.`id` = d.`sim_id`
|
INNER JOIN `sky_phone_sims` s ON s.`id` = d.`sim_id`
|
||||||
|
|||||||
@@ -11,20 +11,30 @@ local online_drivers = {}
|
|||||||
local operation_locks = {}
|
local operation_locks = {}
|
||||||
local services = {}
|
local services = {}
|
||||||
|
|
||||||
if Config.SkyRide.DistanceUnit ~= "kilometer" and Config.SkyRide.DistanceUnit ~= "mile" then
|
local function refresh_runtime_configuration()
|
||||||
error(("[sky_phone] Invalid SkyRide distance unit '%s'."):format(tostring(Config.SkyRide.DistanceUnit)))
|
if Config.SkyRide.DistanceUnit ~= "kilometer" and Config.SkyRide.DistanceUnit ~= "mile" then
|
||||||
|
error(("[sky_phone] Invalid SkyRide distance unit '%s'."):format(tostring(Config.SkyRide.DistanceUnit)))
|
||||||
|
end
|
||||||
|
|
||||||
|
local next_services = {}
|
||||||
|
for index = 1, #Config.SkyRide.Services do
|
||||||
|
local service = Config.SkyRide.Services[index]
|
||||||
|
if service.Id ~= "taxi" and service.Id ~= "comfort" and service.Id ~= "xl" and service.Id ~= "premium" then
|
||||||
|
error(("[sky_phone] Invalid SkyRide service class '%s'."):format(tostring(service.Id)))
|
||||||
|
end
|
||||||
|
if next_services[service.Id] then
|
||||||
|
error(("[sky_phone] Duplicate SkyRide service class '%s'."):format(service.Id))
|
||||||
|
end
|
||||||
|
next_services[service.Id] = service
|
||||||
|
end
|
||||||
|
services = next_services
|
||||||
end
|
end
|
||||||
|
|
||||||
for index = 1, #Config.SkyRide.Services do
|
refresh_runtime_configuration()
|
||||||
local service = Config.SkyRide.Services[index]
|
|
||||||
if service.Id ~= "taxi" and service.Id ~= "comfort" and service.Id ~= "xl" and service.Id ~= "premium" then
|
AddEventHandler("sky_phone:configurator:serverUpdated", function()
|
||||||
error(("[sky_phone] Invalid SkyRide service class '%s'."):format(tostring(service.Id)))
|
refresh_runtime_configuration()
|
||||||
end
|
end)
|
||||||
if services[service.Id] then
|
|
||||||
error(("[sky_phone] Duplicate SkyRide service class '%s'."):format(service.Id))
|
|
||||||
end
|
|
||||||
services[service.Id] = service
|
|
||||||
end
|
|
||||||
|
|
||||||
local ride_select = [[
|
local ride_select = [[
|
||||||
SELECT r.*,
|
SELECT r.*,
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
Bridge.Database.AfterMigration("sky_phone", function()
|
Bridge.Database.AfterMigration("sky_phone", function()
|
||||||
|
|
||||||
if not Config.TestData.Enabled then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
local photo_urls = {
|
local photo_urls = {
|
||||||
city = "https://images.unsplash.com/photo-1519501025264-65ba15a82390?auto=format&fit=crop&w=1200&q=80",
|
city = "https://images.unsplash.com/photo-1519501025264-65ba15a82390?auto=format&fit=crop&w=1200&q=80",
|
||||||
car = "https://images.unsplash.com/photo-1493238792000-8113da705763?auto=format&fit=crop&w=1200&q=80",
|
car = "https://images.unsplash.com/photo-1493238792000-8113da705763?auto=format&fit=crop&w=1200&q=80",
|
||||||
@@ -1041,7 +1037,10 @@ local function seed_for_source(source)
|
|||||||
return account.email
|
return account.email
|
||||||
end
|
end
|
||||||
|
|
||||||
RegisterCommand(Config.TestData.Command, function(source)
|
local function run_test_data_command(source)
|
||||||
|
if not Config.TestData.Enabled then
|
||||||
|
return
|
||||||
|
end
|
||||||
if source <= 0 then
|
if source <= 0 then
|
||||||
Bridge.Debug("warn", "[sky_phone] The test data command must be run by an in-game player.")
|
Bridge.Debug("warn", "[sky_phone] The test data command must be run by an in-game player.")
|
||||||
return
|
return
|
||||||
@@ -1066,7 +1065,34 @@ RegisterCommand(Config.TestData.Command, function(source)
|
|||||||
end
|
end
|
||||||
Bridge.Debug("info", "[sky_phone] Test data seeded for source %s.", tostring(source), { always = true })
|
Bridge.Debug("info", "[sky_phone] Test data seeded for source %s.", tostring(source), { always = true })
|
||||||
TriggerClientEvent("sky_phone:testdata:feedback", source, true, result)
|
TriggerClientEvent("sky_phone:testdata:feedback", source, true, result)
|
||||||
end, false)
|
end
|
||||||
|
|
||||||
|
local active_test_data_command = nil
|
||||||
|
local registered_test_data_commands = {}
|
||||||
|
|
||||||
|
local function refresh_test_data_command()
|
||||||
|
active_test_data_command = Config.TestData.Enabled and Config.TestData.Command or nil
|
||||||
|
if not active_test_data_command then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if type(active_test_data_command) ~= "string" or active_test_data_command == "" then
|
||||||
|
error("[sky_phone] Config.TestData.Command must be a non-empty command name.")
|
||||||
|
end
|
||||||
|
if registered_test_data_commands[active_test_data_command] then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local command_name = active_test_data_command
|
||||||
|
registered_test_data_commands[command_name] = true
|
||||||
|
RegisterCommand(command_name, function(source)
|
||||||
|
if active_test_data_command == command_name then
|
||||||
|
run_test_data_command(source)
|
||||||
|
end
|
||||||
|
end, false)
|
||||||
|
end
|
||||||
|
|
||||||
|
refresh_test_data_command()
|
||||||
|
|
||||||
|
AddEventHandler("sky_phone:configurator:serverUpdated", refresh_test_data_command)
|
||||||
|
|
||||||
AddEventHandler("playerDropped", function()
|
AddEventHandler("playerDropped", function()
|
||||||
seed_attempts[source] = nil
|
seed_attempts[source] = nil
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user