diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_references.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_references.rs index f7d290391..f1b78b1ca 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_references.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_references.rs @@ -172,13 +172,26 @@ fn render_runtime_region_reference_line( let width = sanitize_reference_dimension(reference.width); let height = sanitize_reference_dimension(reference.height); + // `resourceIds` 是本模块唯一由客户端直接给出、且自身还是一条列表的字段:条数不设界时, + // 每个 id 都要扫一遍 manifest(O(assets)),注入提示词的 `关联素材 ID:…` 行也会跟着无界 + // 变长(最终只被 32 MiB 写入护栏拦下,变成一条和原因无关的连接级错误)。这里按模块的 + // 失败关闭口径直接拒绝超限,而不是静默丢掉用户选中的关联。 + if reference.resource_ids.len() > MAX_DIRECT_CODEX_REFERENCES { + return Err(format!( + "运行画面区域一次最多关联 {MAX_DIRECT_CODEX_REFERENCES} 个素材,请重新点选" + )); + } let mut related_resource_ids = Vec::new(); for resource_id in &reference.resource_ids { let resource_id = validate_resource_reference_id(resource_id)?; if !manifest.assets.iter().any(|asset| asset.id == resource_id) { return Err("运行画面引用的素材已变化,请重新点选".to_string()); } - related_resource_ids.push(resource_id); + // 去重:同一个 id 在注入提示词里重复出现没有信息量,只是把行撑长。 + // 条数已按上限收口,所以这里的逐项比较不会退化成大面积二次扫描。 + if !related_resource_ids.contains(&resource_id) { + related_resource_ids.push(resource_id); + } } let mut parts = vec![format!("名称:{label}")]; @@ -325,4 +338,45 @@ mod tests { assert!(!section.contains("onclick")); assert!(!section.contains("secret")); } + + #[test] + fn runtime_region_dedupes_and_bounds_related_resource_ids() { + let project = fixture_project(); + // 同一个 id 重复出现只应产生一条关联。 + let duplicated: DirectCodexTurnReference = serde_json::from_str( + r#"{"type":"runtime-region","label":"开始按钮","resourceIds":["asset-hero","asset-hero"," asset-hero "],"text":"开始游戏"}"#, + ) + .expect("reference json"); + let section = render_direct_codex_references_section( + project.path(), + std::slice::from_ref(&duplicated), + ) + .expect("render") + .expect("section"); + assert!( + section.contains("关联素材 ID:asset-hero\n") + || section.trim_end().ends_with("关联素材 ID:asset-hero"), + "{section}" + ); + assert!( + !section.contains("asset-hero,"), + "重复 id 不得在注入提示词里重复出现:{section}" + ); + + // 超出上限直接失败关闭:不能按对方给的长度注入提示词。 + let oversized_ids = (0..MAX_DIRECT_CODEX_REFERENCES + 1) + .map(|_| "\"asset-hero\"".to_string()) + .collect::>() + .join(","); + let oversized: DirectCodexTurnReference = serde_json::from_str(&format!( + r#"{{"type":"runtime-region","label":"开始按钮","resourceIds":[{oversized_ids}]}}"# + )) + .expect("reference json"); + let error = render_direct_codex_references_section( + project.path(), + std::slice::from_ref(&oversized), + ) + .expect_err("oversized resource id list must fail closed"); + assert!(error.contains("最多关联"), "{error}"); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index 7761e6c05..a546fd671 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -13,7 +13,9 @@ use std::path::{Path, PathBuf}; const DIRECT_PROJECT_HISTORY_SCAN_PROBE_STARTED: usize = 0; const DIRECT_PROJECT_HISTORY_SCAN_PROBE_FINISHED: usize = 1; -const DIRECT_PROJECT_HISTORY_RECORD_TYPE: &str = "response_item"; +/// 写入侧与读取侧共用同一个信封类型:`project.jsonl` 由项目主对话与 DirectProject 共享, +/// 这个值一旦只在写入侧改动,读取侧就会把对方的行当成坏行,整份历史立刻读不出来。 +pub(crate) const DIRECT_PROJECT_HISTORY_RECORD_TYPE: &str = "response_item"; /// 格式切换到 `response_item`(#282)之前,DirectProject 主对话通过通用对话写入器 /// 落到同一份 `project.jsonl`,行形状是 `PersistedLocalConversationMessageRecord`。 const DIRECT_PROJECT_HISTORY_LEGACY_SCHEMA_VERSION: &str = "game-creator-conversation.v1"; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index 8252b6c6a..38208af77 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -1793,7 +1793,7 @@ fn direct_codex_failure_recovery_hint(stage: DirectCodexFailureStage, error: &st return "项目对话历史存在本版本无法识别的记录,旧格式已兼容读取,请检查项目诊断后修复该历史文件再发送需求"; } if direct_project_history_contention_failure(error) { - return "另一个客户端进程正在读写该项目的历史或项目锁,本轮历史未能落盘;请稍后重试,若确认没有其它客户端在运行请重启客户端后再发送需求"; + return "另一个客户端进程正在读写该项目的历史,本轮历史未能落盘;请稍后重试,若确认没有其它客户端在运行请重启客户端后再发送需求"; } match stage { DirectCodexFailureStage::ArtPreparation => { @@ -1880,15 +1880,18 @@ fn direct_project_history_shape_failure(error: &str) -> bool { .any(|marker| error.contains(marker)) } -/// 跨进程锁 / 项目锁争用。 +/// 追加写的**跨进程追加锁**超时。 /// /// 与"行形状"类相反:它不是同一份历史的同一个结论,而是别的进程此刻正拿着锁——锁本身 /// 没有残留(所有权是句柄,进程退出即释放),所以"稍后重试"是真能生效的动作。提示因此 /// 指向现象与动作,而不是原来 CodeGeneration 阶段那句"请检查运行时配置后重试"。 -/// 判据只认两个常量,不各自复制中文。 +/// +/// 判据刻意只认追加锁那一个常量:项目写锁争用(`PROJECT_WRITE_LOCK_CONTENTION_PREFIX`) +/// 在 [`direct_codex_failure_recovery_hint`] 里更早、更具体地判掉了("当前项目仍有写入正在 +/// 结束"),把项目写锁也写进这里只会得到一段永远走不到的判据,并让"历史未能落盘"这句 +/// 与真实原因不符的描述有机会出现。 fn direct_project_history_contention_failure(error: &str) -> bool { error.contains(crate::project::PROJECT_APPEND_LOCK_TIMEOUT_MARKER) - || error.contains(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) } fn direct_codex_error_is_mud_points_insufficient(error: &str) -> bool { @@ -4625,6 +4628,42 @@ mod tests { assert!(direct_codex_failure_is_retryable(io_error)); } + /// 锁争用提示按"更具体的那条赢":项目写锁争用走上面的专用提示, + /// 追加锁超时才落到"历史未能落盘"。两条判据不得重叠。 + #[test] + fn direct_project_history_contention_hint_is_append_lock_only() { + let append_lock_timeout = format!( + "获取DirectProject 历史追加写{}", + crate::project::PROJECT_APPEND_LOCK_TIMEOUT_MARKER + ); + assert!(direct_project_history_contention_failure( + &append_lock_timeout + )); + assert_eq!( + direct_codex_failure_recovery_hint( + DirectCodexFailureStage::CodeGeneration, + &append_lock_timeout + ), + "另一个客户端进程正在读写该项目的历史,本轮历史未能落盘;请稍后重试,若确认没有其它客户端在运行请重启客户端后再发送需求" + ); + + let write_lock_contention = format!( + "{}C:/project", + crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX + ); + assert!( + !direct_project_history_contention_failure(&write_lock_contention), + "项目写锁争用不得再落进历史争用判据" + ); + assert_eq!( + direct_codex_failure_recovery_hint( + DirectCodexFailureStage::CodeGeneration, + &write_lock_contention + ), + "当前项目仍有写入正在结束,请稍后再次发送该需求" + ); + } + #[test] fn client_turn_id_is_strictly_normalized_and_bounded() { assert_eq!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index d902487c6..bbf431c0a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -2043,6 +2043,9 @@ pub(crate) struct DeleteLocalProjectAssetResult { /// - 素材不可变,**不删除磁盘文件**,只摘掉 manifest 登记; /// - 与资源分类更新同口径:持项目写锁后按 `expectedProjectId` / 当前 revision 做 CAS, /// 失败时 manifest 与 revision 都不变。 +/// - **幂等**:CAS 通过后登记已不在 manifest 里(上一次调用删掉了登记、只在推进 revision +/// 时失败)时按 no-op 成功收敛,并照常推进 revision —— 这是「manifest 已提交但 revision +/// 未推进」那条中断路径的自愈方式,重试不再报「项目资源不存在」。 pub(crate) fn delete_manifest_asset_at( root: &Path, expected_project_id: &str, @@ -2091,12 +2094,21 @@ pub(crate) fn delete_manifest_asset_at( .iter() .position(|asset| asset.id == asset_id) else { - return Err(format!("项目资源不存在:{asset_id}")); + // 幂等收敛:上一次调用可能已经把登记删掉、只在最后推进 revision 时失败 + // (manifest 已落盘、revision 仍停在旧值)。此时 `expectedProjectId` 与 + // `expectedProjectRevision` 两项 CAS 都已通过,唯一正确的处置是把它当 + // no-op 成功 —— 重试再报「项目资源不存在」会让这条命令永远无法自愈。 + // 收敛路径上 `localPath` 回报空串(前端只用 assetId 与 revision)。 + return Ok(()); }; local_path = manifest.assets[index].local_path.clone(); manifest.assets.remove(index); if delete_referenced_versions { - let referenced_version_ids: Vec = + // 引用集合在这里算一次即可;成员判定用 `HashSet`:逐版本 `Vec::contains` + // 在引用该素材的版本很多时是 O(V²)。 + // (版本守卫侧的 `allowed_version_removals` 闭包按约定在写入前另算一次, + // 它拿不到这里的可变借用,所以两次扫描无法合并。) + let referenced_version_ids: std::collections::HashSet = asset_referencing_versions(manifest, asset_id) .into_iter() .map(|version| version.version_id) diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index d6396ea4d..4301041d4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1066,14 +1066,21 @@ pub(crate) fn replace_local_project_version_resource( /// 重命名一个已登记素材:磁盘文件改名 + 更新 manifest 的 `localPath`,资产 `id` 不变。 /// /// 只允许在资产当前所在目录内改名,扩展名必须一致,同目录不得已有同名文件;manifest 写失败 -/// 时把文件改回原名,不留半成品。 +/// 时把文件改回原名,不留半成品。与删除 / 分类更新同口径:持项目写锁后按 `expectedProjectId` +/// 与当前 revision 做 CAS,失败时磁盘、manifest 与 revision 都不动。 #[tauri::command] pub(crate) fn rename_local_project_asset( input: RenameLocalProjectAssetInput, ) -> Result { let root = Path::new(input.project_path.trim()); enforce_project_permission_policy(root, "asset.register")?; - rename_local_project_asset_at(root, &input.asset_id, &input.new_file_name, None) + rename_local_project_asset_at( + root, + &input.expected_project_id, + input.expected_project_revision, + &input.asset_id, + &input.new_file_name, + ) } #[tauri::command] diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs index 911f74d4b..bcd6d99d4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs @@ -3572,10 +3572,7 @@ impl ProjectAppendLock { error_label: &str, max_attempts: usize, ) -> Result, String> { - let process_guard = self - .process_lock - .lock() - .map_err(|_| format!("获取{error_label}进程内锁失败:锁已损坏"))?; + let process_guard = self.try_lock_process(error_label, max_attempts)?; let os_lock = acquire_project_append_os_lock(&self.os_lock_path, error_label, max_attempts)?; Ok(ProjectAppendGuard { @@ -3583,6 +3580,39 @@ impl ProjectAppendLock { _os_lock: os_lock, }) } + + /// 进程内锁也按**有界**等待取,窗口与调用方自己的窗口同长。 + /// + /// 这里曾经是无上限的 `lock()`:写者拿着进程内锁把 10s 的跨进程等待窗口走完,同一个进程里的 + /// `lock_short` 读者就一直卡在 `process_lock.lock()` 上 —— "短窗口让读路径保持可响应"只在 + /// 跨进程成立,同进程内并不成立。 + /// + /// 顺序不变:进程内锁**永远先于**跨进程锁取,且从不在持跨进程锁时回头取进程内锁, + /// 因此不存在 ABBA。拿不到时复用 `PROJECT_APPEND_LOCK_TIMEOUT_MARKER`,调用方既有的 + /// "争用可重试"判据照样成立。 + fn try_lock_process( + &self, + error_label: &str, + max_attempts: usize, + ) -> Result, String> { + let max_attempts = max_attempts.max(1); + for attempt in 0..max_attempts { + match self.process_lock.try_lock() { + Ok(guard) => return Ok(guard), + Err(std::sync::TryLockError::Poisoned(_)) => { + return Err(format!("获取{error_label}进程内锁失败:锁已损坏")); + } + Err(std::sync::TryLockError::WouldBlock) => { + if attempt + 1 < max_attempts { + thread::sleep(PROJECT_APPEND_LOCK_RETRY_INTERVAL); + } + } + } + } + Err(format!( + "获取{error_label}{PROJECT_APPEND_LOCK_TIMEOUT_MARKER}:同一个客户端进程内还有一次追加写未结束(进程内锁等待已达上限)" + )) + } } pub(crate) fn project_append_lock_for(path: &Path) -> Result { @@ -3647,7 +3677,7 @@ fn acquire_project_append_os_lock( let max_attempts = max_attempts.max(1); for attempt in 0..max_attempts { if let Some(mut file) = try_open_project_append_os_lock(path, error_label)? { - refresh_project_append_lock_diagnostic(&mut file, error_label); + refresh_project_append_lock_diagnostic(&mut file, path, error_label); return Ok(file); } if attempt + 1 < max_attempts { @@ -3666,10 +3696,21 @@ fn acquire_project_append_os_lock( /// 它**只用于报错文案**:不判活、不回收、不抢占。读不到就明说读不到——现场最怕的是 /// 一句"检查运行时配置",那既不是现象也不是动作。 fn project_append_lock_holder_diagnostic(path: &Path) -> String { + // Windows 上持锁方把锁文件本身按 zero-share 打开(`try_open_project_append_os_lock` 里的 + // `share_mode(0)`),别的进程连"读"都拿不到它,所以先读与锁同目录、按普通共享方式写出的 + // 旁路诊断文件。旁路读不到再退回锁文件本身:Unix 一直是这么读的,而且老版本客户端持锁时 + // 也只有锁文件里有线索。两个来源都读不到才算"身份不可读"。 + if let Ok(content) = fs::read_to_string(project_append_lock_holder_record_path(path)) { + return describe_project_append_lock_holder(&content); + } let Ok(content) = fs::read_to_string(path) else { return "持锁方身份不可读:锁文件正被独占持有或已不可读".to_string(); }; - let Ok(record) = serde_json::from_str::(&content) else { + describe_project_append_lock_holder(&content) +} + +fn describe_project_append_lock_holder(content: &str) -> String { + let Ok(record) = serde_json::from_str::(content) else { return "持锁方身份不可读:锁文件里没有可解析的诊断元数据".to_string(); }; let pid = record.get("pid").and_then(serde_json::Value::as_u64); @@ -3687,23 +3728,32 @@ fn project_append_lock_holder_diagnostic(path: &Path) -> String { } } -/// 把"谁在持这把锁"写进锁文件,供事后排障;同一进程重复取同一把锁时不重复写。 +/// 旁路诊断文件:与锁同级、普通共享写入,专门给"锁文件本身读不到"的平台用。 +/// +/// 它是**只读诊断**,不参与判活 / 回收 / 抢占:拿它永远拿不到锁,锁所有权始终是那个 +/// zero-share 句柄。写入失败也不影响取锁结果(见 [`refresh_project_append_lock_diagnostic`])。 +fn project_append_lock_holder_record_path(path: &Path) -> PathBuf { + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("append.lock"); + path.with_file_name(format!("{file_name}.holder.json")) +} + +/// 把"谁在持这把锁"写进锁文件与旁路诊断文件,供事后排障;同一进程重复取同一把锁时不重复写。 /// /// 所有权是句柄本身,不靠文件内容成立:这里写失败绝不影响取锁结果。诊断元数据也不参与 /// 任何判活/回收/抢占判断(既有口径见 docs/project-memory/shared-memory/decision-log.md /// 2026-09-09「项目写锁残留回收与启动诊断」)。 -fn refresh_project_append_lock_diagnostic(file: &mut File, error_label: &str) { +fn refresh_project_append_lock_diagnostic(file: &mut File, path: &Path, error_label: &str) { let pid = std::process::id(); let mut current = String::new(); - if file.seek(SeekFrom::Start(0)).is_ok() + let lock_file_already_records_this_process = file.seek(SeekFrom::Start(0)).is_ok() && std::io::Read::read_to_string(file, &mut current).is_ok() && serde_json::from_str::(¤t) .ok() .and_then(|value| value.get("pid").and_then(serde_json::Value::as_u64)) - == Some(u64::from(pid)) - { - return; - } + == Some(u64::from(pid)); let Ok(serialized) = serde_json::to_string(&serde_json::json!({ "acquiredAt": unix_timestamp(), "label": error_label, @@ -3714,11 +3764,19 @@ fn refresh_project_append_lock_diagnostic(file: &mut File, error_label: &str) { })) else { return; }; - if file.set_len(0).is_err() || file.seek(SeekFrom::Start(0)).is_err() { - return; + if !lock_file_already_records_this_process { + if file.set_len(0).is_err() || file.seek(SeekFrom::Start(0)).is_err() { + return; + } + let _ = file.write_all(serialized.as_bytes()); + let _ = file.flush(); } - let _ = file.write_all(serialized.as_bytes()); - let _ = file.flush(); + // 旁路文件每次取锁都刷新(不跟着上面的"本进程已写过"短路):上一次可能就是写它失败的那次。 + // 失败静默:它只是排障线索,不能影响取锁结果。 + let _ = fs::write( + project_append_lock_holder_record_path(path), + serialized.as_bytes(), + ); } /// 测试专用探针:判断某个追加写目标此刻是否被别人持有 OS 锁。 @@ -3825,7 +3883,17 @@ fn release_project_append_os_lock_then_repair_acl( ) -> Result<(), String> { drop(held_lock); crate::secure_windows_game_creator_path_for_current_user_with_auto_elevation(path, false, true) - .map_err(|repair_error| format!("{strict_error};自动提权修复未完成:{repair_error}")) + .map_err(|repair_error| { + // 只有"可能靠提权修好"的错误才配得上这句后缀。非提权类失败(锁文件在打开与严格校验 + // 之间被删掉、ACL 报错不在提权口径内)时,自动修复没有做任何事就原样返回了同一个 + // 错误,再贴一句"自动提权修复未完成"等于把同一条错误报两遍,还把现场指向一个 + // 根本没发生过的动作。 + if crate::config::windows_acl_error_may_need_elevation(&repair_error) { + format!("{strict_error};自动提权修复未完成:{repair_error}") + } else { + strict_error.to_string() + } + }) } #[cfg(windows)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs index 9a7239e06..43fe2df3e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs @@ -3197,6 +3197,91 @@ fn append_lock_holder_diagnostic_reports_the_recorded_pid() { fs::remove_dir_all(&root).ok(); } +/// 取锁必须同时写出与锁同级的旁路诊断文件。 +/// +/// Windows 上持锁方以 zero-share 打开锁文件本身,别的进程连读都读不到,"谁在持锁"就只能靠 +/// 这个普通共享的旁路文件回答——那正是这套 UAC / 锁争用改动要落地的平台。 +#[test] +fn append_lock_acquisition_writes_the_holder_record_sidecar() { + let (root, target) = append_lock_test_target("append-lock-holder-sidecar"); + let lock = project_append_lock_for(&target).expect("resolve lock"); + drop( + lock.lock("DirectProject 历史追加写") + .expect("acquire append lock"), + ); + + let lock_path = project_append_os_lock_path(&target).expect("lock path"); + let sidecar = project_append_lock_holder_record_path(&lock_path); + let raw = fs::read_to_string(&sidecar).expect("read holder record sidecar"); + let record: serde_json::Value = + serde_json::from_str(&raw).expect("parse holder record sidecar"); + assert_eq!( + record["pid"], + serde_json::json!(u64::from(std::process::id())) + ); + assert_eq!( + record["label"], + serde_json::json!("DirectProject 历史追加写") + ); + assert!(record["acquiredAt"].is_number(), "{raw}"); + + fs::remove_dir_all(&root).ok(); +} + +/// 锁文件读不到时,诊断必须退回旁路文件,而不是直接判"身份不可读"。 +#[test] +fn append_lock_holder_diagnostic_falls_back_to_the_sidecar() { + let (root, target) = append_lock_test_target("append-lock-holder-sidecar-fallback"); + let lock_path = project_append_os_lock_path(&target).expect("lock path"); + fs::create_dir_all(lock_path.parent().expect("lock parent")).expect("lock dir"); + fs::write( + project_append_lock_holder_record_path(&lock_path), + "{\"acquiredAt\":1,\"label\":\"旁路用途\",\"pid\":4242,\"processStartedAt\":7}", + ) + .expect("seed holder record sidecar"); + // 锁文件此刻不存在:旧实现只会报"身份不可读"。 + assert!(!lock_path.exists()); + + let diagnostic = project_append_lock_holder_diagnostic(&lock_path); + assert!(diagnostic.contains("pid=4242"), "{diagnostic}"); + assert!(diagnostic.contains("旁路用途"), "{diagnostic}"); + + fs::remove_dir_all(&root).ok(); +} + +/// 同进程内的读路径不能被写者的进程内锁拖住:短窗口必须有界地失败,而不是等写者走完 +/// 完整的跨进程等待窗口。 +#[test] +fn append_lock_short_wait_is_bounded_while_a_same_process_writer_holds_the_lock() { + let (root, target) = append_lock_test_target("append-lock-process-lock-bound"); + let lock = project_append_lock_for(&target).expect("resolve lock"); + let writer = lock + .lock("DirectProject 历史追加写") + .expect("acquire append lock"); + + let started = std::time::Instant::now(); + let error = match lock.lock_short("DirectProject 历史追加读") { + Ok(_guard) => panic!("进程内锁被占满时短窗口必须失败而不是无限等待"), + Err(error) => error, + }; + let elapsed = started.elapsed(); + assert!( + error.contains(PROJECT_APPEND_LOCK_TIMEOUT_MARKER), + "{error}" + ); + assert!( + elapsed < std::time::Duration::from_secs(5), + "短窗口必须留在 1s 级,实际等待 {elapsed:?}" + ); + + drop(writer); + drop( + lock.lock_short("DirectProject 历史追加读") + .expect("reacquire after writer releases"), + ); + fs::remove_dir_all(&root).ok(); +} + /// 真的被别人独占持有时:超时文案必须报出锁路径与持锁方线索,而且**不留残留**—— /// 释放后立刻要能重新取到(这把锁的所有权是句柄,所以本来就不该有 stale 回收)。 #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_export.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_export.rs index dc0a19150..d63fef865 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_export.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_export.rs @@ -2,10 +2,16 @@ use super::*; use std::fs::File; use std::io::{Read, Write}; -/// 显式保存:把项目内已登记的素材文件复制到用户选定的目标路径。 +/// 显式保存:把项目内的素材文件复制到用户选定的目标路径。 /// /// 这里刻意不做"浏览器下载"——AGC 是 Tauri/WebView2 宿主,未注册 `on_download` /// 时 `` 能否落盘不可靠,所以保存路径由原生对话框给出,复制由 Rust 完成。 +/// +/// 允许导出的范围 = "项目根内真实存在的普通文件"减去敏感配置与 Runtime 控制面 +/// (见 [`resolve_export_source_file`])。**不额外要求文件已在 manifest 里登记**: +/// 资源画布里的 DirectProject 附件(`attachment:`)是客户端导入的项目内文件, +/// 用户同样要能把它们另存出来,但它们不是 manifest 资产;按登记与否放行会把这条 +/// 正常路径一并拒掉。素材保存因此与通用项目读取共用同一套路径门禁,而不是更宽的门禁。 const ASSET_EXPORT_COPY_CHUNK_BYTES: usize = 64 * 1024; #[derive(Clone, Debug, Deserialize)] @@ -25,10 +31,14 @@ pub(crate) struct SaveLocalProjectAssetFileResult { fn resolve_export_source_file(root: &Path, relative_path: &str) -> Result { let normalized = normalize_relative_path(relative_path.trim())?; - if normalized.is_empty() { - return Err("待保存的素材路径不能为空".to_string()); - } - let source = root.join(&normalized); + // 导出同样是"把项目里的文件交出去",必须与通用项目读取路径同一套拒绝口径: + // 敏感配置(`.env` 等)与 `.agent/runtime|checkpoints|workbench` 控制面都不能被复制到项目外。 + reject_agent_runtime_private_control_path(&normalized)?; + reject_sensitive_project_file_read(&normalized)?; + // 逐段复核符号链接与 Windows reparse point,而不是只看最后一段:`assets` 本身是链接 + // 或 junction 时,`root.join(...)` 仍会解析到项目外,导出就变成任意文件读取。 + // 这里与改名路径共用 `resolve_local_project_path`,保证是同一条边界判据。 + let source = resolve_local_project_path(root, &normalized)?; // 只允许保存项目根内真实存在的普通文件:符号链接与目录都要拒绝。 let metadata = std::fs::symlink_metadata(&source) .map_err(|error| format!("素材文件不存在或不可读:{error}"))?; @@ -63,19 +73,83 @@ fn resolve_export_destination_file(destination_path: &str) -> Result Result { - let root = Path::new(input.project_path.trim()); - validate_project_root(root)?; - enforce_project_auto_permission_policy(root, "file.read")?; +/// 已存在路径的文件身份;读不到身份、或身份是退化常量时返回 `None`。 +/// +/// FAT/exFAT 与部分网络卷上 `file index` 恒为 0,那不是一个能区分文件的身份, +/// 拿它比较只会把正常导出误判成"自我覆盖",所以这种值一律当成"没有身份"。 +fn export_path_file_identity(path: &Path) -> Option<(u64, u64)> { + let identity = File::open(path) + .ok() + .and_then(|file| open_file_identity_key(&file).ok())?; + if identity.1 == 0 { + None + } else { + Some(identity) + } +} - let source = resolve_export_source_file(root, &input.relative_path)?; - let destination = resolve_export_destination_file(&input.destination_path)?; +/// 路径等价判据:Windows / macOS 的文件系统默认大小写不敏感,同一份文件可能只差大小写。 +fn export_paths_equivalent(left: &Path, right: &Path) -> bool { + if cfg!(any(windows, target_os = "macos")) { + left.to_string_lossy() + .eq_ignore_ascii_case(&right.to_string_lossy()) + } else { + left == right + } +} - let mut reader = File::open(&source).map_err(|error| format!("打开素材文件失败:{error}"))?; +/// 保存目标不能与源素材是同一个文件。 +/// +/// 前端用的是原生保存对话框,用户完全可以导航进项目素材目录并选中源文件本身;`File::create` +/// 会先把目标截断,随后的复制只能读到 0 字节,于是"复制成功"(`byte_len = 0`)返回,而源素材 +/// 已经被就地毁掉。路径等价、以及符号链接 / 硬链接指向同一份文件,都要在创建写句柄之前拦住。 +fn reject_export_destination_matching_source( + source: &Path, + destination: &Path, +) -> Result<(), String> { + if !destination.exists() { + return Ok(()); + } + let same_file = match ( + export_path_file_identity(source), + export_path_file_identity(destination), + ) { + (Some(source_identity), Some(destination_identity)) => { + source_identity == destination_identity + } + _ => false, + }; + let same_path = matches!( + (source.canonicalize(), destination.canonicalize()), + (Ok(source_canonical), Ok(destination_canonical)) + if export_paths_equivalent(&source_canonical, &destination_canonical) + ); + if same_file || same_path { + return Err("保存目标不能与素材源文件相同".to_string()); + } + Ok(()) +} + +/// 复制目标文件名用的临时兄弟路径:必须与目标同目录,`rename` 才是同卷原子替换。 +fn export_destination_temp_path(destination: &Path) -> PathBuf { + let file_name = destination + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("asset-export"); + destination.with_file_name(format!( + ".{file_name}.tmp.{}.{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )) +} + +fn copy_export_source_bytes_to(source: &Path, temp_path: &Path) -> Result { + let mut reader = File::open(source).map_err(|error| format!("打开素材文件失败:{error}"))?; let mut writer = - File::create(&destination).map_err(|error| format!("创建保存目标失败:{error}"))?; + File::create(temp_path).map_err(|error| format!("创建保存目标失败:{error}"))?; let mut buffer = vec![0_u8; ASSET_EXPORT_COPY_CHUNK_BYTES]; let mut byte_len = 0_u64; loop { @@ -93,6 +167,42 @@ pub(crate) fn save_local_project_asset_file_at( writer .flush() .map_err(|error| format!("刷新保存目标失败:{error}"))?; + // 替换前必须落盘:只 `flush` 的话,崩溃窗口里换过去的可能是还没写完的内容。 + writer + .sync_all() + .map_err(|error| format!("落盘保存目标失败:{error}"))?; + Ok(byte_len) +} + +/// 先整份复制到目标同目录的临时文件,再原子替换目标。 +/// +/// 直接往最终目标写会在 `File::create` 那一刻截断既有文件:复制中途失败就同时留下半截文件 +/// 和"原目标已被毁掉"两个后果。manifest 写入(`write_manifest_locked`)用的是同一套手法。 +fn copy_export_source_to_destination(source: &Path, destination: &Path) -> Result { + let temp_path = export_destination_temp_path(destination); + let result = copy_export_source_bytes_to(source, &temp_path).and_then(|byte_len| { + fs::rename(&temp_path, destination) + .map_err(|error| format!("替换保存目标失败:{}: {error}", destination.display()))?; + Ok(byte_len) + }); + if result.is_err() { + let _ = fs::remove_file(&temp_path); + } + result +} + +pub(crate) fn save_local_project_asset_file_at( + input: SaveLocalProjectAssetFileInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + validate_project_root(root)?; + enforce_project_auto_permission_policy(root, "file.read")?; + + let source = resolve_export_source_file(root, &input.relative_path)?; + let destination = resolve_export_destination_file(&input.destination_path)?; + reject_export_destination_matching_source(&source, &destination)?; + + let byte_len = copy_export_source_to_destination(&source, &destination)?; Ok(SaveLocalProjectAssetFileResult { destination_path: destination.to_string_lossy().into_owned(), @@ -246,4 +356,165 @@ mod tests { let _ = std::fs::remove_dir_all(&root); let _ = std::fs::remove_dir_all(&destination_directory); } + + /// 敏感配置与 Runtime 控制面文件都不能被导出到项目外:导出走的是同一条 + /// `file.read` 拒绝口径,而不是只做路径拼接。 + #[test] + fn rejects_sensitive_and_agent_control_files_as_source() { + let (root, assets) = create_export_project(); + std::fs::write(root.join(".env"), "SECRET=1").expect("write .env"); + std::fs::write(assets.join("hero.png"), b"payload").expect("write source asset"); + let agent_runtime = root.join(".agent/runtime"); + std::fs::create_dir_all(&agent_runtime).expect("create agent runtime dir"); + std::fs::write(agent_runtime.join("run.json"), "{}").expect("write control file"); + let destination_directory = unique_asset_export_directory("destination"); + std::fs::create_dir_all(&destination_directory).expect("create destination directory"); + + for relative_path in [".env", ".agent/runtime/run.json"] { + let error = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput { + project_path: root.to_string_lossy().into_owned(), + relative_path: relative_path.to_string(), + destination_path: destination_directory + .join("copy.bin") + .to_string_lossy() + .into_owned(), + }) + .expect_err("sensitive or control-plane source must be rejected"); + assert!( + error.contains("拒绝读取敏感配置文件") || error.contains("私有控制面"), + "意外错误:{error}" + ); + } + assert!(!destination_directory.join("copy.bin").exists()); + + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&destination_directory); + } + + /// 中间目录是符号链接时不能把项目外的文件当素材导出:只查最后一段会漏掉这条逃逸路径。 + #[test] + fn rejects_source_reached_through_an_intermediate_symlink() { + let (root, assets) = create_export_project(); + let outside = unique_asset_export_directory("outside"); + std::fs::create_dir_all(&outside).expect("create outside directory"); + std::fs::write(outside.join("secret.png"), b"outside-secret").expect("write outside file"); + std::fs::remove_dir_all(&assets).expect("remove real assets dir"); + // Windows 上建目录符号链接需要开发者模式或特权;拿不到权限就跳过这条断言, + // 不让环境能力决定测试结论。Unix 上恒可用。 + #[cfg(windows)] + let symlink_created = std::os::windows::fs::symlink_dir(&outside, &assets).is_ok(); + #[cfg(not(windows))] + let symlink_created = std::os::unix::fs::symlink(&outside, &assets).is_ok(); + if !symlink_created || !assets.join("secret.png").is_file() { + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&outside); + return; + } + let destination_directory = unique_asset_export_directory("destination"); + std::fs::create_dir_all(&destination_directory).expect("create destination directory"); + + let error = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput { + project_path: root.to_string_lossy().into_owned(), + relative_path: "assets/secret.png".to_string(), + destination_path: destination_directory + .join("leak.png") + .to_string_lossy() + .into_owned(), + }) + .expect_err("source behind an intermediate symlink must be rejected"); + assert!(error.contains("符号链接"), "意外错误:{error}"); + assert!(!destination_directory.join("leak.png").exists()); + + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&outside); + let _ = std::fs::remove_dir_all(&destination_directory); + } + + /// 保存目标就是源文件时必须拒绝,并且源素材一个字节都不能变。 + #[test] + fn rejects_destination_that_is_the_source_file() { + let (root, assets) = create_export_project(); + let source_bytes = b"genarrative-asset-export-payload".to_vec(); + std::fs::write(assets.join("hero.png"), &source_bytes).expect("write source asset"); + let source = assets.join("hero.png"); + + let error = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput { + project_path: root.to_string_lossy().into_owned(), + relative_path: "assets/hero.png".to_string(), + destination_path: source.to_string_lossy().into_owned(), + }) + .expect_err("destination equal to the source must be rejected"); + + assert!(error.contains("不能与素材源文件相同"), "{error}"); + assert_eq!( + std::fs::read(&source).expect("read source asset"), + source_bytes, + "拒绝自我覆盖时源素材必须原样保留" + ); + + let _ = std::fs::remove_dir_all(&root); + } + + /// 硬链接指向同一份文件时同样拒绝:路径不同但内容是同一份,覆盖等于毁掉源素材。 + #[test] + fn rejects_hard_linked_destination_pointing_at_the_source() { + let (root, assets) = create_export_project(); + let source_bytes = b"genarrative-asset-export-payload".to_vec(); + std::fs::write(assets.join("hero.png"), &source_bytes).expect("write source asset"); + let destination_directory = unique_asset_export_directory("destination"); + std::fs::create_dir_all(&destination_directory).expect("create destination directory"); + let destination = destination_directory.join("hero-alias.png"); + if std::fs::hard_link(assets.join("hero.png"), &destination).is_err() { + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&destination_directory); + return; + } + + let error = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput { + project_path: root.to_string_lossy().into_owned(), + relative_path: "assets/hero.png".to_string(), + destination_path: destination.to_string_lossy().into_owned(), + }) + .expect_err("hard-linked destination must be rejected"); + assert!(error.contains("不能与素材源文件相同"), "{error}"); + assert_eq!( + std::fs::read(assets.join("hero.png")).expect("read source asset"), + source_bytes + ); + + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&destination_directory); + } + + /// 覆盖既有目标走临时文件 + 原子替换:内容整体换新,且不留临时文件残渣。 + #[test] + fn replaces_an_existing_destination_without_leaving_temp_files() { + let (root, assets) = create_export_project(); + let source_bytes = b"genarrative-asset-export-payload".to_vec(); + std::fs::write(assets.join("hero.png"), &source_bytes).expect("write source asset"); + let destination_directory = unique_asset_export_directory("destination"); + std::fs::create_dir_all(&destination_directory).expect("create destination directory"); + let destination = destination_directory.join("hero-copy.png"); + std::fs::write(&destination, b"stale-and-longer-content").expect("write stale destination"); + + let result = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput { + project_path: root.to_string_lossy().into_owned(), + relative_path: "assets/hero.png".to_string(), + destination_path: destination.to_string_lossy().into_owned(), + }) + .expect("save asset file"); + + assert_eq!(result.byte_len, source_bytes.len() as u64); + assert_eq!( + std::fs::read(&destination).expect("read copied asset"), + source_bytes + ); + let entries = std::fs::read_dir(&destination_directory) + .expect("read destination directory") + .count(); + assert_eq!(entries, 1, "原子替换不得留下临时文件"); + + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&destination_directory); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_rename.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_rename.rs index ecf2b74e5..9dab8ecf4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_rename.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_rename.rs @@ -6,10 +6,15 @@ use super::filesystem::validate_portable_project_path_component; /// /// 只收"新文件名"而不是新旧两个全路径:改名被限制在资产当前所在目录内,目录由 manifest /// 里的 `localPath` 决定,调用方无法指定目标目录。 +/// +/// `expectedProjectId` / `expectedProjectRevision` 与删除、分类更新同口径:陈旧客户端 +/// 不允许凭旧 revision 改写素材文件与 manifest。 #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct RenameLocalProjectAssetInput { pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) expected_project_revision: u64, pub(crate) asset_id: String, pub(crate) new_file_name: String, } @@ -22,12 +27,24 @@ pub(crate) struct RenameLocalProjectAssetResult { pub(crate) committed_project_revision: u64, } -/// 故障注入点:让测试在不依赖只读路径的前提下验证"manifest 写失败必须回滚文件改名"。 -/// 生产路径始终传 `None`。 -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum RenameLocalProjectAssetFaultStage { - /// 文件已改名、manifest 尚未写入时失败。 - ManifestWrite, +/// 测试注入:`.agent/runtime/test-fail-next-asset-rename-manifest-write` 存在时,下一步 +/// manifest 写入按失败返回,用来验证"文件已改名必须改回原名"的回滚。 +/// +/// 与 `agent_db` 的 `test-fail-next-*` 同一套约定,且整段是 `#[cfg(test)]`:生产签名 +/// ([`rename_local_project_asset_at`])不接受任何故障注入参数,也没有第二个入口能把函数 +/// 推上"只回滚、绝不写 manifest"的那条路。 +#[cfg(test)] +fn take_rename_manifest_write_failure_injection(root: &Path) -> Result<(), String> { + let failure_path = root.join(".agent/runtime/test-fail-next-asset-rename-manifest-write"); + match std::fs::read_to_string(&failure_path) { + Ok(_) => { + std::fs::remove_file(&failure_path) + .map_err(|error| format!("清理素材改名 manifest 写失败注入标记失败:{error}"))?; + Err("fault-injected:rename-asset-manifest-write".to_string()) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!("读取素材改名 manifest 写失败注入标记失败:{error}")), + } } /// 校验调用方给出的新文件名,返回 trim 后的名字。 @@ -64,9 +81,9 @@ fn asset_file_extension(file_name: &str) -> Option { /// 一个帧的 `imageSrc` 是否就是被改名的那份本地文件。 /// -/// 判据:既不是绝对路径、也不是带 scheme 的远程地址、也不是反斜杠路径;去掉目录后文件名与旧名 -/// 一致(大小写不敏感,和扩展名比较同口径)。只认真正指向该文件的帧,同目录但指向其它文件的帧 -/// 保持原样,避免改名连带改坏别的引用。 +/// 判据:既不是绝对路径、也不是带 scheme 的远程地址、也不是反斜杠路径;去掉目录后目录与旧 +/// 目录等价、文件名与旧名一致(都按所在文件系统的大小写语义比较)。只认真正指向该文件的帧, +/// 同目录但指向其它文件的帧保持原样,避免改名连带改坏别的引用。 fn asset_local_frame_matches(image_src: &str, directory: &str, previous_file_name: &str) -> bool { let image_src = image_src.trim(); if image_src.is_empty() @@ -81,7 +98,21 @@ fn asset_local_frame_matches(image_src: &str, directory: &str, previous_file_nam Some((directory, file_name)) => (directory, file_name), None => ("", image_src), }; - frame_directory == directory && frame_file_name.eq_ignore_ascii_case(previous_file_name) + asset_frame_directory_matches(frame_directory, directory) + && frame_file_name.eq_ignore_ascii_case(previous_file_name) +} + +/// 目录段的大小写语义必须与所在文件系统一致。 +/// +/// 文件名一直按大小写不敏感比较(`Assets/Hero.png` 与 `assets/hero.png` 在 Windows / macOS 上 +/// 是同一份文件);目录段如果按大小写敏感比较,同一份文件在 `imageSequenceFrames` 里写成 +/// 不同目录大小写时就对齐不上,改名后留下悬空帧引用。Linux 保持大小写敏感。 +fn asset_frame_directory_matches(frame_directory: &str, directory: &str) -> bool { + if cfg!(any(windows, target_os = "macos")) { + frame_directory.eq_ignore_ascii_case(directory) + } else { + frame_directory == directory + } } /// 把 manifest 条目里指向被改名文件的序列帧对齐到新 `localPath`。 @@ -125,26 +156,53 @@ fn rollback_asset_file_rename( /// /// 事务顺序(全程持既有项目写锁,期间不会出现第二个项目写者;manifest 写入仍走既有边界: /// 版本数组不可变校验 + 安装后回读一致性校验): +/// 0. 与删除 / 分类更新同口径的 CAS:`expectedProjectId` 两段复核 + 当前 revision 比对, +/// 失败时磁盘、manifest、revision 全都不动; /// 1. 读 manifest 定位资产,取旧 `localPath`; /// 2. 校验新文件名、扩展名一致、目标不冲突,然后 `rename` 磁盘文件; /// 3. 更新 manifest 的 `localPath`(`id` / `kind` / `mediaType` / `source` / `category` / `tags` /// 全部不变),并把 `imageSequenceFrames` 里指向该文件的帧对齐到新路径; /// 4. 写 manifest; /// 5. 第 4 步失败时把文件改回原名,不留"文件已改名但 manifest 还是旧路径"的半成品; -/// 改回原名也失败时,两个错误都报出来并标记 reconciliation-required。 +/// 改回原名也失败时,两个错误都报出来并标记 reconciliation-required; +/// 6. 第 4 步成功、第 6 步(推进 revision)失败时**不回滚**:文件与 manifest 都已提交, +/// 这是已提交但对账未完成的状态,按 `reconciliation-required:` 前缀上报,不按普通失败 +/// 处理(普通失败会诱导调用方"重试一次",把已提交的改名再走一遍)。 +/// 此时磁盘与 manifest 都在**新**名字上,同名重试会命中上面的 no-op 分支直接成功, +/// 所以调用方按"已经改名成功"刷新即可收敛。 pub(crate) fn rename_local_project_asset_at( root: &Path, + expected_project_id: &str, + expected_project_revision: u64, asset_id: &str, new_file_name: &str, - fault: Option, ) -> Result { let asset_id = asset_id.trim(); if asset_id.is_empty() { return Err("素材重命名 assetId 不能为空".to_string()); } let new_file_name = normalize_asset_file_name(new_file_name)?; + if expected_project_revision + > shared_contracts::game_creation_app::GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + { + return Err("expectedProjectRevision 超出 JavaScript 安全整数范围".to_string()); + } + let expected_project_id = expected_project_id.trim(); + if expected_project_id.is_empty() { + return Err("素材重命名 expectedProjectId 不能为空".to_string()); + } + if read_existing_manifest_for_project(root)?.project_id != expected_project_id { + return Err("project-identity-conflict".to_string()); + } let _lock = acquire_project_write_lock(root, "asset.register")?; + if read_existing_manifest_for_project(root)?.project_id != expected_project_id { + return Err("project-identity-conflict".to_string()); + } + if read_game_creator_agent_runtime_project_revision(root)?.revision != expected_project_revision + { + return Err("project-revision-conflict".to_string()); + } let manifest_path = root.join(".agent/manifest.json"); let mut manifest = read_existing_manifest_for_project(root)?; let index = manifest @@ -232,12 +290,13 @@ pub(crate) fn rename_local_project_asset_at( let asset = manifest.assets[index].clone(); // 文件已改名:从这里开始的任何失败都必须把文件改回原名。 - let write_error = match fault { - Some(RenameLocalProjectAssetFaultStage::ManifestWrite) => { - Some("fault-injected:rename-asset-manifest-write".to_string()) - } - None => write_manifest(&manifest_path, &manifest).err(), - }; + // 注入结果必须并进 `write_error`、不能就地 `?` 返回,否则会绕过下面的回滚。 + #[cfg(test)] + let injected_write_error = take_rename_manifest_write_failure_injection(root).err(); + #[cfg(not(test))] + let injected_write_error: Option = None; + let write_error = + injected_write_error.or_else(|| write_manifest(&manifest_path, &manifest).err()); if let Some(error) = write_error { return Err(rollback_asset_file_rename( ¤t_absolute, @@ -246,8 +305,13 @@ pub(crate) fn rename_local_project_asset_at( )); } - let committed_project_revision = advance_agent_runtime_project_revision_locked(root) - .map_err(|error| format!("素材已改名,但项目 revision 未能推进:{error}"))?; + let committed_project_revision = advance_agent_runtime_project_revision_locked(root).map_err( + |error| { + format!( + "reconciliation-required: 素材已改名为 {next_local_path} 且 manifest 已落盘,但项目 revision 未能推进:{error}" + ) + }, + )?; Ok(RenameLocalProjectAssetResult { asset, previous_local_path, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs b/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs index 2d98aa1b4..a2cc7952a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs @@ -981,9 +981,14 @@ fn read_persisted_local_conversation_records_unlocked( /// 只认 DirectProject 那一种明确枚举的行信封:`type=response_item` 且带 `payload`。 /// 其余任何形状都不算“对方的行”,仍由上方按坏行失败关闭。 +/// +/// 类型串引用写入侧的 `DIRECT_PROJECT_HISTORY_RECORD_TYPE`,不复制字面量:两边读写的 +/// 是同一份 `project.jsonl`,一旦只在写入侧改名,这里就会把每一行 DirectProject 记录 +/// 都判成坏行并以 `解析对话记录失败` 失败关闭,共享文件的读取整条断掉。 fn is_direct_project_history_row(line: &str) -> bool { serde_json::from_str::(line).is_ok_and(|parsed| { - parsed.get("type").and_then(serde_json::Value::as_str) == Some("response_item") + parsed.get("type").and_then(serde_json::Value::as_str) + == Some(crate::agent::DIRECT_PROJECT_HISTORY_RECORD_TYPE) && parsed.get("payload").is_some() }) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 39ed157a6..38518a7f7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -1108,7 +1108,8 @@ pub(crate) struct UpdateLocalProjectResourceClassificationResult { /// 只更新单个 manifest 资产条目的功能分类与自定义标签,保持条目字段构成与顺序不变。 /// `category` 必须是 6 个合法值之一,非法值直接失败、不回退到 `kind` 派生; -/// `tags` 按既有归一化语义 trim、去空、去重。 +/// `tags` 按既有归一化语义 trim、去空、去重,并在此处再按数量与单标签长度收口: +/// manifest 会被整份读、整份解析、整份重写,无界标签等于让后续每一次读写都为它付费。 pub(crate) fn update_manifest_asset_classification_at( root: &Path, expected_project_id: &str, @@ -1132,12 +1133,12 @@ pub(crate) fn update_manifest_asset_classification_at( } let category = game_creation_app_asset_category_from_str(category) .ok_or_else(|| format!("非法资源分类:{}", category.trim()))?; - let tags = normalize_game_creation_app_asset_tags(&tags); + let tags = normalize_manifest_asset_tags(&tags)?; if read_existing_manifest_for_project(root)?.project_id != expected_project_id { return Err("project-identity-conflict".to_string()); } - let _lock = acquire_project_write_lock(root, "asset.register")?; + let _lock = acquire_project_write_lock(root, "asset.classification.update")?; if read_existing_manifest_for_project(root)?.project_id != expected_project_id { return Err("project-identity-conflict".to_string()); } @@ -1164,19 +1165,26 @@ pub(crate) fn update_manifest_asset_classification_at( // 与 `assets.rs` 的 `asset.register` / `asset.update` 同一位置约定(manifest 写成功后追加)。 // 放在推进 revision 之前是刻意的——分类已经真实落盘,审计不能因为紧随其后的 revision 推进 // 失败而缺失,否则「改过但查不到」正是这条缺陷要修的东西;推进失败仍然照旧报错(见下方 map_err)。 - append_agent_db_record( - root, - serde_json::json!({ - "recordType": "asset.classification.update", - "assetId": asset.id, - "expectedProjectRevision": expected_project_revision, - "previousCategory": previous_category, - "previousTags": previous_tags, - "category": asset.category, - "tags": asset.tags, - }), - ) - .map_err(|error| format!("资源分类已写入,但审计记录失败:{error}"))?; + // + // 这条位置约定的代价是"重试会走到同一个值":上次调用可能已经写进同一份分类、只在推进 + // revision 时失败(revision 未变,所以重试会再次通过 CAS 并重新落到同一个值)。前后值完全 + // 相同的记录不是"从 X 改成 Y",重复追加只会污染这份审计要保住的语义,因此跳过。 + let classification_changed = previous_category != asset.category || previous_tags != asset.tags; + if classification_changed { + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "asset.classification.update", + "assetId": asset.id, + "expectedProjectRevision": expected_project_revision, + "previousCategory": previous_category, + "previousTags": previous_tags, + "category": asset.category, + "tags": asset.tags, + }), + ) + .map_err(|error| format!("资源分类已写入,但审计记录失败:{error}"))?; + } let committed_project_revision = advance_agent_runtime_project_revision_locked(root) .map_err(|error| format!("资源分类已写入,但项目 revision 未能推进:{error}"))?; Ok(UpdateLocalProjectResourceClassificationResult { @@ -1333,6 +1341,32 @@ fn normalize_manifest_task_id_list( Ok(output) } +/// 资源标签的持久化上界:manifest 每次写入都被整份序列化重写、每次读取都被整份解析, +/// 标签数量与单标签长度若无界,客户端就能让这份文件无限膨胀,并把成本摊到之后每一次读写上。 +const ASSET_CLASSIFICATION_MAX_TAGS: usize = 16; +const ASSET_CLASSIFICATION_MAX_TAG_CHARS: usize = 32; + +/// 复用共享契约的归一化(trim / 去空 / 去重),再按 manifest 自己的持久化上界失败关闭。 +/// +/// 这里刻意不做"超长就截断":截断后的标签与共享标签库里的同一条不再相等, +/// 用户看到的标签和实际存下来的标签会静默分叉,问题只是从"文件变大"换成"标签对不上"。 +fn normalize_manifest_asset_tags(tags: &[String]) -> Result, String> { + let normalized = normalize_game_creation_app_asset_tags(tags); + for tag in &normalized { + if tag.chars().count() > ASSET_CLASSIFICATION_MAX_TAG_CHARS { + return Err(format!( + "单个资源标签不能超过 {ASSET_CLASSIFICATION_MAX_TAG_CHARS} 个字符" + )); + } + } + if normalized.len() > ASSET_CLASSIFICATION_MAX_TAGS { + return Err(format!( + "资源标签最多支持 {ASSET_CLASSIFICATION_MAX_TAGS} 个" + )); + } + Ok(normalized) +} + pub(crate) fn read_or_create_manifest( root: &Path, ) -> Result<(PathBuf, GameCreationAppManifest), String> { @@ -1556,14 +1590,20 @@ fn remove_manifest_backup(path: &Path) -> Result<(), String> { /// 这条缓存只服务「这个资源是否已登记」这一层判断;它不放松 `file.read` 权限、项目边界、 /// 敏感路径、普通文件或读取漂移门禁 —— 那些门禁分别在 [`crate::commands::read_local_project_image_preview_at`] /// 与真实字节读取路径上执行,且真实读取从不经过本缓存。 +/// +/// 填充侧同样是"快照 → 读取 → 复核快照":读取前后文件身份一致才入缓存。 pub(crate) fn read_manifest_cached_for_preview( path: &Path, ) -> Result { if let Some(cached) = cached_preview_manifest_if_unchanged(path) { return Ok(cached); } + // **先取身份快照,再读内容**:反过来(先读内容、后取身份)时,读取与取身份之间的一次 + // 并发替换会让缓存里出现"旧内容 + 新文件身份",此后每个读取都会通过身份复核并一直返回 + // 旧 manifest,直到下一次显式失效。读完复核一次身份,只有两次快照一致才允许入缓存。 + let before = preview_manifest_snapshot(path); let manifest = read_manifest(path)?; - remember_preview_manifest(path, &manifest); + remember_preview_manifest_if_stable(path, before, &manifest); Ok(manifest) } @@ -1575,6 +1615,16 @@ struct CachedPreviewManifest { manifest: GameCreationAppManifest, } +/// 缓存命中判据里"这一份文件此刻是什么"的快照:长度 + 修改时间 + 句柄身份。 +/// +/// 阅读侧与写入侧共用同一个快照函数,保证"缓存的内容"与"快照的身份"永远是同一时刻取的。 +#[derive(Clone, Debug, Eq, PartialEq)] +struct PreviewManifestSnapshot { + len: u64, + modified: Option, + identity: Option<(u64, u64)>, +} + /// 缓存容量只需覆盖「同时打开的项目数」,取 8 已远大于实际并发。 const PREVIEW_MANIFEST_CACHE_LIMIT: usize = 8; @@ -1596,8 +1646,9 @@ fn forget_preview_manifest(path: &Path) { } } -fn cached_preview_manifest_if_unchanged(path: &Path) -> Option { - let key = path.to_string_lossy().into_owned(); +/// 取当前文件身份快照;路径不是普通文件(缺失、符号链接、目录、reparse point)时返回 `None`, +/// 表示"这份文件此刻不可缓存"。 +fn preview_manifest_snapshot(path: &Path) -> Option { let metadata = fs::symlink_metadata(path).ok()?; if metadata.file_type().is_symlink() || !metadata.is_file() { return None; @@ -1605,17 +1656,27 @@ fn cached_preview_manifest_if_unchanged(path: &Path) -> Option Option { + let key = path.to_string_lossy().into_owned(); + let snapshot = preview_manifest_snapshot(path)?; let mut cache = preview_manifest_cache().lock().ok()?; let index = cache .iter() .position(|(cached_key, _)| cached_key == &key)?; let cached = &cache[index].1; - if cached.len != len || cached.modified != modified || cached.identity != identity { + if cached.len != snapshot.len + || cached.modified != snapshot.modified + || cached.identity != snapshot.identity + { cache.remove(index); return None; } @@ -1626,17 +1687,27 @@ fn cached_preview_manifest_if_unchanged(path: &Path) -> Option, + manifest: &GameCreationAppManifest, +) { + let Some(before) = before else { return; }; - if metadata.file_type().is_symlink() || !metadata.is_file() { + if preview_manifest_snapshot(path).as_ref() != Some(&before) { return; } - let identity = fs::File::open(path) - .ok() - .and_then(|file| open_file_identity_key(&file).ok()); + remember_preview_manifest(path, before, manifest); +} + +fn remember_preview_manifest( + path: &Path, + snapshot: PreviewManifestSnapshot, + manifest: &GameCreationAppManifest, +) { + let key = path.to_string_lossy().into_owned(); let Ok(mut cache) = preview_manifest_cache().lock() else { return; }; @@ -1646,9 +1717,9 @@ fn remember_preview_manifest(path: &Path, manifest: &GameCreationAppManifest) { cache.push_back(( key, CachedPreviewManifest { - len: metadata.len(), - modified: metadata.modified().ok(), - identity, + len: snapshot.len, + modified: snapshot.modified, + identity: snapshot.identity, manifest: manifest.clone(), }, )); @@ -1841,11 +1912,9 @@ where } let _write_lock = acquire_manifest_write_lock(path)?; after_lock(); - let result = write_manifest_locked(path, manifest, allowed_version_removals); - if result.is_ok() { - forget_preview_manifest(path); - } - result + // 预览缓存的失效放在装盘公共体里(见 `write_manifest_locked_with_version_guard`), + // 这里不再单独调用一次:所有写入通道必须共用同一个失效点。 + write_manifest_locked(path, manifest, allowed_version_removals) } fn write_manifest_locked( @@ -1915,6 +1984,14 @@ fn write_manifest_locked_with_version_guard( "manifest 临时文件", )?; install_manifest_temp_with(path, &temp_path, |from, to| fs::rename(from, to))?; + // 文件已经被替换:从这一刻起缓存里的内容与磁盘不再对应,必须失效。 + // + // 失效点放在这个唯一的装盘公共体里,所有写入通道(`write_manifest`、`mutate_manifest_at`、 + // `mutate_manifest_at_allowing_version_removals`、`mutate_manifest_allowing_version_binding_rewrites`) + // 都覆盖到;命中判据只有长度 + mtime + 文件身份,兜不住同一 mtime 粒度内的同长度原地改写, + // 而 Windows 上某些卷的文件身份又是恒等常量。失败路径(安装后回读不一致)同样要失效 —— + // 那时文件也已经换过了,留着旧缓存才是错的。 + forget_preview_manifest(path); prepare_game_creator_private_path_for_read(path, false, "manifest")?; let installed = read_manifest(path)?; if installed != *manifest { @@ -1965,6 +2042,8 @@ mod classification_tests; #[cfg(test)] mod import_tests; #[cfg(test)] +mod preview_cache_tests; +#[cfg(test)] mod recovery_tests; #[cfg(test)] mod version_binding_rewrite_tests; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs index 2c62a6b5e..d66a0d736 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs @@ -420,6 +420,106 @@ fn asset_classification_rejections_do_not_append_agent_db_audit_records() { fs::remove_dir_all(root).ok(); } +/// 审计只记「真的改了什么」:把同一个分类与同一组标签再写一次不留记录。 +/// +/// 这条同时是「上一次已写入、只在推进 revision 时失败」那种中断态的重试语义: +/// 那次重试会重新落到同一个值,如果照旧追加,审计里就会出现一条 +/// `previousCategory == category` 的假变更记录。 +#[test] +fn asset_classification_repeat_write_does_not_append_a_noop_audit_record() { + let (root, asset_id) = project_with_two_assets("audit-idempotent"); + let project_id = read_existing_manifest_for_project(&root) + .expect("read manifest") + .project_id; + let revision_before = read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision before") + .revision; + + let first = update_manifest_asset_classification_at( + &root, + &project_id, + revision_before, + &asset_id, + "scene", + vec!["主舞台".to_string()], + ) + .expect("first classification update"); + assert_eq!(first.committed_project_revision, revision_before + 1); + assert_eq!(classification_audit_records(&root).len(), 1); + + let repeat = update_manifest_asset_classification_at( + &root, + &project_id, + first.committed_project_revision, + &asset_id, + "scene", + vec!["主舞台".to_string()], + ) + .expect("repeating the same classification must succeed"); + assert_eq!(repeat.committed_project_revision, revision_before + 2); + assert_eq!(repeat.asset.category, GameCreationAppAssetCategory::Scene); + assert_eq!(repeat.asset.tags, vec!["主舞台"]); + assert_eq!( + classification_audit_records(&root).len(), + 1, + "同一个值重复写入不得追加假变更审计" + ); + + fs::remove_dir_all(root).ok(); +} + +/// 标签必须在写入前有界:数量与单标签长度都要拒绝,manifest 与 revision 都不动。 +#[test] +fn asset_classification_rejects_unbounded_tags() { + let (root, asset_id) = project_with_two_assets("tag-bounds"); + let project_id = read_existing_manifest_for_project(&root) + .expect("read manifest") + .project_id; + let revision_before = read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision before") + .revision; + let manifest_before = read_existing_manifest_for_project(&root).expect("read manifest before"); + + let too_many = (0..17) + .map(|index| format!("标签{index}")) + .collect::>(); + let many_error = update_manifest_asset_classification_at( + &root, + &project_id, + revision_before, + &asset_id, + "scene", + too_many, + ) + .expect_err("too many tags must be rejected"); + assert!(many_error.contains("最多支持"), "unexpected: {many_error}"); + + let too_long = vec!["像".repeat(33)]; + let long_error = update_manifest_asset_classification_at( + &root, + &project_id, + revision_before, + &asset_id, + "scene", + too_long, + ) + .expect_err("an over-long tag must be rejected"); + assert!(long_error.contains("不能超过"), "unexpected: {long_error}"); + + assert_eq!( + read_existing_manifest_for_project(&root).expect("read manifest after"), + manifest_before + ); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision after") + .revision, + revision_before + ); + + fs::remove_dir_all(root).ok(); +} + /// 审计写失败必须可见:分类已经落盘的既定行为不变(不静默回滚),但命令要报错, /// 而且不能留下一条假的审计。 #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/preview_cache_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/preview_cache_tests.rs new file mode 100644 index 000000000..f2b98e721 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/preview_cache_tests.rs @@ -0,0 +1,165 @@ +use super::*; + +/// 独立的临时项目根:缓存是进程内全局状态,按路径做键,所以每个用例必须用自己的路径。 +fn preview_cache_test_root(test_name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "genarrative-manifest-preview-cache-{test_name}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )) +} + +fn preview_cache_manifest_path(root: &Path) -> PathBuf { + let path = root.join(".agent/manifest.json"); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create preview cache manifest directory"); + } + path +} + +/// 直接落盘一份 manifest 内容(不走 `write_manifest`,避免它自己带来的缓存失效干扰断言)。 +fn install_preview_cache_manifest(path: &Path, manifest: &GameCreationAppManifest) { + let payload = serde_json::to_string_pretty(manifest).expect("serialize preview cache manifest"); + fs::write(path, format!("{payload}\n")).expect("install preview cache manifest"); +} + +fn preview_cache_entry_count(path: &Path) -> usize { + let key = path.to_string_lossy().into_owned(); + preview_manifest_cache() + .lock() + .expect("lock preview manifest cache") + .iter() + .filter(|(cached_key, _)| cached_key == &key) + .count() +} + +/// 读取前后身份一致时正常入缓存并命中。 +#[test] +fn preview_cache_serves_a_repeated_read_from_the_snapshot() { + let root = preview_cache_test_root("hit"); + let path = preview_cache_manifest_path(&root); + let manifest = new_game_creation_app_manifest("preview-cache-project", "预览缓存项目"); + install_preview_cache_manifest(&path, &manifest); + + let first = read_manifest_cached_for_preview(&path).expect("first cached read"); + assert_eq!(first.project_id, "preview-cache-project"); + assert_eq!(preview_cache_entry_count(&path), 1); + + let second = read_manifest_cached_for_preview(&path).expect("second cached read"); + assert_eq!(second, first); + assert_eq!(preview_cache_entry_count(&path), 1); + + fs::remove_dir_all(root).ok(); +} + +/// 读取期间文件被并发替换(快照对不上)时不得入缓存: +/// 否则缓存里会出现"旧内容 + 新文件身份",之后每个读取都会通过身份复核并一直返回旧内容。 +#[test] +fn preview_cache_skips_insertion_when_the_file_changed_during_the_read() { + let root = preview_cache_test_root("toc-tou"); + let path = preview_cache_manifest_path(&root); + let stale = new_game_creation_app_manifest("preview-cache-stale", "旧内容"); + let fresh = new_game_creation_app_manifest("preview-cache-fresh", "新内容"); + install_preview_cache_manifest(&path, &stale); + + // 读之前取快照 ⇒ 这里模拟"读完之后、入缓存之前"文件被并发写者替换。 + // 生产写入是"临时文件 + rename",所以这里也用 rename:原地 `fs::write` 改写不会换文件身份, + // 长度与 mtime 又可能落在同一粒度内,快照根本区分不出来(那正是写入侧必须显式失效的原因)。 + let before = preview_manifest_snapshot(&path).expect("snapshot before read"); + let replacement_path = path.with_file_name(".manifest.json.concurrent"); + install_preview_cache_manifest(&replacement_path, &fresh); + fs::rename(&replacement_path, &path).expect("replace manifest concurrently"); + remember_preview_manifest_if_stable(&path, Some(before), &stale); + + assert_eq!( + preview_cache_entry_count(&path), + 0, + "读取期间文件变化时不得写入缓存" + ); + let read = read_manifest_cached_for_preview(&path).expect("read after replacement"); + assert_eq!(read.project_id, "preview-cache-fresh"); + + fs::remove_dir_all(root).ok(); +} + +/// 缓存命中判据里"文件身份"这一维必须真的在起作用: +/// 用同长度内容 + 原封不动的 mtime 走 rename 替换文件(长度与修改时间都对得上,只有身份变了), +/// 旧条目必须被丢弃并重新读取,否则缓存会一直返回替换前的 manifest。 +#[test] +fn preview_cache_drops_entries_whose_file_identity_changed() { + let root = preview_cache_test_root("identity-drift"); + let path = preview_cache_manifest_path(&root); + let stale = new_game_creation_app_manifest("preview-cache-before", "替换前"); + let fresh = new_game_creation_app_manifest("preview-cache-after-", "替换后"); + install_preview_cache_manifest(&path, &stale); + let cached = read_manifest_cached_for_preview(&path).expect("prime the cache"); + assert_eq!(cached.project_id, "preview-cache-before"); + + let original = fs::metadata(&path).expect("original manifest metadata"); + let replacement_path = path.with_file_name(".manifest.json.replacement"); + install_preview_cache_manifest(&replacement_path, &fresh); + assert_eq!( + fs::metadata(&replacement_path) + .expect("replacement metadata") + .len(), + original.len(), + "这条用例要求替换内容与原内容同长度" + ); + let replacement = fs::OpenOptions::new() + .write(true) + .open(&replacement_path) + .expect("open replacement manifest"); + replacement + .set_times(fs::FileTimes::new().set_modified(original.modified().expect("original mtime"))) + .expect("restore replacement mtime"); + drop(replacement); + fs::rename(&replacement_path, &path).expect("replace manifest by rename"); + + let read = read_manifest_cached_for_preview(&path).expect("read after external replacement"); + assert_eq!( + read.project_id, "preview-cache-after-", + "长度与修改时间都一致时,只有文件身份能拦住外部替换后的旧缓存" + ); + + fs::remove_dir_all(root).ok(); +} + +/// 主写入通道必须让预览缓存失效:`mutate_manifest_at_allowing_version_removals` +/// (素材登记 / 删除 / 分类更新 / 版本增删)走的是 `write_manifest_locked`, +/// 曾经是唯一不调用 `forget_preview_manifest` 的写入路径。 +#[test] +fn mutate_manifest_at_allowing_version_removals_invalidates_the_preview_cache() { + let root = preview_cache_test_root("invalidate"); + let path = preview_cache_manifest_path(&root); + let mut manifest = new_game_creation_app_manifest("preview-cache-mutate", "原始项目名"); + manifest.goal = Some("原始目标".to_string()); + install_preview_cache_manifest(&path, &manifest); + let cached = read_manifest_cached_for_preview(&path).expect("prime the cache"); + assert_eq!(cached.goal.as_deref(), Some("原始目标")); + assert_eq!(preview_cache_entry_count(&path), 1); + + // 同长度原地改写:命中判据里的长度不变,只有显式失效能保证不返回旧内容。 + mutate_manifest_at_allowing_version_removals(&root, &no_version_removals, |manifest| { + manifest.goal = Some("新目标值".to_string()); + Ok(()) + }) + .expect("mutate manifest"); + + assert_eq!( + preview_cache_entry_count(&path), + 0, + "写入通道必须显式失效预览缓存" + ); + + let read = read_manifest_cached_for_preview(&path).expect("read after mutation"); + assert_eq!( + read.goal.as_deref(), + Some("新目标值"), + "写入通道必须显式失效预览缓存" + ); + + fs::remove_dir_all(root).ok(); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index 160026329..b1f292976 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -1,4 +1,5 @@ use super::*; +use futures::StreamExt; use reqwest::multipart::{Form, Part}; use std::collections::BTreeMap; use std::path::PathBuf; @@ -2460,20 +2461,39 @@ fn is_external_resource_edit_endpoint(endpoint: &str) -> bool { /// 从服务端 4xx 响应体里取出**可读的失败原因**,让用户看到的不是笼统的 HTTP 状态码。 /// /// 平台错误体在不同路由上可能是 `{error:{message,details:{message}}}`、`{error:"文本"}`、 -/// `{details:{message}}` 或顶层 `{message}`。**`details.message` 优先**:平台把所有 4xx 都 +/// `{details:{message}}` 或顶层 `{message}`。**`error.details.message` 优先**:平台把所有 4xx 都 /// 兜底成同一条通用文案(`http_error.rs` 的 `resolve_http_error`),真实原因写在 /// `details.message` 里(例如「当前素材类型不支持图片快速编辑」),只读 `error.message` /// 会让用户永远看到「请求参数不合法」。 async fn editor_api_rejection_reason(response: reqwest::Response) -> Option { - let body = response.text().await.ok()?; + // 错误体来自外部编辑器端点,不能无上限地缓冲:有界的读法把"上游返回一个超大 4xx 正文" + // 变成"读不到可读原因",而不是让 Tauri 后端按对方给的长度分配。 + const MAX_EDITOR_ERROR_BODY_BYTES: usize = 64 * 1024; + let mut body = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.ok()?; + if body.len() + chunk.len() > MAX_EDITOR_ERROR_BODY_BYTES { + return None; + } + body.extend_from_slice(&chunk); + } + let body = String::from_utf8(body).ok()?; editor_api_rejection_reason_from_body(body.as_str()) } +/// 追加到用户可见原因里的外部字段上限:正文已经按 64KiB 收口,但单个字段仍可能把 +/// 「不让整段正文进 UI」的初衷抵消掉,所以每个追加项单独截断。 +const MAX_EDITOR_ERROR_DETAIL_CHARS: usize = 80; + fn editor_api_rejection_reason_from_body(body: &str) -> Option { let value: serde_json::Value = serde_json::from_str(body.trim()).ok()?; let error = value.get("error"); let details = value.get("details"); - let error_details = details.or_else(|| error.and_then(|error| error.get("details"))); + // 先 `error.details`、再顶层 `details`:与下面注释声明的优先级一致。 + // 反过来写会让同时带两种形状的响应体永远命中笼统的顶层 `details.message`, + // 真正可操作的原因(`error.details.message`)永远看不到。 + let error_details = error.and_then(|error| error.get("details")).or(details); // 取值顺序:`error.details.message` → `details.message` → `error.message` → `error` 文本 // → 顶层 `message`。前两者才是平台写进去的具体原因,后两者只是兜底。 let candidate = error_details @@ -2512,16 +2532,20 @@ fn editor_api_rejection_reason_from_body(body: &str) -> Option { .filter(|code| !code.is_empty() && *code != "BAD_REQUEST"); let mut reason: String = candidate.chars().take(200).collect(); if let Some(provider) = detail_text("provider") { - reason.push_str(format!("|provider {provider}").as_str()); + reason.push_str("|provider "); + reason.extend(provider.chars().take(MAX_EDITOR_ERROR_DETAIL_CHARS)); } if let Some(asset_kind) = detail_text("assetKind") { - reason.push_str(format!("|素材类型 {asset_kind}").as_str()); + reason.push_str("|素材类型 "); + reason.extend(asset_kind.chars().take(MAX_EDITOR_ERROR_DETAIL_CHARS)); } if let Some(media_type) = detail_text("mediaType") { - reason.push_str(format!("|媒体类型 {media_type}").as_str()); + reason.push_str("|媒体类型 "); + reason.extend(media_type.chars().take(MAX_EDITOR_ERROR_DETAIL_CHARS)); } if let Some(code) = code { - reason.push_str(format!("|错误码 {code}").as_str()); + reason.push_str("|错误码 "); + reason.extend(code.chars().take(MAX_EDITOR_ERROR_DETAIL_CHARS)); } Some(reason) } @@ -5612,6 +5636,55 @@ mod tests { assert_eq!(reason, "长".repeat(200)); } + /// 两种形状同时出现时 `error.details.message` 必须赢;每个透出字段都要单独有界。 + /// + /// 平台自身目前只发 `error.details`,所以这条是"代码与文档契约一致"的护栏: + /// 一旦取值顺序被写反,同时带顶层 `details` 的响应体就只剩笼统兜底文案。 + #[test] + fn editor_api_rejection_reason_prefers_nested_error_details_and_caps_every_field() { + assert_eq!( + editor_api_rejection_reason_from_body( + serde_json::json!({ + "error": { "details": { "message": "嵌套里的具体原因" } }, + "details": { "message": "顶层的通用文案" }, + "message": "更外层文案", + }) + .to_string() + .as_str(), + ), + Some("嵌套里的具体原因".to_string()), + "error.details.message 必须优先于顶层 details.message" + ); + + // 单个超长字段不能把"有界正文"的初衷抵消掉:provider / assetKind / mediaType / code + // 都按 80 字符截断,最终文案长度因此是常数级而不是随外部正文增长。 + let long = "超".repeat(500); + let reason = editor_api_rejection_reason_from_body( + serde_json::json!({ + "error": { + "code": long.clone(), + "details": { + "message": "具体原因", + "provider": long.clone(), + "assetKind": long.clone(), + "mediaType": long.clone(), + }, + } + }) + .to_string() + .as_str(), + ) + .expect("超长字段不应让原因缺失"); + let truncated = "超".repeat(80); + assert_eq!( + reason, + format!( + "具体原因|provider {truncated}|素材类型 {truncated}|媒体类型 {truncated}|错误码 {truncated}" + ), + "每个透出字段都必须按 80 字符截断" + ); + } + async fn with_test_external_editor_credentials( api_base_url: &str, api_key: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/asset_delete.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/asset_delete.rs index b5e4d3c06..90c1367c8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/asset_delete.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/asset_delete.rs @@ -354,7 +354,8 @@ fn version_removal_stays_rejected_outside_the_confirmed_asset_delete() { fs::remove_dir_all(root).ok(); } -/// 删除同样走 CAS:陈旧 revision、跨项目身份与不存在的素材都被拒绝,manifest 与 revision 不变。 +/// 删除同样走 CAS:陈旧 revision、跨项目身份都被拒绝,manifest 与 revision 不变; +/// 登记已经不在 manifest 里时按幂等 no-op 成功收敛(见下一条用例)。 #[test] fn asset_delete_enforces_revision_and_identity_cas() { let root = asset_delete_project_fixture(); @@ -371,14 +372,6 @@ fn asset_delete_enforces_revision_and_identity_cas() { .expect_err("cross-project identity must be rejected"); assert_eq!(identity_error, "project-identity-conflict"); - let missing_error = - delete_manifest_asset_at(&root, "project-1", revision_before, "absent", true) - .expect_err("missing asset must be rejected"); - assert!( - missing_error.contains("项目资源不存在"), - "unexpected error: {missing_error}" - ); - let manifest = read_manifest_for_project(&root).expect("read manifest after rejected deletes"); assert!( manifest.assets.iter().any(|asset| asset.id == asset_id), @@ -392,3 +385,39 @@ fn asset_delete_enforces_revision_and_identity_cas() { fs::remove_dir_all(root).ok(); } + +/// 「manifest 已提交、revision 未推进」那条中断路径必须能自愈: +/// 用同一个 `expectedProjectRevision` 重试要按 no-op 成功收敛,并补推进 revision。 +#[test] +fn asset_delete_retry_converges_when_only_the_revision_advance_failed() { + let root = asset_delete_project_fixture(); + let asset_id = register_asset_delete_fixture_asset(&root, "assets/hero.png", "art-asset-plan"); + let revision_before = project_revision(&root); + + delete_asset_at_current_revision(&root, &asset_id, false).expect("first delete commits"); + let committed = project_revision(&root); + assert_eq!(committed, revision_before + 1); + + // 复现中断态:登记已经删掉,但 revision 被写回旧值(等价于当年推进失败)。 + let mut revision = read_game_creator_agent_runtime_project_revision(&root).expect("read"); + revision.revision = revision_before; + write_game_creator_agent_runtime_project_revision(&root, &revision).expect("rewind revision"); + + let retry = delete_manifest_asset_at(&root, "project-1", revision_before, &asset_id, false) + .expect("retry must converge instead of reporting a missing asset"); + assert_eq!(retry.asset_id, asset_id); + assert_eq!(retry.committed_project_revision, revision_before + 1); + assert!(retry.file_retained); + assert_eq!( + project_revision(&root), + revision_before + 1, + "收敛路径必须补推进 revision,否则工作区会停在旧 revision" + ); + let manifest = read_manifest_for_project(&root).expect("read manifest after convergent retry"); + assert!( + !manifest.assets.iter().any(|asset| asset.id == asset_id), + "收敛路径不得把登记写回去" + ); + + fs::remove_dir_all(root).ok(); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/asset_rename.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/asset_rename.rs index 9242c4559..21764f49f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/asset_rename.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/asset_rename.rs @@ -48,6 +48,21 @@ fn asset_rename_revision(root: &Path) -> u64 { .revision } +/// 用当前 revision 发起重命名:模拟前端的正常调用(CAS 要求调用方带上它读到的 revision)。 +fn rename_asset_at_current_revision( + root: &Path, + asset_id: &str, + new_file_name: &str, +) -> Result { + rename_local_project_asset_at( + root, + "project-1", + asset_rename_revision(root), + asset_id, + new_file_name, + ) +} + /// 成功改名:磁盘文件真的换了名字,manifest 只改 `localPath`,资产 `id` 与其余字段不变, /// 项目 revision 前进一格。 #[test] @@ -56,8 +71,8 @@ fn rename_local_project_asset_renames_file_and_keeps_asset_identity() { let before = asset_rename_entry(&root, &asset_id); let revision_before = asset_rename_revision(&root); - let result = rename_local_project_asset_at(&root, &asset_id, "hero-idle.png", None) - .expect("rename asset"); + let result = + rename_asset_at_current_revision(&root, &asset_id, "hero-idle.png").expect("rename asset"); assert_eq!(result.previous_local_path, "assets/hero.png"); assert_eq!(result.asset.local_path, "assets/hero-idle.png"); @@ -93,7 +108,7 @@ fn rename_local_project_asset_is_a_noop_for_the_same_file_name() { let revision_before = asset_rename_revision(&root); let result = - rename_local_project_asset_at(&root, &asset_id, "hero.png", None).expect("no-op rename"); + rename_asset_at_current_revision(&root, &asset_id, "hero.png").expect("no-op rename"); assert_eq!(result.previous_local_path, "assets/hero.png"); assert_eq!(result.asset.local_path, "assets/hero.png"); @@ -125,7 +140,7 @@ fn rename_local_project_asset_rejects_unsafe_file_names() { "..png", "assets/../hero.png", ] { - let error = rename_local_project_asset_at(&root, &asset_id, name, None) + let error = rename_asset_at_current_revision(&root, &asset_id, name) .expect_err("unsafe file name must be rejected"); assert!( error.contains("不能为空") || error.contains("路径分隔符") || error.contains(".."), @@ -148,7 +163,7 @@ fn rename_local_project_asset_rejects_cross_directory_targets() { let (root, asset_id) = asset_rename_project_fixture(); fs::create_dir_all(root.join("assets/nested")).expect("nested dir"); - let error = rename_local_project_asset_at(&root, &asset_id, "nested/hero.png", None) + let error = rename_asset_at_current_revision(&root, &asset_id, "nested/hero.png") .expect_err("cross directory rename must be rejected"); assert!(error.contains("路径分隔符"), "意外错误:{error}"); @@ -168,7 +183,7 @@ fn rename_local_project_asset_rejects_extension_changes() { let (root, asset_id) = asset_rename_project_fixture(); for name in ["hero.jpg", "hero", "hero.png.bak"] { - let error = rename_local_project_asset_at(&root, &asset_id, name, None) + let error = rename_asset_at_current_revision(&root, &asset_id, name) .expect_err("extension change must be rejected"); assert!(error.contains("扩展名"), "意外错误:{error}"); } @@ -183,7 +198,7 @@ fn rename_local_project_asset_rejects_extension_changes() { asset_rename_source(), ) .expect("register extensionless asset"); - let error = rename_local_project_asset_at(&root, &extensionless.id, "notes.txt", None) + let error = rename_asset_at_current_revision(&root, &extensionless.id, "notes.txt") .expect_err("adding an extension must be rejected"); assert!(error.contains("扩展名"), "意外错误:{error}"); @@ -205,7 +220,7 @@ fn rename_local_project_asset_rejects_existing_target_name() { .expect("other asset file"); let manifest_before = fs::read(root.join(".agent/manifest.json")).expect("manifest bytes"); - let error = rename_local_project_asset_at(&root, &asset_id, "other.png", None) + let error = rename_asset_at_current_revision(&root, &asset_id, "other.png") .expect_err("existing target must be rejected"); assert!(error.contains("同名文件"), "意外错误:{error}"); @@ -230,12 +245,12 @@ fn rename_local_project_asset_rejects_existing_target_name() { fn rename_local_project_asset_requires_an_existing_asset_and_file() { let (root, asset_id) = asset_rename_project_fixture(); - let error = rename_local_project_asset_at(&root, "missing-asset", "hero-idle.png", None) + let error = rename_asset_at_current_revision(&root, "missing-asset", "hero-idle.png") .expect_err("unknown asset must be rejected"); assert!(error.contains("项目资源不存在"), "意外错误:{error}"); fs::remove_file(root.join("assets/hero.png")).expect("remove asset file"); - let error = rename_local_project_asset_at(&root, &asset_id, "hero-idle.png", None) + let error = rename_asset_at_current_revision(&root, &asset_id, "hero-idle.png") .expect_err("missing file must be rejected"); assert!(error.contains("素材文件不存在"), "意外错误:{error}"); assert!(!root.join("assets/hero-idle.png").exists()); @@ -253,14 +268,14 @@ fn rename_local_project_asset_rolls_back_the_file_when_manifest_write_fails() { let (root, asset_id) = asset_rename_project_fixture(); let manifest_before = fs::read(root.join(".agent/manifest.json")).expect("manifest bytes"); let revision_before = asset_rename_revision(&root); - - let error = rename_local_project_asset_at( - &root, - &asset_id, - "hero-idle.png", - Some(RenameLocalProjectAssetFaultStage::ManifestWrite), + fs::write( + root.join(".agent/runtime/test-fail-next-asset-rename-manifest-write"), + "注入下一次 manifest 写失败", ) - .expect_err("injected manifest write failure must surface"); + .expect("write rename manifest failure injection marker"); + + let error = rename_asset_at_current_revision(&root, &asset_id, "hero-idle.png") + .expect_err("injected manifest write failure must surface"); assert!(error.contains("fault-injected"), "意外错误:{error}"); assert_eq!( @@ -277,6 +292,101 @@ fn rename_local_project_asset_rolls_back_the_file_when_manifest_write_fails() { fs::remove_dir_all(root).ok(); } +/// 重命名同样走 CAS:陈旧 revision 与跨项目身份都被拒绝,磁盘、manifest 与 revision 都不动。 +#[test] +fn rename_local_project_asset_enforces_revision_and_identity_cas() { + let (root, asset_id) = asset_rename_project_fixture(); + let manifest_before = fs::read(root.join(".agent/manifest.json")).expect("manifest bytes"); + let revision_before = asset_rename_revision(&root); + + let revision_error = rename_local_project_asset_at( + &root, + "project-1", + revision_before + 1, + &asset_id, + "hero-idle.png", + ) + .expect_err("stale revision must be rejected"); + assert_eq!(revision_error, "project-revision-conflict"); + + let identity_error = rename_local_project_asset_at( + &root, + "project-2", + revision_before, + &asset_id, + "hero-idle.png", + ) + .expect_err("cross-project identity must be rejected"); + assert_eq!(identity_error, "project-identity-conflict"); + + let blank_identity_error = + rename_local_project_asset_at(&root, " ", revision_before, &asset_id, "hero-idle.png") + .expect_err("blank expectedProjectId must be rejected"); + assert!( + blank_identity_error.contains("不能为空"), + "{blank_identity_error}" + ); + + assert!(root.join("assets/hero.png").is_file()); + assert!(!root.join("assets/hero-idle.png").exists()); + assert_eq!( + fs::read(root.join(".agent/manifest.json")).expect("manifest bytes"), + manifest_before + ); + assert_eq!(asset_rename_revision(&root), revision_before); + + fs::remove_dir_all(root).ok(); +} + +/// `imageSequenceFrames` 的目录段按文件系统大小写语义比较:同一份文件写成别的目录大小写 +/// 也要被对齐(Windows / macOS 上 `Assets/hero.png` 与 `assets/hero.png` 是同一份文件)。 +#[test] +fn rename_local_project_asset_aligns_frames_with_case_insensitive_directory() { + let (root, asset_id) = asset_rename_project_fixture(); + mutate_manifest_at(&root, |manifest| { + let asset = manifest + .assets + .iter_mut() + .find(|asset| asset.id == asset_id) + .expect("asset entry"); + asset.image_sequence_frames = Some( + [ + ("Assets/hero.png", 32u32, 32u32), + ("other/hero.png", 64, 64), + ] + .into_iter() + .map( + |(image_src, width, height)| GameCreationAppImageSequenceFrame { + image_src: image_src.to_string(), + object_key: None, + asset_object_id: None, + width, + height, + }, + ) + .collect(), + ); + Ok(()) + }) + .expect("seed image sequence frames"); + + rename_asset_at_current_revision(&root, &asset_id, "hero-idle.png").expect("rename asset"); + + let frames = asset_rename_entry(&root, &asset_id) + .image_sequence_frames + .expect("image sequence frames"); + let expected = if cfg!(any(windows, target_os = "macos")) { + "assets/hero-idle.png" + } else { + "Assets/hero.png" + }; + assert_eq!(frames[0].image_src, expected); + // 指向别的目录的帧在任何平台都不动。 + assert_eq!(frames[1].image_src, "other/hero.png"); + + fs::remove_dir_all(root).ok(); +} + /// `imageSequenceFrames` 里指向被改名文件的本地帧对齐到新路径,指向别的文件或远程地址的帧不动。 #[test] fn rename_local_project_asset_aligns_image_sequence_frames_of_the_renamed_file() { @@ -309,7 +419,7 @@ fn rename_local_project_asset_aligns_image_sequence_frames_of_the_renamed_file() }) .expect("seed image sequence frames"); - rename_local_project_asset_at(&root, &asset_id, "hero-idle.png", None).expect("rename asset"); + rename_asset_at_current_revision(&root, &asset_id, "hero-idle.png").expect("rename asset"); let frames = asset_rename_entry(&root, &asset_id) .image_sequence_frames diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts index 186eae60d..2b737b484 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts @@ -37,6 +37,37 @@ export function isResourceCanvasInteractionTarget( return Boolean(target?.closest(RESOURCE_CANVAS_INTERACTION_SELECTOR)); } +/** + * 画布浮层里有自己滚动区的那几个:落在它们里面的滚轮归浮层,画布不得消费。 + * + * 词表与 `RESOURCE_CANVAS_INTERACTION_SELECTOR` 的浮层成员同一套类名,只去掉按钮 / 输入 / + * 资源卡这类本身不该吞滚轮的交互控件(在卡片上滚轮照旧平移 / 缩放画布): + * - `.image-canvas-editor__generation-composer`:快速编辑 / 角色动画浮层(自带提示词滚动区); + * - `.game-resource-info-panel`:资源信息浮层(`overflow: auto`); + * - `.game-resource-filter-panel`:筛选面板(卡片区 `overflow-y: auto`)。 + */ +export const RESOURCE_CANVAS_WHEEL_OVERLAY_SELECTOR = [ + '.image-canvas-editor__generation-composer', + '.game-resource-info-panel', + '.game-resource-filter-panel', +].join(', '); + +/** + * 这一次滚轮是不是落在「自己吃滚轮的画布浮层」里。 + * + * portal 到 `document.body` 的浮层(`@` 资源选择器与候选菜单、共享模型 / 比例弹出层…)由 + * `isResourceReferenceOverlayTarget` 与共享 hook 的 portal 弹层判据负责,这里只管留在画布 + * DOM 里的浮层。 + */ +export function isResourceCanvasWheelOverlayTarget( + target: EventTarget | null, +): boolean { + return ( + target instanceof Element && + target.closest(RESOURCE_CANVAS_WHEEL_OVERLAY_SELECTOR) !== null + ); +} + /** * 画布浮层是否可以被清焦点顺手关掉。 * diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 10f93b156..2fed9dc75 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -5768,31 +5768,45 @@ iframe.preview-frame { transition: none; } +/* 缩略卡悬停 / 聚焦时高亮它所在栏目(那一摞)的标题条。 + 取值必须与 `PROJECT_RESOURCE_CANVAS_SECTIONS`(7 个现行分区)逐一对齐: + `code` / `art` 是旧四、五栏目的历史值,总览里不会渲染,写它们等于空规则; + 少写一个现行分区,对应栏目就完全没有悬停高亮。 */ .game-resource-book-manager--main:has( - .game-resource-book-thumbnail[data-resource-book-category='code']:hover, - .game-resource-book-thumbnail[data-resource-book-category='code']:focus-visible + .game-resource-book-thumbnail[data-resource-book-category='ui-interaction']:hover, + .game-resource-book-thumbnail[data-resource-book-category='ui-interaction']:focus-visible ) - .game-resource-book-scene-titlebar[data-resource-book-category='code'], + .game-resource-book-scene-titlebar[data-resource-book-category='ui-interaction'], +.game-resource-book-manager--main:has( + .game-resource-book-thumbnail[data-resource-book-category='character']:hover, + .game-resource-book-thumbnail[data-resource-book-category='character']:focus-visible + ) + .game-resource-book-scene-titlebar[data-resource-book-category='character'], +.game-resource-book-manager--main:has( + .game-resource-book-thumbnail[data-resource-book-category='scene']:hover, + .game-resource-book-thumbnail[data-resource-book-category='scene']:focus-visible + ) + .game-resource-book-scene-titlebar[data-resource-book-category='scene'], +.game-resource-book-manager--main:has( + .game-resource-book-thumbnail[data-resource-book-category='audio']:hover, + .game-resource-book-thumbnail[data-resource-book-category='audio']:focus-visible + ) + .game-resource-book-scene-titlebar[data-resource-book-category='audio'], .game-resource-book-manager--main:has( .game-resource-book-thumbnail[data-resource-book-category='document']:hover, .game-resource-book-thumbnail[data-resource-book-category='document']:focus-visible ) .game-resource-book-scene-titlebar[data-resource-book-category='document'], +.game-resource-book-manager--main:has( + .game-resource-book-thumbnail[data-resource-book-category='unclassified']:hover, + .game-resource-book-thumbnail[data-resource-book-category='unclassified']:focus-visible + ) + .game-resource-book-scene-titlebar[data-resource-book-category='unclassified'], .game-resource-book-manager--main:has( .game-resource-book-thumbnail[data-resource-book-category='version']:hover, .game-resource-book-thumbnail[data-resource-book-category='version']:focus-visible ) - .game-resource-book-scene-titlebar[data-resource-book-category='version'], -.game-resource-book-manager--main:has( - .game-resource-book-thumbnail[data-resource-book-category='art']:hover, - .game-resource-book-thumbnail[data-resource-book-category='art']:focus-visible - ) - .game-resource-book-scene-titlebar[data-resource-book-category='art'], -.game-resource-book-manager--main:has( - .game-resource-book-thumbnail[data-resource-book-category='audio']:hover, - .game-resource-book-thumbnail[data-resource-book-category='audio']:focus-visible - ) - .game-resource-book-scene-titlebar[data-resource-book-category='audio'] { + .game-resource-book-scene-titlebar[data-resource-book-category='version'] { border-top-color: #cc8060; border-right-color: #cc8060; border-bottom-color: #ebd9cf; @@ -6902,10 +6916,13 @@ iframe.preview-frame { 0 0 0 2px rgb(216 115 66 / 24%); } +/* 卡片本体是 `border: 0`(下面那条基规则)。`border-color` 单独写没有意义——0 宽的边框 + 画不出颜色,所以选中 / 悬停 / 聚焦都必须写成完整的 `border`,否则这三个状态在视觉上 + 完全看不出来。宽度与圆角保持 1px / 12px,`box-sizing: border-box` 下不会改变卡片尺寸。 */ .game-resource-card:hover, .game-resource-card:focus-within, .game-resource-card.is-selected { - border-color: #d57b51; + border: 1px solid #d57b51; outline: 0; box-shadow: 0 8px 22px rgb(195 105 62 / 15%); } @@ -10418,13 +10435,18 @@ button.design-workspace-tree__entry:hover, box-shadow: none; } +/* direct-codex 输入区去默认灰描边:焦点可见性交给下面那条 + `.project-supervisor-composer .resource-reference-input:focus-within` + (`--platform-input-focus-ring` 光环)。这条规则比它多一个 `is-direct-codex` 类, + 优先级更高,所以**不能写 `box-shadow: none`**——写回去会把那条光环整条盖掉, + 而 base `.resource-reference-input-editor` 又是 `outline: 0`,输入区就完全没有 + 可见焦点了(无障碍回归)。 */ .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-composer.is-direct-codex .resource-reference-input:focus-within { border: 0; outline: none; - box-shadow: none; } .game-workbench-chat @@ -10704,18 +10726,19 @@ button.design-workspace-tree__entry:hover, line-height: 1.6; } -/* 陶泥儿输入区的操作排(`@` / AI 润色 / 恢复原文)回到文档流:`grid-column: 1 / -1` - 让它独占编辑器下面的一行,`align-items: end` + `justify-content: flex-end` 把它 - 贴到输入框右下角。之前这里用 `position: absolute; right: 48px; bottom: 10px` - 把整排从网格里摘出来浮在输入框中间,AI 润色因此看起来压在文本区里、和下面那排 - 主操作(`.project-supervisor-composer-controls` 的 `@` / 快速 / 发送)脱节。 +/* 陶泥儿输入区的操作排(`AI 润色` / `恢复原文`)回到文档流:网格第二行右侧那一列, + `justify-content: flex-end` 贴住输入框右下角。之前这里用 + `position: absolute; right: 48px; bottom: 10px` 把整排从网格里摘出来浮在输入框中间, + AI 润色因此看起来压在文本区里、和下面那排主操作(`.project-supervisor-composer-controls` + 的 `@` / 快速 / 发送)脱节。 不设 `min-height`:这一行的高度由 28px 方钮自己撑开,行高一旦被顶起来会连带把 编辑器的 `min-height: 96px` 改掉。 */ .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-composer .resource-reference-input-actions { - grid-column: 1 / -1; + grid-row: 2; + grid-column: 2; position: static; display: flex; align-items: end; @@ -10724,46 +10747,35 @@ button.design-workspace-tree__entry:hover, height: auto; } -/* 「润色中… / 润色失败」和应用排同一行:状态文字靠左占剩余宽度,操作排靠右。 - 单独占一行会把输入框整体顶高 22.8px,把操作排和下面那排主操作推开一个状态行的 - 距离;共行则状态出现/消失都不改变输入框高度。 */ +/* 「润色中… / 润色失败」和应用排**同一行**:状态文字在第二行左侧那一列(占剩余宽度), + 操作排在第二行右侧那一列。单独占一行会把输入框整体顶高一个状态行的距离;而给它 + `grid-column: 1 / -1` 又会和操作排落进同一个网格单元互相重叠(状态文字长时压到按钮下面)。 + 共行分列则状态出现/消失都不改变输入框高度,也不会互相压。 */ .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-composer .resource-reference-input-status { - grid-column: 1 / -1; grid-row: 2; + grid-column: 1; align-self: center; min-width: 0; margin-top: 0; } -.game-workbench-chat - .project-supervisor-surface.is-direct-codex - .project-supervisor-composer - .resource-reference-input-at { - position: static !important; - right: auto !important; - bottom: auto !important; - display: grid; - width: 30px; - min-width: 30px; - height: 30px; - min-height: 30px; - padding: 0; - border: 0; - border-radius: 9px; - background: var(--platform-button-primary-fill); - color: var(--platform-button-primary-text); - place-items: center; -} +/* direct-codex 里 `ResourceReferenceInput` 收到的是 `showTriggerButton={!directCodex}`, + 即输入框内的 `.resource-reference-input-at` 根本不会渲染(`@` 触发钮是下面控制排里的 + `.project-supervisor-reference-trigger`)。这里原本有一条针对它的 + `position: static !important` / 30px 主色方块规则,永远匹配不到,已删除; + 要改 direct-codex 的 `@` 触发钮外观请改 `.project-supervisor-reference-trigger`。 */ .design-agent-reasoning { margin: 8px 0; color: var(--text-muted); font-size: 0.82em; } -.design-agent-reasoning summary { cursor: pointer; } +.design-agent-reasoning summary { + cursor: pointer; +} .design-agent-reasoning pre { margin: 6px 0 0; white-space: pre-wrap; diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 1efd206bc..4a7be86a6 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -88,7 +88,10 @@ import { import { ImageCanvasProjectAssetPickerDialog } from '../../../../../src/components/image-editor/ImageCanvasProjectAssetPickerDialog'; import { ImageCanvasQuickEditPanelView } from '../../../../../src/components/image-editor/ImageCanvasQuickEditPanelView'; import { ImageCanvasSelectedLayerToolbarView } from '../../../../../src/components/image-editor/ImageCanvasSelectedLayerToolbarView'; -import { useImageCanvasFloatingOptionDismiss } from '../../../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss'; +import { + isFloatingOverlayWheelEvent, + useImageCanvasFloatingOptionDismiss, +} from '../../../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss'; import { DesignWorkspacePanel } from '../../features/project-workspace/DesignWorkspacePanel'; import { LocalGamePreviewFrame, @@ -116,6 +119,7 @@ import { import { canDismissResourceCanvasQuickEdit, isResourceCanvasInteractionTarget, + isResourceCanvasWheelOverlayTarget, resolveResourceCanvasFloatingPanelDismissOpen, resolveResourceCanvasFloatingPanelOpen, resolveResourceCanvasFocusEscapeActive, @@ -1123,6 +1127,23 @@ function ResourceBookTitleBar({ ); } +/** + * 画布滚轮归属的「已登记浮层」总表,与「点外部关闭」用的是同一套浮层口径: + * - `@` 资源选择器与候选菜单 portal 到 `document.body`; + * - 留在画布 DOM 里、自带滚动区的画布浮层(快速编辑 / 信息 / 筛选,见 + * `isResourceCanvasWheelOverlayTarget`)。 + * + * 落在它们里面的滚轮归浮层自己,画布不消费(不 preventDefault、不动视口)。 + */ +function isResourceCanvasFloatingOverlayWheelTarget( + target: EventTarget | null, +) { + return ( + isResourceReferenceOverlayTarget(target) || + isResourceCanvasWheelOverlayTarget(target) + ); +} + function ResourceBookScene({ plan, resourceBookState, @@ -4435,11 +4456,28 @@ export default function ProjectDevelopmentView({ const handleResourceBookWheel = useCallback( (event: ReactWheelEvent | WheelEvent) => { - resourceBookTransitionControllerRef.current.settle(); const sceneElement = resourceBookManagerRef.current; if (!sceneElement) { return; } + /* + 浮层优先:滚轮只在「这一下确实落在资源画布管理区里」时才归画布。 + + React 的 portal 事件沿 **React 树** 冒泡(React 把委托监听挂在 portal 容器 + `document.body` 上),所以 `@` 选择素材浮层里的滚轮也会走到场景根的 `onWheel` + 这里;而它在 DOM 上根本不在管理区里。判据与上面「点外部关闭」同一份 + (`isFloatingOverlayWheelEvent` = 浮层边界 + 已登记浮层):用户在选择器列表上 + 滚轮时,列表自己滚,背后的画布不再跟着平移 / 缩放。 + */ + if ( + isFloatingOverlayWheelEvent(event.target, { + boundaryRefs: [resourceBookManagerRef], + isInsideExtraOverlay: isResourceCanvasFloatingOverlayWheelTarget, + }) + ) { + return; + } + resourceBookTransitionControllerRef.current.settle(); event.preventDefault(); const rect = sceneElement.getBoundingClientRect(); const screenPoint = { diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx index 58c5ebf77..d02c743b5 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx @@ -893,6 +893,93 @@ describe('project resource live canvas integration', () => { expect(deriveCalls[0]?.prompt).toBe(expectedText); }); + /** + * 用户报的原始现象:在「快速编辑 → 插入素材引用」开出的选择器列表上滚鼠标滚轮, + * 滚的不是列表,而是背后的资源画布(画布跟着缩放 / 平移)。 + * + * 链路:选择器 portal 到 `document.body`,而 React 的 portal 事件沿 **React 树** 冒泡 + * (React 把委托监听挂在 portal 容器上),所以它的 wheel 照样走到画布场景根的 + * `onWheel`;修复前那一下会被画布消费掉。这里用真实事件序列钉住「浮层里的滚轮归浮层、 + * 画布视口一格不动」,同时用对照用例钉住「画布本体的滚轮照旧」。 + */ + it('在选择素材浮层里滚轮:浮层自己收到、画布视口不动,画布本体滚轮照旧', async () => { + installTauri(); + render(); + + fireEvent.click(screen.getByRole('button', { name: '打开待归类' })); + fireEvent.click(await findResourceSelectButton('source-art.png')); + const toolbar = await screen.findByRole('toolbar', { + name: '图片工具栏', + }); + fireEvent.click(within(toolbar).getByRole('button', { name: '快速编辑' })); + const panel = await screen.findByRole('dialog', { + name: '快速编辑图片', + }); + fireEvent.click( + within(panel).getByRole('button', { name: '插入素材引用' }), + ); + const picker = await screen.findByRole('dialog', { name: '选择素材' }); + + // 前提自检:选择器 DOM 上确实不在资源画本里(这正是 React 事件仍会冒泡到画布的原因)。 + const manager = document.querySelector('.game-resource-book-manager'); + expect(manager?.contains(picker)).toBe(false); + expect(picker.parentElement).toBe(document.body); + + const list = picker.querySelector('.resource-reference-picker-list'); + if (!list) throw new Error('missing picker list'); + const overlayWheelCalls = vi.fn(); + list.addEventListener('wheel', overlayWheelCalls); + + const readViewport = () => + document + .querySelector('[data-resource-viewport]') + ?.getAttribute('data-resource-viewport'); + const readSceneWorldTransform = () => + document + .querySelector('.game-resource-book-scene-world') + ?.getAttribute('style'); + const viewportBefore = readViewport(); + const sceneWorldBefore = readSceneWorldTransform(); + expect(viewportBefore).toBeTruthy(); + + // 真实事件序列:从浮层内部元素派发滚轮(等同用户在选择器列表上滚)。 + const overlayWheel = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaY: 240, + clientX: 80, + clientY: 60, + }); + act(() => { + list.dispatchEvent(overlayWheel); + }); + + // 浮层自己收到该事件、且没被画布消费(列表按原生行为滚动)。 + expect(overlayWheelCalls).toHaveBeenCalledTimes(1); + expect(overlayWheel.defaultPrevented).toBe(false); + // 画布视口一格不动。 + expect(readViewport()).toBe(viewportBefore); + expect(readSceneWorldTransform()).toBe(sceneWorldBefore); + + list.removeEventListener('wheel', overlayWheelCalls); + + // 对照用例:画布本体(场景根)上的滚轮必须照旧平移视口——修复没把画布交互一起关掉。 + const sceneRoot = document.querySelector('.game-resource-book-scene'); + if (!sceneRoot) throw new Error('missing scene root'); + const canvasWheel = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaY: 120, + clientX: 90, + clientY: 70, + }); + act(() => { + sceneRoot.dispatchEvent(canvasWheel); + }); + expect(canvasWheel.defaultPrevented).toBe(true); + expect(readViewport()).not.toBe(viewportBefore); + }); + it('creates a brand new media asset from the canvas generation entry with a create-mode derive request', async () => { const { deriveCalls } = installTauri({ failFirstDerive: true }); render(); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx index 2334a8470..3064b7f02 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx @@ -6,11 +6,15 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import type { QuickEditPanelState } from '../../../src/components/image-editor/ImageCanvasEditorTypes'; import { ImageCanvasQuickEditPanelView } from '../../../src/components/image-editor/ImageCanvasQuickEditPanelView'; -import { useImageCanvasFloatingOptionDismiss } from '../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss'; +import { + isFloatingOverlayWheelEvent, + useImageCanvasFloatingOptionDismiss, +} from '../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss'; import { canDismissResourceCanvasQuickEdit, isResourceCanvasHostOverlayOpen, isResourceCanvasInteractionTarget, + isResourceCanvasWheelOverlayTarget, resolveResourceCanvasFloatingPanelDismissOpen, resolveResourceCanvasFloatingPanelOpen, resolveResourceCanvasFocusEscapeActive, @@ -354,6 +358,69 @@ describe('资源画布浮层关闭时机', () => { }); }); +describe('画布滚轮归属判据', () => { + /** + * 与「点外部关闭」同一份口径:portal 出去的东西 DOM 上不在边界里,但 React 的事件会沿 + * React 树冒泡到画布宿主的 `onWheel`。滚轮这类事件必须按 DOM 判归属,否则用户在 + * `@` 选择器列表上滚动时,背后的画布会跟着平移 / 缩放。 + */ + test('portal 出去的浮层与已登记浮层归浮层,边界里的画布元素归画布', () => { + document.body.innerHTML = ` +
+
画布空白
+
共享弹出层选项
+
选择器条目
+
+
portal 到 body 的候选项
+ `; + const ownerRef = { current: document.querySelector('#owner') }; + const options = { + boundaryRefs: [ownerRef], + isInsideExtraOverlay: (target: EventTarget | null) => + target instanceof Element && + target.closest('.resource-reference-picker') !== null, + }; + const at = (selector: string) => + document.querySelector(selector); + + // 画布自己的:DOM 在边界里,也不是浮层。 + expect(isFloatingOverlayWheelEvent(at('#card-blank'), options)).toBe(false); + // portal 到 body 的浮层:DOM 根本不在边界里 ⇒ 归浮层。 + expect(isFloatingOverlayWheelEvent(at('#portal-item'), options)).toBe(true); + // 边界里但落在共享弹出层上:与「点外部关闭」同口径 ⇒ 归浮层。 + expect(isFloatingOverlayWheelEvent(at('#menu-item'), options)).toBe(true); + // 边界里但由宿主登记成浮层(`@` 选择器 / 候选菜单)⇒ 归浮层。 + expect(isFloatingOverlayWheelEvent(at('#picker-item'), options)).toBe(true); + // 判不出归属(没有事件目标)时不抢:归浮层。 + expect(isFloatingOverlayWheelEvent(null, options)).toBe(true); + + document.body.innerHTML = ''; + }); + + test('自带滚动区的画布浮层归浮层,资源卡与画布空白仍归画布', () => { + document.body.innerHTML = ` +
+
卡片
+
画布空白
+
快速编辑提示词
+
信息浮层
+
筛选面板
+
+ `; + const at = (selector: string) => + document.querySelector(selector); + + expect(isResourceCanvasWheelOverlayTarget(at('#card'))).toBe(false); + expect(isResourceCanvasWheelOverlayTarget(at('#blank'))).toBe(false); + expect(isResourceCanvasWheelOverlayTarget(at('#prompt'))).toBe(true); + expect(isResourceCanvasWheelOverlayTarget(at('#info'))).toBe(true); + expect(isResourceCanvasWheelOverlayTarget(at('#filter'))).toBe(true); + expect(isResourceCanvasWheelOverlayTarget(null)).toBe(false); + + document.body.innerHTML = ''; + }); +}); + /** 与 AGC 一样只给必要 props:下拉弹层就地渲染,不走 portal。 */ function QuickEditPanelHarness({ initialPanel, diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 39626df0b..b55af4ce4 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,15 @@ > 当前口径:本文件保留可复用的排障经验;历史条目的旧路由、旧版本和已删除文档仅作根因背景,不得据此恢复退役入口。当前命令、路由和 schema 以代码与 `docs/README.md` 为准。 +## 2026-09-12 画布滚轮要按 DOM 归属判定,portal 出去的浮层不能把滚轮让给画布 + +- **现象**:资源卡「快速编辑」里用 `@` 开出「选择素材」浮层后,在选择器列表上滚鼠标滚轮,列表自己在滚,背后的资源画布也一起平移 / 缩放(用户口语:「滚轮还是回滚到画布上」)。 +- **原因**:选择器与输入区候选菜单 `createPortal(..., document.body)`,DOM 上不在画布管理区里;但 React 的 portal 事件沿 **React 树** 冒泡(React 把委托监听挂在 portal 容器上),所以它们的 `wheel` 照样走到画布场景根的 `onWheel`,被当成画布手势消费。React 的 `wheel` 委托监听是 passive 的:`preventDefault()` 是空操作(只报 warning),真正出问题的是视口状态被改写——所以「事件没被 preventDefault」不能作为「画布没吃这一下」的判据。 +- **处理**:滚轮归属与「点外部关闭」共用同一份浮层口径(`src/components/image-editor/useImageCanvasFloatingOptionDismiss.ts`:`isEventInsideFloatingOverlay` + `isFloatingOverlayWheelEvent`)——DOM 不在宿主边界里的(portal 出去的一律算浮层)与已登记为浮层内部的都归浮层。资源画布只保留 `handleResourceBookWheel` 一处守卫,留在画布 DOM 里自带滚动区的浮层(快速编辑 / 信息 / 筛选,见 `RESOURCE_CANVAS_WHEEL_OVERLAY_SELECTOR`)按同一入口登记,不要再逐浮层加 `stopPropagation`。判据判不出归属时(没有元素目标)不抢滚轮。 +- **排查顺序**:先确认浮层是不是 portal 出去的;是的话不要先怀疑 CSS `overflow`、`overscroll-behavior` 或事件被 `preventDefault`,直接查画布宿主上的 `onWheel` / `onPointerDown` 有没有做 DOM 归属判断。 +- **验证**:`npm run test -- apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx`(判据单测,含 portal / 共享弹出层 / 已登记浮层三种来源与「判不出归属不抢」)与 `npm run test -- apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx`(真实事件序列:选择器列表里派发 `wheel` → 画布 `data-resource-viewport` 不变、浮层自己收到该事件;对照组:场景根上派发 `wheel` → 视口照旧变化)。 +- **关联**:`src/components/image-editor/useImageCanvasFloatingOptionDismiss.ts`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx`(`handleResourceBookWheel`)、`apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts` + ## 2026-09-12 策划项目重开前必须恢复运行模式 - 工作台不能只依赖创建时的内存 `startMode`:重开时丢失该值会挂载游戏资源画布,而对话恢复后又进入策划状态,造成左右区域不一致。 diff --git a/packages/shared/src/components/PlatformResourceFilterBar.test.tsx b/packages/shared/src/components/PlatformResourceFilterBar.test.tsx index f226662ff..b146e9810 100644 --- a/packages/shared/src/components/PlatformResourceFilterBar.test.tsx +++ b/packages/shared/src/components/PlatformResourceFilterBar.test.tsx @@ -6,7 +6,10 @@ import { resolve } from 'node:path'; import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, test, vi } from 'vitest'; -import { PlatformResourceFilterBar } from './PlatformResourceFilterBar'; +import { + PlatformResourceFilterBar, + type PlatformResourceFilterBarProps, +} from './PlatformResourceFilterBar'; import { PlatformSegmentedTabs } from './PlatformSegmentedTabs'; const CATEGORY_ITEMS = [ @@ -15,9 +18,10 @@ const CATEGORY_ITEMS = [ ]; /** - * 分段页签的子项宽度下限是「不叠字」的唯一判据:grid 的轨道按容器等分、scroll 的 - * flex 子项默认可收缩,两者都会把中文标签压到内容宽度以下,标签虽然 nowrap, - * 文字仍会画出自己的盒子压到相邻项上。这条规则一旦被删掉就要失败。 + * 分段页签子项的宽度下限只属于 `layout="scroll"`:那一条有 `overflow-x-auto` 做兜底, + * 宽度下限换来的是「不叠字」。grid 布局没有溢出容器、轨道是 `minmax(0, 1fr)`,同样的 + * `min-width: max-content` 只会把内容画到相邻格上。这两条声明级断言(jsdom 不计算外部 + * 样式表,可见性只能钉在声明与类名上)钉住「下限只挂在 --scroll 修饰类」。 */ function segmentedTabsStylesheet() { return readFileSync( @@ -110,6 +114,50 @@ describe('PlatformResourceFilterBar', () => { expect(screen.getByRole('button', { name: '全部' })).toBeTruthy(); }); + test('标签行是横向滚动容器且不画滚动条', () => { + render( + {}} + tagItems={[{ tag: '像素风', assetCount: 1 }]} + onToggleTag={() => {}} + />, + ); + + const row = screen.getByRole('group', { name: '素材筛选标签' }); + expect(row.className).toContain('platform-category-chip-scroll'); + // `platform-category-chip-scroll` 只提供 `overflow-x: auto`;不配 `scrollbar-hide` + // 它就会成为唯一的「看得见滚动条」的横向滚动容器。 + expect(row.className).toContain('scrollbar-hide'); + expect(segmentedTabsStylesheet()).toMatch( + /\.scrollbar-hide\s*\{[^}]*scrollbar-width:\s*none/s, + ); + }); + + test('没有 onToggleTag 时标签 chip 不得渲染成可点按钮', () => { + // 类型上 `tagItems` 必须带 `onToggleTag`(联合类型让这个非法状态不可表达); + // 这里刻意 `as unknown as` 绕过类型检查,模拟不带类型检查的调用方, + // 断言运行时兜底是「禁用」而不是「看起来可点、点了没反应」。 + const props = { + ariaLabel: '素材筛选', + categoryItems: CATEGORY_ITEMS, + activeCategoryId: 'all', + onCategoryChange: () => {}, + tagItems: [{ tag: '像素风', assetCount: 1 }], + activeTags: [], + } as unknown as PlatformResourceFilterBarProps<'all' | 'character'>; + + render(); + + const chip = screen.getByRole('button', { + name: /像素风/, + }) as HTMLButtonElement; + expect(chip.disabled).toBe(true); + expect(chip.getAttribute('aria-pressed')).toBe('false'); + }); + test('never lets a category chip shrink below its label width', () => { const { container } = render( { // 分类条是 scroll 布局:容器横向可滚,标签不换行。 const row = container.querySelector('.platform-segmented-tabs'); expect(row).not.toBeNull(); + expect(row?.className).toContain('platform-segmented-tabs--scroll'); expect(row?.className).toContain('overflow-x-auto'); for (const button of row?.querySelectorAll('button') ?? []) { expect(button.className).toContain('whitespace-nowrap'); @@ -130,11 +179,15 @@ describe('PlatformResourceFilterBar', () => { // 单靠 nowrap 不够:子项必须有不低于内容宽度的下限,否则文字会画出盒子压到相邻项。 expect(segmentedTabsStylesheet()).toMatch( + /\.platform-segmented-tabs--scroll\s*>\s*button\s*\{[^}]*min-width:\s*max-content/s, + ); + // 反过来,没有溢出容器的 grid 布局不得吃这条下限(否则内容会压到相邻格上)。 + expect(segmentedTabsStylesheet()).not.toMatch( /\.platform-segmented-tabs\s*>\s*button\s*\{[^}]*min-width:\s*max-content/s, ); }); - test('grid 布局的分段页签同样带宽度下限钩子', () => { + test('grid 布局的分段页签不带 scroll 修饰类,靠轨道等分收口', () => { render( { .closest('div'); expect(row?.className).toContain('platform-segmented-tabs'); expect(row?.className).toContain('grid-cols-2'); + // grid 分支没有溢出容器,宽度下限一旦回到这里就会压到相邻格(本轮 review 的回归点)。 + expect(row?.className).not.toContain('platform-segmented-tabs--scroll'); + expect(row?.className).not.toContain('overflow-x-auto'); }); }); diff --git a/packages/shared/src/components/PlatformResourceFilterBar.tsx b/packages/shared/src/components/PlatformResourceFilterBar.tsx index 7d24f96f8..a5b685b1c 100644 --- a/packages/shared/src/components/PlatformResourceFilterBar.tsx +++ b/packages/shared/src/components/PlatformResourceFilterBar.tsx @@ -21,7 +21,7 @@ export type PlatformResourceFilterSearch = { inputRef?: Ref; }; -export type PlatformResourceFilterBarProps = { +type PlatformResourceFilterBarBaseProps = { /** 控件组无障碍名称,例如「资源筛选」。 */ ariaLabel: string; /** 搜索行;宿主不需要搜索时省略即可,其余筛选照常渲染。 */ @@ -30,14 +30,29 @@ export type PlatformResourceFilterBarProps = { categoryItems: readonly PlatformResourceFilterOption[]; activeCategoryId: TId; onCategoryChange: (id: TId) => void; - /** 派生标签库;为空时不渲染标签行。 */ - tagItems?: readonly PlatformResourceTagOption[]; - /** 已选标签(多选叠加)。 */ - activeTags?: readonly string[]; - onToggleTag?: (tag: string) => void; className?: string; }; +/** + * `tagItems` 与 `onToggleTag` 是**成对的**:标签 chip 是带 `aria-pressed` 的按钮, + * 只给标签、不给处理函数就会渲染出一排「看起来可点、点了没反应」的控件。 + * 这里用联合类型让这个非法状态在编译期就不可表达;`Component` 内部再兜一层 + * `disabled`,供不带类型检查的调用方(JS / 动态构造的 props)也拿到诚实的行为。 + */ +export type PlatformResourceFilterBarProps = + | (PlatformResourceFilterBarBaseProps & { + tagItems?: undefined; + activeTags?: readonly string[]; + onToggleTag?: undefined; + }) + | (PlatformResourceFilterBarBaseProps & { + /** 派生标签库;为空数组时不渲染标签行。 */ + tagItems: readonly PlatformResourceTagOption[]; + /** 已选标签(多选叠加)。 */ + activeTags?: readonly string[]; + onToggleTag: (tag: string) => void; + }); + function tagChipClassName(active: boolean) { return [ 'platform-category-chip gap-1.5 px-2.5 text-xs font-bold', @@ -104,7 +119,7 @@ export function PlatformResourceFilterBar({ /> {tagOptions.length > 0 ? (
@@ -116,6 +131,9 @@ export function PlatformResourceFilterBar({ type="button" aria-pressed={active} className={tagChipClassName(active)} + // 类型上 `tagItems` 必然带 `onToggleTag`;这一层兜底是给不带类型检查的 + // 调用方(JS / 动态构造的 props):没有处理函数就不该渲染成可点按钮。 + disabled={!onToggleTag} onClick={() => onToggleTag?.(option.tag)} >