mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
FIX - complete LB custom app compatibility (#11)
* FIX - preserve LB custom app lifecycle * FIX - reset LB export aliases before startup * FIX - report competing custom app providers * FIX - identify LB app frames as live NUI * FIX - complete LB custom app compatibility * FIX - provide LB PicChat runtime exports * FIX - route LB PicChat through Sky Phone * FIX - return cached LB equipped phone number * FIX - harden PicChat compatibility migration * ENH - complete modular phone provider and Creator APIs * DOC - document the Sky Phone Creator API --------- Co-authored-by: DerEchteAlec <bycraky@gmail.com> Co-authored-by: DerEchteAlec <alec.schitzkat@luwan.io>
This commit is contained in:
co-authored by
DerEchteAlec
DerEchteAlec
parent
9aec3eefef
commit
30559048fd
@@ -5,7 +5,10 @@ import {
|
||||
createLbPhoneFrameDocument,
|
||||
createLbPhoneHostSettings,
|
||||
getLbPhoneCallbackResource,
|
||||
getLbPhoneStorageKey,
|
||||
readLbPhoneStorage,
|
||||
usesLbPhoneHostRuntime,
|
||||
writeLbPhoneStorage,
|
||||
} from '@/utils/lbPhoneAppBridge'
|
||||
import { DEFAULT_PHONE_PREFERENCES } from '@/utils/preferences'
|
||||
|
||||
@@ -90,9 +93,10 @@ describe('LB Phone app bridge', () => {
|
||||
|
||||
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>'
|
||||
'<!doctype html><html><head><script>globalThis.previewMode = !window.invokeNative</script><script type="module" src="/ui/dist/assets/index.js"></script></head><body></body></html>'
|
||||
const document = createLbPhoneFrameDocument(html, {
|
||||
appName: 'snake-game',
|
||||
localStorage: { theme: 'dark' },
|
||||
resourceName: 'snake_app',
|
||||
settings: createLbPhoneHostSettings({
|
||||
deviceName: '</script><script>window.injected=true</script>',
|
||||
@@ -109,8 +113,18 @@ describe('LB Phone app bridge', () => {
|
||||
)
|
||||
expect(document).toContain('globalThis.fetchNui = async')
|
||||
expect(document).toContain('globalThis.onNuiEvent = globalThis.useNuiEvent')
|
||||
expect(document).toContain('globalThis.createCall = globalThis.CreateCall')
|
||||
expect(document).toContain('globalThis.createSMS = globalThis.CreateSMS')
|
||||
expect(document).toContain('globalThis.invokeNative = () => undefined')
|
||||
expect(document).toContain(
|
||||
"Object.defineProperty(globalThis, 'localStorage'",
|
||||
)
|
||||
expect(document).toContain('"localStorage":{"theme":"dark"}')
|
||||
expect(document).toContain('https://cfx-nui-snake_app/ui/dist/')
|
||||
expect(document).not.toContain('</script><script>window.injected=true')
|
||||
expect(document.indexOf('globalThis.invokeNative')).toBeLessThan(
|
||||
document.indexOf('globalThis.previewMode'),
|
||||
)
|
||||
|
||||
const openingTag = '<script>'
|
||||
const runtimeStart = document.indexOf(openingTag)
|
||||
@@ -124,4 +138,27 @@ describe('LB Phone app bridge', () => {
|
||||
const runtime = document.slice(runtimeStart + openingTag.length, runtimeEnd)
|
||||
expect(() => new Function(runtime)).not.toThrow()
|
||||
})
|
||||
|
||||
it('persists isolated LB localStorage snapshots without app changes', () => {
|
||||
const values = new Map<string, string>()
|
||||
const storage = {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
}
|
||||
|
||||
expect(
|
||||
writeLbPhoneStorage(storage, 'snake-game', {
|
||||
language: 'de',
|
||||
volume: '0.8',
|
||||
}),
|
||||
).toBe(true)
|
||||
expect(values.has(getLbPhoneStorageKey('snake-game'))).toBe(true)
|
||||
expect(readLbPhoneStorage(storage, 'snake-game')).toEqual({
|
||||
language: 'de',
|
||||
volume: '0.8',
|
||||
})
|
||||
expect(writeLbPhoneStorage(storage, 'snake-game', { invalid: 5 })).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,15 @@ 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
|
||||
@@ -41,6 +50,7 @@ export type LbPhoneHostSettings = {
|
||||
|
||||
type LbPhoneFrameDocumentOptions = {
|
||||
appName: string
|
||||
localStorage: LbPhoneStorageSnapshot
|
||||
resourceName: string
|
||||
settings: LbPhoneHostSettings
|
||||
ui: string
|
||||
@@ -60,6 +70,59 @@ 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';
|
||||
@@ -70,7 +133,22 @@ function applySettings(nextSettings) {
|
||||
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');
|
||||
@@ -150,6 +228,67 @@ function serializeForInlineScript(value: unknown): string {
|
||||
.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 {
|
||||
@@ -228,6 +367,7 @@ export function createLbPhoneFrameDocument(
|
||||
const baseUrl = new URL('.', options.ui).href
|
||||
const config = serializeForInlineScript({
|
||||
appName: options.appName,
|
||||
localStorage: options.localStorage,
|
||||
resourceName: options.resourceName,
|
||||
settings: options.settings,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user