FIX - harden phone lifecycle and server authority

This commit is contained in:
DerEchteAlec
2026-08-12 18:59:05 +02:00
parent 8cffaeaa3c
commit c3b7a0871a
30 changed files with 1630 additions and 174 deletions
+98 -15
View File
@@ -237,6 +237,9 @@ const REFERENCE_VIEWPORT_WIDTH = 1920
const REFERENCE_VIEWPORT_HEIGHT = 1080
const PHONE_BASE_SCALE = 0.69
const DEVELOPMENT_PHONE_SCALE = 1.25
const PHONE_PORTRAIT_WIDTH = 390
const PHONE_PORTRAIT_HEIGHT = 844
const MIN_PRODUCTION_PHONE_ZOOM = 260 / PHONE_PORTRAIT_WIDTH
const isDevelopment = import.meta.env.DEV
const phone = usePhoneStore()
@@ -291,11 +294,34 @@ const phoneBaseZoom = computed(
viewportScale.value *
(isDevelopment ? DEVELOPMENT_PHONE_SCALE : PHONE_BASE_SCALE),
)
const phoneZoom = computed(() => {
const preferred =
phoneBaseZoom.value * (phone.preferences.settings.phoneScale / 100)
if (isDevelopment) return preferred
const edgeGap = 24 * viewportScale.value
const shellWidth = phone.cameraLandscape
? PHONE_PORTRAIT_HEIGHT
: PHONE_PORTRAIT_WIDTH
const shellHeight = phone.cameraLandscape
? PHONE_PORTRAIT_WIDTH
: PHONE_PORTRAIT_HEIGHT
const viewportMaximum = Math.max(
0,
Math.min(
(window.innerWidth - edgeGap) / shellWidth,
(window.innerHeight - edgeGap) / shellHeight,
),
)
return Math.min(
viewportMaximum,
Math.max(MIN_PRODUCTION_PHONE_ZOOM, preferred),
)
})
const phoneResolutionStyle = computed<CSSProperties>(() => ({
'--phone-edge-gap': `${24 * viewportScale.value}px`,
'--phone-stack-gap': `${16 * viewportScale.value}px`,
'--phone-zoom':
phoneBaseZoom.value * (phone.preferences.settings.phoneScale / 100),
'--phone-zoom': phoneZoom.value,
}))
const phoneStageStyle = computed<CSSProperties>(() => ({
...phoneResolutionStyle.value,
@@ -315,6 +341,8 @@ let pendingCompaniesChange: CompanyChangedPayload | null = null
let unlockTimer: number | undefined
let passcodeLockTimer: number | undefined
let unlockedServicesFrame: number | undefined
let phoneClosePending = false
let simPickerClosePending = false
function getViewportScale(): number {
const heightScale = window.innerHeight / REFERENCE_VIEWPORT_HEIGHT
@@ -505,7 +533,7 @@ function onMessage(event: MessageEvent<AppMessage>): void {
hydratePhone(event.data.data as PhoneOpenPayload)
} else if (event.data?.type === 'app:close') {
activitySuspended.value = false
phone.close()
phone.endDeviceSession()
} else if (event.data?.type === 'app:suspend') {
activitySuspended.value = true
} else if (event.data?.type === 'app:resume') {
@@ -899,14 +927,69 @@ function onMessage(event: MessageEvent<AppMessage>): void {
}
}
async function closeSimPicker(): Promise<void> {
if (simPickerClosePending || !simPicker.value) return
simPickerClosePending = true
const closingPicker = simPicker.value
try {
const response = await nuiCall('sim:picker-close')
if (response.success && simPicker.value === closingPicker) {
simPicker.value = null
}
} finally {
simPickerClosePending = false
}
}
async function closePhone(): Promise<void> {
if (phoneClosePending || !phone.isOpen) return
phoneClosePending = true
const closingGeneration = phone.persistenceGeneration
const closingImei = phone.device?.imei ?? null
const closingToken = phone.deviceSessionToken
try {
await phone.flushDevicePersistence()
if (
!phone.isOpen ||
phone.persistenceGeneration !== closingGeneration ||
(phone.device?.imei ?? null) !== closingImei ||
phone.deviceSessionToken !== closingToken
) {
return
}
const response = await nuiCall('close')
if (
!response.success ||
!phone.isOpen ||
phone.persistenceGeneration !== closingGeneration ||
(phone.device?.imei ?? null) !== closingImei ||
phone.deviceSessionToken !== closingToken
) {
return
}
phone.endDeviceSession()
} finally {
phoneClosePending = false
}
}
function onKeydown(event: KeyboardEvent): void {
if (event.key !== 'Escape' || !phone.isOpen || activitySuspended.value) return
if (controlCenterOpened.value) {
controlCenterOpened.value = false
if (event.key !== 'Escape') return
if (simPicker.value) {
event.preventDefault()
void closeSimPicker()
return
}
phone.close()
void nuiCall('close')
queueMicrotask(() => {
if (event.defaultPrevented || !phone.isOpen || activitySuspended.value)
return
if (controlCenterOpened.value) {
controlCenterOpened.value = false
return
}
void closePhone()
})
}
function onSystemColorSchemeChange(event: MediaQueryListEvent): void {
@@ -1051,7 +1134,7 @@ onMounted(() => {
window.addEventListener('resize', updateViewportScale)
systemColorScheme.addEventListener('change', onSystemColorSchemeChange)
phone.setSystemDarkMode(systemColorScheme.matches)
void nuiCall('ui:ready')
void nuiCall('ui:ready', { protocolVersion: 1 })
clockTicker = setInterval(() => {
const now = Date.now()
for (const alarm of clock.dueAlarms(now)) {
@@ -1116,11 +1199,9 @@ watch(
)
watch(
[() => notifications.requiresAttention, () => calls.activeCall],
([requiresAttention, activeCall]) => {
void nuiCall('notification:focus', {
active: requiresAttention || activeCall !== null,
})
() => notifications.requiresAttention,
(requiresAttention) => {
void nuiCall('notification:focus', { active: requiresAttention })
},
)
@@ -1204,7 +1285,7 @@ onBeforeUnmount(() => {
v-if="simPicker"
:choices="simPicker.choices"
:number="simPicker.number"
@close="simPicker = null"
@close="closeSimPicker"
/>
<Transition name="phone-lift" appear>
<main
@@ -1263,7 +1344,9 @@ onBeforeUnmount(() => {
:style="phoneDisplayStyle"
:class="{
dark: phone.isDarkMode,
'phone-app--darkchat': route.params.appId === 'darkchat',
'phone-app--light': !phone.isDarkMode,
'phone-app--messages': route.params.appId === 'messages',
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
'phone-app--unlocking': isUnlocking,
}"
+4 -2
View File
@@ -239,6 +239,7 @@ function onKeydown(event: KeyboardEvent): void {
if (!visible.value) return
if (event.key === 'Escape') {
event.preventDefault()
event.stopImmediatePropagation()
void close()
return
}
@@ -256,7 +257,7 @@ function onKeydown(event: KeyboardEvent): void {
onMounted(() => {
prepareButtonSounds()
window.addEventListener('message', onMessage)
window.addEventListener('keydown', onKeydown)
window.addEventListener('keydown', onKeydown, true)
ticker = window.setInterval(() => {
now.value = Date.now()
}, 250)
@@ -264,7 +265,7 @@ onMounted(() => {
onBeforeUnmount(() => {
window.removeEventListener('message', onMessage)
window.removeEventListener('keydown', onKeydown)
window.removeEventListener('keydown', onKeydown, true)
if (ticker !== undefined) window.clearInterval(ticker)
for (const sound of buttonSounds) {
sound.pause()
@@ -399,6 +400,7 @@ onBeforeUnmount(() => {
rgb(0 0 0 / 84%) 72%
);
font-family: 'Segoe UI', Arial, sans-serif;
pointer-events: auto;
user-select: none;
}
@@ -42,7 +42,6 @@ async function insert(
}
function close(): void {
void nuiCall('sim:picker-close')
emit('close')
}
</script>
+23 -1
View File
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useBankingStore } from '@/stores/banking'
import type { BankingOverview } from '@/types/banking'
import { nuiCall } from '@/utils/nui'
import { nuiCall, type NuiResponse } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
@@ -60,4 +60,26 @@ describe('banking store', () => {
expect(banking.overview).toEqual(overview)
expect(banking.error).toBe('insufficient_funds')
})
it('does not let an older response overwrite the newest overview', async () => {
let resolveOlder!: (response: NuiResponse<BankingOverview>) => void
const olderResponse = new Promise<NuiResponse<BankingOverview>>(
(resolve) => {
resolveOlder = resolve
},
)
const newest = { ...overview, bank: 23000 }
mockNuiCall
.mockReturnValueOnce(olderResponse)
.mockResolvedValueOnce({ data: newest, success: true })
const banking = useBankingStore()
const olderRequest = banking.load()
await banking.load()
resolveOlder({ data: { ...overview, bank: 1 }, success: true })
await olderRequest
expect(banking.overview).toEqual(newest)
expect(banking.isLoading).toBe(false)
})
})
+17 -3
View File
@@ -8,12 +8,21 @@ export const useBankingStore = defineStore('banking', {
error: '',
isLoading: false,
overview: null as BankingOverview | null,
pendingRequests: 0,
requestGeneration: 0,
}),
actions: {
async load(): Promise<boolean> {
const generation = ++this.requestGeneration
this.pendingRequests += 1
this.isLoading = true
const response = await nuiCall<BankingOverview>('banking:overview')
this.isLoading = false
const response = await nuiCall<BankingOverview>('banking:overview').finally(
() => {
this.pendingRequests = Math.max(0, this.pendingRequests - 1)
this.isLoading = this.pendingRequests > 0
},
)
if (generation !== this.requestGeneration) return response.success
if (response.success && response.data) {
this.overview = response.data
this.error = ''
@@ -27,12 +36,17 @@ export const useBankingStore = defineStore('banking', {
amount: number,
phoneNumber?: string,
): Promise<NuiResponse<BankingOverview>> {
const generation = ++this.requestGeneration
this.pendingRequests += 1
this.isLoading = true
const response = await nuiCall<BankingOverview>(`banking:${action}`, {
amount,
...(phoneNumber === undefined ? {} : { phoneNumber }),
}).finally(() => {
this.pendingRequests = Math.max(0, this.pendingRequests - 1)
this.isLoading = this.pendingRequests > 0
})
this.isLoading = false
if (generation !== this.requestGeneration) return response
if (response.success && response.data) {
this.overview = response.data
this.error = ''
+103 -3
View File
@@ -1,9 +1,10 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAccountStore } from '@/stores/account'
import { useMailStore } from '@/stores/mail'
import type { MailCounts, MailListItem } from '@/types/mail'
import { nuiCall } from '@/utils/nui'
import type { MailCounts, MailListItem, MailListResponse } from '@/types/mail'
import { nuiCall, type NuiResponse } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({
nuiCall: vi.fn(),
@@ -45,7 +46,7 @@ describe('mail store', () => {
success: true,
})
.mockResolvedValueOnce({
data: { hasMore: false, items: [listItem(2)] },
data: { hasMore: false, items: [listItem(2)], offset: 0 },
success: true,
})
@@ -129,4 +130,103 @@ describe('mail store', () => {
expect(mail.folder).toBe('inbox')
expect(mail.search).toBe('')
})
it('ignores an older folder response after a newer navigation', async () => {
let resolveOlder!: (response: NuiResponse<MailListResponse>) => void
const olderResponse = new Promise<NuiResponse<MailListResponse>>(
(resolve) => {
resolveOlder = resolve
},
)
mockNuiCall
.mockReturnValueOnce(olderResponse)
.mockResolvedValueOnce({
data: { hasMore: false, items: [listItem(2)] },
success: true,
})
const mail = useMailStore()
const olderRequest = mail.loadFolder('inbox')
await mail.loadFolder('sent')
resolveOlder({
data: { hasMore: false, items: [listItem(1)], offset: 0 },
success: true,
})
await olderRequest
expect(mail.folder).toBe('sent')
expect(mail.items.map((item) => item.id)).toEqual([2])
expect(mail.loading).toBe(false)
})
it('ignores mailbox counts returned after the session was cleared', async () => {
let resolveCounts!: (response: NuiResponse<MailCounts>) => void
mockNuiCall.mockReturnValueOnce(
new Promise<NuiResponse<MailCounts>>((resolve) => {
resolveCounts = resolve
}),
)
const mail = useMailStore()
const bootstrap = mail.bootstrap('alex@ifruit.com')
await mail.bootstrap('')
resolveCounts({ data: counts, success: true })
await bootstrap
expect(mail.accountEmail).toBe('')
expect(mail.counts).toEqual({
drafts: 0,
inbox: 0,
sent: 0,
trash: 0,
unread: 0,
})
})
it('ignores a late login after the mailbox session was cleared', async () => {
let resolveLogin!: (response: NuiResponse<{ devices: []; email: string }>) => void
mockNuiCall.mockReturnValueOnce(
new Promise<NuiResponse<{ devices: []; email: string }>>((resolve) => {
resolveLogin = resolve
}),
)
const mail = useMailStore()
const account = useAccountStore()
const login = mail.login('alex', 'secret')
await mail.bootstrap('')
resolveLogin({
data: { devices: [], email: 'alex@ifruit.com' },
success: true,
})
await login
expect(mail.accountEmail).toBe('')
expect(account.email).toBe('')
})
it('ignores a late login after an external mailbox session change', async () => {
let resolveLogin!: (response: NuiResponse<{ devices: []; email: string }>) => void
mockNuiCall
.mockReturnValueOnce(
new Promise<NuiResponse<{ devices: []; email: string }>>((resolve) => {
resolveLogin = resolve
}),
)
.mockResolvedValueOnce({ data: counts, success: true })
const mail = useMailStore()
const account = useAccountStore()
const login = mail.login('alex', 'secret')
account.hydrate({ devices: [], email: 'morgan@ifruit.com' })
await mail.bootstrap('morgan@ifruit.com')
resolveLogin({
data: { devices: [], email: 'alex@ifruit.com' },
success: true,
})
await login
expect(mail.accountEmail).toBe('morgan@ifruit.com')
expect(account.email).toBe('morgan@ifruit.com')
})
})
+44 -4
View File
@@ -31,14 +31,21 @@ export const useMailStore = defineStore('mail', () => {
const items = ref<MailListItem[]>([])
const loading = ref(false)
const search = ref('')
let authenticationGeneration = 0
let folderRequestGeneration = 0
let sessionGeneration = 0
function clearSession(): void {
authenticationGeneration += 1
sessionGeneration += 1
folderRequestGeneration += 1
accountEmail.value = ''
counts.value = emptyCounts()
items.value = []
hasMore.value = false
folder.value = 'inbox'
search.value = ''
loading.value = false
}
async function bootstrap(email: string): Promise<void> {
@@ -46,16 +53,24 @@ export const useMailStore = defineStore('mail', () => {
clearSession()
return
}
authenticationGeneration += 1
sessionGeneration += 1
folderRequestGeneration += 1
accountEmail.value = email
await refreshCounts()
}
async function login(email: string, password: string) {
const generation = ++authenticationGeneration
const response = await nuiCall<IfruitAccount>('mail:login', {
email,
password,
})
if (response.success && response.data) {
if (
generation === authenticationGeneration &&
response.success &&
response.data
) {
account.hydrate(response.data)
await bootstrap(response.data.email)
}
@@ -63,11 +78,16 @@ export const useMailStore = defineStore('mail', () => {
}
async function register(email: string, password: string) {
const generation = ++authenticationGeneration
const response = await nuiCall<IfruitAccount>('mail:register', {
email,
password,
})
if (response.success && response.data) {
if (
generation === authenticationGeneration &&
response.success &&
response.data
) {
account.hydrate(response.data)
await bootstrap(response.data.email)
}
@@ -75,10 +95,13 @@ export const useMailStore = defineStore('mail', () => {
}
async function logout(): Promise<void> {
const generation = ++authenticationGeneration
if (accountEmail.value) {
const response = await nuiCall('mail:logout')
if (generation !== authenticationGeneration) return
if (response.success) account.hydrate(null)
}
if (generation !== authenticationGeneration) return
clearSession()
}
@@ -87,6 +110,8 @@ export const useMailStore = defineStore('mail', () => {
nextSearch = '',
append = false,
): Promise<boolean> {
const generation = ++folderRequestGeneration
const session = sessionGeneration
loading.value = true
const offset = append ? items.value.length : 0
const response = await nuiCall<MailListResponse>('mail:list', {
@@ -94,7 +119,13 @@ export const useMailStore = defineStore('mail', () => {
offset,
search: nextSearch,
})
loading.value = false
if (generation === folderRequestGeneration) loading.value = false
if (
generation !== folderRequestGeneration ||
session !== sessionGeneration
) {
return false
}
if (!response.success || !response.data) return false
folder.value = nextFolder
@@ -107,8 +138,17 @@ export const useMailStore = defineStore('mail', () => {
}
async function refreshCounts(): Promise<void> {
const email = accountEmail.value
const session = sessionGeneration
const response = await nuiCall<MailCounts>('mail:counts')
if (response.success && response.data) counts.value = response.data
if (
session === sessionGeneration &&
email === accountEmail.value &&
response.success &&
response.data
) {
counts.value = response.data
}
}
async function openMessage(id: number): Promise<MailMessage | null> {
+23
View File
@@ -2,6 +2,7 @@ import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
MAX_LOCK_SCREEN_NOTIFICATIONS,
useNotificationsStore,
type PhoneNotificationDevice,
} from '@/stores/notifications'
@@ -256,4 +257,26 @@ describe('notifications store', () => {
notifications.clearLockScreen()
expect(notifications.lockScreenNotifications).toEqual([])
})
it('bounds persisted lock screen history to the newest notifications', () => {
openPhone('111')
const notifications = useNotificationsStore()
const items = Array.from(
{ length: MAX_LOCK_SCREEN_NOTIFICATIONS + 10 },
(_, index) => ({
appId: 'mail' as const,
id: `saved-${index}`,
text: `Message ${index}`,
title: 'Mail',
}),
)
notifications.hydrate({ items, version: 1 }, '111')
expect(notifications.lockScreenNotifications).toHaveLength(
MAX_LOCK_SCREEN_NOTIFICATIONS,
)
expect(notifications.lockScreenNotifications[0]?.id).toBe('saved-59')
expect(notifications.lockScreenNotifications.at(-1)?.id).toBe('saved-10')
})
})
+9 -4
View File
@@ -43,6 +43,8 @@ type PersistedNotificationsV1 = {
version: 1
}
export const MAX_LOCK_SCREEN_NOTIFICATIONS = 50
const timeoutHandles = new Map<string, ReturnType<typeof setTimeout>>()
const stopToneHandles = new Map<string, () => void>()
const persistenceQueues = new Map<string, Promise<void>>()
@@ -151,7 +153,9 @@ export const useNotificationsStore = defineStore('notifications', () => {
for (const notification of stored) merged.set(notification.id, notification)
for (const notification of lockScreenQueues.value[imei] ?? [])
merged.set(notification.id, notification)
lockScreenQueues.value[imei] = [...merged.values()]
lockScreenQueues.value[imei] = [...merged.values()].slice(
-MAX_LOCK_SCREEN_NOTIFICATIONS,
)
persist(imei)
}
@@ -166,9 +170,10 @@ export const useNotificationsStore = defineStore('notifications', () => {
function remember(notification: PhoneNotification): void {
const imei = notification.device?.imei ?? phone.device?.imei
if (!imei) return
const notifications = lockScreenQueues.value[imei] ?? []
notifications.push(notification)
lockScreenQueues.value[imei] = notifications
lockScreenQueues.value[imei] = [
...(lockScreenQueues.value[imei] ?? []),
notification,
].slice(-MAX_LOCK_SCREEN_NOTIFICATIONS)
persist(imei)
}
@@ -0,0 +1,170 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { usePhoneStore } from '@/stores/phone'
import { nuiCall, type NuiResponse } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({
nuiCall: vi.fn(),
}))
const mockNuiCall = vi.mocked(nuiCall)
function deferredResponse<T>(): {
promise: Promise<NuiResponse<T>>
resolve: (response: NuiResponse<T>) => void
} {
let resolve!: (response: NuiResponse<T>) => void
const promise = new Promise<NuiResponse<T>>((next) => {
resolve = next
})
return { promise, resolve }
}
function openPhone(imei: string, token: string, revision: number): void {
usePhoneStore().open({
device: {
data: { settings: { payload: {}, revision } },
imei,
name: `Phone ${imei}`,
sim: null,
},
token,
})
}
describe('phone device persistence scope', () => {
beforeEach(() => {
vi.stubGlobal('window', {
matchMedia: vi.fn(() => ({ matches: false })),
})
setActivePinia(createPinia())
mockNuiCall.mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('does not apply a late save response to a newer device session', async () => {
const stale = deferredResponse<{ revision: number }>()
mockNuiCall.mockReturnValueOnce(stale.promise)
const phone = usePhoneStore()
openPhone('111', 'session-a', 2)
phone.saveDeviceNamespace('settings', { value: 'old' })
await Promise.resolve()
expect(mockNuiCall).toHaveBeenCalledWith('device:save', {
imei: '111',
namespace: 'settings',
payload: { value: 'old' },
revision: 2,
sessionToken: 'session-a',
})
openPhone('222', 'session-b', 7)
stale.resolve({ data: { revision: 3 }, success: true })
await stale.promise
await Promise.resolve()
expect(phone.device?.imei).toBe('222')
expect(phone.deviceRevisions.settings).toBe(7)
})
it('drops queued writes from an obsolete device generation', async () => {
const first = deferredResponse<{ revision: number }>()
mockNuiCall.mockReturnValueOnce(first.promise)
const phone = usePhoneStore()
openPhone('111', 'session-a', 0)
phone.saveDeviceNamespace('settings', { order: 1 })
phone.saveDeviceNamespace('settings', { order: 2 })
await Promise.resolve()
openPhone('222', 'session-b', 0)
first.resolve({ data: { revision: 1 }, success: true })
await first.promise
await Promise.resolve()
await Promise.resolve()
expect(mockNuiCall).toHaveBeenCalledTimes(1)
})
it('flushes every queued write after a normal visibility close', async () => {
const first = deferredResponse<{ revision: number }>()
mockNuiCall
.mockReturnValueOnce(first.promise)
.mockResolvedValueOnce({ data: { revision: 2 }, success: true })
const phone = usePhoneStore()
openPhone('111', 'session-a', 0)
phone.saveDeviceNamespace('settings', { order: 1 })
phone.saveDeviceNamespace('settings', { order: 2 })
await Promise.resolve()
phone.close()
const flushed = phone.flushDevicePersistence()
first.resolve({ data: { revision: 1 }, success: true })
await flushed
expect(mockNuiCall).toHaveBeenCalledTimes(2)
expect(mockNuiCall).toHaveBeenLastCalledWith('device:save', {
imei: '111',
namespace: 'settings',
payload: { order: 2 },
revision: 1,
sessionToken: 'session-a',
})
expect(phone.deviceRevisions.settings).toBe(2)
})
it('keeps queued writes scoped across a same-session bootstrap update', async () => {
const first = deferredResponse<{ revision: number }>()
mockNuiCall
.mockReturnValueOnce(first.promise)
.mockResolvedValueOnce({ data: { revision: 2 }, success: true })
const phone = usePhoneStore()
openPhone('111', 'session-a', 0)
phone.saveDeviceNamespace('settings', { order: 1 })
phone.saveDeviceNamespace('settings', { order: 2 })
await Promise.resolve()
first.resolve({ data: { revision: 1 }, success: true })
await first.promise
await Promise.resolve()
openPhone('111', 'session-a', 1)
await phone.flushDevicePersistence()
expect(mockNuiCall).toHaveBeenCalledTimes(2)
expect(phone.deviceRevisions.settings).toBe(2)
})
it('waits for writes queued while a persistence flush is in progress', async () => {
const first = deferredResponse<{ revision: number }>()
const queuedDuringFlush = deferredResponse<{ revision: number }>()
mockNuiCall
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(queuedDuringFlush.promise)
const phone = usePhoneStore()
openPhone('111', 'session-a', 0)
phone.saveDeviceNamespace('settings', { order: 1 })
await Promise.resolve()
let flushCompleted = false
const flushed = phone.flushDevicePersistence().then(() => {
flushCompleted = true
})
phone.saveDeviceNamespace('widgets', { order: 2 })
await Promise.resolve()
first.resolve({ data: { revision: 1 }, success: true })
await first.promise
await Promise.resolve()
expect(flushCompleted).toBe(false)
queuedDuringFlush.resolve({ data: { revision: 1 }, success: true })
await flushed
expect(mockNuiCall).toHaveBeenCalledTimes(2)
expect(phone.deviceRevisions.widgets).toBe(1)
})
})
+70 -8
View File
@@ -12,6 +12,7 @@ import { nuiCall } from '@/utils/nui'
import type { NuiResponse } from '@/utils/nui'
import {
DEFAULT_PHONE_PREFERENCES,
clampPhoneScale,
ensureAppNotificationPreferences,
parsePhonePreferences,
type AppNotificationPreferences,
@@ -38,6 +39,7 @@ export type PhoneOpenPayload = {
}
const namespaceQueues = new Map<string, Promise<void>>()
let nextPersistenceSession = 0
const companiesFallbackLocales = {
name: 'Companies',
@@ -3617,11 +3619,14 @@ export const usePhoneStore = defineStore('phone', {
currentPage: 1,
device: null as PhoneDevice | null,
deviceRevisions: {} as Record<string, number>,
deviceSessionToken: null as string | null,
isOpen: false,
lang: 'en',
launchOrigin: null as AppLaunchOrigin | null,
locales: defaultLocales,
preferences: cloneJsonData(DEFAULT_PHONE_PREFERENCES),
persistenceGeneration: 0,
persistenceSession: ++nextPersistenceSession,
security: {
enabled: false,
length: null,
@@ -3642,6 +3647,15 @@ export const usePhoneStore = defineStore('phone', {
this.isOpen = false
},
open(payload: PhoneOpenPayload = {}): void {
const nextImei = payload.device?.imei ?? this.device?.imei ?? null
const nextToken = payload.token ?? this.deviceSessionToken
if (
nextImei !== (this.device?.imei ?? null) ||
nextToken !== this.deviceSessionToken
) {
this.persistenceGeneration += 1
}
this.deviceSessionToken = nextToken
this.lang = payload.lang ?? 'en'
this.locales = payload.locales ?? defaultLocales
if (payload.device) this.hydrateDevice(payload.device)
@@ -3652,6 +3666,13 @@ export const usePhoneStore = defineStore('phone', {
}
this.isOpen = true
},
endDeviceSession(): void {
this.close()
if (this.deviceSessionToken !== null) {
this.deviceSessionToken = null
this.persistenceGeneration += 1
}
},
hydrateDevice(device: PhoneDevice): void {
this.device = device
this.deviceRevisions = Object.fromEntries(
@@ -3665,22 +3686,59 @@ export const usePhoneStore = defineStore('phone', {
)
},
saveDeviceNamespace(namespace: string, payload: unknown): void {
const previous = namespaceQueues.get(namespace) ?? Promise.resolve()
const imei = this.device?.imei
if (!imei) {
console.error(
`[Phone persistence] Could not save ${namespace} without an active device.`,
)
return
}
const generation = this.persistenceGeneration
const session = this.persistenceSession
const token = this.deviceSessionToken
const queuedPayload = cloneJsonData(payload)
const queueKey = `${session}:${generation}:${imei}:${namespace}`
const isCurrentScope = (): boolean =>
this.persistenceSession === session &&
this.persistenceGeneration === generation &&
this.device?.imei === imei &&
this.deviceSessionToken === token
const previous = namespaceQueues.get(queueKey) ?? Promise.resolve()
const queued = previous.then(async () => {
if (!isCurrentScope()) return
const response = await nuiCall<{ revision: number }>('device:save', {
imei,
namespace,
payload,
payload: queuedPayload,
revision: this.deviceRevisions[namespace] ?? 0,
sessionToken: token,
})
if (response.success && response.data) {
this.deviceRevisions[namespace] = response.data.revision
if (
isCurrentScope() &&
response.success &&
Number.isInteger(response.data?.revision) &&
Number(response.data?.revision) >= 0
) {
this.deviceRevisions[namespace] = Number(response.data?.revision)
}
})
const tracked = queued.finally(() => {
if (namespaceQueues.get(namespace) === tracked)
namespaceQueues.delete(namespace)
if (namespaceQueues.get(queueKey) === tracked)
namespaceQueues.delete(queueKey)
})
namespaceQueues.set(namespace, tracked)
namespaceQueues.set(queueKey, tracked)
},
async flushDevicePersistence(): Promise<void> {
const imei = this.device?.imei
if (!imei) return
const queuePrefix = `${this.persistenceSession}:${this.persistenceGeneration}:${imei}:`
while (true) {
const activeQueues = [...namespaceQueues.entries()]
.filter(([key]) => key.startsWith(queuePrefix))
.map(([, queue]) => queue)
if (!activeQueues.length) return
await Promise.all(activeQueues)
}
},
setCurrentPage(page: number, pageCount?: number): void {
this.currentPage = clampPage(page, pageCount)
@@ -3706,7 +3764,11 @@ export const usePhoneStore = defineStore('phone', {
key: K,
value: PhonePreferencesV1['settings'][K],
): void {
this.preferences.settings[key] = value
this.preferences.settings[key] = (
key === 'phoneScale'
? clampPhoneScale(Number(value))
: value
) as PhonePreferencesV1['settings'][K]
this.saveDeviceNamespace('settings', this.preferences)
},
setAlertVolumes(value: number): void {
+63
View File
@@ -0,0 +1,63 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nuiCall } from '@/utils/nui'
describe('nuiCall', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.stubGlobal('window', {
clearTimeout: globalThis.clearTimeout,
location: { search: '' },
setTimeout: globalThis.setTimeout,
})
vi.spyOn(console, 'error').mockImplementation(() => undefined)
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('clears the request timeout after a successful callback', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ data: { value: 1 }, success: true }), {
headers: { 'Content-Type': 'application/json' },
status: 200,
}),
)
vi.stubGlobal('fetch', fetchMock)
await expect(nuiCall<{ value: number }>('test')).resolves.toEqual({
data: { value: 1 },
success: true,
})
expect(vi.getTimerCount()).toBe(0)
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:3002/api/test',
expect.objectContaining({ signal: expect.any(AbortSignal) }),
)
})
it('aborts a callback that never completes', async () => {
vi.stubGlobal(
'fetch',
vi.fn((_url: string, init?: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => {
reject(new DOMException('Aborted', 'AbortError'))
})
}),
),
)
const request = nuiCall('never-responds')
await vi.advanceTimersByTimeAsync(20_000)
await expect(request).resolves.toEqual({
error: 'request_timeout',
success: false,
})
expect(vi.getTimerCount()).toBe(0)
})
})
+11 -1
View File
@@ -1,4 +1,5 @@
const resourceName = globalThis.window?.GetParentResourceName?.() ?? 'sky_phone'
const requestTimeoutMs = 20_000
export type NuiResponse<T = unknown> = {
success: boolean
@@ -24,12 +25,15 @@ export async function nuiCall<T = unknown>(
undefined,
}
: data
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), requestTimeoutMs)
try {
const response = await fetch(`${baseUrl}/${endpoint}`, {
body: JSON.stringify(requestData),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
signal: controller.signal,
})
if (!response.ok) {
@@ -41,8 +45,14 @@ export async function nuiCall<T = unknown>(
const body = await response.text()
return body ? (JSON.parse(body) as NuiResponse<T>) : { success: true }
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
const message = controller.signal.aborted
? 'request_timeout'
: error instanceof Error
? error.message
: 'Unknown error'
console.error(`[NUI] ${endpoint} failed:`, error)
return { error: message, success: false }
} finally {
window.clearTimeout(timeoutId)
}
}
+248
View File
@@ -0,0 +1,248 @@
-- Server-owned vanilla payphone positions used for authoritative proximity checks.
-- Generated from DurtyFree/gta-v-data-dumps worldPublicPhones.json at commit b65684e00f689fdec405c5f1055322c802d3c895.
-- Add custom-map booths here; their model must also be listed in Config.Payphones.Props.
Config.Payphones.Locations = {
{ model = "prop_phonebox_01a", coords = { x = -1819.2284, y = 796.32294, z = 137.12784 } },
{ model = "prop_phonebox_01a", coords = { x = -1773.0256, y = -503.15234, z = 37.80706 } },
{ model = "prop_phonebox_01a", coords = { x = -1772.2114, y = -504.00488, z = 37.81461 } },
{ model = "prop_phonebox_01a", coords = { x = -1457.3734, y = -148.68604, z = 48.7486 } },
{ model = "prop_phonebox_01a", coords = { x = -1456.838, y = -149.31949, z = 48.68604 } },
{ model = "prop_phonebox_01a", coords = { x = -1456.3501, y = -149.95428, z = 48.61642 } },
{ model = "prop_phonebox_01a", coords = { x = -1438.6545, y = -210.77448, z = 47.10766 } },
{ model = "prop_phonebox_01a", coords = { x = -1418.2983, y = -291.36002, z = 42.96778 } },
{ model = "prop_phonebox_01a", coords = { x = -1417.4769, y = -290.64453, z = 42.93899 } },
{ model = "prop_phonebox_01a", coords = { x = -1416.5211, y = -289.8119, z = 42.90134 } },
{ model = "prop_phonebox_01a", coords = { x = -1318.0975, y = -380.79547, z = 35.73553 } },
{ model = "prop_phonebox_01a", coords = { x = -1316.9534, y = -378.42676, z = 35.74885 } },
{ model = "prop_phonebox_01a", coords = { x = -1316.2688, y = -378.07245, z = 35.73169 } },
{ model = "prop_phonebox_01a", coords = { x = -1315.5924, y = -377.7347, z = 35.72274 } },
{ model = "prop_phonebox_01a", coords = { x = -1261.6484, y = -519.13086, z = 30.83657 } },
{ model = "prop_phonebox_01a", coords = { x = -1260.9482, y = -519.9344, z = 30.75686 } },
{ model = "prop_phonebox_01a", coords = { x = -1260.0995, y = -520.9083, z = 30.66707 } },
{ model = "prop_phonebox_01a", coords = { x = -1121.5917, y = -825.6313, z = 14.94339 } },
{ model = "prop_phonebox_01a", coords = { x = -1120.9409, y = -825.0698, z = 14.98657 } },
{ model = "prop_phonebox_01a", coords = { x = -1120.2446, y = -824.4812, z = 15.06407 } },
{ model = "prop_phonebox_01a", coords = { x = -1079.6154, y = -451.0622, z = 35.6144 } },
{ model = "prop_phonebox_01a", coords = { x = -1079.23, y = -451.8739, z = 35.62138 } },
{ model = "prop_phonebox_01a", coords = { x = -1078.874, y = -452.55182, z = 35.62138 } },
{ model = "prop_phonebox_01a", coords = { x = -956.56256, y = -403.17123, z = 36.81676 } },
{ model = "prop_phonebox_01a", coords = { x = -956.1004, y = -404.09232, z = 36.81755 } },
{ model = "prop_phonebox_01a", coords = { x = -524.4403, y = -300.71704, z = 34.26753 } },
{ model = "prop_phonebox_01a", coords = { x = -523.64453, y = -300.41074, z = 34.26273 } },
{ model = "prop_phonebox_01a", coords = { x = -522.872, y = -300.1134, z = 34.25807 } },
{ model = "prop_phonebox_01a", coords = { x = -449.44238, y = -272.8244, z = 34.93996 } },
{ model = "prop_phonebox_01a", coords = { x = -448.8816, y = -274.12805, z = 34.96191 } },
{ model = "prop_phonebox_01a", coords = { x = -448.36926, y = -275.31906, z = 34.96507 } },
{ model = "prop_phonebox_01a", coords = { x = -329.23917, y = 6224.885, z = 30.47861 } },
{ model = "prop_phonebox_01a", coords = { x = -310.30554, y = 6205.3, z = 30.4465 } },
{ model = "prop_phonebox_01a", coords = { x = -280.64215, y = 6224.2314, z = 30.45544 } },
{ model = "prop_phonebox_01a", coords = { x = -243.25082, y = 279.90717, z = 91.04989 } },
{ model = "prop_phonebox_01a", coords = { x = -234.61494, y = 6176.931, z = 30.43884 } },
{ model = "prop_phonebox_01a", coords = { x = -233.6865, y = 6176.0547, z = 30.43884 } },
{ model = "prop_phonebox_01a", coords = { x = -184.35658, y = 6331.4697, z = 30.48767 } },
{ model = "prop_phonebox_01a", coords = { x = -183.68753, y = 6332.1597, z = 30.48987 } },
{ model = "prop_phonebox_01a", coords = { x = -154.76048, y = 6352.27, z = 30.56079 } },
{ model = "prop_phonebox_01a", coords = { x = -153.88358, y = 6351.417, z = 30.56079 } },
{ model = "prop_phonebox_01a", coords = { x = -119.91727, y = 6287.283, z = 30.45911 } },
{ model = "prop_phonebox_01a", coords = { x = -119.44898, y = 6286.768, z = 30.45911 } },
{ model = "prop_phonebox_01a", coords = { x = -92.08037, y = 6462.644, z = 30.44397 } },
{ model = "prop_phonebox_01a", coords = { x = -90.61212, y = 6464.0566, z = 30.44397 } },
{ model = "prop_phonebox_01a", coords = { x = -46.3855, y = 6511.0156, z = 30.44861 } },
{ model = "prop_phonebox_01a", coords = { x = -25.6331, y = 6495.123, z = 30.48767 } },
{ model = "prop_phonebox_01a", coords = { x = -24.59157, y = 6496.0537, z = 30.48767 } },
{ model = "prop_phonebox_01a", coords = { x = 110.07694, y = -1694.206, z = 28.29156 } },
{ model = "prop_phonebox_01a", coords = { x = 136.9704, y = 196.05042, z = 105.73364 } },
{ model = "prop_phonebox_01a", coords = { x = 137.75732, y = 195.764, z = 105.70988 } },
{ model = "prop_phonebox_01a", coords = { x = 138.48807, y = 195.49808, z = 105.69342 } },
{ model = "prop_phonebox_01a", coords = { x = 173.45892, y = -1547.1165, z = 28.25158 } },
{ model = "prop_phonebox_01a", coords = { x = 215.71725, y = -1783.2102, z = 27.99637 } },
{ model = "prop_phonebox_01a", coords = { x = 215.94612, y = -1518.9603, z = 28.29362 } },
{ model = "prop_phonebox_01a", coords = { x = 228.6216, y = -1545.9579, z = 28.28108 } },
{ model = "prop_phonebox_01a", coords = { x = 229.1375, y = -1545.6771, z = 28.28108 } },
{ model = "prop_phonebox_01a", coords = { x = 295.67847, y = -1360.8624, z = 30.91401 } },
{ model = "prop_phonebox_01a", coords = { x = 296.17984, y = -1360.2825, z = 30.91899 } },
{ model = "prop_phonebox_01a", coords = { x = 532.401, y = -151.79816, z = 56.07613 } },
{ model = "prop_phonebox_01a", coords = { x = 539.5577, y = -166.04846, z = 53.4862 } },
{ model = "prop_phonebox_01a", coords = { x = 812.21716, y = -289.03873, z = 65.46264 } },
{ model = "prop_phonebox_01a", coords = { x = 812.3678, y = -289.84793, z = 65.46264 } },
{ model = "prop_phonebox_01a", coords = { x = 819.023, y = -94.03439, z = 79.57648 } },
{ model = "prop_phonebox_01a", coords = { x = 819.37396, y = -93.47577, z = 79.57648 } },
{ model = "prop_phonebox_01a", coords = { x = 891.8809, y = -140.80609, z = 76.11372 } },
{ model = "prop_phonebox_01a", coords = { x = 963.6167, y = -142.79822, z = 73.46588 } },
{ model = "prop_phonebox_01a", coords = { x = 1079.2015, y = -776.68054, z = 57.25418 } },
{ model = "prop_phonebox_01a", coords = { x = 1156.3375, y = -776.99866, z = 56.58559 } },
{ model = "prop_phonebox_01a", coords = { x = 1159.7463, y = -374.87518, z = 66.51784 } },
{ model = "prop_phonebox_01a", coords = { x = 1166.4825, y = -321.59958, z = 68.25383 } },
{ model = "prop_phonebox_01a", coords = { x = 1169.4127, y = 2702.8025, z = 36.99265 } },
{ model = "prop_phonebox_01a", coords = { x = 1170.3059, y = -455.76053, z = 65.49249 } },
{ model = "prop_phonebox_01a", coords = { x = 1172.7772, y = -297.61606, z = 68.01613 } },
{ model = "prop_phonebox_01a", coords = { x = 1172.8646, y = -298.23972, z = 68.01981 } },
{ model = "prop_phonebox_01a", coords = { x = 1173.8961, y = -421.43643, z = 66.07632 } },
{ model = "prop_phonebox_01a", coords = { x = 1201.426, y = -488.8848, z = 64.67129 } },
{ model = "prop_phonebox_01a", coords = { x = 1222.6125, y = -397.32706, z = 67.32355 } },
{ model = "prop_phonebox_01a", coords = { x = 1801.4076, y = 4597.0137, z = 36.67796 } },
{ model = "prop_phonebox_01a", coords = { x = 2558.9167, y = 367.14368, z = 107.63403 } },
{ model = "prop_phonebox_01b", coords = { x = -1684.4233, y = -266.45306, z = 50.89204 } },
{ model = "prop_phonebox_01b", coords = { x = -1683.9019, y = -265.70874, z = 50.89204 } },
{ model = "prop_phonebox_01b", coords = { x = -1543.9277, y = -433.13232, z = 34.57933 } },
{ model = "prop_phonebox_01b", coords = { x = -1543.0599, y = -432.05966, z = 34.58469 } },
{ model = "prop_phonebox_01b", coords = { x = -1522.4791, y = -407.05118, z = 34.58695 } },
{ model = "prop_phonebox_01b", coords = { x = -1412.1201, y = -383.80542, z = 35.68469 } },
{ model = "prop_phonebox_01b", coords = { x = -1205.1965, y = -1393.6274, z = 3.07721 } },
{ model = "prop_phonebox_01b", coords = { x = -1150.6671, y = -1392.6455, z = 4.11812 } },
{ model = "prop_phonebox_01b", coords = { x = -1142.0598, y = -725.3442, z = 19.77577 } },
{ model = "prop_phonebox_01b", coords = { x = -1080.87, y = -2574.942, z = 12.91528 } },
{ model = "prop_phonebox_01b", coords = { x = -1061.7015, y = -2541.7412, z = 12.91528 } },
{ model = "prop_phonebox_01b", coords = { x = -1061.5474, y = -2541.4744, z = 19.15571 } },
{ model = "prop_phonebox_01b", coords = { x = -1046.9408, y = -2516.175, z = 12.91528 } },
{ model = "prop_phonebox_01b", coords = { x = -1046.8787, y = -2516.0674, z = 19.15571 } },
{ model = "prop_phonebox_01b", coords = { x = -1034.5115, y = -2494.6467, z = 19.15571 } },
{ model = "prop_phonebox_01b", coords = { x = -1024.4517, y = -2477.2227, z = 12.91528 } },
{ model = "prop_phonebox_01b", coords = { x = -765.40045, y = -848.8706, z = 21.11398 } },
{ model = "prop_phonebox_01b", coords = { x = -764.83673, y = -848.8654, z = 21.13071 } },
{ model = "prop_phonebox_01b", coords = { x = -756.90735, y = 5586.8223, z = 35.71351 } },
{ model = "prop_phonebox_01b", coords = { x = -756.90735, y = 5587.636, z = 35.71351 } },
{ model = "prop_phonebox_01b", coords = { x = -745.72156, y = 5558.714, z = 35.71351 } },
{ model = "prop_phonebox_01b", coords = { x = -743.95874, y = 5558.714, z = 35.71351 } },
{ model = "prop_phonebox_01b", coords = { x = -715.921, y = 123.33502, z = 54.99648 } },
{ model = "prop_phonebox_01b", coords = { x = -715.40906, y = 123.65546, z = 55.01274 } },
{ model = "prop_phonebox_01b", coords = { x = -700.7271, y = -916.8699, z = 18.21408 } },
{ model = "prop_phonebox_01b", coords = { x = -700.1226, y = -916.8699, z = 18.21408 } },
{ model = "prop_phonebox_01b", coords = { x = -685.93774, y = -854.8967, z = 22.88396 } },
{ model = "prop_phonebox_01b", coords = { x = -670.3093, y = -819.59973, z = 23.4098 } },
{ model = "prop_phonebox_01b", coords = { x = -669.60815, y = -819.6056, z = 23.42427 } },
{ model = "prop_phonebox_01b", coords = { x = -668.81213, y = -819.6378, z = 23.43811 } },
{ model = "prop_phonebox_01b", coords = { x = -665.19354, y = -670.83777, z = 30.40002 } },
{ model = "prop_phonebox_01b", coords = { x = -664.59607, y = -670.8341, z = 30.40393 } },
{ model = "prop_phonebox_01b", coords = { x = -663.99866, y = -670.83044, z = 30.41797 } },
{ model = "prop_phonebox_01b", coords = { x = -655.2001, y = -859.74493, z = 23.50043 } },
{ model = "prop_phonebox_01b", coords = { x = -654.1605, y = -707.27155, z = 28.40153 } },
{ model = "prop_phonebox_01b", coords = { x = -654.1605, y = -706.51654, z = 28.47383 } },
{ model = "prop_phonebox_01b", coords = { x = -654.1605, y = -705.7615, z = 28.54753 } },
{ model = "prop_phonebox_01b", coords = { x = -611.56744, y = -2237.6104, z = 5.10603 } },
{ model = "prop_phonebox_01b", coords = { x = -530.0182, y = -1286.1543, z = 25.03622 } },
{ model = "prop_phonebox_01b", coords = { x = -529.77765, y = -1285.6444, z = 25.04207 } },
{ model = "prop_phonebox_01b", coords = { x = -529.6009, y = -1286.3458, z = 25.04428 } },
{ model = "prop_phonebox_01b", coords = { x = -529.36365, y = -1285.8345, z = 25.03622 } },
{ model = "prop_phonebox_01b", coords = { x = -468.24023, y = -396.44247, z = 32.8951 } },
{ model = "prop_phonebox_01b", coords = { x = -467.58286, y = -396.50574, z = 32.8951 } },
{ model = "prop_phonebox_01b", coords = { x = -259.3869, y = -604.76556, z = 32.59827 } },
{ model = "prop_phonebox_01b", coords = { x = -259.14352, y = -603.98016, z = 32.65002 } },
{ model = "prop_phonebox_01b", coords = { x = -241.57574, y = -766.2026, z = 31.73654 } },
{ model = "prop_phonebox_01b", coords = { x = -241.29694, y = -765.4682, z = 31.77955 } },
{ model = "prop_phonebox_01b", coords = { x = -239.74597, y = -978.22943, z = 28.26393 } },
{ model = "prop_phonebox_01b", coords = { x = -239.51797, y = -977.59296, z = 28.26393 } },
{ model = "prop_phonebox_01b", coords = { x = -178.84961, y = -52.06338, z = 51.10093 } },
{ model = "prop_phonebox_01b", coords = { x = -177.28662, y = -713.48505, z = 33.39728 } },
{ model = "prop_phonebox_01b", coords = { x = -147.61765, y = -287.15277, z = 39.43044 } },
{ model = "prop_phonebox_01b", coords = { x = -147.37784, y = -286.44186, z = 39.49831 } },
{ model = "prop_phonebox_01b", coords = { x = -73.17541, y = -641.5052, z = 35.24065 } },
{ model = "prop_phonebox_01b", coords = { x = -72.92773, y = -640.8415, z = 35.24065 } },
{ model = "prop_phonebox_01b", coords = { x = -53.45404, y = -94.169, z = 56.7686 } },
{ model = "prop_phonebox_01b", coords = { x = -27.98413, y = -100.90671, z = 56.35694 } },
{ model = "prop_phonebox_01b", coords = { x = -26.48154, y = -110.65947, z = 56.06785 } },
{ model = "prop_phonebox_01b", coords = { x = -8.06136, y = -731.61005, z = 43.22259 } },
{ model = "prop_phonebox_01b", coords = { x = -7.37235, y = -731.8549, z = 43.22768 } },
{ model = "prop_phonebox_01b", coords = { x = 43.99314, y = -680.87616, z = 43.20672 } },
{ model = "prop_phonebox_01b", coords = { x = 44.30204, y = -680.0512, z = 43.20672 } },
{ model = "prop_phonebox_01b", coords = { x = 120.37446, y = -205.12677, z = 53.61985 } },
{ model = "prop_phonebox_01b", coords = { x = 121.18489, y = -205.42413, z = 53.61985 } },
{ model = "prop_phonebox_01b", coords = { x = 129.40686, y = 245.3497, z = 106.42847 } },
{ model = "prop_phonebox_01b", coords = { x = 140.18076, y = -1033.1602, z = 28.34242 } },
{ model = "prop_phonebox_01b", coords = { x = 174.5452, y = -1116.4456, z = 28.28443 } },
{ model = "prop_phonebox_01b", coords = { x = 175.22937, y = -1116.4185, z = 28.28425 } },
{ model = "prop_phonebox_01b", coords = { x = 213.8157, y = -852.6208, z = 29.38956 } },
{ model = "prop_phonebox_01b", coords = { x = 214.44952, y = -852.86725, z = 29.38709 } },
{ model = "prop_phonebox_01b", coords = { x = 233.49872, y = 334.44766, z = 104.52145 } },
{ model = "prop_phonebox_01b", coords = { x = 296.66183, y = -1359.7725, z = 30.92093 } },
{ model = "prop_phonebox_01b", coords = { x = 372.30563, y = -966.37286, z = 28.41298 } },
{ model = "prop_phonebox_01b", coords = { x = 394.25433, y = -799.7535, z = 28.23798 } },
{ model = "prop_phonebox_01b", coords = { x = 394.25433, y = -798.6486, z = 28.23798 } },
{ model = "prop_phonebox_01b", coords = { x = 397.567, y = -921.3287, z = 28.3982 } },
{ model = "prop_phonebox_01b", coords = { x = 397.567, y = -920.658, z = 28.3982 } },
{ model = "prop_phonebox_01b", coords = { x = 415.17245, y = -910.94476, z = 28.3982 } },
{ model = "prop_phonebox_01b", coords = { x = 436.0411, y = 137.20285, z = 99.43968 } },
{ model = "prop_phonebox_01b", coords = { x = 436.83813, y = 136.89954, z = 99.38892 } },
{ model = "prop_phonebox_01b", coords = { x = 439.8047, y = -606.65063, z = 27.69825 } },
{ model = "prop_phonebox_01b", coords = { x = 439.99564, y = -604.67474, z = 27.69747 } },
{ model = "prop_phonebox_01b", coords = { x = 445.3808, y = 3567.5305, z = 32.21765 } },
{ model = "prop_phonebox_01b", coords = { x = 452.6532, y = -612.11816, z = 27.54012 } },
{ model = "prop_phonebox_01b", coords = { x = 452.7997, y = -610.4434, z = 27.5457 } },
{ model = "prop_phonebox_01b", coords = { x = 535.5781, y = 102.93228, z = 95.56698 } },
{ model = "prop_phonebox_01b", coords = { x = 779.83923, y = -1755.3914, z = 28.47611 } },
{ model = "prop_phonebox_01b", coords = { x = 780.2285, y = -1755.4475, z = 28.46564 } },
{ model = "prop_phonebox_01b", coords = { x = 809.3085, y = -1074.9281, z = 27.67919 } },
{ model = "prop_phonebox_01b", coords = { x = 903.52423, y = 3646.1294, z = 31.70571 } },
{ model = "prop_phonebox_01b", coords = { x = 1051.0497, y = 2661.3877, z = 38.52392 } },
{ model = "prop_phonebox_01b", coords = { x = 1181.1997, y = 2703.214, z = 37.1464 } },
{ model = "prop_phonebox_01b", coords = { x = 1206.4275, y = 2647.8894, z = 36.81204 } },
{ model = "prop_phonebox_01b", coords = { x = 1401.1744, y = 3602.0786, z = 34.01619 } },
{ model = "prop_phonebox_01b", coords = { x = 1662.8026, y = 4841.53, z = 41.0313 } },
{ model = "prop_phonebox_01b", coords = { x = 1662.9365, y = 4840.332, z = 41.0313 } },
{ model = "prop_phonebox_01b", coords = { x = 1692.9031, y = 6432.025, z = 31.73361 } },
{ model = "prop_phonebox_01b", coords = { x = 1696.5485, y = 3776.0618, z = 33.71252 } },
{ model = "prop_phonebox_01b", coords = { x = 1696.7177, y = 4790.302, z = 40.89749 } },
{ model = "prop_phonebox_01b", coords = { x = 1860.4072, y = 3696.2563, z = 33.26152 } },
{ model = "prop_phonebox_01b", coords = { x = 1861.0801, y = 3695.0903, z = 33.26152 } },
{ model = "prop_phonebox_01b", coords = { x = 2005.9707, y = 3782.6196, z = 31.15662 } },
{ model = "prop_phonebox_01b", coords = { x = 2006.7942, y = 3783.1003, z = 31.14984 } },
{ model = "prop_phonebox_04", coords = { x = -2969.4265, y = 397.46487, z = 14.10208 } },
{ model = "prop_phonebox_04", coords = { x = -1417.7056, y = -94.50974, z = 51.41046 } },
{ model = "prop_phonebox_04", coords = { x = -1416.8014, y = -94.11159, z = 51.44441 } },
{ model = "prop_phonebox_04", coords = { x = -1415.9122, y = -93.72023, z = 51.49042 } },
{ model = "prop_phonebox_04", coords = { x = -1294.0234, y = -390.34976, z = 35.44277 } },
{ model = "prop_phonebox_04", coords = { x = -1293.4121, y = -391.38864, z = 35.44632 } },
{ model = "prop_phonebox_04", coords = { x = -1241.7491, y = -464.37216, z = 32.537 } },
{ model = "prop_phonebox_04", coords = { x = -1224.2532, y = -322.51794, z = 36.57259 } },
{ model = "prop_phonebox_04", coords = { x = -1223.0624, y = -321.95178, z = 36.59326 } },
{ model = "prop_phonebox_04", coords = { x = -1074.0209, y = -397.75607, z = 35.95449 } },
{ model = "prop_phonebox_04", coords = { x = -1025.3336, y = -216.04681, z = 36.93829 } },
{ model = "prop_phonebox_04", coords = { x = -1023.97375, y = -216.74884, z = 36.9369 } },
{ model = "prop_phonebox_04", coords = { x = -985.19824, y = -414.10977, z = 36.85289 } },
{ model = "prop_phonebox_04", coords = { x = -979.73254, y = -369.2069, z = 36.856 } },
{ model = "prop_phonebox_04", coords = { x = -979.1488, y = -370.3092, z = 36.856 } },
{ model = "prop_phonebox_04", coords = { x = -965.37616, y = -2524.3992, z = 13.00643 } },
{ model = "prop_phonebox_04", coords = { x = -963.65985, y = -247.05435, z = 37.0568 } },
{ model = "prop_phonebox_04", coords = { x = -896.8487, y = -247.80585, z = 39.07844 } },
{ model = "prop_phonebox_04", coords = { x = -865.3409, y = -2528.5645, z = 13.00643 } },
{ model = "prop_phonebox_04", coords = { x = -821.83124, y = -251.49579, z = 36.0627 } },
{ model = "prop_phonebox_04", coords = { x = -821.29346, y = -252.4495, z = 36.05127 } },
{ model = "prop_phonebox_04", coords = { x = -701.46173, y = -371.56976, z = 33.2833 } },
{ model = "prop_phonebox_04", coords = { x = -700.3627, y = -372.04968, z = 33.27078 } },
{ model = "prop_phonebox_04", coords = { x = -619.5328, y = -207.68121, z = 36.3736 } },
{ model = "prop_phonebox_04", coords = { x = -618.975, y = -208.61508, z = 36.35005 } },
{ model = "prop_phonebox_04", coords = { x = -617.05457, y = -422.20978, z = 33.7873 } },
{ model = "prop_phonebox_04", coords = { x = -557.25586, y = -386.67517, z = 34.11347 } },
{ model = "prop_phonebox_04", coords = { x = -556.05286, y = -386.66565, z = 34.11989 } },
{ model = "prop_phonebox_04", coords = { x = -554.8701, y = -386.6563, z = 34.12583 } },
{ model = "prop_phonebox_04", coords = { x = -546.57184, y = -334.10083, z = 34.16116 } },
{ model = "prop_phonebox_04", coords = { x = -544.17737, y = -157.39006, z = 37.53791 } },
{ model = "prop_phonebox_04", coords = { x = -388.49542, y = -321.52948, z = 32.10458 } },
{ model = "prop_phonebox_04", coords = { x = -387.68488, y = -322.21454, z = 32.05279 } },
{ model = "prop_phonebox_04", coords = { x = -360.33978, y = -267.18268, z = 32.73604 } },
{ model = "prop_phonebox_04", coords = { x = -347.059, y = -1490.9738, z = 29.79159 } },
{ model = "prop_phonebox_04", coords = { x = -345.83786, y = -1490.9738, z = 29.7867 } },
{ model = "prop_phonebox_04", coords = { x = -263.0376, y = -766.90546, z = 31.57576 } },
{ model = "prop_phonebox_04", coords = { x = -262.6508, y = -766.04095, z = 31.60592 } },
{ model = "prop_phonebox_04", coords = { x = -213.1738, y = -696.5944, z = 32.80729 } },
{ model = "prop_phonebox_04", coords = { x = -174.76907, y = -674.9272, z = 33.27862 } },
{ model = "prop_phonebox_04", coords = { x = -173.80676, y = -675.35236, z = 33.29762 } },
{ model = "prop_phonebox_04", coords = { x = -138.00961, y = -799.9025, z = 31.10711 } },
{ model = "prop_phonebox_04", coords = { x = -137.64026, y = -798.8024, z = 31.14563 } },
{ model = "prop_phonebox_04", coords = { x = 55.44337, y = -1081.1333, z = 28.45174 } },
{ model = "prop_phonebox_04", coords = { x = 55.90165, y = -1080.282, z = 28.45174 } },
{ model = "prop_phonebox_04", coords = { x = 188.01767, y = -1043.9451, z = 28.32789 } },
{ model = "prop_phonebox_04", coords = { x = 189.79306, y = -1044.5588, z = 28.32789 } },
{ model = "prop_phonebox_04", coords = { x = 298.28317, y = -795.153, z = 28.4778 } },
{ model = "prop_phonebox_04", coords = { x = 298.62607, y = -794.289, z = 28.4778 } },
{ model = "prop_phonebox_04", coords = { x = 347.45938, y = -730.9255, z = 28.28353 } },
{ model = "prop_phonebox_04", coords = { x = 564.7501, y = -1748.7141, z = 28.31245 } },
{ model = "prop_phonebox_04", coords = { x = 653.1738, y = 272.5705, z = 102.29323 } },
{ model = "prop_phonebox_04", coords = { x = 654.2313, y = 271.95996, z = 102.29323 } },
{ model = "prop_phonebox_04", coords = { x = 1214.8445, y = -1385.6348, z = 34.34755 } },
{ model = "prop_phonebox_04", coords = { x = 1214.8445, y = -1384.5386, z = 34.34755 } },
{ model = "prop_phonebox_04", coords = { x = 1662.002, y = 4819.523, z = 41.04535 } },
{ model = "prop_phonebox_04", coords = { x = 1816.4236, y = 3671.6497, z = 33.29268 } },
{ model = "prop_phonebox_04", coords = { x = 1818.1936, y = 3668.7485, z = 33.29268 } },
{ model = "prop_phonebox_04", coords = { x = 2007.0574, y = 3784.7974, z = 31.20895 } },
}
+3
View File
@@ -30,6 +30,7 @@ client_scripts {
'source/bridge/client/housing.lua',
'source/bridge/client/housing/*.lua',
'source/client/animations.lua',
'source/client/focus.lua',
'source/client/camera.lua',
'source/client/garage.lua',
'source/client/skyride.lua',
@@ -46,6 +47,7 @@ client_scripts {
server_scripts {
'@oxmysql/lib/MySQL.lua',
'config/config.lua',
'config/payphones.lua',
'config/companies.lua',
'config/media.lua',
'config/music.lua',
@@ -66,6 +68,7 @@ server_scripts {
'source/server/companies.lua',
'source/server/custom_app_storage.lua',
'source/server/sim.lua',
'source/server/payphones.lua',
'source/server/calls.lua',
'source/server/media.lua',
'source/server/messages.lua',
+104 -32
View File
@@ -6,6 +6,10 @@ local ultrawide_fov_multiplier = 2.0
local front_camera_distance = 0.75
local front_camera_height = 0.05
local front_camera_target_height = 0.03
local unfocused_camera_controls = {
1, -- INPUT_LOOK_LR
2, -- INPUT_LOOK_UD
}
local camera_state = {
active = false,
enforcing = false,
@@ -16,6 +20,7 @@ local camera_state = {
front_camera_handle = nil,
landscape = false,
ultrawide_camera_handle = nil,
applied_nui_focus = true,
nui_focused = true,
previous_ped_view = nil,
previous_radar_hidden = nil,
@@ -145,27 +150,32 @@ local function restore_camera_view()
end
end
local function set_camera_focus(focused)
if camera_state.nui_focused == focused then
return
local function apply_unfocused_camera_controls()
DisableAllControlActions(0)
for _, control in ipairs(unfocused_camera_controls) do
EnableControlAction(0, control, true)
end
camera_state.nui_focused = focused
if focused then
SetNuiFocus(true, true)
SetNuiFocusKeepInput(false)
SendNUIMessage({ type = "camera:focus", data = { focused = true } })
return
end
SetNuiFocus(false, false)
SetNuiFocusKeepInput(true)
SendNUIMessage({ type = "camera:focus", data = { focused = false } })
DisablePlayerFiring(PlayerId(), true)
end
local function update_camera_focus_claim()
TriggerEvent("sky_phone:client:setCameraFocus", {
active = camera_state.active,
nuiFocused = camera_state.nui_focused,
})
end
local set_camera_focus
local function watch_unfocused_camera_controls()
if camera_state.focus_watcher then
return
end
camera_state.focus_watcher = true
CreateThread(function()
while camera_state.active and not camera_state.nui_focused do
if IsControlJustReleased(0, 22) then
while camera_state.active and not camera_state.applied_nui_focus do
apply_unfocused_camera_controls()
if IsDisabledControlJustReleased(0, 22) then
set_camera_focus(true)
break
end
@@ -175,6 +185,28 @@ local function set_camera_focus(focused)
end)
end
set_camera_focus = function(focused)
camera_state.nui_focused = focused
update_camera_focus_claim()
end
AddEventHandler("sky_phone:client:cameraFocusApplied", function(data)
if type(data) ~= "table"
or type(data.active) ~= "boolean"
or type(data.focused) ~= "boolean"
or type(data.gameInput) ~= "boolean"
then
return
end
if camera_state.applied_nui_focus ~= data.focused then
camera_state.applied_nui_focus = data.focused
SendNUIMessage({ type = "camera:focus", data = { focused = data.focused } })
end
if data.active and data.gameInput then
watch_unfocused_camera_controls()
end
end)
local function set_camera_active(active)
if camera_state.active == active then
return
@@ -251,11 +283,8 @@ local function set_camera_active(active)
clear_front_camera()
clear_ultrawide_camera()
restore_camera_view()
if not camera_state.nui_focused then
camera_state.nui_focused = true
SetNuiFocusKeepInput(false)
SetNuiFocus(true, true)
end
camera_state.nui_focused = true
update_camera_focus_claim()
TriggerEvent("sky_phone:animation:camera", {
active = false,
front = false,
@@ -325,58 +354,102 @@ local function set_camera_zoom(zoom)
end
RegisterNUICallback("camera:setActive", function(data, cb)
set_camera_active(data and data.active == true)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
set_camera_active(data.active == true)
cb({ success = true })
end)
RegisterNUICallback("camera:setFocus", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
if camera_state.active then
set_camera_focus(data and data.focused == true)
set_camera_focus(data.focused == true)
end
cb({ success = true })
end)
RegisterNUICallback("camera:setFlash", function(data, cb)
set_flash_enabled(data and data.enabled == true)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
set_flash_enabled(data.enabled == true)
cb({ success = true })
end)
RegisterNUICallback("camera:setFacing", function(data, cb)
set_front_camera(data and data.front == true)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
set_front_camera(data.front == true)
cb({ success = true })
end)
RegisterNUICallback("camera:setOrientation", function(data, cb)
set_camera_landscape(data and data.landscape == true)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
set_camera_landscape(data.landscape == true)
cb({ success = true })
end)
RegisterNUICallback("camera:setZoom", function(data, cb)
cb({ success = set_camera_zoom(tonumber(data and data.zoom)) })
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
cb({ success = set_camera_zoom(tonumber(data.zoom)) })
end)
RegisterNUICallback("media:requestUpload", function(data, cb)
TriggerServerEvent("sky_phone:media:request-upload", data or {})
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
TriggerServerEvent("sky_phone:media:request-upload", data)
cb({ success = true })
end)
RegisterNUICallback("media:completeUpload", function(data, cb)
TriggerServerEvent("sky_phone:media:complete-upload", data or {})
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
TriggerServerEvent("sky_phone:media:complete-upload", data)
cb({ success = true })
end)
RegisterNUICallback("media:cancelUpload", function(data, cb)
TriggerServerEvent("sky_phone:media:cancel-upload", data or {})
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
TriggerServerEvent("sky_phone:media:cancel-upload", data)
cb({ success = true })
end)
RegisterNUICallback("media:failUpload", function(data, cb)
TriggerServerEvent("sky_phone:media:fail-upload", data or {})
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
TriggerServerEvent("sky_phone:media:fail-upload", data)
cb({ success = true })
end)
RegisterNUICallback("gallery:delete", function(data, cb)
TriggerServerEvent("sky_phone:media:delete", data or {})
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
TriggerServerEvent("sky_phone:media:delete", data)
cb({ success = true })
end)
@@ -401,6 +474,5 @@ AddEventHandler("onResourceStop", function(resource_name)
if resource_name == GetCurrentResourceName() then
set_flash_enabled(false)
set_camera_active(false)
SetNuiFocusKeepInput(false)
end
end)
+4
View File
@@ -16,6 +16,10 @@ local function draw_overhead_label(coords, username, role)
end
RegisterNUICallback("crewlink:live", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
local result = Bridge.Callbacks.Trigger("sky_phone:crewlink:live", data)
overhead_members = result and result.success and result.data and result.data.overheadMembers or {}
overhead_expires_at = GetGameTimer() + Config.CrewLink.OverheadRefreshMilliseconds * 2
+21
View File
@@ -0,0 +1,21 @@
SkyPhoneFocus = {}
function SkyPhoneFocus.Resolve(state)
if state.activity_suspended then
return { focused = false, keep_input = false }
end
if state.call_focus then
return { focused = true, keep_input = false }
end
if state.camera_active and not state.camera_nui_focused then
return { focused = false, keep_input = true }
end
return {
focused = state.is_open
or state.notification_focus
or state.payphone_focus
or state.sim_picker_open
or (state.camera_active and state.camera_nui_focused),
keep_input = false,
}
end
+4
View File
@@ -441,6 +441,10 @@ local function run_valet_delivery(order)
end
RegisterNUICallback("garage:valet-request", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
if current_valet then
cb({ success = false, error = "valet_active" })
return
+38 -15
View File
@@ -11,18 +11,18 @@ local function server_prepare(action, data)
end
local function suspend_phone()
SetNuiFocus(false, false)
TriggerEvent("sky_phone:client:setSuspended", true)
TriggerEvent("sky_phone:animation:phone", false)
SendNUIMessage({ type = "app:suspend" })
end
local function resume_phone()
SendNUIMessage({ type = "app:resume" })
SetNuiFocus(true, true)
TriggerEvent("sky_phone:client:setSuspended", false)
TriggerEvent("sky_phone:animation:phone", true)
end
local function stop_camera()
local function stop_camera(resume)
local state = camera_state
if not state then
return
@@ -37,7 +37,9 @@ local function stop_camera()
if DoesEntityExist(state.ped) then
FreezeEntityPosition(state.ped, state.frozen)
end
resume_phone()
if resume ~= false then
resume_phone()
end
end
local function camera_number(value, fallback)
@@ -105,13 +107,17 @@ local function run_camera(provider_name, camera_data)
local maximum_left = camera_number(camera_data.maximumLeft)
local maximum_right = camera_number(camera_data.maximumRight)
local night_vision = camera_state.night_vision
local resume_after_camera = false
local close_phone_after_camera = false
while camera_state and camera_state.camera == camera do
Wait(0)
DisableAllControlActions(0)
if IsDisabledControlJustPressed(0, config.ExitControl)
or IsPedDeadOrDying(ped, true)
or GetResourceState(provider_name) ~= "started"
then
if IsDisabledControlJustPressed(0, config.ExitControl) then
resume_after_camera = true
break
end
if IsPedDeadOrDying(ped, true) or GetResourceState(provider_name) ~= "started" then
close_phone_after_camera = true
break
end
@@ -147,12 +153,29 @@ local function run_camera(provider_name, camera_data)
SetNightvision(night_vision)
end
end
stop_camera()
stop_camera(resume_after_camera)
if close_phone_after_camera then
TriggerEvent("sky_phone:client:forceClose")
end
end
RegisterNUICallback("housing:overview", function(_, cb)
RegisterNetEvent("sky_phone:device:invalidated", function()
stop_camera(false)
end)
RegisterNUICallback("housing:overview", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
local result = Bridge.Callbacks.Trigger("sky_phone:housing:overview", {})
cb(result or { success = false, error = "request_failed" })
cb(type(result) == "table" and result or { success = false, error = "request_failed" })
end)
AddEventHandler("sky_phone:client:nuiReady", function()
if camera_state then
suspend_phone()
end
end)
RegisterNUICallback("housing:key-candidates", function(data, cb)
@@ -161,7 +184,7 @@ RegisterNUICallback("housing:key-candidates", function(data, cb)
return
end
local result = server_prepare("key_candidates", data)
cb(result or { success = false, error = "request_failed" })
cb(type(result) == "table" and result or { success = false, error = "request_failed" })
end)
RegisterNUICallback("housing:command", function(data, cb)
@@ -182,8 +205,8 @@ RegisterNUICallback("housing:command", function(data, cb)
end
local prepared = server_prepare(data.action, data)
if not prepared or not prepared.success or type(prepared.data) ~= "table" then
cb(prepared or { success = false, error = "request_failed" })
if type(prepared) ~= "table" or not prepared.success or type(prepared.data) ~= "table" then
cb(type(prepared) == "table" and prepared or { success = false, error = "request_failed" })
return
end
if data.action == "set_waypoint" then
@@ -214,6 +237,6 @@ end)
AddEventHandler("onResourceStop", function(resource_name)
if resource_name == GetCurrentResourceName() and camera_state then
stop_camera()
stop_camera(false)
end
end)
+209 -35
View File
@@ -1,9 +1,17 @@
local is_open = false
local open_requested = false
local notification_focus = false
local call_focus = false
local payphone_focus = false
local camera_active = false
local camera_nui_focused = true
local device_payload = nil
local sim_picker_open = false
local sim_picker_payload = nil
local active_call_payload = nil
local call_channel = 0
local nui_generation = 0
local activity_suspended = false
Bridge.Debug("debug", "[sky_phone] Client script initialized.", { always = true })
@@ -256,6 +264,46 @@ local function get_locale()
return Locales[Config.Bridge.Locale] or Locales["en"]
end
local function update_nui_focus()
local focus = SkyPhoneFocus.Resolve({
activity_suspended = activity_suspended,
call_focus = call_focus,
camera_active = camera_active,
camera_nui_focused = camera_nui_focused,
is_open = is_open,
notification_focus = notification_focus,
payphone_focus = payphone_focus,
sim_picker_open = sim_picker_open,
})
SetNuiFocus(focus.focused, focus.focused)
SetNuiFocusKeepInput(focus.keep_input)
TriggerEvent("sky_phone:client:cameraFocusApplied", {
active = camera_active,
focused = focus.focused,
gameInput = focus.keep_input,
})
end
AddEventHandler("sky_phone:client:setSuspended", function(suspended)
activity_suspended = suspended == true
update_nui_focus()
end)
AddEventHandler("sky_phone:client:setPayphoneFocus", function(focused)
payphone_focus = focused == true
update_nui_focus()
end)
AddEventHandler("sky_phone:client:setCameraFocus", function(data)
if type(data) ~= "table" or type(data.active) ~= "boolean" or type(data.nuiFocused) ~= "boolean" then
Bridge.Debug("error", "[sky_phone] Rejected invalid camera focus claim.")
return
end
camera_active = data.active
camera_nui_focused = data.nuiFocused
update_nui_focus()
end)
local function send_open_message()
if not device_payload then
return
@@ -279,21 +327,31 @@ local function open_phone()
send_open_message()
end
local function close_phone()
local function close_phone(close_device_session)
local was_requested = open_requested
local was_open = is_open
open_requested = false
call_focus = false
activity_suspended = false
TriggerEvent("sky_phone:animation:phone", false)
if not is_open then
return
end
is_open = false
SkyPhoneApps.SetPhoneOpen(false)
TriggerEvent("sky_phone:nuiClosed")
SetNuiFocus(notification_focus or sim_picker_open, notification_focus or sim_picker_open)
SendNUIMessage({ type = "app:close" })
Bridge.Callbacks.Trigger("sky_phone:device:close", {})
if was_open then
SkyPhoneApps.SetPhoneOpen(false)
TriggerEvent("sky_phone:nuiClosed")
end
update_nui_focus()
if was_requested or was_open then
SendNUIMessage({ type = "app:close" })
if close_device_session ~= false then
Bridge.Callbacks.Trigger("sky_phone:device:close", {})
end
end
end
AddEventHandler("sky_phone:client:forceClose", function()
close_phone()
end)
local function leave_call_voice()
if call_channel == 0 then
return
@@ -337,24 +395,58 @@ RegisterNetEvent("sky_phone:testdata:feedback", function(success, detail)
Bridge.Framework.Notify("iFruit", message, success and "success" or "error", 7000)
end)
RegisterNUICallback("ui:ready", function(_, cb)
RegisterNUICallback("ui:ready", function(data, cb)
if type(data) ~= "table" or data.protocolVersion ~= 1 then
cb({ success = false, error = "unsupported_protocol" })
return
end
nui_generation = nui_generation + 1
-- Browser state is recreated on a CEF reload. A notification focus claim
-- cannot survive unless its notification is replayed as part of this handshake.
notification_focus = false
Bridge.Debug("debug", "[sky_phone] NUI reported ready.", { always = true })
TriggerEvent("sky_phone:client:nuiReady")
SkyPhoneApps.SendCatalog()
if open_requested and device_payload then
send_open_message()
end
if active_call_payload then
SendNUIMessage({ type = "call:state", data = active_call_payload })
end
if sim_picker_open and sim_picker_payload then
SendNUIMessage({ type = "sim:picker", data = sim_picker_payload })
else
SendNUIMessage({ type = "sim:picker-close" })
end
cb({ success = true })
update_nui_focus()
TriggerEvent("sky_phone:client:nuiReady", {
generation = nui_generation,
protocolVersion = 1,
})
cb({
success = true,
data = {
generation = nui_generation,
protocolVersion = 1,
},
})
end)
RegisterNUICallback("ui:opened", function(_, cb)
RegisterNUICallback("ui:opened", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
if not open_requested or not device_payload then
Bridge.Debug(
"warn",
"[sky_phone] Ignored a NUI open confirmation without a pending device open.",
{ always = true }
)
SendNUIMessage({ type = "app:close" })
update_nui_focus()
cb({ success = false, error = "open_not_requested" })
return
end
@@ -362,29 +454,49 @@ RegisterNUICallback("ui:opened", function(_, cb)
is_open = true
SkyPhoneApps.SetPhoneOpen(true)
notification_focus = false
SetNuiFocus(true, true)
call_focus = false
update_nui_focus()
TriggerEvent("sky_phone:animation:phone", true)
cb({ success = true })
end)
RegisterNUICallback("close", function(_, cb)
RegisterNUICallback("close", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
close_phone()
cb({ success = true })
end)
RegisterNUICallback("notification:focus", function(data, cb)
if type(data) ~= "table" or type(data.active) ~= "boolean" then
cb({ success = false, error = "invalid_request" })
return
end
notification_focus = data.active == true and not is_open
SetNuiFocus(is_open or notification_focus, is_open or notification_focus)
update_nui_focus()
cb({ success = true })
end)
RegisterNUICallback("sim:picker-close", function(_, cb)
RegisterNUICallback("sim:picker-close", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
sim_picker_open = false
SetNuiFocus(is_open or notification_focus, is_open or notification_focus)
cb({ success = true })
sim_picker_payload = nil
update_nui_focus()
SendNUIMessage({ type = "sim:picker-close" })
local result = Bridge.Callbacks.Trigger("sky_phone:sim:picker-close", {})
cb(type(result) == "table" and result or { success = false, error = "request_failed" })
end)
RegisterNUICallback("map:getPlayerCoords", function(_, cb)
RegisterNUICallback("map:getPlayerCoords", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
local coords = GetEntityCoords(PlayerPedId())
cb({
success = true,
@@ -439,7 +551,11 @@ local function weather_region(coords)
return "los_santos"
end
RegisterNUICallback("weather:get", function(_, cb)
RegisterNUICallback("weather:get", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
local coords = GetEntityCoords(PlayerPedId())
local weather_hash = GetPrevWeatherTypeHashName()
local next_weather_hash = GetNextWeatherTypeHashName()
@@ -479,9 +595,13 @@ local function garage_vehicle_kind(model_hash, fallback)
end
RegisterNUICallback("garage:vehicles", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
local result = Bridge.Callbacks.Trigger("sky_phone:garage:vehicles", data)
if not result or not result.success or type(result.data) ~= "table" then
cb(result or { success = false, error = "request_failed" })
if type(result) ~= "table" or not result.success or type(result.data) ~= "table" then
cb(type(result) == "table" and result or { success = false, error = "request_failed" })
return
end
for _, vehicle in ipairs(result.data.vehicles or {}) do
@@ -505,8 +625,12 @@ end)
for _, callback_name in ipairs(server_callbacks) do
RegisterNUICallback(callback_name, function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
local result = Bridge.Callbacks.Trigger("sky_phone:" .. callback_name, data)
if result then
if type(result) == "table" then
cb(result)
return
end
@@ -516,6 +640,10 @@ for _, callback_name in ipairs(server_callbacks) do
end
RegisterNetEvent("sky_phone:device:open", function(data)
if type(data) ~= "table" or type(data.device) ~= "table" or type(data.device.imei) ~= "string" then
Bridge.Debug("error", "[sky_phone] Rejected invalid device open data.")
return
end
Bridge.Debug(
"debug",
"[sky_phone] Client received device open for IMEI %s account_linked=%s.",
@@ -525,19 +653,27 @@ RegisterNetEvent("sky_phone:device:open", function(data)
)
device_payload = data
open_requested = true
if is_open then
SkyPhoneApps.SendCatalog()
SendNUIMessage({ type = "device:updated", data = data })
return
end
open_phone()
end)
RegisterNetEvent("sky_phone:device:updated", function(data)
if type(data) ~= "table" or type(data.device) ~= "table" or type(data.device.imei) ~= "string" then
Bridge.Debug("error", "[sky_phone] Rejected invalid device update data.")
return
end
device_payload = data
SendNUIMessage({ type = "device:updated", data = data })
end)
RegisterNetEvent("sky_phone:device:invalidated", function()
open_requested = false
device_payload = nil
TriggerEvent("sky_phone:animation:reset")
close_phone()
close_phone(false)
device_payload = nil
end)
RegisterNetEvent("sky_phone:device:error", function(error_code)
@@ -669,14 +805,20 @@ RegisterNetEvent("sky_phone:flare:message", function(data)
end)
RegisterNetEvent("sky_phone:sim:picker", function(data)
if type(data) ~= "table" or type(data.number) ~= "string" or type(data.choices) ~= "table" then
Bridge.Debug("error", "[sky_phone] Rejected invalid SIM picker data.")
return
end
sim_picker_open = true
SetNuiFocus(true, true)
sim_picker_payload = data
update_nui_focus()
SendNUIMessage({ type = "sim:picker", data = data })
end)
RegisterNetEvent("sky_phone:sim:picker-close", function()
sim_picker_open = false
SetNuiFocus(is_open or notification_focus, is_open or notification_focus)
sim_picker_payload = nil
update_nui_focus()
SendNUIMessage({ type = "sim:picker-close" })
end)
@@ -752,13 +894,35 @@ RegisterNetEvent("sky_phone:darkchat:new", function(data)
end)
RegisterNetEvent("sky_phone:call:incoming", function(data)
notification_focus = true
SetNuiFocus(true, true)
if type(data) ~= "table" or type(data.id) ~= "string" or data.state ~= "ringing" then
Bridge.Debug("error", "[sky_phone] Rejected invalid incoming call data.")
return
end
active_call_payload = data
call_focus = true
update_nui_focus()
TriggerEvent("sky_phone:animation:call", data)
SendNUIMessage({ type = "call:incoming", data = data })
end)
RegisterNetEvent("sky_phone:call:state", function(data)
if type(data) ~= "table" or type(data.id) ~= "string" or type(data.state) ~= "string" then
Bridge.Debug("error", "[sky_phone] Rejected invalid call state data.")
return
end
if data.state == "ringing" or data.state == "connected" then
active_call_payload = data
end
if data.state ~= "ringing" then
local focus_changed = call_focus
call_focus = false
if focus_changed then
update_nui_focus()
end
end
if data.state ~= "ringing" and data.state ~= "connected" then
active_call_payload = nil
end
if data.state == "connected" and data.channel then
if not join_call_voice(data.channel) then
TriggerEvent("sky_phone:device:error", "voice_unavailable")
@@ -784,9 +948,19 @@ AddEventHandler("onResourceStop", function(resource_name)
return
end
if is_open or notification_focus then
SetNuiFocus(false, false)
end
is_open = false
open_requested = false
notification_focus = false
call_focus = false
payphone_focus = false
camera_active = false
camera_nui_focused = true
sim_picker_open = false
sim_picker_payload = nil
active_call_payload = nil
activity_suspended = false
SetNuiFocusKeepInput(false)
SetNuiFocus(false, false)
TriggerEvent("sky_phone:animation:reset")
leave_call_voice()
+50 -19
View File
@@ -6,6 +6,7 @@ local active_call_state = nil
local active_call_number = nil
local active_call_elapsed_seconds = 0
local active_call_elapsed_updated_at = 0
local active_call_payload = nil
local call_channel = 0
local replacement_prop = nil
local hidden_prop = nil
@@ -360,11 +361,20 @@ local function booth_payload(booth)
}
end
local function payphone_open_payload()
return {
currency = Config.Payphones.Currency,
maxNumberLength = Config.Sim.NumberLength,
pricePerSecond = Config.Payphones.PricePerSecond,
locales = get_locale().Nui.Payphone,
}
end
local function close_payphone()
local was_open = payphone_open
payphone_open = false
if was_open then
SetNuiFocus(false, false)
TriggerEvent("sky_phone:client:setPayphoneFocus", false)
SendNUIMessage({ type = "payphone:close" })
end
if not active_call_id then
@@ -418,6 +428,7 @@ local function apply_active_call_state(data)
active_call_elapsed_seconds = 0
active_call_elapsed_updated_at = 0
end
active_call_payload = data
end
local function clear_active_call_state()
@@ -426,6 +437,7 @@ local function clear_active_call_state()
active_call_number = nil
active_call_elapsed_seconds = 0
active_call_elapsed_updated_at = 0
active_call_payload = nil
hangup_requested = false
end
@@ -435,48 +447,51 @@ local function open_payphone(booth)
end
active_booth = booth
payphone_open = true
SetNuiFocus(true, true)
TriggerEvent("sky_phone:client:setPayphoneFocus", true)
SendNUIMessage({
type = "payphone:open",
data = {
currency = Config.Payphones.Currency,
maxNumberLength = Config.Sim.NumberLength,
pricePerSecond = Config.Payphones.PricePerSecond,
locales = get_locale().Nui.Payphone,
},
data = payphone_open_payload(),
})
end
RegisterNUICallback("payphone:dial", function(data, cb)
if not payphone_open or not active_booth or active_call_id then
if type(data) ~= "table" or not payphone_open or not active_booth or active_call_id then
cb({ success = false, error = "invalid_request" })
return
end
local payload = booth_payload(active_booth)
payload.phoneNumber = type(data) == "table" and data.phoneNumber or nil
payload.phoneNumber = data.phoneNumber
local result = Bridge.Callbacks.Trigger("sky_phone:payphone:dial", payload)
local call_started = result and result.success and result.data
local call_started = type(result) == "table" and result.success and type(result.data) == "table"
and (result.data.state == "ringing" or result.data.state == "connected")
if call_started then
apply_active_call_state(result.data)
end
cb(result or { success = false, error = "request_failed" })
cb(type(result) == "table" and result or { success = false, error = "request_failed" })
if call_started then
close_payphone()
start_call_visuals()
end
end)
RegisterNUICallback("payphone:hangup", function(_, cb)
RegisterNUICallback("payphone:hangup", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
if not active_call_id then
cb({ success = false, error = "call_not_found" })
return
end
local result = Bridge.Callbacks.Trigger("sky_phone:payphone:hangup", { id = active_call_id })
cb(result or { success = false, error = "request_failed" })
cb(type(result) == "table" and result or { success = false, error = "request_failed" })
end)
RegisterNUICallback("payphone:close", function(_, cb)
RegisterNUICallback("payphone:close", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
if active_call_id then
Bridge.Callbacks.Trigger("sky_phone:payphone:hangup", { id = active_call_id })
end
@@ -485,7 +500,7 @@ RegisterNUICallback("payphone:close", function(_, cb)
end)
RegisterNetEvent("sky_phone:payphone:state", function(data)
if type(data) ~= "table" then
if type(data) ~= "table" or type(data.id) ~= "string" or type(data.state) ~= "string" then
return
end
if data.state == "ringing" or data.state == "connected" then
@@ -506,6 +521,24 @@ RegisterNetEvent("sky_phone:payphone:state", function(data)
SendNUIMessage({ type = "payphone:state", data = data })
end)
AddEventHandler("sky_phone:client:nuiReady", function()
if payphone_open then
TriggerEvent("sky_phone:client:setPayphoneFocus", true)
SendNUIMessage({ type = "payphone:open", data = payphone_open_payload() })
else
TriggerEvent("sky_phone:client:setPayphoneFocus", false)
SendNUIMessage({ type = "payphone:close" })
end
if active_call_payload then
local payload = {}
for key, value in pairs(active_call_payload) do
payload[key] = value
end
payload.elapsedSeconds = current_call_elapsed_seconds()
SendNUIMessage({ type = "payphone:state", data = payload })
end
end)
CreateThread(function()
while true do
if not Config.Payphones.Enabled or payphone_open or active_call_id or visuals_ending then
@@ -625,9 +658,7 @@ AddEventHandler("onResourceStop", function(resource_name)
if resource_name ~= GetCurrentResourceName() then
return
end
if payphone_open then
SetNuiFocus(false, false)
end
TriggerEvent("sky_phone:client:setPayphoneFocus", false)
leave_call_voice()
stop_call_visuals()
clear_active_call_state()
+37 -5
View File
@@ -120,7 +120,10 @@ local function join_radio(primary, secondary)
return approved
end
local data = approved.data or {}
if type(approved.data) ~= "table" then
return { success = false, error = "request_failed" }
end
local data = approved.data
local approved_primary = tonumber(data.frequency) or 0
local approved_secondary = tonumber(data.secondaryFrequency) or 0
if not Bridge.Radio.Join(approved_primary, approved_secondary) then
@@ -148,27 +151,41 @@ local function leave_radio()
return request("disconnect")
end
RegisterNUICallback("radio:get", function(_, cb)
RegisterNUICallback("radio:get", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
local result = request("get")
if result.success then
if result.success and type(result.data) == "table" then
apply_server_state(result.data)
result.data.volume = current_volume
result.data.provider = Bridge.Radio.GetProvider()
result.data.secondarySupported = Bridge.Radio.SupportsSecondary()
elseif result.success then
result = { success = false, error = "request_failed" }
end
cb(result)
end)
RegisterNUICallback("radio:connect", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
cb(join_radio(data.frequency, data.secondaryFrequency))
end)
RegisterNUICallback("radio:disconnect", function(_, cb)
RegisterNUICallback("radio:disconnect", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
cb(leave_radio())
end)
RegisterNUICallback("radio:set-volume", function(data, cb)
local volume = tonumber(data.volume)
local volume = type(data) == "table" and tonumber(data.volume) or nil
if not volume then
cb({ success = false, error = "invalid_volume" })
return
@@ -179,6 +196,10 @@ RegisterNUICallback("radio:set-volume", function(data, cb)
end)
RegisterNUICallback("radio:save-settings", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
local result = request("save-settings", data)
if result.success then
apply_server_state({ settings = result.data })
@@ -187,14 +208,25 @@ RegisterNUICallback("radio:save-settings", function(data, cb)
end)
RegisterNUICallback("radio:save-badge", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
cb(request("save-badge", data))
end)
RegisterNUICallback("radio:save-display-name", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
cb(request("save-display-name", data))
end)
RegisterNetEvent("sky_phone:radio:members", function(data)
if type(data) ~= "table" then
return
end
local frequency = tonumber(data.frequency)
local channel_id = frequency == current_primary and 1 or frequency == current_secondary and 2 or nil
if not channel_id then
+5 -1
View File
@@ -88,7 +88,11 @@ end
for index = 1, #server_callbacks do
local callback_name = server_callbacks[index]
RegisterNUICallback(callback_name, function(data, cb)
local result = Bridge.Callbacks.Trigger("sky_phone:" .. callback_name, data or {})
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
local result = Bridge.Callbacks.Trigger("sky_phone:" .. callback_name, data)
if not result then
cb({ success = false, error = "request_failed" })
return
+34 -17
View File
@@ -1024,31 +1024,45 @@ end)
local payphone_models = {}
for _, model_name in ipairs(Config.Payphones.Props or {}) do
payphone_models[model_name] = true
if type(model_name) == "string" then
payphone_models[model_name] = true
end
end
local function valid_payphone_position(source, data)
if type(data) ~= "table" or not payphone_models[data.model] or type(data.coords) ~= "table" then
return nil
end
local x = tonumber(data.coords.x)
local y = tonumber(data.coords.y)
local z = tonumber(data.coords.z)
if not x or not y or not z or x ~= x or y ~= y or z ~= z
or math.abs(x) > 10000.0 or math.abs(y) > 10000.0 or math.abs(z) > 2000.0
then
return nil
end
local payphone_locations, rejected_payphone_locations = SkyPhonePayphones.ValidateLocations(
Config.Payphones.Locations,
payphone_models
)
if Config.Payphones.Enabled and #payphone_locations == 0 then
Bridge.Debug(
"error",
"[sky_phone] Payphones are enabled, but no valid server-owned locations are configured; payphone calls will be rejected.",
{ always = true }
)
elseif Config.Payphones.Enabled and rejected_payphone_locations > 0 then
Bridge.Debug(
"warn",
"[sky_phone] Ignored %s invalid server-owned payphone location(s).",
rejected_payphone_locations,
{ always = true }
)
end
local function valid_payphone_position(source)
local ped = GetPlayerPed(source)
if not ped or ped == 0 then
return nil
end
local player_coords = GetEntityCoords(ped)
local booth_coords = vector3(x, y, z)
if #(player_coords - booth_coords) > Config.Payphones.ServerValidationDistance then
local location = SkyPhonePayphones.FindNearest(
payphone_locations,
player_coords,
Config.Payphones.ServerValidationDistance
)
if not location then
return nil
end
return booth_coords, data.model
return vector3(location.coords.x, location.coords.y, location.coords.z), location.model
end
local function payphone_terminal(number, state)
@@ -1064,10 +1078,13 @@ local function payphone_terminal(number, state)
end
Bridge.Callbacks.Register("sky_phone:payphone:dial", function(source, data)
if type(data) ~= "table" then
return { success = false, error = "invalid_request" }
end
if not Config.Payphones.Enabled or not SkyPhone.AllowOperation(source, "payphone_dial", 15, 60) then
return { success = false, error = "rate_limited" }
end
local booth_coords, booth_model = valid_payphone_position(source, data)
local booth_coords, booth_model = valid_payphone_position(source)
if not booth_coords then
return { success = false, error = "invalid_payphone" }
end
+105
View File
@@ -0,0 +1,105 @@
SkyPhonePayphones = {}
local maximum_horizontal_coordinate = 10000.0
local maximum_vertical_coordinate = 2000.0
local function finite_number(value)
if type(value) ~= "number" or value ~= value or value == math.huge or value == -math.huge then
return nil
end
return value
end
local function normalize_coordinates(value, allow_vector)
local value_type = type(value)
if value_type ~= "table" and (not allow_vector or value_type ~= "vector3") then
return nil
end
local x = finite_number(value.x)
local y = finite_number(value.y)
local z = finite_number(value.z)
if not x or not y or not z
or math.abs(x) > maximum_horizontal_coordinate
or math.abs(y) > maximum_horizontal_coordinate
or math.abs(z) > maximum_vertical_coordinate
then
return nil
end
return { x = x, y = y, z = z }
end
local function normalize_location(location, allowed_models)
if type(location) ~= "table" or type(location.model) ~= "string" or not allowed_models[location.model] then
return nil
end
local coords = normalize_coordinates(location.coords, false)
if not coords then
return nil
end
return {
model = location.model,
coords = coords,
}
end
function SkyPhonePayphones.ValidateLocations(locations, allowed_models)
if type(locations) ~= "table" or type(allowed_models) ~= "table" then
return {}, 0
end
local validated = {}
local rejected = 0
for index, location in pairs(locations) do
local valid_index = type(index) == "number" and index >= 1 and index % 1 == 0
local normalized = valid_index and normalize_location(location, allowed_models) or nil
if normalized then
normalized.index = index
validated[#validated + 1] = normalized
else
rejected = rejected + 1
end
end
table.sort(validated, function(left, right)
return left.index < right.index
end)
for index = 1, #validated do
validated[index].index = nil
end
return validated, rejected
end
function SkyPhonePayphones.FindNearest(locations, player_coords, maximum_distance)
if type(locations) ~= "table" then
return nil
end
local coords = normalize_coordinates(player_coords, true)
local distance = finite_number(maximum_distance)
if not coords or not distance or distance <= 0 then
return nil
end
local nearest = nil
local nearest_distance_squared = distance * distance
for index = 1, #locations do
local location = locations[index]
if type(location) == "table" and type(location.coords) == "table" then
local dx = coords.x - location.coords.x
local dy = coords.y - location.coords.y
local dz = coords.z - location.coords.z
local distance_squared = dx * dx + dy * dy + dz * dz
if distance_squared <= nearest_distance_squared then
nearest = location
nearest_distance_squared = distance_squared
end
end
end
return nearest
end
+19 -8
View File
@@ -914,15 +914,20 @@ function SkyPhone.OpenDeviceForCall(source, imei)
return false
end
local security = load_device_security(imei)
if sessions[source] and sessions[source].imei ~= imei then
local existing_session = sessions[source]
if existing_session and existing_session.imei ~= imei then
SkyPhoneCompanies.ClearCallAvailability(source)
end
sessions[source] = {
imei = imei,
slot = matches[1].slot,
token = ("%s:%s:%s"):format(imei, tostring(source), tostring(GetGameTimer())),
unlocked = security == nil,
}
if existing_session and existing_session.imei == imei then
existing_session.slot = matches[1].slot
else
sessions[source] = {
imei = imei,
slot = matches[1].slot,
token = ("%s:%s:%s"):format(imei, tostring(source), tostring(GetGameTimer())),
unlocked = security == nil,
}
end
TriggerClientEvent("sky_phone:device:open", source, bootstrap(source))
return true
end
@@ -1075,7 +1080,13 @@ Bridge.Callbacks.Register("sky_phone:device:save", function(source, data)
if not session then
return error_response
end
if type(data) ~= "table" or not allowed_device_namespaces[data.namespace] then
if type(data) ~= "table" then
return { success = false, error = "invalid_request" }
end
if data.imei ~= session.imei or data.sessionToken ~= session.token then
return { success = false, error = "stale_session" }
end
if not allowed_device_namespaces[data.namespace] then
return { success = false, error = "invalid_namespace" }
end
+8
View File
@@ -406,6 +406,14 @@ Bridge.Callbacks.Register("sky_phone:sim:insert", function(source, data)
return insert_sim(source, data.imei, data.confirmed == true)
end)
Bridge.Callbacks.Register("sky_phone:sim:picker-close", function(source)
if operation_locks[source] then
return { success = false, error = "operation_in_progress" }
end
pending_insertions[source] = nil
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:sim:eject", function(source)
if not sim_cards_enabled then
return { success = false, error = "disabled" }
+67
View File
@@ -0,0 +1,67 @@
dofile("sky_phone/source/client/focus.lua")
local function resolve(overrides)
local state = {
activity_suspended = false,
call_focus = false,
camera_active = false,
camera_nui_focused = true,
is_open = false,
notification_focus = false,
payphone_focus = false,
sim_picker_open = false,
}
for key, value in pairs(overrides or {}) do
state[key] = value
end
return SkyPhoneFocus.Resolve(state)
end
local idle = resolve()
assert(not idle.focused and not idle.keep_input, "idle NUI must release focus and game input override")
local minimized_call = resolve()
assert(not minimized_call.focused, "a replayed call without an attention claim must stay unfocused")
local incoming_call = resolve({ call_focus = true })
assert(incoming_call.focused and not incoming_call.keep_input, "incoming call attention must focus the NUI")
local camera_game_input = resolve({
camera_active = true,
camera_nui_focused = false,
is_open = true,
})
assert(
not camera_game_input.focused and camera_game_input.keep_input,
"unfocused camera must own game input over the open phone"
)
local camera_interrupted_by_call = resolve({
call_focus = true,
camera_active = true,
camera_nui_focused = false,
is_open = true,
})
assert(
camera_interrupted_by_call.focused and not camera_interrupted_by_call.keep_input,
"incoming call attention must override unfocused camera input"
)
local camera_after_connected_call = resolve({
call_focus = false,
camera_active = true,
camera_nui_focused = false,
is_open = true,
})
assert(
not camera_after_connected_call.focused and camera_after_connected_call.keep_input,
"connected call without an attention claim must restore unfocused camera input"
)
local payphone_closed_behind_phone = resolve({ is_open = true, payphone_focus = false })
assert(payphone_closed_behind_phone.focused, "releasing payphone focus must not clear mobile phone focus")
local suspended = resolve({ activity_suspended = true, call_focus = true, is_open = true })
assert(not suspended.focused and not suspended.keep_input, "suspended activities must mask every focus claim")
print("Client focus tests passed")
+39
View File
@@ -0,0 +1,39 @@
dofile("sky_phone/source/server/payphones.lua")
local allowed_models = {
prop_phonebox_01a = true,
prop_phonebox_04 = true,
}
local locations, rejected = SkyPhonePayphones.ValidateLocations({
{ model = "prop_phonebox_01a", coords = { x = 100.0, y = 200.0, z = 30.0 } },
{ model = "prop_phonebox_04", coords = { x = 105.0, y = 200.0, z = 30.0 } },
{ model = "not_allowed", coords = { x = 100.0, y = 200.0, z = 30.0 } },
{ model = "prop_phonebox_01a", coords = { x = "100", y = 200.0, z = 30.0 } },
{ model = "prop_phonebox_01a", coords = { x = 10001.0, y = 200.0, z = 30.0 } },
"malformed",
}, allowed_models)
assert(#locations == 2, "only strictly valid configured locations must be accepted")
assert(rejected == 4, "every malformed or disallowed configured location must be reported")
local first = SkyPhonePayphones.FindNearest(locations, { x = 101.0, y = 200.0, z = 30.0 }, 3.0)
assert(first and first.model == "prop_phonebox_01a", "nearest configured booth must be selected")
local second = SkyPhonePayphones.FindNearest(locations, { x = 104.0, y = 200.0, z = 30.0 }, 3.0)
assert(second and second.model == "prop_phonebox_04", "another configured booth must be selected by proximity")
assert(
not SkyPhonePayphones.FindNearest(locations, { x = 0.0, y = 0.0, z = 0.0 }, 3.0),
"a player away from every configured booth must be rejected"
)
assert(
not SkyPhonePayphones.FindNearest(locations, { x = "100", y = 200.0, z = 30.0 }, 3.0),
"malformed player coordinates must be rejected"
)
assert(
not SkyPhonePayphones.FindNearest(locations, { x = 100.0, y = 200.0, z = 30.0 }, 0.0),
"an invalid validation distance must be rejected"
)
print("Server payphone validation tests passed")