FIX - enforce configured app availability

This commit is contained in:
Leon.Schmidt
2026-08-23 14:37:13 +02:00
parent 49c5964f7e
commit 1da9fee997
5 changed files with 114 additions and 3 deletions
@@ -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 = \{\}/,
)
})
})
+4 -1
View File
@@ -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,
+25
View File
@@ -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)
+3 -1
View File
@@ -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(() => {
<template>
<div
v-if="app && !app.adminOnly"
v-if="app && !app.adminOnly && appStore.isInstalled(app.id)"
class="app-window"
:class="{ 'app-window--citywarn': app.id === 'citywarn' }"
:style="launchStyle"
+7 -1
View File
@@ -371,6 +371,9 @@ local function initialize_markets()
end
local function require_phone(source)
if not Config.Crypto.Enabled or not SkyPhone.IsAppEnabled("crypto") then
return nil, nil, { success = false, error = "service_unavailable" }
end
local phone_session, error_response = SkyPhone.RequireSession(source)
if not phone_session then
return nil, nil, error_response
@@ -661,7 +664,7 @@ local function with_exchange_lock(callback)
end
Bridge.Callbacks.Register("sky_phone:crypto:bootstrap", function(source)
if not Config.Crypto.Enabled then
if not Config.Crypto.Enabled or not SkyPhone.IsAppEnabled("crypto") then
return { success = false, error = "service_unavailable" }
end
local profile, error_response = authenticated_profile(source)
@@ -1647,6 +1650,9 @@ end
local function refresh_crypto_runtime()
initialize_markets()
start_crypto_schedulers()
if not Config.Crypto.Enabled or not SkyPhone.IsAppEnabled("crypto") then
sessions = {}
end
end
AddEventHandler("sky_phone:configurator:serverUpdated", refresh_crypto_runtime)