From 0ad56a878f346c5e56faca6e0ecd826b0adfce53 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 18 Jun 2026 22:20:58 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A7=BB=E5=8A=A8=E5=A3=B3=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=20H5=20=E8=BF=94=E5=9B=9E=E6=A0=88=E8=BF=BD=E8=B8=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 Expo WebView 注入当前文档 history 状态追踪脚本 让移动壳合成 H5 路由栈和 WebView 原生返回状态 让 Android 返回键优先回退 H5 当前文档路由 补充移动壳解析测试、注入脚本测试和配置门禁 更新原生壳方案文档和共享决策 --- apps/mobile-shell/scripts/check-config.mjs | 34 ++++- apps/mobile-shell/src/shell/ShellApp.tsx | 63 ++++++--- .../src/shell/webViewGlobals.d.ts | 7 + .../src/shell/webViewHistory.test.ts | 49 +++++++ apps/mobile-shell/src/shell/webViewHistory.ts | 31 +++++ .../src/shell/webViewPolicy.test.ts | 131 +++++++++++++++++- apps/mobile-shell/src/shell/webViewPolicy.ts | 102 ++++++++++++++ .../shared-memory/decision-log.md | 1 + ...€‘ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 2 + scripts/check-native-shells.mjs | 3 + 10 files changed, 398 insertions(+), 25 deletions(-) create mode 100644 apps/mobile-shell/src/shell/webViewGlobals.d.ts create mode 100644 apps/mobile-shell/src/shell/webViewHistory.test.ts create mode 100644 apps/mobile-shell/src/shell/webViewHistory.ts diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 6ef97cacd..8123a418e 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -30,6 +30,8 @@ const urlPath = new URL('../src/shell/url.ts', import.meta.url); const urlSource = fs.readFileSync(urlPath, 'utf8'); const webViewPolicyPath = new URL('../src/shell/webViewPolicy.ts', import.meta.url); const webViewPolicySource = fs.readFileSync(webViewPolicyPath, 'utf8'); +const webViewHistoryPath = new URL('../src/shell/webViewHistory.ts', import.meta.url); +const webViewHistorySource = fs.readFileSync(webViewHistoryPath, 'utf8'); const loadFailurePath = new URL('../src/shell/loadFailure.ts', import.meta.url); const loadFailureSource = fs.readFileSync(loadFailurePath, 'utf8'); const runtimePath = new URL('../src/shell/runtime.ts', import.meta.url); @@ -735,16 +737,24 @@ for (const snippet of [ 'network.statusChanged', 'getMobileNetworkStatus', 'subscribeMobileNetworkStatus', + 'nativeCanGoBackRef', + 'h5CanGoBackRef', + 'syncNavigationCanGoBack', + 'resetNavigationCanGoBack', 'injectHostBridgeEvent', 'injectLifecycleEvent', 'injectNetworkStatusEvent', 'handleWebViewLoad', 'onLoad={handleWebViewLoad}', 'navigation.canGoBack', + "syncNavigationCanGoBack('h5', historyState.canGoBack)", + "syncNavigationCanGoBack('native', event.canGoBack)", + "webViewRef.current?.injectJavaScript('window.history.back(); true;')", 'buildHostBridgeMessageScript', + 'parseMobileWebViewHistoryStateMessage', 'origin: window.location.origin', 'source: window', - 'BLOCK_WEBVIEW_DOWNLOAD_SCRIPT', + 'MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT', 'shouldBlockMobileWebViewNavigationRequest', 'SafeAreaProvider', 'SafeAreaView', @@ -761,7 +771,7 @@ for (const snippet of [ 'thirdPartyCookiesEnabled={false}', 'sharedCookiesEnabled={false}', 'webviewDebuggingEnabled={false}', - 'injectedJavaScriptBeforeContentLoaded={BLOCK_WEBVIEW_DOWNLOAD_SCRIPT}', + 'injectedJavaScriptBeforeContentLoaded={MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT}', 'handleBlockedFileDownload', 'onFileDownload={handleBlockedFileDownload}', 'setSupportMultipleWindows={false}', @@ -789,6 +799,9 @@ for (const snippet of [ for (const snippet of [ 'MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS', + 'BLOCK_WEBVIEW_DOWNLOAD_SCRIPT', + 'TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT', + 'MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT', "'blob:'", "'data:'", "'file:'", @@ -799,12 +812,29 @@ for (const snippet of [ 'event.stopImmediatePropagation()', 'window.open = function(url)', 'HTMLAnchorElement.prototype.click', + 'window.history.pushState = function(state, title, url)', + 'window.history.replaceState = function(state, title, url)', + "window.addEventListener('popstate'", + 'ReactNativeWebView', + 'genarrative.mobile.historyState', + '__genarrativeMobileHistoryIndex', + '__GENARRATIVE_MOBILE_POST_NAVIGATION_STATE__', ]) { if (!webViewPolicySource.includes(snippet)) { throw new Error(`mobile shell WebView policy missing ${snippet}`); } } +for (const snippet of [ + 'parseMobileWebViewHistoryStateMessage', + 'genarrative.mobile.historyState', + 'typeof candidate.canGoBack !== \'boolean\'', +]) { + if (!webViewHistorySource.includes(snippet)) { + throw new Error(`mobile shell WebView history parser missing ${snippet}`); + } +} + if (!appSource.includes("import ShellApp from './src/shell/ShellApp';")) { throw new Error('mobile shell App must import the shell app facade'); } diff --git a/apps/mobile-shell/src/shell/ShellApp.tsx b/apps/mobile-shell/src/shell/ShellApp.tsx index 20bbc0731..01f27e501 100644 --- a/apps/mobile-shell/src/shell/ShellApp.tsx +++ b/apps/mobile-shell/src/shell/ShellApp.tsx @@ -42,9 +42,10 @@ import { resolveMobileShellBaseWebUrl, } from './url'; import { - BLOCK_WEBVIEW_DOWNLOAD_SCRIPT, + MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT, shouldBlockMobileWebViewNavigationRequest, } from './webViewPolicy'; +import { parseMobileWebViewHistoryStateMessage } from './webViewHistory'; function buildHostBridgeMessageScript(message: unknown) { return `window.dispatchEvent(new MessageEvent('message', { data: ${JSON.stringify( @@ -70,6 +71,8 @@ type MobileWebViewHttpErrorEvent = { export default function ShellApp() { const webViewRef = useRef(null); + const nativeCanGoBackRef = useRef(false); + const h5CanGoBackRef = useRef(false); const [canGoBack, setCanGoBack] = useState(false); const baseWebUrl = resolveMobileShellBaseWebUrl( process.env.EXPO_PUBLIC_GENARRATIVE_WEB_URL, @@ -113,12 +116,35 @@ export default function ShellApp() { }, [injectHostBridgeEvent], ); + const syncNavigationCanGoBack = useCallback( + (source: 'native' | 'h5', nextCanGoBack: boolean) => { + if (source === 'native') { + nativeCanGoBackRef.current = nextCanGoBack; + } else { + h5CanGoBackRef.current = nextCanGoBack; + } + + const combinedCanGoBack = + nativeCanGoBackRef.current || h5CanGoBackRef.current; + setCanGoBack(combinedCanGoBack); + injectHostBridgeEvent('navigation.canGoBack', { + canGoBack: combinedCanGoBack, + }); + }, + [injectHostBridgeEvent], + ); + const resetNavigationCanGoBack = useCallback(() => { + nativeCanGoBackRef.current = false; + h5CanGoBackRef.current = false; + setCanGoBack(false); + }, []); useEffect(() => { configureMobileHostBridgeNavigation({ allowedOrigin: allowedWebOrigin, urlOptions, openWebViewUrl(url) { + resetNavigationCanGoBack(); setLoadFailure(null); setWebUrl(url); }, @@ -126,7 +152,7 @@ export default function ShellApp() { }); return () => configureMobileHostBridgeNavigation(null); - }, [allowedWebOrigin, reloadCurrentWebView, urlOptions]); + }, [allowedWebOrigin, reloadCurrentWebView, resetNavigationCanGoBack, urlOptions]); useEffect(() => { let disposed = false; @@ -137,6 +163,7 @@ export default function ShellApp() { baseWebUrl, urlOptions, ); + resetNavigationCanGoBack(); setLoadFailure(null); setWebUrl(nextUrl); }; @@ -155,7 +182,7 @@ export default function ShellApp() { disposed = true; subscription.remove(); }; - }, [baseWebUrl, urlOptions]); + }, [baseWebUrl, resetNavigationCanGoBack, urlOptions]); useEffect(() => { const subscription = BackHandler.addEventListener( @@ -165,7 +192,11 @@ export default function ShellApp() { return false; } - webViewRef.current?.goBack(); + if (h5CanGoBackRef.current) { + webViewRef.current?.injectJavaScript('window.history.back(); true;'); + } else { + webViewRef.current?.goBack(); + } return true; }, ); @@ -197,6 +228,14 @@ export default function ShellApp() { return; } + const historyState = parseMobileWebViewHistoryStateMessage( + event.nativeEvent.data, + ); + if (historyState) { + syncNavigationCanGoBack('h5', historyState.canGoBack); + return; + } + void handleMobileHostBridgeMessage(event.nativeEvent.data, (response) => { webViewRef.current?.injectJavaScript( buildHostBridgeMessageScript(response), @@ -239,6 +278,7 @@ export default function ShellApp() { .catch(() => undefined); }; const handleWebViewLoadError = (event: MobileWebViewLoadErrorEvent) => { + resetNavigationCanGoBack(); setLoadFailure( normalizeMobileShellLoadFailure( { @@ -253,6 +293,7 @@ export default function ShellApp() { ); }; const handleWebViewHttpError = (event: MobileWebViewHttpErrorEvent) => { + resetNavigationCanGoBack(); setLoadFailure( normalizeMobileShellLoadFailure( { @@ -293,7 +334,7 @@ export default function ShellApp() { thirdPartyCookiesEnabled={false} sharedCookiesEnabled={false} webviewDebuggingEnabled={false} - injectedJavaScriptBeforeContentLoaded={BLOCK_WEBVIEW_DOWNLOAD_SCRIPT} + injectedJavaScriptBeforeContentLoaded={MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT} onFileDownload={handleBlockedFileDownload} onMessage={handleMessage} onContentProcessDidTerminate={reloadCurrentWebView} @@ -303,17 +344,7 @@ export default function ShellApp() { onHttpError={handleWebViewHttpError} onShouldStartLoadWithRequest={handleShouldStartLoad} onNavigationStateChange={(event) => { - setCanGoBack(event.canGoBack); - webViewRef.current?.injectJavaScript( - buildHostBridgeMessageScript({ - bridge: 'GenarrativeHostBridge', - version: 1, - event: 'navigation.canGoBack', - payload: { - canGoBack: event.canGoBack, - }, - }), - ); + syncNavigationCanGoBack('native', event.canGoBack); }} setSupportMultipleWindows={false} /> diff --git a/apps/mobile-shell/src/shell/webViewGlobals.d.ts b/apps/mobile-shell/src/shell/webViewGlobals.d.ts new file mode 100644 index 000000000..3c1752c81 --- /dev/null +++ b/apps/mobile-shell/src/shell/webViewGlobals.d.ts @@ -0,0 +1,7 @@ +interface Window { + ReactNativeWebView?: { + postMessage?: (message: string) => void; + }; + __GENARRATIVE_MOBILE_HISTORY_TRACKER_INSTALLED__?: boolean; + __GENARRATIVE_MOBILE_POST_NAVIGATION_STATE__?: () => void; +} diff --git a/apps/mobile-shell/src/shell/webViewHistory.test.ts b/apps/mobile-shell/src/shell/webViewHistory.test.ts new file mode 100644 index 000000000..af60e2cac --- /dev/null +++ b/apps/mobile-shell/src/shell/webViewHistory.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'vitest'; + +import { parseMobileWebViewHistoryStateMessage } from './webViewHistory'; + +describe('parseMobileWebViewHistoryStateMessage', () => { + test('解析移动 WebView H5 路由栈状态消息', () => { + expect( + parseMobileWebViewHistoryStateMessage( + JSON.stringify({ + type: 'genarrative.mobile.historyState', + canGoBack: true, + }), + ), + ).toEqual({ + canGoBack: true, + }); + expect( + parseMobileWebViewHistoryStateMessage( + JSON.stringify({ + type: 'genarrative.mobile.historyState', + canGoBack: false, + }), + ), + ).toEqual({ + canGoBack: false, + }); + }); + + test('忽略非移动路由状态消息', () => { + expect(parseMobileWebViewHistoryStateMessage('not-json')).toBeNull(); + expect( + parseMobileWebViewHistoryStateMessage( + JSON.stringify({ + bridge: 'GenarrativeHostBridge', + version: 1, + method: 'host.getRuntime', + }), + ), + ).toBeNull(); + expect( + parseMobileWebViewHistoryStateMessage( + JSON.stringify({ + type: 'genarrative.mobile.historyState', + canGoBack: 'true', + }), + ), + ).toBeNull(); + }); +}); diff --git a/apps/mobile-shell/src/shell/webViewHistory.ts b/apps/mobile-shell/src/shell/webViewHistory.ts new file mode 100644 index 000000000..f2adb8686 --- /dev/null +++ b/apps/mobile-shell/src/shell/webViewHistory.ts @@ -0,0 +1,31 @@ +export type MobileWebViewHistoryStateMessage = { + canGoBack: boolean; +}; + +export function parseMobileWebViewHistoryStateMessage( + rawMessage: string, +): MobileWebViewHistoryStateMessage | null { + try { + const value = JSON.parse(rawMessage) as unknown; + if (!value || typeof value !== 'object') { + return null; + } + + const candidate = value as { + type?: unknown; + canGoBack?: unknown; + }; + if ( + candidate.type !== 'genarrative.mobile.historyState' || + typeof candidate.canGoBack !== 'boolean' + ) { + return null; + } + + return { + canGoBack: candidate.canGoBack, + }; + } catch { + return null; + } +} diff --git a/apps/mobile-shell/src/shell/webViewPolicy.test.ts b/apps/mobile-shell/src/shell/webViewPolicy.test.ts index bc695e61e..31367097e 100644 --- a/apps/mobile-shell/src/shell/webViewPolicy.test.ts +++ b/apps/mobile-shell/src/shell/webViewPolicy.test.ts @@ -4,8 +4,10 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { BLOCK_WEBVIEW_DOWNLOAD_SCRIPT, + MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT, shouldBlockMobileWebViewDownloadUrl, shouldBlockMobileWebViewNavigationRequest, + TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT, } from './webViewPolicy'; describe('BLOCK_WEBVIEW_DOWNLOAD_SCRIPT', () => { @@ -22,17 +24,19 @@ describe('BLOCK_WEBVIEW_DOWNLOAD_SCRIPT', () => { afterEach(() => { document.body.innerHTML = ''; window.open = originalOpen; - HTMLAnchorElement.prototype.click = originalAnchorClick; vi.restoreAllMocks(); + HTMLAnchorElement.prototype.click = originalAnchorClick; }); test('保留 WebView 注入脚本返回值', () => { expect(BLOCK_WEBVIEW_DOWNLOAD_SCRIPT.trim()).toMatch(/true;$/); + expect(TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT.trim()).toMatch(/true;$/); + expect(MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT.trim()).toMatch(/true;$/); }); test('阻断嵌套元素触发的下载链接点击', () => { document.body.innerHTML = ` - + 保存 `; @@ -49,23 +53,29 @@ describe('BLOCK_WEBVIEW_DOWNLOAD_SCRIPT', () => { expect(event.defaultPrevented).toBe(true); }); - test('放行普通同源页面链接点击', () => { + test('放行非下载锚点点击', () => { document.body.innerHTML = ` - + 打开 `; const target = document.getElementById('nested-page-target'); expect(target).toBeTruthy(); + let defaultPreventedBeforeTarget = true; + target?.addEventListener('click', (event) => { + defaultPreventedBeforeTarget = event.defaultPrevented; + event.preventDefault(); + }); const event = new MouseEvent('click', { bubbles: true, cancelable: true, }); const allowed = target?.dispatchEvent(event); - expect(allowed).toBe(true); - expect(event.defaultPrevented).toBe(false); + expect(allowed).toBe(false); + expect(defaultPreventedBeforeTarget).toBe(false); + expect(event.defaultPrevented).toBe(true); }); test('阻断危险下载协议的窗口打开', () => { @@ -81,7 +91,6 @@ describe('BLOCK_WEBVIEW_DOWNLOAD_SCRIPT', () => { ); window.eval(BLOCK_WEBVIEW_DOWNLOAD_SCRIPT); const anchor = document.createElement('a'); - anchor.href = 'data:text/plain;base64,SGVsbG8='; anchor.download = 'hello.txt'; anchor.click(); @@ -90,6 +99,114 @@ describe('BLOCK_WEBVIEW_DOWNLOAD_SCRIPT', () => { }); }); +describe('TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT', () => { + const originalPushState = window.history.pushState; + const originalReplaceState = window.history.replaceState; + + beforeEach(() => { + window.history.pushState = originalPushState; + window.history.replaceState = originalReplaceState; + delete window.__GENARRATIVE_MOBILE_HISTORY_TRACKER_INSTALLED__; + window.history.replaceState(null, '', '/'); + }); + + afterEach(() => { + delete window.ReactNativeWebView; + delete window.__GENARRATIVE_MOBILE_HISTORY_TRACKER_INSTALLED__; + window.history.pushState = originalPushState; + window.history.replaceState = originalReplaceState; + }); + + test('追踪 H5 当前文档路由栈并上报返回状态', () => { + const postMessage = vi.fn(); + window.ReactNativeWebView = { + postMessage, + }; + + window.eval(TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT); + const initialState = window.history.state; + window.history.pushState({ route: 'detail' }, '', '/works/detail'); + window.history.replaceState({ route: 'detail-updated' }, '', '/works/detail?tab=info'); + window.dispatchEvent(new PopStateEvent('popstate', { + state: initialState, + })); + + expect(postMessage).toHaveBeenNthCalledWith(1, + JSON.stringify({ + type: 'genarrative.mobile.historyState', + canGoBack: false, + }), + ); + expect(postMessage).toHaveBeenNthCalledWith(2, + JSON.stringify({ + type: 'genarrative.mobile.historyState', + canGoBack: true, + }), + ); + expect(postMessage).toHaveBeenNthCalledWith(3, + JSON.stringify({ + type: 'genarrative.mobile.historyState', + canGoBack: true, + }), + ); + expect(postMessage).toHaveBeenNthCalledWith(4, + JSON.stringify({ + type: 'genarrative.mobile.historyState', + canGoBack: false, + }), + ); + }); + + test('重复注入时回放当前 H5 路由栈状态', () => { + const postMessage = vi.fn(); + window.ReactNativeWebView = { + postMessage, + }; + + window.eval(TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT); + window.history.pushState({ route: 'detail' }, '', '/works/detail'); + window.eval(TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT); + + expect(postMessage).toHaveBeenNthCalledWith(1, + JSON.stringify({ + type: 'genarrative.mobile.historyState', + canGoBack: false, + }), + ); + expect(postMessage).toHaveBeenNthCalledWith(2, + JSON.stringify({ + type: 'genarrative.mobile.historyState', + canGoBack: true, + }), + ); + expect(postMessage).toHaveBeenNthCalledWith(3, + JSON.stringify({ + type: 'genarrative.mobile.historyState', + canGoBack: true, + }), + ); + }); + + test('组合注入脚本同时保留下载拦截和路由状态追踪', () => { + const postMessage = vi.fn(); + window.ReactNativeWebView = { + postMessage, + }; + + window.eval(MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT); + const opened = window.open('blob:https://app.genarrative.world/file-id'); + window.history.pushState(null, '', '/creation/puzzle'); + + expect(opened).toBeNull(); + expect(postMessage).toHaveBeenCalledWith( + JSON.stringify({ + type: 'genarrative.mobile.historyState', + canGoBack: true, + }), + ); + }); +}); + describe('shouldBlockMobileWebViewDownloadUrl', () => { test('识别不能进入移动壳 WebView 的下载协议', () => { expect( diff --git a/apps/mobile-shell/src/shell/webViewPolicy.ts b/apps/mobile-shell/src/shell/webViewPolicy.ts index f8f6ecf38..f070ebc4f 100644 --- a/apps/mobile-shell/src/shell/webViewPolicy.ts +++ b/apps/mobile-shell/src/shell/webViewPolicy.ts @@ -114,3 +114,105 @@ export const BLOCK_WEBVIEW_DOWNLOAD_SCRIPT = ` })(); true; `; + +export const TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT = ` +(function() { + if (window.__GENARRATIVE_MOBILE_HISTORY_TRACKER_INSTALLED__) { + if (typeof window.__GENARRATIVE_MOBILE_POST_NAVIGATION_STATE__ === 'function') { + window.__GENARRATIVE_MOBILE_POST_NAVIGATION_STATE__(); + } + return true; + } + + var historyIndexKey = '__genarrativeMobileHistoryIndex'; + var originalPushState = window.history.pushState.bind(window.history); + var originalReplaceState = window.history.replaceState.bind(window.history); + + function readIndex(state) { + if (!state || typeof state !== 'object') { + return null; + } + + var value = state[historyIndexKey]; + return Number.isInteger(value) && value >= 0 ? value : null; + } + + function stateWithIndex(state, index) { + if (state && typeof state === 'object' && !Array.isArray(state)) { + var nextState = {}; + Object.keys(state).forEach(function(key) { + nextState[key] = state[key]; + }); + nextState[historyIndexKey] = index; + return nextState; + } + + var indexedState = {}; + indexedState[historyIndexKey] = index; + return indexedState; + } + + var currentIndex = readIndex(window.history.state) || 0; + + function postNavigationState() { + var bridge = window.ReactNativeWebView; + if (!bridge || typeof bridge.postMessage !== 'function') { + return; + } + + bridge.postMessage(JSON.stringify({ + type: 'genarrative.mobile.historyState', + canGoBack: currentIndex > 0 + })); + } + + function replaceCurrentState() { + try { + originalReplaceState( + stateWithIndex(window.history.state, currentIndex), + '', + window.location.href + ); + } catch (error) {} + } + + window.history.pushState = function(state, title, url) { + var nextIndex = currentIndex + 1; + var result = originalPushState(stateWithIndex(state, nextIndex), title, url); + currentIndex = nextIndex; + postNavigationState(); + return result; + }; + + window.history.replaceState = function(state, title, url) { + var result = originalReplaceState( + stateWithIndex(state, currentIndex), + title, + url + ); + postNavigationState(); + return result; + }; + + window.__GENARRATIVE_MOBILE_POST_NAVIGATION_STATE__ = postNavigationState; + window.addEventListener('popstate', function(event) { + var nextIndex = readIndex(event.state); + currentIndex = nextIndex === null ? 0 : nextIndex; + if (nextIndex === null) { + replaceCurrentState(); + } + postNavigationState(); + }); + + window.__GENARRATIVE_MOBILE_HISTORY_TRACKER_INSTALLED__ = true; + replaceCurrentState(); + postNavigationState(); +})(); +true; +`; + +export const MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT = ` +${BLOCK_WEBVIEW_DOWNLOAD_SCRIPT} +${TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT} +true; +`; diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d47735768..17940f77e 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -38,6 +38,7 @@ - 2026-06-18 移动壳 WebView 状态重放:Expo WebView 每次同源主站页面成功加载后都会补发当前 `app.lifecycle` 与 `network.statusChanged` 状态,覆盖首载、受控刷新、H5 刷新和系统回收 WebView 进程后的新 JS 上下文;补发不新增 HostBridge capability,也不向 `about:blank`、外域或错误页注入宿主状态。 - 2026-06-18 桌面壳 WebView 状态重放:Tauri 主 WebView 每次页面加载完成后都会回放当前 `app.lifecycle` 并重新安装 `network.statusChanged` 监听脚本,覆盖托盘刷新、`app.reloadWebView` 和 H5 自刷新后的新 JS 上下文;桌面 runtime 同步声明 `host.events` 表示该真实事件通道可用,但仍不开放 Tauri event 插件或额外 command。 - 2026-06-18 桌面壳 H5 返回栈事件:Tauri 壳开始声明 `navigation.canGoBack`,但只通过固定注入脚本追踪当前 H5 文档内的 `pushState` / `replaceState` / `popstate` 路由栈并派发 HostBridge event;不把该能力实现为 request method,不开放 H5 到 Tauri 的 event 写入通道,也不声明跨文档 native back-forward list 真相。 +- 2026-06-18 移动壳 H5 返回栈事件:Expo 壳开始用固定 WebView 注入脚本追踪当前 H5 文档内的 `pushState` / `replaceState` / `popstate` 路由栈,并通过内部 `genarrative.mobile.historyState` 消息回传给壳层;壳层把该状态与 `react-native-webview` 原生 `canGoBack` 合成为 HostBridge `navigation.canGoBack` 事件。Android 返回键优先回退 H5 当前文档路由栈,H5 不可回退时才走 WebView 原生 `goBack()`;该内部消息不是 HostBridge request method,不开放通用 H5 -> 原生事件通道,外域 / 危险页面消息仍在进入 HostBridge 前丢弃。 - 2026-06-18 外部生成队列轮询接入宿主网络状态:H5 新增 `useHostNetworkOnline()`,宿主未声明网络能力时按在线处理以保持浏览器和旧壳行为;宿主明确 `isConnected=false` 或 `isInternetReachable=false` 时,平台外部生成队列概览暂停 HTTP 轮询,恢复在线后重新刷新。该能力只减少离线请求,不改变外部生成队列、作品架、弹窗或后端任务状态事实。 - 2026-06-18 桌面图片导入:新增 `file.importImage` 与 `file.imageDropped` HostBridge capability,Tauri 壳通过系统文件选择框和主窗口拖拽事件读取用户选择 / 拖入的真实图片,只允许 `image/png`、`image/jpeg`、`image/webp` 且单次不超过 10 MiB;H5 统一使用 `importHostImageFile()` / `subscribeHostImageDrop()`,宿主只回传文件名、MIME、base64 内容、字节数和可选坐标,不暴露本地绝对路径,也不开放通用文件系统。 - 2026-06-18 移动图片导入:Expo 壳开始声明并实现 `file.importImage`,通过 `expo-image-picker` 请求相册权限并打开系统相册选择器,只允许 `image/png`、`image/jpeg`、`image/webp` 且单次不超过 10 MiB;成功只回传清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI,用户取消返回 `cancelled` 并由 H5 facade 归为 `false`。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 6bba0aac3..e523a4b06 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -277,6 +277,8 @@ GameBridge 禁止: 当前状态:已新增 `apps/mobile-shell/`,通过 Expo development build 运行,`react-native-webview` 加载 H5 URL 并附加 `native_app` 宿主 query。移动壳使用真实品牌图标资产,已接入 `genarrative://` scheme、iOS associated domain 和 Android app link filter,启动和运行时 deep link 只会映射到同源 H5 路径并继续附加 HostBridge 上下文,外域和危险协议回退到默认主站入口。首轮真实能力包括 `host.getRuntime`、`appearance.getColorScheme`、`host.events`、`app.lifecycle`、`network.status`、`network.statusChanged`、`share.open`、`share.setTarget`、`navigation.openNativePage`、`navigation.canGoBack`、`app.reloadWebView`、`app.openExternalUrl`、`clipboard.writeText`、`clipboard.readText`、`file.exportText`、`file.exportImage`、`file.importImage`、`file.captureImage`、`file.importAudio`、`file.exportAudio`、`haptics.impact`、`notification.showLocal` 和 Android 返回键回退;其中 `appearance.getColorScheme` 只读系统配色偏好,不强改 H5 或系统主题;`app.lifecycle` 通过 React Native `AppState` 注入 `active` / `inactive` / `background` 统一状态,供 H5 游戏循环、音频和轮询做真实暂停 / 恢复判断,H5 的 `useHostLifecycleActive()` 会把该事件归一成运行态可播放状态,WebAudio 背景音乐和拼图、抓大鹅等固定玩法 `