import React, { forwardRef, ReactElement, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from 'react'; import { Image, View, Text, NativeModules, ImageSourcePropType, HostComponent, } from 'react-native'; import BatchedBridge from 'react-native/Libraries/BatchedBridge/BatchedBridge'; import EventEmitter from 'react-native/Libraries/vendor/emitter/EventEmitter'; import invariant from 'invariant'; import RNCWebView, { Commands, NativeProps } from './RNCWebViewNativeComponent'; import RNCWebViewModule from './NativeRNCWebViewModule'; import { defaultOriginWhitelist, defaultDeeplinkWhitelist, defaultRenderError, defaultRenderLoading, useWebViewLogic, versionPasses, passesWhitelist, compileWhitelist, } from './WebViewShared'; import { AndroidWebViewProps, WebViewSourceUri, type WebViewMessageEvent, type ShouldStartLoadRequestEvent, } from './WebViewTypes'; import validateProps from './validation'; import styles from './WebView.styles'; const { resolveAssetSource } = Image; const directEventEmitter = new EventEmitter(); const registerCallableModule: (name: string, module: Object) => void = // `registerCallableModule()` is available in React Native 0.74 and above. // Fallback to use `BatchedBridge.registerCallableModule()` for older versions. require('react-native').registerCallableModule ?? BatchedBridge.registerCallableModule.bind(BatchedBridge); registerCallableModule('RNCWebViewMessagingModule', { onShouldStartLoadWithRequest: ( event: ShouldStartLoadRequestEvent & { messagingModuleName?: string } ) => { directEventEmitter.emit('onShouldStartLoadWithRequest', event); }, onMessage: ( event: WebViewMessageEvent & { messagingModuleName?: string } ) => { directEventEmitter.emit('onMessage', event); }, }); const { getWebViewDefaultUserAgent } = NativeModules.RNCWebViewUtils || {}; let userAgentPromise: Promise | undefined; async function getUserAgent(): Promise { if (!getWebViewDefaultUserAgent) return 'unknown'; if (!userAgentPromise) userAgentPromise = getWebViewDefaultUserAgent(); const userAgent = await userAgentPromise; return userAgent || 'unknown'; } // Exodus: Minimum Chrome version enforced by the library for security const hardMinimumChromeVersion = '100.0'; /** * Exodus: Hardcoded security defaults that cannot be overridden by props. * These values are always enforced regardless of what the consumer passes. */ const mediaPlaybackRequiresUserAction = true; const securitySupportMultipleWindows = true; const securityMixedContentMode = 'never' as const; /** * A simple counter to uniquely identify WebView instances. Do not use this for anything else. */ let uniqueRef = 0; const WebViewComponent = forwardRef<{}, AndroidWebViewProps>((props, ref) => { // Exodus: Validate props at runtime validateProps(props); const { overScrollMode = 'always', javaScriptEnabled = true, thirdPartyCookiesEnabled = true, scalesPageToFit = true, allowsFullscreenVideo = false, saveFormDataDisabled = false, cacheEnabled = true, androidLayerType = 'none', originWhitelist = defaultOriginWhitelist, deeplinkWhitelist = defaultDeeplinkWhitelist, setBuiltInZoomControls = true, setDisplayZoomControls = false, nestedScrollEnabled = false, startInLoadingState, onNavigationStateChange, onLoadStart, onError, onLoad, onLoadEnd, onLoadSubResourceError, onLoadProgress, onRenderProcessGone: onRenderProcessGoneProp, onMessage: onMessageProp, onOpenWindow: onOpenWindowProp, renderLoading, renderError, style, containerStyle, source, onShouldStartLoadWithRequest: onShouldStartLoadWithRequestProp, injectedJavaScriptObject, validateMeta, validateData, minimumChromeVersion, unsupportedVersionComponent: UnsupportedVersionComponent, ...otherProps } = props; const messagingModuleName = useRef( `WebViewMessageHandler${(uniqueRef += 1)}` ).current; const webViewRef = useRef > | null>(null); const [userAgent, setUserAgent] = useState(); useEffect(() => { getUserAgent().then(setUserAgent); }, []); const onShouldStartLoadWithRequestCallback = useCallback( (shouldStart: boolean, url: string, lockIdentifier?: number) => { if (lockIdentifier) { RNCWebViewModule.shouldStartLoadWithLockIdentifier( shouldStart, lockIdentifier ); } else if (shouldStart && webViewRef.current) { Commands.loadUrl(webViewRef.current, url); } }, [] ); const { onLoadingStart, onShouldStartLoadWithRequest, onMessage, viewState, setViewState, lastErrorEvent, onLoadingError, onLoadingSubResourceError, onLoadingFinish, onLoadingProgress, onOpenWindow, onRenderProcessGone, } = useWebViewLogic({ onNavigationStateChange, onLoad, onError, onLoadSubResourceError, onLoadEnd, onLoadProgress, onLoadStart, onRenderProcessGoneProp, onMessageProp, onOpenWindowProp, startInLoadingState, originWhitelist, deeplinkWhitelist, onShouldStartLoadWithRequestProp, onShouldStartLoadWithRequestCallback, validateMeta, validateData, }); useImperativeHandle( ref, () => ({ goForward: () => webViewRef.current && Commands.goForward(webViewRef.current), goBack: () => webViewRef.current && Commands.goBack(webViewRef.current), reload: () => { setViewState('LOADING'); if (webViewRef.current) { Commands.reload(webViewRef.current); } }, stopLoading: () => webViewRef.current && Commands.stopLoading(webViewRef.current), postMessage: (data: string) => webViewRef.current && Commands.postMessage(webViewRef.current, data), requestFocus: () => webViewRef.current && Commands.requestFocus(webViewRef.current), clearFormData: () => webViewRef.current && Commands.clearFormData(webViewRef.current), clearCache: (includeDiskFiles: boolean) => webViewRef.current && Commands.clearCache(webViewRef.current, includeDiskFiles), clearHistory: () => webViewRef.current && Commands.clearHistory(webViewRef.current), }), [setViewState, webViewRef] ); useEffect(() => { const onShouldStartLoadWithRequestSubscription = directEventEmitter.addListener( 'onShouldStartLoadWithRequest', ( event: ShouldStartLoadRequestEvent & { messagingModuleName?: string; } ) => { if (event.messagingModuleName === messagingModuleName) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { messagingModuleName: _, ...rest } = event; onShouldStartLoadWithRequest(rest); } } ); const onMessageSubscription = directEventEmitter.addListener( 'onMessage', (event: WebViewMessageEvent & { messagingModuleName?: string }) => { if (event.messagingModuleName === messagingModuleName) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { messagingModuleName: _, ...rest } = event; onMessage(rest); } } ); return () => { onShouldStartLoadWithRequestSubscription.remove(); onMessageSubscription.remove(); }; }, [messagingModuleName, onMessage, onShouldStartLoadWithRequest]); // Exodus: Compile origin whitelist for initial-load guard const compiledWhitelist = useMemo( () => compileWhitelist(originWhitelist), [originWhitelist] ); // Exodus: Guard initial source against origin whitelist (H-02) const safeSource = useMemo(() => { if ( source && typeof source === 'object' && 'uri' in source && typeof source.uri === 'string' ) { if (!passesWhitelist(compiledWhitelist, source.uri)) { console.warn( `WebView: source.uri "${source.uri}" does not pass the origin whitelist. Loading about:blank instead.` ); return { uri: 'about:blank' }; } } return source; }, [source, compiledWhitelist]); // Stop the rendering until userAgent is known if (!userAgent) return null; const chromeVersion = userAgent.match(/chrome\/((?:[0-9]+\.)+[0-9]+)/i)?.[1]; if ( !( versionPasses(chromeVersion, minimumChromeVersion) && versionPasses(chromeVersion, hardMinimumChromeVersion) ) ) { if (UnsupportedVersionComponent) { return ; } return ( Chrome version is outdated and insecure. Update it to continue. ); } let otherView: ReactElement | undefined; if (viewState === 'LOADING') { otherView = (renderLoading || defaultRenderLoading)(); } else if (viewState === 'ERROR') { invariant(lastErrorEvent != null, 'lastErrorEvent expected to be non-null'); if (lastErrorEvent) { otherView = (renderError || defaultRenderError)( lastErrorEvent.domain, lastErrorEvent.code, lastErrorEvent.description ); } } else if (viewState !== 'IDLE') { console.error(`RNCWebView invalid state encountered: ${viewState}`); } const webViewStyles = [styles.container, styles.webView, style]; const webViewContainerStyle = [styles.container, containerStyle]; if (typeof safeSource !== 'number' && safeSource && 'method' in safeSource) { if (safeSource.method === 'POST' && safeSource.headers) { console.warn( 'WebView: `source.headers` is not supported when using POST.' ); } else if (safeSource.method === 'GET' && safeSource.body) { console.warn('WebView: `source.body` is not supported when using GET.'); } } const sourceResolved = resolveAssetSource(safeSource as ImageSourcePropType); const newSource = typeof sourceResolved === 'object' ? Object.entries(sourceResolved as WebViewSourceUri).reduce( (prev, [currKey, currValue]) => { return { ...prev, [currKey]: currKey === 'headers' && currValue && typeof currValue === 'object' ? Object.entries(currValue).map(([key, value]) => { return { name: key, value, }; }) : currValue, }; }, {} ) : sourceResolved; const webView = ( ); return ( {webView} {otherView} ); }); // native implementation should return "true" only for Android 5+ const { isFileUploadSupported } = RNCWebViewModule; const WebView = Object.assign(WebViewComponent, { isFileUploadSupported }); export default WebView;