桌面壳校验文件真实内容
为图片和音频导入导出增加 bytes 头部校验 桌面图片拖入跳过伪装文件继续寻找有效图片 更新桌面壳门禁与宿主壳文件能力文档
This commit is contained in:
@@ -1168,7 +1168,14 @@ const requiredRustHostSnippets = [
|
||||
'import_text_file_payload',
|
||||
'import_image_file_payload',
|
||||
'import_audio_file_payload',
|
||||
'export_image_payload',
|
||||
'export_audio_payload',
|
||||
'detect_image_mime_type',
|
||||
'detect_audio_mime_type',
|
||||
'ensure_image_bytes_match_mime_type',
|
||||
'ensure_audio_bytes_match_mime_type',
|
||||
'"image bytes do not match MIME"',
|
||||
'"audio bytes do not match MIME"',
|
||||
'set_title',
|
||||
'set_badge_count',
|
||||
'window.reload()',
|
||||
@@ -1177,6 +1184,9 @@ const requiredRustHostSnippets = [
|
||||
'window.theme()',
|
||||
'WindowEvent::Focused',
|
||||
'WindowEvent::DragDrop',
|
||||
'first_valid_desktop_image_drop_payload',
|
||||
'.filter(|path| path.is_file() && import_image_mime_type(path).is_some())',
|
||||
'.find_map(|path| import_image_file_payload(path.clone(), "dropped", Some(position)).ok())',
|
||||
'PageLoadEvent',
|
||||
'host_bridge_event_script',
|
||||
'origin: window.location.origin',
|
||||
|
||||
@@ -190,6 +190,68 @@ fn export_audio_extension(mime_type: &str) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
@@ -270,6 +332,9 @@ pub(crate) fn export_image_payload(
|
||||
"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")
|
||||
@@ -334,6 +399,9 @@ pub(crate) fn export_audio_payload(
|
||||
"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")
|
||||
@@ -372,6 +440,7 @@ pub(crate) fn import_image_file_payload(
|
||||
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())
|
||||
@@ -413,6 +482,7 @@ pub(crate) fn import_audio_file_payload(path: PathBuf) -> Result<Value, String>
|
||||
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())
|
||||
@@ -433,6 +503,38 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::host_bridge::protocol::request;
|
||||
|
||||
fn png_bytes() -> Vec<u8> {
|
||||
vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]
|
||||
}
|
||||
|
||||
fn jpeg_bytes() -> Vec<u8> {
|
||||
vec![0xff, 0xd8, 0xff, 0xe0, 0, 0, b'J', b'F', b'I', b'F']
|
||||
}
|
||||
|
||||
fn webp_bytes() -> Vec<u8> {
|
||||
b"RIFF\x04\x00\x00\x00WEBP".to_vec()
|
||||
}
|
||||
|
||||
fn mp3_bytes() -> Vec<u8> {
|
||||
b"ID3\x04\x00\x00\x00\x00\x00\x10".to_vec()
|
||||
}
|
||||
|
||||
fn mp4_audio_bytes() -> Vec<u8> {
|
||||
b"\x00\x00\x00\x18ftypM4A \x00\x00\x00\x00".to_vec()
|
||||
}
|
||||
|
||||
fn wav_bytes() -> Vec<u8> {
|
||||
b"RIFF\x04\x00\x00\x00WAVE".to_vec()
|
||||
}
|
||||
|
||||
fn ogg_bytes() -> Vec<u8> {
|
||||
b"OggS\x00\x02audio".to_vec()
|
||||
}
|
||||
|
||||
fn webm_bytes() -> Vec<u8> {
|
||||
vec![0x1a, 0x45, 0xdf, 0xa3, 0x01, 0x00]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_file_name_normalization_rejects_path_like_characters() {
|
||||
assert_eq!(
|
||||
@@ -555,14 +657,14 @@ mod tests {
|
||||
let mut valid = request("file.exportImage");
|
||||
valid.payload = Some(json!({
|
||||
"fileName": "分享:卡?.png",
|
||||
"base64Data": "c2hhcmUtY2FyZA==",
|
||||
"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, b"share-card");
|
||||
assert_eq!(bytes, png_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -571,7 +673,7 @@ mod tests {
|
||||
"genarrative-host-bridge-import-{}.png",
|
||||
std::process::id()
|
||||
));
|
||||
fs::write(&path, b"image").expect("write import image");
|
||||
fs::write(&path, png_bytes()).expect("write import image");
|
||||
|
||||
let payload =
|
||||
import_image_file_payload(path.clone(), "selected", Some((12, 24))).expect("payload");
|
||||
@@ -581,9 +683,9 @@ mod tests {
|
||||
payload["fileName"],
|
||||
path.file_name().unwrap().to_str().unwrap()
|
||||
);
|
||||
assert_eq!(payload["base64Data"], "aW1hZ2U=");
|
||||
assert_eq!(payload["base64Data"], BASE64_STANDARD.encode(png_bytes()));
|
||||
assert_eq!(payload["mimeType"], "image/png");
|
||||
assert_eq!(payload["bytes"], 5);
|
||||
assert_eq!(payload["bytes"], png_bytes().len() as u64);
|
||||
assert_eq!(payload["position"], json!({ "x": 12, "y": 24 }));
|
||||
assert!(!payload
|
||||
.to_string()
|
||||
@@ -605,6 +707,17 @@ mod tests {
|
||||
);
|
||||
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()
|
||||
@@ -627,7 +740,7 @@ mod tests {
|
||||
"genarrative-host-bridge-import-audio-{}.webm",
|
||||
std::process::id()
|
||||
));
|
||||
fs::write(&path, b"audio").expect("write import audio");
|
||||
fs::write(&path, webm_bytes()).expect("write import audio");
|
||||
|
||||
let payload = import_audio_file_payload(path.clone()).expect("payload");
|
||||
|
||||
@@ -636,9 +749,9 @@ mod tests {
|
||||
payload["fileName"],
|
||||
path.file_name().unwrap().to_str().unwrap()
|
||||
);
|
||||
assert_eq!(payload["base64Data"], "YXVkaW8=");
|
||||
assert_eq!(payload["base64Data"], BASE64_STANDARD.encode(webm_bytes()));
|
||||
assert_eq!(payload["mimeType"], "audio/webm");
|
||||
assert_eq!(payload["bytes"], 5);
|
||||
assert_eq!(payload["bytes"], webm_bytes().len() as u64);
|
||||
assert!(!payload
|
||||
.to_string()
|
||||
.contains(path.to_string_lossy().as_ref()));
|
||||
@@ -659,6 +772,17 @@ mod tests {
|
||||
);
|
||||
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()
|
||||
@@ -680,19 +804,19 @@ mod tests {
|
||||
let mut valid = request("file.exportAudio");
|
||||
valid.payload = Some(json!({
|
||||
"fileName": "敲击:音效?.wav",
|
||||
"base64Data": "YXVkaW8=",
|
||||
"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, b"audio");
|
||||
assert_eq!(bytes, wav_bytes());
|
||||
|
||||
let mut missing_extension = request("file.exportAudio");
|
||||
missing_extension.payload = Some(json!({
|
||||
"fileName": "敲击音效",
|
||||
"base64Data": "YXVkaW8=",
|
||||
"base64Data": BASE64_STANDARD.encode(webm_bytes()),
|
||||
"mimeType": "audio/webm"
|
||||
}));
|
||||
|
||||
@@ -706,7 +830,7 @@ mod tests {
|
||||
let mut invalid_mime = request("file.exportAudio");
|
||||
invalid_mime.payload = Some(json!({
|
||||
"fileName": "hit.txt",
|
||||
"base64Data": "YXVkaW8=",
|
||||
"base64Data": BASE64_STANDARD.encode(wav_bytes()),
|
||||
"mimeType": "text/plain"
|
||||
}));
|
||||
let response = export_audio_payload(&invalid_mime).expect_err("invalid mime");
|
||||
@@ -734,6 +858,18 @@ mod tests {
|
||||
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]
|
||||
@@ -758,6 +894,18 @@ mod tests {
|
||||
response.error.expect("error").message,
|
||||
"base64Data is invalid"
|
||||
);
|
||||
|
||||
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]
|
||||
@@ -794,4 +942,18 @@ mod tests {
|
||||
);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
use crate::host_bridge::files::{import_image_file_payload, import_image_mime_type};
|
||||
use crate::shell::events::host_bridge_event_script;
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
use tauri::{DragDropEvent, WebviewWindow, WindowEvent};
|
||||
|
||||
fn first_valid_desktop_image_drop_payload(
|
||||
paths: &[PathBuf],
|
||||
position: (i32, i32),
|
||||
) -> Option<Value> {
|
||||
paths
|
||||
.iter()
|
||||
.filter(|path| path.is_file() && import_image_mime_type(path).is_some())
|
||||
.find_map(|path| import_image_file_payload(path.clone(), "dropped", Some(position)).ok())
|
||||
}
|
||||
|
||||
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 {
|
||||
let Some(payload) = first_valid_desktop_image_drop_payload(paths, position) else {
|
||||
return Ok(());
|
||||
};
|
||||
let script =
|
||||
@@ -33,3 +37,42 @@ pub(crate) fn register_desktop_file_drop_events(window: &WebviewWindow) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn png_bytes() -> Vec<u8> {
|
||||
vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_drop_skips_disguised_image_before_valid_image() {
|
||||
let invalid_path = std::env::temp_dir().join(format!(
|
||||
"genarrative-desktop-drop-invalid-{}.png",
|
||||
std::process::id()
|
||||
));
|
||||
let valid_path = std::env::temp_dir().join(format!(
|
||||
"genarrative-desktop-drop-valid-{}.png",
|
||||
std::process::id()
|
||||
));
|
||||
fs::write(&invalid_path, b"text").expect("write invalid drop image");
|
||||
fs::write(&valid_path, png_bytes()).expect("write valid drop image");
|
||||
|
||||
let payload = first_valid_desktop_image_drop_payload(
|
||||
&[invalid_path.clone(), valid_path.clone()],
|
||||
(7, 9),
|
||||
)
|
||||
.expect("valid image drop payload");
|
||||
|
||||
assert_eq!(
|
||||
payload["fileName"],
|
||||
valid_path.file_name().unwrap().to_str().unwrap()
|
||||
);
|
||||
assert_eq!(payload["position"], serde_json::json!({ "x": 7, "y": 9 }));
|
||||
|
||||
fs::remove_file(invalid_path).expect("remove invalid drop image");
|
||||
fs::remove_file(valid_path).expect("remove valid drop image");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
- 2026-06-18 桌面壳运行时平台 query:Tauri 静态配置中的 `hostPlatform=unknown` 只作为跨平台构建模板值;Rust `setup` 手动创建主窗口前必须把入口 URL 改写为当前 `macos` / `windows` / `linux`,保证 H5 首屏 query 与 `host.getRuntime` 回读的平台一致。第二实例参数、外部 deep link 或 H5 自报值不得覆盖该字段;桌面壳测试和配置检查会拒绝绕过该归一流程。
|
||||
- 2026-06-18 桌面壳顶层导航边界:Tauri 主 WebView 只允许打包资产 URL 和 `https://app.genarrative.world` 同源 H5 route 留在主窗口;外域 `http:` / `https:`、`mailto:`、`tel:` 导航与 `window.open` 请求交给系统 opener 后拒绝 WebView 留壳;`javascript:`、`file:` 等危险协议直接拒绝。该规则不进入 HostBridge capability,不开放 opener JS guest API,配置检查和 cargo test 覆盖导航策略。
|
||||
- 2026-06-18 桌面壳默认下载边界:Tauri 主 WebView 的下载事件默认拒绝网页自动下载和 `<a download>` 落盘,桌面文件保存只能通过 `file.exportText`、`file.exportImage`、`file.exportAudio` 等已声明 HostBridge method 进入 Rust 侧系统保存对话框,并继续执行 MIME、大小、文件名清洗和用户确认。该规则不进入 HostBridge capability,配置检查和 cargo test 覆盖下载拒绝策略。
|
||||
- 2026-06-18 桌面壳文件 bytes 校验:Tauri 图片 / 音频导入导出不得只信扩展名或 H5 声明 MIME;Rust 侧必须识别 PNG / JPEG / WebP、MP3 / MP4-M4A / WAV / OGG / WebM bytes 头部,要求导入文件扩展名对应 MIME 与真实 bytes 匹配,导出 payload 的 `mimeType` 与 `base64Data` 解码 bytes 匹配。不匹配返回 `invalid_request`,继续不暴露本机绝对路径或通用文件系统能力。配置检查和 cargo test 覆盖该边界。
|
||||
- 2026-06-18 桌面壳 DevTools 边界:Tauri 主 WebView 配置必须显式 `devtools=false`,Cargo 依赖不得启用 Tauri `devtools` feature;桌面壳本地调试走普通浏览器和 Vite,不把 debug / release 桌面包变成可打开浏览器检查器的调试容器。配置检查会拒绝主窗口 DevTools 或 release feature 被重新打开。
|
||||
- 2026-06-18 桌面壳 Tauri 命令白名单:桌面壳源码、Tauri build manifest、主窗口 capability 和本地自动生成权限目录都只能暴露 `host_bridge_request` 一个受控 command;所有桌面能力继续在 Rust 内部按 HostBridge method 白名单分发,不新增可被 H5 直接 `invoke` 的 Tauri command,也不授予插件 JS guest API。检查脚本会拒绝多余 command、权限列表顺序漂移和残留的自动生成权限文件。
|
||||
- 2026-06-18 HostBridge request id replay:Expo 和 Tauri 壳都必须按 request id 回放首次完成结果;同 id 进行中的请求共享同一执行结果,已完成请求直接回放缓存响应,避免系统分享、外链、剪贴板、文件选择 / 保存、本地通知、窗口导航等宿主副作用被重复触发。两端配置检查和测试会锁住 replay 结构。
|
||||
|
||||
@@ -340,6 +340,8 @@ GameBridge 禁止:
|
||||
|
||||
2026-06-18 追加:桌面壳声明并实现 `file.importText`,通过系统文件选择框读取用户选择的文本文件,只接受 `text/plain`、`text/markdown`、`text/csv`、`application/json` 对应扩展名,单次不超过 5 MiB;成功只返回清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露本机绝对路径,也不开放通用文件系统。H5 创作 Agent 工作台复用同一 HostBridge 文本导入入口和后端解析接口;普通浏览器、小程序和未声明该能力的裁剪壳继续保留原 `<input type="file">` 路径。
|
||||
|
||||
2026-06-18 追加:桌面壳图片和音频导入 / 导出不再只信扩展名或 H5 声明的 MIME。Tauri Rust 侧会对 PNG / JPEG / WebP、MP3 / MP4-M4A / WAV / OGG / WebM 做 bytes 头部识别,导入时要求文件扩展名对应的允许 MIME 与真实 bytes 匹配,导出时要求 H5 payload 的 `mimeType` 与 `base64Data` 解码后的 bytes 匹配;不匹配统一返回 `invalid_request`,继续不暴露本机绝对路径或通用文件系统能力。
|
||||
|
||||
2026-06-18 追加:H5 个人中心的邀请码填写和兑换码弹窗开始消费 `clipboard.readText`。Tauri 壳仍只通过 Rust 侧 clipboard-manager 返回纯文本,不开放插件 JS guest API;H5 只把文本填入现有输入框,不自动提交,也不把剪贴板内容交给宿主侧业务处理。
|
||||
|
||||
2026-06-18 追加:H5 的草稿生成完成 / 失败收口开始消费 `notification.showLocal`。Tauri 壳仍只通过 Rust 侧 notification 插件发送即时系统通知,发送前先检查 `permission_state()`,处于 prompt 状态时调用 `request_permission()` 后再复判,最终未授权时返回 `host_error`;桌面 capability 仍只授权 `allow-host-bridge-request`,不开放 notification 插件 JS guest API、远程推送、定时提醒或通知 token;H5 按草稿来源对完成和失败通知去重,同一草稿重新进入生成中后才允许再次通知,通知失败不阻断弹窗、作品架和后端状态回读。
|
||||
|
||||
Reference in New Issue
Block a user