mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 00:01:29 +00:00
FIX - provide LB custom app browser bridge
This commit is contained in:
@@ -27,6 +27,12 @@ import {
|
||||
customAppLifecycleScheduler,
|
||||
getCustomAppSafeArea,
|
||||
} from '@/utils/customAppLifecycle'
|
||||
import {
|
||||
createLbPhoneFrameDocument,
|
||||
createLbPhoneHostSettings,
|
||||
getLbPhoneCallbackResource,
|
||||
usesLbPhoneHostRuntime,
|
||||
} from '@/utils/lbPhoneAppBridge'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -43,7 +49,9 @@ const frame = ref<HTMLIFrameElement | null>(null)
|
||||
const frameLoaded = ref(false)
|
||||
const frameUnavailable = ref(false)
|
||||
const skyBridgeReady = ref(false)
|
||||
const lbFrameDocument = ref<string | null>(null)
|
||||
let loadTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
let frameDocumentController: AbortController | undefined
|
||||
const lifecycleAppId = props.app.id
|
||||
const initialOpenRequest: CustomAppOpenRequest | undefined =
|
||||
catalog.openRequests[lifecycleAppId]
|
||||
@@ -94,11 +102,18 @@ const frameUrl = computed(() => {
|
||||
return url.href
|
||||
})
|
||||
const frameOrigin = computed(() => new URL(frameUrl.value).origin)
|
||||
const lbHostRuntime = computed(() => usesLbPhoneHostRuntime(props.app))
|
||||
const frameMountable = computed(
|
||||
() => !lbHostRuntime.value || lbFrameDocument.value !== null,
|
||||
)
|
||||
const frameSource = computed(() =>
|
||||
lbHostRuntime.value ? undefined : frameUrl.value,
|
||||
)
|
||||
const postMessageOrigin = computed(() =>
|
||||
props.app.bundled ? '*' : frameOrigin.value,
|
||||
props.app.bundled || lbHostRuntime.value ? '*' : frameOrigin.value,
|
||||
)
|
||||
const sandbox = computed(() =>
|
||||
props.app.bundled
|
||||
props.app.bundled || lbHostRuntime.value
|
||||
? 'allow-downloads allow-forms allow-modals allow-scripts'
|
||||
: 'allow-downloads allow-forms allow-modals allow-same-origin allow-scripts',
|
||||
)
|
||||
@@ -120,6 +135,15 @@ const context = computed<SkyPhoneAppContextV1>(() => ({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
safeArea: getCustomAppSafeArea(props.app.orientation),
|
||||
}))
|
||||
const lbSettings = computed(() =>
|
||||
createLbPhoneHostSettings({
|
||||
deviceName: phone.device?.name ?? '',
|
||||
isDarkMode: phone.isDarkMode,
|
||||
language: phone.lang,
|
||||
preferences: phone.preferences,
|
||||
securityEnabled: phone.security.enabled,
|
||||
}),
|
||||
)
|
||||
|
||||
function postToFrame(payload: unknown): boolean {
|
||||
const target = frame.value?.contentWindow
|
||||
@@ -149,6 +173,44 @@ function sendContext(): void {
|
||||
})
|
||||
}
|
||||
|
||||
function sendLbSettings(): void {
|
||||
if (!lbHostRuntime.value || !frameLoaded.value) return
|
||||
postToFrame({
|
||||
settings: lbSettings.value,
|
||||
type: 'sky-phone:lb-settings',
|
||||
})
|
||||
}
|
||||
|
||||
async function prepareLbFrameDocument(): Promise<void> {
|
||||
const controller = new AbortController()
|
||||
frameDocumentController = controller
|
||||
try {
|
||||
const response = await fetch(frameUrl.value, {
|
||||
credentials: 'omit',
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status} ${response.statusText}`)
|
||||
}
|
||||
const html = await response.text()
|
||||
lbFrameDocument.value = createLbPhoneFrameDocument(html, {
|
||||
appName: props.app.id,
|
||||
resourceName: getLbPhoneCallbackResource(props.app),
|
||||
settings: lbSettings.value,
|
||||
ui: props.app.ui,
|
||||
})
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return
|
||||
if (loadTimeout !== undefined) clearTimeout(loadTimeout)
|
||||
loadTimeout = undefined
|
||||
frameUnavailable.value = true
|
||||
console.error(
|
||||
`[Custom apps] Could not prepare LB Phone frame ${props.app.id}.`,
|
||||
error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function flushOpenRequest(): void {
|
||||
const request = catalog.openRequests[props.app.id]
|
||||
if (!request || !frameLoaded.value) return
|
||||
@@ -216,7 +278,7 @@ async function handleBridgeRequest(
|
||||
|
||||
function isTrustedFrameMessage(event: MessageEvent): boolean {
|
||||
if (event.source !== frame.value?.contentWindow) return false
|
||||
if (props.app.bundled) {
|
||||
if (props.app.bundled || lbHostRuntime.value) {
|
||||
return event.origin === 'null' || event.origin === frameOrigin.value
|
||||
}
|
||||
return event.origin === frameOrigin.value
|
||||
@@ -272,6 +334,7 @@ function onFrameLoad(): void {
|
||||
)) {
|
||||
postToFrame(message)
|
||||
}
|
||||
sendLbSettings()
|
||||
if (props.app.bridgeMode === 'legacy' || skyBridgeReady.value) {
|
||||
frameUnavailable.value = false
|
||||
}
|
||||
@@ -300,16 +363,19 @@ onBeforeMount(() => {
|
||||
`[Custom apps] Frame readiness timed out for ${props.app.id}.`,
|
||||
)
|
||||
}, props.app.readyTimeoutMs)
|
||||
if (lbHostRuntime.value) void prepareLbFrameDocument()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (loadTimeout !== undefined) clearTimeout(loadTimeout)
|
||||
frameDocumentController?.abort()
|
||||
window.removeEventListener('message', onFrameMessage)
|
||||
orientation.release()
|
||||
void lifecycle.report('close')
|
||||
})
|
||||
|
||||
watch(context, sendContext, { deep: true })
|
||||
watch(lbSettings, sendLbSettings, { deep: true })
|
||||
watch(
|
||||
() => props.app.orientation,
|
||||
(nextOrientation) => orientation.apply(nextOrientation),
|
||||
@@ -331,6 +397,7 @@ watch(() => catalog.openRequests[props.app.id], flushOpenRequest, {
|
||||
}"
|
||||
>
|
||||
<iframe
|
||||
v-if="frameMountable"
|
||||
ref="frame"
|
||||
v-show="frameReady && !frameUnavailable"
|
||||
class="custom-app-frame"
|
||||
@@ -338,7 +405,8 @@ watch(() => catalog.openRequests[props.app.id], flushOpenRequest, {
|
||||
'custom-app-frame--fix-blur': app.compatibility.fixBlur === true,
|
||||
}"
|
||||
:sandbox="sandbox"
|
||||
:src="frameUrl"
|
||||
:src="frameSource"
|
||||
:srcdoc="lbFrameDocument ?? undefined"
|
||||
:title="app.name"
|
||||
referrerpolicy="no-referrer"
|
||||
@load="onFrameLoad"
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ExternalPhoneAppDefinition } from '@/types/apps'
|
||||
import {
|
||||
createLbPhoneFrameDocument,
|
||||
createLbPhoneHostSettings,
|
||||
getLbPhoneCallbackResource,
|
||||
usesLbPhoneHostRuntime,
|
||||
} from '@/utils/lbPhoneAppBridge'
|
||||
import { DEFAULT_PHONE_PREFERENCES } from '@/utils/preferences'
|
||||
|
||||
function externalApp(
|
||||
overrides: Partial<ExternalPhoneAppDefinition> = {},
|
||||
): ExternalPhoneAppDefinition {
|
||||
return {
|
||||
bridgeMode: 'legacy',
|
||||
bundled: false,
|
||||
capabilities: [],
|
||||
category: 'games',
|
||||
compatibility: { provider: 'lb_phone', resourceName: 'snake_app' },
|
||||
component: null,
|
||||
defaultInstalled: true,
|
||||
description: 'Snake',
|
||||
developer: 'Example',
|
||||
dockOrder: null,
|
||||
gridOrder: 100,
|
||||
icon: {} as ExternalPhoneAppDefinition['icon'],
|
||||
iconClass: 'app-icon--custom',
|
||||
iconImage: 'https://cfx-nui-snake_app/ui/icon.png',
|
||||
id: 'snake-game' as ExternalPhoneAppDefinition['id'],
|
||||
kind: 'external',
|
||||
name: 'Snake',
|
||||
orientation: 'portrait',
|
||||
ownerResource: 'phone_adapter',
|
||||
readyTimeoutMs: 8000,
|
||||
removable: true,
|
||||
route: '/apps/snake-game' as ExternalPhoneAppDefinition['route'],
|
||||
ui: 'https://cfx-nui-snake_app/ui/dist/index.html',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('LB Phone app bridge', () => {
|
||||
it('selects local LB documents without taking over query-driven apps', () => {
|
||||
expect(usesLbPhoneHostRuntime(externalApp())).toBe(true)
|
||||
expect(
|
||||
usesLbPhoneHostRuntime(
|
||||
externalApp({
|
||||
ui: 'https://cfx-nui-snake_app/ui/index.html?route=/dispatch',
|
||||
}),
|
||||
),
|
||||
).toBe(false)
|
||||
expect(
|
||||
usesLbPhoneHostRuntime(
|
||||
externalApp({ compatibility: { provider: '17mov' } }),
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('uses the declared callback resource and rejects malformed overrides', () => {
|
||||
expect(getLbPhoneCallbackResource(externalApp())).toBe('snake_app')
|
||||
expect(
|
||||
getLbPhoneCallbackResource(
|
||||
externalApp({
|
||||
compatibility: {
|
||||
provider: 'lb_phone',
|
||||
resourceName: '../wrong',
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe('phone_adapter')
|
||||
})
|
||||
|
||||
it('maps phone preferences into the LB settings contract', () => {
|
||||
const settings = createLbPhoneHostSettings({
|
||||
deviceName: 'Main phone',
|
||||
isDarkMode: true,
|
||||
language: 'de',
|
||||
preferences: DEFAULT_PHONE_PREFERENCES,
|
||||
securityEnabled: true,
|
||||
})
|
||||
|
||||
expect(settings).toMatchObject({
|
||||
display: { brightness: 1, size: 1, theme: 'dark' },
|
||||
locale: 'de',
|
||||
name: 'Main phone',
|
||||
security: { pinCode: true },
|
||||
})
|
||||
})
|
||||
|
||||
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>'
|
||||
const document = createLbPhoneFrameDocument(html, {
|
||||
appName: 'snake-game',
|
||||
resourceName: 'snake_app',
|
||||
settings: createLbPhoneHostSettings({
|
||||
deviceName: '</script><script>window.injected=true</script>',
|
||||
isDarkMode: false,
|
||||
language: 'en',
|
||||
preferences: DEFAULT_PHONE_PREFERENCES,
|
||||
securityEnabled: false,
|
||||
}),
|
||||
ui: 'https://cfx-nui-snake_app/ui/dist/index.html',
|
||||
})
|
||||
|
||||
expect(document.indexOf('<base href=')).toBeLessThan(
|
||||
document.indexOf('src="/ui/dist/assets/index.js"'),
|
||||
)
|
||||
expect(document).toContain('globalThis.fetchNui = async')
|
||||
expect(document).toContain('globalThis.onNuiEvent = globalThis.useNuiEvent')
|
||||
expect(document).toContain('https://cfx-nui-snake_app/ui/dist/')
|
||||
expect(document).not.toContain('</script><script>window.injected=true')
|
||||
|
||||
const runtime = /<script>([\s\S]*?)<\/script>/.exec(document)?.[1]
|
||||
expect(runtime).toBeTruthy()
|
||||
expect(() => new Function(runtime ?? '')).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,237 @@
|
||||
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, '&')
|
||||
.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')
|
||||
}
|
||||
|
||||
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)}`
|
||||
}
|
||||
@@ -137,6 +137,7 @@ function SkyPhoneCompatibility.BuildLbDefinition(owner_resource, app_data)
|
||||
compatibility = {
|
||||
provider = SkyPhoneCompatibility.Providers.lb,
|
||||
apiVersion = 1,
|
||||
resourceName = type(app_data.resource) == "string" and app_data.resource or owner_resource,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
@@ -13,6 +13,18 @@ assert(lb_definition.id == "dispatch", "LB identifier must map to the Sky app ID
|
||||
assert(lb_definition.ui == "ui/index.html", "LB relative UI must remain owner-relative")
|
||||
assert(lb_definition.orientation == "landscape", "LB landscape flag must be preserved")
|
||||
assert(type(lb_definition.onOpen) == "function", "LB onUse must map to the open lifecycle")
|
||||
assert(lb_definition.compatibility.resourceName == "lb_app", "LB callbacks must target the registering resource")
|
||||
|
||||
local bridged_lb_definition = assert(SkyPhoneCompatibility.BuildLbDefinition("phone_adapter", {
|
||||
identifier = "bridged",
|
||||
name = "Bridged",
|
||||
ui = "manufacturer_app/ui/index.html",
|
||||
resource = "manufacturer_app",
|
||||
}))
|
||||
assert(
|
||||
bridged_lb_definition.compatibility.resourceName == "manufacturer_app",
|
||||
"LB adapter callbacks must target the declared app resource"
|
||||
)
|
||||
|
||||
local invalid_lb, invalid_lb_error = SkyPhoneCompatibility.BuildLbDefinition("lb_app", {
|
||||
identifier = "dispatch",
|
||||
|
||||
Reference in New Issue
Block a user