移动壳放宽音频选择器候选
移动端音频导入的 DocumentPicker 候选加入 audio/* 补充泛 MIME 仍按扩展名和 bytes 校验的移动壳测试 移动壳配置门禁锁定音频选择器类型和 bytes 校验 同步宿主壳方案文档和共享决策日志
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 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`。
|
||||
|
||||
|
||||
@@ -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 按草稿来源对完成和失败通知去重,同一草稿重新进入生成中后才允许再次通知,通知失败不阻断弹窗、作品架和后端状态回读。
|
||||
|
||||
Reference in New Issue
Block a user