ADD - add in-game phone administration and configuration (#25)

* ADD - build in-game admin command center

* ADD - open admin panel by command

* ENH - rebuild admin panel as standalone editor

* ENH - compact admin workspace navigation

* ENH - expand admin device controls

* ENH - refine admin panel navigation style

* ADD - add SQL phone configurator

* FIX - expose complete phone configuration

* FIX - make company jobs freely configurable

* FIX - align configurator tables to left

* ENH - organize configurator collection controls

* ENH - organize nested configurator sections

* ENH - add configurator field descriptions

* FIX - hide fixed configurator add controls

* ENH - refine configurator editing experience

* ENH - refine admin configurator controls

* FIX - apply configurator settings instantly

* FIX - close live activity command registration

---------

Co-authored-by: Leon.Schmidt <leonmauricewill15@gmail.com>
This commit is contained in:
DerEchteAlec
2026-08-22 03:26:52 +02:00
committed by GitHub
parent 7aa4677b81
commit a9143b0fba
74 changed files with 12862 additions and 329 deletions
+36 -3
View File
@@ -11,6 +11,7 @@ import {
import { useRoute, useRouter } from 'vue-router'
import { SkyProvider } from '@/ui'
import AdminPanel from '@/components/AdminPanel.vue'
import PhoneHomeIndicator from '@/components/PhoneHomeIndicator.vue'
import PhoneControlCenter from '@/components/PhoneControlCenter.vue'
import PhoneDynamicIsland from '@/components/PhoneDynamicIsland.vue'
@@ -115,8 +116,13 @@ type AppMessage = {
| CustomAppCatalogEventData
| CustomAppEventData
| NavigationEventData
| AdminPanelOpenPayload
}
type AdminPanelOpenPayload = Required<
Pick<PhoneOpenPayload, 'fallbackLocales' | 'lang' | 'locales'>
>
type CustomAppCatalogEventData = {
apps?: unknown
}
@@ -357,6 +363,9 @@ const appTransitionName = computed(() =>
route.query.transition === 'app-switch' ? 'app-switch' : 'app-window',
)
const isLocked = ref(false)
const adminPanelOpen = ref(
isDevelopment && developmentParameters.has('adminPanel'),
)
const springboardEditing = ref(false)
const isUnlocking = ref(false)
const passcodeBusy = ref(false)
@@ -630,6 +639,8 @@ function loadUnlockedPhoneData(): void {
}
function completePhoneSetup(): void {
const requestedRoute = pendingUnlockRoute.value
pendingUnlockRoute.value = null
setupPreviewDismissed.value = true
setupAppearanceSelected.value = false
isLocked.value = false
@@ -637,7 +648,7 @@ function completePhoneSetup(): void {
passcodeVisible.value = false
passcodeRequired.value = false
controlCenterOpened.value = false
void router.replace('/')
void router.replace(requestedRoute ?? '/')
loadUnlockedPhoneData()
}
@@ -721,7 +732,15 @@ function openDevelopmentPayphonePreview(): void {
function onMessage(event: MessageEvent<AppMessage>): void {
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)
const catalogPayload = event.data.data as
| { apps?: unknown; debug?: unknown }
@@ -766,7 +785,12 @@ function onMessage(event: MessageEvent<AppMessage>): void {
isPhoneAppId(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 {
console.error('[Navigation] Ignored an unavailable app target.')
}
@@ -1731,6 +1755,15 @@ onBeforeUnmount(() => {
</script>
<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 />
<PhoneMemoRecorder />
<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
+5 -2
View File
@@ -328,7 +328,10 @@ onBeforeUnmount(() => {
class="app-icon"
:class="[
app.iconClass,
{ 'app-icon--image': !iconFailed && app.id !== 'calendar' },
{
'app-icon--image':
!iconFailed && Boolean(app.iconImage) && app.id !== 'calendar',
},
]"
:style="iconStyle"
>
@@ -337,7 +340,7 @@ onBeforeUnmount(() => {
<b>{{ calendarDay }}</b>
</span>
<img
v-else-if="!iconFailed"
v-else-if="!iconFailed && app.iconImage"
:src="app.iconImage"
alt=""
draggable="false"
+1
View File
@@ -748,6 +748,7 @@ export function getPhoneAppLabel(
}
export function isPhoneAppRemovable(app: PhoneAppDefinition): boolean {
if (app.adminOnly) return false
return app.kind === 'external'
? app.removable && !app.defaultInstalled
: !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)
},
}
+31 -4
View File
@@ -44,9 +44,12 @@ describe('phone inventory contracts', () => {
const phoneServer = readResourceFile('source/server/phone.lua')
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 registered inventories and limits metadata-free adapters', () => {
@@ -245,8 +248,12 @@ describe('phone inventory contracts', () => {
const phoneServer = readResourceFile('source/server/phone.lua')
expect(config).toContain('Keybind = "F1"')
expect(phoneClient).toContain('refresh_phone_key_mapping = function()')
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(
'Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})',
@@ -256,6 +263,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', () => {
const phoneClient = readResourceFile('source/client/main.lua')
@@ -286,7 +313,7 @@ describe('phone inventory contracts', () => {
const phoneServer = readResourceFile('source/server/phone.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('FROM `sky_phone_character_devices`')
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 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 ?? '/')")
})
})
+238
View File
@@ -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
},
},
})
+7
View File
@@ -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', () => {
const apps = useAppStoreStore()
+16 -12
View File
@@ -58,11 +58,12 @@ function getDefaultDockIds(): LaunchablePhoneAppId[] {
}
function getDefaultInstalledIds(): LaunchablePhoneAppId[] {
return PHONE_APPS.filter((app) =>
isExternalPhoneApp(app)
return PHONE_APPS.filter((app) => {
if (app.adminOnly) return false
return isExternalPhoneApp(app)
? app.defaultInstalled
: DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id),
).map((app) => app.id)
: DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id)
}).map((app) => app.id)
}
function isProtectedHomeApp(appId: LaunchablePhoneAppId): boolean {
@@ -237,13 +238,15 @@ export const useAppStoreStore = defineStore('app-store', {
layoutVersion === 5 ||
layoutVersion === 6
this.claimedApps = Array.isArray(data?.claimedApps)
? data.claimedApps.filter(
(id): id is LaunchablePhoneAppId =>
typeof id === 'string' &&
(isPhoneAppId(id) ||
(supportsPersistedExternalApps &&
isValidExternalPhoneAppId(id))),
)
? data.claimedApps.filter((id): id is LaunchablePhoneAppId => {
if (typeof id !== 'string') return false
const app = getPhoneApp(id)
if (app?.adminOnly) return false
return (
isPhoneAppId(id) ||
(supportsPersistedExternalApps && isValidExternalPhoneAppId(id))
)
})
: []
this.uninstalledApps = Array.isArray(data?.uninstalledApps)
? data.uninstalledApps.filter((id): id is LaunchablePhoneAppId => {
@@ -308,9 +311,10 @@ export const useAppStoreStore = defineStore('app-store', {
}
},
isInstalled(appId: LaunchablePhoneAppId): boolean {
const app = getPhoneApp(appId)
if (app?.adminOnly) return false
if (this.uninstalledApps.includes(appId)) return false
if (this.claimedApps.includes(appId)) return true
const app = getPhoneApp(appId)
if (!app) return false
return isExternalPhoneApp(app)
? app.defaultInstalled
@@ -118,6 +118,29 @@ function collectLuaLocaleValues(source: string): Map<string, string> {
}
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
}
+586 -36
View File
@@ -807,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 = {
AdminPanel: adminPanelFallbackLocales,
Apps: {
citywarn: citywarnFallbackLocales,
crypto: cryptoFallbackLocales,
@@ -2434,46 +2831,190 @@ const defaultLocales: LocaleTree = {
'neon-drop': 'Neon block-dropping puzzle',
},
previews: {
citywarn: { first: 'Live alerts', second: 'Safety zones', third: 'Incident updates' },
crypto: { first: 'Synthetic markets', second: 'Portfolio', third: 'Wallet transfers' },
health: { first: 'Activity rings', second: 'Medical ID', third: 'Health records' },
'weazel-news': { first: 'Top stories', second: 'Local reports', third: 'Breaking news' },
companies: { first: 'Business directory', 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' },
citywarn: {
first: 'Live alerts',
second: 'Safety zones',
third: 'Incident updates',
},
crypto: {
first: 'Synthetic markets',
second: 'Portfolio',
third: 'Wallet transfers',
},
health: {
first: 'Activity rings',
second: 'Medical ID',
third: 'Health records',
},
'weazel-news': {
first: 'Top stories',
second: 'Local reports',
third: 'Breaking news',
},
companies: {
first: 'Business directory',
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' },
notes: { first: 'Notes', second: 'Checklists', third: 'Pinned ideas' },
memos: { first: 'Voice recordings', second: 'Playback', third: 'Favorites' },
calculator: { first: 'Basic calculation', second: 'Scientific tools', third: 'History' },
camera: { first: 'Photo mode', second: 'Video capture', third: 'Zoom controls' },
memos: {
first: 'Voice recordings',
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' },
weather: { first: 'Current weather', second: 'Hourly forecast', third: 'Seven-day outlook' },
photos: { first: 'Media library', second: 'Albums', third: 'Shared media' },
settings: { first: 'Device controls', second: 'Privacy', third: 'Personalization' },
weather: {
first: 'Current weather',
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' },
memory: { first: 'Matched pairs', second: 'Best time', third: 'Card themes' },
'number-merge': { 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' },
memory: {
first: 'Matched pairs',
second: 'Best time',
third: 'Card themes',
},
'number-merge': {
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: {
recommended: 'Recommended',
@@ -5193,6 +5734,15 @@ export const usePhoneStore = defineStore('phone', {
this.cameraLandscape = 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 {
const nextImei = payload.device?.imei ?? this.device?.imei ?? null
const nextToken = payload.token ?? this.deviceSessionToken
+9 -4
View File
@@ -16,10 +16,15 @@ describe('test data seeding contracts', () => {
)
})
it('returns before registering the test-data command when disabled', () => {
expect(
testData.indexOf('if not Config.TestData.Enabled then'),
).toBeLessThan(testData.indexOf('RegisterCommand(Config.TestData.Command'))
it('refreshes the test-data command when its runtime configuration changes', () => {
expect(testData).toContain('local function refresh_test_data_command()')
expect(testData).toContain(
'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', () => {
+179
View File
@@ -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
}
+1
View File
@@ -68,6 +68,7 @@ export type AppLaunchOrigin = {
}
type PhoneAppDefinitionBase = {
adminOnly?: boolean
category: PhoneAppCategory
dockOrder: number | null
gridOrder: number
@@ -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) },
)
}
+4 -1
View File
@@ -13,7 +13,10 @@ const previewModules = import.meta.glob<string>(
const APP_STORE_PREVIEW_IMAGES = Object.fromEntries(
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}`)
return [appId, imageUrl]
}),
+4 -2
View File
@@ -17,7 +17,8 @@ function escapeRegExp(value: string): string {
describe('App Store preview catalog', () => {
it('contains a real captured screenshot for every built-in store app', () => {
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)
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', () => {
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)
expect([...PREVIEWABLE_BUILTIN_APP_IDS].sort()).toEqual(
+1 -1
View File
@@ -28,7 +28,7 @@ const launchStyle = computed(() => {
<template>
<div
v-if="app"
v-if="app && !app.adminOnly"
class="app-window"
:class="{ 'app-window--citywarn': app.id === 'citywarn' }"
:style="launchStyle"
+2 -1
View File
@@ -119,7 +119,7 @@ const downloadDateDescription = computed(() =>
)
const catalog = computed(() =>
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
}
@@ -140,6 +140,7 @@ const dailyCandidates = computed(() =>
PHONE_APPS.filter(
(app): app is LaunchablePhoneAppDefinition =>
isLaunchablePhoneApp(app) &&
!app.adminOnly &&
!isExternalPhoneApp(app) &&
app.id !== 'app-store' &&
!DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) &&
@@ -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',
},
})
})
})
+442 -3
View File
@@ -3,6 +3,8 @@ const { randomUUID } = require('node:crypto')
const cors = require('cors')
const express = require('express')
const { loadConfiguratorSections } = require('./configurator-fixture.cjs')
const app = express()
const port = Number(process.argv[2]) || 3001
@@ -3098,9 +3100,7 @@ const deviceData = {
ringtoneVolume: 80,
streamerMode: false,
wallpaper: 'custom',
wallpaperHistory: [
{ imageUrl: demoWallpaperUrl, wallpaper: 'custom' },
],
wallpaperHistory: [{ imageUrl: demoWallpaperUrl, wallpaper: 'custom' }],
wallpaperImageUrl: demoWallpaperUrl,
},
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) => {
const endpoint = request.params.endpoint
const loggedBody = { ...request.body }
if (typeof loggedBody.password === 'string')
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') {
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() })
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') {
const value = String(request.body.url ?? '')
const customTitle = String(request.body.title ?? '').trim()