mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:08:59 +00:00
FIX - harden phone lifecycle and server authority
This commit is contained in:
+98
-15
@@ -237,6 +237,9 @@ const REFERENCE_VIEWPORT_WIDTH = 1920
|
||||
const REFERENCE_VIEWPORT_HEIGHT = 1080
|
||||
const PHONE_BASE_SCALE = 0.69
|
||||
const DEVELOPMENT_PHONE_SCALE = 1.25
|
||||
const PHONE_PORTRAIT_WIDTH = 390
|
||||
const PHONE_PORTRAIT_HEIGHT = 844
|
||||
const MIN_PRODUCTION_PHONE_ZOOM = 260 / PHONE_PORTRAIT_WIDTH
|
||||
const isDevelopment = import.meta.env.DEV
|
||||
|
||||
const phone = usePhoneStore()
|
||||
@@ -291,11 +294,34 @@ const phoneBaseZoom = computed(
|
||||
viewportScale.value *
|
||||
(isDevelopment ? DEVELOPMENT_PHONE_SCALE : PHONE_BASE_SCALE),
|
||||
)
|
||||
const phoneZoom = computed(() => {
|
||||
const preferred =
|
||||
phoneBaseZoom.value * (phone.preferences.settings.phoneScale / 100)
|
||||
if (isDevelopment) return preferred
|
||||
|
||||
const edgeGap = 24 * viewportScale.value
|
||||
const shellWidth = phone.cameraLandscape
|
||||
? PHONE_PORTRAIT_HEIGHT
|
||||
: PHONE_PORTRAIT_WIDTH
|
||||
const shellHeight = phone.cameraLandscape
|
||||
? PHONE_PORTRAIT_WIDTH
|
||||
: PHONE_PORTRAIT_HEIGHT
|
||||
const viewportMaximum = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
(window.innerWidth - edgeGap) / shellWidth,
|
||||
(window.innerHeight - edgeGap) / shellHeight,
|
||||
),
|
||||
)
|
||||
return Math.min(
|
||||
viewportMaximum,
|
||||
Math.max(MIN_PRODUCTION_PHONE_ZOOM, preferred),
|
||||
)
|
||||
})
|
||||
const phoneResolutionStyle = computed<CSSProperties>(() => ({
|
||||
'--phone-edge-gap': `${24 * viewportScale.value}px`,
|
||||
'--phone-stack-gap': `${16 * viewportScale.value}px`,
|
||||
'--phone-zoom':
|
||||
phoneBaseZoom.value * (phone.preferences.settings.phoneScale / 100),
|
||||
'--phone-zoom': phoneZoom.value,
|
||||
}))
|
||||
const phoneStageStyle = computed<CSSProperties>(() => ({
|
||||
...phoneResolutionStyle.value,
|
||||
@@ -315,6 +341,8 @@ let pendingCompaniesChange: CompanyChangedPayload | null = null
|
||||
let unlockTimer: number | undefined
|
||||
let passcodeLockTimer: number | undefined
|
||||
let unlockedServicesFrame: number | undefined
|
||||
let phoneClosePending = false
|
||||
let simPickerClosePending = false
|
||||
|
||||
function getViewportScale(): number {
|
||||
const heightScale = window.innerHeight / REFERENCE_VIEWPORT_HEIGHT
|
||||
@@ -505,7 +533,7 @@ function onMessage(event: MessageEvent<AppMessage>): void {
|
||||
hydratePhone(event.data.data as PhoneOpenPayload)
|
||||
} else if (event.data?.type === 'app:close') {
|
||||
activitySuspended.value = false
|
||||
phone.close()
|
||||
phone.endDeviceSession()
|
||||
} else if (event.data?.type === 'app:suspend') {
|
||||
activitySuspended.value = true
|
||||
} else if (event.data?.type === 'app:resume') {
|
||||
@@ -899,14 +927,69 @@ function onMessage(event: MessageEvent<AppMessage>): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function closeSimPicker(): Promise<void> {
|
||||
if (simPickerClosePending || !simPicker.value) return
|
||||
simPickerClosePending = true
|
||||
const closingPicker = simPicker.value
|
||||
try {
|
||||
const response = await nuiCall('sim:picker-close')
|
||||
if (response.success && simPicker.value === closingPicker) {
|
||||
simPicker.value = null
|
||||
}
|
||||
} finally {
|
||||
simPickerClosePending = false
|
||||
}
|
||||
}
|
||||
|
||||
async function closePhone(): Promise<void> {
|
||||
if (phoneClosePending || !phone.isOpen) return
|
||||
phoneClosePending = true
|
||||
const closingGeneration = phone.persistenceGeneration
|
||||
const closingImei = phone.device?.imei ?? null
|
||||
const closingToken = phone.deviceSessionToken
|
||||
try {
|
||||
await phone.flushDevicePersistence()
|
||||
if (
|
||||
!phone.isOpen ||
|
||||
phone.persistenceGeneration !== closingGeneration ||
|
||||
(phone.device?.imei ?? null) !== closingImei ||
|
||||
phone.deviceSessionToken !== closingToken
|
||||
) {
|
||||
return
|
||||
}
|
||||
const response = await nuiCall('close')
|
||||
if (
|
||||
!response.success ||
|
||||
!phone.isOpen ||
|
||||
phone.persistenceGeneration !== closingGeneration ||
|
||||
(phone.device?.imei ?? null) !== closingImei ||
|
||||
phone.deviceSessionToken !== closingToken
|
||||
) {
|
||||
return
|
||||
}
|
||||
phone.endDeviceSession()
|
||||
} finally {
|
||||
phoneClosePending = false
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (event.key !== 'Escape' || !phone.isOpen || activitySuspended.value) return
|
||||
if (controlCenterOpened.value) {
|
||||
controlCenterOpened.value = false
|
||||
if (event.key !== 'Escape') return
|
||||
if (simPicker.value) {
|
||||
event.preventDefault()
|
||||
void closeSimPicker()
|
||||
return
|
||||
}
|
||||
phone.close()
|
||||
void nuiCall('close')
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (event.defaultPrevented || !phone.isOpen || activitySuspended.value)
|
||||
return
|
||||
if (controlCenterOpened.value) {
|
||||
controlCenterOpened.value = false
|
||||
return
|
||||
}
|
||||
void closePhone()
|
||||
})
|
||||
}
|
||||
|
||||
function onSystemColorSchemeChange(event: MediaQueryListEvent): void {
|
||||
@@ -1051,7 +1134,7 @@ onMounted(() => {
|
||||
window.addEventListener('resize', updateViewportScale)
|
||||
systemColorScheme.addEventListener('change', onSystemColorSchemeChange)
|
||||
phone.setSystemDarkMode(systemColorScheme.matches)
|
||||
void nuiCall('ui:ready')
|
||||
void nuiCall('ui:ready', { protocolVersion: 1 })
|
||||
clockTicker = setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const alarm of clock.dueAlarms(now)) {
|
||||
@@ -1116,11 +1199,9 @@ watch(
|
||||
)
|
||||
|
||||
watch(
|
||||
[() => notifications.requiresAttention, () => calls.activeCall],
|
||||
([requiresAttention, activeCall]) => {
|
||||
void nuiCall('notification:focus', {
|
||||
active: requiresAttention || activeCall !== null,
|
||||
})
|
||||
() => notifications.requiresAttention,
|
||||
(requiresAttention) => {
|
||||
void nuiCall('notification:focus', { active: requiresAttention })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1204,7 +1285,7 @@ onBeforeUnmount(() => {
|
||||
v-if="simPicker"
|
||||
:choices="simPicker.choices"
|
||||
:number="simPicker.number"
|
||||
@close="simPicker = null"
|
||||
@close="closeSimPicker"
|
||||
/>
|
||||
<Transition name="phone-lift" appear>
|
||||
<main
|
||||
@@ -1263,7 +1344,9 @@ onBeforeUnmount(() => {
|
||||
:style="phoneDisplayStyle"
|
||||
:class="{
|
||||
dark: phone.isDarkMode,
|
||||
'phone-app--darkchat': route.params.appId === 'darkchat',
|
||||
'phone-app--light': !phone.isDarkMode,
|
||||
'phone-app--messages': route.params.appId === 'messages',
|
||||
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
|
||||
'phone-app--unlocking': isUnlocking,
|
||||
}"
|
||||
|
||||
@@ -239,6 +239,7 @@ function onKeydown(event: KeyboardEvent): void {
|
||||
if (!visible.value) return
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
void close()
|
||||
return
|
||||
}
|
||||
@@ -256,7 +257,7 @@ function onKeydown(event: KeyboardEvent): void {
|
||||
onMounted(() => {
|
||||
prepareButtonSounds()
|
||||
window.addEventListener('message', onMessage)
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
window.addEventListener('keydown', onKeydown, true)
|
||||
ticker = window.setInterval(() => {
|
||||
now.value = Date.now()
|
||||
}, 250)
|
||||
@@ -264,7 +265,7 @@ onMounted(() => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('message', onMessage)
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
window.removeEventListener('keydown', onKeydown, true)
|
||||
if (ticker !== undefined) window.clearInterval(ticker)
|
||||
for (const sound of buttonSounds) {
|
||||
sound.pause()
|
||||
@@ -399,6 +400,7 @@ onBeforeUnmount(() => {
|
||||
rgb(0 0 0 / 84%) 72%
|
||||
);
|
||||
font-family: 'Segoe UI', Arial, sans-serif;
|
||||
pointer-events: auto;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,6 @@ async function insert(
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
void nuiCall('sim:picker-close')
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useBankingStore } from '@/stores/banking'
|
||||
import type { BankingOverview } from '@/types/banking'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||
|
||||
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
|
||||
|
||||
@@ -60,4 +60,26 @@ describe('banking store', () => {
|
||||
expect(banking.overview).toEqual(overview)
|
||||
expect(banking.error).toBe('insufficient_funds')
|
||||
})
|
||||
|
||||
it('does not let an older response overwrite the newest overview', async () => {
|
||||
let resolveOlder!: (response: NuiResponse<BankingOverview>) => void
|
||||
const olderResponse = new Promise<NuiResponse<BankingOverview>>(
|
||||
(resolve) => {
|
||||
resolveOlder = resolve
|
||||
},
|
||||
)
|
||||
const newest = { ...overview, bank: 23000 }
|
||||
mockNuiCall
|
||||
.mockReturnValueOnce(olderResponse)
|
||||
.mockResolvedValueOnce({ data: newest, success: true })
|
||||
const banking = useBankingStore()
|
||||
|
||||
const olderRequest = banking.load()
|
||||
await banking.load()
|
||||
resolveOlder({ data: { ...overview, bank: 1 }, success: true })
|
||||
await olderRequest
|
||||
|
||||
expect(banking.overview).toEqual(newest)
|
||||
expect(banking.isLoading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,12 +8,21 @@ export const useBankingStore = defineStore('banking', {
|
||||
error: '',
|
||||
isLoading: false,
|
||||
overview: null as BankingOverview | null,
|
||||
pendingRequests: 0,
|
||||
requestGeneration: 0,
|
||||
}),
|
||||
actions: {
|
||||
async load(): Promise<boolean> {
|
||||
const generation = ++this.requestGeneration
|
||||
this.pendingRequests += 1
|
||||
this.isLoading = true
|
||||
const response = await nuiCall<BankingOverview>('banking:overview')
|
||||
this.isLoading = false
|
||||
const response = await nuiCall<BankingOverview>('banking:overview').finally(
|
||||
() => {
|
||||
this.pendingRequests = Math.max(0, this.pendingRequests - 1)
|
||||
this.isLoading = this.pendingRequests > 0
|
||||
},
|
||||
)
|
||||
if (generation !== this.requestGeneration) return response.success
|
||||
if (response.success && response.data) {
|
||||
this.overview = response.data
|
||||
this.error = ''
|
||||
@@ -27,12 +36,17 @@ export const useBankingStore = defineStore('banking', {
|
||||
amount: number,
|
||||
phoneNumber?: string,
|
||||
): Promise<NuiResponse<BankingOverview>> {
|
||||
const generation = ++this.requestGeneration
|
||||
this.pendingRequests += 1
|
||||
this.isLoading = true
|
||||
const response = await nuiCall<BankingOverview>(`banking:${action}`, {
|
||||
amount,
|
||||
...(phoneNumber === undefined ? {} : { phoneNumber }),
|
||||
}).finally(() => {
|
||||
this.pendingRequests = Math.max(0, this.pendingRequests - 1)
|
||||
this.isLoading = this.pendingRequests > 0
|
||||
})
|
||||
this.isLoading = false
|
||||
if (generation !== this.requestGeneration) return response
|
||||
if (response.success && response.data) {
|
||||
this.overview = response.data
|
||||
this.error = ''
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useAccountStore } from '@/stores/account'
|
||||
import { useMailStore } from '@/stores/mail'
|
||||
import type { MailCounts, MailListItem } from '@/types/mail'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import type { MailCounts, MailListItem, MailListResponse } from '@/types/mail'
|
||||
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||
|
||||
vi.mock('@/utils/nui', () => ({
|
||||
nuiCall: vi.fn(),
|
||||
@@ -45,7 +46,7 @@ describe('mail store', () => {
|
||||
success: true,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: { hasMore: false, items: [listItem(2)] },
|
||||
data: { hasMore: false, items: [listItem(2)], offset: 0 },
|
||||
success: true,
|
||||
})
|
||||
|
||||
@@ -129,4 +130,103 @@ describe('mail store', () => {
|
||||
expect(mail.folder).toBe('inbox')
|
||||
expect(mail.search).toBe('')
|
||||
})
|
||||
|
||||
it('ignores an older folder response after a newer navigation', async () => {
|
||||
let resolveOlder!: (response: NuiResponse<MailListResponse>) => void
|
||||
const olderResponse = new Promise<NuiResponse<MailListResponse>>(
|
||||
(resolve) => {
|
||||
resolveOlder = resolve
|
||||
},
|
||||
)
|
||||
mockNuiCall
|
||||
.mockReturnValueOnce(olderResponse)
|
||||
.mockResolvedValueOnce({
|
||||
data: { hasMore: false, items: [listItem(2)] },
|
||||
success: true,
|
||||
})
|
||||
const mail = useMailStore()
|
||||
|
||||
const olderRequest = mail.loadFolder('inbox')
|
||||
await mail.loadFolder('sent')
|
||||
resolveOlder({
|
||||
data: { hasMore: false, items: [listItem(1)], offset: 0 },
|
||||
success: true,
|
||||
})
|
||||
await olderRequest
|
||||
|
||||
expect(mail.folder).toBe('sent')
|
||||
expect(mail.items.map((item) => item.id)).toEqual([2])
|
||||
expect(mail.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores mailbox counts returned after the session was cleared', async () => {
|
||||
let resolveCounts!: (response: NuiResponse<MailCounts>) => void
|
||||
mockNuiCall.mockReturnValueOnce(
|
||||
new Promise<NuiResponse<MailCounts>>((resolve) => {
|
||||
resolveCounts = resolve
|
||||
}),
|
||||
)
|
||||
const mail = useMailStore()
|
||||
|
||||
const bootstrap = mail.bootstrap('alex@ifruit.com')
|
||||
await mail.bootstrap('')
|
||||
resolveCounts({ data: counts, success: true })
|
||||
await bootstrap
|
||||
|
||||
expect(mail.accountEmail).toBe('')
|
||||
expect(mail.counts).toEqual({
|
||||
drafts: 0,
|
||||
inbox: 0,
|
||||
sent: 0,
|
||||
trash: 0,
|
||||
unread: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores a late login after the mailbox session was cleared', async () => {
|
||||
let resolveLogin!: (response: NuiResponse<{ devices: []; email: string }>) => void
|
||||
mockNuiCall.mockReturnValueOnce(
|
||||
new Promise<NuiResponse<{ devices: []; email: string }>>((resolve) => {
|
||||
resolveLogin = resolve
|
||||
}),
|
||||
)
|
||||
const mail = useMailStore()
|
||||
const account = useAccountStore()
|
||||
|
||||
const login = mail.login('alex', 'secret')
|
||||
await mail.bootstrap('')
|
||||
resolveLogin({
|
||||
data: { devices: [], email: 'alex@ifruit.com' },
|
||||
success: true,
|
||||
})
|
||||
await login
|
||||
|
||||
expect(mail.accountEmail).toBe('')
|
||||
expect(account.email).toBe('')
|
||||
})
|
||||
|
||||
it('ignores a late login after an external mailbox session change', async () => {
|
||||
let resolveLogin!: (response: NuiResponse<{ devices: []; email: string }>) => void
|
||||
mockNuiCall
|
||||
.mockReturnValueOnce(
|
||||
new Promise<NuiResponse<{ devices: []; email: string }>>((resolve) => {
|
||||
resolveLogin = resolve
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({ data: counts, success: true })
|
||||
const mail = useMailStore()
|
||||
const account = useAccountStore()
|
||||
|
||||
const login = mail.login('alex', 'secret')
|
||||
account.hydrate({ devices: [], email: 'morgan@ifruit.com' })
|
||||
await mail.bootstrap('morgan@ifruit.com')
|
||||
resolveLogin({
|
||||
data: { devices: [], email: 'alex@ifruit.com' },
|
||||
success: true,
|
||||
})
|
||||
await login
|
||||
|
||||
expect(mail.accountEmail).toBe('morgan@ifruit.com')
|
||||
expect(account.email).toBe('morgan@ifruit.com')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,14 +31,21 @@ export const useMailStore = defineStore('mail', () => {
|
||||
const items = ref<MailListItem[]>([])
|
||||
const loading = ref(false)
|
||||
const search = ref('')
|
||||
let authenticationGeneration = 0
|
||||
let folderRequestGeneration = 0
|
||||
let sessionGeneration = 0
|
||||
|
||||
function clearSession(): void {
|
||||
authenticationGeneration += 1
|
||||
sessionGeneration += 1
|
||||
folderRequestGeneration += 1
|
||||
accountEmail.value = ''
|
||||
counts.value = emptyCounts()
|
||||
items.value = []
|
||||
hasMore.value = false
|
||||
folder.value = 'inbox'
|
||||
search.value = ''
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function bootstrap(email: string): Promise<void> {
|
||||
@@ -46,16 +53,24 @@ export const useMailStore = defineStore('mail', () => {
|
||||
clearSession()
|
||||
return
|
||||
}
|
||||
authenticationGeneration += 1
|
||||
sessionGeneration += 1
|
||||
folderRequestGeneration += 1
|
||||
accountEmail.value = email
|
||||
await refreshCounts()
|
||||
}
|
||||
|
||||
async function login(email: string, password: string) {
|
||||
const generation = ++authenticationGeneration
|
||||
const response = await nuiCall<IfruitAccount>('mail:login', {
|
||||
email,
|
||||
password,
|
||||
})
|
||||
if (response.success && response.data) {
|
||||
if (
|
||||
generation === authenticationGeneration &&
|
||||
response.success &&
|
||||
response.data
|
||||
) {
|
||||
account.hydrate(response.data)
|
||||
await bootstrap(response.data.email)
|
||||
}
|
||||
@@ -63,11 +78,16 @@ export const useMailStore = defineStore('mail', () => {
|
||||
}
|
||||
|
||||
async function register(email: string, password: string) {
|
||||
const generation = ++authenticationGeneration
|
||||
const response = await nuiCall<IfruitAccount>('mail:register', {
|
||||
email,
|
||||
password,
|
||||
})
|
||||
if (response.success && response.data) {
|
||||
if (
|
||||
generation === authenticationGeneration &&
|
||||
response.success &&
|
||||
response.data
|
||||
) {
|
||||
account.hydrate(response.data)
|
||||
await bootstrap(response.data.email)
|
||||
}
|
||||
@@ -75,10 +95,13 @@ export const useMailStore = defineStore('mail', () => {
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
const generation = ++authenticationGeneration
|
||||
if (accountEmail.value) {
|
||||
const response = await nuiCall('mail:logout')
|
||||
if (generation !== authenticationGeneration) return
|
||||
if (response.success) account.hydrate(null)
|
||||
}
|
||||
if (generation !== authenticationGeneration) return
|
||||
clearSession()
|
||||
}
|
||||
|
||||
@@ -87,6 +110,8 @@ export const useMailStore = defineStore('mail', () => {
|
||||
nextSearch = '',
|
||||
append = false,
|
||||
): Promise<boolean> {
|
||||
const generation = ++folderRequestGeneration
|
||||
const session = sessionGeneration
|
||||
loading.value = true
|
||||
const offset = append ? items.value.length : 0
|
||||
const response = await nuiCall<MailListResponse>('mail:list', {
|
||||
@@ -94,7 +119,13 @@ export const useMailStore = defineStore('mail', () => {
|
||||
offset,
|
||||
search: nextSearch,
|
||||
})
|
||||
loading.value = false
|
||||
if (generation === folderRequestGeneration) loading.value = false
|
||||
if (
|
||||
generation !== folderRequestGeneration ||
|
||||
session !== sessionGeneration
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (!response.success || !response.data) return false
|
||||
|
||||
folder.value = nextFolder
|
||||
@@ -107,8 +138,17 @@ export const useMailStore = defineStore('mail', () => {
|
||||
}
|
||||
|
||||
async function refreshCounts(): Promise<void> {
|
||||
const email = accountEmail.value
|
||||
const session = sessionGeneration
|
||||
const response = await nuiCall<MailCounts>('mail:counts')
|
||||
if (response.success && response.data) counts.value = response.data
|
||||
if (
|
||||
session === sessionGeneration &&
|
||||
email === accountEmail.value &&
|
||||
response.success &&
|
||||
response.data
|
||||
) {
|
||||
counts.value = response.data
|
||||
}
|
||||
}
|
||||
|
||||
async function openMessage(id: number): Promise<MailMessage | null> {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,6 +43,8 @@ type PersistedNotificationsV1 = {
|
||||
version: 1
|
||||
}
|
||||
|
||||
export const MAX_LOCK_SCREEN_NOTIFICATIONS = 50
|
||||
|
||||
const timeoutHandles = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
const stopToneHandles = new Map<string, () => void>()
|
||||
const persistenceQueues = new Map<string, Promise<void>>()
|
||||
@@ -151,7 +153,9 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
||||
for (const notification of stored) merged.set(notification.id, notification)
|
||||
for (const notification of lockScreenQueues.value[imei] ?? [])
|
||||
merged.set(notification.id, notification)
|
||||
lockScreenQueues.value[imei] = [...merged.values()]
|
||||
lockScreenQueues.value[imei] = [...merged.values()].slice(
|
||||
-MAX_LOCK_SCREEN_NOTIFICATIONS,
|
||||
)
|
||||
persist(imei)
|
||||
}
|
||||
|
||||
@@ -166,9 +170,10 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
||||
function remember(notification: PhoneNotification): void {
|
||||
const imei = notification.device?.imei ?? phone.device?.imei
|
||||
if (!imei) return
|
||||
const notifications = lockScreenQueues.value[imei] ?? []
|
||||
notifications.push(notification)
|
||||
lockScreenQueues.value[imei] = notifications
|
||||
lockScreenQueues.value[imei] = [
|
||||
...(lockScreenQueues.value[imei] ?? []),
|
||||
notification,
|
||||
].slice(-MAX_LOCK_SCREEN_NOTIFICATIONS)
|
||||
persist(imei)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||
|
||||
vi.mock('@/utils/nui', () => ({
|
||||
nuiCall: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockNuiCall = vi.mocked(nuiCall)
|
||||
|
||||
function deferredResponse<T>(): {
|
||||
promise: Promise<NuiResponse<T>>
|
||||
resolve: (response: NuiResponse<T>) => void
|
||||
} {
|
||||
let resolve!: (response: NuiResponse<T>) => void
|
||||
const promise = new Promise<NuiResponse<T>>((next) => {
|
||||
resolve = next
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function openPhone(imei: string, token: string, revision: number): void {
|
||||
usePhoneStore().open({
|
||||
device: {
|
||||
data: { settings: { payload: {}, revision } },
|
||||
imei,
|
||||
name: `Phone ${imei}`,
|
||||
sim: null,
|
||||
},
|
||||
token,
|
||||
})
|
||||
}
|
||||
|
||||
describe('phone device persistence scope', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('window', {
|
||||
matchMedia: vi.fn(() => ({ matches: false })),
|
||||
})
|
||||
setActivePinia(createPinia())
|
||||
mockNuiCall.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('does not apply a late save response to a newer device session', async () => {
|
||||
const stale = deferredResponse<{ revision: number }>()
|
||||
mockNuiCall.mockReturnValueOnce(stale.promise)
|
||||
const phone = usePhoneStore()
|
||||
openPhone('111', 'session-a', 2)
|
||||
|
||||
phone.saveDeviceNamespace('settings', { value: 'old' })
|
||||
await Promise.resolve()
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('device:save', {
|
||||
imei: '111',
|
||||
namespace: 'settings',
|
||||
payload: { value: 'old' },
|
||||
revision: 2,
|
||||
sessionToken: 'session-a',
|
||||
})
|
||||
|
||||
openPhone('222', 'session-b', 7)
|
||||
stale.resolve({ data: { revision: 3 }, success: true })
|
||||
await stale.promise
|
||||
await Promise.resolve()
|
||||
|
||||
expect(phone.device?.imei).toBe('222')
|
||||
expect(phone.deviceRevisions.settings).toBe(7)
|
||||
})
|
||||
|
||||
it('drops queued writes from an obsolete device generation', async () => {
|
||||
const first = deferredResponse<{ revision: number }>()
|
||||
mockNuiCall.mockReturnValueOnce(first.promise)
|
||||
const phone = usePhoneStore()
|
||||
openPhone('111', 'session-a', 0)
|
||||
|
||||
phone.saveDeviceNamespace('settings', { order: 1 })
|
||||
phone.saveDeviceNamespace('settings', { order: 2 })
|
||||
await Promise.resolve()
|
||||
openPhone('222', 'session-b', 0)
|
||||
first.resolve({ data: { revision: 1 }, success: true })
|
||||
await first.promise
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mockNuiCall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('flushes every queued write after a normal visibility close', async () => {
|
||||
const first = deferredResponse<{ revision: number }>()
|
||||
mockNuiCall
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockResolvedValueOnce({ data: { revision: 2 }, success: true })
|
||||
const phone = usePhoneStore()
|
||||
openPhone('111', 'session-a', 0)
|
||||
|
||||
phone.saveDeviceNamespace('settings', { order: 1 })
|
||||
phone.saveDeviceNamespace('settings', { order: 2 })
|
||||
await Promise.resolve()
|
||||
phone.close()
|
||||
const flushed = phone.flushDevicePersistence()
|
||||
|
||||
first.resolve({ data: { revision: 1 }, success: true })
|
||||
await flushed
|
||||
|
||||
expect(mockNuiCall).toHaveBeenCalledTimes(2)
|
||||
expect(mockNuiCall).toHaveBeenLastCalledWith('device:save', {
|
||||
imei: '111',
|
||||
namespace: 'settings',
|
||||
payload: { order: 2 },
|
||||
revision: 1,
|
||||
sessionToken: 'session-a',
|
||||
})
|
||||
expect(phone.deviceRevisions.settings).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps queued writes scoped across a same-session bootstrap update', async () => {
|
||||
const first = deferredResponse<{ revision: number }>()
|
||||
mockNuiCall
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockResolvedValueOnce({ data: { revision: 2 }, success: true })
|
||||
const phone = usePhoneStore()
|
||||
openPhone('111', 'session-a', 0)
|
||||
|
||||
phone.saveDeviceNamespace('settings', { order: 1 })
|
||||
phone.saveDeviceNamespace('settings', { order: 2 })
|
||||
await Promise.resolve()
|
||||
first.resolve({ data: { revision: 1 }, success: true })
|
||||
await first.promise
|
||||
await Promise.resolve()
|
||||
openPhone('111', 'session-a', 1)
|
||||
await phone.flushDevicePersistence()
|
||||
|
||||
expect(mockNuiCall).toHaveBeenCalledTimes(2)
|
||||
expect(phone.deviceRevisions.settings).toBe(2)
|
||||
})
|
||||
|
||||
it('waits for writes queued while a persistence flush is in progress', async () => {
|
||||
const first = deferredResponse<{ revision: number }>()
|
||||
const queuedDuringFlush = deferredResponse<{ revision: number }>()
|
||||
mockNuiCall
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(queuedDuringFlush.promise)
|
||||
const phone = usePhoneStore()
|
||||
openPhone('111', 'session-a', 0)
|
||||
|
||||
phone.saveDeviceNamespace('settings', { order: 1 })
|
||||
await Promise.resolve()
|
||||
let flushCompleted = false
|
||||
const flushed = phone.flushDevicePersistence().then(() => {
|
||||
flushCompleted = true
|
||||
})
|
||||
phone.saveDeviceNamespace('widgets', { order: 2 })
|
||||
await Promise.resolve()
|
||||
|
||||
first.resolve({ data: { revision: 1 }, success: true })
|
||||
await first.promise
|
||||
await Promise.resolve()
|
||||
expect(flushCompleted).toBe(false)
|
||||
|
||||
queuedDuringFlush.resolve({ data: { revision: 1 }, success: true })
|
||||
await flushed
|
||||
|
||||
expect(mockNuiCall).toHaveBeenCalledTimes(2)
|
||||
expect(phone.deviceRevisions.widgets).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,7 @@ import { nuiCall } from '@/utils/nui'
|
||||
import type { NuiResponse } from '@/utils/nui'
|
||||
import {
|
||||
DEFAULT_PHONE_PREFERENCES,
|
||||
clampPhoneScale,
|
||||
ensureAppNotificationPreferences,
|
||||
parsePhonePreferences,
|
||||
type AppNotificationPreferences,
|
||||
@@ -38,6 +39,7 @@ export type PhoneOpenPayload = {
|
||||
}
|
||||
|
||||
const namespaceQueues = new Map<string, Promise<void>>()
|
||||
let nextPersistenceSession = 0
|
||||
|
||||
const companiesFallbackLocales = {
|
||||
name: 'Companies',
|
||||
@@ -3617,11 +3619,14 @@ export const usePhoneStore = defineStore('phone', {
|
||||
currentPage: 1,
|
||||
device: null as PhoneDevice | null,
|
||||
deviceRevisions: {} as Record<string, number>,
|
||||
deviceSessionToken: null as string | null,
|
||||
isOpen: false,
|
||||
lang: 'en',
|
||||
launchOrigin: null as AppLaunchOrigin | null,
|
||||
locales: defaultLocales,
|
||||
preferences: cloneJsonData(DEFAULT_PHONE_PREFERENCES),
|
||||
persistenceGeneration: 0,
|
||||
persistenceSession: ++nextPersistenceSession,
|
||||
security: {
|
||||
enabled: false,
|
||||
length: null,
|
||||
@@ -3642,6 +3647,15 @@ export const usePhoneStore = defineStore('phone', {
|
||||
this.isOpen = false
|
||||
},
|
||||
open(payload: PhoneOpenPayload = {}): void {
|
||||
const nextImei = payload.device?.imei ?? this.device?.imei ?? null
|
||||
const nextToken = payload.token ?? this.deviceSessionToken
|
||||
if (
|
||||
nextImei !== (this.device?.imei ?? null) ||
|
||||
nextToken !== this.deviceSessionToken
|
||||
) {
|
||||
this.persistenceGeneration += 1
|
||||
}
|
||||
this.deviceSessionToken = nextToken
|
||||
this.lang = payload.lang ?? 'en'
|
||||
this.locales = payload.locales ?? defaultLocales
|
||||
if (payload.device) this.hydrateDevice(payload.device)
|
||||
@@ -3652,6 +3666,13 @@ export const usePhoneStore = defineStore('phone', {
|
||||
}
|
||||
this.isOpen = true
|
||||
},
|
||||
endDeviceSession(): void {
|
||||
this.close()
|
||||
if (this.deviceSessionToken !== null) {
|
||||
this.deviceSessionToken = null
|
||||
this.persistenceGeneration += 1
|
||||
}
|
||||
},
|
||||
hydrateDevice(device: PhoneDevice): void {
|
||||
this.device = device
|
||||
this.deviceRevisions = Object.fromEntries(
|
||||
@@ -3665,22 +3686,59 @@ export const usePhoneStore = defineStore('phone', {
|
||||
)
|
||||
},
|
||||
saveDeviceNamespace(namespace: string, payload: unknown): void {
|
||||
const previous = namespaceQueues.get(namespace) ?? Promise.resolve()
|
||||
const imei = this.device?.imei
|
||||
if (!imei) {
|
||||
console.error(
|
||||
`[Phone persistence] Could not save ${namespace} without an active device.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
const generation = this.persistenceGeneration
|
||||
const session = this.persistenceSession
|
||||
const token = this.deviceSessionToken
|
||||
const queuedPayload = cloneJsonData(payload)
|
||||
const queueKey = `${session}:${generation}:${imei}:${namespace}`
|
||||
const isCurrentScope = (): boolean =>
|
||||
this.persistenceSession === session &&
|
||||
this.persistenceGeneration === generation &&
|
||||
this.device?.imei === imei &&
|
||||
this.deviceSessionToken === token
|
||||
const previous = namespaceQueues.get(queueKey) ?? Promise.resolve()
|
||||
const queued = previous.then(async () => {
|
||||
if (!isCurrentScope()) return
|
||||
const response = await nuiCall<{ revision: number }>('device:save', {
|
||||
imei,
|
||||
namespace,
|
||||
payload,
|
||||
payload: queuedPayload,
|
||||
revision: this.deviceRevisions[namespace] ?? 0,
|
||||
sessionToken: token,
|
||||
})
|
||||
if (response.success && response.data) {
|
||||
this.deviceRevisions[namespace] = response.data.revision
|
||||
if (
|
||||
isCurrentScope() &&
|
||||
response.success &&
|
||||
Number.isInteger(response.data?.revision) &&
|
||||
Number(response.data?.revision) >= 0
|
||||
) {
|
||||
this.deviceRevisions[namespace] = Number(response.data?.revision)
|
||||
}
|
||||
})
|
||||
const tracked = queued.finally(() => {
|
||||
if (namespaceQueues.get(namespace) === tracked)
|
||||
namespaceQueues.delete(namespace)
|
||||
if (namespaceQueues.get(queueKey) === tracked)
|
||||
namespaceQueues.delete(queueKey)
|
||||
})
|
||||
namespaceQueues.set(namespace, tracked)
|
||||
namespaceQueues.set(queueKey, tracked)
|
||||
},
|
||||
async flushDevicePersistence(): Promise<void> {
|
||||
const imei = this.device?.imei
|
||||
if (!imei) return
|
||||
const queuePrefix = `${this.persistenceSession}:${this.persistenceGeneration}:${imei}:`
|
||||
while (true) {
|
||||
const activeQueues = [...namespaceQueues.entries()]
|
||||
.filter(([key]) => key.startsWith(queuePrefix))
|
||||
.map(([, queue]) => queue)
|
||||
if (!activeQueues.length) return
|
||||
await Promise.all(activeQueues)
|
||||
}
|
||||
},
|
||||
setCurrentPage(page: number, pageCount?: number): void {
|
||||
this.currentPage = clampPage(page, pageCount)
|
||||
@@ -3706,7 +3764,11 @@ export const usePhoneStore = defineStore('phone', {
|
||||
key: K,
|
||||
value: PhonePreferencesV1['settings'][K],
|
||||
): void {
|
||||
this.preferences.settings[key] = value
|
||||
this.preferences.settings[key] = (
|
||||
key === 'phoneScale'
|
||||
? clampPhoneScale(Number(value))
|
||||
: value
|
||||
) as PhonePreferencesV1['settings'][K]
|
||||
this.saveDeviceNamespace('settings', this.preferences)
|
||||
},
|
||||
setAlertVolumes(value: number): void {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
describe('nuiCall', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('window', {
|
||||
clearTimeout: globalThis.clearTimeout,
|
||||
location: { search: '' },
|
||||
setTimeout: globalThis.setTimeout,
|
||||
})
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('clears the request timeout after a successful callback', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ data: { value: 1 }, success: true }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 200,
|
||||
}),
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(nuiCall<{ value: number }>('test')).resolves.toEqual({
|
||||
data: { value: 1 },
|
||||
success: true,
|
||||
})
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:3002/api/test',
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
)
|
||||
})
|
||||
|
||||
it('aborts a callback that never completes', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((_url: string, init?: RequestInit) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
reject(new DOMException('Aborted', 'AbortError'))
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const request = nuiCall('never-responds')
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
|
||||
await expect(request).resolves.toEqual({
|
||||
error: 'request_timeout',
|
||||
success: false,
|
||||
})
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
const resourceName = globalThis.window?.GetParentResourceName?.() ?? 'sky_phone'
|
||||
const requestTimeoutMs = 20_000
|
||||
|
||||
export type NuiResponse<T = unknown> = {
|
||||
success: boolean
|
||||
@@ -24,12 +25,15 @@ export async function nuiCall<T = unknown>(
|
||||
undefined,
|
||||
}
|
||||
: data
|
||||
const controller = new AbortController()
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), requestTimeoutMs)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/${endpoint}`, {
|
||||
body: JSON.stringify(requestData),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -41,8 +45,14 @@ export async function nuiCall<T = unknown>(
|
||||
const body = await response.text()
|
||||
return body ? (JSON.parse(body) as NuiResponse<T>) : { success: true }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
const message = controller.signal.aborted
|
||||
? 'request_timeout'
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: 'Unknown error'
|
||||
console.error(`[NUI] ${endpoint} failed:`, error)
|
||||
return { error: message, success: false }
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } },
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user