接入桌面壳图片导入能力
新增 file.importImage 与 file.imageDropped HostBridge 能力 Tauri 壳通过系统图片选择和主窗口拖拽读取真实图片文件 H5 统一使用导入图片与拖入图片事件 facade 限制图片 MIME 与大小并避免暴露本机绝对路径 更新壳能力校验、测试和架构文档
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user