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 const MAX_STORAGE_BYTES = 65_536 const MAX_STORAGE_ENTRIES = 128 const MAX_STORAGE_KEY_LENGTH = 512 const STORAGE_KEY_PREFIX = 'sky_phone:lb-app-storage:v1:' export const LB_PHONE_STORAGE_MESSAGE_TYPE = 'sky-phone:lb-storage' export const LB_PHONE_ACTION_MESSAGE_TYPE = 'sky-phone:lb-action' export type LbPhoneStorageSnapshot = Record 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 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 localStorage: LbPhoneStorageSnapshot 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 createStorage(initialValues, onChange) { const values = new Map(Object.entries(initialValues ?? {})); const snapshot = () => Object.fromEntries(values); const storage = { clear() { if (values.size === 0) return; values.clear(); onChange(snapshot()); }, getItem(key) { const normalizedKey = String(key); return values.has(normalizedKey) ? values.get(normalizedKey) : null; }, key(index) { const normalizedIndex = Number(index); if (!Number.isInteger(normalizedIndex) || normalizedIndex < 0) return null; return Array.from(values.keys())[normalizedIndex] ?? null; }, removeItem(key) { if (!values.delete(String(key))) return; onChange(snapshot()); }, setItem(key, value) { values.set(String(key), String(value)); onChange(snapshot()); } }; Object.defineProperty(storage, 'length', { enumerable: true, get: () => values.size }); return storage; } const localStorageBridge = createStorage(config.localStorage, (storage) => { globalThis.parent.postMessage({ appId: config.appName, protocolVersion: 1, storage, type: '${LB_PHONE_STORAGE_MESSAGE_TYPE}' }, '*'); }); Object.defineProperty(globalThis, 'localStorage', { configurable: true, enumerable: true, value: localStorageBridge }); Object.defineProperty(globalThis, 'sessionStorage', { configurable: true, enumerable: true, value: createStorage({}, () => undefined) }); 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; } function prepareDocument() { Object.assign(document.documentElement.style, { height: '100%', margin: '0', padding: '0', width: '100%' }); if (!document.body) return; document.body.dataset.device = 'phone'; Object.assign(document.body.style, { height: '100%', margin: '0', padding: '0', visibility: 'visible', width: '100%' }); } globalThis.resourceName = config.resourceName; globalThis.appName = config.appName; globalThis.components = globalThis.components ?? {}; // Official LB app templates use this binding to distinguish live NUI from browser preview mode. if (typeof globalThis.invokeNative !== 'function') { globalThis.invokeNative = () => undefined; } globalThis.GetParentResourceName = () => config.resourceName; function requestPhoneAction(action, options) { globalThis.parent.postMessage({ action, appId: config.appName, options, protocolVersion: 1, type: '${LB_PHONE_ACTION_MESSAGE_TYPE}' }, '*'); } globalThis.createCall = globalThis.CreateCall = (options) => requestPhoneAction('createCall', options); globalThis.createSMS = globalThis.CreateSMS = (options) => requestPhoneAction('createSMS', options); 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 === 'componentsLoaded') { globalThis.componentsLoaded = true; prepareDocument(); return; } 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', () => { prepareDocument(); applySettings(globalThis.settings); }, { once: true }); ` function escapeAttribute(value: string): string { return value .replace(/&/g, '&') .replace(/"/g, '"') .replace(//g, '>') } function serializeForInlineScript(value: unknown): string { return JSON.stringify(value) .replace(/ MAX_STORAGE_ENTRIES) return null const normalized: LbPhoneStorageSnapshot = {} for (const [key, item] of entries) { if ( key.length > MAX_STORAGE_KEY_LENGTH || typeof item !== 'string' || key === '__proto__' || key === 'constructor' || key === 'prototype' ) { return null } normalized[key] = item } return new TextEncoder().encode(JSON.stringify(normalized)).byteLength <= MAX_STORAGE_BYTES ? normalized : null } export function getLbPhoneStorageKey(appName: string): string { if (!RESOURCE_NAME_PATTERN.test(appName)) { throw new Error('invalid_lb_phone_storage_app') } return `${STORAGE_KEY_PREFIX}${appName}` } export function readLbPhoneStorage( storage: Pick, appName: string, ): LbPhoneStorageSnapshot { const serialized = storage.getItem(getLbPhoneStorageKey(appName)) if (serialized === null) return {} const normalized = normalizeStorageSnapshot(JSON.parse(serialized)) if (!normalized) throw new Error('invalid_lb_phone_storage') return normalized } export function writeLbPhoneStorage( storage: Pick, appName: string, value: unknown, ): boolean { const normalized = normalizeStorageSnapshot(value) if (!normalized) return false storage.setItem(getLbPhoneStorageKey(appName), JSON.stringify(normalized)) return true } 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 = /]*)?>/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, localStorage: options.localStorage, resourceName: options.resourceName, settings: options.settings, }) const injection = `