import escapeStringRegexp from 'escape-string-regexp';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { Linking, View, ActivityIndicator, Text, Platform } from 'react-native';
import {
OnShouldStartLoadWithRequest,
ShouldStartLoadRequestEvent,
WebViewError,
WebViewErrorEvent,
WebViewMessage,
WebViewMessageEvent,
WebViewNavigation,
WebViewNativeEvent,
WebViewNavigationEvent,
WebViewOpenWindowEvent,
WebViewProgressEvent,
WebViewRenderProcessGoneEvent,
WebViewTerminatedEvent,
} from './WebViewTypes';
import styles from './WebView.styles';
// Exodus: Only allow HTTPS by default for security
const defaultOriginWhitelist = ['https://*'] as const;
// Exodus: Default protocol schemes for deep linking
const defaultDeeplinkWhitelist = ['https:'] as const;
const defaultDeeplinkBlocklist = ['http:', 'file:', 'javascript:'] as const;
// Exodus: Extract protocol scheme from URL using native URL parsing
const urlToProtocolScheme = (url: string): string | null => {
try {
return new URL(url).protocol;
} catch {
// Protocol schemes must start with a letter and cannot start with digits, underscores etc.
// e.g 0invalid, _invalid, +invalid, -invalid, .invalid will all become null
return null;
}
};
// Exodus: Check if a value exists in a list of strings
const matchWithStringList = (
prefixes: readonly string[],
value: string
): boolean => {
if (typeof value !== 'string') {
throw new Error('value was not a string');
}
return Array.prototype.includes.call(prefixes, value);
};
// Exodus: Convert whitelist string to RegExp with exact matching (^ and $ anchors)
const stringWhitelistToRegex = (originWhitelist: string): RegExp =>
new RegExp(`^${escapeStringRegexp(originWhitelist).replace(/\\\*/g, '.*')}$`);
// Exodus: Test value against a list of compiled RegExp patterns
const matchWithRegexList = (
compiledRegexList: readonly RegExp[],
value: string
): boolean => {
return compiledRegexList.some((x) => x.test(value));
};
// Exodus: Compile whitelist strings into RegExp array for efficient matching
const compileWhitelist = (
originWhitelist: readonly string[]
): readonly RegExp[] =>
['about:blank', ...(originWhitelist || [])].map(stringWhitelistToRegex);
// Exodus: Check if URL passes whitelist using native URL API for robust parsing
// Falls back to href when origin is null (handles data:, blob:, etc.)
const passesWhitelist = (
compiledWhitelist: readonly RegExp[],
url: string
): boolean => {
try {
const { href, origin } = new URL(url);
// Check origin first (most common case)
if (origin && origin !== 'null') {
return matchWithRegexList(compiledWhitelist, origin);
}
// Fallback to href for URLs where origin is null (data:, blob:, javascript:, etc.)
return matchWithRegexList(compiledWhitelist, href);
} catch {
// Malformed URL - fail closed for security
return false;
}
};
const createOnShouldStartLoadWithRequest = (
loadRequest: (
shouldStart: boolean,
url: string,
lockIdentifier: number
) => void,
originWhitelist: readonly string[],
deeplinkWhitelist: readonly string[],
onShouldStartLoadWithRequest?: OnShouldStartLoadWithRequest
) => {
const compiledWhitelist = compileWhitelist(originWhitelist);
return ({ nativeEvent }: ShouldStartLoadRequestEvent) => {
let shouldStart = true;
const { url, lockIdentifier, isTopFrame } = nativeEvent;
// Exodus: Check if the url passes the origin whitelist
if (!passesWhitelist(compiledWhitelist, url)) {
const protocol = urlToProtocolScheme(url);
// Check that the protocol was properly parsed
if (protocol !== null) {
// Exodus: Check if the protocol passes the hardcoded deeplink blocklist
const foundMatchInBlocklist = matchWithStringList(
defaultDeeplinkBlocklist,
protocol
);
if (!foundMatchInBlocklist) {
// Exodus: Check if the protocol passes the dynamic deeplink allowlist
const foundMatchInAllowlist = matchWithStringList(
deeplinkWhitelist,
protocol
);
if (foundMatchInAllowlist) {
Linking.canOpenURL(url)
.then((supported) => {
// Allow mailto: even if canOpenURL returns false (RN Linking quirk)
if (
(supported && isTopFrame) ||
protocol.startsWith('mailto:')
) {
return Linking.openURL(url);
}
console.warn(`Can't open url: ${url}`);
return undefined;
})
.catch((e) => {
console.warn('Error opening URL: ', e);
});
} else {
console.warn(`Failed to pass whitelist for deep link url: ${url}`);
}
} else {
console.warn(
`Failed to pass default block list for deep link url: ${url}`
);
}
}
shouldStart = false;
} else if (onShouldStartLoadWithRequest) {
shouldStart = onShouldStartLoadWithRequest(nativeEvent);
}
loadRequest(shouldStart, url, lockIdentifier);
};
};
const defaultRenderLoading = () => (
);
const defaultRenderError = (
errorDomain: string | undefined,
errorCode: number,
errorDesc: string
) => (
Error loading page
{`Domain: ${errorDomain}`}
{`Error Code: ${errorCode}`}
{`Description: ${errorDesc}`}
);
export {
defaultOriginWhitelist,
defaultDeeplinkWhitelist,
createOnShouldStartLoadWithRequest,
defaultRenderLoading,
defaultRenderError,
passesWhitelist,
compileWhitelist,
};
export const useWebViewLogic = ({
startInLoadingState,
onNavigationStateChange,
onLoadStart,
onLoad,
onLoadProgress,
onLoadEnd,
onError,
onLoadSubResourceError,
onMessageProp,
onOpenWindowProp,
onRenderProcessGoneProp,
onContentProcessDidTerminateProp,
originWhitelist,
deeplinkWhitelist,
onShouldStartLoadWithRequestProp,
onShouldStartLoadWithRequestCallback,
validateMeta,
validateData,
}: {
startInLoadingState?: boolean;
onNavigationStateChange?: (event: WebViewNavigation) => void;
onLoadStart?: (event: WebViewNavigationEvent) => void;
onLoad?: (event: WebViewNavigationEvent) => void;
onLoadProgress?: (event: WebViewProgressEvent) => void;
onLoadEnd?: (event: WebViewNavigationEvent | WebViewErrorEvent) => void;
onError?: (event: WebViewErrorEvent) => void;
onLoadSubResourceError?: (event: WebViewErrorEvent) => void;
onMessageProp?: (event: WebViewMessage) => void;
onOpenWindowProp?: (event: WebViewOpenWindowEvent) => void;
onRenderProcessGoneProp?: (event: WebViewRenderProcessGoneEvent) => void;
onContentProcessDidTerminateProp?: (event: WebViewTerminatedEvent) => void;
originWhitelist: readonly string[];
deeplinkWhitelist: readonly string[];
onShouldStartLoadWithRequestProp?: OnShouldStartLoadWithRequest;
onShouldStartLoadWithRequestCallback: (
shouldStart: boolean,
url: string,
lockIdentifier?: number | undefined
) => void;
validateMeta: (event: WebViewNativeEvent) => WebViewNativeEvent;
validateData: (data: object) => object;
}) => {
const [viewState, setViewState] = useState<'IDLE' | 'LOADING' | 'ERROR'>(
startInLoadingState ? 'LOADING' : 'IDLE'
);
const [lastErrorEvent, setLastErrorEvent] = useState(
null
);
const startUrl = useRef(null);
// Exodus: Helper to check if URL passes origin whitelist
const passesWhitelistCallback = useCallback(
(url: string) => {
if (!url || typeof url !== 'string') return false;
return passesWhitelist(compileWhitelist(originWhitelist), url);
},
[originWhitelist]
);
// Exodus: Extract and sanitize metadata from native event
const extractMeta = (
nativeEvent: WebViewNativeEvent
): WebViewNativeEvent => ({
url: String(nativeEvent.url),
loading: Boolean(nativeEvent.loading),
title: String(nativeEvent.title).slice(0, 512),
canGoBack: Boolean(nativeEvent.canGoBack),
canGoForward: Boolean(nativeEvent.canGoForward),
lockIdentifier: Number(nativeEvent.lockIdentifier),
});
const updateNavigationState = useCallback(
(event: WebViewNavigationEvent) => {
onNavigationStateChange?.(event.nativeEvent);
},
[onNavigationStateChange]
);
const onLoadingStart = useCallback(
(event: WebViewNavigationEvent) => {
// Needed for android
startUrl.current = event.nativeEvent.url;
// !Needed for android
onLoadStart?.(event);
updateNavigationState(event);
},
[onLoadStart, updateNavigationState]
);
const onLoadingError = useCallback(
(event: WebViewErrorEvent) => {
event.persist();
if (onError) {
onError(event);
} else {
console.warn('Encountered an error loading page', event.nativeEvent);
}
onLoadEnd?.(event);
if (event.isDefaultPrevented()) {
return;
}
setViewState('ERROR');
setLastErrorEvent(event.nativeEvent);
},
[onError, onLoadEnd]
);
const onLoadingSubResourceError = useCallback(
(event: WebViewErrorEvent) => {
onLoadSubResourceError?.(event);
},
[onLoadSubResourceError]
);
// Android Only
const onRenderProcessGone = useCallback(
(event: WebViewRenderProcessGoneEvent) => {
onRenderProcessGoneProp?.(event);
},
[onRenderProcessGoneProp]
);
// !Android Only
// iOS Only
const onContentProcessDidTerminate = useCallback(
(event: WebViewTerminatedEvent) => {
onContentProcessDidTerminateProp?.(event);
},
[onContentProcessDidTerminateProp]
);
// !iOS Only
const onLoadingFinish = useCallback(
(event: WebViewNavigationEvent) => {
onLoad?.(event);
onLoadEnd?.(event);
const {
nativeEvent: { url },
} = event;
// on Android, only if url === startUrl
if (Platform.OS !== 'android' || url === startUrl.current) {
setViewState('IDLE');
}
// !on Android, only if url === startUrl
updateNavigationState(event);
},
[onLoad, onLoadEnd, updateNavigationState]
);
const onMessage = useCallback(
(event: WebViewMessageEvent) => {
const { nativeEvent } = event;
// Exodus: Validate URL against whitelist before processing message
if (!passesWhitelistCallback(nativeEvent.url)) return;
try {
const parsedData = JSON.parse(nativeEvent.data);
const data = JSON.stringify(validateData(parsedData));
const meta = validateMeta(extractMeta(nativeEvent));
onMessageProp?.({ ...meta, data });
} catch (err) {
console.error('Error parsing WebView message', err);
}
},
[onMessageProp, passesWhitelistCallback, validateData, validateMeta]
);
const onLoadingProgress = useCallback(
(event: WebViewProgressEvent) => {
const {
nativeEvent: { progress },
} = event;
// patch for Android only
if (Platform.OS === 'android' && progress === 1) {
setViewState((prevViewState) =>
prevViewState === 'LOADING' ? 'IDLE' : prevViewState
);
}
// !patch for Android only
onLoadProgress?.(event);
},
[onLoadProgress]
);
const onShouldStartLoadWithRequest = useMemo(
() =>
createOnShouldStartLoadWithRequest(
onShouldStartLoadWithRequestCallback,
originWhitelist,
deeplinkWhitelist,
onShouldStartLoadWithRequestProp
),
[
originWhitelist,
deeplinkWhitelist,
onShouldStartLoadWithRequestProp,
onShouldStartLoadWithRequestCallback,
]
);
const onOpenWindow = useCallback(
(event: WebViewOpenWindowEvent) => {
onOpenWindowProp?.(event);
},
[onOpenWindowProp]
);
return {
onShouldStartLoadWithRequest,
onLoadingStart,
onLoadingProgress,
onLoadingError,
onLoadingSubResourceError,
onLoadingFinish,
onRenderProcessGone,
onContentProcessDidTerminate,
onMessage,
onOpenWindow,
viewState,
setViewState,
lastErrorEvent,
};
};
/**
* Exodus: Check if a version string passes the minimum version requirement.
* Supports complex version constraints like "12.5.6 <13, 13.6.1 <14, 14.8.1 <15, 15.7.1"
* which means:
* - 12.5.6 or higher but less than 13
* - OR 13.6.1 or higher but less than 14
* - OR 14.8.1 or higher but less than 15
* - OR 15.7.1 or higher (no upper bound)
*/
export const versionPasses = (
version: string | undefined,
minimum: string | undefined
): boolean => {
if (!version || !minimum) return false;
if (typeof version !== 'string' || typeof minimum !== 'string') return false;
// Handle multiple version ranges separated by ", "
if (minimum.includes(', ')) {
const variants = minimum.split(', ');
// Every entry but the last one should have an upper bound
if (!variants.slice(0, -1).every((x) => x.includes(' <'))) return false;
// Any match passes
return variants.some((x) => versionPasses(version, x));
}
// Handle version range with upper bound (e.g., "12.5.6 <13")
if (minimum.includes(' <')) {
const [min, max, ...rest] = minimum.split(' <');
if (rest.length > 0) return false;
// Must be >= min AND < max
// Last check validates that max > min (formatting validation)
return (
versionPasses(version, min) &&
!versionPasses(version, max) &&
versionPasses(max, version)
);
}
// Simple version comparison (e.g., "15.7.1")
const versionRegex = /^[0-9]+(\.[0-9]+)*$/;
if (!versionRegex.test(version) || !versionRegex.test(minimum)) return false;
const versionParts = version.split('.').map(Number);
const minimumParts = minimum.split('.').map(Number);
const len = Math.max(versionParts.length, minimumParts.length);
for (let i = 0; i < len; i += 1) {
const ver = versionParts[i] || 0;
const min = minimumParts[i] || 0;
if (ver > min) return true;
if (ver < min) return false;
}
return true; // equals
};