diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index f2fa75659..9577be02c 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -94,6 +94,14 @@ const desktopHostBridgeFilesSource = fs.readFileSync( desktopHostBridgeFilesPath, 'utf8', ); +const desktopHostBridgeFilePayloadsPath = new URL( + '../src-tauri/src/host_bridge/file_payloads.rs', + import.meta.url, +); +const desktopHostBridgeFilePayloadsSource = fs.readFileSync( + desktopHostBridgeFilePayloadsPath, + 'utf8', +); const desktopHostBridgeNavigationPath = new URL( '../src-tauri/src/host_bridge/navigation.rs', import.meta.url, @@ -259,6 +267,7 @@ const expectedDesktopHostBridgeRustFiles = [ 'capabilities.rs', 'clipboard.rs', 'dispatch.rs', + 'file_payloads.rs', 'files.rs', 'mod.rs', 'navigation.rs', @@ -2060,6 +2069,7 @@ const expectedRustHostBridgeFiles = [ 'capabilities.rs', 'clipboard.rs', 'dispatch.rs', + 'file_payloads.rs', 'files.rs', 'mod.rs', 'navigation.rs', @@ -2318,7 +2328,7 @@ assertSameList( 'desktop shell Rust bridge modules', ); const desktopExportImagePayloadBody = extractFunctionBody( - desktopHostBridgeFilesSource, + desktopHostBridgeFilePayloadsSource, 'export_image_payload', ); if ( diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs b/apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs new file mode 100644 index 000000000..64a324aaf --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs @@ -0,0 +1,1127 @@ +use crate::host_bridge::protocol::{failed, HostBridgeRequest, HostBridgeResponse}; +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +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"; +const EXPORT_FILE_NAME_MAX_LENGTH: usize = 120; + +pub(crate) fn normalize_export_file_name(raw_file_name: &str) -> String { + let mut file_name = String::new(); + let mut last_was_space = false; + + for character in raw_file_name + .trim() + .chars() + .take(EXPORT_FILE_NAME_MAX_LENGTH) + { + if character.is_control() + || matches!( + character, + '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' + ) + { + file_name.push('-'); + last_was_space = false; + continue; + } + + if character.is_whitespace() { + if !last_was_space { + file_name.push(' '); + last_was_space = true; + } + continue; + } + + file_name.push(character); + last_was_space = false; + } + + let file_name = file_name + .trim() + .trim_start_matches(|character| matches!(character, '.' | '-') || character.is_whitespace()) + .trim(); + if file_name.is_empty() { + EXPORT_FILE_NAME_FALLBACK.to_string() + } else { + file_name.to_string() + } +} + +pub(crate) fn export_text_payload( + request: &HostBridgeRequest, +) -> Result<(String, String), HostBridgeResponse> { + let payload = request.payload.as_ref().ok_or_else(|| { + failed( + request.id.clone(), + "invalid_request", + "fileName and content are required", + ) + })?; + let file_name = payload + .get("fileName") + .and_then(Value::as_str) + .map(normalize_export_file_name) + .unwrap_or_else(|| EXPORT_FILE_NAME_FALLBACK.to_string()); + let content = payload + .get("content") + .and_then(Value::as_str) + .ok_or_else(|| failed(request.id.clone(), "invalid_request", "content is required"))?; + if normalize_export_text_mime_type(payload.get("mimeType").and_then(Value::as_str)).is_none() { + return Err(failed( + request.id.clone(), + "invalid_request", + "mimeType must be an allowed text type", + )); + } + + if content.len() > EXPORT_TEXT_MAX_BYTES { + return Err(failed( + request.id.clone(), + "invalid_request", + "content exceeds file export size limit", + )); + } + + Ok((file_name, content.to_string())) +} + +pub(crate) fn write_export_text_file(path: PathBuf, content: String) -> Result { + fs::write(path, content.as_bytes()).map_err(|error| error.to_string())?; + Ok(content.len()) +} + +fn import_text_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("txt") => Some("text/plain"), + Some("md") | Some("markdown") => Some("text/markdown"), + Some("csv") => Some("text/csv"), + Some("json") => Some("application/json"), + _ => None, + } +} + +fn normalize_export_text_mime_type(value: Option<&str>) -> Option<&'static str> { + match value.map(|mime_type| mime_type.to_ascii_lowercase()) { + None => Some("text/plain"), + Some(mime_type) if mime_type == "text/plain" => Some("text/plain"), + Some(mime_type) if mime_type == "text/markdown" => Some("text/markdown"), + Some(mime_type) if mime_type == "text/csv" => Some("text/csv"), + Some(mime_type) if mime_type == "application/json" => Some("application/json"), + _ => None, + } +} + +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()); + } + + 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, + })) +} + +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"), + "image/jpeg" => Some("jpg"), + "image/webp" => Some("webp"), + _ => None, + } +} + +pub(crate) fn import_image_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("png") => Some("image/png"), + Some("jpg") | Some("jpeg") => Some("image/jpeg"), + Some("webp") => Some("image/webp"), + _ => None, + } +} + +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 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 riff_container_matches(bytes: &[u8], kind: &[u8; 4]) -> bool { + bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == kind +} + +fn detect_image_mime_type(bytes: &[u8]) -> Option<&'static str> { + if bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]) { + return Some("image/png"); + } + + if bytes.len() >= 3 && bytes[0] == 0xff && bytes[1] == 0xd8 && bytes[2] == 0xff { + return Some("image/jpeg"); + } + + if riff_container_matches(bytes, b"WEBP") { + return Some("image/webp"); + } + + None +} + +fn detect_audio_mime_type(bytes: &[u8]) -> Option<&'static str> { + if bytes.starts_with(b"ID3") + || (bytes.len() >= 2 && bytes[0] == 0xff && (bytes[1] & 0xe0) == 0xe0) + { + return Some("audio/mpeg"); + } + + if bytes.len() >= 12 && &bytes[4..8] == b"ftyp" { + return Some("audio/mp4"); + } + + if riff_container_matches(bytes, b"WAVE") { + return Some("audio/wav"); + } + + if bytes.starts_with(b"OggS") { + return Some("audio/ogg"); + } + + if bytes.starts_with(&[0x1a, 0x45, 0xdf, 0xa3]) { + return Some("audio/webm"); + } + + None +} + +fn ensure_image_bytes_match_mime_type(bytes: &[u8], mime_type: &str) -> Result<(), String> { + if detect_image_mime_type(bytes) == Some(mime_type) { + Ok(()) + } else { + Err("image bytes do not match MIME".to_string()) + } +} + +fn ensure_audio_bytes_match_mime_type(bytes: &[u8], mime_type: &str) -> Result<(), String> { + if detect_audio_mime_type(bytes) == Some(mime_type) { + Ok(()) + } else { + Err("audio bytes do not match MIME".to_string()) + } +} + +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"); + if !file_name + .to_ascii_lowercase() + .ends_with(&format!(".{}", extension)) + { + file_name.push('.'); + file_name.push_str(extension); + } + 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 +} + +pub(crate) fn export_image_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_image_extension(mime_type).is_none() { + return Err(failed( + request.id.clone(), + "invalid_request", + "mimeType must be an allowed image 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_IMAGE_MAX_BYTES { + return Err(failed( + request.id.clone(), + "invalid_request", + "image exceeds file export size limit", + )); + } + ensure_image_bytes_match_mime_type(&bytes, mime_type) + .map_err(|message| failed(request.id.clone(), "invalid_request", message))?; + + let file_name = payload + .get("fileName") + .and_then(Value::as_str) + .map(|file_name| normalize_export_image_file_name(file_name, mime_type)) + .unwrap_or_else(|| normalize_export_image_file_name("genarrative-share-card", mime_type)); + + Ok((file_name, bytes)) +} + +pub(crate) 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", + )); + } + ensure_audio_bytes_match_mime_type(&bytes, mime_type) + .map_err(|message| failed(request.id.clone(), "invalid_request", message))?; + + 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)) +} + +pub(crate) 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())?; + Ok(byte_count) +} + +pub(crate) fn import_image_file_payload( + path: PathBuf, + action: &'static str, + position: Option<(i32, i32)>, +) -> Result { + if !path.is_file() { + return Err("image file is required".to_string()); + } + + let mime_type = + import_image_mime_type(&path).ok_or_else(|| "image 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_IMAGE_MAX_BYTES { + return Err("image 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_IMAGE_MAX_BYTES { + return Err("image exceeds import size limit".to_string()); + } + ensure_image_bytes_match_mime_type(&bytes, mime_type)?; + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .map(normalize_export_file_name) + .unwrap_or_else(|| "genarrative-import.png".to_string()); + let mut payload = json!({ + "action": action, + "fileName": file_name, + "base64Data": BASE64_STANDARD.encode(bytes), + "mimeType": mime_type, + "bytes": byte_count, + }); + + if let Some((x, y)) = position { + payload["position"] = json!({ + "x": x, + "y": y, + }); + } + + Ok(payload) +} + +pub(crate) 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()); + } + ensure_audio_bytes_match_mime_type(&bytes, mime_type)?; + 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, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host_bridge::protocol::request; + + fn png_bytes() -> Vec { + vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0] + } + + fn jpeg_bytes() -> Vec { + vec![0xff, 0xd8, 0xff, 0xe0, 0, 0, b'J', b'F', b'I', b'F'] + } + + fn webp_bytes() -> Vec { + b"RIFF\x04\x00\x00\x00WEBP".to_vec() + } + + fn mp3_bytes() -> Vec { + b"ID3\x04\x00\x00\x00\x00\x00\x10".to_vec() + } + + fn mp4_audio_bytes() -> Vec { + b"\x00\x00\x00\x18ftypM4A \x00\x00\x00\x00".to_vec() + } + + fn wav_bytes() -> Vec { + b"RIFF\x04\x00\x00\x00WAVE".to_vec() + } + + fn ogg_bytes() -> Vec { + b"OggS\x00\x02audio".to_vec() + } + + fn webm_bytes() -> Vec { + vec![0x1a, 0x45, 0xdf, 0xa3, 0x01, 0x00] + } + + #[test] + fn export_file_name_normalization_rejects_path_like_characters() { + assert_eq!( + normalize_export_file_name(" 作品:记录?.txt "), + "作品-记录-.txt" + ); + assert_eq!(normalize_export_file_name("../secret.txt"), "secret.txt"); + assert_eq!(normalize_export_file_name(""), EXPORT_FILE_NAME_FALLBACK); + + let long_file_name = "甲".repeat(140); + assert_eq!( + normalize_export_file_name(&long_file_name).chars().count(), + EXPORT_FILE_NAME_MAX_LENGTH + ); + } + + #[test] + fn export_text_payload_requires_text_content() { + let mut invalid = request("file.exportText"); + invalid.payload = Some(json!({ + "fileName": "作品记录.txt", + "content": 123 + })); + + let response = export_text_payload(&invalid).expect_err("invalid content"); + + assert!(!response.ok); + let error = response.error.expect("error"); + assert_eq!(error.code, "invalid_request"); + assert_eq!(error.message, "content is required"); + } + + #[test] + fn export_text_payload_rejects_oversized_content() { + let mut invalid = request("file.exportText"); + invalid.payload = Some(json!({ + "fileName": "作品记录.txt", + "content": "a".repeat(EXPORT_TEXT_MAX_BYTES + 1) + })); + + let response = export_text_payload(&invalid).expect_err("oversized content"); + + assert!(!response.ok); + let error = response.error.expect("error"); + assert_eq!(error.code, "invalid_request"); + assert_eq!(error.message, "content exceeds file export size limit"); + } + + #[test] + fn export_text_payload_rejects_non_text_mime_type() { + let mut invalid = request("file.exportText"); + invalid.payload = Some(json!({ + "fileName": "作品记录.txt", + "content": "暖灯猫街", + "mimeType": "image/png" + })); + + let response = export_text_payload(&invalid).expect_err("invalid MIME"); + + assert!(!response.ok); + let error = response.error.expect("error"); + assert_eq!(error.code, "invalid_request"); + assert_eq!(error.message, "mimeType must be an allowed text type"); + } + + #[test] + fn write_export_text_file_persists_utf8_content() { + let path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-export-{}.txt", + std::process::id() + )); + + let bytes = write_export_text_file(path.clone(), "暖灯猫街".to_string()) + .expect("write export file"); + + assert_eq!(bytes, "暖灯猫街".len()); + assert_eq!( + fs::read_to_string(&path).expect("read export file"), + "暖灯猫街" + ); + 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 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"); + valid.payload = Some(json!({ + "fileName": "分享:卡?.png", + "base64Data": BASE64_STANDARD.encode(png_bytes()), + "mimeType": "image/png" + })); + + let (file_name, bytes) = export_image_payload(&valid).expect("image payload"); + + assert_eq!(file_name, "分享-卡-.png"); + assert_eq!(bytes, png_bytes()); + } + + #[test] + fn import_image_file_payload_reads_allowed_image_without_exposing_path() { + let path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-{}.png", + std::process::id() + )); + fs::write(&path, png_bytes()).expect("write import image"); + + let payload = + import_image_file_payload(path.clone(), "selected", Some((12, 24))).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(png_bytes())); + assert_eq!(payload["mimeType"], "image/png"); + assert_eq!(payload["bytes"], png_bytes().len() as u64); + assert_eq!(payload["position"], json!({ "x": 12, "y": 24 })); + assert!(!payload + .to_string() + .contains(path.to_string_lossy().as_ref())); + + fs::remove_file(path).expect("remove import image"); + } + + #[test] + fn import_image_file_payload_rejects_invalid_or_oversized_images() { + let text_path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-{}.txt", + std::process::id() + )); + fs::write(&text_path, b"text").expect("write text file"); + assert_eq!( + import_image_file_payload(text_path.clone(), "selected", None).unwrap_err(), + "image MIME must be allowed" + ); + fs::remove_file(text_path).expect("remove text file"); + + let disguised_path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-disguised-{}.png", + std::process::id() + )); + fs::write(&disguised_path, b"text").expect("write disguised image"); + assert_eq!( + import_image_file_payload(disguised_path.clone(), "selected", None).unwrap_err(), + "image bytes do not match MIME" + ); + fs::remove_file(disguised_path).expect("remove disguised image"); + + let large_path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-large-{}.webp", + std::process::id() + )); + fs::write( + &large_path, + vec![1u8; (IMPORT_IMAGE_MAX_BYTES + 1) as usize], + ) + .expect("write large image"); + assert_eq!( + import_image_file_payload(large_path.clone(), "selected", None).unwrap_err(), + "image exceeds import size limit" + ); + 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, webm_bytes()).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"], BASE64_STANDARD.encode(webm_bytes())); + assert_eq!(payload["mimeType"], "audio/webm"); + assert_eq!(payload["bytes"], webm_bytes().len() as u64); + 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 disguised_path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-import-audio-disguised-{}.mp3", + std::process::id() + )); + fs::write(&disguised_path, b"audio").expect("write disguised audio"); + assert_eq!( + import_audio_file_payload(disguised_path.clone()).unwrap_err(), + "audio bytes do not match MIME" + ); + fs::remove_file(disguised_path).expect("remove disguised audio"); + + 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_audio_payload_decodes_allowed_audio_base64() { + let mut valid = request("file.exportAudio"); + valid.payload = Some(json!({ + "fileName": "敲击:音效?.wav", + "base64Data": BASE64_STANDARD.encode(wav_bytes()), + "mimeType": "audio/wav" + })); + + let (file_name, bytes) = export_audio_payload(&valid).expect("audio payload"); + + assert_eq!(file_name, "敲击-音效-.wav"); + assert_eq!(bytes, wav_bytes()); + + let mut missing_extension = request("file.exportAudio"); + missing_extension.payload = Some(json!({ + "fileName": "敲击音效", + "base64Data": BASE64_STANDARD.encode(webm_bytes()), + "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": BASE64_STANDARD.encode(wav_bytes()), + "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"); + + let mut mismatched = request("file.exportAudio"); + mismatched.payload = Some(json!({ + "fileName": "hit.wav", + "base64Data": BASE64_STANDARD.encode(mp3_bytes()), + "mimeType": "audio/wav" + })); + let response = export_audio_payload(&mismatched).expect_err("mismatched audio"); + assert_eq!( + response.error.expect("error").message, + "audio bytes do not match MIME" + ); + } + + #[test] + fn export_image_payload_rejects_invalid_mime_and_base64() { + let mut invalid_mime = request("file.exportImage"); + invalid_mime.payload = Some(json!({ + "fileName": "分享卡.txt", + "base64Data": "c2hhcmUtY2FyZA==", + "mimeType": "text/plain" + })); + let response = export_image_payload(&invalid_mime).expect_err("invalid mime"); + assert_eq!(response.error.expect("error").code, "invalid_request"); + + let mut invalid_base64 = request("file.exportImage"); + invalid_base64.payload = Some(json!({ + "fileName": "分享卡.png", + "base64Data": "not base64!", + "mimeType": "image/png" + })); + let response = export_image_payload(&invalid_base64).expect_err("invalid base64"); + assert_eq!( + response.error.expect("error").message, + "base64Data is invalid" + ); + + let mut empty = request("file.exportImage"); + empty.payload = Some(json!({ + "fileName": "分享卡.png", + "base64Data": "", + "mimeType": "image/png" + })); + let response = export_image_payload(&empty).expect_err("empty image"); + assert_eq!(response.error.expect("error").code, "invalid_request"); + + let mut mismatched = request("file.exportImage"); + mismatched.payload = Some(json!({ + "fileName": "分享卡.png", + "base64Data": BASE64_STANDARD.encode(jpeg_bytes()), + "mimeType": "image/png" + })); + let response = export_image_payload(&mismatched).expect_err("mismatched image"); + assert_eq!( + response.error.expect("error").message, + "image bytes do not match MIME" + ); + } + + #[test] + fn export_image_payload_rejects_oversized_image() { + let mut invalid = request("file.exportImage"); + invalid.payload = Some(json!({ + "fileName": "分享卡.png", + "base64Data": BASE64_STANDARD.encode(vec![1u8; EXPORT_IMAGE_MAX_BYTES + 1]), + "mimeType": "image/png" + })); + + let response = export_image_payload(&invalid).expect_err("oversized image"); + + assert_eq!( + response.error.expect("error").message, + "image exceeds file export size limit" + ); + } + + #[test] + fn write_export_bytes_file_persists_binary_content() { + let path = std::env::temp_dir().join(format!( + "genarrative-host-bridge-share-card-{}.png", + std::process::id() + )); + + let bytes = write_export_bytes_file(path.clone(), vec![0x89, b'P', b'N', b'G']) + .expect("write image file"); + + assert_eq!(bytes, 4); + assert_eq!( + fs::read(&path).expect("read image file"), + vec![0x89, b'P', b'N', b'G'] + ); + fs::remove_file(path).expect("remove image file"); + } + + #[test] + fn detects_allowed_image_and_audio_headers() { + assert_eq!(detect_image_mime_type(&png_bytes()), Some("image/png")); + assert_eq!(detect_image_mime_type(&jpeg_bytes()), Some("image/jpeg")); + assert_eq!(detect_image_mime_type(&webp_bytes()), Some("image/webp")); + assert_eq!(detect_image_mime_type(b"text"), None); + assert_eq!(detect_audio_mime_type(&mp3_bytes()), Some("audio/mpeg")); + assert_eq!( + detect_audio_mime_type(&mp4_audio_bytes()), + Some("audio/mp4") + ); + assert_eq!(detect_audio_mime_type(&wav_bytes()), Some("audio/wav")); + assert_eq!(detect_audio_mime_type(&ogg_bytes()), Some("audio/ogg")); + assert_eq!(detect_audio_mime_type(&webm_bytes()), Some("audio/webm")); + assert_eq!(detect_audio_mime_type(b"audio"), None); + } +} 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 9dc06b73d..9e927ad00 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/files.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/files.rs @@ -1,568 +1,12 @@ +use crate::host_bridge::file_payloads::{ + export_audio_payload, export_image_payload, export_text_payload, import_audio_file_payload, + 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::{failed, ok, HostBridgeRequest, HostBridgeResponse}; -use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; -use serde_json::{json, Value}; -use std::fs; -use std::path::{Path, PathBuf}; +use serde_json::json; use tauri_plugin_dialog::DialogExt; -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"; -const EXPORT_FILE_NAME_MAX_LENGTH: usize = 120; - -pub(crate) fn normalize_export_file_name(raw_file_name: &str) -> String { - let mut file_name = String::new(); - let mut last_was_space = false; - - for character in raw_file_name - .trim() - .chars() - .take(EXPORT_FILE_NAME_MAX_LENGTH) - { - if character.is_control() - || matches!( - character, - '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' - ) - { - file_name.push('-'); - last_was_space = false; - continue; - } - - if character.is_whitespace() { - if !last_was_space { - file_name.push(' '); - last_was_space = true; - } - continue; - } - - file_name.push(character); - last_was_space = false; - } - - let file_name = file_name - .trim() - .trim_start_matches(|character| matches!(character, '.' | '-') || character.is_whitespace()) - .trim(); - if file_name.is_empty() { - EXPORT_FILE_NAME_FALLBACK.to_string() - } else { - file_name.to_string() - } -} - -pub(crate) fn export_text_payload( - request: &HostBridgeRequest, -) -> Result<(String, String), HostBridgeResponse> { - let payload = request.payload.as_ref().ok_or_else(|| { - failed( - request.id.clone(), - "invalid_request", - "fileName and content are required", - ) - })?; - let file_name = payload - .get("fileName") - .and_then(Value::as_str) - .map(normalize_export_file_name) - .unwrap_or_else(|| EXPORT_FILE_NAME_FALLBACK.to_string()); - let content = payload - .get("content") - .and_then(Value::as_str) - .ok_or_else(|| failed(request.id.clone(), "invalid_request", "content is required"))?; - if normalize_export_text_mime_type(payload.get("mimeType").and_then(Value::as_str)).is_none() { - return Err(failed( - request.id.clone(), - "invalid_request", - "mimeType must be an allowed text type", - )); - } - - if content.len() > EXPORT_TEXT_MAX_BYTES { - return Err(failed( - request.id.clone(), - "invalid_request", - "content exceeds file export size limit", - )); - } - - Ok((file_name, content.to_string())) -} - -pub(crate) fn write_export_text_file(path: PathBuf, content: String) -> Result { - fs::write(path, content.as_bytes()).map_err(|error| error.to_string())?; - Ok(content.len()) -} - -fn import_text_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("txt") => Some("text/plain"), - Some("md") | Some("markdown") => Some("text/markdown"), - Some("csv") => Some("text/csv"), - Some("json") => Some("application/json"), - _ => None, - } -} - -fn normalize_export_text_mime_type(value: Option<&str>) -> Option<&'static str> { - match value.map(|mime_type| mime_type.to_ascii_lowercase()) { - None => Some("text/plain"), - Some(mime_type) if mime_type == "text/plain" => Some("text/plain"), - Some(mime_type) if mime_type == "text/markdown" => Some("text/markdown"), - Some(mime_type) if mime_type == "text/csv" => Some("text/csv"), - Some(mime_type) if mime_type == "application/json" => Some("application/json"), - _ => None, - } -} - -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()); - } - - 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, - })) -} - -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"), - "image/jpeg" => Some("jpg"), - "image/webp" => Some("webp"), - _ => None, - } -} - -pub(crate) fn import_image_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("png") => Some("image/png"), - Some("jpg") | Some("jpeg") => Some("image/jpeg"), - Some("webp") => Some("image/webp"), - _ => None, - } -} - -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 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 riff_container_matches(bytes: &[u8], kind: &[u8; 4]) -> bool { - bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == kind -} - -fn detect_image_mime_type(bytes: &[u8]) -> Option<&'static str> { - if bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]) { - return Some("image/png"); - } - - if bytes.len() >= 3 && bytes[0] == 0xff && bytes[1] == 0xd8 && bytes[2] == 0xff { - return Some("image/jpeg"); - } - - if riff_container_matches(bytes, b"WEBP") { - return Some("image/webp"); - } - - None -} - -fn detect_audio_mime_type(bytes: &[u8]) -> Option<&'static str> { - if bytes.starts_with(b"ID3") - || (bytes.len() >= 2 && bytes[0] == 0xff && (bytes[1] & 0xe0) == 0xe0) - { - return Some("audio/mpeg"); - } - - if bytes.len() >= 12 && &bytes[4..8] == b"ftyp" { - return Some("audio/mp4"); - } - - if riff_container_matches(bytes, b"WAVE") { - return Some("audio/wav"); - } - - if bytes.starts_with(b"OggS") { - return Some("audio/ogg"); - } - - if bytes.starts_with(&[0x1a, 0x45, 0xdf, 0xa3]) { - return Some("audio/webm"); - } - - None -} - -fn ensure_image_bytes_match_mime_type(bytes: &[u8], mime_type: &str) -> Result<(), String> { - if detect_image_mime_type(bytes) == Some(mime_type) { - Ok(()) - } else { - Err("image bytes do not match MIME".to_string()) - } -} - -fn ensure_audio_bytes_match_mime_type(bytes: &[u8], mime_type: &str) -> Result<(), String> { - if detect_audio_mime_type(bytes) == Some(mime_type) { - Ok(()) - } else { - Err("audio bytes do not match MIME".to_string()) - } -} - -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"); - if !file_name - .to_ascii_lowercase() - .ends_with(&format!(".{}", extension)) - { - file_name.push('.'); - file_name.push_str(extension); - } - 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 -} - -pub(crate) fn export_image_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_image_extension(mime_type).is_none() { - return Err(failed( - request.id.clone(), - "invalid_request", - "mimeType must be an allowed image 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_IMAGE_MAX_BYTES { - return Err(failed( - request.id.clone(), - "invalid_request", - "image exceeds file export size limit", - )); - } - ensure_image_bytes_match_mime_type(&bytes, mime_type) - .map_err(|message| failed(request.id.clone(), "invalid_request", message))?; - - let file_name = payload - .get("fileName") - .and_then(Value::as_str) - .map(|file_name| normalize_export_image_file_name(file_name, mime_type)) - .unwrap_or_else(|| normalize_export_image_file_name("genarrative-share-card", mime_type)); - - Ok((file_name, bytes)) -} - -pub(crate) 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", - )); - } - ensure_audio_bytes_match_mime_type(&bytes, mime_type) - .map_err(|message| failed(request.id.clone(), "invalid_request", message))?; - - 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)) -} - -pub(crate) 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())?; - Ok(byte_count) -} - -pub(crate) fn import_image_file_payload( - path: PathBuf, - action: &'static str, - position: Option<(i32, i32)>, -) -> Result { - if !path.is_file() { - return Err("image file is required".to_string()); - } - - let mime_type = - import_image_mime_type(&path).ok_or_else(|| "image 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_IMAGE_MAX_BYTES { - return Err("image 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_IMAGE_MAX_BYTES { - return Err("image exceeds import size limit".to_string()); - } - ensure_image_bytes_match_mime_type(&bytes, mime_type)?; - let file_name = path - .file_name() - .and_then(|name| name.to_str()) - .map(normalize_export_file_name) - .unwrap_or_else(|| "genarrative-import.png".to_string()); - let mut payload = json!({ - "action": action, - "fileName": file_name, - "base64Data": BASE64_STANDARD.encode(bytes), - "mimeType": mime_type, - "bytes": byte_count, - }); - - if let Some((x, y)) = position { - payload["position"] = json!({ - "x": x, - "y": y, - }); - } - - Ok(payload) -} - -pub(crate) 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()); - } - ensure_audio_bytes_match_mime_type(&bytes, mime_type)?; - 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, - })) -} - pub(crate) async fn export_desktop_host_bridge_text_file( app: &tauri::AppHandle, request: &HostBridgeRequest, @@ -782,567 +226,3 @@ pub(crate) async fn export_desktop_host_bridge_audio_file( }), ) } - -#[cfg(test)] -mod tests { - use super::*; - use crate::host_bridge::protocol::request; - - fn png_bytes() -> Vec { - vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0] - } - - fn jpeg_bytes() -> Vec { - vec![0xff, 0xd8, 0xff, 0xe0, 0, 0, b'J', b'F', b'I', b'F'] - } - - fn webp_bytes() -> Vec { - b"RIFF\x04\x00\x00\x00WEBP".to_vec() - } - - fn mp3_bytes() -> Vec { - b"ID3\x04\x00\x00\x00\x00\x00\x10".to_vec() - } - - fn mp4_audio_bytes() -> Vec { - b"\x00\x00\x00\x18ftypM4A \x00\x00\x00\x00".to_vec() - } - - fn wav_bytes() -> Vec { - b"RIFF\x04\x00\x00\x00WAVE".to_vec() - } - - fn ogg_bytes() -> Vec { - b"OggS\x00\x02audio".to_vec() - } - - fn webm_bytes() -> Vec { - vec![0x1a, 0x45, 0xdf, 0xa3, 0x01, 0x00] - } - - #[test] - fn export_file_name_normalization_rejects_path_like_characters() { - assert_eq!( - normalize_export_file_name(" 作品:记录?.txt "), - "作品-记录-.txt" - ); - assert_eq!(normalize_export_file_name("../secret.txt"), "secret.txt"); - assert_eq!(normalize_export_file_name(""), EXPORT_FILE_NAME_FALLBACK); - - let long_file_name = "甲".repeat(140); - assert_eq!( - normalize_export_file_name(&long_file_name).chars().count(), - EXPORT_FILE_NAME_MAX_LENGTH - ); - } - - #[test] - fn export_text_payload_requires_text_content() { - let mut invalid = request("file.exportText"); - invalid.payload = Some(json!({ - "fileName": "作品记录.txt", - "content": 123 - })); - - let response = export_text_payload(&invalid).expect_err("invalid content"); - - assert!(!response.ok); - let error = response.error.expect("error"); - assert_eq!(error.code, "invalid_request"); - assert_eq!(error.message, "content is required"); - } - - #[test] - fn export_text_payload_rejects_oversized_content() { - let mut invalid = request("file.exportText"); - invalid.payload = Some(json!({ - "fileName": "作品记录.txt", - "content": "a".repeat(EXPORT_TEXT_MAX_BYTES + 1) - })); - - let response = export_text_payload(&invalid).expect_err("oversized content"); - - assert!(!response.ok); - let error = response.error.expect("error"); - assert_eq!(error.code, "invalid_request"); - assert_eq!(error.message, "content exceeds file export size limit"); - } - - #[test] - fn export_text_payload_rejects_non_text_mime_type() { - let mut invalid = request("file.exportText"); - invalid.payload = Some(json!({ - "fileName": "作品记录.txt", - "content": "暖灯猫街", - "mimeType": "image/png" - })); - - let response = export_text_payload(&invalid).expect_err("invalid MIME"); - - assert!(!response.ok); - let error = response.error.expect("error"); - assert_eq!(error.code, "invalid_request"); - assert_eq!(error.message, "mimeType must be an allowed text type"); - } - - #[test] - fn write_export_text_file_persists_utf8_content() { - let path = std::env::temp_dir().join(format!( - "genarrative-host-bridge-export-{}.txt", - std::process::id() - )); - - let bytes = write_export_text_file(path.clone(), "暖灯猫街".to_string()) - .expect("write export file"); - - assert_eq!(bytes, "暖灯猫街".len()); - assert_eq!( - fs::read_to_string(&path).expect("read export file"), - "暖灯猫街" - ); - 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 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"); - valid.payload = Some(json!({ - "fileName": "分享:卡?.png", - "base64Data": BASE64_STANDARD.encode(png_bytes()), - "mimeType": "image/png" - })); - - let (file_name, bytes) = export_image_payload(&valid).expect("image payload"); - - assert_eq!(file_name, "分享-卡-.png"); - assert_eq!(bytes, png_bytes()); - } - - #[test] - fn import_image_file_payload_reads_allowed_image_without_exposing_path() { - let path = std::env::temp_dir().join(format!( - "genarrative-host-bridge-import-{}.png", - std::process::id() - )); - fs::write(&path, png_bytes()).expect("write import image"); - - let payload = - import_image_file_payload(path.clone(), "selected", Some((12, 24))).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(png_bytes())); - assert_eq!(payload["mimeType"], "image/png"); - assert_eq!(payload["bytes"], png_bytes().len() as u64); - assert_eq!(payload["position"], json!({ "x": 12, "y": 24 })); - assert!(!payload - .to_string() - .contains(path.to_string_lossy().as_ref())); - - fs::remove_file(path).expect("remove import image"); - } - - #[test] - fn import_image_file_payload_rejects_invalid_or_oversized_images() { - let text_path = std::env::temp_dir().join(format!( - "genarrative-host-bridge-import-{}.txt", - std::process::id() - )); - fs::write(&text_path, b"text").expect("write text file"); - assert_eq!( - import_image_file_payload(text_path.clone(), "selected", None).unwrap_err(), - "image MIME must be allowed" - ); - fs::remove_file(text_path).expect("remove text file"); - - let disguised_path = std::env::temp_dir().join(format!( - "genarrative-host-bridge-import-disguised-{}.png", - std::process::id() - )); - fs::write(&disguised_path, b"text").expect("write disguised image"); - assert_eq!( - import_image_file_payload(disguised_path.clone(), "selected", None).unwrap_err(), - "image bytes do not match MIME" - ); - fs::remove_file(disguised_path).expect("remove disguised image"); - - let large_path = std::env::temp_dir().join(format!( - "genarrative-host-bridge-import-large-{}.webp", - std::process::id() - )); - fs::write( - &large_path, - vec![1u8; (IMPORT_IMAGE_MAX_BYTES + 1) as usize], - ) - .expect("write large image"); - assert_eq!( - import_image_file_payload(large_path.clone(), "selected", None).unwrap_err(), - "image exceeds import size limit" - ); - 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, webm_bytes()).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"], BASE64_STANDARD.encode(webm_bytes())); - assert_eq!(payload["mimeType"], "audio/webm"); - assert_eq!(payload["bytes"], webm_bytes().len() as u64); - 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 disguised_path = std::env::temp_dir().join(format!( - "genarrative-host-bridge-import-audio-disguised-{}.mp3", - std::process::id() - )); - fs::write(&disguised_path, b"audio").expect("write disguised audio"); - assert_eq!( - import_audio_file_payload(disguised_path.clone()).unwrap_err(), - "audio bytes do not match MIME" - ); - fs::remove_file(disguised_path).expect("remove disguised audio"); - - 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_audio_payload_decodes_allowed_audio_base64() { - let mut valid = request("file.exportAudio"); - valid.payload = Some(json!({ - "fileName": "敲击:音效?.wav", - "base64Data": BASE64_STANDARD.encode(wav_bytes()), - "mimeType": "audio/wav" - })); - - let (file_name, bytes) = export_audio_payload(&valid).expect("audio payload"); - - assert_eq!(file_name, "敲击-音效-.wav"); - assert_eq!(bytes, wav_bytes()); - - let mut missing_extension = request("file.exportAudio"); - missing_extension.payload = Some(json!({ - "fileName": "敲击音效", - "base64Data": BASE64_STANDARD.encode(webm_bytes()), - "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": BASE64_STANDARD.encode(wav_bytes()), - "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"); - - let mut mismatched = request("file.exportAudio"); - mismatched.payload = Some(json!({ - "fileName": "hit.wav", - "base64Data": BASE64_STANDARD.encode(mp3_bytes()), - "mimeType": "audio/wav" - })); - let response = export_audio_payload(&mismatched).expect_err("mismatched audio"); - assert_eq!( - response.error.expect("error").message, - "audio bytes do not match MIME" - ); - } - - #[test] - fn export_image_payload_rejects_invalid_mime_and_base64() { - let mut invalid_mime = request("file.exportImage"); - invalid_mime.payload = Some(json!({ - "fileName": "分享卡.txt", - "base64Data": "c2hhcmUtY2FyZA==", - "mimeType": "text/plain" - })); - let response = export_image_payload(&invalid_mime).expect_err("invalid mime"); - assert_eq!(response.error.expect("error").code, "invalid_request"); - - let mut invalid_base64 = request("file.exportImage"); - invalid_base64.payload = Some(json!({ - "fileName": "分享卡.png", - "base64Data": "not base64!", - "mimeType": "image/png" - })); - let response = export_image_payload(&invalid_base64).expect_err("invalid base64"); - assert_eq!( - response.error.expect("error").message, - "base64Data is invalid" - ); - - let mut empty = request("file.exportImage"); - empty.payload = Some(json!({ - "fileName": "分享卡.png", - "base64Data": "", - "mimeType": "image/png" - })); - let response = export_image_payload(&empty).expect_err("empty image"); - assert_eq!(response.error.expect("error").code, "invalid_request"); - - let mut mismatched = request("file.exportImage"); - mismatched.payload = Some(json!({ - "fileName": "分享卡.png", - "base64Data": BASE64_STANDARD.encode(jpeg_bytes()), - "mimeType": "image/png" - })); - let response = export_image_payload(&mismatched).expect_err("mismatched image"); - assert_eq!( - response.error.expect("error").message, - "image bytes do not match MIME" - ); - } - - #[test] - fn export_image_payload_rejects_oversized_image() { - let mut invalid = request("file.exportImage"); - invalid.payload = Some(json!({ - "fileName": "分享卡.png", - "base64Data": BASE64_STANDARD.encode(vec![1u8; EXPORT_IMAGE_MAX_BYTES + 1]), - "mimeType": "image/png" - })); - - let response = export_image_payload(&invalid).expect_err("oversized image"); - - assert_eq!( - response.error.expect("error").message, - "image exceeds file export size limit" - ); - } - - #[test] - fn write_export_bytes_file_persists_binary_content() { - let path = std::env::temp_dir().join(format!( - "genarrative-host-bridge-share-card-{}.png", - std::process::id() - )); - - let bytes = write_export_bytes_file(path.clone(), vec![0x89, b'P', b'N', b'G']) - .expect("write image file"); - - assert_eq!(bytes, 4); - assert_eq!( - fs::read(&path).expect("read image file"), - vec![0x89, b'P', b'N', b'G'] - ); - fs::remove_file(path).expect("remove image file"); - } - - #[test] - fn detects_allowed_image_and_audio_headers() { - assert_eq!(detect_image_mime_type(&png_bytes()), Some("image/png")); - assert_eq!(detect_image_mime_type(&jpeg_bytes()), Some("image/jpeg")); - assert_eq!(detect_image_mime_type(&webp_bytes()), Some("image/webp")); - assert_eq!(detect_image_mime_type(b"text"), None); - assert_eq!(detect_audio_mime_type(&mp3_bytes()), Some("audio/mpeg")); - assert_eq!( - detect_audio_mime_type(&mp4_audio_bytes()), - Some("audio/mp4") - ); - assert_eq!(detect_audio_mime_type(&wav_bytes()), Some("audio/wav")); - assert_eq!(detect_audio_mime_type(&ogg_bytes()), Some("audio/ogg")); - assert_eq!(detect_audio_mime_type(&webm_bytes()), Some("audio/webm")); - assert_eq!(detect_audio_mime_type(b"audio"), None); - } -} diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/mod.rs b/apps/desktop-shell/src-tauri/src/host_bridge/mod.rs index a8a90e840..7aa6afe3d 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/mod.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/mod.rs @@ -3,6 +3,7 @@ pub(crate) mod capabilities; mod badge; mod clipboard; mod dispatch; +pub(crate) mod file_payloads; pub(crate) mod files; mod navigation; mod network; diff --git a/apps/desktop-shell/src-tauri/src/shell/file_drop.rs b/apps/desktop-shell/src-tauri/src/shell/file_drop.rs index 6a9cd01b7..1b34beef8 100644 --- a/apps/desktop-shell/src-tauri/src/shell/file_drop.rs +++ b/apps/desktop-shell/src-tauri/src/shell/file_drop.rs @@ -1,4 +1,4 @@ -use crate::host_bridge::files::{import_image_file_payload, import_image_mime_type}; +use crate::host_bridge::file_payloads::{import_image_file_payload, import_image_mime_type}; use crate::shell::events::host_bridge_event_script; use crate::shell::lifecycle::log_desktop_host_event_result; use serde_json::Value; diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index abf99e525..12fa4a62b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -38,7 +38,7 @@ - 2026-06-19 桌面壳系统分享 URL 边界:Tauri `share.open` 写入系统剪贴板前同样只允许把 `url`、`href`、`path`、`targetPath` 和 `work` 归一为 `https://app.genarrative.world` 同源公开 URL;外域、协议相对 URL、`javascript:` 等危险目标必须返回 `invalid_request`,且显式非法 payload 不得回退到之前缓存的 `share.setTarget` 目标。桌面壳配置检查会拒绝移除同源分享 URL 归一和协议相对 URL 拦截。 - 2026-06-19 原生壳分享桥接边界:Expo `share.setTarget` / `share.open` 的缓存目标、分享 payload 归一、系统分享调用和 HostBridge 响应统一收口在 `apps/mobile-shell/src/host-bridge/share.ts`;Tauri `share.setTarget` / `share.open` 的缓存目标、分享文本生成、剪贴板 fallback 写入和 HostBridge 响应统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/share.rs`。两端 `dispatch` 只负责委托对应 share 模块,配置检查会拒绝分发层直接持有分享状态、生成分享文本、写入分享剪贴板结果或包装分享成功响应。 - 2026-06-19 桌面壳窗口标题桥接边界:Tauri `app.setTitle` 的 payload 校验、非空 / 控制字符拒绝、80 字符截断和主窗口 `set_title` 调用统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/title.rs`;`dispatch.rs` 只负责委托 `set_desktop_host_bridge_window_title(...)`。桌面壳配置检查和根级结构门禁会覆盖 `title.rs` 文件清单、共享标题长度镜像和 dispatch 委托关系。 -- 2026-06-19 桌面壳文件桥接执行边界:Tauri `file.exportText` / `file.importText` / `file.importDocument` / `file.exportImage` / `file.importImage` / `file.importAudio` / `file.exportAudio` 的系统文件对话框过滤器、用户取消语义、路径转换、异步读写编排和 HostBridge 响应统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/files.rs`;`dispatch.rs` 只负责按 method 委托 `export_desktop_host_bridge_*_file(...)` / `import_desktop_host_bridge_*_file(...)`。桌面壳配置检查会拒绝分发层直接调用 `.dialog()`、`blocking_save_file` / `blocking_pick_file`、文件 payload helper 或落盘 helper,避免文件访问边界重新散落。 +- 2026-06-19 桌面壳文件桥接执行边界:Tauri `file.exportText` / `file.importText` / `file.importDocument` / `file.exportImage` / `file.importImage` / `file.importAudio` / `file.exportAudio` 的系统文件对话框过滤器、用户取消语义、路径转换、异步读写编排和 HostBridge 响应统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/files.rs`;MIME、大小、base64、文件名清洗、本地副本读写和 HostBridge payload 组装统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs`;`dispatch.rs` 只负责按 method 委托 `export_desktop_host_bridge_*_file(...)` / `import_desktop_host_bridge_*_file(...)`。桌面壳配置检查会拒绝分发层直接调用 `.dialog()`、`blocking_save_file` / `blocking_pick_file`、文件 payload helper 或落盘 helper,避免文件访问边界重新散落。 - 2026-06-19 桌面壳外链打开 helper 共用:Tauri WebView 外域拦截和 HostBridge `app.openExternalUrl` 都必须复用 `open_normalized_desktop_external_url` 执行系统外链打开动作;HostBridge 分支仍先用 `normalize_external_url` 保留 payload 错误语义并把 opener 错误回传给 H5,WebView 拦截保持 best-effort 静默处理。桌面壳配置检查会拒绝 `dispatch.rs` 直接调用 `app.opener().open_url` 绕过该 helper,避免两条离壳路径漂移。 - 2026-06-20 H5 原生导航预校验:`navigateHostNativePage()` 在 `native_app` 下发送 `navigation.openNativePage` 前必须先拒绝空值、控制字符、协议相对 URL、外域绝对 URL 和非 `http:` / `https:` 协议目标;同源绝对 URL、`/path` 和保留给桌面壳兼容的相对 route 继续交给 Expo / Tauri 壳二次归一并补写宿主上下文。微信小程序分支仍按小程序页面 URL 语义走 `wx.miniProgram.navigateTo`,不套原生 App 同源 H5 预校验。根级 `npm run check:native-shells` 会反查 H5 facade 仍使用 `normalizeNativeAppPageUrl(...)` 且发送归一后的 URL,避免明显不安全目标触达原生壳。 - 2026-06-18 能力声明收紧:`packages/shared/src/contracts/hostBridge.ts` 提供 HostBridge method / capability 白名单,H5 的 `getHostRuntime()` 会解析并过滤 `hostCapabilities`;`openHostShare`、`writeHostClipboardText`、`requestHostHapticsImpact`、`setHostAppTitle`、`exportHostTextFile` 等 native 能力只在宿主声明对应 capability 后调用。发布分享弹窗只有声明 `share.open` 时才显示“系统分享”,避免旧壳或裁剪壳露出不可用入口。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 0ff5df013..1915f9c13 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -64,13 +64,13 @@ src/ 已落地:`packages/shared/src/contracts/hostBridge.ts` 保存消息 envelope、method、payload 和错误码,H5、Expo 壳与 Tauri 壳共享同一份协议类型。 -三端宿主桥接层按职责对齐命名:微信小程序页面路由仍保留在 `miniprogram/pages/*`,`miniprogram/host-bridge/protocol.js` 只沉淀微信壳能力、页面 URL、结果 hash / storage key 和分享消息类型等常量,`dispatch.js` 只作为 `protocol`、`webView`、`payment`、`shareGrid`、`subscribeMessage` 的薄索引,真实协议归一、支付 / 订阅 / 分享结果编解码仍分别在 `webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,不把微信小程序硬改成 Expo / Tauri 的 request 总线;Page 生命周期、`wx.*` 容器调用、WebView 容器行为和页面工厂统一放在 `miniprogram/shell/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,页面入口只做 `Page(createWechat...Page())` 装配。Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/protocol.ts` 承接 envelope、request 校验、ok / failure 响应和 replay 基础类型,`capabilities.ts` 只引用共享 HostBridge capability profile 并选择 iOS 差异能力,`dispatch.ts` 承接 method 分发和宿主能力调用,`appearance.ts` 承接系统配色读取,`navigation.ts` 承接外链打开、受控 H5 跳转和 WebView 刷新,`network.ts` 承接网络状态查询,`badge.ts` 承接受控角标能力,`clipboard.ts` 承接剪贴板读写与 HostBridge payload / 响应边界,`files.ts` / `share.ts` / `scanner.ts` / `notifications.ts` 分别承接文件、分享、扫码和本地通知能力,`bridge.ts` 只作为 WebView message 入口、request id replay 编排和对外 facade;`apps/mobile-shell/App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx`,由 `apps/mobile-shell/src/shell/*.ts(x)` 承接 WebView 容器、URL、导航、网络、生命周期、安全区、扫码 overlay 和 WebView policy。Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs` 承接 envelope、method 白名单、request 校验和 replay 状态,`runtime.rs` 承接桌面 runtime 回包的平台、hostVersion、bridgeVersion 和 capability 清单组装,`appearance.rs` 承接窗口主题读取和 HostBridge 配色归一,`navigation.rs` 承接外链打开、受控 H5 跳转和主窗口刷新,`network.rs` 承接网络状态查询,`badge.rs` 承接受控任务栏角标能力,`clipboard.rs` 承接剪贴板读写与 HostBridge payload / 响应边界,`title.rs` 承接窗口标题 payload / 响应边界,`capabilities.rs` 承接共享桌面 capability profile 的 Rust 运行时镜像,`dispatch.rs` 承接 method 分发和宿主能力调用,`files.rs` / `share.rs` / `notifications.rs` 分别承接文件、分享和本地通知能力,`mod.rs` 只保留模块声明、必要 re-export、`host_bridge_request` command facade 和 replay 编排;`apps/desktop-shell/src-tauri/src/shell/runtime.rs`、`url.rs`、`navigation.rs`、`network.rs`、`lifecycle.rs`、`file_drop.rs`、`events.rs`、`deep_link.rs`、`tray.rs`、`menu.rs`、`window_state.rs` 和 `webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘、应用菜单、窗口状态持久化和 WebView 门面,`apps/desktop-shell/src-tauri/src/app.rs` 承接 Tauri builder / plugin / window 装配,`main.rs` 只保留薄入口并调用 `app::run()`。 +三端宿主桥接层按职责对齐命名:微信小程序页面路由仍保留在 `miniprogram/pages/*`,`miniprogram/host-bridge/protocol.js` 只沉淀微信壳能力、页面 URL、结果 hash / storage key 和分享消息类型等常量,`dispatch.js` 只作为 `protocol`、`webView`、`payment`、`shareGrid`、`subscribeMessage` 的薄索引,真实协议归一、支付 / 订阅 / 分享结果编解码仍分别在 `webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,不把微信小程序硬改成 Expo / Tauri 的 request 总线;Page 生命周期、`wx.*` 容器调用、WebView 容器行为和页面工厂统一放在 `miniprogram/shell/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,页面入口只做 `Page(createWechat...Page())` 装配。Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/protocol.ts` 承接 envelope、request 校验、ok / failure 响应和 replay 基础类型,`capabilities.ts` 只引用共享 HostBridge capability profile 并选择 iOS 差异能力,`dispatch.ts` 承接 method 分发和宿主能力调用,`appearance.ts` 承接系统配色读取,`navigation.ts` 承接外链打开、受控 H5 跳转和 WebView 刷新,`network.ts` 承接网络状态查询,`badge.ts` 承接受控角标能力,`clipboard.ts` 承接剪贴板读写与 HostBridge payload / 响应边界,`files.ts` / `share.ts` / `scanner.ts` / `notifications.ts` 分别承接文件、分享、扫码和本地通知能力,`bridge.ts` 只作为 WebView message 入口、request id replay 编排和对外 facade;`apps/mobile-shell/App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx`,由 `apps/mobile-shell/src/shell/*.ts(x)` 承接 WebView 容器、URL、导航、网络、生命周期、安全区、扫码 overlay 和 WebView policy。Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs` 承接 envelope、method 白名单、request 校验和 replay 状态,`runtime.rs` 承接桌面 runtime 回包的平台、hostVersion、bridgeVersion 和 capability 清单组装,`appearance.rs` 承接窗口主题读取和 HostBridge 配色归一,`navigation.rs` 承接外链打开、受控 H5 跳转和主窗口刷新,`network.rs` 承接网络状态查询,`badge.rs` 承接受控任务栏角标能力,`clipboard.rs` 承接剪贴板读写与 HostBridge payload / 响应边界,`title.rs` 承接窗口标题 payload / 响应边界,`capabilities.rs` 承接共享桌面 capability profile 的 Rust 运行时镜像,`dispatch.rs` 承接 method 分发和宿主能力调用,`files.rs` 承接系统文件对话框、取消语义和异步读写编排,`file_payloads.rs` 承接文件 MIME、大小、base64、文件名清洗、本地副本读写和 HostBridge payload 边界,`share.rs` / `notifications.rs` 分别承接分享和本地通知能力,`mod.rs` 只保留模块声明、必要 re-export、`host_bridge_request` command facade 和 replay 编排;`apps/desktop-shell/src-tauri/src/shell/runtime.rs`、`url.rs`、`navigation.rs`、`network.rs`、`lifecycle.rs`、`file_drop.rs`、`events.rs`、`deep_link.rs`、`tray.rs`、`menu.rs`、`window_state.rs` 和 `webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘、应用菜单、窗口状态持久化和 WebView 门面,`apps/desktop-shell/src-tauri/src/app.rs` 承接 Tauri builder / plugin / window 装配,`main.rs` 只保留薄入口并调用 `app::run()`。 当前 `npm run check:native-shells` 锁定的生产文件清单为:微信桥接层 `dispatch.js`、`payment.js`、`protocol.js`、`shareGrid.js`、`subscribeMessage.js`、`webView.js`;微信 shell 层 `payment.js`、`shareGrid.js`、`subscribeMessage.js`、`webView.js`;微信页面包装层 `share-grid/index.js`、`share-grid/index.json`、`share-grid/index.wxml`、`share-grid/index.wxss`、`subscribe-message/index.js`、`subscribe-message/index.json`、`subscribe-message/index.wxml`、`subscribe-message/index.wxss`、`web-view/index.js`、`web-view/index.json`、`web-view/index.wxml`、`web-view/index.wxss`、`wechat-pay/index.js`、`wechat-pay/index.json`、`wechat-pay/index.wxml`、`wechat-pay/index.wxss`;移动源码根 `env.d.ts`;移动桥接层 `appearance.ts`、`badge.ts`、`bridge.ts`、`capabilities.ts`、`clipboard.ts`、`dispatch.ts`、`files.ts`、`haptics.ts`、`navigation.ts`、`network.ts`、`notifications.ts`、`protocol.ts`、`runtime.ts`、`scanner.ts`、`share.ts`;移动 shell 层 `QrScannerOverlay.tsx`、`ShellApp.tsx`、`deepLink.ts`、`lifecycle.ts`、`loadFailure.ts`、`navigation.ts`、`network.ts`、`runtime.ts`、`safeArea.ts`、`url.ts`、`webViewGlobals.d.ts`、`webViewHistory.ts`、`webViewPolicy.ts`;桌面入口 `app.rs`、`main.rs`;桌面桥接层 `appearance.rs`、`badge.rs`、`capabilities.rs`、`clipboard.rs`、`dispatch.rs`、`files.rs`、`mod.rs`、`navigation.rs`、`network.rs`、`notifications.rs`、`protocol.rs`、`runtime.rs`、`share.rs`、`title.rs`;桌面 shell 层 `deep_link.rs`、`events.rs`、`file_drop.rs`、`lifecycle.rs`、`menu.rs`、`mod.rs`、`navigation.rs`、`network.rs`、`runtime.rs`、`tray.rs`、`url.rs`、`webview.rs`、`window_state.rs`。 生产替身词扫描只覆盖上述壳源码、分发配置、共享 HostBridge 契约和已接入真实宿主能力的 H5 调用链;Expo export、Tauri `target/`、Cargo / Metro 缓存和 release 构建产物不进入扫描范围,避免本地或 CI 生成文件污染源码门禁。 -结构门禁按完整相对路径反查文档和目录:微信桥接层为 `miniprogram/host-bridge/dispatch.js`、`miniprogram/host-bridge/payment.js`、`miniprogram/host-bridge/protocol.js`、`miniprogram/host-bridge/shareGrid.js`、`miniprogram/host-bridge/subscribeMessage.js`、`miniprogram/host-bridge/webView.js`;微信 shell 层为 `miniprogram/shell/payment.js`、`miniprogram/shell/shareGrid.js`、`miniprogram/shell/subscribeMessage.js`、`miniprogram/shell/webView.js`;微信页面包装层为 `miniprogram/pages/share-grid/index.js`、`miniprogram/pages/share-grid/index.json`、`miniprogram/pages/share-grid/index.wxml`、`miniprogram/pages/share-grid/index.wxss`、`miniprogram/pages/subscribe-message/index.js`、`miniprogram/pages/subscribe-message/index.json`、`miniprogram/pages/subscribe-message/index.wxml`、`miniprogram/pages/subscribe-message/index.wxss`、`miniprogram/pages/web-view/index.js`、`miniprogram/pages/web-view/index.json`、`miniprogram/pages/web-view/index.wxml`、`miniprogram/pages/web-view/index.wxss`、`miniprogram/pages/wechat-pay/index.js`、`miniprogram/pages/wechat-pay/index.json`、`miniprogram/pages/wechat-pay/index.wxml`、`miniprogram/pages/wechat-pay/index.wxss`;移动源码根为 `apps/mobile-shell/src/env.d.ts`;移动桥接层为 `apps/mobile-shell/src/host-bridge/appearance.ts`、`apps/mobile-shell/src/host-bridge/badge.ts`、`apps/mobile-shell/src/host-bridge/bridge.ts`、`apps/mobile-shell/src/host-bridge/capabilities.ts`、`apps/mobile-shell/src/host-bridge/clipboard.ts`、`apps/mobile-shell/src/host-bridge/dispatch.ts`、`apps/mobile-shell/src/host-bridge/files.ts`、`apps/mobile-shell/src/host-bridge/haptics.ts`、`apps/mobile-shell/src/host-bridge/navigation.ts`、`apps/mobile-shell/src/host-bridge/network.ts`、`apps/mobile-shell/src/host-bridge/notifications.ts`、`apps/mobile-shell/src/host-bridge/protocol.ts`、`apps/mobile-shell/src/host-bridge/runtime.ts`、`apps/mobile-shell/src/host-bridge/scanner.ts`、`apps/mobile-shell/src/host-bridge/share.ts`;移动 shell 层为 `apps/mobile-shell/src/shell/QrScannerOverlay.tsx`、`apps/mobile-shell/src/shell/ShellApp.tsx`、`apps/mobile-shell/src/shell/deepLink.ts`、`apps/mobile-shell/src/shell/lifecycle.ts`、`apps/mobile-shell/src/shell/loadFailure.ts`、`apps/mobile-shell/src/shell/navigation.ts`、`apps/mobile-shell/src/shell/network.ts`、`apps/mobile-shell/src/shell/runtime.ts`、`apps/mobile-shell/src/shell/safeArea.ts`、`apps/mobile-shell/src/shell/url.ts`、`apps/mobile-shell/src/shell/webViewGlobals.d.ts`、`apps/mobile-shell/src/shell/webViewHistory.ts`、`apps/mobile-shell/src/shell/webViewPolicy.ts`;桌面入口为 `apps/desktop-shell/src-tauri/src/app.rs`、`apps/desktop-shell/src-tauri/src/main.rs`;桌面桥接层为 `apps/desktop-shell/src-tauri/src/host_bridge/appearance.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/badge.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/files.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/mod.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/network.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/runtime.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/share.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/title.rs`;桌面 shell 层为 `apps/desktop-shell/src-tauri/src/shell/deep_link.rs`、`apps/desktop-shell/src-tauri/src/shell/events.rs`、`apps/desktop-shell/src-tauri/src/shell/file_drop.rs`、`apps/desktop-shell/src-tauri/src/shell/lifecycle.rs`、`apps/desktop-shell/src-tauri/src/shell/menu.rs`、`apps/desktop-shell/src-tauri/src/shell/mod.rs`、`apps/desktop-shell/src-tauri/src/shell/navigation.rs`、`apps/desktop-shell/src-tauri/src/shell/network.rs`、`apps/desktop-shell/src-tauri/src/shell/runtime.rs`、`apps/desktop-shell/src-tauri/src/shell/tray.rs`、`apps/desktop-shell/src-tauri/src/shell/url.rs`、`apps/desktop-shell/src-tauri/src/shell/webview.rs`、`apps/desktop-shell/src-tauri/src/shell/window_state.rs`。这些目录不得新增未登记子目录或生产入口;移动端和桌面端单端配置检查同样会拒绝未登记生产模块。 +结构门禁按完整相对路径反查文档和目录:微信桥接层为 `miniprogram/host-bridge/dispatch.js`、`miniprogram/host-bridge/payment.js`、`miniprogram/host-bridge/protocol.js`、`miniprogram/host-bridge/shareGrid.js`、`miniprogram/host-bridge/subscribeMessage.js`、`miniprogram/host-bridge/webView.js`;微信 shell 层为 `miniprogram/shell/payment.js`、`miniprogram/shell/shareGrid.js`、`miniprogram/shell/subscribeMessage.js`、`miniprogram/shell/webView.js`;微信页面包装层为 `miniprogram/pages/share-grid/index.js`、`miniprogram/pages/share-grid/index.json`、`miniprogram/pages/share-grid/index.wxml`、`miniprogram/pages/share-grid/index.wxss`、`miniprogram/pages/subscribe-message/index.js`、`miniprogram/pages/subscribe-message/index.json`、`miniprogram/pages/subscribe-message/index.wxml`、`miniprogram/pages/subscribe-message/index.wxss`、`miniprogram/pages/web-view/index.js`、`miniprogram/pages/web-view/index.json`、`miniprogram/pages/web-view/index.wxml`、`miniprogram/pages/web-view/index.wxss`、`miniprogram/pages/wechat-pay/index.js`、`miniprogram/pages/wechat-pay/index.json`、`miniprogram/pages/wechat-pay/index.wxml`、`miniprogram/pages/wechat-pay/index.wxss`;移动源码根为 `apps/mobile-shell/src/env.d.ts`;移动桥接层为 `apps/mobile-shell/src/host-bridge/appearance.ts`、`apps/mobile-shell/src/host-bridge/badge.ts`、`apps/mobile-shell/src/host-bridge/bridge.ts`、`apps/mobile-shell/src/host-bridge/capabilities.ts`、`apps/mobile-shell/src/host-bridge/clipboard.ts`、`apps/mobile-shell/src/host-bridge/dispatch.ts`、`apps/mobile-shell/src/host-bridge/files.ts`、`apps/mobile-shell/src/host-bridge/haptics.ts`、`apps/mobile-shell/src/host-bridge/navigation.ts`、`apps/mobile-shell/src/host-bridge/network.ts`、`apps/mobile-shell/src/host-bridge/notifications.ts`、`apps/mobile-shell/src/host-bridge/protocol.ts`、`apps/mobile-shell/src/host-bridge/runtime.ts`、`apps/mobile-shell/src/host-bridge/scanner.ts`、`apps/mobile-shell/src/host-bridge/share.ts`;移动 shell 层为 `apps/mobile-shell/src/shell/QrScannerOverlay.tsx`、`apps/mobile-shell/src/shell/ShellApp.tsx`、`apps/mobile-shell/src/shell/deepLink.ts`、`apps/mobile-shell/src/shell/lifecycle.ts`、`apps/mobile-shell/src/shell/loadFailure.ts`、`apps/mobile-shell/src/shell/navigation.ts`、`apps/mobile-shell/src/shell/network.ts`、`apps/mobile-shell/src/shell/runtime.ts`、`apps/mobile-shell/src/shell/safeArea.ts`、`apps/mobile-shell/src/shell/url.ts`、`apps/mobile-shell/src/shell/webViewGlobals.d.ts`、`apps/mobile-shell/src/shell/webViewHistory.ts`、`apps/mobile-shell/src/shell/webViewPolicy.ts`;桌面入口为 `apps/desktop-shell/src-tauri/src/app.rs`、`apps/desktop-shell/src-tauri/src/main.rs`;桌面桥接层为 `apps/desktop-shell/src-tauri/src/host_bridge/appearance.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/badge.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/files.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/mod.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/network.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/runtime.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/share.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/title.rs`;桌面 shell 层为 `apps/desktop-shell/src-tauri/src/shell/deep_link.rs`、`apps/desktop-shell/src-tauri/src/shell/events.rs`、`apps/desktop-shell/src-tauri/src/shell/file_drop.rs`、`apps/desktop-shell/src-tauri/src/shell/lifecycle.rs`、`apps/desktop-shell/src-tauri/src/shell/menu.rs`、`apps/desktop-shell/src-tauri/src/shell/mod.rs`、`apps/desktop-shell/src-tauri/src/shell/navigation.rs`、`apps/desktop-shell/src-tauri/src/shell/network.rs`、`apps/desktop-shell/src-tauri/src/shell/runtime.rs`、`apps/desktop-shell/src-tauri/src/shell/tray.rs`、`apps/desktop-shell/src-tauri/src/shell/url.rs`、`apps/desktop-shell/src-tauri/src/shell/webview.rs`、`apps/desktop-shell/src-tauri/src/shell/window_state.rs`。这些目录不得新增未登记子目录或生产入口;移动端和桌面端单端配置检查同样会拒绝未登记生产模块。 ## HostBridge 消息协议 @@ -529,7 +529,7 @@ GameBridge 禁止: - 移动端接入系统分享、推送、原生登录和渠道支付。 - 移动端和桌面端的自动更新、崩溃上报、analytics、渠道分发、原生登录和渠道支付都必须等真实 SDK、后端契约、发布流程和隐私口径确定后逐项接入;文件导出、图片拖拽导入、系统托盘、即时本地通知和系统分享已按真实宿主能力逐项接入。 -- Tauri 桌面壳的文件导入导出执行边界统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/files.rs`,系统文件对话框过滤器、用户取消语义、路径转换、异步读写和 HostBridge 响应都由文件模块负责;`dispatch.rs` 只按 method 委托文件模块。 +- Tauri 桌面壳的文件导入导出执行边界分为两层:`apps/desktop-shell/src-tauri/src/host_bridge/files.rs` 统一承接系统文件对话框过滤器、用户取消语义、路径转换、异步读写编排和 HostBridge 响应,`apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs` 统一承接 MIME、大小、base64、文件名清洗、本地副本读写和 payload 组装;`dispatch.rs` 只按 method 委托文件模块。 - 所有新增能力先更新 HostBridge 契约和测试,再落壳实现。 ### Phase 5:AI H5 sandbox diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md index b601ea4d3..c4969fd3f 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -37,13 +37,13 @@ AI H5 sandbox -> parent HostBridge adapter ``` -桥接层文件结构按宿主统一为“协议 / 能力清单 / 分发 / 宿主容器行为”四类职责。微信小程序不硬套 Expo / Tauri 的 request 总线:`miniprogram/host-bridge/protocol.js` 只沉淀微信壳能力、页面 URL、结果 hash / storage key 和分享消息类型等常量,`dispatch.js` 只作为 `protocol`、`webView`、`payment`、`shareGrid`、`subscribeMessage` 的薄索引,真实协议归一、支付 / 订阅 / 分享结果编解码仍分别放在 `webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`;`miniprogram/shell/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js` 承接 Page 生命周期、`wx.*` 容器调用、WebView 容器行为、支付页和订阅页装配,页面目录只保留 `Page(createWechat...Page())` 装配。Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/protocol.ts` 承接 envelope、request 校验、ok / failure 响应和 replay 基础类型,`capabilities.ts` 只引用共享 HostBridge capability profile 并选择 iOS 差异能力,`dispatch.ts` 承接 method 分发和宿主能力调用,`appearance.ts` 承接系统配色读取,`navigation.ts` 承接外链打开、受控 H5 跳转和 WebView 刷新,`network.ts` 承接网络状态查询,`badge.ts` 承接受控角标能力,`clipboard.ts` 承接剪贴板读写与 HostBridge payload / 响应边界,`files.ts` / `share.ts` / `scanner.ts` / `notifications.ts` 分别承接文件、分享、扫码和本地通知能力,`bridge.ts` 只作为 WebView message 入口、request id replay 编排和对外 facade;`apps/mobile-shell/App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx`,由 `apps/mobile-shell/src/shell/*.ts(x)` 承接 WebView 容器、URL、导航、网络、生命周期、安全区、扫码 overlay 和 WebView policy。Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs` 承接 envelope、method 白名单、request 校验和 replay 状态,`runtime.rs` 承接桌面 runtime 回包的平台、hostVersion、bridgeVersion 和 capability 清单组装,`appearance.rs` 承接窗口主题读取和 HostBridge 配色归一,`navigation.rs` 承接外链打开、受控 H5 跳转和主窗口刷新,`network.rs` 承接网络状态查询,`badge.rs` 承接受控任务栏角标能力,`clipboard.rs` 承接剪贴板读写与 HostBridge payload / 响应边界,`title.rs` 承接窗口标题 payload / 响应边界,`capabilities.rs` 承接共享桌面 capability profile 的 Rust 运行时镜像,`dispatch.rs` 承接 method 分发和宿主能力调用,`files.rs` / `share.rs` / `notifications.rs` 分别承接文件、分享和本地通知能力,`mod.rs` 只保留模块声明、必要 re-export、`host_bridge_request` command facade 和 replay 编排;`apps/desktop-shell/src-tauri/src/shell/runtime.rs`、`url.rs`、`navigation.rs`、`network.rs`、`lifecycle.rs`、`file_drop.rs`、`events.rs`、`deep_link.rs`、`tray.rs`、`menu.rs`、`window_state.rs` 和 `webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘、应用菜单、窗口状态持久化和 WebView 门面,`apps/desktop-shell/src-tauri/src/app.rs` 承接 Tauri builder / plugin / window 装配,`main.rs` 只保留薄入口并调用 `app::run()`。`npm run check:native-shells` 会检查这些目录清单。 +桥接层文件结构按宿主统一为“协议 / 能力清单 / 分发 / 宿主容器行为”四类职责。微信小程序不硬套 Expo / Tauri 的 request 总线:`miniprogram/host-bridge/protocol.js` 只沉淀微信壳能力、页面 URL、结果 hash / storage key 和分享消息类型等常量,`dispatch.js` 只作为 `protocol`、`webView`、`payment`、`shareGrid`、`subscribeMessage` 的薄索引,真实协议归一、支付 / 订阅 / 分享结果编解码仍分别放在 `webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`;`miniprogram/shell/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js` 承接 Page 生命周期、`wx.*` 容器调用、WebView 容器行为、支付页和订阅页装配,页面目录只保留 `Page(createWechat...Page())` 装配。Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/protocol.ts` 承接 envelope、request 校验、ok / failure 响应和 replay 基础类型,`capabilities.ts` 只引用共享 HostBridge capability profile 并选择 iOS 差异能力,`dispatch.ts` 承接 method 分发和宿主能力调用,`appearance.ts` 承接系统配色读取,`navigation.ts` 承接外链打开、受控 H5 跳转和 WebView 刷新,`network.ts` 承接网络状态查询,`badge.ts` 承接受控角标能力,`clipboard.ts` 承接剪贴板读写与 HostBridge payload / 响应边界,`files.ts` / `share.ts` / `scanner.ts` / `notifications.ts` 分别承接文件、分享、扫码和本地通知能力,`bridge.ts` 只作为 WebView message 入口、request id replay 编排和对外 facade;`apps/mobile-shell/App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx`,由 `apps/mobile-shell/src/shell/*.ts(x)` 承接 WebView 容器、URL、导航、网络、生命周期、安全区、扫码 overlay 和 WebView policy。Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs` 承接 envelope、method 白名单、request 校验和 replay 状态,`runtime.rs` 承接桌面 runtime 回包的平台、hostVersion、bridgeVersion 和 capability 清单组装,`appearance.rs` 承接窗口主题读取和 HostBridge 配色归一,`navigation.rs` 承接外链打开、受控 H5 跳转和主窗口刷新,`network.rs` 承接网络状态查询,`badge.rs` 承接受控任务栏角标能力,`clipboard.rs` 承接剪贴板读写与 HostBridge payload / 响应边界,`title.rs` 承接窗口标题 payload / 响应边界,`capabilities.rs` 承接共享桌面 capability profile 的 Rust 运行时镜像,`dispatch.rs` 承接 method 分发和宿主能力调用,`files.rs` 承接系统文件对话框、取消语义和异步读写编排,`file_payloads.rs` 承接文件 MIME、大小、base64、文件名清洗、本地副本读写和 HostBridge payload 边界,`share.rs` / `notifications.rs` 分别承接分享和本地通知能力,`mod.rs` 只保留模块声明、必要 re-export、`host_bridge_request` command facade 和 replay 编排;`apps/desktop-shell/src-tauri/src/shell/runtime.rs`、`url.rs`、`navigation.rs`、`network.rs`、`lifecycle.rs`、`file_drop.rs`、`events.rs`、`deep_link.rs`、`tray.rs`、`menu.rs`、`window_state.rs` 和 `webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘、应用菜单、窗口状态持久化和 WebView 门面,`apps/desktop-shell/src-tauri/src/app.rs` 承接 Tauri builder / plugin / window 装配,`main.rs` 只保留薄入口并调用 `app::run()`。`npm run check:native-shells` 会检查这些目录清单。 当前 `npm run check:native-shells` 锁定的生产文件清单为:微信桥接层 `dispatch.js`、`payment.js`、`protocol.js`、`shareGrid.js`、`subscribeMessage.js`、`webView.js`;微信 shell 层 `payment.js`、`shareGrid.js`、`subscribeMessage.js`、`webView.js`;微信页面包装层 `share-grid/index.js`、`share-grid/index.json`、`share-grid/index.wxml`、`share-grid/index.wxss`、`subscribe-message/index.js`、`subscribe-message/index.json`、`subscribe-message/index.wxml`、`subscribe-message/index.wxss`、`web-view/index.js`、`web-view/index.json`、`web-view/index.wxml`、`web-view/index.wxss`、`wechat-pay/index.js`、`wechat-pay/index.json`、`wechat-pay/index.wxml`、`wechat-pay/index.wxss`;移动源码根 `env.d.ts`;移动桥接层 `appearance.ts`、`badge.ts`、`bridge.ts`、`capabilities.ts`、`clipboard.ts`、`dispatch.ts`、`files.ts`、`haptics.ts`、`navigation.ts`、`network.ts`、`notifications.ts`、`protocol.ts`、`runtime.ts`、`scanner.ts`、`share.ts`;移动 shell 层 `QrScannerOverlay.tsx`、`ShellApp.tsx`、`deepLink.ts`、`lifecycle.ts`、`loadFailure.ts`、`navigation.ts`、`network.ts`、`runtime.ts`、`safeArea.ts`、`url.ts`、`webViewGlobals.d.ts`、`webViewHistory.ts`、`webViewPolicy.ts`;桌面入口 `app.rs`、`main.rs`;桌面桥接层 `appearance.rs`、`badge.rs`、`capabilities.rs`、`clipboard.rs`、`dispatch.rs`、`files.rs`、`mod.rs`、`navigation.rs`、`network.rs`、`notifications.rs`、`protocol.rs`、`runtime.rs`、`share.rs`、`title.rs`;桌面 shell 层 `deep_link.rs`、`events.rs`、`file_drop.rs`、`lifecycle.rs`、`menu.rs`、`mod.rs`、`navigation.rs`、`network.rs`、`runtime.rs`、`tray.rs`、`url.rs`、`webview.rs`、`window_state.rs`。 生产替身词扫描只覆盖上述壳源码、分发配置、共享 HostBridge 契约和已接入真实宿主能力的 H5 调用链;Expo export、Tauri `target/`、Cargo / Metro 缓存和 release 构建产物不进入扫描范围,避免本地或 CI 生成文件污染源码门禁。 -结构门禁按完整相对路径反查文档和目录:微信桥接层为 `miniprogram/host-bridge/dispatch.js`、`miniprogram/host-bridge/payment.js`、`miniprogram/host-bridge/protocol.js`、`miniprogram/host-bridge/shareGrid.js`、`miniprogram/host-bridge/subscribeMessage.js`、`miniprogram/host-bridge/webView.js`;微信 shell 层为 `miniprogram/shell/payment.js`、`miniprogram/shell/shareGrid.js`、`miniprogram/shell/subscribeMessage.js`、`miniprogram/shell/webView.js`;微信页面包装层为 `miniprogram/pages/share-grid/index.js`、`miniprogram/pages/share-grid/index.json`、`miniprogram/pages/share-grid/index.wxml`、`miniprogram/pages/share-grid/index.wxss`、`miniprogram/pages/subscribe-message/index.js`、`miniprogram/pages/subscribe-message/index.json`、`miniprogram/pages/subscribe-message/index.wxml`、`miniprogram/pages/subscribe-message/index.wxss`、`miniprogram/pages/web-view/index.js`、`miniprogram/pages/web-view/index.json`、`miniprogram/pages/web-view/index.wxml`、`miniprogram/pages/web-view/index.wxss`、`miniprogram/pages/wechat-pay/index.js`、`miniprogram/pages/wechat-pay/index.json`、`miniprogram/pages/wechat-pay/index.wxml`、`miniprogram/pages/wechat-pay/index.wxss`;移动源码根为 `apps/mobile-shell/src/env.d.ts`;移动桥接层为 `apps/mobile-shell/src/host-bridge/appearance.ts`、`apps/mobile-shell/src/host-bridge/badge.ts`、`apps/mobile-shell/src/host-bridge/bridge.ts`、`apps/mobile-shell/src/host-bridge/capabilities.ts`、`apps/mobile-shell/src/host-bridge/clipboard.ts`、`apps/mobile-shell/src/host-bridge/dispatch.ts`、`apps/mobile-shell/src/host-bridge/files.ts`、`apps/mobile-shell/src/host-bridge/haptics.ts`、`apps/mobile-shell/src/host-bridge/navigation.ts`、`apps/mobile-shell/src/host-bridge/network.ts`、`apps/mobile-shell/src/host-bridge/notifications.ts`、`apps/mobile-shell/src/host-bridge/protocol.ts`、`apps/mobile-shell/src/host-bridge/runtime.ts`、`apps/mobile-shell/src/host-bridge/scanner.ts`、`apps/mobile-shell/src/host-bridge/share.ts`;移动 shell 层为 `apps/mobile-shell/src/shell/QrScannerOverlay.tsx`、`apps/mobile-shell/src/shell/ShellApp.tsx`、`apps/mobile-shell/src/shell/deepLink.ts`、`apps/mobile-shell/src/shell/lifecycle.ts`、`apps/mobile-shell/src/shell/loadFailure.ts`、`apps/mobile-shell/src/shell/navigation.ts`、`apps/mobile-shell/src/shell/network.ts`、`apps/mobile-shell/src/shell/runtime.ts`、`apps/mobile-shell/src/shell/safeArea.ts`、`apps/mobile-shell/src/shell/url.ts`、`apps/mobile-shell/src/shell/webViewGlobals.d.ts`、`apps/mobile-shell/src/shell/webViewHistory.ts`、`apps/mobile-shell/src/shell/webViewPolicy.ts`;桌面入口为 `apps/desktop-shell/src-tauri/src/app.rs`、`apps/desktop-shell/src-tauri/src/main.rs`;桌面桥接层为 `apps/desktop-shell/src-tauri/src/host_bridge/appearance.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/badge.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/files.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/mod.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/network.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/runtime.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/share.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/title.rs`;桌面 shell 层为 `apps/desktop-shell/src-tauri/src/shell/deep_link.rs`、`apps/desktop-shell/src-tauri/src/shell/events.rs`、`apps/desktop-shell/src-tauri/src/shell/file_drop.rs`、`apps/desktop-shell/src-tauri/src/shell/lifecycle.rs`、`apps/desktop-shell/src-tauri/src/shell/menu.rs`、`apps/desktop-shell/src-tauri/src/shell/mod.rs`、`apps/desktop-shell/src-tauri/src/shell/navigation.rs`、`apps/desktop-shell/src-tauri/src/shell/network.rs`、`apps/desktop-shell/src-tauri/src/shell/runtime.rs`、`apps/desktop-shell/src-tauri/src/shell/tray.rs`、`apps/desktop-shell/src-tauri/src/shell/url.rs`、`apps/desktop-shell/src-tauri/src/shell/webview.rs`、`apps/desktop-shell/src-tauri/src/shell/window_state.rs`。这些目录不得新增未登记子目录或生产入口;移动端和桌面端单端配置检查同样会拒绝未登记生产模块。 +结构门禁按完整相对路径反查文档和目录:微信桥接层为 `miniprogram/host-bridge/dispatch.js`、`miniprogram/host-bridge/payment.js`、`miniprogram/host-bridge/protocol.js`、`miniprogram/host-bridge/shareGrid.js`、`miniprogram/host-bridge/subscribeMessage.js`、`miniprogram/host-bridge/webView.js`;微信 shell 层为 `miniprogram/shell/payment.js`、`miniprogram/shell/shareGrid.js`、`miniprogram/shell/subscribeMessage.js`、`miniprogram/shell/webView.js`;微信页面包装层为 `miniprogram/pages/share-grid/index.js`、`miniprogram/pages/share-grid/index.json`、`miniprogram/pages/share-grid/index.wxml`、`miniprogram/pages/share-grid/index.wxss`、`miniprogram/pages/subscribe-message/index.js`、`miniprogram/pages/subscribe-message/index.json`、`miniprogram/pages/subscribe-message/index.wxml`、`miniprogram/pages/subscribe-message/index.wxss`、`miniprogram/pages/web-view/index.js`、`miniprogram/pages/web-view/index.json`、`miniprogram/pages/web-view/index.wxml`、`miniprogram/pages/web-view/index.wxss`、`miniprogram/pages/wechat-pay/index.js`、`miniprogram/pages/wechat-pay/index.json`、`miniprogram/pages/wechat-pay/index.wxml`、`miniprogram/pages/wechat-pay/index.wxss`;移动源码根为 `apps/mobile-shell/src/env.d.ts`;移动桥接层为 `apps/mobile-shell/src/host-bridge/appearance.ts`、`apps/mobile-shell/src/host-bridge/badge.ts`、`apps/mobile-shell/src/host-bridge/bridge.ts`、`apps/mobile-shell/src/host-bridge/capabilities.ts`、`apps/mobile-shell/src/host-bridge/clipboard.ts`、`apps/mobile-shell/src/host-bridge/dispatch.ts`、`apps/mobile-shell/src/host-bridge/files.ts`、`apps/mobile-shell/src/host-bridge/haptics.ts`、`apps/mobile-shell/src/host-bridge/navigation.ts`、`apps/mobile-shell/src/host-bridge/network.ts`、`apps/mobile-shell/src/host-bridge/notifications.ts`、`apps/mobile-shell/src/host-bridge/protocol.ts`、`apps/mobile-shell/src/host-bridge/runtime.ts`、`apps/mobile-shell/src/host-bridge/scanner.ts`、`apps/mobile-shell/src/host-bridge/share.ts`;移动 shell 层为 `apps/mobile-shell/src/shell/QrScannerOverlay.tsx`、`apps/mobile-shell/src/shell/ShellApp.tsx`、`apps/mobile-shell/src/shell/deepLink.ts`、`apps/mobile-shell/src/shell/lifecycle.ts`、`apps/mobile-shell/src/shell/loadFailure.ts`、`apps/mobile-shell/src/shell/navigation.ts`、`apps/mobile-shell/src/shell/network.ts`、`apps/mobile-shell/src/shell/runtime.ts`、`apps/mobile-shell/src/shell/safeArea.ts`、`apps/mobile-shell/src/shell/url.ts`、`apps/mobile-shell/src/shell/webViewGlobals.d.ts`、`apps/mobile-shell/src/shell/webViewHistory.ts`、`apps/mobile-shell/src/shell/webViewPolicy.ts`;桌面入口为 `apps/desktop-shell/src-tauri/src/app.rs`、`apps/desktop-shell/src-tauri/src/main.rs`;桌面桥接层为 `apps/desktop-shell/src-tauri/src/host_bridge/appearance.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/badge.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/files.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/mod.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/network.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/runtime.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/share.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/title.rs`;桌面 shell 层为 `apps/desktop-shell/src-tauri/src/shell/deep_link.rs`、`apps/desktop-shell/src-tauri/src/shell/events.rs`、`apps/desktop-shell/src-tauri/src/shell/file_drop.rs`、`apps/desktop-shell/src-tauri/src/shell/lifecycle.rs`、`apps/desktop-shell/src-tauri/src/shell/menu.rs`、`apps/desktop-shell/src-tauri/src/shell/mod.rs`、`apps/desktop-shell/src-tauri/src/shell/navigation.rs`、`apps/desktop-shell/src-tauri/src/shell/network.rs`、`apps/desktop-shell/src-tauri/src/shell/runtime.rs`、`apps/desktop-shell/src-tauri/src/shell/tray.rs`、`apps/desktop-shell/src-tauri/src/shell/url.rs`、`apps/desktop-shell/src-tauri/src/shell/webview.rs`、`apps/desktop-shell/src-tauri/src/shell/window_state.rs`。这些目录不得新增未登记子目录或生产入口;移动端和桌面端单端配置检查同样会拒绝未登记生产模块。 Tauri 桌面壳启动时必须按 `label="main"` 解析 `tauri.conf.json` 主窗口配置,并在创建 WebView 前补写 `native_app`、`tauri_desktop` 和真实 capability 上下文;缺少主窗口配置时启动直接失败,不允许按 `windows[0]` 兜底或无主窗口静默运行。 @@ -85,7 +85,7 @@ HostBridge 事件名以 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_ - `importHostAudioFile()`:原生 App 宿主的受控音频导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统音频选择器,Tauri 壳通过系统文件选择框读取用户选择的音频;两端都只接受 `audio/mpeg`、`audio/mp4`、`audio/wav`、`audio/ogg`、`audio/webm` 或对应扩展名,单次不超过 20 MiB,成功只返回清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;宿主必须在读取音频内容或生成 base64 前拿到可信 byte count 并完成上限校验,移动壳在 picker 缺少 `size` 时改用 Expo `File.size`,仍拿不到可信大小时直接拒绝导入;H5 facade 收到结果后继续通过共享契约 `normalizeHostBridgeImportAudioResult()` 复核文件名、MIME、base64 和字节数。H5 的通用音频输入面板 `CreativeAudioInputPanel` 在 `native_app` 且声明 `file.importAudio` 时优先调用宿主导入,并把结果转换成现有 `File` 后继续复用 `readFileAsAsset(file, 'uploaded')` 音频处理链路;视觉小说结果页音乐和环境音素材上传同样优先调用宿主音频导入,再把返回副本转换成浏览器 `File` 后继续交给 `uploadVisualNovelAsset` 上传和场景音频字段写回链路。普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。 - `exportHostAudioFile()`:原生 App 宿主的受控音频导出入口。H5 只传当前页面已持有的音频 `base64Data`、清洗后的文件名和允许的 `audio/mpeg` / `audio/mp4` / `audio/wav` / `audio/ogg` / `audio/webm` MIME;H5 facade 发起请求前先通过共享契约 `normalizeHostBridgeExportAudioPayload()` 预校验文件名、MIME、base64 和 20 MiB 上限,Expo 与 Tauri 壳仍必须二次校验真实字节与 MIME。Expo 移动壳写入缓存音频后交给系统分享 / 保存面板,Tauri 壳打开系统保存对话框并写入音频字节。成功只返回文件名和字节数,不回传本机绝对路径,也不让宿主代读任意本地文件。H5 的通用音频输入面板只在当前资产包含本地 `Blob`、`fileName` 和允许 MIME 且宿主声明 `file.exportAudio` 时展示导出入口;远端已上传音频、浏览器、小程序和未声明能力的裁剪壳不展示该入口。 -Tauri 桌面壳的文件能力边界统一在 `apps/desktop-shell/src-tauri/src/host_bridge/files.rs` 内完成:该模块同时持有文件 payload 校验、系统文件对话框过滤器、用户取消语义、路径转换、异步读写和 HostBridge 响应归一;`dispatch.rs` 只按 method 委托文件模块,不直接调用 `.dialog()`、`blocking_save_file` / `blocking_pick_file`、文件 payload helper 或落盘 helper。 +Tauri 桌面壳的文件能力边界分为两层:`apps/desktop-shell/src-tauri/src/host_bridge/files.rs` 统一持有系统文件对话框过滤器、用户取消语义、路径转换、异步读写和 HostBridge 响应归一,`apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs` 统一持有文件 payload 校验、MIME / 大小 / bytes 边界、文件名清洗、本地副本读写和导入导出 payload 组装;`dispatch.rs` 只按 method 委托文件模块,不直接调用 `.dialog()`、`blocking_save_file` / `blocking_pick_file`、文件 payload helper 或落盘 helper。 ## 迁移顺序 diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index fccb2c0ab..c146843f9 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -303,6 +303,7 @@ const expectedDesktopHostBridgeRustFiles = [ 'capabilities.rs', 'clipboard.rs', 'dispatch.rs', + 'file_payloads.rs', 'files.rs', 'mod.rs', 'navigation.rs',