import * as Clipboard from 'expo-clipboard'; import * as DocumentPicker from 'expo-document-picker'; import { File, Paths } from 'expo-file-system'; import * as Haptics from 'expo-haptics'; import * as ImagePicker from 'expo-image-picker'; import * as Linking from 'expo-linking'; import * as Notifications from 'expo-notifications'; import * as Sharing from 'expo-sharing'; import { Appearance, Platform, PushNotificationIOS, Share, } from 'react-native'; import { type ClipboardReadTextResult, type ClipboardWriteTextPayload, type FileExportImagePayload, type FileExportImageResult, type FileExportTextPayload, type FileExportTextResult, type FileImportImageResult, type FileImportTextResult, type HapticsImpactPayload, HOST_BRIDGE_PROTOCOL, HOST_BRIDGE_VERSION, type HostBridgeCapability, type HostBridgeError, type HostBridgeImageMimeType, type HostBridgeMethod, type HostBridgeRequest, type HostBridgeResponse, type HostBridgeTextMimeType, type NavigateNativePagePayload, normalizeHostBridgeBadgeCount, normalizeHostBridgeClipboardText, normalizeHostBridgeColorScheme, normalizeHostBridgeExportFileName, normalizeHostBridgeExternalUrl, normalizeHostBridgeLocalNotification, type OpenExternalUrlPayload, type SetBadgeCountPayload, type ShareOpenPayload, } from '../../../packages/shared/src/contracts/hostBridge'; import { resolveMobileShellWebViewUrl } from './mobileShellNavigation'; import { getMobileNetworkStatus } from './mobileShellNetwork'; const WEB_APP_ORIGIN = 'https://app.genarrative.world'; const EXPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024; const EXPORT_IMAGE_MAX_BYTES = 5 * 1024 * 1024; const IMPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024; const IMPORT_IMAGE_MAX_BYTES = 10 * 1024 * 1024; const LOCAL_NOTIFICATION_CHANNEL_ID = 'genarrative-local'; const HOST_BRIDGE_TEXT_MIME_TYPES = new Set([ 'text/plain', 'text/markdown', 'text/csv', 'application/json', ]); const HOST_BRIDGE_IMAGE_MIME_TYPES = new Set([ 'image/png', 'image/jpeg', 'image/webp', ]); Notifications.setNotificationHandler({ handleNotification: async () => ({ shouldShowBanner: true, shouldShowList: true, shouldPlaySound: false, shouldSetBadge: false, }), }); export const MOBILE_HOST_CAPABILITIES: 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.exportImage', 'file.importImage', 'haptics.impact', 'notification.showLocal', ]; export const IOS_MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [ ...MOBILE_HOST_CAPABILITIES, 'app.setBadgeCount', ]; export function resolveMobileHostCapabilities(platform = Platform.OS) { return platform === 'ios' ? IOS_MOBILE_HOST_CAPABILITIES : MOBILE_HOST_CAPABILITIES; } export type MobileHostBridgeNavigation = { allowedOrigin: string; openWebViewUrl: (url: string) => void; reloadWebView: () => void; }; let currentShareTarget: unknown = null; let navigation: MobileHostBridgeNavigation | null = null; export function configureMobileHostBridgeNavigation( nextNavigation: MobileHostBridgeNavigation | null, ) { navigation = nextNavigation; } function unsupported(method: HostBridgeMethod): HostBridgeError { return { code: 'unsupported_method', message: `${method} unsupported in mobile shell`, }; } function invalidRequest(message: string): HostBridgeError { return { code: 'invalid_request', message, }; } function utf8ByteLength(value: string) { let bytes = 0; for (const character of value) { const codePoint = character.codePointAt(0) ?? 0; if (codePoint <= 0x7f) { bytes += 1; } else if (codePoint <= 0x7ff) { bytes += 2; } else if (codePoint <= 0xffff) { bytes += 3; } else { bytes += 4; } } return bytes; } function normalizedBase64Data(value: unknown) { if (typeof value !== 'string') { return null; } const normalizedValue = value.trim(); if ( !normalizedValue || normalizedValue.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/u.test(normalizedValue) ) { return null; } return normalizedValue; } function base64DecodedByteLength(value: string) { const padding = value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0; return Math.floor((value.length * 3) / 4) - padding; } function isHostBridgeRequest(value: unknown): value is HostBridgeRequest { if (!value || typeof value !== 'object') { return false; } const candidate = value as Partial; return ( candidate.bridge === HOST_BRIDGE_PROTOCOL && candidate.version === HOST_BRIDGE_VERSION && typeof candidate.id === 'string' && typeof candidate.method === 'string' ); } function parseRequest(raw: string) { try { return JSON.parse(raw) as unknown; } catch { return null; } } function ok( request: HostBridgeRequest, result?: Result, ): HostBridgeResponse { return { bridge: HOST_BRIDGE_PROTOCOL, version: HOST_BRIDGE_VERSION, id: request.id, ok: true, result, }; } function failure( request: Pick, error: HostBridgeError, ): HostBridgeResponse { return { bridge: HOST_BRIDGE_PROTOCOL, version: HOST_BRIDGE_VERSION, id: request.id, ok: false, error, }; } async function openExternalUrl(payload: unknown) { const url = normalizeHostBridgeExternalUrl( (payload as OpenExternalUrlPayload | undefined)?.url, ); if (!url) { throw invalidRequest('url must use an allowed external protocol'); } await Linking.openURL(url); return true; } async function writeClipboard(payload: unknown) { const text = (payload as ClipboardWriteTextPayload | undefined)?.text; if (typeof text !== 'string') { throw invalidRequest('text is required'); } await Clipboard.setStringAsync(text); return true; } async function readClipboard(): Promise { const result = normalizeHostBridgeClipboardText( await Clipboard.getStringAsync(), ); if (!result) { throw { code: 'host_error', message: 'clipboard text unavailable', } satisfies HostBridgeError; } return result; } async function exportTextFile(payload: unknown): Promise { const exportPayload = payload as FileExportTextPayload | undefined; const content = exportPayload?.content; if (typeof content !== 'string') { throw invalidRequest('content is required'); } const bytes = utf8ByteLength(content); if (bytes > EXPORT_TEXT_MAX_BYTES) { throw invalidRequest('content exceeds file export size limit'); } const isSharingAvailable = await Sharing.isAvailableAsync(); if (!isSharingAvailable) { throw { code: 'unsupported_capability', message: 'file sharing is unavailable in mobile shell', } satisfies HostBridgeError; } const fileName = normalizeHostBridgeExportFileName(exportPayload?.fileName); const mimeType = exportPayload?.mimeType || 'text/plain'; const file = new File(Paths.cache, fileName); file.write(content); await Sharing.shareAsync(file.uri, { mimeType, UTI: 'public.plain-text', dialogTitle: fileName, }); return { action: 'saved', fileName, bytes, }; } function normalizeImportedTextMimeType( value: unknown, fileName: string, ): HostBridgeTextMimeType | null { if (typeof value === 'string') { const mimeType = value.toLowerCase(); if (HOST_BRIDGE_TEXT_MIME_TYPES.has(mimeType as HostBridgeTextMimeType)) { return mimeType as HostBridgeTextMimeType; } } const normalizedName = fileName.toLowerCase(); if (normalizedName.endsWith('.json')) { return 'application/json'; } if (normalizedName.endsWith('.md') || normalizedName.endsWith('.markdown')) { return 'text/markdown'; } if (normalizedName.endsWith('.csv')) { return 'text/csv'; } if (normalizedName.endsWith('.txt')) { return 'text/plain'; } return null; } async function importTextFile(): Promise { const result = await DocumentPicker.getDocumentAsync({ copyToCacheDirectory: true, multiple: false, type: ['text/*', 'application/json'], }); if (result.canceled) { throw { code: 'cancelled', message: 'file import cancelled', } satisfies HostBridgeError; } const asset = result.assets[0]; if (!asset?.uri) { throw invalidRequest('text file is required'); } const fileName = normalizeHostBridgeExportFileName( asset.name || 'genarrative-import.txt', ); const mimeType = normalizeImportedTextMimeType(asset.mimeType, fileName); if (!mimeType) { throw invalidRequest('mimeType must be an allowed text type'); } if ( typeof asset.size === 'number' && (asset.size <= 0 || asset.size > IMPORT_TEXT_MAX_BYTES) ) { throw invalidRequest('text exceeds file import size limit'); } const file = new File(asset.uri); const content = await file.text(); const bytes = utf8ByteLength(content); if (bytes <= 0 || bytes > IMPORT_TEXT_MAX_BYTES) { throw invalidRequest('text exceeds file import size limit'); } return { action: 'selected', fileName, content, mimeType, bytes, }; } async function exportImageFile(payload: unknown): Promise { const exportPayload = payload as FileExportImagePayload | undefined; const mimeType = exportPayload?.mimeType; if ( typeof mimeType !== 'string' || !HOST_BRIDGE_IMAGE_MIME_TYPES.has(mimeType as HostBridgeImageMimeType) ) { throw invalidRequest('mimeType must be an allowed image type'); } const base64Data = normalizedBase64Data(exportPayload?.base64Data); if (!base64Data) { throw invalidRequest('base64Data is required'); } const bytes = base64DecodedByteLength(base64Data); if (bytes > EXPORT_IMAGE_MAX_BYTES) { throw invalidRequest('image exceeds file export size limit'); } const isSharingAvailable = await Sharing.isAvailableAsync(); if (!isSharingAvailable) { throw { code: 'unsupported_capability', message: 'file sharing is unavailable in mobile shell', } satisfies HostBridgeError; } const fileName = normalizeHostBridgeExportFileName(exportPayload?.fileName); const file = new File(Paths.cache, fileName); file.write(base64Data, { encoding: 'base64' }); await Sharing.shareAsync(file.uri, { mimeType, UTI: mimeType === 'image/png' ? 'public.png' : 'public.image', dialogTitle: fileName, }); return { action: 'saved', fileName, bytes, }; } function normalizeImportedImageMimeType( value: unknown, ): HostBridgeImageMimeType | null { if (typeof value !== 'string') { return null; } const mimeType = value.toLowerCase(); return HOST_BRIDGE_IMAGE_MIME_TYPES.has(mimeType as HostBridgeImageMimeType) ? (mimeType as HostBridgeImageMimeType) : null; } function fallbackImportedImageFileName(mimeType: HostBridgeImageMimeType) { if (mimeType === 'image/jpeg') { return 'genarrative-import.jpg'; } if (mimeType === 'image/webp') { return 'genarrative-import.webp'; } return 'genarrative-import.png'; } async function importImageFile(): Promise { const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (permission.status !== ImagePicker.PermissionStatus.GRANTED) { throw { code: 'host_error', message: 'photo library permission denied', } satisfies HostBridgeError; } const result = await ImagePicker.launchImageLibraryAsync({ allowsEditing: false, allowsMultipleSelection: false, base64: true, exif: false, mediaTypes: ['images'], quality: 1, }); if (result.canceled) { throw { code: 'cancelled', message: 'file import cancelled', } satisfies HostBridgeError; } const asset = result.assets[0]; if (!asset || asset.type !== 'image') { throw invalidRequest('image asset is required'); } const mimeType = normalizeImportedImageMimeType(asset.mimeType); if (!mimeType) { throw invalidRequest('mimeType must be an allowed image type'); } const base64Data = normalizedBase64Data(asset.base64); if (!base64Data) { throw invalidRequest('base64Data is required'); } const bytes = base64DecodedByteLength(base64Data); if (bytes <= 0 || bytes > IMPORT_IMAGE_MAX_BYTES) { throw invalidRequest('image exceeds file import size limit'); } if ( typeof asset.fileSize === 'number' && asset.fileSize > IMPORT_IMAGE_MAX_BYTES ) { throw invalidRequest('image exceeds file import size limit'); } return { action: 'selected', fileName: normalizeHostBridgeExportFileName( asset.fileName || fallbackImportedImageFileName(mimeType), ), base64Data, mimeType, bytes, }; } async function runHaptics(payload: unknown) { const style = (payload as HapticsImpactPayload | undefined)?.style; const impactStyle = style === 'heavy' ? Haptics.ImpactFeedbackStyle.Heavy : style === 'medium' ? Haptics.ImpactFeedbackStyle.Medium : Haptics.ImpactFeedbackStyle.Light; await Haptics.impactAsync(impactStyle); return true; } function setBadgeCount(payload: unknown) { if (Platform.OS !== 'ios') { throw { code: 'unsupported_capability', message: 'app badge count is only supported on iOS mobile shell', } satisfies HostBridgeError; } const count = normalizeHostBridgeBadgeCount( (payload as SetBadgeCountPayload | undefined)?.count, ); if (count === null) { throw invalidRequest('count must be an integer between 0 and 99999'); } PushNotificationIOS.setApplicationIconBadgeNumber(count); return true; } function hasNotificationPermission( permission: Awaited>, ) { return ( permission.granted || permission.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL ); } async function ensureNotificationPermission() { const currentPermission = await Notifications.getPermissionsAsync(); if (hasNotificationPermission(currentPermission)) { return; } const requestedPermission = await Notifications.requestPermissionsAsync({ ios: { allowAlert: true, allowBadge: false, allowSound: false, }, }); if (!hasNotificationPermission(requestedPermission)) { throw { code: 'host_error', message: 'notification permission denied', } satisfies HostBridgeError; } } async function showLocalNotification(payload: unknown) { const notification = normalizeHostBridgeLocalNotification(payload); if (!notification) { throw invalidRequest('title is required'); } await ensureNotificationPermission(); if (Platform.OS === 'android') { await Notifications.setNotificationChannelAsync( LOCAL_NOTIFICATION_CHANNEL_ID, { name: 'Genarrative', importance: Notifications.AndroidImportance.DEFAULT, }, ); } await Notifications.scheduleNotificationAsync({ content: notification, trigger: Platform.OS === 'android' ? { channelId: LOCAL_NOTIFICATION_CHANNEL_ID } : null, }); return true; } function getColorScheme() { return { colorScheme: normalizeHostBridgeColorScheme(Appearance.getColorScheme()), }; } function stringField(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 shareTargetPayload(value: unknown) { if (!value || typeof value !== 'object') { return value; } const target = value as Record; return target.target ?? value; } function workDetailUrl(work: string) { return `${WEB_APP_ORIGIN}/works/detail?work=${encodeURIComponent(work)}`; } function webAppPathUrl(path: string) { return new URL(path, WEB_APP_ORIGIN).toString(); } function normalizeSharePayload(value: unknown): ShareOpenPayload | null { const target = shareTargetPayload(value); const payload = target && typeof target === 'object' ? (target as Record).payload ?? target : target; if (!payload || typeof payload !== 'object') { return null; } const title = stringField(payload, 'title'); const message = stringField(payload, 'message'); const directUrl = stringField(payload, 'url') ?? stringField(payload, 'href'); const work = stringField(payload, 'work'); const path = stringField(payload, 'path') ?? stringField(payload, 'targetPath'); const url = directUrl ?? (work ? workDetailUrl(work) : undefined) ?? (path ? webAppPathUrl(path) : undefined); if (!title && !message && !url) { return null; } return { ...(title ? { title } : {}), ...(message ? { message } : {}), ...(url ? { url } : {}), }; } async function openShare(payload: unknown) { const sharePayload = normalizeSharePayload(payload) ?? normalizeSharePayload(currentShareTarget); if (!sharePayload) { throw invalidRequest('share target is required'); } const url = sharePayload?.url; const message = [sharePayload?.message, url].filter(Boolean).join('\n'); await Share.share({ title: sharePayload?.title, message: message || url || sharePayload?.title || '', url, }); return true; } function openNativePage(payload: unknown) { if (!navigation) { throw unsupported('navigation.openNativePage'); } const url = (payload as NavigateNativePagePayload | undefined)?.url; if (typeof url !== 'string') { throw invalidRequest('url is required'); } const webViewUrl = resolveMobileShellWebViewUrl( url, navigation.allowedOrigin, ); if (!webViewUrl) { throw invalidRequest('url must be an allowed same-origin web path'); } navigation.openWebViewUrl(webViewUrl); return true; } function reloadWebView() { if (!navigation) { throw unsupported('app.reloadWebView'); } navigation.reloadWebView(); return true; } async function handleRequest(request: HostBridgeRequest) { switch (request.method) { case 'host.getRuntime': return ok(request, { shell: 'expo_mobile', platform: Platform.OS === 'ios' ? 'ios' : 'android', hostVersion: '0.1.0', bridgeVersion: HOST_BRIDGE_VERSION, capabilities: resolveMobileHostCapabilities(), }); case 'appearance.getColorScheme': return ok(request, getColorScheme()); case 'app.openExternalUrl': return ok(request, await openExternalUrl(request.payload)); case 'app.reloadWebView': return ok(request, reloadWebView()); case 'network.status': return ok(request, await getMobileNetworkStatus()); case 'clipboard.writeText': return ok(request, await writeClipboard(request.payload)); case 'clipboard.readText': return ok(request, await readClipboard()); case 'file.exportText': return ok(request, await exportTextFile(request.payload)); case 'file.importText': return ok(request, await importTextFile()); case 'file.exportImage': return ok(request, await exportImageFile(request.payload)); case 'file.importImage': return ok(request, await importImageFile()); case 'haptics.impact': return ok(request, await runHaptics(request.payload)); case 'notification.showLocal': return ok(request, await showLocalNotification(request.payload)); case 'app.setBadgeCount': return ok(request, setBadgeCount(request.payload)); case 'share.open': return ok(request, await openShare(request.payload)); case 'share.setTarget': currentShareTarget = request.payload && typeof request.payload === 'object' ? (request.payload as { target?: unknown }).target : null; return ok(request, true); case 'navigation.openNativePage': return ok(request, openNativePage(request.payload)); case 'auth.requestLogin': case 'payment.request': return failure(request, unsupported(request.method)); default: return failure(request, unsupported(request.method)); } } function normalizeError(error: unknown): HostBridgeError { if ( error && typeof error === 'object' && 'code' in error && 'message' in error ) { return error as HostBridgeError; } return { code: 'host_error', message: error instanceof Error ? error.message : String(error), }; } export async function handleMobileHostBridgeMessage( rawMessage: string, sendResponse: (response: HostBridgeResponse) => void, ) { const parsed = parseRequest(rawMessage); if (!isHostBridgeRequest(parsed)) { sendResponse( failure( { id: 'invalid' }, invalidRequest('invalid host bridge request'), ), ); return; } try { sendResponse(await handleRequest(parsed)); } catch (error) { sendResponse(failure(parsed, normalizeError(error))); } } export function resetMobileHostBridgeForTest() { currentShareTarget = null; navigation = null; }