diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index b4a747d4a..d656d2861 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -1055,6 +1055,10 @@ const sharedHostBridgePayloadLimits = { sharedContractSource, 'HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES', ), + HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES: extractTsNumberConst( + sharedContractSource, + 'HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES', + ), HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES: extractTsNumberConst( sharedContractSource, 'HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES', @@ -1105,6 +1109,10 @@ const desktopHostBridgePayloadLimits = { rustHostSource, 'IMPORT_TEXT_MAX_BYTES', ), + HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES: extractRustNumberConst( + rustHostSource, + 'IMPORT_DOCUMENT_MAX_BYTES', + ), HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES: extractRustNumberConst( rustHostSource, 'EXPORT_IMAGE_MAX_BYTES', @@ -1593,6 +1601,7 @@ const requiredRustHostSnippets = [ '"clipboard.readText"', '"file.exportText"', '"file.importText"', + '"file.importDocument"', '"file.exportImage"', '"file.importImage"', '"file.importAudio"', diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs b/apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs index a37c49eb0..81ec74679 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs @@ -18,6 +18,7 @@ pub(crate) fn capabilities() -> Vec<&'static str> { "clipboard.readText", "file.exportText", "file.importText", + "file.importDocument", "file.exportImage", "file.importImage", "file.importAudio", @@ -55,6 +56,7 @@ mod tests { "clipboard.readText", "file.exportText", "file.importText", + "file.importDocument", "file.exportImage", "file.importImage", "file.importAudio", diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs b/apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs index e638a9e4f..2af5e9563 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs @@ -1,7 +1,8 @@ use crate::host_bridge::capabilities::capabilities; use crate::host_bridge::files::{ export_audio_payload, export_image_payload, export_text_payload, import_audio_file_payload, - import_image_file_payload, import_text_file_payload, write_export_bytes_file, + import_document_file_payload, import_image_file_payload, import_text_file_payload, + write_export_bytes_file, write_export_text_file, }; use crate::host_bridge::protocol::{ @@ -313,6 +314,28 @@ pub(super) async fn execute_host_bridge_request( Err(error) => failed(request.id, "host_error", error.to_string()), } } + "file.importDocument" => { + let file_path = app + .dialog() + .file() + .add_filter("Document", &["txt", "md", "markdown", "csv", "json", "docx"]) + .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_document_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()), + } + } "file.exportImage" => { let (file_name, bytes) = match export_image_payload(&request) { Ok(payload) => payload, @@ -608,6 +631,10 @@ mod tests { .as_array() .unwrap() .contains(&json!("file.importText"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("file.importDocument"))); assert!(result["capabilities"] .as_array() .unwrap() diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/files.rs b/apps/desktop-shell/src-tauri/src/host_bridge/files.rs index 5334e6bd7..7136d3e39 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/files.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/files.rs @@ -8,6 +8,7 @@ pub(crate) 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; pub(crate) const IMPORT_TEXT_MAX_BYTES: u64 = 5 * 1024 * 1024; +const IMPORT_DOCUMENT_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"; @@ -107,6 +108,20 @@ fn import_text_mime_type(path: &Path) -> Option<&'static str> { } } +fn import_document_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("docx") => { + Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document") + } + _ => import_text_mime_type(path), + } +} + pub(crate) fn import_text_file_payload(path: PathBuf) -> Result { if !path.is_file() { return Err("text file is required".to_string()); @@ -140,6 +155,39 @@ pub(crate) fn import_text_file_payload(path: PathBuf) -> Result { })) } +pub(crate) fn import_document_file_payload(path: PathBuf) -> Result { + if !path.is_file() { + return Err("document file is required".to_string()); + } + + let mime_type = import_document_mime_type(&path) + .ok_or_else(|| "document 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_DOCUMENT_MAX_BYTES { + return Err("document 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_DOCUMENT_MAX_BYTES { + return Err("document 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-document.txt".to_string()); + + Ok(json!({ + "action": "selected", + "fileName": file_name, + "base64Data": BASE64_STANDARD.encode(bytes), + "mimeType": mime_type, + "bytes": byte_count, + })) +} + fn export_image_extension(mime_type: &str) -> Option<&'static str> { match mime_type { "image/png" => Some("png"), @@ -652,6 +700,81 @@ mod tests { fs::remove_file(large_path).expect("remove large text"); } + #[test] + fn import_document_file_payload_reads_docx_without_exposing_path() { + let path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-document-{}.docx", + std::process::id() + )); + let bytes = b"PK\x03\x04docx".to_vec(); + fs::write(&path, &bytes).expect("write import document"); + + let payload = import_document_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"], BASE64_STANDARD.encode(&bytes)); + assert_eq!( + payload["mimeType"], + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ); + assert_eq!(payload["bytes"], bytes.len() as u64); + assert!(!payload + .to_string() + .contains(path.to_string_lossy().as_ref())); + + fs::remove_file(path).expect("remove import document"); + } + + #[test] + fn import_document_file_payload_reuses_text_document_mime_types() { + let path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-document-{}.md", + std::process::id() + )); + fs::write(&path, "暖灯猫街").expect("write import document"); + + let payload = import_document_file_payload(path.clone()).expect("payload"); + + assert_eq!(payload["base64Data"], BASE64_STANDARD.encode("暖灯猫街")); + assert_eq!(payload["mimeType"], "text/markdown"); + assert_eq!(payload["bytes"], "暖灯猫街".len() as u64); + + fs::remove_file(path).expect("remove import document"); + } + + #[test] + fn import_document_file_payload_rejects_invalid_or_oversized_documents() { + let image_path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-document-{}.png", + std::process::id() + )); + fs::write(&image_path, b"text").expect("write image-like document"); + assert_eq!( + import_document_file_payload(image_path.clone()).unwrap_err(), + "document MIME must be allowed" + ); + fs::remove_file(image_path).expect("remove image-like document"); + + let large_path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-document-large-{}.docx", + std::process::id() + )); + fs::write( + &large_path, + vec![b'a'; IMPORT_DOCUMENT_MAX_BYTES as usize + 1], + ) + .expect("write large document"); + assert_eq!( + import_document_file_payload(large_path.clone()).unwrap_err(), + "document exceeds import size limit" + ); + fs::remove_file(large_path).expect("remove large document"); + } + #[test] fn export_image_payload_decodes_allowed_image_base64() { let mut valid = request("file.exportImage"); diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs b/apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs index 4211c84d6..ca9810728 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs @@ -5,7 +5,7 @@ use std::sync::{Arc, Condvar, Mutex}; pub(crate) const HOST_BRIDGE_PROTOCOL: &str = "GenarrativeHostBridge"; pub(crate) const HOST_BRIDGE_VERSION: u8 = 1; -pub(crate) const HOST_BRIDGE_METHODS: [&str; 24] = [ +pub(crate) const HOST_BRIDGE_METHODS: [&str; 25] = [ "host.getRuntime", "appearance.getColorScheme", "auth.requestLogin", @@ -22,6 +22,7 @@ pub(crate) const HOST_BRIDGE_METHODS: [&str; 24] = [ "clipboard.readText", "file.exportText", "file.importText", + "file.importDocument", "file.exportImage", "file.importImage", "file.captureImage", diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 3395151de..c30133111 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -641,6 +641,7 @@ for (const boundaryImport of sharedPayloadBoundaryImports) { const forbiddenLocalPayloadBoundaryDeclarations = [ 'EXPORT_TEXT_MAX_BYTES', 'IMPORT_TEXT_MAX_BYTES', + 'IMPORT_DOCUMENT_MAX_BYTES', 'EXPORT_IMAGE_MAX_BYTES', 'IMPORT_IMAGE_MAX_BYTES', 'EXPORT_AUDIO_MAX_BYTES', @@ -648,12 +649,14 @@ const forbiddenLocalPayloadBoundaryDeclarations = [ 'HOST_BRIDGE_BADGE_COUNT_MAX', 'HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES', 'HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES', + 'HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES', 'HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES', 'HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES', 'HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES', 'HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES', 'HOST_BRIDGE_QR_CODE_VALUE_MAX_LENGTH', 'HOST_BRIDGE_TEXT_MIME_TYPES', + 'HOST_BRIDGE_DOCUMENT_MIME_TYPES', 'HOST_BRIDGE_IMAGE_MIME_TYPES', 'HOST_BRIDGE_AUDIO_MIME_TYPES', ]; @@ -671,6 +674,7 @@ if (!hostBridgeSource.includes('function assertImportedFileSizeWithinLimit')) { for (const [functionName, readCall] of [ ['importTextFile', 'file.text()'], + ['importDocumentFile', 'file.base64()'], ['importAudioFile', 'file.base64()'], ]) { const functionBody = extractFunctionBody(hostBridgeSource, functionName); @@ -1339,6 +1343,7 @@ if ( for (const snippet of [ 'file.exportText', 'file.importText', + 'file.importDocument', 'file.exportImage', 'file.importImage', 'file.captureImage', @@ -1367,6 +1372,7 @@ for (const snippet of [ 'cancelQrCodeScan', 'failQrCodeScan', 'subscribeQrScannerState', + 'MOBILE_DOCUMENT_PICKER_TYPES', 'MOBILE_AUDIO_DOCUMENT_PICKER_TYPES', "'audio/*'", 'File(asset.uri)', @@ -1495,6 +1501,7 @@ for (const capability of [ 'clipboard.readText', 'file.exportText', 'file.importText', + 'file.importDocument', 'file.exportImage', 'file.importImage', 'file.captureImage', diff --git a/apps/mobile-shell/src/host-bridge/bridge.test.ts b/apps/mobile-shell/src/host-bridge/bridge.test.ts index 5329141cf..f6a4c4e81 100644 --- a/apps/mobile-shell/src/host-bridge/bridge.test.ts +++ b/apps/mobile-shell/src/host-bridge/bridge.test.ts @@ -77,6 +77,7 @@ 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]); +const DOCX_BASE64 = Buffer.from('PK\x03\x04docx', 'binary').toString('base64'); vi.mock('expo-clipboard', () => ({ getStringAsync: vi.fn(), @@ -369,6 +370,9 @@ describe('handleMobileHostBridgeMessage', () => { expect( (okResponse.result as { capabilities: string[] }).capabilities, ).toContain('file.importText'); + expect( + (okResponse.result as { capabilities: string[] }).capabilities, + ).toContain('file.importDocument'); expect( (okResponse.result as { capabilities: string[] }).capabilities, ).toContain('file.importImage'); @@ -1107,6 +1111,130 @@ describe('handleMobileHostBridgeMessage', () => { expect(expectFailed(oversized).error.code).toBe('invalid_request'); }); + test('file.importDocument 调起系统文档选择器并返回受控 DOCX 数据', async () => { + fileBase64Data.set('file:///private/mobile/world.docx', DOCX_BASE64); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/world.docx', + name: ' ../世界:设定?.docx ', + mimeType: + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + size: 8, + lastModified: 1, + }, + ], + }); + + const response = await send(request('file.importDocument')); + + expect(expectOk(response).result).toEqual({ + action: 'selected', + fileName: '世界-设定-.docx', + base64Data: DOCX_BASE64, + mimeType: + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + bytes: 8, + }); + expect(DocumentPicker.getDocumentAsync).toHaveBeenCalledWith({ + copyToCacheDirectory: true, + multiple: false, + type: [ + 'text/*', + 'text/plain', + 'text/markdown', + 'text/csv', + 'application/json', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ], + }); + }); + + test('file.importDocument 在系统选择结果缺少 size 时先用文件大小门禁', async () => { + fileBase64Data.set('file:///private/mobile/world.docx', DOCX_BASE64); + fileSizes.set('file:///private/mobile/world.docx', 8); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/world.docx', + name: 'world.docx', + mimeType: 'application/octet-stream', + lastModified: 1, + }, + ], + }); + + const response = await send(request('file.importDocument')); + + expect(expectOk(response).result).toEqual({ + action: 'selected', + fileName: 'world.docx', + base64Data: DOCX_BASE64, + mimeType: + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + bytes: 8, + }); + expect(fileBase64Reads).toEqual(['file:///private/mobile/world.docx']); + + fileSizes.set('file:///private/mobile/world.docx', 5 * 1024 * 1024 + 1); + fileBase64Reads.length = 0; + + const oversized = await send(request('file.importDocument')); + + expect(expectFailed(oversized).error.code).toBe('invalid_request'); + expect(fileBase64Reads).toEqual([]); + }); + + test('file.importDocument 取消选择并拒绝非法 MIME 与超限文档', async () => { + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: true, + assets: null, + }); + + const cancelled = await send(request('file.importDocument')); + + expect(expectFailed(cancelled).error.code).toBe('cancelled'); + + fileBase64Data.set('file:///private/mobile/story.png', DOCX_BASE64); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/story.png', + name: 'story.png', + mimeType: 'image/png', + size: 8, + lastModified: 1, + }, + ], + }); + + const unsupportedMime = await send(request('file.importDocument')); + + expect(expectFailed(unsupportedMime).error.code).toBe('invalid_request'); + + fileBase64Data.set('file:///private/mobile/world.docx', DOCX_BASE64); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/world.docx', + name: 'world.docx', + mimeType: + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + size: 5 * 1024 * 1024 + 1, + lastModified: 1, + }, + ], + }); + + const oversized = await send(request('file.importDocument')); + + expect(expectFailed(oversized).error.code).toBe('invalid_request'); + }); + test('file.exportImage 写入缓存图片并调起系统分享', async () => { const response = await send( request('file.exportImage', { diff --git a/apps/mobile-shell/src/host-bridge/dispatch.ts b/apps/mobile-shell/src/host-bridge/dispatch.ts index 771a50816..29b33dd44 100644 --- a/apps/mobile-shell/src/host-bridge/dispatch.ts +++ b/apps/mobile-shell/src/host-bridge/dispatch.ts @@ -36,6 +36,7 @@ import { exportImageFile, exportTextFile, importAudioFile, + importDocumentFile, importImageFile, importTextFile, } from './files'; @@ -277,6 +278,8 @@ export async function dispatchMobileHostBridgeRequest( return ok(request, await exportTextFile(request.payload)); case 'file.importText': return ok(request, await importTextFile()); + case 'file.importDocument': + return ok(request, await importDocumentFile()); case 'file.exportImage': return ok(request, await exportImageFile(request.payload)); case 'file.importImage': diff --git a/apps/mobile-shell/src/host-bridge/files.ts b/apps/mobile-shell/src/host-bridge/files.ts index 8377cdc51..51149dd07 100644 --- a/apps/mobile-shell/src/host-bridge/files.ts +++ b/apps/mobile-shell/src/host-bridge/files.ts @@ -11,18 +11,22 @@ import { type FileExportTextPayload, type FileExportTextResult, type FileImportAudioResult, + type FileImportDocumentResult, type FileImportImageResult, type FileImportTextResult, type HostBridgeAudioMimeType, + type HostBridgeDocumentMimeType, type HostBridgeError, type HostBridgeImageMimeType, type HostBridgeTextMimeType, HOST_BRIDGE_AUDIO_MIME_TYPES, + HOST_BRIDGE_DOCUMENT_MIME_TYPES, HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES, HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES, HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES, HOST_BRIDGE_IMAGE_MIME_TYPES, HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES, + HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES, HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES, HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES, HOST_BRIDGE_TEXT_MIME_TYPES, @@ -33,6 +37,9 @@ import { invalidRequest } from './protocol'; const HOST_BRIDGE_TEXT_MIME_TYPE_SET = new Set( HOST_BRIDGE_TEXT_MIME_TYPES, ); +const HOST_BRIDGE_DOCUMENT_MIME_TYPE_SET = new Set( + HOST_BRIDGE_DOCUMENT_MIME_TYPES, +); const HOST_BRIDGE_IMAGE_MIME_TYPE_SET = new Set( HOST_BRIDGE_IMAGE_MIME_TYPES, ); @@ -43,6 +50,10 @@ export const MOBILE_AUDIO_DOCUMENT_PICKER_TYPES = [ 'audio/*', ...HOST_BRIDGE_AUDIO_MIME_TYPES, ]; +export const MOBILE_DOCUMENT_PICKER_TYPES = [ + 'text/*', + ...HOST_BRIDGE_DOCUMENT_MIME_TYPES, +]; const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; @@ -294,6 +305,31 @@ function normalizeImportedTextMimeType( return null; } +function normalizeImportedDocumentMimeType( + value: unknown, + fileName: string, +): HostBridgeDocumentMimeType | null { + if (typeof value === 'string') { + const mimeType = value.toLowerCase(); + if ( + HOST_BRIDGE_DOCUMENT_MIME_TYPE_SET.has( + mimeType as HostBridgeDocumentMimeType, + ) + ) { + return mimeType as HostBridgeDocumentMimeType; + } + } + + const textMimeType = normalizeImportedTextMimeType(value, fileName); + if (textMimeType) { + return textMimeType; + } + + return fileName.toLowerCase().endsWith('.docx') + ? 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + : null; +} + function assertImportedFileSizeWithinLimit( pickerSize: unknown, file: File, @@ -372,6 +408,57 @@ export async function importTextFile(): Promise { }; } +export async function importDocumentFile(): Promise { + const result = await DocumentPicker.getDocumentAsync({ + copyToCacheDirectory: true, + multiple: false, + type: MOBILE_DOCUMENT_PICKER_TYPES, + }); + if (result.canceled) { + throw { + code: 'cancelled', + message: 'file import cancelled', + } satisfies HostBridgeError; + } + + const asset = result.assets[0]; + if (!asset?.uri) { + throw invalidRequest('document file is required'); + } + + const fileName = normalizeHostBridgeExportFileName( + asset.name || 'genarrative-import-document.txt', + ); + const mimeType = normalizeImportedDocumentMimeType(asset.mimeType, fileName); + if (!mimeType) { + throw invalidRequest('mimeType must be an allowed document type'); + } + + const file = new File(asset.uri); + assertImportedFileSizeWithinLimit( + asset.size, + file, + HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES, + 'document exceeds file import size limit', + ); + const base64Data = normalizedBase64Data(await file.base64()); + if (!base64Data) { + throw invalidRequest('base64Data is required'); + } + const bytes = base64DecodedByteLength(base64Data); + if (bytes <= 0 || bytes > HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES) { + throw invalidRequest('document exceeds file import size limit'); + } + + return { + action: 'selected', + fileName, + base64Data, + mimeType, + bytes, + }; +} + export async function exportImageFile( payload: unknown, ): Promise { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 70ccda8ee..ea2e9afc5 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -75,6 +75,7 @@ - 2026-06-19 草稿生成 HostBridge 消费门禁:`PlatformEntryFlowShellImpl` 只负责派生草稿通知和未读数量,实际宿主同步经 `platformHostBridgeSync.ts` 调用 `showHostLocalNotification` / `setHostAppBadgeCount`;`npm run check:native-shells` 必须运行该同步层的真实 Tauri transport 测试,并继续覆盖通知模型、未读计数模型、音频导入和文档导入等 H5 HostBridge 消费测试。 - 2026-06-18 剪贴板读取能力:新增 `clipboard.readText` HostBridge capability,H5 只能读取纯文本结果,契约限制返回文本最多 100000 字符;Expo 壳通过 `expo-clipboard` 读取系统剪贴板文本,Tauri 壳通过 Rust 侧 `tauri-plugin-clipboard-manager` 读取文本且不开放插件 JS guest API。该能力不读取图片、HTML、文件列表或剪贴板监听事件,宿主未声明或读取失败时由 H5 视作失败并保留原流程。 - 2026-06-18 文本文件导入能力:新增 `file.importText` HostBridge capability,H5 统一通过 `importHostTextFile()` 读取宿主返回的纯文本内容;Expo 壳通过 `expo-document-picker` 打开系统文档选择器,Tauri 壳通过系统文件选择框读取真实文本文件。两端只接受 `text/plain`、`text/markdown`、`text/csv`、`application/json` 或对应扩展名,单次不超过 5 MiB,成功只返回清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露设备 URI / 本机绝对路径,也不开放通用文件系统。 +- 2026-06-19 文档文件导入能力:新增 `file.importDocument` HostBridge capability,作为创作 Agent 工作台优先导入路径;Expo 壳通过 DocumentPicker、Tauri 壳通过系统文件选择框读取文本类文档或 DOCX 副本。两端只接受文本 MIME / DOCX MIME 或对应扩展名,单次不超过 5 MiB,成功只返回清洗后的文件名、MIME、base64 内容和字节数,不暴露设备 URI、本机绝对路径,也不开放通用文件系统;H5 把返回内容转换成浏览器 `File` 后继续走后端 `/api/runtime/creation-agent/document-inputs/parse`,不在前端解析 DOCX。旧壳只声明 `file.importText` 时继续使用文本导入兜底。 - 2026-06-18 Tauri 系统托盘:桌面壳启用真实 OS 托盘并复用品牌图标,托盘菜单只执行显示主窗口、刷新主窗口和退出应用,左键点击托盘图标恢复并聚焦主窗口;该能力归桌面壳自身,不进入 HostBridge capability,不向 H5 暴露托盘、菜单、shell 或任意窗口控制 API。托盘注册成功时主窗口关闭按钮只隐藏到托盘,必须通过托盘“退出”结束应用;托盘注册失败不得阻断主窗口启动,也不得拦截关闭,避免窗口消失后无法恢复。`check:native-shells` 和 Tauri cargo test 覆盖托盘配置、菜单动作映射和关闭策略。 - 2026-06-18 Tauri 单实例:桌面壳启用 `tauri-plugin-single-instance` 并要求该插件最先注册;重复启动 App 时第二实例退出,只唤醒、取消最小化并聚焦已有主窗口,不把第二实例 argv / cwd 作为事件透传给 H5。Windows / Linux 的二次实例深链只通过 single-instance 的 `deep-link` feature 交给 Tauri deep-link 插件,再由 `shell/deep_link.rs` 做受控 URL 归一。 - 2026-06-18 Tauri 桌面深链:桌面壳启用 `tauri-plugin-deep-link`,但不安装 JS guest 包、不把 deep-link command 加入主窗口 capability,也不新增 HostBridge capability。Tauri 配置只注册 `genarrative` scheme;Rust 层只接受 `genarrative://open/...`、`genarrative://app/...`、`genarrative://` 和 `https://app.genarrative.world/...`,统一跳转到同源 H5 并补写 `native_app`、`tauri_desktop`、当前平台、版本和真实 capability 清单;外域、明文协议和危险协议不进入主 WebView。 @@ -2377,7 +2378,7 @@ ## 2026-06-18 创作 Agent 文档上传接入原生壳文本导入 - 背景:创作 Agent 工作台已有“上传文档”入口,但在 Expo / Tauri 壳内仍只触发浏览器隐藏文件输入;移动和桌面壳已经具备 `file.importText` 的真实系统文档选择能力。 -- 决策:创作 Agent 工作台在 `native_app` 且宿主声明 `file.importText` 时优先调用 `importHostTextFile()`,将宿主返回的 UTF-8 文本副本转换成浏览器 `File` 后继续调用现有 `/api/runtime/creation-agent/document-inputs/parse`。原生壳只负责受控选择和返回文本内容,不暴露设备 URI、本机路径或通用文件系统,也不在前端绕过后端文档解析、256KB 解析限制、docx 支持或错误口径。普通浏览器、小程序和未声明该能力的裁剪壳继续使用原 `` 路径。 +- 决策:创作 Agent 工作台在 `native_app` 且宿主声明 `file.importDocument` 时优先调用 `importHostDocumentFile()`,把宿主返回的文本类文档或 DOCX base64 副本转换成浏览器 `File` 后继续调用现有 `/api/runtime/creation-agent/document-inputs/parse`;旧壳只声明 `file.importText` 时才调用 `importHostTextFile()` 兜底。原生壳只负责受控选择和返回文档副本,不暴露设备 URI、本机路径或通用文件系统,也不在前端绕过后端文档解析、256KB 解析限制、docx 支持或错误口径。普通浏览器、小程序和未声明能力的裁剪壳继续使用原 `` 路径。 - 影响范围:`src/components/creation-agent/CreationAgentWorkspace.tsx`、`src/services/host-bridge/hostBridge.ts`、Expo / Tauri HostBridge 文档。 - 验证方式:`npm run check:native-shells`、`npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 @@ -2545,7 +2546,7 @@ ## 2026-06-19 HostBridge 载荷边界单一来源 - 背景:文件导入导出、剪贴板、角标、本地通知和 request id 都已经在 Expo 与 Tauri 两套壳里有运行时校验;如果 MIME 清单、字节上限或文本长度只靠人工同步,新增文件类型或调整上限时会出现 H5 契约、移动壳和桌面壳互相漂移。 -- 决策:`packages/shared/src/contracts/hostBridge.ts` 是 HostBridge 载荷边界的声明来源,导出文本 / 图片 / 音频 MIME 清单、导入 / 导出字节上限、导出文件名 fallback / 长度上限、request id 长度、角标上限、剪贴板文本长度和本地通知标题 / 正文长度。Expo 移动壳必须直接导入这些共享常量,`apps/mobile-shell/scripts/check-config.mjs` 会拒绝移动壳重新本地声明文件大小或 MIME 清单;移动壳 `file.importText` / `file.importAudio` 必须在读取文本内容或音频 base64 前,通过 picker `size` 或 Expo `File.size` 拿到可信 byte count 并完成上限校验,无法拿到可信大小时直接拒绝导入。Tauri 桌面壳按 Rust 运行时代码镜像实现,`apps/desktop-shell/scripts/check-config.mjs` 必须反查共享契约并拒绝漂移。 +- 决策:`packages/shared/src/contracts/hostBridge.ts` 是 HostBridge 载荷边界的声明来源,导出文本 / 图片 / 音频 MIME 清单、文档导入 MIME 清单、导入 / 导出字节上限、导出文件名 fallback / 长度上限、request id 长度、角标上限、剪贴板文本长度和本地通知标题 / 正文长度。Expo 移动壳必须直接导入这些共享常量,`apps/mobile-shell/scripts/check-config.mjs` 会拒绝移动壳重新本地声明文件大小或 MIME 清单;移动壳 `file.importText` / `file.importDocument` / `file.importAudio` 必须在读取文本内容或 base64 前,通过 picker `size` 或 Expo `File.size` 拿到可信 byte count 并完成上限校验,无法拿到可信大小时直接拒绝导入。Tauri 桌面壳按 Rust 运行时代码镜像实现,`apps/desktop-shell/scripts/check-config.mjs` 必须反查共享契约并拒绝漂移。 - 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`apps/mobile-shell/src/host-bridge/files.ts`、`apps/mobile-shell/scripts/check-config.mjs`、`apps/desktop-shell/src-tauri/src/host_bridge/`、`apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。 - 验证方式:`npm run mobile-shell:typecheck`、`npm run desktop-shell:typecheck`、`npm run test -- packages/shared/src/contracts/hostBridge.test.ts`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 79d078736..d6f2a1e29 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -217,6 +217,7 @@ npm run check:native-shells ``` 该命令会覆盖 H5 HostBridge 关键测试、微信 / Expo / Tauri 三端桥接层文件结构门禁、完整相对路径文档反查、H5 HostBridge 事件订阅双能力门控反查、移动端和桌面端单端源码清单门禁、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面壳 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描,确认 Expo managed config、移动端 iOS / Android production bundle、打包 H5 资产、Tauri release 入口和 H5 HostBridge 真实调用链没有漂移;扫描范围包含微信小程序壳生产 `.js`、共享 HostBridge 契约、H5 native transport,并自动覆盖已接入真实宿主能力 facade 的 H5 生产调用链文件。壳源码和配置继续严格禁止 mock / fake / placeholder / stub / TODO / FIXME / 占位 / 模拟 / 伪造;H5 业务调用链允许正常表单 `placeholder` 属性和业务占位图文案,但仍禁止 mock / fake / stub / TODO / FIXME / 模拟 / 伪造等替身痕迹。 +创作 Agent 原生壳文档导入优先走 `file.importDocument`,旧壳只声明 `file.importText` 时才回退文本导入;相关变更必须让根级和单端门禁覆盖共享 method、capability profile、文档 MIME / 5 MiB 上限、读取前 size 校验,以及 H5 base64 转 `File` 后继续走后端文档解析的链路。 该命令会反查微信小程序 `WECHAT_HOST_CAPABILITIES` 与共享 `HOST_BRIDGE_WECHAT_MINI_PROGRAM_CAPABILITIES` 一致;小程序生产代码继续保留 CommonJS 运行时镜像,不直接 import TypeScript shared 包。 该命令同时会运行微信小程序 `miniprogram/host-bridge/`、`miniprogram/shell/`、`pages/web-view` 样式和 `scripts/miniprogram-web-view-auth.test.ts` 的壳层测试,保证微信桥接层拆分后的支付、订阅消息、九宫切图、分享目标和 WebView 登录 / 分享入口行为与 Expo、Tauri 壳一起验收。 该命令还会反查微信小程序 `app.json.pages` 与 `host-bridge/protocol.js` 页面 URL、H5 小程序页面常量、H5 订阅授权页面常量、WebView 分享入口、分享目标消息类型、`WEB_VIEW_SOURCE_QUERY`、微信请求头运行时标记、H5 runtime parser、H5 路由保留字段和 H5 / API base URL 格式,避免页面路由、来源标记、宿主上下文 query 或域名配置在微信壳、H5 HostBridge 与运行时配置之间分叉。生产 / 开发 H5 与 API 域名都必须显式配置为纯 HTTPS domain,运行时开发域名回退生产域名只作为异常兜底。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index d95b2fe1e..1c2f44b1d 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -149,6 +149,7 @@ type HostBridgeEvent = { | `clipboard.readText` | 读取纯文本剪贴板 | 支持 | 支持 | | `file.exportText` | 导出文本到用户选择的本地文件 | 支持系统分享 / 保存面板 | 支持系统保存对话框 | | `file.importText` | 导入用户选择的文本文件 | 支持系统文档选择器 | 支持系统选择文本文件 | +| `file.importDocument` | 导入用户选择的文本 / DOCX 文档副本 | 支持系统文档选择器 | 支持系统选择文档文件 | | `file.exportImage` | 导出当前 H5 已持有的图片文件 | 支持系统分享 / 保存面板 | 支持系统保存对话框 | | `file.importImage` | 导入用户选择的图片文件 | 支持系统相册选择图片 | 支持系统选择图片 | | `file.captureImage` | 拍摄图片并导入当前 H5 流程 | 支持系统相机拍照 | 不声明 | @@ -297,9 +298,9 @@ 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.captureImage`、`scanner.scanQrCode`、`file.importAudio`、`file.exportAudio`、`haptics.impact`、`notification.showLocal` 和 Android 返回键回退;其中 `appearance.getColorScheme` 只读系统配色偏好,不强改 H5 或系统主题;`app.lifecycle` 通过 React Native `AppState` 注入 `active` / `inactive` / `background` 统一状态,供 H5 游戏循环、音频和轮询做真实暂停 / 恢复判断,H5 的 `useHostLifecycleActive()` 会把该事件归一成运行态可播放状态,WebAudio 背景音乐和拼图、抓大鹅等固定玩法 `