mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-31 02:08:58 +00:00
522 lines
14 KiB
TypeScript
522 lines
14 KiB
TypeScript
import type {
|
|
CustomAppBridgeMode,
|
|
SkyPhoneAppBridgeRequest,
|
|
SkyPhoneAppBridgeResponse,
|
|
SkyPhoneAppCapability,
|
|
} from '@/types/apps'
|
|
import type { NuiResponse } from '@/utils/nui'
|
|
import type { NotificationSoundId } from '@/utils/preferences'
|
|
|
|
const STORAGE_KEY_PATTERN = /^[A-Za-z0-9._-]{1,64}$/
|
|
const NOTIFICATION_SOUNDS: ReadonlySet<NotificationSoundId> = new Set([
|
|
'chime',
|
|
'signal',
|
|
'soft',
|
|
])
|
|
const JSON_MAX_DEPTH = 8
|
|
const JSON_MAX_NODES = 512
|
|
const HANDLED_REQUEST_ID_LIMIT = 512
|
|
const STORAGE_VALUE_MAX_BYTES = 65_536
|
|
const DEEP_LINK_DATA_MAX_BYTES = 16_384
|
|
|
|
type JsonResult = { success: true; value: unknown } | { success: false }
|
|
|
|
export type CustomAppBridgeNotification = {
|
|
appId: string
|
|
route: string
|
|
sound?: NotificationSoundId
|
|
subtitle?: string
|
|
text: string
|
|
title: string
|
|
}
|
|
|
|
export type CustomAppBridgeTarget = {
|
|
external: boolean
|
|
id: string
|
|
route: string
|
|
}
|
|
|
|
export type CustomAppBridgeEffect =
|
|
| { type: 'close' }
|
|
| { route: string; type: 'open' }
|
|
|
|
export type CustomAppBridgeResult = {
|
|
effect?: CustomAppBridgeEffect
|
|
response: SkyPhoneAppBridgeResponse
|
|
}
|
|
|
|
type CustomAppBridgeDependencies = {
|
|
createNotification: (
|
|
notification: CustomAppBridgeNotification,
|
|
) => string | null
|
|
getSourceApp: () => { capabilities: readonly string[]; id: string }
|
|
prepareExternalOpen: (appId: string, data?: unknown) => boolean
|
|
resolveTarget: (appId: string) => CustomAppBridgeTarget | null
|
|
storageCall: (
|
|
endpoint: 'custom-app:storage:get' | 'custom-app:storage:set',
|
|
payload: Record<string, unknown>,
|
|
) => Promise<NuiResponse>
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
}
|
|
|
|
function hasOnlyKeys(
|
|
value: Record<string, unknown>,
|
|
allowedKeys: readonly string[],
|
|
): boolean {
|
|
const allowed = new Set(allowedKeys)
|
|
return Object.keys(value).every((key) => allowed.has(key))
|
|
}
|
|
|
|
function readText(value: unknown, maximumLength: number): string | null {
|
|
if (typeof value !== 'string') return null
|
|
const normalized = value.trim()
|
|
return normalized.length > 0 && normalized.length <= maximumLength
|
|
? normalized
|
|
: null
|
|
}
|
|
|
|
function cloneJsonValue(
|
|
value: unknown,
|
|
depth: number,
|
|
state: { nodes: number; seen: WeakSet<object> },
|
|
): JsonResult {
|
|
state.nodes += 1
|
|
if (state.nodes > JSON_MAX_NODES || depth > JSON_MAX_DEPTH) {
|
|
return { success: false }
|
|
}
|
|
if (
|
|
value === null ||
|
|
typeof value === 'boolean' ||
|
|
typeof value === 'string'
|
|
) {
|
|
return { success: true, value }
|
|
}
|
|
if (typeof value === 'number') {
|
|
return Number.isFinite(value)
|
|
? { success: true, value }
|
|
: { success: false }
|
|
}
|
|
if (typeof value !== 'object' || state.seen.has(value)) {
|
|
return { success: false }
|
|
}
|
|
|
|
state.seen.add(value)
|
|
if (Array.isArray(value)) {
|
|
if (value.length > 128) return { success: false }
|
|
const result: unknown[] = []
|
|
for (const item of value) {
|
|
const normalized = cloneJsonValue(item, depth + 1, state)
|
|
if (!normalized.success) return normalized
|
|
result.push(normalized.value)
|
|
}
|
|
state.seen.delete(value)
|
|
return { success: true, value: result }
|
|
}
|
|
|
|
const prototype = Object.getPrototypeOf(value)
|
|
if (prototype !== Object.prototype && prototype !== null) {
|
|
return { success: false }
|
|
}
|
|
const entries = Object.entries(value)
|
|
if (entries.length > 128) return { success: false }
|
|
const result: Record<string, unknown> = {}
|
|
for (const [key, item] of entries) {
|
|
if (
|
|
key.length < 1 ||
|
|
key.length > 64 ||
|
|
key === '__proto__' ||
|
|
key === 'constructor' ||
|
|
key === 'prototype'
|
|
) {
|
|
return { success: false }
|
|
}
|
|
const normalized = cloneJsonValue(item, depth + 1, state)
|
|
if (!normalized.success) return normalized
|
|
result[key] = normalized.value
|
|
}
|
|
state.seen.delete(value)
|
|
return { success: true, value: result }
|
|
}
|
|
|
|
function normalizeJsonValue(value: unknown, maximumBytes: number): JsonResult {
|
|
const normalized = cloneJsonValue(value, 0, {
|
|
nodes: 0,
|
|
seen: new WeakSet(),
|
|
})
|
|
if (!normalized.success) return normalized
|
|
|
|
const serialized = JSON.stringify(normalized.value)
|
|
if (
|
|
typeof serialized !== 'string' ||
|
|
new TextEncoder().encode(serialized).byteLength > maximumBytes
|
|
) {
|
|
return { success: false }
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
function normalizeStorageGetPayload(payload: unknown): { key: string } | null {
|
|
if (!isRecord(payload) || !hasOnlyKeys(payload, ['key'])) return null
|
|
return typeof payload.key === 'string' &&
|
|
STORAGE_KEY_PATTERN.test(payload.key)
|
|
? { key: payload.key }
|
|
: null
|
|
}
|
|
|
|
function normalizeStorageSetPayload(
|
|
payload: unknown,
|
|
): { key: string; revision: number; value: unknown } | null {
|
|
if (
|
|
!isRecord(payload) ||
|
|
!hasOnlyKeys(payload, ['key', 'revision', 'value']) ||
|
|
!Object.prototype.hasOwnProperty.call(payload, 'value') ||
|
|
typeof payload.key !== 'string' ||
|
|
!STORAGE_KEY_PATTERN.test(payload.key) ||
|
|
typeof payload.revision !== 'number' ||
|
|
!Number.isInteger(payload.revision) ||
|
|
payload.revision < 0 ||
|
|
payload.revision > 4_294_967_294
|
|
) {
|
|
return null
|
|
}
|
|
|
|
const value = normalizeJsonValue(payload.value, STORAGE_VALUE_MAX_BYTES)
|
|
return value.success
|
|
? { key: payload.key, revision: payload.revision, value: value.value }
|
|
: null
|
|
}
|
|
|
|
function normalizeNotificationPayload(
|
|
appId: string,
|
|
payload: unknown,
|
|
): CustomAppBridgeNotification | null {
|
|
if (
|
|
!isRecord(payload) ||
|
|
!hasOnlyKeys(payload, ['sound', 'subtitle', 'text', 'title'])
|
|
) {
|
|
return null
|
|
}
|
|
|
|
const title = readText(payload.title, 80)
|
|
const text = readText(payload.text, 240)
|
|
if (!title || !text) return null
|
|
|
|
const subtitle =
|
|
payload.subtitle === undefined ? undefined : readText(payload.subtitle, 80)
|
|
if (payload.subtitle !== undefined && !subtitle) return null
|
|
const sound =
|
|
payload.sound === undefined ||
|
|
(typeof payload.sound === 'string' &&
|
|
NOTIFICATION_SOUNDS.has(payload.sound as NotificationSoundId))
|
|
? (payload.sound as NotificationSoundId | undefined)
|
|
: null
|
|
if (sound === null) return null
|
|
|
|
return {
|
|
appId,
|
|
route: `/apps/${appId}`,
|
|
...(sound ? { sound } : {}),
|
|
...(subtitle ? { subtitle } : {}),
|
|
text,
|
|
title,
|
|
}
|
|
}
|
|
|
|
function normalizeOpenPayload(
|
|
payload: unknown,
|
|
): { appId: string; data?: unknown } | null {
|
|
if (
|
|
!isRecord(payload) ||
|
|
!hasOnlyKeys(payload, ['appId', 'data']) ||
|
|
typeof payload.appId !== 'string' ||
|
|
payload.appId.length < 1 ||
|
|
payload.appId.length > 64
|
|
) {
|
|
return null
|
|
}
|
|
if (!Object.prototype.hasOwnProperty.call(payload, 'data')) {
|
|
return { appId: payload.appId }
|
|
}
|
|
|
|
const data = normalizeJsonValue(payload.data, DEEP_LINK_DATA_MAX_BYTES)
|
|
if (!data.success || !isRecord(data.value)) return null
|
|
return { appId: payload.appId, data: data.value }
|
|
}
|
|
|
|
function hasPermission(
|
|
capabilities: readonly string[],
|
|
permission: string,
|
|
): boolean {
|
|
return capabilities.includes(permission)
|
|
}
|
|
|
|
function response(
|
|
requestId: string,
|
|
success: boolean,
|
|
data?: unknown,
|
|
error?: string,
|
|
): SkyPhoneAppBridgeResponse {
|
|
return {
|
|
...(data === undefined ? {} : { data }),
|
|
...(error ? { error } : {}),
|
|
requestId,
|
|
success,
|
|
}
|
|
}
|
|
|
|
export function getSkyPhoneAppCapabilities(
|
|
permissions: readonly string[],
|
|
): SkyPhoneAppCapability[] {
|
|
const capabilities: SkyPhoneAppCapability[] = []
|
|
if (hasPermission(permissions, 'app.close')) capabilities.push('app.close')
|
|
if (hasPermission(permissions, 'app.open')) capabilities.push('app.open')
|
|
if (hasPermission(permissions, 'device.storage')) {
|
|
capabilities.push('device.storage.get', 'device.storage.set')
|
|
}
|
|
if (hasPermission(permissions, 'locale.read'))
|
|
capabilities.push('locale.read')
|
|
if (hasPermission(permissions, 'notifications')) {
|
|
capabilities.push('notification.create')
|
|
}
|
|
if (hasPermission(permissions, 'theme.read')) capabilities.push('theme.read')
|
|
return capabilities
|
|
}
|
|
|
|
export function shouldReportCustomAppReady(
|
|
bridgeMode: CustomAppBridgeMode,
|
|
signal: 'bridge-ready' | 'frame-load',
|
|
): boolean {
|
|
return bridgeMode === 'sky'
|
|
? signal === 'bridge-ready'
|
|
: signal === 'frame-load'
|
|
}
|
|
|
|
export function getCustomAppFrameBootstrapMessages(
|
|
compatibility: Readonly<Record<string, unknown>>,
|
|
): readonly unknown[] {
|
|
if (compatibility.provider === 'lb_phone') {
|
|
return ['componentsLoaded']
|
|
}
|
|
return []
|
|
}
|
|
|
|
export function createCustomAppBridgeRequestHandler(
|
|
dependencies: CustomAppBridgeDependencies,
|
|
): {
|
|
handle: (
|
|
request: SkyPhoneAppBridgeRequest,
|
|
) => Promise<CustomAppBridgeResult | null>
|
|
} {
|
|
const handledRequestIds = new Set<string>()
|
|
const handledRequestOrder: string[] = []
|
|
|
|
return {
|
|
async handle(
|
|
request: SkyPhoneAppBridgeRequest,
|
|
): Promise<CustomAppBridgeResult | null> {
|
|
if (
|
|
typeof request.requestId !== 'string' ||
|
|
request.requestId.length < 1 ||
|
|
request.requestId.length > 128 ||
|
|
typeof request.method !== 'string' ||
|
|
request.method.length < 1 ||
|
|
request.method.length > 64 ||
|
|
handledRequestIds.has(request.requestId)
|
|
) {
|
|
return null
|
|
}
|
|
handledRequestIds.add(request.requestId)
|
|
handledRequestOrder.push(request.requestId)
|
|
if (handledRequestOrder.length > HANDLED_REQUEST_ID_LIMIT) {
|
|
const expiredRequestId = handledRequestOrder.shift()
|
|
if (expiredRequestId) handledRequestIds.delete(expiredRequestId)
|
|
}
|
|
|
|
const source = dependencies.getSourceApp()
|
|
try {
|
|
if (request.method === 'app.close') {
|
|
return hasPermission(source.capabilities, 'app.close')
|
|
? {
|
|
effect: { type: 'close' },
|
|
response: response(request.requestId, true),
|
|
}
|
|
: {
|
|
response: response(
|
|
request.requestId,
|
|
false,
|
|
undefined,
|
|
'permission_denied',
|
|
),
|
|
}
|
|
}
|
|
|
|
if (
|
|
request.method === 'device.storage.get' ||
|
|
request.method === 'device.storage.set'
|
|
) {
|
|
if (!hasPermission(source.capabilities, 'device.storage')) {
|
|
return {
|
|
response: response(
|
|
request.requestId,
|
|
false,
|
|
undefined,
|
|
'permission_denied',
|
|
),
|
|
}
|
|
}
|
|
|
|
const storagePayload =
|
|
request.method === 'device.storage.get'
|
|
? normalizeStorageGetPayload(request.payload)
|
|
: normalizeStorageSetPayload(request.payload)
|
|
if (!storagePayload) {
|
|
return {
|
|
response: response(
|
|
request.requestId,
|
|
false,
|
|
undefined,
|
|
'invalid_storage_request',
|
|
),
|
|
}
|
|
}
|
|
|
|
const result = await dependencies.storageCall(
|
|
request.method === 'device.storage.get'
|
|
? 'custom-app:storage:get'
|
|
: 'custom-app:storage:set',
|
|
{ appId: source.id, ...storagePayload },
|
|
)
|
|
return {
|
|
response: response(
|
|
request.requestId,
|
|
result.success,
|
|
result.data,
|
|
result.error,
|
|
),
|
|
}
|
|
}
|
|
|
|
if (request.method === 'notification.create') {
|
|
if (!hasPermission(source.capabilities, 'notifications')) {
|
|
return {
|
|
response: response(
|
|
request.requestId,
|
|
false,
|
|
undefined,
|
|
'permission_denied',
|
|
),
|
|
}
|
|
}
|
|
const notification = normalizeNotificationPayload(
|
|
source.id,
|
|
request.payload,
|
|
)
|
|
if (!notification) {
|
|
return {
|
|
response: response(
|
|
request.requestId,
|
|
false,
|
|
undefined,
|
|
'invalid_notification',
|
|
),
|
|
}
|
|
}
|
|
const notificationId = dependencies.createNotification(notification)
|
|
return {
|
|
response: response(request.requestId, true, { notificationId }),
|
|
}
|
|
}
|
|
|
|
if (request.method === 'app.open') {
|
|
if (!hasPermission(source.capabilities, 'app.open')) {
|
|
return {
|
|
response: response(
|
|
request.requestId,
|
|
false,
|
|
undefined,
|
|
'permission_denied',
|
|
),
|
|
}
|
|
}
|
|
const openPayload = normalizeOpenPayload(request.payload)
|
|
if (!openPayload) {
|
|
return {
|
|
response: response(
|
|
request.requestId,
|
|
false,
|
|
undefined,
|
|
'invalid_app_open',
|
|
),
|
|
}
|
|
}
|
|
const target = dependencies.resolveTarget(openPayload.appId)
|
|
if (!target) {
|
|
return {
|
|
response: response(
|
|
request.requestId,
|
|
false,
|
|
undefined,
|
|
'app_not_found',
|
|
),
|
|
}
|
|
}
|
|
if (openPayload.data !== undefined && !target.external) {
|
|
return {
|
|
response: response(
|
|
request.requestId,
|
|
false,
|
|
undefined,
|
|
'open_data_not_supported',
|
|
),
|
|
}
|
|
}
|
|
if (
|
|
target.external &&
|
|
!dependencies.prepareExternalOpen(target.id, openPayload.data)
|
|
) {
|
|
return {
|
|
response: response(
|
|
request.requestId,
|
|
false,
|
|
undefined,
|
|
'open_failed',
|
|
),
|
|
}
|
|
}
|
|
return {
|
|
effect: { route: target.route, type: 'open' },
|
|
response: response(request.requestId, true, {
|
|
appId: target.id,
|
|
}),
|
|
}
|
|
}
|
|
|
|
return {
|
|
response: response(
|
|
request.requestId,
|
|
false,
|
|
undefined,
|
|
'unsupported_method',
|
|
),
|
|
}
|
|
} catch (error) {
|
|
console.error(
|
|
`[Custom apps] Bridge request failed for ${source.id}.`,
|
|
error,
|
|
)
|
|
return {
|
|
response: response(
|
|
request.requestId,
|
|
false,
|
|
undefined,
|
|
'request_failed',
|
|
),
|
|
}
|
|
}
|
|
},
|
|
}
|
|
}
|