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 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, '&') .replace(/"/g, '"') .replace(//g, '>') } function serializeForInlineScript(value: unknown): string { return JSON.stringify(value) .replace(/ [ 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, resourceName: options.resourceName, settings: options.settings, }) const injection = `