ADD - expand phone application platform

Add the Companies app, resilient call handling, custom app registry, vendor compatibility, storage policies, frontend bridge, tests, documentation, config, locale, and SQL migrations.
This commit is contained in:
Leon.Schmidt
2026-08-11 00:22:39 +02:00
parent 72e5f91e02
commit 42ac955052
77 changed files with 17630 additions and 333 deletions
+512
View File
@@ -0,0 +1,512 @@
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 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',
),
}
}
},
}
}