From 199f02cf9facf8e3c5da99884ac5d9bc45d791d7 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 18 Jun 2026 02:52:37 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E6=A1=8C=E9=9D=A2=E5=A3=B3?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E5=AF=BC=E5=85=A5=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 file.importImage 与 file.imageDropped HostBridge 能力 Tauri 壳通过系统图片选择和主窗口拖拽读取真实图片文件 H5 统一使用导入图片与拖入图片事件 facade 限制图片 MIME 与大小并避免暴露本机绝对路径 更新壳能力校验、测试和架构文档 --- apps/desktop-shell/scripts/check-config.mjs | 7 + apps/desktop-shell/src-tauri/src/main.rs | 184 +++++++++++++++++- apps/desktop-shell/src-tauri/tauri.conf.json | 4 +- .../shared-memory/decision-log.md | 1 + ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 4 +- ...前端架构】宿主壳能力统一协议-2026-06-17.md | 1 + .../shared/src/contracts/hostBridge.test.ts | 2 + packages/shared/src/contracts/hostBridge.ts | 19 ++ src/services/host-bridge/hostBridge.test.ts | 108 ++++++++++ src/services/host-bridge/hostBridge.ts | 92 +++++++++ 10 files changed, 418 insertions(+), 4 deletions(-) diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index f038edf6..0744dbb1 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -145,17 +145,24 @@ const requiredMainSnippets = [ '"clipboard.writeText"', '"file.exportText"', '"file.exportImage"', + '"file.importImage"', + '"file.imageDropped"', 'tauri_plugin_dialog::init()', '"copied_to_clipboard"', '"file export cancelled"', + '"file import cancelled"', 'BASE64_STANDARD.decode', + 'blocking_pick_file', + 'import_image_file_payload', 'set_title', 'set_badge_count', 'window.theme()', 'WindowEvent::Focused', + 'WindowEvent::DragDrop', 'host_bridge_event_script', 'resolve_desktop_network_status', 'network.statusChanged', + 'file.imageDropped', ]; for (const permission of requiredPermissions) { diff --git a/apps/desktop-shell/src-tauri/src/main.rs b/apps/desktop-shell/src-tauri/src/main.rs index 480a55cf..68c19094 100644 --- a/apps/desktop-shell/src-tauri/src/main.rs +++ b/apps/desktop-shell/src-tauri/src/main.rs @@ -3,9 +3,10 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::fs; use std::net::{TcpStream, ToSocketAddrs}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::time::Duration; +use tauri::DragDropEvent; use tauri::Manager; use tauri::Theme; use tauri::Url; @@ -21,6 +22,7 @@ const WEB_APP_ORIGIN: &str = "https://app.genarrative.world"; const EXTERNAL_URL_PROTOCOLS: [&str; 4] = ["http:", "https:", "mailto:", "tel:"]; const EXPORT_TEXT_MAX_BYTES: usize = 5 * 1024 * 1024; const EXPORT_IMAGE_MAX_BYTES: usize = 5 * 1024 * 1024; +const IMPORT_IMAGE_MAX_BYTES: u64 = 10 * 1024 * 1024; const EXPORT_FILE_NAME_FALLBACK: &str = "genarrative-export.txt"; const EXPORT_FILE_NAME_MAX_LENGTH: usize = 120; const BADGE_COUNT_MAX: i64 = 99999; @@ -98,6 +100,8 @@ fn capabilities() -> Vec<&'static str> { "clipboard.writeText", "file.exportText", "file.exportImage", + "file.importImage", + "file.imageDropped", ] } @@ -328,6 +332,20 @@ fn export_image_extension(mime_type: &str) -> Option<&'static str> { } } +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 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"); @@ -411,6 +429,51 @@ fn write_export_bytes_file(path: PathBuf, bytes: Vec) -> Result, +) -> 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()); + } + 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) +} + fn host_bridge_event_script(event: &str, payload: Value) -> Result { let message = json!({ "bridge": HOST_BRIDGE_PROTOCOL, @@ -488,6 +551,37 @@ fn register_desktop_lifecycle_events(window: &WebviewWindow) { }); } +fn emit_desktop_image_drop_event( + window: &WebviewWindow, + paths: &[PathBuf], + position: (i32, i32), +) -> tauri::Result<()> { + let Some(path) = paths + .iter() + .find(|path| path.is_file() && import_image_mime_type(path).is_some()) + .cloned() + else { + return Ok(()); + }; + let Ok(payload) = import_image_file_payload(path, "dropped", Some(position)) else { + return Ok(()); + }; + let script = + host_bridge_event_script("file.imageDropped", payload).map_err(tauri::Error::Json)?; + + window.eval(script) +} + +fn register_desktop_file_drop_events(window: &WebviewWindow) { + let drop_window = window.clone(); + window.on_window_event(move |event| { + if let WindowEvent::DragDrop(DragDropEvent::Drop { paths, position }) = event { + let drop_position = (position.x.round() as i32, position.y.round() as i32); + let _ = emit_desktop_image_drop_event(&drop_window, paths, drop_position); + } + }); +} + fn register_desktop_network_events(window: &WebviewWindow) -> tauri::Result<()> { let online_script = host_bridge_event_script( "network.statusChanged", @@ -782,6 +876,29 @@ async fn host_bridge_request( }), ) } + "file.importImage" => { + let file_path = app + .dialog() + .file() + .add_filter("Image", &["png", "jpg", "jpeg", "webp"]) + .blocking_pick_file(); + let Some(file_path) = file_path else { + return failed(request.id, "cancelled", "file import cancelled"); + }; + let path = match file_path.into_path() { + Ok(path) => path, + Err(error) => return failed(request.id, "host_error", error.to_string()), + }; + let import_result = tauri::async_runtime::spawn_blocking(move || { + import_image_file_payload(path, "selected", None) + }) + .await; + match import_result { + Ok(Ok(payload)) => ok(request.id, payload), + Ok(Err(error)) => failed(request.id, "invalid_request", error), + Err(error) => failed(request.id, "host_error", error.to_string()), + } + } "app.setTitle" => { let title = match required_string_payload(&request, "title") .ok() @@ -875,6 +992,7 @@ fn main() { register_desktop_lifecycle_events(&window); let _ = emit_desktop_lifecycle_event(&window, "active", true, "created"); let _ = register_desktop_network_events(&window); + register_desktop_file_drop_events(&window); } Ok(()) }) @@ -950,6 +1068,14 @@ mod tests { .as_array() .unwrap() .contains(&json!("file.exportImage"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("file.importImage"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("file.imageDropped"))); } #[test] @@ -1209,6 +1335,62 @@ mod tests { assert_eq!(bytes, b"share-card"); } + #[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, b"image").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"], "aW1hZ2U="); + assert_eq!(payload["mimeType"], "image/png"); + assert_eq!(payload["bytes"], 5); + 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 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 export_image_payload_rejects_invalid_mime_and_base64() { let mut invalid_mime = request("file.exportImage"); diff --git a/apps/desktop-shell/src-tauri/tauri.conf.json b/apps/desktop-shell/src-tauri/tauri.conf.json index 466413aa..0f975f99 100644 --- a/apps/desktop-shell/src-tauri/tauri.conf.json +++ b/apps/desktop-shell/src-tauri/tauri.conf.json @@ -6,7 +6,7 @@ "build": { "beforeDevCommand": "npm --prefix ../.. run dev:web", "beforeBuildCommand": "npm --prefix ../.. run build:raw && npm run typecheck", - "devUrl": "http://127.0.0.1:3000/?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,appearance.getColorScheme,app.lifecycle,share.open,share.setTarget,navigation.openNativePage,app.openExternalUrl,app.setTitle,app.setBadgeCount,network.status,network.statusChanged,clipboard.writeText,file.exportText,file.exportImage", + "devUrl": "http://127.0.0.1:3000/?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,appearance.getColorScheme,app.lifecycle,share.open,share.setTarget,navigation.openNativePage,app.openExternalUrl,app.setTitle,app.setBadgeCount,network.status,network.statusChanged,clipboard.writeText,file.exportText,file.exportImage,file.importImage,file.imageDropped", "frontendDist": "../../../dist" }, "app": { @@ -14,7 +14,7 @@ { "create": false, "label": "main", - "url": "index.html?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,appearance.getColorScheme,app.lifecycle,share.open,share.setTarget,navigation.openNativePage,app.openExternalUrl,app.setTitle,app.setBadgeCount,network.status,network.statusChanged,clipboard.writeText,file.exportText,file.exportImage", + "url": "index.html?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,appearance.getColorScheme,app.lifecycle,share.open,share.setTarget,navigation.openNativePage,app.openExternalUrl,app.setTitle,app.setBadgeCount,network.status,network.statusChanged,clipboard.writeText,file.exportText,file.exportImage,file.importImage,file.imageDropped", "title": "Genarrative", "width": 1280, "height": 820, diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7bd21682..f5b49d3a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -32,6 +32,7 @@ - 2026-06-18 宿主外观只读查询:新增 `appearance.getColorScheme` HostBridge capability,Expo 壳通过 React Native `Appearance.getColorScheme()` 读取系统配色,Tauri 壳通过主窗口 `theme()` 读取窗口主题;该能力只返回 `light` / `dark` / `unknown`,不设置 H5 主题、不覆盖系统主题,也不作为强制 UI 样式入口。 - 2026-06-18 原生壳生命周期事件:新增 `app.lifecycle` HostBridge capability,Expo 壳通过 React Native `AppState` 派发 `active` / `inactive` / `background`,Tauri 壳通过主窗口 focus / blur 派发 `active` / `inactive`;H5 只通过 `subscribeHostAppLifecycle()` 订阅统一状态,后续游戏循环、音频和轮询暂停 / 恢复不得直接依赖 Expo / Tauri 平台细节。 - 2026-06-18 原生壳网络状态:新增 `network.status` 与 `network.statusChanged` HostBridge capability,Expo 壳通过 `expo-network` 查询和订阅真实系统网络状态,Tauri 壳通过短超时连接 `app.genarrative.world:443` 查询主站可达性,并通过 WebView `online` / `offline` 注入变化事件;H5 统一使用 `getHostNetworkStatus()` / `subscribeHostNetworkStatusChange()`,不得直接读取 Expo / Tauri 私有网络 API。 +- 2026-06-18 桌面图片导入:新增 `file.importImage` 与 `file.imageDropped` HostBridge capability,Tauri 壳通过系统文件选择框和主窗口拖拽事件读取用户选择 / 拖入的真实图片,只允许 `image/png`、`image/jpeg`、`image/webp` 且单次不超过 10 MiB;H5 统一使用 `importHostImageFile()` / `subscribeHostImageDrop()`,宿主只回传文件名、MIME、base64 内容、字节数和可选坐标,不暴露本地绝对路径,也不开放通用文件系统。 - 影响范围:`src/services/host-bridge/`、未来 `apps/mobile-shell/`、未来 `apps/desktop-shell/`、移动端支付 / 分享 / 深链 / 推送、桌面端系统能力、AI H5 sandbox 的 GameBridge 边界。 - 验证方式:普通浏览器、小程序、Expo 壳、Tauri 壳都能返回正确 `getHostRuntime()`;未支持能力能回退 H5;固定玩法在各宿主中读取同一作品数据和运行态 snapshot;AI sandbox 无法直接调用 HostBridge;Tauri release 不允许任意远端页面调用桌面命令。 - 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`、`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 50f5ea14..ab533a50 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -126,6 +126,8 @@ type HostBridgeEvent = { | `network.statusChanged` | 通知 H5 网络状态变化 | 支持 Expo Network 事件 | 支持 WebView online / offline 事件 | | `clipboard.writeText` | 写剪贴板 | 支持 | 支持 | | `file.exportText` | 导出文本到用户选择的本地文件 | 支持系统分享 / 保存面板 | 支持系统保存对话框 | +| `file.importImage` | 导入用户选择的图片文件 | 不声明 | 支持系统选择图片 | +| `file.imageDropped` | 通知 H5 桌面拖入图片 | 不声明 | 支持主窗口拖拽图片事件 | | `haptics.impact` | 轻量触感反馈 | 支持 | 不声明 | 每个 method 都必须有明确 payload schema、超时、错误码和能力开关;H5 看到不支持时回退到现有浏览器路径。 @@ -258,7 +260,7 @@ GameBridge 禁止: - 实现 runtime、openExternalUrl、clipboard、share fallback、窗口标题同步。 - 验证 macOS / Windows / Linux 至少一条本地 smoke。 -当前状态:已新增 `apps/desktop-shell/`,Tauri dev 直接加载本地主站 Vite,release 打包根 `dist` 主站资产。Rust 侧只把 `host_bridge_request` command 授给主窗口,`appearance.getColorScheme` 由 Rust 内部读取主窗口 `theme()` 并返回 `light` / `dark` / `unknown`,不设置或覆盖系统主题;`app.lifecycle` 由主窗口 focus / blur 事件注入 `active` / `inactive` 统一状态,不开放 Tauri event 插件给前端;`network.status` 由 Rust 对 `app.genarrative.world:443` 做短超时 TCP 可达性查询,`network.statusChanged` 由主 WebView 的 `online` / `offline` 事件注入,不开放任意网络探测给 H5;`app.openExternalUrl` 由 Rust 内部通过 opener 插件执行且只允许 `http:`、`https:`、`mailto:`、`tel:` 外链协议,ICP备案号和资产调试原图等 H5 外链入口在 `native_app` 中优先通过该能力离开主窗口并交给系统浏览器;`navigation.openNativePage` 只接受 `https://app.genarrative.world` 同源 H5 route 并在主窗口内受控跳转,`clipboard.writeText` 由 Rust 内部通过 clipboard-manager 插件写入系统剪贴板并由 H5 复制服务优先调用,`app.setTitle` 通过 Tauri 主窗口 API 同步窗口标题并拒绝空标题 / 控制字符,`app.setBadgeCount` 通过主窗口 `set_badge_count` 设置任务栏角标,数量只接受 `0-99999` 整数且 `0` 表示清除;H5 主站会按当前平台阶段先更新 `document.title`,再通过 `app.setTitle` 把同一标题同步给 Tauri 窗口,Expo 移动壳不声明该能力时静默忽略;不把 opener、clipboard 或 dialog 插件命令直接暴露给前端。当前真实能力为 `host.getRuntime`、`appearance.getColorScheme`、`app.lifecycle`、`network.status`、`network.statusChanged`、`share.setTarget`、`share.open`、`navigation.openNativePage`、`app.openExternalUrl`、`app.setTitle`、`app.setBadgeCount`、`clipboard.writeText`、`file.exportText` 和 `file.exportImage`;H5 会在主 App 启动时通过真实 `host.getRuntime` 回读并缓存这些能力,即使入口 URL 缺少 `hostCapabilities`,也只按宿主真实回包开启能力入口;其中 `share.open` 会把直接传入的分享 payload 或 `share.setTarget` 缓存的作品目标整理成非空分享文本并写入系统剪贴板,返回 `copied_to_clipboard`,发布分享弹窗在桌面壳中也通过该能力提供“系统分享”动作;`file.exportText` 通过系统保存对话框让用户选择本地路径,清洗文件名、限制单次文本导出不超过 5 MiB,写入成功后只返回文件名和字节数,不把本机绝对路径暴露给 H5,用户取消返回 `cancelled`;`file.exportImage` 同样通过系统保存对话框写入 H5 生成的图片字节,只接受 `image/png` / `image/jpeg` / `image/webp` base64 数据,单次不超过 5 MiB,分享卡下载会优先走该能力,用户取消返回 `cancelled`。原生系统分享面板、登录和支付未接入真实插件 / 渠道前不声明支持,不返回 mock 成功。 +当前状态:已新增 `apps/desktop-shell/`,Tauri dev 直接加载本地主站 Vite,release 打包根 `dist` 主站资产。Rust 侧只把 `host_bridge_request` command 授给主窗口,`appearance.getColorScheme` 由 Rust 内部读取主窗口 `theme()` 并返回 `light` / `dark` / `unknown`,不设置或覆盖系统主题;`app.lifecycle` 由主窗口 focus / blur 事件注入 `active` / `inactive` 统一状态,不开放 Tauri event 插件给前端;`network.status` 由 Rust 对 `app.genarrative.world:443` 做短超时 TCP 可达性查询,`network.statusChanged` 由主 WebView 的 `online` / `offline` 事件注入,不开放任意网络探测给 H5;`app.openExternalUrl` 由 Rust 内部通过 opener 插件执行且只允许 `http:`、`https:`、`mailto:`、`tel:` 外链协议,ICP备案号和资产调试原图等 H5 外链入口在 `native_app` 中优先通过该能力离开主窗口并交给系统浏览器;`navigation.openNativePage` 只接受 `https://app.genarrative.world` 同源 H5 route 并在主窗口内受控跳转,`clipboard.writeText` 由 Rust 内部通过 clipboard-manager 插件写入系统剪贴板并由 H5 复制服务优先调用,`app.setTitle` 通过 Tauri 主窗口 API 同步窗口标题并拒绝空标题 / 控制字符,`app.setBadgeCount` 通过主窗口 `set_badge_count` 设置任务栏角标,数量只接受 `0-99999` 整数且 `0` 表示清除;H5 主站会按当前平台阶段先更新 `document.title`,再通过 `app.setTitle` 把同一标题同步给 Tauri 窗口,Expo 移动壳不声明该能力时静默忽略;不把 opener、clipboard 或 dialog 插件命令直接暴露给前端。当前真实能力为 `host.getRuntime`、`appearance.getColorScheme`、`app.lifecycle`、`network.status`、`network.statusChanged`、`share.setTarget`、`share.open`、`navigation.openNativePage`、`app.openExternalUrl`、`app.setTitle`、`app.setBadgeCount`、`clipboard.writeText`、`file.exportText`、`file.exportImage`、`file.importImage` 和 `file.imageDropped`;H5 会在主 App 启动时通过真实 `host.getRuntime` 回读并缓存这些能力,即使入口 URL 缺少 `hostCapabilities`,也只按宿主真实回包开启能力入口;其中 `share.open` 会把直接传入的分享 payload 或 `share.setTarget` 缓存的作品目标整理成非空分享文本并写入系统剪贴板,返回 `copied_to_clipboard`,发布分享弹窗在桌面壳中也通过该能力提供“系统分享”动作;`file.exportText` 通过系统保存对话框让用户选择本地路径,清洗文件名、限制单次文本导出不超过 5 MiB,写入成功后只返回文件名和字节数,不把本机绝对路径暴露给 H5,用户取消返回 `cancelled`;`file.exportImage` 同样通过系统保存对话框写入 H5 生成的图片字节,只接受 `image/png` / `image/jpeg` / `image/webp` base64 数据,单次不超过 5 MiB,分享卡下载会优先走该能力,用户取消返回 `cancelled`;`file.importImage` 通过系统选择框读取用户选择的图片,`file.imageDropped` 通过主窗口拖拽事件读取用户拖入的图片,二者都只接受 `image/png` / `image/jpeg` / `image/webp`、单次不超过 10 MiB,成功只返回文件名、MIME、base64 内容和字节数,不把本机绝对路径暴露给 H5,也不开放通用文件系统。原生系统分享面板、登录和支付未接入真实插件 / 渠道前不声明支持,不返回 mock 成功。 ### Phase 4:宿主能力扩展 diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md index b0b46ea7..70cdab4f 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -56,6 +56,7 @@ AI H5 sandbox - `navigateHostNativePage()`:受控跳转宿主页,供订阅授权、支付、登录等 adapter 复用。Expo 移动壳首版只接受同源 H5 route 并切换 WebView URL;Tauri 桌面壳同样只接受 `https://app.genarrative.world` 同源 H5 route 并在主窗口内跳转。真正原生页面、登录和支付能力必须等对应 SDK / 页面接入后再声明支持。 - `exportHostTextFile()`:原生 App 宿主的受控文本导出入口。Expo 移动壳通过 `file.exportText` 写入缓存文本文件并交给系统分享 / 保存面板;Tauri 桌面壳通过 `file.exportText` 打开系统保存对话框并写入用户选择的文件。文件名必须清洗,单次文本不超过 5 MiB,成功只返回文件名和字节数,不把本机绝对路径暴露给 H5;系统分享不可用或用户取消时返回明确错误,由 H5 fallback 承接。 - `exportHostImageFile()`:原生 App 宿主的受控图片导出入口。H5 只传自己生成的图片 `base64Data`、清洗后的文件名和允许的 `image/png` / `image/jpeg` / `image/webp` MIME;Expo 移动壳写入缓存图片后交给系统分享 / 保存面板,Tauri 桌面壳打开系统保存对话框并写入图片字节。单次图片不超过 5 MiB,成功只返回文件名和字节数,不回传本机绝对路径。当前分享卡下载在 native app 中优先走 `file.exportImage`,宿主未声明时保留浏览器下载路径。 +- `importHostImageFile()` / `subscribeHostImageDrop()`:桌面 App 宿主的受控图片导入入口。Tauri 壳通过系统文件选择框或主窗口拖拽事件读取用户选择 / 拖入的图片,只接受 `image/png`、`image/jpeg`、`image/webp`,单次不超过 10 MiB,成功只返回文件名、MIME、base64 内容、字节数和可选拖入坐标,不把本机绝对路径暴露给 H5,也不开放通用文件系统能力;Expo 移动壳未接入真实图片选择器前不声明该能力。 ## 迁移顺序 diff --git a/packages/shared/src/contracts/hostBridge.test.ts b/packages/shared/src/contracts/hostBridge.test.ts index f7f12ba6..c4851612 100644 --- a/packages/shared/src/contracts/hostBridge.test.ts +++ b/packages/shared/src/contracts/hostBridge.test.ts @@ -54,6 +54,8 @@ describe('HostBridge shared contract helpers', () => { expect(isHostBridgeCapability('app.lifecycle')).toBe(true); expect(isHostBridgeCapability('network.status')).toBe(true); expect(isHostBridgeCapability('network.statusChanged')).toBe(true); + expect(isHostBridgeCapability('file.importImage')).toBe(true); + expect(isHostBridgeCapability('file.imageDropped')).toBe(true); expect(isHostBridgeCapability('app.setBadgeCount')).toBe(true); expect(isHostBridgeCapability('navigation.canGoBack')).toBe(true); expect(isHostBridgeCapability('unknown.capability')).toBe(false); diff --git a/packages/shared/src/contracts/hostBridge.ts b/packages/shared/src/contracts/hostBridge.ts index 60a072a7..76cbe121 100644 --- a/packages/shared/src/contracts/hostBridge.ts +++ b/packages/shared/src/contracts/hostBridge.ts @@ -28,6 +28,7 @@ export const HOST_BRIDGE_METHODS = [ 'clipboard.writeText', 'file.exportText', 'file.exportImage', + 'file.importImage', 'haptics.impact', ] as const; @@ -38,6 +39,7 @@ export const HOST_BRIDGE_CAPABILITIES = [ 'host.events', 'app.lifecycle', 'network.statusChanged', + 'file.imageDropped', 'navigation.canGoBack', ] as const; @@ -281,6 +283,23 @@ export type FileExportImageResult = { bytes: number; }; +export type HostBridgeImageMimeType = + | 'image/png' + | 'image/jpeg' + | 'image/webp'; + +export type FileImportImageResult = { + action: 'selected' | 'dropped'; + fileName: string; + base64Data: string; + mimeType: HostBridgeImageMimeType; + bytes: number; + position?: { + x: number; + y: number; + }; +}; + export type HapticsImpactPayload = { style?: 'light' | 'medium' | 'heavy'; }; diff --git a/src/services/host-bridge/hostBridge.test.ts b/src/services/host-bridge/hostBridge.test.ts index e71b6609..286fe559 100644 --- a/src/services/host-bridge/hostBridge.test.ts +++ b/src/services/host-bridge/hostBridge.test.ts @@ -12,6 +12,7 @@ import { getHostNetworkStatus, getHostRuntime, getNativeAppHostRuntime, + importHostImageFile, isWechatMiniProgramWebViewRuntime, navigateHostNativePage, openHostExternalUrl, @@ -31,6 +32,7 @@ import { setHostAppTitle, setHostShareTarget, subscribeHostAppLifecycle, + subscribeHostImageDrop, subscribeHostNetworkStatusChange, subscribeHostRuntimeChange, writeHostClipboardText, @@ -591,6 +593,14 @@ describe('hostBridge', () => { connectionType: 'ethernet', nativeType: 'online', } + : request.method === 'file.importImage' + ? { + action: 'selected', + fileName: '参考图.png', + base64Data: 'aW1hZ2U=', + mimeType: 'image/png', + bytes: 5, + } : true, }; }); @@ -611,6 +621,8 @@ describe('hostBridge', () => { 'network.status', 'share.open', 'file.exportImage', + 'file.importImage', + 'file.imageDropped', ]), ); window.__TAURI__ = { @@ -667,6 +679,13 @@ describe('hostBridge', () => { mimeType: 'image/png', }), ).resolves.toBe(true); + await expect(importHostImageFile()).resolves.toEqual({ + action: 'selected', + fileName: '参考图.png', + base64Data: 'aW1hZ2U=', + mimeType: 'image/png', + bytes: 5, + }); expect(invoke).toHaveBeenCalledWith('host_bridge_request', { request: expect.objectContaining({ @@ -759,6 +778,12 @@ describe('hostBridge', () => { timeoutMs: 30000, }), }); + expect(invoke).toHaveBeenCalledWith('host_bridge_request', { + request: expect.objectContaining({ + method: 'file.importImage', + timeoutMs: 30000, + }), + }); }); test('原生 App 宿主不支持能力时回退到 H5 路径', async () => { @@ -824,6 +849,7 @@ describe('hostBridge', () => { mimeType: 'image/png', }), ).resolves.toBe(false); + await expect(importHostImageFile()).resolves.toBe(false); }); test('普通浏览器不处理宿主文件导出', async () => { @@ -841,6 +867,7 @@ describe('hostBridge', () => { mimeType: 'image/png', }), ).resolves.toBe(false); + await expect(importHostImageFile()).resolves.toBe(false); }); test('原生 App 宿主通过 HostBridge 导出文本文件', async () => { @@ -981,4 +1008,85 @@ describe('hostBridge', () => { }), }); }); + + test('原生 App 宿主通过 HostBridge 导入和订阅拖入图片文件', async () => { + const invoke = vi.fn( + async (_command: string, args?: Record) => { + const request = (args as { request: { id: string } }).request; + return { + bridge: 'GenarrativeHostBridge', + version: 1, + id: request.id, + ok: true, + result: { + action: 'selected', + fileName: ' 参考图.png ', + base64Data: 'aW1hZ2U=', + mimeType: 'image/png', + bytes: 5, + }, + }; + }, + ); + const listener = vi.fn(); + window.history.replaceState( + null, + '', + nativeAppPath(['file.importImage', 'file.imageDropped']), + ); + window.__TAURI__ = { + core: { + invoke: asTauriInvoke(invoke), + }, + }; + + await expect(importHostImageFile()).resolves.toEqual({ + action: 'selected', + fileName: '参考图.png', + base64Data: 'aW1hZ2U=', + mimeType: 'image/png', + bytes: 5, + }); + + const unsubscribe = subscribeHostImageDrop(listener); + window.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ + bridge: 'GenarrativeHostBridge', + version: 1, + event: 'file.imageDropped', + payload: { + action: 'dropped', + fileName: '拖入图.webp', + base64Data: 'ZHJvcA==', + mimeType: 'image/webp', + bytes: 4, + position: { + x: 32, + y: 48, + }, + }, + }), + }), + ); + unsubscribe(); + + expect(listener).toHaveBeenCalledWith({ + action: 'dropped', + fileName: '拖入图.webp', + base64Data: 'ZHJvcA==', + mimeType: 'image/webp', + bytes: 4, + position: { + x: 32, + y: 48, + }, + }); + expect(invoke).toHaveBeenCalledWith('host_bridge_request', { + request: expect.objectContaining({ + method: 'file.importImage', + timeoutMs: 30000, + }), + }); + }); }); diff --git a/src/services/host-bridge/hostBridge.ts b/src/services/host-bridge/hostBridge.ts index 2e57920f..3ddb94e0 100644 --- a/src/services/host-bridge/hostBridge.ts +++ b/src/services/host-bridge/hostBridge.ts @@ -5,8 +5,10 @@ import type { FileExportImageResult, FileExportTextPayload, FileExportTextResult, + FileImportImageResult, HapticsImpactPayload, HostBridgeCapability, + HostBridgeImageMimeType, HostBridgeMethod, HostBridgeRuntimeResult, NetworkStatusResult, @@ -86,6 +88,8 @@ export type HostFileExportTextRequest = FileExportTextPayload; export type HostFileExportImageRequest = FileExportImagePayload; +export type HostFileImportImageResult = FileImportImageResult; + export type HostClipboardWriteTextRequest = { text: string; }; @@ -108,6 +112,8 @@ export type HostAppLifecycleSnapshot = AppLifecycleEventPayload; export type HostNetworkStatusSnapshot = NetworkStatusResult; +export type HostImageDropSnapshot = FileImportImageResult; + const HOST_RUNTIME_REFRESH_TIMEOUT_MS = 3000; let cachedNativeHostRuntime: HostBridgeRuntimeResult | null = null; @@ -729,6 +735,74 @@ export async function exportHostImageFile( } } +const HOST_BRIDGE_IMAGE_MIME_TYPES = new Set([ + 'image/png', + 'image/jpeg', + 'image/webp', +]); + +function normalizeHostImageImportResult( + payload: FileImportImageResult | null | undefined, +): FileImportImageResult | false { + if ( + !payload || + typeof payload !== 'object' || + (payload.action !== 'selected' && payload.action !== 'dropped') || + !HOST_BRIDGE_IMAGE_MIME_TYPES.has(payload.mimeType) || + typeof payload.fileName !== 'string' || + !payload.fileName.trim() || + typeof payload.base64Data !== 'string' || + !payload.base64Data.trim() || + typeof payload.bytes !== 'number' || + !Number.isInteger(payload.bytes) || + payload.bytes <= 0 + ) { + return false; + } + + const position = + payload.position && + typeof payload.position.x === 'number' && + Number.isFinite(payload.position.x) && + typeof payload.position.y === 'number' && + Number.isFinite(payload.position.y) + ? { + x: payload.position.x, + y: payload.position.y, + } + : undefined; + + return { + action: payload.action, + fileName: payload.fileName.trim(), + base64Data: payload.base64Data, + mimeType: payload.mimeType, + bytes: payload.bytes, + ...(position ? { position } : {}), + }; +} + +export async function importHostImageFile() { + if (!canUseNativeHostCapability('file.importImage')) { + return false; + } + + try { + return normalizeHostImageImportResult( + await requestNativeAppHostBridge( + 'file.importImage', + undefined, + { timeoutMs: 30000 }, + ), + ); + } catch (error) { + if (isUnsupportedHostBridgeError(error)) { + return false; + } + throw error; + } +} + export async function requestHostHapticsImpact( params: HostHapticsImpactRequest = {}, ) { @@ -872,3 +946,21 @@ export function subscribeHostNetworkStatusChange( }, ); } + +export function subscribeHostImageDrop( + listener: (payload: HostImageDropSnapshot) => void, +) { + if (!canUseNativeHostCapability('file.imageDropped')) { + return () => undefined; + } + + return subscribeNativeAppHostBridgeEvent( + 'file.imageDropped', + (payload) => { + const normalizedPayload = normalizeHostImageImportResult(payload); + if (normalizedPayload) { + listener(normalizedPayload); + } + }, + ); +}