diff --git a/apps/mobile-shell/App.tsx b/apps/mobile-shell/App.tsx
index 30cd3c811..e6418ef61 100644
--- a/apps/mobile-shell/App.tsx
+++ b/apps/mobile-shell/App.tsx
@@ -31,6 +31,7 @@ import {
buildMobileShellUrl,
resolveMobileShellBaseWebUrl,
} from './src/mobileShellUrl';
+import { BLOCK_WEBVIEW_DOWNLOAD_SCRIPT } from './src/mobileShellWebViewPolicy';
function buildHostBridgeMessageScript(message: unknown) {
return `window.dispatchEvent(new MessageEvent('message', { data: ${JSON.stringify(
@@ -199,6 +200,8 @@ export default function App() {
thirdPartyCookiesEnabled={false}
sharedCookiesEnabled={false}
webviewDebuggingEnabled={false}
+ injectedJavaScriptBeforeContentLoaded={BLOCK_WEBVIEW_DOWNLOAD_SCRIPT}
+ onFileDownload={() => undefined}
onMessage={handleMessage}
onShouldStartLoadWithRequest={handleShouldStartLoad}
onNavigationStateChange={(event) => {
diff --git a/apps/mobile-shell/app.json b/apps/mobile-shell/app.json
index 6a96b0c94..005394c7d 100644
--- a/apps/mobile-shell/app.json
+++ b/apps/mobile-shell/app.json
@@ -55,10 +55,14 @@
"allowBackup": false,
"softwareKeyboardLayoutMode": "resize",
"blockedPermissions": [
+ "android.permission.MANAGE_EXTERNAL_STORAGE",
+ "android.permission.READ_EXTERNAL_STORAGE",
"android.permission.RECORD_AUDIO",
"android.permission.RECEIVE_BOOT_COMPLETED",
+ "android.permission.REQUEST_INSTALL_PACKAGES",
"android.permission.SCHEDULE_EXACT_ALARM",
- "android.permission.USE_EXACT_ALARM"
+ "android.permission.USE_EXACT_ALARM",
+ "android.permission.WRITE_EXTERNAL_STORAGE"
],
"adaptiveIcon": {
"foregroundImage": "./assets/icon.png",
diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs
index 8f585ad9b..f99f22922 100644
--- a/apps/mobile-shell/scripts/check-config.mjs
+++ b/apps/mobile-shell/scripts/check-config.mjs
@@ -72,10 +72,14 @@ const blockedMobileChannelSnippets = [
'posthog.init',
];
const blockedAndroidPermissions = [
+ 'android.permission.MANAGE_EXTERNAL_STORAGE',
+ 'android.permission.READ_EXTERNAL_STORAGE',
'android.permission.RECORD_AUDIO',
'android.permission.RECEIVE_BOOT_COMPLETED',
+ 'android.permission.REQUEST_INSTALL_PACKAGES',
'android.permission.SCHEDULE_EXACT_ALARM',
'android.permission.USE_EXACT_ALARM',
+ 'android.permission.WRITE_EXTERNAL_STORAGE',
];
function extractStringArrayExport(source, exportName) {
@@ -425,6 +429,7 @@ for (const snippet of [
'subscribeMobileNetworkStatus',
'navigation.canGoBack',
'buildHostBridgeMessageScript',
+ 'BLOCK_WEBVIEW_DOWNLOAD_SCRIPT',
'SafeAreaProvider',
'SafeAreaView',
'MOBILE_SHELL_SAFE_AREA_EDGES',
@@ -440,6 +445,8 @@ for (const snippet of [
'thirdPartyCookiesEnabled={false}',
'sharedCookiesEnabled={false}',
'webviewDebuggingEnabled={false}',
+ 'injectedJavaScriptBeforeContentLoaded={BLOCK_WEBVIEW_DOWNLOAD_SCRIPT}',
+ 'onFileDownload={() => undefined}',
'setSupportMultipleWindows={false}',
]) {
if (!appSource.includes(snippet)) {
diff --git a/apps/mobile-shell/scripts/check-expo-config.mjs b/apps/mobile-shell/scripts/check-expo-config.mjs
index 692f26335..22ae2a2f0 100644
--- a/apps/mobile-shell/scripts/check-expo-config.mjs
+++ b/apps/mobile-shell/scripts/check-expo-config.mjs
@@ -135,11 +135,31 @@ assertEqual(
'resize',
'Android software keyboard layout mode',
);
+assertIncludes(
+ expoConfig.android?.blockedPermissions,
+ 'android.permission.MANAGE_EXTERNAL_STORAGE',
+ 'Android external download blocked permissions',
+);
+assertIncludes(
+ expoConfig.android?.blockedPermissions,
+ 'android.permission.READ_EXTERNAL_STORAGE',
+ 'Android external download blocked permissions',
+);
assertIncludes(
expoConfig.android?.blockedPermissions,
'android.permission.RECORD_AUDIO',
'Android blocked permissions',
);
+assertIncludes(
+ expoConfig.android?.blockedPermissions,
+ 'android.permission.REQUEST_INSTALL_PACKAGES',
+ 'Android external install blocked permissions',
+);
+assertIncludes(
+ expoConfig.android?.blockedPermissions,
+ 'android.permission.WRITE_EXTERNAL_STORAGE',
+ 'Android external download blocked permissions',
+);
for (const permission of [
'android.permission.RECEIVE_BOOT_COMPLETED',
'android.permission.SCHEDULE_EXACT_ALARM',
diff --git a/apps/mobile-shell/src/mobileShellWebViewPolicy.test.ts b/apps/mobile-shell/src/mobileShellWebViewPolicy.test.ts
new file mode 100644
index 000000000..ec86e29c7
--- /dev/null
+++ b/apps/mobile-shell/src/mobileShellWebViewPolicy.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, test } from 'vitest';
+
+import { BLOCK_WEBVIEW_DOWNLOAD_SCRIPT } from './mobileShellWebViewPolicy';
+
+describe('BLOCK_WEBVIEW_DOWNLOAD_SCRIPT', () => {
+ test('阻断 WebView 内的网页下载链接', () => {
+ expect(BLOCK_WEBVIEW_DOWNLOAD_SCRIPT).toContain(
+ "element.tagName === 'A'",
+ );
+ expect(BLOCK_WEBVIEW_DOWNLOAD_SCRIPT).toContain(
+ "element.hasAttribute('download')",
+ );
+ expect(BLOCK_WEBVIEW_DOWNLOAD_SCRIPT).toContain('event.preventDefault()');
+ expect(BLOCK_WEBVIEW_DOWNLOAD_SCRIPT.trim()).toMatch(/true;$/);
+ });
+});
diff --git a/apps/mobile-shell/src/mobileShellWebViewPolicy.ts b/apps/mobile-shell/src/mobileShellWebViewPolicy.ts
new file mode 100644
index 000000000..de76082b5
--- /dev/null
+++ b/apps/mobile-shell/src/mobileShellWebViewPolicy.ts
@@ -0,0 +1,13 @@
+export const BLOCK_WEBVIEW_DOWNLOAD_SCRIPT = `
+document.addEventListener('click', function(event) {
+ var element = event.target;
+ while (element && element !== document) {
+ if (element.tagName === 'A' && element.hasAttribute('download')) {
+ event.preventDefault();
+ return false;
+ }
+ element = element.parentNode;
+ }
+}, true);
+true;
+`;
diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
index 873aac03b..2b17ce5d3 100644
--- a/docs/project-memory/shared-memory/decision-log.md
+++ b/docs/project-memory/shared-memory/decision-log.md
@@ -75,6 +75,7 @@
- 2026-06-18 移动壳麦克风权限禁用:Expo 移动壳的 `expo-image-picker` 插件必须保持 `microphonePermission=false`,Android 包配置必须通过 `android.blockedPermissions` 显式移除 `android.permission.RECORD_AUDIO`,且不得在 `android.permissions` 中重新声明;移动壳现阶段没有录音、后台音频采集或远程语音 SDK,音频导入只走系统文档选择器。配置检查会拒绝麦克风权限拦截缺失或被反向加入。
- 2026-06-18 移动壳 Android 自动备份关闭:Expo 移动壳必须保持 `android.allowBackup=false`,避免 WebView cookie、localStorage、缓存文件和宿主文件导入导出中间态进入 Google Drive 自动备份 / 恢复链路;正式业务事实仍以后端账号、作品、钱包和草稿状态为准。配置检查会拒绝恢复 Android 默认允许备份的包配置。
- 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 导航留壳规则共用同源判断,配置检查和移动壳导航测试会拒绝移除。
- 影响范围:`src/services/host-bridge/`、未来 `apps/mobile-shell/`、未来 `apps/desktop-shell/`、移动端支付 / 分享 / 深链 / 推送、桌面端系统能力、AI H5 sandbox 的 GameBridge 边界。
- 验证方式:普通浏览器、小程序、Expo 壳、Tauri 壳都能返回正确 `getHostRuntime()`;未支持能力能回退 H5;固定玩法在各宿主中读取同一作品数据和运行态 snapshot;AI sandbox 无法直接调用 HostBridge;Tauri release 不允许任意远端页面调用桌面命令。
diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md
index b8b4579e8..3290143e3 100644
--- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md
+++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md
@@ -240,6 +240,7 @@ GameBridge 禁止:
- Tauri 主 WebView 禁止默认下载落盘;桌面文件保存只能通过受控 HostBridge 导出能力进入系统保存对话框。
- Tauri 主 WebView 禁止默认打开 DevTools;不得通过配置或 Cargo feature 为分发壳启用浏览器检查器。
- RN WebView 禁止打开任意 URL 后仍保留完整 HostBridge;跳外链只允许 `http:`、`https:`、`mailto:`、`tel:`,并使用系统浏览器或降级能力,危险协议直接阻断。
+- RN WebView 禁止网页自动下载和 `` 直接落盘;移动端文件保存只能通过 `file.exportText`、`file.exportImage`、`file.exportAudio` 等受控 HostBridge method 进入系统分享 / 保存面板。
- Expo 移动壳的通知能力只覆盖即时本地通知;Android 包配置必须阻断重启后通知恢复和精确定时权限,前端代码不得注册 Expo push token、远程推送监听或通知响应跳转流程。
- AI sandbox iframe 必须使用独立 CSP、`sandbox` 属性和单独 GameBridge allowlist。
@@ -361,6 +362,8 @@ GameBridge 禁止:
2026-06-18 追加:移动壳 WebView 原生安全开关显式收紧。`react-native-webview` 只加载同源主站入口,保留 JS 和 DOM storage 以运行现有 H5,但禁用 JS 自动开窗、多窗口、文件访问、file URL 跨源访问、HTTPS 页面加载 HTTP 混合内容、第三方 Cookie、共享 Cookie 和 WebView 远程调试;外链继续只允许 `http:`、`https:`、`mailto:`、`tel:` 离开 WebView 交给系统。`apps/mobile-shell/scripts/check-config.mjs` 和 `mobileShellNavigation.test.ts` 会覆盖这些壳边界,避免后续为单个页面调试把完整 HostBridge 暴露给外域页面。
+2026-06-18 追加:移动壳 WebView 默认下载路径显式关闭。壳层在 WebView 注入脚本中阻断 `` 点击,iOS `onFileDownload` 事件只丢弃不落盘,Android 包配置阻断外部存储读写、管理外部存储和请求安装包权限;H5 文本、图片、音频保存继续只能走 `file.exportText`、`file.exportImage`、`file.exportAudio` 的受控 HostBridge 导出能力。
+
2026-06-18 追加:移动壳 HostBridge 消息入口增加来源校验。`onMessage` 不只依赖导航拦截和 `originWhitelist`,还会读取 `event.nativeEvent.url`,只有同源主站页面才能进入 `handleMobileHostBridgeMessage`;`about:blank`、外域 URL、协议降级或危险协议页面发来的消息全部丢弃,不返回 HostBridge 错误细节。该校验与 `navigation.openNativePage` 共用同源规则,防止历史中间页或异常页面在带完整 HostBridge 的 WebView 中发起宿主能力请求。
### Phase 4:宿主能力扩展