接入桌面壳图片导入能力

新增 file.importImage 与 file.imageDropped HostBridge 能力

Tauri 壳通过系统图片选择和主窗口拖拽读取真实图片文件

H5 统一使用导入图片与拖入图片事件 facade

限制图片 MIME 与大小并避免暴露本机绝对路径

更新壳能力校验、测试和架构文档
This commit is contained in:
2026-06-18 02:52:37 +08:00
parent 586e46fa63
commit 199f02cf9f
10 changed files with 418 additions and 4 deletions

View File

@@ -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) {

View File

@@ -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<u8>) -> Result<usize, Strin
Ok(byte_count)
}
fn import_image_file_payload(
path: PathBuf,
action: &'static str,
position: Option<(i32, i32)>,
) -> Result<Value, String> {
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<String, serde_json::Error> {
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");

View File

@@ -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,