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
+72 -4
View File
@@ -27,6 +27,12 @@ import {
customAppLifecycleScheduler,
getCustomAppSafeArea,
} from '@/utils/customAppLifecycle'
import {
createLbPhoneFrameDocument,
createLbPhoneHostSettings,
getLbPhoneCallbackResource,
usesLbPhoneHostRuntime,
} from '@/utils/lbPhoneAppBridge'
import { nuiCall } from '@/utils/nui'
const props = defineProps<{
@@ -43,7 +49,9 @@ const frame = ref<HTMLIFrameElement | null>(null)
const frameLoaded = ref(false)
const frameUnavailable = ref(false)
const skyBridgeReady = ref(false)
const lbFrameDocument = ref<string | null>(null)
let loadTimeout: ReturnType<typeof setTimeout> | undefined
let frameDocumentController: AbortController | undefined
const lifecycleAppId = props.app.id
const initialOpenRequest: CustomAppOpenRequest | undefined =
catalog.openRequests[lifecycleAppId]
@@ -94,11 +102,18 @@ const frameUrl = computed(() => {
return url.href
})
const frameOrigin = computed(() => new URL(frameUrl.value).origin)
const lbHostRuntime = computed(() => usesLbPhoneHostRuntime(props.app))
const frameMountable = computed(
() => !lbHostRuntime.value || lbFrameDocument.value !== null,
)
const frameSource = computed(() =>
lbHostRuntime.value ? undefined : frameUrl.value,
)
const postMessageOrigin = computed(() =>
props.app.bundled ? '*' : frameOrigin.value,
props.app.bundled || lbHostRuntime.value ? '*' : frameOrigin.value,
)
const sandbox = computed(() =>
props.app.bundled
props.app.bundled || lbHostRuntime.value
? 'allow-downloads allow-forms allow-modals allow-scripts'
: 'allow-downloads allow-forms allow-modals allow-same-origin allow-scripts',
)
@@ -120,6 +135,15 @@ const context = computed<SkyPhoneAppContextV1>(() => ({
protocolVersion: PROTOCOL_VERSION,
safeArea: getCustomAppSafeArea(props.app.orientation),
}))
const lbSettings = computed(() =>
createLbPhoneHostSettings({
deviceName: phone.device?.name ?? '',
isDarkMode: phone.isDarkMode,
language: phone.lang,
preferences: phone.preferences,
securityEnabled: phone.security.enabled,
}),
)
function postToFrame(payload: unknown): boolean {
const target = frame.value?.contentWindow
@@ -149,6 +173,44 @@ function sendContext(): void {
})
}
function sendLbSettings(): void {
if (!lbHostRuntime.value || !frameLoaded.value) return
postToFrame({
settings: lbSettings.value,
type: 'sky-phone:lb-settings',
})
}
async function prepareLbFrameDocument(): Promise<void> {
const controller = new AbortController()
frameDocumentController = controller
try {
const response = await fetch(frameUrl.value, {
credentials: 'omit',
signal: controller.signal,
})
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`)
}
const html = await response.text()
lbFrameDocument.value = createLbPhoneFrameDocument(html, {
appName: props.app.id,
resourceName: getLbPhoneCallbackResource(props.app),
settings: lbSettings.value,
ui: props.app.ui,
})
} catch (error) {
if (controller.signal.aborted) return
if (loadTimeout !== undefined) clearTimeout(loadTimeout)
loadTimeout = undefined
frameUnavailable.value = true
console.error(
`[Custom apps] Could not prepare LB Phone frame ${props.app.id}.`,
error,
)
}
}
function flushOpenRequest(): void {
const request = catalog.openRequests[props.app.id]
if (!request || !frameLoaded.value) return
@@ -216,7 +278,7 @@ async function handleBridgeRequest(
function isTrustedFrameMessage(event: MessageEvent): boolean {
if (event.source !== frame.value?.contentWindow) return false
if (props.app.bundled) {
if (props.app.bundled || lbHostRuntime.value) {
return event.origin === 'null' || event.origin === frameOrigin.value
}
return event.origin === frameOrigin.value
@@ -272,6 +334,7 @@ function onFrameLoad(): void {
)) {
postToFrame(message)
}
sendLbSettings()
if (props.app.bridgeMode === 'legacy' || skyBridgeReady.value) {
frameUnavailable.value = false
}
@@ -300,16 +363,19 @@ onBeforeMount(() => {
`[Custom apps] Frame readiness timed out for ${props.app.id}.`,
)
}, props.app.readyTimeoutMs)
if (lbHostRuntime.value) void prepareLbFrameDocument()
})
onBeforeUnmount(() => {
if (loadTimeout !== undefined) clearTimeout(loadTimeout)
frameDocumentController?.abort()
window.removeEventListener('message', onFrameMessage)
orientation.release()
void lifecycle.report('close')
})
watch(context, sendContext, { deep: true })
watch(lbSettings, sendLbSettings, { deep: true })
watch(
() => props.app.orientation,
(nextOrientation) => orientation.apply(nextOrientation),
@@ -331,6 +397,7 @@ watch(() => catalog.openRequests[props.app.id], flushOpenRequest, {
}"
>
<iframe
v-if="frameMountable"
ref="frame"
v-show="frameReady && !frameUnavailable"
class="custom-app-frame"
@@ -338,7 +405,8 @@ watch(() => catalog.openRequests[props.app.id], flushOpenRequest, {
'custom-app-frame--fix-blur': app.compatibility.fixBlur === true,
}"
:sandbox="sandbox"
:src="frameUrl"
:src="frameSource"
:srcdoc="lbFrameDocument ?? undefined"
:title="app.name"
referrerpolicy="no-referrer"
@load="onFrameLoad"
+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)}`
}
@@ -137,6 +137,7 @@ function SkyPhoneCompatibility.BuildLbDefinition(owner_resource, app_data)
compatibility = {
provider = SkyPhoneCompatibility.Providers.lb,
apiVersion = 1,
resourceName = type(app_data.resource) == "string" and app_data.resource or owner_resource,
},
}
end
+12
View File
@@ -13,6 +13,18 @@ assert(lb_definition.id == "dispatch", "LB identifier must map to the Sky app ID
assert(lb_definition.ui == "ui/index.html", "LB relative UI must remain owner-relative")
assert(lb_definition.orientation == "landscape", "LB landscape flag must be preserved")
assert(type(lb_definition.onOpen) == "function", "LB onUse must map to the open lifecycle")
assert(lb_definition.compatibility.resourceName == "lb_app", "LB callbacks must target the registering resource")
local bridged_lb_definition = assert(SkyPhoneCompatibility.BuildLbDefinition("phone_adapter", {
identifier = "bridged",
name = "Bridged",
ui = "manufacturer_app/ui/index.html",
resource = "manufacturer_app",
}))
assert(
bridged_lb_definition.compatibility.resourceName == "manufacturer_app",
"LB adapter callbacks must target the declared app resource"
)
local invalid_lb, invalid_lb_error = SkyPhoneCompatibility.BuildLbDefinition("lb_app", {
identifier = "dispatch",