移动壳补齐 H5 返回栈追踪

为 Expo WebView 注入当前文档 history 状态追踪脚本

让移动壳合成 H5 路由栈和 WebView 原生返回状态

让 Android 返回键优先回退 H5 当前文档路由

补充移动壳解析测试、注入脚本测试和配置门禁

更新原生壳方案文档和共享决策
This commit is contained in:
2026-06-18 22:20:58 +08:00
parent 13f1738d9c
commit 0ad56a878f
10 changed files with 398 additions and 25 deletions
+32 -2
View File
@@ -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');
}
+47 -16
View File
@@ -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}
/>
+7
View File
@@ -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
+3
View File
@@ -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',
];