FIX - provide LB custom app browser bridge

This commit is contained in:
Leon.Schmidt
2026-08-11 20:31:01 +02:00
parent 8b205739a5
commit dc401e2b87
5 changed files with 441 additions and 4 deletions
+119
View File
@@ -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> = {},
): 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 =
'<!doctype html><html><head><script type="module" src="/ui/dist/assets/index.js"></script></head><body></body></html>'
const document = createLbPhoneFrameDocument(html, {
appName: 'snake-game',
resourceName: 'snake_app',
settings: createLbPhoneHostSettings({
deviceName: '</script><script>window.injected=true</script>',
isDarkMode: false,
language: 'en',
preferences: DEFAULT_PHONE_PREFERENCES,
securityEnabled: false,
}),
ui: 'https://cfx-nui-snake_app/ui/dist/index.html',
})
expect(document.indexOf('<base href=')).toBeLessThan(
document.indexOf('src="/ui/dist/assets/index.js"'),
)
expect(document).toContain('globalThis.fetchNui = async')
expect(document).toContain('globalThis.onNuiEvent = globalThis.useNuiEvent')
expect(document).toContain('https://cfx-nui-snake_app/ui/dist/')
expect(document).not.toContain('</script><script>window.injected=true')
const runtime = /<script>([\s\S]*?)<\/script>/.exec(document)?.[1]
expect(runtime).toBeTruthy()
expect(() => new Function(runtime ?? '')).not.toThrow()
})
})
+237
View File
@@ -0,0 +1,237 @@
import type { ExternalPhoneAppDefinition } from '@/types/apps'
import type { PhonePreferencesV1 } from '@/utils/preferences'
const LB_PHONE_PROVIDER = 'lb_phone'
const RESOURCE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/
const MAX_FRAME_DOCUMENT_BYTES = 1_048_576
export type LbPhoneHostSettings = {
airplaneMode: boolean
apps: string[][]
display: {
automatic: boolean
brightness: number
size: number
theme: 'dark' | 'light'
}
doNotDisturb: boolean
locale: string
lockscreen: {
color: string
fontStyle: number
layout: number
}
name: string
notifications: Record<string, { enabled: boolean; sound: boolean }>
phone: { showCallerId: boolean }
security: { faceId: boolean; pinCode: boolean }
sound: {
ringtone: string
silent: boolean
texttone: string
volume: number
}
storage: { total: number; used: number }
streamerMode: boolean
time: { twelveHourClock: boolean }
version: string
wallpaper: { background: string }
weather: { celcius: boolean }
}
type LbPhoneFrameDocumentOptions = {
appName: string
resourceName: string
settings: LbPhoneHostSettings
ui: string
}
type LbPhoneSettingsOptions = {
deviceName: string
isDarkMode: boolean
language: string
preferences: PhonePreferencesV1
securityEnabled: boolean
}
const LB_PHONE_RUNTIME_SOURCE = String.raw`
const listeners = new Map();
const settingsListeners = new Set();
const resourcePattern = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
const eventPattern = /^[A-Za-z0-9][A-Za-z0-9:._/-]{0,127}$/;
function applySettings(nextSettings) {
globalThis.settings = nextSettings;
const theme = nextSettings?.display?.theme === 'dark' ? 'dark' : 'light';
document.documentElement.dataset.theme = theme;
if (document.body) document.body.dataset.theme = theme;
}
globalThis.resourceName = config.resourceName;
globalThis.appName = config.appName;
globalThis.components = globalThis.components ?? {};
globalThis.GetParentResourceName = () => config.resourceName;
globalThis.fetchNui = async (eventName, data, requestedResource) => {
if (typeof eventName !== 'string' || !eventPattern.test(eventName) || eventName.includes('..')) {
throw new TypeError('Invalid NUI callback name');
}
const targetResource = typeof requestedResource === 'string'
? requestedResource
: config.resourceName;
if (!resourcePattern.test(targetResource)) {
throw new TypeError('Invalid NUI callback resource');
}
const response = await fetch('https://' + targetResource + '/' + eventName, {
body: JSON.stringify(data === undefined ? {} : data),
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
method: 'POST'
});
if (!response.ok) {
throw new Error('NUI callback failed with HTTP ' + response.status);
}
const body = await response.text();
return body ? JSON.parse(body) : null;
};
globalThis.onNuiEvent = globalThis.useNuiEvent = (eventName, callback) => {
if (typeof eventName !== 'string' || !eventPattern.test(eventName) || typeof callback !== 'function') {
throw new TypeError('Invalid NUI event listener');
}
const callbacks = listeners.get(eventName) ?? new Set();
callbacks.add(callback);
listeners.set(eventName, callbacks);
};
globalThis.onSettingsChange = (callback) => {
if (typeof callback !== 'function') throw new TypeError('Invalid settings listener');
settingsListeners.add(callback);
};
globalThis.getSettings = async () => globalThis.settings;
globalThis.addEventListener('message', (event) => {
const message = event.data;
if (!message || typeof message !== 'object') return;
if (message.type === 'sky-phone:lb-settings') {
applySettings(message.settings);
for (const callback of settingsListeners) callback(globalThis.settings);
return;
}
const eventName = typeof message.action === 'string'
? message.action
: typeof message.type === 'string'
? message.type
: null;
if (!eventName) return;
const data = Object.prototype.hasOwnProperty.call(message, 'data')
? message.data
: message;
for (const callback of listeners.get(eventName) ?? []) callback(data);
});
applySettings(config.settings);
document.addEventListener('DOMContentLoaded', () => applySettings(globalThis.settings), { once: true });
`
function escapeAttribute(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
function serializeForInlineScript(value: unknown): string {
return JSON.stringify(value)
.replace(/</g, '\\u003c')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029')
}
export function usesLbPhoneHostRuntime(
app: ExternalPhoneAppDefinition,
): boolean {
if (app.compatibility.provider !== LB_PHONE_PROVIDER) return false
const url = new URL(app.ui)
return url.protocol === 'https:' && url.search === ''
}
export function getLbPhoneCallbackResource(
app: ExternalPhoneAppDefinition,
): string {
const configured = app.compatibility.resourceName
return typeof configured === 'string' &&
RESOURCE_NAME_PATTERN.test(configured)
? configured
: app.ownerResource
}
export function createLbPhoneHostSettings(
options: LbPhoneSettingsOptions,
): LbPhoneHostSettings {
const preferences = options.preferences.settings
return {
airplaneMode: preferences.airplaneMode,
apps: [],
display: {
automatic: preferences.appearanceMode === 'automatic',
brightness: preferences.screenBrightness / 100,
size: preferences.phoneScale / 100,
theme: options.isDarkMode ? 'dark' : 'light',
},
doNotDisturb: preferences.focusMode,
locale: options.language,
lockscreen: { color: '#ffffff', fontStyle: 0, layout: 0 },
name: options.deviceName,
notifications: Object.fromEntries(
Object.entries(preferences.notifications).map(([appId, settings]) => [
appId,
{ enabled: settings.enabled, sound: settings.sounds },
]),
),
phone: { showCallerId: true },
security: { faceId: false, pinCode: options.securityEnabled },
sound: {
ringtone: preferences.ringtone,
silent:
preferences.notificationVolume === 0 &&
preferences.ringtoneVolume === 0,
texttone: preferences.notificationSound,
volume: preferences.notificationVolume / 100,
},
storage: { total: 0, used: 0 },
streamerMode: preferences.streamerMode,
time: { twelveHourClock: false },
version: 'sky_phone',
wallpaper: { background: preferences.wallpaper },
weather: { celcius: true },
}
}
export function createLbPhoneFrameDocument(
html: string,
options: LbPhoneFrameDocumentOptions,
): string {
if (
new TextEncoder().encode(html).byteLength > MAX_FRAME_DOCUMENT_BYTES ||
!RESOURCE_NAME_PATTERN.test(options.resourceName)
) {
throw new Error('invalid_lb_phone_frame_document')
}
const head = /<head(?:\s[^>]*)?>/i.exec(html)
if (!head) throw new Error('invalid_lb_phone_frame_document')
const baseUrl = new URL('.', options.ui).href
const config = serializeForInlineScript({
appName: options.appName,
resourceName: options.resourceName,
settings: options.settings,
})
const injection = `<base href="${escapeAttribute(baseUrl)}"><script>(() => { const config = ${config};${LB_PHONE_RUNTIME_SOURCE}\n})();<\/script>`
const insertionPoint = head.index + head[0].length
return `${html.slice(0, insertionPoint)}${injection}${html.slice(insertionPoint)}`
}