修复 UI 编辑器导入提交与恢复边界
为图片和远程素材导入增加服务端数量、单文件及批次总量上限 改为提交成功后推进项目 revision,并保留 reconciliation-required 结果 远程 IPC 改传签名地址并流式限量下载,UI State 使用稳定 previous 恢复副本
This commit is contained in:
@@ -4,6 +4,9 @@ use crate::ui_editor::resource::font::FontAsset;
|
||||
const UI_EDITOR_FONT_MAX_FILE_SIZE: u64 = 8 * 1024 * 1024;
|
||||
const UI_EDITOR_FONT_MAX_TOTAL_SIZE: u64 = 32 * 1024 * 1024;
|
||||
const UI_EDITOR_FONT_MAX_COUNT: usize = 64;
|
||||
const UI_EDITOR_IMAGE_MAX_FILE_SIZE: u64 = 20 * 1024 * 1024;
|
||||
const UI_EDITOR_IMAGE_MAX_TOTAL_SIZE: u64 = 256 * 1024 * 1024;
|
||||
const UI_EDITOR_IMAGE_MAX_COUNT: usize = 100;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -183,42 +186,12 @@ fn validate_remote_asset_import_requirements(
|
||||
if assets.len() > requirements.max_items {
|
||||
return Err(format!("最多导入 {} 个文件", requirements.max_items));
|
||||
}
|
||||
let mut total_size = 0u64;
|
||||
for asset in assets {
|
||||
let bytes = asset
|
||||
.get("bytes")
|
||||
.cloned()
|
||||
.ok_or_else(|| "远端素材缺少文件内容".to_string())
|
||||
.and_then(|value| {
|
||||
serde_json::from_value::<Vec<u8>>(value).map_err(|_| "远端素材内容无效".to_string())
|
||||
})?;
|
||||
let size = bytes.len() as u64;
|
||||
if let Some(max_size) = requirements.max_file_size_bytes {
|
||||
if size > max_size {
|
||||
return Err(format!("文件超过 {} 字节限制", max_size));
|
||||
}
|
||||
}
|
||||
total_size = total_size.saturating_add(size);
|
||||
}
|
||||
if let Some(max_total_size) = requirements.max_total_size_bytes {
|
||||
if total_size > max_total_size {
|
||||
return Err(format!("文件总量超过 {} 字节限制", max_total_size));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn import_ui_editor_assets(
|
||||
request: UiEditorAssetImportRequest,
|
||||
) -> Result<LocalImportResult, String> {
|
||||
tokio::task::spawn_blocking(move || import_ui_editor_assets_blocking(request))
|
||||
.await
|
||||
.map_err(|error| format!("UI 编辑器素材导入任务意外终止:{error}"))?
|
||||
}
|
||||
|
||||
fn import_ui_editor_assets_blocking(
|
||||
request: UiEditorAssetImportRequest,
|
||||
) -> Result<LocalImportResult, String> {
|
||||
match request {
|
||||
UiEditorAssetImportRequest::Local {
|
||||
@@ -229,15 +202,15 @@ fn import_ui_editor_assets_blocking(
|
||||
} => {
|
||||
let is_font = validate_asset_import_policy(&local_policy)?;
|
||||
validate_local_asset_import_requirements(&local_source_paths, &local_requirements)?;
|
||||
if is_font {
|
||||
import_ui_editor_local_fonts(project_path, local_source_paths)
|
||||
} else {
|
||||
import_ui_editor_local_files(
|
||||
project_path,
|
||||
local_source_paths,
|
||||
local_requirements.max_file_size_bytes.unwrap_or(u64::MAX),
|
||||
)
|
||||
}
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if is_font {
|
||||
import_ui_editor_local_fonts(project_path, local_source_paths)
|
||||
} else {
|
||||
import_ui_editor_local_files(project_path, local_source_paths)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("UI 编辑器素材导入任务意外终止:{error}"))?
|
||||
}
|
||||
UiEditorAssetImportRequest::Remote {
|
||||
project_path,
|
||||
@@ -250,11 +223,7 @@ fn import_ui_editor_assets_blocking(
|
||||
return Err("字体导入不支持云端素材".to_string());
|
||||
}
|
||||
validate_remote_asset_import_requirements(&remote_assets, &remote_requirements)?;
|
||||
import_ui_editor_remote_assets(
|
||||
project_path,
|
||||
remote_assets,
|
||||
remote_requirements.max_file_size_bytes.unwrap_or(u64::MAX),
|
||||
)
|
||||
import_ui_editor_remote_assets(project_path, remote_assets).await
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1742,25 +1711,45 @@ pub(crate) async fn sync_canvas_project_assets(
|
||||
pub(crate) fn import_ui_editor_local_files(
|
||||
project_path: String,
|
||||
source_paths: Vec<String>,
|
||||
max_file_size: u64,
|
||||
) -> Result<LocalImportResult, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "asset.upload")?;
|
||||
let _lock = acquire_project_write_lock(root, "asset.upload")?;
|
||||
advance_agent_runtime_project_revision_locked(root)?;
|
||||
if source_paths.len() > UI_EDITOR_IMAGE_MAX_COUNT {
|
||||
return Err(format!("一次最多选择 {UI_EDITOR_IMAGE_MAX_COUNT} 张图片"));
|
||||
}
|
||||
// V1 有意采用增量导入:每个文件独立写入并登记,后续失败不回滚此前成功项。
|
||||
// 调用方必须以返回结果和 manifest 为准重建已提交集合;整批原子语义需另行定义 transaction/reconciliation 合同。
|
||||
let mut inputs = Vec::new();
|
||||
let mut total_size = 0u64;
|
||||
for source in source_paths {
|
||||
let path = Path::new(source.trim());
|
||||
let metadata = fs::symlink_metadata(path).map_err(|e| format!("读取本地图片失败:{e}"))?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return Err("只能导入普通图片文件".to_string());
|
||||
}
|
||||
if metadata.len() > max_file_size {
|
||||
return Err(format!("图片超过 {} 字节限制", max_file_size));
|
||||
if metadata.len() > UI_EDITOR_IMAGE_MAX_FILE_SIZE {
|
||||
return Err(format!(
|
||||
"图片超过 {} 字节限制",
|
||||
UI_EDITOR_IMAGE_MAX_FILE_SIZE
|
||||
));
|
||||
}
|
||||
let bytes = fs::read(path).map_err(|e| format!("读取本地图片失败:{e}"))?;
|
||||
if bytes.len() as u64 > UI_EDITOR_IMAGE_MAX_FILE_SIZE {
|
||||
return Err(format!(
|
||||
"图片超过 {} 字节限制",
|
||||
UI_EDITOR_IMAGE_MAX_FILE_SIZE
|
||||
));
|
||||
}
|
||||
total_size = total_size
|
||||
.checked_add(bytes.len() as u64)
|
||||
.filter(|size| *size <= UI_EDITOR_IMAGE_MAX_TOTAL_SIZE)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"图片批次总量超过 {} 字节限制",
|
||||
UI_EDITOR_IMAGE_MAX_TOTAL_SIZE
|
||||
)
|
||||
})?;
|
||||
let media_type = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
"image/png"
|
||||
} else if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
|
||||
@@ -1777,17 +1766,20 @@ pub(crate) fn import_ui_editor_local_files(
|
||||
.to_string();
|
||||
inputs.push((name, media_type.to_string(), bytes));
|
||||
}
|
||||
let assets = inputs
|
||||
.into_iter()
|
||||
.map(|(name, media, bytes)| upload_local_asset_at(root, &name, &media, &bytes))
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.into_iter()
|
||||
.map(|asset| ImportedAsset {
|
||||
let mut assets = Vec::with_capacity(inputs.len());
|
||||
for (name, media, bytes) in inputs {
|
||||
let asset = upload_local_asset_at(root, &name, &media, &bytes)?;
|
||||
// 参考 save_ui_design_state_at:只有文件和 manifest 写入成功后才推进 revision。
|
||||
// 每个成功项目独立提交,后续失败不会让已落盘项目停留在旧 revision。
|
||||
advance_agent_runtime_project_revision_locked(root).map_err(|error| {
|
||||
format!("reconciliation-required: 图片已导入,但项目 revision 未能推进:{error}")
|
||||
})?;
|
||||
assets.push(ImportedAsset {
|
||||
id: asset.id,
|
||||
local_path: asset.local_path,
|
||||
asset_kind: None,
|
||||
})
|
||||
.collect();
|
||||
});
|
||||
}
|
||||
Ok(LocalImportResult { assets })
|
||||
}
|
||||
|
||||
@@ -1945,7 +1937,6 @@ pub(crate) fn import_ui_editor_local_fonts(
|
||||
}
|
||||
|
||||
if !new_inputs.is_empty() {
|
||||
advance_agent_runtime_project_revision_locked(root)?;
|
||||
let font_root = root.join("assets/fonts");
|
||||
fs::create_dir_all(&font_root).map_err(|_| "创建项目字体目录失败".to_string())?;
|
||||
}
|
||||
@@ -1996,6 +1987,10 @@ pub(crate) fn import_ui_editor_local_fonts(
|
||||
)?;
|
||||
existing_by_hash.insert(font.content_sha256.clone(), font.clone());
|
||||
result.push(font);
|
||||
// 参考 save_ui_design_state_at:登记成功后才推进 revision;失败项不会伪造提交版本。
|
||||
advance_agent_runtime_project_revision_locked(root).map_err(|error| {
|
||||
format!("reconciliation-required: 字体已复制并登记,但项目 revision 未能推进:{error}")
|
||||
})?;
|
||||
}
|
||||
Ok(LocalImportResult {
|
||||
assets: result
|
||||
@@ -2210,29 +2205,35 @@ mod ui_editor_font_tests {
|
||||
pub(crate) fn import_ui_editor_remote_assets(
|
||||
project_path: String,
|
||||
assets: Vec<serde_json::Value>,
|
||||
max_file_size: u64,
|
||||
) -> Result<RemoteImportResult, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "canvas.asset_import")?;
|
||||
let _lock = acquire_project_write_lock(root, "canvas.asset_import")?;
|
||||
advance_agent_runtime_project_revision_locked(root)?;
|
||||
if assets.len() > UI_EDITOR_IMAGE_MAX_COUNT {
|
||||
return Err(format!("一次最多选择 {UI_EDITOR_IMAGE_MAX_COUNT} 张图片"));
|
||||
}
|
||||
// 远程素材导入保持与本地图片/字体相同的增量语义,不对已成功项目文件做整批回滚。
|
||||
let mut downloads = Vec::new();
|
||||
let mut total_size = 0u64;
|
||||
let mut imported = Vec::with_capacity(assets.len());
|
||||
for asset in assets {
|
||||
let asset_id = json_string_field(&asset, "assetId")
|
||||
.or_else(|| json_string_field(&asset, "objectKey"))
|
||||
.ok_or_else(|| "平台素材缺少稳定 assetId/objectKey,拒绝导入".to_string())?;
|
||||
let bytes = asset
|
||||
.get("bytes")
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("平台素材 {asset_id} 缺少图片内容"))
|
||||
.and_then(|value| {
|
||||
serde_json::from_value::<Vec<u8>>(value)
|
||||
.map_err(|_| format!("平台素材 {asset_id} 图片内容无效"))
|
||||
// 参考资源同步流程:IPC 只传稳定引用/签名 URL,由 Rust 流式下载并限制响应体,
|
||||
// 避免把不受控的 Vec<u8> 在 IPC 反序列化阶段一次性放入内存。
|
||||
let download_url = json_string_field(&asset, "downloadUrl")
|
||||
.ok_or_else(|| format!("平台素材 {asset_id} 缺少 downloadUrl"))?;
|
||||
let remaining = UI_EDITOR_IMAGE_MAX_TOTAL_SIZE.saturating_sub(total_size);
|
||||
let bytes = download_ui_editor_remote_asset(&download_url, remaining).await?;
|
||||
total_size = total_size
|
||||
.checked_add(bytes.len() as u64)
|
||||
.filter(|size| *size <= UI_EDITOR_IMAGE_MAX_TOTAL_SIZE)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"图片批次总量超过 {} 字节限制",
|
||||
UI_EDITOR_IMAGE_MAX_TOTAL_SIZE
|
||||
)
|
||||
})?;
|
||||
if bytes.len() as u64 > max_file_size {
|
||||
return Err(format!("图片超过 {} 字节限制", max_file_size));
|
||||
}
|
||||
let media_type = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
"image/png"
|
||||
} else if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
|
||||
@@ -2242,10 +2243,6 @@ pub(crate) fn import_ui_editor_remote_assets(
|
||||
} else {
|
||||
return Err("平台素材不是受支持的 PNG/JPEG/WEBP 图片".to_string());
|
||||
};
|
||||
downloads.push((asset, asset_id, media_type.to_string(), bytes));
|
||||
}
|
||||
let mut imported = Vec::new();
|
||||
for (asset, asset_id, media_type, bytes) in downloads {
|
||||
let extension = infer_file_extension(
|
||||
json_string_field(&asset, "objectKey")
|
||||
.or_else(|| json_string_field(&asset, "imageSrc"))
|
||||
@@ -2283,10 +2280,63 @@ pub(crate) fn import_ui_editor_remote_assets(
|
||||
local_path: registered.local_path,
|
||||
asset_kind: json_string_field(&asset, "assetKind"),
|
||||
});
|
||||
// 参考 save_ui_design_state_at:远程文件与 manifest 成功后才推进 revision。
|
||||
advance_agent_runtime_project_revision_locked(root).map_err(|error| {
|
||||
format!("reconciliation-required: 远程素材已导入,但项目 revision 未能推进:{error}")
|
||||
})?;
|
||||
}
|
||||
Ok(RemoteImportResult { assets: imported })
|
||||
}
|
||||
|
||||
async fn download_ui_editor_remote_asset(url: &str, max_bytes: u64) -> Result<Vec<u8>, String> {
|
||||
if max_bytes == 0 {
|
||||
return Err("图片批次总量已达到服务端限制".to_string());
|
||||
}
|
||||
let parsed = validate_external_asset_download_url(url, "", false)?;
|
||||
let client = build_external_asset_download_client(&parsed, "", false).await?;
|
||||
let mut response = client
|
||||
.get(parsed)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("下载平台素材失败:{error}"))?;
|
||||
if response.status().is_redirection() {
|
||||
return Err("平台素材下载地址发生重定向,已拒绝继续请求".to_string());
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"下载平台素材失败:HTTP {}",
|
||||
response.status().as_u16()
|
||||
));
|
||||
}
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|size| size > UI_EDITOR_IMAGE_MAX_FILE_SIZE || size > max_bytes)
|
||||
{
|
||||
return Err(format!(
|
||||
"平台素材超过单文件 {} 或批次剩余 {} 字节限制",
|
||||
UI_EDITOR_IMAGE_MAX_FILE_SIZE, max_bytes
|
||||
));
|
||||
}
|
||||
let capacity = response
|
||||
.content_length()
|
||||
.and_then(|size| usize::try_from(size).ok())
|
||||
.unwrap_or_default()
|
||||
.min(UI_EDITOR_IMAGE_MAX_FILE_SIZE as usize);
|
||||
let mut bytes = Vec::with_capacity(capacity);
|
||||
while let Some(chunk) = response
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|error| format!("读取平台素材失败:{error}"))?
|
||||
{
|
||||
let next_size = bytes.len().saturating_add(chunk.len());
|
||||
if next_size as u64 > UI_EDITOR_IMAGE_MAX_FILE_SIZE || next_size as u64 > max_bytes {
|
||||
return Err("平台素材超过服务端下载限制".to_string());
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn generate_platform_art_asset(
|
||||
project_path: String,
|
||||
|
||||
@@ -329,8 +329,9 @@ fn serialize_ui_design_document(document: &PersistedUiDesignState) -> Result<Vec
|
||||
}
|
||||
|
||||
/// Installs a single JSON document without touching the sibling `.previous`
|
||||
/// recovery copy. The shape follows the project's sidecar atomic-write
|
||||
/// discipline while keeping a durable, non-recursive recovery generation.
|
||||
/// recovery copy. The replacement path deliberately follows the shared
|
||||
/// runtime sidecar protocol: a stable `.previous` is recoverable after a
|
||||
/// process crash, unlike a random `.replace.<pid>.<nonce>` file.
|
||||
fn write_ui_design_raw_file(path: &Path, label: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
if bytes.len() > UI_DESIGN_STATE_MAX_BYTES {
|
||||
return Err(format!("{label} 超过 {UI_DESIGN_STATE_MAX_BYTES} 字节上限"));
|
||||
@@ -350,8 +351,7 @@ fn write_ui_design_raw_file(path: &Path, label: &str, bytes: &[u8]) -> Result<()
|
||||
.map_err(|error| format!("生成 {label} 临时文件名失败:{error}"))?
|
||||
.as_nanos();
|
||||
let temp_path = path.with_file_name(format!(".{name}.tmp.{}.{}", std::process::id(), nonce));
|
||||
let replacement_path =
|
||||
path.with_file_name(format!(".{name}.replace.{}.{}", std::process::id(), nonce));
|
||||
let replacement_path = agent_runtime_json_sidecar_backup_path(path);
|
||||
let mut options = fs::OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
@@ -377,6 +377,8 @@ fn write_ui_design_raw_file(path: &Path, label: &str, bytes: &[u8]) -> Result<()
|
||||
drop(file);
|
||||
|
||||
match fs::rename(&temp_path, path) {
|
||||
// `.previous` is prepared by write_ui_design_document before this call;
|
||||
// preserve it on the normal atomic-rename path for startup recovery.
|
||||
Ok(()) => {}
|
||||
Err(first_error) => {
|
||||
let metadata = fs::symlink_metadata(path).map_err(|error| {
|
||||
@@ -387,6 +389,9 @@ fn write_ui_design_raw_file(path: &Path, label: &str, bytes: &[u8]) -> Result<()
|
||||
let _ = fs::remove_file(&temp_path);
|
||||
return Err(format!("{label} 必须是普通文件"));
|
||||
}
|
||||
// 与 agent/runtime_protocol/json_sidecar.rs 的通用写入器保持一致:
|
||||
// 先清理旧恢复副本,再把旧主文件放到稳定 `.previous`,使崩溃窗口可恢复。
|
||||
remove_agent_runtime_json_sidecar_backup(&replacement_path, label)?;
|
||||
fs::rename(path, &replacement_path).map_err(|error| {
|
||||
let _ = fs::remove_file(&temp_path);
|
||||
format!("准备替换 {label} 失败:{first_error};{error}")
|
||||
@@ -399,8 +404,7 @@ fn write_ui_design_raw_file(path: &Path, label: &str, bytes: &[u8]) -> Result<()
|
||||
.unwrap_or_default();
|
||||
return Err(format!("替换 {label} 失败:{error}{restore_detail}"));
|
||||
}
|
||||
fs::remove_file(&replacement_path)
|
||||
.map_err(|error| format!("清理 {label} 替换副本失败:{error}"))?;
|
||||
remove_agent_runtime_json_sidecar_backup(&replacement_path, label)?;
|
||||
}
|
||||
}
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
loadEditorAssetLibrary,
|
||||
readClientAssetBytes,
|
||||
resolveClientAssetReadUrl,
|
||||
} from '../../services/clientApi';
|
||||
import { ThemedModal } from '../modal/ThemedModal';
|
||||
import {
|
||||
@@ -223,8 +223,9 @@ export function AssetImporter({
|
||||
});
|
||||
const sourcePaths = Array.isArray(paths) ? paths : paths ? [paths] : [];
|
||||
if (!sourcePaths.length) return;
|
||||
if (sourcePaths.length > maxItems)
|
||||
if (sourcePaths.length > maxItems) {
|
||||
throw new Error(`最多选择 ${maxItems} 个文件`);
|
||||
}
|
||||
const result = await invoke<GenericImportResponse>(
|
||||
'import_ui_editor_assets',
|
||||
{
|
||||
@@ -271,18 +272,13 @@ export function AssetImporter({
|
||||
remoteAssets: await Promise.all(
|
||||
remoteChosen.map(async (item) => {
|
||||
const asset = item.asset!;
|
||||
const response = asset.localPath.startsWith('http')
|
||||
? await fetch(asset.localPath)
|
||||
: await readClientAssetBytes(asset.localPath);
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
`读取平台素材失败(HTTP ${response.status})`,
|
||||
);
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
const downloadUrl = asset.localPath.startsWith('http')
|
||||
? asset.localPath
|
||||
: await resolveClientAssetReadUrl(asset.localPath);
|
||||
return {
|
||||
assetId: asset.id,
|
||||
objectKey: asset.localPath,
|
||||
bytes: Array.from(bytes),
|
||||
downloadUrl,
|
||||
};
|
||||
}),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user