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 @@ + +