From 1da9fee99761c642be33a450281d2d8744c64021 Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Sun, 23 Aug 2026 14:37:13 +0200 Subject: [PATCH] FIX - enforce configured app availability --- frontend/src/appAvailability.contract.test.ts | 75 +++++++++++++++++++ frontend/src/router/index.ts | 5 +- frontend/src/stores/app-store.test.ts | 25 +++++++ frontend/src/views/PhoneAppWindow.vue | 4 +- sky_phone/source/server/crypto.lua | 8 +- 5 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 frontend/src/appAvailability.contract.test.ts diff --git a/frontend/src/appAvailability.contract.test.ts b/frontend/src/appAvailability.contract.test.ts new file mode 100644 index 0000000..acf591f --- /dev/null +++ b/frontend/src/appAvailability.contract.test.ts @@ -0,0 +1,75 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { + PHONE_APPS, + isExternalPhoneApp, + isLaunchablePhoneApp, +} from '@/config/apps' + +const repositoryRoot = join(import.meta.dirname, '../..') +const source = (path: string) => + readFileSync(join(repositoryRoot, path), 'utf8') + +const app = source('frontend/src/App.vue') +const appStore = source('frontend/src/stores/app-store.ts') +const appStoreView = source('frontend/src/views/apps/AppStoreApp.vue') +const appWindow = source('frontend/src/views/PhoneAppWindow.vue') +const client = source('sky_phone/source/client/main.lua') +const config = source('sky_phone/config/config.lua') +const configDefault = source('sky_phone/source/shared/config_default.lua') +const crypto = source('sky_phone/source/server/crypto.lua') +const phone = source('sky_phone/source/server/phone.lua') +const router = source('frontend/src/router/index.ts') + +describe('configured app availability contract', () => { + it('keeps every built-in app in the file and runtime defaults', () => { + const builtInAppIds = PHONE_APPS.filter( + (app) => + isLaunchablePhoneApp(app) && !app.adminOnly && !isExternalPhoneApp(app), + ).map((app) => app.id) + + for (const appId of builtInAppIds) { + const entry = appId.includes('-') + ? `["${appId}"] = true` + : `${appId} = true` + expect(config).toContain(entry) + expect(configDefault).toContain(entry) + } + }) + + it('sends disabled apps and reapplies them to an open phone after live saves', () => { + expect(phone).toContain('disabledApps = SkyPhone.GetDisabledApps()') + expect(phone).toMatch( + /function SkyPhone\.GetDisabledApps\(\)[\s\S]*?enabled == false/, + ) + expect(client).toMatch( + /configurator:updated[\s\S]*?apply_disabled_apps\(device_payload\)[\s\S]*?device:updated/, + ) + expect(app).toContain( + 'appStore.hydrate(payload.device?.data.apps?.payload, payload.disabledApps)', + ) + }) + + it('removes disabled apps from every launch path without changing device installs', () => { + expect(appStore).toContain('disabledApps: [] as LaunchablePhoneAppId[]') + expect(appStore).toContain('if (!this.isAvailable(appId)) return false') + expect(appStoreView).toContain('appStore.isAvailable(app.id)') + expect(router).toContain('useAppStoreStore().isInstalled(to.params.appId)') + expect(appWindow).toContain('appStore.isInstalled(app.id)') + expect(app).toMatch( + /isPhoneAppId\(currentAppId\)[\s\S]*?!appStore\.isInstalled\(currentAppId\)[\s\S]*?router\.push\('\/'\)/, + ) + }) + + it('rejects crypto operations after the app is disabled at runtime', () => { + expect(crypto).toMatch( + /local function require_phone\(source\)[\s\S]*?not Config\.Crypto\.Enabled or not SkyPhone\.IsAppEnabled\("crypto"\)/, + ) + expect(crypto).toMatch( + /local function refresh_crypto_runtime\(\)[\s\S]*?not Config\.Crypto\.Enabled or not SkyPhone\.IsAppEnabled\("crypto"\)[\s\S]*?sessions = \{\}/, + ) + }) +}) diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index c168ca0..05d7773 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -5,6 +5,7 @@ import { } from 'vue-router' import { isPhoneAppId } from '@/config/apps' +import { useAppStoreStore } from '@/stores/app-store' import PhoneAppWindow from '@/views/PhoneAppWindow.vue' import SpringboardView from '@/views/SpringboardView.vue' @@ -34,7 +35,9 @@ export default createRouter({ }, { beforeEnter: (to) => - typeof to.params.appId === 'string' && isPhoneAppId(to.params.appId) + typeof to.params.appId === 'string' && + isPhoneAppId(to.params.appId) && + useAppStoreStore().isInstalled(to.params.appId) ? true : '/', component: PhoneAppWindow, diff --git a/frontend/src/stores/app-store.test.ts b/frontend/src/stores/app-store.test.ts index 159c1b0..d7ff2ac 100644 --- a/frontend/src/stores/app-store.test.ts +++ b/frontend/src/stores/app-store.test.ts @@ -276,6 +276,31 @@ describe('app store', () => { expect(apps.isInstalled('phone')).toBe(true) }) + it('temporarily blocks server-disabled apps without changing their installation', () => { + const apps = useAppStoreStore() + apps.hydrate({ claimedApps: ['crypto'] }, [ + 'crypto', + 'citywarn', + 'not-an-app', + ]) + mocks.phone.saveDeviceNamespace.mockClear() + + expect(apps.disabledApps).toEqual(['crypto', 'citywarn']) + expect(apps.isAvailable('crypto')).toBe(false) + expect(apps.isInstalled('crypto')).toBe(false) + expect(apps.isInstalled('citywarn')).toBe(false) + expect(apps.claimedApps).toEqual(['crypto']) + + apps.installApp('crypto') + expect(apps.installingApps).toEqual({}) + expect(mocks.phone.saveDeviceNamespace).not.toHaveBeenCalled() + + apps.hydrate({ claimedApps: ['crypto'] }) + expect(apps.isAvailable('crypto')).toBe(true) + expect(apps.isInstalled('crypto')).toBe(true) + expect(apps.isInstalled('citywarn')).toBe(true) + }) + it('protects every default app from full uninstallation', () => { const apps = useAppStoreStore() apps.hydrate(null) diff --git a/frontend/src/views/PhoneAppWindow.vue b/frontend/src/views/PhoneAppWindow.vue index 0dcc86e..3896f49 100644 --- a/frontend/src/views/PhoneAppWindow.vue +++ b/frontend/src/views/PhoneAppWindow.vue @@ -4,11 +4,13 @@ import { useRoute } from 'vue-router' import CustomAppFrame from '@/components/CustomAppFrame.vue' import { getPhoneApp, isExternalPhoneApp } from '@/config/apps' +import { useAppStoreStore } from '@/stores/app-store' import { usePhoneStore } from '@/stores/phone' import { getCustomAppFrameKey } from '@/utils/customAppLifecycle' import AppStoreApp from '@/views/apps/AppStoreApp.vue' const route = useRoute() +const appStore = useAppStoreStore() const phone = usePhoneStore() const app = computed(() => getPhoneApp(route.params.appId)) const builtinAppComponent = computed(() => @@ -28,7 +30,7 @@ const launchStyle = computed(() => {