移动壳补齐 H5 返回栈追踪
为 Expo WebView 注入当前文档 history 状态追踪脚本 让移动壳合成 H5 路由栈和 WebView 原生返回状态 让 Android 返回键优先回退 H5 当前文档路由 补充移动壳解析测试、注入脚本测试和配置门禁 更新原生壳方案文档和共享决策
This commit is contained in:
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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<WebView>(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}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
interface Window {
|
||||
ReactNativeWebView?: {
|
||||
postMessage?: (message: string) => void;
|
||||
};
|
||||
__GENARRATIVE_MOBILE_HISTORY_TRACKER_INSTALLED__?: boolean;
|
||||
__GENARRATIVE_MOBILE_POST_NAVIGATION_STATE__?: () => void;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 = `
|
||||
<a href="/asset.png" download target="_blank">
|
||||
<a download>
|
||||
<span id="nested-download-target">保存</span>
|
||||
</a>
|
||||
`;
|
||||
@@ -49,23 +53,29 @@ describe('BLOCK_WEBVIEW_DOWNLOAD_SCRIPT', () => {
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
test('放行普通同源页面链接点击', () => {
|
||||
test('放行非下载锚点点击', () => {
|
||||
document.body.innerHTML = `
|
||||
<a href="#section">
|
||||
<a>
|
||||
<span id="nested-page-target">打开</span>
|
||||
</a>
|
||||
`;
|
||||
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(
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
@@ -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`。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -60,6 +60,9 @@ const expectedMobileShellFiles = [
|
||||
'safeArea.ts',
|
||||
'url.test.ts',
|
||||
'url.ts',
|
||||
'webViewGlobals.d.ts',
|
||||
'webViewHistory.test.ts',
|
||||
'webViewHistory.ts',
|
||||
'webViewPolicy.test.ts',
|
||||
'webViewPolicy.ts',
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user