diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 8123a418e..0bd896ce4 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -992,6 +992,8 @@ for (const snippet of [ 'ImagePicker.launchCameraAsync', 'ImagePicker.requestMediaLibraryPermissionsAsync', 'ImagePicker.requestCameraPermissionsAsync', + 'MOBILE_AUDIO_DOCUMENT_PICKER_TYPES', + "'audio/*'", 'File(asset.uri)', 'file.base64()', 'detectImageMimeType', diff --git a/apps/mobile-shell/src/host-bridge/bridge.test.ts b/apps/mobile-shell/src/host-bridge/bridge.test.ts index a3a90269b..68999210d 100644 --- a/apps/mobile-shell/src/host-bridge/bridge.test.ts +++ b/apps/mobile-shell/src/host-bridge/bridge.test.ts @@ -67,6 +67,9 @@ const WAV_BASE64 = Buffer.from('RIFF\x04\x00\x00\x00WAVE', 'binary').toString( const MP3_BASE64 = Buffer.from('ID3\x04\x00\x00\x00\x00\x00\x10', 'binary').toString( 'base64', ); +const MP4_AUDIO_BASE64 = encodeBytes([ + 0, 0, 0, 0x18, 0x66, 0x74, 0x79, 0x70, 0x4d, 0x34, 0x41, 0x20, +]); const WEBM_BASE64 = encodeBytes([0x1a, 0x45, 0xdf, 0xa3, 0x01, 0x00]); vi.mock('expo-clipboard', () => ({ @@ -1370,6 +1373,7 @@ describe('handleMobileHostBridgeMessage', () => { copyToCacheDirectory: true, multiple: false, type: [ + 'audio/*', 'audio/mpeg', 'audio/mp4', 'audio/wav', @@ -1379,6 +1383,52 @@ describe('handleMobileHostBridgeMessage', () => { }); }); + test('file.importAudio 允许系统 MIME 缺失时按扩展名和 bytes 校验导入', async () => { + fileBase64Data.set('file:///private/mobile/hit.m4a', MP3_BASE64); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/hit.m4a', + name: 'hit.m4a', + mimeType: 'application/octet-stream', + size: 10, + lastModified: 1, + }, + ], + }); + + const mismatched = await send(request('file.importAudio')); + + expect(expectFailed(mismatched).error.message).toBe( + 'audio bytes do not match MIME', + ); + + fileBase64Data.set('file:///private/mobile/hit.m4a', MP4_AUDIO_BASE64); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/hit.m4a', + name: 'hit.m4a', + mimeType: 'application/octet-stream', + size: 12, + lastModified: 1, + }, + ], + }); + + const imported = await send(request('file.importAudio')); + + expect(expectOk(imported).result).toEqual({ + action: 'selected', + fileName: 'hit.m4a', + base64Data: MP4_AUDIO_BASE64, + mimeType: 'audio/mp4', + bytes: 12, + }); + }); + test('file.importAudio 取消选择并拒绝非法 MIME 与超限音频', async () => { vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ canceled: true, diff --git a/apps/mobile-shell/src/host-bridge/files.ts b/apps/mobile-shell/src/host-bridge/files.ts index 2a9bd304b..ae65ff5f6 100644 --- a/apps/mobile-shell/src/host-bridge/files.ts +++ b/apps/mobile-shell/src/host-bridge/files.ts @@ -45,6 +45,14 @@ const HOST_BRIDGE_AUDIO_MIME_TYPES = new Set([ 'audio/ogg', 'audio/webm', ]); +export const MOBILE_AUDIO_DOCUMENT_PICKER_TYPES = [ + 'audio/*', + 'audio/mpeg', + 'audio/mp4', + 'audio/wav', + 'audio/ogg', + 'audio/webm', +]; const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; @@ -617,13 +625,7 @@ export async function importAudioFile(): Promise { const result = await DocumentPicker.getDocumentAsync({ copyToCacheDirectory: true, multiple: false, - type: [ - 'audio/mpeg', - 'audio/mp4', - 'audio/wav', - 'audio/ogg', - 'audio/webm', - ], + type: MOBILE_AUDIO_DOCUMENT_PICKER_TYPES, }); if (result.canceled) { throw { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index b3971cf8a..f659eac55 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2350,7 +2350,7 @@ ## 2026-06-18 原生壳音频文件导入只返回受控内容副本 - 背景:木鱼等固定玩法的音频上传面板需要在 Expo 移动壳和 Tauri 桌面壳内走真实系统选择器;如果直接暴露设备 URI、本机路径或通用文件系统能力,会把一次用户选择扩大成长期本地文件权限。 -- 决策:新增 HostBridge method `file.importAudio`、H5 facade `importHostAudioFile()` 和通用音频输入面板接入。移动端通过 Expo DocumentPicker,桌面端通过 Tauri 系统文件选择框;两端只接受 `audio/mpeg`、`audio/mp4`、`audio/wav`、`audio/ogg`、`audio/webm` 或对应扩展名,单次不超过 20 MiB。宿主成功时只返回清洗后的文件名、MIME、base64 内容和字节数,不返回设备 URI 或本机绝对路径,也不开放通用文件系统。H5 将宿主结果转换成现有浏览器 `File`,继续复用 `readFileAsAsset(file, 'uploaded')` 音频处理链路。 +- 决策:新增 HostBridge method `file.importAudio`、H5 facade `importHostAudioFile()` 和通用音频输入面板接入。移动端通过 Expo DocumentPicker,picker 展示范围包含 `audio/*` 和当前允许的精确音频 MIME;桌面端通过 Tauri 系统文件选择框。HostBridge 返回 H5 前,两端仍只接受 `audio/mpeg`、`audio/mp4`、`audio/wav`、`audio/ogg`、`audio/webm` 或对应扩展名,并要求音频 bytes 与归一后的 MIME 匹配,单次不超过 20 MiB。宿主成功时只返回清洗后的文件名、MIME、base64 内容和字节数,不返回设备 URI 或本机绝对路径,也不开放通用文件系统。H5 将宿主结果转换成现有浏览器 `File`,继续复用 `readFileAsAsset(file, 'uploaded')` 音频处理链路。 - 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/hostBridge.ts`、`src/components/common/CreativeAudioInputPanel.tsx`、`apps/mobile-shell/`、`apps/desktop-shell/`、原生壳能力检查脚本和 HostBridge 架构文档。 - 验证方式:`npm run check:native-shells`、`npm run test -- src/components/common/CreativeAudioInputPanel.test.tsx`、针对变更文件执行 ESLint、`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 85b3e43b3..25828838d 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -287,6 +287,8 @@ GameBridge 禁止: 2026-06-18 追加:移动壳图片和音频导入 / 导出不再只信 Expo picker 返回的 MIME 或 H5 声明的 MIME。Expo 壳会对 PNG / JPEG / WebP、MP3 / MP4-M4A / WAV / OGG / WebM 做 base64 bytes 头部识别,导入时要求系统 picker 的 MIME 与 bytes 匹配,导出时要求 H5 payload 的 `mimeType` 与 `base64Data` 解码后的 bytes 匹配;不匹配统一返回 `invalid_request`,不会写入缓存文件、调起系统分享或把内容回传给 H5。移动图片导出还会按 MIME 补齐 `.png` / `.jpg` / `.webp` 文件名扩展,避免系统分享 / 保存面板拿到无扩展或错扩展的缓存图片。 +2026-06-18 追加:移动壳音频导入的 DocumentPicker `type` 参数包含 `audio/*`,用于兼容 iOS / Android 文件提供方只按通用音频类型暴露候选文件的情况;这不放宽 HostBridge 返回契约。系统返回 `application/octet-stream`、空 MIME 或其它宽泛 MIME 时,移动壳只会按 `.mp3` / `.m4a` / `.mp4` / `.wav` / `.ogg` / `.webm` 扩展名归一到允许 MIME,并继续执行 base64 bytes 匹配,扩展名或 bytes 任一不匹配都返回 `invalid_request`。 + 2026-06-18 追加:H5 个人中心的邀请码填写和兑换码弹窗开始消费 `clipboard.readText`。Expo 壳仍只通过 `expo-clipboard` 返回纯文本,H5 只把文本填入现有输入框,不自动提交,也不把剪贴板内容交给宿主侧业务处理;普通浏览器、小程序和未声明该能力的裁剪壳不显示粘贴动作。 2026-06-18 追加:H5 的草稿生成完成 / 失败收口开始消费 `notification.showLocal`。Expo 壳仍只发送即时本地通知,不注册远程推送 token、不做定时提醒;H5 按草稿来源对完成和失败通知去重,同一草稿重新进入生成中后才允许再次通知,通知失败不阻断弹窗、作品架和后端状态回读。