From 3deb901179af6e965704775e24d0938126668edf Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 18 Jun 2026 20:54:40 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A7=BB=E5=8A=A8=E5=A3=B3=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E7=9C=9F=E5=AE=9E=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为移动图片和音频导入导出增加 bytes 头部校验 补充移动 HostBridge 文件能力测试与配置门禁 更新宿主壳文件能力文档 --- apps/mobile-shell/scripts/check-config.mjs | 14 ++ .../src/host-bridge/bridge.test.ts | 131 +++++++++++++---- apps/mobile-shell/src/host-bridge/files.ts | 132 +++++++++++++++++- .../shared-memory/decision-log.md | 1 + ...€‘ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 2 + 5 files changed, 254 insertions(+), 26 deletions(-) diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index c5032600a..b13b3aa55 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -922,6 +922,14 @@ for (const forbiddenNotificationSnippet of [ } } +for (const forbiddenHostBridgeRuntimeSnippet of ['atob(', 'Buffer.from']) { + if (hostBridgeSource.includes(forbiddenHostBridgeRuntimeSnippet)) { + throw new Error( + `mobile shell HostBridge production code must not rely on ${forbiddenHostBridgeRuntimeSnippet}`, + ); + } +} + if ( !/trigger:\s*Platform\.OS === 'android'\s*\?\s*\{\s*channelId: LOCAL_NOTIFICATION_CHANNEL_ID\s*\}\s*:\s*null/.test( dispatchSource, @@ -956,6 +964,12 @@ for (const snippet of [ 'ImagePicker.requestCameraPermissionsAsync', 'File(asset.uri)', 'file.base64()', + 'detectImageMimeType', + 'detectAudioMimeType', + 'ensureImageBytesMatchMimeType', + 'ensureAudioBytesMatchMimeType', + "'image bytes do not match MIME'", + "'audio bytes do not match MIME'", 'normalizeHostBridgeExportFileName', 'normalizeHostBridgeClipboardText', 'normalizeHostBridgeHapticsImpactStyle', diff --git a/apps/mobile-shell/src/host-bridge/bridge.test.ts b/apps/mobile-shell/src/host-bridge/bridge.test.ts index 1b03ffdde..7daba6703 100644 --- a/apps/mobile-shell/src/host-bridge/bridge.test.ts +++ b/apps/mobile-shell/src/host-bridge/bridge.test.ts @@ -53,6 +53,22 @@ const TEST_MOBILE_URL_OPTIONS: MobileShellUrlOptions = { capabilities: ['host.getRuntime', 'share.open'], }; +function encodeBytes(bytes: readonly number[]) { + return Buffer.from(bytes).toString('base64'); +} + +const PNG_BASE64 = encodeBytes([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0, +]); +const JPEG_BASE64 = encodeBytes([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0]); +const WAV_BASE64 = Buffer.from('RIFF\x04\x00\x00\x00WAVE', 'binary').toString( + 'base64', +); +const MP3_BASE64 = Buffer.from('ID3\x04\x00\x00\x00\x00\x00\x10', 'binary').toString( + 'base64', +); +const WEBM_BASE64 = encodeBytes([0x1a, 0x45, 0xdf, 0xa3, 0x01, 0x00]); + vi.mock('expo-clipboard', () => ({ getStringAsync: vi.fn(), setStringAsync: vi.fn(), @@ -928,7 +944,7 @@ describe('handleMobileHostBridgeMessage', () => { const response = await send( request('file.exportImage', { fileName: ' ../分享:卡?.png ', - base64Data: 'c2hhcmUtY2FyZA==', + base64Data: PNG_BASE64, mimeType: 'image/png', }), ); @@ -938,12 +954,12 @@ describe('handleMobileHostBridgeMessage', () => { expect(okResponse.result).toEqual({ action: 'saved', fileName: '分享-卡-.png', - bytes: 10, + bytes: 12, }); expect(writtenFiles).toEqual([ { uri: 'file:///cache/分享-卡-.png', - content: 'c2hhcmUtY2FyZA==', + content: PNG_BASE64, options: { encoding: 'base64' }, }, ]); @@ -961,7 +977,7 @@ describe('handleMobileHostBridgeMessage', () => { const unsupportedMime = await send( request('file.exportImage', { fileName: '分享卡.txt', - base64Data: 'c2hhcmUtY2FyZA==', + base64Data: PNG_BASE64, mimeType: 'text/plain', }), ); @@ -977,6 +993,18 @@ describe('handleMobileHostBridgeMessage', () => { ); expect(expectFailed(oversized).error.code).toBe('invalid_request'); + + const mismatched = await send( + request('file.exportImage', { + fileName: '分享卡.png', + base64Data: JPEG_BASE64, + mimeType: 'image/png', + }), + ); + + expect(expectFailed(mismatched).error.message).toBe( + 'image bytes do not match MIME', + ); expect(writtenFiles).toEqual([]); expect(Sharing.shareAsync).not.toHaveBeenCalled(); }); @@ -985,7 +1013,7 @@ describe('handleMobileHostBridgeMessage', () => { const response = await send( request('file.exportAudio', { fileName: ' ../敲击:音效?.wav ', - base64Data: 'YXVkaW8=', + base64Data: WAV_BASE64, mimeType: 'audio/wav', }), ); @@ -995,12 +1023,12 @@ describe('handleMobileHostBridgeMessage', () => { expect(okResponse.result).toEqual({ action: 'saved', fileName: '敲击-音效-.wav', - bytes: 5, + bytes: 12, }); expect(writtenFiles).toEqual([ { uri: 'file:///cache/敲击-音效-.wav', - content: 'YXVkaW8=', + content: WAV_BASE64, options: { encoding: 'base64' }, }, ]); @@ -1020,7 +1048,7 @@ describe('handleMobileHostBridgeMessage', () => { const response = await send( request('file.exportAudio', { fileName: 'hit.wav', - base64Data: 'YXVkaW8=', + base64Data: WAV_BASE64, mimeType: 'audio/wav', }), ); @@ -1036,7 +1064,7 @@ describe('handleMobileHostBridgeMessage', () => { const unsupportedMime = await send( request('file.exportAudio', { fileName: 'hit.txt', - base64Data: 'YXVkaW8=', + base64Data: WAV_BASE64, mimeType: 'text/plain', }), ); @@ -1059,6 +1087,17 @@ describe('handleMobileHostBridgeMessage', () => { }), ); expect(expectFailed(oversized).error.code).toBe('invalid_request'); + + const mismatched = await send( + request('file.exportAudio', { + fileName: 'hit.wav', + base64Data: MP3_BASE64, + mimeType: 'audio/wav', + }), + ); + expect(expectFailed(mismatched).error.message).toBe( + 'audio bytes do not match MIME', + ); expect(writtenFiles).toEqual([]); expect(Sharing.shareAsync).not.toHaveBeenCalled(); }); @@ -1073,8 +1112,8 @@ describe('handleMobileHostBridgeMessage', () => { height: 80, type: 'image', fileName: ' ../参考:图?.png ', - fileSize: 5, - base64: 'aW1hZ2U=', + fileSize: 12, + base64: PNG_BASE64, mimeType: 'image/png', }, ], @@ -1085,9 +1124,9 @@ describe('handleMobileHostBridgeMessage', () => { expect(expectOk(response).result).toEqual({ action: 'selected', fileName: '参考-图-.png', - base64Data: 'aW1hZ2U=', + base64Data: PNG_BASE64, mimeType: 'image/png', - bytes: 5, + bytes: 12, }); expect(ImagePicker.requestMediaLibraryPermissionsAsync).toHaveBeenCalled(); expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledWith({ @@ -1145,7 +1184,7 @@ describe('handleMobileHostBridgeMessage', () => { type: 'image', fileName: '参考图.gif', fileSize: 5, - base64: 'aW1hZ2U=', + base64: PNG_BASE64, mimeType: 'image/gif', }, ], @@ -1165,7 +1204,7 @@ describe('handleMobileHostBridgeMessage', () => { type: 'image', fileName: '参考图.png', fileSize: 10 * 1024 * 1024 + 1, - base64: 'aW1hZ2U=', + base64: PNG_BASE64, mimeType: 'image/png', }, ], @@ -1174,6 +1213,28 @@ describe('handleMobileHostBridgeMessage', () => { const oversized = await send(request('file.importImage')); expect(expectFailed(oversized).error.code).toBe('invalid_request'); + + vi.mocked(ImagePicker.launchImageLibraryAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/参考图.png', + width: 120, + height: 80, + type: 'image', + fileName: '参考图.png', + fileSize: 7, + base64: JPEG_BASE64, + mimeType: 'image/png', + }, + ], + }); + + const mismatched = await send(request('file.importImage')); + + expect(expectFailed(mismatched).error.message).toBe( + 'image bytes do not match MIME', + ); }); test('file.captureImage 调起系统相机并返回受控图片数据', async () => { @@ -1186,8 +1247,8 @@ describe('handleMobileHostBridgeMessage', () => { height: 80, type: 'image', fileName: null, - fileSize: 6, - base64: 'Y2FtZXJh', + fileSize: 7, + base64: JPEG_BASE64, mimeType: 'image/jpeg', }, ], @@ -1198,9 +1259,9 @@ describe('handleMobileHostBridgeMessage', () => { expect(expectOk(response).result).toEqual({ action: 'captured', fileName: 'genarrative-import.jpg', - base64Data: 'Y2FtZXJh', + base64Data: JPEG_BASE64, mimeType: 'image/jpeg', - bytes: 6, + bytes: 7, }); expect(ImagePicker.requestCameraPermissionsAsync).toHaveBeenCalled(); expect(ImagePicker.launchCameraAsync).toHaveBeenCalledWith({ @@ -1242,7 +1303,7 @@ describe('handleMobileHostBridgeMessage', () => { }); test('file.importAudio 调起系统文档选择器并返回受控音频数据', async () => { - fileBase64Data.set('file:///private/mobile/hit.webm', 'YXVkaW8='); + fileBase64Data.set('file:///private/mobile/hit.webm', WEBM_BASE64); vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ canceled: false, assets: [ @@ -1250,7 +1311,7 @@ describe('handleMobileHostBridgeMessage', () => { uri: 'file:///private/mobile/hit.webm', name: ' ../敲击:音效?.webm ', mimeType: 'audio/webm', - size: 5, + size: 6, lastModified: 1, }, ], @@ -1261,9 +1322,9 @@ describe('handleMobileHostBridgeMessage', () => { expect(expectOk(response).result).toEqual({ action: 'selected', fileName: '敲击-音效-.webm', - base64Data: 'YXVkaW8=', + base64Data: WEBM_BASE64, mimeType: 'audio/webm', - bytes: 5, + bytes: 6, }); expect(DocumentPicker.getDocumentAsync).toHaveBeenCalledWith({ copyToCacheDirectory: true, @@ -1288,7 +1349,7 @@ describe('handleMobileHostBridgeMessage', () => { expect(expectFailed(cancelled).error.code).toBe('cancelled'); - fileBase64Data.set('file:///private/mobile/hit.txt', 'YXVkaW8='); + fileBase64Data.set('file:///private/mobile/hit.txt', WEBM_BASE64); vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ canceled: false, assets: [ @@ -1306,7 +1367,7 @@ describe('handleMobileHostBridgeMessage', () => { expect(expectFailed(unsupportedMime).error.code).toBe('invalid_request'); - fileBase64Data.set('file:///private/mobile/hit.webm', 'YXVkaW8='); + fileBase64Data.set('file:///private/mobile/hit.webm', WEBM_BASE64); vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ canceled: false, assets: [ @@ -1323,5 +1384,25 @@ describe('handleMobileHostBridgeMessage', () => { const oversized = await send(request('file.importAudio')); expect(expectFailed(oversized).error.code).toBe('invalid_request'); + + fileBase64Data.set('file:///private/mobile/hit.webm', MP3_BASE64); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/hit.webm', + name: 'hit.webm', + mimeType: 'audio/webm', + size: 10, + lastModified: 1, + }, + ], + }); + + const mismatched = await send(request('file.importAudio')); + + expect(expectFailed(mismatched).error.message).toBe( + 'audio bytes do not match MIME', + ); }); }); diff --git a/apps/mobile-shell/src/host-bridge/files.ts b/apps/mobile-shell/src/host-bridge/files.ts index 45d327182..2e526a185 100644 --- a/apps/mobile-shell/src/host-bridge/files.ts +++ b/apps/mobile-shell/src/host-bridge/files.ts @@ -45,6 +45,8 @@ const HOST_BRIDGE_AUDIO_MIME_TYPES = new Set([ 'audio/ogg', 'audio/webm', ]); +const BASE64_ALPHABET = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; export function utf8ByteLength(value: string) { let bytes = 0; @@ -85,6 +87,127 @@ function base64DecodedByteLength(value: string) { return Math.floor((value.length * 3) / 4) - padding; } +function base64Bytes(value: string) { + const bytes = new Uint8Array(base64DecodedByteLength(value)); + let byteIndex = 0; + + for (let index = 0; index < value.length; index += 4) { + const first = BASE64_ALPHABET.indexOf(value[index] ?? 'A'); + const second = BASE64_ALPHABET.indexOf(value[index + 1] ?? 'A'); + const third = + value[index + 2] === '=' + ? 0 + : BASE64_ALPHABET.indexOf(value[index + 2] ?? 'A'); + const fourth = + value[index + 3] === '=' + ? 0 + : BASE64_ALPHABET.indexOf(value[index + 3] ?? 'A'); + const chunk = (first << 18) | (second << 12) | (third << 6) | fourth; + + if (byteIndex < bytes.length) { + bytes[byteIndex] = (chunk >> 16) & 0xff; + byteIndex += 1; + } + if (byteIndex < bytes.length) { + bytes[byteIndex] = (chunk >> 8) & 0xff; + byteIndex += 1; + } + if (byteIndex < bytes.length) { + bytes[byteIndex] = chunk & 0xff; + byteIndex += 1; + } + } + + return bytes; +} + +function byteAt(bytes: Uint8Array, index: number) { + return bytes[index] ?? 0; +} + +function bytesStartWith(bytes: Uint8Array, header: readonly number[]) { + return header.every((value, index) => byteAt(bytes, index) === value); +} + +function riffContainerMatches(bytes: Uint8Array, kind: string) { + return ( + bytes.length >= 12 && + byteAt(bytes, 0) === 0x52 && + byteAt(bytes, 1) === 0x49 && + byteAt(bytes, 2) === 0x46 && + byteAt(bytes, 3) === 0x46 && + String.fromCharCode(...bytes.slice(8, 12)) === kind + ); +} + +function detectImageMimeType(base64Data: string): HostBridgeImageMimeType | null { + const bytes = base64Bytes(base64Data); + if (bytesStartWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) { + return 'image/png'; + } + if ( + bytes.length >= 3 && + byteAt(bytes, 0) === 0xff && + byteAt(bytes, 1) === 0xd8 && + byteAt(bytes, 2) === 0xff + ) { + return 'image/jpeg'; + } + if (riffContainerMatches(bytes, 'WEBP')) { + return 'image/webp'; + } + + return null; +} + +function detectAudioMimeType(base64Data: string): HostBridgeAudioMimeType | null { + const bytes = base64Bytes(base64Data); + if ( + bytesStartWith(bytes, [0x49, 0x44, 0x33]) || + (bytes.length >= 2 && byteAt(bytes, 0) === 0xff && (byteAt(bytes, 1) & 0xe0) === 0xe0) + ) { + return 'audio/mpeg'; + } + if ( + bytes.length >= 12 && + byteAt(bytes, 4) === 0x66 && + byteAt(bytes, 5) === 0x74 && + byteAt(bytes, 6) === 0x79 && + byteAt(bytes, 7) === 0x70 + ) { + return 'audio/mp4'; + } + if (riffContainerMatches(bytes, 'WAVE')) { + return 'audio/wav'; + } + if (bytesStartWith(bytes, [0x4f, 0x67, 0x67, 0x53])) { + return 'audio/ogg'; + } + if (bytesStartWith(bytes, [0x1a, 0x45, 0xdf, 0xa3])) { + return 'audio/webm'; + } + + return null; +} + +function ensureImageBytesMatchMimeType( + base64Data: string, + mimeType: HostBridgeImageMimeType, +) { + if (detectImageMimeType(base64Data) !== mimeType) { + throw invalidRequest('image bytes do not match MIME'); + } +} + +function ensureAudioBytesMatchMimeType( + base64Data: string, + mimeType: HostBridgeAudioMimeType, +) { + if (detectAudioMimeType(base64Data) !== mimeType) { + throw invalidRequest('audio bytes do not match MIME'); + } +} + export async function exportTextFile( payload: unknown, ): Promise { @@ -220,6 +343,7 @@ export async function exportImageFile( if (bytes > EXPORT_IMAGE_MAX_BYTES) { throw invalidRequest('image exceeds file export size limit'); } + ensureImageBytesMatchMimeType(base64Data, mimeType as HostBridgeImageMimeType); const isSharingAvailable = await Sharing.isAvailableAsync(); if (!isSharingAvailable) { @@ -298,6 +422,7 @@ function imagePickerResultToImportPayload( if (bytes <= 0 || bytes > IMPORT_IMAGE_MAX_BYTES) { throw invalidRequest('image exceeds file import size limit'); } + ensureImageBytesMatchMimeType(base64Data, mimeType); if ( typeof asset.fileSize === 'number' && asset.fileSize > IMPORT_IMAGE_MAX_BYTES @@ -435,6 +560,7 @@ export async function exportAudioFile( if (bytes <= 0 || bytes > EXPORT_AUDIO_MAX_BYTES) { throw invalidRequest('audio exceeds file export size limit'); } + ensureAudioBytesMatchMimeType(base64Data, mimeType as HostBridgeAudioMimeType); const isSharingAvailable = await Sharing.isAvailableAsync(); if (!isSharingAvailable) { @@ -502,11 +628,15 @@ export async function importAudioFile(): Promise { } const file = new File(asset.uri); - const base64Data = await file.base64(); + const base64Data = normalizedBase64Data(await file.base64()); + if (!base64Data) { + throw invalidRequest('base64Data is required'); + } const bytes = base64DecodedByteLength(base64Data); if (bytes <= 0 || bytes > IMPORT_AUDIO_MAX_BYTES) { throw invalidRequest('audio exceeds file import size limit'); } + ensureAudioBytesMatchMimeType(base64Data, mimeType); return { action: 'selected', diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 446092d8d..54480db61 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -70,6 +70,7 @@ - 2026-06-18 桌面壳顶层导航边界:Tauri 主 WebView 只允许打包资产 URL 和 `https://app.genarrative.world` 同源 H5 route 留在主窗口;外域 `http:` / `https:`、`mailto:`、`tel:` 导航与 `window.open` 请求交给系统 opener 后拒绝 WebView 留壳;`javascript:`、`file:` 等危险协议直接拒绝。该规则不进入 HostBridge capability,不开放 opener JS guest API,配置检查和 cargo test 覆盖导航策略。 - 2026-06-18 桌面壳默认下载边界:Tauri 主 WebView 的下载事件默认拒绝网页自动下载和 `` 落盘,桌面文件保存只能通过 `file.exportText`、`file.exportImage`、`file.exportAudio` 等已声明 HostBridge method 进入 Rust 侧系统保存对话框,并继续执行 MIME、大小、文件名清洗和用户确认。该规则不进入 HostBridge capability,配置检查和 cargo test 覆盖下载拒绝策略。 - 2026-06-18 桌面壳文件 bytes 校验:Tauri 图片 / 音频导入导出不得只信扩展名或 H5 声明 MIME;Rust 侧必须识别 PNG / JPEG / WebP、MP3 / MP4-M4A / WAV / OGG / WebM bytes 头部,要求导入文件扩展名对应 MIME 与真实 bytes 匹配,导出 payload 的 `mimeType` 与 `base64Data` 解码 bytes 匹配。不匹配返回 `invalid_request`,继续不暴露本机绝对路径或通用文件系统能力。配置检查和 cargo test 覆盖该边界。 +- 2026-06-18 移动壳文件 bytes 校验:Expo 图片 / 音频导入导出不得只信系统 picker 返回 MIME、文件扩展名或 H5 声明 MIME;移动壳必须识别 PNG / JPEG / WebP、MP3 / MP4-M4A / WAV / OGG / WebM base64 bytes 头部,要求导入 MIME 归一结果与真实 bytes 匹配,导出 payload 的 `mimeType` 与 `base64Data` 解码 bytes 匹配。不匹配返回 `invalid_request`,不会写入缓存文件、调起系统分享或把内容回传给 H5。配置检查和移动壳测试覆盖该边界。 - 2026-06-18 桌面壳 DevTools 边界:Tauri 主 WebView 配置必须显式 `devtools=false`,Cargo 依赖不得启用 Tauri `devtools` feature;桌面壳本地调试走普通浏览器和 Vite,不把 debug / release 桌面包变成可打开浏览器检查器的调试容器。配置检查会拒绝主窗口 DevTools 或 release feature 被重新打开。 - 2026-06-18 桌面壳 Tauri 命令白名单:桌面壳源码、Tauri build manifest、主窗口 capability 和本地自动生成权限目录都只能暴露 `host_bridge_request` 一个受控 command;所有桌面能力继续在 Rust 内部按 HostBridge method 白名单分发,不新增可被 H5 直接 `invoke` 的 Tauri command,也不授予插件 JS guest API。检查脚本会拒绝多余 command、权限列表顺序漂移和残留的自动生成权限文件。 - 2026-06-18 HostBridge request id replay:Expo 和 Tauri 壳都必须按 request id 回放首次完成结果;同 id 进行中的请求共享同一执行结果,已完成请求直接回放缓存响应,避免系统分享、外链、剪贴板、文件选择 / 保存、本地通知、窗口导航等宿主副作用被重复触发。两端配置检查和测试会锁住 replay 结构。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 976fececf..cc091c1d3 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -279,6 +279,8 @@ GameBridge 禁止: 2026-06-18 追加:移动壳声明并实现 `file.importText`,通过 Expo DocumentPicker 打开系统文档选择器,只接受 `text/plain`、`text/markdown`、`text/csv`、`application/json` 或对应扩展名,单次不超过 5 MiB;成功只返回清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露设备本地 URI,也不开放通用文件系统。H5 创作 Agent 工作台在原生壳声明该能力时优先打开宿主系统选择器,再把返回文本转换成现有浏览器 `File` 并继续调用 `/api/runtime/creation-agent/document-inputs/parse`,不在前端绕过后端文档解析、大小校验或 docx 处理。 +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。 + 2026-06-18 追加:H5 个人中心的邀请码填写和兑换码弹窗开始消费 `clipboard.readText`。Expo 壳仍只通过 `expo-clipboard` 返回纯文本,H5 只把文本填入现有输入框,不自动提交,也不把剪贴板内容交给宿主侧业务处理;普通浏览器、小程序和未声明该能力的裁剪壳不显示粘贴动作。 2026-06-18 追加:H5 的草稿生成完成 / 失败收口开始消费 `notification.showLocal`。Expo 壳仍只发送即时本地通知,不注册远程推送 token、不做定时提醒;H5 按草稿来源对完成和失败通知去重,同一草稿重新进入生成中后才允许再次通知,通知失败不阻断弹窗、作品架和后端状态回读。