diff --git a/README.md b/README.md index 6c4b500..6d385de 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,16 @@ # sky_phone +## Custom Apps + +`sky_phone` erkennt Registrierungen gestarteter Fremd-App-Ressourcen über integrierte +Hersteller-Aliase und übernimmt unterstützte Apps automatisch in Springboard und App Store. In +App-Ressourcen müssen dafür keine Sky-Vorlagen abgelegt werden. Unterstützt werden die +dokumentierten Basisverträge von LB Phone, 17Movement, High Phone, Quasar Smartphone V3 und +YSeries; die Resource- und Cfx-Export-Aliase `lb-phone`, `17mov_Phone`, `high-phone`, +`qs-smartphone` und `yseries` werden direkt von `sky_phone` bereitgestellt. Einrichtung und +ehrliche Kompatibilitätsgrenzen stehen in der +[deutschen Custom-App-Anleitung](docs/custom-apps.md). + ## FlipTok verification FlipTok verification is server-authoritative and limited to the framework groups configured in diff --git a/frontend/public/img/custom-app.svg b/frontend/public/img/custom-app.svg new file mode 100644 index 0000000..8b9ec86 --- /dev/null +++ b/frontend/public/img/custom-app.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 140147f..c3c8283 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -30,6 +30,7 @@ import { useGamesStore } from '@/features/games/store' import { useCallsStore } from '@/stores/calls' import { useBankingStore } from '@/stores/banking' import { useBillingStore } from '@/stores/billing' +import { useCompaniesStore } from '@/stores/companies' import { useAccountStore } from '@/stores/account' import { useMailStore } from '@/stores/mail' import { useMessagesStore } from '@/stores/messages' @@ -40,6 +41,7 @@ import { usePicstagramStore } from '@/stores/picstagram' import { useFeatherStore } from '@/stores/feather' import { useMediaStore } from '@/stores/media' import { useMarketplaceStore } from '@/stores/marketplace' +import { useAppCatalogStore } from '@/stores/app-catalog' import { useAppStoreStore } from '@/stores/app-store' import { useWidgetsStore } from '@/stores/widgets' import { isPhoneAppId } from '@/config/apps' @@ -48,17 +50,23 @@ import { useWeatherStore } from '@/stores/weather' import { useEasyShareStore } from '@/stores/easyshare' import { useNotificationsStore, + type PhoneNotification, type PhoneNotificationInput, } from '@/stores/notifications' import { usePhoneStore, type PhoneOpenPayload } from '@/stores/phone' import type { PhoneNotificationDevicePayload } from '@/types/device' import type { MailCounts } from '@/types/mail' import type { MarketplaceCounts } from '@/types/marketplace' +import type { + CompanyChangedPayload, + CompanyUnreadCounts, +} from '@/types/companies' import type { PhoneCall } from '@/types/phone' import type { EasyShareEvent } from '@/types/easyshare' import { nuiCall } from '@/utils/nui' import { formatTimer } from '@/utils/clock' import { parsePhonePreferences } from '@/utils/preferences' +import { isTrustedRootMessageSource } from '@/utils/windowMessages' import SpringboardView from '@/views/SpringboardView.vue' type AppMessage = { @@ -67,6 +75,7 @@ type AppMessage = { | CalendarReminderData | MailEventData | MarketplaceEventData + | CompaniesEventData | MessagesEventData | DarkChatEventData | FlareEventData @@ -80,6 +89,18 @@ type AppMessage = { | PhoneCall | PhoneNotificationInput | PhoneOpenPayload + | CustomAppCatalogEventData + | CustomAppEventData +} + +type CustomAppCatalogEventData = { + apps?: unknown +} + +type CustomAppEventData = { + appId?: unknown + data?: unknown + payload?: unknown } type SimPickerPayload = { @@ -108,6 +129,18 @@ type MessagesEventData = { title?: string } +type CompaniesEventData = { + area?: CompanyChangedPayload['area'] + companyId?: string + counts?: CompanyUnreadCounts + device?: PhoneNotificationDevicePayload + kind?: 'assigned' | 'newMessage' | 'newRequest' | 'requestUpdated' + requestId?: string + subtitle?: string + text?: string + title?: string +} + type DarkChatEventData = { conversationId?: string device?: PhoneNotificationDevicePayload @@ -213,6 +246,7 @@ const games = useGamesStore() const calls = useCallsStore() const banking = useBankingStore() const billing = useBillingStore() +const companies = useCompaniesStore() const mail = useMailStore() const messages = useMessagesStore() const darkchat = useDarkChatStore() @@ -222,6 +256,7 @@ const picstagram = usePicstagramStore() const feather = useFeatherStore() const media = useMediaStore() const marketplace = useMarketplaceStore() +const appCatalog = useAppCatalogStore() const appStore = useAppStoreStore() const widgets = useWidgetsStore() const notes = useNotesStore() @@ -275,6 +310,8 @@ const phoneFrameImage = computed( () => PHONE_FRAME_IMAGES[phone.preferences.settings.frame], ) let clockTicker: ReturnType | undefined +let companiesChangeTimer: number | undefined +let pendingCompaniesChange: CompanyChangedPayload | null = null let unlockTimer: number | undefined let passcodeLockTimer: number | undefined let unlockedServicesFrame: number | undefined @@ -287,7 +324,16 @@ function getViewportScale(): number { } function hydratePhone(payload: PhoneOpenPayload): void { + if (payload.device?.imei) { + companies.bindDeviceScope( + payload.device.imei, + payload.device.sim?.id ?? null, + ) + } phone.open(payload) + phone.ensureAppNotificationPreferences( + appCatalog.externalApps.map((app) => app.id), + ) if (payload.device?.imei) { notifications.hydrate( payload.device.data.notifications?.payload, @@ -304,6 +350,31 @@ function hydratePhone(payload: PhoneOpenPayload): void { widgets.hydrate(payload.device?.data.widgets?.payload) } +function queueCompaniesChange(change: CompanyChangedPayload): void { + if (!pendingCompaniesChange) { + pendingCompaniesChange = { ...change } + } else { + pendingCompaniesChange = { + area: pendingCompaniesChange.area === change.area ? change.area : 'all', + companyId: + pendingCompaniesChange.companyId === change.companyId + ? change.companyId + : undefined, + requestId: + pendingCompaniesChange.requestId === change.requestId + ? change.requestId + : undefined, + } + } + if (companiesChangeTimer !== undefined) return + companiesChangeTimer = window.setTimeout(() => { + companiesChangeTimer = undefined + const queuedChange = pendingCompaniesChange + pendingCompaniesChange = null + if (queuedChange) void companies.applyChanged(queuedChange) + }, 2000) +} + function loadUnlockedPhoneData(): void { if (unlockedServicesLoaded.value) return unlockedServicesLoaded.value = true @@ -323,6 +394,7 @@ function loadUnlockedPhoneData(): void { void calls.bootstrap() void messages.loadConversations() void billing.loadOverview() + void companies.refreshUnreadCounts() if (account.email) void darkchat.bootstrap() }) } @@ -354,7 +426,35 @@ async function hydrateDevelopmentPhone(): Promise { } function onMessage(event: MessageEvent): void { - if (event.data?.type === 'app:open') { + if (!isTrustedRootMessageSource(event.source, window)) return + + if (event.data?.type === 'custom-apps:catalog') { + appCatalog.replaceCatalog(event.data.data) + const activeAppId = route.params.appId + if (typeof activeAppId === 'string' && !isPhoneAppId(activeAppId)) { + void router.push('/') + } + } else if (event.data?.type === 'custom-app:message') { + const data = event.data.data as CustomAppEventData | undefined + if (typeof data?.appId === 'string') { + appCatalog.queueHostMessage(data.appId, data.payload) + } else { + console.error('[Custom apps] Ignored a message without a valid app id.') + } + } else if (event.data?.type === 'custom-app:open') { + const data = event.data.data as CustomAppEventData | undefined + if ( + typeof data?.appId === 'string' && + appCatalog.requestOpen(data.appId, data.data) + ) { + void router.push(`/apps/${data.appId}`) + } + } else if (event.data?.type === 'custom-app:close') { + const data = event.data.data as CustomAppEventData | undefined + if (typeof data?.appId === 'string' && route.params.appId === data.appId) { + void router.push('/') + } + } else if (event.data?.type === 'app:open') { hydratePhone(event.data.data as PhoneOpenPayload) void nuiCall('ui:opened') } else if (event.data?.type === 'device:updated') { @@ -370,10 +470,7 @@ function onMessage(event: MessageEvent): void { const data = event.data.data as NotificationEventData const { device, ...input } = data const notification: PhoneNotificationInput = input - if ( - device && - (!phone.isOpen || device.imei !== phone.device?.imei) - ) { + if (device && (!phone.isOpen || device.imei !== phone.device?.imei)) { notification.device = { imei: device.imei, name: device.name, @@ -415,6 +512,47 @@ function onMessage(event: MessageEvent): void { } else if (event.data?.type === 'marketplace:changed' && event.data.data) { const data = event.data.data as MarketplaceEventData if (data.counts) marketplace.setCounts(data.counts) + } else if (event.data?.type === 'companies:changed') { + const data = event.data.data as CompaniesEventData | undefined + if (data?.counts) companies.applyUnreadCounts(data.counts) + if (data?.area) { + queueCompaniesChange({ + area: data.area, + companyId: data.companyId, + requestId: data.requestId, + }) + } else if (!data?.counts) queueCompaniesChange({ area: 'all' }) + } else if (event.data?.type === 'companies:notification' && event.data.data) { + const data = event.data.data as CompaniesEventData + if (data.counts) companies.applyUnreadCounts(data.counts) + + const notification: PhoneNotificationInput = { + appId: 'companies', + ...(data.requestId && data.area + ? { + route: `/apps/companies?requestId=${encodeURIComponent(data.requestId)}&area=${encodeURIComponent(data.area)}`, + } + : {}), + persistent: !phone.isOpen && Boolean(data.requestId && data.area), + subtitle: data.subtitle, + text: + data.text ?? + phone.t( + `Apps.companies.notifications.${data.kind ?? 'requestUpdated'}`, + ), + title: data.title ?? phone.t('Apps.companies.name'), + } + if ( + data.device && + (!phone.isOpen || data.device.imei !== phone.device?.imei) + ) { + notification.device = { + imei: data.device.imei, + name: data.device.name, + preferences: parsePhonePreferences(data.device.settings ?? null), + } + } + notifications.show(notification) } else if ( event.data?.type === 'fliptok:verification-changed' && event.data.data @@ -678,10 +816,7 @@ function onMessage(event: MessageEvent): void { } } notifications.show(notification) - } else if ( - event.data?.type === 'crewlink:notification' && - event.data.data - ) { + } else if (event.data?.type === 'crewlink:notification' && event.data.data) { const data = event.data.data as CrewLinkNotificationData const notification: PhoneNotificationInput = { appId: 'crewlink', @@ -767,6 +902,28 @@ function unlockPhone(): void { finishUnlock() } +function openLockScreenNotification(notification: PhoneNotification): void { + if (!notification.route) return + pendingUnlockRoute.value = notification.route + notifications.dismissFromLockScreen(notification.id) + unlockPhone() +} + +async function openNotificationPreview( + notification: PhoneNotification, +): Promise { + if (!notification.route) return + const imei = notification.device?.imei ?? phone.device?.imei + if (!imei) return + pendingUnlockRoute.value = notification.route + const response = await nuiCall('device:notification-open', { imei }) + if (!response.success) { + pendingUnlockRoute.value = null + return + } + notifications.dismissFromLockScreen(notification.id, imei) +} + function cancelPasscode(): void { if (passcodeBusy.value) return passcodeVisible.value = false @@ -925,6 +1082,7 @@ watch( (isOpen) => { if (unlockTimer !== undefined) window.clearTimeout(unlockTimer) if (!isOpen) { + appStore.cancelPendingInstalls() if (unlockedServicesFrame !== undefined) { window.cancelAnimationFrame(unlockedServicesFrame) unlockedServicesFrame = undefined @@ -979,6 +1137,9 @@ onBeforeUnmount(() => { } weather.stop() if (clockTicker) clearInterval(clockTicker) + if (companiesChangeTimer !== undefined) { + window.clearTimeout(companiesChangeTimer) + } if (unlockTimer !== undefined) window.clearTimeout(unlockTimer) if (passcodeLockTimer !== undefined) window.clearInterval(passcodeLockTimer) window.removeEventListener('message', onMessage) @@ -1025,6 +1186,7 @@ onBeforeUnmount(() => { 100) " @close="notifications.dismiss(notification.id)" + @open="openNotificationPreview" /> { @camera="unlockCamera" @clear-notifications="notifications.clearLockScreen" @dismiss-notification="notifications.dismissFromLockScreen" + @open-notification="openLockScreenNotification" @unlock="unlockPhone" /> @@ -1116,6 +1279,7 @@ onBeforeUnmount(() => { diff --git a/frontend/src/assets/img/app-icons/companies.svg b/frontend/src/assets/img/app-icons/companies.svg new file mode 100644 index 0000000..b52f7ce --- /dev/null +++ b/frontend/src/assets/img/app-icons/companies.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 5b9a211..34ee5e5 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -720,6 +720,16 @@ button { background-color: rgb(28 28 32 / 72%); text-shadow: 0 1px 3px #0009; } +.phone-notification.is-actionable { + cursor: pointer; +} +.lock-screen__notification.is-actionable { + cursor: pointer; +} +.lock-screen__notification.is-actionable:focus-visible { + outline: 2px solid #fff; + outline-offset: 2px; +} .lock-screen__notification-icon { width: 42px; height: 42px; diff --git a/frontend/src/components/AppIcon.vue b/frontend/src/components/AppIcon.vue index c56d222..17fb331 100644 --- a/frontend/src/components/AppIcon.vue +++ b/frontend/src/components/AppIcon.vue @@ -1,12 +1,13 @@ + + + + + + + + {{ phone.t('Apps.customApps.loading') }} + + + + + {{ phone.t('Apps.customApps.unavailableTitle') }} + {{ phone.t('Apps.customApps.unavailableBody') }} + + + {{ phone.t('Apps.customApps.close') }} + + + + + + diff --git a/frontend/src/components/EasyShareSheet.vue b/frontend/src/components/EasyShareSheet.vue index ffc9f94..5579bcf 100644 --- a/frontend/src/components/EasyShareSheet.vue +++ b/frontend/src/components/EasyShareSheet.vue @@ -13,7 +13,7 @@ import { import { computed, ref } from 'vue' import { useRouter } from 'vue-router' -import { getPhoneApp } from '@/config/apps' +import { getPhoneApp, getPhoneAppLabel } from '@/config/apps' import { useAppStoreStore } from '@/stores/app-store' import { useCallsStore } from '@/stores/calls' import { useDarkChatStore } from '@/stores/darkchat' @@ -22,7 +22,6 @@ import { useFlareStore } from '@/stores/flare' import { useMessagesStore } from '@/stores/messages' import { useNotesStore } from '@/stores/notes' import { usePhoneStore } from '@/stores/phone' -import type { LaunchablePhoneAppId } from '@/types/apps' import type { EasyShareChatApp, EasyShareTransfer, @@ -45,6 +44,7 @@ let dragPointerId: number | null = null let dragStartTime = 0 let dragStartY = 0 const visibilityOptions: EasyShareVisibility[] = ['everyone', 'contacts', 'hidden'] +type EasyShareDestinationAppId = 'darkchat' | 'messages' | 'notes' const app = computed(() => easyShare.payload ? getPhoneApp(easyShare.payload.appId) : undefined, ) @@ -104,12 +104,18 @@ const sharePeople = computed(() => { return people }) -const shareAppIds: LaunchablePhoneAppId[] = ['messages', 'darkchat', 'notes'] +const shareAppIds: EasyShareDestinationAppId[] = [ + 'messages', + 'darkchat', + 'notes', +] const shareApps = computed(() => shareAppIds .filter((id) => !appStore.homeLayout.hidden.includes(id)) - .map((id) => getPhoneApp(id)) - .filter((entry) => entry !== undefined), + .flatMap((id) => { + const app = getPhoneApp(id) + return app ? [{ app, id }] : [] + }), ) const sheetStyle = computed(() => ({ transform: easyShare.opened @@ -193,7 +199,7 @@ function openChatApp(kind: 'darkchat' | 'messages'): void { void router.push(`/apps/${kind}`) } -function openShareApp(appId: LaunchablePhoneAppId): void { +function openShareApp(appId: EasyShareDestinationAppId): void { if (appId === 'messages' || appId === 'darkchat') { openChatApp(appId) return @@ -257,7 +263,7 @@ async function cancelTransfer(transfer: EasyShareTransfer): Promise { - {{ app ? phone.t(app.labelKey) : label('name') }} + {{ app ? getPhoneAppLabel(app, phone.t) : label('name') }} {{ easyShare.payload?.title ?? label('incoming') }} {{ easyShare.payload?.subtitle || easyShare.payload?.copyText }} @@ -305,8 +311,10 @@ async function cancelTransfer(transfer: EasyShareTransfer): Promise { class="easyshare-action" @click="openShareApp(destination.id)" > - - {{ phone.t(destination.labelKey) }} + + {{ getPhoneAppLabel(destination.app, phone.t) }} diff --git a/frontend/src/components/NotificationPhonePreview.vue b/frontend/src/components/NotificationPhonePreview.vue index d5e8e1a..3d253fc 100644 --- a/frontend/src/components/NotificationPhonePreview.vue +++ b/frontend/src/components/NotificationPhonePreview.vue @@ -14,6 +14,7 @@ const props = defineProps<{ }>() const emit = defineEmits<{ close: [] + open: [notification: PhoneNotification] }>() const phone = usePhoneStore() const device = computed(() => props.notification.device!) @@ -69,6 +70,7 @@ const wrapperStyle = computed(() => ({ zoom: props.zoom })) diff --git a/frontend/src/components/PayphoneOverlay.vue b/frontend/src/components/PayphoneOverlay.vue index 8dd459e..cf547c1 100644 --- a/frontend/src/components/PayphoneOverlay.vue +++ b/frontend/src/components/PayphoneOverlay.vue @@ -3,6 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue' import payphoneFrame from '@/assets/img/payphone/american-payphone-frame.png' import { nuiCall } from '@/utils/nui' +import { isTrustedRootMessageSource } from '@/utils/windowMessages' type PayphoneState = | 'idle' @@ -214,6 +215,7 @@ async function close(): Promise { } function onMessage(event: MessageEvent): void { + if (!isTrustedRootMessageSource(event.source, window)) return if (event.data?.type === 'payphone:open' && event.data.data) { const payload = event.data.data as PayphoneOpenPayload currency.value = payload.currency diff --git a/frontend/src/components/PhoneLockScreen.vue b/frontend/src/components/PhoneLockScreen.vue index fe3b714..f7e7a78 100644 --- a/frontend/src/components/PhoneLockScreen.vue +++ b/frontend/src/components/PhoneLockScreen.vue @@ -19,6 +19,7 @@ const emit = defineEmits<{ camera: [] clearNotifications: [] dismissNotification: [id: string] + openNotification: [notification: PhoneNotification] unlock: [] }>() @@ -72,9 +73,7 @@ const flashlightShortcutColors = computed(() => function onPointerDown(event: PointerEvent): void { if (props.preview) return if ( - (event.target as HTMLElement).closest( - 'button, .lock-screen__notifications', - ) + (event.target as HTMLElement).closest('button, .lock-screen__notifications') ) return pointerStart = event.clientY @@ -83,6 +82,10 @@ function onPointerDown(event: PointerEvent): void { ;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId) } +function openNotification(notification: PhoneNotification): void { + if (notification.route) emit('openNotification', notification) +} + function onPointerMove(event: PointerEvent): void { if (!dragging.value) return dragOffset.value = Math.min(0, event.clientY - pointerStart) @@ -194,7 +197,16 @@ onBeforeUnmount(() => { v-for="notification in props.notifications" :key="notification.id" class="lock-screen__notification" + :class="{ 'is-actionable': !!notification.route }" :highlight="false" + :role="notification.route ? 'button' : undefined" + :tabindex="notification.route ? 0 : undefined" + :aria-label=" + notification.route ? phone.t('Notifications.open') : undefined + " + @click="openNotification(notification)" + @keydown.enter.prevent="openNotification(notification)" + @keydown.space.prevent="openNotification(notification)" > { {{ notification.title }} {{ phone.t('Notifications.now') }} - {{ notification.subtitle }} + {{ + notification.subtitle + }} {{ notification.text }} { } function onMessage(event: MessageEvent): void { + if (!isTrustedRootMessageSource(event.source, window)) return const message = event.data as { data?: Record type?: string diff --git a/frontend/src/components/PhoneNotifications.vue b/frontend/src/components/PhoneNotifications.vue index 4aac0d7..03298ba 100644 --- a/frontend/src/components/PhoneNotifications.vue +++ b/frontend/src/components/PhoneNotifications.vue @@ -11,6 +11,7 @@ const props = defineProps<{ }>() const emit = defineEmits<{ close: [] + open: [notification: PhoneNotification] }>() const phone = usePhoneStore() const icon = computed(() => @@ -18,6 +19,16 @@ const icon = computed(() => ? getPhoneApp(props.notification.appId)?.iconImage : undefined, ) + +function openNotification(event: MouseEvent): void { + if ( + !props.notification?.route || + (event.target as HTMLElement).closest('button') + ) { + return + } + emit('open', props.notification) +} @@ -29,7 +40,9 @@ const icon = computed(() => :title-right-text="phone.t('Notifications.now')" button="close" class="phone-notification" + :class="{ 'is-actionable': !!notification?.route }" @close="emit('close')" + @click="openNotification" > diff --git a/frontend/src/components/RadioHud.vue b/frontend/src/components/RadioHud.vue index b46a754..e219864 100644 --- a/frontend/src/components/RadioHud.vue +++ b/frontend/src/components/RadioHud.vue @@ -3,6 +3,7 @@ import { Headphones } from 'lucide-vue-next' import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue' import type { RadioHudConfig, RadioHudMember } from '@/types/radio' +import { isTrustedRootMessageSource } from '@/utils/windowMessages' type RadioHudEntry = RadioHudMember & { state: 'recent' | 'talking' @@ -122,6 +123,7 @@ function updateConfig(value: Partial): void { } function onMessage(event: MessageEvent): void { + if (!isTrustedRootMessageSource(event.source, window)) return if (event.data?.type === 'radio:hud-config' && event.data.data) { updateConfig(event.data.data as Partial) } else if (event.data?.type === 'radio:hud-update' && event.data.data) { diff --git a/frontend/src/components/SharedContentCard.vue b/frontend/src/components/SharedContentCard.vue index 4dc9958..ccc8622 100644 --- a/frontend/src/components/SharedContentCard.vue +++ b/frontend/src/components/SharedContentCard.vue @@ -2,7 +2,7 @@ import { Image, MapPin, Music2, Play, UserRound } from 'lucide-vue-next' import { computed, ref, watch } from 'vue' -import { getPhoneApp } from '@/config/apps' +import { getPhoneApp, getPhoneAppLabel } from '@/config/apps' import { usePhoneStore } from '@/stores/phone' import type { EasySharePayload } from '@/types/easyshare' @@ -51,7 +51,9 @@ watch( - {{ sourceApp ? phone.t(sourceApp.labelKey) : payload.appId }} + {{ + sourceApp ? getPhoneAppLabel(sourceApp, phone.t) : payload.appId + }} {{ phone.t(`Apps.easyShare.kinds.${payload.kind}`) }} diff --git a/frontend/src/config/apps.test.ts b/frontend/src/config/apps.test.ts index f69db3a..4a76207 100644 --- a/frontend/src/config/apps.test.ts +++ b/frontend/src/config/apps.test.ts @@ -52,6 +52,12 @@ describe('app registry', () => { labelKey: 'Apps.house.name', route: '/apps/house', }) + expect(PHONE_APPS.find((app) => app.id === 'companies')).toMatchObject({ + category: 'utilities', + gridOrder: 28, + labelKey: 'Apps.companies.name', + route: '/apps/companies', + }) expect(PHONE_APPS.find((app) => app.id === 'music')).toMatchObject({ category: 'utilities', gridOrder: 26, @@ -128,6 +134,7 @@ describe('app registry', () => { expect(isPhoneAppId('clock')).toBe(true) expect(isPhoneAppId('skyride')).toBe(true) expect(isPhoneAppId('music')).toBe(true) + expect(isPhoneAppId('companies')).toBe(true) expect( PHONE_APPS.filter((app) => app.category === 'games').map((app) => app.id), ).toEqual([ diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index efcac80..4f49726 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -32,8 +32,9 @@ import { Feather, ReceiptText, UsersRound, + Building2, } from 'lucide-vue-next' -import { defineAsyncComponent, markRaw } from 'vue' +import { defineAsyncComponent, markRaw, shallowReactive } from 'vue' import appStoreIcon from '@/assets/img/app-icons/apps.webp' import calculatorIcon from '@/assets/img/app-icons/calculator.webp' @@ -70,13 +71,32 @@ import skyRideIcon from '@/assets/img/app-icons/skyride.svg' import musicIcon from '@/assets/img/app-icons/music.svg' import featherIcon from '@/assets/img/app-icons/feather.svg' import crewLinkIcon from '@/assets/img/app-icons/crewlink.svg' +import companiesIcon from '@/assets/img/app-icons/companies.svg' import type { + BuiltinPhoneAppDefinition, + BuiltinPhoneAppId, + ExternalPhoneAppDefinition, + ExternalPhoneAppId, LaunchablePhoneAppDefinition, LaunchablePhoneAppId, PhoneAppDefinition, } from '@/types/apps' -export const PHONE_APPS: PhoneAppDefinition[] = [ +export const PHONE_APPS = shallowReactive([ + { + category: 'utilities', + component: markRaw( + defineAsyncComponent(() => import('@/views/apps/CompaniesApp.vue')), + ), + dockOrder: null, + gridOrder: 28, + icon: markRaw(Building2), + iconClass: 'app-icon--companies', + iconImage: companiesIcon, + id: 'companies', + labelKey: 'Apps.companies.name', + route: '/apps/companies', + }, { category: 'utilities', component: markRaw( @@ -567,7 +587,13 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ labelKey: 'Apps.neonDrop.name', route: '/apps/neon-drop', }, -] +]) + +const BUILTIN_PHONE_APPS = [...PHONE_APPS] as BuiltinPhoneAppDefinition[] + +export const BUILTIN_PHONE_APP_IDS: ReadonlySet = new Set( + BUILTIN_PHONE_APPS.map((app) => app.id), +) export const NON_REMOVABLE_PHONE_APP_IDS: ReadonlySet = new Set([ @@ -582,6 +608,12 @@ export const NON_REMOVABLE_PHONE_APP_IDS: ReadonlySet = export const PHONE_APP_IDS = PHONE_APPS.map((app) => app.id) +export function replaceExternalPhoneApps( + apps: ExternalPhoneAppDefinition[], +): void { + PHONE_APPS.splice(0, PHONE_APPS.length, ...BUILTIN_PHONE_APPS, ...apps) +} + export function getPhoneApp( id: string | string[] | undefined, ): PhoneAppDefinition | undefined { @@ -594,8 +626,33 @@ export function isPhoneAppId(value: string): value is LaunchablePhoneAppId { return !!app && isLaunchablePhoneApp(app) } +export function isExternalPhoneApp( + app: PhoneAppDefinition | undefined, +): app is ExternalPhoneAppDefinition { + return app?.kind === 'external' +} + +export function isValidExternalPhoneAppId( + value: string, +): value is ExternalPhoneAppId { + return /^[a-z0-9][a-z0-9._-]{1,63}$/.test(value) +} + +export function getPhoneAppLabel( + app: PhoneAppDefinition, + translate: (key: string) => string, +): string { + return app.kind === 'external' ? app.name : translate(app.labelKey) +} + +export function isPhoneAppRemovable(app: PhoneAppDefinition): boolean { + return app.kind === 'external' + ? app.removable + : !NON_REMOVABLE_PHONE_APP_IDS.has(app.id) +} + export function isLaunchablePhoneApp( app: PhoneAppDefinition, ): app is LaunchablePhoneAppDefinition { - return app.component !== null && app.route !== null + return app.kind === 'external' || app.component !== null } diff --git a/frontend/src/stores/app-catalog.test.ts b/frontend/src/stores/app-catalog.test.ts new file mode 100644 index 0000000..ada00e3 --- /dev/null +++ b/frontend/src/stores/app-catalog.test.ts @@ -0,0 +1,135 @@ +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + getPhoneApp, + isExternalPhoneApp, + replaceExternalPhoneApps, +} from '@/config/apps' +import { + normalizeExternalPhoneApp, + useAppCatalogStore, +} from '@/stores/app-catalog' + +const mocks = vi.hoisted(() => ({ + ensureAppNotificationPreferences: vi.fn(), + reconcileCatalog: vi.fn(), +})) + +vi.mock('@/stores/app-store', () => ({ + useAppStoreStore: () => ({ reconcileCatalog: mocks.reconcileCatalog }), +})) +vi.mock('@/stores/phone', () => ({ + usePhoneStore: () => ({ + ensureAppNotificationPreferences: mocks.ensureAppNotificationPreferences, + }), +})) + +function validApp(overrides: Record = {}) { + return { + bundled: false, + category: 'utilities', + icon: 'https://cfx-nui-example/web/icon.webp', + id: 'example-app', + name: 'Example App', + ownerResource: 'example_resource', + ui: 'https://cfx-nui-example/web/index.html', + ...overrides, + } +} + +describe('custom app catalog', () => { + beforeEach(() => { + setActivePinia(createPinia()) + replaceExternalPhoneApps([]) + mocks.ensureAppNotificationPreferences.mockReset() + mocks.reconcileCatalog.mockReset() + }) + + afterEach(() => { + replaceExternalPhoneApps([]) + vi.restoreAllMocks() + }) + + it('normalizes a validated external iframe app', () => { + const app = normalizeExternalPhoneApp( + validApp({ + compatibility: { fixBlur: true, unsafe: () => undefined }, + defaultInstalled: true, + iconBackground: '#1d4ed8', + permissions: ['app.close', 'app.close', 'device.storage'], + }), + ) + + expect(app).toMatchObject({ + bridgeMode: 'legacy', + capabilities: ['app.close', 'device.storage'], + compatibility: { fixBlur: true }, + defaultInstalled: true, + id: 'example-app', + iconBackground: '#1d4ed8', + kind: 'external', + readyTimeoutMs: 8000, + removable: true, + route: '/apps/example-app', + }) + }) + + it('accepts only color-shaped icon backgrounds', () => { + expect( + normalizeExternalPhoneApp( + validApp({ iconBackground: 'hsl(221 83% 53%)' }), + ), + ).toMatchObject({ iconBackground: 'hsl(221 83% 53%)' }) + expect( + normalizeExternalPhoneApp( + validApp({ + iconBackground: 'url(https://attacker.test/tracker.png)', + }), + ), + ).not.toHaveProperty('iconBackground') + }) + + it('rejects unsafe URLs and built-in id collisions', () => { + expect( + normalizeExternalPhoneApp(validApp({ ui: 'javascript:alert(1)' })), + ).toBeNull() + expect(normalizeExternalPhoneApp(validApp({ id: 'phone' }))).toBeNull() + expect( + normalizeExternalPhoneApp(validApp({ readyTimeoutMs: 31_000 })), + ).toMatchObject({ readyTimeoutMs: 8000 }) + }) + + it('replaces the runtime entries and reconciles dependent stores', () => { + const catalog = useAppCatalogStore() + + catalog.replaceCatalog({ + apps: [ + validApp(), + validApp(), + validApp({ id: 'invalid', ui: 'http://example.test' }), + ], + }) + + const app = getPhoneApp('example-app') + expect(isExternalPhoneApp(app)).toBe(true) + expect(catalog.externalApps).toHaveLength(1) + expect(mocks.ensureAppNotificationPreferences).toHaveBeenCalledWith([ + 'example-app', + ]) + expect(mocks.reconcileCatalog).toHaveBeenCalledOnce() + }) + + it('queues messages only for registered external apps', () => { + const catalog = useAppCatalogStore() + catalog.replaceCatalog({ apps: [validApp()] }) + + expect(catalog.queueHostMessage('example-app', { hello: 'world' })).toBe( + true, + ) + expect(catalog.queueHostMessage('phone', {})).toBe(false) + expect(catalog.hostMessages['example-app']).toMatchObject([ + { payload: { hello: 'world' }, sequence: 1 }, + ]) + }) +}) diff --git a/frontend/src/stores/app-catalog.ts b/frontend/src/stores/app-catalog.ts new file mode 100644 index 0000000..3c0be3d --- /dev/null +++ b/frontend/src/stores/app-catalog.ts @@ -0,0 +1,314 @@ +import { Grid2X2 } from 'lucide-vue-next' +import { defineStore } from 'pinia' +import { markRaw } from 'vue' + +import { + BUILTIN_PHONE_APP_IDS, + getPhoneApp, + isExternalPhoneApp, + isValidExternalPhoneAppId, + PHONE_APPS, + replaceExternalPhoneApps, +} from '@/config/apps' +import { useAppStoreStore } from '@/stores/app-store' +import { usePhoneStore } from '@/stores/phone' +import type { + CustomAppHostMessage, + CustomAppOpenRequest, + BuiltinPhoneAppId, + ExternalPhoneAppDefinition, + PhoneAppCategory, +} from '@/types/apps' + +const APP_CATEGORIES: ReadonlySet = new Set([ + 'games', + 'productivity', + 'shopping', + 'social', + 'utilities', +]) +const MAX_PENDING_MESSAGES = 50 +const ICON_BACKGROUND_HEX_PATTERN = + /^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i +const ICON_BACKGROUND_NAMED_PATTERN = /^[a-z]{3,32}$/i +const ICON_BACKGROUND_FUNCTION_PATTERN = + /^(?:rgb|rgba|hsl|hsla)\([\d\s.,%+\-/degraturn]+\)$/i + +function readRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null +} + +function readRequiredString( + source: Record, + key: string, + maximumLength: number, +): string | null { + const value = source[key] + if (typeof value !== 'string') return null + const normalized = value.trim() + return normalized && normalized.length <= maximumLength ? normalized : null +} + +function readOptionalString( + source: Record, + key: string, + maximumLength: number, +): string { + const value = source[key] + if (typeof value !== 'string') return '' + return value.trim().slice(0, maximumLength) +} + +function readIconBackground(value: unknown): string { + if (typeof value !== 'string') return '' + const normalized = value.trim() + if (!normalized || normalized.length > 64) return '' + + return ICON_BACKGROUND_HEX_PATTERN.test(normalized) || + ICON_BACKGROUND_NAMED_PATTERN.test(normalized) || + ICON_BACKGROUND_FUNCTION_PATTERN.test(normalized) + ? normalized + : '' +} + +function readHttpsUrl(value: unknown): string | null { + if (typeof value !== 'string' || value.length > 2048) return null + + try { + const parsed = new URL(value) + if (parsed.protocol !== 'https:' || parsed.username || parsed.password) { + return null + } + return parsed.href + } catch { + return null + } +} + +function readCapabilities(value: unknown): string[] { + if (!Array.isArray(value)) return [] + + const capabilities: string[] = [] + for (const capability of value) { + if ( + typeof capability === 'string' && + /^[a-z][a-z0-9._-]{1,63}$/.test(capability) && + !capabilities.includes(capability) + ) { + capabilities.push(capability) + } + } + return capabilities +} + +function readCompatibilityValue(value: unknown, depth = 0): unknown { + if ( + value === null || + typeof value === 'boolean' || + (typeof value === 'number' && Number.isFinite(value)) + ) { + return value + } + if (typeof value === 'string') return value.slice(0, 256) + if (depth >= 2) return undefined + if (Array.isArray(value)) { + return value + .slice(0, 32) + .map((item) => readCompatibilityValue(item, depth + 1)) + .filter((item) => item !== undefined) + } + + const source = readRecord(value) + if (!source) return undefined + const result: Record = {} + for (const [key, item] of Object.entries(source).slice(0, 32)) { + if (!/^[A-Za-z0-9_.-]{1,64}$/.test(key)) continue + const normalized = readCompatibilityValue(item, depth + 1) + if (normalized !== undefined) result[key] = normalized + } + return result +} + +function readCompatibility(value: unknown): Record { + const source = readRecord(value) + if (!source) return {} + return (readCompatibilityValue(source) as Record) ?? {} +} + +export function normalizeExternalPhoneApp( + value: unknown, + fallbackOrder = 0, +): ExternalPhoneAppDefinition | null { + const source = readRecord(value) + if (!source) return null + + const id = readRequiredString(source, 'id', 64) + const name = readRequiredString(source, 'name', 64) + const ownerResource = readRequiredString(source, 'ownerResource', 128) + const ui = readHttpsUrl(source.ui) + const icon = readHttpsUrl(source.icon) + if ( + !id || + !isValidExternalPhoneAppId(id) || + BUILTIN_PHONE_APP_IDS.has(id as BuiltinPhoneAppId) || + !name || + !ownerResource || + !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(ownerResource) || + !ui || + !icon + ) { + return null + } + + const category = APP_CATEGORIES.has(source.category as PhoneAppCategory) + ? (source.category as PhoneAppCategory) + : 'utilities' + const bundled = source.bundled === true + const gridOrder = + typeof source.gridOrder === 'number' && + Number.isFinite(source.gridOrder) && + source.gridOrder >= 0 + ? Math.floor(source.gridOrder) + : 1000 + fallbackOrder + const iconBackground = readIconBackground(source.iconBackground) + + return { + bridgeMode: + source.bridgeMode === 'sky' || source.bridgeMode === 'legacy' + ? source.bridgeMode + : bundled + ? 'sky' + : 'legacy', + bundled, + capabilities: readCapabilities(source.capabilities ?? source.permissions), + category, + component: null, + compatibility: readCompatibility(source.compatibility), + defaultInstalled: source.defaultInstalled === true, + description: readOptionalString(source, 'description', 320), + developer: readOptionalString(source, 'developer', 96), + dockOrder: null, + gridOrder, + icon: markRaw(Grid2X2), + ...(iconBackground ? { iconBackground } : {}), + iconClass: 'app-icon--custom', + iconImage: icon, + id, + kind: 'external', + name, + orientation: source.orientation === 'landscape' ? 'landscape' : 'portrait', + ownerResource, + readyTimeoutMs: + typeof source.readyTimeoutMs === 'number' && + Number.isFinite(source.readyTimeoutMs) && + source.readyTimeoutMs >= 1000 && + source.readyTimeoutMs <= 30_000 + ? Math.floor(source.readyTimeoutMs) + : 8000, + removable: source.removable !== false, + route: `/apps/${id}`, + ui, + } +} + +export const useAppCatalogStore = defineStore('app-catalog', { + state: () => ({ + externalApps: [] as ExternalPhoneAppDefinition[], + hostMessages: {} as Record, + nextSequence: 1, + openRequests: {} as Record, + }), + getters: { + apps: () => PHONE_APPS, + }, + actions: { + replaceCatalog(payload: unknown): void { + const source = readRecord(payload) + if (!source || !Array.isArray(source.apps)) { + console.error('[Custom apps] Ignored an invalid catalog payload.') + return + } + + const seenIds = new Set() + const apps: ExternalPhoneAppDefinition[] = [] + for (const [index, value] of source.apps.entries()) { + const app = normalizeExternalPhoneApp(value, index) + if (!app) { + console.error( + `[Custom apps] Ignored invalid catalog entry at index ${index}.`, + ) + continue + } + if (seenIds.has(app.id)) { + console.error(`[Custom apps] Ignored duplicate app id: ${app.id}`) + continue + } + seenIds.add(app.id) + apps.push(app) + } + + this.externalApps = apps + replaceExternalPhoneApps(apps) + + for (const appId of Object.keys(this.hostMessages)) { + if (!seenIds.has(appId)) delete this.hostMessages[appId] + } + for (const appId of Object.keys(this.openRequests)) { + if (!seenIds.has(appId)) delete this.openRequests[appId] + } + + usePhoneStore().ensureAppNotificationPreferences( + apps.map((app) => app.id), + ) + useAppStoreStore().reconcileCatalog() + }, + queueHostMessage(appId: string, payload: unknown): boolean { + const app = getPhoneApp(appId) + if (!isExternalPhoneApp(app)) { + console.error( + `[Custom apps] Message target is not registered: ${appId}`, + ) + return false + } + + const messages = this.hostMessages[appId] ?? [] + messages.push({ payload, sequence: this.nextSequence }) + this.nextSequence += 1 + if (messages.length > MAX_PENDING_MESSAGES) { + console.error( + `[Custom apps] Pending message limit reached for ${appId}; dropped the oldest message.`, + ) + } + this.hostMessages[appId] = messages.slice(-MAX_PENDING_MESSAGES) + return true + }, + consumeHostMessages(appId: string, throughSequence: number): void { + const remaining = (this.hostMessages[appId] ?? []).filter( + (message) => message.sequence > throughSequence, + ) + if (remaining.length) this.hostMessages[appId] = remaining + else delete this.hostMessages[appId] + }, + requestOpen(appId: string, data?: unknown): boolean { + const app = getPhoneApp(appId) + if (!isExternalPhoneApp(app)) { + console.error(`[Custom apps] Open target is not registered: ${appId}`) + return false + } + + this.openRequests[appId] = { + ...(data === undefined ? {} : { data }), + sequence: this.nextSequence, + } + this.nextSequence += 1 + return true + }, + consumeOpenRequest(appId: string, sequence: number): void { + if (this.openRequests[appId]?.sequence === sequence) { + delete this.openRequests[appId] + } + }, + }, +}) diff --git a/frontend/src/stores/app-store.test.ts b/frontend/src/stores/app-store.test.ts index c09d8da..a57a421 100644 --- a/frontend/src/stores/app-store.test.ts +++ b/frontend/src/stores/app-store.test.ts @@ -5,17 +5,23 @@ import { NON_REMOVABLE_PHONE_APP_IDS } from '@/config/apps' import { useAppStoreStore } from '@/stores/app-store' import { removeHomeApp } from '@/utils/homeLayout' -const mocks = vi.hoisted(() => ({ saveDeviceNamespace: vi.fn() })) +const mocks = vi.hoisted(() => ({ + phone: { + device: { imei: 'phone-a' }, + isOpen: true, + saveDeviceNamespace: vi.fn(), + }, +})) vi.mock('@/stores/phone', () => ({ - usePhoneStore: () => ({ - saveDeviceNamespace: mocks.saveDeviceNamespace, - }), + usePhoneStore: () => mocks.phone, })) describe('app store', () => { beforeEach(() => { setActivePinia(createPinia()) - mocks.saveDeviceNamespace.mockReset() + mocks.phone.device.imei = 'phone-a' + mocks.phone.isOpen = true + mocks.phone.saveDeviceNamespace.mockReset() }) afterEach(() => { @@ -32,7 +38,7 @@ describe('app store', () => { apps.recordLaunch('mail') expect(apps.launchCounts).toEqual({ mail: 4 }) - expect(mocks.saveDeviceNamespace).toHaveBeenCalledWith('apps', { + expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledWith('apps', { claimedApps: ['snake'], homeLayout: apps.homeLayout, launchCounts: { mail: 4 }, @@ -54,7 +60,7 @@ describe('app store', () => { vi.advanceTimersByTime(1) expect(apps.installingApps.snake).toBeUndefined() expect(apps.claimedApps).toEqual(['snake']) - expect(mocks.saveDeviceNamespace).toHaveBeenCalledWith('apps', { + expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledWith('apps', { claimedApps: ['snake'], homeLayout: apps.homeLayout, launchCounts: {}, @@ -71,7 +77,7 @@ describe('app store', () => { vi.advanceTimersByTime(3000) expect(apps.claimedApps).toEqual(['memory', 'snake']) - expect(mocks.saveDeviceNamespace).toHaveBeenCalledTimes(1) + expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1) }) it('reinstalls core and claimed apps removed from the Home Screen', () => { @@ -81,7 +87,7 @@ describe('app store', () => { apps.hydrate({ claimedApps: ['memory'] }) apps.removeHomeApp('notes') apps.removeHomeApp('memory') - mocks.saveDeviceNamespace.mockClear() + mocks.phone.saveDeviceNamespace.mockClear() apps.installApp('notes') apps.installApp('memory') @@ -96,13 +102,13 @@ describe('app store', () => { expect(apps.homeLayout.grid).toContain('notes') expect(apps.homeLayout.grid).toContain('memory') expect(apps.claimedApps).toEqual(['memory']) - expect(mocks.saveDeviceNamespace).toHaveBeenCalledTimes(2) + expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(2) }) it('prevents protected apps from being removed from the Home Screen', () => { const apps = useAppStoreStore() apps.hydrate(null) - mocks.saveDeviceNamespace.mockClear() + mocks.phone.saveDeviceNamespace.mockClear() expect([...NON_REMOVABLE_PHONE_APP_IDS]).toEqual([ 'app-store', @@ -118,7 +124,7 @@ describe('app store', () => { expect(apps.homeLayout.hidden).not.toContain(appId) } - expect(mocks.saveDeviceNamespace).not.toHaveBeenCalled() + expect(mocks.phone.saveDeviceNamespace).not.toHaveBeenCalled() }) it('restores protected apps hidden by older persisted layouts', () => { @@ -129,7 +135,7 @@ describe('app store', () => { expect(apps.homeLayout.hidden).not.toContain('mail') expect(apps.homeLayout.grid).toContain('mail') - expect(mocks.saveDeviceNamespace).toHaveBeenCalledWith('apps', { + expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledWith('apps', { claimedApps: [], homeLayout: apps.homeLayout, launchCounts: {}, @@ -147,10 +153,54 @@ describe('app store', () => { apps.removeHomeApp('notes') expect(apps.homeLayout.grid).not.toContain('notes') expect(apps.homeLayout.hidden).toContain('notes') - expect(mocks.saveDeviceNamespace).toHaveBeenLastCalledWith('apps', { + expect(mocks.phone.saveDeviceNamespace).toHaveBeenLastCalledWith('apps', { claimedApps: [], homeLayout: apps.homeLayout, launchCounts: {}, }) }) + + it('does not commit an installation to a different phone', () => { + vi.useFakeTimers() + const apps = useAppStoreStore() + + apps.installApp('snake') + mocks.phone.device.imei = 'phone-b' + vi.advanceTimersByTime(3000) + + expect(apps.installingApps).toEqual({}) + expect(apps.claimedApps).toEqual([]) + expect(mocks.phone.saveDeviceNamespace).not.toHaveBeenCalled() + }) + + it('cancels installation timers when hydration changes device scope', () => { + vi.useFakeTimers() + const apps = useAppStoreStore() + + apps.installApp('snake') + expect(vi.getTimerCount()).toBe(1) + + mocks.phone.device.imei = 'phone-b' + apps.hydrate(null) + expect(vi.getTimerCount()).toBe(0) + + vi.advanceTimersByTime(3000) + expect(apps.claimedApps).toEqual([]) + expect(mocks.phone.saveDeviceNamespace).not.toHaveBeenCalled() + }) + + it('cancels installation timers when the phone closes', () => { + vi.useFakeTimers() + const apps = useAppStoreStore() + + apps.installApp('snake') + mocks.phone.isOpen = false + apps.cancelPendingInstalls() + + expect(vi.getTimerCount()).toBe(0) + expect(apps.installingApps).toEqual({}) + vi.advanceTimersByTime(3000) + expect(apps.claimedApps).toEqual([]) + expect(mocks.phone.saveDeviceNamespace).not.toHaveBeenCalled() + }) }) diff --git a/frontend/src/stores/app-store.ts b/frontend/src/stores/app-store.ts index 401cb9c..3c37870 100644 --- a/frontend/src/stores/app-store.ts +++ b/frontend/src/stores/app-store.ts @@ -1,7 +1,11 @@ import { defineStore } from 'pinia' import { + getPhoneApp, + isExternalPhoneApp, isPhoneAppId, + isPhoneAppRemovable, + isValidExternalPhoneAppId, NON_REMOVABLE_PHONE_APP_IDS, PHONE_APPS, } from '@/config/apps' @@ -17,26 +21,55 @@ import { restoreHomeApp, type HomeArea, } from '@/utils/homeLayout' +import { nuiCall } from '@/utils/nui' const INSTALL_DURATION_MS = 3000 -const DEFAULT_GRID_IDS = [...PHONE_APPS] - .sort((a, b) => a.gridOrder - b.gridOrder) - .map((app) => app.id) -const DEFAULT_DOCK_IDS = PHONE_APPS.filter((app) => app.dockOrder !== null) - .sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0)) - .map((app) => app.id) -const CORE_APP_IDS = PHONE_APPS.filter((app) => app.category !== 'games').map( - (app) => app.id, -) + +type PendingInstallation = { + deviceImei: string + timer: ReturnType + token: symbol +} + +const pendingInstallations = new WeakMap< + object, + Map +>() + +function getDefaultGridIds(): LaunchablePhoneAppId[] { + return [...PHONE_APPS] + .sort((a, b) => a.gridOrder - b.gridOrder) + .map((app) => app.id) +} + +function getDefaultDockIds(): LaunchablePhoneAppId[] { + return PHONE_APPS.filter((app) => app.dockOrder !== null) + .sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0)) + .map((app) => app.id) +} + +function getDefaultInstalledIds(): LaunchablePhoneAppId[] { + return PHONE_APPS.filter((app) => + isExternalPhoneApp(app) ? app.defaultInstalled : app.category !== 'games', + ).map((app) => app.id) +} + +function isProtectedHomeApp(appId: LaunchablePhoneAppId): boolean { + const app = getPhoneApp(appId) + return app + ? !isPhoneAppRemovable(app) + : NON_REMOVABLE_PHONE_APP_IDS.has(appId) +} export const useAppStoreStore = defineStore('app-store', { state: () => ({ claimedApps: [] as LaunchablePhoneAppId[], homeLayout: createDefaultHomeLayout( - CORE_APP_IDS, - DEFAULT_GRID_IDS, - DEFAULT_DOCK_IDS, + getDefaultInstalledIds(), + getDefaultGridIds(), + getDefaultDockIds(), ), + hydrated: false, installingApps: {} as Partial>, launchCounts: {} as Partial>, }), @@ -62,9 +95,18 @@ export const useAppStoreStore = defineStore('app-store', { this.persist() } }, + cancelPendingInstalls(): void { + const installations = pendingInstallations.get(this) + if (installations) { + for (const installation of installations.values()) { + globalThis.clearTimeout(installation.timer) + } + pendingInstallations.delete(this) + } + this.installingApps = {} + }, installApp(id: LaunchablePhoneAppId): void { - const installed = - CORE_APP_IDS.includes(id) || this.claimedApps.includes(id) + const installed = this.isInstalled(id) if ( this.installingApps[id] || (installed && !this.homeLayout.hidden.includes(id)) @@ -72,51 +114,111 @@ export const useAppStoreStore = defineStore('app-store', { return } + const phone = usePhoneStore() + const deviceImei = phone.device?.imei + if (!phone.isOpen || !deviceImei) { + console.error( + `[App store] Installation cancelled because no phone device is open for ${id}.`, + ) + return + } + + const app = getPhoneApp(id) + const reportInstall = !installed && isExternalPhoneApp(app) + const token = Symbol(id) + const installations = + pendingInstallations.get(this) ?? + new Map() this.installingApps[id] = true - globalThis.setTimeout(() => { - if (CORE_APP_IDS.includes(id) || this.claimedApps.includes(id)) { + const timer = globalThis.setTimeout(() => { + const pending = installations.get(id) + if (!pending || pending.token !== token) return + installations.delete(id) + if (!installations.size) pendingInstallations.delete(this) + + const activePhone = usePhoneStore() + if ( + !activePhone.isOpen || + activePhone.device?.imei !== pending.deviceImei + ) { + delete this.installingApps[id] + console.error( + `[App store] Installation cancelled because the active phone changed for ${id}.`, + ) + return + } + if (reportInstall && !isExternalPhoneApp(getPhoneApp(id))) { + delete this.installingApps[id] + console.error( + `[Custom apps] Installation cancelled because ${id} is no longer registered.`, + ) + return + } + if (this.isInstalled(id)) { this.restoreHomeApp(id) } else { this.claimApp(id) } delete this.installingApps[id] + if (reportInstall) { + void nuiCall('custom-app:lifecycle', { + appId: id, + event: 'install', + }).then((response) => { + if (!response.success) { + console.error( + `[Custom apps] Install lifecycle failed for ${id}: ${response.error ?? 'request_failed'}`, + ) + } + }) + } }, INSTALL_DURATION_MS) + installations.set(id, { deviceImei, timer, token }) + pendingInstallations.set(this, installations) }, hydrate(payload: unknown): void { + this.cancelPendingInstalls() const data = payload as { claimedApps?: unknown homeLayout?: unknown launchCounts?: unknown } | null + const layoutVersion = + data?.homeLayout && typeof data.homeLayout === 'object' + ? (data.homeLayout as { version?: unknown }).version + : undefined this.claimedApps = Array.isArray(data?.claimedApps) ? data.claimedApps.filter( (id): id is LaunchablePhoneAppId => - typeof id === 'string' && isPhoneAppId(id), + typeof id === 'string' && + (isPhoneAppId(id) || + (layoutVersion === 3 && isValidExternalPhoneAppId(id))), ) : [] - const installedIds = [...CORE_APP_IDS, ...this.claimedApps] + const installedIds = [ + ...new Set([...getDefaultInstalledIds(), ...this.claimedApps]), + ] const defaults = createDefaultHomeLayout( installedIds, - DEFAULT_GRID_IDS, - DEFAULT_DOCK_IDS, + getDefaultGridIds(), + getDefaultDockIds(), ) this.homeLayout = parseHomeLayout( data?.homeLayout, defaults, installedIds, ) - const protectedHiddenAppIds = this.homeLayout.hidden.filter((id) => - NON_REMOVABLE_PHONE_APP_IDS.has(id), - ) + const protectedHiddenAppIds = + this.homeLayout.hidden.filter(isProtectedHomeApp) for (const appId of protectedHiddenAppIds) { this.homeLayout = restoreHomeApp(this.homeLayout, appId) } - this.installingApps = {} this.launchCounts = {} if (data?.launchCounts && typeof data.launchCounts === 'object') { for (const [appId, count] of Object.entries(data.launchCounts)) { if ( - isPhoneAppId(appId) && + (isPhoneAppId(appId) || + (layoutVersion === 3 && isValidExternalPhoneAppId(appId))) && typeof count === 'number' && Number.isFinite(count) && count > 0 @@ -125,8 +227,39 @@ export const useAppStoreStore = defineStore('app-store', { } } } + this.hydrated = true if (protectedHiddenAppIds.length) this.persist() }, + isInstalled(appId: LaunchablePhoneAppId): boolean { + if (this.claimedApps.includes(appId)) return true + const app = getPhoneApp(appId) + if (!app) return false + return isExternalPhoneApp(app) + ? app.defaultInstalled + : app.category !== 'games' + }, + reconcileCatalog(): void { + const installedIds = [ + ...new Set([...getDefaultInstalledIds(), ...this.claimedApps]), + ] + const defaults = createDefaultHomeLayout( + installedIds, + getDefaultGridIds(), + getDefaultDockIds(), + ) + const previous = JSON.stringify(this.homeLayout) + this.homeLayout = parseHomeLayout(this.homeLayout, defaults, installedIds) + + for (const appId of [...this.homeLayout.hidden]) { + if (isProtectedHomeApp(appId)) { + this.homeLayout = restoreHomeApp(this.homeLayout, appId) + } + } + + if (this.hydrated && previous !== JSON.stringify(this.homeLayout)) { + this.persist() + } + }, recordLaunch(appId: LaunchablePhoneAppId): void { this.launchCounts[appId] = (this.launchCounts[appId] ?? 0) + 1 this.persist() @@ -147,7 +280,7 @@ export const useAppStoreStore = defineStore('app-store', { this.persist() }, removeHomeApp(appId: LaunchablePhoneAppId): void { - if (NON_REMOVABLE_PHONE_APP_IDS.has(appId)) return + if (isProtectedHomeApp(appId)) return this.homeLayout = removeHomeApp(this.homeLayout, appId) this.persist() diff --git a/frontend/src/stores/companies.test.ts b/frontend/src/stores/companies.test.ts new file mode 100644 index 0000000..2a49e89 --- /dev/null +++ b/frontend/src/stores/companies.test.ts @@ -0,0 +1,327 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useCompaniesStore } from '@/stores/companies' +import type { + Company, + CompanyDirectoryFilters, + CompanyRequest, + CompanySummary, +} from '@/types/companies' +import { nuiCall } from '@/utils/nui' + +vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() })) + +const mockNuiCall = vi.mocked(nuiCall) + +const summary: CompanySummary = { + acceptsRequests: true, + announcement: null, + availability: 'available', + availabilityUpdatedAt: '2026-08-10T10:00:00.000Z', + canCall: true, + canMessage: false, + categoryId: 'public', + categoryName: 'Public services', + description: 'City emergency service.', + id: 'police', + location: { + address: 'Mission Row', + coords: { x: 441, y: -981, z: 30 }, + district: 'Mission Row', + label: 'Mission Row Station', + }, + logoUrl: null, + name: 'Los Santos Police', + phoneNumber: '911', + serviceSummary: 'Emergency response', + verified: true, +} + +const company: Company = { + ...summary, + coverUrl: null, + hours: [], + revision: 3, + services: [], +} + +const request: CompanyRequest = { + actions: { + allowedStatuses: ['in_progress'], + canAssign: false, + canCall: true, + canCancel: true, + canClaim: false, + canReply: true, + }, + assignedLabel: null, + companyId: company.id, + companyLogoUrl: null, + companyName: company.name, + createdAt: '2026-08-10T10:00:00.000Z', + description: 'I need assistance.', + events: [], + id: 'request-1', + media: [], + messages: [], + phoneNumber: '911', + revision: 1, + serviceId: 'response', + serviceName: 'Emergency response', + status: 'new', + subject: 'Help needed', + unreadCount: 1, + updatedAt: '2026-08-10T10:00:00.000Z', +} + +const filters: CompanyDirectoryFilters = { + acceptsRequests: false, + availability: null, + categoryId: null, + hasLocation: false, + search: '', + sort: 'relevance', +} + +describe('companies store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + mockNuiCall.mockReset() + }) + + it('loads, filters and deduplicates cursor pages', async () => { + mockNuiCall + .mockResolvedValueOnce({ + data: { + categories: [{ id: 'public', name: 'Public services' }], + companies: [summary], + nextCursor: 'page-2', + }, + success: true, + }) + .mockResolvedValueOnce({ + data: { + categories: [], + companies: [ + { ...summary, description: 'Updated description' }, + { ...summary, id: 'medical', name: 'Los Santos Medical' }, + ], + nextCursor: null, + }, + success: true, + }) + const store = useCompaniesStore() + + expect(await store.loadCompanies(filters)).toBe(true) + expect(await store.loadCompanies(filters, true)).toBe(true) + + expect(store.directory.map((item) => item.id)).toEqual([ + 'police', + 'medical', + ]) + expect(store.directory[0].description).toBe('Updated description') + expect(mockNuiCall).toHaveBeenNthCalledWith(2, 'companies:list', { + acceptsRequests: false, + availability: null, + categoryId: null, + cursor: 'page-2', + hasLocation: false, + search: '', + sort: 'relevance', + }) + }) + + it('uses server-provided unread counts for the app badge', async () => { + mockNuiCall.mockResolvedValueOnce({ + data: { + nextCursor: null, + requests: [request], + unreadCount: 4, + }, + success: true, + }) + const store = useCompaniesStore() + + await store.loadMyRequests('open') + store.applyUnreadCounts({ work: 2 }) + + expect(store.customerUnreadCount).toBe(4) + expect(store.workUnreadCount).toBe(2) + expect(store.unreadCount).toBe(6) + }) + + it('does not refresh directory data before the app has loaded it', async () => { + const store = useCompaniesStore() + + await store.applyChanged({ area: 'directory', companyId: 'police' }) + + expect(mockNuiCall).not.toHaveBeenCalled() + + mockNuiCall.mockResolvedValue({ + data: { categories: [], companies: [summary], nextCursor: null }, + success: true, + }) + await store.loadCompanies(filters) + mockNuiCall.mockClear() + + await store.applyChanged({ area: 'directory', companyId: 'police' }) + + expect(mockNuiCall).toHaveBeenCalledOnce() + }) + + it('clears customer badge state when the active device has no usable SIM', async () => { + mockNuiCall.mockResolvedValueOnce({ error: 'no_sim', success: false }) + const store = useCompaniesStore() + store.customerUnreadCount = 4 + + expect(await store.loadMyRequests('open')).toBe(false) + expect(store.customerUnreadCount).toBe(0) + }) + + it('drops SIM-scoped request data when the active device changes', () => { + const store = useCompaniesStore() + store.bindDeviceScope('device-a', 'sim-a') + store.customerUnreadCount = 3 + store.myRequests = [request] + store.request = request + + store.bindDeviceScope('device-a', 'sim-b') + + expect(store.customerUnreadCount).toBe(0) + expect(store.myRequests).toEqual([]) + expect(store.request).toBeNull() + }) + + it('keeps server-resolved request media in the loaded thread', async () => { + const requestWithMedia = { + ...request, + media: [{ id: 12, url: 'https://example.test/request-photo.jpg' }], + } + mockNuiCall.mockResolvedValueOnce({ + data: { request: requestWithMedia }, + success: true, + }) + const store = useCompaniesStore() + + expect(await store.loadRequest(request.id)).toBe(true) + expect(store.request?.media).toEqual(requestWithMedia.media) + }) + + it('refreshes counts without replacing the selected request list', async () => { + mockNuiCall + .mockResolvedValueOnce({ + data: { nextCursor: null, requests: [], unreadCount: 3 }, + success: true, + }) + .mockResolvedValueOnce({ + data: { + context: { + authorized: false, + callAvailable: false, + company: null, + metrics: { assigned: 0, completedToday: 0, new: 0, waiting: 0 }, + ownRequests: [], + permissions: { + canAssign: false, + canManageAnnouncement: false, + canManageHours: false, + canManageProfile: false, + canManageServices: false, + canSetAvailability: false, + canTakeCalls: false, + }, + recentRequests: [], + role: null, + unreadCount: 0, + }, + }, + success: true, + }) + const store = useCompaniesStore() + store.myRequestsList = 'closed' + + await store.refreshUnreadCounts() + + expect(mockNuiCall).toHaveBeenCalledWith('companies:my-requests', { + cursor: null, + list: 'closed', + }) + }) + + it('sends only server-resolved request identifiers when claiming', async () => { + mockNuiCall.mockResolvedValueOnce({ + data: { request: { ...request, revision: 2, status: 'assigned' } }, + success: true, + }) + const store = useCompaniesStore() + + await store.claimRequest(request.id, request.revision) + + expect(mockNuiCall).toHaveBeenCalledWith('companies:claim-request', { + requestId: request.id, + revision: request.revision, + }) + expect(store.request?.status).toBe('assigned') + }) + + it('does not apply manager edits until the server confirms them', async () => { + mockNuiCall.mockResolvedValueOnce({ + error: 'revision_conflict', + success: false, + }) + const store = useCompaniesStore() + store.company = company + + await store.updateProfile({ + acceptsRequests: false, + address: 'New address', + description: 'Changed locally', + district: 'Downtown', + locationLabel: 'Office', + revision: company.revision, + }) + + expect(store.company).toEqual(company) + expect(store.mutationError).toBe('revision_conflict') + }) + + it('includes the current revision when availability changes', async () => { + mockNuiCall.mockResolvedValueOnce({ + data: { company: { ...company, availability: 'busy', revision: 4 } }, + success: true, + }) + const store = useCompaniesStore() + + await store.updateAvailability('busy', 3) + + expect(mockNuiCall).toHaveBeenCalledWith('companies:update-availability', { + availability: 'busy', + revision: 3, + }) + }) + + it('creates requests without client-owned identity fields', async () => { + mockNuiCall.mockResolvedValueOnce({ + data: { request }, + success: true, + }) + const store = useCompaniesStore() + + await store.createRequest({ + companyId: 'police', + description: 'I need assistance.', + mediaIds: ['10'], + serviceId: 'response', + subject: 'Help needed', + }) + + expect(mockNuiCall).toHaveBeenCalledWith('companies:create-request', { + companyId: 'police', + description: 'I need assistance.', + mediaIds: ['10'], + serviceId: 'response', + subject: 'Help needed', + }) + }) +}) diff --git a/frontend/src/stores/companies.ts b/frontend/src/stores/companies.ts new file mode 100644 index 0000000..4e46162 --- /dev/null +++ b/frontend/src/stores/companies.ts @@ -0,0 +1,539 @@ +import { defineStore } from 'pinia' + +import type { + Company, + CompanyAvailability, + CompanyChangedPayload, + CompanyDirectoryFilters, + CompanyDirectoryPage, + CompanyHours, + CompanyMember, + CompanyMembersResult, + CompanyMutationResult, + CompanyRequest, + CompanyRequestList, + CompanyRequestMutationResult, + CompanyRequestPage, + CompanyRequestStatus, + CompanyRequestSummary, + CompanyService, + CompanySummary, + CompanyUnreadCounts, + CompanyWorkContext, + CompanyWorkFilter, + CompanyWorkQueuePage, + CreateCompanyRequest, + PublishCompanyAnnouncement, + UpdateCompanyProfile, +} from '@/types/companies' +import type { PhoneCall } from '@/types/phone' +import { nuiCall, type NuiResponse } from '@/utils/nui' + +function mergeById(current: T[], incoming: T[]): T[] { + const merged = new Map(current.map((item) => [item.id, item])) + for (const item of incoming) merged.set(item.id, item) + return [...merged.values()] +} + +export const useCompaniesStore = defineStore('companies', { + state: () => ({ + categories: [] as CompanyDirectoryPage['categories'], + company: null as Company | null, + customerUnreadCount: 0, + directory: [] as CompanySummary[], + directoryFilters: { + acceptsRequests: false, + availability: null, + categoryId: null, + hasLocation: false, + search: '', + sort: 'relevance', + } as CompanyDirectoryFilters, + directoryError: '', + directoryLoaded: false, + directoryLoading: false, + directoryLoadingMore: false, + directoryNextCursor: null as string | null, + deviceScopeKey: '', + deviceScopeVersion: 0, + members: [] as CompanyMember[], + membersLoading: false, + mutationError: '', + mutating: false, + myRequests: [] as CompanyRequestSummary[], + myRequestsList: 'open' as CompanyRequestList, + myRequestsError: '', + myRequestsLoaded: false, + myRequestsLoading: false, + myRequestsLoadingMore: false, + myRequestsNextCursor: null as string | null, + request: null as CompanyRequest | null, + requestError: '', + requestLoading: false, + workContext: null as CompanyWorkContext | null, + workContextError: '', + workContextLoaded: false, + workContextLoading: false, + workQueue: [] as CompanyRequestSummary[], + workQueueFilter: 'new' as CompanyWorkFilter, + workQueueError: '', + workQueueLoading: false, + workQueueLoadingMore: false, + workQueueNextCursor: null as string | null, + workUnreadCount: 0, + }), + getters: { + unreadCount: (state): number => + state.customerUnreadCount + state.workUnreadCount, + }, + actions: { + bindDeviceScope(imei: string, simId: string | null): void { + const key = `${imei}:${simId ?? 'no-sim'}` + if (!this.deviceScopeKey) { + this.deviceScopeKey = key + return + } + if (this.deviceScopeKey === key) return + this.deviceScopeKey = key + this.resetDeviceScope() + }, + applyUnreadCounts(counts: CompanyUnreadCounts): void { + if (typeof counts.customer === 'number') { + this.customerUnreadCount = Math.max(0, Math.floor(counts.customer)) + } + if (typeof counts.work === 'number') { + this.workUnreadCount = Math.max(0, Math.floor(counts.work)) + } + }, + async refreshUnreadCounts(): Promise { + await Promise.all([ + this.loadMyRequests(this.myRequestsList), + this.loadWorkContext(), + ]) + }, + async applyChanged(change: CompanyChangedPayload): Promise { + const refreshDirectory = + this.directoryLoaded && + (change.area === 'all' || change.area === 'directory') + const refreshCustomer = + this.myRequestsLoaded && + (change.area === 'all' || change.area === 'customer') + const refreshWork = + this.workContextLoaded && + (change.area === 'all' || change.area === 'work') + const tasks: Promise[] = [] + if (refreshDirectory) { + tasks.push(this.loadCompanies(this.directoryFilters)) + if (change.companyId && this.company?.id === change.companyId) { + tasks.push(this.loadCompany(change.companyId)) + } + } + if (refreshCustomer) tasks.push(this.loadMyRequests(this.myRequestsList)) + if (refreshWork) { + tasks.push( + this.loadWorkContext().then((loaded) => + loaded && this.workContext?.authorized + ? this.loadWorkQueue(this.workQueueFilter) + : false, + ), + ) + } + if ( + change.requestId && + this.request?.id === change.requestId && + (refreshCustomer || refreshWork) + ) { + tasks.push(this.loadRequest(change.requestId)) + } + await Promise.all(tasks) + }, + async loadCompanies( + filters: CompanyDirectoryFilters, + append = false, + ): Promise { + if (append && (!this.directoryNextCursor || this.directoryLoadingMore)) { + return false + } + if (append) this.directoryLoadingMore = true + else this.directoryLoading = true + this.directoryLoaded = true + this.directoryFilters = { ...filters } + const response = await nuiCall('companies:list', { + acceptsRequests: filters.acceptsRequests, + availability: filters.availability, + categoryId: filters.categoryId, + cursor: append ? this.directoryNextCursor : null, + hasLocation: filters.hasLocation, + search: filters.search, + sort: filters.sort, + }) + this.directoryLoading = false + this.directoryLoadingMore = false + if (!response.success || !response.data) { + this.directoryError = response.error ?? 'request_failed' + if (!append) { + this.directory = [] + this.directoryNextCursor = null + } + return false + } + this.categories = response.data.categories ?? this.categories + this.directory = append + ? mergeById(this.directory, response.data.companies) + : response.data.companies + this.directoryNextCursor = response.data.nextCursor + this.directoryError = '' + return true + }, + async loadCompany(companyId: string): Promise { + this.directoryError = '' + this.directoryLoading = true + const response = await nuiCall<{ company: Company }>('companies:get', { + companyId, + }) + this.directoryLoading = false + if (!response.success || !response.data?.company) { + this.directoryError = response.error ?? 'request_failed' + return false + } + this.company = response.data.company + this.replaceCompanySummary(response.data.company) + return true + }, + async loadMyRequests( + list: CompanyRequestList, + append = false, + ): Promise { + if ( + append && + (!this.myRequestsNextCursor || this.myRequestsLoadingMore) + ) { + return false + } + if (append) this.myRequestsLoadingMore = true + else this.myRequestsLoading = true + this.myRequestsLoaded = true + this.myRequestsList = list + const deviceScopeVersion = this.deviceScopeVersion + const response = await nuiCall( + 'companies:my-requests', + { + cursor: append ? this.myRequestsNextCursor : null, + list, + }, + ) + this.myRequestsLoading = false + this.myRequestsLoadingMore = false + if (deviceScopeVersion !== this.deviceScopeVersion) return false + if (!response.success || !response.data) { + this.myRequestsError = response.error ?? 'request_failed' + if (!append) { + this.myRequests = [] + this.myRequestsNextCursor = null + if ( + response.error === 'anonymous_sim' || + response.error === 'device_not_found' || + response.error === 'no_sim' + ) { + this.customerUnreadCount = 0 + } + } + return false + } + this.myRequests = append + ? mergeById(this.myRequests, response.data.requests) + : response.data.requests + this.myRequestsNextCursor = response.data.nextCursor + this.customerUnreadCount = Math.max(0, response.data.unreadCount) + this.myRequestsError = '' + return true + }, + async loadRequest(requestId: string): Promise { + this.requestLoading = true + const deviceScopeVersion = this.deviceScopeVersion + const response = await nuiCall<{ request: CompanyRequest }>( + 'companies:get-request', + { requestId }, + ) + this.requestLoading = false + if (deviceScopeVersion !== this.deviceScopeVersion) return false + if (!response.success || !response.data?.request) { + this.requestError = response.error ?? 'request_failed' + return false + } + this.request = response.data.request + this.requestError = '' + this.replaceRequestSummary(response.data.request) + return true + }, + async loadWorkContext(): Promise { + this.workContextLoaded = true + this.workContextLoading = true + const response = await nuiCall<{ context: CompanyWorkContext }>( + 'companies:work-context', + ) + this.workContextLoading = false + if (!response.success || !response.data?.context) { + this.workContextError = response.error ?? 'request_failed' + return false + } + this.workContext = response.data.context + this.workUnreadCount = Math.max(0, response.data.context.unreadCount) + this.workContextError = '' + return true + }, + async loadWorkQueue( + filter: CompanyWorkFilter, + append = false, + ): Promise { + if (append && (!this.workQueueNextCursor || this.workQueueLoadingMore)) { + return false + } + if (append) this.workQueueLoadingMore = true + else this.workQueueLoading = true + this.workQueueFilter = filter + const response = await nuiCall( + 'companies:work-queue', + { + cursor: append ? this.workQueueNextCursor : null, + filter, + }, + ) + this.workQueueLoading = false + this.workQueueLoadingMore = false + if (!response.success || !response.data) { + this.workQueueError = response.error ?? 'request_failed' + if (!append) { + this.workQueue = [] + this.workQueueNextCursor = null + } + return false + } + this.workQueue = append + ? mergeById(this.workQueue, response.data.requests) + : response.data.requests + this.workQueueNextCursor = response.data.nextCursor + this.workQueueError = '' + return true + }, + async loadMembers(): Promise { + this.membersLoading = true + const response = await nuiCall( + 'companies:list-members', + ) + this.membersLoading = false + if (!response.success || !response.data) { + this.mutationError = response.error ?? 'request_failed' + return false + } + this.members = response.data.members + this.mutationError = '' + return true + }, + async createRequest( + draft: CreateCompanyRequest, + ): Promise> { + return this.mutateRequest('companies:create-request', draft) + }, + async cancelRequest( + requestId: string, + revision: number, + ): Promise> { + return this.mutateRequest('companies:cancel-request', { + requestId, + revision, + }) + }, + async sendMessage( + requestId: string, + body: string, + revision: number, + ): Promise> { + return this.mutateRequest('companies:send-message', { + body, + requestId, + revision, + }) + }, + async claimRequest( + requestId: string, + revision: number, + ): Promise> { + return this.mutateRequest('companies:claim-request', { + requestId, + revision, + }) + }, + async assignRequest( + requestId: string, + memberId: string, + revision: number, + ): Promise> { + return this.mutateRequest('companies:assign-request', { + memberId, + requestId, + revision, + }) + }, + async updateRequestStatus( + requestId: string, + status: CompanyRequestStatus, + revision: number, + ): Promise> { + return this.mutateRequest('companies:update-request-status', { + requestId, + revision, + status, + }) + }, + async updateAvailability( + availability: CompanyAvailability, + revision: number, + ): Promise> { + return this.mutateCompany('companies:update-availability', { + availability, + revision, + }) + }, + async updateProfile( + profile: UpdateCompanyProfile, + ): Promise> { + return this.mutateCompany('companies:update-profile', profile) + }, + async updateHours( + revision: number, + hours: CompanyHours[], + ): Promise> { + return this.mutateCompany('companies:update-hours', { hours, revision }) + }, + async updateServices( + revision: number, + services: CompanyService[], + ): Promise> { + return this.mutateCompany('companies:update-services', { + revision, + services, + }) + }, + async publishAnnouncement( + announcement: PublishCompanyAnnouncement, + ): Promise> { + return this.mutateCompany('companies:publish-announcement', announcement) + }, + async setCallAvailability( + available: boolean, + ): Promise> { + this.mutating = true + const response = await nuiCall<{ context: CompanyWorkContext }>( + 'companies:set-call-availability', + { available }, + ) + this.mutating = false + if (!response.success || !response.data?.context) { + this.mutationError = response.error ?? 'request_failed' + return response + } + this.workContext = response.data.context + this.workUnreadCount = Math.max(0, response.data.context.unreadCount) + this.mutationError = '' + return response + }, + async callCustomer(requestId: string): Promise> { + this.mutating = true + const response = await nuiCall('companies:call-customer', { + requestId, + }) + this.mutating = false + this.mutationError = response.success + ? '' + : (response.error ?? 'request_failed') + return response + }, + async mutateRequest( + endpoint: string, + payload: Record, + ): Promise> { + this.mutating = true + const deviceScopeVersion = this.deviceScopeVersion + const response = await nuiCall( + endpoint, + payload, + ) + this.mutating = false + if (deviceScopeVersion !== this.deviceScopeVersion) { + return { success: false, error: 'device_changed' } + } + if (!response.success || !response.data?.request) { + this.mutationError = response.error ?? 'request_failed' + return response + } + this.request = response.data.request + this.replaceRequestSummary(response.data.request) + if (response.data.context) { + this.workContext = response.data.context + this.workUnreadCount = Math.max(0, response.data.context.unreadCount) + } + this.mutationError = '' + return response + }, + async mutateCompany( + endpoint: string, + payload: Record, + ): Promise> { + this.mutating = true + const response = await nuiCall(endpoint, payload) + this.mutating = false + if (!response.success || !response.data?.company) { + this.mutationError = response.error ?? 'request_failed' + return response + } + this.company = response.data.company + this.replaceCompanySummary(response.data.company) + if (this.workContext) this.workContext.company = response.data.company + if (response.data.context) { + this.workContext = response.data.context + this.workUnreadCount = Math.max(0, response.data.context.unreadCount) + } + this.mutationError = '' + return response + }, + replaceCompanySummary(company: Company): void { + const index = this.directory.findIndex((item) => item.id === company.id) + if (index >= 0) this.directory[index] = company + }, + replaceRequestSummary(request: CompanyRequest): void { + const lists = [this.myRequests, this.workQueue] + for (const list of lists) { + const index = list.findIndex((item) => item.id === request.id) + if (index >= 0) list[index] = request + } + if (this.workContext) { + const contextLists = [ + this.workContext.ownRequests, + this.workContext.recentRequests, + ] + for (const list of contextLists) { + const index = list.findIndex((item) => item.id === request.id) + if (index >= 0) list[index] = request + } + } + }, + resetRequest(): void { + this.request = null + this.requestError = '' + }, + resetDeviceScope(): void { + this.deviceScopeVersion += 1 + this.customerUnreadCount = 0 + this.myRequests = [] + this.myRequestsError = '' + this.myRequestsLoaded = false + this.myRequestsLoading = false + this.myRequestsLoadingMore = false + this.myRequestsNextCursor = null + this.request = null + this.requestError = '' + this.requestLoading = false + }, + }, +}) diff --git a/frontend/src/stores/notifications.test.ts b/frontend/src/stores/notifications.test.ts index 86b8578..e38b885 100644 --- a/frontend/src/stores/notifications.test.ts +++ b/frontend/src/stores/notifications.test.ts @@ -145,10 +145,11 @@ describe('notifications store', () => { const notifications = useNotificationsStore() notifications.show({ - appId: 'mail', + appId: 'companies', device: device('111'), + route: '/apps/companies?requestId=request-1&area=customer', text: 'Store while closed', - title: 'Mail', + title: 'Companies', }) await Promise.resolve() await Promise.resolve() @@ -158,9 +159,10 @@ describe('notifications store', () => { payload: { items: [ expect.objectContaining({ - appId: 'mail', + appId: 'companies', + route: '/apps/companies?requestId=request-1&area=customer', text: 'Store while closed', - title: 'Mail', + title: 'Companies', }), ], version: 1, @@ -247,10 +249,7 @@ describe('notifications store', () => { }) const notifications = useNotificationsStore() - notifications.hydrate( - phone.device?.data.notifications?.payload, - '111', - ) + notifications.hydrate(phone.device?.data.notifications?.payload, '111') vi.advanceTimersByTime(60_000) expect(notifications.lockScreenNotifications[0].text).toBe('Saved message') diff --git a/frontend/src/stores/notifications.ts b/frontend/src/stores/notifications.ts index d92ddfc..1487a2c 100644 --- a/frontend/src/stores/notifications.ts +++ b/frontend/src/stores/notifications.ts @@ -5,7 +5,10 @@ import { isPhoneAppId } from '@/config/apps' import { usePhoneStore } from '@/stores/phone' import type { LaunchablePhoneAppId } from '@/types/apps' import { nuiCall } from '@/utils/nui' -import type { PhonePreferencesV1 } from '@/utils/preferences' +import { + DEFAULT_APP_NOTIFICATION_PREFERENCES, + type PhonePreferencesV1, +} from '@/utils/preferences' import { playPhoneTone, type PhoneToneId } from '@/utils/tones' export type PhoneNotificationDevice = { @@ -19,6 +22,7 @@ export type PhoneNotificationInput = { critical?: boolean device?: PhoneNotificationDevice persistent?: boolean + route?: string sound?: PhoneToneId subtitle?: string text: string @@ -31,7 +35,7 @@ export type PhoneNotification = PhoneNotificationInput & { type PersistedPhoneNotification = Pick< PhoneNotification, - 'appId' | 'id' | 'subtitle' | 'text' | 'title' + 'appId' | 'id' | 'route' | 'subtitle' | 'text' | 'title' > type PersistedNotificationsV1 = { @@ -73,9 +77,10 @@ export const useNotificationsStore = defineStore('notifications', () => { function persist(imei: string): void { const items = (lockScreenQueues.value[imei] ?? []).map( - ({ appId, id, subtitle, text, title }) => ({ + ({ appId, id, route, subtitle, text, title }) => ({ appId, id, + ...(route ? { route } : {}), ...(subtitle ? { subtitle } : {}), text, title, @@ -108,7 +113,9 @@ export const useNotificationsStore = defineStore('notifications', () => { (payload as Partial).version !== 1 || !Array.isArray((payload as Partial).items) ) { - console.error('[Phone notifications] Invalid persisted notification data.') + console.error( + '[Phone notifications] Invalid persisted notification data.', + ) } else { for (const item of (payload as PersistedNotificationsV1).items) { if ( @@ -117,14 +124,21 @@ export const useNotificationsStore = defineStore('notifications', () => { !isPhoneAppId(item.appId) || typeof item?.text !== 'string' || typeof item?.title !== 'string' || + (item.route !== undefined && + (typeof item.route !== 'string' || + (item.route !== `/apps/${item.appId}` && + !item.route.startsWith(`/apps/${item.appId}?`)))) || (item.subtitle !== undefined && typeof item.subtitle !== 'string') ) { - console.error('[Phone notifications] Ignored an invalid persisted notification.') + console.error( + '[Phone notifications] Ignored an invalid persisted notification.', + ) continue } stored.push({ appId: item.appId, id: item.id, + ...(item.route ? { route: item.route } : {}), ...(item.subtitle ? { subtitle: item.subtitle } : {}), text: item.text, title: item.title, @@ -161,7 +175,8 @@ export const useNotificationsStore = defineStore('notifications', () => { function activate(notification: PhoneNotification): void { const preferences = notification.device?.preferences ?? phone.preferences const appPreferences = - preferences.settings.notifications[notification.appId] + preferences.settings.notifications[notification.appId] ?? + DEFAULT_APP_NOTIFICATION_PREFERENCES if (appPreferences.sounds || notification.critical) { const sound = notification.sound ?? preferences.settings.notificationSound const volume = notification.critical @@ -218,15 +233,19 @@ export const useNotificationsStore = defineStore('notifications', () => { if (current.value) dismiss(current.value.id) } - function dismissFromLockScreen(id: string): void { + function dismissFromLockScreen( + id: string, + targetImei = phone.device?.imei, + ): void { dismiss(id) - const imei = phone.device?.imei - if (!imei) return - const notifications = lockScreenQueues.value[imei] + if (!targetImei) return + const notifications = lockScreenQueues.value[targetImei] if (!notifications) return - const index = notifications.findIndex((notification) => notification.id === id) + const index = notifications.findIndex( + (notification) => notification.id === id, + ) if (index >= 0) notifications.splice(index, 1) - persist(imei) + persist(targetImei) } function clearLockScreen(): void { @@ -248,12 +267,14 @@ export const useNotificationsStore = defineStore('notifications', () => { } function show(input: PhoneNotificationInput): string | null { - const preferences = input.device?.preferences ?? phone.preferences - const appPreferences = preferences.settings.notifications[input.appId] - if (!appPreferences) { + if (!isPhoneAppId(input.appId)) { console.error(`[Phone notifications] Unknown app: ${input.appId}`) return null } + const preferences = input.device?.preferences ?? phone.preferences + const appPreferences = + preferences.settings.notifications[input.appId] ?? + DEFAULT_APP_NOTIFICATION_PREFERENCES if (!input.critical && preferences.settings.focusMode) return null if (!input.critical && !appPreferences.enabled) { return null diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 3ad36db..f70f02b 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -12,6 +12,7 @@ import { nuiCall } from '@/utils/nui' import type { NuiResponse } from '@/utils/nui' import { DEFAULT_PHONE_PREFERENCES, + ensureAppNotificationPreferences, parsePhonePreferences, type AppNotificationPreferences, type PhonePreferencesV1, @@ -38,6 +39,311 @@ export type PhoneOpenPayload = { const namespaceQueues = new Map>() +const companiesFallbackLocales = { + name: 'Companies', + navigation: 'Companies navigation', + actions: 'Request actions', + request: 'Service Request', + verified: 'Verified company', + back: 'Back', + close: 'Close', + tryAgain: 'Try Again', + loadMore: 'Load More', + routeSet: 'GPS route set.', + tabs: { + directory: 'Discover', + requests: 'Requests', + work: 'Work', + }, + availability: { + available: 'Available', + busy: 'Busy', + closed: 'Closed', + }, + requestStatuses: { + new: 'New', + assigned: 'Assigned', + in_progress: 'In Progress', + waiting_customer: 'Waiting', + completed: 'Completed', + cancelled: 'Cancelled', + }, + roles: { + employee: 'Employee', + manager: 'Manager', + }, + categories: { + gastronomy: 'Food & Drink', + vehicles: 'Vehicles', + transport: 'Transport', + crafts: 'Trades', + retail: 'Retail', + real_estate: 'Real Estate', + media: 'Media', + nightlife: 'Nightlife', + public_services: 'Public Services', + emergency: 'Emergency Services', + medical: 'Medical', + mechanics: 'Mechanics', + government: 'Government', + }, + days: { + monday: 'Monday', + tuesday: 'Tuesday', + wednesday: 'Wednesday', + thursday: 'Thursday', + friday: 'Friday', + saturday: 'Saturday', + sunday: 'Sunday', + }, + directory: { + searchPlaceholder: 'Search companies or services', + categories: 'Company categories', + allCategories: 'All', + allCompanies: 'All Companies', + availableNow: 'Available now', + availableNowHint: 'Only show companies taking calls right now.', + availableSection: 'Available Now', + moreFilters: 'More filters', + hasLocation: 'Location', + acceptsRequests: 'Requests', + resetFilters: 'Reset Filters', + }, + loading: { + directory: 'Loading companies...', + profile: 'Loading company profile...', + request: 'Loading request...', + requests: 'Loading your requests...', + work: 'Loading workspace...', + }, + states: { + directoryError: 'Companies are unavailable', + requestsError: 'Requests are unavailable', + workError: 'Workspace unavailable', + profileError: 'Company unavailable', + requestError: 'Request unavailable', + noCompanies: 'No companies listed', + noCompaniesBody: 'Public companies will appear here when configured.', + noResults: 'No matching companies', + noResultsBody: 'Try another search or reset your filters.', + noOpenRequests: 'No open requests', + noClosedRequests: 'No completed requests', + noRequestsBody: 'Requests you create with a company will appear here.', + }, + actionUnavailable: { + call: 'This company cannot be called right now.', + message: 'This service line does not accept text messages.', + route: 'This company has no public location.', + request: 'This company is not accepting service requests.', + }, + profile: { + updated: 'Status updated {time}', + announcement: 'Latest Update', + location: 'Location', + noLocation: 'No public location', + hours: 'Opening Hours', + closed: 'Closed', + byAvailability: 'Open by availability', + services: 'Services', + noServices: 'No public services listed', + call: 'Call', + message: 'Message', + route: 'Route', + request: 'Request', + }, + composer: { + title: 'New Service Request', + chooseService: 'Choose a Service', + subject: 'Subject', + subjectPlaceholder: 'What do you need?', + description: 'Details', + descriptionPlaceholder: + 'Describe what happened and how the company can help.', + contact: 'Contact number', + registeredSim: 'Replies go to your registered SIM {number}.', + registeredSimRequired: 'A registered SIM is required to send a request.', + addPhotos: 'Add Photos ({count}/3)', + selectedPhoto: 'Selected request photo', + removePhoto: 'Remove photo', + review: 'Review Request', + confirmTitle: 'Send this request?', + confirmBody: 'Send your {service} request to {company}.', + edit: 'Keep Editing', + send: 'Send Request', + }, + requests: { + open: 'Open', + closed: 'Completed', + findCompany: 'Find a Company', + generalService: 'General service', + attachments: 'Request photos', + attachedPhoto: 'Attached request photo', + timeline: 'Timeline', + conversation: 'Conversation', + noMessages: 'No replies yet', + replyPlaceholder: 'Write a reply', + sendReply: 'Send reply', + call: 'Call Company', + cancel: 'Cancel Request', + cancelTitle: 'Cancel this request?', + cancelBody: 'The company will be notified and can no longer complete it.', + keep: 'Keep Request', + cancelConfirm: 'Cancel Request', + }, + timeline: { + created: 'Request created', + assigned: 'Request assigned', + cancelled: 'Request cancelled', + completed: 'Request completed', + statusChanged: 'Status changed to {status}', + }, + time: { + justNow: 'just now', + minutesAgo: '{count} min ago', + hoursAgo: '{count} hr ago', + daysAgo: '{count} days ago', + }, + work: { + notAuthorized: 'No company workspace', + notAuthorizedBody: + 'Your current job is not connected to a configured company.', + workspace: 'Company Workspace', + publicAvailability: 'Public Availability', + takeCalls: 'Take company calls', + takeCallsBody: 'Route new service-line calls to this active SIM.', + overview: 'Today at a Glance', + metrics: { + new: 'New', + assigned: 'Mine', + waiting: 'Waiting', + completedToday: 'Done Today', + }, + queue: 'Request Queue', + filters: { + new: 'New', + assigned: 'Mine', + in_progress: 'In Progress', + waiting_customer: 'Waiting', + completed: 'Done', + }, + unassigned: 'Unassigned', + emptyQueue: 'Queue is clear', + emptyQueueBody: 'No requests match this filter.', + }, + workActions: { + claim: 'Claim Request', + assign: 'Assign Employee', + callCustomer: 'Call Customer', + setStatus: 'Set status: {status}', + }, + assignment: { + title: 'Assign Employee', + online: 'Online', + offline: 'Offline', + unknownMember: 'Unknown employee', + confirm: 'Assign Request', + }, + assignmentLabels: { + you: 'Assigned to you', + assigned: 'Assigned to a colleague', + }, + messageAuthors: { + you: 'You', + customer: 'Customer', + company: 'Company', + }, + manager: { + title: 'Company Profile', + subtitle: 'Manage public information, services, and announcements.', + revision: 'Server revision {revision}', + availability: 'Availability', + profile: 'Public Profile', + description: 'Description', + descriptionPlaceholder: 'Describe the company and what it offers.', + phoneNumber: 'Service number', + phoneNumberManaged: 'Configured by the server and cannot be edited here.', + noPhoneNumber: 'No service number configured', + chooseCover: 'Choose cover photo', + chooseLogo: 'Choose logo', + coverPhoto: 'Company cover photo', + logoPhoto: 'Company logo', + locationLabel: 'Location name', + locationReady: 'A map position is attached to this profile.', + useCurrentLocation: 'Use Current Location', + address: 'Address', + district: 'District', + acceptRequests: 'Accept service requests', + acceptRequestsBody: 'Allow registered SIMs to open structured requests.', + saveProfile: 'Save Profile', + hours: 'Opening Hours', + dayOpen: 'Company is open on this day', + opensAt: 'Opens', + closesAt: 'Closes', + saveHours: 'Save Hours', + services: 'Services', + serviceTitle: 'Service name', + serviceDescription: 'Description', + priceText: 'Price text', + serviceActive: 'Publicly visible', + serviceRequests: 'Accept requests for this service', + removeService: 'Remove Service', + addService: 'Add Service', + saveServices: 'Save Services', + announcement: 'Current Announcement', + announcementText: 'Announcement', + announcementPlaceholder: 'Share a short, timely update.', + expiresAt: 'Expires at', + publish: 'Publish Announcement', + }, + feedback: { + requestCreated: 'Request sent.', + requestCancelled: 'Request cancelled.', + requestClaimed: 'Request claimed.', + requestAssigned: 'Request assigned.', + statusUpdated: 'Request status updated.', + availabilityUpdated: 'Public availability updated.', + locationUpdated: 'Current location selected.', + callsEnabled: 'Company calls enabled.', + callsDisabled: 'Company calls disabled.', + profileSaved: 'Company profile saved.', + hoursSaved: 'Opening hours saved.', + servicesSaved: 'Services saved.', + announcementPublished: 'Announcement published.', + }, + conflict: { + title: 'Newer changes are available', + body: 'Another manager updated this information. Reload before saving again.', + reload: 'Reload', + }, + notifications: { + newRequest: 'New company request', + requestUpdated: 'A company request was updated.', + newMessage: 'New reply to your company request.', + assigned: 'A company request was assigned to you.', + }, + errors: { + anonymous_sim: 'A registered SIM is required for service requests.', + call_unavailable: 'This service line is currently unavailable.', + company_not_found: 'This company is no longer available.', + invalid_profile: 'Check the company profile fields.', + invalid_request: 'Check the subject and request details.', + invalid_media: 'One or more attached photos are unavailable.', + invalid_expiration: 'Choose a valid announcement expiration.', + invalid_service: 'This service is no longer available.', + invalid_status: 'That status change is not allowed.', + messaging_unavailable: 'This company does not accept text messages.', + no_sim: 'Insert a SIM card to continue.', + not_authorized: 'You are not authorized for this company action.', + rate_limited: 'Please wait before trying again.', + request_not_found: 'This request is no longer available.', + revision_conflict: + 'The data changed on another device. Reload and try again.', + service_unavailable: 'The Companies service is temporarily unavailable.', + too_many_open_requests: 'You already have too many open requests.', + request_failed: 'Companies could not complete the request.', + }, +} + const defaultLocales: LocaleTree = { Apps: { easyShare: { @@ -104,6 +410,7 @@ const defaultLocales: LocaleTree = { request_failed: 'EasyShare is temporarily unavailable.', }, }, + companies: companiesFallbackLocales, crewlink: { name: 'CrewLink', connecting: 'Connecting your crew...', @@ -126,8 +433,7 @@ const defaultLocales: LocaleTree = { noGroupBody: 'Create a crew or join friends with a private invitation code.', createGroup: 'Create Group', - createGroupBody: - 'Give your crew a recognizable name and signal colour.', + createGroupBody: 'Give your crew a recognizable name and signal colour.', joinGroup: 'Join Group', joinWithCode: 'Join with Code', joinWithCodeBody: @@ -178,8 +484,7 @@ const defaultLocales: LocaleTree = { invite: 'Invite', inviteSent: 'Invitation sent to @{username}.', nobodyNearby: 'Nobody in range', - nobodyNearbyBody: - 'Move closer to another CrewLink user and scan again.', + nobodyNearbyBody: 'Move closer to another CrewLink user and scan again.', scanAgain: 'Scan Again', liveCoordination: 'Live coordination', noPings: 'No active pings', @@ -1131,6 +1436,8 @@ const defaultLocales: LocaleTree = { message: 'Message', send: 'Send', details: 'Details', + officialContact: 'Official company contact', + messagingUnavailable: 'This company contact does not accept messages.', filterUnread: 'Show Unread Messages', smsLabel: 'Text Message · SMS', photo: 'Photo', @@ -1207,6 +1514,7 @@ const defaultLocales: LocaleTree = { gif_provider_failed: 'GIF search is temporarily unavailable.', self_message: 'You cannot message your own number.', recipient_not_found: 'That number is unavailable.', + messaging_unavailable: 'This company contact does not accept messages.', no_sim: 'This phone has no SIM card.', rate_limited: 'Too many messages. Try again in a minute.', request_failed: 'Messages are temporarily unavailable.', @@ -1258,6 +1566,7 @@ const defaultLocales: LocaleTree = { lastName: 'Last Name', companyOrGroup: 'Company or Group', phoneNumber: 'Phone Number', + officialContact: 'Official company contact', choosePhoto: 'Choose Contact Photo', chooseGallery: 'Gallery', takePhoto: 'Camera', @@ -1316,6 +1625,7 @@ const defaultLocales: LocaleTree = { errors: { invalid_contact: 'Enter a name and valid phone number.', invalid_number: 'Enter a valid phone number.', + invalid_sim: 'The active SIM card is unavailable.', message_unavailable: 'The conversation could not be opened.', contact_remove_failed: 'The contact could not be removed.', contact_favorite_failed: 'The favorite could not be updated.', @@ -1325,6 +1635,8 @@ const defaultLocales: LocaleTree = { blocked: 'This number is blocked.', recipient_not_found: 'This number is not known.', busy: 'The line is busy.', + company_unavailable: 'This company service line is unavailable.', + readonly_contact: 'Official company contacts cannot be changed.', rate_limited: 'Too many calls. Try again in a minute.', voice_unavailable: 'The configured phone voice service is unavailable.', inventory_full: 'There is no room for the ejected SIM card.', @@ -3096,7 +3408,11 @@ const defaultLocales: LocaleTree = { stop: 'Stop', use: 'Use', }, - Notifications: { now: 'now' }, + Notifications: { + clearAll: 'Clear All', + now: 'now', + open: 'Open notification', + }, LockScreen: { label: 'Lock Screen', flashlight: 'Flashlight', @@ -3296,6 +3612,9 @@ export const usePhoneStore = defineStore('phone', { setLaunchOrigin(origin: AppLaunchOrigin | null): void { this.launchOrigin = origin }, + ensureAppNotificationPreferences(appIds: LaunchablePhoneAppId[]): void { + ensureAppNotificationPreferences(this.preferences, appIds) + }, setAppNotification( appId: LaunchablePhoneAppId, key: keyof AppNotificationPreferences, diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts index 6483166..a21a28d 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -1,6 +1,6 @@ import type { Component } from 'vue' -export type PhoneAppId = +export type BuiltinPhoneAppId = | 'phone' | 'messages' | 'darkchat' @@ -36,7 +36,15 @@ export type PhoneAppId = | 'skyride' | 'feather' | 'crewlink' + | 'companies' +declare const externalPhoneAppId: unique symbol + +export type ExternalPhoneAppId = string & { + readonly [externalPhoneAppId]: true +} + +export type PhoneAppId = BuiltinPhoneAppId | ExternalPhoneAppId export type LaunchablePhoneAppId = PhoneAppId export type PhoneAppCategory = @@ -54,21 +62,107 @@ export type AppLaunchOrigin = { y: number } -export type PhoneAppDefinition = { +type PhoneAppDefinitionBase = { category: PhoneAppCategory - component: Component | null dockOrder: number | null gridOrder: number icon: Component iconClass: string iconImage: string - id: PhoneAppId - labelKey: string - route: `/apps/${LaunchablePhoneAppId}` | null } -export type LaunchablePhoneAppDefinition = PhoneAppDefinition & { +export type BuiltinPhoneAppDefinition = PhoneAppDefinitionBase & { component: Component - id: LaunchablePhoneAppId - route: `/apps/${LaunchablePhoneAppId}` + id: BuiltinPhoneAppId + kind?: 'builtin' + labelKey: string + route: `/apps/${BuiltinPhoneAppId}` +} + +export type CustomAppBridgeMode = 'legacy' | 'sky' + +export type ExternalPhoneAppDefinition = PhoneAppDefinitionBase & { + bridgeMode: CustomAppBridgeMode + bundled: boolean + capabilities: string[] + compatibility: Record + component: null + defaultInstalled: boolean + description: string + developer: string + iconBackground?: string + id: ExternalPhoneAppId + kind: 'external' + name: string + orientation: 'landscape' | 'portrait' + ownerResource: string + readyTimeoutMs: number + removable: boolean + route: `/apps/${ExternalPhoneAppId}` + ui: string +} + +export type PhoneAppDefinition = + | BuiltinPhoneAppDefinition + | ExternalPhoneAppDefinition + +export type LaunchablePhoneAppDefinition = PhoneAppDefinition + +export type CustomAppCatalogPayload = { + apps: unknown[] +} + +export type CustomAppHostMessage = { + payload: unknown + sequence: number +} + +export type CustomAppOpenRequest = { + data?: unknown + sequence: number +} + +export type SkyPhoneAppCapability = + | 'app.close' + | 'app.open' + | 'device.storage.get' + | 'device.storage.set' + | 'locale.read' + | 'notification.create' + | 'theme.read' + +export type SkyPhoneAppBridgeMethod = + | 'app.close' + | 'app.open' + | 'device.storage.get' + | 'device.storage.set' + | 'notification.create' + +export type SkyPhoneAppBridgeRequest = { + method: string + payload?: unknown + requestId: string +} + +export type SkyPhoneAppBridgeResponse = { + data?: unknown + error?: string + requestId: string + success: boolean +} + +export type SkyPhoneAppContextV1 = { + appId: string + capabilities: SkyPhoneAppCapability[] + colorScheme: 'dark' | 'light' + language: string + locale: Record + phoneScale: number + protocolVersion: 1 + safeArea: { + bottom: number + left: number + right: number + top: number + } } diff --git a/frontend/src/types/companies.ts b/frontend/src/types/companies.ts new file mode 100644 index 0000000..4807571 --- /dev/null +++ b/frontend/src/types/companies.ts @@ -0,0 +1,271 @@ +export type CompanyAvailability = 'available' | 'busy' | 'closed' + +export type CompanyDirectorySort = 'name' | 'relevance' | 'updated' + +export type CompanyRequestList = 'closed' | 'open' + +export type CompanyRequestStatus = + | 'assigned' + | 'cancelled' + | 'completed' + | 'in_progress' + | 'new' + | 'waiting_customer' + +export type CompanyWorkFilter = + | 'assigned' + | 'completed' + | 'in_progress' + | 'new' + | 'waiting_customer' + +export type CompanyRequestEventType = + | 'assigned' + | 'cancelled' + | 'completed' + | 'created' + | 'status_changed' + +export type CompanyCategory = { + id: string + name: string +} + +export type CompanyCoordinates = { + x: number + y: number + z: number +} + +export type CompanyLocation = { + address: string + coords: CompanyCoordinates + district: string + label: string +} + +export type CompanyAnnouncement = { + body: string + expiresAt: string | null + publishedAt: string +} + +export type CompanyService = { + acceptsRequests: boolean + active: boolean + description: string + id: string + priceText: string | null + title: string +} + +export type CompanyHours = { + closesAt: string | null + day: number + isClosed: boolean + opensAt: string | null +} + +export type CompanySummary = { + acceptsRequests: boolean + announcement: CompanyAnnouncement | null + availability: CompanyAvailability + availabilityUpdatedAt: string + canCall: boolean + canMessage: boolean + categoryId: string + categoryName: string + description: string + id: string + location: CompanyLocation | null + logoUrl: string | null + name: string + phoneNumber: string | null + serviceSummary: string + verified: boolean +} + +export type Company = CompanySummary & { + coverUrl: string | null + hours: CompanyHours[] + revision: number + services: CompanyService[] +} + +export type CompanyDirectoryFilters = { + acceptsRequests: boolean + availability: CompanyAvailability | null + categoryId: string | null + hasLocation: boolean + search: string + sort: CompanyDirectorySort +} + +export type CompanyDirectoryPage = { + categories: CompanyCategory[] + companies: CompanySummary[] + nextCursor: string | null +} + +export type CompanyRequestActions = { + allowedStatuses: CompanyRequestStatus[] + canAssign: boolean + canCall: boolean + canCancel: boolean + canClaim: boolean + canReply: boolean +} + +export type CompanyRequestSummary = { + assignedLabel: 'assigned' | 'you' | null + companyId: string + companyLogoUrl: string | null + companyName: string + createdAt: string + id: string + serviceId: string | null + serviceName: string | null + status: CompanyRequestStatus + subject: string + unreadCount: number + updatedAt: string +} + +export type CompanyRequestMessage = { + author: 'company' | 'customer' + authorLabel: 'company' | 'customer' | 'you' + body: string + createdAt: string + id: string + isMine: boolean +} + +export type CompanyRequestMedia = { + id: number + url: string +} + +export type CompanyRequestEvent = { + createdAt: string + id: string + status: CompanyRequestStatus | null + type: CompanyRequestEventType +} + +export type CompanyRequest = CompanyRequestSummary & { + actions: CompanyRequestActions + description: string + events: CompanyRequestEvent[] + media: CompanyRequestMedia[] + messages: CompanyRequestMessage[] + phoneNumber: string | null + revision: number +} + +export type CompanyRequestPage = { + nextCursor: string | null + requests: CompanyRequestSummary[] + unreadCount: number +} + +export type CompanyWorkMetrics = { + assigned: number + completedToday: number + new: number + waiting: number +} + +export type CompanyWorkPermissions = { + canAssign: boolean + canManageAnnouncement: boolean + canManageHours: boolean + canManageProfile: boolean + canManageServices: boolean + canSetAvailability: boolean + canTakeCalls: boolean +} + +export type CompanyWorkContext = { + authorized: boolean + callAvailable: boolean + company: Company | null + metrics: CompanyWorkMetrics + ownRequests: CompanyRequestSummary[] + permissions: CompanyWorkPermissions + recentRequests: CompanyRequestSummary[] + role: 'employee' | 'manager' | null + unreadCount: number +} + +export type CompanyWorkQueuePage = { + nextCursor: string | null + requests: CompanyRequestSummary[] +} + +export type CompanyMember = { + id: string + name: string + online: boolean + role: string +} + +export type CompanyMembersResult = { + members: CompanyMember[] +} + +export type CreateCompanyRequest = { + companyId: string + description: string + mediaIds: string[] + serviceId: string + subject: string +} + +export type CompanyRequestMutationResult = { + context?: CompanyWorkContext + request: CompanyRequest +} + +export type CompanyMutationResult = { + company: Company + context?: CompanyWorkContext +} + +export type UpdateCompanyProfile = { + acceptsRequests: boolean + address: string + coords?: CompanyCoordinates + coverMediaId?: number + description: string + district: string + logoMediaId?: number + locationLabel: string + revision: number +} + +export type UpdateCompanyHours = { + hours: CompanyHours[] + revision: number +} + +export type UpdateCompanyServices = { + revision: number + services: CompanyService[] +} + +export type PublishCompanyAnnouncement = { + body: string + expiresAt: string | null + revision: number +} + +export type CompanyUnreadCounts = { + customer?: number + work?: number +} + +export type CompanyChangedPayload = { + area: 'all' | 'customer' | 'directory' | 'work' + companyId?: string + requestId?: string +} diff --git a/frontend/src/types/phone.ts b/frontend/src/types/phone.ts index 1527126..a925024 100644 --- a/frontend/src/types/phone.ts +++ b/frontend/src/types/phone.ts @@ -11,14 +11,21 @@ export type PhoneSim = { export type PhoneContact = { avatar_media_id?: number | null avatar_url?: string | null + canCall?: boolean + canMessage?: boolean + companyId?: string created_at?: string favorite?: boolean | number id: string + icon?: string name: string notes?: string | null organization?: string | null phone_number: string + readonly?: boolean + source?: 'personal' | 'company' updated_at?: string + verified?: boolean } export type CallDirection = 'incoming' | 'outgoing' diff --git a/frontend/src/utils/clone.test.ts b/frontend/src/utils/clone.test.ts new file mode 100644 index 0000000..2c4c8cf --- /dev/null +++ b/frontend/src/utils/clone.test.ts @@ -0,0 +1,20 @@ +import { reactive } from 'vue' +import { describe, expect, it } from 'vitest' + +import { cloneJsonData } from '@/utils/clone' + +describe('JSON data cloning', () => { + it('turns reactive custom app payloads into structured-cloneable data', () => { + const payload = reactive({ + action: 'setSpeedCameras', + data: [{ id: 1, label: 'Alta Street' }], + }) + + expect(() => structuredClone(payload)).toThrow() + + const cloned = cloneJsonData(payload) + + expect(cloned).toEqual(payload) + expect(() => structuredClone(cloned)).not.toThrow() + }) +}) diff --git a/frontend/src/utils/customAppBridge.test.ts b/frontend/src/utils/customAppBridge.test.ts new file mode 100644 index 0000000..4e61985 --- /dev/null +++ b/frontend/src/utils/customAppBridge.test.ts @@ -0,0 +1,278 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + createCustomAppBridgeRequestHandler, + getCustomAppFrameBootstrapMessages, + getSkyPhoneAppCapabilities, + shouldReportCustomAppReady, + type CustomAppBridgeNotification, + type CustomAppBridgeTarget, +} from '@/utils/customAppBridge' + +const source = { + capabilities: [] as string[], + id: 'source-app', +} +const createNotification = + vi.fn<(input: CustomAppBridgeNotification) => string | null>() +const prepareExternalOpen = vi.fn<(appId: string, data?: unknown) => boolean>() +const resolveTarget = vi.fn<(appId: string) => CustomAppBridgeTarget | null>() +const storageCall = vi.fn() + +function createHandler() { + return createCustomAppBridgeRequestHandler({ + createNotification, + getSourceApp: () => source, + prepareExternalOpen, + resolveTarget, + storageCall, + }) +} + +describe('custom app bridge', () => { + beforeEach(() => { + source.capabilities = [] + createNotification.mockReset().mockReturnValue('notification-1') + prepareExternalOpen.mockReset().mockReturnValue(true) + resolveTarget.mockReset().mockReturnValue(null) + storageCall.mockReset().mockResolvedValue({ success: true }) + }) + + it('advertises only implemented methods granted by manifest permissions', () => { + expect( + getSkyPhoneAppCapabilities([ + 'app.close', + 'device.storage', + 'notifications', + 'location.read', + ]), + ).toEqual([ + 'app.close', + 'device.storage.get', + 'device.storage.set', + 'notification.create', + ]) + }) + + it('reports ready lifecycle from load only for legacy frames', () => { + expect(shouldReportCustomAppReady('legacy', 'frame-load')).toBe(true) + expect(shouldReportCustomAppReady('legacy', 'bridge-ready')).toBe(false) + expect(shouldReportCustomAppReady('sky', 'frame-load')).toBe(false) + expect(shouldReportCustomAppReady('sky', 'bridge-ready')).toBe(true) + }) + + it('boots LB Phone frames with their documented ready signal', () => { + expect( + getCustomAppFrameBootstrapMessages({ provider: 'lb_phone' }), + ).toEqual(['componentsLoaded']) + expect( + getCustomAppFrameBootstrapMessages({ provider: '17mov' }), + ).toEqual([]) + }) + + it('passes validated storage data and server response data through', async () => { + source.capabilities = ['device.storage'] + storageCall.mockResolvedValue({ + data: { exists: true, revision: 4, value: { enabled: true } }, + success: true, + }) + const handler = createHandler() + + const result = await handler.handle({ + method: 'device.storage.get', + payload: { key: 'settings.main' }, + requestId: 'storage-get', + }) + + expect(storageCall).toHaveBeenCalledWith('custom-app:storage:get', { + appId: 'source-app', + key: 'settings.main', + }) + expect(result?.response).toEqual({ + data: { exists: true, revision: 4, value: { enabled: true } }, + requestId: 'storage-get', + success: true, + }) + }) + + it('validates storage writes and deduplicates request ids', async () => { + source.capabilities = ['device.storage'] + const handler = createHandler() + const request = { + method: 'device.storage.set', + payload: { key: 'profile', revision: 2, value: { color: 'blue' } }, + requestId: 'storage-set', + } + + const first = await handler.handle(request) + const duplicate = await handler.handle(request) + const invalid = await handler.handle({ + method: 'device.storage.set', + payload: { key: '../profile', revision: -1, value: Number.NaN }, + requestId: 'storage-invalid', + }) + + expect(first?.response.success).toBe(true) + expect(duplicate).toBeNull() + expect(invalid?.response).toMatchObject({ + error: 'invalid_storage_request', + success: false, + }) + expect(storageCall).toHaveBeenCalledWith('custom-app:storage:set', { + appId: 'source-app', + key: 'profile', + revision: 2, + value: { color: 'blue' }, + }) + expect(storageCall).toHaveBeenCalledOnce() + }) + + it('bounds the request-id deduplication window', async () => { + const handler = createHandler() + const first = await handler.handle({ + method: 'unknown', + requestId: 'oldest-request', + }) + for (let index = 0; index < 512; index += 1) { + await handler.handle({ + method: 'unknown', + requestId: `request-${index}`, + }) + } + const afterEviction = await handler.handle({ + method: 'unknown', + requestId: 'oldest-request', + }) + + expect(first?.response.error).toBe('unsupported_method') + expect(afterEviction?.response.error).toBe('unsupported_method') + }) + + it('requires storage and close permissions', async () => { + const handler = createHandler() + + const storage = await handler.handle({ + method: 'device.storage.get', + payload: { key: 'profile' }, + requestId: 'denied-storage', + }) + const close = await handler.handle({ + method: 'app.close', + requestId: 'denied-close', + }) + + expect(storage?.response.error).toBe('permission_denied') + expect(close?.response.error).toBe('permission_denied') + expect(close?.effect).toBeUndefined() + }) + + it('creates a small text-only notification routed to its own app', async () => { + source.capabilities = ['notifications'] + const handler = createHandler() + + const result = await handler.handle({ + method: 'notification.create', + payload: { + sound: 'signal', + subtitle: ' Update ', + text: ' Work completed. ', + title: ' Example ', + }, + requestId: 'notification', + }) + + expect(createNotification).toHaveBeenCalledWith({ + appId: 'source-app', + route: '/apps/source-app', + sound: 'signal', + subtitle: 'Update', + text: 'Work completed.', + title: 'Example', + }) + expect(result?.response.data).toEqual({ + notificationId: 'notification-1', + }) + }) + + it('rejects notification route injection and undeclared notification use', async () => { + source.capabilities = ['notifications'] + const handler = createHandler() + const injected = await handler.handle({ + method: 'notification.create', + payload: { + route: 'https://example.test', + text: 'Body', + title: 'Title', + }, + requestId: 'notification-injected', + }) + source.capabilities = [] + const denied = await handler.handle({ + method: 'notification.create', + payload: { text: 'Body', title: 'Title' }, + requestId: 'notification-denied', + }) + + expect(injected?.response.error).toBe('invalid_notification') + expect(denied?.response.error).toBe('permission_denied') + expect(createNotification).not.toHaveBeenCalled() + }) + + it('opens only registered targets and passes JSON data to external apps', async () => { + source.capabilities = ['app.open'] + resolveTarget.mockReturnValue({ + external: true, + id: 'target-app', + route: '/apps/target-app', + }) + const handler = createHandler() + const data = { screen: 'details', selectedId: 17 } + + const result = await handler.handle({ + method: 'app.open', + payload: { appId: 'target-app', data }, + requestId: 'open-target', + }) + + expect(resolveTarget).toHaveBeenCalledWith('target-app') + expect(prepareExternalOpen).toHaveBeenCalledWith('target-app', data) + expect(result).toMatchObject({ + effect: { route: '/apps/target-app', type: 'open' }, + response: { + data: { appId: 'target-app' }, + success: true, + }, + }) + }) + + it('rejects foreign URLs, unknown apps, and data for built-in apps', async () => { + source.capabilities = ['app.open'] + const handler = createHandler() + + const foreignUrl = await handler.handle({ + method: 'app.open', + payload: { appId: 'target-app', url: 'https://example.test' }, + requestId: 'open-url', + }) + const missing = await handler.handle({ + method: 'app.open', + payload: { appId: 'missing-app' }, + requestId: 'open-missing', + }) + resolveTarget.mockReturnValue({ + external: false, + id: 'notes', + route: '/apps/notes', + }) + const builtInData = await handler.handle({ + method: 'app.open', + payload: { appId: 'notes', data: { noteId: 'one' } }, + requestId: 'open-built-in-data', + }) + + expect(foreignUrl?.response.error).toBe('invalid_app_open') + expect(missing?.response.error).toBe('app_not_found') + expect(builtInData?.response.error).toBe('open_data_not_supported') + expect(prepareExternalOpen).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/utils/customAppBridge.ts b/frontend/src/utils/customAppBridge.ts new file mode 100644 index 0000000..fdde136 --- /dev/null +++ b/frontend/src/utils/customAppBridge.ts @@ -0,0 +1,521 @@ +import type { + CustomAppBridgeMode, + SkyPhoneAppBridgeRequest, + SkyPhoneAppBridgeResponse, + SkyPhoneAppCapability, +} from '@/types/apps' +import type { NuiResponse } from '@/utils/nui' +import type { NotificationSoundId } from '@/utils/preferences' + +const STORAGE_KEY_PATTERN = /^[A-Za-z0-9._-]{1,64}$/ +const NOTIFICATION_SOUNDS: ReadonlySet = new Set([ + 'chime', + 'signal', + 'soft', +]) +const JSON_MAX_DEPTH = 8 +const JSON_MAX_NODES = 512 +const HANDLED_REQUEST_ID_LIMIT = 512 +const STORAGE_VALUE_MAX_BYTES = 65_536 +const DEEP_LINK_DATA_MAX_BYTES = 16_384 + +type JsonResult = { success: true; value: unknown } | { success: false } + +export type CustomAppBridgeNotification = { + appId: string + route: string + sound?: NotificationSoundId + subtitle?: string + text: string + title: string +} + +export type CustomAppBridgeTarget = { + external: boolean + id: string + route: string +} + +export type CustomAppBridgeEffect = + | { type: 'close' } + | { route: string; type: 'open' } + +export type CustomAppBridgeResult = { + effect?: CustomAppBridgeEffect + response: SkyPhoneAppBridgeResponse +} + +type CustomAppBridgeDependencies = { + createNotification: ( + notification: CustomAppBridgeNotification, + ) => string | null + getSourceApp: () => { capabilities: readonly string[]; id: string } + prepareExternalOpen: (appId: string, data?: unknown) => boolean + resolveTarget: (appId: string) => CustomAppBridgeTarget | null + storageCall: ( + endpoint: 'custom-app:storage:get' | 'custom-app:storage:set', + payload: Record, + ) => Promise +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function hasOnlyKeys( + value: Record, + allowedKeys: readonly string[], +): boolean { + const allowed = new Set(allowedKeys) + return Object.keys(value).every((key) => allowed.has(key)) +} + +function readText(value: unknown, maximumLength: number): string | null { + if (typeof value !== 'string') return null + const normalized = value.trim() + return normalized.length > 0 && normalized.length <= maximumLength + ? normalized + : null +} + +function cloneJsonValue( + value: unknown, + depth: number, + state: { nodes: number; seen: WeakSet }, +): JsonResult { + state.nodes += 1 + if (state.nodes > JSON_MAX_NODES || depth > JSON_MAX_DEPTH) { + return { success: false } + } + if ( + value === null || + typeof value === 'boolean' || + typeof value === 'string' + ) { + return { success: true, value } + } + if (typeof value === 'number') { + return Number.isFinite(value) + ? { success: true, value } + : { success: false } + } + if (typeof value !== 'object' || state.seen.has(value)) { + return { success: false } + } + + state.seen.add(value) + if (Array.isArray(value)) { + if (value.length > 128) return { success: false } + const result: unknown[] = [] + for (const item of value) { + const normalized = cloneJsonValue(item, depth + 1, state) + if (!normalized.success) return normalized + result.push(normalized.value) + } + state.seen.delete(value) + return { success: true, value: result } + } + + const prototype = Object.getPrototypeOf(value) + if (prototype !== Object.prototype && prototype !== null) { + return { success: false } + } + const entries = Object.entries(value) + if (entries.length > 128) return { success: false } + const result: Record = {} + for (const [key, item] of entries) { + if ( + key.length < 1 || + key.length > 64 || + key === '__proto__' || + key === 'constructor' || + key === 'prototype' + ) { + return { success: false } + } + const normalized = cloneJsonValue(item, depth + 1, state) + if (!normalized.success) return normalized + result[key] = normalized.value + } + state.seen.delete(value) + return { success: true, value: result } +} + +function normalizeJsonValue(value: unknown, maximumBytes: number): JsonResult { + const normalized = cloneJsonValue(value, 0, { + nodes: 0, + seen: new WeakSet(), + }) + if (!normalized.success) return normalized + + const serialized = JSON.stringify(normalized.value) + if ( + typeof serialized !== 'string' || + new TextEncoder().encode(serialized).byteLength > maximumBytes + ) { + return { success: false } + } + return normalized +} + +function normalizeStorageGetPayload(payload: unknown): { key: string } | null { + if (!isRecord(payload) || !hasOnlyKeys(payload, ['key'])) return null + return typeof payload.key === 'string' && + STORAGE_KEY_PATTERN.test(payload.key) + ? { key: payload.key } + : null +} + +function normalizeStorageSetPayload( + payload: unknown, +): { key: string; revision: number; value: unknown } | null { + if ( + !isRecord(payload) || + !hasOnlyKeys(payload, ['key', 'revision', 'value']) || + !Object.prototype.hasOwnProperty.call(payload, 'value') || + typeof payload.key !== 'string' || + !STORAGE_KEY_PATTERN.test(payload.key) || + typeof payload.revision !== 'number' || + !Number.isInteger(payload.revision) || + payload.revision < 0 || + payload.revision > 4_294_967_294 + ) { + return null + } + + const value = normalizeJsonValue(payload.value, STORAGE_VALUE_MAX_BYTES) + return value.success + ? { key: payload.key, revision: payload.revision, value: value.value } + : null +} + +function normalizeNotificationPayload( + appId: string, + payload: unknown, +): CustomAppBridgeNotification | null { + if ( + !isRecord(payload) || + !hasOnlyKeys(payload, ['sound', 'subtitle', 'text', 'title']) + ) { + return null + } + + const title = readText(payload.title, 80) + const text = readText(payload.text, 240) + if (!title || !text) return null + + const subtitle = + payload.subtitle === undefined ? undefined : readText(payload.subtitle, 80) + if (payload.subtitle !== undefined && !subtitle) return null + const sound = + payload.sound === undefined || + (typeof payload.sound === 'string' && + NOTIFICATION_SOUNDS.has(payload.sound as NotificationSoundId)) + ? (payload.sound as NotificationSoundId | undefined) + : null + if (sound === null) return null + + return { + appId, + route: `/apps/${appId}`, + ...(sound ? { sound } : {}), + ...(subtitle ? { subtitle } : {}), + text, + title, + } +} + +function normalizeOpenPayload( + payload: unknown, +): { appId: string; data?: unknown } | null { + if ( + !isRecord(payload) || + !hasOnlyKeys(payload, ['appId', 'data']) || + typeof payload.appId !== 'string' || + payload.appId.length < 1 || + payload.appId.length > 64 + ) { + return null + } + if (!Object.prototype.hasOwnProperty.call(payload, 'data')) { + return { appId: payload.appId } + } + + const data = normalizeJsonValue(payload.data, DEEP_LINK_DATA_MAX_BYTES) + if (!data.success || !isRecord(data.value)) return null + return { appId: payload.appId, data: data.value } +} + +function hasPermission( + capabilities: readonly string[], + permission: string, +): boolean { + return capabilities.includes(permission) +} + +function response( + requestId: string, + success: boolean, + data?: unknown, + error?: string, +): SkyPhoneAppBridgeResponse { + return { + ...(data === undefined ? {} : { data }), + ...(error ? { error } : {}), + requestId, + success, + } +} + +export function getSkyPhoneAppCapabilities( + permissions: readonly string[], +): SkyPhoneAppCapability[] { + const capabilities: SkyPhoneAppCapability[] = [] + if (hasPermission(permissions, 'app.close')) capabilities.push('app.close') + if (hasPermission(permissions, 'app.open')) capabilities.push('app.open') + if (hasPermission(permissions, 'device.storage')) { + capabilities.push('device.storage.get', 'device.storage.set') + } + if (hasPermission(permissions, 'locale.read')) + capabilities.push('locale.read') + if (hasPermission(permissions, 'notifications')) { + capabilities.push('notification.create') + } + if (hasPermission(permissions, 'theme.read')) capabilities.push('theme.read') + return capabilities +} + +export function shouldReportCustomAppReady( + bridgeMode: CustomAppBridgeMode, + signal: 'bridge-ready' | 'frame-load', +): boolean { + return bridgeMode === 'sky' + ? signal === 'bridge-ready' + : signal === 'frame-load' +} + +export function getCustomAppFrameBootstrapMessages( + compatibility: Readonly>, +): readonly unknown[] { + if (compatibility.provider === 'lb_phone') { + return ['componentsLoaded'] + } + return [] +} + +export function createCustomAppBridgeRequestHandler( + dependencies: CustomAppBridgeDependencies, +): { + handle: ( + request: SkyPhoneAppBridgeRequest, + ) => Promise +} { + const handledRequestIds = new Set() + const handledRequestOrder: string[] = [] + + return { + async handle( + request: SkyPhoneAppBridgeRequest, + ): Promise { + if ( + typeof request.requestId !== 'string' || + request.requestId.length < 1 || + request.requestId.length > 128 || + typeof request.method !== 'string' || + request.method.length < 1 || + request.method.length > 64 || + handledRequestIds.has(request.requestId) + ) { + return null + } + handledRequestIds.add(request.requestId) + handledRequestOrder.push(request.requestId) + if (handledRequestOrder.length > HANDLED_REQUEST_ID_LIMIT) { + const expiredRequestId = handledRequestOrder.shift() + if (expiredRequestId) handledRequestIds.delete(expiredRequestId) + } + + const source = dependencies.getSourceApp() + try { + if (request.method === 'app.close') { + return hasPermission(source.capabilities, 'app.close') + ? { + effect: { type: 'close' }, + response: response(request.requestId, true), + } + : { + response: response( + request.requestId, + false, + undefined, + 'permission_denied', + ), + } + } + + if ( + request.method === 'device.storage.get' || + request.method === 'device.storage.set' + ) { + if (!hasPermission(source.capabilities, 'device.storage')) { + return { + response: response( + request.requestId, + false, + undefined, + 'permission_denied', + ), + } + } + + const storagePayload = + request.method === 'device.storage.get' + ? normalizeStorageGetPayload(request.payload) + : normalizeStorageSetPayload(request.payload) + if (!storagePayload) { + return { + response: response( + request.requestId, + false, + undefined, + 'invalid_storage_request', + ), + } + } + + const result = await dependencies.storageCall( + request.method === 'device.storage.get' + ? 'custom-app:storage:get' + : 'custom-app:storage:set', + { appId: source.id, ...storagePayload }, + ) + return { + response: response( + request.requestId, + result.success, + result.data, + result.error, + ), + } + } + + if (request.method === 'notification.create') { + if (!hasPermission(source.capabilities, 'notifications')) { + return { + response: response( + request.requestId, + false, + undefined, + 'permission_denied', + ), + } + } + const notification = normalizeNotificationPayload( + source.id, + request.payload, + ) + if (!notification) { + return { + response: response( + request.requestId, + false, + undefined, + 'invalid_notification', + ), + } + } + const notificationId = dependencies.createNotification(notification) + return { + response: response(request.requestId, true, { notificationId }), + } + } + + if (request.method === 'app.open') { + if (!hasPermission(source.capabilities, 'app.open')) { + return { + response: response( + request.requestId, + false, + undefined, + 'permission_denied', + ), + } + } + const openPayload = normalizeOpenPayload(request.payload) + if (!openPayload) { + return { + response: response( + request.requestId, + false, + undefined, + 'invalid_app_open', + ), + } + } + const target = dependencies.resolveTarget(openPayload.appId) + if (!target) { + return { + response: response( + request.requestId, + false, + undefined, + 'app_not_found', + ), + } + } + if (openPayload.data !== undefined && !target.external) { + return { + response: response( + request.requestId, + false, + undefined, + 'open_data_not_supported', + ), + } + } + if ( + target.external && + !dependencies.prepareExternalOpen(target.id, openPayload.data) + ) { + return { + response: response( + request.requestId, + false, + undefined, + 'open_failed', + ), + } + } + return { + effect: { route: target.route, type: 'open' }, + response: response(request.requestId, true, { + appId: target.id, + }), + } + } + + return { + response: response( + request.requestId, + false, + undefined, + 'unsupported_method', + ), + } + } catch (error) { + console.error( + `[Custom apps] Bridge request failed for ${source.id}.`, + error, + ) + return { + response: response( + request.requestId, + false, + undefined, + 'request_failed', + ), + } + } + }, + } +} diff --git a/frontend/src/utils/customAppLifecycle.test.ts b/frontend/src/utils/customAppLifecycle.test.ts new file mode 100644 index 0000000..cf67f98 --- /dev/null +++ b/frontend/src/utils/customAppLifecycle.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { ExternalPhoneAppDefinition } from '@/types/apps' +import { + createCustomAppLifecycleReporter, + createCustomAppLifecycleScheduler, + createCustomAppOrientationCoordinator, + getCustomAppFrameKey, + getCustomAppSafeArea, +} from '@/utils/customAppLifecycle' +import type { NuiResponse } from '@/utils/nui' + +function deferredResponse(): { + promise: Promise + resolve: (response: NuiResponse) => void +} { + let resolve!: (response: NuiResponse) => void + const promise = new Promise((complete) => { + resolve = complete + }) + return { promise, resolve } +} + +function externalApp( + overrides: Partial = {}, +): ExternalPhoneAppDefinition { + return { + bridgeMode: 'sky', + bundled: true, + capabilities: ['theme.read'], + category: 'utilities', + compatibility: {}, + component: null, + defaultInstalled: true, + description: 'Example description', + developer: 'Example developer', + dockOrder: null, + gridOrder: 100, + icon: {} as ExternalPhoneAppDefinition['icon'], + iconClass: 'app-icon--custom', + iconImage: 'https://cfx-nui-example/web/icon.svg', + id: 'example-app' as ExternalPhoneAppDefinition['id'], + kind: 'external', + name: 'Example App', + orientation: 'portrait', + ownerResource: 'example_resource', + readyTimeoutMs: 8000, + removable: true, + route: '/apps/example-app' as ExternalPhoneAppDefinition['route'], + ui: 'https://cfx-nui-example/web/index.html', + ...overrides, + } +} + +describe('custom app lifecycle', () => { + it('waits for delayed open and preserves fast ready/close ordering', async () => { + const open = deferredResponse() + const ready = deferredResponse() + const close = deferredResponse() + const calls: string[] = [] + const send = vi.fn((event: string) => { + calls.push(event) + if (event === 'open') return open.promise + if (event === 'ready') return ready.promise + return close.promise + }) + const reporter = createCustomAppLifecycleReporter({ + scheduler: createCustomAppLifecycleScheduler(), + send, + }) + + const openResult = reporter.report('open', { screen: 'details' }) + const readyResult = reporter.report('ready') + const closeResult = reporter.report('close') + await Promise.resolve() + + expect(calls).toEqual(['open']) + + open.resolve({ success: true }) + await openResult + await Promise.resolve() + expect(calls).toEqual(['open', 'ready']) + + ready.resolve({ success: true }) + await readyResult + await Promise.resolve() + expect(calls).toEqual(['open', 'ready', 'close']) + + close.resolve({ success: true }) + await expect(closeResult).resolves.toEqual({ success: true }) + expect(send).toHaveBeenNthCalledWith(1, 'open', { screen: 'details' }) + }) + + it('finalizes only successful events and retries a failed event', async () => { + const failure = vi.fn() + const send = vi + .fn() + .mockResolvedValueOnce({ error: 'phone_closed', success: false }) + .mockResolvedValueOnce({ success: true }) + const reporter = createCustomAppLifecycleReporter({ + onFailure: failure, + scheduler: createCustomAppLifecycleScheduler(), + send, + }) + + await expect(reporter.report('open')).resolves.toMatchObject({ + error: 'phone_closed', + success: false, + }) + expect(reporter.isComplete('open')).toBe(false) + + await expect(reporter.report('open')).resolves.toEqual({ success: true }) + expect(reporter.isComplete('open')).toBe(true) + expect(send).toHaveBeenCalledTimes(2) + expect(failure).toHaveBeenCalledWith('open', 'phone_closed') + }) + + it('treats a failed hook as applied and continues with ready', async () => { + const failure = vi.fn() + const send = vi + .fn() + .mockResolvedValueOnce({ error: 'hook_failed', success: false }) + .mockResolvedValueOnce({ success: true }) + const reporter = createCustomAppLifecycleReporter({ + onFailure: failure, + scheduler: createCustomAppLifecycleScheduler(), + send, + }) + + const open = reporter.report('open') + const ready = reporter.report('ready') + + await expect(open).resolves.toEqual({ + error: 'hook_failed', + success: false, + }) + await expect(ready).resolves.toEqual({ success: true }) + expect(reporter.isComplete('open')).toBe(true) + expect(reporter.isComplete('ready')).toBe(true) + expect(failure).toHaveBeenCalledWith('open', 'hook_failed') + expect(send).toHaveBeenCalledTimes(2) + }) + + it('does not report ready when its queued open failed', async () => { + const send = vi.fn().mockResolvedValue({ + error: 'phone_closed', + success: false, + }) + const reporter = createCustomAppLifecycleReporter({ + scheduler: createCustomAppLifecycleScheduler(), + send, + }) + + const open = reporter.report('open') + const ready = reporter.report('ready') + + await expect(open).resolves.toMatchObject({ success: false }) + await expect(ready).resolves.toEqual({ + error: 'open_not_completed', + success: false, + }) + expect(send).toHaveBeenCalledOnce() + }) + + it('turns a rejected transport promise into a retryable response', async () => { + const send = vi + .fn() + .mockRejectedValueOnce(new Error('network down')) + .mockResolvedValueOnce({ success: true }) + const reporter = createCustomAppLifecycleReporter({ + scheduler: createCustomAppLifecycleScheduler(), + send, + }) + + await expect(reporter.report('open')).resolves.toEqual({ + error: 'network down', + success: false, + }) + await expect(reporter.report('open')).resolves.toEqual({ success: true }) + expect(reporter.isComplete('open')).toBe(true) + }) + + it('turns iframe, bridge, permission, and orientation updates into remounts', () => { + const original = externalApp() + const originalKey = getCustomAppFrameKey(original) + + expect( + getCustomAppFrameKey( + externalApp({ ui: 'https://cfx-nui-example/web/v2.html' }), + ), + ).not.toBe(originalKey) + expect( + getCustomAppFrameKey(externalApp({ bridgeMode: 'legacy' })), + ).not.toBe(originalKey) + expect( + getCustomAppFrameKey(externalApp({ capabilities: ['app.close'] })), + ).not.toBe(originalKey) + expect( + getCustomAppFrameKey(externalApp({ orientation: 'landscape' })), + ).not.toBe(originalKey) + }) + + it('applies landscape safe areas and resets only the current owner', () => { + const changes: boolean[] = [] + const coordinator = createCustomAppOrientationCoordinator() + const previous = coordinator.createSession((value) => changes.push(value)) + const current = coordinator.createSession((value) => changes.push(value)) + + previous.apply('landscape') + current.apply('landscape') + previous.release() + current.apply('portrait') + current.release() + + expect(changes).toEqual([true, true, false, false]) + expect(getCustomAppSafeArea('portrait')).toEqual({ + bottom: 25, + left: 0, + right: 0, + top: 44, + }) + expect(getCustomAppSafeArea('landscape')).toEqual({ + bottom: 0, + left: 44, + right: 25, + top: 0, + }) + }) +}) diff --git a/frontend/src/utils/customAppLifecycle.ts b/frontend/src/utils/customAppLifecycle.ts new file mode 100644 index 0000000..4742481 --- /dev/null +++ b/frontend/src/utils/customAppLifecycle.ts @@ -0,0 +1,183 @@ +import type { ExternalPhoneAppDefinition } from '@/types/apps' +import type { NuiResponse } from '@/utils/nui' + +export type CustomAppLifecycleEvent = 'close' | 'open' | 'ready' +export type CustomAppOrientation = ExternalPhoneAppDefinition['orientation'] + +type CustomAppLifecycleTask = () => Promise + +export type CustomAppLifecycleScheduler = { + enqueue: (task: CustomAppLifecycleTask) => Promise +} + +type CustomAppLifecycleReporterOptions = { + onFailure?: (event: CustomAppLifecycleEvent, error: string) => void + scheduler: CustomAppLifecycleScheduler + send: (event: CustomAppLifecycleEvent, data?: unknown) => Promise +} + +export type CustomAppOrientationCoordinator = { + createSession: (setLandscape: (landscape: boolean) => void) => { + apply: (orientation: CustomAppOrientation) => void + release: () => void + } +} + +function stableValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stableValue) + if (!value || typeof value !== 'object') return value + + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, stableValue(item)]), + ) +} + +export function getCustomAppFrameKey(app: ExternalPhoneAppDefinition): string { + return JSON.stringify( + stableValue({ + bridgeMode: app.bridgeMode, + bundled: app.bundled, + capabilities: app.capabilities, + compatibility: app.compatibility, + description: app.description, + id: app.id, + name: app.name, + orientation: app.orientation, + ownerResource: app.ownerResource, + readyTimeoutMs: app.readyTimeoutMs, + ui: app.ui, + }), + ) +} + +export function getCustomAppSafeArea(orientation: CustomAppOrientation): { + bottom: number + left: number + right: number + top: number +} { + return orientation === 'landscape' + ? { bottom: 0, left: 44, right: 25, top: 0 } + : { bottom: 25, left: 0, right: 0, top: 44 } +} + +export function createCustomAppOrientationCoordinator(): CustomAppOrientationCoordinator { + let owner: symbol | null = null + + return { + createSession(setLandscape) { + const token = Symbol('custom-app-orientation') + + return { + apply(orientation): void { + owner = token + setLandscape(orientation === 'landscape') + }, + release(): void { + if (owner !== token) return + owner = null + setLandscape(false) + }, + } + }, + } +} + +export const customAppOrientationCoordinator = + createCustomAppOrientationCoordinator() + +export function createCustomAppLifecycleScheduler(): CustomAppLifecycleScheduler { + let tail: Promise = Promise.resolve() + + return { + enqueue(task): Promise { + const operation = tail.then(task) + tail = operation.then( + () => undefined, + () => undefined, + ) + return operation + }, + } +} + +export const customAppLifecycleScheduler = createCustomAppLifecycleScheduler() + +export function createCustomAppLifecycleReporter( + options: CustomAppLifecycleReporterOptions, +): { + isComplete: (event: CustomAppLifecycleEvent) => boolean + report: ( + event: CustomAppLifecycleEvent, + data?: unknown, + ) => Promise +} { + const completed = new Set() + const pending = new Map>() + + function notifyFailure(event: CustomAppLifecycleEvent, error: string): void { + try { + options.onFailure?.(event, error) + } catch (failureHandlerError) { + console.error( + '[Custom apps] Lifecycle failure handler threw an error.', + failureHandlerError, + ) + } + } + + function report( + event: CustomAppLifecycleEvent, + data?: unknown, + ): Promise { + if (completed.has(event)) return Promise.resolve({ success: true }) + + const existing = pending.get(event) + if (existing) return existing + + const operation = options.scheduler.enqueue(async () => { + if (event === 'ready' && !completed.has('open')) { + return { error: 'open_not_completed', success: false } + } + + try { + return await options.send(event, data) + } catch (error) { + return { + error: error instanceof Error ? error.message : 'request_failed', + success: false, + } + } + }) + + const settled = operation.then( + (response) => { + if (response.success || response.error === 'hook_failed') { + completed.add(event) + } + if (!response.success) + notifyFailure(event, response.error ?? 'request_failed') + if (pending.get(event) === settled) pending.delete(event) + return response + }, + (error) => { + const response = { + error: error instanceof Error ? error.message : 'request_failed', + success: false, + } + notifyFailure(event, response.error) + if (pending.get(event) === settled) pending.delete(event) + return response + }, + ) + pending.set(event, settled) + return settled + } + + return { + isComplete: (event) => completed.has(event), + report, + } +} diff --git a/frontend/src/utils/homeLayout.test.ts b/frontend/src/utils/homeLayout.test.ts index 9bcf86d..72602c6 100644 --- a/frontend/src/utils/homeLayout.test.ts +++ b/frontend/src/utils/homeLayout.test.ts @@ -34,7 +34,7 @@ describe('home layout', () => { null, ]) expect(layout.dock).toEqual(['phone', 'messages', 'clock', null]) - expect(layout.version).toBe(2) + expect(layout.version).toBe(3) }) it('migrates compact persisted arrays and appends newly installed apps', () => { @@ -51,7 +51,7 @@ describe('home layout', () => { expect(layout.dock).toEqual(['messages', null, null, null]) expect(layout.grid.slice(0, 4)).toEqual(['mail', 'clock', 'notes', null]) expect(layout.hidden).toEqual(['phone']) - expect(layout.version).toBe(2) + expect(layout.version).toBe(3) }) it('preserves explicit gaps in versioned layouts', () => { @@ -79,6 +79,28 @@ describe('home layout', () => { expect(layout.dock).toEqual(['messages', null, 'clock', null]) }) + it('keeps valid custom-app tombstones in version 3 layouts', () => { + const grid: HomeLayout['grid'] = Array.from( + { length: HOME_GRID_PAGE_SIZE }, + () => null, + ) + grid[6] = 'temporarily-missing' as HomeLayout['hidden'][number] + + const layout = parseHomeLayout( + { + dock: ['phone', null, null, null], + grid, + hidden: [], + version: 3, + }, + defaults, + [...installed], + ) + + expect(layout.grid[6]).toBe('temporarily-missing') + expect(layout.version).toBe(3) + }) + it('preserves independently positioned shortcuts for the same app', () => { const grid: HomeLayout['grid'] = Array.from( { length: HOME_GRID_PAGE_SIZE }, diff --git a/frontend/src/utils/homeLayout.ts b/frontend/src/utils/homeLayout.ts index e55356c..01bb146 100644 --- a/frontend/src/utils/homeLayout.ts +++ b/frontend/src/utils/homeLayout.ts @@ -11,7 +11,11 @@ export type HomeLayout = { dock: HomeSlot[] grid: HomeSlot[] hidden: LaunchablePhoneAppId[] - version: 2 + version: 3 +} + +function isPersistableAppId(value: unknown): value is LaunchablePhoneAppId { + return typeof value === 'string' && /^[a-z0-9][a-z0-9._-]{1,63}$/.test(value) } function readAppIds( @@ -134,7 +138,7 @@ export function createDefaultHomeLayout( dock[index] = id } - return { dock, grid, hidden: [], version: 2 } + return { dock, grid, hidden: [], version: 3 } } export function parseHomeLayout( @@ -146,6 +150,14 @@ export function parseHomeLayout( const source = value as Partial> const availableIds = new Set(installedIds) + if (source.version === 3) { + for (const collection of [source.dock, source.grid, source.hidden]) { + if (!Array.isArray(collection)) continue + for (const appId of collection) { + if (isPersistableAppId(appId)) availableIds.add(appId) + } + } + } const hidden = readAppIds(source.hidden, availableIds) const hiddenIds = new Set(hidden) const persistedGridLength = Array.isArray(source.grid) @@ -158,7 +170,7 @@ export function parseHomeLayout( let grid: HomeSlot[] let dock: HomeSlot[] - if (source.version === 2) { + if (source.version === 2 || source.version === 3) { grid = readSlots(source.grid, availableIds, gridLength) dock = readSlots(source.dock, availableIds, HOME_DOCK_CAPACITY) } else { @@ -189,7 +201,7 @@ export function parseHomeLayout( } } - return { dock, grid, hidden, version: 2 } + return { dock, grid, hidden, version: 3 } } export function removeHomeApp( @@ -202,7 +214,7 @@ export function removeHomeApp( hidden: layout.hidden.includes(appId) ? [...layout.hidden] : [...layout.hidden, appId], - version: 2, + version: 3, } } @@ -217,7 +229,7 @@ export function restoreHomeApp( dock: [...layout.dock], grid, hidden: layout.hidden.filter((id) => id !== appId), - version: 2, + version: 3, } } @@ -230,7 +242,7 @@ export function addHomePage(layout: HomeLayout): HomeLayout { dock: [...layout.dock], grid: [...layout.grid, ...createSlots(HOME_GRID_PAGE_SIZE)], hidden: [...layout.hidden], - version: 2, + version: 3, } } @@ -252,7 +264,7 @@ export function deleteHomePage(layout: HomeLayout, page: number): HomeLayout { dock: [...layout.dock], grid, hidden: [...layout.hidden], - version: 2, + version: 3, } } @@ -267,7 +279,7 @@ export function moveHomeApp( dock: [...layout.dock], grid: [...layout.grid], hidden: [...layout.hidden], - version: 2, + version: 3, } const source = next[from] const target = next[to] diff --git a/frontend/src/utils/lbPhoneAppBridge.test.ts b/frontend/src/utils/lbPhoneAppBridge.test.ts new file mode 100644 index 0000000..c26af99 --- /dev/null +++ b/frontend/src/utils/lbPhoneAppBridge.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' + +import type { ExternalPhoneAppDefinition } from '@/types/apps' +import { + createLbPhoneFrameDocument, + createLbPhoneHostSettings, + getLbPhoneCallbackResource, + usesLbPhoneHostRuntime, +} from '@/utils/lbPhoneAppBridge' +import { DEFAULT_PHONE_PREFERENCES } from '@/utils/preferences' + +function externalApp( + overrides: Partial = {}, +): ExternalPhoneAppDefinition { + return { + bridgeMode: 'legacy', + bundled: false, + capabilities: [], + category: 'games', + compatibility: { provider: 'lb_phone', resourceName: 'snake_app' }, + component: null, + defaultInstalled: true, + description: 'Snake', + developer: 'Example', + dockOrder: null, + gridOrder: 100, + icon: {} as ExternalPhoneAppDefinition['icon'], + iconClass: 'app-icon--custom', + iconImage: 'https://cfx-nui-snake_app/ui/icon.png', + id: 'snake-game' as ExternalPhoneAppDefinition['id'], + kind: 'external', + name: 'Snake', + orientation: 'portrait', + ownerResource: 'phone_adapter', + readyTimeoutMs: 8000, + removable: true, + route: '/apps/snake-game' as ExternalPhoneAppDefinition['route'], + ui: 'https://cfx-nui-snake_app/ui/dist/index.html', + ...overrides, + } +} + +describe('LB Phone app bridge', () => { + it('selects local LB documents without taking over query-driven apps', () => { + expect(usesLbPhoneHostRuntime(externalApp())).toBe(true) + expect( + usesLbPhoneHostRuntime( + externalApp({ + ui: 'https://cfx-nui-snake_app/ui/index.html?route=/dispatch', + }), + ), + ).toBe(false) + expect( + usesLbPhoneHostRuntime( + externalApp({ compatibility: { provider: '17mov' } }), + ), + ).toBe(false) + }) + + it('uses the declared callback resource and rejects malformed overrides', () => { + expect(getLbPhoneCallbackResource(externalApp())).toBe('snake_app') + expect( + getLbPhoneCallbackResource( + externalApp({ + compatibility: { + provider: 'lb_phone', + resourceName: '../wrong', + }, + }), + ), + ).toBe('phone_adapter') + }) + + it('maps phone preferences into the LB settings contract', () => { + const settings = createLbPhoneHostSettings({ + deviceName: 'Main phone', + isDarkMode: true, + language: 'de', + preferences: DEFAULT_PHONE_PREFERENCES, + securityEnabled: true, + }) + + expect(settings).toMatchObject({ + display: { brightness: 1, size: 1, theme: 'dark' }, + locale: 'de', + name: 'Main phone', + security: { pinCode: true }, + }) + }) + + it('injects the LB runtime and asset base before the vendor bundle', () => { + const html = + '' + const document = createLbPhoneFrameDocument(html, { + appName: 'snake-game', + resourceName: 'snake_app', + settings: createLbPhoneHostSettings({ + deviceName: '', + isDarkMode: false, + language: 'en', + preferences: DEFAULT_PHONE_PREFERENCES, + securityEnabled: false, + }), + ui: 'https://cfx-nui-snake_app/ui/dist/index.html', + }) + + expect(document.indexOf(' - - + + + {{ phone.t('Common.loading') }} diff --git a/frontend/src/views/SpringboardView.vue b/frontend/src/views/SpringboardView.vue index e5a0ab3..aa02bf4 100644 --- a/frontend/src/views/SpringboardView.vue +++ b/frontend/src/views/SpringboardView.vue @@ -7,7 +7,7 @@ import AppIcon from '@/components/AppIcon.vue' import SpringboardWidgetGrid from '@/components/SpringboardWidgetGrid.vue' import WidgetConfigSheet from '@/components/WidgetConfigSheet.vue' import WidgetPickerSheet from '@/components/WidgetPickerSheet.vue' -import { PHONE_APPS } from '@/config/apps' +import { getPhoneAppLabel, PHONE_APPS } from '@/config/apps' import { useAppStoreStore } from '@/stores/app-store' import { usePhoneStore } from '@/stores/phone' import { useWidgetsStore } from '@/stores/widgets' @@ -74,9 +74,7 @@ let edgePageDirection = 0 let edgePageLocked = false const installedApps = computed(() => - PHONE_APPS.filter( - (app) => app.category !== 'games' || appStore.claimedApps.includes(app.id), - ), + PHONE_APPS.filter((app) => appStore.isInstalled(app.id)), ) const installedAppsById = computed( () => new Map(installedApps.value.map((app) => [app.id, app])), @@ -190,7 +188,9 @@ const filteredApps = computed(() => { const query = searchQuery.value.trim().toLocaleLowerCase(phone.lang) if (!query) return installedApps.value return installedApps.value.filter((app) => - phone.t(app.labelKey).toLocaleLowerCase(phone.lang).includes(query), + getPhoneAppLabel(app, phone.t) + .toLocaleLowerCase(phone.lang) + .includes(query), ) }) const appGroups = computed(() => { @@ -223,9 +223,14 @@ const appGroups = computed(() => { const alphabeticalGroups = computed(() => { const groups: Array<{ apps: PhoneAppDefinition[]; letter: string }> = [] for (const app of [...filteredApps.value].sort((a, b) => - phone.t(a.labelKey).localeCompare(phone.t(b.labelKey), phone.lang), + getPhoneAppLabel(a, phone.t).localeCompare( + getPhoneAppLabel(b, phone.t), + phone.lang, + ), )) { - const letter = phone.t(app.labelKey).charAt(0).toLocaleUpperCase(phone.lang) + const letter = getPhoneAppLabel(app, phone.t) + .charAt(0) + .toLocaleUpperCase(phone.lang) const group = groups.find((candidate) => candidate.letter === letter) if (group) group.apps.push(app) else groups.push({ apps: [app], letter }) @@ -803,7 +808,9 @@ watch(isEditablePage, (visible) => { rounded class="app-library-restore-button" :aria-label=" - phone.t('Home.addToHome', { app: phone.t(app.labelKey) }) + phone.t('Home.addToHome', { + app: getPhoneAppLabel(app, phone.t), + }) " @click.stop="restoreHomeApp(app.id)" > @@ -864,14 +871,16 @@ watch(isEditablePage, (visible) => { class="app-library-row" > - {{ phone.t(app.labelKey) }} + {{ getPhoneAppLabel(app, phone.t) }} diff --git a/frontend/src/views/apps/AppStoreApp.vue b/frontend/src/views/apps/AppStoreApp.vue index ecd399c..301b40e 100644 --- a/frontend/src/views/apps/AppStoreApp.vue +++ b/frontend/src/views/apps/AppStoreApp.vue @@ -11,7 +11,11 @@ import { Gamepad2, Grid2X2, Search } from 'lucide-vue-next' import { computed, ref } from 'vue' import { useRouter } from 'vue-router' -import { PHONE_APPS } from '@/config/apps' +import { + getPhoneAppLabel, + isLaunchablePhoneApp, + PHONE_APPS, +} from '@/config/apps' import { useAppStoreStore } from '@/stores/app-store' import { usePhoneStore } from '@/stores/phone' import type { @@ -35,16 +39,11 @@ const tabBarColors = { } const catalog = computed(() => PHONE_APPS.filter((app): app is LaunchablePhoneAppDefinition => { - if ( - app.component === null || - app.route === null || - app.id === 'app-store' - ) { + if (!isLaunchablePhoneApp(app) || app.id === 'app-store') { return false } - const installed = - app.category !== 'games' || appStore.claimedApps.includes(app.id) + const installed = appStore.isInstalled(app.id) return ( !installed || appStore.homeLayout.hidden.includes(app.id) || @@ -63,7 +62,9 @@ const shownApps = computed(() => { const search = query.value.trim().toLocaleLowerCase(phone.lang) if (!search) return catalog.value return catalog.value.filter((app) => - phone.t(app.labelKey).toLocaleLowerCase(phone.lang).includes(search), + getPhoneAppLabel(app, phone.t) + .toLocaleLowerCase(phone.lang) + .includes(search), ) }) @@ -76,8 +77,7 @@ function appAction( ): 'get' | 'installing' | 'open' { if (appStore.installingApps[app.id]) return 'installing' - const installed = - app.category !== 'games' || appStore.claimedApps.includes(app.id) + const installed = appStore.isInstalled(app.id) if ( installed && !appStore.homeLayout.hidden.includes(app.id) && @@ -130,13 +130,13 @@ function handleApp(app: LaunchablePhoneAppDefinition): void { draggable="false" /> - {{ phone.t(app.labelKey) }} + {{ getPhoneAppLabel(app, phone.t) }} {{ phone.t(`Home.groups.${app.category}`) }} { } function onBillingMessage(event: MessageEvent): void { + if (!isTrustedRootMessageSource(event.source, window)) return if ( event.data?.type !== 'billing:changed' && event.data?.type !== 'billing:new' diff --git a/frontend/src/views/apps/CameraApp.vue b/frontend/src/views/apps/CameraApp.vue index e840f08..c009a22 100644 --- a/frontend/src/views/apps/CameraApp.vue +++ b/frontend/src/views/apps/CameraApp.vue @@ -20,6 +20,7 @@ import type { MediaType, PhoneMedia, UploadResult } from '@/types/media' import { createGameView, type GameView } from '@/utils/gameView' import { formatRecordingDuration, mediaErrorKey } from '@/utils/media' import { nuiCall } from '@/utils/nui' +import { isTrustedRootMessageSource } from '@/utils/windowMessages' type CaptureItem = { error?: string @@ -317,6 +318,7 @@ function onKeydown(event: KeyboardEvent): void { } function onMessage(event: MessageEvent): void { + if (!isTrustedRootMessageSource(event.source, window)) return const message = event.data as { data?: Record type?: string diff --git a/frontend/src/views/apps/CompaniesApp.vue b/frontend/src/views/apps/CompaniesApp.vue new file mode 100644 index 0000000..3e2de93 --- /dev/null +++ b/frontend/src/views/apps/CompaniesApp.vue @@ -0,0 +1,3264 @@ + + + + + + + + + + + + + + + + + + + + + + + + + {{ phone.t('Apps.companies.directory.allCategories') }} + + + {{ categoryLabel(category.id, category.name) }} + + + + + + {{ phone.t('Apps.companies.loading.directory') }} + + + + {{ phone.t('Apps.companies.states.directoryError') }} + {{ errorText(companies.directoryError) }} + + + {{ phone.t('Apps.companies.tryAgain') }} + + + + + + {{ + phone.t( + filtersActive + ? 'Apps.companies.states.noResults' + : 'Apps.companies.states.noCompanies', + ) + }} + + + {{ + phone.t( + filtersActive + ? 'Apps.companies.states.noResultsBody' + : 'Apps.companies.states.noCompaniesBody', + ) + }} + + + {{ phone.t('Apps.companies.directory.resetFilters') }} + + + + + {{ + phone.t('Apps.companies.directory.availableSection') + }} + + + + + {{ companyInitials(company) }} + + {{ company.name }} + {{ + company.location?.district ?? + categoryLabel(company.categoryId, company.categoryName) + }} + + {{ + phone.t(`Apps.companies.availability.${company.availability}`) + }} + + + + + + {{ + phone.t('Apps.companies.directory.allCompanies') + }} + + + + + + {{ companyInitials(company) }} + + + + + + {{ + phone.t( + `Apps.companies.availability.${company.availability}`, + ) + }} + + {{ + company.location?.district ?? + categoryLabel(company.categoryId, company.categoryName) + }} + + + + + + + {{ phone.t('Apps.companies.loadMore') }} + + + + + + + + + {{ phone.t('Apps.companies.requests.open') }} + + + {{ phone.t('Apps.companies.requests.closed') }} + + + + + + + {{ phone.t('Apps.companies.loading.requests') }} + + + + {{ phone.t('Apps.companies.states.requestsError') }} + {{ errorText(companies.myRequestsError) }} + + {{ phone.t('Apps.companies.tryAgain') }} + + + + + {{ + phone.t( + `Apps.companies.states.no${requestList === 'open' ? 'Open' : 'Closed'}Requests`, + ) + }} + {{ phone.t('Apps.companies.states.noRequestsBody') }} + + {{ phone.t('Apps.companies.requests.findCompany') }} + + + + + + + + + + {{ + item.unreadCount + }} + + {{ phone.t(`Apps.companies.requestStatuses.${item.status}`) }} + + + + + + + {{ phone.t('Apps.companies.loadMore') }} + + + + + + + {{ phone.t('Apps.companies.loading.work') }} + + + + {{ phone.t('Apps.companies.states.workError') }} + {{ errorText(companies.workContextError) }} + + {{ phone.t('Apps.companies.tryAgain') }} + + + + + {{ phone.t('Apps.companies.work.notAuthorized') }} + {{ phone.t('Apps.companies.work.notAuthorizedBody') }} + + + + + + {{ companyInitials(workCompany) }} + + + {{ phone.t('Apps.companies.work.workspace') }} + {{ workCompany.name }} + {{ + phone.t(`Apps.companies.roles.${companies.workContext.role}`) + }} + + + {{ + phone.t(`Apps.companies.availability.${workCompany.availability}`) + }} + + + + {{ + phone.t('Apps.companies.work.publicAvailability') + }} + + + {{ phone.t(`Apps.companies.availability.${availability}`) }} + + + + + + + + + + + + + + + + {{ + phone.t('Apps.companies.work.overview') + }} + + + {{ companies.workContext.metrics.new }} + {{ phone.t('Apps.companies.work.metrics.new') }} + + + {{ companies.workContext.metrics.assigned }} + {{ phone.t('Apps.companies.work.metrics.assigned') }} + + + {{ companies.workContext.metrics.waiting }} + {{ phone.t('Apps.companies.work.metrics.waiting') }} + + + {{ companies.workContext.metrics.completedToday }} + {{ + phone.t('Apps.companies.work.metrics.completedToday') + }} + + + + + + + + + {{ phone.t('Apps.companies.loading.profile') }} + + + + {{ phone.t('Apps.companies.states.profileError') }} + {{ errorText(companies.directoryError) }} + {{ + phone.t('Apps.companies.back') + }} + + + + + + {{ companyInitials(activeCompany) }} + + + + + {{ + categoryLabel( + activeCompany.categoryId, + activeCompany.categoryName, + ) + }} + + + {{ activeCompany.name }} + + {{ + phone.t( + `Apps.companies.availability.${activeCompany.availability}`, + ) + }} + + + {{ + phone.t('Apps.companies.profile.updated', { + time: relativeTime(activeCompany.availabilityUpdatedAt), + }) + }} + + {{ activeCompany.description }} + + + + + + {{ + phone.t('Apps.companies.profile.announcement') + }} + {{ activeCompany.announcement.body }} + + + + {{ + phone.t('Apps.companies.profile.location') + }} + + + + + + + + + + {{ + phone.t('Apps.companies.profile.hours') + }} + + + + + + + + {{ + phone.t('Apps.companies.profile.services') + }} + + + + + + + + {{ phone.t('Apps.companies.profile.call') }} + + + {{ + phone.t('Apps.companies.profile.message') + }} + + + {{ phone.t('Apps.companies.profile.route') }} + + + {{ + phone.t('Apps.companies.profile.request') + }} + + + + + + + + + {{ phone.t('Apps.companies.loading.request') }} + + + + {{ phone.t('Apps.companies.states.requestError') }} + {{ errorText(companies.requestError) }} + {{ + phone.t('Apps.companies.back') + }} + + + + + + {{ companies.request.companyName }} + {{ companies.request.subject }} + + + {{ + phone.t( + `Apps.companies.requestStatuses.${companies.request.status}`, + ) + }} + + {{ companies.request.description }} + + {{ + companies.request.serviceName ?? + phone.t('Apps.companies.requests.generalService') + }} + · {{ formatDate(companies.request.createdAt) }} + + + + + + + + {{ + phone.t('Apps.companies.requests.timeline') + }} + + + + + {{ eventLabel(event) }} + {{ formatDate(event.createdAt) }} + + + + + {{ + phone.t('Apps.companies.requests.conversation') + }} + + + {{ phone.t('Apps.companies.requests.noMessages') }} + + + + + + + {{ phone.t('Apps.companies.requests.call') }} + + + {{ phone.t('Apps.companies.requests.cancel') }} + + + + + + + + + + + + + + + + + + + + {{ workCompany.name }} + {{ + phone.t('Apps.companies.manager.revision', { + revision: String(workCompany.revision), + }) + }} + + + + {{ + phone.t('Apps.companies.manager.availability') + }} + + + {{ phone.t(`Apps.companies.availability.${availability}`) }} + + + + + {{ + phone.t('Apps.companies.manager.profile') + }} + + + + + {{ phone.t('Apps.companies.manager.chooseCover') }} + + + + + {{ phone.t('Apps.companies.manager.chooseLogo') }} + + + + + + + + + + + + + + + + {{ + phone.t('Apps.companies.manager.useCurrentLocation') + }} + + + {{ phone.t('Apps.companies.manager.locationReady') }} + + + {{ phone.t('Apps.companies.manager.saveProfile') }} + + + + + {{ + phone.t('Apps.companies.manager.hours') + }} + + + + + + + + + + + + + + + {{ phone.t('Apps.companies.manager.saveHours') }} + + + + + {{ + phone.t('Apps.companies.manager.services') + }} + + + + + + + + + + + + + + + + + + {{ + phone.t('Apps.companies.manager.removeService') + }} + + + + {{ phone.t('Apps.companies.manager.addService') }} + + + {{ phone.t('Apps.companies.manager.saveServices') }} + + + + + {{ + phone.t('Apps.companies.manager.announcement') + }} + + + + + + {{ + phone.t('Apps.companies.manager.publish') + }} + + + + + + + + {{ + phone.t('Apps.companies.tabs.directory') + }} + + + + {{ + phone.t('Apps.companies.tabs.requests') + }} + + + + {{ + companies.customerUnreadCount + }} + + + + + {{ phone.t('Apps.companies.tabs.work') }} + + + + {{ + companies.workUnreadCount + }} + + + + + + + + + + + + {{ activeCompany.name }} + {{ phone.t('Apps.companies.composer.title') }} + + + + + + + {{ + phone.t('Apps.companies.composer.chooseService') + }} + + + + + + + {{ + service.priceText + }} + + + + + + + + + + + {{ phone.t('Apps.companies.composer.contact') }} + + {{ + phone.t('Apps.companies.composer.registeredSim', { + number: maskedPhoneNumber(phone.device.sim.number), + }) + }} + + {{ + phone.t('Apps.companies.composer.registeredSimRequired') + }} + + + + + {{ + phone.t('Apps.companies.composer.addPhotos', { + count: String(requestMedia.length), + }) + }} + + + + + + + + + + + + {{ phone.t('Apps.companies.composer.send') }} + + + + + + + + {{ phone.t('Apps.companies.workActions.claim') }} + + + {{ phone.t('Apps.companies.workActions.assign') }} + + + {{ phone.t('Apps.companies.workActions.callCustomer') }} + + + {{ + phone.t('Apps.companies.workActions.setStatus', { + status: phone.t(`Apps.companies.requestStatuses.${status}`), + }) + }} + + + + + {{ phone.t('Apps.companies.close') }} + + + + + + + + + + {{ phone.t('Apps.companies.assignment.title') }} + + + + + + + + + + + + + + + + + + {{ phone.t('Apps.companies.assignment.confirm') }} + + + + + + + + {{ phone.t('Apps.companies.requests.keep') }} + + + {{ phone.t('Apps.companies.requests.cancelConfirm') }} + + + + + + + + {{ phone.t('Apps.companies.close') }} + + + {{ phone.t('Apps.companies.conflict.reload') }} + + + + + + {{ toastText }} + + + + + diff --git a/frontend/src/views/apps/CrewLinkApp.vue b/frontend/src/views/apps/CrewLinkApp.vue index dd90b9c..c0d0fdf 100644 --- a/frontend/src/views/apps/CrewLinkApp.vue +++ b/frontend/src/views/apps/CrewLinkApp.vue @@ -82,6 +82,7 @@ import type { } from '@/types/crewlink' import { copyText } from '@/utils/clipboard' import { nuiCall } from '@/utils/nui' +import { isTrustedRootMessageSource } from '@/utils/windowMessages' type CrewLinkTab = 'map' | 'group' | 'pings' | 'profile' type CrewLinkSheet = @@ -660,6 +661,7 @@ function onWheel(event: WheelEvent): void { } function onCrewLinkMessage(event: MessageEvent): void { + if (!isTrustedRootMessageSource(event.source, window)) return if (event.data?.type === 'crewlink:changed') void crew.bootstrap() } diff --git a/frontend/src/views/apps/GalleryApp.vue b/frontend/src/views/apps/GalleryApp.vue index c827fe3..0539d21 100644 --- a/frontend/src/views/apps/GalleryApp.vue +++ b/frontend/src/views/apps/GalleryApp.vue @@ -27,6 +27,7 @@ import { mergeMedia, } from '@/utils/media' import { nuiCall } from '@/utils/nui' +import { isTrustedRootMessageSource } from '@/utils/windowMessages' const isDevelopment = import.meta.env.DEV const developmentGalleryState = isDevelopment @@ -348,6 +349,7 @@ async function deleteSelected(): Promise { } function onMessage(event: MessageEvent): void { + if (!isTrustedRootMessageSource(event.source, window)) return const message = event.data as { data?: DeleteResult; type?: string } if (message.type === 'gallery:changed') { void loadGallery() diff --git a/frontend/src/views/apps/GarageApp.vue b/frontend/src/views/apps/GarageApp.vue index d4dcc97..a7724f9 100644 --- a/frontend/src/views/apps/GarageApp.vue +++ b/frontend/src/views/apps/GarageApp.vue @@ -44,6 +44,7 @@ import type { GarageVehicleStatus, GarageValetState, } from '@/types/garage' +import { isTrustedRootMessageSource } from '@/utils/windowMessages' type GarageFilter = 'all' | GarageVehicleStatus @@ -208,6 +209,7 @@ async function cancelValet(): Promise { } function handleValetStatus(event: MessageEvent): void { + if (!isTrustedRootMessageSource(event.source, window)) return if (event.data?.type !== 'garage:valet-status') return garage.setValetState((event.data.data as GarageValetState | null) ?? null) } diff --git a/frontend/src/views/apps/MailApp.vue b/frontend/src/views/apps/MailApp.vue index 1bf48a1..b34618b 100644 --- a/frontend/src/views/apps/MailApp.vue +++ b/frontend/src/views/apps/MailApp.vue @@ -51,6 +51,7 @@ import { type MailSwipeAction, type MailSwipeAxis, } from '@/utils/mailSwipe' +import { isTrustedRootMessageSource } from '@/utils/windowMessages' type AuthMode = 'login' | 'register' type MailScreen = 'folders' | 'list' | 'message' | 'compose' @@ -594,6 +595,7 @@ function goBack(): void { } function onMailEvent(event: MessageEvent): void { + if (!isTrustedRootMessageSource(event.source, window)) return if (event.data.type === 'mail:changed' && event.data.data?.counts) { mail.setCounts(event.data.data.counts) if (screen.value === 'list') { diff --git a/frontend/src/views/apps/MessagesApp.vue b/frontend/src/views/apps/MessagesApp.vue index eb7dc82..2b87689 100644 --- a/frontend/src/views/apps/MessagesApp.vue +++ b/frontend/src/views/apps/MessagesApp.vue @@ -139,8 +139,11 @@ const contactAvatarUrls = computed( ) const contactSuggestions = computed(() => { const query = composerNumber.value.trim().toLocaleLowerCase(phone.lang) - if (!query) return calls.contacts.slice(0, 8) - return calls.contacts + const contacts = calls.contacts.filter( + (contact) => contact.canMessage !== false, + ) + if (!query) return contacts.slice(0, 8) + return contacts .filter((contact) => `${contact.name} ${contact.phone_number}` .toLocaleLowerCase(phone.lang) @@ -156,6 +159,9 @@ const activeContact = computed(() => (contact) => contact.phone_number === messages.activeNumber, ), ) +const activeCanMessage = computed( + () => activeContact.value?.canMessage !== false, +) const attachmentPanelOpen = computed( () => emojiOpen.value || attachmentPicker.value !== null, ) @@ -392,6 +398,13 @@ function beginCompose(): void { } async function chooseRecipient(number: string): Promise { + const contact = calls.contacts.find( + (candidate) => candidate.phone_number === number, + ) + if (contact?.canMessage === false) { + showToast(phone.t('Apps.messages.messagingUnavailable')) + return + } composerNumber.value = number if (!(await messages.openThread(number))) { showToast(errorText('invalid_number')) @@ -433,6 +446,10 @@ function openContactDetails(): void { } async function saveContactDetails(): Promise { + if (activeContact.value?.readonly) { + showToast(phone.t('Apps.phone.errors.readonly_contact')) + return + } if (!contactNameDraft.value.trim() || !contactNumberDraft.value.trim()) return const response = await calls.saveContact({ id: activeContact.value?.id, @@ -452,6 +469,10 @@ async function saveContactDetails(): Promise { async function deleteActiveContact(): Promise { if (!activeContact.value) return + if (activeContact.value.readonly) { + showToast(phone.t('Apps.phone.errors.readonly_contact')) + return + } if (!(await calls.deleteContact(activeContact.value.id))) { showToast(phone.t('Apps.messages.contactDeleteFailed')) return @@ -461,7 +482,7 @@ async function deleteActiveContact(): Promise { } async function callActiveContact(): Promise { - if (!messages.activeNumber) return + if (!messages.activeNumber || activeContact.value?.canCall === false) return const response = await calls.dial(messages.activeNumber) if (!response.success) showToast(phone.t('Apps.messages.callFailed')) } @@ -509,7 +530,7 @@ async function sendAttachment( mediaAssetId: string, mediaDurationMs?: number, ): Promise { - if (!messages.activeNumber || sending.value) return + if (!messages.activeNumber || !activeCanMessage.value || sending.value) return attachmentMenuOpen.value = false attachmentPicker.value = null sending.value = true @@ -606,9 +627,12 @@ function queueGifSearch(): void { async function sendTextMessage(): Promise { if ( !messages.activeNumber || + !activeCanMessage.value || (!draft.value.trim() && !shareDraft.value) || sending.value - ) return + ) { + return + } const body = draft.value const shared = shareDraft.value draft.value = '' @@ -656,6 +680,7 @@ function sampleMicrophone(): void { } async function startVoiceRecording(): Promise { + if (!activeCanMessage.value) return emojiOpen.value = false if ( !navigator.mediaDevices?.getUserMedia || @@ -774,7 +799,7 @@ async function finishVoiceRecording(): Promise { const waveform = compressedWaveform() const shouldDiscard = discardRecording cleanupRecorder() - if (shouldDiscard) return + if (shouldDiscard || !activeCanMessage.value) return const blob = new Blob(chunks, { type: mime }) if (!blob.size || blob.size > VOICE_MAX_BYTES) { showToast(phone.t('Apps.messages.recordingTooLarge')) @@ -1147,6 +1172,7 @@ onBeforeUnmount(() => { {{ phone.t('Apps.messages.contactDetails') }} { {{ activeTitle }} - {{ messages.activeNumber }} + + {{ messages.activeNumber }} + + · {{ phone.t('Apps.phone.officialContact') }} + + - + {{ phone.t('Apps.messages.call') }} - + {{ phone.t('Apps.messages.messageAction') }} @@ -1193,7 +1232,7 @@ onBeforeUnmount(() => { {{ phone.t('Apps.messages.contactName') }} @@ -1201,7 +1240,7 @@ onBeforeUnmount(() => { {{ phone.t('Apps.messages.phoneNumber') }} @@ -1216,7 +1255,7 @@ onBeforeUnmount(() => { {{ phone.t('Apps.messages.addContact') }} { - + {{ phone.t('Apps.messages.attachPhoto') }} @@ -1302,7 +1344,10 @@ onBeforeUnmount(() => { - + {{ @@ -1388,12 +1433,15 @@ onBeforeUnmount(() => { - + { - + { { + + {{ phone.t('Apps.messages.messagingUnavailable') }} + diff --git a/frontend/src/views/apps/PhoneApp.vue b/frontend/src/views/apps/PhoneApp.vue index 2f79898..e0461be 100644 --- a/frontend/src/views/apps/PhoneApp.vue +++ b/frontend/src/views/apps/PhoneApp.vue @@ -278,28 +278,22 @@ function openContact(contact?: PhoneContact, number = ''): void { contactNotes.value = contact?.notes ?? '' contactNumber.value = contact?.phone_number ?? number contactAvatarMediaId.value = contact?.avatar_media_id ?? null - contactAvatarUrl.value = contact?.avatar_url ?? '' + contactAvatarUrl.value = contact?.avatar_url ?? contact?.icon ?? '' error.value = '' editorOpened.value = true } function openContactPhotoPicker(source: 'camera' | 'photos'): void { - mediaPicker.begin( - 'phone:contact-photo', - 'photo', - '/apps/phone', - 1, - { - avatarMediaId: contactAvatarMediaId.value, - avatarUrl: contactAvatarUrl.value, - contactId: editingContact.value?.id, - firstName: contactFirstName.value, - lastName: contactLastName.value, - notes: contactNotes.value, - organization: contactOrganization.value, - phoneNumber: contactNumber.value, - } satisfies ContactPhotoContext, - ) + mediaPicker.begin('phone:contact-photo', 'photo', '/apps/phone', 1, { + avatarMediaId: contactAvatarMediaId.value, + avatarUrl: contactAvatarUrl.value, + contactId: editingContact.value?.id, + firstName: contactFirstName.value, + lastName: contactLastName.value, + notes: contactNotes.value, + organization: contactOrganization.value, + phoneNumber: contactNumber.value, + } satisfies ContactPhotoContext) editorOpened.value = false void router.push({ path: `/apps/${source}`, @@ -409,6 +403,10 @@ async function toggleSelectedFavorite(): Promise { } async function saveContact(): Promise { + if (editingContact.value?.readonly) { + error.value = phone.t('Apps.phone.errors.readonly_contact') + return + } const number = normalizePhoneNumber(contactNumber.value) const name = [contactFirstName.value, contactLastName.value] .map((part) => part.trim()) @@ -448,10 +446,13 @@ async function openMessage(number: string): Promise { function triggerContactAction(action: ContactProfileAction): void { if (action === 'call') { + if (selectedContact.value?.canCall === false) return void startCall(selectedNumber.value) return } - if (action === 'message') void openMessage(selectedNumber.value) + if (action === 'message' && selectedContact.value?.canMessage !== false) { + void openMessage(selectedNumber.value) + } } async function startCall(number: string): Promise { @@ -562,6 +563,10 @@ function addInCallDigit(digit: string): void { async function deleteEditedContact(): Promise { if (!editingContact.value) return + if (editingContact.value.readonly) { + error.value = phone.t('Apps.phone.errors.readonly_contact') + return + } if (await calls.deleteContact(editingContact.value.id)) { editorOpened.value = false } @@ -895,7 +900,12 @@ onBeforeUnmount(() => { rounded class="phone-profile-action phone-profile-action--compact" :disabled=" - viewingOwnCard || ['video', 'mail'].includes(action.id) + viewingOwnCard || + ['video', 'mail'].includes(action.id) || + (action.id === 'call' && + selectedContact?.canCall === false) || + (action.id === 'message' && + selectedContact?.canMessage === false) " :aria-label="phone.t(`Apps.phone.${action.id}`)" @click="triggerContactAction(action.id)" @@ -929,8 +939,8 @@ onBeforeUnmount(() => { {{ @@ -956,7 +966,12 @@ onBeforeUnmount(() => { rounded class="phone-profile-action" :disabled=" - viewingOwnCard || ['video', 'mail'].includes(action.id) + viewingOwnCard || + ['video', 'mail'].includes(action.id) || + (action.id === 'call' && + selectedContact?.canCall === false) || + (action.id === 'message' && + selectedContact?.canMessage === false) " :aria-label="phone.t(`Apps.phone.${action.id}`)" @click="triggerContactAction(action.id)" @@ -992,14 +1007,22 @@ onBeforeUnmount(() => { - + {{ phone.t('Apps.phone.mobile') }} {{ formatPhoneNumber(selectedNumber) }} - + {{ phone.t('Apps.phone.messagesProfile') }} {{ selectedDisplayName }} @@ -1015,7 +1038,11 @@ onBeforeUnmount(() => { - + {{ phone.t('Apps.phone.sendMessage') }} { {{ @@ -1234,10 +1264,21 @@ onBeforeUnmount(() => { @click="openRecentDetail(contact.phone_number)" > - - {{ contactInitials(contact.phone_number) }} + + {{ + contactInitials(contact.phone_number) + }} + + + {{ contact.name }} + {{ + phone.t('Apps.phone.officialContact') + }} - {{ contact.name }} { @click="openRecentDetail(contact.phone_number)" > - - {{ contactInitials(contact.phone_number) }} + + {{ + contactInitials(contact.phone_number) + }} + + + {{ contact.name }} + {{ + phone.t('Apps.phone.officialContact') + }} - {{ contact.name }} @@ -1310,8 +1362,8 @@ onBeforeUnmount(() => { > {{ @@ -1416,7 +1468,11 @@ onBeforeUnmount(() => { aria-modal="true" :aria-label=" phone.t( - editingContact ? 'Apps.phone.editContact' : 'Apps.phone.addContact', + editingContact?.readonly + ? 'Apps.phone.officialContact' + : editingContact + ? 'Apps.phone.editContact' + : 'Apps.phone.addContact', ) " > @@ -1432,13 +1488,16 @@ onBeforeUnmount(() => { {{ phone.t( - editingContact - ? 'Apps.phone.editContact' - : 'Apps.phone.newContact', + editingContact?.readonly + ? 'Apps.phone.officialContact' + : editingContact + ? 'Apps.phone.editContact' + : 'Apps.phone.newContact', ) }} { class="phone-contact-editor__avatar" type="button" :aria-label="phone.t('Apps.phone.choosePhoto')" + :disabled="editingContact?.readonly" @click="openContactPhotoPicker('photos')" > @@ -1469,7 +1529,7 @@ onBeforeUnmount(() => { { {{ phone.t('Apps.phone.removePhoto') }} - + { :value="contactFirstName" :placeholder="phone.t('Apps.phone.firstName')" autocomplete="given-name" + :readonly="editingContact?.readonly" @input="contactFirstName = eventValue($event)" /> @@ -1529,6 +1595,7 @@ onBeforeUnmount(() => { :placeholder="phone.t('Apps.phone.phoneNumber')" inputmode="tel" autocomplete="tel" + :readonly="editingContact?.readonly" @input="contactNumber = eventValue($event)" > @@ -1546,6 +1613,7 @@ onBeforeUnmount(() => { :placeholder="phone.t('Apps.phone.notes')" :maxlength="500" autocapitalize="sentences" + :readonly="editingContact?.readonly" @input="contactNotes = eventValue($event)" /> @@ -1553,7 +1621,7 @@ onBeforeUnmount(() => { {{ error }} @@ -2205,7 +2273,10 @@ onBeforeUnmount(() => { display: flex; min-width: 0; min-height: 68px; - align-items: center; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 2px; overflow: hidden; font-size: 16px; font-weight: 600; @@ -2213,6 +2284,12 @@ onBeforeUnmount(() => { white-space: nowrap; } +.phone-contact-name small { + color: #8e8e93; + font-size: 10px; + font-weight: 500; +} + .phone-contact-index { position: fixed; z-index: 40; @@ -3387,7 +3464,8 @@ onBeforeUnmount(() => { } .phone-contact-editor__avatar-wrap--has-photo:hover - .phone-contact-editor__avatar img { + .phone-contact-editor__avatar + img { filter: brightness(0.58); transform: scale(1.015); } @@ -3752,7 +3830,6 @@ onBeforeUnmount(() => { background: rgba(118, 118, 128, 0.14); transform: translateY(-1px); } - } @media (hover: none) { diff --git a/frontend/src/views/apps/RadioApp.vue b/frontend/src/views/apps/RadioApp.vue index 00a600d..ea762d9 100644 --- a/frontend/src/views/apps/RadioApp.vue +++ b/frontend/src/views/apps/RadioApp.vue @@ -28,6 +28,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { usePhoneStore } from '@/stores/phone' import { useRadioStore } from '@/stores/radio' import type { RadioHistoryEntry } from '@/types/radio' +import { isTrustedRootMessageSource } from '@/utils/windowMessages' type RadioTab = 'radio' | 'settings' @@ -152,6 +153,7 @@ async function saveRadioProfile(): Promise { } function onMessage(event: MessageEvent): void { + if (!isTrustedRootMessageSource(event.source, window)) return if (event.data?.type === 'radio:updated' && event.data.data?.members) { memberSnapshotAt.value = Date.now() radio.updateMembers(event.data.data.members) diff --git a/frontend/src/views/apps/SettingsApp.vue b/frontend/src/views/apps/SettingsApp.vue index 9c7ce1c..f7b1a44 100644 --- a/frontend/src/views/apps/SettingsApp.vue +++ b/frontend/src/views/apps/SettingsApp.vue @@ -49,7 +49,11 @@ import { } from 'vue' import { PHONE_FRAME_COLORS } from '@/config/appearance' -import { isLaunchablePhoneApp, PHONE_APPS } from '@/config/apps' +import { + getPhoneAppLabel, + isLaunchablePhoneApp, + PHONE_APPS, +} from '@/config/apps' import { usePhoneStore } from '@/stores/phone' import PhonePasscode from '@/components/PhonePasscode.vue' import { useAccountStore } from '@/stores/account' @@ -281,7 +285,7 @@ const activeTitle = computed(() => { } if (activeView.value === 'notification-detail') { return selectedNotificationApp.value - ? phone.t(selectedNotificationApp.value.labelKey) + ? getPhoneAppLabel(selectedNotificationApp.value, phone.t) : phone.t('Apps.settings.notifications') } return phone.t(`Apps.settings.${activeView.value}`) @@ -1030,7 +1034,7 @@ onBeforeUnmount(() => { v-for="app in notificationApps" :key="app.id" link - :title="phone.t(app.labelKey)" + :title="getPhoneAppLabel(app, phone.t)" :after=" phone.t( phone.preferences.settings.notifications[app.id].enabled @@ -1058,7 +1062,9 @@ onBeforeUnmount(() => { " > - + { " :aria-label=" phone.t('Apps.settings.toggle.notifications', { - app: phone.t(selectedNotificationApp.labelKey), + app: getPhoneAppLabel(selectedNotificationApp, phone.t), }) " @change=" @@ -1110,7 +1116,7 @@ onBeforeUnmount(() => { " :aria-label=" phone.t('Apps.settings.toggle.notificationSounds', { - app: phone.t(selectedNotificationApp.labelKey), + app: getPhoneAppLabel(selectedNotificationApp, phone.t), }) " @change=" diff --git a/frontend/src/views/apps/SkyRideApp.vue b/frontend/src/views/apps/SkyRideApp.vue index 2882862..d7968d7 100644 --- a/frontend/src/views/apps/SkyRideApp.vue +++ b/frontend/src/views/apps/SkyRideApp.vue @@ -78,6 +78,7 @@ import type { SkyRideRide, SkyRideRideStatus, } from '@/types/skyride' +import { isTrustedRootMessageSource } from '@/utils/windowMessages' type SkyRideTab = 'home' | 'rides' | 'activity' | 'messages' | 'profile' type LocationTarget = 'pickup' | 'destination' @@ -677,6 +678,7 @@ function selectTab(tab: SkyRideTab): void { } function handleSkyRideMessage(event: MessageEvent): void { + if (!isTrustedRootMessageSource(event.source, window)) return if (typeof event.data !== 'object' || event.data === null) return const message = event.data as Partial if (message.type !== 'skyride:changed' || !message.data) return diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index 6dc953e..e0108aa 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -1106,6 +1106,42 @@ const contacts = [ phone_number: '5558675309', updated_at: '2026-08-04 12:00:00', }, + { + canCall: true, + canMessage: false, + companyId: 'police', + icon: 'https://picsum.photos/seed/companies-police-logo/180/180', + id: 'company:police', + name: 'Los Santos Police', + phone_number: '911', + readonly: true, + source: 'company', + verified: true, + }, + { + canCall: true, + canMessage: false, + companyId: 'ambulance', + icon: 'https://picsum.photos/seed/companies-ems-logo/180/180', + id: 'company:ambulance', + name: 'Los Santos Medical', + phone_number: '912', + readonly: true, + source: 'company', + verified: true, + }, + { + canCall: true, + canMessage: true, + companyId: 'bennys', + icon: 'https://picsum.photos/seed/companies-bennys-logo/180/180', + id: 'company:bennys', + name: "Benny's Motor Works", + phone_number: '5550102', + readonly: true, + source: 'company', + verified: true, + }, { created_at: isoTime(-14 * 86_400_000), id: 'contact-morgan', @@ -2608,6 +2644,488 @@ function flareBootstrap() { } } +const companyCategories = [ + { id: 'public_services', name: 'Public Services' }, + { id: 'medical', name: 'Medical' }, + { id: 'mechanics', name: 'Mechanics' }, + { id: 'transport', name: 'Transport' }, + { id: 'gastronomy', name: 'Food & Drink' }, +] +let companyCallAvailable = false +const companyProfiles = [ + { + acceptsRequests: false, + announcement: { + body: 'Community traffic unit active around Legion Square.', + expiresAt: isoTime(6 * 60 * 60 * 1000), + publishedAt: isoTime(-35 * 60 * 1000), + }, + availability: 'available', + availabilityUpdatedAt: isoTime(-12 * 60 * 1000), + canCall: true, + canMessage: false, + categoryId: 'public_services', + categoryName: 'Public Services', + coverUrl: 'https://picsum.photos/seed/companies-police-cover/900/360', + description: + 'Public safety, non-emergency assistance, and community response for Los Santos.', + hours: [], + id: 'police', + location: { + address: 'Sinner Street', + coords: { x: 441.2, y: -981.9, z: 30.7 }, + district: 'Mission Row', + label: 'Mission Row Police Station', + }, + logoUrl: 'https://picsum.photos/seed/companies-police-logo/180/180', + name: 'Los Santos Police', + phoneNumber: '911', + revision: 3, + services: [ + { + acceptsRequests: false, + active: true, + description: 'Immediate police response through the service line.', + id: 'emergency-response', + priceText: null, + title: 'Emergency Response', + }, + { + acceptsRequests: false, + active: true, + description: 'General information and non-emergency assistance.', + id: 'public-assistance', + priceText: null, + title: 'Public Assistance', + }, + ], + serviceSummary: 'Emergency response and public assistance', + verified: true, + }, + { + acceptsRequests: false, + announcement: null, + availability: 'busy', + availabilityUpdatedAt: isoTime(-22 * 60 * 1000), + canCall: true, + canMessage: false, + categoryId: 'medical', + categoryName: 'Medical', + coverUrl: 'https://picsum.photos/seed/companies-ems-cover/900/360', + description: + 'Emergency medical response and patient care across Los Santos County.', + hours: [], + id: 'ambulance', + location: { + address: 'Elgin Avenue', + coords: { x: 298.4, y: -584.6, z: 43.3 }, + district: 'Pillbox Hill', + label: 'Pillbox Medical Center', + }, + logoUrl: 'https://picsum.photos/seed/companies-ems-logo/180/180', + name: 'Los Santos Medical', + phoneNumber: '912', + revision: 1, + services: [ + { + acceptsRequests: false, + active: true, + description: 'Urgent medical assistance through the service line.', + id: 'medical-response', + priceText: null, + title: 'Medical Response', + }, + ], + serviceSummary: 'Emergency medical care', + verified: true, + }, + { + acceptsRequests: true, + announcement: { + body: 'Same-day repairs available until 10 PM.', + expiresAt: isoTime(10 * 60 * 60 * 1000), + publishedAt: isoTime(-48 * 60 * 1000), + }, + availability: 'available', + availabilityUpdatedAt: isoTime(-6 * 60 * 1000), + canCall: true, + canMessage: true, + categoryId: 'mechanics', + categoryName: 'Mechanics', + coverUrl: 'https://picsum.photos/seed/companies-bennys-cover/900/360', + description: + 'Repairs, roadside assistance, performance upgrades, and custom bodywork.', + hours: [ + { closesAt: '22:00', day: 0, isClosed: false, opensAt: '10:00' }, + { closesAt: '22:00', day: 1, isClosed: false, opensAt: '10:00' }, + { closesAt: '22:00', day: 2, isClosed: false, opensAt: '10:00' }, + { closesAt: '22:00', day: 3, isClosed: false, opensAt: '10:00' }, + { closesAt: '23:30', day: 4, isClosed: false, opensAt: '10:00' }, + { closesAt: '23:30', day: 5, isClosed: false, opensAt: '12:00' }, + { closesAt: null, day: 6, isClosed: true, opensAt: null }, + ], + id: 'bennys', + location: { + address: 'Alta Street', + coords: { x: -211.6, y: -1324.2, z: 30.9 }, + district: 'Strawberry', + label: "Benny's Original Motor Works", + }, + logoUrl: 'https://picsum.photos/seed/companies-bennys-logo/180/180', + name: "Benny's Motor Works", + phoneNumber: '5550102', + revision: 7, + services: [ + { + acceptsRequests: true, + active: true, + description: 'Diagnostics and general mechanical repairs.', + id: 'repair', + priceText: 'from $250', + title: 'Vehicle Repair', + }, + { + acceptsRequests: true, + active: true, + description: 'Mobile help for disabled vehicles.', + id: 'roadside', + priceText: 'from $175', + title: 'Roadside Assistance', + }, + { + acceptsRequests: true, + active: true, + description: 'Paint, wheels, and body modifications.', + id: 'customization', + priceText: 'Quote', + title: 'Customization', + }, + ], + serviceSummary: 'Repairs, roadside help, and customization', + verified: true, + }, + { + acceptsRequests: true, + announcement: null, + availability: 'available', + availabilityUpdatedAt: isoTime(-19 * 60 * 1000), + canCall: true, + canMessage: true, + categoryId: 'transport', + categoryName: 'Transport', + coverUrl: 'https://picsum.photos/seed/companies-taxi-cover/900/360', + description: 'Citywide passenger transport and pre-arranged group rides.', + hours: [], + id: 'downtown-cab', + location: { + address: 'Tangerine Street', + coords: { x: 900.3, y: -170.2, z: 74.1 }, + district: 'East Vinewood', + label: 'Downtown Cab Co.', + }, + logoUrl: 'https://picsum.photos/seed/companies-taxi-logo/180/180', + name: 'Downtown Cab Co.', + phoneNumber: '5550103', + revision: 2, + services: [ + { + acceptsRequests: true, + active: true, + description: 'A driver will collect you at your location.', + id: 'pickup', + priceText: 'Metered', + title: 'Passenger Pickup', + }, + ], + serviceSummary: 'Passenger pickups throughout the city', + verified: true, + }, + { + acceptsRequests: true, + announcement: null, + availability: 'closed', + availabilityUpdatedAt: isoTime(-3 * 60 * 60 * 1000), + canCall: true, + canMessage: true, + categoryId: 'gastronomy', + categoryName: 'Food & Drink', + coverUrl: 'https://picsum.photos/seed/companies-burgershot-cover/900/360', + description: 'Burgers, fries, shakes, and late-night catering.', + hours: [], + id: 'burgershot', + location: { + address: 'San Andreas Avenue', + coords: { x: -1193.8, y: -892.5, z: 14 }, + district: 'Vespucci', + label: 'Burger Shot', + }, + logoUrl: 'https://picsum.photos/seed/companies-burgershot-logo/180/180', + name: 'Burger Shot', + phoneNumber: '5550104', + revision: 1, + services: [ + { + acceptsRequests: true, + active: true, + description: 'Food order for collection at the restaurant.', + id: 'catering', + priceText: 'Quote', + title: 'Event Catering', + }, + ], + serviceSummary: 'Food, drinks, and event catering', + verified: true, + }, +] + +let companyRequestSequence = 4 +let companyRequests = [ + { + actions: { + allowedStatuses: ['in_progress', 'waiting_customer', 'completed'], + canAssign: true, + canCall: true, + canCancel: true, + canClaim: false, + canReply: true, + }, + assignedLabel: 'you', + companyId: 'bennys', + companyLogoUrl: companyProfiles[2].logoUrl, + companyName: companyProfiles[2].name, + createdAt: isoTime(-2 * 60 * 60 * 1000), + description: + 'My Sultan stopped near Legion Square and the engine will not start.', + events: [ + { + createdAt: isoTime(-2 * 60 * 60 * 1000), + id: 'company-event-1', + status: 'new', + type: 'created', + }, + { + createdAt: isoTime(-95 * 60 * 1000), + id: 'company-event-2', + status: 'assigned', + type: 'assigned', + }, + { + createdAt: isoTime(-80 * 60 * 1000), + id: 'company-event-3', + status: 'in_progress', + type: 'status_changed', + }, + ], + id: 'company-request-1', + media: [ + { + id: 3, + url: 'https://picsum.photos/seed/sky-phone-3/800/600', + }, + { + id: 4, + url: 'https://picsum.photos/seed/sky-phone-4/800/600', + }, + ], + messages: [ + { + author: 'customer', + authorLabel: 'you', + body: 'I am parked on the north side of the square.', + createdAt: isoTime(-110 * 60 * 1000), + id: 'company-message-1', + isMine: true, + }, + { + author: 'company', + authorLabel: 'company', + body: 'A mechanic is heading your way. Please stay near the vehicle.', + createdAt: isoTime(-78 * 60 * 1000), + id: 'company-message-2', + isMine: false, + }, + ], + phoneNumber: companyProfiles[2].phoneNumber, + revision: 3, + serviceId: 'roadside', + serviceName: 'Roadside Assistance', + status: 'in_progress', + subject: 'Vehicle will not start', + unreadCount: 1, + updatedAt: isoTime(-78 * 60 * 1000), + }, + { + actions: { + allowedStatuses: ['assigned', 'cancelled'], + canAssign: true, + canCall: true, + canCancel: true, + canClaim: true, + canReply: true, + }, + assignedLabel: null, + companyId: 'bennys', + companyLogoUrl: companyProfiles[2].logoUrl, + companyName: companyProfiles[2].name, + createdAt: isoTime(-18 * 60 * 1000), + description: + 'I would like a quote for a metallic blue repaint and new wheels.', + events: [ + { + createdAt: isoTime(-18 * 60 * 1000), + id: 'company-event-4', + status: 'new', + type: 'created', + }, + ], + id: 'company-request-2', + media: [], + messages: [], + phoneNumber: companyProfiles[2].phoneNumber, + revision: 1, + serviceId: 'customization', + serviceName: 'Customization', + status: 'new', + subject: 'Repaint and wheels', + unreadCount: 0, + updatedAt: isoTime(-18 * 60 * 1000), + }, + { + actions: { + allowedStatuses: [], + canAssign: false, + canCall: true, + canCancel: false, + canClaim: false, + canReply: false, + }, + assignedLabel: 'assigned', + companyId: 'bennys', + companyLogoUrl: companyProfiles[2].logoUrl, + companyName: companyProfiles[2].name, + createdAt: isoTime(-2 * 24 * 60 * 60 * 1000), + description: 'Routine engine service and fluids.', + events: [ + { + createdAt: isoTime(-2 * 24 * 60 * 60 * 1000), + id: 'company-event-5', + status: 'new', + type: 'created', + }, + { + createdAt: isoTime(-26 * 60 * 60 * 1000), + id: 'company-event-6', + status: 'completed', + type: 'completed', + }, + ], + id: 'company-request-3', + media: [], + messages: [], + phoneNumber: companyProfiles[2].phoneNumber, + revision: 4, + serviceId: 'repair', + serviceName: 'Vehicle Repair', + status: 'completed', + subject: 'Routine service', + unreadCount: 0, + updatedAt: isoTime(-26 * 60 * 60 * 1000), + }, +] + +const companyMembers = [ + { id: 'member-mia', name: 'Mia Torres', online: true, role: 'Mechanic' }, + { id: 'member-jay', name: 'Jay Coleman', online: true, role: 'Tow Operator' }, + { id: 'member-robin', name: '', online: false, role: 'Mechanic' }, +] + +function companySummary(company) { + const { coverUrl, hours, revision, services, ...summary } = company + return { + ...summary, + serviceSummary: + company.serviceSummary ?? + services.map((service) => service.title).join(', '), + } +} + +function companyRequestSummary(request) { + const { + actions, + description, + events, + media, + messages, + phoneNumber, + revision, + ...summary + } = request + return summary +} + +function companyWorkContext(testScenario = '') { + if (testScenario === 'companies-unauthorized') { + return { + authorized: false, + callAvailable: false, + company: null, + metrics: { assigned: 0, completedToday: 0, new: 0, waiting: 0 }, + ownRequests: [], + permissions: { + canAssign: false, + canManageAnnouncement: false, + canManageHours: false, + canManageProfile: false, + canManageServices: false, + canSetAvailability: false, + canTakeCalls: false, + }, + recentRequests: [], + role: null, + unreadCount: 0, + } + } + const manager = testScenario === 'companies-manager' + const open = companyRequests.filter( + (request) => + request.companyId === 'bennys' && + !['completed', 'cancelled'].includes(request.status), + ) + return { + authorized: true, + callAvailable: companyCallAvailable, + company: companyProfiles.find((company) => company.id === 'bennys'), + metrics: { + assigned: open.filter((request) => request.assignedLabel).length, + completedToday: companyRequests.filter( + (request) => + request.companyId === 'bennys' && request.status === 'completed', + ).length, + new: open.filter((request) => request.status === 'new').length, + waiting: open.filter((request) => request.status === 'waiting_customer') + .length, + }, + ownRequests: open + .filter((request) => request.assignedLabel) + .map(companyRequestSummary), + permissions: { + canAssign: manager, + canManageAnnouncement: manager, + canManageHours: manager, + canManageProfile: manager, + canManageServices: manager, + canSetAvailability: true, + canTakeCalls: true, + }, + recentRequests: open.slice(0, 4).map(companyRequestSummary), + role: manager ? 'manager' : 'employee', + unreadCount: open.reduce( + (total, request) => total + request.unreadCount, + 0, + ), + } +} + app.post('/api/:endpoint', async (request, response, next) => { console.log(`[NUI] ${request.params.endpoint}`, request.body) const endpoint = request.params.endpoint @@ -3314,6 +3832,412 @@ app.post('/api/:endpoint', (request, response) => { console.log(`[NUI] ${request.params.endpoint}`, request.body) const endpoint = request.params.endpoint const testScenario = String(request.body._testScenario ?? '') + if (endpoint.startsWith('companies:') && testScenario === 'companies-error') { + response.json({ success: false, error: 'service_unavailable' }) + return + } + if (endpoint === 'companies:list') { + const reply = () => { + const search = String(request.body.search ?? '') + .trim() + .toLowerCase() + const categoryId = String(request.body.categoryId ?? '') + const availability = String(request.body.availability ?? '') + const offset = Math.max(0, Number(request.body.cursor ?? 0)) + const pageSize = 3 + let items = companyProfiles.filter((company) => { + if (categoryId && company.categoryId !== categoryId) return false + if (availability && company.availability !== availability) return false + if (request.body.hasLocation && !company.location) return false + if (request.body.acceptsRequests && !company.acceptsRequests) + return false + if (!search) return true + return [ + company.name, + company.categoryName, + company.description, + company.location?.district, + ...company.services.flatMap((service) => [ + service.title, + service.description, + ]), + ] + .filter(Boolean) + .some((value) => String(value).toLowerCase().includes(search)) + }) + if (request.body.sort === 'name') { + items = [...items].sort((left, right) => + left.name.localeCompare(right.name), + ) + } else if (request.body.sort === 'updated') { + items = [...items].sort( + (left, right) => + new Date(right.availabilityUpdatedAt).getTime() - + new Date(left.availabilityUpdatedAt).getTime(), + ) + } + if ( + testScenario === 'companies-empty-search' || + search === 'no results' + ) { + items = [] + } + const page = items.slice(offset, offset + pageSize) + response.json({ + success: true, + data: { + categories: companyCategories, + companies: page.map(companySummary), + nextCursor: + offset + page.length < items.length + ? String(offset + page.length) + : null, + }, + }) + } + if (testScenario === 'companies-loading') setTimeout(reply, 1200) + else reply() + return + } + if (endpoint === 'companies:get') { + const company = companyProfiles.find( + (item) => item.id === String(request.body.companyId), + ) + response.json( + company + ? { success: true, data: { company } } + : { success: false, error: 'company_not_found' }, + ) + return + } + if (endpoint === 'companies:my-requests') { + const list = request.body.list === 'closed' ? 'closed' : 'open' + const offset = Math.max(0, Number(request.body.cursor ?? 0)) + const matches = companyRequests.filter((item) => + list === 'closed' + ? ['completed', 'cancelled'].includes(item.status) + : !['completed', 'cancelled'].includes(item.status), + ) + const page = matches.slice(offset, offset + 2) + response.json({ + success: true, + data: { + nextCursor: + offset + page.length < matches.length + ? String(offset + page.length) + : null, + requests: page.map(companyRequestSummary), + unreadCount: companyRequests.reduce( + (total, item) => total + item.unreadCount, + 0, + ), + }, + }) + return + } + if (endpoint === 'companies:get-request') { + const item = companyRequests.find( + (candidate) => candidate.id === String(request.body.requestId), + ) + if (item) item.unreadCount = 0 + response.json( + item + ? { success: true, data: { request: item } } + : { success: false, error: 'request_not_found' }, + ) + return + } + if (endpoint === 'companies:work-context') { + response.json({ + success: true, + data: { context: companyWorkContext(testScenario) }, + }) + return + } + if (endpoint === 'companies:work-queue') { + const filter = String(request.body.filter ?? 'new') + const offset = Math.max(0, Number(request.body.cursor ?? 0)) + const matches = companyRequests.filter((item) => { + if (item.companyId !== 'bennys') return false + if (filter === 'assigned') return item.assignedLabel === 'you' + return item.status === filter + }) + const page = matches.slice(offset, offset + 2) + response.json({ + success: true, + data: { + nextCursor: + offset + page.length < matches.length + ? String(offset + page.length) + : null, + requests: page.map(companyRequestSummary), + }, + }) + return + } + if (endpoint === 'companies:list-members') { + response.json({ success: true, data: { members: companyMembers } }) + return + } + if (endpoint === 'companies:create-request') { + const company = companyProfiles.find( + (item) => item.id === String(request.body.companyId), + ) + const service = company?.services.find( + (item) => item.id === String(request.body.serviceId), + ) + if (!company?.acceptsRequests || !service?.acceptsRequests) { + response.json({ success: false, error: 'invalid_service' }) + return + } + const now = new Date().toISOString() + const item = { + actions: { + allowedStatuses: ['assigned', 'cancelled'], + canAssign: true, + canCall: true, + canCancel: true, + canClaim: true, + canReply: true, + }, + assignedLabel: null, + companyId: company.id, + companyLogoUrl: company.logoUrl, + companyName: company.name, + createdAt: now, + description: String(request.body.description ?? ''), + events: [ + { + createdAt: now, + id: `company-event-${Date.now()}`, + status: 'new', + type: 'created', + }, + ], + id: `company-request-${companyRequestSequence++}`, + media: (Array.isArray(request.body.mediaIds) + ? request.body.mediaIds + : [] + ).flatMap((mediaId) => { + const media = mockMedia.find( + (candidate) => + candidate.id === Number(mediaId) && candidate.mediaType === 'photo', + ) + return media ? [{ id: media.id, url: media.url }] : [] + }), + messages: [], + phoneNumber: company.phoneNumber, + revision: 1, + serviceId: service.id, + serviceName: service.title, + status: 'new', + subject: String(request.body.subject ?? ''), + unreadCount: 0, + updatedAt: now, + } + companyRequests.unshift(item) + response.json({ success: true, data: { request: item } }) + return + } + if ( + [ + 'companies:cancel-request', + 'companies:send-message', + 'companies:claim-request', + 'companies:assign-request', + 'companies:update-request-status', + ].includes(endpoint) + ) { + const item = companyRequests.find( + (candidate) => candidate.id === String(request.body.requestId), + ) + if (!item) { + response.json({ success: false, error: 'request_not_found' }) + return + } + if (Number(request.body.revision) !== item.revision) { + response.json({ success: false, error: 'revision_conflict' }) + return + } + const now = new Date().toISOString() + if (endpoint === 'companies:cancel-request') { + item.status = 'cancelled' + item.actions = { + allowedStatuses: [], + canAssign: false, + canCall: true, + canCancel: false, + canClaim: false, + canReply: false, + } + item.events.push({ + createdAt: now, + id: `company-event-${Date.now()}`, + status: 'cancelled', + type: 'cancelled', + }) + } + if (endpoint === 'companies:send-message') { + item.messages.push({ + author: 'customer', + authorLabel: 'you', + body: String(request.body.body ?? ''), + createdAt: now, + id: `company-message-${Date.now()}`, + isMine: true, + }) + } + if (endpoint === 'companies:claim-request') { + item.assignedLabel = 'you' + item.status = 'assigned' + item.actions.canClaim = false + item.events.push({ + createdAt: now, + id: `company-event-${Date.now()}`, + status: 'assigned', + type: 'assigned', + }) + } + if (endpoint === 'companies:assign-request') { + const member = companyMembers.find( + (candidate) => candidate.id === String(request.body.memberId), + ) + if (!member?.online) { + response.json({ success: false, error: 'not_authorized' }) + return + } + item.assignedLabel = 'assigned' + item.status = 'assigned' + item.actions.canClaim = false + item.events.push({ + createdAt: now, + id: `company-event-${Date.now()}`, + status: 'assigned', + type: 'assigned', + }) + } + if (endpoint === 'companies:update-request-status') { + item.status = String(request.body.status) + item.events.push({ + createdAt: now, + id: `company-event-${Date.now()}`, + status: item.status, + type: + item.status === 'completed' + ? 'completed' + : item.status === 'cancelled' + ? 'cancelled' + : 'status_changed', + }) + } + item.revision += 1 + item.updatedAt = now + response.json({ + success: true, + data: { + context: companyWorkContext(testScenario), + request: item, + }, + }) + return + } + if ( + [ + 'companies:update-availability', + 'companies:update-profile', + 'companies:update-hours', + 'companies:update-services', + 'companies:publish-announcement', + ].includes(endpoint) + ) { + const company = companyProfiles.find((item) => item.id === 'bennys') + if ( + testScenario === 'companies-conflict' || + Number(request.body.revision) !== company.revision + ) { + response.json({ success: false, error: 'revision_conflict' }) + return + } + if (endpoint === 'companies:update-availability') { + company.availability = String(request.body.availability) + company.availabilityUpdatedAt = new Date().toISOString() + } + if (endpoint === 'companies:update-profile') { + company.acceptsRequests = request.body.acceptsRequests === true + company.description = String(request.body.description ?? '') + company.location = { + ...company.location, + address: String(request.body.address ?? ''), + coords: + request.body.coords && typeof request.body.coords === 'object' + ? { ...request.body.coords } + : company.location.coords, + district: String(request.body.district ?? ''), + label: String(request.body.locationLabel ?? ''), + } + const logo = mockMedia.find( + (item) => item.id === Number(request.body.logoMediaId), + ) + const cover = mockMedia.find( + (item) => item.id === Number(request.body.coverMediaId), + ) + if (logo) company.logoUrl = logo.url + if (cover) company.coverUrl = cover.url + } + if (endpoint === 'companies:update-hours') { + company.hours = Array.isArray(request.body.hours) + ? request.body.hours + : [] + } + if (endpoint === 'companies:update-services') { + company.services = Array.isArray(request.body.services) + ? request.body.services.map((service, index) => ({ + ...service, + id: service.id || `service-${Date.now()}-${index}`, + })) + : [] + company.serviceSummary = company.services + .filter((service) => service.active) + .map((service) => service.title) + .join(', ') + } + if (endpoint === 'companies:publish-announcement') { + const body = String(request.body.body ?? '').trim() + company.announcement = body + ? { + body, + expiresAt: request.body.expiresAt || null, + publishedAt: new Date().toISOString(), + } + : null + } + company.revision += 1 + response.json({ + success: true, + data: { + company, + context: companyWorkContext(testScenario), + }, + }) + return + } + if (endpoint === 'companies:set-call-availability') { + companyCallAvailable = request.body.available === true + response.json({ + success: true, + data: { context: companyWorkContext(testScenario) }, + }) + return + } + if (endpoint === 'companies:call-customer') { + const item = companyRequests.find( + (candidate) => candidate.id === String(request.body.requestId), + ) + response.json( + item ? { success: true } : { success: false, error: 'request_not_found' }, + ) + return + } if (endpoint === 'crewlink:bootstrap') { response.json({ success: true, data: crewLinkBootstrap(testScenario) }) return @@ -3389,7 +4313,9 @@ app.post('/api/:endpoint', (request, response) => { return } if (endpoint === 'crewlink:update-group') { - const group = crewLinkGroups.find((item) => item.id === request.body.groupId) + const group = crewLinkGroups.find( + (item) => item.id === request.body.groupId, + ) if (!group) { response.json({ success: false, error: 'group_not_found' }) return @@ -3404,7 +4330,9 @@ app.post('/api/:endpoint', (request, response) => { return } if (endpoint === 'crewlink:delete-group') { - crewLinkGroups = crewLinkGroups.filter((item) => item.id !== request.body.groupId) + crewLinkGroups = crewLinkGroups.filter( + (item) => item.id !== request.body.groupId, + ) delete crewLinkMembers[request.body.groupId] delete crewLinkPings[request.body.groupId] crewLinkProfile.activeGroupId = crewLinkGroups[0]?.id ?? null @@ -3412,7 +4340,9 @@ app.post('/api/:endpoint', (request, response) => { return } if (endpoint === 'crewlink:set-active') { - const group = crewLinkGroups.find((item) => item.id === request.body.groupId) + const group = crewLinkGroups.find( + (item) => item.id === request.body.groupId, + ) if (!group) { response.json({ success: false, error: 'group_not_found' }) return @@ -3457,7 +4387,9 @@ app.post('/api/:endpoint', (request, response) => { return } if (endpoint === 'crewlink:rotate-code') { - const group = crewLinkGroups.find((item) => item.id === request.body.groupId) + const group = crewLinkGroups.find( + (item) => item.id === request.body.groupId, + ) if (group) group.inviteCode = 'FRESH247' response.json({ success: true, data: { inviteCode: 'FRESH247' } }) return @@ -3503,7 +4435,9 @@ app.post('/api/:endpoint', (request, response) => { return } if (endpoint === 'crewlink:transfer-owner') { - const group = crewLinkGroups.find((item) => item.id === request.body.groupId) + const group = crewLinkGroups.find( + (item) => item.id === request.body.groupId, + ) const members = crewLinkMembers[request.body.groupId] ?? [] const current = members.find((item) => item.id === crewLinkProfile.id) const next = members.find((item) => item.id === request.body.profileId) @@ -3522,13 +4456,17 @@ app.post('/api/:endpoint', (request, response) => { crewLinkMembers[request.body.groupId] = members.filter( (item) => item.id !== request.body.profileId, ) - const group = crewLinkGroups.find((item) => item.id === request.body.groupId) + const group = crewLinkGroups.find( + (item) => item.id === request.body.groupId, + ) if (group) group.memberCount = crewLinkMembers[request.body.groupId].length response.json({ success: true }) return } if (endpoint === 'crewlink:leave') { - crewLinkGroups = crewLinkGroups.filter((item) => item.id !== request.body.groupId) + crewLinkGroups = crewLinkGroups.filter( + (item) => item.id !== request.body.groupId, + ) crewLinkProfile.activeGroupId = crewLinkGroups[0]?.id ?? null response.json({ success: true, data: crewLinkBootstrap() }) return @@ -5996,8 +6934,12 @@ app.post('/api/:endpoint', (request, response) => { } if (endpoint === 'contacts:save') { const name = String(request.body.name ?? '').trim() - const notes = String(request.body.notes ?? '').trim().slice(0, 500) - const organization = String(request.body.organization ?? '').trim().slice(0, 80) + const notes = String(request.body.notes ?? '') + .trim() + .slice(0, 500) + const organization = String(request.body.organization ?? '') + .trim() + .slice(0, 80) const phoneNumber = String(request.body.phoneNumber ?? '').trim() const avatarMediaId = Number(request.body.avatarMediaId) || 0 const avatarMedia = avatarMediaId @@ -6010,6 +6952,10 @@ app.post('/api/:endpoint', (request, response) => { return } let contact = contacts.find((item) => item.id === request.body.id) + if (contact?.readonly) { + response.json({ success: false, error: 'readonly_contact' }) + return + } if (contact) { contact.name = name contact.notes = notes || null @@ -6056,6 +7002,10 @@ app.post('/api/:endpoint', (request, response) => { } if (endpoint === 'contacts:delete') { const index = contacts.findIndex((item) => item.id === request.body.id) + if (index >= 0 && contacts[index].readonly) { + response.json({ success: false, error: 'readonly_contact' }) + return + } if (index >= 0) contacts.splice(index, 1) response.json({ success: true }) return @@ -6214,7 +7164,10 @@ app.post('/api/:endpoint', (request, response) => { return } if (endpoint === 'calls:dial') { - const phoneNumber = String(request.body.phoneNumber ?? '').replace(/\D/g, '') + const phoneNumber = String(request.body.phoneNumber ?? '').replace( + /\D/g, + '', + ) if (phoneNumber.length !== 10) { response.json({ success: false, error: 'invalid_number' }) return @@ -6247,7 +7200,10 @@ app.post('/api/:endpoint', (request, response) => { return } if (endpoint === 'calls:block') { - const phoneNumber = String(request.body.phoneNumber ?? '').replace(/\D/g, '') + const phoneNumber = String(request.body.phoneNumber ?? '').replace( + /\D/g, + '', + ) if (!phoneNumber) { response.json({ success: false, error: 'invalid_number' }) return diff --git a/sky_phone/config/companies.lua b/sky_phone/config/companies.lua new file mode 100644 index 0000000..efb5130 --- /dev/null +++ b/sky_phone/config/companies.lua @@ -0,0 +1,248 @@ +Config.Companies = { + Enabled = true, + PageSize = 20, + MaximumPageSize = 50, + MaximumOpenRequestsPerSim = 5, + MaximumServices = 25, + MaximumRequestMedia = 3, + SubjectMaxLength = 120, + RequestBodyMaxLength = 2000, + MessageMaxLength = 2000, + ProfileDescriptionMaxLength = 1000, + DistrictMaxLength = 80, + AddressMaxLength = 160, + ServiceTitleMaxLength = 80, + ServiceDescriptionMaxLength = 500, + ServicePriceMaxLength = 80, + AnnouncementTitleMaxLength = 120, + AnnouncementBodyMaxLength = 1000, + AvailabilityMaximumSeconds = 24 * 60 * 60, + AnnouncementMaximumSeconds = 30 * 24 * 60 * 60, + RetentionDays = 180, + RateLimits = { + Read = 120, + Search = 60, + CreateRequest = 5, + Message = 30, + RequestAction = 30, + Profile = 12, + CallAvailability = 30, + }, + CallRouting = { + MaxAttempts = 3, + RingSeconds = 10, + }, + Categories = { + "public_services", + "vehicles", + "transport", + }, + Statuses = { + new = true, + assigned = true, + in_progress = true, + waiting_customer = true, + completed = true, + cancelled = true, + }, + AvailabilityStatuses = { + available = true, + busy = true, + closed = true, + }, + Definitions = { + police = { + Job = "police", + Name = "Los Santos Police Department", + Category = "public_services", + Public = true, + Emergency = true, + Verified = true, + Icon = "shield", + Description = "Public safety, emergency response, and police services.", + DefaultAvailability = "closed", + AcceptsRequests = false, + District = "Mission Row", + LocationLabel = "Mission Row Police Station", + Address = "Mission Row Police Station", + Location = vector3(425.1, -979.5, 30.7), + ServiceLine = { + Number = "9110000000", + AutoContact = true, + CanCall = true, + CanMessage = false, + Routing = "round_robin", + MinimumGrade = 0, + }, + Permissions = { + WorkQueue = 0, + Availability = 1, + Assign = 2, + Profile = 3, + Hours = 3, + Services = 3, + Announcement = 3, + }, + Services = {}, + }, + ambulance = { + Job = "ambulance", + Name = "Emergency Medical Services", + Category = "public_services", + Public = true, + Emergency = true, + Verified = true, + Icon = "medical", + Description = "Emergency medical response and patient care.", + DefaultAvailability = "closed", + AcceptsRequests = false, + District = "Pillbox Hill", + LocationLabel = "Pillbox Hill Medical Center", + Address = "Pillbox Hill Medical Center", + Location = vector3(307.2, -595.3, 43.3), + ServiceLine = { + Number = "9120000000", + AutoContact = true, + CanCall = true, + CanMessage = false, + Routing = "round_robin", + MinimumGrade = 0, + }, + Permissions = { + WorkQueue = 0, + Availability = 1, + Assign = 2, + Profile = 3, + Hours = 3, + Services = 3, + Announcement = 3, + }, + Services = {}, + }, + fire = { + Job = "fire", + Name = "Los Santos Fire Department", + Category = "public_services", + Public = true, + Emergency = true, + Verified = true, + Icon = "flame", + Description = "Fire response, rescue, and public safety services.", + DefaultAvailability = "closed", + AcceptsRequests = false, + District = "El Burro Heights", + LocationLabel = "Capital Boulevard Fire Station", + Address = "Capital Boulevard Fire Station", + Location = vector3(1200.6, -1473.6, 34.9), + ServiceLine = { + Number = "9130000000", + AutoContact = true, + CanCall = true, + CanMessage = false, + Routing = "round_robin", + MinimumGrade = 0, + }, + Permissions = { + WorkQueue = 0, + Availability = 1, + Assign = 2, + Profile = 3, + Hours = 3, + Services = 3, + Announcement = 3, + }, + Services = {}, + }, + mechanic = { + Job = "mechanic", + Name = "Los Santos Customs", + Category = "vehicles", + Public = true, + Emergency = false, + Verified = true, + Icon = "wrench", + Description = "Vehicle diagnostics, repairs, and roadside assistance.", + DefaultAvailability = "closed", + AcceptsRequests = true, + District = "Burton", + LocationLabel = "Los Santos Customs", + Address = "Carcer Way", + Location = vector3(-337.3, -136.9, 39.0), + ServiceLine = { + Number = "5550101000", + AutoContact = true, + CanCall = true, + CanMessage = false, + Routing = "round_robin", + MinimumGrade = 0, + }, + Permissions = { + WorkQueue = 0, + Availability = 1, + Assign = 2, + Profile = 3, + Hours = 3, + Services = 3, + Announcement = 3, + }, + Services = { + { + Id = "mechanic-repair", + Title = "Vehicle repair", + Description = "Diagnostics and repairs for road vehicles.", + Price = "Price after inspection", + RequestsEnabled = true, + }, + { + Id = "mechanic-towing", + Title = "Roadside assistance", + Description = "Assistance for disabled vehicles.", + Price = "Price by distance", + RequestsEnabled = true, + }, + }, + }, + taxi = { + Job = "taxi", + Name = "Downtown Cab Co.", + Category = "transport", + Public = true, + Emergency = false, + Verified = true, + Icon = "car", + Description = "Staffed taxi rides throughout Los Santos and Blaine County.", + DefaultAvailability = "closed", + AcceptsRequests = true, + District = "East Vinewood", + LocationLabel = "Downtown Cab Co.", + Address = "Tangerine Street", + Location = vector3(895.0, -179.2, 74.7), + ServiceLine = { + Number = "5550102000", + AutoContact = true, + CanCall = true, + CanMessage = false, + Routing = "round_robin", + MinimumGrade = 0, + }, + Permissions = { + WorkQueue = 0, + Availability = 1, + Assign = 2, + Profile = 3, + Hours = 3, + Services = 3, + Announcement = 3, + }, + Services = { + { + Id = "taxi-ride", + Title = "Taxi ride", + Description = "Request a staffed taxi service.", + Price = "Metered fare", + RequestsEnabled = true, + }, + }, + }, + }, +} diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index ff6fc71..2fdd483 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -20,6 +20,23 @@ Config.Phone = { DeviceName = "iFruit Phone", } +Config.CustomApps = { + Enabled = true, + BundledApps = true, + ExternalApps = true, + ReadyTimeoutMs = 8000, + MaximumMessageBytes = 65536, + MaximumStorageBytesPerApp = 262144, + MaximumStorageValueBytes = 65536, -- Bridge v1 ceiling; lower values tighten the server policy. + MaximumStorageKeyLength = 64, -- Bridge v1 ceiling; lower values tighten the server policy. + MaximumStorageKeysPerApp = 128, + StorageRequestsPerMinute = 120, + AllowRemoteOrigins = { + -- ["https://apps.example.com"] = true, + }, + TrustedAdapters = {}, +} + Config.Security = { PasscodePepperConvar = "sky_phone_passcode_pepper", MaximumAttempts = 5, diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 1693aba..f8110dd 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -77,7 +77,7 @@ Locales["en"] = { open = "Open Control Center", play = "Play", previous = "Previous track", quickActions = "Quick actions", timer = "Timer", unmuteRingtone = "Unmute ringtone and notifications", volume = "Volume", wifi = "Wi-Fi", }, - Notifications = { clearAll = "Clear All", now = "now" }, + Notifications = { clearAll = "Clear All", now = "now", open = "Open notification" }, LockScreen = { label = "Lock Screen", flashlight = "Flashlight", camera = "Camera", swipeUp = "Swipe up to open", passcode = { @@ -115,6 +115,12 @@ Locales["en"] = { }, }, Apps = { + customApps = { + loading = "Opening app...", + unavailableTitle = "App unavailable", + unavailableBody = "This custom app could not be loaded. Check that its resource is started, then reopen the app.", + close = "Close app", + }, crewlink = { name = "CrewLink", connecting = "Connecting your crew...", privateNetwork = "Private location network", signInTitle = "Connect your iFruit Account", signInBody = "CrewLink uses your private iFruit identity to keep groups and roles available across your phones.", openSettings = "Open iFruit Settings", @@ -344,6 +350,7 @@ Locales["en"] = { messages = { name = "Messages", newMessage = "New message from {sender}", compose = "New Message", search = "Search", to = "To:", message = "Message", send = "Send", details = "Details", + officialContact = "Official company contact", messagingUnavailable = "This company contact does not accept messages.", filterUnread = "Show Unread Messages", smsLabel = "Text Message · SMS", photo = "Photo", gif = "GIF", video = "Video", contact = "Contact", attachPhoto = "Attach Photo", takePhoto = "Take Photo", attachGif = "Attach GIF", attachVideo = "Attach Video", photos = "Photos", gifs = "GIFs", videos = "Videos", @@ -372,19 +379,322 @@ Locales["en"] = { recording_in_progress = "A video is already being recorded.", recording_not_found = "No active video recording was found.", gif_provider_unconfigured = "GIF search is not configured.", gif_provider_failed = "GIF search is temporarily unavailable.", self_message = "You cannot message your own number.", recipient_not_found = "That number is unavailable.", + messaging_unavailable = "This company contact does not accept messages.", no_sim = "This phone has no SIM card.", rate_limited = "Too many messages. Try again in a minute.", request_failed = "Messages are temporarily unavailable.", default = "The message could not be sent.", }, }, + companies = { + name = "Companies", + navigation = "Companies navigation", + actions = "Request actions", + request = "Service Request", + verified = "Verified company", + back = "Back", + close = "Close", + tryAgain = "Try Again", + loadMore = "Load More", + routeSet = "GPS route set.", + tabs = { + directory = "Discover", + requests = "Requests", + work = "Work", + }, + availability = { + available = "Available", + busy = "Busy", + closed = "Closed", + }, + requestStatuses = { + new = "New", + assigned = "Assigned", + in_progress = "In Progress", + waiting_customer = "Waiting", + completed = "Completed", + cancelled = "Cancelled", + }, + roles = { + employee = "Employee", + manager = "Manager", + }, + categories = { + gastronomy = "Food & Drink", + vehicles = "Vehicles", + transport = "Transport", + crafts = "Trades", + retail = "Retail", + real_estate = "Real Estate", + media = "Media", + nightlife = "Nightlife", + public_services = "Public Services", + emergency = "Emergency Services", + medical = "Medical", + mechanics = "Mechanics", + government = "Government", + }, + days = { + monday = "Monday", + tuesday = "Tuesday", + wednesday = "Wednesday", + thursday = "Thursday", + friday = "Friday", + saturday = "Saturday", + sunday = "Sunday", + }, + directory = { + searchPlaceholder = "Search companies or services", + categories = "Company categories", + allCategories = "All", + allCompanies = "All Companies", + availableNow = "Available now", + availableNowHint = "Only show companies taking calls right now.", + availableSection = "Available Now", + moreFilters = "More filters", + hasLocation = "Location", + acceptsRequests = "Requests", + resetFilters = "Reset Filters", + }, + loading = { + directory = "Loading companies...", + profile = "Loading company profile...", + request = "Loading request...", + requests = "Loading your requests...", + work = "Loading workspace...", + }, + states = { + directoryError = "Companies are unavailable", + requestsError = "Requests are unavailable", + workError = "Workspace unavailable", + profileError = "Company unavailable", + requestError = "Request unavailable", + noCompanies = "No companies listed", + noCompaniesBody = "Public companies will appear here when configured.", + noResults = "No matching companies", + noResultsBody = "Try another search or reset your filters.", + noOpenRequests = "No open requests", + noClosedRequests = "No completed requests", + noRequestsBody = "Requests you create with a company will appear here.", + }, + actionUnavailable = { + call = "This company cannot be called right now.", + message = "This service line does not accept text messages.", + route = "This company has no public location.", + request = "This company is not accepting service requests.", + }, + profile = { + updated = "Status updated {time}", + announcement = "Latest Update", + location = "Location", + noLocation = "No public location", + hours = "Opening Hours", + closed = "Closed", + byAvailability = "Open by availability", + services = "Services", + noServices = "No public services listed", + call = "Call", + message = "Message", + route = "Route", + request = "Request", + }, + composer = { + title = "New Service Request", + chooseService = "Choose a Service", + subject = "Subject", + subjectPlaceholder = "What do you need?", + description = "Details", + descriptionPlaceholder = "Describe what happened and how the company can help.", + contact = "Contact number", + registeredSim = "Replies go to your registered SIM {number}.", + registeredSimRequired = "A registered SIM is required to send a request.", + addPhotos = "Add Photos ({count}/3)", + selectedPhoto = "Selected request photo", + removePhoto = "Remove photo", + review = "Review Request", + confirmTitle = "Send this request?", + confirmBody = "Send your {service} request to {company}.", + edit = "Keep Editing", + send = "Send Request", + }, + requests = { + open = "Open", + closed = "Completed", + findCompany = "Find a Company", + generalService = "General service", + attachments = "Request photos", + attachedPhoto = "Attached request photo", + timeline = "Timeline", + conversation = "Conversation", + noMessages = "No replies yet", + replyPlaceholder = "Write a reply", + sendReply = "Send reply", + call = "Call Company", + cancel = "Cancel Request", + cancelTitle = "Cancel this request?", + cancelBody = "The company will be notified and can no longer complete it.", + keep = "Keep Request", + cancelConfirm = "Cancel Request", + }, + timeline = { + created = "Request created", + assigned = "Request assigned", + cancelled = "Request cancelled", + completed = "Request completed", + statusChanged = "Status changed to {status}", + }, + time = { + justNow = "just now", + minutesAgo = "{count} min ago", + hoursAgo = "{count} hr ago", + daysAgo = "{count} days ago", + }, + work = { + notAuthorized = "No company workspace", + notAuthorizedBody = "Your current job is not connected to a configured company.", + workspace = "Company Workspace", + publicAvailability = "Public Availability", + takeCalls = "Take company calls", + takeCallsBody = "Route new service-line calls to this active SIM.", + overview = "Today at a Glance", + metrics = { + new = "New", + assigned = "Mine", + waiting = "Waiting", + completedToday = "Done Today", + }, + queue = "Request Queue", + filters = { + new = "New", + assigned = "Mine", + in_progress = "In Progress", + waiting_customer = "Waiting", + completed = "Done", + }, + unassigned = "Unassigned", + emptyQueue = "Queue is clear", + emptyQueueBody = "No requests match this filter.", + }, + workActions = { + claim = "Claim Request", + assign = "Assign Employee", + callCustomer = "Call Customer", + setStatus = "Set status: {status}", + }, + assignment = { + title = "Assign Employee", + online = "Online", + offline = "Offline", + unknownMember = "Unknown employee", + confirm = "Assign Request", + }, + assignmentLabels = { + you = "Assigned to you", + assigned = "Assigned to a colleague", + }, + messageAuthors = { + you = "You", + customer = "Customer", + company = "Company", + }, + manager = { + title = "Company Profile", + subtitle = "Manage public information, services, and announcements.", + revision = "Server revision {revision}", + availability = "Availability", + profile = "Public Profile", + description = "Description", + descriptionPlaceholder = "Describe the company and what it offers.", + phoneNumber = "Service number", + phoneNumberManaged = "Configured by the server and cannot be edited here.", + noPhoneNumber = "No service number configured", + chooseCover = "Choose cover photo", + chooseLogo = "Choose logo", + coverPhoto = "Company cover photo", + logoPhoto = "Company logo", + locationLabel = "Location name", + locationReady = "A map position is attached to this profile.", + useCurrentLocation = "Use Current Location", + address = "Address", + district = "District", + acceptRequests = "Accept service requests", + acceptRequestsBody = "Allow registered SIMs to open structured requests.", + saveProfile = "Save Profile", + hours = "Opening Hours", + dayOpen = "Company is open on this day", + opensAt = "Opens", + closesAt = "Closes", + saveHours = "Save Hours", + services = "Services", + serviceTitle = "Service name", + serviceDescription = "Description", + priceText = "Price text", + serviceActive = "Publicly visible", + serviceRequests = "Accept requests for this service", + removeService = "Remove Service", + addService = "Add Service", + saveServices = "Save Services", + announcement = "Current Announcement", + announcementText = "Announcement", + announcementPlaceholder = "Share a short, timely update.", + expiresAt = "Expires at", + publish = "Publish Announcement", + }, + feedback = { + requestCreated = "Request sent.", + requestCancelled = "Request cancelled.", + requestClaimed = "Request claimed.", + requestAssigned = "Request assigned.", + statusUpdated = "Request status updated.", + availabilityUpdated = "Public availability updated.", + locationUpdated = "Current location selected.", + callsEnabled = "Company calls enabled.", + callsDisabled = "Company calls disabled.", + profileSaved = "Company profile saved.", + hoursSaved = "Opening hours saved.", + servicesSaved = "Services saved.", + announcementPublished = "Announcement published.", + }, + conflict = { + title = "Newer changes are available", + body = "Another manager updated this information. Reload before saving again.", + reload = "Reload", + }, + notifications = { + newRequest = "New company request", + requestUpdated = "A company request was updated.", + newMessage = "New reply to your company request.", + assigned = "A company request was assigned to you.", + }, + errors = { + anonymous_sim = "A registered SIM is required for service requests.", + call_unavailable = "This service line is currently unavailable.", + company_not_found = "This company is no longer available.", + invalid_profile = "Check the company profile fields.", + invalid_request = "Check the subject and request details.", + invalid_media = "One or more attached photos are unavailable.", + invalid_expiration = "Choose a valid announcement expiration.", + invalid_service = "This service is no longer available.", + invalid_status = "That status change is not allowed.", + messaging_unavailable = "This company does not accept text messages.", + no_sim = "Insert a SIM card to continue.", + not_authorized = "You are not authorized for this company action.", + rate_limited = "Please wait before trying again.", + request_not_found = "This request is no longer available.", + revision_conflict = "The data changed on another device. Reload and try again.", + service_unavailable = "The Companies service is temporarily unavailable.", + too_many_open_requests = "You already have too many open requests.", + request_failed = "Companies could not complete the request.", + }, + }, phone = { name = "Phone", recents = "Recents", contacts = "Contacts", keypad = "Keypad", noSim = "No SIM", noSimBody = "Insert a SIM card in Settings to make calls.", - noRecents = "No Recent Calls", noContacts = "No Contacts", searchContacts = "Search Contacts", favorites = "Favorites", + noRecents = "No Recent Calls", noContacts = "No Contacts", searchContacts = "Search Contacts", favorites = "Favorites", addContact = "New Contact", newContact = "New", editContact = "Edit Contact", contactName = "Name", firstName = "First Name", lastName = "Last Name", companyOrGroup = "Company or Group", phoneNumber = "Phone Number", + officialContact = "Official company contact", choosePhoto = "Choose Contact Photo", chooseGallery = "Gallery", takePhoto = "Camera", removePhoto = "Remove Photo", message = "Message", video = "Video", mail = "Mail", defaultLabel = "Default", privateLabel = "Private", contactCard = "Contact Card", removeContact = "Remove Contact", returnToCall = "Return to Call", mobile = "Mobile", messagesProfile = "Messages", notes = "Notes", - sendMessage = "Send Message", shareContact = "Share Contact", addFavorite = "Add to Favorites", removeFavorite = "Remove from Favorites", + sendMessage = "Send Message", shareContact = "Share Contact", addFavorite = "Add to Favorites", removeFavorite = "Remove from Favorites", addEmergency = "Add to Emergency Contacts", blockContact = "Block Contact", block = "Block", blockCaller = "Block Caller", blockCallerTitle = "Block this caller?", blockCallerBody = "{number} will no longer be able to call this SIM.", @@ -398,10 +708,13 @@ Locales["en"] = { choosePhoneBody = "Select the phone that should receive {number}.", emptyPhone = "No SIM inserted", errors = { invalid_contact = "Enter a name and valid phone number.", invalid_number = "Enter a valid phone number.", - message_unavailable = "The conversation could not be opened.", contact_remove_failed = "The contact could not be removed.", contact_favorite_failed = "The favorite could not be updated.", + invalid_sim = "The active SIM card is unavailable.", no_sim = "This phone has no SIM card.", airplane_mode = "Turn off Airplane Mode to make calls.", self_call = "You cannot call your own number.", busy = "The line is busy.", - blocked = "This number is blocked.", recipient_not_found = "This number is not known.", + company_unavailable = "This company service line is unavailable.", + readonly_contact = "Official company contacts cannot be changed.", + message_unavailable = "The conversation could not be opened.", contact_remove_failed = "The contact could not be removed.", contact_favorite_failed = "The favorite could not be updated.", + blocked = "This number is blocked.", recipient_not_found = "This number is not known.", rate_limited = "Too many calls. Try again in a minute.", voice_unavailable = "The configured phone voice service is unavailable.", inventory_full = "There is no room for the ejected SIM card.", request_failed = "The phone request failed.", operation_in_progress = "Another phone operation is already in progress.", sim_request_expired = "The SIM selection expired. Use the SIM card again.", diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua index 42eaf2c..fb4cf85 100644 --- a/sky_phone/fxmanifest.lua +++ b/sky_phone/fxmanifest.lua @@ -7,16 +7,19 @@ author 'Sky-Systems' description 'Sky Phone' version '0.1.0' -escrow_ignore { - 'config/**', - 'source/bridge/**', -} +provide 'lb-phone' +provide '17mov_Phone' +provide 'high-phone' +provide 'qs-smartphone' +provide 'yseries' shared_scripts { 'config/init.lua', 'source/bridge/shared.lua', 'source/shared/imei.lua', 'source/shared/sim_number.lua', + 'source/shared/custom_apps.lua', + 'source/shared/custom_app_compat.lua', } client_scripts { @@ -34,6 +37,8 @@ client_scripts { 'source/client/crewlink.lua', 'source/bridge/client/radio.lua', 'source/client/payphones.lua', + 'source/client/custom_apps.lua', + 'source/client/custom_app_compat.lua', 'source/client/main.lua', 'source/client/radio.lua', } @@ -41,6 +46,7 @@ client_scripts { server_scripts { '@oxmysql/lib/MySQL.lua', 'config/config.lua', + 'config/companies.lua', 'config/media.lua', 'config/music.lua', 'config/locales/*.lua', @@ -53,8 +59,12 @@ server_scripts { 'source/bridge/server/housing/*.lua', 'source/bridge/server/inventory.lua', 'source/bridge/server/inventory/*.lua', + 'source/server/custom_apps.lua', + 'source/server/custom_app_compat.lua', 'source/server/db_migrate.lua', 'source/server/phone.lua', + 'source/server/companies.lua', + 'source/server/custom_app_storage.lua', 'source/server/sim.lua', 'source/server/calls.lua', 'source/server/media.lua', diff --git a/sky_phone/source/bridge/client/housing/esx_property.lua b/sky_phone/source/bridge/client/housing/esx_property.lua index 08fe3f3..9c9ef8c 100644 --- a/sky_phone/source/bridge/client/housing/esx_property.lua +++ b/sky_phone/source/bridge/client/housing/esx_property.lua @@ -2,6 +2,11 @@ if Bridge.Framework.GetName() ~= "esx" then return end +if GetResourceState("esx_property") ~= "started" then + print("[sky_phone] esx_property is not started; the housing provider is disabled.") + return +end + local ESX = exports["es_extended"]:getSharedObject() local callback_names = { diff --git a/sky_phone/source/bridge/server/frameworks/esx.lua b/sky_phone/source/bridge/server/frameworks/esx.lua index 33658e1..1c4a820 100644 --- a/sky_phone/source/bridge/server/frameworks/esx.lua +++ b/sky_phone/source/bridge/server/frameworks/esx.lua @@ -2,6 +2,10 @@ if Bridge.Framework.Name ~= "esx" then return end +if GetResourceState("es_extended") ~= "started" then + error("[sky_phone] ESX is configured, but es_extended is not started.") +end + local ESX = exports["es_extended"]:getSharedObject() local function get_player(source) diff --git a/sky_phone/source/client/crewlink.lua b/sky_phone/source/client/crewlink.lua index d554d0a..5ecf4eb 100644 --- a/sky_phone/source/client/crewlink.lua +++ b/sky_phone/source/client/crewlink.lua @@ -1,4 +1,5 @@ local overhead_members = {} +local overhead_expires_at = 0 local function draw_overhead_label(coords, username, role) SetDrawOrigin(coords.x, coords.y, coords.z + 1.05, 0) @@ -14,17 +15,25 @@ local function draw_overhead_label(coords, username, role) ClearDrawOrigin() end -CreateThread(function() - while true do - local result = Bridge.Callbacks.Trigger("sky_phone:crewlink:overhead", {}) - overhead_members = result and result.success and result.data and result.data.members or {} - Wait(Config.CrewLink.OverheadRefreshMilliseconds) - end +RegisterNUICallback("crewlink:live", function(data, cb) + 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 + cb(result or { success = false, error = "request_failed" }) +end) + +AddEventHandler("sky_phone:nuiClosed", function() + overhead_members = {} + overhead_expires_at = 0 end) CreateThread(function() while true do local sleep = 1000 + if overhead_expires_at > 0 and GetGameTimer() >= overhead_expires_at then + overhead_members = {} + overhead_expires_at = 0 + end if #overhead_members > 0 then sleep = 0 local player_coords = GetEntityCoords(PlayerPedId()) diff --git a/sky_phone/source/client/custom_app_compat.lua b/sky_phone/source/client/custom_app_compat.lua new file mode 100644 index 0000000..57ec252 --- /dev/null +++ b/sky_phone/source/client/custom_app_compat.lua @@ -0,0 +1,611 @@ +local RESOURCE_NAME = GetCurrentResourceName() +local providers = SkyPhoneCompatibility.Providers +local core = SkyPhoneApps.CompatibilityCore +local provider_apps = {} +local high_client_apps = {} +local high_server_apps = {} + +local function get_calling_resource(export_name) + local owner_resource = GetInvokingResource() + if owner_resource then + return owner_resource + end + + print(("[%s] %s rejected: the export must be called by another resource."):format( + RESOURCE_NAME, + export_name + )) + return nil, "invalid_owner" +end + +local function copy_record_data(value) + if type(value) ~= "table" then + return nil + end + + local copied = {} + for key, nested_value in pairs(value) do + copied[key] = nested_value + end + return copied +end + +local function register_provider_app(provider, owner_resource, definition, vendor_data) + local app_id = definition.id + local existing = provider_apps[app_id] + if existing and (existing.owner_resource ~= owner_resource or existing.provider ~= provider) then + return false, "duplicate_app_id" + end + + local success, error_message + if existing then + success, error_message = core.Update(owner_resource, definition) + else + success, error_message = core.Add(owner_resource, definition) + end + if success then + provider_apps[app_id] = { + definition = definition, + owner_resource = owner_resource, + provider = provider, + vendor_data = vendor_data, + } + end + return success, error_message +end + +local function get_provider_app(owner_resource, app_id, allowed_providers) + if type(app_id) ~= "string" then + return nil, "invalid_app_id" + end + + local record = provider_apps[app_id] + if not record then + return nil, "app_not_found" + end + if record.owner_resource ~= owner_resource then + return nil, "app_owner_mismatch" + end + if allowed_providers and not allowed_providers[record.provider] then + return nil, "app_provider_mismatch" + end + return record +end + +local function remove_provider_app(owner_resource, app_id, allowed_providers) + local record, record_error = get_provider_app(owner_resource, app_id, allowed_providers) + if not record then + return false, record_error + end + + return core.Remove(owner_resource, app_id) +end + +function SkyPhoneApps.RegisterCompatibilityExport(owner_resource, app_data) + local provider + local definition + local definition_error + if app_data.identifier ~= nil then + provider = providers.lb + definition, definition_error = SkyPhoneCompatibility.BuildLbDefinition(owner_resource, app_data) + elseif app_data.key ~= nil then + provider = providers.yseries + definition, definition_error = SkyPhoneCompatibility.BuildYSeriesDefinition(app_data) + else + return false, "unknown_app_provider" + end + + if not definition then + print(("[%s] %s registration rejected for %s: %s."):format( + RESOURCE_NAME, + provider, + owner_resource, + definition_error + )) + return false, definition_error + end + return register_provider_app(provider, owner_resource, definition, copy_record_data(app_data)) +end + +SkyPhoneApps.OnCompatibilityAppRemoved = function(owner_resource, app_id) + local record = provider_apps[app_id] + if record and record.owner_resource == owner_resource then + provider_apps[app_id] = nil + end +end + +local function add_17mov_application(app_data) + local owner_resource, owner_error = get_calling_resource("AddApplication") + if not owner_resource then + return false, owner_error + end + + local definition, definition_error = SkyPhoneCompatibility.Build17MovDefinition(app_data) + if not definition then + print(("[%s] 17mov registration rejected for %s: %s."):format( + RESOURCE_NAME, + owner_resource, + definition_error + )) + return false, definition_error + end + return register_provider_app( + providers.seventeen, + owner_resource, + definition, + copy_record_data(app_data) + ) +end + +local function remove_17mov_application(app_name, _resource_name) + local owner_resource, owner_error = get_calling_resource("RemoveApplication") + if not owner_resource then + return false, owner_error + end + + local app_id = type(app_name) == "table" and app_name.name or app_name + return remove_provider_app(owner_resource, app_id, { + [providers.seventeen] = true, + }) +end + +local function send_app_message(app_id, data) + local owner_resource, owner_error = get_calling_resource("SendAppMessage") + if not owner_resource then + return false, owner_error + end + + local record, record_error = get_provider_app(owner_resource, app_id, { + [providers.seventeen] = true, + [providers.yseries] = true, + }) + if not record then + return false, record_error + end + return core.SendMessage(owner_resource, app_id, data) +end + +local function add_high_application(app_name, data, locales) + local owner_resource, owner_error = get_calling_resource("addApplication") + if not owner_resource then + return false, owner_error + end + + local definition, definition_error = SkyPhoneCompatibility.BuildHighDefinition( + owner_resource, + app_name, + data, + locales + ) + if not definition then + print(("[%s] High Phone registration rejected for %s: %s."):format( + RESOURCE_NAME, + owner_resource, + definition_error + )) + return false, definition_error + end + + high_client_apps[app_name] = { + definition = definition, + owner_resource = owner_resource, + } + local server_record = high_server_apps[app_name] + if server_record then + if server_record.owner_resource ~= owner_resource then + return false, "duplicate_app_id" + end + if server_record.registered then + return true + end + return false, "server_definition_not_registered" + end + + return register_provider_app( + providers.high, + owner_resource, + definition, + copy_record_data(data) + ) +end + +local function send_high_app_nui(app_name, data) + local owner_resource, owner_error = get_calling_resource("sendAppNui") + if not owner_resource then + return false, owner_error + end + + local record, record_error = get_provider_app(owner_resource, app_name, { + [providers.high] = true, + }) + if not record then + return false, record_error + end + return core.SendMessage(owner_resource, app_name, data) +end + +local function add_quasar_app_for_owner(owner_resource, app_data) + local definition, definition_error = SkyPhoneCompatibility.BuildQuasarDefinition(app_data) + if not definition then + print(("[%s] Quasar registration rejected for %s: %s."):format( + RESOURCE_NAME, + owner_resource, + definition_error + )) + return false, definition_error + end + + return register_provider_app( + providers.quasar, + owner_resource, + definition, + SkyPhoneCompatibility.CopyQuasarData(app_data) + ) +end + +local function add_quasar_app(app_data) + local owner_resource, owner_error = get_calling_resource("addCustomApp") + if not owner_resource then + return false, owner_error + end + if type(app_data) ~= "table" then + return false, "invalid_app_data" + end + return add_quasar_app_for_owner(owner_resource, app_data) +end + +local function add_quasar_apps_batch(apps) + local owner_resource, owner_error = get_calling_resource("addCustomAppsBatch") + if not owner_resource then + return false, owner_error + end + if type(apps) ~= "table" then + return false, "invalid_app_batch" + end + + for index = 1, #apps do + local success, error_message = add_quasar_app_for_owner(owner_resource, apps[index]) + if not success then + return false, error_message + end + end + return true +end + +local function update_quasar_app(app_id, patch) + local owner_resource, owner_error = get_calling_resource("updateCustomApp") + if not owner_resource then + return false, owner_error + end + if type(patch) ~= "table" then + return false, "invalid_patch" + end + + local record, record_error = get_provider_app(owner_resource, app_id, { + [providers.quasar] = true, + }) + if not record then + return false, record_error + end + + local merged = SkyPhoneCompatibility.CopyQuasarData(record.vendor_data) + for key, value in pairs(patch) do + if key == "iframe" and type(value) == "table" then + merged.iframe = merged.iframe or {} + for iframe_key, iframe_value in pairs(value) do + merged.iframe[iframe_key] = iframe_value + end + elseif key ~= "id" then + merged[key] = value + end + end + merged.id = app_id + + local definition, definition_error = SkyPhoneCompatibility.BuildQuasarDefinition(merged) + if not definition then + return false, definition_error + end + + local success, error_message = core.Update(owner_resource, definition) + if success then + record.definition = definition + record.vendor_data = SkyPhoneCompatibility.CopyQuasarData(merged) + end + return success, error_message +end + +local function remove_quasar_app(app_id) + local owner_resource, owner_error = get_calling_resource("removeCustomApp") + if not owner_resource then + return false, owner_error + end + return remove_provider_app(owner_resource, app_id, { + [providers.quasar] = true, + }) +end + +local function get_quasar_apps() + local ids = {} + for app_id, record in pairs(provider_apps) do + if record.provider == providers.quasar then + ids[#ids + 1] = app_id + end + end + table.sort(ids) + + local apps = {} + for index = 1, #ids do + apps[index] = SkyPhoneCompatibility.CopyQuasarData(provider_apps[ids[index]].vendor_data) + end + return apps +end + +local function open_quasar_app(app_id) + local owner_resource, owner_error = get_calling_resource("OpenPhoneApp") + if not owner_resource then + return false, owner_error + end + + local record, record_error = get_provider_app(owner_resource, app_id, { + [providers.quasar] = true, + }) + if not record then + return false, record_error + end + return core.Open(owner_resource, app_id) +end + +local function open_phone_app(app_id, data) + local owner_resource, owner_error = get_calling_resource("OpenApp") + if not owner_resource then + return false, owner_error + end + + local record, record_error = get_provider_app(owner_resource, app_id) + if not record then + return false, record_error + end + return core.Open(owner_resource, app_id, data) +end + +local function close_phone_app(options) + local owner_resource, owner_error = get_calling_resource("CloseApp") + if not owner_resource then + return false, owner_error + end + + local app_id = type(options) == "table" and options.app or nil + if app_id then + local record, record_error = get_provider_app(owner_resource, app_id, { + [providers.lb] = true, + [providers.yseries] = true, + }) + if not record then + return false, record_error + end + return core.Close(owner_resource, app_id) + end + return core.CloseActive(owner_resource) +end + +local function register_high_server_app(owner_resource, definition, revision) + if type(owner_resource) ~= "string" + or type(definition) ~= "table" + or type(definition.id) ~= "string" + or type(revision) ~= "number" + or revision ~= math.floor(revision) + or revision < 1 + then + print(("[%s] Rejected invalid High Phone server application snapshot."):format(RESOURCE_NAME)) + return nil + end + + local app_id = definition.id + local existing = high_server_apps[app_id] + if existing and revision <= existing.revision then + if revision < existing.revision or existing.registered then + return app_id + end + if existing.owner_resource ~= owner_resource then + print(("[%s] Rejected conflicting High Phone owner for %s."):format( + RESOURCE_NAME, + app_id + )) + return app_id + end + end + + local provider_record = provider_apps[app_id] + local success, error_message + local retained_registration = false + if provider_record then + if provider_record.owner_resource ~= owner_resource or provider_record.provider ~= providers.high then + success, error_message = false, "duplicate_app_id" + else + retained_registration = true + success, error_message = core.Update(owner_resource, definition) + end + else + success, error_message = core.Add(owner_resource, definition) + end + + high_server_apps[app_id] = { + definition = definition, + last_error = success and nil or error_message, + owner_resource = owner_resource, + registered = success or retained_registration, + revision = revision, + } + if success then + provider_apps[app_id] = { + definition = definition, + owner_resource = owner_resource, + provider = providers.high, + } + elseif not existing or existing.revision ~= revision or existing.last_error ~= error_message then + print(("[%s] Could not register High Phone server application %s revision %s: %s."):format( + RESOURCE_NAME, + app_id, + revision, + error_message or "unknown_error" + )) + end + return app_id +end + +local function remove_high_server_app(owner_resource, app_id) + local record = high_server_apps[app_id] + if not record or record.owner_resource ~= owner_resource then + return + end + + high_server_apps[app_id] = nil + if record.registered then + core.Remove(owner_resource, app_id) + end + + local client_record = high_client_apps[app_id] + if client_record then + local success, error_message = register_provider_app( + providers.high, + client_record.owner_resource, + client_record.definition, + nil + ) + if not success then + print(("[%s] Could not restore High Phone client application %s: %s."):format( + RESOURCE_NAME, + app_id, + error_message or "unknown_error" + )) + end + end +end + +RegisterNetEvent("sky_phone:compat:high:client:syncApplication", function(owner_resource, definition, revision) + if source ~= 65535 then + print(("[%s] Rejected locally invoked High Phone application sync."):format(RESOURCE_NAME)) + return + end + register_high_server_app(owner_resource, definition, revision) +end) + +RegisterNetEvent("sky_phone:compat:high:client:removeApplication", function(owner_resource, app_id) + if source ~= 65535 then + print(("[%s] Rejected locally invoked High Phone application removal."):format(RESOURCE_NAME)) + return + end + if type(owner_resource) ~= "string" or type(app_id) ~= "string" then + print(("[%s] Rejected invalid High Phone application removal."):format(RESOURCE_NAME)) + return + end + remove_high_server_app(owner_resource, app_id) +end) + +RegisterNetEvent("sky_phone:compat:high:client:replaceSnapshot", function(snapshot) + if source ~= 65535 then + print(("[%s] Rejected locally invoked High Phone application snapshot."):format(RESOURCE_NAME)) + return + end + if type(snapshot) ~= "table" then + print(("[%s] Rejected invalid High Phone application snapshot."):format(RESOURCE_NAME)) + return + end + + local seen = {} + for index = 1, #snapshot do + local record = snapshot[index] + if type(record) == "table" then + local app_id = register_high_server_app( + record.owner_resource, + record.definition, + record.revision + ) + if app_id then + seen[app_id] = true + end + end + end + + local removed = {} + for app_id, record in pairs(high_server_apps) do + if not seen[app_id] then + removed[#removed + 1] = { + app_id = app_id, + owner_resource = record.owner_resource, + } + end + end + for index = 1, #removed do + remove_high_server_app(removed[index].owner_resource, removed[index].app_id) + end +end) + +AddEventHandler("onClientResourceStart", function(resource_name) + for _, record in pairs(high_server_apps) do + if record.owner_resource == resource_name and not record.registered then + register_high_server_app(record.owner_resource, record.definition, record.revision) + end + end +end) + +AddEventHandler("onClientResourceStop", function(resource_name) + for app_id, record in pairs(high_client_apps) do + if record.owner_resource == resource_name then + high_client_apps[app_id] = nil + end + end +end) + +exports("AddApplication", add_17mov_application) +exports("RemoveApplication", remove_17mov_application) +exports("SendAppMessage", send_app_message) +exports("addApplication", add_high_application) +exports("sendAppNui", send_high_app_nui) +exports("addCustomApp", add_quasar_app) +exports("addCustomAppsBatch", add_quasar_apps_batch) +exports("updateCustomApp", update_quasar_app) +exports("removeCustomApp", remove_quasar_app) +exports("getCustomApps", get_quasar_apps) +exports("OpenPhoneApp", open_quasar_app) +exports("OpenApp", open_phone_app) +exports("CloseApp", close_phone_app) + +SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "OpenApp", open_phone_app) +SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "CloseApp", close_phone_app) + +SkyPhoneCompatibility.RegisterExportAlias("17mov_Phone", "AddApplication", add_17mov_application) +SkyPhoneCompatibility.RegisterExportAlias("17mov_Phone", "RemoveApplication", remove_17mov_application) +SkyPhoneCompatibility.RegisterExportAlias("17mov_Phone", "SendAppMessage", send_app_message) + +SkyPhoneCompatibility.RegisterExportAlias("high-phone", "addApplication", add_high_application) +SkyPhoneCompatibility.RegisterExportAlias("high-phone", "sendAppNui", send_high_app_nui) + +SkyPhoneCompatibility.RegisterExportAlias("qs-smartphone", "addCustomApp", add_quasar_app) +SkyPhoneCompatibility.RegisterExportAlias("qs-smartphone", "addCustomAppsBatch", add_quasar_apps_batch) +SkyPhoneCompatibility.RegisterExportAlias("qs-smartphone", "updateCustomApp", update_quasar_app) +SkyPhoneCompatibility.RegisterExportAlias("qs-smartphone", "removeCustomApp", remove_quasar_app) +SkyPhoneCompatibility.RegisterExportAlias("qs-smartphone", "getCustomApps", get_quasar_apps) +SkyPhoneCompatibility.RegisterExportAlias("qs-smartphone", "OpenPhoneApp", open_quasar_app) + +SkyPhoneCompatibility.RegisterExportAlias("yseries", "SendAppMessage", send_app_message) +SkyPhoneCompatibility.RegisterExportAlias("yseries", "CloseApp", close_phone_app) +SkyPhoneCompatibility.RegisterExportAlias("yseries", "GetDataLoaded", function() + return true +end) + +CreateThread(function() + TriggerServerEvent("sky_phone:compat:high:server:requestSnapshot") + TriggerEvent("17mov_Phone:Client:Ready") + + local provider_resources = { + "lb-phone", + "17mov_Phone", + "high-phone", + "qs-smartphone", + "yseries", + } + for index = 1, #provider_resources do + TriggerEvent("onResourceStart", provider_resources[index]) + end +end) diff --git a/sky_phone/source/client/custom_apps.lua b/sky_phone/source/client/custom_apps.lua new file mode 100644 index 0000000..ea0ed2c --- /dev/null +++ b/sky_phone/source/client/custom_apps.lua @@ -0,0 +1,1481 @@ +local COMPATIBILITY_MAX_BYTES = 8192 +local JSON_MAX_DEPTH = 12 +local JSON_MAX_VALUES = 4096 +local MAX_QUEUED_MESSAGES = 64 +local NOTIFICATION_TEXT_MAX_LENGTH = 2000 +local NOTIFICATION_TITLE_MAX_LENGTH = 160 + +local NOTIFICATION_SOUNDS = { + chime = true, + signal = true, + soft = true, +} + +local HOOK_NAMES = { + onClose = true, + onInstall = true, + onOpen = true, + onReady = true, +} + +local apps_by_id = {} +local app_ids_by_adapter = {} +local app_ids_by_owner = {} +local current_resource = GetCurrentResourceName() +local active_app_id = nil +local active_app_ready = false +local phone_open = false +local queued_messages = {} +local default_icon_url = ("https://cfx-nui-%s/img/custom-app.svg"):format(current_resource) + +local function trim(value) + return value:match("^%s*(.-)%s*$") +end + +local function is_callable(value) + if type(value) == "function" then + return true + end + if type(value) ~= "table" then + return false + end + + local value_metatable = getmetatable(value) + return type(value_metatable) == "table" and type(value_metatable.__call) == "function" +end + +local function validate_owner_resource(owner_resource, allow_stopping) + if type(owner_resource) ~= "string" + or #owner_resource == 0 + or #owner_resource > 64 + or not owner_resource:match("^[%w][%w._-]*$") + then + return false, "invalid_owner_resource" + end + + local state = GetResourceState(owner_resource) + if state == "started" or state == "starting" or (allow_stopping and state == "stopping") then + return true + end + + return false, "owner_resource_not_running" +end + +local function resolve_localized_text(value) + if type(value) == "string" then + return value + end + + local locale = Config.Bridge.Locale:lower():gsub("_", "-") + if value[locale] then + return value[locale] + end + + local base_locale = locale:match("^([a-z]+)-") + if base_locale and value[base_locale] then + return value[base_locale] + end + if value.en then + return value.en + end + if value.de then + return value.de + end + + local locales = {} + for available_locale in pairs(value) do + locales[#locales + 1] = available_locale + end + table.sort(locales) + return value[locales[1]] +end + +local function is_allowed_remote_origin(url) + local origin = url:match("^(https://[^/%?#]+)") + if not origin then + return false + end + + local allowed_origins = Config.CustomApps.AllowRemoteOrigins + if allowed_origins[origin] then + return true + end + + for index = 1, #allowed_origins do + if allowed_origins[index] == origin then + return true + end + end + + return false +end + +local function validate_asset_path(path) + if #path == 0 + or #path > 1024 + or path:find("\\", 1, true) + or path:find("[%c]") + or path:lower():find("%2e", 1, true) + then + return false + end + + local path_only = path:match("^[^?#]+") or path + for segment in path_only:gmatch("[^/]+") do + if segment == "." or segment == ".." then + return false + end + end + + return true +end + +local function normalize_asset_url(value, original_owner, adapter_resource, asset_resource, required) + if value == nil and not required then + return nil + end + if type(value) ~= "string" then + return nil, "invalid_asset_url" + end + + local url = trim(value) + if not validate_asset_path(url) then + return nil, "invalid_asset_url" + end + + local allowed_owners = { + [original_owner] = true, + } + if adapter_resource then + allowed_owners[adapter_resource] = true + end + if asset_resource then + allowed_owners[asset_resource] = true + end + + local cfx_owner = url:match("^https://cfx%-nui%-([^/]+)/") + if cfx_owner then + if not allowed_owners[cfx_owner] then + return nil, "asset_owner_mismatch" + end + return url + end + + local nui_owner, nui_path = url:match("^nui://([^/]+)/(.+)$") + if nui_owner then + if not allowed_owners[nui_owner] then + return nil, "asset_owner_mismatch" + end + return ("https://cfx-nui-%s/%s"):format(nui_owner, nui_path) + end + + if url:sub(1, 8) == "https://" then + if not is_allowed_remote_origin(url) then + return nil, "remote_origin_not_allowed" + end + return url + end + + if url:match("^[%a][%w+.-]*:") or url:sub(1, 2) == "//" or url:sub(1, 1) == "/" then + return nil, "invalid_asset_url" + end + + local asset_owner = original_owner + local asset_path = url + local original_prefix = original_owner .. "/" + if url:sub(1, #original_prefix) == original_prefix then + asset_path = url:sub(#original_prefix + 1) + elseif adapter_resource then + local adapter_prefix = adapter_resource .. "/" + if url:sub(1, #adapter_prefix) == adapter_prefix then + asset_owner = adapter_resource + asset_path = url:sub(#adapter_prefix + 1) + end + end + if asset_resource then + local asset_prefix = asset_resource .. "/" + if url:sub(1, #asset_prefix) == asset_prefix then + asset_owner = asset_resource + asset_path = url:sub(#asset_prefix + 1) + end + end + + if not validate_asset_path(asset_path) then + return nil, "invalid_asset_url" + end + + return ("https://cfx-nui-%s/%s"):format(asset_owner, asset_path) +end + +local function validate_json_value(value, depth, seen, counter) + local value_type = type(value) + if value_type == "nil" or value_type == "boolean" then + return true + end + if value_type == "number" then + return value == value and value ~= math.huge and value ~= -math.huge + end + if value_type == "string" then + return #value <= Config.CustomApps.MaximumMessageBytes + end + if value_type ~= "table" or depth >= JSON_MAX_DEPTH or seen[value] then + return false + end + + seen[value] = true + local key_type = nil + local numeric_keys = 0 + local value_count = 0 + for key, nested_value in pairs(value) do + counter.count = counter.count + 1 + value_count = value_count + 1 + if counter.count > JSON_MAX_VALUES then + seen[value] = nil + return false + end + + local current_key_type = type(key) + if current_key_type == "number" then + if key < 1 or key % 1 ~= 0 then + seen[value] = nil + return false + end + numeric_keys = numeric_keys + 1 + elseif current_key_type ~= "string" or #key > 128 then + seen[value] = nil + return false + end + + if key_type and key_type ~= current_key_type then + seen[value] = nil + return false + end + key_type = current_key_type + + if not validate_json_value(nested_value, depth + 1, seen, counter) then + seen[value] = nil + return false + end + end + + if key_type == "number" and (numeric_keys ~= value_count or #value ~= value_count) then + seen[value] = nil + return false + end + + seen[value] = nil + return true +end + +local function normalize_json_payload(payload, maximum_bytes) + if not validate_json_value(payload, 0, {}, { count = 0 }) then + return nil, "invalid_payload" + end + + local encoded_success, encoded = pcall(json.encode, payload) + if not encoded_success or type(encoded) ~= "string" then + return nil, "invalid_payload" + end + if #encoded > maximum_bytes then + return nil, "payload_too_large" + end + + local decoded = json.decode(encoded) + return decoded +end + +local function validate_optional_text(value, error_code, maximum_length) + if value == nil then + return nil + end + if type(value) ~= "string" then + return nil, error_code + end + + local normalized = trim(value) + if #normalized == 0 or #normalized > maximum_length then + return nil, error_code + end + + return normalized +end + +local function normalize_external_definition(owner_resource, adapter_resource, definition) + if type(definition) ~= "table" then + return nil, "invalid_definition" + end + if definition.schemaVersion ~= nil and definition.schemaVersion ~= SkyPhoneApps.ProtocolVersion then + return nil, "unsupported_schema_version" + end + if definition.onDelete ~= nil then + return nil, "unsupported_on_delete" + end + + local valid_id, id_error = SkyPhoneApps.ValidateAppId(definition.id) + if not valid_id then + return nil, id_error + end + if SkyPhoneApps.ReservedAppIds[definition.id] then + return nil, "reserved_app_id" + end + + local name, name_error = SkyPhoneApps.ValidateLocalizedText( + definition.name, + "invalid_name", + 64, + true + ) + if not name then + return nil, name_error + end + + local description, description_error = SkyPhoneApps.ValidateLocalizedText( + definition.description, + "invalid_description", + 320, + false + ) + if definition.description ~= nil and not description then + return nil, description_error + end + + local developer, developer_error = validate_optional_text(definition.developer, "invalid_developer", 96) + if definition.developer ~= nil and not developer then + return nil, developer_error + end + + local category = definition.category or "utilities" + if not SkyPhoneApps.AllowedCategories[category] then + return nil, "invalid_category" + end + + local asset_resource = definition.assetResource + if asset_resource ~= nil then + if definition.compatibility == nil then + return nil, "asset_resource_not_allowed" + end + local valid_asset_resource, asset_resource_error = validate_owner_resource(asset_resource, false) + if not valid_asset_resource then + return nil, asset_resource_error + end + end + + local ui, ui_error = normalize_asset_url( + definition.ui, + owner_resource, + adapter_resource, + asset_resource, + true + ) + if not ui then + return nil, ui_error + end + + local icon, icon_error = normalize_asset_url( + definition.icon, + owner_resource, + adapter_resource, + asset_resource, + false + ) + if definition.icon ~= nil and not icon then + if definition.compatibility == nil then + return nil, icon_error + end + print(( + "[sky_phone] Custom app '%s' from '%s' uses an unavailable icon (%s); using the default icon." + ):format(definition.id, owner_resource, icon_error)) + end + + local permissions, permissions_error = SkyPhoneApps.ValidatePermissions(definition.permissions) + if not permissions then + return nil, permissions_error + end + + local orientation = definition.orientation or "portrait" + if not SkyPhoneApps.AllowedOrientations[orientation] then + return nil, "invalid_orientation" + end + + if definition.defaultInstalled ~= nil and type(definition.defaultInstalled) ~= "boolean" then + return nil, "invalid_default_installed" + end + if definition.removable ~= nil and type(definition.removable) ~= "boolean" then + return nil, "invalid_removable" + end + + local grid_order = definition.gridOrder + if grid_order ~= nil + and (type(grid_order) ~= "number" or grid_order ~= math.floor(grid_order) or grid_order < 0 or grid_order > 9999) + then + return nil, "invalid_grid_order" + end + + local icon_background = validate_optional_text(definition.iconBackground, "invalid_icon_background", 64) + if definition.iconBackground ~= nil and not icon_background then + return nil, "invalid_icon_background" + end + if icon_background and icon_background:find("[\r\n;{}]") then + return nil, "invalid_icon_background" + end + + local bridge_mode = definition.bridgeMode or "legacy" + if bridge_mode ~= "sky" and bridge_mode ~= "legacy" then + return nil, "invalid_bridge_mode" + end + + local compatibility = nil + if definition.compatibility ~= nil then + if type(definition.compatibility) ~= "table" or next(definition.compatibility) == nil then + return nil, "invalid_compatibility" + end + compatibility, permissions_error = normalize_json_payload(definition.compatibility, COMPATIBILITY_MAX_BYTES) + if not compatibility then + return nil, permissions_error + end + end + + local hooks = {} + for hook_name in pairs(HOOK_NAMES) do + local hook = definition[hook_name] + if hook ~= nil and not is_callable(hook) then + return nil, "invalid_" .. hook_name + end + hooks[hook_name] = hook + end + + return { + adapterResource = adapter_resource, + catalog = { + bridgeMode = bridge_mode, + bundled = false, + category = category, + compatibility = compatibility, + defaultInstalled = definition.defaultInstalled == true, + description = description and resolve_localized_text(description) or nil, + developer = developer, + gridOrder = grid_order, + icon = icon or default_icon_url, + iconBackground = icon_background, + id = definition.id, + kind = "external", + name = resolve_localized_text(name), + orientation = orientation, + ownerResource = owner_resource, + permissions = permissions, + readyTimeoutMs = Config.CustomApps.ReadyTimeoutMs, + removable = definition.removable ~= false, + ui = ui, + }, + hooks = hooks, + ownerResource = owner_resource, + } +end + +local function normalize_bundled_manifest(manifest) + local resource_base_path = ("custom_apps/%s/"):format(manifest.folder) + local base_url = ("https://cfx-nui-%s/%s"):format(current_resource, resource_base_path) + if LoadResourceFile(current_resource, resource_base_path .. manifest.entry) == nil then + error(("[sky_phone] Bundled custom app '%s' entry file is missing: %s"):format( + manifest.id, + manifest.entry + )) + end + if manifest.icon and LoadResourceFile(current_resource, resource_base_path .. manifest.icon) == nil then + error(("[sky_phone] Bundled custom app '%s' icon file is missing: %s"):format( + manifest.id, + manifest.icon + )) + end + for index = 1, #manifest.screenshots do + if LoadResourceFile(current_resource, resource_base_path .. manifest.screenshots[index]) == nil then + error(("[sky_phone] Bundled custom app '%s' screenshot file is missing: %s"):format( + manifest.id, + manifest.screenshots[index] + )) + end + end + + return { + adapterResource = nil, + catalog = { + bridgeMode = manifest.bridgeMode, + bundled = true, + category = manifest.category, + defaultInstalled = manifest.defaultInstalled, + description = resolve_localized_text(manifest.description), + developer = manifest.developer, + gridOrder = manifest.gridOrder, + icon = manifest.icon and (base_url .. manifest.icon) or default_icon_url, + iconBackground = manifest.iconBackground, + id = manifest.id, + kind = "external", + name = resolve_localized_text(manifest.name), + orientation = manifest.orientation, + ownerResource = current_resource, + permissions = manifest.permissions, + readyTimeoutMs = Config.CustomApps.ReadyTimeoutMs, + removable = manifest.removable, + ui = base_url .. manifest.entry, + }, + hooks = {}, + ownerResource = current_resource, + } +end + +local function get_catalog() + local catalog = {} + for _, app in pairs(apps_by_id) do + catalog[#catalog + 1] = app.catalog + end + table.sort(catalog, function(left, right) + return left.id < right.id + end) + return catalog +end + +local function send_catalog() + SendNUIMessage({ + type = "custom-apps:catalog", + data = { + apps = get_catalog(), + }, + }) +end + +local function sync_catalog_if_open() + if phone_open then + send_catalog() + end +end + +local function add_owner_index(index, resource_name, app_id) + local app_ids = index[resource_name] + if not app_ids then + app_ids = {} + index[resource_name] = app_ids + end + app_ids[app_id] = true +end + +local function remove_owner_index(index, resource_name, app_id) + local app_ids = index[resource_name] + if not app_ids then + return + end + + app_ids[app_id] = nil + if not next(app_ids) then + index[resource_name] = nil + end +end + +local function register_app(app) + local app_id = app.catalog.id + local existing = apps_by_id[app_id] + if existing then + print(( + "[sky_phone] Rejected duplicate custom app '%s' from '%s'; it is already owned by '%s'." + ):format(app_id, app.ownerResource, existing.ownerResource)) + return false, "duplicate_app_id" + end + + apps_by_id[app_id] = app + add_owner_index(app_ids_by_owner, app.ownerResource, app_id) + if app.adapterResource then + add_owner_index(app_ids_by_adapter, app.adapterResource, app_id) + end + sync_catalog_if_open() + return true +end + +local function invoke_hook(app, hook_name, payload) + local hook = app.hooks[hook_name] + if not hook then + return true + end + + local success, hook_error = xpcall(function() + hook(payload) + end, debug.traceback) + if success then + return true + end + + print(("[sky_phone] Custom app '%s' hook '%s' failed: %s"):format( + app.catalog.id, + hook_name, + tostring(hook_error) + )) + return false +end + +local function invoke_or_defer_hook(app, hook_name, payload, deferred_hooks) + if not app.catalog.compatibility then + return invoke_hook(app, hook_name, payload) + end + + deferred_hooks[#deferred_hooks + 1] = { + app = app, + hookName = hook_name, + payload = payload, + } + return true +end + +local function invoke_deferred_hooks(deferred_hooks) + for index = 1, #deferred_hooks do + local deferred_hook = deferred_hooks[index] + CreateThread(function() + invoke_hook(deferred_hook.app, deferred_hook.hookName, deferred_hook.payload) + end) + end +end + +local function clear_active_app(invoke_close_hook, deferred_hooks) + if not active_app_id then + return + end + + local app = apps_by_id[active_app_id] + if app and invoke_close_hook then + if deferred_hooks then + invoke_or_defer_hook(app, "onClose", nil, deferred_hooks) + else + invoke_hook(app, "onClose") + end + end + queued_messages[active_app_id] = nil + active_app_id = nil + active_app_ready = false +end + +local function remove_registered_app(app_id, invoke_close_hook, synchronize) + local app = apps_by_id[app_id] + if not app then + return false, "app_not_found" + end + + if active_app_id == app_id then + SendNUIMessage({ type = "custom-app:close", data = { appId = app_id } }) + clear_active_app(invoke_close_hook) + end + + apps_by_id[app_id] = nil + remove_owner_index(app_ids_by_owner, app.ownerResource, app_id) + if app.adapterResource then + remove_owner_index(app_ids_by_adapter, app.adapterResource, app_id) + end + if SkyPhoneApps.OnCompatibilityAppRemoved then + SkyPhoneApps.OnCompatibilityAppRemoved(app.ownerResource, app_id) + end + if synchronize then + sync_catalog_if_open() + end + return true +end + +local function get_direct_owner() + local owner_resource = GetInvokingResource() + if not owner_resource then + return nil, "missing_invoking_resource" + end + + local valid_owner, owner_error = validate_owner_resource(owner_resource, false) + if not valid_owner then + return nil, owner_error + end + + return owner_resource +end + +local function get_trusted_adapter() + local adapter_resource = GetInvokingResource() + if not adapter_resource or not Config.CustomApps.TrustedAdapters[adapter_resource] then + return nil, "untrusted_adapter" + end + + local valid_adapter, adapter_error = validate_owner_resource(adapter_resource, false) + if not valid_adapter then + return nil, adapter_error + end + + return adapter_resource +end + +local function verify_owned_app(owner_resource, app_id, adapter_resource) + local valid_id, id_error = SkyPhoneApps.ValidateAppId(app_id) + if not valid_id then + return nil, id_error + end + + local app = apps_by_id[app_id] + if not app then + return nil, "app_not_found" + end + if app.ownerResource ~= owner_resource then + return nil, "app_owner_mismatch" + end + if adapter_resource and app.adapterResource ~= adapter_resource then + return nil, "app_adapter_mismatch" + end + + return app +end + +local function add_custom_app(definition) + if not Config.CustomApps.Enabled or not Config.CustomApps.ExternalApps then + return false, "external_apps_disabled" + end + + local owner_resource, owner_error = get_direct_owner() + if not owner_resource then + return false, owner_error + end + + local app, definition_error = normalize_external_definition(owner_resource, nil, definition) + if not app then + return false, definition_error + end + + return register_app(app) +end + +local function add_custom_app_from_adapter(owner_resource, definition) + if not Config.CustomApps.Enabled or not Config.CustomApps.ExternalApps then + return false, "external_apps_disabled" + end + + local adapter_resource, adapter_error = get_trusted_adapter() + if not adapter_resource then + return false, adapter_error + end + local valid_owner, owner_error = validate_owner_resource(owner_resource, false) + if not valid_owner then + return false, owner_error + end + + local app, definition_error = normalize_external_definition(owner_resource, adapter_resource, definition) + if not app then + return false, definition_error + end + + return register_app(app) +end + +local function add_compatibility_app(owner_resource, definition) + if not Config.CustomApps.Enabled or not Config.CustomApps.ExternalApps then + return false, "external_apps_disabled" + end + + local valid_owner, owner_error = validate_owner_resource(owner_resource, false) + if not valid_owner then + return false, owner_error + end + + local app, definition_error = normalize_external_definition(owner_resource, nil, definition) + if not app then + return false, definition_error + end + + return register_app(app) +end + +local function replace_registered_app(app, adapter_resource) + local app_id = app.catalog.id + local existing = apps_by_id[app_id] + if not existing then + return false, "app_not_found" + end + if existing.catalog.bundled then + return false, "bundled_app" + end + if existing.ownerResource ~= app.ownerResource then + return false, "app_owner_mismatch" + end + if existing.adapterResource ~= adapter_resource then + return false, "app_adapter_mismatch" + end + + apps_by_id[app_id] = app + sync_catalog_if_open() + return true +end + +local function update_custom_app(definition) + if not Config.CustomApps.Enabled or not Config.CustomApps.ExternalApps then + return false, "external_apps_disabled" + end + + local owner_resource, owner_error = get_direct_owner() + if not owner_resource then + return false, owner_error + end + + local app, definition_error = normalize_external_definition(owner_resource, nil, definition) + if not app then + return false, definition_error + end + return replace_registered_app(app, nil) +end + +local function update_custom_app_from_adapter(owner_resource, definition) + if not Config.CustomApps.Enabled or not Config.CustomApps.ExternalApps then + return false, "external_apps_disabled" + end + + local adapter_resource, adapter_error = get_trusted_adapter() + if not adapter_resource then + return false, adapter_error + end + local valid_owner, owner_error = validate_owner_resource(owner_resource, false) + if not valid_owner then + return false, owner_error + end + + local app, definition_error = normalize_external_definition(owner_resource, adapter_resource, definition) + if not app then + return false, definition_error + end + return replace_registered_app(app, adapter_resource) +end + +local function update_compatibility_app(owner_resource, definition) + if not Config.CustomApps.Enabled or not Config.CustomApps.ExternalApps then + return false, "external_apps_disabled" + end + + local valid_owner, owner_error = validate_owner_resource(owner_resource, false) + if not valid_owner then + return false, owner_error + end + + local app, definition_error = normalize_external_definition(owner_resource, nil, definition) + if not app then + return false, definition_error + end + return replace_registered_app(app, nil) +end + +local function remove_custom_app(app_id) + local owner_resource, owner_error = get_direct_owner() + if not owner_resource then + return false, owner_error + end + + local app, app_error = verify_owned_app(owner_resource, app_id) + if not app then + return false, app_error + end + if app.catalog.bundled then + return false, "bundled_app" + end + + return remove_registered_app(app_id, true, true) +end + +local function remove_custom_app_from_adapter(owner_resource, app_id) + local adapter_resource, adapter_error = get_trusted_adapter() + if not adapter_resource then + return false, adapter_error + end + + local app, app_error = verify_owned_app(owner_resource, app_id, adapter_resource) + if not app then + return false, app_error + end + + return remove_registered_app(app_id, true, true) +end + +local function remove_compatibility_app(owner_resource, app_id) + local valid_owner, owner_error = validate_owner_resource(owner_resource, false) + if not valid_owner then + return false, owner_error + end + + local app, app_error = verify_owned_app(owner_resource, app_id) + if not app then + return false, app_error + end + if app.catalog.bundled then + return false, "bundled_app" + end + + return remove_registered_app(app_id, true, true) +end + +local function deliver_custom_app_message(app_id, payload) + SendNUIMessage({ + type = "custom-app:message", + data = { + appId = app_id, + payload = payload, + }, + }) +end + +local function send_owned_custom_app_message(owner_resource, app_id, payload, adapter_resource) + local app, app_error = verify_owned_app(owner_resource, app_id, adapter_resource) + if not app then + return false, app_error + end + if not phone_open or active_app_id ~= app_id then + return false, "app_not_active" + end + + local normalized_payload, payload_error = normalize_json_payload( + payload, + Config.CustomApps.MaximumMessageBytes + ) + if payload_error then + return false, payload_error + end + + if active_app_ready then + deliver_custom_app_message(app_id, normalized_payload) + return true + end + + local pending = queued_messages[app_id] or {} + if #pending >= MAX_QUEUED_MESSAGES then + return false, "message_queue_full" + end + pending[#pending + 1] = { payload = normalized_payload } + queued_messages[app_id] = pending + return true +end + +local function send_custom_app_message(app_id, payload) + local owner_resource, owner_error = get_direct_owner() + if not owner_resource then + return false, owner_error + end + return send_owned_custom_app_message(owner_resource, app_id, payload) +end + +local function send_custom_app_message_from_adapter(owner_resource, app_id, payload) + local adapter_resource, adapter_error = get_trusted_adapter() + if not adapter_resource then + return false, adapter_error + end + return send_owned_custom_app_message(owner_resource, app_id, payload, adapter_resource) +end + +local function send_compatibility_app_message(owner_resource, app_id, payload) + local valid_owner, owner_error = validate_owner_resource(owner_resource, false) + if not valid_owner then + return false, owner_error + end + return send_owned_custom_app_message(owner_resource, app_id, payload) +end + +local function open_owned_custom_app(owner_resource, app_id, payload, adapter_resource) + local app, app_error = verify_owned_app(owner_resource, app_id, adapter_resource) + if not app then + return false, app_error + end + if not phone_open then + return false, "phone_closed" + end + + local normalized_payload, payload_error = normalize_json_payload( + payload, + Config.CustomApps.MaximumMessageBytes + ) + if payload_error then + return false, payload_error + end + + SendNUIMessage({ + type = "custom-app:open", + data = { + appId = app_id, + data = normalized_payload, + }, + }) + return true +end + +local function open_custom_app(app_id, payload) + local owner_resource, owner_error = get_direct_owner() + if not owner_resource then + return false, owner_error + end + return open_owned_custom_app(owner_resource, app_id, payload) +end + +local function open_custom_app_from_adapter(owner_resource, app_id, payload) + local adapter_resource, adapter_error = get_trusted_adapter() + if not adapter_resource then + return false, adapter_error + end + return open_owned_custom_app(owner_resource, app_id, payload, adapter_resource) +end + +local function open_compatibility_app(owner_resource, app_id, payload) + local valid_owner, owner_error = validate_owner_resource(owner_resource, false) + if not valid_owner then + return false, owner_error + end + return open_owned_custom_app(owner_resource, app_id, payload) +end + +local function close_owned_custom_app(owner_resource, app_id, adapter_resource) + local app, app_error = verify_owned_app(owner_resource, app_id, adapter_resource) + if not app then + return false, app_error + end + if active_app_id ~= app_id then + return false, "app_not_active" + end + + SendNUIMessage({ type = "custom-app:close", data = { appId = app_id } }) + return true +end + +local function close_custom_app(app_id) + local owner_resource, owner_error = get_direct_owner() + if not owner_resource then + return false, owner_error + end + return close_owned_custom_app(owner_resource, app_id) +end + +local function close_custom_app_from_adapter(owner_resource, app_id) + local adapter_resource, adapter_error = get_trusted_adapter() + if not adapter_resource then + return false, adapter_error + end + return close_owned_custom_app(owner_resource, app_id, adapter_resource) +end + +local function close_active_custom_app_from_adapter(owner_resource) + local adapter_resource, adapter_error = get_trusted_adapter() + if not adapter_resource then + return false, adapter_error + end + if not active_app_id then + return false, "app_not_active" + end + + local app, app_error = verify_owned_app(owner_resource, active_app_id, adapter_resource) + if not app then + return false, app_error + end + + SendNUIMessage({ type = "custom-app:close", data = { appId = active_app_id } }) + return true +end + +local function close_compatibility_app(owner_resource, app_id) + local valid_owner, owner_error = validate_owner_resource(owner_resource, false) + if not valid_owner then + return false, owner_error + end + return close_owned_custom_app(owner_resource, app_id) +end + +local function close_active_compatibility_app(owner_resource) + local valid_owner, owner_error = validate_owner_resource(owner_resource, false) + if not valid_owner then + return false, owner_error + end + if not active_app_id then + return false, "app_not_active" + end + + local app, app_error = verify_owned_app(owner_resource, active_app_id) + if not app then + return false, app_error + end + + SendNUIMessage({ type = "custom-app:close", data = { appId = active_app_id } }) + return true +end + +local function has_permission(app, permission) + local permissions = app.catalog.permissions + for index = 1, #permissions do + if permissions[index] == permission then + return true + end + end + return false +end + +local function normalize_notification(app, notification) + if type(notification) ~= "table" then + return nil, "invalid_notification" + end + if not has_permission(app, "notifications") then + return nil, "permission_denied" + end + + local title = notification.title + if title == nil then + title = app.catalog.name + end + title = validate_optional_text(title, "invalid_notification_title", NOTIFICATION_TITLE_MAX_LENGTH) + if not title then + return nil, "invalid_notification_title" + end + + local text = validate_optional_text( + notification.text or notification.content, + "invalid_notification_text", + NOTIFICATION_TEXT_MAX_LENGTH + ) + if not text then + return nil, "invalid_notification_text" + end + + local subtitle = validate_optional_text(notification.subtitle, "invalid_notification_subtitle", 160) + if notification.subtitle ~= nil and not subtitle then + return nil, "invalid_notification_subtitle" + end + + if notification.critical ~= nil and type(notification.critical) ~= "boolean" then + return nil, "invalid_notification_critical" + end + if notification.critical and not has_permission(app, "notifications.critical") then + return nil, "permission_denied" + end + if notification.persistent ~= nil and type(notification.persistent) ~= "boolean" then + return nil, "invalid_notification_persistent" + end + if notification.sound ~= nil and not NOTIFICATION_SOUNDS[notification.sound] then + return nil, "invalid_notification_sound" + end + + local app_route = "/apps/" .. app.catalog.id + local route = notification.route or app_route + if type(route) ~= "string" + or route ~= app_route + or route:find("[\r\n]") + or #route > 512 + then + return nil, "invalid_notification_route" + end + + return { + appId = app.catalog.id, + critical = notification.critical == true, + persistent = notification.persistent == true, + route = route, + sound = notification.sound, + subtitle = subtitle, + text = text, + title = title, + } +end + +local function send_owned_custom_app_notification(owner_resource, app_id, notification, adapter_resource) + local app, app_error = verify_owned_app(owner_resource, app_id, adapter_resource) + if not app then + return false, app_error + end + + local normalized_notification, notification_error = normalize_notification(app, notification) + if not normalized_notification then + return false, notification_error + end + + send_catalog() + SendNUIMessage({ type = "notification:show", data = normalized_notification }) + return true +end + +local function send_custom_app_notification(app_id, notification) + local owner_resource, owner_error = get_direct_owner() + if not owner_resource then + return false, owner_error + end + return send_owned_custom_app_notification(owner_resource, app_id, notification) +end + +local function send_custom_app_notification_from_adapter(owner_resource, app_id, notification) + local adapter_resource, adapter_error = get_trusted_adapter() + if not adapter_resource then + return false, adapter_error + end + return send_owned_custom_app_notification(owner_resource, app_id, notification, adapter_resource) +end + +local function get_custom_app_capabilities() + return { + abiVersion = 1, + enabled = Config.CustomApps.Enabled, + bridgeMethods = { + "app.close", + "app.open", + "device.storage.get", + "device.storage.set", + "notification.create", + }, + contextCapabilities = { + "locale.read", + "theme.read", + }, + exports = { + "AddCustomApp", + "AddCustomAppFromAdapter", + "CloseActiveCustomAppFromAdapter", + "CloseCustomApp", + "CloseCustomAppFromAdapter", + "OpenCustomApp", + "OpenCustomAppFromAdapter", + "RemoveCustomApp", + "RemoveCustomAppFromAdapter", + "SendCustomAppMessage", + "SendCustomAppMessageFromAdapter", + "SendCustomAppNotification", + "SendCustomAppNotificationFromAdapter", + "UpdateCustomApp", + "UpdateCustomAppFromAdapter", + }, + maximumMessageBytes = Config.CustomApps.MaximumMessageBytes, + maximumStorageBytesPerApp = Config.CustomApps.MaximumStorageBytesPerApp, + maximumStorageKeyLength = math.min(Config.CustomApps.MaximumStorageKeyLength, 64), + maximumStorageValueBytes = math.min(Config.CustomApps.MaximumStorageValueBytes, 65536), + protocolVersion = SkyPhoneApps.ProtocolVersion, + } +end + +local function set_phone_open(is_open) + phone_open = is_open + if phone_open then + send_catalog() + return + end + + clear_active_app(true) +end + +local function register_bundled_client_hooks(app_id, hooks) + local valid_id, id_error = SkyPhoneApps.ValidateAppId(app_id) + if not valid_id then + return false, id_error + end + + local app = apps_by_id[app_id] + if not app then + return false, "app_not_found" + end + if not app.catalog.bundled then + return false, "external_app" + end + if type(hooks) ~= "table" then + return false, "invalid_hooks" + end + + local normalized_hooks = {} + for hook_name, hook in pairs(hooks) do + if not HOOK_NAMES[hook_name] or type(hook) ~= "function" then + return false, "invalid_hook" + end + normalized_hooks[hook_name] = hook + end + + app.hooks = normalized_hooks + return true +end + +SkyPhoneApps.SendCatalog = send_catalog +SkyPhoneApps.SetPhoneOpen = set_phone_open +SkyPhoneApps.RegisterClientHooks = register_bundled_client_hooks + +if Config.CustomApps.Enabled and Config.CustomApps.BundledApps then + local bundled = SkyPhoneApps.GetBundledManifests() + for index = 1, #bundled do + local registered, register_error = register_app(normalize_bundled_manifest(bundled[index])) + if not registered then + error(("[sky_phone] Could not register bundled custom app '%s': %s"):format( + bundled[index].id, + register_error + )) + end + end +end + +local function add_custom_app_export(definition) + if type(definition) ~= "table" then + return add_custom_app(definition) + end + + local provider_markers = 0 + if definition.id ~= nil then + provider_markers = provider_markers + 1 + end + if definition.identifier ~= nil then + provider_markers = provider_markers + 1 + end + if definition.key ~= nil then + provider_markers = provider_markers + 1 + end + if provider_markers > 1 then + return false, "ambiguous_app_provider" + end + if definition.identifier == nil and definition.key == nil then + return add_custom_app(definition) + end + + local owner_resource, owner_error = get_direct_owner() + if not owner_resource then + return false, owner_error + end + if not SkyPhoneApps.RegisterCompatibilityExport then + return false, "compatibility_not_ready" + end + return SkyPhoneApps.RegisterCompatibilityExport(owner_resource, definition) +end + +SkyPhoneApps.CompatibilityCore = { + Add = add_compatibility_app, + Close = close_compatibility_app, + CloseActive = close_active_compatibility_app, + Open = open_compatibility_app, + Remove = remove_compatibility_app, + SendMessage = send_compatibility_app_message, + Update = update_compatibility_app, +} + +exports("AddCustomApp", add_custom_app_export) +exports("AddCustomAppFromAdapter", add_custom_app_from_adapter) +exports("CloseActiveCustomAppFromAdapter", close_active_custom_app_from_adapter) +exports("CloseCustomApp", close_custom_app) +exports("CloseCustomAppFromAdapter", close_custom_app_from_adapter) +exports("GetCustomAppCapabilities", get_custom_app_capabilities) +exports("OpenCustomApp", open_custom_app) +exports("OpenCustomAppFromAdapter", open_custom_app_from_adapter) +exports("RemoveCustomApp", remove_custom_app) +exports("RemoveCustomAppFromAdapter", remove_custom_app_from_adapter) +exports("SendCustomAppMessage", send_custom_app_message) +exports("SendCustomAppMessageFromAdapter", send_custom_app_message_from_adapter) +exports("SendCustomAppNotification", send_custom_app_notification) +exports("SendCustomAppNotificationFromAdapter", send_custom_app_notification_from_adapter) +exports("UpdateCustomApp", update_custom_app) +exports("UpdateCustomAppFromAdapter", update_custom_app_from_adapter) + +SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "AddCustomApp", add_custom_app_export) +SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "RemoveCustomApp", remove_custom_app) +SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "SendCustomAppMessage", send_custom_app_message) +SkyPhoneCompatibility.RegisterExportAlias("yseries", "AddCustomApp", add_custom_app_export) +SkyPhoneCompatibility.RegisterExportAlias("yseries", "RemoveCustomApp", remove_custom_app) + +RegisterNUICallback("custom-app:lifecycle", function(data, cb) + if type(data) ~= "table" then + cb({ success = false, error = "invalid_lifecycle_request" }) + return + end + + local valid_id, id_error = SkyPhoneApps.ValidateAppId(data.appId) + if not valid_id then + cb({ success = false, error = id_error }) + return + end + + local lifecycle_event = data.event + if lifecycle_event ~= "install" + and lifecycle_event ~= "open" + and lifecycle_event ~= "ready" + and lifecycle_event ~= "close" + then + cb({ success = false, error = "invalid_lifecycle_event" }) + return + end + + local app = apps_by_id[data.appId] + if lifecycle_event == "close" + and (not phone_open or not app or active_app_id ~= data.appId) + then + cb({ success = true }) + return + end + if not phone_open then + cb({ success = false, error = "phone_closed" }) + return + end + if not app then + cb({ success = false, error = "app_not_found" }) + return + end + + local lifecycle_payload, payload_error = normalize_json_payload( + data.data, + Config.CustomApps.MaximumMessageBytes + ) + if payload_error then + cb({ success = false, error = payload_error }) + return + end + + local deferred_hooks = {} + local hook_success = true + if lifecycle_event == "install" then + hook_success = invoke_or_defer_hook(app, "onInstall", lifecycle_payload, deferred_hooks) + elseif lifecycle_event == "open" then + if active_app_id and active_app_id ~= data.appId then + clear_active_app(true, deferred_hooks) + end + active_app_id = data.appId + active_app_ready = false + queued_messages[data.appId] = {} + hook_success = invoke_or_defer_hook(app, "onOpen", lifecycle_payload, deferred_hooks) + elseif active_app_id ~= data.appId then + cb({ success = false, error = "app_not_active" }) + return + elseif lifecycle_event == "ready" then + if active_app_ready then + cb({ success = true }) + return + end + active_app_ready = true + local pending = queued_messages[data.appId] or {} + queued_messages[data.appId] = {} + for index = 1, #pending do + deliver_custom_app_message(data.appId, pending[index].payload) + end + hook_success = invoke_or_defer_hook(app, "onReady", lifecycle_payload, deferred_hooks) + else + hook_success = invoke_or_defer_hook(app, "onClose", lifecycle_payload, deferred_hooks) + clear_active_app(false) + end + + if not hook_success then + cb({ success = false, error = "hook_failed" }) + return + end + cb({ success = true }) + invoke_deferred_hooks(deferred_hooks) +end) + +AddEventHandler("onClientResourceStop", function(resource_name) + if resource_name == current_resource then + return + end + + local app_ids = {} + local owner_apps = app_ids_by_owner[resource_name] + if owner_apps then + for app_id in pairs(owner_apps) do + app_ids[app_id] = true + end + end + + local adapter_apps = app_ids_by_adapter[resource_name] + if adapter_apps then + for app_id in pairs(adapter_apps) do + app_ids[app_id] = true + end + end + + local removed = false + for app_id in pairs(app_ids) do + local success = remove_registered_app(app_id, false, false) + removed = success or removed + end + if removed then + sync_catalog_if_open() + end +end) diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index 0ca0bc8..5799100 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -13,7 +13,10 @@ local server_callbacks = { "security:change-passcode", "security:disable-passcode", "device:save", + "device:notification-open", "notifications:save", + "custom-app:storage:get", + "custom-app:storage:set", "device:factory-reset", "account:login", "account:register", @@ -165,7 +168,26 @@ local server_callbacks = { "crewlink:leave", "crewlink:create-ping", "crewlink:remove-ping", - "crewlink:live", + "companies:list", + "companies:get", + "companies:my-requests", + "companies:get-request", + "companies:work-context", + "companies:work-queue", + "companies:list-members", + "companies:create-request", + "companies:cancel-request", + "companies:send-message", + "companies:claim-request", + "companies:assign-request", + "companies:update-request-status", + "companies:update-availability", + "companies:update-profile", + "companies:update-hours", + "companies:update-services", + "companies:publish-announcement", + "companies:set-call-availability", + "companies:call-customer", "sim:insert", "sim:eject", "contacts:list", @@ -235,6 +257,7 @@ local function send_open_message() local payload = device_payload payload.lang = Config.Bridge.Locale payload.locales = get_locale().Nui + SkyPhoneApps.SendCatalog() SendNUIMessage({ type = "app:open", data = payload, @@ -257,6 +280,7 @@ local function close_phone() 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" }) @@ -300,6 +324,7 @@ end RegisterNUICallback("ui:ready", function(_, cb) 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 @@ -319,6 +344,7 @@ RegisterNUICallback("ui:opened", function(_, cb) end is_open = true + SkyPhoneApps.SetPhoneOpen(true) notification_focus = false SetNuiFocus(true, true) TriggerEvent("sky_phone:animation:phone", true) @@ -532,6 +558,18 @@ RegisterNetEvent("sky_phone:marketplace:changed", function(data) SendNUIMessage({ type = "marketplace:changed", data = data }) end) +RegisterNetEvent("sky_phone:companies:changed", function(data) + SendNUIMessage({ type = "companies:changed", data = data }) +end) + +RegisterNetEvent("sky_phone:companies:notification", function(data) + local companies_locale = get_locale().Nui.Apps.companies + data.title = companies_locale.name + data.text = companies_locale.notifications[data.kind] + or companies_locale.notifications.requestUpdated + SendNUIMessage({ type = "companies:notification", data = data }) +end) + RegisterNetEvent("sky_phone:fliptok:command-feedback", function(data) Bridge.Framework.Notify("FlipTok", data.message, data.notificationType, 5000) end) diff --git a/sky_phone/source/server/calls.lua b/sky_phone/source/server/calls.lua index 01a9049..537306c 100644 --- a/sky_phone/source/server/calls.lua +++ b/sky_phone/source/server/calls.lua @@ -8,6 +8,7 @@ local active_by_sim = {} local dial_locks = {} local dialing_by_sim = {} local next_voice_channel = 10000 +local reroute_company_call local function uuid() local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {}) @@ -240,11 +241,17 @@ local function finish_call(call, status) params = { status, duration, call.id }, }, { - query = "UPDATE `sky_phone_call_entries` SET `status` = ? WHERE `call_id` = ? AND `direction` = 'outgoing'", + query = [[ + UPDATE `sky_phone_call_entries` SET `status` = ? + WHERE `call_id` = ? AND `direction` = 'outgoing' AND `status` IN ('ringing', 'connected') + ]], params = { status, call.id }, }, { - query = "UPDATE `sky_phone_call_entries` SET `status` = ? WHERE `call_id` = ? AND `direction` = 'incoming'", + query = [[ + UPDATE `sky_phone_call_entries` SET `status` = ? + WHERE `call_id` = ? AND `direction` = 'incoming' AND `status` IN ('ringing', 'connected') + ]], params = { callee_status, call.id }, }, }) @@ -281,9 +288,17 @@ end function SkyPhoneCalls.EndForSim(sim_id, reason) local call_id = active_by_sim[sim_id] - if call_id then - finish_call(calls[call_id], reason or "ended") + local call = call_id and calls[call_id] or nil + if not call then + return end + if call.callee_sim_id == sim_id and call.company_service_call and not call.answered_at then + if not reroute_company_call(call, "missed") then + finish_call(call, "unavailable") + end + return + end + finish_call(call, reason or "ended") end function SkyPhoneCalls.LinkAccountData(account_id, imei) @@ -348,7 +363,27 @@ Bridge.Callbacks.Register("sky_phone:contacts:list", function(source) `created_at`, `updated_at` FROM `sky_phone_contacts` WHERE %s ORDER BY LOWER(`name`), `phone_number` ]]):format(condition), params) - return { success = true, data = rows } + local system_contacts = SkyPhoneCompanies.GetSystemContacts() + local system_ids = {} + local contacts = {} + for _, contact in ipairs(system_contacts) do + system_ids[contact.id] = true + contacts[#contacts + 1] = contact + end + for _, contact in ipairs(rows) do + if not system_ids[contact.id] and not SkyPhoneCompanies.IsSystemContactNumber(contact.phone_number) then + contacts[#contacts + 1] = contact + end + end + table.sort(contacts, function(left, right) + local left_name = tostring(left.name):lower() + local right_name = tostring(right.name):lower() + if left_name == right_name then + return tostring(left.phone_number) < tostring(right.phone_number) + end + return left_name < right_name + end) + return { success = true, data = contacts } end) Bridge.Callbacks.Register("sky_phone:contacts:save", function(source, data) @@ -367,6 +402,16 @@ Bridge.Callbacks.Register("sky_phone:contacts:save", function(source, data) if not name or name == "" or #name > Config.Calls.ContactNameMaxLength or #notes > Config.Calls.ContactNotesMaxLength or #organization > Config.Calls.ContactNameMaxLength or not number then return { success = false, error = "invalid_contact" } end + if (type(data.id) == "string" and data.id:sub(1, 8) == "company:") + or SkyPhoneCompanies.IsSystemContactNumber(number) + then + Bridge.Debug( + "warn", + "[sky_phone] Source %s attempted to modify a read-only company contact.", + tostring(source) + ) + return { success = false, error = "readonly_contact" } + end if avatar_media_id < 0 or avatar_media_id ~= math.floor(avatar_media_id) then return { success = false, error = "invalid_contact" } end @@ -456,6 +501,14 @@ Bridge.Callbacks.Register("sky_phone:contacts:delete", function(source, data) if not scope then return error_response end + if data.id:sub(1, 8) == "company:" then + Bridge.Debug( + "warn", + "[sky_phone] Source %s attempted to delete a read-only company contact.", + tostring(source) + ) + return { success = false, error = "readonly_contact" } + end local condition, values = scope_condition(scope) local params = { data.id } for _, value in ipairs(values) do @@ -485,13 +538,20 @@ Bridge.Callbacks.Register("sky_phone:calls:recents", function(source) return { success = true, data = rows } end) -local function create_terminal_call(scope, number, target_sim, status) +local function create_terminal_call(scope, number, target_sim, status, caller_number) local id = uuid() Bridge.Database.Query([[ INSERT INTO `sky_phone_calls` (`id`, `caller_sim_id`, `callee_sim_id`, `caller_number`, `callee_number`, `status`, `ended_at`) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) - ]], { id, scope.device.sim_id, target_sim and target_sim.id or nil, scope.device.phone_number, number, status }) + ]], { + id, + scope.device.sim_id, + target_sim and target_sim.id or nil, + caller_number or scope.device.phone_number, + number, + status, + }) add_call_entry(id, scope.device, "outgoing", status, number) notify_recents(scope.device, nil) return { @@ -503,6 +563,338 @@ local function create_terminal_call(scope, number, target_sim, status) } end +local function company_call_target(company_id, caller_source, caller_sim_id, excluded_sim_ids) + local has_busy_target = false + for _, candidate in ipairs(SkyPhoneCompanies.GetCallTargets(company_id)) do + local candidate_source = tonumber(candidate.source) + local candidate_sim_id = candidate.simId + local candidate_imei = candidate.imei + if candidate_source and type(candidate_sim_id) == "string" and type(candidate_imei) == "string" + and candidate_source ~= caller_source and candidate_sim_id ~= caller_sim_id + and (not excluded_sim_ids or not excluded_sim_ids[candidate_sim_id]) + then + if active_by_source[candidate_source] or active_by_sim[candidate_sim_id] + or dialing_by_sim[candidate_sim_id] + then + has_busy_target = true + else + dialing_by_sim[candidate_sim_id] = true + local device = SkyPhone.LoadDevice(candidate_imei) + local holder = device and find_device_holder(candidate_imei) or nil + if not device or device.sim_id ~= candidate_sim_id or holder ~= candidate_source then + dialing_by_sim[candidate_sim_id] = nil + elseif SkyPhoneCompanies.IsServiceNumber(device.phone_number) then + dialing_by_sim[candidate_sim_id] = nil + Bridge.Debug( + "error", + "[sky_phone] Company call target %s uses reserved service number %s.", + tostring(candidate_source), + tostring(device.phone_number) + ) + elseif airplane_mode(candidate_imei) then + dialing_by_sim[candidate_sim_id] = nil + else + return { + device = device, + sim_id = candidate_sim_id, + source = candidate_source, + } + end + end + end + end + return nil, has_busy_target and "busy" or "unavailable" +end + +local handle_no_answer + +local function ring_callee(call) + SkyPhone.OpenDeviceForCall(call.callee_source, call.callee_device.imei) + TriggerClientEvent("sky_phone:call:incoming", call.callee_source, { + id = call.id, + state = "ringing", + direction = "incoming", + otherNumber = call.caller_number, + startedAt = call.started_at, + device = { + imei = call.callee_device.imei, + name = call.callee_device.device_name, + }, + }) +end + +local function schedule_no_answer(call) + call.ring_attempt = (call.ring_attempt or 0) + 1 + local ring_attempt = call.ring_attempt + SetTimeout(call.ring_seconds * 1000, function() + local active_call = calls[call.id] + if active_call and not active_call.answered_at and active_call.ring_attempt == ring_attempt then + handle_no_answer(active_call) + end + end) +end + +local function start_ringing_call(call, ring_seconds) + if not call.payphone then + Bridge.Database.Query([[ + INSERT INTO `sky_phone_calls` + (`id`, `caller_sim_id`, `callee_sim_id`, `caller_number`, `callee_number`, `status`) + VALUES (?, ?, ?, ?, ?, 'ringing') + ]], { call.id, call.caller_sim_id, call.callee_sim_id, call.caller_number, call.callee_number }) + add_call_entry(call.id, call.caller_device, "outgoing", "ringing", call.callee_number) + add_call_entry(call.id, call.callee_device, "incoming", "ringing", call.caller_number) + end + + calls[call.id] = call + active_by_source[call.caller_source] = call.id + active_by_source[call.callee_source] = call.id + if call.caller_sim_id then + active_by_sim[call.caller_sim_id] = call.id + dialing_by_sim[call.caller_sim_id] = nil + end + active_by_sim[call.callee_sim_id] = call.id + dialing_by_sim[call.callee_sim_id] = nil + dial_locks[call.caller_source] = nil + + send_state(call, call.caller_source, "ringing") + if call.payphone then + send_payphone_visual(call, "start") + end + call.ring_seconds = math.max(1, math.floor(tonumber(ring_seconds) or Config.Calls.RingSeconds)) + ring_callee(call) + schedule_no_answer(call) + + local result = { + id = call.id, + state = "ringing", + direction = "outgoing", + otherNumber = call.callee_number, + startedAt = call.started_at, + } + if call.payphone then + result.elapsedSeconds = 0 + result.totalCost = 0 + end + return result +end + +reroute_company_call = function(call, previous_status) + if not call.company_service_call or call.company_attempts_remaining <= 0 then + return false + end + if call.rerouting then + return true + end + call.rerouting = true + local next_target = company_call_target( + call.company_id, + call.caller_source, + call.caller_sim_id, + call.company_attempted_sims + ) + if not next_target then + call.rerouting = nil + return false + end + if call.ended or calls[call.id] ~= call then + dialing_by_sim[next_target.sim_id] = nil + call.rerouting = nil + return true + end + + local previous_source = call.callee_source + local previous_sim_id = call.callee_sim_id + local previous_device = call.callee_device + if not call.payphone then + local next_account_id, next_device_imei = scope_for_device(next_target.device) + local updated = Bridge.Database.Transaction({ + { + query = "UPDATE `sky_phone_calls` SET `callee_sim_id` = ? WHERE `id` = ? AND `status` = 'ringing'", + params = { next_target.sim_id, call.id }, + }, + { + query = [[ + UPDATE `sky_phone_call_entries` SET `status` = ? + WHERE `call_id` = ? AND `direction` = 'incoming' AND `status` = 'ringing' + ]], + params = { previous_status, call.id }, + }, + { + query = [[ + INSERT INTO `sky_phone_call_entries` + (`call_id`, `account_id`, `device_imei`, `direction`, `status`, `other_number`) + SELECT `id`, ?, ?, 'incoming', 'ringing', ? + FROM `sky_phone_calls` + WHERE `id` = ? AND `status` = 'ringing' + ]], + params = { next_account_id, next_device_imei, call.caller_number, call.id }, + }, + }) + if not updated then + dialing_by_sim[next_target.sim_id] = nil + call.rerouting = nil + Bridge.Debug( + "error", + "[sky_phone] Could not reroute company call %s to the next target.", + tostring(call.id) + ) + return false + end + if call.ended or calls[call.id] ~= call then + dialing_by_sim[next_target.sim_id] = nil + call.rerouting = nil + return true + end + end + + send_state(call, previous_source, previous_status) + if active_by_source[previous_source] == call.id then + active_by_source[previous_source] = nil + end + if active_by_sim[previous_sim_id] == call.id then + active_by_sim[previous_sim_id] = nil + end + if not call.payphone then + notify_recents(previous_device, previous_source) + end + if call.ended or calls[call.id] ~= call then + dialing_by_sim[next_target.sim_id] = nil + call.rerouting = nil + return true + end + + call.callee_source = next_target.source + call.callee_sim_id = next_target.sim_id + call.callee_device = next_target.device + call.company_attempted_sims[next_target.sim_id] = true + call.company_attempts_remaining = call.company_attempts_remaining - 1 + active_by_source[call.callee_source] = call.id + active_by_sim[call.callee_sim_id] = call.id + dialing_by_sim[call.callee_sim_id] = nil + call.rerouting = nil + ring_callee(call) + schedule_no_answer(call) + return true +end + +handle_no_answer = function(call) + if not reroute_company_call(call, "missed") then + finish_call(call, "no_answer") + end +end + +local function lock_direct_target(caller_source, target, caller_sim_id) + if not target or not target.imei then + return nil, "unavailable" + end + if caller_sim_id then + local blocks = Bridge.Database.Query([[ + SELECT 1 FROM `sky_phone_call_blocks` + WHERE `blocker_sim_id` = ? AND `blocked_sim_id` = ? LIMIT 1 + ]], { target.id, caller_sim_id }) + if blocks[1] then + return nil, "declined" + end + end + local callee_source = find_device_holder(target.imei) + if not callee_source or callee_source == caller_source then + return nil, "unavailable" + end + if active_by_source[callee_source] or active_by_sim[target.id] or dialing_by_sim[target.id] then + return nil, "busy" + end + dialing_by_sim[target.id] = true + if airplane_mode(target.imei) then + dialing_by_sim[target.id] = nil + return nil, "unavailable" + end + return callee_source +end + +function SkyPhoneCalls.StartCompanyCall(source, company_id, customer_number) + source = tonumber(source) + if not source or type(company_id) ~= "string" then + return { success = false, error = "invalid_request" } + end + if not SkyPhoneCompanies.CanPlaceCompanyCall(source, company_id) then + return { success = false, error = "not_authorized" } + end + if not SkyPhone.AllowOperation(source, "company_call_customer", 15, 60) then + return { success = false, error = "rate_limited" } + end + if dial_locks[source] then + return { success = false, error = "busy" } + end + + local service_line = SkyPhoneCompanies.GetServiceLineForCompany(company_id) + if not service_line or not service_line.canCall then + return { success = false, error = "company_unavailable" } + end + local scope, error_response = current_scope(source) + if not scope then + return error_response + end + if not scope.device.sim_id then + return { success = false, error = "no_sim" } + end + if SkyPhoneCompanies.IsServiceNumber(scope.device.phone_number) then + Bridge.Debug( + "error", + "[sky_phone] Source %s attempted to call from a SIM using reserved service number %s.", + tostring(source), + tostring(scope.device.phone_number) + ) + return { success = false, error = "invalid_sim" } + end + if airplane_mode(scope.device.imei) then + return { success = false, error = "airplane_mode" } + end + + local number = SkyPhoneSimNumber.Normalize(customer_number, Config.Sim.NumberLength, Config.Sim.NumberPrefix) + if not number or SkyPhoneCompanies.IsServiceNumber(number) then + return { success = false, error = "invalid_number" } + end + if number == scope.device.phone_number then + return { success = false, error = "self_call" } + end + if active_by_source[source] or active_by_sim[scope.device.sim_id] or dialing_by_sim[scope.device.sim_id] then + return { success = false, error = "busy" } + end + + dial_locks[source] = true + dialing_by_sim[scope.device.sim_id] = true + local targets = Bridge.Database.Query([[ + SELECT s.`id`, s.`phone_number`, d.`imei`, d.`account_id`, d.`device_name` + FROM `sky_phone_sims` s LEFT JOIN `sky_phone_devices` d ON d.`sim_id` = s.`id` + WHERE s.`phone_number` = ? LIMIT 1 + ]], { number }) + local target = targets[1] + local callee_source, target_status = lock_direct_target(source, target, scope.device.sim_id) + if not callee_source then + local terminal = create_terminal_call(scope, number, target, target_status, service_line.number) + dialing_by_sim[scope.device.sim_id] = nil + dial_locks[source] = nil + return { success = true, data = terminal } + end + + return { + success = true, + data = start_ringing_call({ + id = uuid(), + caller_source = source, + caller_sim_id = scope.device.sim_id, + caller_number = service_line.number, + caller_device = scope.device, + callee_source = callee_source, + callee_sim_id = target.id, + callee_number = number, + callee_device = target, + company_id = company_id, + started_at = os.time(), + }, Config.Calls.RingSeconds), + } +end + Bridge.Callbacks.Register("sky_phone:calls:dial", function(source, data) if not SkyPhone.AllowOperation(source, "call_dial", 15, 60) then return { success = false, error = "rate_limited" } @@ -523,6 +915,16 @@ Bridge.Callbacks.Register("sky_phone:calls:dial", function(source, data) dial_locks[source] = nil return { success = false, error = "no_sim" } end + if SkyPhoneCompanies.IsServiceNumber(scope.device.phone_number) then + Bridge.Debug( + "error", + "[sky_phone] Source %s attempted to call from a SIM using reserved service number %s.", + tostring(source), + tostring(scope.device.phone_number) + ) + dial_locks[source] = nil + return { success = false, error = "invalid_sim" } + end if airplane_mode(scope.device.imei) then dial_locks[source] = nil return { success = false, error = "airplane_mode" } @@ -541,6 +943,49 @@ Bridge.Callbacks.Register("sky_phone:calls:dial", function(source, data) return { success = false, error = "busy" } end dialing_by_sim[scope.device.sim_id] = true + local service_line = SkyPhoneCompanies.GetServiceLine(number) + if service_line then + if not service_line.canCall then + local terminal = create_terminal_call(scope, number, nil, "unavailable") + dialing_by_sim[scope.device.sim_id] = nil + dial_locks[source] = nil + return { success = true, data = terminal } + end + local company_target, target_status = company_call_target( + service_line.companyId, + source, + scope.device.sim_id + ) + if not company_target then + local terminal = create_terminal_call(scope, number, nil, target_status) + dialing_by_sim[scope.device.sim_id] = nil + dial_locks[source] = nil + return { success = true, data = terminal } + end + return { + success = true, + data = start_ringing_call({ + id = uuid(), + caller_source = source, + caller_sim_id = scope.device.sim_id, + caller_number = scope.device.phone_number, + caller_device = scope.device, + callee_source = company_target.source, + callee_sim_id = company_target.sim_id, + callee_number = service_line.number, + callee_device = company_target.device, + company_id = service_line.companyId, + company_service_call = service_line.routing == "round_robin", + company_attempted_sims = { [company_target.sim_id] = true }, + company_attempts_remaining = math.max( + 0, + math.floor(tonumber(Config.Companies.CallRouting.MaxAttempts) or 1) - 1 + ), + started_at = os.time(), + }, Config.Companies.CallRouting.RingSeconds), + } + end + local targets = Bridge.Database.Query([[ SELECT s.`id`, s.`phone_number`, d.`imei`, d.`account_id`, d.`device_name` FROM `sky_phone_sims` s LEFT JOIN `sky_phone_devices` d ON d.`sim_id` = s.`id` @@ -552,84 +997,29 @@ Bridge.Callbacks.Register("sky_phone:calls:dial", function(source, data) dial_locks[source] = nil return { success = false, error = "recipient_not_found" } end - if not target.imei then - local terminal = create_terminal_call(scope, number, target, "unavailable") + local callee_source, target_status = lock_direct_target(source, target, scope.device.sim_id) + if not callee_source then + local terminal = create_terminal_call(scope, number, target, target_status) dialing_by_sim[scope.device.sim_id] = nil dial_locks[source] = nil return { success = true, data = terminal } end - local blocks = Bridge.Database.Query([[ - SELECT 1 FROM `sky_phone_call_blocks` - WHERE `blocker_sim_id` = ? AND `blocked_sim_id` = ? LIMIT 1 - ]], { target.id, scope.device.sim_id }) - if blocks[1] then - local terminal = create_terminal_call(scope, number, target, "declined") - dialing_by_sim[scope.device.sim_id] = nil - dial_locks[source] = nil - return { success = true, data = terminal } - end - local callee_source = find_device_holder(target.imei) - if not callee_source or airplane_mode(target.imei) then - local terminal = create_terminal_call(scope, number, target, "unavailable") - dialing_by_sim[scope.device.sim_id] = nil - dial_locks[source] = nil - return { success = true, data = terminal } - end - if active_by_source[callee_source] or active_by_sim[target.id] or dialing_by_sim[target.id] then - local terminal = create_terminal_call(scope, number, target, "busy") - dialing_by_sim[scope.device.sim_id] = nil - dial_locks[source] = nil - return { success = true, data = terminal } - end - dialing_by_sim[target.id] = true - local id = uuid() - local call = { - id = id, - caller_source = source, - caller_sim_id = scope.device.sim_id, - caller_number = scope.device.phone_number, - caller_device = scope.device, - callee_source = callee_source, - callee_sim_id = target.id, - callee_number = number, - callee_device = target, - started_at = os.time(), + return { + success = true, + data = start_ringing_call({ + id = uuid(), + caller_source = source, + caller_sim_id = scope.device.sim_id, + caller_number = scope.device.phone_number, + caller_device = scope.device, + callee_source = callee_source, + callee_sim_id = target.id, + callee_number = number, + callee_device = target, + started_at = os.time(), + }, Config.Calls.RingSeconds), } - Bridge.Database.Query([[ - INSERT INTO `sky_phone_calls` - (`id`, `caller_sim_id`, `callee_sim_id`, `caller_number`, `callee_number`, `status`) - VALUES (?, ?, ?, ?, ?, 'ringing') - ]], { id, call.caller_sim_id, call.callee_sim_id, call.caller_number, call.callee_number }) - add_call_entry(id, scope.device, "outgoing", "ringing", number) - add_call_entry(id, target, "incoming", "ringing", call.caller_number) - calls[id] = call - active_by_source[source] = id - active_by_source[callee_source] = id - active_by_sim[call.caller_sim_id] = id - active_by_sim[call.callee_sim_id] = id - dialing_by_sim[call.caller_sim_id] = nil - dialing_by_sim[call.callee_sim_id] = nil - dial_locks[source] = nil - send_state(call, source, "ringing") - SkyPhone.OpenDeviceForCall(callee_source, target.imei) - TriggerClientEvent("sky_phone:call:incoming", callee_source, { - id = id, - state = "ringing", - direction = "incoming", - otherNumber = call.caller_number, - startedAt = call.started_at, - device = { - imei = target.imei, - name = target.device_name, - }, - }) - SetTimeout(Config.Calls.RingSeconds * 1000, function() - if calls[id] and not calls[id].answered_at then - finish_call(calls[id], "no_answer") - end - end) - return { success = true, data = { id = id, state = "ringing", direction = "outgoing", otherNumber = number, startedAt = call.started_at } } end) local payphone_models = {} @@ -696,89 +1086,96 @@ Bridge.Callbacks.Register("sky_phone:payphone:dial", function(source, data) end dial_locks[source] = true + local service_line = SkyPhoneCompanies.GetServiceLine(number) + if service_line then + if not service_line.canCall then + dial_locks[source] = nil + return { success = true, data = payphone_terminal(number, "unavailable") } + end + local company_target, target_status = company_call_target(service_line.companyId, source, nil) + if not company_target then + dial_locks[source] = nil + return { success = true, data = payphone_terminal(number, target_status) } + end + return { + success = true, + data = start_ringing_call({ + id = uuid(), + caller_source = source, + caller_number = Config.Payphones.CallerNumber, + callee_source = company_target.source, + callee_sim_id = company_target.sim_id, + callee_number = service_line.number, + callee_device = company_target.device, + company_id = service_line.companyId, + company_service_call = service_line.routing == "round_robin", + company_attempted_sims = { [company_target.sim_id] = true }, + company_attempts_remaining = math.max( + 0, + math.floor(tonumber(Config.Companies.CallRouting.MaxAttempts) or 1) - 1 + ), + started_at = os.time(), + payphone = { + elapsed_seconds = 0, + total_cost = 0, + coords = booth_coords, + model = booth_model, + price_per_second = price_per_second, + routing_bucket = GetPlayerRoutingBucket(source), + }, + }, Config.Companies.CallRouting.RingSeconds), + } + end + local targets = Bridge.Database.Query([[ SELECT s.`id`, s.`phone_number`, d.`imei`, d.`account_id`, d.`device_name` FROM `sky_phone_sims` s LEFT JOIN `sky_phone_devices` d ON d.`sim_id` = s.`id` WHERE s.`phone_number` = ? LIMIT 1 ]], { number }) local target = targets[1] - if not target or not target.imei then + local callee_source, target_status = lock_direct_target(source, target) + if not callee_source then dial_locks[source] = nil - return { success = true, data = payphone_terminal(number, "unavailable") } + return { success = true, data = payphone_terminal(number, target_status) } end - local callee_source = find_device_holder(target.imei) - if not callee_source or callee_source == source or airplane_mode(target.imei) then - dial_locks[source] = nil - return { success = true, data = payphone_terminal(number, "unavailable") } - end - if active_by_source[callee_source] or active_by_sim[target.id] or dialing_by_sim[target.id] then - dial_locks[source] = nil - return { success = true, data = payphone_terminal(number, "busy") } - end - dialing_by_sim[target.id] = true - local id = uuid() - local call = { - id = id, - caller_source = source, - caller_number = Config.Payphones.CallerNumber, - callee_source = callee_source, - callee_sim_id = target.id, - callee_number = number, - callee_device = target, - started_at = os.time(), - payphone = { - elapsed_seconds = 0, - total_cost = 0, - coords = booth_coords, - model = booth_model, - price_per_second = price_per_second, - routing_bucket = GetPlayerRoutingBucket(source), - }, - } - calls[id] = call - active_by_source[source] = id - active_by_source[callee_source] = id - active_by_sim[target.id] = id - dialing_by_sim[target.id] = nil - dial_locks[source] = nil - - send_state(call, source, "ringing") - send_payphone_visual(call, "start") - SkyPhone.OpenDeviceForCall(callee_source, target.imei) - TriggerClientEvent("sky_phone:call:incoming", callee_source, { - id = id, - state = "ringing", - direction = "incoming", - otherNumber = call.caller_number, - startedAt = call.started_at, - device = { - imei = target.imei, - name = target.device_name, - }, - }) - SetTimeout(math.max(1, math.floor(tonumber(Config.Payphones.NoAnswerTimeoutSeconds) or 30)) * 1000, function() - if calls[id] and not calls[id].answered_at then - finish_call(calls[id], "no_answer") - end - end) return { success = true, - data = { - id = id, - state = "ringing", - direction = "outgoing", - otherNumber = number, - startedAt = call.started_at, - elapsedSeconds = 0, - totalCost = 0, - }, + data = start_ringing_call({ + id = uuid(), + caller_source = source, + caller_number = Config.Payphones.CallerNumber, + callee_source = callee_source, + callee_sim_id = target.id, + callee_number = number, + callee_device = target, + started_at = os.time(), + payphone = { + elapsed_seconds = 0, + total_cost = 0, + coords = booth_coords, + model = booth_model, + price_per_second = price_per_second, + routing_bucket = GetPlayerRoutingBucket(source), + }, + }, Config.Payphones.NoAnswerTimeoutSeconds), } end) Bridge.Callbacks.Register("sky_phone:calls:answer", function(source, data) local call = type(data) == "table" and calls[data.id] or nil - if not call or call.callee_source ~= source or call.answered_at then + if not call or call.callee_source ~= source or call.answered_at or call.rerouting then + return { success = false, error = "call_not_found" } + end + if call.company_service_call and not SkyPhoneCompanies.CanAnswerCompanyCall( + source, + call.company_id, + call.callee_device.imei, + call.callee_sim_id + ) then + if not reroute_company_call(call, "missed") then + finish_call(call, "unavailable") + end return { success = false, error = "call_not_found" } end if not SkyPhone.FindDeviceSlots(source, call.callee_device.imei)[1] then @@ -792,8 +1189,17 @@ Bridge.Callbacks.Register("sky_phone:calls:answer", function(source, data) call.channel = next_voice_channel next_voice_channel = next_voice_channel + 1 if not call.payphone then - Bridge.Database.Query("UPDATE `sky_phone_calls` SET `status` = 'connected', `answered_at` = CURRENT_TIMESTAMP WHERE `id` = ?", { call.id }) - Bridge.Database.Query("UPDATE `sky_phone_call_entries` SET `status` = 'connected' WHERE `call_id` = ?", { call.id }) + Bridge.Database.Query([[ + UPDATE `sky_phone_calls` SET `status` = 'connected', `answered_at` = CURRENT_TIMESTAMP + WHERE `id` = ? AND `status` = 'ringing' + ]], { call.id }) + Bridge.Database.Query([[ + UPDATE `sky_phone_call_entries` SET `status` = 'connected' + WHERE `call_id` = ? AND `status` = 'ringing' + ]], { call.id }) + end + if call.ended or calls[call.id] ~= call then + return { success = false, error = "call_not_found" } end send_state(call, call.caller_source, "connected", call.channel) send_state(call, call.callee_source, "connected", call.channel) @@ -802,10 +1208,12 @@ end) Bridge.Callbacks.Register("sky_phone:calls:decline", function(source, data) local call = type(data) == "table" and calls[data.id] or nil - if not call or call.callee_source ~= source or call.answered_at then + if not call or call.callee_source ~= source or call.answered_at or call.rerouting then return { success = false, error = "call_not_found" } end - finish_call(call, "declined") + if not reroute_company_call(call, "declined") then + finish_call(call, "declined") + end return { success = true } end) @@ -817,6 +1225,15 @@ Bridge.Callbacks.Register("sky_phone:calls:hangup", function(source, data) then return { success = false, error = "call_not_found" } end + if call.company_service_call and not call.answered_at and call.callee_source == source then + if call.rerouting then + return { success = false, error = "call_not_found" } + end + if not reroute_company_call(call, "declined") then + finish_call(call, "declined") + end + return { success = true } + end finish_call(call, call.answered_at and "completed" or "cancelled") return { success = true } end) @@ -896,8 +1313,16 @@ CreateThread(function() end local callee_valid = not call.callee_source or SkyPhone.FindDeviceSlots(call.callee_source, call.callee_device.imei)[1] ~= nil - if not caller_valid or not callee_valid then + if not caller_valid then calls_to_finish[call_id] = "disconnected" + elseif not callee_valid then + if call.company_service_call and not call.answered_at then + if not reroute_company_call(call, "missed") then + calls_to_finish[call_id] = "unavailable" + end + else + calls_to_finish[call_id] = "disconnected" + end elseif call.payphone and call.answered_at and not call.ended then local elapsed_seconds = math.max(0, os.time() - call.answered_at) local total_cost = elapsed_seconds * call.payphone.price_per_second @@ -921,9 +1346,17 @@ end) AddEventHandler("playerDropped", function() local call_id = active_by_source[source] - if call_id then - finish_call(calls[call_id], "disconnected") + local call = call_id and calls[call_id] or nil + if not call then + return end + if call.callee_source == source and call.company_service_call and not call.answered_at then + if not reroute_company_call(call, "missed") then + finish_call(call, "unavailable") + end + return + end + finish_call(call, "disconnected") end) AddEventHandler("onResourceStop", function(resource_name) diff --git a/sky_phone/source/server/companies.lua b/sky_phone/source/server/companies.lua new file mode 100644 index 0000000..3a18a55 --- /dev/null +++ b/sky_phone/source/server/companies.lua @@ -0,0 +1,3028 @@ +Bridge.Database.AfterMigration("sky_phone", function() +SkyPhoneCompanies = {} + +local definitions = {} +local definition_ids = {} +local definitions_by_job = {} +local service_lines_by_number = {} +local call_availability = {} +local round_robin_positions = {} +local member_tokens = {} +local terminal_statuses = { + completed = true, + cancelled = true, +} +local status_transitions = { + new = { cancelled = true }, + assigned = { in_progress = true, cancelled = true }, + in_progress = { waiting_customer = true, completed = true, cancelled = true }, + waiting_customer = { in_progress = true, cancelled = true }, +} + +local function uuid() + local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {}) + if not rows[1] or type(rows[1].id) ~= "string" then + error("[sky_phone] Database did not generate a Companies UUID.") + end + return rows[1].id +end + +local function trim(value) + if type(value) ~= "string" then + return nil + end + return value:match("^%s*(.-)%s*$") +end + +local function valid_text(value, maximum, allow_empty) + local text = trim(value) + if not text or text:find("%z") then + return nil + end + local length = utf8.len(text) + if not length or length > maximum or (not allow_empty and length == 0) then + return nil + end + return text +end + +local function valid_integer(value, minimum, maximum) + local number = tonumber(value) + if not number or number ~= number or number < minimum or number > maximum + or number ~= math.floor(number) + then + return nil + end + return number +end + +local function valid_uuid(value) + return type(value) == "string" + and value:match("^%x%x%x%x%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%x%x%x%x%x%x%x%x$") ~= nil +end + +local function valid_service_id(value) + return type(value) == "string" + and #value >= 1 + and #value <= 64 + and value:match("^[%w_-]+$") ~= nil +end + +local function valid_array(value, maximum) + if type(value) ~= "table" or #value > maximum then + return false + end + local count = 0 + for key in pairs(value) do + if type(key) ~= "number" or key < 1 or key ~= math.floor(key) or key > #value then + return false + end + count = count + 1 + end + return count == #value +end + +local function iso_time(value) + local seconds = tonumber(value) + if not seconds or seconds <= 0 then + return nil + end + return os.date("!%Y-%m-%dT%H:%M:%SZ", seconds) +end + +local function days_from_civil(year, month, day) + year = year - (month <= 2 and 1 or 0) + local era = math.floor(year / 400) + local year_of_era = year - era * 400 + local adjusted_month = month + (month > 2 and -3 or 9) + local day_of_year = math.floor((153 * adjusted_month + 2) / 5) + day - 1 + local day_of_era = year_of_era * 365 + math.floor(year_of_era / 4) + - math.floor(year_of_era / 100) + day_of_year + return era * 146097 + day_of_era - 719468 +end + +local function utc_epoch(year, month, day, hour, minute, second) + if year < 1970 or year > 2100 or month < 1 or month > 12 + or day < 1 or hour < 0 or hour > 23 or minute < 0 or minute > 59 + or second < 0 or second > 59 + then + return nil + end + local month_lengths = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 } + if year % 400 == 0 or (year % 4 == 0 and year % 100 ~= 0) then + month_lengths[2] = 29 + end + if day > month_lengths[month] then + return nil + end + return days_from_civil(year, month, day) * 86400 + hour * 3600 + minute * 60 + second +end + +local function parse_expiry(value, maximum_seconds) + if value == nil or value == "" then + return nil + end + if type(value) ~= "string" or #value > 40 then + return false + end + local year, month, day, hour, minute, second = value:match( + "^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)Z$" + ) + if not year then + year, month, day, hour, minute, second = value:match( + "^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)%.%d+Z$" + ) + end + if not year then + return false + end + local epoch = utc_epoch( + tonumber(year), + tonumber(month), + tonumber(day), + tonumber(hour), + tonumber(minute), + tonumber(second) + ) + local now = os.time() + if not epoch or epoch <= now or epoch - now > maximum_seconds then + return false + end + return epoch +end + +local function permission_grade(definition, permission) + local grade = definition.Permissions and tonumber(definition.Permissions[permission]) + return grade and math.max(0, math.floor(grade)) or nil +end + +local function validate_configuration() + if type(Config.Companies) ~= "table" or type(Config.Companies.Definitions) ~= "table" then + error("[sky_phone] Config.Companies.Definitions must be configured.") + end + if type(Config.Companies.Enabled) ~= "boolean" then + error("[sky_phone] Config.Companies.Enabled must be a boolean.") + end + for _, field in ipairs({ + { "PageSize", 1000 }, + { "MaximumPageSize", 1000 }, + { "MaximumOpenRequestsPerSim", 1000 }, + { "MaximumServices", 255 }, + { "MaximumRequestMedia", 255 }, + { "SubjectMaxLength", 120 }, + { "RequestBodyMaxLength", 2000 }, + { "MessageMaxLength", 2000 }, + { "ProfileDescriptionMaxLength", 1000 }, + { "DistrictMaxLength", 80 }, + { "AddressMaxLength", 160 }, + { "ServiceTitleMaxLength", 80 }, + { "ServiceDescriptionMaxLength", 500 }, + { "ServicePriceMaxLength", 80 }, + { "AnnouncementTitleMaxLength", 120 }, + { "AnnouncementBodyMaxLength", 1000 }, + { "AvailabilityMaximumSeconds", 31536000 }, + { "AnnouncementMaximumSeconds", 31536000 }, + { "RetentionDays", 36500 }, + }) do + if not valid_integer(Config.Companies[field[1]], 1, field[2]) then + error(("[sky_phone] Config.Companies.%s is outside its supported range."):format(field[1])) + end + end + if Config.Companies.PageSize > Config.Companies.MaximumPageSize then + error("[sky_phone] Companies PageSize cannot exceed MaximumPageSize.") + end + if type(Config.Companies.RateLimits) ~= "table" then + error("[sky_phone] Config.Companies.RateLimits must be configured.") + end + for _, name in ipairs({ "Read", "Search", "CreateRequest", "Message", "RequestAction", "Profile", "CallAvailability" }) do + if not valid_integer(Config.Companies.RateLimits[name], 1, 100000) then + error(("[sky_phone] Companies rate limit '%s' is invalid."):format(name)) + end + end + if type(Config.Companies.CallRouting) ~= "table" + or not valid_integer(Config.Companies.CallRouting.MaxAttempts, 1, 20) + or not valid_integer(Config.Companies.CallRouting.RingSeconds, 1, 120) + then + error("[sky_phone] Config.Companies.CallRouting is invalid.") + end + local configured_statuses = { + new = true, + assigned = true, + in_progress = true, + waiting_customer = true, + completed = true, + cancelled = true, + } + if type(Config.Companies.Statuses) ~= "table" then + error("[sky_phone] Config.Companies.Statuses must be configured.") + end + for status in pairs(configured_statuses) do + if Config.Companies.Statuses[status] ~= true then + error(("[sky_phone] Companies status '%s' must be enabled."):format(status)) + end + end + for status, enabled in pairs(Config.Companies.Statuses) do + if not configured_statuses[status] or enabled ~= true then + error(("[sky_phone] Companies status '%s' is unsupported."):format(tostring(status))) + end + end + if type(Config.Companies.AvailabilityStatuses) ~= "table" then + error("[sky_phone] Config.Companies.AvailabilityStatuses must be configured.") + end + local configured_availability = { available = true, busy = true, closed = true } + for _, status in ipairs({ "available", "busy", "closed" }) do + if Config.Companies.AvailabilityStatuses[status] ~= true then + error(("[sky_phone] Companies availability status '%s' must be enabled."):format(status)) + end + end + for status, enabled in pairs(Config.Companies.AvailabilityStatuses) do + if not configured_availability[status] or enabled ~= true then + error(("[sky_phone] Companies availability status '%s' is unsupported."):format(tostring(status))) + end + end + if not valid_array(Config.Companies.Categories, 100) then + error("[sky_phone] Config.Companies.Categories must be a bounded array.") + end + local category_ids = {} + local configured_service_ids = {} + for _, category_id in ipairs(Config.Companies.Categories) do + if type(category_id) ~= "string" or #category_id > 64 + or not category_id:match("^[a-z0-9_-]+$") or category_ids[category_id] + then + error("[sky_phone] Companies contains an invalid category ID.") + end + category_ids[category_id] = true + end + + for company_id, definition in pairs(Config.Companies.Definitions) do + if type(company_id) ~= "string" or #company_id > 64 or not company_id:match("^[a-z0-9_-]+$") + or type(definition) ~= "table" + or type(definition.Job) ~= "string" or #definition.Job > 64 + or not definition.Job:match("^[%w_-]+$") + or not valid_text(definition.Name, 120, false) + or not category_ids[definition.Category] + then + error(("[sky_phone] Company definition '%s' is invalid."):format(tostring(company_id))) + end + local description = valid_text(definition.Description or "", Config.Companies.ProfileDescriptionMaxLength, true) + local district = valid_text(definition.District or "", Config.Companies.DistrictMaxLength, true) + local location_label = valid_text( + definition.LocationLabel or definition.Address or "", + Config.Companies.DistrictMaxLength, + true + ) + local address = valid_text(definition.Address or "", Config.Companies.AddressMaxLength, true) + if not description or not district or not location_label or not address + or type(definition.Public) ~= "boolean" or type(definition.Emergency) ~= "boolean" + or type(definition.Verified) ~= "boolean" or type(definition.AcceptsRequests) ~= "boolean" + or not Config.Companies.AvailabilityStatuses[definition.DefaultAvailability] + or not valid_text(definition.Icon, 64, false) + or (definition.Emergency and definition.AcceptsRequests) + then + error(("[sky_phone] Company definition '%s' has invalid public profile defaults."):format(company_id)) + end + definition.Name = trim(definition.Name) + definition.Description = description + definition.District = district + definition.LocationLabel = location_label + definition.Address = address + if definition.Location ~= nil then + local location_type = type(definition.Location) + if location_type ~= "table" and location_type ~= "vector3" then + error(("[sky_phone] Company definition '%s' has invalid location coordinates."):format(company_id)) + end + local x = tonumber(definition.Location.x) + local y = tonumber(definition.Location.y) + local z = tonumber(definition.Location.z) + if not x or not y or not z or x ~= x or y ~= y or z ~= z + or math.abs(x) > 10000 or math.abs(y) > 10000 or math.abs(z) > 2000 + then + error(("[sky_phone] Company definition '%s' has invalid location coordinates."):format(company_id)) + end + end + if definitions_by_job[definition.Job] then + error(("[sky_phone] Framework job '%s' is assigned to more than one company."):format(definition.Job)) + end + local line = definition.ServiceLine + if type(line) ~= "table" then + error(("[sky_phone] Company '%s' has no service line configuration."):format(company_id)) + end + local number = SkyPhoneSimNumber.Normalize(line.Number, Config.Sim.NumberLength, Config.Sim.NumberPrefix) + if not number then + error(("[sky_phone] Company '%s' has an invalid service number."):format(company_id)) + end + if service_lines_by_number[number] then + error(("[sky_phone] Service number '%s' is assigned more than once."):format(number)) + end + if type(line.AutoContact) ~= "boolean" or type(line.CanCall) ~= "boolean" + or type(line.CanMessage) ~= "boolean" + or not valid_integer(line.MinimumGrade, 0, 10000) + then + error(("[sky_phone] Company '%s' has invalid service line flags or grade."):format(company_id)) + end + if line.AutoContact and not definition.Public then + error(("[sky_phone] Private company '%s' cannot create a public system contact."):format(company_id)) + end + if line.Routing ~= "round_robin" then + error(("[sky_phone] Company '%s' uses unsupported call routing '%s'."):format(company_id, tostring(line.Routing))) + end + if line.CanMessage then + error(("[sky_phone] Company '%s' enables messaging without a virtual service-line message router."):format(company_id)) + end + line.Number = number + definitions[company_id] = definition + definition_ids[#definition_ids + 1] = company_id + definitions_by_job[definition.Job] = company_id + service_lines_by_number[number] = company_id + for _, permission in ipairs({ "WorkQueue", "Availability", "Assign", "Profile", "Hours", "Services", "Announcement" }) do + if not definition.Permissions or not valid_integer(definition.Permissions[permission], 0, 10000) then + error(("[sky_phone] Company '%s' has no valid '%s' grade."):format(company_id, permission)) + end + end + local default_services = definition.Services + if default_services == nil then + default_services = {} + definition.Services = default_services + end + if not valid_array(default_services, Config.Companies.MaximumServices) then + error(("[sky_phone] Company '%s' has an invalid default service list."):format(company_id)) + end + for _, service in ipairs(default_services) do + if type(service) ~= "table" then + error(("[sky_phone] Company '%s' has an invalid default service."):format(company_id)) + end + local title = valid_text(service.Title, Config.Companies.ServiceTitleMaxLength, false) + local service_description = valid_text( + service.Description or "", + Config.Companies.ServiceDescriptionMaxLength, + true + ) + local price = valid_text(service.Price or "", Config.Companies.ServicePriceMaxLength, true) + if not valid_service_id(service.Id) or not title or not service_description or not price + or type(service.RequestsEnabled) ~= "boolean" + then + error(("[sky_phone] Company '%s' has an invalid default service."):format(company_id)) + end + service.Title = title + service.Description = service_description + service.Price = price + if configured_service_ids[service.Id] then + error(("[sky_phone] Default company service ID '%s' is configured more than once."):format(service.Id)) + end + configured_service_ids[service.Id] = true + end + end + + table.sort(definition_ids, function(left, right) + local left_name = definitions[left].Name:lower() + local right_name = definitions[right].Name:lower() + return left_name == right_name and left < right or left_name < right_name + end) +end + +local function seed_companies() + if #definition_ids > 0 then + local placeholders = {} + local numbers = {} + for index, company_id in ipairs(definition_ids) do + placeholders[index] = "?" + numbers[index] = definitions[company_id].ServiceLine.Number + end + local collisions = Bridge.Database.Query(([[ + SELECT `phone_number` FROM `sky_phone_sims` + WHERE `phone_number` IN (%s) + LIMIT 1 + ]]):format(table.concat(placeholders, ", ")), numbers) + if collisions[1] then + error(("[sky_phone] Company service number '%s' collides with an existing SIM."):format( + tostring(collisions[1].phone_number) + )) + end + end + + for _, company_id in ipairs(definition_ids) do + local definition = definitions[company_id] + local location = definition.Location + if location then + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_company_profiles` + (`company_id`, `description`, `district`, `location_label`, `address`, + `location_x`, `location_y`, `location_z`, `availability`, `accepts_requests`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ]], { + company_id, + definition.Description or "", + definition.District or "", + definition.LocationLabel or definition.Address or "", + definition.Address or "", + location.x, + location.y, + location.z, + definition.DefaultAvailability or "closed", + definition.AcceptsRequests and 1 or 0, + }) + else + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_company_profiles` + (`company_id`, `description`, `district`, `location_label`, `address`, + `availability`, `accepts_requests`) + VALUES (?, ?, ?, ?, ?, ?, ?) + ]], { + company_id, + definition.Description or "", + definition.District or "", + definition.LocationLabel or definition.Address or "", + definition.Address or "", + definition.DefaultAvailability or "closed", + definition.AcceptsRequests and 1 or 0, + }) + end + for index, service in ipairs(definition.Services or {}) do + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_company_services` + (`id`, `company_id`, `title`, `description`, `price_text`, `requests_enabled`, `active`, `sort_order`) + VALUES (?, ?, ?, ?, ?, ?, 1, ?) + ]], { + service.Id, + company_id, + service.Title, + service.Description or "", + service.Price or "", + service.RequestsEnabled and 1 or 0, + index, + }) + local seeded = Bridge.Database.Query( + "SELECT `company_id` FROM `sky_phone_company_services` WHERE `id` = ? LIMIT 1", + { service.Id } + ) + if not seeded[1] or seeded[1].company_id ~= company_id then + error(("[sky_phone] Default service ID '%s' collides with another company."):format(service.Id)) + end + end + end + +end + +local function tombstone_removed_companies() + local profiles = Bridge.Database.Query([[ + SELECT DISTINCT profile.`company_id` + FROM `sky_phone_company_profiles` profile + INNER JOIN `sky_phone_company_requests` request ON request.`company_id` = profile.`company_id` + WHERE request.`status` NOT IN ('completed', 'cancelled') + ]], {}) + for _, profile in ipairs(profiles) do + if not definitions[profile.company_id] then + local mutation_token = uuid() + if not Bridge.Database.Transaction({ + { + query = [[ + UPDATE `sky_phone_company_requests` + SET `status` = 'cancelled', `revision` = `revision` + 1, + `customer_unread` = `customer_unread` + 1, + `cancelled_at` = CURRENT_TIMESTAMP, `mutation_token` = ? + WHERE `company_id` = ? AND `status` NOT IN ('completed', 'cancelled') + ]], + params = { mutation_token, profile.company_id }, + }, + { + query = [[ + INSERT INTO `sky_phone_company_request_events` + (`id`, `request_id`, `event_type`, `actor_type`, `to_status`, `detail`) + SELECT UUID(), `id`, 'cancelled', 'system', 'cancelled', 'company_removed' + FROM `sky_phone_company_requests` + WHERE `company_id` = ? AND `mutation_token` = ? + ]], + params = { profile.company_id, mutation_token }, + }, + }) then + error(("[sky_phone] Could not tombstone removed company '%s'."):format( + tostring(profile.company_id) + )) + end + end + end +end + +local function service_line_payload(company_id) + local definition = definitions[company_id] + if not Config.Companies.Enabled or not definition then + return nil + end + local line = definition.ServiceLine + return { + companyId = company_id, + number = line.Number, + name = definition.Name, + canCall = line.CanCall == true, + canMessage = line.CanMessage == true, + autoContact = line.AutoContact == true, + routing = line.Routing, + icon = definition.Icon, + } +end + +function SkyPhoneCompanies.GetServiceLine(number) + local normalized = SkyPhoneSimNumber.Normalize(number, Config.Sim.NumberLength, Config.Sim.NumberPrefix) + return normalized and service_line_payload(service_lines_by_number[normalized]) or nil +end + +function SkyPhoneCompanies.GetServiceLineForCompany(company_id) + return type(company_id) == "string" and service_line_payload(company_id) or nil +end + +function SkyPhoneCompanies.IsServiceNumber(number) + local normalized = SkyPhoneSimNumber.Normalize(number, Config.Sim.NumberLength, Config.Sim.NumberPrefix) + return normalized ~= nil and service_lines_by_number[normalized] ~= nil +end + +function SkyPhoneCompanies.IsSystemContactNumber(number) + if not Config.Companies.Enabled then + return false + end + local normalized = SkyPhoneSimNumber.Normalize(number, Config.Sim.NumberLength, Config.Sim.NumberPrefix) + local company_id = normalized and service_lines_by_number[normalized] or nil + local definition = company_id and definitions[company_id] or nil + return definition ~= nil and definition.Public == true and definition.ServiceLine.AutoContact == true +end + +function SkyPhoneCompanies.GetSystemContacts() + local contacts = {} + if not Config.Companies.Enabled then + return contacts + end + for _, company_id in ipairs(definition_ids) do + local definition = definitions[company_id] + local line = definition.ServiceLine + if definition.Public and line.AutoContact then + contacts[#contacts + 1] = { + id = "company:" .. company_id, + companyId = company_id, + name = definition.Name, + phone_number = line.Number, + source = "company", + readonly = true, + canCall = line.CanCall == true, + canMessage = line.CanMessage == true, + verified = definition.Verified == true, + icon = definition.Icon, + } + end + end + return contacts +end + +function SkyPhoneCompanies.ClearCallAvailability(source) + call_availability[tonumber(source) or source] = nil +end + +local function current_device(source, registered_required) + local session, error_response = SkyPhone.RequireSession(source) + if not session then + return nil, error_response + end + local device = SkyPhone.LoadDevice(session.imei) + if not device then + return nil, { success = false, error = "request_failed" } + end + if not device.sim_id then + return nil, { success = false, error = "no_sim" } + end + if registered_required and (device.sim_type ~= "registered" or not device.registered_at) then + return nil, { success = false, error = "anonymous_sim" } + end + return device +end + +local function membership(source) + local job = Bridge.Framework.GetJob(source) + local company_id = definitions_by_job[job.name] + if not company_id then + return nil + end + return { + company_id = company_id, + definition = definitions[company_id], + grade = tonumber(job.grade) or 0, + grade_label = job.gradeLabel or job.label or "", + } +end + +local function require_permission(source, permission) + local member = membership(source) + if not member then + return nil, { success = false, error = "not_authorized" } + end + local minimum = permission_grade(member.definition, permission) + if not minimum or member.grade < minimum then + return nil, { success = false, error = "not_authorized" } + end + local identifier = Bridge.Framework.GetIdentifier(source) + if type(identifier) ~= "string" or identifier == "" then + return nil, { success = false, error = "not_authorized" } + end + member.identifier = identifier + return member +end + +local function call_member(source) + local member = membership(source) + if not member then + return nil + end + local minimum = tonumber(member.definition.ServiceLine.MinimumGrade) or 0 + if member.grade < minimum then + return nil + end + return member +end + +function SkyPhoneCompanies.CanAnswerCompanyCall(source, company_id, imei, sim_id) + source = tonumber(source) + if not Config.Companies.Enabled or not source or type(company_id) ~= "string" + or type(imei) ~= "string" or type(sim_id) ~= "string" + then + return false + end + local readiness = call_availability[source] + local member = readiness and call_member(source) or nil + return member ~= nil + and member.company_id == company_id + and member.definition.ServiceLine.CanCall == true + and readiness.company_id == company_id + and readiness.imei == imei + and readiness.sim_id == sim_id +end + +function SkyPhoneCompanies.CanPlaceCompanyCall(source, company_id) + source = tonumber(source) + if not Config.Companies.Enabled or not source or type(company_id) ~= "string" then + return false + end + local member = call_member(source) + return member ~= nil and member.company_id == company_id + and member.definition.ServiceLine.CanCall == true +end + +function SkyPhoneCompanies.GetCallTargets(company_id) + local targets = {} + local definition = definitions[company_id] + if not Config.Companies.Enabled or not definition or not definition.ServiceLine.CanCall then + return targets + end + local online = {} + for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do + online[tonumber(player_source) or player_source] = true + end + for source, readiness in pairs(call_availability) do + local member = online[source] and call_member(source) or nil + local device = member and current_device(source, true) or nil + if not member or not device or member.company_id ~= readiness.company_id + or readiness.company_id ~= company_id or readiness.imei ~= device.imei + or readiness.sim_id ~= device.sim_id + then + if not member or not device or member.company_id ~= readiness.company_id + or readiness.imei ~= (device and device.imei) + or readiness.sim_id ~= (device and device.sim_id) + then + call_availability[source] = nil + end + else + targets[#targets + 1] = { + source = source, + simId = device.sim_id, + phoneNumber = device.phone_number, + imei = device.imei, + deviceName = device.device_name, + } + end + end + table.sort(targets, function(left, right) + return left.source < right.source + end) + if #targets < 2 then + return targets + end + local start = (round_robin_positions[company_id] or 0) % #targets + 1 + local ordered = {} + for offset = 0, #targets - 1 do + ordered[#ordered + 1] = targets[(start + offset - 1) % #targets + 1] + end + round_robin_positions[company_id] = start + return ordered +end + +local function profile_row(company_id) + local rows = Bridge.Database.Query([[ + SELECT p.`company_id`, p.`description`, p.`district`, p.`location_label`, p.`address`, + p.`location_x`, p.`location_y`, p.`location_z`, p.`availability`, + UNIX_TIMESTAMP(p.`availability_updated_at`) AS `availability_updated_at_unix`, + UNIX_TIMESTAMP(p.`availability_expires_at`) AS `availability_expires_at_unix`, + p.`logo_media_id`, p.`cover_media_id`, p.`accepts_requests`, p.`revision`, + UNIX_TIMESTAMP(p.`updated_at`) AS `updated_at_unix`, + logo.`url` AS `logo_url`, cover.`url` AS `cover_url` + FROM `sky_phone_company_profiles` p + LEFT JOIN `sky_phone_media` logo ON logo.`id` = p.`logo_media_id` + LEFT JOIN `sky_phone_media` cover ON cover.`id` = p.`cover_media_id` + WHERE p.`company_id` = ? + LIMIT 1 + ]], { company_id }) + return rows[1] +end + +local function company_services(company_id, include_inactive) + local condition = include_inactive and " AND `archived` = 0" + or " AND `archived` = 0 AND `active` = 1" + local rows = Bridge.Database.Query(([[ + SELECT `id`, `title`, `description`, `price_text`, `requests_enabled`, `active` + FROM `sky_phone_company_services` + WHERE `company_id` = ?%s + ORDER BY `sort_order`, `title`, `id` + ]]):format(condition), { company_id }) + local services = {} + for _, row in ipairs(rows) do + services[#services + 1] = { + id = row.id, + title = row.title, + description = row.description, + priceText = row.price_text ~= "" and row.price_text or nil, + acceptsRequests = tonumber(row.requests_enabled) == 1, + active = tonumber(row.active) == 1, + } + end + return services +end + +local function company_hours(company_id) + local rows = Bridge.Database.Query([[ + SELECT `weekday`, `is_closed`, `opens_at`, `closes_at` + FROM `sky_phone_company_hours` + WHERE `company_id` = ? + ORDER BY `weekday` + ]], { company_id }) + local hours = {} + for _, row in ipairs(rows) do + hours[#hours + 1] = { + day = tonumber(row.weekday), + isClosed = tonumber(row.is_closed) == 1, + opensAt = row.opens_at, + closesAt = row.closes_at, + } + end + return hours +end + +local function current_announcement(company_id) + local rows = Bridge.Database.Query([[ + SELECT `body`, UNIX_TIMESTAMP(`expires_at`) AS `expires_at_unix`, + UNIX_TIMESTAMP(`created_at`) AS `created_at_unix` + FROM `sky_phone_company_announcements` + WHERE `company_id` = ? AND `active` = 1 + AND (`expires_at` IS NULL OR `expires_at` > CURRENT_TIMESTAMP) + ORDER BY `created_at` DESC + LIMIT 1 + ]], { company_id }) + local row = rows[1] + return row and { + body = row.body, + expiresAt = iso_time(row.expires_at_unix), + publishedAt = iso_time(row.created_at_unix), + } or nil +end + +local function company_payload(company_id, include_inactive_services) + local definition = definitions[company_id] + local row = definition and profile_row(company_id) or nil + if not definition or not row then + return nil + end + local services = company_services(company_id, include_inactive_services) + local availability = row.availability + local availability_expires = tonumber(row.availability_expires_at_unix) + if availability_expires and availability_expires <= os.time() then + availability = definition.DefaultAvailability or "closed" + end + local location = nil + local x = tonumber(row.location_x) + local y = tonumber(row.location_y) + local z = tonumber(row.location_z) + if x and y and z then + location = { + address = row.address, + district = row.district, + label = row.location_label, + coords = { x = x, y = y, z = z }, + } + end + local line = definition.ServiceLine + return { + id = company_id, + name = definition.Name, + categoryId = definition.Category, + categoryName = definition.Category, + verified = definition.Verified == true, + description = row.description, + availability = availability, + availabilityUpdatedAt = iso_time(row.availability_updated_at_unix) + or iso_time(row.updated_at_unix), + acceptsRequests = tonumber(row.accepts_requests) == 1 and not definition.Emergency, + phoneNumber = line and line.Number or nil, + canCall = line and line.CanCall == true or false, + canMessage = line and line.CanMessage == true or false, + location = location, + logoUrl = row.logo_url, + coverUrl = row.cover_url, + serviceSummary = services[1] and services[1].title or "", + announcement = current_announcement(company_id), + services = services, + hours = company_hours(company_id), + revision = tonumber(row.revision) or 1, + updatedAtUnix = tonumber(row.updated_at_unix) or 0, + } +end + +local function public_company(company_id) + local definition = definitions[company_id] + if not definition or not definition.Public then + return nil + end + return company_payload(company_id, false) +end + +local function category_payloads() + local categories = {} + for _, category_id in ipairs(Config.Companies.Categories or {}) do + categories[#categories + 1] = { id = category_id, name = category_id } + end + return categories +end + +local function allow_read(source, operation) + if not Config.Companies.Enabled then + return nil, { success = false, error = "service_unavailable" } + end + local limit = Config.Companies.RateLimits[operation] or Config.Companies.RateLimits.Read + if not SkyPhone.AllowOperation(source, "companies_" .. operation:lower(), limit, 60) then + return nil, { success = false, error = "rate_limited" } + end + local session, error_response = SkyPhone.RequireSession(source) + if not session then + return nil, error_response + end + return session +end + +local function cleanup_retained_data() + local cutoff = os.time() - Config.Companies.RetentionDays * 86400 + local success = Bridge.Database.Transaction({ + { + query = [[ + DELETE FROM `sky_phone_company_requests` + WHERE `status` IN ('completed', 'cancelled') + AND COALESCE(`completed_at`, `cancelled_at`, `updated_at`) < FROM_UNIXTIME(?) + ]], + params = { cutoff }, + }, + { + query = [[ + DELETE FROM `sky_phone_company_audit` + WHERE `created_at` < FROM_UNIXTIME(?) + ]], + params = { cutoff }, + }, + { + query = [[ + DELETE FROM `sky_phone_company_announcements` + WHERE (`active` = 0 OR `expires_at` <= CURRENT_TIMESTAMP) + AND `created_at` < FROM_UNIXTIME(?) + ]], + params = { cutoff }, + }, + }) + if not success then + print("[sky_phone] Companies retention cleanup failed.") + end +end + +validate_configuration() +seed_companies() +tombstone_removed_companies() +cleanup_retained_data() + +CreateThread(function() + while true do + Wait(24 * 60 * 60 * 1000) + cleanup_retained_data() + end +end) + +CreateThread(function() + while true do + Wait(1000) + local online = {} + for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do + online[tonumber(player_source) or player_source] = true + end + for source, readiness in pairs(call_availability) do + local member = online[source] and call_member(source) or nil + if not member or member.company_id ~= readiness.company_id + or member.definition.ServiceLine.CanCall ~= true + then + call_availability[source] = nil + end + end + end +end) + +local function company_summary(company) + return { + id = company.id, + name = company.name, + categoryId = company.categoryId, + categoryName = company.categoryName, + verified = company.verified, + description = company.description, + availability = company.availability, + availabilityUpdatedAt = company.availabilityUpdatedAt, + acceptsRequests = company.acceptsRequests, + phoneNumber = company.phoneNumber, + canCall = company.canCall, + canMessage = company.canMessage, + location = company.location, + logoUrl = company.logoUrl, + serviceSummary = company.serviceSummary, + announcement = company.announcement, + } +end + +local function search_score(company, search) + if search == "" then + return 0 + end + local name = company.name:lower() + if name:sub(1, #search) == search then + return 4 + end + if name:find(search, 1, true) then + return 3 + end + local values = { + company.categoryId, + company.description, + company.location and company.location.district or "", + company.location and company.location.address or "", + company.serviceSummary, + } + for _, value in ipairs(values) do + if tostring(value):lower():find(search, 1, true) then + return 1 + end + end + for _, service in ipairs(company.services) do + if service.title:lower():find(search, 1, true) + or service.description:lower():find(search, 1, true) + then + return 1 + end + end + return -1 +end + +Bridge.Callbacks.Register("sky_phone:companies:list", function(source, data) + local session, error_response = allow_read(source, "Search") + if not session then + return error_response + end + data = type(data) == "table" and data or {} + local search = valid_text(data.search or "", 80, true) + if not search then + return { success = false, error = "invalid_request" } + end + search = search:lower() + local category_id = data.categoryId + if category_id ~= nil then + local category_valid = false + for _, configured in ipairs(Config.Companies.Categories or {}) do + if category_id == configured then + category_valid = true + break + end + end + if not category_valid then + return { success = false, error = "invalid_request" } + end + end + local availability = data.availability + if availability ~= nil and not Config.Companies.AvailabilityStatuses[availability] then + return { success = false, error = "invalid_request" } + end + if data.acceptsRequests ~= nil and type(data.acceptsRequests) ~= "boolean" then + return { success = false, error = "invalid_request" } + end + if data.hasLocation ~= nil and type(data.hasLocation) ~= "boolean" then + return { success = false, error = "invalid_request" } + end + local sort = data.sort or "relevance" + if sort ~= "relevance" and sort ~= "name" and sort ~= "updated" then + return { success = false, error = "invalid_request" } + end + local cursor = data.cursor == nil and 0 or valid_integer(data.cursor, 0, 100000) + if not cursor then + return { success = false, error = "invalid_request" } + end + + local matches = {} + for _, company_id in ipairs(definition_ids) do + local company = public_company(company_id) + if company then + local score = search_score(company, search) + if score >= 0 + and (not category_id or company.categoryId == category_id) + and (not availability or company.availability == availability) + and (data.acceptsRequests ~= true or company.acceptsRequests) + and (data.hasLocation ~= true or company.location ~= nil) + then + matches[#matches + 1] = { company = company, score = score } + end + end + end + table.sort(matches, function(left, right) + if sort == "updated" and left.company.updatedAtUnix ~= right.company.updatedAtUnix then + return left.company.updatedAtUnix > right.company.updatedAtUnix + end + if sort == "relevance" and left.score ~= right.score then + return left.score > right.score + end + local left_name = left.company.name:lower() + local right_name = right.company.name:lower() + return left_name == right_name and left.company.id < right.company.id or left_name < right_name + end) + + local companies = {} + local page_size = math.min(Config.Companies.PageSize, Config.Companies.MaximumPageSize) + local last_index = math.min(#matches, cursor + page_size) + for index = cursor + 1, last_index do + companies[#companies + 1] = company_summary(matches[index].company) + end + return { + success = true, + data = { + companies = companies, + categories = category_payloads(), + nextCursor = last_index < #matches and tostring(last_index) or nil, + }, + } +end) + +Bridge.Callbacks.Register("sky_phone:companies:get", function(source, data) + local session, error_response = allow_read(source, "Read") + if not session then + return error_response + end + local company_id = type(data) == "table" and data.companyId or nil + local company = type(company_id) == "string" and public_company(company_id) or nil + if not company then + return { success = false, error = "company_not_found" } + end + company.updatedAtUnix = nil + return { success = true, data = { company = company } } +end) + +local function decode_request_cursor(value) + if value == nil then + return nil, nil + end + if type(value) ~= "string" or #value > 64 then + return false, false + end + local timestamp, request_id = value:match("^(%d+):(.+)$") + timestamp = valid_integer(timestamp, 1, 4102444800) + if not timestamp or not valid_uuid(request_id) then + return false, false + end + return timestamp, request_id +end + +local function encode_request_cursor(row) + return ("%s:%s"):format(tostring(math.floor(tonumber(row.updated_at_unix) or 0)), row.id) +end + +local function request_row(request_id) + local rows = Bridge.Database.Query([[ + SELECT r.`id`, r.`company_id`, r.`service_id`, r.`customer_sim_id`, r.`subject`, r.`description`, + r.`status`, r.`assigned_identifier`, r.`customer_unread`, + r.`company_activity_revision`, r.`revision`, + UNIX_TIMESTAMP(r.`created_at`) AS `created_at_unix`, + UNIX_TIMESTAMP(r.`updated_at`) AS `updated_at_unix`, service.`title` AS `service_title`, + logo.`url` AS `company_logo_url` + FROM `sky_phone_company_requests` r + LEFT JOIN `sky_phone_company_services` service ON service.`id` = r.`service_id` + LEFT JOIN `sky_phone_company_profiles` profile ON profile.`company_id` = r.`company_id` + LEFT JOIN `sky_phone_media` logo ON logo.`id` = profile.`logo_media_id` + WHERE r.`id` = ? + LIMIT 1 + ]], { request_id }) + return rows[1] +end + +local function request_summary(row, audience, identifier) + local definition = definitions[row.company_id] + local assigned_label = nil + if row.assigned_identifier then + assigned_label = identifier and row.assigned_identifier == identifier and "you" or "assigned" + end + local unread_count + if audience == "customer" then + unread_count = tonumber(row.customer_unread) or 0 + else + local relevant = not terminal_statuses[row.status] + and (row.status == "new" or row.assigned_identifier == identifier) + unread_count = relevant and math.max( + 0, + (tonumber(row.company_activity_revision) or 1) + - (tonumber(row.company_read_revision) or 0) + ) or 0 + end + return { + id = row.id, + companyId = row.company_id, + companyName = definition and definition.Name or row.company_id, + companyLogoUrl = row.company_logo_url, + serviceId = row.service_id, + serviceName = row.service_title, + subject = row.subject, + status = row.status, + assignedLabel = assigned_label, + unreadCount = unread_count, + createdAt = iso_time(row.created_at_unix), + updatedAt = iso_time(row.updated_at_unix), + } +end + +local function request_access(source, request_id) + local session, error_response = SkyPhone.RequireSession(source) + if not session then + return nil, error_response + end + local row = request_row(request_id) + if not row then + return nil, { success = false, error = "request_not_found" } + end + local device = SkyPhone.LoadDevice(session.imei) + if not device then + return nil, { success = false, error = "request_failed" } + end + if device.sim_id and device.sim_id == row.customer_sim_id + and device.sim_type == "registered" and device.registered_at + then + return { audience = "customer", row = row, device = device } + end + local member = membership(source) + if not member or member.company_id ~= row.company_id + or member.grade < permission_grade(member.definition, "WorkQueue") + then + return nil, { success = false, error = "request_not_found" } + end + local identifier = Bridge.Framework.GetIdentifier(source) + if type(identifier) ~= "string" or identifier == "" then + return nil, { success = false, error = "not_authorized" } + end + member.identifier = identifier + return { audience = "company", row = row, device = device, member = member } +end + +local function can_handle_request(access) + if access.audience ~= "company" then + return false + end + return access.row.assigned_identifier == access.member.identifier + or access.member.grade >= permission_grade(access.member.definition, "Assign") +end + +local function request_actions(access) + local row = access.row + local definition = definitions[row.company_id] + local active = not terminal_statuses[row.status] + if access.audience == "customer" then + return { + allowedStatuses = {}, + canAssign = false, + canCall = definition ~= nil and definition.ServiceLine.CanCall == true, + canCancel = active, + canClaim = false, + canReply = active, + } + end + local handler = can_handle_request(access) + local allowed = {} + if handler then + for _, status in ipairs({ "in_progress", "waiting_customer", "completed", "cancelled" }) do + if status_transitions[row.status] and status_transitions[row.status][status] then + allowed[#allowed + 1] = status + end + end + end + return { + allowedStatuses = allowed, + canAssign = active and access.member.grade >= permission_grade(access.member.definition, "Assign"), + canCall = active + and access.member.definition.ServiceLine.CanCall == true + and access.member.grade >= (tonumber(access.member.definition.ServiceLine.MinimumGrade) or 0), + canCancel = active and handler, + canClaim = row.status == "new" and not row.assigned_identifier, + canReply = active and handler, + } +end + +local function request_detail(access) + local row = request_row(access.row.id) + if not row then + return nil + end + access.row = row + if access.audience == "customer" then + Bridge.Database.Query( + "UPDATE `sky_phone_company_requests` SET `customer_unread` = 0, `updated_at` = `updated_at` WHERE `id` = ?", + { row.id } + ) + row.customer_unread = 0 + else + Bridge.Database.Query([[ + INSERT INTO `sky_phone_company_request_reads` + (`request_id`, `reader_identifier`, `read_revision`) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE + `read_revision` = GREATEST(`read_revision`, VALUES(`read_revision`)), + `updated_at` = CURRENT_TIMESTAMP + ]], { row.id, access.member.identifier, row.company_activity_revision }) + row.company_read_revision = row.company_activity_revision + end + local messages = Bridge.Database.Query([[ + SELECT `id`, `sender_type`, `sender_identifier`, `sender_sim_id`, `body`, + UNIX_TIMESTAMP(`created_at`) AS `created_at_unix` + FROM `sky_phone_company_request_messages` + WHERE `request_id` = ? + ORDER BY `created_at`, `id` + ]], { row.id }) + local message_payloads = {} + for _, message in ipairs(messages) do + local mine = access.audience == "customer" + and message.sender_type == "customer" and message.sender_sim_id == row.customer_sim_id + or access.audience == "company" + and message.sender_type == "company" and message.sender_identifier == access.member.identifier + message_payloads[#message_payloads + 1] = { + id = message.id, + author = message.sender_type, + authorLabel = mine and "you" or message.sender_type, + body = message.body, + createdAt = iso_time(message.created_at_unix), + isMine = mine, + } + end + local events = Bridge.Database.Query([[ + SELECT `id`, `event_type`, `to_status`, UNIX_TIMESTAMP(`created_at`) AS `created_at_unix` + FROM `sky_phone_company_request_events` + WHERE `request_id` = ? + ORDER BY `created_at`, `id` + ]], { row.id }) + local event_payloads = {} + for _, event in ipairs(events) do + local event_type = event.event_type + if event_type == "status" then + event_type = event.to_status == "completed" and "completed" or "status_changed" + end + event_payloads[#event_payloads + 1] = { + id = event.id, + type = event_type, + status = event.to_status, + createdAt = iso_time(event.created_at_unix), + } + end + local media_rows = Bridge.Database.Query([[ + SELECT media.`id`, media.`url` + FROM `sky_phone_company_request_media` request_media + INNER JOIN `sky_phone_media` media ON media.`id` = request_media.`media_id` + WHERE request_media.`request_id` = ? + ORDER BY request_media.`sort_order` + ]], { row.id }) + local payload = request_summary( + row, + access.audience, + access.member and access.member.identifier or nil + ) + payload.description = row.description + payload.revision = tonumber(row.revision) or 1 + local definition = definitions[row.company_id] + payload.phoneNumber = definition and definition.ServiceLine.Number or nil + payload.messages = message_payloads + payload.events = event_payloads + payload.media = media_rows + payload.actions = request_actions(access) + return payload +end + +local function list_request_rows(query, parameters, audience, identifier) + local rows = Bridge.Database.Query(query, parameters) + local has_more = #rows > Config.Companies.PageSize + if has_more then + rows[#rows] = nil + end + local requests = {} + for _, row in ipairs(rows) do + requests[#requests + 1] = request_summary(row, audience, identifier) + end + return requests, has_more and encode_request_cursor(rows[#rows]) or nil +end + +local request_list_select = [[ + SELECT r.`id`, r.`company_id`, r.`service_id`, r.`customer_sim_id`, r.`subject`, r.`status`, + r.`assigned_identifier`, r.`customer_unread`, r.`company_activity_revision`, r.`revision`, + UNIX_TIMESTAMP(r.`created_at`) AS `created_at_unix`, + UNIX_TIMESTAMP(r.`updated_at`) AS `updated_at_unix`, service.`title` AS `service_title`, + logo.`url` AS `company_logo_url`, NULL AS `company_read_revision` + FROM `sky_phone_company_requests` r + LEFT JOIN `sky_phone_company_services` service ON service.`id` = r.`service_id` + LEFT JOIN `sky_phone_company_profiles` profile ON profile.`company_id` = r.`company_id` + LEFT JOIN `sky_phone_media` logo ON logo.`id` = profile.`logo_media_id` +]] + +local company_request_list_select = [[ + SELECT r.`id`, r.`company_id`, r.`service_id`, r.`customer_sim_id`, r.`subject`, r.`status`, + r.`assigned_identifier`, r.`customer_unread`, r.`company_activity_revision`, r.`revision`, + UNIX_TIMESTAMP(r.`created_at`) AS `created_at_unix`, + UNIX_TIMESTAMP(r.`updated_at`) AS `updated_at_unix`, service.`title` AS `service_title`, + logo.`url` AS `company_logo_url`, company_read.`read_revision` AS `company_read_revision` + FROM `sky_phone_company_requests` r + LEFT JOIN `sky_phone_company_services` service ON service.`id` = r.`service_id` + LEFT JOIN `sky_phone_company_profiles` profile ON profile.`company_id` = r.`company_id` + LEFT JOIN `sky_phone_media` logo ON logo.`id` = profile.`logo_media_id` + LEFT JOIN `sky_phone_company_request_reads` company_read + ON company_read.`request_id` = r.`id` AND company_read.`reader_identifier` = ? +]] + +local function context_request_rows(company_id, identifier, own) + local condition = own + and " AND r.`assigned_identifier` = ? AND r.`status` NOT IN ('completed', 'cancelled')" + or "" + local parameters = { identifier, company_id } + if own then + parameters[#parameters + 1] = identifier + end + parameters[#parameters + 1] = 4 + local rows = Bridge.Database.Query(company_request_list_select .. ([=[ + WHERE r.`company_id` = ?%s + ORDER BY r.`updated_at` DESC, r.`id` DESC + LIMIT ? + ]=]):format(condition), parameters) + local requests = {} + for _, row in ipairs(rows) do + requests[#requests + 1] = request_summary(row, "company", identifier) + end + return requests +end + +local function work_context(source) + local member = membership(source) + if not member or member.grade < permission_grade(member.definition, "WorkQueue") then + return { + authorized = false, + callAvailable = false, + company = nil, + metrics = { assigned = 0, completedToday = 0, new = 0, waiting = 0 }, + ownRequests = {}, + recentRequests = {}, + permissions = { + canAssign = false, + canManageAnnouncement = false, + canManageHours = false, + canManageProfile = false, + canManageServices = false, + canSetAvailability = false, + canTakeCalls = false, + }, + role = nil, + unreadCount = 0, + } + end + local identifier = Bridge.Framework.GetIdentifier(source) + if type(identifier) ~= "string" or identifier == "" then + return nil + end + member.identifier = identifier + local rows = Bridge.Database.Query([[ + SELECT + COALESCE(SUM(request.`status` = 'new'), 0) AS `new_count`, + COALESCE(SUM(request.`assigned_identifier` = ? + AND request.`status` NOT IN ('completed', 'cancelled')), 0) AS `assigned_count`, + COALESCE(SUM(request.`status` = 'waiting_customer'), 0) AS `waiting_count`, + COALESCE(SUM(request.`status` = 'completed' + AND DATE(request.`completed_at`) = CURRENT_DATE), 0) AS `completed_today`, + COALESCE(SUM(CASE + WHEN request.`status` NOT IN ('completed', 'cancelled') + AND (request.`status` = 'new' OR request.`assigned_identifier` = ?) + AND COALESCE(company_read.`read_revision`, 0) < request.`company_activity_revision` + THEN request.`company_activity_revision` - COALESCE(company_read.`read_revision`, 0) + ELSE 0 + END), 0) AS `unread_count` + FROM `sky_phone_company_requests` request + LEFT JOIN `sky_phone_company_request_reads` company_read + ON company_read.`request_id` = request.`id` AND company_read.`reader_identifier` = ? + WHERE request.`company_id` = ? + ]], { identifier, identifier, identifier, member.company_id }) + local metrics = rows[1] or {} + local permissions = { + canAssign = member.grade >= permission_grade(member.definition, "Assign"), + canManageAnnouncement = member.grade >= permission_grade(member.definition, "Announcement"), + canManageHours = member.grade >= permission_grade(member.definition, "Hours"), + canManageProfile = member.grade >= permission_grade(member.definition, "Profile"), + canManageServices = member.grade >= permission_grade(member.definition, "Services"), + canSetAvailability = member.grade >= permission_grade(member.definition, "Availability"), + canTakeCalls = member.definition.ServiceLine.CanCall == true + and member.grade >= (tonumber(member.definition.ServiceLine.MinimumGrade) or 0), + } + local manager = permissions.canAssign or permissions.canManageAnnouncement + or permissions.canManageHours or permissions.canManageProfile or permissions.canManageServices + local readiness = call_availability[source] + local company = company_payload(member.company_id, true) + company.updatedAtUnix = nil + return { + authorized = true, + callAvailable = permissions.canTakeCalls and readiness ~= nil + and readiness.company_id == member.company_id, + company = company, + metrics = { + new = tonumber(metrics.new_count) or 0, + assigned = tonumber(metrics.assigned_count) or 0, + waiting = tonumber(metrics.waiting_count) or 0, + completedToday = tonumber(metrics.completed_today) or 0, + }, + ownRequests = context_request_rows(member.company_id, identifier, true), + recentRequests = context_request_rows(member.company_id, identifier, false), + permissions = permissions, + role = manager and "manager" or "employee", + unreadCount = tonumber(metrics.unread_count) or 0, + } +end + +local function notify_sim(sim_id, event_name, payload) + local devices = Bridge.Database.Query([[ + SELECT d.`imei`, d.`device_name`, settings.`payload` AS `settings` + FROM `sky_phone_devices` d + LEFT JOIN `sky_phone_device_data` settings + ON settings.`device_imei` = d.`imei` AND settings.`namespace` = 'settings' + WHERE d.`sim_id` = ? + ]], { sim_id }) + for _, device in ipairs(devices) do + for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do + local target = tonumber(player_source) or player_source + if SkyPhone.FindDeviceSlots(target, device.imei)[1] then + local event_payload = {} + for key, value in pairs(payload) do + event_payload[key] = value + end + event_payload.device = { + imei = device.imei, + name = device.device_name, + settings = device.settings, + } + TriggerClientEvent(event_name, target, event_payload) + end + end + end +end + +local function notify_source(source, event_name, payload) + local session = SkyPhone.RequireSession(source) + if not session then + return + end + local rows = Bridge.Database.Query([[ + SELECT d.`device_name`, settings.`payload` AS `settings` + FROM `sky_phone_devices` d + LEFT JOIN `sky_phone_device_data` settings + ON settings.`device_imei` = d.`imei` AND settings.`namespace` = 'settings' + WHERE d.`imei` = ? + LIMIT 1 + ]], { session.imei }) + if not rows[1] then + return + end + local event_payload = {} + for key, value in pairs(payload) do + event_payload[key] = value + end + event_payload.device = { + imei = session.imei, + name = rows[1].device_name, + settings = rows[1].settings, + } + TriggerClientEvent(event_name, source, event_payload) +end + +local function notify_company(company_id, event_name, payload, excluded_source) + local definition = definitions[company_id] + if not definition then + return + end + for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do + local target = tonumber(player_source) or player_source + if target ~= excluded_source then + local job = Bridge.Framework.GetJob(target) + if job.name == definition.Job and (tonumber(job.grade) or 0) >= permission_grade(definition, "WorkQueue") then + notify_source(target, event_name, payload) + end + end + end +end + +local function notify_identifier(identifier, company_id, event_name, payload) + for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do + local target = tonumber(player_source) or player_source + local member = membership(target) + if member and member.company_id == company_id + and member.grade >= permission_grade(member.definition, "WorkQueue") + and Bridge.Framework.GetIdentifier(target) == identifier + then + notify_source(target, event_name, payload) + end + end +end + +local function emit_public_change(company_id) + local definition = definitions[company_id] + if not definition then + return + end + notify_company(company_id, "sky_phone:companies:changed", { + area = "work", + companyId = company_id, + }) + if not definition.Public then + return + end + for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do + notify_source(tonumber(player_source) or player_source, "sky_phone:companies:changed", { + area = "directory", + companyId = company_id, + }) + end +end + +local function emit_request_change(row, customer, company, excluded_source) + local payload = { + area = customer and "customer" or "work", + companyId = row.company_id, + requestId = row.id, + } + if customer then + notify_sim(row.customer_sim_id, "sky_phone:companies:changed", payload) + end + if company then + payload = { + area = "work", + companyId = row.company_id, + requestId = row.id, + } + notify_company(row.company_id, "sky_phone:companies:changed", payload, excluded_source) + end +end + +Bridge.Callbacks.Register("sky_phone:companies:my-requests", function(source, data) + local session, error_response = allow_read(source, "Read") + if not session then + return error_response + end + local device, device_error = current_device(source, true) + if not device then + return device_error + end + data = type(data) == "table" and data or {} + local list = data.list or "open" + if list ~= "open" and list ~= "closed" then + return { success = false, error = "invalid_request" } + end + local cursor_time, cursor_id = decode_request_cursor(data.cursor) + if cursor_time == false then + return { success = false, error = "invalid_request" } + end + local status_condition = list == "open" + and "r.`status` NOT IN ('completed', 'cancelled')" + or "r.`status` IN ('completed', 'cancelled')" + local cursor_condition = "" + local parameters = { device.sim_id } + if cursor_time then + cursor_condition = [[ + AND (r.`updated_at` < FROM_UNIXTIME(?) + OR (r.`updated_at` = FROM_UNIXTIME(?) AND r.`id` < ?)) + ]] + parameters[#parameters + 1] = cursor_time + parameters[#parameters + 1] = cursor_time + parameters[#parameters + 1] = cursor_id + end + parameters[#parameters + 1] = Config.Companies.PageSize + 1 + local requests, next_cursor = list_request_rows(request_list_select .. ([=[ + WHERE r.`customer_sim_id` = ? AND %s %s + ORDER BY r.`updated_at` DESC, r.`id` DESC + LIMIT ? + ]=]):format(status_condition, cursor_condition), parameters, "customer") + local unread_rows = Bridge.Database.Query([[ + SELECT COALESCE(SUM(`customer_unread`), 0) AS `unread_count` + FROM `sky_phone_company_requests` + WHERE `customer_sim_id` = ? + ]], { device.sim_id }) + return { + success = true, + data = { + requests = requests, + nextCursor = next_cursor, + unreadCount = tonumber(unread_rows[1] and unread_rows[1].unread_count) or 0, + }, + } +end) + +Bridge.Callbacks.Register("sky_phone:companies:get-request", function(source, data) + local session, error_response = allow_read(source, "Read") + if not session then + return error_response + end + local request_id = type(data) == "table" and data.requestId or nil + if not valid_uuid(request_id) then + return { success = false, error = "invalid_request" } + end + local access, access_error = request_access(source, request_id) + if not access then + return access_error + end + local request = request_detail(access) + if not request then + return { success = false, error = "request_not_found" } + end + return { success = true, data = { request = request } } +end) + +Bridge.Callbacks.Register("sky_phone:companies:work-context", function(source) + local session, error_response = allow_read(source, "Read") + if not session then + return error_response + end + local context = work_context(source) + if not context then + return { success = false, error = "not_authorized" } + end + return { success = true, data = { context = context } } +end) + +Bridge.Callbacks.Register("sky_phone:companies:work-queue", function(source, data) + local session, error_response = allow_read(source, "Read") + if not session then + return error_response + end + local member, member_error = require_permission(source, "WorkQueue") + if not member then + return member_error + end + data = type(data) == "table" and data or {} + local filter = data.filter or "new" + local filters = { + new = "r.`status` = 'new'", + assigned = "r.`assigned_identifier` = ? AND r.`status` NOT IN ('completed', 'cancelled')", + in_progress = "r.`status` = 'in_progress'", + waiting_customer = "r.`status` = 'waiting_customer'", + completed = "r.`status` = 'completed'", + } + if not filters[filter] then + return { success = false, error = "invalid_request" } + end + local cursor_time, cursor_id = decode_request_cursor(data.cursor) + if cursor_time == false then + return { success = false, error = "invalid_request" } + end + local parameters = { member.identifier, member.company_id } + if filter == "assigned" then + parameters[#parameters + 1] = member.identifier + end + local cursor_condition = "" + if cursor_time then + cursor_condition = [[ + AND (r.`updated_at` < FROM_UNIXTIME(?) + OR (r.`updated_at` = FROM_UNIXTIME(?) AND r.`id` < ?)) + ]] + parameters[#parameters + 1] = cursor_time + parameters[#parameters + 1] = cursor_time + parameters[#parameters + 1] = cursor_id + end + parameters[#parameters + 1] = Config.Companies.PageSize + 1 + local requests, next_cursor = list_request_rows(company_request_list_select .. ([=[ + WHERE r.`company_id` = ? AND %s %s + ORDER BY r.`updated_at` DESC, r.`id` DESC + LIMIT ? + ]=]):format(filters[filter], cursor_condition), parameters, "company", member.identifier) + return { success = true, data = { requests = requests, nextCursor = next_cursor } } +end) + +local function member_token(target, company_id, identifier) + local entry = member_tokens[target] + if not entry or entry.company_id ~= company_id or entry.identifier ~= identifier + or os.time() - entry.created_at > 300 + then + entry = { + token = uuid(), + company_id = company_id, + identifier = identifier, + created_at = os.time(), + } + member_tokens[target] = entry + end + return entry.token +end + +Bridge.Callbacks.Register("sky_phone:companies:list-members", function(source) + local session, error_response = allow_read(source, "Read") + if not session then + return error_response + end + local member, member_error = require_permission(source, "Assign") + if not member then + return member_error + end + local members = {} + for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do + local target = tonumber(player_source) or player_source + local target_member = membership(target) + local target_identifier = target_member and Bridge.Framework.GetIdentifier(target) or nil + if target_member and target_member.company_id == member.company_id + and target_member.grade >= permission_grade(target_member.definition, "WorkQueue") + and type(target_identifier) == "string" and target_identifier ~= "" + then + local first_name = trim(Bridge.Framework.GetFirstname(target) or "") or "" + local last_name = trim(Bridge.Framework.GetLastname(target) or "") or "" + local name = trim(first_name .. " " .. last_name) + members[#members + 1] = { + id = member_token(target, member.company_id, target_identifier), + name = name, + online = true, + role = target_member.grade_label, + } + end + end + table.sort(members, function(left, right) + return left.name:lower() < right.name:lower() + end) + return { success = true, data = { members = members } } +end) + +local function allow_mutation(source, operation, limit_name) + if not Config.Companies.Enabled then + return false, { success = false, error = "service_unavailable" } + end + local limit = Config.Companies.RateLimits[limit_name] + if not SkyPhone.AllowOperation(source, "companies_" .. operation, limit, 60) then + return false, { success = false, error = "rate_limited" } + end + local session, error_response = SkyPhone.RequireSession(source) + if not session then + return false, error_response + end + return true +end + +local function mutation_request_payload(source, request_id) + local access, access_error = request_access(source, request_id) + if not access then + return nil, access_error + end + local request = request_detail(access) + if not request then + return nil, { success = false, error = "request_not_found" } + end + local data = { request = request } + if access.audience == "company" then + data.context = work_context(source) + end + return data +end + +local function notification_payload(kind, area, row) + return { + kind = kind, + area = area, + companyId = row.company_id, + requestId = row.id, + } +end + +Bridge.Callbacks.Register("sky_phone:companies:create-request", function(source, data) + local allowed, rate_error = allow_mutation(source, "create_request", "CreateRequest") + if not allowed then + return rate_error + end + local device, device_error = current_device(source, true) + if not device then + return device_error + end + if type(data) ~= "table" then + return { success = false, error = "invalid_request" } + end + local company_id = data.companyId + local definition = type(company_id) == "string" and definitions[company_id] or nil + if not definition or not definition.Public or definition.Emergency then + return { success = false, error = "company_not_found" } + end + local subject = valid_text(data.subject, Config.Companies.SubjectMaxLength, false) + local description = valid_text(data.description, Config.Companies.RequestBodyMaxLength, false) + if not subject or not description or not valid_service_id(data.serviceId) then + return { success = false, error = "invalid_request" } + end + local profiles = Bridge.Database.Query( + "SELECT `accepts_requests` FROM `sky_phone_company_profiles` WHERE `company_id` = ? LIMIT 1", + { company_id } + ) + if not profiles[1] or tonumber(profiles[1].accepts_requests) ~= 1 then + return { success = false, error = "invalid_service" } + end + local services = Bridge.Database.Query([[ + SELECT `id` FROM `sky_phone_company_services` + WHERE `id` = ? AND `company_id` = ? AND `archived` = 0 + AND `active` = 1 AND `requests_enabled` = 1 + LIMIT 1 + ]], { data.serviceId, company_id }) + if not services[1] then + return { success = false, error = "invalid_service" } + end + local media_ids = data.mediaIds + if media_ids == nil then + media_ids = {} + end + if not valid_array(media_ids, Config.Companies.MaximumRequestMedia) then + return { success = false, error = "invalid_media" } + end + local media_seen = {} + local normalized_media = {} + for _, media_id in ipairs(media_ids) do + local normalized = valid_integer(media_id, 1, 9007199254740991) + if not normalized or media_seen[normalized] + or not SkyPhoneMedia.ResolveOwnedMedia(source, normalized, "photo") + then + return { success = false, error = "invalid_media" } + end + media_seen[normalized] = true + normalized_media[#normalized_media + 1] = normalized + end + local counts = Bridge.Database.Query([[ + SELECT COUNT(*) AS `count` + FROM `sky_phone_company_requests` + WHERE `customer_sim_id` = ? AND `status` NOT IN ('completed', 'cancelled') + ]], { device.sim_id }) + if (tonumber(counts[1] and counts[1].count) or 0) >= Config.Companies.MaximumOpenRequestsPerSim then + return { success = false, error = "too_many_open_requests" } + end + local request_id = uuid() + local event_id = uuid() + local statements = { + { + query = "UPDATE `sky_phone_sims` SET `updated_at` = `updated_at` WHERE `id` = ?", + params = { device.sim_id }, + }, + { + query = [[ + INSERT INTO `sky_phone_company_requests` + (`id`, `company_id`, `service_id`, `customer_sim_id`, `subject`, `description`) + SELECT ?, profile.`company_id`, service.`id`, sim.`id`, ?, ? + FROM `sky_phone_sims` sim + INNER JOIN `sky_phone_company_profiles` profile ON profile.`company_id` = ? + INNER JOIN `sky_phone_company_services` service + ON service.`id` = ? AND service.`company_id` = profile.`company_id` + WHERE sim.`id` = ? AND profile.`accepts_requests` = 1 + AND service.`archived` = 0 AND service.`active` = 1 + AND service.`requests_enabled` = 1 AND ( + SELECT COUNT(*) FROM `sky_phone_company_requests` + WHERE `customer_sim_id` = ? AND `status` NOT IN ('completed', 'cancelled') + ) < ? + ]], + params = { + request_id, + subject, + description, + company_id, + data.serviceId, + device.sim_id, + device.sim_id, + Config.Companies.MaximumOpenRequestsPerSim, + }, + }, + { + query = [[ + INSERT INTO `sky_phone_company_request_events` + (`id`, `request_id`, `event_type`, `actor_type`, `to_status`) + SELECT ?, `id`, 'created', 'customer', 'new' + FROM `sky_phone_company_requests` WHERE `id` = ? + ]], + params = { event_id, request_id }, + }, + } + for index, media_id in ipairs(normalized_media) do + statements[#statements + 1] = { + query = [[ + INSERT INTO `sky_phone_company_request_media` (`request_id`, `media_id`, `sort_order`) + SELECT `id`, ?, ? FROM `sky_phone_company_requests` WHERE `id` = ? + ]], + params = { media_id, index, request_id }, + } + end + if not Bridge.Database.Transaction(statements) then + return { success = false, error = "request_failed" } + end + local row = request_row(request_id) + if not row then + local available = Bridge.Database.Query([[ + SELECT service.`id` + FROM `sky_phone_company_profiles` profile + INNER JOIN `sky_phone_company_services` service ON service.`company_id` = profile.`company_id` + WHERE profile.`company_id` = ? AND profile.`accepts_requests` = 1 + AND service.`id` = ? AND service.`archived` = 0 + AND service.`active` = 1 AND service.`requests_enabled` = 1 + LIMIT 1 + ]], { company_id, data.serviceId }) + if not available[1] then + return { success = false, error = "invalid_service" } + end + return { success = false, error = "too_many_open_requests" } + end + emit_request_change(row, true, true, source) + notify_company(company_id, "sky_phone:companies:notification", notification_payload( + "newRequest", + "work", + row + ), source) + local payload, payload_error = mutation_request_payload(source, request_id) + if not payload then + return payload_error + end + return { success = true, data = payload } +end) + +Bridge.Callbacks.Register("sky_phone:companies:cancel-request", function(source, data) + local allowed, rate_error = allow_mutation(source, "cancel_request", "RequestAction") + if not allowed then + return rate_error + end + local request_id = type(data) == "table" and data.requestId or nil + local revision = type(data) == "table" and valid_integer(data.revision, 1, 4294967295) or nil + if not valid_uuid(request_id) or not revision then + return { success = false, error = "invalid_request" } + end + local access, access_error = request_access(source, request_id) + if not access then + return access_error + end + if terminal_statuses[access.row.status] + or (access.audience == "company" and not can_handle_request(access)) + then + return { success = false, error = "invalid_status" } + end + local actor_type = access.audience + local customer_unread_update = actor_type == "company" + and ", `customer_unread` = `customer_unread` + 1" or "" + local mutation_token = uuid() + local event_id = uuid() + local event_query + local event_params + if actor_type == "customer" then + event_query = [[ + INSERT INTO `sky_phone_company_request_events` + (`id`, `request_id`, `event_type`, `actor_type`, `from_status`, `to_status`) + SELECT ?, `id`, 'cancelled', 'customer', ?, 'cancelled' + FROM `sky_phone_company_requests` + WHERE `id` = ? AND `revision` = ? AND `mutation_token` = ? + ]] + event_params = { event_id, access.row.status, request_id, revision + 1, mutation_token } + else + event_query = [[ + INSERT INTO `sky_phone_company_request_events` + (`id`, `request_id`, `event_type`, `actor_type`, `actor_identifier`, `from_status`, `to_status`) + SELECT ?, `id`, 'cancelled', 'company', ?, ?, 'cancelled' + FROM `sky_phone_company_requests` + WHERE `id` = ? AND `revision` = ? AND `mutation_token` = ? + ]] + event_params = { + event_id, + access.member.identifier, + access.row.status, + request_id, + revision + 1, + mutation_token, + } + end + if not Bridge.Database.Transaction({ + { + query = (([[ + UPDATE `sky_phone_company_requests` + SET `status` = 'cancelled', `revision` = `revision` + 1, + `cancelled_at` = CURRENT_TIMESTAMP, `mutation_token` = ?%s + WHERE `id` = ? AND `revision` = ? AND `status` = ? + ]])):format(customer_unread_update), + params = { mutation_token, request_id, revision, access.row.status }, + }, + { query = event_query, params = event_params }, + }) then + return { success = false, error = "request_failed" } + end + local committed = Bridge.Database.Query( + "SELECT `id` FROM `sky_phone_company_request_events` WHERE `id` = ? LIMIT 1", + { event_id } + ) + if not committed[1] then + return { success = false, error = "revision_conflict" } + end + local row = request_row(request_id) + emit_request_change(row, true, true, source) + if actor_type == "customer" then + notify_company(row.company_id, "sky_phone:companies:notification", notification_payload( + "requestUpdated", + "work", + row + ), source) + else + notify_sim(row.customer_sim_id, "sky_phone:companies:notification", notification_payload( + "requestUpdated", + "customer", + row + )) + end + local payload, payload_error = mutation_request_payload(source, request_id) + if not payload then + return payload_error + end + return { success = true, data = payload } +end) + +Bridge.Callbacks.Register("sky_phone:companies:send-message", function(source, data) + local allowed, rate_error = allow_mutation(source, "send_message", "Message") + if not allowed then + return rate_error + end + local request_id = type(data) == "table" and data.requestId or nil + local revision = type(data) == "table" and valid_integer(data.revision, 1, 4294967295) or nil + local body = type(data) == "table" and valid_text(data.body, Config.Companies.MessageMaxLength, false) or nil + if not valid_uuid(request_id) or not revision or not body then + return { success = false, error = "invalid_request" } + end + local access, access_error = request_access(source, request_id) + if not access then + return access_error + end + if terminal_statuses[access.row.status] + or (access.audience == "company" and not can_handle_request(access)) + then + return { success = false, error = "invalid_status" } + end + local message_id = uuid() + local mutation_token = uuid() + local new_status = access.audience == "customer" and access.row.status == "waiting_customer" + and "in_progress" or access.row.status + local audience_unread_update = access.audience == "customer" + and ", `company_activity_revision` = `company_activity_revision` + 1" + or ", `customer_unread` = `customer_unread` + 1" + local statements = { + { + query = ([[ + UPDATE `sky_phone_company_requests` + SET `status` = ?, `revision` = `revision` + 1, `mutation_token` = ?%s + WHERE `id` = ? AND `revision` = ? AND `status` = ? + AND `status` NOT IN ('completed', 'cancelled') + ]]):format(audience_unread_update), + params = { new_status, mutation_token, request_id, revision, access.row.status }, + }, + } + if access.audience == "customer" then + statements[#statements + 1] = { + query = [[ + INSERT INTO `sky_phone_company_request_messages` + (`id`, `request_id`, `sender_type`, `sender_sim_id`, `body`) + SELECT ?, `id`, 'customer', ?, ? FROM `sky_phone_company_requests` + WHERE `id` = ? AND `revision` = ? AND `mutation_token` = ? + ]], + params = { message_id, access.row.customer_sim_id, body, request_id, revision + 1, mutation_token }, + } + else + statements[#statements + 1] = { + query = [[ + INSERT INTO `sky_phone_company_request_messages` + (`id`, `request_id`, `sender_type`, `sender_identifier`, `body`) + SELECT ?, `id`, 'company', ?, ? FROM `sky_phone_company_requests` + WHERE `id` = ? AND `revision` = ? AND `mutation_token` = ? + ]], + params = { message_id, access.member.identifier, body, request_id, revision + 1, mutation_token }, + } + end + if new_status ~= access.row.status then + statements[#statements + 1] = { + query = [[ + INSERT INTO `sky_phone_company_request_events` + (`id`, `request_id`, `event_type`, `actor_type`, `from_status`, `to_status`) + SELECT ?, `id`, 'status', 'customer', ?, ? FROM `sky_phone_company_requests` + WHERE `id` = ? AND `revision` = ? AND `mutation_token` = ? + ]], + params = { uuid(), access.row.status, new_status, request_id, revision + 1, mutation_token }, + } + end + if not Bridge.Database.Transaction(statements) then + return { success = false, error = "request_failed" } + end + local inserted = Bridge.Database.Query( + "SELECT `id` FROM `sky_phone_company_request_messages` WHERE `id` = ? LIMIT 1", + { message_id } + ) + if not inserted[1] then + return { success = false, error = "revision_conflict" } + end + local row = request_row(request_id) + emit_request_change(row, true, true, source) + if access.audience == "customer" then + local notification = notification_payload("newMessage", "work", row) + if row.assigned_identifier then + notify_identifier( + row.assigned_identifier, + row.company_id, + "sky_phone:companies:notification", + notification + ) + end + else + notify_sim(row.customer_sim_id, "sky_phone:companies:notification", notification_payload( + "newMessage", + "customer", + row + )) + end + local payload, payload_error = mutation_request_payload(source, request_id) + if not payload then + return payload_error + end + return { success = true, data = payload } +end) + +Bridge.Callbacks.Register("sky_phone:companies:claim-request", function(source, data) + local allowed, rate_error = allow_mutation(source, "claim_request", "RequestAction") + if not allowed then + return rate_error + end + local member, member_error = require_permission(source, "WorkQueue") + if not member then + return member_error + end + local request_id = type(data) == "table" and data.requestId or nil + local revision = type(data) == "table" and valid_integer(data.revision, 1, 4294967295) or nil + if not valid_uuid(request_id) or not revision then + return { success = false, error = "invalid_request" } + end + local mutation_token = uuid() + local event_id = uuid() + if not Bridge.Database.Transaction({ + { + query = [[ + UPDATE `sky_phone_company_requests` + SET `assigned_identifier` = ?, `status` = 'assigned', `revision` = `revision` + 1, + `customer_unread` = `customer_unread` + 1, `mutation_token` = ? + WHERE `id` = ? AND `company_id` = ? AND `revision` = ? + AND `status` = 'new' AND `assigned_identifier` IS NULL + ]], + params = { member.identifier, mutation_token, request_id, member.company_id, revision }, + }, + { + query = [[ + INSERT INTO `sky_phone_company_request_events` + (`id`, `request_id`, `event_type`, `actor_type`, `actor_identifier`, `from_status`, `to_status`) + SELECT ?, `id`, 'assigned', 'company', ?, 'new', 'assigned' + FROM `sky_phone_company_requests` + WHERE `id` = ? AND `revision` = ? AND `mutation_token` = ? + ]], + params = { event_id, member.identifier, request_id, revision + 1, mutation_token }, + }, + }) then + return { success = false, error = "request_failed" } + end + local committed = Bridge.Database.Query( + "SELECT `id` FROM `sky_phone_company_request_events` WHERE `id` = ? LIMIT 1", + { event_id } + ) + if not committed[1] then + local current = Bridge.Database.Query([[ + SELECT `id` FROM `sky_phone_company_requests` + WHERE `id` = ? AND `company_id` = ? + LIMIT 1 + ]], { request_id, member.company_id }) + return { success = false, error = current[1] and "revision_conflict" or "request_not_found" } + end + local row = request_row(request_id) + emit_request_change(row, true, true, source) + notify_sim(row.customer_sim_id, "sky_phone:companies:notification", notification_payload( + "assigned", + "customer", + row + )) + local payload, payload_error = mutation_request_payload(source, request_id) + if not payload then + return payload_error + end + return { success = true, data = payload } +end) + +local function resolve_member(member_id, company_id) + if not valid_uuid(member_id) then + return nil + end + local online = {} + for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do + online[tonumber(player_source) or player_source] = true + end + for target, token in pairs(member_tokens) do + if token.token == member_id and token.company_id == company_id and online[target] + and os.time() - token.created_at <= 300 + then + local target_member = membership(target) + if not target_member or target_member.company_id ~= company_id + or target_member.grade < permission_grade(target_member.definition, "WorkQueue") + then + return nil + end + local identifier = Bridge.Framework.GetIdentifier(target) + if type(identifier) ~= "string" or identifier == "" or identifier ~= token.identifier then + return nil + end + return { source = target, identifier = identifier } + end + end + return nil +end + +Bridge.Callbacks.Register("sky_phone:companies:assign-request", function(source, data) + local allowed, rate_error = allow_mutation(source, "assign_request", "RequestAction") + if not allowed then + return rate_error + end + local member, member_error = require_permission(source, "Assign") + if not member then + return member_error + end + local request_id = type(data) == "table" and data.requestId or nil + local revision = type(data) == "table" and valid_integer(data.revision, 1, 4294967295) or nil + local target = type(data) == "table" and resolve_member(data.memberId, member.company_id) or nil + if not valid_uuid(request_id) or not revision or not target then + return { success = false, error = "invalid_request" } + end + local row = request_row(request_id) + if not row or row.company_id ~= member.company_id then + return { success = false, error = "request_not_found" } + end + if terminal_statuses[row.status] then + return { success = false, error = "invalid_status" } + end + if row.assigned_identifier == target.identifier then + return { success = false, error = "invalid_status" } + end + local next_status = row.status == "new" and "assigned" or row.status + local mutation_token = uuid() + local event_id = uuid() + if not Bridge.Database.Transaction({ + { + query = [[ + UPDATE `sky_phone_company_requests` + SET `assigned_identifier` = ?, `status` = ?, `revision` = `revision` + 1, + `customer_unread` = `customer_unread` + 1, + `company_activity_revision` = `company_activity_revision` + 1, + `mutation_token` = ? + WHERE `id` = ? AND `company_id` = ? AND `revision` = ? AND `status` = ? + AND `status` NOT IN ('completed', 'cancelled') + ]], + params = { + target.identifier, + next_status, + mutation_token, + request_id, + member.company_id, + revision, + row.status, + }, + }, + { + query = [[ + INSERT INTO `sky_phone_company_request_events` + (`id`, `request_id`, `event_type`, `actor_type`, `actor_identifier`, `from_status`, `to_status`) + SELECT ?, `id`, 'assigned', 'company', ?, ?, ? + FROM `sky_phone_company_requests` + WHERE `id` = ? AND `revision` = ? AND `mutation_token` = ? + ]], + params = { + event_id, + member.identifier, + row.status, + next_status, + request_id, + revision + 1, + mutation_token, + }, + }, + }) then + return { success = false, error = "request_failed" } + end + local committed = Bridge.Database.Query( + "SELECT `id` FROM `sky_phone_company_request_events` WHERE `id` = ? LIMIT 1", + { event_id } + ) + if not committed[1] then + return { success = false, error = "revision_conflict" } + end + row = request_row(request_id) + emit_request_change(row, true, true, source) + notify_identifier( + target.identifier, + row.company_id, + "sky_phone:companies:notification", + notification_payload("assigned", "work", row) + ) + notify_sim(row.customer_sim_id, "sky_phone:companies:notification", notification_payload( + "assigned", + "customer", + row + )) + local payload, payload_error = mutation_request_payload(source, request_id) + if not payload then + return payload_error + end + return { success = true, data = payload } +end) + +Bridge.Callbacks.Register("sky_phone:companies:update-request-status", function(source, data) + local allowed, rate_error = allow_mutation(source, "update_request_status", "RequestAction") + if not allowed then + return rate_error + end + local request_id = type(data) == "table" and data.requestId or nil + local revision = type(data) == "table" and valid_integer(data.revision, 1, 4294967295) or nil + local status = type(data) == "table" and data.status or nil + if not valid_uuid(request_id) or not revision or not Config.Companies.Statuses[status] then + return { success = false, error = "invalid_request" } + end + local access, access_error = request_access(source, request_id) + if not access then + return access_error + end + if access.audience ~= "company" or not can_handle_request(access) then + return { success = false, error = "not_authorized" } + end + if not status_transitions[access.row.status] or not status_transitions[access.row.status][status] then + return { success = false, error = "invalid_status" } + end + local mutation_token = uuid() + local event_id = uuid() + if not Bridge.Database.Transaction({ + { + query = [[ + UPDATE `sky_phone_company_requests` + SET `status` = ?, `revision` = `revision` + 1, `customer_unread` = `customer_unread` + 1, + `completed_at` = CASE WHEN ? = 'completed' THEN CURRENT_TIMESTAMP ELSE `completed_at` END, + `cancelled_at` = CASE WHEN ? = 'cancelled' THEN CURRENT_TIMESTAMP ELSE `cancelled_at` END, + `mutation_token` = ? + WHERE `id` = ? AND `company_id` = ? AND `revision` = ? AND `status` = ? + ]], + params = { + status, + status, + status, + mutation_token, + request_id, + access.row.company_id, + revision, + access.row.status, + }, + }, + { + query = [[ + INSERT INTO `sky_phone_company_request_events` + (`id`, `request_id`, `event_type`, `actor_type`, `actor_identifier`, `from_status`, `to_status`) + SELECT ?, `id`, ?, 'company', ?, ?, ? + FROM `sky_phone_company_requests` + WHERE `id` = ? AND `revision` = ? AND `mutation_token` = ? + ]], + params = { + event_id, + status == "cancelled" and "cancelled" or "status", + access.member.identifier, + access.row.status, + status, + request_id, + revision + 1, + mutation_token, + }, + }, + }) then + return { success = false, error = "request_failed" } + end + local committed = Bridge.Database.Query( + "SELECT `id` FROM `sky_phone_company_request_events` WHERE `id` = ? LIMIT 1", + { event_id } + ) + if not committed[1] then + return { success = false, error = "revision_conflict" } + end + local row = request_row(request_id) + emit_request_change(row, true, true, source) + notify_sim(row.customer_sim_id, "sky_phone:companies:notification", notification_payload( + "requestUpdated", + "customer", + row + )) + local payload, payload_error = mutation_request_payload(source, request_id) + if not payload then + return payload_error + end + return { success = true, data = payload } +end) + +Bridge.Callbacks.Register("sky_phone:companies:call-customer", function(source, data) + local allowed, rate_error = allow_mutation(source, "call_customer", "RequestAction") + if not allowed then + return rate_error + end + local request_id = type(data) == "table" and data.requestId or nil + if not valid_uuid(request_id) then + return { success = false, error = "invalid_request" } + end + local access, access_error = request_access(source, request_id) + if not access then + return access_error + end + if access.audience ~= "company" or not can_handle_request(access) or terminal_statuses[access.row.status] then + return { success = false, error = "not_authorized" } + end + if not SkyPhoneCompanies.CanPlaceCompanyCall(source, access.row.company_id) then + return { success = false, error = "not_authorized" } + end + local rows = Bridge.Database.Query([[ + SELECT sim.`phone_number` + FROM `sky_phone_company_requests` request + INNER JOIN `sky_phone_sims` sim ON sim.`id` = request.`customer_sim_id` + WHERE request.`id` = ? AND request.`company_id` = ? + LIMIT 1 + ]], { request_id, access.row.company_id }) + if not rows[1] then + return { success = false, error = "call_unavailable" } + end + local result = SkyPhoneCalls.StartCompanyCall(source, access.row.company_id, rows[1].phone_number) + if not result.success and (result.error == "recipient_unavailable" or result.error == "company_unavailable") then + return { success = false, error = "call_unavailable" } + end + return result +end) + +local function company_mutation_payload(source, company_id) + local company = company_payload(company_id, true) + if not company then + return nil + end + company.updatedAtUnix = nil + return { company = company, context = work_context(source) } +end + +local function audit_statement(company_id, identifier, action, target_type, target_id, mutation_token, audit_id) + return { + query = [[ + INSERT INTO `sky_phone_company_audit` + (`id`, `company_id`, `actor_identifier`, `action`, `target_type`, `target_id`, `metadata`) + SELECT ?, `company_id`, ?, ?, ?, ?, ? + FROM `sky_phone_company_profiles` + WHERE `company_id` = ? AND `mutation_token` = ? + ]], + params = { + audit_id, + identifier, + action, + target_type, + target_id, + json.encode({ revisionChecked = true }), + company_id, + mutation_token, + }, + } +end + +local function audit_committed(audit_id) + local rows = Bridge.Database.Query([[ + SELECT `id` FROM `sky_phone_company_audit` + WHERE `id` = ? + LIMIT 1 + ]], { audit_id }) + return rows[1] ~= nil +end + +Bridge.Callbacks.Register("sky_phone:companies:update-availability", function(source, data) + local allowed, rate_error = allow_mutation(source, "update_availability", "Profile") + if not allowed then + return rate_error + end + local member, member_error = require_permission(source, "Availability") + if not member then + return member_error + end + local availability = type(data) == "table" and data.availability or nil + local revision = type(data) == "table" and valid_integer(data.revision, 1, 4294967295) or nil + if not Config.Companies.AvailabilityStatuses[availability] or not revision then + return { success = false, error = "invalid_request" } + end + local expiry = parse_expiry(data.expiresAt, Config.Companies.AvailabilityMaximumSeconds) + if expiry == false then + return { success = false, error = "invalid_expiration" } + end + local mutation_token = uuid() + local audit_id = uuid() + local update_query + local update_params + if expiry then + update_query = [[ + UPDATE `sky_phone_company_profiles` + SET `availability` = ?, `availability_updated_by` = ?, + `availability_updated_at` = CURRENT_TIMESTAMP, + `availability_expires_at` = FROM_UNIXTIME(?), + `revision` = `revision` + 1, `mutation_token` = ? + WHERE `company_id` = ? AND `revision` = ? + ]] + update_params = { availability, member.identifier, expiry, mutation_token, member.company_id, revision } + else + update_query = [[ + UPDATE `sky_phone_company_profiles` + SET `availability` = ?, `availability_updated_by` = ?, + `availability_updated_at` = CURRENT_TIMESTAMP, `availability_expires_at` = NULL, + `revision` = `revision` + 1, `mutation_token` = ? + WHERE `company_id` = ? AND `revision` = ? + ]] + update_params = { availability, member.identifier, mutation_token, member.company_id, revision } + end + if not Bridge.Database.Transaction({ + { query = update_query, params = update_params }, + audit_statement( + member.company_id, + member.identifier, + "update_availability", + "profile", + member.company_id, + mutation_token, + audit_id + ), + }) then + return { success = false, error = "request_failed" } + end + if not audit_committed(audit_id) then + return { success = false, error = "revision_conflict" } + end + emit_public_change(member.company_id) + return { success = true, data = company_mutation_payload(source, member.company_id) } +end) + +local function media_id_for_profile(source, value) + local media_id = valid_integer(value, 1, 9007199254740991) + if not media_id or not SkyPhoneMedia.ResolveOwnedMedia(source, media_id, "photo") then + return nil + end + return media_id +end + +Bridge.Callbacks.Register("sky_phone:companies:update-profile", function(source, data) + local allowed, rate_error = allow_mutation(source, "update_profile", "Profile") + if not allowed then + return rate_error + end + local member, member_error = require_permission(source, "Profile") + if not member then + return member_error + end + if type(data) ~= "table" then + return { success = false, error = "invalid_request" } + end + local revision = valid_integer(data.revision, 1, 4294967295) + local description = valid_text(data.description, Config.Companies.ProfileDescriptionMaxLength, true) + local district = valid_text(data.district, Config.Companies.DistrictMaxLength, true) + local location_label = valid_text(data.locationLabel or "", Config.Companies.DistrictMaxLength, true) + local address = valid_text(data.address, Config.Companies.AddressMaxLength, true) + if not revision or not description or not district or not location_label or not address + or type(data.acceptsRequests) ~= "boolean" + or (member.definition.Emergency and data.acceptsRequests) + then + return { success = false, error = "invalid_profile" } + end + if data.phoneNumber ~= nil then + local number = SkyPhoneSimNumber.Normalize(data.phoneNumber, Config.Sim.NumberLength, Config.Sim.NumberPrefix) + if number ~= member.definition.ServiceLine.Number then + return { success = false, error = "invalid_profile" } + end + end + local set_parts = { + "`description` = ?", + "`district` = ?", + "`location_label` = ?", + "`address` = ?", + "`accepts_requests` = ?", + } + local parameters = { description, district, location_label, address, data.acceptsRequests and 1 or 0 } + if data.coords ~= nil then + if type(data.coords) ~= "table" then + return { success = false, error = "invalid_profile" } + 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 or math.abs(y) > 10000 or math.abs(z) > 2000 + then + return { success = false, error = "invalid_profile" } + end + set_parts[#set_parts + 1] = "`location_x` = ?" + set_parts[#set_parts + 1] = "`location_y` = ?" + set_parts[#set_parts + 1] = "`location_z` = ?" + parameters[#parameters + 1] = x + parameters[#parameters + 1] = y + parameters[#parameters + 1] = z + end + for _, media_field in ipairs({ + { input = "logoMediaId", column = "logo_media_id" }, + { input = "coverMediaId", column = "cover_media_id" }, + }) do + if data[media_field.input] ~= nil then + local media_id = media_id_for_profile(source, data[media_field.input]) + if not media_id then + return { success = false, error = "invalid_media" } + end + set_parts[#set_parts + 1] = ("`%s` = ?"):format(media_field.column) + parameters[#parameters + 1] = media_id + end + end + local mutation_token = uuid() + local audit_id = uuid() + set_parts[#set_parts + 1] = "`revision` = `revision` + 1" + set_parts[#set_parts + 1] = "`mutation_token` = ?" + parameters[#parameters + 1] = mutation_token + parameters[#parameters + 1] = member.company_id + parameters[#parameters + 1] = revision + if not Bridge.Database.Transaction({ + { + query = ("UPDATE `sky_phone_company_profiles` SET %s WHERE `company_id` = ? AND `revision` = ?") + :format(table.concat(set_parts, ", ")), + params = parameters, + }, + audit_statement( + member.company_id, + member.identifier, + "update_profile", + "profile", + member.company_id, + mutation_token, + audit_id + ), + }) then + return { success = false, error = "request_failed" } + end + if not audit_committed(audit_id) then + return { success = false, error = "revision_conflict" } + end + emit_public_change(member.company_id) + return { success = true, data = company_mutation_payload(source, member.company_id) } +end) + +local function valid_clock(value) + if type(value) ~= "string" then + return false + end + local hour, minute = value:match("^(%d%d):(%d%d)$") + hour = tonumber(hour) + minute = tonumber(minute) + return hour and minute and hour <= 23 and minute <= 59 +end + +Bridge.Callbacks.Register("sky_phone:companies:update-hours", function(source, data) + local allowed, rate_error = allow_mutation(source, "update_hours", "Profile") + if not allowed then + return rate_error + end + local member, member_error = require_permission(source, "Hours") + if not member then + return member_error + end + local revision = type(data) == "table" and valid_integer(data.revision, 1, 4294967295) or nil + local hours = type(data) == "table" and data.hours or nil + if not revision or not valid_array(hours, 7) then + return { success = false, error = "invalid_profile" } + end + local seen_days = {} + for _, entry in ipairs(hours) do + local day = type(entry) == "table" and valid_integer(entry.day, 0, 6) or nil + if not day or seen_days[day] or type(entry.isClosed) ~= "boolean" + or (not entry.isClosed and (not valid_clock(entry.opensAt) or not valid_clock(entry.closesAt) + or entry.opensAt == entry.closesAt)) + then + return { success = false, error = "invalid_profile" } + end + seen_days[day] = true + end + local mutation_token = uuid() + local audit_id = uuid() + local statements = { + { + query = [[ + UPDATE `sky_phone_company_profiles` + SET `revision` = `revision` + 1, `mutation_token` = ? + WHERE `company_id` = ? AND `revision` = ? + ]], + params = { mutation_token, member.company_id, revision }, + }, + { + query = [[ + DELETE hours FROM `sky_phone_company_hours` hours + INNER JOIN `sky_phone_company_profiles` profile ON profile.`company_id` = hours.`company_id` + WHERE hours.`company_id` = ? AND profile.`mutation_token` = ? + ]], + params = { member.company_id, mutation_token }, + }, + } + for _, entry in ipairs(hours) do + local query + local params + if entry.isClosed then + query = [[ + INSERT INTO `sky_phone_company_hours` (`company_id`, `weekday`, `is_closed`) + SELECT `company_id`, ?, 1 FROM `sky_phone_company_profiles` + WHERE `company_id` = ? AND `mutation_token` = ? + ]] + params = { entry.day, member.company_id, mutation_token } + else + query = [[ + INSERT INTO `sky_phone_company_hours` + (`company_id`, `weekday`, `is_closed`, `opens_at`, `closes_at`) + SELECT `company_id`, ?, 0, ?, ? FROM `sky_phone_company_profiles` + WHERE `company_id` = ? AND `mutation_token` = ? + ]] + params = { entry.day, entry.opensAt, entry.closesAt, member.company_id, mutation_token } + end + statements[#statements + 1] = { query = query, params = params } + end + statements[#statements + 1] = audit_statement( + member.company_id, + member.identifier, + "update_hours", + "hours", + member.company_id, + mutation_token, + audit_id + ) + if not Bridge.Database.Transaction(statements) then + return { success = false, error = "request_failed" } + end + if not audit_committed(audit_id) then + return { success = false, error = "revision_conflict" } + end + emit_public_change(member.company_id) + return { success = true, data = company_mutation_payload(source, member.company_id) } +end) + +Bridge.Callbacks.Register("sky_phone:companies:update-services", function(source, data) + local allowed, rate_error = allow_mutation(source, "update_services", "Profile") + if not allowed then + return rate_error + end + local member, member_error = require_permission(source, "Services") + if not member then + return member_error + end + local revision = type(data) == "table" and valid_integer(data.revision, 1, 4294967295) or nil + local services = type(data) == "table" and data.services or nil + if not revision or not valid_array(services, Config.Companies.MaximumServices) then + return { success = false, error = "invalid_profile" } + end + local existing_rows = Bridge.Database.Query( + "SELECT `id` FROM `sky_phone_company_services` WHERE `company_id` = ? AND `archived` = 0", + { member.company_id } + ) + local existing = {} + for _, row in ipairs(existing_rows) do + existing[row.id] = true + end + local normalized = {} + local seen = {} + for index, service in ipairs(services) do + if type(service) ~= "table" or type(service.active) ~= "boolean" + or type(service.acceptsRequests) ~= "boolean" + then + return { success = false, error = "invalid_profile" } + end + local title = valid_text(service.title, Config.Companies.ServiceTitleMaxLength, false) + local description = valid_text( + service.description or "", + Config.Companies.ServiceDescriptionMaxLength, + true + ) + local price = service.priceText == nil and "" + or valid_text(service.priceText, Config.Companies.ServicePriceMaxLength, true) + if not title or not description or not price then + return { success = false, error = "invalid_profile" } + end + local service_id = valid_service_id(service.id) and existing[service.id] and service.id or uuid() + if seen[service_id] then + return { success = false, error = "invalid_profile" } + end + seen[service_id] = true + normalized[#normalized + 1] = { + id = service_id, + title = title, + description = description, + price = price, + accepts_requests = service.acceptsRequests, + active = service.active, + sort_order = index, + } + end + local mutation_token = uuid() + local audit_id = uuid() + local statements = { + { + query = [[ + UPDATE `sky_phone_company_profiles` + SET `revision` = `revision` + 1, `mutation_token` = ? + WHERE `company_id` = ? AND `revision` = ? + ]], + params = { mutation_token, member.company_id, revision }, + }, + { + query = [[ + UPDATE `sky_phone_company_services` service + INNER JOIN `sky_phone_company_profiles` profile ON profile.`company_id` = service.`company_id` + SET service.`active` = 0, service.`requests_enabled` = 0, service.`archived` = 1 + WHERE service.`company_id` = ? AND profile.`mutation_token` = ? + ]], + params = { member.company_id, mutation_token }, + }, + } + for _, service in ipairs(normalized) do + statements[#statements + 1] = { + query = [[ + INSERT INTO `sky_phone_company_services` + (`id`, `company_id`, `title`, `description`, `price_text`, `requests_enabled`, `active`, `sort_order`) + SELECT ?, `company_id`, ?, ?, ?, ?, ?, ? FROM `sky_phone_company_profiles` + WHERE `company_id` = ? AND `mutation_token` = ? + ON DUPLICATE KEY UPDATE + `title` = VALUES(`title`), `description` = VALUES(`description`), + `price_text` = VALUES(`price_text`), `requests_enabled` = VALUES(`requests_enabled`), + `active` = VALUES(`active`), `archived` = 0, `sort_order` = VALUES(`sort_order`) + ]], + params = { + service.id, + service.title, + service.description, + service.price, + service.accepts_requests and 1 or 0, + service.active and 1 or 0, + service.sort_order, + member.company_id, + mutation_token, + }, + } + end + statements[#statements + 1] = audit_statement( + member.company_id, + member.identifier, + "update_services", + "services", + member.company_id, + mutation_token, + audit_id + ) + if not Bridge.Database.Transaction(statements) then + return { success = false, error = "request_failed" } + end + if not audit_committed(audit_id) then + return { success = false, error = "revision_conflict" } + end + emit_public_change(member.company_id) + return { success = true, data = company_mutation_payload(source, member.company_id) } +end) + +Bridge.Callbacks.Register("sky_phone:companies:publish-announcement", function(source, data) + local allowed, rate_error = allow_mutation(source, "publish_announcement", "Profile") + if not allowed then + return rate_error + end + local member, member_error = require_permission(source, "Announcement") + if not member then + return member_error + end + local revision = type(data) == "table" and valid_integer(data.revision, 1, 4294967295) or nil + local body = type(data) == "table" + and valid_text(data.body or "", Config.Companies.AnnouncementBodyMaxLength, true) or nil + if not revision or body == nil then + return { success = false, error = "invalid_profile" } + end + local expiry = parse_expiry(data.expiresAt, Config.Companies.AnnouncementMaximumSeconds) + if expiry == false then + return { success = false, error = "invalid_expiration" } + end + local mutation_token = uuid() + local audit_id = uuid() + local statements = { + { + query = [[ + UPDATE `sky_phone_company_profiles` + SET `revision` = `revision` + 1, `mutation_token` = ? + WHERE `company_id` = ? AND `revision` = ? + ]], + params = { mutation_token, member.company_id, revision }, + }, + { + query = [[ + UPDATE `sky_phone_company_announcements` announcement + INNER JOIN `sky_phone_company_profiles` profile ON profile.`company_id` = announcement.`company_id` + SET announcement.`active` = 0 + WHERE announcement.`company_id` = ? AND profile.`mutation_token` = ? + ]], + params = { member.company_id, mutation_token }, + }, + } + if body ~= "" then + local insert_query + local insert_params + if expiry then + insert_query = [[ + INSERT INTO `sky_phone_company_announcements` + (`id`, `company_id`, `title`, `body`, `created_by`, `expires_at`) + SELECT ?, `company_id`, '', ?, ?, FROM_UNIXTIME(?) FROM `sky_phone_company_profiles` + WHERE `company_id` = ? AND `mutation_token` = ? + ]] + insert_params = { uuid(), body, member.identifier, expiry, member.company_id, mutation_token } + else + insert_query = [[ + INSERT INTO `sky_phone_company_announcements` + (`id`, `company_id`, `title`, `body`, `created_by`) + SELECT ?, `company_id`, '', ?, ? FROM `sky_phone_company_profiles` + WHERE `company_id` = ? AND `mutation_token` = ? + ]] + insert_params = { uuid(), body, member.identifier, member.company_id, mutation_token } + end + statements[#statements + 1] = { query = insert_query, params = insert_params } + end + statements[#statements + 1] = audit_statement( + member.company_id, + member.identifier, + "publish_announcement", + "announcement", + member.company_id, + mutation_token, + audit_id + ) + if not Bridge.Database.Transaction(statements) then + return { success = false, error = "request_failed" } + end + if not audit_committed(audit_id) then + return { success = false, error = "revision_conflict" } + end + emit_public_change(member.company_id) + return { success = true, data = company_mutation_payload(source, member.company_id) } +end) + +Bridge.Callbacks.Register("sky_phone:companies:set-call-availability", function(source, data) + local allowed, rate_error = allow_mutation(source, "set_call_availability", "CallAvailability") + if not allowed then + return rate_error + end + if type(data) ~= "table" or type(data.available) ~= "boolean" then + return { success = false, error = "invalid_request" } + end + if not data.available then + SkyPhoneCompanies.ClearCallAvailability(source) + return { success = true, data = { context = work_context(source) } } + end + local member = call_member(source) + if not member or not member.definition.ServiceLine.CanCall then + return { success = false, error = "not_authorized" } + end + local device, device_error = current_device(source, true) + if not device then + return device_error + end + call_availability[source] = { + company_id = member.company_id, + sim_id = device.sim_id, + imei = device.imei, + } + return { success = true, data = { context = work_context(source) } } +end) + +AddEventHandler("playerDropped", function() + SkyPhoneCompanies.ClearCallAvailability(source) + member_tokens[source] = nil +end) + +AddEventHandler("onResourceStop", function(resource_name) + if resource_name ~= GetCurrentResourceName() then + return + end + call_availability = {} + round_robin_positions = {} + member_tokens = {} +end) +end) diff --git a/sky_phone/source/server/crewlink.lua b/sky_phone/source/server/crewlink.lua index 7dca0f3..0bb5cfe 100644 --- a/sky_phone/source/server/crewlink.lua +++ b/sky_phone/source/server/crewlink.lua @@ -1089,51 +1089,39 @@ Bridge.Callbacks.Register("sky_phone:crewlink:live", function(source) if not profile then return error_response end - if not profile.active_group_id or not membership(profile.id, profile.active_group_id) then - return { success = true, data = { members = {}, pings = {} } } + if not profile.active_group_id then + return { success = true, data = { members = {}, overheadMembers = {}, pings = {} } } + end + local active_membership = membership(profile.id, profile.active_group_id) + if not active_membership then + return { success = true, data = { members = {}, overheadMembers = {}, pings = {} } } + end + local members = live_group(profile.active_group_id) + local overhead_members = {} + if tonumber(profile.overhead_visible) == 1 + and tonumber(active_membership.overhead_allowed) == 1 + then + for _, member in ipairs(members) do + if member.source and member.source ~= source and member.overheadVisible then + overhead_members[#overhead_members + 1] = { + source = member.source, + username = member.username, + role = member.role, + roleLabel = member.role:sub(1, 1):upper() .. member.role:sub(2), + } + end + end end return { success = true, data = { - members = live_group(profile.active_group_id), + members = members, + overheadMembers = overhead_members, pings = active_pings(profile.active_group_id), }, } end) -Bridge.Callbacks.Register("sky_phone:crewlink:overhead", function(source) - if not SkyPhone.AllowOperation( - source, - "crewlink:overhead", - Config.CrewLink.LiveRequestsPerMinute, - 60 - ) then - return { success = false, error = "rate_limited" } - end - local profile = require_profile(source) - if not profile or not profile.active_group_id or tonumber(profile.overhead_visible) ~= 1 then - return { success = true, data = { members = {} } } - end - local active_membership = membership(profile.id, profile.active_group_id) - if not active_membership or tonumber(active_membership.overhead_allowed) ~= 1 then - return { success = true, data = { members = {} } } - end - local live_sources = live_sources_by_account() - local members = {} - for _, member in ipairs(member_dtos(profile.active_group_id)) do - local member_source = live_sources[member.account_id] - if member_source and member_source ~= source and member.overheadVisible then - members[#members + 1] = { - source = member_source, - username = member.username, - role = member.role, - roleLabel = member.role:sub(1, 1):upper() .. member.role:sub(2), - } - end - end - return { success = true, data = { members = members } } -end) - exports("GetCrewLinkActiveGroup", function(source) local profile = require_profile(source) if not profile or not profile.active_group_id then diff --git a/sky_phone/source/server/custom_app_compat.lua b/sky_phone/source/server/custom_app_compat.lua new file mode 100644 index 0000000..022c21b --- /dev/null +++ b/sky_phone/source/server/custom_app_compat.lua @@ -0,0 +1,139 @@ +local RESOURCE_NAME = GetCurrentResourceName() +local SNAPSHOT_COOLDOWN_MS = 2000 +local SNAPSHOT_REJECTION_LOG_COOLDOWN_MS = 10000 +local registered_apps = {} +local snapshot_requests = {} +local snapshot_rejection_logs = {} + +local function get_calling_resource(export_name) + local owner_resource = GetInvokingResource() + if owner_resource then + return owner_resource + end + + print(("[%s] %s rejected: the export must be called by another resource."):format( + RESOURCE_NAME, + export_name + )) + return nil, "invalid_owner" +end + +local function add_high_application(app_name, data, locales) + local owner_resource, owner_error = get_calling_resource("addApplication") + if not owner_resource then + return false, owner_error + end + + local definition, definition_error = SkyPhoneCompatibility.BuildHighDefinition( + owner_resource, + app_name, + data, + locales + ) + if not definition then + print(("[%s] High Phone registration rejected for %s: %s."):format( + RESOURCE_NAME, + owner_resource, + definition_error + )) + return false, definition_error + end + + local existing = registered_apps[app_name] + if existing and existing.owner_resource ~= owner_resource then + return false, "duplicate_app_id" + end + + local revision = existing and existing.revision + 1 or 1 + registered_apps[app_name] = { + definition = definition, + owner_resource = owner_resource, + revision = revision, + } + TriggerClientEvent( + "sky_phone:compat:high:client:syncApplication", + -1, + owner_resource, + definition, + revision + ) + return true +end + +local function build_snapshot() + local ids = {} + for app_id in pairs(registered_apps) do + ids[#ids + 1] = app_id + end + table.sort(ids) + + local snapshot = {} + for index = 1, #ids do + snapshot[index] = registered_apps[ids[index]] + end + return snapshot +end + +RegisterNetEvent("sky_phone:compat:high:server:requestSnapshot", function() + local player_source = source + if player_source <= 0 then + print(("[%s] Rejected High Phone snapshot request without a player source."):format( + RESOURCE_NAME + )) + return + end + + local now = GetGameTimer() + local last_request = snapshot_requests[player_source] + local elapsed = last_request and now - last_request or SNAPSHOT_COOLDOWN_MS + if elapsed >= 0 and elapsed < SNAPSHOT_COOLDOWN_MS then + local last_log = snapshot_rejection_logs[player_source] + local log_elapsed = last_log and now - last_log or SNAPSHOT_REJECTION_LOG_COOLDOWN_MS + if log_elapsed < 0 or log_elapsed >= SNAPSHOT_REJECTION_LOG_COOLDOWN_MS then + snapshot_rejection_logs[player_source] = now + print(("[%s] Rate-limited High Phone snapshot request from player %s."):format( + RESOURCE_NAME, + player_source + )) + end + return + end + snapshot_requests[player_source] = now + + TriggerClientEvent( + "sky_phone:compat:high:client:replaceSnapshot", + player_source, + build_snapshot() + ) +end) + +AddEventHandler("playerDropped", function() + snapshot_requests[source] = nil + snapshot_rejection_logs[source] = nil +end) + +AddEventHandler("onResourceStop", function(resource_name) + if resource_name == RESOURCE_NAME then + return + end + + local removed_ids = {} + for app_id, record in pairs(registered_apps) do + if record.owner_resource == resource_name then + removed_ids[#removed_ids + 1] = app_id + end + end + for index = 1, #removed_ids do + local app_id = removed_ids[index] + registered_apps[app_id] = nil + TriggerClientEvent( + "sky_phone:compat:high:client:removeApplication", + -1, + resource_name, + app_id + ) + end +end) + +exports("addApplication", add_high_application) +SkyPhoneCompatibility.RegisterExportAlias("high-phone", "addApplication", add_high_application) diff --git a/sky_phone/source/server/custom_app_storage.lua b/sky_phone/source/server/custom_app_storage.lua new file mode 100644 index 0000000..c777e39 --- /dev/null +++ b/sky_phone/source/server/custom_app_storage.lua @@ -0,0 +1,271 @@ +Bridge.Database.AfterMigration("sky_phone", function() +local STORAGE_JSON_MAX_DEPTH = 8 +local STORAGE_JSON_MAX_NODES = 512 +local STORAGE_KEY_MAX_LENGTH = math.min(Config.CustomApps.MaximumStorageKeyLength, 64) +local STORAGE_VALUE_MAX_BYTES = math.min(Config.CustomApps.MaximumStorageValueBytes, 65536) +local storage_write_locks = {} + +local function affected_rows(result) + if type(result) == "number" then + return result + end + + return type(result) == "table" and tonumber(result.affectedRows) or 0 +end + +local function validate_storage_json(value, depth, state) + state.nodes = state.nodes + 1 + if state.nodes > STORAGE_JSON_MAX_NODES or depth > STORAGE_JSON_MAX_DEPTH then + return false + end + + local value_type = type(value) + if value_type == "nil" or value_type == "boolean" then + return true + end + if value_type == "number" then + return value == value and value ~= math.huge and value ~= -math.huge + end + if value_type == "string" then + return #value <= STORAGE_VALUE_MAX_BYTES + end + if value_type ~= "table" or state.seen[value] then + return false + end + + state.seen[value] = true + local key_type = nil + local numeric_keys = 0 + local value_count = 0 + for key, nested_value in pairs(value) do + value_count = value_count + 1 + local current_key_type = type(key) + if current_key_type == "number" then + if key < 1 or key % 1 ~= 0 then + state.seen[value] = nil + return false + end + numeric_keys = numeric_keys + 1 + elseif current_key_type ~= "string" + or #key == 0 + or #key > 64 + or key == "__proto__" + or key == "constructor" + or key == "prototype" + then + state.seen[value] = nil + return false + end + + if key_type and key_type ~= current_key_type then + state.seen[value] = nil + return false + end + key_type = current_key_type + + if not validate_storage_json(nested_value, depth + 1, state) then + state.seen[value] = nil + return false + end + end + + if key_type == "number" and (numeric_keys ~= value_count or #value ~= value_count) then + state.seen[value] = nil + return false + end + + state.seen[value] = nil + return true +end + +local function with_storage_write_lock(lock_key, callback) + local previous_lock = storage_write_locks[lock_key] + local current_lock = promise.new() + storage_write_locks[lock_key] = current_lock + + if previous_lock then + Citizen.Await(previous_lock) + end + + local success, result = xpcall(callback, debug.traceback) + current_lock:resolve(true) + if storage_write_locks[lock_key] == current_lock then + storage_write_locks[lock_key] = nil + end + if not success then + error(result, 0) + end + return result +end + +local function validate_request(source, data, operation) + if not Config.CustomApps.Enabled then + return nil, nil, { success = false, error = "custom_apps_disabled" } + end + if not SkyPhone.AllowOperation( + source, + "custom_app_storage_" .. operation, + Config.CustomApps.StorageRequestsPerMinute, + 60 + ) then + return nil, nil, { success = false, error = "rate_limited" } + end + + local session, error_response = SkyPhone.RequireSession(source) + if not session then + return nil, nil, error_response + end + if type(data) ~= "table" or not SkyPhoneApps.HasPermission(data.appId, "device.storage") then + return nil, nil, { success = false, error = "storage_not_allowed" } + end + if type(data.key) ~= "string" + or #data.key == 0 + or #data.key > STORAGE_KEY_MAX_LENGTH + or not data.key:match("^[%w._-]+$") + then + return nil, nil, { success = false, error = "invalid_storage_key" } + end + + return session, data, nil +end + +local function read_row(imei, app_id, data_key) + local rows = Bridge.Database.Query([[ + SELECT `payload`, `revision` + FROM `sky_phone_custom_app_data` + WHERE `device_imei` = ? AND `app_id` = ? AND `data_key` = ? + LIMIT 1 + ]], { imei, app_id, data_key }) + return rows[1] +end + +local function conflict_response(row) + if not row then + return { + success = false, + error = "storage_conflict", + data = { exists = false, revision = 0 }, + } + end + + return { + success = false, + error = "storage_conflict", + data = { + exists = true, + revision = tonumber(row.revision) or 0, + value = json.decode(row.payload), + }, + } +end + +Bridge.Callbacks.Register("sky_phone:custom-app:storage:get", function(source, data) + local session, request, error_response = validate_request(source, data, "get") + if not session then + return error_response + end + + local row = read_row(session.imei, request.appId, request.key) + if not row then + return { success = true, data = { exists = false, revision = 0 } } + end + + return { + success = true, + data = { + exists = true, + revision = tonumber(row.revision) or 0, + value = json.decode(row.payload), + }, + } +end) + +Bridge.Callbacks.Register("sky_phone:custom-app:storage:set", function(source, data) + local session, request, error_response = validate_request(source, data, "set") + if not session then + return error_response + end + if request.value == nil then + return { success = false, error = "invalid_storage_value" } + end + + local expected_revision = tonumber(request.revision) + if not expected_revision + or expected_revision ~= math.floor(expected_revision) + or expected_revision < 0 + or expected_revision > 4294967294 + then + return { success = false, error = "invalid_storage_revision" } + end + + if not validate_storage_json(request.value, 0, { nodes = 0, seen = {} }) then + return { success = false, error = "invalid_storage_value" } + end + + local encoded, payload = pcall(json.encode, request.value) + if not encoded + or type(payload) ~= "string" + or #payload > STORAGE_VALUE_MAX_BYTES + then + return { success = false, error = "storage_value_too_large" } + end + + local lock_key = session.imei .. "\0" .. request.appId + return with_storage_write_lock(lock_key, function() + local current_session, session_error = SkyPhone.RequireSession(source) + if not current_session then + return session_error + end + if current_session.imei ~= session.imei + or not SkyPhoneApps.HasPermission(request.appId, "device.storage") + then + return { success = false, error = "storage_not_allowed" } + end + + local current = read_row(session.imei, request.appId, request.key) + local current_revision = current and tonumber(current.revision) or 0 + if current_revision ~= expected_revision then + return conflict_response(current) + end + + local totals = Bridge.Database.Query([[ + SELECT COALESCE(SUM(OCTET_LENGTH(`payload`)), 0) AS `total`, COUNT(*) AS `keys` + FROM `sky_phone_custom_app_data` + WHERE `device_imei` = ? AND `app_id` = ? + ]], { session.imei, request.appId }) + local current_size = current and #current.payload or 0 + local next_total = (tonumber(totals[1] and totals[1].total) or 0) - current_size + #payload + if next_total > Config.CustomApps.MaximumStorageBytesPerApp then + return { success = false, error = "storage_quota_exceeded" } + end + if not current + and (tonumber(totals[1] and totals[1].keys) or 0) >= Config.CustomApps.MaximumStorageKeysPerApp + then + return { success = false, error = "storage_key_limit" } + end + + if expected_revision == 0 then + local inserted = Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_custom_app_data` + (`device_imei`, `app_id`, `data_key`, `payload`, `revision`) + VALUES (?, ?, ?, ?, 1) + ]], { session.imei, request.appId, request.key, payload }) + if affected_rows(inserted) ~= 1 then + return conflict_response(read_row(session.imei, request.appId, request.key)) + end + return { success = true, data = { revision = 1 } } + end + + local updated = Bridge.Database.Query([[ + UPDATE `sky_phone_custom_app_data` + SET `payload` = ?, `revision` = `revision` + 1 + WHERE `device_imei` = ? AND `app_id` = ? AND `data_key` = ? AND `revision` = ? + ]], { payload, session.imei, request.appId, request.key, expected_revision }) + if affected_rows(updated) ~= 1 then + return conflict_response(read_row(session.imei, request.appId, request.key)) + end + + return { success = true, data = { revision = expected_revision + 1 } } + end) +end) +end) diff --git a/sky_phone/source/server/custom_apps.lua b/sky_phone/source/server/custom_apps.lua new file mode 100644 index 0000000..e53219b --- /dev/null +++ b/sky_phone/source/server/custom_apps.lua @@ -0,0 +1,385 @@ +local policies_by_id = {} +local policy_ids_by_adapter = {} +local policy_ids_by_owner = {} +local current_resource = GetCurrentResourceName() + +local function validate_owner_resource(owner_resource, allow_stopping) + if type(owner_resource) ~= "string" + or #owner_resource == 0 + or #owner_resource > 64 + or not owner_resource:match("^[%w][%w._-]*$") + then + return false, "invalid_owner_resource" + end + + local state = GetResourceState(owner_resource) + if state == "started" or state == "starting" or (allow_stopping and state == "stopping") then + return true + end + + return false, "owner_resource_not_running" +end + +local function add_owner_index(index, resource_name, app_id) + local app_ids = index[resource_name] + if not app_ids then + app_ids = {} + index[resource_name] = app_ids + end + app_ids[app_id] = true +end + +local function remove_owner_index(index, resource_name, app_id) + local app_ids = index[resource_name] + if not app_ids then + return + end + + app_ids[app_id] = nil + if not next(app_ids) then + index[resource_name] = nil + end +end + +local function build_permission_set(permissions) + local permission_set = {} + for index = 1, #permissions do + permission_set[permissions[index]] = true + end + return permission_set +end + +local function register_policy(policy) + local app_id = policy.id + local existing = policies_by_id[app_id] + if existing then + print(( + "[sky_phone] Rejected duplicate custom app policy '%s' from '%s'; it is already owned by '%s'." + ):format(app_id, policy.ownerResource, existing.ownerResource)) + return false, "duplicate_app_id" + end + + policies_by_id[app_id] = policy + add_owner_index(policy_ids_by_owner, policy.ownerResource, app_id) + if policy.adapterResource then + add_owner_index(policy_ids_by_adapter, policy.adapterResource, app_id) + end + return true +end + +local function remove_policy(app_id) + local policy = policies_by_id[app_id] + if not policy then + return false, "app_not_found" + end + + policies_by_id[app_id] = nil + remove_owner_index(policy_ids_by_owner, policy.ownerResource, app_id) + if policy.adapterResource then + remove_owner_index(policy_ids_by_adapter, policy.adapterResource, app_id) + end + return true +end + +local function get_direct_owner() + local owner_resource = GetInvokingResource() + if not owner_resource then + return nil, "missing_invoking_resource" + end + + local valid_owner, owner_error = validate_owner_resource(owner_resource, false) + if not valid_owner then + return nil, owner_error + end + + return owner_resource +end + +local function get_trusted_adapter() + local adapter_resource = GetInvokingResource() + if not adapter_resource or not Config.CustomApps.TrustedAdapters[adapter_resource] then + return nil, "untrusted_adapter" + end + + local valid_adapter, adapter_error = validate_owner_resource(adapter_resource, false) + if not valid_adapter then + return nil, adapter_error + end + + return adapter_resource +end + +local function normalize_policy(owner_resource, adapter_resource, definition) + if type(definition) ~= "table" then + return nil, "invalid_definition" + end + if definition.schemaVersion ~= nil and definition.schemaVersion ~= SkyPhoneApps.ProtocolVersion then + return nil, "unsupported_schema_version" + end + + local valid_id, id_error = SkyPhoneApps.ValidateAppId(definition.id) + if not valid_id then + return nil, id_error + end + if SkyPhoneApps.ReservedAppIds[definition.id] then + return nil, "reserved_app_id" + end + + local permissions, permissions_error = SkyPhoneApps.ValidatePermissions(definition.permissions) + if not permissions then + return nil, permissions_error + end + + return { + adapterResource = adapter_resource, + bundled = false, + id = definition.id, + ownerResource = owner_resource, + permissions = build_permission_set(permissions), + } +end + +local function add_custom_app_policy(definition) + if not Config.CustomApps.Enabled or not Config.CustomApps.ExternalApps then + return false, "external_apps_disabled" + end + + local owner_resource, owner_error = get_direct_owner() + if not owner_resource then + return false, owner_error + end + + local policy, policy_error = normalize_policy(owner_resource, nil, definition) + if not policy then + return false, policy_error + end + return register_policy(policy) +end + +local function add_custom_app_policy_from_adapter(owner_resource, definition) + if not Config.CustomApps.Enabled or not Config.CustomApps.ExternalApps then + return false, "external_apps_disabled" + end + + local adapter_resource, adapter_error = get_trusted_adapter() + if not adapter_resource then + return false, adapter_error + end + local valid_owner, owner_error = validate_owner_resource(owner_resource, false) + if not valid_owner then + return false, owner_error + end + + local policy, policy_error = normalize_policy(owner_resource, adapter_resource, definition) + if not policy then + return false, policy_error + end + return register_policy(policy) +end + +local function verify_owned_policy(owner_resource, app_id, adapter_resource) + local valid_id, id_error = SkyPhoneApps.ValidateAppId(app_id) + if not valid_id then + return nil, id_error + end + + local policy = policies_by_id[app_id] + if not policy then + return nil, "app_not_found" + end + if policy.ownerResource ~= owner_resource then + return nil, "app_owner_mismatch" + end + if adapter_resource and policy.adapterResource ~= adapter_resource then + return nil, "app_adapter_mismatch" + end + + return policy +end + +local function replace_policy(policy, adapter_resource) + local existing, policy_error = verify_owned_policy( + policy.ownerResource, + policy.id, + adapter_resource + ) + if not existing then + return false, policy_error + end + if existing.bundled then + return false, "bundled_app" + end + + policies_by_id[policy.id] = policy + return true +end + +local function update_custom_app_policy(definition) + if not Config.CustomApps.Enabled or not Config.CustomApps.ExternalApps then + return false, "external_apps_disabled" + end + + local owner_resource, owner_error = get_direct_owner() + if not owner_resource then + return false, owner_error + end + + local policy, policy_error = normalize_policy(owner_resource, nil, definition) + if not policy then + return false, policy_error + end + return replace_policy(policy, nil) +end + +local function update_custom_app_policy_from_adapter(owner_resource, definition) + if not Config.CustomApps.Enabled or not Config.CustomApps.ExternalApps then + return false, "external_apps_disabled" + end + + local adapter_resource, adapter_error = get_trusted_adapter() + if not adapter_resource then + return false, adapter_error + end + local valid_owner, owner_error = validate_owner_resource(owner_resource, false) + if not valid_owner then + return false, owner_error + end + + local policy, policy_error = normalize_policy(owner_resource, adapter_resource, definition) + if not policy then + return false, policy_error + end + return replace_policy(policy, adapter_resource) +end + +local function remove_custom_app_policy(app_id) + local owner_resource, owner_error = get_direct_owner() + if not owner_resource then + return false, owner_error + end + + local policy, policy_error = verify_owned_policy(owner_resource, app_id) + if not policy then + return false, policy_error + end + if policy.bundled then + return false, "bundled_app" + end + + return remove_policy(app_id) +end + +local function remove_custom_app_policy_from_adapter(owner_resource, app_id) + local adapter_resource, adapter_error = get_trusted_adapter() + if not adapter_resource then + return false, adapter_error + end + + local policy, policy_error = verify_owned_policy(owner_resource, app_id, adapter_resource) + if not policy then + return false, policy_error + end + return remove_policy(app_id) +end + +local function has_custom_app_permission(app_id, permission) + local valid_id = SkyPhoneApps.ValidateAppId(app_id) + if not valid_id or type(permission) ~= "string" or not SkyPhoneApps.AllowedPermissions[permission] then + return false + end + + local policy = policies_by_id[app_id] + return policy ~= nil and policy.permissions[permission] == true +end + +local function get_custom_app_policy(app_id) + local valid_id = SkyPhoneApps.ValidateAppId(app_id) + if not valid_id then + return nil + end + + local policy = policies_by_id[app_id] + if not policy then + return nil + end + + local permissions = {} + for permission in pairs(policy.permissions) do + permissions[#permissions + 1] = permission + end + table.sort(permissions) + return { + bundled = policy.bundled, + id = policy.id, + ownerResource = policy.ownerResource, + permissions = permissions, + } +end + +local function get_custom_app_capabilities() + return { + abiVersion = 1, + enabled = Config.CustomApps.Enabled, + policyRegistration = true, + policyUpdate = true, + protocolVersion = SkyPhoneApps.ProtocolVersion, + } +end + +SkyPhoneApps.GetPolicy = get_custom_app_policy +SkyPhoneApps.HasPermission = has_custom_app_permission + +if Config.CustomApps.Enabled and Config.CustomApps.BundledApps then + local bundled = SkyPhoneApps.GetBundledManifests() + for index = 1, #bundled do + local manifest = bundled[index] + local registered, register_error = register_policy({ + adapterResource = nil, + bundled = true, + id = manifest.id, + ownerResource = current_resource, + permissions = build_permission_set(manifest.permissions), + }) + if not registered then + error(("[sky_phone] Could not register bundled custom app policy '%s': %s"):format( + manifest.id, + register_error + )) + end + end +end + +exports("AddCustomAppPolicy", add_custom_app_policy) +exports("AddCustomAppPolicyFromAdapter", add_custom_app_policy_from_adapter) +exports("GetCustomAppCapabilities", get_custom_app_capabilities) +exports("GetCustomAppPolicy", get_custom_app_policy) +exports("HasCustomAppPermission", has_custom_app_permission) +exports("RemoveCustomAppPolicy", remove_custom_app_policy) +exports("RemoveCustomAppPolicyFromAdapter", remove_custom_app_policy_from_adapter) +exports("UpdateCustomAppPolicy", update_custom_app_policy) +exports("UpdateCustomAppPolicyFromAdapter", update_custom_app_policy_from_adapter) + +AddEventHandler("onResourceStop", function(resource_name) + if resource_name == current_resource then + return + end + + local app_ids = {} + local owner_policies = policy_ids_by_owner[resource_name] + if owner_policies then + for app_id in pairs(owner_policies) do + app_ids[app_id] = true + end + end + + local adapter_policies = policy_ids_by_adapter[resource_name] + if adapter_policies then + for app_id in pairs(adapter_policies) do + app_ids[app_id] = true + end + end + + for app_id in pairs(app_ids) do + remove_policy(app_id) + end +end) diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua index 59ac7e8..aae4a3c 100644 --- a/sky_phone/source/server/db_migrate.lua +++ b/sky_phone/source/server/db_migrate.lua @@ -242,6 +242,56 @@ local schema = { }, tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", }, + { + name = "sky_phone_custom_app_data", + columns = { + { name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" }, + { + name = "device_imei", + type = "CHAR(15) NOT NULL", + characterSet = "ascii", + collation = "ascii_bin", + }, + { + name = "app_id", + type = "VARCHAR(64) NOT NULL", + characterSet = "ascii", + collation = "ascii_bin", + }, + { + name = "data_key", + type = "VARCHAR(64) NOT NULL", + characterSet = "ascii", + collation = "ascii_bin", + }, + { name = "payload", type = "LONGTEXT NOT NULL" }, + { name = "revision", type = "INT UNSIGNED NOT NULL DEFAULT 1" }, + { + name = "updated_at", + type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP", + }, + }, + primaryKey = "id", + uniqueKeys = { + { + name = "uniq_sky_phone_custom_app_data", + columns = "(`device_imei`, `app_id`, `data_key`)", + }, + }, + indexes = { + { + name = "idx_sky_phone_custom_app_storage", + columns = "(`device_imei`, `app_id`, `updated_at`)", + }, + }, + foreignKeys = { + { + column = "device_imei", + references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE", + }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, { name = "sky_phone_device_security", columns = { @@ -2128,6 +2178,39 @@ local schema = { }, tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", }, + { + name = "sky_phone_company_profiles", + columns = { + { name = "company_id", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "description", type = "VARCHAR(1000) NOT NULL DEFAULT ''" }, + { name = "district", type = "VARCHAR(80) NOT NULL DEFAULT ''" }, + { name = "location_label", type = "VARCHAR(80) NOT NULL DEFAULT ''" }, + { name = "address", type = "VARCHAR(160) NOT NULL DEFAULT ''" }, + { name = "location_x", type = "DECIMAL(10,3) NULL" }, + { name = "location_y", type = "DECIMAL(10,3) NULL" }, + { name = "location_z", type = "DECIMAL(10,3) NULL" }, + { name = "logo_media_id", type = "BIGINT UNSIGNED NULL" }, + { name = "cover_media_id", type = "BIGINT UNSIGNED NULL" }, + { name = "availability", type = "ENUM('available','busy','closed') NOT NULL DEFAULT 'closed'" }, + { name = "availability_updated_by", type = "VARCHAR(80) NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "availability_updated_at", type = "DATETIME NULL" }, + { name = "availability_expires_at", type = "DATETIME NULL" }, + { name = "accepts_requests", type = "TINYINT(1) NOT NULL DEFAULT 1" }, + { name = "revision", type = "INT UNSIGNED NOT NULL DEFAULT 1" }, + { name = "mutation_token", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + { name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }, + }, + primaryKey = "company_id", + indexes = { + { name = "idx_sky_phone_company_profiles_public", columns = "(`availability`, `updated_at`)" }, + }, + foreignKeys = { + { column = "logo_media_id", references = "`sky_phone_media` (`id`) ON DELETE SET NULL" }, + { column = "cover_media_id", references = "`sky_phone_media` (`id`) ON DELETE SET NULL" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, { name = "sky_phone_easyshare_transfers", columns = { @@ -2155,6 +2238,209 @@ local schema = { }, tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", }, + { + name = "sky_phone_company_hours", + columns = { + { name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" }, + { name = "company_id", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "weekday", type = "TINYINT UNSIGNED NOT NULL" }, + { name = "is_closed", type = "TINYINT(1) NOT NULL DEFAULT 0" }, + { name = "opens_at", type = "CHAR(5) NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "closes_at", type = "CHAR(5) NULL", characterSet = "ascii", collation = "ascii_bin" }, + }, + primaryKey = "id", + uniqueKeys = { + { name = "uniq_sky_phone_company_hours_day", columns = "(`company_id`, `weekday`)" }, + }, + foreignKeys = { + { column = "company_id", references = "`sky_phone_company_profiles` (`company_id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_company_services", + columns = { + { name = "id", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "company_id", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "title", type = "VARCHAR(80) NOT NULL" }, + { name = "description", type = "VARCHAR(500) NOT NULL DEFAULT ''" }, + { name = "price_text", type = "VARCHAR(80) NOT NULL DEFAULT ''" }, + { name = "requests_enabled", type = "TINYINT(1) NOT NULL DEFAULT 1" }, + { name = "active", type = "TINYINT(1) NOT NULL DEFAULT 1" }, + { name = "archived", type = "TINYINT(1) NOT NULL DEFAULT 0" }, + { name = "sort_order", type = "TINYINT UNSIGNED NOT NULL DEFAULT 0" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + { name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + indexes = { + { name = "idx_sky_phone_company_services_list", columns = "(`company_id`, `archived`, `active`, `sort_order`)" }, + }, + foreignKeys = { + { column = "company_id", references = "`sky_phone_company_profiles` (`company_id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_company_announcements", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "company_id", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "title", type = "VARCHAR(120) NOT NULL" }, + { name = "body", type = "VARCHAR(1000) NOT NULL" }, + { name = "created_by", type = "VARCHAR(80) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "active", type = "TINYINT(1) NOT NULL DEFAULT 1" }, + { name = "expires_at", type = "DATETIME NULL" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + { name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + indexes = { + { name = "idx_sky_phone_company_announcements_active", columns = "(`company_id`, `active`, `expires_at`, `created_at`)" }, + }, + foreignKeys = { + { column = "company_id", references = "`sky_phone_company_profiles` (`company_id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_company_requests", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "company_id", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "service_id", type = "VARCHAR(64) NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "customer_sim_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "subject", type = "VARCHAR(120) NOT NULL" }, + { name = "description", type = "VARCHAR(2000) NOT NULL" }, + { name = "status", type = "ENUM('new','assigned','in_progress','waiting_customer','completed','cancelled') NOT NULL DEFAULT 'new'" }, + { name = "assigned_identifier", type = "VARCHAR(80) NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "customer_unread", type = "INT UNSIGNED NOT NULL DEFAULT 0" }, + { name = "company_activity_revision", type = "INT UNSIGNED NOT NULL DEFAULT 1" }, + { name = "revision", type = "INT UNSIGNED NOT NULL DEFAULT 1" }, + { name = "mutation_token", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + { name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }, + { name = "completed_at", type = "DATETIME NULL" }, + { name = "cancelled_at", type = "DATETIME NULL" }, + }, + primaryKey = "id", + indexes = { + { name = "idx_sky_phone_company_requests_customer", columns = "(`customer_sim_id`, `updated_at`, `id`)" }, + { name = "idx_sky_phone_company_requests_queue", columns = "(`company_id`, `status`, `updated_at`, `id`)" }, + { name = "idx_sky_phone_company_requests_assignee", columns = "(`company_id`, `assigned_identifier`, `status`)" }, + }, + foreignKeys = { + { column = "company_id", references = "`sky_phone_company_profiles` (`company_id`) ON DELETE CASCADE" }, + { column = "service_id", references = "`sky_phone_company_services` (`id`) ON DELETE SET NULL" }, + { column = "customer_sim_id", references = "`sky_phone_sims` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_company_request_reads", + columns = { + { name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" }, + { name = "request_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "reader_identifier", type = "VARCHAR(80) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "read_revision", type = "INT UNSIGNED NOT NULL DEFAULT 0" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + { name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + uniqueKeys = { + { name = "uniq_sky_phone_company_request_reads_reader", columns = "(`request_id`, `reader_identifier`)" }, + }, + indexes = { + { name = "idx_sky_phone_company_request_reads_identifier", columns = "(`reader_identifier`, `updated_at`)" }, + }, + foreignKeys = { + { column = "request_id", references = "`sky_phone_company_requests` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_company_request_media", + columns = { + { name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" }, + { name = "request_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "media_id", type = "BIGINT UNSIGNED NOT NULL" }, + { name = "sort_order", type = "TINYINT UNSIGNED NOT NULL DEFAULT 0" }, + }, + primaryKey = "id", + uniqueKeys = { + { name = "uniq_sky_phone_company_request_media", columns = "(`request_id`, `media_id`)" }, + { name = "uniq_sky_phone_company_request_media_order", columns = "(`request_id`, `sort_order`)" }, + }, + foreignKeys = { + { column = "request_id", references = "`sky_phone_company_requests` (`id`) ON DELETE CASCADE" }, + { column = "media_id", references = "`sky_phone_media` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_company_request_messages", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "request_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "sender_type", type = "ENUM('customer','company') NOT NULL" }, + { name = "sender_identifier", type = "VARCHAR(80) NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "sender_sim_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "body", type = "VARCHAR(2000) NOT NULL" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + indexes = { + { name = "idx_sky_phone_company_request_messages", columns = "(`request_id`, `created_at`, `id`)" }, + }, + foreignKeys = { + { column = "request_id", references = "`sky_phone_company_requests` (`id`) ON DELETE CASCADE" }, + { column = "sender_sim_id", references = "`sky_phone_sims` (`id`) ON DELETE SET NULL" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_company_request_events", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "request_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "event_type", type = "ENUM('created','assigned','status','cancelled') NOT NULL" }, + { name = "actor_type", type = "ENUM('customer','company','system') NOT NULL" }, + { name = "actor_identifier", type = "VARCHAR(80) NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "from_status", type = "VARCHAR(32) NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "to_status", type = "VARCHAR(32) NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "detail", type = "VARCHAR(255) NOT NULL DEFAULT ''" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + indexes = { + { name = "idx_sky_phone_company_request_events", columns = "(`request_id`, `created_at`, `id`)" }, + }, + foreignKeys = { + { column = "request_id", references = "`sky_phone_company_requests` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_company_audit", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "company_id", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "actor_identifier", type = "VARCHAR(80) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "action", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "target_type", type = "VARCHAR(32) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "target_id", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "metadata", type = "LONGTEXT NOT NULL" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + indexes = { + { name = "idx_sky_phone_company_audit", columns = "(`company_id`, `created_at`, `id`)" }, + }, + foreignKeys = { + { column = "company_id", references = "`sky_phone_company_profiles` (`company_id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, } Bridge.Database.Migrate("sky_phone", schema) diff --git a/sky_phone/source/server/phone.lua b/sky_phone/source/server/phone.lua index 8220d6d..91d0a0f 100644 --- a/sky_phone/source/server/phone.lua +++ b/sky_phone/source/server/phone.lua @@ -492,6 +492,9 @@ local function bootstrap(source, security, security_loaded) error(("[sky_phone] Active IMEI %s has no device row."):format(session.imei)) end + session.account_id = device.account_id and tonumber(device.account_id) or nil + session.account_email = session.account_id and device.email or nil + return { token = session.token, security = security_status(device.imei, security, security_loaded), @@ -701,6 +704,7 @@ function SkyPhone.RequireDeviceSession(source) local matches = find_device_slots(source, session.imei) if not matches[1] then + SkyPhoneCompanies.ClearCallAvailability(source) sessions[source] = nil TriggerClientEvent("sky_phone:device:invalidated", source) return nil, { success = false, error = "device_not_owned" } @@ -746,14 +750,13 @@ function SkyPhone.RequireAccount(source) if not session then return nil, error_response end - local device = load_device(session.imei) - if not device or not device.account_id then + if not session.account_id then return nil, { success = false, error = "not_authenticated" } end return { - id = tonumber(device.account_id), - email = device.email, - imei = device.imei, + id = session.account_id, + email = session.account_email, + imei = session.imei, } end @@ -880,6 +883,9 @@ local function open_phone(source, used_item) end local security = load_device_security(imei) + if sessions[source] and sessions[source].imei ~= imei then + SkyPhoneCompanies.ClearCallAvailability(source) + end sessions[source] = { imei = imei, slot = slot.slot, @@ -908,6 +914,9 @@ function SkyPhone.OpenDeviceForCall(source, imei) return false end local security = load_device_security(imei) + if sessions[source] and sessions[source].imei ~= imei then + SkyPhoneCompanies.ClearCallAvailability(source) + end sessions[source] = { imei = imei, slot = matches[1].slot, @@ -934,10 +943,23 @@ Bridge.Debug( ) Bridge.Callbacks.Register("sky_phone:device:close", function(source) + SkyPhoneCompanies.ClearCallAvailability(source) sessions[source] = nil return { success = true } end) +Bridge.Callbacks.Register("sky_phone:device:notification-open", function(source, data) + if not SkyPhone.AllowOperation(source, "notification_open", 10, 60) + or type(data) ~= "table" or type(data.imei) ~= "string" + then + return { success = false, error = "invalid_request" } + end + if not SkyPhone.OpenDeviceForCall(source, data.imei) then + return { success = false, error = "device_not_owned" } + end + return { success = true } +end) + Bridge.Callbacks.Register("sky_phone:security:unlock", function(source, data) if not SkyPhone.AllowOperation(source, "security_unlock", Config.Security.AttemptsPerMinute, 60) then return { success = false, error = "rate_limited" } @@ -1245,6 +1267,10 @@ Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source) query = "DELETE FROM `sky_phone_device_data` WHERE `device_imei` = ?", params = { session.imei }, }, + { + query = "DELETE FROM `sky_phone_custom_app_data` WHERE `device_imei` = ?", + params = { session.imei }, + }, { query = "DELETE FROM `sky_phone_notes` WHERE `device_imei` = ? AND `account_id` IS NULL", params = { session.imei }, @@ -1287,6 +1313,7 @@ Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source) end) AddEventHandler("playerDropped", function() + SkyPhoneCompanies.ClearCallAvailability(source) sessions[source] = nil auth_attempts[source] = nil operation_attempts[source] = nil diff --git a/sky_phone/source/server/sim.lua b/sky_phone/source/server/sim.lua index e2e6b58..34bde46 100644 --- a/sky_phone/source/server/sim.lua +++ b/sky_phone/source/server/sim.lua @@ -29,6 +29,9 @@ end local function reserve_sim(sim_type, is_virtual) local sim_id local number = SkyPhoneSimNumber.Reserve(uuid, function(candidate) + if SkyPhoneCompanies.IsServiceNumber(candidate) then + return false + end sim_id = uuid() local result = Bridge.Database.Query([[ INSERT IGNORE INTO `sky_phone_sims` (`id`, `phone_number`, `sim_type`, `is_virtual`) @@ -177,6 +180,15 @@ local function ensure_sim(source, slot, sim_type) ) then return nil, "invalid_sim" end + if sim and SkyPhoneCompanies.IsServiceNumber(sim.phone_number) then + Bridge.Debug( + "error", + "[sky_phone] SIM %s uses reserved company service number %s.", + tostring(sim.id), + tostring(sim.phone_number) + ) + return nil, "invalid_sim" + end if not sim then sim = reserve_sim(sim_type) if not Bridge.Inventory.SetSlotMetadata(source, slot.slot, sim_metadata(sim)) then @@ -237,6 +249,16 @@ local function insert_sim(source, phone_imei, confirmed) pending_insertions[source] = nil return { success = false, error = "sim_request_expired" } end + if SkyPhoneCompanies.IsServiceNumber(pending.sim.phone_number) then + pending_insertions[source] = nil + Bridge.Debug( + "error", + "[sky_phone] Refused to insert SIM %s with reserved company service number %s.", + tostring(pending.sim.id), + tostring(pending.sim.phone_number) + ) + return { success = false, error = "invalid_sim" } + end local sim_slot = Bridge.Inventory.GetSlot(source, pending.slot) if not sim_slot or sim_slot.name ~= pending.item_name or not sim_slot.metadata or sim_slot.metadata.sim_id ~= pending.sim.id then @@ -311,6 +333,7 @@ local function insert_sim(source, phone_imei, confirmed) pending_insertions[source] = nil operation_locks[source] = nil if old_sim then + SkyPhoneCompanies.ClearCallAvailability(source) SkyPhoneCalls.EndForSim(old_sim.id, "sim_removed") end SkyPhone.RefreshDevice(phone_imei) @@ -426,6 +449,7 @@ Bridge.Callbacks.Register("sky_phone:sim:eject", function(source) return { success = false, error = "request_failed" } end operation_locks[source] = nil + SkyPhoneCompanies.ClearCallAvailability(source) SkyPhoneCalls.EndForSim(sim.id, "sim_removed") SkyPhone.RefreshDevice(session.imei) return { success = true } diff --git a/sky_phone/source/shared/custom_app_compat.lua b/sky_phone/source/shared/custom_app_compat.lua new file mode 100644 index 0000000..514ddff --- /dev/null +++ b/sky_phone/source/shared/custom_app_compat.lua @@ -0,0 +1,269 @@ +SkyPhoneCompatibility = {} + +function SkyPhoneCompatibility.RegisterExportAlias(resource_name, export_name, handler) + assert(type(resource_name) == "string" and resource_name ~= "", "Export alias resource must be a non-empty string") + assert(type(export_name) == "string" and export_name ~= "", "Export alias name must be a non-empty string") + assert(type(handler) == "function", "Export alias handler must be a function") + + AddEventHandler(("__cfx_export_%s_%s"):format(resource_name, export_name), function(set_callback) + set_callback(handler) + end) +end + +SkyPhoneCompatibility.Providers = { + lb = "lb_phone", + seventeen = "17mov", + high = "high_phone", + quasar = "quasar_v3", + yseries = "yseries", +} + +local function normalize_at_resource_url(owner_resource, url, error_prefix) + if type(url) ~= "string" or url == "" then + return nil, "invalid_" .. error_prefix + end + if url:sub(1, 7) == "http://" then + return nil, "insecure_" .. error_prefix + end + if url:sub(1, 1) ~= "@" then + return url + end + + local url_owner, url_path = url:match("^@([^/]+)/(.+)$") + if url_owner ~= owner_resource or not url_path then + return nil, error_prefix .. "_owner_mismatch" + end + return owner_resource .. "/" .. url_path +end + +local function resolve_lb_asset_resource(owner_resource, app_data) + local ui = app_data.ui + if type(ui) ~= "string" then + return nil + end + + local explicit_ui_resource = ui:match("^https://cfx%-nui%-([^/]+)/") + or ui:match("^nui://([^/]+)/") + local asset_resource = explicit_ui_resource or ui:match("^([%w][%w._-]*)/") + if not asset_resource or asset_resource == owner_resource then + return nil + end + + if explicit_ui_resource or app_data.resource == asset_resource then + return asset_resource + end + + local icon = app_data.icon + local icon_resource = type(icon) == "string" and ( + icon:match("^https://cfx%-nui%-([^/]+)/") or icon:match("^nui://([^/]+)/") + ) or nil + if icon_resource == asset_resource then + return asset_resource + end + + return nil +end + +local function build_locale_maps(app_name, locales) + local names = {} + local descriptions = {} + + if type(locales) == "table" then + for locale, locale_data in pairs(locales) do + if type(locale) == "string" and type(locale_data) == "table" then + if type(locale_data.label) == "string" and locale_data.label ~= "" then + names[locale] = locale_data.label + end + if type(locale_data.description) == "string" and locale_data.description ~= "" then + descriptions[locale] = locale_data.description + end + end + end + end + + if not next(names) then + return app_name, app_name + end + for locale, label in pairs(names) do + if not descriptions[locale] then + descriptions[locale] = label + end + end + return names, descriptions +end + +function SkyPhoneCompatibility.CopyQuasarData(app_data) + local copy = {} + for key, value in pairs(app_data) do + if key == "iframe" and type(value) == "table" then + copy.iframe = {} + for iframe_key, iframe_value in pairs(value) do + copy.iframe[iframe_key] = iframe_value + end + else + copy[key] = value + end + end + return copy +end + +function SkyPhoneCompatibility.BuildLbDefinition(owner_resource, app_data) + if type(app_data) ~= "table" or type(app_data.identifier) ~= "string" then + return nil, "invalid_app_data" + end + + local ui, ui_error = normalize_at_resource_url(owner_resource, app_data.ui, "ui") + if not ui then + return nil, ui_error + end + + return { + schemaVersion = 1, + id = app_data.identifier, + name = app_data.name, + description = app_data.description, + developer = app_data.developer, + category = app_data.game and "games" or "utilities", + ui = ui, + assetResource = resolve_lb_asset_resource(owner_resource, app_data), + bridgeMode = "legacy", + icon = app_data.icon, + defaultInstalled = app_data.defaultApp or false, + removable = not app_data.defaultApp, + orientation = app_data.landscape and "landscape" or "portrait", + onInstall = app_data.onInstall, + onOpen = app_data.onOpen or app_data.onUse, + onClose = app_data.onClose, + compatibility = { + provider = SkyPhoneCompatibility.Providers.lb, + apiVersion = 1, + resourceName = type(app_data.resource) == "string" and app_data.resource or owner_resource, + }, + } +end + +function SkyPhoneCompatibility.Build17MovDefinition(app_data) + if type(app_data) ~= "table" or type(app_data.name) ~= "string" then + return nil, "invalid_app_data" + end + + return { + schemaVersion = 1, + id = app_data.name, + name = app_data.label, + description = app_data.description or app_data.label, + category = "utilities", + ui = app_data.ui, + bridgeMode = "legacy", + icon = app_data.icon, + iconBackground = type(app_data.iconBackground) == "string" and app_data.iconBackground or nil, + defaultInstalled = app_data.default or app_data.preInstalled or false, + removable = not app_data.default, + orientation = "portrait", + compatibility = { + provider = SkyPhoneCompatibility.Providers.seventeen, + apiVersion = 1, + }, + } +end + +function SkyPhoneCompatibility.BuildHighDefinition(owner_resource, app_name, data, locales) + if type(owner_resource) ~= "string" or type(app_name) ~= "string" or type(data) ~= "table" then + return nil, "invalid_app_data" + end + if #app_name > 64 or not app_name:match("^[a-z0-9][a-z0-9._-]+$") then + return nil, "invalid_app_id" + end + + local external_url, url_error = normalize_at_resource_url( + owner_resource, + data.externalUrl, + "external_url" + ) + if not external_url then + return nil, url_error + end + + local name, description = build_locale_maps(app_name, locales) + local icon = type(data.icon) == "table" and data.icon.imageUrl or data.icon + return { + schemaVersion = 1, + id = app_name, + name = name, + description = description, + developer = data.developer, + category = "utilities", + ui = external_url, + bridgeMode = "legacy", + icon = icon, + iconBackground = type(data.icon) == "table" and data.icon.background or nil, + defaultInstalled = data.preAdded or false, + removable = data.removable ~= false, + orientation = "portrait", + compatibility = { + provider = SkyPhoneCompatibility.Providers.high, + apiVersion = 1, + }, + } +end + +function SkyPhoneCompatibility.BuildQuasarDefinition(app_data) + local iframe_url = type(app_data) == "table" + and type(app_data.iframe) == "table" + and app_data.iframe.url + or nil + if type(app_data) ~= "table" + or type(app_data.id) ~= "string" + or type(app_data.label) ~= "string" + or type(iframe_url) ~= "string" + then + return nil, "invalid_app_data" + end + + return { + schemaVersion = 1, + id = app_data.id, + name = app_data.label, + description = app_data.description or app_data.label, + category = "utilities", + ui = iframe_url, + bridgeMode = "legacy", + icon = app_data.icon, + defaultInstalled = app_data.defaultInstalled or false, + removable = true, + orientation = "portrait", + compatibility = { + provider = SkyPhoneCompatibility.Providers.quasar, + apiVersion = 1, + }, + } +end + +function SkyPhoneCompatibility.BuildYSeriesDefinition(app_data) + if type(app_data) ~= "table" or type(app_data.key) ~= "string" then + return nil, "invalid_app_data" + end + + local icon = app_data.icon + if type(icon) == "table" then + icon = icon.yos or icon.humanoid + end + + return { + schemaVersion = 1, + id = app_data.key, + name = app_data.name, + description = app_data.description or app_data.name, + category = app_data.game and "games" or "utilities", + ui = app_data.ui, + bridgeMode = "legacy", + icon = icon, + defaultInstalled = app_data.defaultApp or false, + removable = true, + orientation = "portrait", + compatibility = { + provider = SkyPhoneCompatibility.Providers.yseries, + apiVersion = 1, + }, + } +end diff --git a/sky_phone/source/shared/custom_apps.lua b/sky_phone/source/shared/custom_apps.lua new file mode 100644 index 0000000..a21d199 --- /dev/null +++ b/sky_phone/source/shared/custom_apps.lua @@ -0,0 +1,451 @@ +local APP_ID_MAX_LENGTH = 64 +local DESCRIPTION_MAX_LENGTH = 320 +local DEVELOPER_MAX_LENGTH = 96 +local NAME_MAX_LENGTH = 64 +local PATH_MAX_LENGTH = 512 +local VERSION_MAX_LENGTH = 32 + +local ALLOWED_CATEGORIES = { + games = true, + productivity = true, + shopping = true, + social = true, + utilities = true, +} + +local ALLOWED_ORIENTATIONS = { + any = true, + landscape = true, + portrait = true, +} + +local ALLOWED_PERMISSIONS = { + ["app.close"] = true, + ["app.open"] = true, + ["camera.capture"] = true, + ["contacts.pick"] = true, + ["device.storage"] = true, + ["locale.read"] = true, + ["location.read"] = true, + ["media.pick"] = true, + ["notifications"] = true, + ["notifications.critical"] = true, + ["nui.fetch"] = true, + ["theme.read"] = true, +} + +local RESERVED_APP_IDS = { + ["app-store"] = true, + banking = true, + billing = true, + calculator = true, + calendar = true, + camera = true, + citymarkt = true, + clock = true, + companies = true, + crewlink = true, + darkchat = true, + feather = true, + flare = true, + fliptok = true, + garage = true, + house = true, + ["local-pages"] = true, + mail = true, + map = true, + memory = true, + messages = true, + minesweeper = true, + music = true, + ["neon-drop"] = true, + notes = true, + ["number-merge"] = true, + phone = true, + photos = true, + picstagram = true, + radio = true, + settings = true, + ["sky-flappy"] = true, + skyride = true, + snake = true, + ["tower-stack"] = true, + weather = true, +} + +local bundled_manifests = {} +local bundled_manifests_by_id = {} + +SkyPhoneApps = SkyPhoneApps or {} +SkyPhoneApps.ProtocolVersion = 1 + +local function trim(value) + return value:match("^%s*(.-)%s*$") +end + +local function validate_app_id(app_id) + if type(app_id) ~= "string" then + return false, "invalid_app_id" + end + + if #app_id == 0 or #app_id > APP_ID_MAX_LENGTH then + return false, "invalid_app_id" + end + + if not app_id:match("^[a-z0-9][a-z0-9._-]+$") then + return false, "invalid_app_id" + end + + return true +end + +local function validate_text(value, error_code, maximum_length, required) + if value == nil and not required then + return nil + end + + if type(value) ~= "string" then + return nil, error_code + end + + local normalized = trim(value) + if (required and #normalized == 0) or #normalized > maximum_length then + return nil, error_code + end + + return normalized +end + +local function validate_localized_text(value, error_code, maximum_length, required) + if type(value) == "string" then + return validate_text(value, error_code, maximum_length, required) + end + + if type(value) ~= "table" then + return nil, error_code + end + + local normalized = {} + local count = 0 + for locale, text in pairs(value) do + if type(locale) ~= "string" or not locale:match("^[a-zA-Z][a-zA-Z0-9_-]*$") then + return nil, error_code + end + + local normalized_text = validate_text(text, error_code, maximum_length, true) + if not normalized_text then + return nil, error_code + end + + count = count + 1 + if count > 16 then + return nil, error_code + end + + normalized[locale:lower():gsub("_", "-")] = normalized_text + end + + if count == 0 then + return nil, error_code + end + + return normalized +end + +local function validate_local_path(path, required) + if path == nil and not required then + return nil + end + + if type(path) ~= "string" or #path == 0 or #path > PATH_MAX_LENGTH then + return nil, "invalid_local_path" + end + + if path:sub(1, 1) == "/" or path:find("\\", 1, true) or path:find(":", 1, true) then + return nil, "invalid_local_path" + end + + if not path:match("^web/[%w%._%-%/]+$") then + return nil, "invalid_local_path" + end + + for segment in path:gmatch("[^/]+") do + if segment == "." or segment == ".." then + return nil, "invalid_local_path" + end + end + + return path +end + +local function validate_string_array(value, error_code, maximum_items, validator) + if value == nil then + return {} + end + + if type(value) ~= "table" then + return nil, error_code + end + + local normalized = {} + local count = 0 + for key in pairs(value) do + if type(key) ~= "number" or key < 1 or key % 1 ~= 0 then + return nil, error_code + end + count = count + 1 + end + + if count ~= #value or count > maximum_items then + return nil, error_code + end + + for index = 1, count do + local item = value[index] + if type(item) ~= "string" then + return nil, error_code + end + + local normalized_item, validation_error = validator(item) + if not normalized_item then + return nil, validation_error or error_code + end + + normalized[index] = normalized_item + end + + return normalized +end + +local function validate_permissions(value) + local permissions, error_code = validate_string_array( + value, + "invalid_permissions", + 32, + function(permission) + if not ALLOWED_PERMISSIONS[permission] then + return nil, "unknown_permission" + end + return permission + end + ) + if not permissions then + return nil, error_code + end + + local seen = {} + for index = 1, #permissions do + local permission = permissions[index] + if seen[permission] then + return nil, "duplicate_permission" + end + seen[permission] = true + end + + return permissions +end + +local function validate_grid_order(value) + if value == nil then + return nil + end + + if type(value) ~= "number" or value ~= math.floor(value) or value < 0 or value > 9999 then + return nil, "invalid_grid_order" + end + + return value +end + +local function validate_icon_background(value) + if value == nil then + return nil + end + + if type(value) ~= "string" or #value == 0 or #value > 64 then + return nil, "invalid_icon_background" + end + + if value:find("[\r\n;{}]") then + return nil, "invalid_icon_background" + end + + return value +end + +local function normalize_manifest(folder, definition) + if type(folder) ~= "string" or type(definition) ~= "table" then + return nil, "invalid_manifest" + end + + if definition.schemaVersion ~= SkyPhoneApps.ProtocolVersion then + return nil, "unsupported_schema_version" + end + + local app_id = definition.id + local valid_id, id_error = validate_app_id(app_id) + if not valid_id then + return nil, id_error + end + if RESERVED_APP_IDS[app_id] then + return nil, "reserved_app_id" + end + if folder ~= app_id then + return nil, "folder_id_mismatch" + end + + if definition.defaultInstalled ~= nil and type(definition.defaultInstalled) ~= "boolean" then + return nil, "invalid_default_installed" + end + if definition.removable ~= nil and type(definition.removable) ~= "boolean" then + return nil, "invalid_removable" + end + + local version, version_error = validate_text( + definition.version or "1.0.0", + "invalid_version", + VERSION_MAX_LENGTH, + true + ) + if not version then + return nil, version_error + end + if not version:match("^[%w%._+-]+$") then + return nil, "invalid_version" + end + + local name, name_error = validate_localized_text( + definition.name, + "invalid_name", + NAME_MAX_LENGTH, + true + ) + if not name then + return nil, name_error + end + + local description, description_error = validate_localized_text( + definition.description, + "invalid_description", + DESCRIPTION_MAX_LENGTH, + true + ) + if not description then + return nil, description_error + end + + local developer, developer_error = validate_text( + definition.developer, + "invalid_developer", + DEVELOPER_MAX_LENGTH, + false + ) + if definition.developer ~= nil and not developer then + return nil, developer_error + end + + local category = definition.category or "utilities" + if not ALLOWED_CATEGORIES[category] then + return nil, "invalid_category" + end + + local entry, entry_error = validate_local_path(definition.entry, true) + if not entry then + return nil, entry_error + end + + local icon, icon_error = validate_local_path(definition.icon, false) + if definition.icon ~= nil and not icon then + return nil, icon_error + end + + local screenshots, screenshots_error = validate_string_array( + definition.screenshots, + "invalid_screenshots", + 8, + function(path) + return validate_local_path(path, true) + end + ) + if not screenshots then + return nil, screenshots_error + end + + local permissions, permissions_error = validate_permissions(definition.permissions) + if not permissions then + return nil, permissions_error + end + + local orientation = definition.orientation or "portrait" + if not ALLOWED_ORIENTATIONS[orientation] then + return nil, "invalid_orientation" + end + + local grid_order, grid_order_error = validate_grid_order(definition.gridOrder) + if definition.gridOrder ~= nil and not grid_order then + return nil, grid_order_error + end + + local icon_background, background_error = validate_icon_background(definition.iconBackground) + if definition.iconBackground ~= nil and not icon_background then + return nil, background_error + end + + local bridge_mode = definition.bridgeMode or "sky" + if bridge_mode ~= "sky" and bridge_mode ~= "legacy" then + return nil, "invalid_bridge_mode" + end + + return { + bridgeMode = bridge_mode, + category = category, + defaultInstalled = definition.defaultInstalled == true, + description = description, + developer = developer, + entry = entry, + folder = folder, + gridOrder = grid_order, + icon = icon, + iconBackground = icon_background, + id = app_id, + name = name, + orientation = orientation, + permissions = permissions, + removable = definition.removable ~= false, + schemaVersion = SkyPhoneApps.ProtocolVersion, + screenshots = screenshots, + version = version, + } +end + +---@param folder string App folder relative to custom_apps. +---@param definition table Declarative bundled app manifest. +function SkyPhoneApps.RegisterManifest(folder, definition) + local manifest, error_code = normalize_manifest(folder, definition) + if not manifest then + error(("[sky_phone] Invalid custom app manifest '%s': %s"):format(tostring(folder), error_code), 2) + end + + if bundled_manifests_by_id[manifest.id] then + error(("[sky_phone] Duplicate bundled custom app id '%s'."):format(manifest.id), 2) + end + + bundled_manifests[#bundled_manifests + 1] = manifest + bundled_manifests_by_id[manifest.id] = manifest +end + +function SkyPhoneApps.GetBundledManifests() + return bundled_manifests +end + +function SkyPhoneApps.GetBundledManifest(app_id) + return bundled_manifests_by_id[app_id] +end + +SkyPhoneApps.AllowedCategories = ALLOWED_CATEGORIES +SkyPhoneApps.AllowedOrientations = ALLOWED_ORIENTATIONS +SkyPhoneApps.AllowedPermissions = ALLOWED_PERMISSIONS +SkyPhoneApps.ReservedAppIds = RESERVED_APP_IDS +SkyPhoneApps.ValidateAppId = validate_app_id +SkyPhoneApps.ValidateLocalizedText = validate_localized_text +SkyPhoneApps.ValidatePermissions = validate_permissions diff --git a/sky_phone/sql/install.sql b/sky_phone/sql/install.sql index 73b8fd9..1bf67a1 100644 --- a/sky_phone/sql/install.sql +++ b/sky_phone/sql/install.sql @@ -103,6 +103,20 @@ CREATE TABLE IF NOT EXISTS `sky_phone_device_data` ( FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `sky_phone_custom_app_data` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `app_id` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `data_key` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `payload` LONGTEXT NOT NULL, + `revision` INT UNSIGNED NOT NULL DEFAULT 1, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_custom_app_data` (`device_imei`, `app_id`, `data_key`), + KEY `idx_sky_phone_custom_app_storage` (`device_imei`, `app_id`, `updated_at`), + FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `sky_phone_device_security` ( `device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, `passcode_hash` BINARY(32) NOT NULL, @@ -957,3 +971,167 @@ CREATE TABLE IF NOT EXISTS `sky_phone_crewlink_pings` ( FOREIGN KEY (`group_id`) REFERENCES `sky_phone_crewlink_groups` (`id`) ON DELETE CASCADE, FOREIGN KEY (`creator_profile_id`) REFERENCES `sky_phone_crewlink_profiles` (`id`) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_company_profiles` ( + `company_id` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `description` VARCHAR(1000) NOT NULL DEFAULT '', + `district` VARCHAR(80) NOT NULL DEFAULT '', + `location_label` VARCHAR(80) NOT NULL DEFAULT '', + `address` VARCHAR(160) NOT NULL DEFAULT '', + `location_x` DECIMAL(10,3) NULL, + `location_y` DECIMAL(10,3) NULL, + `location_z` DECIMAL(10,3) NULL, + `logo_media_id` BIGINT UNSIGNED NULL, + `cover_media_id` BIGINT UNSIGNED NULL, + `availability` ENUM('available','busy','closed') NOT NULL DEFAULT 'closed', + `availability_updated_by` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NULL, + `availability_updated_at` DATETIME NULL, + `availability_expires_at` DATETIME NULL, + `accepts_requests` TINYINT(1) NOT NULL DEFAULT 1, + `revision` INT UNSIGNED NOT NULL DEFAULT 1, + `mutation_token` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`company_id`), + KEY `idx_sky_phone_company_profiles_public` (`availability`,`updated_at`), + FOREIGN KEY (`logo_media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE SET NULL, + FOREIGN KEY (`cover_media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_company_hours` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `company_id` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `weekday` TINYINT UNSIGNED NOT NULL, + `is_closed` TINYINT(1) NOT NULL DEFAULT 0, + `opens_at` CHAR(5) CHARACTER SET ascii COLLATE ascii_bin NULL, + `closes_at` CHAR(5) CHARACTER SET ascii COLLATE ascii_bin NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_company_hours_day` (`company_id`,`weekday`), + FOREIGN KEY (`company_id`) REFERENCES `sky_phone_company_profiles` (`company_id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_company_services` ( + `id` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `company_id` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `title` VARCHAR(80) NOT NULL, + `description` VARCHAR(500) NOT NULL DEFAULT '', + `price_text` VARCHAR(80) NOT NULL DEFAULT '', + `requests_enabled` TINYINT(1) NOT NULL DEFAULT 1, + `active` TINYINT(1) NOT NULL DEFAULT 1, + `archived` TINYINT(1) NOT NULL DEFAULT 0, + `sort_order` TINYINT UNSIGNED NOT NULL DEFAULT 0, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_sky_phone_company_services_list` (`company_id`,`archived`,`active`,`sort_order`), + FOREIGN KEY (`company_id`) REFERENCES `sky_phone_company_profiles` (`company_id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_company_announcements` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `company_id` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `title` VARCHAR(120) NOT NULL, + `body` VARCHAR(1000) NOT NULL, + `created_by` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `active` TINYINT(1) NOT NULL DEFAULT 1, + `expires_at` DATETIME NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_sky_phone_company_announcements_active` (`company_id`,`active`,`expires_at`,`created_at`), + FOREIGN KEY (`company_id`) REFERENCES `sky_phone_company_profiles` (`company_id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_company_requests` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `company_id` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `service_id` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL, + `customer_sim_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `subject` VARCHAR(120) NOT NULL, + `description` VARCHAR(2000) NOT NULL, + `status` ENUM('new','assigned','in_progress','waiting_customer','completed','cancelled') NOT NULL DEFAULT 'new', + `assigned_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NULL, + `customer_unread` INT UNSIGNED NOT NULL DEFAULT 0, + `company_activity_revision` INT UNSIGNED NOT NULL DEFAULT 1, + `revision` INT UNSIGNED NOT NULL DEFAULT 1, + `mutation_token` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `completed_at` DATETIME NULL, + `cancelled_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_sky_phone_company_requests_customer` (`customer_sim_id`,`updated_at`,`id`), + KEY `idx_sky_phone_company_requests_queue` (`company_id`,`status`,`updated_at`,`id`), + KEY `idx_sky_phone_company_requests_assignee` (`company_id`,`assigned_identifier`,`status`), + FOREIGN KEY (`company_id`) REFERENCES `sky_phone_company_profiles` (`company_id`) ON DELETE CASCADE, + FOREIGN KEY (`service_id`) REFERENCES `sky_phone_company_services` (`id`) ON DELETE SET NULL, + FOREIGN KEY (`customer_sim_id`) REFERENCES `sky_phone_sims` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_company_request_reads` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `request_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `reader_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `read_revision` INT UNSIGNED NOT NULL DEFAULT 0, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_company_request_reads_reader` (`request_id`,`reader_identifier`), + KEY `idx_sky_phone_company_request_reads_identifier` (`reader_identifier`,`updated_at`), + FOREIGN KEY (`request_id`) REFERENCES `sky_phone_company_requests` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_company_request_media` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `request_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `media_id` BIGINT UNSIGNED NOT NULL, + `sort_order` TINYINT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_company_request_media` (`request_id`,`media_id`), + UNIQUE KEY `uniq_sky_phone_company_request_media_order` (`request_id`,`sort_order`), + FOREIGN KEY (`request_id`) REFERENCES `sky_phone_company_requests` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_company_request_messages` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `request_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `sender_type` ENUM('customer','company') NOT NULL, + `sender_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NULL, + `sender_sim_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL, + `body` VARCHAR(2000) NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_sky_phone_company_request_messages` (`request_id`,`created_at`,`id`), + FOREIGN KEY (`request_id`) REFERENCES `sky_phone_company_requests` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`sender_sim_id`) REFERENCES `sky_phone_sims` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_company_request_events` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `request_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `event_type` ENUM('created','assigned','status','cancelled') NOT NULL, + `actor_type` ENUM('customer','company','system') NOT NULL, + `actor_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NULL, + `from_status` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NULL, + `to_status` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NULL, + `detail` VARCHAR(255) NOT NULL DEFAULT '', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_sky_phone_company_request_events` (`request_id`,`created_at`,`id`), + FOREIGN KEY (`request_id`) REFERENCES `sky_phone_company_requests` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_company_audit` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `company_id` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `actor_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `action` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `target_type` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `target_id` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `metadata` LONGTEXT NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_sky_phone_company_audit` (`company_id`,`created_at`,`id`), + FOREIGN KEY (`company_id`) REFERENCES `sky_phone_company_profiles` (`company_id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/tests/custom_app_compat.lua b/tests/custom_app_compat.lua new file mode 100644 index 0000000..2613ad3 --- /dev/null +++ b/tests/custom_app_compat.lua @@ -0,0 +1,92 @@ +dofile("sky_phone/source/shared/custom_app_compat.lua") + +local lb_definition = assert(SkyPhoneCompatibility.BuildLbDefinition("lb_app", { + identifier = "dispatch", + name = "Dispatch", + description = "Dispatch terminal", + ui = "ui/index.html", + defaultApp = true, + landscape = true, + onUse = function() end, +})) +assert(lb_definition.id == "dispatch", "LB identifier must map to the Sky app ID") +assert(lb_definition.ui == "ui/index.html", "LB relative UI must remain owner-relative") +assert(lb_definition.orientation == "landscape", "LB landscape flag must be preserved") +assert(type(lb_definition.onOpen) == "function", "LB onUse must map to the open lifecycle") +assert(lb_definition.compatibility.resourceName == "lb_app", "LB callbacks must target the registering resource") + +local bridged_lb_definition = assert(SkyPhoneCompatibility.BuildLbDefinition("phone_adapter", { + identifier = "bridged", + name = "Bridged", + ui = "manufacturer_app/ui/index.html", + resource = "manufacturer_app", +})) +assert( + bridged_lb_definition.compatibility.resourceName == "manufacturer_app", + "LB adapter callbacks must target the declared app resource" +) + +local invalid_lb, invalid_lb_error = SkyPhoneCompatibility.BuildLbDefinition("lb_app", { + identifier = "dispatch", + name = "Dispatch", + ui = "@another_resource/ui/index.html", +}) +assert(invalid_lb == nil, "LB must reject an @ URL owned by another resource") +assert(invalid_lb_error == "ui_owner_mismatch", "LB owner mismatch must be explicit") + +local mov_definition = assert(SkyPhoneCompatibility.Build17MovDefinition({ + name = "market", + label = "Market", + ui = "https://cfx-nui-market_app/ui/index.html", + iconBackground = "#112233", + preInstalled = true, +})) +assert(mov_definition.id == "market", "17mov name must map to the Sky app ID") +assert(mov_definition.iconBackground == "#112233", "17mov icon background must be preserved") +assert(mov_definition.defaultInstalled, "17mov preInstalled must be preserved") + +local high_definition = assert(SkyPhoneCompatibility.BuildHighDefinition("high_app", "bankingv2", { + externalUrl = "@high_app/ui/index.html", + icon = { + imageUrl = "ui/icon.png", + background = "#1b1b1b", + }, + preAdded = true, +}, { + en = { + label = "Banking", + description = "Banking app", + }, +})) +assert(high_definition.ui == "high_app/ui/index.html", "High @ URL must normalize to its owner") +assert(high_definition.name.en == "Banking", "High locale labels must be preserved") +assert(high_definition.iconBackground == "#1b1b1b", "High icon background must be preserved") + +local quasar_data = { + id = "services", + label = "Services", + iframe = { + url = "https://cfx-nui-services/ui/index.html", + }, +} +local quasar_definition = assert(SkyPhoneCompatibility.BuildQuasarDefinition(quasar_data)) +local quasar_copy = SkyPhoneCompatibility.CopyQuasarData(quasar_data) +quasar_copy.iframe.url = "changed" +assert(quasar_definition.id == "services", "Quasar ID must be preserved") +assert(quasar_data.iframe.url ~= quasar_copy.iframe.url, "Quasar iframe data must be copied") + +local yseries_definition = assert(SkyPhoneCompatibility.BuildYSeriesDefinition({ + key = "slots", + name = "Slots", + ui = "https://cfx-nui-slots/ui/index.html", + icon = { + yos = "ui/yos.png", + humanoid = "ui/humanoid.png", + }, + game = true, +})) +assert(yseries_definition.id == "slots", "YSeries key must map to the Sky app ID") +assert(yseries_definition.icon == "ui/yos.png", "YSeries must prefer the YOS icon") +assert(yseries_definition.category == "games", "YSeries game flag must be preserved") + +print("Custom app compatibility mapping tests passed") diff --git a/tests/custom_app_compat_client.lua b/tests/custom_app_compat_client.lua new file mode 100644 index 0000000..cd548fb --- /dev/null +++ b/tests/custom_app_compat_client.lua @@ -0,0 +1,331 @@ +local registered_exports = {} +local registered_event_handlers = {} +local registered_nui_callbacks = {} +local triggered_events = {} +local nui_messages = {} +local invoking_resource = nil + +Config = { + Bridge = { + Locale = "en", + }, + CustomApps = { + AllowRemoteOrigins = {}, + BundledApps = false, + Enabled = true, + ExternalApps = true, + MaximumMessageBytes = 65536, + MaximumStorageBytesPerApp = 262144, + MaximumStorageKeyLength = 64, + MaximumStorageValueBytes = 65536, + ReadyTimeoutMs = 8000, + TrustedAdapters = {}, + }, +} + +json = { + decode = function(encoded) + if encoded == "null" then + return nil, 5 + end + return {} + end, + encode = function(value) + if value == nil then + return "null" + end + return "{}" + end, +} + +exports = setmetatable({}, { + __call = function(_, export_name, handler) + registered_exports[export_name] = handler + end, +}) + +function GetCurrentResourceName() + return "sky_phone" +end + +function GetInvokingResource() + return invoking_resource +end + +function GetResourceState(resource_name) + if resource_name == "missing_resource" or resource_name == "ui" then + return "missing" + end + return "started" +end + +function RegisterNUICallback(callback_name, handler) + registered_nui_callbacks[callback_name] = handler +end +function RegisterNetEvent() end +function AddEventHandler(event_name, handler) + local handlers = registered_event_handlers[event_name] or {} + handlers[#handlers + 1] = handler + registered_event_handlers[event_name] = handlers +end +function SendNUIMessage(message) + nui_messages[#nui_messages + 1] = message +end +function TriggerServerEvent() end +function TriggerEvent(event_name, ...) + triggered_events[#triggered_events + 1] = { + event_name = event_name, + arguments = { ... }, + } +end + +function CreateThread(handler) + handler() +end + +dofile("sky_phone/source/shared/custom_apps.lua") +dofile("sky_phone/source/shared/custom_app_compat.lua") +dofile("sky_phone/source/client/custom_apps.lua") +dofile("sky_phone/source/client/custom_app_compat.lua") + +local function get_alias_export(resource_name, export_name) + local event_name = ("__cfx_export_%s_%s"):format(resource_name, export_name) + local handlers = registered_event_handlers[event_name] + assert(handlers and #handlers == 1, ("Missing %s:%s export alias"):format(resource_name, export_name)) + + local alias_handler + local export_callback = setmetatable({ + __cfx_functionReference = "test-export-callback", + }, { + __call = function(_, handler) + alias_handler = handler + end, + }) + handlers[1](export_callback) + assert(type(alias_handler) == "function", ("Invalid %s:%s export alias"):format(resource_name, export_name)) + return alias_handler +end + +local function make_cfx_function_reference(reference, handler) + return setmetatable({ + __cfx_functionReference = reference, + }, { + __call = function(_, ...) + return handler(...) + end, + }) +end + +local lb_add_custom_app = get_alias_export("lb-phone", "AddCustomApp") +local lb_remove_custom_app = get_alias_export("lb-phone", "RemoveCustomApp") +local lb_send_custom_app_message = get_alias_export("lb-phone", "SendCustomAppMessage") +local lb_open_app = get_alias_export("lb-phone", "OpenApp") +local lb_close_app = get_alias_export("lb-phone", "CloseApp") +local mov_add_application = get_alias_export("17mov_Phone", "AddApplication") +local mov_remove_application = get_alias_export("17mov_Phone", "RemoveApplication") +local mov_send_app_message = get_alias_export("17mov_Phone", "SendAppMessage") +local high_add_application = get_alias_export("high-phone", "addApplication") +local high_send_app_nui = get_alias_export("high-phone", "sendAppNui") +local quasar_add_custom_app = get_alias_export("qs-smartphone", "addCustomApp") +local quasar_add_custom_apps_batch = get_alias_export("qs-smartphone", "addCustomAppsBatch") +local quasar_update_custom_app = get_alias_export("qs-smartphone", "updateCustomApp") +local quasar_remove_custom_app = get_alias_export("qs-smartphone", "removeCustomApp") +local quasar_get_custom_apps = get_alias_export("qs-smartphone", "getCustomApps") +local quasar_open_phone_app = get_alias_export("qs-smartphone", "OpenPhoneApp") +local yseries_add_custom_app = get_alias_export("yseries", "AddCustomApp") +local yseries_remove_custom_app = get_alias_export("yseries", "RemoveCustomApp") +local yseries_send_app_message = get_alias_export("yseries", "SendAppMessage") +local yseries_close_app = get_alias_export("yseries", "CloseApp") +local yseries_get_data_loaded = get_alias_export("yseries", "GetDataLoaded") + +local expected_provider_resources = { + ["lb-phone"] = true, + ["17mov_Phone"] = true, + ["high-phone"] = true, + ["qs-smartphone"] = true, + ["yseries"] = true, +} +local resource_start_events = {} +for index = 1, #triggered_events do + local event = triggered_events[index] + local resource_name = event.arguments[1] + if event.event_name == "onResourceStart" then + resource_start_events[resource_name] = true + end +end +for resource_name in pairs(expected_provider_resources) do + assert(resource_start_events[resource_name], ("Missing %s provider start signal"):format(resource_name)) +end +assert(yseries_get_data_loaded(), "YSeries must see the compatibility provider as loaded") + +invoking_resource = "lb_app" +local lifecycle_response_delivered = false +local install_hook_after_response = false +local open_hook_after_response = false +local lb_definition = { + identifier = "dispatch", + name = "Dispatch", + description = "Dispatch terminal", + ui = "ui/index.html", + onInstall = make_cfx_function_reference("install-hook", function() + install_hook_after_response = lifecycle_response_delivered + error("vendor install failure") + end), + onOpen = make_cfx_function_reference("open-hook", function() + open_hook_after_response = lifecycle_response_delivered + error(5) + end), +} +local lb_success, lb_error = lb_add_custom_app(lb_definition) +assert(lb_success and lb_error == nil, "LB AddCustomApp must register through the shared export") +lb_definition.name = "Dispatch Updated" +local retry_success, retry_error = lb_add_custom_app(lb_definition) +assert(retry_success and retry_error == nil, "same-owner LB registration retries must update in place") + +local lifecycle_callback = assert(registered_nui_callbacks["custom-app:lifecycle"]) +local lifecycle_response +SkyPhoneApps.SetPhoneOpen(true) +local retry_catalog = nui_messages[#nui_messages] +local retried_app +for index = 1, #retry_catalog.data.apps do + if retry_catalog.data.apps[index].id == "dispatch" then + retried_app = retry_catalog.data.apps[index] + break + end +end +assert(retried_app and retried_app.name == "Dispatch Updated", "same-owner retry must publish the updated app") +lifecycle_response_delivered = false +lifecycle_callback({ appId = "dispatch", event = "install" }, function(response) + lifecycle_response = response + lifecycle_response_delivered = true +end) +assert(lifecycle_response.success, "a vendor install hook failure must not fail installation") +assert(install_hook_after_response, "a vendor install hook must run after the NUI response") +lifecycle_response_delivered = false +lifecycle_callback({ appId = "dispatch", event = "open" }, function(response) + lifecycle_response = response + lifecycle_response_delivered = true +end) +assert(lifecycle_response.success, "a vendor open hook failure must not prevent opening the app") +assert(open_hook_after_response, "a vendor open hook must run after the NUI response") +lifecycle_callback({ appId = "dispatch", event = "ready" }, function(response) + lifecycle_response = response +end) +assert(lifecycle_response.success, "ready must complete after a failed vendor open hook") +SkyPhoneApps.SetPhoneOpen(false) + +invoking_resource = "another_app" +local duplicate_success, duplicate_error = lb_add_custom_app({ + identifier = "dispatch", + name = "Hijack", + description = "Hijack", + ui = "ui/index.html", +}) +assert(not duplicate_success and duplicate_error == "duplicate_app_id", "cross-owner IDs must be rejected") + +local remove_success, remove_error = lb_remove_custom_app("dispatch") +assert(not remove_success and remove_error == "app_owner_mismatch", "cross-owner removal must be rejected") + +invoking_resource = "lb_app" +local inactive_message_success, inactive_message_error = lb_send_custom_app_message("dispatch", { type = "ping" }) +assert(not inactive_message_success and inactive_message_error == "app_not_active", "LB message alias must preserve app state checks") +local closed_open_success, closed_open_error = lb_open_app("dispatch") +assert(not closed_open_success and closed_open_error == "phone_closed", "LB open alias must preserve phone state checks") +local inactive_close_success, inactive_close_error = lb_close_app({ app = "dispatch" }) +assert(not inactive_close_success and inactive_close_error == "app_not_active", "LB close alias must preserve app state checks") +assert(lb_remove_custom_app("dispatch"), "the LB owner must be able to remove its app") + +invoking_resource = "phone_adapter" +assert(lb_add_custom_app({ + identifier = "manufacturer-app", + name = "Manufacturer App", + description = "Manufacturer-owned UI assets", + ui = "manufacturer_app/ui/index.html", + icon = "nui://manufacturer_app/ui/icon.png", +}), "LB resource-prefixed assets must register through an adapter") +SkyPhoneApps.SetPhoneOpen(true) +SkyPhoneApps.SendCatalog() +local catalog_message = nui_messages[#nui_messages] +local manufacturer_app +for index = 1, #catalog_message.data.apps do + if catalog_message.data.apps[index].id == "manufacturer-app" then + manufacturer_app = catalog_message.data.apps[index] + break + end +end +assert(manufacturer_app, "manufacturer app must be present in the catalog") +assert( + manufacturer_app.ui == "https://cfx-nui-manufacturer_app/ui/index.html", + "LB resource-prefixed UI must preserve its asset resource" +) +assert( + manufacturer_app.icon == "https://cfx-nui-manufacturer_app/ui/icon.png", + "LB resource-prefixed icon must preserve its asset resource" +) +SkyPhoneApps.SetPhoneOpen(false) + +invoking_resource = "yseries_app" +assert(yseries_add_custom_app({ + key = "slots", + name = "Slots", + ui = "https://cfx-nui-yseries_app/ui/index.html", + icon = { + yos = "https://cdn.example.com/slots.png", + }, +}), "YSeries AddCustomApp must be selected from the key field") +local yseries_message_success, yseries_message_error = yseries_send_app_message("slots", { type = "ping" }) +assert(not yseries_message_success and yseries_message_error == "app_not_active", "YSeries message alias must preserve app state checks") +local yseries_close_success, yseries_close_error = yseries_close_app({ app = "slots" }) +assert(not yseries_close_success and yseries_close_error == "app_not_active", "YSeries close alias must preserve app state checks") +assert(yseries_remove_custom_app("slots"), "YSeries RemoveCustomApp must use the provider alias") + +local ambiguous_success, ambiguous_error = registered_exports.AddCustomApp({ + id = "ambiguous", + identifier = "ambiguous", + name = "Ambiguous", + ui = "ui/index.html", +}) +assert(not ambiguous_success and ambiguous_error == "ambiguous_app_provider", "ambiguous schemas must fail") + +invoking_resource = "mov_app" +assert(mov_add_application({ + name = "market", + label = "Market", + ui = "https://cfx-nui-mov_app/ui/index.html", +}), "17mov AddApplication must register") +local mov_message_success, mov_message_error = mov_send_app_message("market", { type = "ping" }) +assert(not mov_message_success and mov_message_error == "app_not_active", "17mov message alias must preserve app state checks") +assert(mov_remove_application("market"), "17mov RemoveApplication must use the provider alias") + +invoking_resource = "high_app" +assert(high_add_application("bankingv2", { + externalUrl = "@high_app/ui/index.html", +}, { + en = { + label = "Banking", + description = "Banking app", + }, +}), "High addApplication must register") +local high_message_success, high_message_error = high_send_app_nui("bankingv2", { type = "ping" }) +assert(not high_message_success and high_message_error == "app_not_active", "High message alias must preserve app state checks") + +invoking_resource = "quasar_app" +assert(quasar_add_custom_app({ + id = "services", + label = "Services", + iframe = { + url = "https://cfx-nui-quasar_app/ui/index.html", + }, +}), "Quasar addCustomApp must register") + +local quasar_apps = quasar_get_custom_apps() +assert(#quasar_apps == 1 and quasar_apps[1].id == "services", "Quasar getCustomApps must expose the registry") +assert(quasar_update_custom_app("services", { + label = "City Services", +}), "Quasar updateCustomApp must update an owned app") +local quasar_open_success, quasar_open_error = quasar_open_phone_app("services") +assert(not quasar_open_success and quasar_open_error == "phone_closed", "Quasar open alias must preserve phone state checks") +assert(quasar_remove_custom_app("services"), "Quasar removeCustomApp must remove an owned app") +assert(quasar_add_custom_apps_batch({}), "Quasar batch alias must accept an empty batch") + +print("Custom app compatibility client tests passed") diff --git a/tests/custom_app_compat_server.lua b/tests/custom_app_compat_server.lua new file mode 100644 index 0000000..3348d66 --- /dev/null +++ b/tests/custom_app_compat_server.lua @@ -0,0 +1,86 @@ +local event_handlers = {} +local registered_event_handlers = {} +local registered_exports = {} +local sent_events = {} +local invoking_resource = nil + +exports = setmetatable({}, { + __call = function(_, export_name, handler) + registered_exports[export_name] = handler + end, +}) + +function GetCurrentResourceName() + return "sky_phone" +end + +function GetInvokingResource() + return invoking_resource +end + +function GetGameTimer() + return 5000 +end + +function RegisterNetEvent(event_name, handler) + event_handlers[event_name] = handler +end + +function AddEventHandler(event_name, handler) + local handlers = registered_event_handlers[event_name] or {} + handlers[#handlers + 1] = handler + registered_event_handlers[event_name] = handlers +end + +function TriggerClientEvent(event_name, target, ...) + sent_events[#sent_events + 1] = { + arguments = { ... }, + event_name = event_name, + target = target, + } +end + +dofile("sky_phone/source/shared/custom_app_compat.lua") +dofile("sky_phone/source/server/custom_app_compat.lua") + +local alias_handlers = registered_event_handlers["__cfx_export_high-phone_addApplication"] +assert(alias_handlers and #alias_handlers == 1, "Missing high-phone:addApplication export alias") +local high_add_application +local export_callback = setmetatable({ + __cfx_functionReference = "test-export-callback", +}, { + __call = function(_, handler) + high_add_application = handler + end, +}) +alias_handlers[1](export_callback) +assert(type(high_add_application) == "function", "Invalid high-phone:addApplication export alias") + +invoking_resource = "high_server_app" +local add_success, add_error = high_add_application("bankingv2", { + externalUrl = "@high_server_app/ui/index.html", +}, { + en = { + label = "Banking", + description = "Banking app", + }, +}) +assert(add_success and add_error == nil, "High server addApplication must register") +assert(#sent_events == 1, "High server registration must broadcast an update") +assert(sent_events[1].event_name == "sky_phone:compat:high:client:syncApplication", "High sync event must be used") +assert(sent_events[1].arguments[1] == "high_server_app", "High sync must preserve the owner") + +invoking_resource = "other_server_app" +local duplicate_success, duplicate_error = high_add_application("bankingv2", { + externalUrl = "@other_server_app/ui/index.html", +}) +assert(not duplicate_success and duplicate_error == "duplicate_app_id", "High cross-owner replacement must fail") + +source = 42 +event_handlers["sky_phone:compat:high:server:requestSnapshot"]() +assert(#sent_events == 2, "High snapshot request must emit one response") +assert(sent_events[2].event_name == "sky_phone:compat:high:client:replaceSnapshot", "High snapshot event must be used") +assert(sent_events[2].target == 42, "High snapshot must target only the requester") +assert(#sent_events[2].arguments[1] == 1, "High snapshot must include the registered app") + +print("Custom app compatibility server tests passed")
{{ phone.t('Apps.customApps.unavailableBody') }}
{{ easyShare.payload?.subtitle || easyShare.payload?.copyText }}
{{ notification.text }}
{{ errorText(companies.directoryError) }}
+ {{ + phone.t( + filtersActive + ? 'Apps.companies.states.noResultsBody' + : 'Apps.companies.states.noCompaniesBody', + ) + }} +
{{ errorText(companies.myRequestsError) }}
{{ phone.t('Apps.companies.states.noRequestsBody') }}
{{ errorText(companies.workContextError) }}
{{ phone.t('Apps.companies.work.notAuthorizedBody') }}
{{ activeCompany.description }}
{{ errorText(companies.requestError) }}
{{ companies.request.description }}
+ {{ eventLabel(event) }} + {{ formatDate(event.createdAt) }} +
{{ error }}