From b3278739a57c01e1cee12365fd4e9a0871c17286 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 18 Jun 2026 05:42:16 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E5=8E=9F=E7=94=9F=E5=A3=B3?= =?UTF-8?q?=E9=9F=B3=E9=A2=91=E6=96=87=E4=BB=B6=E5=AF=BC=E5=87=BA=E8=83=BD?= =?UTF-8?q?=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 file.exportAudio HostBridge 契约和 H5 facade 移动端通过 Expo 缓存文件与系统分享导出受控音频 桌面端通过 Tauri 保存对话框写入受控音频字节 通用音频输入面板仅对本地音频资产展示宿主导出入口 更新壳能力检查、测试、方案文档和共享决策记录 --- apps/desktop-shell/scripts/check-config.mjs | 2 + apps/desktop-shell/src-tauri/src/main.rs | 191 ++++++++++++++++++ apps/desktop-shell/src-tauri/tauri.conf.json | 4 +- apps/mobile-shell/scripts/check-config.mjs | 2 + .../mobile-shell/src/mobileHostBridge.test.ts | 82 ++++++++ apps/mobile-shell/src/mobileHostBridge.ts | 81 ++++++++ .../shared-memory/decision-log.md | 7 + ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 5 +- ...前端架构】宿主壳能力统一协议-2026-06-17.md | 1 + .../shared/src/contracts/hostBridge.test.ts | 1 + packages/shared/src/contracts/hostBridge.ts | 13 ++ .../common/CreativeAudioInputPanel.test.tsx | 133 ++++++++++++ .../common/CreativeAudioInputPanel.tsx | 129 ++++++++++-- src/services/host-bridge/hostBridge.test.ts | 76 +++++++ src/services/host-bridge/hostBridge.ts | 25 +++ 15 files changed, 734 insertions(+), 18 deletions(-) diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index b091c278e..3d29555c2 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -150,6 +150,7 @@ const requiredMainSnippets = [ '"file.exportImage"', '"file.importImage"', '"file.importAudio"', + '"file.exportAudio"', '"file.imageDropped"', '"notification.showLocal"', 'tauri_plugin_dialog::init()', @@ -163,6 +164,7 @@ const requiredMainSnippets = [ 'import_text_file_payload', 'import_image_file_payload', 'import_audio_file_payload', + 'export_audio_payload', 'set_title', 'set_badge_count', 'window.reload()', diff --git a/apps/desktop-shell/src-tauri/src/main.rs b/apps/desktop-shell/src-tauri/src/main.rs index 2c1eca802..afb311b09 100644 --- a/apps/desktop-shell/src-tauri/src/main.rs +++ b/apps/desktop-shell/src-tauri/src/main.rs @@ -23,6 +23,7 @@ const WEB_APP_ORIGIN: &str = "https://app.genarrative.world"; const EXTERNAL_URL_PROTOCOLS: [&str; 4] = ["http:", "https:", "mailto:", "tel:"]; const EXPORT_TEXT_MAX_BYTES: usize = 5 * 1024 * 1024; const EXPORT_IMAGE_MAX_BYTES: usize = 5 * 1024 * 1024; +const EXPORT_AUDIO_MAX_BYTES: usize = 20 * 1024 * 1024; const IMPORT_TEXT_MAX_BYTES: u64 = 5 * 1024 * 1024; const IMPORT_IMAGE_MAX_BYTES: u64 = 10 * 1024 * 1024; const IMPORT_AUDIO_MAX_BYTES: u64 = 20 * 1024 * 1024; @@ -111,6 +112,7 @@ fn capabilities() -> Vec<&'static str> { "file.exportImage", "file.importImage", "file.importAudio", + "file.exportAudio", "file.imageDropped", "notification.showLocal", ] @@ -475,6 +477,17 @@ fn import_audio_mime_type(path: &Path) -> Option<&'static str> { } } +fn export_audio_extension(mime_type: &str) -> Option<&'static str> { + match mime_type { + "audio/mpeg" => Some("mp3"), + "audio/mp4" => Some("m4a"), + "audio/wav" => Some("wav"), + "audio/ogg" => Some("ogg"), + "audio/webm" => Some("webm"), + _ => None, + } +} + fn normalize_export_image_file_name(raw_file_name: &str, mime_type: &str) -> String { let mut file_name = normalize_export_file_name(raw_file_name); let extension = export_image_extension(mime_type).unwrap_or("png"); @@ -488,6 +501,19 @@ fn normalize_export_image_file_name(raw_file_name: &str, mime_type: &str) -> Str file_name } +fn normalize_export_audio_file_name(raw_file_name: &str, mime_type: &str) -> String { + let mut file_name = normalize_export_file_name(raw_file_name); + let extension = export_audio_extension(mime_type).unwrap_or("webm"); + if !file_name + .to_ascii_lowercase() + .ends_with(&format!(".{}", extension)) + { + file_name.push('.'); + file_name.push_str(extension); + } + file_name +} + fn export_image_payload( request: &HostBridgeRequest, ) -> Result<(String, Vec), HostBridgeResponse> { @@ -552,6 +578,70 @@ fn export_image_payload( Ok((file_name, bytes)) } +fn export_audio_payload( + request: &HostBridgeRequest, +) -> Result<(String, Vec), HostBridgeResponse> { + let payload = request.payload.as_ref().ok_or_else(|| { + failed( + request.id.clone(), + "invalid_request", + "fileName, mimeType and base64Data are required", + ) + })?; + let mime_type = payload + .get("mimeType") + .and_then(Value::as_str) + .ok_or_else(|| { + failed( + request.id.clone(), + "invalid_request", + "mimeType is required", + ) + })?; + if export_audio_extension(mime_type).is_none() { + return Err(failed( + request.id.clone(), + "invalid_request", + "mimeType must be an allowed audio type", + )); + } + + let base64_data = payload + .get("base64Data") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + failed( + request.id.clone(), + "invalid_request", + "base64Data is required", + ) + })?; + let bytes = BASE64_STANDARD.decode(base64_data).map_err(|_| { + failed( + request.id.clone(), + "invalid_request", + "base64Data is invalid", + ) + })?; + if bytes.is_empty() || bytes.len() > EXPORT_AUDIO_MAX_BYTES { + return Err(failed( + request.id.clone(), + "invalid_request", + "audio exceeds file export size limit", + )); + } + + let file_name = payload + .get("fileName") + .and_then(Value::as_str) + .map(|file_name| normalize_export_audio_file_name(file_name, mime_type)) + .unwrap_or_else(|| normalize_export_audio_file_name("genarrative-audio", mime_type)); + + Ok((file_name, bytes)) +} + fn write_export_bytes_file(path: PathBuf, bytes: Vec) -> Result { let byte_count = bytes.len(); fs::write(path, bytes).map_err(|error| error.to_string())?; @@ -1120,6 +1210,41 @@ async fn host_bridge_request( Err(error) => failed(request.id, "host_error", error.to_string()), } } + "file.exportAudio" => { + let (file_name, bytes) = match export_audio_payload(&request) { + Ok(payload) => payload, + Err(response) => return response, + }; + let file_path = app + .dialog() + .file() + .add_filter("Audio", &["mp3", "m4a", "wav", "ogg", "webm"]) + .set_file_name(file_name.clone()) + .blocking_save_file(); + let Some(file_path) = file_path else { + return failed(request.id, "cancelled", "file export cancelled"); + }; + let path = match file_path.into_path() { + Ok(path) => path, + Err(error) => return failed(request.id, "host_error", error.to_string()), + }; + let export_result = + tauri::async_runtime::spawn_blocking(move || write_export_bytes_file(path, bytes)) + .await; + let byte_count = match export_result { + Ok(Ok(byte_count)) => byte_count, + Ok(Err(error)) => return failed(request.id, "host_error", error), + Err(error) => return failed(request.id, "host_error", error.to_string()), + }; + ok( + request.id, + json!({ + "action": "saved", + "fileName": file_name, + "bytes": byte_count, + }), + ) + } "app.setTitle" => { let title = match required_string_payload(&request, "title") .ok() @@ -1325,6 +1450,10 @@ mod tests { .as_array() .unwrap() .contains(&json!("file.importAudio"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("file.exportAudio"))); assert!(result["capabilities"] .as_array() .unwrap() @@ -1797,6 +1926,68 @@ mod tests { fs::remove_file(large_path).expect("remove large audio"); } + #[test] + fn export_audio_payload_decodes_allowed_audio_base64() { + let mut valid = request("file.exportAudio"); + valid.payload = Some(json!({ + "fileName": "敲击:音效?.wav", + "base64Data": "YXVkaW8=", + "mimeType": "audio/wav" + })); + + let (file_name, bytes) = export_audio_payload(&valid).expect("audio payload"); + + assert_eq!(file_name, "敲击-音效-.wav"); + assert_eq!(bytes, b"audio"); + + let mut missing_extension = request("file.exportAudio"); + missing_extension.payload = Some(json!({ + "fileName": "敲击音效", + "base64Data": "YXVkaW8=", + "mimeType": "audio/webm" + })); + + let (file_name, _bytes) = + export_audio_payload(&missing_extension).expect("audio payload"); + + assert_eq!(file_name, "敲击音效.webm"); + } + + #[test] + fn export_audio_payload_rejects_invalid_or_oversized_audio() { + let mut invalid_mime = request("file.exportAudio"); + invalid_mime.payload = Some(json!({ + "fileName": "hit.txt", + "base64Data": "YXVkaW8=", + "mimeType": "text/plain" + })); + let response = export_audio_payload(&invalid_mime).expect_err("invalid mime"); + assert!(!response.ok); + assert_eq!(response.error.expect("error").code, "invalid_request"); + + let mut empty = request("file.exportAudio"); + empty.payload = Some(json!({ + "fileName": "hit.wav", + "base64Data": "", + "mimeType": "audio/wav" + })); + let response = export_audio_payload(&empty).expect_err("empty audio"); + assert!(!response.ok); + assert_eq!(response.error.expect("error").code, "invalid_request"); + + let mut oversized = request("file.exportAudio"); + oversized.payload = Some(json!({ + "fileName": "hit.webm", + "base64Data": BASE64_STANDARD.encode(vec![1u8; EXPORT_AUDIO_MAX_BYTES + 1]), + "mimeType": "audio/webm" + })); + let response = export_audio_payload(&oversized).expect_err("oversized audio"); + assert!(!response.ok); + let error = response.error.expect("error"); + assert_eq!(error.code, "invalid_request"); + assert_eq!(error.message, "audio exceeds file export size limit"); + } + #[test] fn export_image_payload_rejects_invalid_mime_and_base64() { let mut invalid_mime = request("file.exportImage"); diff --git a/apps/desktop-shell/src-tauri/tauri.conf.json b/apps/desktop-shell/src-tauri/tauri.conf.json index 4c2b383ec..25ce2983d 100644 --- a/apps/desktop-shell/src-tauri/tauri.conf.json +++ b/apps/desktop-shell/src-tauri/tauri.conf.json @@ -6,7 +6,7 @@ "build": { "beforeDevCommand": "npm --prefix ../.. run dev:web", "beforeBuildCommand": "npm --prefix ../.. run build:raw && npm run typecheck", - "devUrl": "http://127.0.0.1:3000/?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,appearance.getColorScheme,app.lifecycle,share.open,share.setTarget,navigation.openNativePage,app.reloadWebView,app.openExternalUrl,app.setTitle,app.setBadgeCount,network.status,network.statusChanged,clipboard.writeText,clipboard.readText,file.exportText,file.importText,file.exportImage,file.importImage,file.importAudio,file.imageDropped,notification.showLocal", + "devUrl": "http://127.0.0.1:3000/?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,appearance.getColorScheme,app.lifecycle,share.open,share.setTarget,navigation.openNativePage,app.reloadWebView,app.openExternalUrl,app.setTitle,app.setBadgeCount,network.status,network.statusChanged,clipboard.writeText,clipboard.readText,file.exportText,file.importText,file.exportImage,file.importImage,file.importAudio,file.exportAudio,file.imageDropped,notification.showLocal", "frontendDist": "../../../dist" }, "app": { @@ -14,7 +14,7 @@ { "create": false, "label": "main", - "url": "index.html?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,appearance.getColorScheme,app.lifecycle,share.open,share.setTarget,navigation.openNativePage,app.reloadWebView,app.openExternalUrl,app.setTitle,app.setBadgeCount,network.status,network.statusChanged,clipboard.writeText,clipboard.readText,file.exportText,file.importText,file.exportImage,file.importImage,file.importAudio,file.imageDropped,notification.showLocal", + "url": "index.html?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,appearance.getColorScheme,app.lifecycle,share.open,share.setTarget,navigation.openNativePage,app.reloadWebView,app.openExternalUrl,app.setTitle,app.setBadgeCount,network.status,network.statusChanged,clipboard.writeText,clipboard.readText,file.exportText,file.importText,file.exportImage,file.importImage,file.importAudio,file.exportAudio,file.imageDropped,notification.showLocal", "title": "Genarrative", "width": 1280, "height": 820, diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 08765315d..8092cccd5 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -183,6 +183,7 @@ for (const snippet of [ 'file.exportImage', 'file.importImage', 'file.importAudio', + 'file.exportAudio', 'clipboard.readText', 'notification.showLocal', 'network.status', @@ -236,6 +237,7 @@ for (const capability of [ 'file.exportImage', 'file.importImage', 'file.importAudio', + 'file.exportAudio', 'haptics.impact', 'notification.showLocal', ]) { diff --git a/apps/mobile-shell/src/mobileHostBridge.test.ts b/apps/mobile-shell/src/mobileHostBridge.test.ts index f23058493..62e92a5a9 100644 --- a/apps/mobile-shell/src/mobileHostBridge.test.ts +++ b/apps/mobile-shell/src/mobileHostBridge.test.ts @@ -851,6 +851,88 @@ describe('handleMobileHostBridgeMessage', () => { expect(Sharing.shareAsync).not.toHaveBeenCalled(); }); + test('file.exportAudio 写入缓存音频并调起系统分享', async () => { + const response = await send( + request('file.exportAudio', { + fileName: ' ../敲击:音效?.wav ', + base64Data: 'YXVkaW8=', + mimeType: 'audio/wav', + }), + ); + + const okResponse = expectOk(response); + + expect(okResponse.result).toEqual({ + action: 'saved', + fileName: '敲击-音效-.wav', + bytes: 5, + }); + expect(writtenFiles).toEqual([ + { + uri: 'file:///cache/敲击-音效-.wav', + content: 'YXVkaW8=', + options: { encoding: 'base64' }, + }, + ]); + expect(Sharing.shareAsync).toHaveBeenCalledWith( + 'file:///cache/敲击-音效-.wav', + { + mimeType: 'audio/wav', + UTI: 'public.audio', + dialogTitle: '敲击-音效-.wav', + }, + ); + }); + + test('file.exportAudio 在系统分享不可用时明确返回 unsupported capability', async () => { + vi.mocked(Sharing.isAvailableAsync).mockResolvedValue(false); + + const response = await send( + request('file.exportAudio', { + fileName: 'hit.wav', + base64Data: 'YXVkaW8=', + mimeType: 'audio/wav', + }), + ); + + const failedResponse = expectFailed(response); + + expect(failedResponse.error.code).toBe('unsupported_capability'); + expect(writtenFiles).toEqual([]); + expect(Sharing.shareAsync).not.toHaveBeenCalled(); + }); + + test('file.exportAudio 拒绝非法 MIME、空内容与超限内容', async () => { + const unsupportedMime = await send( + request('file.exportAudio', { + fileName: 'hit.txt', + base64Data: 'YXVkaW8=', + mimeType: 'text/plain', + }), + ); + expect(expectFailed(unsupportedMime).error.code).toBe('invalid_request'); + + const emptyAudio = await send( + request('file.exportAudio', { + fileName: 'hit.wav', + base64Data: '', + mimeType: 'audio/wav', + }), + ); + expect(expectFailed(emptyAudio).error.code).toBe('invalid_request'); + + const oversized = await send( + request('file.exportAudio', { + fileName: 'hit.webm', + base64Data: 'A'.repeat(28 * 1024 * 1024), + mimeType: 'audio/webm', + }), + ); + expect(expectFailed(oversized).error.code).toBe('invalid_request'); + expect(writtenFiles).toEqual([]); + expect(Sharing.shareAsync).not.toHaveBeenCalled(); + }); + test('file.importImage 调起系统相册并返回受控图片数据', async () => { vi.mocked(ImagePicker.launchImageLibraryAsync).mockResolvedValue({ canceled: false, diff --git a/apps/mobile-shell/src/mobileHostBridge.ts b/apps/mobile-shell/src/mobileHostBridge.ts index 6b065dcea..8a4e1d1a3 100644 --- a/apps/mobile-shell/src/mobileHostBridge.ts +++ b/apps/mobile-shell/src/mobileHostBridge.ts @@ -16,6 +16,8 @@ import { import { type ClipboardReadTextResult, type ClipboardWriteTextPayload, + type FileExportAudioPayload, + type FileExportAudioResult, type FileExportImagePayload, type FileExportImageResult, type FileExportTextPayload, @@ -51,6 +53,7 @@ import { getMobileNetworkStatus } from './mobileShellNetwork'; const WEB_APP_ORIGIN = 'https://app.genarrative.world'; const EXPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024; const EXPORT_IMAGE_MAX_BYTES = 5 * 1024 * 1024; +const EXPORT_AUDIO_MAX_BYTES = 20 * 1024 * 1024; const IMPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024; const IMPORT_IMAGE_MAX_BYTES = 10 * 1024 * 1024; const IMPORT_AUDIO_MAX_BYTES = 20 * 1024 * 1024; @@ -103,6 +106,7 @@ export const MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [ 'file.exportImage', 'file.importImage', 'file.importAudio', + 'file.exportAudio', 'haptics.impact', 'notification.showLocal', ]; @@ -449,6 +453,33 @@ function fallbackImportedImageFileName(mimeType: HostBridgeImageMimeType) { return 'genarrative-import.png'; } +function audioFileExtension(mimeType: HostBridgeAudioMimeType) { + if (mimeType === 'audio/mpeg') { + return 'mp3'; + } + if (mimeType === 'audio/mp4') { + return 'm4a'; + } + if (mimeType === 'audio/wav') { + return 'wav'; + } + if (mimeType === 'audio/ogg') { + return 'ogg'; + } + return 'webm'; +} + +function normalizeExportedAudioFileName( + rawFileName: unknown, + mimeType: HostBridgeAudioMimeType, +) { + const fileName = normalizeHostBridgeExportFileName(rawFileName); + const extension = audioFileExtension(mimeType); + return fileName.toLowerCase().endsWith(`.${extension}`) + ? fileName + : `${fileName}.${extension}`; +} + function normalizeImportedAudioMimeType( value: unknown, fileName: string, @@ -480,6 +511,54 @@ function normalizeImportedAudioMimeType( return null; } +async function exportAudioFile( + payload: unknown, +): Promise { + const exportPayload = payload as FileExportAudioPayload | undefined; + const mimeType = exportPayload?.mimeType; + if ( + typeof mimeType !== 'string' || + !HOST_BRIDGE_AUDIO_MIME_TYPES.has(mimeType as HostBridgeAudioMimeType) + ) { + throw invalidRequest('mimeType must be an allowed audio type'); + } + + const base64Data = normalizedBase64Data(exportPayload?.base64Data); + if (!base64Data) { + throw invalidRequest('base64Data is required'); + } + const bytes = base64DecodedByteLength(base64Data); + if (bytes <= 0 || bytes > EXPORT_AUDIO_MAX_BYTES) { + throw invalidRequest('audio exceeds file export size limit'); + } + + const isSharingAvailable = await Sharing.isAvailableAsync(); + if (!isSharingAvailable) { + throw { + code: 'unsupported_capability', + message: 'file sharing is unavailable in mobile shell', + } satisfies HostBridgeError; + } + + const fileName = normalizeExportedAudioFileName( + exportPayload?.fileName, + mimeType as HostBridgeAudioMimeType, + ); + const file = new File(Paths.cache, fileName); + file.write(base64Data, { encoding: 'base64' }); + await Sharing.shareAsync(file.uri, { + mimeType, + UTI: 'public.audio', + dialogTitle: fileName, + }); + + return { + action: 'saved', + fileName, + bytes, + }; +} + async function importImageFile(): Promise { const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (permission.status !== ImagePicker.PermissionStatus.GRANTED) { @@ -831,6 +910,8 @@ async function handleRequest(request: HostBridgeRequest) { return ok(request, await importImageFile()); case 'file.importAudio': return ok(request, await importAudioFile()); + case 'file.exportAudio': + return ok(request, await exportAudioFile(request.payload)); case 'haptics.impact': return ok(request, await runHaptics(request.payload)); case 'notification.showLocal': diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f54cf3f14..ec6a36c1f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2297,3 +2297,10 @@ - 决策:新增 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')` 音频处理链路。 - 影响范围:`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`。 + +## 2026-06-18 原生壳音频导出只写入 H5 已持有字节 + +- 背景:木鱼创作的本地录音 / 上传音频会在浏览器侧处理成 `Blob`,原生壳需要能把这份本地处理结果交给系统保存 / 分享;但宿主不能替 H5 读取任意本地音频文件,也不能把文件系统能力扩成通用读写。 +- 决策:新增 HostBridge method `file.exportAudio`、H5 facade `exportHostAudioFile()` 和通用音频输入面板导出入口。H5 只传当前页面已持有的 `base64Data`、清洗后的文件名和允许的 `audio/mpeg`、`audio/mp4`、`audio/wav`、`audio/ogg`、`audio/webm` MIME;移动端写入 Expo 缓存音频后交给系统分享 / 保存面板,桌面端打开 Tauri 系统保存对话框并写入音频字节。单次不超过 20 MiB,成功只返回文件名和字节数。`CreativeAudioInputPanel` 只在当前资产包含本地 `Blob`、`fileName`、允许 MIME 且宿主声明 `file.exportAudio` 时显示导出入口;远端已上传音频不展示导出。 +- 影响范围:`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 b61001f34..33bfb9302 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -131,6 +131,7 @@ type HostBridgeEvent = { | `file.importText` | 导入用户选择的文本文件 | 支持系统文档选择器 | 支持系统选择文本文件 | | `file.importImage` | 导入用户选择的图片文件 | 支持系统相册选择图片 | 支持系统选择图片 | | `file.importAudio` | 导入用户选择的音频文件 | 支持系统文档选择器 | 支持系统选择音频文件 | +| `file.exportAudio` | 导出当前 H5 已持有的音频文件 | 支持系统分享 / 保存面板 | 支持系统保存对话框 | | `file.imageDropped` | 通知 H5 桌面拖入图片 | 不声明 | 支持主窗口拖拽图片事件 | | `haptics.impact` | 轻量触感反馈 | 支持 | 不声明 | | `notification.showLocal` | 发送即时本地系统通知 | 支持 Expo Notifications | 支持 Rust 侧 Tauri notification | @@ -254,7 +255,7 @@ GameBridge 禁止: - iOS / Android 深链打开作品详情、创作页和邀请码。 - 登录和支付先 fallback 到 H5;只把能力边界跑通。 -当前状态:已新增 `apps/mobile-shell/`,通过 Expo development build 运行,`react-native-webview` 加载 H5 URL 并附加 `native_app` 宿主 query。移动壳使用真实品牌图标资产,已接入 `genarrative://` scheme、iOS associated domain 和 Android app link filter,启动和运行时 deep link 只会映射到同源 H5 路径并继续附加 HostBridge 上下文,外域和危险协议回退到默认主站入口。首轮真实能力包括 `host.getRuntime`、`appearance.getColorScheme`、`host.events`、`app.lifecycle`、`network.status`、`network.statusChanged`、`share.open`、`share.setTarget`、`navigation.openNativePage`、`navigation.canGoBack`、`app.reloadWebView`、`app.openExternalUrl`、`clipboard.writeText`、`clipboard.readText`、`file.exportText`、`file.exportImage`、`file.importImage`、`file.importAudio`、`haptics.impact`、`notification.showLocal` 和 Android 返回键回退;其中 `appearance.getColorScheme` 只读系统配色偏好,不强改 H5 或系统主题;`app.lifecycle` 通过 React Native `AppState` 注入 `active` / `inactive` / `background` 统一状态,供 H5 游戏循环、音频和轮询做真实暂停 / 恢复判断,H5 的 `useHostLifecycleActive()` 会把该事件归一成运行态可播放状态,WebAudio 背景音乐和拼图、抓大鹅等固定玩法 `