mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user