diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/mod.rs new file mode 100644 index 000000000..733fe7291 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/mod.rs @@ -0,0 +1 @@ +pub mod xhs_minitool; diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/commands.rs new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/commands.rs @@ -0,0 +1 @@ + diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/mod.rs new file mode 100644 index 000000000..1449482ff --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/mod.rs @@ -0,0 +1,17 @@ +pub mod commands; + +struct XHSMiniToolExportForm { + // max 14 unicode + name: String, + // max 14 unicode + introduction: String, + // png jpg jpeg, max 5M + icon_path: String, +} +const XHS_MINI_TOOL_CODE_ZIP_FILENAME: &str = "xhx-minitool.zip"; + +const XHS_MINI_TOOL_REGISTRY_FILENAME: &str = "xhx-minitool.json"; +const XHS_MINI_TOOL_EXPORT_VITE_SCRIPT: &str = "build-xhx-minitool"; +struct XHSMiniToolExportRegistry { + form: XHSMiniToolExportForm, +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/export/mod.rs new file mode 100644 index 000000000..9c02c65b2 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/mod.rs @@ -0,0 +1,20 @@ +//! 导出产物:把用户项目导出成第三方平台的制品。 +//! +//! 边界见 `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 的 +//! 2026-10-05 条目。一句话:宿主只做四件确定性的事——读写项目内 `.export/` 注册表、校验表单 +//! 与构建脚本是否存在、以既有 `command.exec` 边界运行项目自己的 npm 脚本、把产物交给用户。 +//! 适配与打包全部由 code agent 首次实验出来并固化成用户项目里的脚本;宿主不复刻平台规范、 +//! 不解析 zip 结构,也不替用户跑 `pack.mjs`。 + +pub mod draft; + +mod registry; + +pub(crate) use registry::{content_hash, load_or_create, save, Loaded, RegistryError}; + +/// 导出工作目录:项目根下的 `.export/`。 +/// +/// 全部导出目标共用一个 flat 目录:共享文件(例如多目标复用的同一个 icon)直接放这一层, +/// 目标专属文件挂 target 前缀(`xhs-minitool.json`、`xhs-minitool.zip`……)。整个目录排除出 +/// 项目快照——所以恢复或换机后适配成果不跟随,需要重新让 agent 适配。 +pub(crate) const EXPORT_WORK_RELATIVE_DIR: &str = ".export"; diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/registry.rs b/apps/ai-game-creator-shell/src-tauri/src/export/registry.rs new file mode 100644 index 000000000..ea13958ee --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/registry.rs @@ -0,0 +1,233 @@ +//! 导出注册表的**文件级**读写。 +//! +//! 这个模块只认「一个 JSON 文件 + 一个内容指纹」,不认任何 target:自动创建、严格解析、原子 +//! 写入与内容 hash 全部收在这里,目标模块只负责提供自己的 DTO 与路径。指纹是**原始文件字节** +//! 的 SHA-256,不写进文件——它是「有没有别人改过」的判据,不是持久化状态。 + +use std::fmt; +use std::fs; +use std::io::Write as _; +use std::path::{Path, PathBuf}; + +use serde::de::DeserializeOwned; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +/// 已读出的注册表值与它的内容指纹。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Loaded { + pub(crate) value: T, + /// 原始文件内容的指纹;保存时用它判断「读完之后有没有别人写过」。 + pub(crate) content_hash: String, +} + +/// 注册表文件级失败。目标模块负责把它映射成自己的 typed error。 +#[derive(Debug)] +pub(crate) enum RegistryError { + /// 路径不可读写(含父目录创建失败)。 + Io { path: PathBuf, cause: String }, + /// 文件内容不是本目标期望的形状。fail closed:不猜测、不修补。 + Malformed { path: PathBuf, cause: String }, + /// 宿主自己的 DTO 序列化失败,属于编程错误。 + Encode { cause: String }, +} + +impl fmt::Display for RegistryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io { path, cause } => { + write!(formatter, "注册表读写失败:{}:{cause}", path.display()) + } + Self::Malformed { path, cause } => { + write!( + formatter, + "注册表内容不符合约定:{}:{cause}", + path.display() + ) + } + Self::Encode { cause } => write!(formatter, "注册表序列化失败:{cause}"), + } + } +} + +/// 原始内容指纹(SHA-256,小写十六进制)。 +pub(crate) fn content_hash(text: &str) -> String { + format!("{:x}", Sha256::digest(text.as_bytes())) +} + +/// 读注册表;文件不存在时先用 `empty` 的规范序列化补一份空注册表,再读回来。 +/// +/// 「自动创建」是产品要求:面板一打开就要能显示空表单,而不是先报「文件不存在」。 +pub(crate) fn load_or_create(path: &Path, empty: &T) -> Result, RegistryError> +where + T: Serialize + DeserializeOwned, +{ + let text = match fs::read_to_string(path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let created = encode(empty)?; + write_atomically(path, &created)?; + created + } + Err(error) => { + return Err(RegistryError::Io { + path: path.to_path_buf(), + cause: error.to_string(), + }); + } + }; + let value = decode(path, &text)?; + Ok(Loaded { + value, + content_hash: content_hash(&text), + }) +} + +/// 以规范形式写回注册表,返回**新内容**的指纹。 +pub(crate) fn save(path: &Path, value: &T) -> Result { + let text = encode(value)?; + write_atomically(path, &text)?; + Ok(content_hash(&text)) +} + +/// 规范形式:两空格缩进 + 结尾换行。agent 会手改这个文件,人类可读比紧凑更重要。 +fn encode(value: &T) -> Result { + let mut text = serde_json::to_string_pretty(value).map_err(|error| RegistryError::Encode { + cause: error.to_string(), + })?; + text.push('\n'); + Ok(text) +} + +fn decode(path: &Path, text: &str) -> Result { + serde_json::from_str(text).map_err(|error| RegistryError::Malformed { + path: path.to_path_buf(), + cause: error.to_string(), + }) +} + +/// 先写同目录临时文件再原子替换。 +/// +/// 直接对目标 `File::create` 会在中途失败时同时留下半截文件和「原内容已被截断」两个后果; +/// manifest 与素材保存用的是同一套手法。 +fn write_atomically(path: &Path, text: &str) -> Result<(), RegistryError> { + let parent = path.parent().ok_or_else(|| RegistryError::Io { + path: path.to_path_buf(), + cause: "注册表路径缺少父目录".to_string(), + })?; + fs::create_dir_all(parent).map_err(|error| RegistryError::Io { + path: path.to_path_buf(), + cause: format!("创建导出目录失败:{error}"), + })?; + let temp = temp_path(path); + let write = || -> std::io::Result<()> { + let mut file = fs::File::create(&temp)?; + file.write_all(text.as_bytes())?; + // 替换前落盘:只 flush 的话,崩溃窗口里换过去的可能是还没写完的内容。 + file.sync_all() + }; + if let Err(error) = write() { + let _ = fs::remove_file(&temp); + return Err(RegistryError::Io { + path: path.to_path_buf(), + cause: error.to_string(), + }); + } + fs::rename(&temp, path).map_err(|error| { + let _ = fs::remove_file(&temp); + RegistryError::Io { + path: path.to_path_buf(), + cause: error.to_string(), + } + }) +} + +fn temp_path(path: &Path) -> PathBuf { + let file_name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "registry.json".to_string()); + path.with_file_name(format!(".{file_name}.{}.tmp", std::process::id())) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Deserialize; + + #[derive(Debug, Deserialize, PartialEq, Serialize)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + struct Fixture { + name: String, + } + + fn fixture_directory(prefix: &str) -> PathBuf { + let directory = std::env::temp_dir().join(format!( + "genarrative-export-registry-{prefix}-{}-{}", + std::process::id(), + NEXT_TEST_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + fs::create_dir_all(&directory).expect("create fixture directory"); + directory + } + + static NEXT_TEST_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + + #[test] + fn creates_empty_registry_and_keeps_its_hash_on_reread() { + let path = fixture_directory("create").join("target.json"); + let empty = Fixture { + name: String::new(), + }; + + let first = load_or_create(&path, &empty).expect("create registry"); + assert_eq!(first.value, empty); + assert_eq!( + fs::read_to_string(&path).unwrap(), + "{\n \"name\": \"\"\n}\n" + ); + + let second = load_or_create(&path, &empty).expect("reread registry"); + assert_eq!(second.content_hash, first.content_hash); + } + + #[test] + fn rejects_malformed_json_without_rewriting_it() { + let path = fixture_directory("malformed").join("target.json"); + fs::write(&path, "{ not json").unwrap(); + let empty = Fixture { + name: String::new(), + }; + + let error = load_or_create(&path, &empty).expect_err("malformed must fail closed"); + assert!(matches!(error, RegistryError::Malformed { .. })); + assert_eq!(fs::read_to_string(&path).unwrap(), "{ not json"); + } + + #[test] + fn rejects_unknown_fields_so_agent_drift_is_visible() { + let path = fixture_directory("unknown").join("target.json"); + fs::write(&path, "{\"name\":\"a\",\"status\":\"ready\"}\n").unwrap(); + let empty = Fixture { + name: String::new(), + }; + + let error = load_or_create(&path, &empty).expect_err("unknown field must fail"); + assert!(matches!(error, RegistryError::Malformed { .. })); + } + + #[test] + fn save_returns_the_hash_of_what_it_wrote() { + let path = fixture_directory("save") + .join(".export") + .join("target.json"); + let value = Fixture { + name: "小红书".to_string(), + }; + + let hash = save(&path, &value).expect("save registry"); + let loaded = load_or_create(&path, &value).expect("reload registry"); + assert_eq!(loaded.value, value); + assert_eq!(loaded.content_hash, hash); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 8db190f8d..a56425d58 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2130,6 +2130,7 @@ mod diagnostic_log_tests { } } +pub mod export; #[cfg(test)] mod tests; pub mod ui_editor;