export const HOST_BRIDGE_PROTOCOL = 'GenarrativeHostBridge'; export const HOST_BRIDGE_VERSION = 1; export const HOST_BRIDGE_TAURI_COMMAND = 'host_bridge_request'; export const HOST_BRIDGE_PUBLIC_WEB_ORIGIN = 'https://www.genarrative.world'; export const HOST_BRIDGE_PUBLIC_WEB_URL = 'https://www.genarrative.world/'; export type HostShellKind = 'browser' | 'wechat_mini_program' | 'native_app'; export type NativeHostShell = 'expo_mobile' | 'tauri_desktop'; export type NativeHostPlatform = | 'ios' | 'android' | 'macos' | 'windows' | 'linux' | 'unknown'; export const HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY = { clientRuntime: 'clientRuntime', clientType: 'clientType', miniProgramEnv: 'miniProgramEnv', hostShell: 'hostShell', hostPlatform: 'hostPlatform', hostVersion: 'hostVersion', bridgeVersion: 'bridgeVersion', hostCapabilities: 'hostCapabilities', } as const; export const HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS = [ 'clientType', 'clientRuntime', 'miniProgramEnv', 'hostShell', 'hostPlatform', 'hostVersion', 'bridgeVersion', 'hostCapabilities', ] as const; export const HOST_BRIDGE_NATIVE_APP_QUERY_KEY = { clientRuntime: 'clientRuntime', clientType: 'clientType', hostShell: 'hostShell', hostPlatform: 'hostPlatform', hostVersion: 'hostVersion', bridgeVersion: 'bridgeVersion', hostCapabilities: 'hostCapabilities', } as const; export const HOST_BRIDGE_NATIVE_APP_QUERY_KEYS = [ 'clientRuntime', 'clientType', 'hostShell', 'hostPlatform', 'hostVersion', 'bridgeVersion', 'hostCapabilities', ] as const; export const HOST_BRIDGE_NATIVE_APP_QUERY = { clientRuntime: 'native_app', clientType: 'native_app', hostShellExpoMobile: 'expo_mobile', hostShellTauriDesktop: 'tauri_desktop', } as const; export const HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY = { clientType: 'mini_program', clientRuntime: 'wechat_mini_program', } as const; export const HOST_BRIDGE_METHODS = [ 'host.getRuntime', 'appearance.getColorScheme', 'auth.requestLogin', 'payment.request', 'share.setTarget', 'share.open', 'navigation.openNativePage', 'app.reloadWebView', 'app.openExternalUrl', 'app.setTitle', 'app.setBadgeCount', 'network.status', 'clipboard.writeText', 'clipboard.readText', 'file.exportText', 'file.importText', 'file.importDocument', 'file.exportImage', 'file.importImage', 'file.captureImage', 'scanner.scanQrCode', 'file.importAudio', 'file.exportAudio', 'haptics.impact', 'notification.showLocal', ] as const; export type HostBridgeMethod = (typeof HOST_BRIDGE_METHODS)[number]; export function isHostBridgeMethod(value: unknown): value is HostBridgeMethod { return ( typeof value === 'string' && HOST_BRIDGE_METHODS.includes(value as HostBridgeMethod) ); } export const HOST_BRIDGE_CAPABILITIES = [ ...HOST_BRIDGE_METHODS, 'host.events', 'app.lifecycle', 'network.statusChanged', 'file.imageDropped', 'navigation.canGoBack', ] as const; export type HostBridgeCapability = (typeof HOST_BRIDGE_CAPABILITIES)[number]; export function isHostBridgeCapability( value: unknown, ): value is HostBridgeCapability { return ( typeof value === 'string' && HOST_BRIDGE_CAPABILITIES.includes(value as HostBridgeCapability) ); } export const HOST_BRIDGE_WECHAT_MINI_PROGRAM_CAPABILITIES: readonly HostBridgeCapability[] = ['auth.requestLogin', 'payment.request', 'share.setTarget', 'share.open']; export const HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES: readonly HostBridgeCapability[] = [ 'host.getRuntime', 'appearance.getColorScheme', 'host.events', 'app.lifecycle', 'share.open', 'share.setTarget', 'navigation.openNativePage', 'navigation.canGoBack', 'app.reloadWebView', 'app.openExternalUrl', 'network.status', 'network.statusChanged', 'clipboard.writeText', 'clipboard.readText', 'file.exportText', 'file.importText', 'file.importDocument', 'file.exportImage', 'file.importImage', 'file.captureImage', 'scanner.scanQrCode', 'file.importAudio', 'file.exportAudio', 'haptics.impact', 'notification.showLocal', ]; export const HOST_BRIDGE_EXPO_MOBILE_IOS_EXTRA_CAPABILITIES: readonly HostBridgeCapability[] = ['app.setBadgeCount']; export const HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES: readonly HostBridgeCapability[] = [ ...HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES, ...HOST_BRIDGE_EXPO_MOBILE_IOS_EXTRA_CAPABILITIES, ]; export const HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES: readonly HostBridgeCapability[] = [ 'host.getRuntime', 'appearance.getColorScheme', 'host.events', 'app.lifecycle', 'share.open', 'share.setTarget', 'navigation.openNativePage', 'navigation.canGoBack', 'app.reloadWebView', 'app.openExternalUrl', 'app.setTitle', 'app.setBadgeCount', 'network.status', 'clipboard.writeText', 'clipboard.readText', 'file.exportText', 'file.importText', 'file.importDocument', 'file.exportImage', 'file.importImage', 'file.importAudio', 'file.exportAudio', 'file.imageDropped', 'notification.showLocal', ]; export type HostBridgeRuntimeResult = { shell: NativeHostShell; platform: NativeHostPlatform; hostVersion: string | null; bridgeVersion: number; capabilities: readonly HostBridgeCapability[]; }; export type HostBridgeRequest = { bridge: typeof HOST_BRIDGE_PROTOCOL; version: typeof HOST_BRIDGE_VERSION; id: string; method: HostBridgeMethod; payload?: Payload; timeoutMs?: number; }; export const HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS = 8000; export const HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS = 60000; export const HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS = 3000; export const HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS = 30000; export const HOST_BRIDGE_SCANNER_TIMEOUT_MS = 60000; export const HOST_BRIDGE_DESKTOP_NETWORK_CHECK_TIMEOUT_MS = 1200; export const HOST_BRIDGE_RESPONSE_CACHE_MAX = 128; export const HOST_BRIDGE_REQUEST_ID_MAX_LENGTH = 120; export function normalizeHostBridgeRequestId(rawId: unknown) { if (typeof rawId !== 'string') { return null; } const id = rawId.trim(); if ( !id || id.length > HOST_BRIDGE_REQUEST_ID_MAX_LENGTH || hasHostBridgeControlCharacter(id) ) { return null; } return id; } export type HostBridgeError = { code: | 'invalid_request' | 'unsupported_method' | 'unsupported_capability' | 'timeout' | 'cancelled' | 'host_error'; message: string; }; export type HostBridgeResponse = { bridge: typeof HOST_BRIDGE_PROTOCOL; version: typeof HOST_BRIDGE_VERSION; id: string; } & ( | { ok: true; result?: Result; } | { ok: false; error: HostBridgeError; } ); export const HOST_BRIDGE_EVENTS = [ 'app.lifecycle', 'network.statusChanged', 'navigation.canGoBack', 'file.imageDropped', ] as const; export type HostBridgeEventName = (typeof HOST_BRIDGE_EVENTS)[number]; export function isHostBridgeEventName( value: unknown, ): value is HostBridgeEventName { return ( typeof value === 'string' && HOST_BRIDGE_EVENTS.includes(value as HostBridgeEventName) ); } export type HostBridgeEvent = { bridge: typeof HOST_BRIDGE_PROTOCOL; version: typeof HOST_BRIDGE_VERSION; event: HostBridgeEventName; payload?: Payload; }; export type NavigationCanGoBackEventPayload = { canGoBack: boolean; }; export type HostAppLifecycleState = 'active' | 'inactive' | 'background'; export type AppLifecycleEventPayload = { state: HostAppLifecycleState; focused: boolean; nativeState?: string; }; export function normalizeHostBridgeLifecycleState( rawState: unknown, ): HostAppLifecycleState { return rawState === 'active' || rawState === 'background' ? rawState : 'inactive'; } export type HostNetworkConnectionType = | 'none' | 'unknown' | 'cellular' | 'wifi' | 'ethernet' | 'bluetooth' | 'vpn' | 'other'; export type NetworkStatusResult = { isConnected: boolean; isInternetReachable: boolean | null; connectionType: HostNetworkConnectionType; nativeType?: string; }; export function normalizeHostBridgeConnectionType( rawConnectionType: unknown, ): HostNetworkConnectionType { if (typeof rawConnectionType !== 'string') { return 'unknown'; } const normalizedType = rawConnectionType.toLowerCase(); if ( normalizedType === 'none' || normalizedType === 'cellular' || normalizedType === 'wifi' || normalizedType === 'ethernet' || normalizedType === 'bluetooth' || normalizedType === 'vpn' ) { return normalizedType; } if (normalizedType === 'other') { return 'other'; } return 'unknown'; } export type HostAppearanceColorScheme = 'light' | 'dark' | 'unknown'; export type AppearanceColorSchemeResult = { colorScheme: HostAppearanceColorScheme; }; export function normalizeHostBridgeColorScheme( rawColorScheme: unknown, ): HostAppearanceColorScheme { return rawColorScheme === 'light' || rawColorScheme === 'dark' ? rawColorScheme : 'unknown'; } export type NavigateNativePagePayload = { url: string; }; export type OpenExternalUrlPayload = { url: string; }; export type SetTitlePayload = { title: string; }; export const HOST_BRIDGE_APP_TITLE_MAX_LENGTH = 80; export function normalizeHostBridgeAppTitle( rawTitle: unknown, ): SetTitlePayload | null { if (typeof rawTitle !== 'string') { return null; } const title = rawTitle.trim(); if (!title || hasHostBridgeControlCharacter(title)) { return null; } return { title: title.slice(0, HOST_BRIDGE_APP_TITLE_MAX_LENGTH), }; } export type SetBadgeCountPayload = { count: number; }; export const HOST_BRIDGE_BADGE_COUNT_MAX = 99999; export function normalizeHostBridgeBadgeCount(rawCount: unknown) { if ( typeof rawCount !== 'number' || !Number.isInteger(rawCount) || rawCount < 0 || rawCount > HOST_BRIDGE_BADGE_COUNT_MAX ) { return null; } return rawCount; } export const HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS = [ 'http:', 'https:', 'mailto:', 'tel:', ] as const; export type HostBridgeExternalUrlProtocol = (typeof HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS)[number]; export const HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS = [ 'blob:', 'data:', 'file:', 'filesystem:', ] as const; export type HostBridgeMobileWebViewBlockedDownloadProtocol = (typeof HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS)[number]; function hasHostBridgeControlCharacter(value: string) { return [...value].some((character) => { const codePoint = character.codePointAt(0) ?? 0; return codePoint <= 31 || codePoint === 127; }); } export function normalizeHostBridgeExternalUrl(rawUrl: unknown) { if (typeof rawUrl !== 'string') { return null; } const urlText = rawUrl.trim(); if (!urlText || hasHostBridgeControlCharacter(urlText)) { return null; } try { const url = new URL(urlText); if ( !HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS.includes( url.protocol as HostBridgeExternalUrlProtocol, ) ) { return null; } return url.toString(); } catch { return null; } } export function normalizeHostBridgeExternalUrlPayload( rawUrl: unknown, ): OpenExternalUrlPayload | null { const url = normalizeHostBridgeExternalUrl(rawUrl); return url ? { url } : null; } export type ClipboardWriteTextPayload = { text: string; }; export type ClipboardReadTextResult = { text: string; }; export const HOST_BRIDGE_CLIPBOARD_TEXT_MAX_LENGTH = 100000; export function normalizeHostBridgeClipboardText( rawText: unknown, ): ClipboardReadTextResult | null { if (typeof rawText !== 'string') { return null; } return { text: rawText.slice(0, HOST_BRIDGE_CLIPBOARD_TEXT_MAX_LENGTH), }; } export type FileExportTextPayload = { fileName: string; content: string; mimeType?: HostBridgeTextMimeType; }; export type FileExportTextResult = { action: 'saved'; fileName: string; bytes: number; }; export type HostBridgeTextMimeType = | 'text/plain' | 'text/markdown' | 'text/csv' | 'application/json'; export const HOST_BRIDGE_TEXT_MIME_TYPES = [ 'text/plain', 'text/markdown', 'text/csv', 'application/json', ] as const satisfies readonly HostBridgeTextMimeType[]; const HOST_BRIDGE_TEXT_MIME_TYPE_SET = new Set( HOST_BRIDGE_TEXT_MIME_TYPES, ); export type FileImportTextResult = { action: 'selected'; fileName: string; content: string; mimeType: HostBridgeTextMimeType; bytes: number; }; export type HostBridgeDocumentMimeType = | HostBridgeTextMimeType | 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; export const HOST_BRIDGE_DOCUMENT_MIME_TYPES = [ ...HOST_BRIDGE_TEXT_MIME_TYPES, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', ] as const satisfies readonly HostBridgeDocumentMimeType[]; export type FileImportDocumentResult = { action: 'selected'; fileName: string; base64Data: string; mimeType: HostBridgeDocumentMimeType; bytes: number; }; export type FileExportImagePayload = { fileName: string; base64Data: string; mimeType: 'image/png' | 'image/jpeg' | 'image/webp'; }; export type FileExportImageResult = { action: 'saved'; fileName: string; bytes: number; }; export type HostBridgeImageMimeType = 'image/png' | 'image/jpeg' | 'image/webp'; export const HOST_BRIDGE_IMAGE_MIME_TYPES = [ 'image/png', 'image/jpeg', 'image/webp', ] as const satisfies readonly HostBridgeImageMimeType[]; const HOST_BRIDGE_IMAGE_MIME_TYPE_SET = new Set( HOST_BRIDGE_IMAGE_MIME_TYPES, ); export type FileImportImageResult = { action: 'selected' | 'dropped' | 'captured'; fileName: string; base64Data: string; mimeType: HostBridgeImageMimeType; bytes: number; position?: { x: number; y: number; }; }; export type ScannerScanQrCodeResult = { value: string; format: 'qr_code'; }; export const HOST_BRIDGE_QR_CODE_VALUE_MAX_LENGTH = 4096; export function normalizeHostBridgeQrCodeValue( rawValue: unknown, ): ScannerScanQrCodeResult | null { if (typeof rawValue !== 'string') { return null; } const value = rawValue.trim(); if (!value || hasHostBridgeControlCharacter(value)) { return null; } return { value: value.slice(0, HOST_BRIDGE_QR_CODE_VALUE_MAX_LENGTH), format: 'qr_code', }; } export type HostBridgeAudioMimeType = | 'audio/mpeg' | 'audio/mp4' | 'audio/wav' | 'audio/ogg' | 'audio/webm'; export const HOST_BRIDGE_AUDIO_MIME_TYPES = [ 'audio/mpeg', 'audio/mp4', 'audio/wav', 'audio/ogg', 'audio/webm', ] as const satisfies readonly HostBridgeAudioMimeType[]; const HOST_BRIDGE_AUDIO_MIME_TYPE_SET = new Set( HOST_BRIDGE_AUDIO_MIME_TYPES, ); export type FileImportAudioResult = { action: 'selected'; fileName: string; base64Data: string; mimeType: HostBridgeAudioMimeType; bytes: number; }; export type FileExportAudioPayload = { fileName: string; base64Data: string; mimeType: HostBridgeAudioMimeType; }; export type FileExportAudioResult = { action: 'saved'; fileName: string; bytes: number; }; export const HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024; export const HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024; export const HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES = 5 * 1024 * 1024; export const HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES = 5 * 1024 * 1024; export const HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES = 10 * 1024 * 1024; export const HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES = 20 * 1024 * 1024; export const HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES = 20 * 1024 * 1024; function estimateHostBridgeUtf8Bytes(value: string) { return new TextEncoder().encode(value).length; } export function normalizeHostBridgeExportTextPayload( payload: unknown, ): FileExportTextPayload | null { if (!payload || typeof payload !== 'object') { return null; } const candidate = payload as Partial; if (typeof candidate.content !== 'string') { return null; } if ( estimateHostBridgeUtf8Bytes(candidate.content) > HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES ) { return null; } const normalizedPayload: FileExportTextPayload = { fileName: normalizeHostBridgeExportFileName(candidate.fileName), content: candidate.content, }; if (candidate.mimeType === undefined) { return normalizedPayload; } const mimeType = candidate.mimeType as HostBridgeTextMimeType; if (!HOST_BRIDGE_TEXT_MIME_TYPE_SET.has(mimeType)) { return null; } return { ...normalizedPayload, mimeType, }; } function normalizeHostBridgeImportedBytes(rawBytes: unknown, maxBytes: number) { if ( typeof rawBytes !== 'number' || !Number.isInteger(rawBytes) || rawBytes <= 0 || rawBytes > maxBytes ) { return null; } return rawBytes; } export function normalizeHostBridgeImportFileName(rawFileName: unknown) { return normalizeHostBridgeExportFileName(rawFileName); } export function normalizeHostBridgeImportTextResult( payload: unknown, ): FileImportTextResult | null { if (!payload || typeof payload !== 'object') { return null; } const candidate = payload as Partial; if (candidate.action !== 'selected') { return null; } const fileName = normalizeHostBridgeImportFileName(candidate.fileName); const bytes = normalizeHostBridgeImportedBytes( candidate.bytes, HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES, ); const mimeType = candidate.mimeType as HostBridgeTextMimeType; if ( !fileName || bytes === null || !HOST_BRIDGE_TEXT_MIME_TYPE_SET.has(mimeType) || typeof candidate.content !== 'string' || estimateHostBridgeUtf8Bytes(candidate.content) > HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES ) { return null; } return { action: 'selected', fileName, content: candidate.content, mimeType, bytes, }; } export function normalizeHostBridgeImportDocumentResult( payload: unknown, ): FileImportDocumentResult | null { if (!payload || typeof payload !== 'object') { return null; } const candidate = payload as Partial; if (candidate.action !== 'selected') { return null; } const fileName = normalizeHostBridgeImportFileName(candidate.fileName); const bytes = normalizeHostBridgeImportedBytes( candidate.bytes, HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES, ); const mimeType = candidate.mimeType as HostBridgeDocumentMimeType; const base64Data = normalizeHostBridgeBase64Data(candidate.base64Data); if ( !fileName || bytes === null || !HOST_BRIDGE_DOCUMENT_MIME_TYPES.includes(mimeType) || !base64Data || estimateHostBridgeBase64Bytes(base64Data) > HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES ) { return null; } return { action: 'selected', fileName, base64Data, mimeType, bytes, }; } export function normalizeHostBridgeImportImageResult( payload: unknown, ): FileImportImageResult | null { if (!payload || typeof payload !== 'object') { return null; } const candidate = payload as Partial; if ( candidate.action !== 'selected' && candidate.action !== 'dropped' && candidate.action !== 'captured' ) { return null; } const fileName = normalizeHostBridgeImportFileName(candidate.fileName); const bytes = normalizeHostBridgeImportedBytes( candidate.bytes, HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES, ); const mimeType = candidate.mimeType as HostBridgeImageMimeType; const base64Data = normalizeHostBridgeBase64Data(candidate.base64Data); if ( !fileName || bytes === null || !HOST_BRIDGE_IMAGE_MIME_TYPE_SET.has(mimeType) || !base64Data || estimateHostBridgeBase64Bytes(base64Data) > HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES ) { return null; } const position = candidate.position && typeof candidate.position.x === 'number' && Number.isFinite(candidate.position.x) && typeof candidate.position.y === 'number' && Number.isFinite(candidate.position.y) ? { x: candidate.position.x, y: candidate.position.y, } : undefined; return { action: candidate.action, fileName, base64Data, mimeType, bytes, ...(position ? { position } : {}), }; } export function normalizeHostBridgeImportAudioResult( payload: unknown, ): FileImportAudioResult | null { if (!payload || typeof payload !== 'object') { return null; } const candidate = payload as Partial; if (candidate.action !== 'selected') { return null; } const fileName = normalizeHostBridgeImportFileName(candidate.fileName); const bytes = normalizeHostBridgeImportedBytes( candidate.bytes, HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES, ); const mimeType = candidate.mimeType as HostBridgeAudioMimeType; const base64Data = normalizeHostBridgeBase64Data(candidate.base64Data); if ( !fileName || bytes === null || !HOST_BRIDGE_AUDIO_MIME_TYPE_SET.has(mimeType) || !base64Data || estimateHostBridgeBase64Bytes(base64Data) > HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES ) { return null; } return { action: 'selected', fileName, base64Data, mimeType, bytes, }; } function normalizeHostBridgeBase64Data(rawData: unknown) { if (typeof rawData !== 'string') { return null; } const base64Data = rawData.trim(); if (!base64Data || hasHostBridgeControlCharacter(base64Data)) { return null; } return base64Data; } function estimateHostBridgeBase64Bytes(base64Data: string) { const normalizedLength = base64Data.replace(/\s+/g, '').length; return Math.ceil((normalizedLength * 3) / 4); } export function normalizeHostBridgeExportImagePayload( payload: unknown, ): FileExportImagePayload | null { if (!payload || typeof payload !== 'object') { return null; } const candidate = payload as Partial; const mimeType = candidate.mimeType as HostBridgeImageMimeType; if (!HOST_BRIDGE_IMAGE_MIME_TYPE_SET.has(mimeType)) { return null; } const base64Data = normalizeHostBridgeBase64Data(candidate.base64Data); if ( !base64Data || estimateHostBridgeBase64Bytes(base64Data) > HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES ) { return null; } return { fileName: normalizeHostBridgeExportFileName(candidate.fileName), base64Data, mimeType, }; } export function normalizeHostBridgeExportAudioPayload( payload: unknown, ): FileExportAudioPayload | null { if (!payload || typeof payload !== 'object') { return null; } const candidate = payload as Partial; const mimeType = candidate.mimeType as HostBridgeAudioMimeType; if (!HOST_BRIDGE_AUDIO_MIME_TYPE_SET.has(mimeType)) { return null; } const base64Data = normalizeHostBridgeBase64Data(candidate.base64Data); if ( !base64Data || estimateHostBridgeBase64Bytes(base64Data) > HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES ) { return null; } return { fileName: normalizeHostBridgeExportFileName(candidate.fileName), base64Data, mimeType, }; } export const HOST_BRIDGE_HAPTICS_IMPACT_STYLES = [ 'light', 'medium', 'heavy', ] as const; export type HostBridgeHapticsImpactStyle = (typeof HOST_BRIDGE_HAPTICS_IMPACT_STYLES)[number]; export function normalizeHostBridgeHapticsImpactStyle(rawStyle: unknown) { if (rawStyle === undefined) { return 'light'; } return HOST_BRIDGE_HAPTICS_IMPACT_STYLES.includes( rawStyle as HostBridgeHapticsImpactStyle, ) ? (rawStyle as HostBridgeHapticsImpactStyle) : null; } export type HapticsImpactPayload = { style?: HostBridgeHapticsImpactStyle; }; export type LocalNotificationPayload = { title: string; body?: string; }; export type LocalNotificationResult = { action: 'delivered_to_system'; }; export const HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT: LocalNotificationResult = { action: 'delivered_to_system', }; export const HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH = 80; export const HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH = 240; export const HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID = 'genarrative-local'; function normalizeHostBridgePlainText( value: unknown, maxLength: number, required: boolean, ) { if (typeof value !== 'string') { return required ? null : undefined; } if (hasHostBridgeControlCharacter(value)) { return null; } const text = value.trim().replace(/\s+/g, ' '); if (!text) { return required ? null : undefined; } return text.slice(0, maxLength); } export function normalizeHostBridgeLocalNotification( payload: unknown, ): LocalNotificationPayload | null { if (!payload || typeof payload !== 'object') { return null; } const candidate = payload as Partial; const title = normalizeHostBridgePlainText( candidate.title, HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH, true, ); if (!title) { return null; } const body = normalizeHostBridgePlainText( candidate.body, HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH, false, ); if (body === null) { return null; } return body ? { title, body } : { title }; } export type ShareSetTargetPayload = { target: unknown; }; export type ShareOpenPayload = { title?: string; message?: string; url?: string; href?: string; path?: string; targetPath?: string; work?: string; }; export type HostBridgeSharePayloadNormalization = | { status: 'empty' } | { status: 'invalid' } | { status: 'valid'; payload: ShareOpenPayload }; function hostBridgeStringField(value: unknown, field: string) { if (!value || typeof value !== 'object') { return undefined; } const fieldValue = (value as Record)[field]; if (typeof fieldValue !== 'string') { return undefined; } const text = fieldValue.trim(); return text || undefined; } function hostBridgeShareTargetPayload(value: unknown) { if (!value || typeof value !== 'object') { return value; } const target = value as Record; return target.target ?? value; } function normalizeHostBridgePublicShareUrl(rawUrl: string | undefined) { if (!rawUrl || rawUrl.startsWith('//')) { return undefined; } try { const url = new URL(rawUrl, HOST_BRIDGE_PUBLIC_WEB_ORIGIN); if (url.origin !== HOST_BRIDGE_PUBLIC_WEB_ORIGIN) { return undefined; } return url.toString(); } catch { return undefined; } } function hostBridgeWorkDetailUrl(work: string) { const searchParams = new URLSearchParams({ work }); return `${HOST_BRIDGE_PUBLIC_WEB_ORIGIN}/works/detail?${searchParams.toString()}`; } export function normalizeHostBridgeShareOpenPayload( value: unknown, ): HostBridgeSharePayloadNormalization { const target = hostBridgeShareTargetPayload(value); const payload = target && typeof target === 'object' ? ((target as Record).payload ?? target) : target; if (!payload || typeof payload !== 'object') { return { status: 'empty' }; } const title = hostBridgeStringField(payload, 'title'); const message = hostBridgeStringField(payload, 'message'); const rawDirectUrl = hostBridgeStringField(payload, 'url') ?? hostBridgeStringField(payload, 'href'); const directUrl = normalizeHostBridgePublicShareUrl(rawDirectUrl); if (rawDirectUrl && !directUrl) { return { status: 'invalid' }; } const work = hostBridgeStringField(payload, 'work'); const rawPath = hostBridgeStringField(payload, 'path') ?? hostBridgeStringField(payload, 'targetPath'); const pathUrl = normalizeHostBridgePublicShareUrl(rawPath); if (rawPath && !pathUrl) { return { status: 'invalid' }; } const url = directUrl ?? (work ? hostBridgeWorkDetailUrl(work) : undefined) ?? pathUrl; if (!title && !message && !url) { return { status: 'empty' }; } return { status: 'valid', payload: { ...(title ? { title } : {}), ...(message ? { message } : {}), ...(url ? { url } : {}), }, }; } export const HOST_BRIDGE_FILE_NAME_FALLBACK = 'genarrative-export.txt'; export const HOST_BRIDGE_FILE_NAME_MAX_LENGTH = 120; function isHostBridgeInvalidFileNameCharacter(value: string) { if (hasHostBridgeControlCharacter(value)) { return true; } return ['<', '>', ':', '"', '/', '\\', '|', '?', '*'].includes(value); } export function normalizeHostBridgeExportFileName(rawFileName: unknown) { if (typeof rawFileName !== 'string') { return HOST_BRIDGE_FILE_NAME_FALLBACK; } const fileName = rawFileName .trim() .split('') .map((character) => isHostBridgeInvalidFileNameCharacter(character) ? '-' : character, ) .join('') .replace(/\s+/g, ' ') .replace(/^[.\s-]+/, '') .slice(0, HOST_BRIDGE_FILE_NAME_MAX_LENGTH) .trim(); return fileName || HOST_BRIDGE_FILE_NAME_FALLBACK; }