移动壳放宽音频选择器候选

移动端音频导入的 DocumentPicker 候选加入 audio/*

补充泛 MIME 仍按扩展名和 bytes 校验的移动壳测试

移动壳配置门禁锁定音频选择器类型和 bytes 校验

同步宿主壳方案文档和共享决策日志
This commit is contained in:
2026-06-18 23:12:28 +08:00
parent 6c17b4917f
commit dbcfa51191
5 changed files with 64 additions and 8 deletions
@@ -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',
@@ -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,
+9 -7
View File
@@ -45,6 +45,14 @@ const HOST_BRIDGE_AUDIO_MIME_TYPES = new Set<HostBridgeAudioMimeType>([
'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<FileImportAudioResult> {
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 {
@@ -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 DocumentPickerpicker 展示范围包含 `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`
@@ -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 按草稿来源对完成和失败通知去重,同一草稿重新进入生成中后才允许再次通知,通知失败不阻断弹窗、作品架和后端状态回读。