diff --git a/apps/mobile-shell/App.tsx b/apps/mobile-shell/App.tsx index cbe79fcb0..3d3d65ed8 100644 --- a/apps/mobile-shell/App.tsx +++ b/apps/mobile-shell/App.tsx @@ -1,257 +1,5 @@ -import { StatusBar } from 'expo-status-bar'; -import { useCallback, 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/host-bridge/mobileHostBridge'; -import { buildMobileShellUrlFromDeepLink } from './src/shell/mobileShellDeepLink'; -import { lifecyclePayloadFromAppState } from './src/shell/mobileShellLifecycle'; -import { - resolveMobileShellExternalUrl, - shouldAcceptMobileShellHostBridgeMessage, - shouldOpenInMobileShellWebView, -} from './src/shell/mobileShellNavigation'; -import { - getMobileNetworkStatus, - subscribeMobileNetworkStatus, -} from './src/shell/mobileShellNetwork'; -import { MOBILE_SHELL_HOST_VERSION } from './src/shell/mobileShellRuntime'; -import { MOBILE_SHELL_SAFE_AREA_EDGES } from './src/shell/mobileShellSafeArea'; -import { - buildMobileShellUrl, - resolveMobileShellBaseWebUrl, -} from './src/shell/mobileShellUrl'; -import { BLOCK_WEBVIEW_DOWNLOAD_SCRIPT } from './src/shell/mobileShellWebViewPolicy'; - -function buildHostBridgeMessageScript(message: unknown) { - return `window.dispatchEvent(new MessageEvent('message', { data: ${JSON.stringify( - JSON.stringify(message), - )}, origin: window.location.origin, source: window })); true;`; -} +import MobileShellApp from './src/shell/MobileShellApp'; export default function App() { - const webViewRef = useRef(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]); - const reloadCurrentWebView = useCallback(() => { - webViewRef.current?.reload(); - }, []); - const injectHostBridgeEvent = useCallback((event: string, payload: unknown) => { - webViewRef.current?.injectJavaScript( - buildHostBridgeMessageScript({ - bridge: 'GenarrativeHostBridge', - version: 1, - event, - payload, - }), - ); - }, []); - const injectLifecycleEvent = useCallback( - (state: AppStateStatus) => { - injectHostBridgeEvent('app.lifecycle', lifecyclePayloadFromAppState(state)); - }, - [injectHostBridgeEvent], - ); - const injectNetworkStatusEvent = useCallback( - (payload: Awaited>) => { - injectHostBridgeEvent('network.statusChanged', payload); - }, - [injectHostBridgeEvent], - ); - - useEffect(() => { - configureMobileHostBridgeNavigation({ - allowedOrigin: allowedWebOrigin, - openWebViewUrl(url) { - setWebUrl(url); - }, - reloadWebView: reloadCurrentWebView, - }); - - return () => configureMobileHostBridgeNavigation(null); - }, [allowedWebOrigin, reloadCurrentWebView]); - - 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 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; - } - - 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; - }; - - const handleWebViewLoad = (event: { nativeEvent: { url: string } }) => { - if ( - !shouldAcceptMobileShellHostBridgeMessage( - event.nativeEvent.url, - allowedWebOrigin, - ) - ) { - return; - } - - injectLifecycleEvent(AppState.currentState); - void getMobileNetworkStatus() - .then(injectNetworkStatusEvent) - .catch(() => undefined); - }; - - return ( - - - - undefined} - onMessage={handleMessage} - onContentProcessDidTerminate={reloadCurrentWebView} - onRenderProcessGone={reloadCurrentWebView} - onLoad={handleWebViewLoad} - onShouldStartLoadWithRequest={handleShouldStartLoad} - onNavigationStateChange={(event) => { - setCanGoBack(event.canGoBack); - webViewRef.current?.injectJavaScript( - buildHostBridgeMessageScript({ - bridge: 'GenarrativeHostBridge', - version: 1, - event: 'navigation.canGoBack', - payload: { - canGoBack: event.canGoBack, - }, - }), - ); - }} - setSupportMultipleWindows={false} - /> - - - ); + return ; } - -const styles = StyleSheet.create({ - root: { - flex: 1, - backgroundColor: '#fffdf9', - }, -}); diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 397895415..5536e871d 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -6,6 +6,8 @@ const appConfigPath = new URL('../app.json', import.meta.url); const appConfig = JSON.parse(fs.readFileSync(appConfigPath, 'utf8')).expo; const appPath = new URL('../App.tsx', import.meta.url); const appSource = fs.readFileSync(appPath, 'utf8'); +const mobileShellAppPath = new URL('../src/shell/MobileShellApp.tsx', import.meta.url); +const mobileShellAppSource = fs.readFileSync(mobileShellAppPath, 'utf8'); const bridgePath = new URL('../src/host-bridge/mobileHostBridge.ts', import.meta.url); const bridgeSource = fs.readFileSync(bridgePath, 'utf8'); const bridgeDirPath = new URL('../src/host-bridge/', import.meta.url); @@ -207,6 +209,7 @@ function assertNoBlockedMobileChannelSnippets() { const sources = [ ['app.json', JSON.stringify(appConfig)], ['App.tsx', appSource], + ['MobileShellApp.tsx', mobileShellAppSource], ['src/host-bridge', hostBridgeSource], ['mobileShellUrl.ts', mobileShellUrlSource], ['mobileShellRuntime.ts', mobileShellRuntimeSource], @@ -747,13 +750,29 @@ for (const snippet of [ 'onFileDownload={() => undefined}', 'setSupportMultipleWindows={false}', ]) { - if (!appSource.includes(snippet)) { - throw new Error(`mobile shell App missing ${snippet}`); + if (!mobileShellAppSource.includes(snippet)) { + throw new Error(`mobile shell MobileShellApp missing ${snippet}`); } } -if (appSource.includes('process.env.EXPO_PUBLIC_GENARRATIVE_WEB_URL ||')) { - throw new Error('mobile shell App must normalize EXPO_PUBLIC_GENARRATIVE_WEB_URL'); +if (!appSource.includes("import MobileShellApp from './src/shell/MobileShellApp';")) { + throw new Error('mobile shell App must import the shell app facade'); +} + +if (!appSource.includes('return ;')) { + throw new Error('mobile shell App must only render the shell app facade'); +} + +if (appSource.includes('./src/host-bridge/')) { + throw new Error('mobile shell App must not import HostBridge directly'); +} + +if ( + mobileShellAppSource.includes('process.env.EXPO_PUBLIC_GENARRATIVE_WEB_URL ||') +) { + throw new Error( + 'mobile shell MobileShellApp must normalize EXPO_PUBLIC_GENARRATIVE_WEB_URL', + ); } if ( @@ -777,8 +796,10 @@ for (const snippet of [ } } -if (appSource.includes('127.0.0.1:3000')) { - throw new Error('mobile shell App must not hard-code localhost as the default H5 URL'); +if (mobileShellAppSource.includes('127.0.0.1:3000')) { + throw new Error( + 'mobile shell MobileShellApp must not hard-code localhost as the default H5 URL', + ); } for (const dependency of [ @@ -903,15 +924,15 @@ for (const snippet of [ } const capabilityQuerySnippet = "capabilities: MOBILE_HOST_CAPABILITIES"; -if (appSource.includes(capabilityQuerySnippet)) { +if (mobileShellAppSource.includes(capabilityQuerySnippet)) { throw new Error('mobile shell URL must resolve platform-aware capabilities'); } -if (!appSource.includes('capabilities: resolveMobileHostCapabilities()')) { +if (!mobileShellAppSource.includes('capabilities: resolveMobileHostCapabilities()')) { throw new Error('mobile shell URL must use resolveMobileHostCapabilities()'); } -if (!appSource.includes('hostVersion: MOBILE_SHELL_HOST_VERSION')) { +if (!mobileShellAppSource.includes('hostVersion: MOBILE_SHELL_HOST_VERSION')) { throw new Error('mobile shell URL must use the shared mobile shell host version'); } @@ -919,12 +940,18 @@ if (!bridgeSource.includes('hostVersion: MOBILE_SHELL_HOST_VERSION')) { throw new Error('mobile shell runtime response must use the shared mobile shell host version'); } -if (appSource.includes("hostVersion: '0.1.0'") || bridgeSource.includes("hostVersion: '0.1.0'")) { +if ( + mobileShellAppSource.includes("hostVersion: '0.1.0'") || + bridgeSource.includes("hostVersion: '0.1.0'") +) { throw new Error('mobile shell HostBridge version must not be duplicated in app or bridge source'); } for (const capability of sdkBackedCapabilities) { - if (appSource.includes(`'${capability}'`) || appSource.includes(`"${capability}"`)) { + if ( + mobileShellAppSource.includes(`'${capability}'`) || + mobileShellAppSource.includes(`"${capability}"`) + ) { throw new Error( `mobile shell URL must not advertise ${capability} without a real SDK/channel flow`, ); diff --git a/apps/mobile-shell/src/shell/MobileShellApp.tsx b/apps/mobile-shell/src/shell/MobileShellApp.tsx new file mode 100644 index 000000000..9e0cc2f8a --- /dev/null +++ b/apps/mobile-shell/src/shell/MobileShellApp.tsx @@ -0,0 +1,257 @@ +import { StatusBar } from 'expo-status-bar'; +import { useCallback, 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 '../host-bridge/mobileHostBridge'; +import { buildMobileShellUrlFromDeepLink } from './mobileShellDeepLink'; +import { lifecyclePayloadFromAppState } from './mobileShellLifecycle'; +import { + resolveMobileShellExternalUrl, + shouldAcceptMobileShellHostBridgeMessage, + shouldOpenInMobileShellWebView, +} from './mobileShellNavigation'; +import { + getMobileNetworkStatus, + subscribeMobileNetworkStatus, +} from './mobileShellNetwork'; +import { MOBILE_SHELL_HOST_VERSION } from './mobileShellRuntime'; +import { MOBILE_SHELL_SAFE_AREA_EDGES } from './mobileShellSafeArea'; +import { + buildMobileShellUrl, + resolveMobileShellBaseWebUrl, +} from './mobileShellUrl'; +import { BLOCK_WEBVIEW_DOWNLOAD_SCRIPT } from './mobileShellWebViewPolicy'; + +function buildHostBridgeMessageScript(message: unknown) { + return `window.dispatchEvent(new MessageEvent('message', { data: ${JSON.stringify( + JSON.stringify(message), + )}, origin: window.location.origin, source: window })); true;`; +} + +export default function MobileShellApp() { + const webViewRef = useRef(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]); + const reloadCurrentWebView = useCallback(() => { + webViewRef.current?.reload(); + }, []); + const injectHostBridgeEvent = useCallback((event: string, payload: unknown) => { + webViewRef.current?.injectJavaScript( + buildHostBridgeMessageScript({ + bridge: 'GenarrativeHostBridge', + version: 1, + event, + payload, + }), + ); + }, []); + const injectLifecycleEvent = useCallback( + (state: AppStateStatus) => { + injectHostBridgeEvent('app.lifecycle', lifecyclePayloadFromAppState(state)); + }, + [injectHostBridgeEvent], + ); + const injectNetworkStatusEvent = useCallback( + (payload: Awaited>) => { + injectHostBridgeEvent('network.statusChanged', payload); + }, + [injectHostBridgeEvent], + ); + + useEffect(() => { + configureMobileHostBridgeNavigation({ + allowedOrigin: allowedWebOrigin, + openWebViewUrl(url) { + setWebUrl(url); + }, + reloadWebView: reloadCurrentWebView, + }); + + return () => configureMobileHostBridgeNavigation(null); + }, [allowedWebOrigin, reloadCurrentWebView]); + + 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 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; + } + + 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; + }; + + const handleWebViewLoad = (event: { nativeEvent: { url: string } }) => { + if ( + !shouldAcceptMobileShellHostBridgeMessage( + event.nativeEvent.url, + allowedWebOrigin, + ) + ) { + return; + } + + injectLifecycleEvent(AppState.currentState); + void getMobileNetworkStatus() + .then(injectNetworkStatusEvent) + .catch(() => undefined); + }; + + return ( + + + + undefined} + onMessage={handleMessage} + onContentProcessDidTerminate={reloadCurrentWebView} + onRenderProcessGone={reloadCurrentWebView} + onLoad={handleWebViewLoad} + onShouldStartLoadWithRequest={handleShouldStartLoad} + onNavigationStateChange={(event) => { + setCanGoBack(event.canGoBack); + webViewRef.current?.injectJavaScript( + buildHostBridgeMessageScript({ + bridge: 'GenarrativeHostBridge', + version: 1, + event: 'navigation.canGoBack', + payload: { + canGoBack: event.canGoBack, + }, + }), + ); + }} + setSupportMultipleWindows={false} + /> + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: '#fffdf9', + }, +}); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 534aa493b..c158c3fd1 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -86,7 +86,7 @@ - 2026-06-18 移动壳 WebView 安全开关:Expo 移动壳 WebView 必须显式禁用 JS 自动开窗、多窗口、文件访问、file URL 跨源访问、HTTPS 混合内容、第三方 Cookie、共享 Cookie 和 WebView 远程调试;同源主站页面才能留在带 HostBridge 的 WebView 内,外链只通过受控协议离开容器交给系统。配置检查和移动壳导航测试会拒绝这些边界被放宽。 - 2026-06-18 移动壳 WebView 默认下载边界:Expo WebView 内网页自动下载和 `` 直接落盘默认关闭;壳层注入脚本阻断 download 链接,iOS `onFileDownload` 只丢弃不落盘,Android 包配置通过 `blockedPermissions` 移除外部存储读写、管理外部存储和请求安装包权限。移动端文本、图片、音频保存只能通过 `file.exportText`、`file.exportImage`、`file.exportAudio` 等 HostBridge 受控导出能力进入系统分享 / 保存面板。 - 2026-06-18 移动壳 HostBridge 消息来源校验:Expo 移动壳 `onMessage` 必须根据 `event.nativeEvent.url` 校验消息来源,只有同源主站页面能进入 `handleMobileHostBridgeMessage`;`about:blank`、外域、协议降级和危险协议页面消息直接丢弃,不返回宿主能力错误细节。该规则与 WebView 导航留壳规则共用同源判断,配置检查和移动壳导航测试会拒绝移除。 -- 2026-06-18 三端桥接层目录同构:微信小程序、Expo 移动壳和 Tauri 桌面壳都按 `host-bridge / shell` 两层管理宿主桥接代码。微信 `miniprogram/host-bridge/wechatHostBridge*.js` 只放协议归一、支付 / 订阅 / 分享结果编解码和可测试桥接函数,`miniprogram/shell/wechatShell*.js` 承接 Page 生命周期、`wx.*` 容器调用、WebView 容器行为和页面工厂;页面目录只保留 `Page(createWechat...Page())` 装配。Expo `mobileHostBridgeProtocol.ts`、`mobileHostBridgeFiles.ts`、`mobileHostBridgeShare.ts` 和 facade `mobileHostBridge.ts` 分别对齐 Tauri `host_bridge/protocol.rs`、`files.rs`、`share.rs`、`mod.rs`。Tauri `shell/deep_link.rs`、`shell/tray.rs`、`shell/webview.rs` 分别承接深链、托盘和 WebView 容器行为。`npm run check:native-shells` 会校验微信、移动和桌面三端目录清单,新增宿主能力必须按同一边界落文件和测试。 +- 2026-06-18 三端桥接层目录同构:微信小程序、Expo 移动壳和 Tauri 桌面壳都按 `host-bridge / shell` 两层管理宿主桥接代码。微信 `miniprogram/host-bridge/wechatHostBridge*.js` 只放协议归一、支付 / 订阅 / 分享结果编解码和可测试桥接函数,`miniprogram/shell/wechatShell*.js` 承接 Page 生命周期、`wx.*` 容器调用、WebView 容器行为和页面工厂;页面目录只保留 `Page(createWechat...Page())` 装配。Expo `mobileHostBridgeProtocol.ts`、`mobileHostBridgeFiles.ts`、`mobileHostBridgeShare.ts` 和 facade `mobileHostBridge.ts` 分别对齐 Tauri `host_bridge/protocol.rs`、`files.rs`、`share.rs`、`mod.rs`,根 `App.tsx` 只装配 `src/shell/MobileShellApp.tsx`,不直接进口 HostBridge。Tauri `shell/deep_link.rs`、`shell/tray.rs`、`shell/webview.rs` 分别承接深链、托盘和 WebView 容器行为。`npm run check:native-shells` 会校验微信、移动和桌面三端目录清单,新增宿主能力必须按同一边界落文件和测试。 - 影响范围:`src/services/host-bridge/`、未来 `apps/mobile-shell/`、未来 `apps/desktop-shell/`、移动端支付 / 分享 / 深链 / 推送、桌面端系统能力、AI H5 sandbox 的 GameBridge 边界。 - 验证方式:普通浏览器、小程序、Expo 壳、Tauri 壳都能返回正确 `getHostRuntime()`;未支持能力能回退 H5;固定玩法在各宿主中读取同一作品数据和运行态 snapshot;AI sandbox 无法直接调用 HostBridge;Tauri release 不允许任意远端页面调用桌面命令。 - 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`、`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`。 @@ -2475,6 +2475,6 @@ ## 2026-06-18 三端宿主桥接层文件结构对齐 - 背景:微信小程序壳、Expo 移动壳和 Tauri 桌面壳都在承接宿主能力;如果微信页面继续散落 `index.shared.js`,桌面端继续把桥接分发堆在 `main.rs`,后续新增登录、支付、文件、通知或 sandbox 转发能力时会很难跨端对照 owner。 -- 决策:三端桥接层按职责对齐。微信小程序页面路由不改,但可测试桥接逻辑统一放到 `miniprogram/host-bridge/wechatHostBridge*.js`,页面目录只保留生命周期和装配;Expo 移动壳拆成 `apps/mobile-shell/src/host-bridge/mobileHostBridgeProtocol.ts`、`mobileHostBridgeFiles.ts`、`mobileHostBridgeShare.ts` 和 facade `mobileHostBridge.ts`,分别负责协议 / 能力清单 / request 校验 / replay 基础、文件能力、分享能力和 method 分发,`apps/mobile-shell/src/shell/mobileShell*.ts` 负责 URL、导航、网络、生命周期、安全区和 WebView policy;Tauri 桌面壳拆成 `apps/desktop-shell/src-tauri/src/host_bridge/*.rs` 与 `apps/desktop-shell/src-tauri/src/shell/*.rs`,其中 deep link、tray、webview 分文件承接容器行为,`main.rs` 只做 builder、plugin、窗口和状态装配。`scripts/check-native-shells.mjs` 锁定三端桥接层目录清单。 +- 决策:三端桥接层按职责对齐。微信小程序页面路由不改,但可测试桥接逻辑统一放到 `miniprogram/host-bridge/wechatHostBridge*.js`,页面目录只保留生命周期和装配;Expo 移动壳拆成 `apps/mobile-shell/src/host-bridge/mobileHostBridgeProtocol.ts`、`mobileHostBridgeFiles.ts`、`mobileHostBridgeShare.ts` 和 facade `mobileHostBridge.ts`,分别负责协议 / 能力清单 / request 校验 / replay 基础、文件能力、分享能力和 method 分发,根 `App.tsx` 只装配 `apps/mobile-shell/src/shell/MobileShellApp.tsx`,`apps/mobile-shell/src/shell/mobileShell*.ts(x)` 负责 WebView 容器、URL、导航、网络、生命周期、安全区和 WebView policy;Tauri 桌面壳拆成 `apps/desktop-shell/src-tauri/src/host_bridge/*.rs` 与 `apps/desktop-shell/src-tauri/src/shell/*.rs`,其中 deep link、tray、webview 分文件承接容器行为,`main.rs` 只做 builder、plugin、窗口和状态装配。`scripts/check-native-shells.mjs` 锁定三端桥接层目录清单,并拒绝移动根入口直接引用 HostBridge。 - 影响范围:`miniprogram/host-bridge/`、`miniprogram/pages/*/index.js`、`apps/mobile-shell/src/`、`apps/desktop-shell/src-tauri/src/`、`scripts/check-native-shells.mjs`、宿主壳方案文档。 - 验证方式:`npm run test -- miniprogram/host-bridge/wechatHostBridgeWebView.test.js miniprogram/host-bridge/wechatHostBridgePayment.test.js miniprogram/host-bridge/wechatHostBridgeShareGrid.test.js miniprogram/host-bridge/wechatHostBridgeSubscribeMessage.test.js miniprogram/pages/web-view/index.style.test.js`、`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 88155018d..612930ce4 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -64,7 +64,7 @@ src/ 已落地:`packages/shared/src/contracts/hostBridge.ts` 保存消息 envelope、method、payload 和错误码,H5、Expo 壳与 Tauri 壳共享同一份协议类型。 -三端宿主桥接层按职责对齐命名:微信小程序页面路由仍保留在 `miniprogram/pages/*`,协议归一、支付 / 订阅 / 分享结果编解码统一放在 `miniprogram/host-bridge/wechatHostBridge*.js`,Page 生命周期、`wx.*` 容器调用、WebView 容器行为和页面工厂统一放在 `miniprogram/shell/wechatShell*.js`,页面入口只做 `Page(createWechat...Page())` 装配;Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/mobileHostBridgeProtocol.ts` 承接 envelope、能力清单、request 校验和 replay 基础,`mobileHostBridgeFiles.ts` / `mobileHostBridgeShare.ts` 分别承接文件和分享能力,`mobileHostBridge.ts` 只作为对外 facade 与 method 分发入口,`apps/mobile-shell/src/shell/mobileShell*.ts` 承接 URL、导航、网络、生命周期、安全区和 WebView policy;Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs`、`files.rs`、`share.rs` 和 `mod.rs` 承接协议、文件、分享和分发,`apps/desktop-shell/src-tauri/src/shell/*.rs` 承接 WebView、托盘和容器行为,`main.rs` 只保留 Tauri builder / plugin / window 装配。 +三端宿主桥接层按职责对齐命名:微信小程序页面路由仍保留在 `miniprogram/pages/*`,协议归一、支付 / 订阅 / 分享结果编解码统一放在 `miniprogram/host-bridge/wechatHostBridge*.js`,Page 生命周期、`wx.*` 容器调用、WebView 容器行为和页面工厂统一放在 `miniprogram/shell/wechatShell*.js`,页面入口只做 `Page(createWechat...Page())` 装配;Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/mobileHostBridgeProtocol.ts` 承接 envelope、能力清单、request 校验和 replay 基础,`mobileHostBridgeFiles.ts` / `mobileHostBridgeShare.ts` 分别承接文件和分享能力,`mobileHostBridge.ts` 只作为对外 facade 与 method 分发入口,`apps/mobile-shell/App.tsx` 只装配 `apps/mobile-shell/src/shell/MobileShellApp.tsx`,由 `apps/mobile-shell/src/shell/mobileShell*.ts(x)` 承接 WebView 容器、URL、导航、网络、生命周期、安全区和 WebView policy;Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs`、`files.rs`、`share.rs` 和 `mod.rs` 承接协议、文件、分享和分发,`apps/desktop-shell/src-tauri/src/shell/*.rs` 承接 WebView、托盘和容器行为,`main.rs` 只保留 Tauri builder / plugin / window 装配。 ## HostBridge 消息协议 @@ -312,7 +312,7 @@ GameBridge 禁止: 2026-06-18 追加:移动壳安装包身份固定为 `world.genarrative.mobile`。Expo `app.json` 中的 `ios.bundleIdentifier` 与 `android.package` 使用同一包标识,应用版本为 `0.1.0`,iOS `buildNumber` 从字符串 `"1"` 起步,Android `versionCode` 从整数 `1` 起步;后续每次生成可分发安装包时只递增构建号 / versionCode,产品版本号按发布节奏单独调整。`apps/mobile-shell/scripts/check-config.mjs` 会校验这些字段与 `package.json` 版本一致,避免 iOS、Android 和 H5 HostBridge `hostVersion` 发生静默漂移;`npm run mobile-shell:config` 会调用真实 Expo CLI 解析 public managed config,确认最终 Expo 配置仍保留同一包身份、深链、安全字段、插件权限和 HostBridge 版本。当前仍不写入假商店上架信息、假更新端点或占位渠道 SDK 配置。 -2026-06-18 追加:移动壳 H5 入口 query 和 `host.getRuntime` 回包统一读取 `MOBILE_SHELL_HOST_VERSION`,该常量必须与 Expo `app.json` / `package.json` 版本一致。配置检查会拒绝在 `App.tsx` 或 `apps/mobile-shell/src/host-bridge/mobileHostBridge.ts` 内重新散落硬编码版本,避免升级移动安装包时 H5 首屏上下文和宿主 runtime 回读版本不一致。 +2026-06-18 追加:移动壳 H5 入口 query 和 `host.getRuntime` 回包统一读取 `MOBILE_SHELL_HOST_VERSION`,该常量必须与 Expo `app.json` / `package.json` 版本一致。配置检查会拒绝在 `apps/mobile-shell/src/shell/MobileShellApp.tsx` 或 `apps/mobile-shell/src/host-bridge/mobileHostBridge.ts` 内重新散落硬编码版本,避免升级移动安装包时 H5 首屏上下文和宿主 runtime 回读版本不一致。 2026-06-18 追加:移动壳默认显式关闭 Expo OTA 更新,直到存在真实发布通道、更新端点、签名 / 回滚策略和团队发布流程后再接入。`app.json` 只允许 `updates.enabled=false`,不得配置 `runtimeVersion`、release channel、EAS channel、`expo-updates` 插件或移动端 crash / analytics / CodePush 依赖;`apps/mobile-shell/scripts/check-config.mjs` 和 Expo public config smoke 会共同拒绝这些发布通道能力被提前打开,移动壳生产入口、HostBridge 和 URL/runtime 配置也不得提前初始化 Sentry、Firebase Analytics、PostHog、Amplitude、Segment、CodePush 或 Expo Updates。由于移动壳运行依赖会从根安装树解析,根 H5 `package.json` 也不得直接安装这些移动端发布通道、崩溃上报、analytics 或 CodePush SDK;根 `package-lock.json` 也不得解析 `expo-updates`、Sentry、Firebase Analytics、PostHog、Amplitude、Segment、CodePush 等真实发布 / 观测 SDK。`expo-application` 可能由 Expo 自身传递解析,但项目不得把它作为 direct dependency 主动用于渠道逻辑。 @@ -415,7 +415,7 @@ GameBridge 禁止: 2026-06-18 追加:移动壳 HostBridge 消息入口增加来源校验。`onMessage` 不只依赖导航拦截和 `originWhitelist`,还会读取 `event.nativeEvent.url`,只有同源主站页面才能进入 `handleMobileHostBridgeMessage`;`about:blank`、外域 URL、协议降级或危险协议页面发来的消息全部丢弃,不返回 HostBridge 错误细节。该校验与 `navigation.openNativePage` 共用同源规则,防止历史中间页或异常页面在带完整 HostBridge 的 WebView 中发起宿主能力请求。 -2026-06-18 追加:微信、移动端和桌面端桥接层文件结构按职责对齐。微信小程序的 `web-view`、支付、九宫切图和订阅消息桥接逻辑统一迁入 `miniprogram/host-bridge/wechatHostBridge*.js`,页面目录只保留页面生命周期、WXML/WXSS 和装配;移动壳拆成 `apps/mobile-shell/src/host-bridge/mobileHostBridgeProtocol.ts`、`mobileHostBridgeFiles.ts`、`mobileHostBridgeShare.ts` 和 facade `mobileHostBridge.ts`,与桌面端 `host_bridge/protocol.rs`、`files.rs`、`share.rs`、`mod.rs` 对齐;桌面壳 Rust 源码拆成 `apps/desktop-shell/src-tauri/src/host_bridge/*.rs` 与 `apps/desktop-shell/src-tauri/src/shell/*.rs`,其中 `shell/deep_link.rs`、`shell/tray.rs`、`shell/webview.rs` 分别承接深链、托盘和 WebView 容器行为,薄 `main.rs` 只声明两个模块并装配 Tauri builder / plugin / window。根级 `npm run check:native-shells` 会锁定三端桥接层目录清单,避免后续把能力逻辑重新散落到页面或桌面入口。 +2026-06-18 追加:微信、移动端和桌面端桥接层文件结构按职责对齐。微信小程序的 `web-view`、支付、九宫切图和订阅消息桥接逻辑统一迁入 `miniprogram/host-bridge/wechatHostBridge*.js`,页面目录只保留页面生命周期、WXML/WXSS 和装配;移动壳拆成 `apps/mobile-shell/src/host-bridge/mobileHostBridgeProtocol.ts`、`mobileHostBridgeFiles.ts`、`mobileHostBridgeShare.ts` 和 facade `mobileHostBridge.ts`,与桌面端 `host_bridge/protocol.rs`、`files.rs`、`share.rs`、`mod.rs` 对齐;移动壳根 `App.tsx` 也保持薄入口,只装配 `src/shell/MobileShellApp.tsx`,WebView 容器、深链、网络、生命周期和安全策略全部留在 `src/shell/`;桌面壳 Rust 源码拆成 `apps/desktop-shell/src-tauri/src/host_bridge/*.rs` 与 `apps/desktop-shell/src-tauri/src/shell/*.rs`,其中 `shell/deep_link.rs`、`shell/tray.rs`、`shell/webview.rs` 分别承接深链、托盘和 WebView 容器行为,薄 `main.rs` 只声明两个模块并装配 Tauri builder / plugin / window。根级 `npm run check:native-shells` 会锁定三端桥接层目录清单,避免后续把能力逻辑重新散落到页面、移动入口或桌面入口。 ### Phase 4:宿主能力扩展 diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md index fecd4ae84..f8d09bdc1 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -37,7 +37,7 @@ AI H5 sandbox -> parent HostBridge adapter ``` -桥接层文件结构按宿主统一为“协议 / 分发 / 宿主容器行为”三类职责。微信小程序的协议与可测试桥接函数统一放在 `miniprogram/host-bridge/wechatHostBridge*.js`,`miniprogram/shell/wechatShell*.js` 承接 Page 生命周期、`wx.*` 容器调用、WebView 容器行为、支付页和订阅页装配,页面目录只保留 `Page(createWechat...Page())` 装配;Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/mobileHostBridgeProtocol.ts` 承接 envelope、能力清单、request 校验和 replay 基础,`mobileHostBridgeFiles.ts` / `mobileHostBridgeShare.ts` 分别承接文件和分享能力,`mobileHostBridge.ts` 只作为对外 facade 与 method 分发入口,`apps/mobile-shell/src/shell/mobileShell*.ts` 承接 URL、导航、网络、生命周期、安全区和 WebView policy;Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs`、`files.rs`、`share.rs` 和 `mod.rs` 承接协议、文件、分享和分发,`apps/desktop-shell/src-tauri/src/shell/*.rs` 承接 WebView、托盘和容器行为,`main.rs` 只保留 Tauri builder / plugin / window 装配。`npm run check:native-shells` 会检查这些目录清单。 +桥接层文件结构按宿主统一为“协议 / 分发 / 宿主容器行为”三类职责。微信小程序的协议与可测试桥接函数统一放在 `miniprogram/host-bridge/wechatHostBridge*.js`,`miniprogram/shell/wechatShell*.js` 承接 Page 生命周期、`wx.*` 容器调用、WebView 容器行为、支付页和订阅页装配,页面目录只保留 `Page(createWechat...Page())` 装配;Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/mobileHostBridgeProtocol.ts` 承接 envelope、能力清单、request 校验和 replay 基础,`mobileHostBridgeFiles.ts` / `mobileHostBridgeShare.ts` 分别承接文件和分享能力,`mobileHostBridge.ts` 只作为对外 facade 与 method 分发入口,`apps/mobile-shell/App.tsx` 只装配 `apps/mobile-shell/src/shell/MobileShellApp.tsx`,由 `apps/mobile-shell/src/shell/mobileShell*.ts(x)` 承接 WebView 容器、URL、导航、网络、生命周期、安全区和 WebView policy;Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs`、`files.rs`、`share.rs` 和 `mod.rs` 承接协议、文件、分享和分发,`apps/desktop-shell/src-tauri/src/shell/*.rs` 承接 WebView、托盘和容器行为,`main.rs` 只保留 Tauri builder / plugin / window 装配。`npm run check:native-shells` 会检查这些目录清单。 ## 首批能力 diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 625f51447..afa31b56e 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -35,6 +35,7 @@ const expectedMobileHostBridgeFiles = [ 'mobileHostBridgeShare.ts', ]; const expectedMobileShellFiles = [ + 'MobileShellApp.tsx', 'mobileShellDeepLink.test.ts', 'mobileShellDeepLink.ts', 'mobileShellLifecycle.test.ts', @@ -276,6 +277,14 @@ function assertHostBridgeLayerLayout() { 'mobile shell bridge files', ); + const mobileAppSource = fs.readFileSync('apps/mobile-shell/App.tsx', 'utf8'); + if (!mobileAppSource.includes("import MobileShellApp from './src/shell/MobileShellApp';")) { + throw new Error('mobile shell App.tsx must import from apps/mobile-shell/src/shell'); + } + if (mobileAppSource.includes('./src/host-bridge/')) { + throw new Error('mobile shell App.tsx must not import HostBridge directly'); + } + const desktopEntrypointRustFiles = fs .readdirSync('apps/desktop-shell/src-tauri/src', { withFileTypes: true }) .filter((entry) => entry.isFile() && entry.name.endsWith('.rs'))