Files
sky_phone-sky-systems/frontend/src/utils/lbPhoneAppBridge.ts
T
2026-08-11 20:31:01 +02:00

238 lines
7.4 KiB
TypeScript

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)}`
}