ff438e8897
移动壳 WebView 注入下载阻断脚本并丢弃 iOS 文件下载事件。 移动壳 Android 包配置阻断外部存储和请求安装包权限。 移动壳配置检查和 Expo public config smoke 锁定下载边界。 新增移动壳 WebView 下载阻断策略测试。 更新宿主壳方案和共享决策日志记录移动端下载只能走 HostBridge 导出。
233 lines
6.5 KiB
TypeScript
233 lines
6.5 KiB
TypeScript
import { StatusBar } from 'expo-status-bar';
|
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
AppState,
|
|
type AppStateStatus,
|
|
BackHandler,
|
|
Linking,
|
|
Platform,
|
|
StyleSheet,
|
|
} 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 {
|
|
configureMobileHostBridgeNavigation,
|
|
handleMobileHostBridgeMessage,
|
|
resolveMobileHostCapabilities,
|
|
} from './src/mobileHostBridge';
|
|
import { buildMobileShellUrlFromDeepLink } from './src/mobileShellDeepLink';
|
|
import { lifecyclePayloadFromAppState } from './src/mobileShellLifecycle';
|
|
import {
|
|
resolveMobileShellExternalUrl,
|
|
shouldAcceptMobileShellHostBridgeMessage,
|
|
shouldOpenInMobileShellWebView,
|
|
} from './src/mobileShellNavigation';
|
|
import { subscribeMobileNetworkStatus } from './src/mobileShellNetwork';
|
|
import { MOBILE_SHELL_HOST_VERSION } from './src/mobileShellRuntime';
|
|
import { MOBILE_SHELL_SAFE_AREA_EDGES } from './src/mobileShellSafeArea';
|
|
import {
|
|
buildMobileShellUrl,
|
|
resolveMobileShellBaseWebUrl,
|
|
} from './src/mobileShellUrl';
|
|
import { BLOCK_WEBVIEW_DOWNLOAD_SCRIPT } from './src/mobileShellWebViewPolicy';
|
|
|
|
function buildHostBridgeMessageScript(message: unknown) {
|
|
return `window.dispatchEvent(new MessageEvent('message', { data: ${JSON.stringify(
|
|
JSON.stringify(message),
|
|
)} })); true;`;
|
|
}
|
|
|
|
export default function App() {
|
|
const webViewRef = useRef<WebView>(null);
|
|
const [canGoBack, setCanGoBack] = useState(false);
|
|
const baseWebUrl = resolveMobileShellBaseWebUrl(
|
|
process.env.EXPO_PUBLIC_GENARRATIVE_WEB_URL,
|
|
);
|
|
const mobileShellUrlOptions = useMemo(
|
|
() => ({
|
|
platform: Platform.OS === 'ios' ? 'ios' as const : 'android' as const,
|
|
hostVersion: MOBILE_SHELL_HOST_VERSION,
|
|
capabilities: resolveMobileHostCapabilities(),
|
|
}),
|
|
[],
|
|
);
|
|
const [webUrl, setWebUrl] = useState(() =>
|
|
buildMobileShellUrl(baseWebUrl, mobileShellUrlOptions),
|
|
);
|
|
const allowedWebOrigin = useMemo(() => new URL(webUrl).origin, [webUrl]);
|
|
|
|
useEffect(() => {
|
|
configureMobileHostBridgeNavigation({
|
|
allowedOrigin: allowedWebOrigin,
|
|
openWebViewUrl(url) {
|
|
setWebUrl(url);
|
|
},
|
|
reloadWebView() {
|
|
webViewRef.current?.reload();
|
|
},
|
|
});
|
|
|
|
return () => configureMobileHostBridgeNavigation(null);
|
|
}, [allowedWebOrigin]);
|
|
|
|
useEffect(() => {
|
|
let disposed = false;
|
|
|
|
const openDeepLink = (url: string | null | undefined) => {
|
|
const nextUrl = buildMobileShellUrlFromDeepLink(
|
|
url,
|
|
baseWebUrl,
|
|
mobileShellUrlOptions,
|
|
);
|
|
setWebUrl(nextUrl);
|
|
};
|
|
|
|
void Linking.getInitialURL().then((url) => {
|
|
if (!disposed) {
|
|
openDeepLink(url);
|
|
}
|
|
});
|
|
|
|
const subscription = Linking.addEventListener('url', (event) => {
|
|
openDeepLink(event.url);
|
|
});
|
|
|
|
return () => {
|
|
disposed = true;
|
|
subscription.remove();
|
|
};
|
|
}, [baseWebUrl, mobileShellUrlOptions]);
|
|
|
|
useEffect(() => {
|
|
const subscription = BackHandler.addEventListener(
|
|
'hardwareBackPress',
|
|
() => {
|
|
if (!canGoBack) {
|
|
return false;
|
|
}
|
|
|
|
webViewRef.current?.goBack();
|
|
return true;
|
|
},
|
|
);
|
|
|
|
return () => subscription.remove();
|
|
}, [canGoBack]);
|
|
|
|
useEffect(() => {
|
|
const sendLifecycleEvent = (state: AppStateStatus) => {
|
|
webViewRef.current?.injectJavaScript(
|
|
buildHostBridgeMessageScript({
|
|
bridge: 'GenarrativeHostBridge',
|
|
version: 1,
|
|
event: 'app.lifecycle',
|
|
payload: lifecyclePayloadFromAppState(state),
|
|
}),
|
|
);
|
|
};
|
|
|
|
const subscription = AppState.addEventListener(
|
|
'change',
|
|
sendLifecycleEvent,
|
|
);
|
|
sendLifecycleEvent(AppState.currentState);
|
|
|
|
return () => subscription.remove();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
return subscribeMobileNetworkStatus((payload) => {
|
|
webViewRef.current?.injectJavaScript(
|
|
buildHostBridgeMessageScript({
|
|
bridge: 'GenarrativeHostBridge',
|
|
version: 1,
|
|
event: 'network.statusChanged',
|
|
payload,
|
|
}),
|
|
);
|
|
});
|
|
}, []);
|
|
|
|
const handleMessage = (event: WebViewMessageEvent) => {
|
|
if (
|
|
!shouldAcceptMobileShellHostBridgeMessage(
|
|
event.nativeEvent.url,
|
|
allowedWebOrigin,
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
void handleMobileHostBridgeMessage(event.nativeEvent.data, (response) => {
|
|
webViewRef.current?.injectJavaScript(
|
|
buildHostBridgeMessageScript(response),
|
|
);
|
|
});
|
|
};
|
|
|
|
const handleShouldStartLoad = (request: { url: string }) => {
|
|
if (shouldOpenInMobileShellWebView(request.url, allowedWebOrigin)) {
|
|
return true;
|
|
}
|
|
|
|
const externalUrl = resolveMobileShellExternalUrl(request.url);
|
|
if (externalUrl) {
|
|
void Linking.openURL(externalUrl).catch(() => undefined);
|
|
}
|
|
return false;
|
|
};
|
|
|
|
return (
|
|
<SafeAreaProvider>
|
|
<SafeAreaView style={styles.root} edges={MOBILE_SHELL_SAFE_AREA_EDGES}>
|
|
<StatusBar style="auto" />
|
|
<WebView
|
|
ref={webViewRef}
|
|
source={{ uri: webUrl }}
|
|
javaScriptEnabled
|
|
javaScriptCanOpenWindowsAutomatically={false}
|
|
domStorageEnabled
|
|
mixedContentMode="never"
|
|
originWhitelist={[allowedWebOrigin]}
|
|
allowFileAccess={false}
|
|
allowFileAccessFromFileURLs={false}
|
|
allowUniversalAccessFromFileURLs={false}
|
|
allowsFullscreenVideo
|
|
allowsInlineMediaPlayback
|
|
mediaPlaybackRequiresUserAction
|
|
thirdPartyCookiesEnabled={false}
|
|
sharedCookiesEnabled={false}
|
|
webviewDebuggingEnabled={false}
|
|
injectedJavaScriptBeforeContentLoaded={BLOCK_WEBVIEW_DOWNLOAD_SCRIPT}
|
|
onFileDownload={() => undefined}
|
|
onMessage={handleMessage}
|
|
onShouldStartLoadWithRequest={handleShouldStartLoad}
|
|
onNavigationStateChange={(event) => {
|
|
setCanGoBack(event.canGoBack);
|
|
webViewRef.current?.injectJavaScript(
|
|
buildHostBridgeMessageScript({
|
|
bridge: 'GenarrativeHostBridge',
|
|
version: 1,
|
|
event: 'navigation.canGoBack',
|
|
payload: {
|
|
canGoBack: event.canGoBack,
|
|
},
|
|
}),
|
|
);
|
|
}}
|
|
setSupportMultipleWindows={false}
|
|
/>
|
|
</SafeAreaView>
|
|
</SafeAreaProvider>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
root: {
|
|
flex: 1,
|
|
backgroundColor: '#fffdf9',
|
|
},
|
|
});
|