From 42ac955052d5d5353752e27ad6fceedf0935d746 Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Tue, 11 Aug 2026 00:22:39 +0200 Subject: [PATCH 01/13] ADD - expand phone application platform Add the Companies app, resilient call handling, custom app registry, vendor compatibility, storage policies, frontend bridge, tests, documentation, config, locale, and SQL migrations. --- README.md | 10 + frontend/src/App.vue | 182 +- .../src/assets/img/app-icons/companies.svg | 29 + frontend/src/assets/main.css | 10 + frontend/src/components/AppIcon.vue | 33 +- frontend/src/components/CustomAppFrame.vue | 421 +++ .../components/NotificationPhonePreview.vue | 2 + frontend/src/components/PayphoneOverlay.vue | 2 + frontend/src/components/PhoneLockScreen.vue | 22 +- frontend/src/components/PhoneMediaCapture.vue | 2 + .../src/components/PhoneNotifications.vue | 13 + frontend/src/components/RadioHud.vue | 2 + frontend/src/config/apps.test.ts | 7 + frontend/src/config/apps.ts | 65 +- frontend/src/stores/app-catalog.test.ts | 135 + frontend/src/stores/app-catalog.ts | 314 ++ frontend/src/stores/app-store.test.ts | 78 +- frontend/src/stores/app-store.ts | 185 +- frontend/src/stores/companies.test.ts | 327 ++ frontend/src/stores/companies.ts | 539 +++ frontend/src/stores/notifications.test.ts | 15 +- frontend/src/stores/notifications.ts | 51 +- frontend/src/stores/phone.ts | 329 +- frontend/src/types/apps.ts | 112 +- frontend/src/types/companies.ts | 271 ++ frontend/src/types/phone.ts | 7 + frontend/src/utils/customAppBridge.test.ts | 268 ++ frontend/src/utils/customAppBridge.ts | 512 +++ frontend/src/utils/customAppLifecycle.test.ts | 229 ++ frontend/src/utils/customAppLifecycle.ts | 183 + frontend/src/utils/homeLayout.test.ts | 26 +- frontend/src/utils/homeLayout.ts | 30 +- frontend/src/utils/nui.ts | 2 +- frontend/src/utils/preferences.test.ts | 22 + frontend/src/utils/preferences.ts | 50 +- frontend/src/utils/windowMessages.test.ts | 19 + frontend/src/utils/windowMessages.ts | 6 + frontend/src/views/PhoneAppWindow.vue | 13 +- frontend/src/views/SpringboardView.vue | 29 +- frontend/src/views/apps/AppStoreApp.vue | 26 +- frontend/src/views/apps/BillingApp.vue | 2 + frontend/src/views/apps/CameraApp.vue | 2 + frontend/src/views/apps/CompaniesApp.vue | 3331 +++++++++++++++++ frontend/src/views/apps/CrewLinkApp.vue | 2 + frontend/src/views/apps/GalleryApp.vue | 2 + frontend/src/views/apps/GarageApp.vue | 2 + frontend/src/views/apps/MailApp.vue | 2 + frontend/src/views/apps/MessagesApp.vue | 86 +- frontend/src/views/apps/PhoneApp.vue | 36 +- frontend/src/views/apps/RadioApp.vue | 2 + frontend/src/views/apps/SettingsApp.vue | 18 +- frontend/src/views/apps/SkyRideApp.vue | 2 + frontend/testserver/index.cjs | 960 ++++- sky_phone/config/companies.lua | 248 ++ sky_phone/config/config.lua | 17 + sky_phone/config/locales/en.lua | 315 +- sky_phone/custom_apps/_sdk/default-icon.svg | 13 + sky_phone/custom_apps/_sdk/sky-phone-app.d.ts | 74 + sky_phone/custom_apps/_sdk/sky-phone-app.js | 211 ++ sky_phone/fxmanifest.lua | 21 + sky_phone/source/client/custom_app_compat.lua | 581 +++ sky_phone/source/client/custom_apps.lua | 1396 +++++++ sky_phone/source/client/main.lua | 39 + sky_phone/source/server/calls.lua | 720 +++- sky_phone/source/server/companies.lua | 3028 +++++++++++++++ sky_phone/source/server/custom_app_compat.lua | 138 + .../source/server/custom_app_storage.lua | 271 ++ sky_phone/source/server/custom_apps.lua | 385 ++ sky_phone/source/server/db_migrate.lua | 286 ++ sky_phone/source/server/phone.lua | 25 + sky_phone/source/server/sim.lua | 24 + sky_phone/source/shared/custom_app_compat.lua | 229 ++ sky_phone/source/shared/custom_apps.lua | 451 +++ sky_phone/sql/install.sql | 178 + tests/custom_app_compat.lua | 80 + tests/custom_app_compat_client.lua | 140 + tests/custom_app_compat_server.lua | 68 + 77 files changed, 17630 insertions(+), 333 deletions(-) create mode 100644 frontend/src/assets/img/app-icons/companies.svg create mode 100644 frontend/src/components/CustomAppFrame.vue create mode 100644 frontend/src/stores/app-catalog.test.ts create mode 100644 frontend/src/stores/app-catalog.ts create mode 100644 frontend/src/stores/companies.test.ts create mode 100644 frontend/src/stores/companies.ts create mode 100644 frontend/src/types/companies.ts create mode 100644 frontend/src/utils/customAppBridge.test.ts create mode 100644 frontend/src/utils/customAppBridge.ts create mode 100644 frontend/src/utils/customAppLifecycle.test.ts create mode 100644 frontend/src/utils/customAppLifecycle.ts create mode 100644 frontend/src/utils/windowMessages.test.ts create mode 100644 frontend/src/utils/windowMessages.ts create mode 100644 frontend/src/views/apps/CompaniesApp.vue create mode 100644 sky_phone/config/companies.lua create mode 100644 sky_phone/custom_apps/_sdk/default-icon.svg create mode 100644 sky_phone/custom_apps/_sdk/sky-phone-app.d.ts create mode 100644 sky_phone/custom_apps/_sdk/sky-phone-app.js create mode 100644 sky_phone/source/client/custom_app_compat.lua create mode 100644 sky_phone/source/client/custom_apps.lua create mode 100644 sky_phone/source/server/companies.lua create mode 100644 sky_phone/source/server/custom_app_compat.lua create mode 100644 sky_phone/source/server/custom_app_storage.lua create mode 100644 sky_phone/source/server/custom_apps.lua create mode 100644 sky_phone/source/shared/custom_app_compat.lua create mode 100644 sky_phone/source/shared/custom_apps.lua create mode 100644 tests/custom_app_compat.lua create mode 100644 tests/custom_app_compat_client.lua create mode 100644 tests/custom_app_compat_server.lua diff --git a/README.md b/README.md index 90802cd..9786b89 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,15 @@ # 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 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/src/App.vue b/frontend/src/App.vue index b7c966a..dee09c2 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -29,6 +29,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' @@ -39,6 +40,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' @@ -46,16 +48,22 @@ import { useNotesStore } from '@/stores/notes' import { useWeatherStore } from '@/stores/weather' 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 { 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 = { @@ -64,6 +72,7 @@ type AppMessage = { | CalendarReminderData | MailEventData | MarketplaceEventData + | CompaniesEventData | MessagesEventData | DarkChatEventData | FlareEventData @@ -76,6 +85,18 @@ type AppMessage = { | PhoneCall | PhoneNotificationInput | PhoneOpenPayload + | CustomAppCatalogEventData + | CustomAppEventData +} + +type CustomAppCatalogEventData = { + apps?: unknown +} + +type CustomAppEventData = { + appId?: unknown + data?: unknown + payload?: unknown } type SimPickerPayload = { @@ -104,6 +125,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 @@ -209,6 +242,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() @@ -218,6 +252,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() @@ -267,6 +302,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 @@ -279,7 +316,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, @@ -296,6 +342,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 @@ -315,6 +386,7 @@ function loadUnlockedPhoneData(): void { void calls.bootstrap() void messages.loadConversations() void billing.loadOverview() + void companies.refreshUnreadCounts() if (account.email) void darkchat.bootstrap() }) } @@ -345,7 +417,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') { @@ -361,10 +461,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, @@ -406,6 +503,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 @@ -667,10 +805,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', @@ -756,6 +891,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 @@ -907,6 +1064,7 @@ watch( (isOpen) => { if (unlockTimer !== undefined) window.clearTimeout(unlockTimer) if (!isOpen) { + appStore.cancelPendingInstalls() if (unlockedServicesFrame !== undefined) { window.cancelAnimationFrame(unlockedServicesFrame) unlockedServicesFrame = undefined @@ -961,6 +1119,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) @@ -1007,6 +1168,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" /> @@ -1096,6 +1259,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 6006bb2..fb69afa 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -679,6 +679,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 @@ + +