mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:08:59 +00:00
dca0868753
* FIX - stabilize homescreen dragging across scaling Render the home drag preview in an unzoomed viewport portal and normalize FiveM CEF DOM geometry before hit testing and drop settling. Cover Chrome 103, fractional scaling, 4K, ultrawide, and non-16:9 viewport behavior. * FIX - Implement LB guest app layout and lifecycle bridge Ensure custom LB apps are properly sized and visible by applying the required CSS contract on initialization and upon receiving the 'componentsLoaded' signal. * FIX - match uppercase script tags in LB bridge test
405 lines
12 KiB
TypeScript
405 lines
12 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
|
|
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<string, string>
|
|
|
|
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
|
|
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, '<')
|
|
.replace(/>/g, '>')
|
|
}
|
|
|
|
function serializeForInlineScript(value: unknown): string {
|
|
return JSON.stringify(value)
|
|
.replace(/</g, '\\u003c')
|
|
.replace(/\u2028/g, '\\u2028')
|
|
.replace(/\u2029/g, '\\u2029')
|
|
}
|
|
|
|
function normalizeStorageSnapshot(
|
|
value: unknown,
|
|
): LbPhoneStorageSnapshot | null {
|
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
return null
|
|
}
|
|
|
|
const entries = Object.entries(value)
|
|
if (entries.length > 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<Storage, 'getItem'>,
|
|
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<Storage, 'setItem'>,
|
|
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 = /<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,
|
|
localStorage: options.localStorage,
|
|
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)}`
|
|
}
|