diff --git a/frontend/src/App.vue b/frontend/src/App.vue index acf5d8b..bbf35c1 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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(() => ({ '--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(() => ({ ...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): 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): void { } } +async function closeSimPicker(): Promise { + 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 { + 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" />
{ :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, }" diff --git a/frontend/src/components/PayphoneOverlay.vue b/frontend/src/components/PayphoneOverlay.vue index cf547c1..eb1fbd3 100644 --- a/frontend/src/components/PayphoneOverlay.vue +++ b/frontend/src/components/PayphoneOverlay.vue @@ -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; } diff --git a/frontend/src/components/SimPhonePicker.vue b/frontend/src/components/SimPhonePicker.vue index f5e0a4e..6db474d 100644 --- a/frontend/src/components/SimPhonePicker.vue +++ b/frontend/src/components/SimPhonePicker.vue @@ -42,7 +42,6 @@ async function insert( } function close(): void { - void nuiCall('sim:picker-close') emit('close') } diff --git a/frontend/src/stores/banking.test.ts b/frontend/src/stores/banking.test.ts index 338c39b..52e1062 100644 --- a/frontend/src/stores/banking.test.ts +++ b/frontend/src/stores/banking.test.ts @@ -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) => void + const olderResponse = new Promise>( + (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) + }) }) diff --git a/frontend/src/stores/banking.ts b/frontend/src/stores/banking.ts index dab3572..4c8ba80 100644 --- a/frontend/src/stores/banking.ts +++ b/frontend/src/stores/banking.ts @@ -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 { + const generation = ++this.requestGeneration + this.pendingRequests += 1 this.isLoading = true - const response = await nuiCall('banking:overview') - this.isLoading = false + const response = await nuiCall('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> { + const generation = ++this.requestGeneration + this.pendingRequests += 1 this.isLoading = true const response = await nuiCall(`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 = '' diff --git a/frontend/src/stores/mail.test.ts b/frontend/src/stores/mail.test.ts index bcf80cc..17e7a3a 100644 --- a/frontend/src/stores/mail.test.ts +++ b/frontend/src/stores/mail.test.ts @@ -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) => void + const olderResponse = new Promise>( + (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) => void + mockNuiCall.mockReturnValueOnce( + new Promise>((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>((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>((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') + }) }) diff --git a/frontend/src/stores/mail.ts b/frontend/src/stores/mail.ts index 238bea6..01a4c5b 100644 --- a/frontend/src/stores/mail.ts +++ b/frontend/src/stores/mail.ts @@ -31,14 +31,21 @@ export const useMailStore = defineStore('mail', () => { const items = ref([]) 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 { @@ -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('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('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 { + 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 { + const generation = ++folderRequestGeneration + const session = sessionGeneration loading.value = true const offset = append ? items.value.length : 0 const response = await nuiCall('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 { + const email = accountEmail.value + const session = sessionGeneration const response = await nuiCall('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 { diff --git a/frontend/src/stores/notifications.test.ts b/frontend/src/stores/notifications.test.ts index e38b885..59eff5f 100644 --- a/frontend/src/stores/notifications.test.ts +++ b/frontend/src/stores/notifications.test.ts @@ -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') + }) }) diff --git a/frontend/src/stores/notifications.ts b/frontend/src/stores/notifications.ts index 1487a2c..2777000 100644 --- a/frontend/src/stores/notifications.ts +++ b/frontend/src/stores/notifications.ts @@ -43,6 +43,8 @@ type PersistedNotificationsV1 = { version: 1 } +export const MAX_LOCK_SCREEN_NOTIFICATIONS = 50 + const timeoutHandles = new Map>() const stopToneHandles = new Map void>() const persistenceQueues = new Map>() @@ -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) } diff --git a/frontend/src/stores/phone-persistence.test.ts b/frontend/src/stores/phone-persistence.test.ts new file mode 100644 index 0000000..1c4027a --- /dev/null +++ b/frontend/src/stores/phone-persistence.test.ts @@ -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(): { + promise: Promise> + resolve: (response: NuiResponse) => void +} { + let resolve!: (response: NuiResponse) => void + const promise = new Promise>((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) + }) +}) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 8fdfddb..dd8263a 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -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>() +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, + 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 { + 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 { diff --git a/frontend/src/utils/nui.test.ts b/frontend/src/utils/nui.test.ts new file mode 100644 index 0000000..39dc5e2 --- /dev/null +++ b/frontend/src/utils/nui.test.ts @@ -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((_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) + }) +}) diff --git a/frontend/src/utils/nui.ts b/frontend/src/utils/nui.ts index 22ecce7..eecee7e 100644 --- a/frontend/src/utils/nui.ts +++ b/frontend/src/utils/nui.ts @@ -1,4 +1,5 @@ const resourceName = globalThis.window?.GetParentResourceName?.() ?? 'sky_phone' +const requestTimeoutMs = 20_000 export type NuiResponse = { success: boolean @@ -24,12 +25,15 @@ export async function nuiCall( 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( const body = await response.text() return body ? (JSON.parse(body) as NuiResponse) : { 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) } } diff --git a/sky_phone/config/payphones.lua b/sky_phone/config/payphones.lua new file mode 100644 index 0000000..e2a3ecd --- /dev/null +++ b/sky_phone/config/payphones.lua @@ -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 } }, +} diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua index ab0e6f8..e5f67e7 100644 --- a/sky_phone/fxmanifest.lua +++ b/sky_phone/fxmanifest.lua @@ -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', diff --git a/sky_phone/source/client/camera.lua b/sky_phone/source/client/camera.lua index 1cfa566..581d9b0 100644 --- a/sky_phone/source/client/camera.lua +++ b/sky_phone/source/client/camera.lua @@ -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) diff --git a/sky_phone/source/client/crewlink.lua b/sky_phone/source/client/crewlink.lua index 5ecf4eb..4767466 100644 --- a/sky_phone/source/client/crewlink.lua +++ b/sky_phone/source/client/crewlink.lua @@ -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 diff --git a/sky_phone/source/client/focus.lua b/sky_phone/source/client/focus.lua new file mode 100644 index 0000000..8252694 --- /dev/null +++ b/sky_phone/source/client/focus.lua @@ -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 diff --git a/sky_phone/source/client/garage.lua b/sky_phone/source/client/garage.lua index 6a40adf..cee1d5d 100644 --- a/sky_phone/source/client/garage.lua +++ b/sky_phone/source/client/garage.lua @@ -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 diff --git a/sky_phone/source/client/housing.lua b/sky_phone/source/client/housing.lua index d54d217..93f3b5b 100644 --- a/sky_phone/source/client/housing.lua +++ b/sky_phone/source/client/housing.lua @@ -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) diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index 8c97ab3..2e88d41 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -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() diff --git a/sky_phone/source/client/payphones.lua b/sky_phone/source/client/payphones.lua index d3fb3b4..d59b569 100644 --- a/sky_phone/source/client/payphones.lua +++ b/sky_phone/source/client/payphones.lua @@ -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() diff --git a/sky_phone/source/client/radio.lua b/sky_phone/source/client/radio.lua index 0132f7d..2b087cf 100644 --- a/sky_phone/source/client/radio.lua +++ b/sky_phone/source/client/radio.lua @@ -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 diff --git a/sky_phone/source/client/skyride.lua b/sky_phone/source/client/skyride.lua index abf5862..42240c4 100644 --- a/sky_phone/source/client/skyride.lua +++ b/sky_phone/source/client/skyride.lua @@ -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 diff --git a/sky_phone/source/server/calls.lua b/sky_phone/source/server/calls.lua index 537306c..b81e731 100644 --- a/sky_phone/source/server/calls.lua +++ b/sky_phone/source/server/calls.lua @@ -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 diff --git a/sky_phone/source/server/payphones.lua b/sky_phone/source/server/payphones.lua new file mode 100644 index 0000000..3a1fa47 --- /dev/null +++ b/sky_phone/source/server/payphones.lua @@ -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 diff --git a/sky_phone/source/server/phone.lua b/sky_phone/source/server/phone.lua index 91d0a0f..97c003d 100644 --- a/sky_phone/source/server/phone.lua +++ b/sky_phone/source/server/phone.lua @@ -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 diff --git a/sky_phone/source/server/sim.lua b/sky_phone/source/server/sim.lua index 34bde46..fe6f3c4 100644 --- a/sky_phone/source/server/sim.lua +++ b/sky_phone/source/server/sim.lua @@ -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" } diff --git a/tests/client_focus.lua b/tests/client_focus.lua new file mode 100644 index 0000000..f70ae6c --- /dev/null +++ b/tests/client_focus.lua @@ -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") diff --git a/tests/server_payphones.lua b/tests/server_payphones.lua new file mode 100644 index 0000000..1798a08 --- /dev/null +++ b/tests/server_payphones.lua @@ -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")