接入移动壳安全区布局

Expo 移动壳使用 SafeAreaProvider 和 SafeAreaView 包裹 WebView

新增安全区边界配置和测试

补充移动壳安全区依赖检查和架构文档
This commit is contained in:
2026-06-18 08:14:27 +08:00
parent 94046153c6
commit 94a866b48b
9 changed files with 76 additions and 27 deletions

View File

@@ -7,8 +7,8 @@ import {
Linking, Linking,
Platform, Platform,
StyleSheet, StyleSheet,
View,
} from 'react-native'; } from 'react-native';
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
import type { WebViewMessageEvent } from 'react-native-webview'; import type { WebViewMessageEvent } from 'react-native-webview';
import { WebView } from 'react-native-webview'; import { WebView } from 'react-native-webview';
@@ -24,6 +24,7 @@ import {
shouldOpenInMobileShellWebView, shouldOpenInMobileShellWebView,
} from './src/mobileShellNavigation'; } from './src/mobileShellNavigation';
import { subscribeMobileNetworkStatus } from './src/mobileShellNetwork'; import { subscribeMobileNetworkStatus } from './src/mobileShellNetwork';
import { MOBILE_SHELL_SAFE_AREA_EDGES } from './src/mobileShellSafeArea';
import { buildMobileShellUrl } from './src/mobileShellUrl'; import { buildMobileShellUrl } from './src/mobileShellUrl';
const defaultWebUrl = 'http://127.0.0.1:3000/'; const defaultWebUrl = 'http://127.0.0.1:3000/';
@@ -165,32 +166,34 @@ export default function App() {
}; };
return ( return (
<View style={styles.root}> <SafeAreaProvider>
<StatusBar style="auto" /> <SafeAreaView style={styles.root} edges={MOBILE_SHELL_SAFE_AREA_EDGES}>
<WebView <StatusBar style="auto" />
ref={webViewRef} <WebView
source={{ uri: webUrl }} ref={webViewRef}
javaScriptEnabled source={{ uri: webUrl }}
domStorageEnabled javaScriptEnabled
originWhitelist={[allowedWebOrigin]} domStorageEnabled
onMessage={handleMessage} originWhitelist={[allowedWebOrigin]}
onShouldStartLoadWithRequest={handleShouldStartLoad} onMessage={handleMessage}
onNavigationStateChange={(event) => { onShouldStartLoadWithRequest={handleShouldStartLoad}
setCanGoBack(event.canGoBack); onNavigationStateChange={(event) => {
webViewRef.current?.injectJavaScript( setCanGoBack(event.canGoBack);
buildHostBridgeMessageScript({ webViewRef.current?.injectJavaScript(
bridge: 'GenarrativeHostBridge', buildHostBridgeMessageScript({
version: 1, bridge: 'GenarrativeHostBridge',
event: 'navigation.canGoBack', version: 1,
payload: { event: 'navigation.canGoBack',
canGoBack: event.canGoBack, payload: {
}, canGoBack: event.canGoBack,
}), },
); }),
}} );
setSupportMultipleWindows={false} }}
/> setSupportMultipleWindows={false}
</View> />
</SafeAreaView>
</SafeAreaProvider>
); );
} }

View File

@@ -26,6 +26,7 @@
"expo-status-bar": "^56.0.4", "expo-status-bar": "^56.0.4",
"react": "^19.0.0", "react": "^19.0.0",
"react-native": "^0.86.0", "react-native": "^0.86.0",
"react-native-safe-area-context": "^5.8.0",
"react-native-webview": "^13.16.1" "react-native-webview": "^13.16.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -124,6 +124,9 @@ for (const snippet of [
'subscribeMobileNetworkStatus', 'subscribeMobileNetworkStatus',
'navigation.canGoBack', 'navigation.canGoBack',
'buildHostBridgeMessageScript', 'buildHostBridgeMessageScript',
'SafeAreaProvider',
'SafeAreaView',
'MOBILE_SHELL_SAFE_AREA_EDGES',
]) { ]) {
if (!appSource.includes(snippet)) { if (!appSource.includes(snippet)) {
throw new Error(`mobile shell App missing ${snippet}`); throw new Error(`mobile shell App missing ${snippet}`);
@@ -137,6 +140,7 @@ for (const dependency of [
'expo-network', 'expo-network',
'expo-notifications', 'expo-notifications',
'expo-sharing', 'expo-sharing',
'react-native-safe-area-context',
]) { ]) {
if (!packageConfig.dependencies?.[dependency]) { if (!packageConfig.dependencies?.[dependency]) {
throw new Error(`mobile shell package missing ${dependency}`); throw new Error(`mobile shell package missing ${dependency}`);

View File

@@ -0,0 +1,14 @@
import { describe, expect, test } from 'vitest';
import { MOBILE_SHELL_SAFE_AREA_EDGES } from './mobileShellSafeArea';
describe('mobile shell safe area', () => {
test('protects the WebView from every device edge', () => {
expect(MOBILE_SHELL_SAFE_AREA_EDGES).toEqual([
'top',
'right',
'bottom',
'left',
]);
});
});

View File

@@ -0,0 +1,6 @@
export const MOBILE_SHELL_SAFE_AREA_EDGES = [
'top',
'right',
'bottom',
'left',
] as const;

View File

@@ -38,6 +38,7 @@
- 2026-06-18 移动图片导入Expo 壳开始声明并实现 `file.importImage`,通过 `expo-image-picker` 请求相册权限并打开系统相册选择器,只允许 `image/png``image/jpeg``image/webp` 且单次不超过 10 MiB成功只回传清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI用户取消返回 `cancelled` 并由 H5 facade 归为 `false` - 2026-06-18 移动图片导入Expo 壳开始声明并实现 `file.importImage`,通过 `expo-image-picker` 请求相册权限并打开系统相册选择器,只允许 `image/png``image/jpeg``image/webp` 且单次不超过 10 MiB成功只回传清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI用户取消返回 `cancelled` 并由 H5 facade 归为 `false`
- 2026-06-18 移动图片拍摄导入Expo 壳新增 `file.captureImage` HostBridge capability通过 `expo-image-picker` 请求相机权限并打开系统相机拍摄图片,沿用 `file.importImage` 的 MIME、体积、base64 和文件名清洗规则,成功回传 `action=captured`,不暴露设备本地 URI也不请求麦克风权限Tauri 壳不声明该能力,不伪造桌面拍摄。 - 2026-06-18 移动图片拍摄导入Expo 壳新增 `file.captureImage` HostBridge capability通过 `expo-image-picker` 请求相机权限并打开系统相机拍摄图片,沿用 `file.importImage` 的 MIME、体积、base64 和文件名清洗规则,成功回传 `action=captured`,不暴露设备本地 URI也不请求麦克风权限Tauri 壳不声明该能力,不伪造桌面拍摄。
- 2026-06-18 H5 图片上传接入宿主导入:`CreativeImageInputPanel``native_app` 且声明 `file.importImage` / `file.captureImage` 时,主图上传和描述参考图上传可分别调用 `importHostImageFile()` / `captureHostImageFile()`,并把宿主返回的 base64 图片转换为现有 `File` 回调;浏览器、小程序和未声明能力的裁剪壳继续走原生 `<input type="file">` 路径,不新增玩法侧上传分叉。 - 2026-06-18 H5 图片上传接入宿主导入:`CreativeImageInputPanel``native_app` 且声明 `file.importImage` / `file.captureImage` 时,主图上传和描述参考图上传可分别调用 `importHostImageFile()` / `captureHostImageFile()`,并把宿主返回的 base64 图片转换为现有 `File` 回调;浏览器、小程序和未声明能力的裁剪壳继续走原生 `<input type="file">` 路径,不新增玩法侧上传分叉。
- 2026-06-18 移动壳安全区Expo 壳根布局使用 `react-native-safe-area-context``SafeAreaProvider` 与四边 `SafeAreaView` 保护 WebView避免 H5 主站内容贴进 iOS 刘海、底部 Home Indicator、Android 状态栏或横屏边缘;该能力属于宿主壳布局保护,不新增 H5 占位 UI不改变玩法 runtime 或 HostBridge capability。
- 2026-06-18 桌面图片拖入接入主图槽位:`CreativeImageInputPanel` 在桌面壳声明 `file.imageDropped` 时订阅宿主拖入事件,只在拖入坐标命中当前主图卡片且未被上层元素遮挡时消费事件,避免窗口级拖入被多个创作面板同时接收;成功后仍转换为现有 `File` 上传回调。 - 2026-06-18 桌面图片拖入接入主图槽位:`CreativeImageInputPanel` 在桌面壳声明 `file.imageDropped` 时订阅宿主拖入事件,只在拖入坐标命中当前主图卡片且未被上层元素遮挡时消费事件,避免窗口级拖入被多个创作面板同时接收;成功后仍转换为现有 `File` 上传回调。
- 2026-06-18 H5 背景音乐接入宿主生命周期:`useBackgroundMusic` 通过 `useHostLifecycleActive()` 消费 `subscribeHostAppLifecycle()` 的归一结果宿主进入后台、inactive 或桌面窗口失焦时降低音量并暂停音频循环,同时 `suspend` WebAudio context回到 `active + focused` 且用户原本开启音乐时再恢复播放,不改变用户音量设置。 - 2026-06-18 H5 背景音乐接入宿主生命周期:`useBackgroundMusic` 通过 `useHostLifecycleActive()` 消费 `subscribeHostAppLifecycle()` 的归一结果宿主进入后台、inactive 或桌面窗口失焦时降低音量并暂停音频循环,同时 `suspend` WebAudio context回到 `active + focused` 且用户原本开启音乐时再恢复播放,不改变用户音量设置。
- 2026-06-18 固定玩法音频接入宿主生命周期:前端新增 `useHostLifecycleActive()` 统一消费 `subscribeHostAppLifecycle()``useBackgroundMusic`、拼图运行态和抓大鹅运行态都只依赖该归一状态判断音频可播放性;宿主 inactive、background 或窗口失焦时暂停 `<audio>` / WebAudio回到 `active + focused` 后仅在运行态仍在播放、音源存在且用户音乐音量大于 0 时恢复,不改变用户音量设置。 - 2026-06-18 固定玩法音频接入宿主生命周期:前端新增 `useHostLifecycleActive()` 统一消费 `subscribeHostAppLifecycle()``useBackgroundMusic`、拼图运行态和抓大鹅运行态都只依赖该归一状态判断音频可播放性;宿主 inactive、background 或窗口失焦时暂停 `<audio>` / WebAudio回到 `active + focused` 后仅在运行态仍在播放、音源存在且用户音乐音量大于 0 时恢复,不改变用户音量设置。

View File

@@ -270,6 +270,8 @@ GameBridge 禁止:
2026-06-18 追加H5 账号状态刷新开始消费 `app.reloadWebView`。用户登录成功、退出登录或其它身份边界变化需要整页重新初始化时,`AuthGate` 会优先请求 Expo 壳刷新当前 WebView宿主未声明或刷新失败时再回退浏览器刷新避免在移动壳内绕过受控容器刷新入口。 2026-06-18 追加H5 账号状态刷新开始消费 `app.reloadWebView`。用户登录成功、退出登录或其它身份边界变化需要整页重新初始化时,`AuthGate` 会优先请求 Expo 壳刷新当前 WebView宿主未声明或刷新失败时再回退浏览器刷新避免在移动壳内绕过受控容器刷新入口。
2026-06-18 追加:移动壳根布局接入 `react-native-safe-area-context`,用 `SafeAreaProvider` 和四边 `SafeAreaView` 承接 iOS 刘海、底部 Home Indicator、Android 状态栏和横屏边缘安全区WebView 仍加载同一 H5 主站,不改变 H5 路由、玩法 runtime 或 HostBridge capability。该能力属于宿主壳布局保护不在 H5 内补额外占位 UI。
### Phase 3Tauri 桌面壳 MVP ### Phase 3Tauri 桌面壳 MVP
- 新增 `apps/desktop-shell/` - 新增 `apps/desktop-shell/`

17
package-lock.json generated
View File

@@ -32,6 +32,7 @@
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-native": "^0.86.0", "react-native": "^0.86.0",
"react-native-safe-area-context": "^5.8.0",
"react-native-webview": "^13.16.1", "react-native-webview": "^13.16.1",
"three": "^0.184.0", "three": "^0.184.0",
"vite": "^6.2.0" "vite": "^6.2.0"
@@ -9975,6 +9976,16 @@
} }
} }
}, },
"node_modules/react-native-safe-area-context": {
"version": "5.8.0",
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.8.0.tgz",
"integrity": "sha512-t+ZsAVzY/wWzzx34vqGbo3/as9EEESJdbyZNL7Yg5EYX+toYMtMqFoDDCvqZUi35eeGVsXc6pAaEk4edMwbuCQ==",
"license": "MIT",
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-webview": { "node_modules/react-native-webview": {
"version": "13.16.1", "version": "13.16.1",
"resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.16.1.tgz", "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.16.1.tgz",
@@ -19870,6 +19881,12 @@
} }
} }
}, },
"react-native-safe-area-context": {
"version": "5.8.0",
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.8.0.tgz",
"integrity": "sha512-t+ZsAVzY/wWzzx34vqGbo3/as9EEESJdbyZNL7Yg5EYX+toYMtMqFoDDCvqZUi35eeGVsXc6pAaEk4edMwbuCQ==",
"requires": {}
},
"react-native-webview": { "react-native-webview": {
"version": "13.16.1", "version": "13.16.1",
"resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.16.1.tgz", "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.16.1.tgz",

View File

@@ -102,6 +102,7 @@
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-native": "^0.86.0", "react-native": "^0.86.0",
"react-native-safe-area-context": "^5.8.0",
"react-native-webview": "^13.16.1", "react-native-webview": "^13.16.1",
"three": "^0.184.0", "three": "^0.184.0",
"vite": "^6.2.0" "vite": "^6.2.0"