import { StatusBar } from 'expo-status-bar'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AppState, type AppStateStatus, BackHandler, Linking, Platform, Pressable, StyleSheet, Text, View, } from 'react-native'; import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'; import type { WebViewMessageEvent } from 'react-native-webview'; import { WebView } from 'react-native-webview'; import { type HostBridgeEventName, HOST_BRIDGE_PROTOCOL, HOST_BRIDGE_VERSION, } from '../../../../packages/shared/src/contracts/hostBridge'; import { configureMobileHostBridgeNavigation, handleMobileHostBridgeMessage, resolveMobileHostCapabilities, } from '../host-bridge/bridge'; import { resolveMobileShellUrlFromDeepLink } from './deepLink'; import { lifecyclePayloadFromAppState } from './lifecycle'; import { type MobileShellLoadFailure, normalizeMobileShellLoadFailure, } from './loadFailure'; import { openMobileShellExternalNavigation, shouldAcceptMobileShellHostBridgeMessage, shouldOpenInMobileShellWebView, } from './navigation'; import { getMobileNetworkStatus, subscribeMobileNetworkStatus, } from './network'; import { MOBILE_SHELL_HOST_VERSION } from './runtime'; import { MOBILE_SHELL_SAFE_AREA_EDGES } from './safeArea'; import { buildMobileShellUrl, resolveMobileShellBaseWebUrl, } from './url'; import { MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT, shouldBlockMobileWebViewNavigationRequest, } from './webViewPolicy'; import { parseMobileWebViewHistoryStateMessage } from './webViewHistory'; import { QrScannerOverlay } from './QrScannerOverlay'; function buildHostBridgeMessageScript(message: unknown) { return `window.dispatchEvent(new MessageEvent('message', { data: ${JSON.stringify( JSON.stringify(message), )}, origin: window.location.origin, source: window })); true;`; } function logMobileHostEventFailure(label: HostBridgeEventName, _error: unknown) { console.warn(`mobile host event failed for ${label}`); } function logMobileHostBridgeMessageFailure(_error: unknown) { console.warn('mobile HostBridge message injection failed'); } function logMobileShellNavigationFailure(label: string, _error: unknown) { console.warn(`mobile shell navigation failed for ${label}`); } function logMobileShellDownloadBlocked(_event: unknown) { console.warn('mobile shell blocked WebView file download'); } function logMobileShellDeepLinkFailure(label: string, _error: unknown) { console.warn(`mobile shell deep link failed for ${label}`); } const MOBILE_WEBVIEW_PROCESS_FAILURE_WINDOW_MS = 30_000; const MOBILE_WEBVIEW_PROCESS_FAILURE_RELOAD_LIMIT = 1; type MobileWebViewLoadErrorEvent = { nativeEvent: { url: string; code?: number; description?: string; }; }; type MobileWebViewHttpErrorEvent = { nativeEvent: { url: string; statusCode?: number; description?: string; }; }; export default function ShellApp() { const webViewRef = useRef(null); const isShellMountedRef = useRef(true); const nativeCanGoBackRef = useRef(false); const h5CanGoBackRef = useRef(false); const webViewProcessFailureRef = useRef({ count: 0, firstFailureAt: 0, }); const [canGoBack, setCanGoBack] = useState(false); const baseWebUrl = resolveMobileShellBaseWebUrl( process.env.EXPO_PUBLIC_GENARRATIVE_WEB_URL, ); const urlOptions = useMemo( () => ({ platform: Platform.OS === 'ios' ? 'ios' as const : 'android' as const, hostVersion: MOBILE_SHELL_HOST_VERSION, capabilities: resolveMobileHostCapabilities(), }), [], ); const [webUrl, setWebUrl] = useState(() => buildMobileShellUrl(baseWebUrl, urlOptions), ); const [loadFailure, setLoadFailure] = useState(null); const allowedWebOrigin = useMemo(() => new URL(webUrl).origin, [webUrl]); const reloadCurrentWebView = useCallback(() => { webViewRef.current?.reload(); }, []); const resetWebViewProcessFailureWindow = useCallback(() => { webViewProcessFailureRef.current = { count: 0, firstFailureAt: 0, }; }, []); useEffect(() => { isShellMountedRef.current = true; return () => { isShellMountedRef.current = false; }; }, []); const injectHostBridgeMessage = useCallback( ( message: unknown, onError: (error: unknown) => void, ) => { if (!isShellMountedRef.current) { return; } try { webViewRef.current?.injectJavaScript( buildHostBridgeMessageScript(message), ); } catch (error) { onError(error); } }, [], ); const injectHostBridgeEvent = useCallback( (event: HostBridgeEventName, payload: unknown) => { injectHostBridgeMessage( { bridge: HOST_BRIDGE_PROTOCOL, version: HOST_BRIDGE_VERSION, event, payload, }, (error) => logMobileHostEventFailure(event, error), ); }, [injectHostBridgeMessage], ); const injectLifecycleEvent = useCallback( (state: AppStateStatus) => { injectHostBridgeEvent('app.lifecycle', lifecyclePayloadFromAppState(state)); }, [injectHostBridgeEvent], ); const injectNetworkStatusEvent = useCallback( (payload: Awaited>) => { injectHostBridgeEvent('network.statusChanged', payload); }, [injectHostBridgeEvent], ); const syncNavigationCanGoBack = useCallback( (source: 'native' | 'h5', nextCanGoBack: boolean) => { if (source === 'native') { nativeCanGoBackRef.current = nextCanGoBack; } else { h5CanGoBackRef.current = nextCanGoBack; } const combinedCanGoBack = nativeCanGoBackRef.current || h5CanGoBackRef.current; setCanGoBack(combinedCanGoBack); injectHostBridgeEvent('navigation.canGoBack', { canGoBack: combinedCanGoBack, }); }, [injectHostBridgeEvent], ); const resetNavigationCanGoBack = useCallback(() => { nativeCanGoBackRef.current = false; h5CanGoBackRef.current = false; setCanGoBack(false); }, []); useEffect(() => { configureMobileHostBridgeNavigation({ allowedOrigin: allowedWebOrigin, urlOptions, openWebViewUrl(url) { resetWebViewProcessFailureWindow(); resetNavigationCanGoBack(); setLoadFailure(null); setWebUrl(url); }, reloadWebView: reloadCurrentWebView, }); return () => configureMobileHostBridgeNavigation(null); }, [ allowedWebOrigin, reloadCurrentWebView, resetNavigationCanGoBack, resetWebViewProcessFailureWindow, urlOptions, ]); useEffect(() => { let disposed = false; const openDeepLink = ( url: string | null | undefined, source: 'initial_url' | 'runtime_url', ) => { const resolution = resolveMobileShellUrlFromDeepLink( url, baseWebUrl, urlOptions, ); if (resolution.status === 'rejected') { logMobileShellDeepLinkFailure(`${source}.rejected`, url); } resetWebViewProcessFailureWindow(); resetNavigationCanGoBack(); setLoadFailure(null); setWebUrl(resolution.url); }; void Linking.getInitialURL() .then((url) => { if (!disposed) { openDeepLink(url, 'initial_url'); } }) .catch((error: unknown) => { logMobileShellDeepLinkFailure('initial_url.read', error); }); const subscription = Linking.addEventListener('url', (event) => { try { openDeepLink(event.url, 'runtime_url'); } catch (error) { logMobileShellDeepLinkFailure('runtime_url.open', error); } }); return () => { disposed = true; subscription.remove(); }; }, [ baseWebUrl, resetNavigationCanGoBack, resetWebViewProcessFailureWindow, urlOptions, ]); useEffect(() => { const subscription = BackHandler.addEventListener( 'hardwareBackPress', () => { if (!canGoBack) { return false; } if (h5CanGoBackRef.current) { webViewRef.current?.injectJavaScript('window.history.back(); true;'); } else { webViewRef.current?.goBack(); } return true; }, ); return () => subscription.remove(); }, [canGoBack]); useEffect(() => { const subscription = AppState.addEventListener( 'change', injectLifecycleEvent, ); injectLifecycleEvent(AppState.currentState); return () => subscription.remove(); }, [injectLifecycleEvent]); useEffect(() => { return subscribeMobileNetworkStatus(injectNetworkStatusEvent); }, [injectNetworkStatusEvent]); const handleMessage = (event: WebViewMessageEvent) => { if ( !shouldAcceptMobileShellHostBridgeMessage( event.nativeEvent.url, allowedWebOrigin, ) ) { return; } const historyState = parseMobileWebViewHistoryStateMessage( event.nativeEvent.data, ); if (historyState) { syncNavigationCanGoBack('h5', historyState.canGoBack); return; } void handleMobileHostBridgeMessage(event.nativeEvent.data, (response) => { injectHostBridgeMessage(response, logMobileHostBridgeMessageFailure); }); }; const handleShouldStartLoad = (request: { url: string }) => { if (shouldBlockMobileWebViewNavigationRequest(request)) { return false; } if (shouldOpenInMobileShellWebView(request.url, allowedWebOrigin)) { return true; } void openMobileShellExternalNavigation(Linking, request.url).catch( (error: unknown) => { logMobileShellNavigationFailure('external_navigation.open', error); }, ); return false; }; const handleWebViewLoad = (event: { nativeEvent: { url: string } }) => { if ( !shouldAcceptMobileShellHostBridgeMessage( event.nativeEvent.url, allowedWebOrigin, ) ) { return; } setLoadFailure(null); resetWebViewProcessFailureWindow(); injectLifecycleEvent(AppState.currentState); void getMobileNetworkStatus() .then(injectNetworkStatusEvent) .catch((error: unknown) => { logMobileHostEventFailure('network.statusChanged', error); }); }; const handleWebViewLoadError = (event: MobileWebViewLoadErrorEvent) => { resetNavigationCanGoBack(); setLoadFailure( normalizeMobileShellLoadFailure( { type: 'native', url: event.nativeEvent.url, code: event.nativeEvent.code, description: event.nativeEvent.description, }, allowedWebOrigin, webUrl, ), ); }; const handleWebViewHttpError = (event: MobileWebViewHttpErrorEvent) => { resetNavigationCanGoBack(); setLoadFailure( normalizeMobileShellLoadFailure( { type: 'http', url: event.nativeEvent.url, statusCode: event.nativeEvent.statusCode, description: event.nativeEvent.description, }, allowedWebOrigin, webUrl, ), ); }; const handleRetryLoadFailure = () => { setLoadFailure(null); resetWebViewProcessFailureWindow(); reloadCurrentWebView(); }; const handleWebViewProcessFailure = (label: string) => { const now = Date.now(); const current = webViewProcessFailureRef.current; const isWithinFailureWindow = current.firstFailureAt > 0 && now - current.firstFailureAt <= MOBILE_WEBVIEW_PROCESS_FAILURE_WINDOW_MS; const nextFailureWindow = isWithinFailureWindow ? { count: current.count + 1, firstFailureAt: current.firstFailureAt, } : { count: 1, firstFailureAt: now, }; webViewProcessFailureRef.current = nextFailureWindow; console.warn(`mobile WebView process failed for ${label}`); if (nextFailureWindow.count <= MOBILE_WEBVIEW_PROCESS_FAILURE_RELOAD_LIMIT) { reloadCurrentWebView(); return; } resetNavigationCanGoBack(); setLoadFailure( normalizeMobileShellLoadFailure( { type: 'process', url: webUrl, description: 'WebView renderer terminated repeatedly', }, allowedWebOrigin, webUrl, ), ); }; const handleBlockedFileDownload = (event: unknown) => { logMobileShellDownloadBlocked(event); }; return ( { handleWebViewProcessFailure('content_process_terminated'); }} onRenderProcessGone={() => { handleWebViewProcessFailure('render_process_gone'); }} onLoad={handleWebViewLoad} onError={handleWebViewLoadError} onHttpError={handleWebViewHttpError} onShouldStartLoadWithRequest={handleShouldStartLoad} onNavigationStateChange={(event) => { syncNavigationCanGoBack('native', event.canGoBack); }} setSupportMultipleWindows={false} /> {loadFailure ? ( {loadFailure.title} {loadFailure.detail} {loadFailure.retryLabel} ) : null} ); } const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: '#fffdf9', }, loadFailurePanel: { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 28, backgroundColor: '#fffdf9', }, loadFailureTitle: { color: '#211a16', fontSize: 18, fontWeight: '700', lineHeight: 24, textAlign: 'center', }, loadFailureDetail: { maxWidth: 320, marginTop: 10, color: '#6a5c52', fontSize: 14, lineHeight: 20, textAlign: 'center', }, loadFailureButton: { minWidth: 112, minHeight: 42, alignItems: 'center', justifyContent: 'center', marginTop: 20, borderRadius: 8, backgroundColor: '#211a16', paddingHorizontal: 22, }, loadFailureButtonText: { color: '#fffdf9', fontSize: 15, fontWeight: '700', lineHeight: 20, textAlign: 'center', }, });