FIX - harden phone lifecycle and server authority

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