diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index a958e34a..b091c278 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -149,6 +149,7 @@ const requiredMainSnippets = [ '"file.importText"', '"file.exportImage"', '"file.importImage"', + '"file.importAudio"', '"file.imageDropped"', '"notification.showLocal"', 'tauri_plugin_dialog::init()', @@ -161,6 +162,7 @@ const requiredMainSnippets = [ 'blocking_pick_file', 'import_text_file_payload', 'import_image_file_payload', + 'import_audio_file_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 0dde1121..2c1eca80 100644 --- a/apps/desktop-shell/src-tauri/src/main.rs +++ b/apps/desktop-shell/src-tauri/src/main.rs @@ -25,6 +25,7 @@ const EXPORT_TEXT_MAX_BYTES: usize = 5 * 1024 * 1024; const EXPORT_IMAGE_MAX_BYTES: usize = 5 * 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; const EXPORT_FILE_NAME_FALLBACK: &str = "genarrative-export.txt"; const EXPORT_FILE_NAME_MAX_LENGTH: usize = 120; const BADGE_COUNT_MAX: i64 = 99999; @@ -109,6 +110,7 @@ fn capabilities() -> Vec<&'static str> { "file.importText", "file.exportImage", "file.importImage", + "file.importAudio", "file.imageDropped", "notification.showLocal", ] @@ -457,6 +459,22 @@ fn import_image_mime_type(path: &Path) -> Option<&'static str> { } } +fn import_audio_mime_type(path: &Path) -> Option<&'static str> { + match path + .extension() + .and_then(|extension| extension.to_str()) + .map(|extension| extension.to_ascii_lowercase()) + .as_deref() + { + Some("mp3") => Some("audio/mpeg"), + Some("m4a") | Some("mp4") => Some("audio/mp4"), + Some("wav") => Some("audio/wav"), + Some("ogg") => Some("audio/ogg"), + Some("webm") => Some("audio/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"); @@ -585,6 +603,39 @@ fn import_image_file_payload( Ok(payload) } +fn import_audio_file_payload(path: PathBuf) -> Result { + if !path.is_file() { + return Err("audio file is required".to_string()); + } + + let mime_type = + import_audio_mime_type(&path).ok_or_else(|| "audio MIME must be allowed".to_string())?; + let metadata = fs::metadata(&path).map_err(|error| error.to_string())?; + let byte_count = metadata.len(); + if byte_count == 0 || byte_count > IMPORT_AUDIO_MAX_BYTES { + return Err("audio exceeds import size limit".to_string()); + } + + let bytes = fs::read(&path).map_err(|error| error.to_string())?; + let byte_count = bytes.len() as u64; + if byte_count == 0 || byte_count > IMPORT_AUDIO_MAX_BYTES { + return Err("audio exceeds import size limit".to_string()); + } + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .map(normalize_export_file_name) + .unwrap_or_else(|| "genarrative-import-audio.webm".to_string()); + + Ok(json!({ + "action": "selected", + "fileName": file_name, + "base64Data": BASE64_STANDARD.encode(bytes), + "mimeType": mime_type, + "bytes": byte_count, + })) +} + fn host_bridge_event_script(event: &str, payload: Value) -> Result { let message = json!({ "bridge": HOST_BRIDGE_PROTOCOL, @@ -1047,6 +1098,28 @@ async fn host_bridge_request( Err(error) => failed(request.id, "host_error", error.to_string()), } } + "file.importAudio" => { + let file_path = app + .dialog() + .file() + .add_filter("Audio", &["mp3", "m4a", "mp4", "wav", "ogg", "webm"]) + .blocking_pick_file(); + let Some(file_path) = file_path else { + return failed(request.id, "cancelled", "file import cancelled"); + }; + let path = match file_path.into_path() { + Ok(path) => path, + Err(error) => return failed(request.id, "host_error", error.to_string()), + }; + let import_result = + tauri::async_runtime::spawn_blocking(move || import_audio_file_payload(path)) + .await; + match import_result { + Ok(Ok(payload)) => ok(request.id, payload), + Ok(Err(error)) => failed(request.id, "invalid_request", error), + Err(error) => failed(request.id, "host_error", error.to_string()), + } + } "app.setTitle" => { let title = match required_string_payload(&request, "title") .ok() @@ -1248,6 +1321,10 @@ mod tests { .as_array() .unwrap() .contains(&json!("file.importImage"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("file.importAudio"))); assert!(result["capabilities"] .as_array() .unwrap() @@ -1666,6 +1743,60 @@ mod tests { fs::remove_file(large_path).expect("remove large image"); } + #[test] + fn import_audio_file_payload_reads_allowed_audio_without_exposing_path() { + let path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-audio-{}.webm", + std::process::id() + )); + fs::write(&path, b"audio").expect("write import audio"); + + let payload = import_audio_file_payload(path.clone()).expect("payload"); + + assert_eq!(payload["action"], "selected"); + assert_eq!( + payload["fileName"], + path.file_name().unwrap().to_str().unwrap() + ); + assert_eq!(payload["base64Data"], "YXVkaW8="); + assert_eq!(payload["mimeType"], "audio/webm"); + assert_eq!(payload["bytes"], 5); + assert!(!payload + .to_string() + .contains(path.to_string_lossy().as_ref())); + + fs::remove_file(path).expect("remove import audio"); + } + + #[test] + fn import_audio_file_payload_rejects_invalid_or_oversized_audio() { + let text_path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-audio-{}.txt", + std::process::id() + )); + fs::write(&text_path, b"audio").expect("write text file"); + assert_eq!( + import_audio_file_payload(text_path.clone()).unwrap_err(), + "audio MIME must be allowed" + ); + fs::remove_file(text_path).expect("remove text file"); + + let large_path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-audio-large-{}.mp3", + std::process::id() + )); + fs::write( + &large_path, + vec![1u8; (IMPORT_AUDIO_MAX_BYTES + 1) as usize], + ) + .expect("write large audio"); + assert_eq!( + import_audio_file_payload(large_path.clone()).unwrap_err(), + "audio exceeds import size limit" + ); + fs::remove_file(large_path).expect("remove large audio"); + } + #[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 fe013d95..4c2b383e 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.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.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.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.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 baf17651..08765315 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -182,6 +182,7 @@ for (const snippet of [ 'file.importText', 'file.exportImage', 'file.importImage', + 'file.importAudio', 'clipboard.readText', 'notification.showLocal', 'network.status', @@ -196,6 +197,8 @@ for (const snippet of [ 'Clipboard.getStringAsync', 'ImagePicker.launchImageLibraryAsync', 'ImagePicker.requestMediaLibraryPermissionsAsync', + 'File(asset.uri)', + 'file.base64()', 'normalizeHostBridgeExportFileName', 'normalizeHostBridgeClipboardText', 'base64Data', @@ -232,6 +235,7 @@ for (const capability of [ 'file.importText', 'file.exportImage', 'file.importImage', + 'file.importAudio', 'haptics.impact', 'notification.showLocal', ]) { diff --git a/apps/mobile-shell/src/mobileHostBridge.test.ts b/apps/mobile-shell/src/mobileHostBridge.test.ts index e206a4e8..f2305849 100644 --- a/apps/mobile-shell/src/mobileHostBridge.test.ts +++ b/apps/mobile-shell/src/mobileHostBridge.test.ts @@ -50,6 +50,7 @@ vi.mock('expo-clipboard', () => ({ })); const fileTexts = vi.hoisted(() => new Map()); +const fileBase64Data = vi.hoisted(() => new Map()); const writtenFiles = vi.hoisted( () => @@ -83,6 +84,10 @@ vi.mock('expo-file-system', () => ({ text() { return Promise.resolve(fileTexts.get(this.uri) ?? ''); } + + base64() { + return Promise.resolve(fileBase64Data.get(this.uri) ?? ''); + } }, })); @@ -260,6 +265,7 @@ afterEach(() => { vi.mocked(Sharing.shareAsync).mockReset(); vi.mocked(Share.share).mockReset(); fileTexts.clear(); + fileBase64Data.clear(); writtenFiles.length = 0; resetMobileHostBridgeForTest(); }); @@ -286,6 +292,9 @@ describe('handleMobileHostBridgeMessage', () => { expect( (okResponse.result as { capabilities: string[] }).capabilities, ).toContain('file.importImage'); + expect( + (okResponse.result as { capabilities: string[] }).capabilities, + ).toContain('file.importAudio'); expect( (okResponse.result as { capabilities: string[] }).capabilities, ).toEqual( @@ -954,4 +963,88 @@ describe('handleMobileHostBridgeMessage', () => { expect(expectFailed(oversized).error.code).toBe('invalid_request'); }); + + test('file.importAudio 调起系统文档选择器并返回受控音频数据', async () => { + fileBase64Data.set('file:///private/mobile/hit.webm', 'YXVkaW8='); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/hit.webm', + name: ' ../敲击:音效?.webm ', + mimeType: 'audio/webm', + size: 5, + lastModified: 1, + }, + ], + }); + + const response = await send(request('file.importAudio')); + + expect(expectOk(response).result).toEqual({ + action: 'selected', + fileName: '敲击-音效-.webm', + base64Data: 'YXVkaW8=', + mimeType: 'audio/webm', + bytes: 5, + }); + expect(DocumentPicker.getDocumentAsync).toHaveBeenCalledWith({ + copyToCacheDirectory: true, + multiple: false, + type: [ + 'audio/mpeg', + 'audio/mp4', + 'audio/wav', + 'audio/ogg', + 'audio/webm', + ], + }); + }); + + test('file.importAudio 取消选择并拒绝非法 MIME 与超限音频', async () => { + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: true, + assets: null, + }); + + const cancelled = await send(request('file.importAudio')); + + expect(expectFailed(cancelled).error.code).toBe('cancelled'); + + fileBase64Data.set('file:///private/mobile/hit.txt', 'YXVkaW8='); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/hit.txt', + name: 'hit.txt', + mimeType: 'text/plain', + size: 5, + lastModified: 1, + }, + ], + }); + + const unsupportedMime = await send(request('file.importAudio')); + + expect(expectFailed(unsupportedMime).error.code).toBe('invalid_request'); + + fileBase64Data.set('file:///private/mobile/hit.webm', 'YXVkaW8='); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/hit.webm', + name: 'hit.webm', + mimeType: 'audio/webm', + size: 20 * 1024 * 1024 + 1, + lastModified: 1, + }, + ], + }); + + const oversized = await send(request('file.importAudio')); + + expect(expectFailed(oversized).error.code).toBe('invalid_request'); + }); }); diff --git a/apps/mobile-shell/src/mobileHostBridge.ts b/apps/mobile-shell/src/mobileHostBridge.ts index 99c3859b..6b065dce 100644 --- a/apps/mobile-shell/src/mobileHostBridge.ts +++ b/apps/mobile-shell/src/mobileHostBridge.ts @@ -20,11 +20,13 @@ import { type FileExportImageResult, type FileExportTextPayload, type FileExportTextResult, + type FileImportAudioResult, type FileImportImageResult, type FileImportTextResult, type HapticsImpactPayload, HOST_BRIDGE_PROTOCOL, HOST_BRIDGE_VERSION, + type HostBridgeAudioMimeType, type HostBridgeCapability, type HostBridgeError, type HostBridgeImageMimeType, @@ -51,6 +53,7 @@ const EXPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024; const EXPORT_IMAGE_MAX_BYTES = 5 * 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; const LOCAL_NOTIFICATION_CHANNEL_ID = 'genarrative-local'; const HOST_BRIDGE_TEXT_MIME_TYPES = new Set([ 'text/plain', @@ -63,6 +66,13 @@ const HOST_BRIDGE_IMAGE_MIME_TYPES = new Set([ 'image/jpeg', 'image/webp', ]); +const HOST_BRIDGE_AUDIO_MIME_TYPES = new Set([ + 'audio/mpeg', + 'audio/mp4', + 'audio/wav', + 'audio/ogg', + 'audio/webm', +]); Notifications.setNotificationHandler({ handleNotification: async () => ({ @@ -92,6 +102,7 @@ export const MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [ 'file.importText', 'file.exportImage', 'file.importImage', + 'file.importAudio', 'haptics.impact', 'notification.showLocal', ]; @@ -438,6 +449,37 @@ function fallbackImportedImageFileName(mimeType: HostBridgeImageMimeType) { return 'genarrative-import.png'; } +function normalizeImportedAudioMimeType( + value: unknown, + fileName: string, +): HostBridgeAudioMimeType | null { + if (typeof value === 'string') { + const mimeType = value.toLowerCase(); + if (HOST_BRIDGE_AUDIO_MIME_TYPES.has(mimeType as HostBridgeAudioMimeType)) { + return mimeType as HostBridgeAudioMimeType; + } + } + + const normalizedName = fileName.toLowerCase(); + if (normalizedName.endsWith('.mp3')) { + return 'audio/mpeg'; + } + if (normalizedName.endsWith('.m4a') || normalizedName.endsWith('.mp4')) { + return 'audio/mp4'; + } + if (normalizedName.endsWith('.wav')) { + return 'audio/wav'; + } + if (normalizedName.endsWith('.ogg')) { + return 'audio/ogg'; + } + if (normalizedName.endsWith('.webm')) { + return 'audio/webm'; + } + + return null; +} + async function importImageFile(): Promise { const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (permission.status !== ImagePicker.PermissionStatus.GRANTED) { @@ -499,6 +541,60 @@ async function importImageFile(): Promise { }; } +async function importAudioFile(): Promise { + const result = await DocumentPicker.getDocumentAsync({ + copyToCacheDirectory: true, + multiple: false, + type: [ + 'audio/mpeg', + 'audio/mp4', + 'audio/wav', + 'audio/ogg', + 'audio/webm', + ], + }); + if (result.canceled) { + throw { + code: 'cancelled', + message: 'file import cancelled', + } satisfies HostBridgeError; + } + + const asset = result.assets[0]; + if (!asset?.uri) { + throw invalidRequest('audio file is required'); + } + + const fileName = normalizeHostBridgeExportFileName( + asset.name || 'genarrative-import-audio.webm', + ); + const mimeType = normalizeImportedAudioMimeType(asset.mimeType, fileName); + if (!mimeType) { + throw invalidRequest('mimeType must be an allowed audio type'); + } + if ( + typeof asset.size === 'number' && + (asset.size <= 0 || asset.size > IMPORT_AUDIO_MAX_BYTES) + ) { + throw invalidRequest('audio exceeds file import size limit'); + } + + const file = new File(asset.uri); + const base64Data = await file.base64(); + const bytes = base64DecodedByteLength(base64Data); + if (bytes <= 0 || bytes > IMPORT_AUDIO_MAX_BYTES) { + throw invalidRequest('audio exceeds file import size limit'); + } + + return { + action: 'selected', + fileName, + base64Data, + mimeType, + bytes, + }; +} + async function runHaptics(payload: unknown) { const style = (payload as HapticsImpactPayload | undefined)?.style; const impactStyle = @@ -733,6 +829,8 @@ async function handleRequest(request: HostBridgeRequest) { return ok(request, await exportImageFile(request.payload)); case 'file.importImage': return ok(request, await importImageFile()); + case 'file.importAudio': + return ok(request, await importAudioFile()); 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 ff742300..f54cf3f1 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2290,3 +2290,10 @@ - 决策:新增 HostBridge method `app.reloadWebView` 和 H5 facade `reloadHostWebView()`。移动端只调用当前 `react-native-webview` 的 `reload()`,桌面端只调用 Tauri 主 `WebviewWindow.reload()`;该 method 不接受 payload,成功只表示宿主已发起刷新,刷新后当前 H5 上下文会卸载。继续把同源跳转留给 `navigation.openNativePage`,外链离开容器留给 `app.openExternalUrl`。 - 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/hostBridge.ts`、`apps/mobile-shell/`、`apps/desktop-shell/`、原生壳能力检查脚本和 HostBridge 架构文档。 - 验证方式:`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + +## 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')` 音频处理链路。 +- 影响范围:`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 902d860a..b61001f3 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -130,6 +130,7 @@ type HostBridgeEvent = { | `file.exportText` | 导出文本到用户选择的本地文件 | 支持系统分享 / 保存面板 | 支持系统保存对话框 | | `file.importText` | 导入用户选择的文本文件 | 支持系统文档选择器 | 支持系统选择文本文件 | | `file.importImage` | 导入用户选择的图片文件 | 支持系统相册选择图片 | 支持系统选择图片 | +| `file.importAudio` | 导入用户选择的音频文件 | 支持系统文档选择器 | 支持系统选择音频文件 | | `file.imageDropped` | 通知 H5 桌面拖入图片 | 不声明 | 支持主窗口拖拽图片事件 | | `haptics.impact` | 轻量触感反馈 | 支持 | 不声明 | | `notification.showLocal` | 发送即时本地系统通知 | 支持 Expo Notifications | 支持 Rust 侧 Tauri notification | @@ -253,7 +254,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`、`haptics.impact`、`notification.showLocal` 和 Android 返回键回退;其中 `appearance.getColorScheme` 只读系统配色偏好,不强改 H5 或系统主题;`app.lifecycle` 通过 React Native `AppState` 注入 `active` / `inactive` / `background` 统一状态,供 H5 游戏循环、音频和轮询做真实暂停 / 恢复判断,H5 的 `useHostLifecycleActive()` 会把该事件归一成运行态可播放状态,WebAudio 背景音乐和拼图、抓大鹅等固定玩法 `