diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index aa3c8c46..744d37a4 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -145,6 +145,7 @@ const requiredMainSnippets = [ '"clipboard.writeText"', '"clipboard.readText"', '"file.exportText"', + '"file.importText"', '"file.exportImage"', '"file.importImage"', '"file.imageDropped"', @@ -157,6 +158,7 @@ const requiredMainSnippets = [ '"file import cancelled"', 'BASE64_STANDARD.decode', 'blocking_pick_file', + 'import_text_file_payload', 'import_image_file_payload', 'set_title', 'set_badge_count', diff --git a/apps/desktop-shell/src-tauri/src/main.rs b/apps/desktop-shell/src-tauri/src/main.rs index 291b1220..2c18f0c4 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 IMPORT_TEXT_MAX_BYTES: u64 = 5 * 1024 * 1024; const IMPORT_IMAGE_MAX_BYTES: u64 = 10 * 1024 * 1024; const EXPORT_FILE_NAME_FALLBACK: &str = "genarrative-export.txt"; const EXPORT_FILE_NAME_MAX_LENGTH: usize = 120; @@ -104,6 +105,7 @@ fn capabilities() -> Vec<&'static str> { "clipboard.writeText", "clipboard.readText", "file.exportText", + "file.importText", "file.exportImage", "file.importImage", "file.imageDropped", @@ -383,6 +385,54 @@ fn write_export_text_file(path: PathBuf, content: String) -> Result Option<&'static str> { + match path + .extension() + .and_then(|extension| extension.to_str()) + .map(|extension| extension.to_ascii_lowercase()) + .as_deref() + { + Some("txt") => Some("text/plain"), + Some("md") | Some("markdown") => Some("text/markdown"), + Some("csv") => Some("text/csv"), + Some("json") => Some("application/json"), + _ => None, + } +} + +fn import_text_file_payload(path: PathBuf) -> Result { + if !path.is_file() { + return Err("text file is required".to_string()); + } + + let mime_type = + import_text_mime_type(&path).ok_or_else(|| "text 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_TEXT_MAX_BYTES { + return Err("text exceeds import size limit".to_string()); + } + + let content = fs::read_to_string(&path).map_err(|error| error.to_string())?; + let byte_count = content.len() as u64; + if byte_count == 0 || byte_count > IMPORT_TEXT_MAX_BYTES { + return Err("text 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.txt".to_string()); + + Ok(json!({ + "action": "selected", + "fileName": file_name, + "content": content, + "mimeType": mime_type, + "bytes": byte_count, + })) +} + fn export_image_extension(mime_type: &str) -> Option<&'static str> { match mime_type { "image/png" => Some("png"), @@ -910,6 +960,27 @@ async fn host_bridge_request( }), ) } + "file.importText" => { + let file_path = app + .dialog() + .file() + .add_filter("Text", &["txt", "md", "markdown", "csv", "json"]) + .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_text_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, @@ -1153,6 +1224,10 @@ mod tests { .as_array() .unwrap() .contains(&json!("file.exportText"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("file.importText"))); assert!(result["capabilities"] .as_array() .unwrap() @@ -1457,6 +1532,57 @@ mod tests { fs::remove_file(path).expect("remove export file"); } + #[test] + fn import_text_file_payload_reads_allowed_text_without_exposing_path() { + let path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-text-{}.md", + std::process::id() + )); + fs::write(&path, "暖灯猫街").expect("write import text"); + + let payload = import_text_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["content"], "暖灯猫街"); + assert_eq!(payload["mimeType"], "text/markdown"); + assert_eq!(payload["bytes"], "暖灯猫街".len() as u64); + assert!(!payload + .to_string() + .contains(path.to_string_lossy().as_ref())); + + fs::remove_file(path).expect("remove import text"); + } + + #[test] + fn import_text_file_payload_rejects_invalid_or_oversized_text() { + let image_path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-text-{}.png", + std::process::id() + )); + fs::write(&image_path, "text").expect("write image-like text"); + assert_eq!( + import_text_file_payload(image_path.clone()).unwrap_err(), + "text MIME must be allowed" + ); + fs::remove_file(image_path).expect("remove image-like text"); + + let large_path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-text-large-{}.txt", + std::process::id() + )); + fs::write(&large_path, "a".repeat(IMPORT_TEXT_MAX_BYTES as usize + 1)) + .expect("write large text"); + assert_eq!( + import_text_file_payload(large_path.clone()).unwrap_err(), + "text exceeds import size limit" + ); + fs::remove_file(large_path).expect("remove large text"); + } + #[test] fn export_image_payload_decodes_allowed_image_base64() { let mut valid = request("file.exportImage"); diff --git a/apps/desktop-shell/src-tauri/tauri.conf.json b/apps/desktop-shell/src-tauri/tauri.conf.json index d7637f85..cccc3409 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.openExternalUrl,app.setTitle,app.setBadgeCount,network.status,network.statusChanged,clipboard.writeText,clipboard.readText,file.exportText,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.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", "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.openExternalUrl,app.setTitle,app.setBadgeCount,network.status,network.statusChanged,clipboard.writeText,clipboard.readText,file.exportText,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.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", "title": "Genarrative", "width": 1280, "height": 820, diff --git a/apps/mobile-shell/package.json b/apps/mobile-shell/package.json index a2aeddba..a082ee21 100644 --- a/apps/mobile-shell/package.json +++ b/apps/mobile-shell/package.json @@ -15,6 +15,7 @@ "@expo/metro-runtime": "^56.0.15", "expo": "^56.0.12", "expo-clipboard": "^56.0.4", + "expo-document-picker": "^56.0.4", "expo-file-system": "^56.0.8", "expo-haptics": "^56.0.3", "expo-image-picker": "^56.0.18", diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 8526c604..335d029a 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -131,6 +131,7 @@ for (const snippet of [ for (const dependency of [ 'expo-file-system', + 'expo-document-picker', 'expo-image-picker', 'expo-network', 'expo-notifications', @@ -177,6 +178,7 @@ if (Array.isArray(notificationsPlugin)) { for (const snippet of [ 'file.exportText', + 'file.importText', 'file.exportImage', 'file.importImage', 'clipboard.readText', @@ -188,6 +190,7 @@ for (const snippet of [ 'Notifications.getPermissionsAsync', 'Notifications.requestPermissionsAsync', 'Sharing.shareAsync', + 'DocumentPicker.getDocumentAsync', 'Clipboard.getStringAsync', 'ImagePicker.launchImageLibraryAsync', 'ImagePicker.requestMediaLibraryPermissionsAsync', @@ -223,6 +226,7 @@ for (const capability of [ 'clipboard.writeText', 'clipboard.readText', 'file.exportText', + 'file.importText', 'file.exportImage', 'file.importImage', 'haptics.impact', diff --git a/apps/mobile-shell/src/mobileHostBridge.test.ts b/apps/mobile-shell/src/mobileHostBridge.test.ts index 9c6932a6..9a7b5dbb 100644 --- a/apps/mobile-shell/src/mobileHostBridge.test.ts +++ b/apps/mobile-shell/src/mobileHostBridge.test.ts @@ -1,4 +1,5 @@ import * as Clipboard from 'expo-clipboard'; +import * as DocumentPicker from 'expo-document-picker'; import * as Haptics from 'expo-haptics'; import * as ImagePicker from 'expo-image-picker'; import * as Linking from 'expo-linking'; @@ -48,6 +49,8 @@ vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn(), })); +const fileTexts = vi.hoisted(() => new Map()); + const writtenFiles = vi.hoisted( () => [] as { @@ -64,8 +67,9 @@ vi.mock('expo-file-system', () => ({ File: class MockFile { uri: string; - constructor(_base: string, fileName: string) { - this.uri = `file:///cache/${fileName}`; + constructor(base: string, fileName?: string) { + this.uri = + typeof fileName === 'string' ? `file:///cache/${fileName}` : base; } write(content: string, options?: { encoding?: 'utf8' | 'base64' }) { @@ -75,9 +79,17 @@ vi.mock('expo-file-system', () => ({ options, }); } + + text() { + return Promise.resolve(fileTexts.get(this.uri) ?? ''); + } }, })); +vi.mock('expo-document-picker', () => ({ + getDocumentAsync: vi.fn(), +})); + vi.mock('expo-haptics', () => ({ ImpactFeedbackStyle: { Heavy: 'heavy', @@ -203,6 +215,11 @@ afterEach(() => { vi.mocked(Clipboard.getStringAsync).mockReset(); vi.mocked(Clipboard.getStringAsync).mockResolvedValue('作品号 PZ-1'); vi.mocked(Clipboard.setStringAsync).mockReset(); + vi.mocked(DocumentPicker.getDocumentAsync).mockReset(); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: true, + assets: null, + }); vi.mocked(ImagePicker.launchImageLibraryAsync).mockReset(); vi.mocked(ImagePicker.launchImageLibraryAsync).mockResolvedValue({ canceled: true, @@ -242,6 +259,7 @@ afterEach(() => { vi.mocked(Sharing.isAvailableAsync).mockResolvedValue(true); vi.mocked(Sharing.shareAsync).mockReset(); vi.mocked(Share.share).mockReset(); + fileTexts.clear(); writtenFiles.length = 0; resetMobileHostBridgeForTest(); }); @@ -262,6 +280,9 @@ describe('handleMobileHostBridgeMessage', () => { expect( (okResponse.result as { capabilities: string[] }).capabilities, ).toContain('file.exportText'); + expect( + (okResponse.result as { capabilities: string[] }).capabilities, + ).toContain('file.importText'); expect( (okResponse.result as { capabilities: string[] }).capabilities, ).toContain('file.importImage'); @@ -667,6 +688,86 @@ describe('handleMobileHostBridgeMessage', () => { expect(Sharing.shareAsync).not.toHaveBeenCalled(); }); + test('file.importText 调起系统文档选择器并返回受控文本数据', async () => { + fileTexts.set('file:///private/mobile/story.md', '暖灯猫街'); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/story.md', + name: ' ../剧情:草稿?.md ', + mimeType: 'text/markdown', + size: 12, + lastModified: 1, + }, + ], + }); + + const response = await send(request('file.importText')); + + expect(expectOk(response).result).toEqual({ + action: 'selected', + fileName: '剧情-草稿-.md', + content: '暖灯猫街', + mimeType: 'text/markdown', + bytes: 12, + }); + expect(DocumentPicker.getDocumentAsync).toHaveBeenCalledWith({ + copyToCacheDirectory: true, + multiple: false, + type: ['text/*', 'application/json'], + }); + }); + + test('file.importText 取消选择时返回 cancelled', async () => { + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: true, + assets: null, + }); + + const response = await send(request('file.importText')); + + expect(expectFailed(response).error.code).toBe('cancelled'); + }); + + test('file.importText 拒绝非法 MIME 与超限文本', async () => { + fileTexts.set('file:///private/mobile/story.png', '暖灯猫街'); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/story.png', + name: 'story.png', + mimeType: 'image/png', + size: 12, + lastModified: 1, + }, + ], + }); + + const unsupportedMime = await send(request('file.importText')); + + expect(expectFailed(unsupportedMime).error.code).toBe('invalid_request'); + + fileTexts.set('file:///private/mobile/story.txt', 'a'.repeat(5 * 1024 * 1024 + 1)); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/story.txt', + name: 'story.txt', + mimeType: 'text/plain', + size: 5 * 1024 * 1024 + 1, + lastModified: 1, + }, + ], + }); + + const oversized = await send(request('file.importText')); + + 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/mobileHostBridge.ts b/apps/mobile-shell/src/mobileHostBridge.ts index e98d92c8..6c3fb627 100644 --- a/apps/mobile-shell/src/mobileHostBridge.ts +++ b/apps/mobile-shell/src/mobileHostBridge.ts @@ -1,4 +1,5 @@ import * as Clipboard from 'expo-clipboard'; +import * as DocumentPicker from 'expo-document-picker'; import { File, Paths } from 'expo-file-system'; import * as Haptics from 'expo-haptics'; import * as ImagePicker from 'expo-image-picker'; @@ -20,6 +21,7 @@ import { type FileExportTextPayload, type FileExportTextResult, type FileImportImageResult, + type FileImportTextResult, type HapticsImpactPayload, HOST_BRIDGE_PROTOCOL, HOST_BRIDGE_VERSION, @@ -29,6 +31,7 @@ import { type HostBridgeMethod, type HostBridgeRequest, type HostBridgeResponse, + type HostBridgeTextMimeType, type NavigateNativePagePayload, normalizeHostBridgeBadgeCount, normalizeHostBridgeClipboardText, @@ -46,8 +49,15 @@ 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 IMPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024; const IMPORT_IMAGE_MAX_BYTES = 10 * 1024 * 1024; const LOCAL_NOTIFICATION_CHANNEL_ID = 'genarrative-local'; +const HOST_BRIDGE_TEXT_MIME_TYPES = new Set([ + 'text/plain', + 'text/markdown', + 'text/csv', + 'application/json', +]); const HOST_BRIDGE_IMAGE_MIME_TYPES = new Set([ 'image/png', 'image/jpeg', @@ -78,6 +88,7 @@ export const MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [ 'clipboard.writeText', 'clipboard.readText', 'file.exportText', + 'file.importText', 'file.exportImage', 'file.importImage', 'haptics.impact', @@ -283,6 +294,82 @@ async function exportTextFile(payload: unknown): Promise { }; } +function normalizeImportedTextMimeType( + value: unknown, + fileName: string, +): HostBridgeTextMimeType | null { + if (typeof value === 'string') { + const mimeType = value.toLowerCase(); + if (HOST_BRIDGE_TEXT_MIME_TYPES.has(mimeType as HostBridgeTextMimeType)) { + return mimeType as HostBridgeTextMimeType; + } + } + + const normalizedName = fileName.toLowerCase(); + if (normalizedName.endsWith('.json')) { + return 'application/json'; + } + if (normalizedName.endsWith('.md') || normalizedName.endsWith('.markdown')) { + return 'text/markdown'; + } + if (normalizedName.endsWith('.csv')) { + return 'text/csv'; + } + if (normalizedName.endsWith('.txt')) { + return 'text/plain'; + } + + return null; +} + +async function importTextFile(): Promise { + const result = await DocumentPicker.getDocumentAsync({ + copyToCacheDirectory: true, + multiple: false, + type: ['text/*', 'application/json'], + }); + if (result.canceled) { + throw { + code: 'cancelled', + message: 'file import cancelled', + } satisfies HostBridgeError; + } + + const asset = result.assets[0]; + if (!asset?.uri) { + throw invalidRequest('text file is required'); + } + + const fileName = normalizeHostBridgeExportFileName( + asset.name || 'genarrative-import.txt', + ); + const mimeType = normalizeImportedTextMimeType(asset.mimeType, fileName); + if (!mimeType) { + throw invalidRequest('mimeType must be an allowed text type'); + } + if ( + typeof asset.size === 'number' && + (asset.size <= 0 || asset.size > IMPORT_TEXT_MAX_BYTES) + ) { + throw invalidRequest('text exceeds file import size limit'); + } + + const file = new File(asset.uri); + const content = await file.text(); + const bytes = utf8ByteLength(content); + if (bytes <= 0 || bytes > IMPORT_TEXT_MAX_BYTES) { + throw invalidRequest('text exceeds file import size limit'); + } + + return { + action: 'selected', + fileName, + content, + mimeType, + bytes, + }; +} + async function exportImageFile(payload: unknown): Promise { const exportPayload = payload as FileExportImagePayload | undefined; const mimeType = exportPayload?.mimeType; @@ -627,6 +714,8 @@ async function handleRequest(request: HostBridgeRequest) { return ok(request, await readClipboard()); case 'file.exportText': return ok(request, await exportTextFile(request.payload)); + case 'file.importText': + return ok(request, await importTextFile()); case 'file.exportImage': return ok(request, await exportImageFile(request.payload)); case 'file.importImage': diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index bf1999cc..7f3f3372 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -40,6 +40,7 @@ - 2026-06-18 固定玩法音频接入宿主生命周期:前端新增 `useHostLifecycleActive()` 统一消费 `subscribeHostAppLifecycle()`,`useBackgroundMusic`、拼图运行态和抓大鹅运行态都只依赖该归一状态判断音频可播放性;宿主 inactive、background 或窗口失焦时暂停 `