From 6a507dc4ce423bcd6a760c96648bbe71280d4315 Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 24 Sep 2026 10:23:21 +0000 Subject: [PATCH] =?UTF-8?q?=E6=B8=85=E7=90=86=20AGC=20=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E7=BC=96=E8=AF=91=E6=AD=BB=E4=BB=A3=E7=A0=81=E5=91=8A=E8=AD=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 分离桌面启动与命令包装,按实际消费者限定编译范围 保留共享业务实现并补充设计会话、取消回合、UI 与音频派发定向回归 同步壳结构检查和编译告警保留边界文档 --- .../src/agent/codex_app_server/mod.rs | 112 ++++++++ .../src-tauri/src/agent/design_runtime.rs | 177 +++++++++++-- .../src-tauri/src/agent/design_tools.rs | 3 + .../src/agent/direct_codex_user_item/mod.rs | 6 +- .../src/agent/direct_project_history.rs | 1 + .../src/agent/direct_runtime/user_input.rs | 1 + .../src/agent/direct_thread_manager.rs | 1 + .../src-tauri/src/agent/direct_tool_bridge.rs | 1 + .../src-tauri/src/agent/direct_tools_mcp.rs | 19 +- .../agent/generation/loop_orchestration.rs | 1 + .../src-tauri/src/agent/runtime_driver.rs | 5 +- .../src/agent/runtime_driver/entrypoints.rs | 3 + .../src-tauri/src/agent/runtime_protocol.rs | 3 +- .../src-tauri/src/analytics/goal.rs | 17 +- .../src-tauri/src/analytics/goal/desktop.rs | 15 ++ .../src-tauri/src/analytics/gui.rs | 216 +--------------- .../src-tauri/src/analytics/gui/desktop.rs | 213 +++++++++++++++ .../src-tauri/src/analytics/upload.rs | 28 +- .../src-tauri/src/analytics/upload/desktop.rs | 23 ++ .../src-tauri/src/asset_generation_tasks.rs | 5 + .../src-tauri/src/assets.rs | 2 + .../src-tauri/src/client_extensions.rs | 154 +---------- .../src/client_extensions/desktop.rs | 150 +++++++++++ .../src-tauri/src/commands.rs | 26 -- .../src-tauri/src/commands/desktop.rs | 26 ++ .../src-tauri/src/config.rs | 16 +- .../src-tauri/src/editor_adapters.rs | 184 +------------ .../src-tauri/src/editor_adapters/startup.rs | 181 +++++++++++++ .../src-tauri/src/main.rs | 4 +- .../src-tauri/src/platform_session.rs | 229 +---------------- .../src-tauri/src/platform_session/startup.rs | 224 ++++++++++++++++ .../src-tauri/src/plugin_host.rs | 157 +++--------- .../src-tauri/src/plugin_host/desktop.rs | 126 +++++++++ .../src-tauri/src/preview.rs | 90 +------ .../src-tauri/src/preview/desktop.rs | 81 ++++++ .../src-tauri/src/process_session/model.rs | 2 + .../src-tauri/src/project/asset_rename.rs | 1 + .../src-tauri/src/project/manifest.rs | 1 + .../project/manifest/classification_tests.rs | 29 ++- .../src/project/resource_dependency_graph.rs | 3 + .../src-tauri/src/project/resource_editor.rs | 71 +++++ .../project/version_resource_replacement.rs | 2 + .../src-tauri/src/project_snapshot/desktop.rs | 242 ++++++++++++++++++ .../src-tauri/src/project_snapshot/index.rs | 6 - .../src/project_snapshot/lifecycle.rs | 91 +------ .../src-tauri/src/project_snapshot/mod.rs | 150 +---------- .../src-tauri/src/runner.rs | 29 ++- .../src-tauri/src/runner/client.rs | 15 +- .../src-tauri/src/runner/dispatch.rs | 68 +---- .../src-tauri/src/runner/dispatch/desktop.rs | 64 +++++ .../src-tauri/src/runner/endpoint.rs | 3 + .../src-tauri/src/runner/protocol.rs | 4 + .../src-tauri/src/runner/server.rs | 217 ++-------------- .../src-tauri/src/runner/server/desktop.rs | 200 +++++++++++++++ .../src-tauri/src/runner/state.rs | 9 +- .../src-tauri/src/template_library.rs | 166 +----------- .../src-tauri/src/template_library/desktop.rs | 164 ++++++++++++ .../src/ui_editor/commands/binding.rs | 1 + .../src-tauri/src/ui_editor/commands/merge.rs | 1 + .../src/ui_editor/commands/recognition.rs | 1 + .../src/ui_editor/commands/separation/mod.rs | 74 ++++++ .../commands/ui_design_suggestion.rs | 152 ++++++++++- ...发运维】本地开发验证与生产运维-2026-05-15.md | 8 + scripts/check-native-shells.mjs | 9 +- 64 files changed, 2508 insertions(+), 1775 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/analytics/goal/desktop.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/analytics/gui/desktop.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/analytics/upload/desktop.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/client_extensions/desktop.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/editor_adapters/startup.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/platform_session/startup.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/plugin_host/desktop.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/preview/desktop.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/project_snapshot/desktop.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/runner/dispatch/desktop.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/runner/server/desktop.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/template_library/desktop.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 26e0704e4..f00332809 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -5057,6 +5057,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( /// fresh isolated read-only workspace and a stable in-process thread so a /// normal conversation can continue without creating a project, assets, a /// preview, or a version record. +#[cfg(not(test))] pub(crate) async fn direct_game_creator_home_codex_chat( system_prompt: String, user_prompt: String, @@ -5261,6 +5262,117 @@ mod tests { assert!(table.select(&key, None).is_err()); } + fn exited_direct_turn_cancellation() -> Arc { + Arc::new(CodexTurnStartCancellation { + inner: Weak::new(), + thread_id: String::new(), + turn_id: std::sync::Mutex::new(None), + cancelled: AtomicBool::new(false), + interrupt_sent: AtomicBool::new(false), + execution: None, + }) + } + + #[test] + fn cancel_direct_codex_turn_rejects_another_turn_without_releasing_it() { + let root = tempfile::tempdir().expect("direct turn project"); + let invocation = crate::agent::DirectTaonierActiveInvocationGuard::enter( + root.path(), + "client-turn-current", + ) + .expect("active invocation"); + let cancellation = exited_direct_turn_cancellation(); + let key = direct_codex_active_turn_key(root.path()); + let _registered = register_active_direct_codex_turn( + key.clone(), + "client-turn-current", + Arc::clone(&cancellation), + ); + let thread_id = direct_thread_id_for_project(root.path()); + let subscription = crate::agent::subscribe_direct_thread(&thread_id); + + let error = cancel_direct_codex_turn_at(root.path(), Some("client-turn-other")) + .expect_err("another turn must not be interrupted or released"); + assert_eq!(error, DIRECT_CODEX_ANOTHER_TURN_RUNNING_MESSAGE); + assert!(!cancellation.cancelled.load(Ordering::Acquire)); + assert_eq!( + direct_codex_active_turns() + .lock() + .expect("active turn table") + .registered_client_turn_id(&key), + Some("client-turn-current") + ); + assert_eq!( + crate::agent::read_direct_taonier_active_invocation_at(root.path()) + .expect("read active invocation") + .expect("original turn remains active") + .client_turn_id, + "client-turn-current" + ); + assert!( + crate::agent::consume_direct_thread(&subscription.subscription_id) + .expect("consume turn events") + .events + .is_empty(), + "wrong-turn rejection must not publish a terminal event" + ); + drop(invocation); + } + + #[test] + fn cancel_direct_codex_turn_releases_exited_executor_and_completes_original_turn() { + let root = tempfile::tempdir().expect("direct turn project"); + let invocation = crate::agent::DirectTaonierActiveInvocationGuard::enter( + root.path(), + "client-turn-exited", + ) + .expect("active invocation"); + let _registered = register_active_direct_codex_turn( + direct_codex_active_turn_key(root.path()), + "client-turn-exited", + exited_direct_turn_cancellation(), + ); + let thread_id = direct_thread_id_for_project(root.path()); + let subscription = crate::agent::subscribe_direct_thread(&thread_id); + + let view = cancel_direct_codex_turn_at(root.path(), Some("client-turn-exited")) + .expect("exited executor releases the residual invocation"); + assert_eq!(view.outcome, DIRECT_TURN_CANCEL_OUTCOME_RELEASED); + assert_eq!(view.client_turn_id, "client-turn-exited"); + assert!(view.message.contains("执行进程已退出")); + assert_eq!( + crate::agent::read_direct_taonier_active_invocation_at(root.path()) + .expect("read released invocation"), + None + ); + let events = crate::agent::consume_direct_thread(&subscription.subscription_id) + .expect("consume terminal event") + .events; + assert!(matches!( + events.as_slice(), + [DirectThreadEvent::TurnCompleted { + status, + at: Some(_), + user_item_id: Some(user_item_id), + }] if status == "aborted" && user_item_id == "direct-codex:client-turn-exited:user" + )); + + let next = crate::agent::DirectTaonierActiveInvocationGuard::enter( + root.path(), + "client-turn-next", + ) + .expect("new turn can start after stale release"); + drop(invocation); + assert_eq!( + crate::agent::read_direct_taonier_active_invocation_at(root.path()) + .expect("read next invocation") + .expect("old guard must not clear next turn") + .client_turn_id, + "client-turn-next" + ); + drop(next); + } + /// 终止路径会从同步命令线程和 `Drop` 里派发 app-server 任务:那些线程没有 tokio /// runtime 上下文。`tokio::spawn` 在那里 panic,panic 跨不过 IPC 回调边界就把整个 /// 进程 abort(0xC0000409,"点终止就闪退")。这条用例把派发入口钉在没有 runtime diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs index e42722a1a..10585c471 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs @@ -8,6 +8,7 @@ use std::fs::File; use std::path::{Path, PathBuf}; use std::sync::OnceLock; use std::time::{Duration, Instant}; +#[cfg(not(test))] use tauri::Emitter; use uuid::Uuid; @@ -1401,12 +1402,23 @@ fn ensure_design_runtime_active(root: &Path) -> Result<(), String> { Ok(()) } +#[cfg(not(test))] #[tauri::command] pub(crate) async fn hydrate_design_agent_session( app: tauri::AppHandle, project_path: String, ) -> Result, String> { let root = Path::new(project_path.trim()); + hydrate_design_agent_session_at(root, move || { + DesignResources::new(resolve_design_resources_root(&app)?) + }) + .await +} + +async fn hydrate_design_agent_session_at( + root: &Path, + load_resources: impl FnOnce() -> Result + Send, +) -> Result, String> { enforce_project_permission_policy(root, "conversation.read")?; if read_design_runtime_mode(root)? .as_ref() @@ -1435,7 +1447,7 @@ pub(crate) async fn hydrate_design_agent_session( return Ok(Some(design_view(&session, true))); }; if design_session_has_uncertain_batch(&session) { - let resources = DesignResources::new(resolve_design_resources_root(&app)?)?; + let resources = load_resources()?; let view = recover_uncertain_design_batch(root, &resources, session, active).await?; return Ok(Some(view)); } @@ -1479,9 +1491,14 @@ fn quarantine_corrupted_design_session(root: &Path) -> Result<(), String> { std::fs::rename(&path, &backup).map_err(|error| format!("隔离损坏的策划会话失败:{error}")) } +#[cfg(not(test))] #[tauri::command] pub(crate) fn reset_design_agent_session(project_path: String) -> Result<(), String> { let root = Path::new(project_path.trim()); + reset_design_agent_session_at(root) +} + +fn reset_design_agent_session_at(root: &Path) -> Result<(), String> { enforce_project_permission_policy(root, "conversation.write")?; design_project_id(root)?; let path = crate::agent::runtime_protocol::design_session_path(root); @@ -1491,6 +1508,7 @@ pub(crate) fn reset_design_agent_session(project_path: String) -> Result<(), Str Ok(()) } +#[cfg(not(test))] #[tauri::command] pub(crate) fn get_design_agent_runtime_mode( project_path: String, @@ -1518,8 +1536,13 @@ fn design_debug_enabled() -> bool { == Some("1") } +#[cfg(not(test))] #[tauri::command] pub(crate) fn is_design_agent_debug_enabled() -> bool { + design_debug_command_enabled() +} + +fn design_debug_command_enabled() -> bool { cfg!(debug_assertions) && design_debug_enabled() } @@ -1543,16 +1566,27 @@ pub(crate) fn set_design_agent_runtime_mode( write_design_runtime_mode(root, active_runtime.trim()) } +#[cfg(not(test))] #[tauri::command] pub(crate) fn debug_fast_forward_design_session( app: tauri::AppHandle, project_path: String, target_phase: String, ) -> Result { - if !is_design_agent_debug_enabled() { + debug_fast_forward_design_session_at(Path::new(project_path.trim()), &target_phase, |event| { + app.emit("design-agent-update", event) + .map_err(|error| format!("刷新策划调试状态失败:{error}")) + }) +} + +fn debug_fast_forward_design_session_at( + root: &Path, + target_phase: &str, + emit: impl FnOnce(DesignEvent) -> Result<(), String>, +) -> Result { + if !design_debug_command_enabled() { return Err("策划 Agent 快速推进仅可用于 Debug 构建".to_string()); } - let root = Path::new(project_path.trim()); let project_id = design_project_id(root)?; let target_index = design_phase_index(target_phase.trim())?; let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( @@ -1600,21 +1634,18 @@ pub(crate) fn debug_fast_forward_design_session( session.updated_at = unix_timestamp(); write_design_session(root, &session)?; let mode = write_design_runtime_mode(root, "design")?; - app.emit( - "design-agent-update", - design_event( - root, - "debug-fast-forward", - "state", - None, - None, - Some(design_view(&session, false)), - ), - ) - .map_err(|error| format!("刷新策划调试状态失败:{error}"))?; + emit(design_event( + root, + "debug-fast-forward", + "state", + None, + None, + Some(design_view(&session, false)), + ))?; Ok(mode) } +#[cfg(not(test))] #[tauri::command] pub(crate) async fn continue_design_agent_session( app: tauri::AppHandle, @@ -1639,6 +1670,7 @@ pub(crate) async fn continue_design_agent_session( .await } +#[cfg(not(test))] #[tauri::command] pub(crate) async fn decide_design_phase( app: tauri::AppHandle, @@ -1665,6 +1697,7 @@ pub(crate) async fn decide_design_phase( .await } +#[cfg(not(test))] #[tauri::command] pub(crate) fn list_design_workspace( project_path: String, @@ -1675,6 +1708,7 @@ pub(crate) fn list_design_workspace( list_design_workspace_files(root) } +#[cfg(not(test))] #[tauri::command] pub(crate) fn read_design_workspace_file( project_path: String, @@ -3239,6 +3273,105 @@ mod tests { assert!(next.session.last_error.is_none()); } + #[tokio::test(flavor = "current_thread")] + async fn hydrate_quarantines_a_corrupted_session_without_loading_resources() { + let (_temp, root, _resources) = init_design_project(); + let path = crate::agent::runtime_protocol::design_session_path(&root); + fs::create_dir_all(path.parent().expect("session directory")).expect("create session dir"); + fs::write(&path, b"{broken").expect("write malformed session"); + + let view = hydrate_design_agent_session_at(&root, || -> Result { + panic!("corrupted session must not load resources") + }) + .await + .expect("quarantine malformed session"); + assert!(view.is_none()); + assert!(!path.exists()); + let backups = fs::read_dir(path.parent().expect("session directory")) + .expect("list session directory") + .map(|entry| entry.expect("session entry").path()) + .filter(|entry| { + entry + .file_name() + .is_some_and(|name| name.to_string_lossy().starts_with("session.corrupted.")) + }) + .collect::>(); + assert_eq!(backups.len(), 1); + assert_eq!( + fs::read(&backups[0]).expect("read quarantined session"), + b"{broken" + ); + } + + #[test] + fn reset_archives_the_existing_design_session() { + let (_temp, root, _resources) = init_design_project(); + let path = crate::agent::runtime_protocol::design_session_path(&root); + write_design_session(&root, &new_design_session("design-fake", "")) + .expect("write design session"); + + reset_design_agent_session_at(&root).expect("reset design session"); + assert!(!path.exists()); + let backups = fs::read_dir(path.parent().expect("session directory")) + .expect("list session directory") + .map(|entry| entry.expect("session entry").path()) + .filter(|entry| { + entry + .file_name() + .is_some_and(|name| name.to_string_lossy().starts_with("session.corrupted.")) + }) + .collect::>(); + assert_eq!(backups.len(), 1); + reset_design_agent_session_at(&root).expect("repeat reset without active session"); + } + + #[cfg(debug_assertions)] + #[test] + fn debug_fast_forward_checks_the_gate_and_persists_before_event_delivery() { + let (_temp, root, _resources) = init_design_project(); + let _debug_env = enable_design_debug_for_test(); + std::env::remove_var("GENARRATIVE_AGC_DESIGN_DEBUG"); + let error = debug_fast_forward_design_session_at(&root, "architecture", |_| { + panic!("debug gate must reject before emitting") + }) + .expect_err("debug gate should be closed"); + assert!(error.contains("Debug 构建")); + assert!(read_design_session(&root).expect("read session").is_none()); + + std::env::set_var("GENARRATIVE_AGC_DESIGN_DEBUG", "1"); + let existing = root.join("design_artifacts/project/00_concept/design.md"); + fs::create_dir_all(existing.parent().expect("artifact directory")) + .expect("create artifact directory"); + fs::write(&existing, "已有真实内容").expect("write existing artifact"); + let error = debug_fast_forward_design_session_at(&root, "architecture", |event| { + assert_eq!(event.kind, "state"); + assert_eq!(event.client_turn_id, "debug-fast-forward"); + Err("event delivery failed".to_string()) + }) + .expect_err("event failure should be reported"); + assert_eq!(error, "event delivery failed"); + assert_eq!( + fs::read_to_string(existing).expect("read existing artifact"), + "已有真实内容" + ); + assert!(root + .join("design_artifacts/project/02_architecture/design.md") + .is_file()); + let session = read_design_session(&root) + .expect("read fast-forwarded session") + .expect("fast-forwarded session"); + assert_eq!(session.current_phase, "architecture"); + assert_eq!(session.approved_phases, vec!["concept", "top_design"]); + assert!(session.pending_batch.is_none() && session.turn.is_none()); + assert_eq!( + read_design_runtime_mode(&root) + .expect("read runtime mode") + .expect("runtime mode") + .active_runtime, + "design" + ); + } + #[tokio::test(flavor = "current_thread")] async fn uncertain_batch_hydrate_continues_the_original_turn_without_replaying_file_tools() { let (_temp, root, resources) = init_design_project(); @@ -3294,16 +3427,10 @@ mod tests { ))], 0, ); - let view = recover_uncertain_design_batch(&root, &resources, session, { - try_open_game_creator_agent_runtime_task_lock_file( - &root, - ".agent/design-agent/active.lock", - ) - .expect("open active lock") - .expect("active lock is free") - }) - .await - .expect("recover uncertain batch"); + let view = hydrate_design_agent_session_at(&root, move || Ok(resources)) + .await + .expect("hydrate uncertain batch") + .expect("restored session"); assert!(!view.running); assert!(view.session.last_error.is_none()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs index 460ac0dad..6d6b410e3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs @@ -6,6 +6,7 @@ use std::collections::BTreeMap; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; +#[cfg(not(test))] use tauri::Manager; const DESIGN_WORKSPACE_ROOT: &str = "design_artifacts"; @@ -168,6 +169,7 @@ impl DesignResources { } } +#[cfg(not(test))] pub(crate) fn resolve_design_resources_root(app: &tauri::AppHandle) -> Result { let mut candidates = vec![PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("design-agent")]; if let Ok(dir) = app.path().resource_dir() { @@ -549,6 +551,7 @@ pub(crate) fn read_design_workspace_file_at(root: &Path, path: &str) -> Result Result, String> { Ok(read_direct_project_history_items_slice_at(root, DirectProjectHistoryAnchor::Newest, 1)?.3) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs index c29455565..281c9b0eb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -27,6 +27,7 @@ pub(crate) fn normalize_direct_client_turn_id( Ok(client_turn_id.to_string()) } +#[cfg(not(test))] #[tauri::command] pub(crate) async fn chat_with_game_creator_direct_codex( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index ee3cfcd41..9922afcad 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -369,6 +369,7 @@ fn global_direct_thread_manager() -> &'static Mutex { DIRECT_THREAD_MANAGER.get_or_init(|| Mutex::new(DirectThreadManager::new())) } +#[cfg(not(test))] pub(crate) fn set_direct_thread_manager_app_handle(app: tauri::AppHandle) { let _ = DIRECT_THREAD_MANAGER_APP_HANDLE.set(app); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 93b0d5741..af8afe32c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -3047,6 +3047,7 @@ pub(crate) async fn start_direct_tool_bridge( start_tool_bridge_for_source(root, controlled_web_search, true).await } +#[cfg(not(test))] pub(crate) async fn start_external_client_tool_bridge( root: &Path, controlled_web_search: bool, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 28c506608..9e354945c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -1,8 +1,13 @@ use super::*; -use axum::extract::{DefaultBodyLimit, State as AxumState}; +#[cfg(not(test))] +use axum::extract::DefaultBodyLimit; +use axum::extract::State as AxumState; use axum::http::{HeaderMap, StatusCode}; +#[cfg(not(test))] use axum::routing::post; -use axum::{Json, Router}; +use axum::Json; +#[cfg(not(test))] +use axum::Router; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; use std::io::{BufRead, BufReader, Write}; @@ -25,16 +30,19 @@ const EXTERNAL_MCP_RESPONSE_SUMMARY_MAX_CHARS: usize = 240; const EXTERNAL_MCP_JOURNAL_MAX_BYTES: u64 = 8 * 1024 * 1024; const EXTERNAL_MCP_JOURNAL_RELATIVE_PATH: &str = ".agent/conversations/codex-responses.jsonl"; static EXTERNAL_MCP_JOURNAL_LOCK: OnceLock> = OnceLock::new(); +#[cfg(not(test))] static EXTERNAL_MCP_SERVER: OnceLock>> = OnceLock::new(); tokio::task_local! { static EXTERNAL_MCP_BRIDGE_URL: String; } +#[cfg(not(test))] pub(crate) struct ExternalMcpServer { _bridge: super::direct_tool_bridge::DirectToolBridge, task: tokio::task::JoinHandle<()>, } +#[cfg(not(test))] impl Drop for ExternalMcpServer { fn drop(&mut self) { self.task.abort(); @@ -55,6 +63,7 @@ pub(crate) fn direct_tools_mcp_mode_requested(args: &[String]) -> bool { args == [DIRECT_TOOLS_MCP_MODE_FLAG] } +#[cfg(not(test))] pub(crate) fn run_direct_tools_mcp_if_requested(args: &[String]) -> Option { if !direct_tools_mcp_mode_requested(args) { return None; @@ -1977,6 +1986,7 @@ fn write_direct_tools_mcp_response( .map_err(|_| "写入 MCP 响应失败".to_string()) } +#[cfg(not(test))] fn read_direct_tools_mcp_requests( mut reader: impl BufRead, requests: tokio::sync::mpsc::Sender, String>>, @@ -2055,6 +2065,7 @@ where terminal_error.map_or(Ok(()), Err) } +#[cfg(not(test))] async fn run_direct_tools_mcp_stdio() -> Result<(), String> { let root = validate_direct_tools_project_root( &std::env::current_dir().map_err(|_| "读取 MCP 工作目录失败".to_string())?, @@ -2114,6 +2125,7 @@ async fn handle_external_mcp_http_request( Ok(Json(response)) } +#[cfg(not(test))] pub(crate) async fn start_external_mcp_loopback( root: &Path, controlled_web_search: bool, @@ -2162,6 +2174,7 @@ pub(crate) async fn start_external_mcp_loopback( Ok((url, token)) } +#[cfg(not(test))] pub(crate) fn stop_external_mcp_loopback() { if let Some(registry) = EXTERNAL_MCP_SERVER.get() { if let Ok(mut guard) = registry.lock() { @@ -2170,6 +2183,7 @@ pub(crate) fn stop_external_mcp_loopback() { } } +#[cfg(not(test))] #[tauri::command] pub(crate) async fn start_game_creator_external_mcp(project_path: String) -> Result { let root = Path::new(project_path.trim()); @@ -2178,6 +2192,7 @@ pub(crate) async fn start_game_creator_external_mcp(project_path: String) -> Res Ok(json!({ "url": url, "token": token, "transport": "streamable-http" })) } +#[cfg(not(test))] #[tauri::command] pub(crate) fn stop_game_creator_external_mcp() -> Result<(), String> { stop_external_mcp_loopback(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs index 72fa03629..ff5f56d55 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs @@ -6,6 +6,7 @@ pub(crate) struct AgentProgressEmitter<'a> { } impl<'a> AgentProgressEmitter<'a> { + #[cfg(not(test))] pub(crate) fn new(app: &'a tauri::AppHandle, project_path: &str) -> Self { Self { app, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index 6b4519eac..0eb5edfcf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -294,9 +294,12 @@ pub(crate) use entrypoints::{ game_creator_agent_runtime_update_event, generate_local_game_draft_at, read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at, register_game_creator_manifest_invalidation_event_sink, + validate_game_creator_manifest_invalidation_event_sink, +}; +#[cfg(not(test))] +pub(crate) use entrypoints::{ set_game_creator_agent_runtime_update_app_handle, start_game_creator_manifest_invalidation_event_sink, - validate_game_creator_manifest_invalidation_event_sink, }; #[cfg(test)] pub(crate) use finalization::resume_game_creator_agent_finalization_for_test_at; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index 0efa1d1ea..82a986d50 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs @@ -11,6 +11,7 @@ fn lock_game_creator_manifest_invalidation_event_sinks( .unwrap_or_else(|poisoned| poisoned.into_inner()) } +#[cfg(not(test))] pub(crate) fn set_game_creator_agent_runtime_update_app_handle(app: tauri::AppHandle) { let _ = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.set(app); } @@ -37,6 +38,7 @@ pub(crate) struct DirectGameCreatorTurnUpdateEmitter { } impl DirectGameCreatorTurnUpdateEmitter { + #[cfg(not(test))] pub(crate) fn new(root: &Path, turn_id: String) -> Self { Self { project_path: root.to_string_lossy().into_owned(), @@ -169,6 +171,7 @@ impl DirectGameCreatorTurnUpdateEmitter { } } +#[cfg(not(test))] pub(crate) fn start_game_creator_manifest_invalidation_event_sink( app: tauri::AppHandle, ) -> Result { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs index b889baafd..c0a90b032 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs @@ -89,6 +89,8 @@ pub(crate) use run_configuration::{ game_creator_agent_runtime_project_revision_path, game_creator_agent_runtime_run_profile_binding_path, }; +#[cfg(not(test))] +pub(crate) use steering::game_creator_agent_runtime_provider_request_count_for_roots; #[cfg(test)] pub(crate) use steering::{ acquire_game_creator_agent_runtime_steer_project_write_lock_with_wait, @@ -96,7 +98,6 @@ pub(crate) use steering::{ }; pub(crate) use steering::{ consume_game_creator_agent_runtime_steers, - game_creator_agent_runtime_provider_request_count_for_roots, interrupt_game_creator_agent_runtime_provider_request_at, interrupt_game_creator_agent_runtime_provider_requests_for_roots, register_game_creator_agent_runtime_provider_request, diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/goal.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/goal.rs index c01db32bd..546d01e81 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/analytics/goal.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/goal.rs @@ -114,19 +114,10 @@ pub(crate) fn created(writer: Option<&AnalyticsWriter>, root: &Path, project_id: } } -pub(super) fn retry_pending(writer: &AnalyticsWriter) { - let projects: Vec<_> = pending() - .lock() - .unwrap_or_else(|error| error.into_inner()) - .projects - .iter() - .cloned() - .collect(); - for (root, project_id) in projects { - // 投递失败不移除资格;后续真实受理仍可重试。 - writer.try_goal(Request::Created { root, project_id }); - } -} +#[cfg(not(test))] +mod desktop; +#[cfg(not(test))] +pub(super) use desktop::retry_pending; // 调用方已取得项目独立埋点锁;不得在文件 I/O 期间持有 pending 内存锁。 fn initialize_pending(root: &Path, project_id: &str, path: &Path) -> Result<(), String> { diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/goal/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/goal/desktop.rs new file mode 100644 index 000000000..200fbf448 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/goal/desktop.rs @@ -0,0 +1,15 @@ +use super::{pending, AnalyticsWriter, Request}; + +pub(crate) fn retry_pending(writer: &AnalyticsWriter) { + let projects: Vec<_> = pending() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .projects + .iter() + .cloned() + .collect(); + for (root, project_id) in projects { + // 投递失败不移除资格;后续真实受理仍可重试。 + writer.try_goal(Request::Created { root, project_id }); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/gui.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/gui.rs index 71e58c272..82383bf6c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/analytics/gui.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/gui.rs @@ -3,12 +3,15 @@ use super::contract::*; use super::session::{LifecycleState, SessionRecord}; use super::store::AnalyticsWriter; use std::collections::HashMap; -use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; use std::time::Instant; -use tauri::Manager; use uuid::Uuid; +#[cfg(not(test))] +mod desktop; +#[cfg(not(test))] +pub(crate) use desktop::*; + struct OpenProject { id: String, path: String, @@ -44,8 +47,6 @@ struct Service { } static GUI: OnceLock = OnceLock::new(); -// 仅串行化发布服务与退出;后台等待认证状态时不占此锁。 -static EXITING: Mutex = Mutex::new(false); #[cfg(test)] pub(crate) struct LifecycleFixture(GuiState); @@ -373,50 +374,6 @@ fn elapsed_ms(start: Instant) -> u64 { start.elapsed().as_millis().min(9_007_199_254_740_991) as u64 } -pub(crate) fn initialize(app: tauri::AppHandle, config_dir: PathBuf, version: String) { - tauri::async_runtime::spawn_blocking(move || { - crate::platform_session::initialize_analytics_identity(|route, sequence| { - let Ok(exiting) = EXITING.lock() else { - return; - }; - if !*exiting { - initialize_with_identity(config_dir, version, route, sequence); - } - }); - if let Some(gui) = GUI.get() { - super::goal::retry_pending(&gui.writer); - } - let handle = app.clone(); - let _ = app.run_on_main_thread(move || observe_windows(&handle, None, false, true)); - }); -} - -fn initialize_with_identity(config_dir: PathBuf, version: String, route: Route, sequence: u64) { - if GUI.get().is_some() { - return; - } - let session_id = Uuid::new_v4().to_string(); - let writer = AnalyticsWriter::start(config_dir.clone(), session_id.clone()); - let context = Context { - route, - editor_session_id: session_id.clone(), - client_version: version.clone(), - }; - let state = GuiState::new(context, sequence, writer.clone()); - if GUI - .set(Service { - session_id, - version, - writer, - state: Mutex::new(state), - }) - .is_ok() - { - with_state(|state| state.start()); - super::upload::start(config_dir); - } -} - fn with_state(f: impl FnOnce(&mut GuiState) -> T) -> Option { let gui = GUI.get()?; // 临界区只更新内存并 try_send,不做磁盘/网络操作,也不重新取得业务锁。 @@ -444,11 +401,6 @@ pub(crate) fn capture_writer_context() -> Option<(Context, AnalyticsWriter)> { Some((capture_analytics_context()?, GUI.get()?.writer.clone())) } -#[tauri::command] -pub(crate) fn settle_direct_run_analytics(attempt_id: String, discard: bool) { - super::run::settle(capture_writer_context(), &attempt_id, discard); -} - fn valid_context(context: &Context) -> bool { GUI.get().is_some_and(|gui| { context.editor_session_id == gui.session_id @@ -485,164 +437,6 @@ pub(crate) fn created( } } -#[tauri::command] -pub(crate) async fn record_analytics_project_open( - window: tauri::Window, - context: Context, - project_path: String, - operation_id: String, - open_source: OpenSource, - event_time: String, -) { - if !valid_context(&context) - || Uuid::parse_str(&operation_id).is_err() - || project_path.len() > 32768 - || !valid_time(&event_time) - { - return; - } - let label = window.label().to_string(); - if with_state(|state| { - state.reserve_open(&label, &operation_id, &project_path, &event_time); - }) - .is_none() - { - return; - } - // 目录/manifest 读取不占用 GUI 线程,也不会把读失败返回给业务操作。 - let _ = tauri::async_runtime::spawn_blocking(move || { - let Ok(root) = crate::validated_local_project_directory_path(&project_path) else { - return; - }; - let Ok(manifest) = crate::read_existing_manifest_for_project(&root) else { - return; - }; - let project = OpenProject { - id: manifest.project_id, - path: project_path, - operation_id, - }; - with_state(|state| state.project_opened(label, context, project, open_source, event_time)); - }) - .await; -} - -#[tauri::command] -pub(crate) fn record_analytics_project_leave(window: tauri::Window, project_path: String) { - with_state(|state| state.leave(window.label(), Some(&project_path))); -} - -#[tauri::command] -pub(crate) async fn record_analytics_ui_save( - context: Context, - project_path: String, - operation_id: String, - save_source: SaveSource, - changed: bool, - event_time: String, -) { - if !valid_context(&context) - || Uuid::parse_str(&operation_id).is_err() - || project_path.len() > 32768 - || !valid_time(&event_time) - || !matches!(save_source, SaveSource::Manual | SaveSource::Auto) - || (save_source == SaveSource::Auto && !changed) - { - return; - } - let Some(gui) = GUI.get() else { return }; - let writer = gui.writer.clone(); - let _ = tauri::async_runtime::spawn_blocking(move || { - let Ok(root) = crate::validated_local_project_directory_path(&project_path) else { - return; - }; - let Ok(manifest) = crate::read_existing_manifest_for_project(&root) else { - return; - }; - super::project::saved( - Some((context, writer)), - &manifest.project_id, - Source::UiEditor, - &operation_id, - ProjectSaved { - save_source, - revision_id: None, - }, - Some(&event_time), - ); - }) - .await; -} - -pub(crate) fn mark_restart() { - with_state(|state| state.restart = true); -} - -pub(crate) fn page_loading(window: &str) { - with_state(|state| state.leave(window, None)); -} - -pub(crate) fn exit() { - if let Ok(mut exiting) = EXITING.lock() { - *exiting = true; - } - with_state(GuiState::exit); -} - -pub(crate) fn observe_windows( - app: &tauri::AppHandle, - excluded: Option<&str>, - minimized: bool, - initial: bool, -) { - if GUI.get().is_none() { - return; - } - let mut focused = None; - let mut unknown = false; - for (label, window) in app.webview_windows() { - if excluded == Some(label.as_str()) { - continue; - } - match ( - window.is_focused(), - window.is_minimized(), - window.is_visible(), - ) { - (Ok(true), Ok(false), Ok(true)) => { - focused = Some(label); - break; - } - (Ok(_), Ok(_), Ok(_)) => {} - _ => unknown = true, - } - } - if focused.is_none() && unknown { - return; - } - with_state(|state| state.windows(focused, minimized, initial)); -} - -pub(crate) fn window_event(window: &tauri::Window, event: &tauri::WindowEvent) { - let destroyed = matches!(event, tauri::WindowEvent::Destroyed); - if destroyed { - with_state(|state| state.leave(window.label(), None)); - } - if destroyed - || matches!( - event, - tauri::WindowEvent::Focused(_) | tauri::WindowEvent::Resized(_) - ) - { - observe_windows( - window.app_handle(), - destroyed.then_some(window.label()), - window.is_minimized().unwrap_or(false), - false, - ); - } -} - #[cfg(test)] #[path = "gui_tests.rs"] mod tests; diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/gui/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/gui/desktop.rs new file mode 100644 index 000000000..1bed401d0 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/gui/desktop.rs @@ -0,0 +1,213 @@ +use super::*; +use std::path::PathBuf; +use tauri::Manager; + +// 仅串行化发布服务与退出;后台等待认证状态时不占此锁。 +static EXITING: Mutex = Mutex::new(false); + +pub(crate) fn initialize(app: tauri::AppHandle, config_dir: PathBuf, version: String) { + tauri::async_runtime::spawn_blocking(move || { + crate::platform_session::initialize_analytics_identity(|route, sequence| { + let Ok(exiting) = EXITING.lock() else { + return; + }; + if !*exiting { + initialize_with_identity(config_dir, version, route, sequence); + } + }); + if let Some(gui) = GUI.get() { + super::super::goal::retry_pending(&gui.writer); + } + let handle = app.clone(); + let _ = app.run_on_main_thread(move || observe_windows(&handle, None, false, true)); + }); +} + +fn initialize_with_identity(config_dir: PathBuf, version: String, route: Route, sequence: u64) { + if GUI.get().is_some() { + return; + } + let session_id = Uuid::new_v4().to_string(); + let writer = AnalyticsWriter::start(config_dir.clone(), session_id.clone()); + let context = Context { + route, + editor_session_id: session_id.clone(), + client_version: version.clone(), + }; + let state = GuiState::new(context, sequence, writer.clone()); + if GUI + .set(Service { + session_id, + version, + writer, + state: Mutex::new(state), + }) + .is_ok() + { + with_state(|state| state.start()); + super::super::upload::start(config_dir); + } +} + +#[tauri::command] +pub(crate) fn settle_direct_run_analytics(attempt_id: String, discard: bool) { + super::super::run::settle(capture_writer_context(), &attempt_id, discard); +} + +#[tauri::command] +pub(crate) async fn record_analytics_project_open( + window: tauri::Window, + context: Context, + project_path: String, + operation_id: String, + open_source: OpenSource, + event_time: String, +) { + if !valid_context(&context) + || Uuid::parse_str(&operation_id).is_err() + || project_path.len() > 32768 + || !valid_time(&event_time) + { + return; + } + let label = window.label().to_string(); + if with_state(|state| { + state.reserve_open(&label, &operation_id, &project_path, &event_time); + }) + .is_none() + { + return; + } + // 目录/manifest 读取不占用 GUI 线程,也不会把读失败返回给业务操作。 + let _ = tauri::async_runtime::spawn_blocking(move || { + let Ok(root) = crate::validated_local_project_directory_path(&project_path) else { + return; + }; + let Ok(manifest) = crate::read_existing_manifest_for_project(&root) else { + return; + }; + let project = OpenProject { + id: manifest.project_id, + path: project_path, + operation_id, + }; + with_state(|state| state.project_opened(label, context, project, open_source, event_time)); + }) + .await; +} + +#[tauri::command] +pub(crate) fn record_analytics_project_leave(window: tauri::Window, project_path: String) { + with_state(|state| state.leave(window.label(), Some(&project_path))); +} + +#[tauri::command] +pub(crate) async fn record_analytics_ui_save( + context: Context, + project_path: String, + operation_id: String, + save_source: SaveSource, + changed: bool, + event_time: String, +) { + if !valid_context(&context) + || Uuid::parse_str(&operation_id).is_err() + || project_path.len() > 32768 + || !valid_time(&event_time) + || !matches!(save_source, SaveSource::Manual | SaveSource::Auto) + || (save_source == SaveSource::Auto && !changed) + { + return; + } + let Some(gui) = GUI.get() else { return }; + let writer = gui.writer.clone(); + let _ = tauri::async_runtime::spawn_blocking(move || { + let Ok(root) = crate::validated_local_project_directory_path(&project_path) else { + return; + }; + let Ok(manifest) = crate::read_existing_manifest_for_project(&root) else { + return; + }; + super::super::project::saved( + Some((context, writer)), + &manifest.project_id, + Source::UiEditor, + &operation_id, + ProjectSaved { + save_source, + revision_id: None, + }, + Some(&event_time), + ); + }) + .await; +} + +pub(crate) fn mark_restart() { + with_state(|state| state.restart = true); +} + +pub(crate) fn page_loading(window: &str) { + with_state(|state| state.leave(window, None)); +} + +pub(crate) fn exit() { + if let Ok(mut exiting) = EXITING.lock() { + *exiting = true; + } + with_state(GuiState::exit); +} + +pub(crate) fn observe_windows( + app: &tauri::AppHandle, + excluded: Option<&str>, + minimized: bool, + initial: bool, +) { + if GUI.get().is_none() { + return; + } + let mut focused = None; + let mut unknown = false; + for (label, window) in app.webview_windows() { + if excluded == Some(label.as_str()) { + continue; + } + match ( + window.is_focused(), + window.is_minimized(), + window.is_visible(), + ) { + (Ok(true), Ok(false), Ok(true)) => { + focused = Some(label); + break; + } + (Ok(_), Ok(_), Ok(_)) => {} + _ => unknown = true, + } + } + if focused.is_none() && unknown { + return; + } + with_state(|state| state.windows(focused, minimized, initial)); +} + +pub(crate) fn window_event(window: &tauri::Window, event: &tauri::WindowEvent) { + let destroyed = matches!(event, tauri::WindowEvent::Destroyed); + if destroyed { + with_state(|state| state.leave(window.label(), None)); + } + if destroyed + || matches!( + event, + tauri::WindowEvent::Focused(_) | tauri::WindowEvent::Resized(_) + ) + { + observe_windows( + window.app_handle(), + destroyed.then_some(window.label()), + window.is_minimized().unwrap_or(false), + false, + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/upload.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/upload.rs index 8d6bda7b9..696dfdc41 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/analytics/upload.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/upload.rs @@ -2,7 +2,7 @@ use super::{contract::Route, store}; use crate::platform_session::{current_platform_session, PlatformSessionSnapshot}; use std::fs::{File, OpenOptions}; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant}; @@ -12,33 +12,17 @@ const CYCLE_BUDGET: Duration = Duration::from_secs(120); const MAX_BATCHES: usize = 20; static FAILURES: AtomicU64 = AtomicU64::new(0); +#[cfg(not(test))] +mod desktop; +#[cfg(not(test))] +pub(super) use desktop::start; + fn failure() { let _ = FAILURES.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |n| { Some(n.saturating_add(1)) }); } -pub(super) fn start(config_dir: PathBuf) { - tauri::async_runtime::spawn(async move { - let Ok(client) = reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .timeout(REQUEST_TIMEOUT) - .build() - else { - return; - }; - let root = config_dir.join("analytics"); - let mut next = tokio::time::Instant::now() + INTERVAL; - loop { - tokio::time::sleep_until(next).await; - // 休眠恢复只跑一次,下一轮从当前时间重新计时。 - if advance_schedule(tokio::time::Instant::now(), &mut next) { - cycle(&root, &client).await; - } - } - }); -} - fn advance_schedule(now: tokio::time::Instant, next: &mut tokio::time::Instant) -> bool { if now < *next { return false; diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/upload/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/upload/desktop.rs new file mode 100644 index 000000000..ef3ef0a47 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/upload/desktop.rs @@ -0,0 +1,23 @@ +use super::{advance_schedule, cycle, INTERVAL, REQUEST_TIMEOUT}; +use std::path::PathBuf; + +pub(crate) fn start(config_dir: PathBuf) { + tauri::async_runtime::spawn(async move { + let Ok(client) = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(REQUEST_TIMEOUT) + .build() + else { + return; + }; + let root = config_dir.join("analytics"); + let mut next = tokio::time::Instant::now() + INTERVAL; + loop { + tokio::time::sleep_until(next).await; + // 休眠恢复只跑一次,下一轮从当前时间重新计时。 + if advance_schedule(tokio::time::Instant::now(), &mut next) { + cycle(&root, &client).await; + } + } + }); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs index e76900c5b..85b969895 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs @@ -869,6 +869,11 @@ mod asset_generation_task_tests { assert_eq!(record.phase_detail, ASSET_GENERATION_TASK_PHASE_QUEUED); assert!(record.output_path.is_none()); assert_eq!(record.asset_name, "新背景音乐"); + assert_eq!(request.operation_id, record.task_id); + assert_eq!( + request.idempotency_key, + "9a1b2c3d-4e5f-4a1b-8c2d-3e4f5a6b7c8d" + ); assert_eq!(request.prompt, "一段平静的钢琴曲"); assert_eq!( request.edit_kind, 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 8bdca4c5f..e02bc6abb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -2026,6 +2026,7 @@ fn register_local_asset_entry_with_change( )) } +#[cfg(not(test))] #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct ReadLocalProjectAssetReferencesInput { @@ -2093,6 +2094,7 @@ pub(crate) fn read_manifest_asset_references_at( }) } +#[cfg(not(test))] #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct DeleteLocalProjectAssetInput { diff --git a/apps/ai-game-creator-shell/src-tauri/src/client_extensions.rs b/apps/ai-game-creator-shell/src-tauri/src/client_extensions.rs index 6047adc23..323ecc870 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/client_extensions.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/client_extensions.rs @@ -4,10 +4,13 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fs; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; -use tauri::Manager; -use tauri_plugin_dialog::DialogExt; use zip::ZipArchive; +#[cfg(not(test))] +mod desktop; +#[cfg(not(test))] +pub(crate) use desktop::*; + const CLIENT_EXTENSIONS_DIR_NAME: &str = "extensions"; const CLIENT_EXTENSIONS_INDEX_FILE_NAME: &str = "index.json"; const CLIENT_EXTENSIONS_SCHEMA_VERSION: &str = "direct-project-client-extensions.v1"; @@ -1284,84 +1287,6 @@ fn record_client_mcp_startup_status_at( }) } -#[tauri::command] -pub(crate) async fn pick_client_extension_file( - app: tauri::AppHandle, -) -> Result, String> { - let (sender, receiver) = tokio::sync::oneshot::channel(); - let mut dialog = app - .dialog() - .file() - .set_title("导入 Skill、MCP 或 Plugin 文件"); - if let Some(window) = app.get_webview_window("client") { - dialog = dialog.set_parent(&window); - } - dialog.pick_file(move |path| { - let _ = sender.send(path); - }); - let Some(path) = receiver - .await - .map_err(|_| "扩展文件选择器意外关闭".to_string())? - else { - return Ok(None); - }; - let path = path - .into_path() - .map_err(|error| format!("读取扩展文件失败:{error}"))?; - #[cfg(windows)] - crate::register_game_creator_user_selected_path(&path, false); - if let Err(error) = - crate::prepare_game_creator_user_selected_path_for_read(&path, false, "用户选择扩展文件") - { - #[cfg(windows)] - crate::revoke_game_creator_user_selected_path(&path); - return Err(error); - } - Ok(Some(path.to_string_lossy().into_owned())) -} - -#[tauri::command] -pub(crate) async fn pick_client_extension_directory( - app: tauri::AppHandle, -) -> Result, String> { - let (sender, receiver) = tokio::sync::oneshot::channel(); - let mut dialog = app - .dialog() - .file() - .set_title("导入 Skill、MCP 或 Plugin 目录"); - if let Some(window) = app.get_webview_window("client") { - dialog = dialog.set_parent(&window); - } - dialog.pick_folder(move |path| { - let _ = sender.send(path); - }); - let Some(path) = receiver - .await - .map_err(|_| "扩展目录选择器意外关闭".to_string())? - else { - return Ok(None); - }; - let path = path - .into_path() - .map_err(|error| format!("读取扩展目录失败:{error}"))?; - #[cfg(windows)] - crate::register_game_creator_user_selected_path(&path, true); - if let Err(error) = - crate::prepare_game_creator_user_selected_path_for_read(&path, true, "用户选择扩展目录") - { - #[cfg(windows)] - crate::revoke_game_creator_user_selected_path(&path); - return Err(error); - } - Ok(Some(path.to_string_lossy().into_owned())) -} - -#[tauri::command] -pub(crate) fn list_client_extensions() -> Result, String> { - let root = extensions_root()?; - list_client_extensions_at(&root) -} - pub(crate) fn list_client_extensions_at(root: &Path) -> Result, String> { read_client_extension_index_locked(&root, |_, index| { Ok(index @@ -1431,35 +1356,6 @@ fn extension_effectively_enabled(index: &ClientExtensionIndex, item: &StoredExte }) } -#[tauri::command] -pub(crate) fn import_client_extension( - source_path: String, - host: tauri::State<'_, crate::plugin_host::PluginHost>, -) -> Result { - let source = PathBuf::from(source_path.trim()); - if source.as_os_str().is_empty() { - return Err("扩展来源不能为空".to_string()); - } - if !source.is_absolute() { - return Err("扩展来源必须是绝对路径".to_string()); - } - let metadata = fs::metadata(&source) - .map_err(|error| format!("读取扩展来源失败:{}: {error}", source.display()))?; - if !metadata.is_file() && !metadata.is_dir() { - return Err("扩展来源必须是普通文件或目录".to_string()); - } - crate::prepare_game_creator_user_selected_path_for_read( - &source, - metadata.is_dir(), - "客户端扩展来源", - )?; - - let root = extensions_root()?; - let result = import_client_extension_at(&root, &source, &metadata)?; - host.refresh()?; - Ok(result) -} - pub(crate) fn import_client_extension_at( root: &Path, source: &Path, @@ -1581,18 +1477,6 @@ pub(crate) fn import_client_extension_at( result } -#[tauri::command] -pub(crate) fn set_client_extension_enabled( - id: String, - enabled: bool, - host: tauri::State<'_, crate::plugin_host::PluginHost>, -) -> Result { - let root = extensions_root()?; - let item = set_client_extension_enabled_at(&root, &id, enabled)?; - host.refresh()?; - Ok(item) -} - fn set_client_extension_enabled_at( root: &Path, id: &str, @@ -1627,23 +1511,6 @@ fn set_client_extension_enabled_at( }) } -#[tauri::command] -pub(crate) fn rename_client_extension( - id: String, - name: String, - host: tauri::State<'_, crate::plugin_host::PluginHost>, -) -> Result { - let requested = name.trim(); - if requested.is_empty() { - return Err("扩展名称不能为空".to_string()); - } - let normalized = native_name(requested); - let root = extensions_root()?; - let item = rename_client_extension_at(&root, &id, &normalized)?; - host.refresh()?; - Ok(item) -} - fn rename_client_extension_at( root: &Path, id: &str, @@ -1691,17 +1558,6 @@ fn rename_client_extension_at( }) } -#[tauri::command] -pub(crate) fn remove_client_extension( - id: String, - host: tauri::State<'_, crate::plugin_host::PluginHost>, -) -> Result<(), String> { - let root = extensions_root()?; - remove_client_extension_at(&root, &id)?; - host.refresh()?; - Ok(()) -} - fn remove_client_extension_at(root: &Path, id: &str) -> Result<(), String> { invalidate_client_mcp_connection(id.trim())?; update_client_extension_index_at(root, |_, index| { diff --git a/apps/ai-game-creator-shell/src-tauri/src/client_extensions/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/client_extensions/desktop.rs new file mode 100644 index 000000000..e51d73f60 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/client_extensions/desktop.rs @@ -0,0 +1,150 @@ +use super::*; +use tauri::Manager; +use tauri_plugin_dialog::DialogExt; + +#[tauri::command] +pub(crate) async fn pick_client_extension_file( + app: tauri::AppHandle, +) -> Result, String> { + let (sender, receiver) = tokio::sync::oneshot::channel(); + let mut dialog = app + .dialog() + .file() + .set_title("导入 Skill、MCP 或 Plugin 文件"); + if let Some(window) = app.get_webview_window("client") { + dialog = dialog.set_parent(&window); + } + dialog.pick_file(move |path| { + let _ = sender.send(path); + }); + let Some(path) = receiver + .await + .map_err(|_| "扩展文件选择器意外关闭".to_string())? + else { + return Ok(None); + }; + let path = path + .into_path() + .map_err(|error| format!("读取扩展文件失败:{error}"))?; + #[cfg(windows)] + crate::register_game_creator_user_selected_path(&path, false); + if let Err(error) = + crate::prepare_game_creator_user_selected_path_for_read(&path, false, "用户选择扩展文件") + { + #[cfg(windows)] + crate::revoke_game_creator_user_selected_path(&path); + return Err(error); + } + Ok(Some(path.to_string_lossy().into_owned())) +} + +#[tauri::command] +pub(crate) async fn pick_client_extension_directory( + app: tauri::AppHandle, +) -> Result, String> { + let (sender, receiver) = tokio::sync::oneshot::channel(); + let mut dialog = app + .dialog() + .file() + .set_title("导入 Skill、MCP 或 Plugin 目录"); + if let Some(window) = app.get_webview_window("client") { + dialog = dialog.set_parent(&window); + } + dialog.pick_folder(move |path| { + let _ = sender.send(path); + }); + let Some(path) = receiver + .await + .map_err(|_| "扩展目录选择器意外关闭".to_string())? + else { + return Ok(None); + }; + let path = path + .into_path() + .map_err(|error| format!("读取扩展目录失败:{error}"))?; + #[cfg(windows)] + crate::register_game_creator_user_selected_path(&path, true); + if let Err(error) = + crate::prepare_game_creator_user_selected_path_for_read(&path, true, "用户选择扩展目录") + { + #[cfg(windows)] + crate::revoke_game_creator_user_selected_path(&path); + return Err(error); + } + Ok(Some(path.to_string_lossy().into_owned())) +} + +#[tauri::command] +pub(crate) fn list_client_extensions() -> Result, String> { + let root = extensions_root()?; + list_client_extensions_at(&root) +} + +#[tauri::command] +pub(crate) fn import_client_extension( + source_path: String, + host: tauri::State<'_, crate::plugin_host::PluginHost>, +) -> Result { + let source = PathBuf::from(source_path.trim()); + if source.as_os_str().is_empty() { + return Err("扩展来源不能为空".to_string()); + } + if !source.is_absolute() { + return Err("扩展来源必须是绝对路径".to_string()); + } + let metadata = fs::metadata(&source) + .map_err(|error| format!("读取扩展来源失败:{}: {error}", source.display()))?; + if !metadata.is_file() && !metadata.is_dir() { + return Err("扩展来源必须是普通文件或目录".to_string()); + } + crate::prepare_game_creator_user_selected_path_for_read( + &source, + metadata.is_dir(), + "客户端扩展来源", + )?; + + let root = extensions_root()?; + let result = import_client_extension_at(&root, &source, &metadata)?; + host.refresh()?; + Ok(result) +} + +#[tauri::command] +pub(crate) fn set_client_extension_enabled( + id: String, + enabled: bool, + host: tauri::State<'_, crate::plugin_host::PluginHost>, +) -> Result { + let root = extensions_root()?; + let item = set_client_extension_enabled_at(&root, &id, enabled)?; + host.refresh()?; + Ok(item) +} + +#[tauri::command] +pub(crate) fn rename_client_extension( + id: String, + name: String, + host: tauri::State<'_, crate::plugin_host::PluginHost>, +) -> Result { + let requested = name.trim(); + if requested.is_empty() { + return Err("扩展名称不能为空".to_string()); + } + let normalized = native_name(requested); + let root = extensions_root()?; + let item = rename_client_extension_at(&root, &id, &normalized)?; + host.refresh()?; + Ok(item) +} + +#[tauri::command] +pub(crate) fn remove_client_extension( + id: String, + host: tauri::State<'_, crate::plugin_host::PluginHost>, +) -> Result<(), String> { + let root = extensions_root()?; + remove_client_extension_at(&root, &id)?; + host.refresh()?; + Ok(()) +} 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 28112d28b..d7f11c0b7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -376,21 +376,6 @@ pub(crate) fn closest_existing_project_picker_directory(path: &Path) -> Option

Result { - app.path() - .app_data_dir() - .map(|app_data_root| { - // Automatic workspaces are AGC-managed data. Keeping them below - // the hardened per-user app-data root avoids applying the strict - // private-DACL gate to a user Documents directory whose inherited - // ACL AGC is not allowed to rewrite. - app_data_root.join(AUTOMATIC_PROJECTS_DIRECTORY_NAME) - }) - .map_err(|error| format!("无法读取 AGC 应用数据目录:{error}")) -} - /// 校验用户选择的项目创建目录。 /// /// 目录必须已经存在(原生目录选择器返回的结果),并且先过 AGC 私有路径门禁:门禁失败时 @@ -417,17 +402,6 @@ pub(crate) fn validate_requested_game_project_creation_root( Ok(root.to_path_buf()) } -/// 解析本次建项要使用的根目录:没选就用 AGC 管理的默认目录,选了就用用户指定的目录。 -pub(crate) fn resolve_game_project_creation_root( - app: &tauri::AppHandle, - requested: Option<&str>, -) -> Result { - match requested.map(str::trim).filter(|value| !value.is_empty()) { - Some(requested) => validate_requested_game_project_creation_root(requested), - None => automatic_local_game_projects_root(app), - } -} - pub(crate) fn create_automatic_local_game_project_at( projects_root: &Path, requested_name: Option<&str>, diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs index c309ef745..712ce0937 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs @@ -1661,3 +1661,29 @@ pub(crate) fn write_project_permission_policy( let _lock = acquire_project_write_lock(root, "project.policy_write")?; write_project_permission_policy_at(root, policy) } + +const AUTOMATIC_PROJECTS_DIRECTORY_NAME: &str = "projects"; + +fn automatic_local_game_projects_root(app: &tauri::AppHandle) -> Result { + app.path() + .app_data_dir() + .map(|app_data_root| { + // Automatic workspaces are AGC-managed data. Keeping them below + // the hardened per-user app-data root avoids applying the strict + // private-DACL gate to a user Documents directory whose inherited + // ACL AGC is not allowed to rewrite. + app_data_root.join(AUTOMATIC_PROJECTS_DIRECTORY_NAME) + }) + .map_err(|error| format!("无法读取 AGC 应用数据目录:{error}")) +} + +/// 解析本次建项要使用的根目录:没选就用 AGC 管理的默认目录,选了就用用户指定的目录。 +pub(crate) fn resolve_game_project_creation_root( + app: &tauri::AppHandle, + requested: Option<&str>, +) -> Result { + match requested.map(str::trim).filter(|value| !value.is_empty()) { + Some(requested) => validate_requested_game_project_creation_root(requested), + None => automatic_local_game_projects_root(app), + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 76930c5ba..44bb269f9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -1118,7 +1118,7 @@ impl WindowsAclRepairScope { } } -#[cfg(windows)] +#[cfg(all(windows, not(test)))] pub(crate) fn parse_windows_acl_repair_scope(value: &str) -> Result { match value.trim() { "managed" => Ok(WindowsAclRepairScope::Managed), @@ -2488,7 +2488,7 @@ fn current_windows_token_user_sid_string() -> Result { /// The elevated helper may run under administrator credentials that differ /// from the original desktop user's token. Keep ownership and the private /// DACL bound to the original TokenUser SID passed by the caller. -#[cfg(windows)] +#[cfg(all(windows, not(test)))] pub(crate) fn repair_game_creator_private_acl_for_user_sid( path: &Path, target_user_sid: &str, @@ -2556,16 +2556,16 @@ fn windows_acl_repair_target(path: &Path, scope: WindowsAclRepairScope) -> PathB path.to_path_buf() } -#[cfg(windows)] +#[cfg(all(windows, not(test)))] const WINDOWS_ACL_REPAIR_AUTHORIZATION_MAX_BYTES: u64 = 4 * 1024; -#[cfg(windows)] +#[cfg(all(windows, not(test)))] struct WindowsAclRepairAuthorizationCleanup { path: PathBuf, armed: bool, } -#[cfg(windows)] +#[cfg(all(windows, not(test)))] impl WindowsAclRepairAuthorizationCleanup { fn new(path: PathBuf) -> Self { Self { path, armed: true } @@ -2576,7 +2576,7 @@ impl WindowsAclRepairAuthorizationCleanup { } } -#[cfg(windows)] +#[cfg(all(windows, not(test)))] impl Drop for WindowsAclRepairAuthorizationCleanup { fn drop(&mut self) { if self.armed { @@ -2660,7 +2660,7 @@ fn create_windows_acl_repair_authorization( Ok(nonce) } -#[cfg(windows)] +#[cfg(all(windows, not(test)))] pub(crate) fn consume_windows_acl_repair_authorization( path: &Path, target_user_sid: &str, @@ -3440,6 +3440,8 @@ fn secure_windows_game_creator_path_for_user_sid_with_owner_policy( result } +// 桌面 setup 接入;配置校验、迁移与目录安全规则仍参与测试。 +#[cfg(not(test))] pub(crate) fn configure_game_creator_runtime_config_dir( app: &tauri::AppHandle, ) -> Result<(), Box> { diff --git a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs index 2801d84eb..5d93f740e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs @@ -4,187 +4,17 @@ //! 目前由宿主在编译期链接(Cargo path 依赖),再按插件 manifest 的 `adapter` //! 字段注册到通用插件宿主。宿主只认适配器 id,不包含目标编辑器知识。 -#[cfg(all(windows, feature = "cocos-editor-execute"))] -use std::path::PathBuf; - -#[cfg(all(windows, feature = "cocos-editor-execute"))] -use tauri::Manager; - -use crate::plugin_host::PluginHost; -#[cfg(all( - windows, - target_arch = "x86_64", - any(feature = "unity-editor-execute", feature = "godot-editor-execute") -))] -use editor_adapter_api::{EditorAdapter, EditorConnectionInfo}; -#[cfg(any( - test, - all( - windows, - target_arch = "x86_64", - any(feature = "unity-editor-execute", feature = "godot-editor-execute") - ) -))] -use serde_json::json; -#[cfg(all( - windows, - target_arch = "x86_64", - any(feature = "unity-editor-execute", feature = "godot-editor-execute") -))] -use serde_json::Value; -use std::path::Path; - mod execution; pub(crate) use execution::*; -/// GUI 只转发已有 Runner RPC;每个引擎的连接和回执均归同一个 owner。 -#[cfg(all( - windows, - target_arch = "x86_64", - any(feature = "unity-editor-execute", feature = "godot-editor-execute") -))] -struct RunnerManagedEditorAdapter(ManagedEditor); +// 桌面注册与 Runner 资源定位属于生产启动接入。 +#[cfg(not(test))] +mod startup; +#[cfg(not(test))] +pub(crate) use startup::*; -#[cfg(all( - windows, - target_arch = "x86_64", - any(feature = "unity-editor-execute", feature = "godot-editor-execute") -))] -impl EditorAdapter for RunnerManagedEditorAdapter { - fn id(&self) -> &'static str { - self.0.adapter() - } - fn detect(&self, project_path: &Path) -> Result { - serde_json::from_value(managed_editor_rpc( - self.0, - "detect", - json!({"projectPath":project_path.to_string_lossy()}), - )?) - .map_err(|_| "编辑器探测回执格式无效".to_string()) - } - fn connect( - &mut self, - pid: u32, - project_path: &Path, - _version: &str, - ) -> Result { - serde_json::from_value(managed_editor_rpc( - self.0, - "connect", - json!({"processId":pid,"projectPath":project_path.to_string_lossy()}), - )?) - .map_err(|_| "编辑器连接回执格式无效".to_string()) - } - fn disconnect(&mut self) { - let _ = disconnect_managed_editor_connection(self.0); - } - fn translate_rpc(&self, method: &str, params: Value) -> Result { - match self.0 { - ManagedEditor::Unity => unity_editor_bridge::UnityEditorAdapter::new(Vec::new()) - .translate_rpc(method, params), - ManagedEditor::Godot => godot_editor_bridge::GodotEditorAdapter::new(Vec::new()) - .translate_rpc(method, params), - } - } - fn rpc(&self, method: &str, params: Value) -> Result { - managed_editor_rpc(self.0, method, params) - } -} - -/// 随包 payload 相对资源根目录的位置,与 `tauri.windows.conf.json` 的资源映射保持一致。 -#[allow(dead_code)] -pub(crate) const COCOS_BRIDGE_PAYLOAD_RELATIVE: &str = - "plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll"; - -#[allow(dead_code)] -pub(crate) const UNITY_ATTACH_HELPER_RELATIVE: &str = - "plugins/agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe"; - -/// Runner 没有 GUI setup,仍只从随包位置或编译期工作区取受控 helper。 -pub(crate) fn configure_unity_helper_for_runtime() -> Result<(), String> { - let mut candidates = Vec::new(); - if let Ok(executable) = std::env::current_exe() { - if let Some(directory) = executable.parent() { - candidates.push(directory.join(UNITY_ATTACH_HELPER_RELATIVE)); - } - } - #[cfg(debug_assertions)] - candidates.push( - std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .ancestors() - .nth(3) - .ok_or("插件工作区目录不可用")? - .join(UNITY_ATTACH_HELPER_RELATIVE), - ); - unity_editor_bridge::configure_helper_candidates(candidates) -} - -pub(crate) const GODOT_BRIDGE_PAYLOAD_RELATIVE: &str = - "plugins/agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll"; - -/// 安装包与开发构建使用同一插件资源布局,不将 DLL 复制进 Godot 工程。 -pub(crate) fn configure_godot_payload_for_runtime(config_dir: &Path) -> Result<(), String> { - godot_editor_bridge::configure_runtime_cache_dir(config_dir.join("godot-editor-runtime"))?; - let mut candidates = Vec::new(); - if let Ok(executable) = std::env::current_exe() { - if let Some(directory) = executable.parent() { - candidates.push(directory.join(GODOT_BRIDGE_PAYLOAD_RELATIVE)); - } - } - #[cfg(debug_assertions)] - candidates.push( - std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .ancestors() - .nth(3) - .ok_or("插件工作区目录不可用")? - .join(GODOT_BRIDGE_PAYLOAD_RELATIVE), - ); - godot_editor_bridge::configure_payload_candidates(candidates) -} - -pub(crate) fn register_linked_editor_adapters( - app: &tauri::AppHandle, - host: &PluginHost, -) -> Result<(), String> { - #[cfg(all(windows, feature = "cocos-editor-execute"))] - { - let adapter = - cocos_editor_bridge::CocosEditorAdapter::new(cocos_bridge_payload_candidates(app)); - host.register_editor_adapter(Box::new(adapter))?; - } - #[cfg(not(all(windows, feature = "cocos-editor-execute")))] - { - let _ = (app, host); - } - #[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))] - { - host.register_editor_adapter(Box::new(RunnerManagedEditorAdapter(ManagedEditor::Unity)))?; - } - #[cfg(all(windows, target_arch = "x86_64", feature = "godot-editor-execute"))] - { - host.register_editor_adapter(Box::new(RunnerManagedEditorAdapter(ManagedEditor::Godot)))?; - } - Ok(()) -} - -#[cfg(all(windows, feature = "cocos-editor-execute"))] -fn cocos_bridge_payload_candidates(app: &tauri::AppHandle) -> Vec { - let mut candidates = Vec::new(); - if let Ok(resource_dir) = app.path().resource_dir() { - candidates.push(resource_dir.join(COCOS_BRIDGE_PAYLOAD_RELATIVE)); - } - // 开发构建还要能直接从 plugins/ 工作区读取尚未打包的 payload。 - #[cfg(debug_assertions)] - { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - candidates.push( - manifest_dir - .join("../../../plugins/agc-cocos-editor/native/payload") - .join("cocos-editor-bridge.dll"), - ); - } - candidates -} +#[cfg(test)] +use serde_json::json; #[cfg(test)] mod unity_receipt_tests { diff --git a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters/startup.rs b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters/startup.rs new file mode 100644 index 000000000..5732d26f3 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters/startup.rs @@ -0,0 +1,181 @@ +#[cfg(all(windows, feature = "cocos-editor-execute"))] +use std::path::PathBuf; + +#[cfg(all(windows, feature = "cocos-editor-execute"))] +use tauri::Manager; + +use crate::plugin_host::PluginHost; +#[cfg(all( + windows, + target_arch = "x86_64", + any(feature = "unity-editor-execute", feature = "godot-editor-execute") +))] +use editor_adapter_api::{EditorAdapter, EditorConnectionInfo}; +#[cfg(all( + windows, + target_arch = "x86_64", + any(feature = "unity-editor-execute", feature = "godot-editor-execute") +))] +use serde_json::json; +#[cfg(all( + windows, + target_arch = "x86_64", + any(feature = "unity-editor-execute", feature = "godot-editor-execute") +))] +use serde_json::Value; +use std::path::Path; + +#[cfg(all( + windows, + target_arch = "x86_64", + any(feature = "unity-editor-execute", feature = "godot-editor-execute") +))] +use super::*; + +/// GUI 只转发已有 Runner RPC;每个引擎的连接和回执均归同一个 owner。 +#[cfg(all( + windows, + target_arch = "x86_64", + any(feature = "unity-editor-execute", feature = "godot-editor-execute") +))] +struct RunnerManagedEditorAdapter(ManagedEditor); + +#[cfg(all( + windows, + target_arch = "x86_64", + any(feature = "unity-editor-execute", feature = "godot-editor-execute") +))] +impl EditorAdapter for RunnerManagedEditorAdapter { + fn id(&self) -> &'static str { + self.0.adapter() + } + fn detect(&self, project_path: &Path) -> Result { + serde_json::from_value(managed_editor_rpc( + self.0, + "detect", + json!({"projectPath":project_path.to_string_lossy()}), + )?) + .map_err(|_| "编辑器探测回执格式无效".to_string()) + } + fn connect( + &mut self, + pid: u32, + project_path: &Path, + _version: &str, + ) -> Result { + serde_json::from_value(managed_editor_rpc( + self.0, + "connect", + json!({"processId":pid,"projectPath":project_path.to_string_lossy()}), + )?) + .map_err(|_| "编辑器连接回执格式无效".to_string()) + } + fn disconnect(&mut self) { + let _ = disconnect_managed_editor_connection(self.0); + } + fn translate_rpc(&self, method: &str, params: Value) -> Result { + match self.0 { + ManagedEditor::Unity => unity_editor_bridge::UnityEditorAdapter::new(Vec::new()) + .translate_rpc(method, params), + ManagedEditor::Godot => godot_editor_bridge::GodotEditorAdapter::new(Vec::new()) + .translate_rpc(method, params), + } + } + fn rpc(&self, method: &str, params: Value) -> Result { + managed_editor_rpc(self.0, method, params) + } +} + +/// 随包 payload 相对资源根目录的位置,与 `tauri.windows.conf.json` 的资源映射保持一致。 +#[cfg(all(windows, feature = "cocos-editor-execute"))] +const COCOS_BRIDGE_PAYLOAD_RELATIVE: &str = + "plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll"; + +const UNITY_ATTACH_HELPER_RELATIVE: &str = + "plugins/agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe"; + +/// Runner 没有 GUI setup,仍只从随包位置或编译期工作区取受控 helper。 +pub(crate) fn configure_unity_helper_for_runtime() -> Result<(), String> { + let mut candidates = Vec::new(); + if let Ok(executable) = std::env::current_exe() { + if let Some(directory) = executable.parent() { + candidates.push(directory.join(UNITY_ATTACH_HELPER_RELATIVE)); + } + } + #[cfg(debug_assertions)] + candidates.push( + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(3) + .ok_or("插件工作区目录不可用")? + .join(UNITY_ATTACH_HELPER_RELATIVE), + ); + unity_editor_bridge::configure_helper_candidates(candidates) +} + +pub(crate) const GODOT_BRIDGE_PAYLOAD_RELATIVE: &str = + "plugins/agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll"; + +/// 安装包与开发构建使用同一插件资源布局,不将 DLL 复制进 Godot 工程。 +pub(crate) fn configure_godot_payload_for_runtime(config_dir: &Path) -> Result<(), String> { + godot_editor_bridge::configure_runtime_cache_dir(config_dir.join("godot-editor-runtime"))?; + let mut candidates = Vec::new(); + if let Ok(executable) = std::env::current_exe() { + if let Some(directory) = executable.parent() { + candidates.push(directory.join(GODOT_BRIDGE_PAYLOAD_RELATIVE)); + } + } + #[cfg(debug_assertions)] + candidates.push( + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(3) + .ok_or("插件工作区目录不可用")? + .join(GODOT_BRIDGE_PAYLOAD_RELATIVE), + ); + godot_editor_bridge::configure_payload_candidates(candidates) +} + +pub(crate) fn register_linked_editor_adapters( + app: &tauri::AppHandle, + host: &PluginHost, +) -> Result<(), String> { + #[cfg(all(windows, feature = "cocos-editor-execute"))] + { + let adapter = + cocos_editor_bridge::CocosEditorAdapter::new(cocos_bridge_payload_candidates(app)); + host.register_editor_adapter(Box::new(adapter))?; + } + #[cfg(not(all(windows, feature = "cocos-editor-execute")))] + { + let _ = (app, host); + } + #[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))] + { + host.register_editor_adapter(Box::new(RunnerManagedEditorAdapter(ManagedEditor::Unity)))?; + } + #[cfg(all(windows, target_arch = "x86_64", feature = "godot-editor-execute"))] + { + host.register_editor_adapter(Box::new(RunnerManagedEditorAdapter(ManagedEditor::Godot)))?; + } + Ok(()) +} + +#[cfg(all(windows, feature = "cocos-editor-execute"))] +fn cocos_bridge_payload_candidates(app: &tauri::AppHandle) -> Vec { + let mut candidates = Vec::new(); + if let Ok(resource_dir) = app.path().resource_dir() { + candidates.push(resource_dir.join(COCOS_BRIDGE_PAYLOAD_RELATIVE)); + } + // 开发构建还要能直接从 plugins/ 工作区读取尚未打包的 payload。 + #[cfg(debug_assertions)] + { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + candidates.push( + manifest_dir + .join("../../../plugins/agc-cocos-editor/native/payload") + .join("cocos-editor-bridge.dll"), + ); + } + candidates +} 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 3ac8f313f..5c000f889 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -49,7 +49,9 @@ use shared_contracts::game_creation_app::{ }; // `Emitter` 同时被 `use super::*` 的子模块依赖(通知、Agent 事件等都从 crate 根取该 trait), // 不要因为根模块自身不再直接 `.emit(..)` 就删掉它。 -use tauri::{Emitter, Manager}; +use tauri::Emitter; +#[cfg(not(test))] +use tauri::Manager; /// Rust 侧普通文本日志:保留 stderr 输出,同时将同一行持久化到 AppData。 /// 诊断包只在用户主动提交时读取这些 raw log;结构化错误事件仍只留在内存。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs index 0f4b03cd4..53ae8b5f8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs @@ -1,15 +1,9 @@ -use serde::Deserialize; -use std::fs::{self, OpenOptions}; -use std::io::Read; -use std::path::{Path, PathBuf}; -use std::sync::{Mutex, OnceLock}; +#[cfg(not(test))] +mod startup; +#[cfg(not(test))] +pub(crate) use startup::load_platform_session_fixture_from_env; -/// Debug-only fixture hook used by the deterministic AGC E2E. The hook takes -/// a path, rather than credentials on argv, so a child Runner can inherit the -/// test identity without putting the bearer token in process listings. -pub(crate) const PLATFORM_SESSION_FIXTURE_ENV: &str = "GENARRATIVE_AGC_PLATFORM_SESSION_FIXTURE"; -const PLATFORM_SESSION_FIXTURE_SCHEMA_VERSION: &str = "genarrative-agc-platform-session-fixture.v1"; -const PLATFORM_SESSION_FIXTURE_MAX_BYTES: u64 = 16 * 1024; +use std::sync::{Mutex, OnceLock}; /// 平台会话快照 = 身份(登录主体 + 服务 origin)+ 凭据(当前 access token)。 /// @@ -69,217 +63,6 @@ pub(crate) fn editor_api_mode() -> EditorApiMode { editor_api_mode_for_build(cfg!(debug_assertions), cfg!(debug_assertions)) } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct PlatformSessionFixture { - schema_version: String, - user_id: String, - access_token: String, - api_base_url: String, - generation: u64, -} - -fn metadata_is_link_or_reparse(metadata: &fs::Metadata) -> bool { - if metadata.file_type().is_symlink() { - return true; - } - #[cfg(windows)] - { - use std::os::windows::fs::MetadataExt; - const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; - if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { - return true; - } - } - false -} - -fn path_component_is_inside(root: &Path, candidate: &Path) -> bool { - #[cfg(windows)] - { - // Windows paths are case-insensitive. Comparing normalized UTF-16 - // strings would introduce an unnecessary lossy conversion; component - // comparison with ASCII-folding covers the drive/normal path forms - // used by the fixture while retaining separators as boundaries. - let root = root.components().collect::>(); - let candidate = candidate.components().collect::>(); - return candidate.len() > root.len() - && root.iter().zip(candidate.iter()).all(|(left, right)| { - left.as_os_str() - .to_string_lossy() - .eq_ignore_ascii_case(&right.as_os_str().to_string_lossy()) - }); - } - #[cfg(not(windows))] - { - candidate.starts_with(root) && candidate != root - } -} - -fn validate_fixture_path(config_dir: &Path, fixture_path: &Path) -> Result { - if !config_dir.is_absolute() { - return Err("平台登录态 fixture 所在 AppData 必须是绝对路径".to_string()); - } - if !fixture_path.is_absolute() { - return Err("平台登录态 fixture 路径必须是绝对路径".to_string()); - } - - // Check the directory entry before canonicalization so a symlink/junction - // cannot be silently followed into an unrelated credential location. - let config_entry = fs::symlink_metadata(config_dir) - .map_err(|_| "平台登录态 fixture AppData 不可读取".to_string())?; - if metadata_is_link_or_reparse(&config_entry) || !config_entry.is_dir() { - return Err("平台登录态 fixture AppData 必须是普通目录".to_string()); - } - let canonical_config = fs::canonicalize(config_dir) - .map_err(|_| "平台登录态 fixture AppData 不可解析".to_string())?; - - let fixture_entry = fs::symlink_metadata(fixture_path) - .map_err(|_| "平台登录态 fixture 文件不可读取".to_string())?; - if metadata_is_link_or_reparse(&fixture_entry) || !fixture_entry.is_file() { - return Err("平台登录态 fixture 必须是普通文件".to_string()); - } - if fixture_entry.len() > PLATFORM_SESSION_FIXTURE_MAX_BYTES { - return Err("平台登录态 fixture 过大,已拒绝读取".to_string()); - } - let canonical_fixture = fs::canonicalize(fixture_path) - .map_err(|_| "平台登录态 fixture 文件不可解析".to_string())?; - if !path_component_is_inside(&canonical_config, &canonical_fixture) { - return Err("平台登录态 fixture 必须位于 --config-dir 内".to_string()); - } - - // Walk the path below AppData and reject links/reparse points in every - // ancestor as well as at the leaf. This keeps the check useful on - // platforms where canonicalize otherwise follows a junction. - let relative = canonical_fixture - .strip_prefix(&canonical_config) - .map_err(|_| "平台登录态 fixture 必须位于 --config-dir 内".to_string())?; - let mut current = canonical_config; - for component in relative.components() { - current.push(component.as_os_str()); - let metadata = fs::symlink_metadata(¤t) - .map_err(|_| "平台登录态 fixture 路径不可读取".to_string())?; - if metadata_is_link_or_reparse(&metadata) { - return Err("平台登录态 fixture 路径不能包含链接或 reparse point".to_string()); - } - if current != canonical_fixture && !metadata.is_dir() { - return Err("平台登录态 fixture 父路径必须是普通目录".to_string()); - } - } - Ok(canonical_fixture) -} - -fn read_fixture_file(path: &Path) -> Result, String> { - let before = - fs::symlink_metadata(path).map_err(|_| "平台登录态 fixture 文件不可读取".to_string())?; - if metadata_is_link_or_reparse(&before) || !before.is_file() { - return Err("平台登录态 fixture 必须是普通文件".to_string()); - } - if before.len() > PLATFORM_SESSION_FIXTURE_MAX_BYTES { - return Err("平台登录态 fixture 过大,已拒绝读取".to_string()); - } - - let mut options = OpenOptions::new(); - options.read(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.custom_flags(libc::O_NOFOLLOW); - } - let file = options - .open(path) - .map_err(|_| "平台登录态 fixture 文件不可读取".to_string())?; - let opened = file - .metadata() - .map_err(|_| "平台登录态 fixture 文件不可读取".to_string())?; - if metadata_is_link_or_reparse(&opened) || !opened.is_file() || opened.len() > before.len() { - return Err("平台登录态 fixture 文件身份校验失败".to_string()); - } - let mut bytes = - Vec::with_capacity(opened.len().min(PLATFORM_SESSION_FIXTURE_MAX_BYTES) as usize); - file.take(PLATFORM_SESSION_FIXTURE_MAX_BYTES + 1) - .read_to_end(&mut bytes) - .map_err(|_| "读取平台登录态 fixture 失败".to_string())?; - if bytes.len() as u64 > PLATFORM_SESSION_FIXTURE_MAX_BYTES { - return Err("平台登录态 fixture 过大,已拒绝读取".to_string()); - } - Ok(bytes) -} - -fn parse_platform_session_fixture(bytes: &[u8]) -> Result { - let fixture = serde_json::from_slice::(bytes) - .map_err(|_| "平台登录态 fixture 格式无效".to_string())?; - if fixture.schema_version != PLATFORM_SESSION_FIXTURE_SCHEMA_VERSION { - return Err("平台登录态 fixture 版本不受支持".to_string()); - } - if fixture.generation == 0 { - return Err("平台登录态 fixture generation 无效".to_string()); - } - if fixture - .user_id - .chars() - .any(|character| character.is_control()) - { - return Err("平台登录态 fixture 用户身份无效".to_string()); - } - if fixture - .access_token - .chars() - .any(|character| character.is_control()) - { - return Err("平台登录态 fixture 凭据无效".to_string()); - } - Ok(fixture) -} - -/// Loads a deliberately narrow, file-backed account fixture for Debug E2E -/// processes. Release binaries fail closed if the hook is present. The -/// fixture path must be a regular file below the process' explicit -/// `--config-dir`; credentials are never accepted on argv or emitted in an -/// error string. -pub(crate) fn load_platform_session_fixture_from_env(config_dir: &Path) -> Result<(), String> { - load_platform_session_fixture_from_env_for_build(config_dir, cfg!(debug_assertions)) -} - -pub(crate) fn load_platform_session_fixture_from_env_for_build( - config_dir: &Path, - debug_build: bool, -) -> Result<(), String> { - let Some(raw_path) = std::env::var_os(PLATFORM_SESSION_FIXTURE_ENV) else { - return Ok(()); - }; - if !debug_build { - return Err("当前发行版拒绝使用平台登录态测试 fixture".to_string()); - } - let raw_path = raw_path - .to_str() - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| "平台登录态 fixture 路径无效".to_string())?; - let fixture_path = validate_fixture_path(config_dir, Path::new(raw_path))?; - let bytes = read_fixture_file(&fixture_path)?; - let fixture = parse_platform_session_fixture(&bytes)?; - // fixture 的 generation 同时充当身份代次与写入 revision:一个 fixture 只表达 - // “从零安装一次确定的会话”,不表达同一身份的凭据续期。 - let snapshot = validated_platform_session_snapshot( - &fixture.user_id, - &fixture.access_token, - &fixture.api_base_url, - fixture.generation, - fixture.generation, - )?; - let mut current = platform_session() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - // A fresh CLI/Runner normally starts at revision zero. Replacing the - // state here also makes a Debug GUI fixture deterministic without relaxing - // the normal account-switch rules. - current.revision = snapshot.revision; - current.identity_generation = snapshot.identity_generation; - current.snapshot = Some(snapshot); - Ok(()) -} - #[derive(Default)] struct PlatformSessionState { revision: u64, @@ -318,6 +101,7 @@ pub(crate) fn analytics_identity_snapshot() -> Option<(crate::analytics::contrac // 仅供后台初始化使用:持锁发布 GUI 身份,避免快照与通知之间出现空窗。 // 回调只允许更新内存或非阻塞投递,不能访问磁盘或重新取得认证锁。 +#[cfg(not(test))] pub(crate) fn initialize_analytics_identity( initialize: impl FnOnce(crate::analytics::contract::Route, u64), ) { @@ -454,6 +238,7 @@ fn validated_platform_session_snapshot( }) } +#[cfg(not(test))] pub(crate) fn validate_platform_session_input( user_id: &str, access_token: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/platform_session/startup.rs b/apps/ai-game-creator-shell/src-tauri/src/platform_session/startup.rs new file mode 100644 index 000000000..770506cc9 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/platform_session/startup.rs @@ -0,0 +1,224 @@ +// 桌面与 CLI 启动的 Debug E2E 会话接入;发行版拒绝逻辑与文件校验保持一致。 +use super::{platform_session, validated_platform_session_snapshot}; +use serde::Deserialize; +use std::fs::{self, OpenOptions}; +use std::io::Read; +use std::path::{Path, PathBuf}; + +/// Debug-only fixture hook used by the deterministic AGC E2E. The hook takes +/// a path, rather than credentials on argv, so a child Runner can inherit the +/// test identity without putting the bearer token in process listings. +pub(crate) const PLATFORM_SESSION_FIXTURE_ENV: &str = "GENARRATIVE_AGC_PLATFORM_SESSION_FIXTURE"; +const PLATFORM_SESSION_FIXTURE_SCHEMA_VERSION: &str = "genarrative-agc-platform-session-fixture.v1"; +const PLATFORM_SESSION_FIXTURE_MAX_BYTES: u64 = 16 * 1024; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PlatformSessionFixture { + schema_version: String, + user_id: String, + access_token: String, + api_base_url: String, + generation: u64, +} + +fn metadata_is_link_or_reparse(metadata: &fs::Metadata) -> bool { + if metadata.file_type().is_symlink() { + return true; + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return true; + } + } + false +} + +fn path_component_is_inside(root: &Path, candidate: &Path) -> bool { + #[cfg(windows)] + { + // Windows paths are case-insensitive. Comparing normalized UTF-16 + // strings would introduce an unnecessary lossy conversion; component + // comparison with ASCII-folding covers the drive/normal path forms + // used by the fixture while retaining separators as boundaries. + let root = root.components().collect::>(); + let candidate = candidate.components().collect::>(); + return candidate.len() > root.len() + && root.iter().zip(candidate.iter()).all(|(left, right)| { + left.as_os_str() + .to_string_lossy() + .eq_ignore_ascii_case(&right.as_os_str().to_string_lossy()) + }); + } + #[cfg(not(windows))] + { + candidate.starts_with(root) && candidate != root + } +} + +fn validate_fixture_path(config_dir: &Path, fixture_path: &Path) -> Result { + if !config_dir.is_absolute() { + return Err("平台登录态 fixture 所在 AppData 必须是绝对路径".to_string()); + } + if !fixture_path.is_absolute() { + return Err("平台登录态 fixture 路径必须是绝对路径".to_string()); + } + + // Check the directory entry before canonicalization so a symlink/junction + // cannot be silently followed into an unrelated credential location. + let config_entry = fs::symlink_metadata(config_dir) + .map_err(|_| "平台登录态 fixture AppData 不可读取".to_string())?; + if metadata_is_link_or_reparse(&config_entry) || !config_entry.is_dir() { + return Err("平台登录态 fixture AppData 必须是普通目录".to_string()); + } + let canonical_config = fs::canonicalize(config_dir) + .map_err(|_| "平台登录态 fixture AppData 不可解析".to_string())?; + + let fixture_entry = fs::symlink_metadata(fixture_path) + .map_err(|_| "平台登录态 fixture 文件不可读取".to_string())?; + if metadata_is_link_or_reparse(&fixture_entry) || !fixture_entry.is_file() { + return Err("平台登录态 fixture 必须是普通文件".to_string()); + } + if fixture_entry.len() > PLATFORM_SESSION_FIXTURE_MAX_BYTES { + return Err("平台登录态 fixture 过大,已拒绝读取".to_string()); + } + let canonical_fixture = fs::canonicalize(fixture_path) + .map_err(|_| "平台登录态 fixture 文件不可解析".to_string())?; + if !path_component_is_inside(&canonical_config, &canonical_fixture) { + return Err("平台登录态 fixture 必须位于 --config-dir 内".to_string()); + } + + // Walk the path below AppData and reject links/reparse points in every + // ancestor as well as at the leaf. This keeps the check useful on + // platforms where canonicalize otherwise follows a junction. + let relative = canonical_fixture + .strip_prefix(&canonical_config) + .map_err(|_| "平台登录态 fixture 必须位于 --config-dir 内".to_string())?; + let mut current = canonical_config; + for component in relative.components() { + current.push(component.as_os_str()); + let metadata = fs::symlink_metadata(¤t) + .map_err(|_| "平台登录态 fixture 路径不可读取".to_string())?; + if metadata_is_link_or_reparse(&metadata) { + return Err("平台登录态 fixture 路径不能包含链接或 reparse point".to_string()); + } + if current != canonical_fixture && !metadata.is_dir() { + return Err("平台登录态 fixture 父路径必须是普通目录".to_string()); + } + } + Ok(canonical_fixture) +} + +fn read_fixture_file(path: &Path) -> Result, String> { + let before = + fs::symlink_metadata(path).map_err(|_| "平台登录态 fixture 文件不可读取".to_string())?; + if metadata_is_link_or_reparse(&before) || !before.is_file() { + return Err("平台登录态 fixture 必须是普通文件".to_string()); + } + if before.len() > PLATFORM_SESSION_FIXTURE_MAX_BYTES { + return Err("平台登录态 fixture 过大,已拒绝读取".to_string()); + } + + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } + let file = options + .open(path) + .map_err(|_| "平台登录态 fixture 文件不可读取".to_string())?; + let opened = file + .metadata() + .map_err(|_| "平台登录态 fixture 文件不可读取".to_string())?; + if metadata_is_link_or_reparse(&opened) || !opened.is_file() || opened.len() > before.len() { + return Err("平台登录态 fixture 文件身份校验失败".to_string()); + } + let mut bytes = + Vec::with_capacity(opened.len().min(PLATFORM_SESSION_FIXTURE_MAX_BYTES) as usize); + file.take(PLATFORM_SESSION_FIXTURE_MAX_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|_| "读取平台登录态 fixture 失败".to_string())?; + if bytes.len() as u64 > PLATFORM_SESSION_FIXTURE_MAX_BYTES { + return Err("平台登录态 fixture 过大,已拒绝读取".to_string()); + } + Ok(bytes) +} + +fn parse_platform_session_fixture(bytes: &[u8]) -> Result { + let fixture = serde_json::from_slice::(bytes) + .map_err(|_| "平台登录态 fixture 格式无效".to_string())?; + if fixture.schema_version != PLATFORM_SESSION_FIXTURE_SCHEMA_VERSION { + return Err("平台登录态 fixture 版本不受支持".to_string()); + } + if fixture.generation == 0 { + return Err("平台登录态 fixture generation 无效".to_string()); + } + if fixture + .user_id + .chars() + .any(|character| character.is_control()) + { + return Err("平台登录态 fixture 用户身份无效".to_string()); + } + if fixture + .access_token + .chars() + .any(|character| character.is_control()) + { + return Err("平台登录态 fixture 凭据无效".to_string()); + } + Ok(fixture) +} + +/// Loads a deliberately narrow, file-backed account fixture for Debug E2E +/// processes. Release binaries fail closed if the hook is present. The +/// fixture path must be a regular file below the process' explicit +/// `--config-dir`; credentials are never accepted on argv or emitted in an +/// error string. +pub(crate) fn load_platform_session_fixture_from_env(config_dir: &Path) -> Result<(), String> { + load_platform_session_fixture_from_env_for_build(config_dir, cfg!(debug_assertions)) +} + +pub(crate) fn load_platform_session_fixture_from_env_for_build( + config_dir: &Path, + debug_build: bool, +) -> Result<(), String> { + let Some(raw_path) = std::env::var_os(PLATFORM_SESSION_FIXTURE_ENV) else { + return Ok(()); + }; + if !debug_build { + return Err("当前发行版拒绝使用平台登录态测试 fixture".to_string()); + } + let raw_path = raw_path + .to_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "平台登录态 fixture 路径无效".to_string())?; + let fixture_path = validate_fixture_path(config_dir, Path::new(raw_path))?; + let bytes = read_fixture_file(&fixture_path)?; + let fixture = parse_platform_session_fixture(&bytes)?; + // fixture 的 generation 同时充当身份代次与写入 revision:一个 fixture 只表达 + // “从零安装一次确定的会话”,不表达同一身份的凭据续期。 + let snapshot = validated_platform_session_snapshot( + &fixture.user_id, + &fixture.access_token, + &fixture.api_base_url, + fixture.generation, + fixture.generation, + )?; + let mut current = platform_session() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + // A fresh CLI/Runner normally starts at revision zero. Replacing the + // state here also makes a Debug GUI fixture deterministic without relaxing + // the normal account-switch rules. + current.revision = snapshot.revision; + current.identity_generation = snapshot.identity_generation; + current.snapshot = Some(snapshot); + Ok(()) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs index 61c2387e1..7694d53f3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs @@ -19,10 +19,14 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use tauri::{Manager, State}; use crate::editor_adapter::EditorAdapter; +#[cfg(not(test))] +mod desktop; +#[cfg(not(test))] +pub(crate) use desktop::*; + type EditorRegistry = Arc>>>; type ProjectContext = Arc>>; type PendingRpc = Arc>>>>; @@ -476,32 +480,6 @@ fn plugin_root(config_dir: &Path) -> Result { Ok(root) } -/// 解析插件工作区目录:环境变量优先,其次随包资源目录 `plugins/`, -/// 开发构建再回退仓库里的 `plugins/` 工作区。 -pub(crate) fn resolve_plugin_workspace(app: &tauri::AppHandle) -> Option { - if let Some(workspace) = std::env::var_os("AGC_PLUGIN_WORKSPACE") { - let workspace = PathBuf::from(workspace); - if workspace.is_dir() { - return Some(workspace); - } - } - if let Ok(resource_dir) = app.path().resource_dir() { - let bundled = resource_dir.join("plugins"); - if bundled.is_dir() { - return Some(bundled); - } - } - #[cfg(debug_assertions)] - { - let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.."); - let workspace = repo_root.join("plugins"); - if workspace.is_dir() { - return Some(workspace); - } - } - None -} - /// 统一后的插件来源,屏蔽“AppData 导入”和“plugins/ 工作区”的差别。 #[derive(Clone, Debug)] struct ScannedPluginSource { @@ -1213,12 +1191,6 @@ impl PluginHost { self.summary_locked(record) } - pub(crate) fn reload(&self, id: &str) -> Result { - let _ = self.stop(id)?; - self.refresh()?; - self.start(id) - } - /// 内置插件的可用开关:禁用时先停进程,再持久化状态并重新扫描。 /// /// 该状态同时被 Agent 工具目录消费,禁用后插件不能启动,对应 Runtime 工具 @@ -1902,96 +1874,6 @@ impl PluginHost { } } -#[tauri::command] -pub(crate) fn list_agc_plugins(host: State<'_, PluginHost>) -> Result, String> { - host.list() -} - -#[tauri::command] -pub(crate) fn list_agc_extensions( - host: State<'_, PluginHost>, -) -> Result, String> { - host.list_extensions() -} - -#[tauri::command] -pub(crate) fn refresh_agc_plugins( - host: State<'_, PluginHost>, -) -> Result, String> { - host.refresh() -} - -#[tauri::command] -pub(crate) fn start_agc_plugin( - id: String, - host: State<'_, PluginHost>, -) -> Result { - host.start(id.trim()) -} - -#[tauri::command] -pub(crate) fn stop_agc_plugin( - id: String, - host: State<'_, PluginHost>, -) -> Result { - host.stop(id.trim()) -} - -#[tauri::command] -pub(crate) fn reload_agc_plugin( - id: String, - host: State<'_, PluginHost>, -) -> Result { - host.reload(id.trim()) -} - -#[tauri::command] -pub(crate) fn set_agc_plugin_enabled( - id: String, - enabled: bool, - host: State<'_, PluginHost>, -) -> Result, String> { - host.set_enabled(id.trim(), enabled) -} - -#[tauri::command] -pub(crate) async fn call_agc_plugin( - id: String, - method: String, - params: Value, - app: tauri::AppHandle, -) -> Result { - if !valid_text(&method, 120) || method.chars().any(char::is_control) { - return Err("插件 RPC 方法无效".to_string()); - } - tauri::async_runtime::spawn_blocking(move || { - app.state::().call(id.trim(), method, params) - }) - .await - .map_err(|_| "插件 RPC 任务失败".to_string())? -} - -#[tauri::command] -pub(crate) fn read_agc_plugin_panel( - id: String, - panel_id: String, - host: State<'_, PluginHost>, -) -> Result { - host.read_panel(&id, &panel_id) -} - -#[tauri::command] -pub(crate) async fn set_agc_plugin_project_path( - project_path: Option, - app: tauri::AppHandle, -) -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || { - app.state::().set_active_project(project_path) - }) - .await - .map_err(|_| "切换插件项目上下文任务失败".to_string())? -} - #[cfg(test)] mod tests { use super::*; @@ -2399,7 +2281,18 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p plugin.id == "agc-unity-editor" && plugin.commands.len() == 1 && plugin.capabilities.len() == 1 - }) { + }) && host.state.lock().unwrap().plugins["agc-unity-editor"] + .running + .as_ref() + .unwrap() + .registrations + .lock() + .unwrap() + .subscriptions + .values() + .any(|event| event == "project.changed") + { + // 后续切换项目会发送事件;先订阅,避免注册完成后丢失项目通知。 break; } assert!(Instant::now() < deadline); @@ -2659,11 +2552,23 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p && plugin.panels.len() == 1 }); if registered { - break; + // 注册完成早于项目订阅快照到达;只读查询确认插件已收到上下文。 + match host.call( + "agc-cocos-editor", + "cocos.editor.connection".to_string(), + json!({"operation": "status", "processId": 1}), + ) { + Ok(response) => { + assert_eq!(response["params"]["projectPath"], project); + break; + } + Err(error) if error.contains("当前没有受控项目路径") => {} + Err(error) => panic!("cocos context readiness rpc: {error}"), + } } assert!( std::time::Instant::now() < deadline, - "Cocos 插件未在期限内完成注册" + "Cocos 插件未在期限内完成注册及项目上下文初始化" ); thread::sleep(Duration::from_millis(25)); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/plugin_host/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/plugin_host/desktop.rs new file mode 100644 index 000000000..161286ebc --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/plugin_host/desktop.rs @@ -0,0 +1,126 @@ +use super::*; +use tauri::{Manager, State}; + +/// 解析插件工作区目录:环境变量优先,其次随包资源目录 `plugins/`, +/// 开发构建再回退仓库里的 `plugins/` 工作区。 +pub(crate) fn resolve_plugin_workspace(app: &tauri::AppHandle) -> Option { + if let Some(workspace) = std::env::var_os("AGC_PLUGIN_WORKSPACE") { + let workspace = PathBuf::from(workspace); + if workspace.is_dir() { + return Some(workspace); + } + } + if let Ok(resource_dir) = app.path().resource_dir() { + let bundled = resource_dir.join("plugins"); + if bundled.is_dir() { + return Some(bundled); + } + } + #[cfg(debug_assertions)] + { + let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.."); + let workspace = repo_root.join("plugins"); + if workspace.is_dir() { + return Some(workspace); + } + } + None +} + +impl PluginHost { + pub(crate) fn reload(&self, id: &str) -> Result { + let _ = self.stop(id)?; + self.refresh()?; + self.start(id) + } +} + +#[tauri::command] +pub(crate) fn list_agc_plugins(host: State<'_, PluginHost>) -> Result, String> { + host.list() +} + +#[tauri::command] +pub(crate) fn list_agc_extensions( + host: State<'_, PluginHost>, +) -> Result, String> { + host.list_extensions() +} + +#[tauri::command] +pub(crate) fn refresh_agc_plugins( + host: State<'_, PluginHost>, +) -> Result, String> { + host.refresh() +} + +#[tauri::command] +pub(crate) fn start_agc_plugin( + id: String, + host: State<'_, PluginHost>, +) -> Result { + host.start(id.trim()) +} + +#[tauri::command] +pub(crate) fn stop_agc_plugin( + id: String, + host: State<'_, PluginHost>, +) -> Result { + host.stop(id.trim()) +} + +#[tauri::command] +pub(crate) fn reload_agc_plugin( + id: String, + host: State<'_, PluginHost>, +) -> Result { + host.reload(id.trim()) +} + +#[tauri::command] +pub(crate) fn set_agc_plugin_enabled( + id: String, + enabled: bool, + host: State<'_, PluginHost>, +) -> Result, String> { + host.set_enabled(id.trim(), enabled) +} + +#[tauri::command] +pub(crate) async fn call_agc_plugin( + id: String, + method: String, + params: Value, + app: tauri::AppHandle, +) -> Result { + if !valid_text(&method, 120) || method.chars().any(char::is_control) { + return Err("插件 RPC 方法无效".to_string()); + } + tauri::async_runtime::spawn_blocking(move || { + app.state::().call(id.trim(), method, params) + }) + .await + .map_err(|_| "插件 RPC 任务失败".to_string())? +} + +#[tauri::command] +pub(crate) fn read_agc_plugin_panel( + id: String, + panel_id: String, + host: State<'_, PluginHost>, +) -> Result { + host.read_panel(&id, &panel_id) +} + +#[tauri::command] +pub(crate) async fn set_agc_plugin_project_path( + project_path: Option, + app: tauri::AppHandle, +) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + app.state::().set_active_project(project_path) + }) + .await + .map_err(|_| "切换插件项目上下文任务失败".to_string())? +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/preview.rs index 390961292..8ca838f6d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/preview.rs @@ -1,5 +1,11 @@ use super::*; +// 桌面 Tauri 命令接线不参与 Rust 单元测试;registry 与预览生命周期实现仍在本模块。 +#[cfg(not(test))] +mod desktop; +#[cfg(not(test))] +pub(crate) use desktop::*; + #[derive(Clone, Default)] pub(crate) struct PreviewRegistry { current: Arc>>, @@ -721,20 +727,6 @@ pub(crate) fn filter_preview_status_for_project( } } -#[tauri::command] -pub(crate) fn start_local_game_preview( - project_path: String, - expected_revision: Option, - preview_source: Option, - registry: tauri::State<'_, PreviewRegistry>, -) -> Result { - let root = Path::new(project_path.trim()); - let capture = (preview_source == Some(crate::analytics::contract::PreviewSource::User)) - .then(crate::analytics::gui::capture_writer_context) - .flatten(); - start_local_game_preview_with_capture(root, expected_revision, ®istry, capture) -} - pub(crate) fn start_local_game_preview_at( root: &Path, registry: &PreviewRegistry, @@ -821,25 +813,6 @@ fn start_local_game_preview_with_capture( #[path = "preview_analytics_tests.rs"] mod analytics_tests; -#[tauri::command] -pub(crate) fn stop_local_game_preview( - project_path: Option, - registry: tauri::State<'_, PreviewRegistry>, -) -> Result { - let project_path = project_path - .as_deref() - .map(str::trim) - .filter(|path| !path.is_empty()); - let root = project_path.map(Path::new); - let _lock = if let Some(root) = root { - enforce_project_permission_policy(root, "preview.stop")?; - Some(acquire_project_write_lock(root, "preview.stop")?) - } else { - None - }; - stop_local_game_preview_for_root(root, ®istry) -} - pub(crate) fn stop_local_game_preview_for_root( root: Option<&Path>, registry: &PreviewRegistry, @@ -883,19 +856,6 @@ pub(crate) fn stop_local_game_preview_on_exit(registry: &PreviewRegistry) -> Res stop_local_game_preview_for_root(Some(Path::new(root)), registry).map(|_| ()) } -#[tauri::command] -pub(crate) fn stop_local_game_preview_if_matches( - project_path: String, - expected_preview: LocalPreviewResult, - registry: tauri::State<'_, PreviewRegistry>, -) -> Result { - stop_local_game_preview_if_matches_at( - Path::new(project_path.trim()), - &expected_preview, - ®istry, - ) -} - pub(crate) fn stop_local_game_preview_if_matches_at( root: &Path, expected_preview: &LocalPreviewResult, @@ -923,14 +883,6 @@ pub(crate) fn stop_local_game_preview_if_matches_at( Ok(true) } -#[tauri::command] -pub(crate) fn get_local_game_preview_status( - registry: tauri::State<'_, PreviewRegistry>, - project_path: Option, -) -> Result { - get_local_game_preview_status_at(®istry, project_path.as_deref()) -} - pub(crate) fn get_local_game_preview_status_at( registry: &PreviewRegistry, project_path: Option<&str>, @@ -945,21 +897,6 @@ pub(crate) fn get_local_game_preview_status_at( )) } -#[tauri::command] -pub(crate) async fn get_local_game_project_revision( - project_path: String, -) -> Result { - tauri::async_runtime::spawn_blocking(move || get_local_game_project_revision_sync(project_path)) - .await - .map_err(|error| format!("读取项目版本后台任务失败:{error}"))? -} - -pub(crate) fn get_local_game_project_revision_sync( - project_path: String, -) -> Result { - get_local_game_project_revision_at(Path::new(project_path.trim())) -} - pub(crate) fn get_local_game_project_revision_at( root: &Path, ) -> Result { @@ -970,17 +907,6 @@ pub(crate) fn get_local_game_project_revision_at( }) } -#[tauri::command] -pub(crate) fn activate_local_game_preview( - registry: tauri::State<'_, PreviewRegistry>, - project_path: Option, -) -> Result { - let status = registry.status(); - validate_preview_open_project(&status, project_path.as_deref())?; - preview_open_url(&status)?; - Ok(status) -} - pub(crate) fn start_local_game_preview_for_project( root: &Path, ) -> Result<(LocalPreviewResult, mpsc::Sender<()>), String> { @@ -1843,9 +1769,7 @@ mod tests { #[test] fn npm_preview_requires_build_and_prefers_bundled_assets() { - let base = PathBuf::from(std::env::var("HOME").unwrap()).join("data/tmp"); - fs::create_dir_all(&base).unwrap(); - let root = tempfile::tempdir_in(base).unwrap(); + let root = tempfile::tempdir().unwrap(); fs::write(root.path().join("package.json"), "{}").unwrap(); fs::write(root.path().join("index.html"), "source").unwrap(); assert!(resolve_preview_path(root.path(), "/").is_err()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/preview/desktop.rs new file mode 100644 index 000000000..0d0bc016b --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/preview/desktop.rs @@ -0,0 +1,81 @@ +use super::*; + +#[tauri::command] +pub(crate) fn start_local_game_preview( + project_path: String, + expected_revision: Option, + preview_source: Option, + registry: tauri::State<'_, PreviewRegistry>, +) -> Result { + let root = Path::new(project_path.trim()); + let capture = (preview_source == Some(crate::analytics::contract::PreviewSource::User)) + .then(crate::analytics::gui::capture_writer_context) + .flatten(); + start_local_game_preview_with_capture(root, expected_revision, ®istry, capture) +} + +#[tauri::command] +pub(crate) fn stop_local_game_preview( + project_path: Option, + registry: tauri::State<'_, PreviewRegistry>, +) -> Result { + let project_path = project_path + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty()); + let root = project_path.map(Path::new); + let _lock = if let Some(root) = root { + enforce_project_permission_policy(root, "preview.stop")?; + Some(acquire_project_write_lock(root, "preview.stop")?) + } else { + None + }; + stop_local_game_preview_for_root(root, ®istry) +} + +#[tauri::command] +pub(crate) fn stop_local_game_preview_if_matches( + project_path: String, + expected_preview: LocalPreviewResult, + registry: tauri::State<'_, PreviewRegistry>, +) -> Result { + stop_local_game_preview_if_matches_at( + Path::new(project_path.trim()), + &expected_preview, + ®istry, + ) +} + +#[tauri::command] +pub(crate) fn get_local_game_preview_status( + registry: tauri::State<'_, PreviewRegistry>, + project_path: Option, +) -> Result { + get_local_game_preview_status_at(®istry, project_path.as_deref()) +} + +#[tauri::command] +pub(crate) async fn get_local_game_project_revision( + project_path: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || get_local_game_project_revision_sync(project_path)) + .await + .map_err(|error| format!("读取项目版本后台任务失败:{error}"))? +} + +fn get_local_game_project_revision_sync( + project_path: String, +) -> Result { + get_local_game_project_revision_at(Path::new(project_path.trim())) +} + +#[tauri::command] +pub(crate) fn activate_local_game_preview( + registry: tauri::State<'_, PreviewRegistry>, + project_path: Option, +) -> Result { + let status = registry.status(); + validate_preview_open_project(&status, project_path.as_deref())?; + preview_open_url(&status)?; + Ok(status) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs index 620e3dc77..6d086e49c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs @@ -587,6 +587,8 @@ pub(crate) fn process_session_boot_id() -> &'static str { .as_str() } +// Runner 启动时绑定 owner;共享的会话归属与清理逻辑仍参与测试。 +#[cfg(not(test))] pub(crate) fn initialize_process_session_boot_id(boot_id: &str) -> Result<(), String> { let boot_id = boot_id.trim(); if boot_id.is_empty() 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 9dab8ecf4..b04384118 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 @@ -9,6 +9,7 @@ use super::filesystem::validate_portable_project_path_component; /// /// `expectedProjectId` / `expectedProjectRevision` 与删除、分类更新同口径:陈旧客户端 /// 不允许凭旧 revision 改写素材文件与 manifest。 +#[cfg(not(test))] #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct RenameLocalProjectAssetInput { 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 873305801..d491900c9 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 @@ -2581,6 +2581,7 @@ pub(crate) fn sanitize_file_name(file_name: &str) -> String { } } +#[cfg(not(test))] pub(crate) fn parse_asset_source_kind( value: &str, ) -> Result { 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 78ca10a5d..09d81b19f 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 @@ -69,19 +69,24 @@ fn asset_classification_update_writes_category_and_normalized_tags() { .expect("read revision before") .revision; + // 正式 camelCase 请求解码后仍须绑定同一项目、revision 和素材身份。 + let input: UpdateLocalProjectResourceClassificationInput = + serde_json::from_value(serde_json::json!({ + "projectPath": root.to_string_lossy(), + "expectedProjectId": project_id, + "expectedProjectRevision": revision_before, + "assetId": asset_id, + "category": "scene", + "tags": [" 场景 ", "", " ", "场景", "主舞台"], + })) + .expect("decode classification request"); let result = update_manifest_asset_classification_at( - &root, - &project_id, - revision_before, - &asset_id, - "scene", - vec![ - " 场景 ".to_string(), - String::new(), - " ".to_string(), - "场景".to_string(), - "主舞台".to_string(), - ], + Path::new(&input.project_path), + &input.expected_project_id, + input.expected_project_revision, + &input.asset_id, + &input.category, + input.tags, ) .expect("update classification"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs index 88f18ef86..3c10d0a02 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs @@ -1,6 +1,7 @@ use super::*; use std::collections::{BTreeMap, BTreeSet}; +#[cfg(not(test))] const RESOURCE_GRAPH_AGENT_DB_READ_BYTES: u64 = 32 * 1024 * 1024; #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] @@ -612,6 +613,8 @@ pub(crate) fn build_project_resource_graph( } } +// 桌面读取接入;依赖图构造与有界记录读取分别保留在共享实现中验证。 +#[cfg(not(test))] pub(crate) fn read_project_resource_graph_at( root: &Path, expected_project_id: &str, 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 923e1c5d3..472dad7a1 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 @@ -8523,6 +8523,77 @@ mod tests { } } + #[tokio::test] + async fn audio_dispatch_uses_current_revision_and_preserves_identity_on_remote_failure() { + let directory = tempfile::tempdir().expect("audio dispatch fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "音频派发基线测试") + .expect("initialize project"); + let operation_id = Uuid::new_v4().to_string(); + let idempotency_key = Uuid::new_v4().to_string(); + let request = prepare_local_project_audio_generation( + &operation_id, + "background-music", + "平静的钢琴曲", + "背景音乐", + &idempotency_key, + ) + .expect("prepare audio submission"); + // 提交后、后台派发前发生编辑:派发必须读取此时的 revision。 + let mut revision = read_game_creator_agent_runtime_project_revision(root) + .expect("read submission revision"); + revision.revision += 1; + write_game_creator_agent_runtime_project_revision(root, &revision) + .expect("advance revision before dispatch"); + let manifest_before = fs::read(root.join(".agent/manifest.json")).expect("manifest before"); + let listener = TcpListener::bind("127.0.0.1:0").expect("audio API fixture"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let server = std::thread::spawn(move || { + let mut stream = + accept_resource_editor_fixture_connection(&listener, "audio dispatch", 0); + let request = read_http_request(&mut stream); + assert!( + request.starts_with("GET /api/external/v1/editor/projects?view=summary "), + "{request}" + ); + write_json( + &mut stream, + "503 Service Unavailable", + serde_json::json!({"error":{"code":"UNAVAILABLE","message":"fixture unavailable"}}), + ); + }); + let error = with_test_external_editor_credentials(&base_url, "audio-fixture-key", async { + run_local_project_audio_generation_at(root.to_str().expect("project path"), &request) + .await + }) + .await + .expect_err("remote context failure must stop dispatch"); + server.join().expect("audio fixture completed"); + assert!(error.contains("读取外部画布项目"), "{error}"); + let ledger = read_resource_edit_ledger(root, &operation_id) + .expect("read audio ledger") + .expect("prepared audio ledger"); + assert_eq!(ledger.expected_project_revision, revision.revision); + assert_eq!(ledger.operation_id, operation_id); + assert_eq!(ledger.idempotency_key, idempotency_key); + assert_eq!( + ledger.generation_mode, + LocalProjectResourceGenerationMode::Create + ); + assert_eq!(ledger.phase, ResourceEditLedgerPhase::Prepared); + assert!(ledger.remote_operation_id.is_none()); + assert_eq!( + fs::read(root.join(".agent/manifest.json")).unwrap(), + manifest_before + ); + assert_eq!( + read_game_creator_agent_runtime_project_revision(root) + .unwrap() + .revision, + revision.revision, + ); + } + #[tokio::test] async fn canvas_context_transport_failure_stays_prepared_before_resume() { let directory = tempfile::tempdir().expect("create transport failure fixture"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/version_resource_replacement.rs b/apps/ai-game-creator-shell/src-tauri/src/project/version_resource_replacement.rs index 9da8b13de..41036d92d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/version_resource_replacement.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/version_resource_replacement.rs @@ -33,6 +33,7 @@ const VERSION_REPLACEMENT_MAX_ID_CHARS: usize = 512; /// 直接替换的审计记录类型,与 `asset.register` / `asset.update` 同族。 const VERSION_BINDING_REPLACE_RECORD_TYPE: &str = "asset.version_binding.replace"; +#[cfg(not(test))] #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct ReadLocalProjectVersionReplacementCandidatesInput { @@ -93,6 +94,7 @@ pub(crate) struct ReadLocalProjectVersionReplacementCandidatesResult { pub(crate) candidates: Vec, } +#[cfg(not(test))] #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct ReplaceLocalProjectVersionResourceInput { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/desktop.rs new file mode 100644 index 000000000..f3d25bca5 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/desktop.rs @@ -0,0 +1,242 @@ +use super::*; +use tauri::Manager; + +/// 应用退出前等待在途同步完成的上限。 +const PROJECT_SNAPSHOT_EXIT_BUDGET_SECONDS: u64 = 15; +const PROJECT_SNAPSHOT_DEFAULT_SYNC_INTERVAL_SECONDS: u64 = 300; +const PROJECT_SNAPSHOT_SYNC_INTERVAL_ENV: &str = + "GENARRATIVE_AGC_PROJECT_SNAPSHOT_SYNC_INTERVAL_SECONDS"; + +fn project_snapshot_sync_interval() -> Duration { + let seconds = std::env::var(PROJECT_SNAPSHOT_SYNC_INTERVAL_ENV) + .ok() + .and_then(|value| value.trim().parse::().ok()) + .filter(|seconds| *seconds > 0) + .unwrap_or(PROJECT_SNAPSHOT_DEFAULT_SYNC_INTERVAL_SECONDS); + Duration::from_secs(seconds) +} + +pub(crate) fn request_project_snapshot_sync( + project_root: PathBuf, + trigger: ProjectSnapshotSyncTrigger, +) { + if !project_snapshot_sync_enabled() { + return; + } + let key = project_snapshot_sync_key(&project_root); + let spawn = spawn_project_snapshot_sync_task(move || { + let run = || sync_project_snapshot_blocking(&project_root, trigger); + let outcome = if matches!(trigger, ProjectSnapshotSyncTrigger::Periodic) { + try_run_project_snapshot_sync(&key, run) + } else { + Some(run_project_snapshot_sync(&key, run)) + }; + if let Some(Err(error)) = outcome { + let error = error.replace(project_root.to_string_lossy().as_ref(), ""); + app_log!( + "project_snapshot.sync.failed projectKey={:016x} trigger={}: {error}", + fnv1a64(key.as_bytes()), + trigger.as_str() + ); + } + }); + if let Err(error) = spawn { + app_log!("project_snapshot.sync.spawn.failed: {error}"); + } +} + +fn sync_project_snapshot_blocking( + project_root: &Path, + trigger: ProjectSnapshotSyncTrigger, +) -> Result { + tauri::async_runtime::block_on(sync_project_snapshot_async(project_root, trigger)) +} + +/// 应用退出前等待在途同步收尾。 +/// +/// 退出时刻窗口已销毁,按窗口重新枚举项目只会得到空集,因此这里不重复发起同步: +/// 关窗触发的同步已经在 `CloseRequested` 时启动,退出路径只需要给它一个有界窗口。 +pub(crate) fn wait_for_project_snapshot_syncs_on_exit() { + if !project_snapshot_sync_enabled() { + return; + } + if !wait_for_project_snapshot_syncs(Duration::from_secs(PROJECT_SNAPSHOT_EXIT_BUDGET_SECONDS)) { + app_log!("project_snapshot.sync.exit.budget-exhausted"); + } +} + +/// 周期定时器:只为当前窗口登记的项目触发,不扫描其它本地项目。 +pub(crate) fn spawn_project_snapshot_scheduler(app: tauri::AppHandle) { + if !project_snapshot_sync_enabled() { + app_log!("project_snapshot.scheduler.disabled"); + return; + } + let interval = project_snapshot_sync_interval(); + let spawned = std::thread::Builder::new() + .name("agc-project-snapshot-scheduler".to_string()) + .spawn(move || loop { + std::thread::sleep(interval); + for project_path in open_project_snapshot_workspaces(&app) { + request_project_snapshot_sync( + PathBuf::from(project_path), + ProjectSnapshotSyncTrigger::Periodic, + ); + } + }); + if let Err(error) = spawned { + app_log!("project_snapshot.scheduler.spawn.failed: {error}"); + } +} + +fn resolve_project_snapshot_root(project_path: &str) -> Result { + let trimmed = project_path.trim(); + if trimmed.is_empty() { + return Err("请提供项目绝对路径".to_string()); + } + let root = PathBuf::from(trimmed); + validate_project_root(&root)?; + Ok(root) +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectSnapshotStateView { + project_id: String, + index_path: String, + index_present: bool, + file_count: usize, + sync_revision: u64, + synced_at_ms: u64, + enabled: bool, +} + +/// 手动触发一次项目快照同步。同步本身跑在阻塞工作线程上,不占用窗口线程。 +#[tauri::command] +pub(crate) async fn sync_local_project_snapshot( + project_path: String, +) -> Result { + let root = resolve_project_snapshot_root(&project_path)?; + let key = project_snapshot_sync_key(&root); + tauri::async_runtime::spawn_blocking(move || { + run_project_snapshot_sync(&key, || { + sync_project_snapshot_blocking(&root, ProjectSnapshotSyncTrigger::Manual) + }) + }) + .await + .map_err(|error| format!("项目快照同步任务失败:{error}"))? +} + +#[tauri::command] +pub(crate) fn read_local_project_snapshot_state( + project_path: String, +) -> Result { + let root = resolve_project_snapshot_root(&project_path)?; + let manifest = read_existing_manifest_for_project(&root)?; + let project_id = validate_project_snapshot_project_id(manifest.project_id.trim())?; + let index_path = project_snapshot_index_path(&project_id)?; + let index = read_project_snapshot_index(&project_id)?; + Ok(ProjectSnapshotStateView { + project_id, + index_path: index_path.to_string_lossy().into_owned(), + index_present: index_path.exists(), + file_count: index.files.len(), + sync_revision: index.sync_revision, + synced_at_ms: index.synced_at_ms, + enabled: project_snapshot_sync_enabled(), + }) +} + +fn project_snapshot_index_path(project_id: &str) -> Result { + Ok(project_snapshot_index_path_at( + &project_snapshot_index_directory(project_id)?, + )) +} + +static PROJECT_SNAPSHOT_WORKSPACES: OnceLock> = OnceLock::new(); + +fn project_snapshot_workspaces() -> &'static Mutex { + PROJECT_SNAPSHOT_WORKSPACES.get_or_init(|| Mutex::new(ProjectSnapshotWorkspaces::default())) +} + +/// 窗口身份由 Tauri 注入,前端只能登记自身已经打开的普通项目目录。 +#[tauri::command] +pub(crate) fn set_active_project_snapshot_workspace( + window: tauri::Window, + project_path: Option, +) -> Result<(), String> { + let project_path = project_path + .map(|path| { + let root = resolve_project_snapshot_root(&path)?; + let manifest = read_existing_manifest_for_project(&root)?; + validate_project_snapshot_project_id(manifest.project_id.trim())?; + Ok::<_, String>(root.to_string_lossy().into_owned()) + }) + .transpose() + .inspect_err(|_| { + app_log!( + "project_snapshot.workspace.registration.failed window={} reason=invalid-project", + window.label() + ); + })?; + let requests = project_snapshot_workspaces() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .set_project(window.label(), project_path); + for (root, trigger) in requests { + request_project_snapshot_sync(root, trigger); + } + Ok(()) +} + +fn open_project_snapshot_workspaces(app: &tauri::AppHandle) -> Vec { + let registry = project_snapshot_workspaces() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone(); + // WebView URL 读取可能回到主线程,不能持登记锁等待它,否则会与关窗/登记互锁。 + let mut paths = BTreeMap::new(); + for window in app.webview_windows().into_values() { + let url_project = window + .url() + .ok() + .and_then(|url| project_snapshot_project_path_from_url(&url)); + if let Some(path) = registry.project_for_window(window.label(), url_project) { + paths.insert(project_snapshot_sync_key(Path::new(&path)), path); + } + } + paths.into_values().collect() +} + +pub(crate) fn handle_project_snapshot_window_event( + window: &tauri::Window, + event: &tauri::WindowEvent, +) { + if matches!(event, tauri::WindowEvent::Destroyed) { + project_snapshot_workspaces() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .windows + .remove(window.label()); + return; + } + if !matches!(event, tauri::WindowEvent::CloseRequested { .. }) { + return; + } + let url_project = window + .app_handle() + .get_webview_window(window.label()) + .and_then(|webview| webview.url().ok()) + .and_then(|url| project_snapshot_project_path_from_url(&url)); + let mut registry = project_snapshot_workspaces() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let project_path = registry.project_for_window(window.label(), url_project); + registry.set_project(window.label(), None); + drop(registry); + if let Some(project_path) = project_path { + request_project_snapshot_sync( + PathBuf::from(project_path), + ProjectSnapshotSyncTrigger::ProjectClose, + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/index.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/index.rs index 010642de6..d48b8a2b8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/index.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/index.rs @@ -69,12 +69,6 @@ pub(crate) fn project_snapshot_index_directory_at( Ok(config_dir.join(PROJECT_SNAPSHOT_DIRECTORY).join(project_id)) } -pub(crate) fn project_snapshot_index_path(project_id: &str) -> Result { - Ok(project_snapshot_index_path_at( - &project_snapshot_index_directory(project_id)?, - )) -} - pub(crate) fn project_snapshot_index_path_at(directory: &Path) -> PathBuf { directory.join(PROJECT_SNAPSHOT_INDEX_FILE_NAME) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/lifecycle.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/lifecycle.rs index ece545897..2b0fcf466 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/lifecycle.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/lifecycle.rs @@ -3,7 +3,7 @@ use super::*; /// 显式空登记保留在表中,避免返回首页后又从窗口的旧 URL 恢复项目。 #[derive(Clone, Default)] pub(crate) struct ProjectSnapshotWorkspaces { - windows: BTreeMap>, + pub(super) windows: BTreeMap>, } impl ProjectSnapshotWorkspaces { @@ -44,92 +44,3 @@ impl ProjectSnapshotWorkspaces { requests } } - -static PROJECT_SNAPSHOT_WORKSPACES: OnceLock> = OnceLock::new(); - -fn project_snapshot_workspaces() -> &'static Mutex { - PROJECT_SNAPSHOT_WORKSPACES.get_or_init(|| Mutex::new(ProjectSnapshotWorkspaces::default())) -} - -/// 窗口身份由 Tauri 注入,前端只能登记自身已经打开的普通项目目录。 -#[tauri::command] -pub(crate) fn set_active_project_snapshot_workspace( - window: tauri::Window, - project_path: Option, -) -> Result<(), String> { - let project_path = project_path - .map(|path| { - let root = resolve_project_snapshot_root(&path)?; - let manifest = read_existing_manifest_for_project(&root)?; - validate_project_snapshot_project_id(manifest.project_id.trim())?; - Ok::<_, String>(root.to_string_lossy().into_owned()) - }) - .transpose() - .inspect_err(|_| { - app_log!( - "project_snapshot.workspace.registration.failed window={} reason=invalid-project", - window.label() - ); - })?; - let requests = project_snapshot_workspaces() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .set_project(window.label(), project_path); - for (root, trigger) in requests { - request_project_snapshot_sync(root, trigger); - } - Ok(()) -} - -pub(crate) fn open_project_snapshot_workspaces(app: &tauri::AppHandle) -> Vec { - let registry = project_snapshot_workspaces() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .clone(); - // WebView URL 读取可能回到主线程,不能持登记锁等待它,否则会与关窗/登记互锁。 - let mut paths = BTreeMap::new(); - for window in app.webview_windows().into_values() { - let url_project = window - .url() - .ok() - .and_then(|url| project_snapshot_project_path_from_url(&url)); - if let Some(path) = registry.project_for_window(window.label(), url_project) { - paths.insert(project_snapshot_sync_key(Path::new(&path)), path); - } - } - paths.into_values().collect() -} - -pub(crate) fn handle_project_snapshot_window_event( - window: &tauri::Window, - event: &tauri::WindowEvent, -) { - if matches!(event, tauri::WindowEvent::Destroyed) { - project_snapshot_workspaces() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .windows - .remove(window.label()); - return; - } - if !matches!(event, tauri::WindowEvent::CloseRequested { .. }) { - return; - } - let url_project = window - .app_handle() - .get_webview_window(window.label()) - .and_then(|webview| webview.url().ok()) - .and_then(|url| project_snapshot_project_path_from_url(&url)); - let mut registry = project_snapshot_workspaces() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let project_path = registry.project_for_window(window.label(), url_project); - registry.set_project(window.label(), None); - drop(registry); - if let Some(project_path) = project_path { - request_project_snapshot_sync( - PathBuf::from(project_path), - ProjectSnapshotSyncTrigger::ProjectClose, - ); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs index 503682525..97bd7907b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs @@ -9,6 +9,8 @@ use std::collections::BTreeSet; use std::sync::{Condvar, MutexGuard}; use std::time::Instant; +#[cfg(not(test))] +mod desktop; mod diff; mod index; mod lifecycle; @@ -18,6 +20,8 @@ mod transport; #[cfg(test)] mod tests; +#[cfg(not(test))] +pub(crate) use desktop::*; pub(crate) use diff::*; pub(crate) use index::*; pub(crate) use lifecycle::*; @@ -43,16 +47,12 @@ const PROJECT_SNAPSHOT_MANIFEST_ENDPOINT: &str = "/api/agc/project-snapshots/man /// 单个请求的完成上限;关闭与退出路径依赖它保持有界。 const PROJECT_SNAPSHOT_REQUEST_TIMEOUT_SECONDS: u64 = 60; -/// 应用退出前等待在途同步完成的上限。 -const PROJECT_SNAPSHOT_EXIT_BUDGET_SECONDS: u64 = 15; -const PROJECT_SNAPSHOT_DEFAULT_SYNC_INTERVAL_SECONDS: u64 = 300; -const PROJECT_SNAPSHOT_SYNC_INTERVAL_ENV: &str = - "GENARRATIVE_AGC_PROJECT_SNAPSHOT_SYNC_INTERVAL_SECONDS"; const PROJECT_SNAPSHOT_DISABLED_ENV: &str = "GENARRATIVE_AGC_PROJECT_SNAPSHOT_DISABLED"; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum ProjectSnapshotSyncTrigger { ProjectOpen, + #[cfg(not(test))] Periodic, ProjectClose, Manual, @@ -62,6 +62,7 @@ impl ProjectSnapshotSyncTrigger { fn as_str(self) -> &'static str { match self { Self::ProjectOpen => "project-open", + #[cfg(not(test))] Self::Periodic => "periodic", Self::ProjectClose => "project-close", Self::Manual => "manual", @@ -109,15 +110,6 @@ pub(crate) fn project_snapshot_sync_enabled() -> bool { } } -pub(crate) fn project_snapshot_sync_interval() -> Duration { - let seconds = std::env::var(PROJECT_SNAPSHOT_SYNC_INTERVAL_ENV) - .ok() - .and_then(|value| value.trim().parse::().ok()) - .filter(|seconds| *seconds > 0) - .unwrap_or(PROJECT_SNAPSHOT_DEFAULT_SYNC_INTERVAL_SECONDS); - Duration::from_secs(seconds) -} - /// 同一项目的同步串行执行;周期触发遇到在途同步直接让位,不排队堆积。 static PROJECT_SNAPSHOT_PROJECT_LOCKS: OnceLock>>>> = OnceLock::new(); @@ -232,35 +224,6 @@ fn spawn_project_snapshot_sync_task( }) } -pub(crate) fn request_project_snapshot_sync( - project_root: PathBuf, - trigger: ProjectSnapshotSyncTrigger, -) { - if !project_snapshot_sync_enabled() { - return; - } - let key = project_snapshot_sync_key(&project_root); - let spawn = spawn_project_snapshot_sync_task(move || { - let run = || sync_project_snapshot_blocking(&project_root, trigger); - let outcome = if matches!(trigger, ProjectSnapshotSyncTrigger::Periodic) { - try_run_project_snapshot_sync(&key, run) - } else { - Some(run_project_snapshot_sync(&key, run)) - }; - if let Some(Err(error)) = outcome { - let error = error.replace(project_root.to_string_lossy().as_ref(), ""); - app_log!( - "project_snapshot.sync.failed projectKey={:016x} trigger={}: {error}", - fnv1a64(key.as_bytes()), - trigger.as_str() - ); - } - }); - if let Err(error) = spawn { - app_log!("project_snapshot.sync.spawn.failed: {error}"); - } -} - pub(crate) fn project_snapshot_sync_key(project_root: &Path) -> String { let value = project_root.to_string_lossy().replace('\\', "/"); #[cfg(windows)] @@ -273,13 +236,6 @@ pub(crate) fn project_snapshot_sync_key(project_root: &Path) -> String { } } -fn sync_project_snapshot_blocking( - project_root: &Path, - trigger: ProjectSnapshotSyncTrigger, -) -> Result { - tauri::async_runtime::block_on(sync_project_snapshot_async(project_root, trigger)) -} - async fn sync_project_snapshot_async( project_root: &Path, trigger: ProjectSnapshotSyncTrigger, @@ -463,97 +419,3 @@ pub(crate) fn project_snapshot_project_path_from_url(url: &url::Url) -> Option Result { - let trimmed = project_path.trim(); - if trimmed.is_empty() { - return Err("请提供项目绝对路径".to_string()); - } - let root = PathBuf::from(trimmed); - validate_project_root(&root)?; - Ok(root) -} - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct ProjectSnapshotStateView { - project_id: String, - index_path: String, - index_present: bool, - file_count: usize, - sync_revision: u64, - synced_at_ms: u64, - enabled: bool, -} - -/// 手动触发一次项目快照同步。同步本身跑在阻塞工作线程上,不占用窗口线程。 -#[tauri::command] -pub(crate) async fn sync_local_project_snapshot( - project_path: String, -) -> Result { - let root = resolve_project_snapshot_root(&project_path)?; - let key = project_snapshot_sync_key(&root); - tauri::async_runtime::spawn_blocking(move || { - run_project_snapshot_sync(&key, || { - sync_project_snapshot_blocking(&root, ProjectSnapshotSyncTrigger::Manual) - }) - }) - .await - .map_err(|error| format!("项目快照同步任务失败:{error}"))? -} - -#[tauri::command] -pub(crate) fn read_local_project_snapshot_state( - project_path: String, -) -> Result { - let root = resolve_project_snapshot_root(&project_path)?; - let manifest = read_existing_manifest_for_project(&root)?; - let project_id = validate_project_snapshot_project_id(manifest.project_id.trim())?; - let index_path = project_snapshot_index_path(&project_id)?; - let index = read_project_snapshot_index(&project_id)?; - Ok(ProjectSnapshotStateView { - project_id, - index_path: index_path.to_string_lossy().into_owned(), - index_present: index_path.exists(), - file_count: index.files.len(), - sync_revision: index.sync_revision, - synced_at_ms: index.synced_at_ms, - enabled: project_snapshot_sync_enabled(), - }) -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner.rs b/apps/ai-game-creator-shell/src-tauri/src/runner.rs index e10cd50a3..3c0a813d6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner.rs @@ -6,25 +6,28 @@ mod protocol; mod server; mod state; -#[allow(unused_imports)] +#[cfg(any(not(test), unix))] +pub(crate) use client::configure_external_agent_runner_read_only; +#[cfg(not(test))] pub(crate) use client::{ - attach_external_agent_runner_gui_owner, cancel_external_agent_runner_goal, - clear_external_agent_runner_platform_session, compact_external_agent_runner_context, - configure_external_agent_runner, configure_external_agent_runner_read_only, - continue_external_agent_runner_action, ensure_external_agent_runner_started, - hold_external_agent_runner_gui_participant_lock, - install_external_agent_runner_platform_session, pause_external_agent_runner, - read_external_agent_runner_status, - require_external_agent_runner_configured_for_cli_runtime_write, - require_external_agent_runner_for_cli_runtime_write, resume_external_agent_runner, - shutdown_external_agent_runner_for_gui_exit, shutdown_external_agent_runner_if_idle, - steer_external_agent_runner, wake_external_agent_runner_pending, - wake_external_agent_runner_pending_for_run, + attach_external_agent_runner_gui_owner, clear_external_agent_runner_platform_session, + configure_external_agent_runner, hold_external_agent_runner_gui_participant_lock, + install_external_agent_runner_platform_session, shutdown_external_agent_runner_for_gui_exit, }; pub(crate) use client::{ call_external_managed_editor, disconnect_external_managed_editor_project, mark_external_editor_uncertain, }; +#[allow(unused_imports)] +pub(crate) use client::{ + cancel_external_agent_runner_goal, compact_external_agent_runner_context, + continue_external_agent_runner_action, ensure_external_agent_runner_started, + pause_external_agent_runner, read_external_agent_runner_status, + require_external_agent_runner_configured_for_cli_runtime_write, + require_external_agent_runner_for_cli_runtime_write, resume_external_agent_runner, + shutdown_external_agent_runner_if_idle, steer_external_agent_runner, + wake_external_agent_runner_pending, wake_external_agent_runner_pending_for_run, +}; pub(crate) use endpoint::external_agent_runner_process_start_identity; #[cfg(windows)] pub(crate) use endpoint::validate_windows_regular_file_handle; diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index f67bf93aa..8436a1cbe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -1,5 +1,7 @@ use super::{dispatch::*, endpoint::*, project_owner::*, protocol::*, state::*}; -use crate::{AgentRuntimeContextCompactionResult, GameCreatorManifestInvalidationEventSink}; +use crate::AgentRuntimeContextCompactionResult; +#[cfg(not(test))] +use crate::GameCreatorManifestInvalidationEventSink; use serde_json::Value; use sha2::{Digest as _, Sha256}; use std::ffi::OsString; @@ -44,6 +46,7 @@ static EXTERNAL_AGENT_RUNNER_GUI_OWNER_ATTACHMENT_STATE: OnceLock< Mutex, > = OnceLock::new(); +#[cfg(not(test))] static EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK: OnceLock< Mutex>, > = OnceLock::new(); @@ -54,6 +57,7 @@ fn external_agent_runner_gui_owner_attachment_state( .get_or_init(|| Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default())) } +#[cfg(not(test))] fn external_agent_runner_gui_participant_lock( ) -> &'static Mutex> { EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK.get_or_init(|| Mutex::new(None)) @@ -63,6 +67,7 @@ fn external_agent_runner_gui_participant_lock( /// /// 参与锁是共享句柄锁:同一 AppData 的多个窗口可以同时持有,Runner 用独占探测 /// 判断是否仍有窗口存活,因此这个锁同时也是“Runner 不能先退出”的存活凭据。 +#[cfg(not(test))] pub(crate) fn hold_external_agent_runner_gui_participant_lock( config_dir: &Path, ) -> Result<(), String> { @@ -71,6 +76,7 @@ pub(crate) fn hold_external_agent_runner_gui_participant_lock( Ok(()) } +#[cfg(not(test))] fn release_external_agent_runner_gui_participant_lock() { drop(lock_unpoisoned(external_agent_runner_gui_participant_lock()).take()); } @@ -1025,6 +1031,7 @@ fn force_terminate_external_agent_runner_process( Err("当前平台不支持核验并强制终止 Agent Runner".to_string()) } +#[cfg(not(test))] pub(crate) fn attach_external_agent_runner_gui_owner( event_sink: &GameCreatorManifestInvalidationEventSink, ) -> Result<(), String> { @@ -1072,6 +1079,7 @@ pub(crate) fn attach_external_agent_runner_gui_owner( ensure_external_agent_runner(&config_dir).map(|_| ()) } +#[cfg(not(test))] pub(crate) fn install_external_agent_runner_platform_session( user_id: &str, access_token: &str, @@ -1106,6 +1114,7 @@ pub(crate) fn install_external_agent_runner_platform_session( ) } +#[cfg(not(test))] pub(crate) fn clear_external_agent_runner_platform_session( identity_generation: u64, revision: u64, @@ -1138,6 +1147,7 @@ pub(crate) fn clear_external_agent_runner_platform_session( ) } +#[cfg(not(test))] fn validate_external_agent_runner_platform_session_attachment( state: &Mutex, config_dir: &Path, @@ -1310,6 +1320,7 @@ fn attach_registered_external_agent_runner_gui_owner_if_needed( /// /// 返回 `Ok(false)` 表示仍检测到其它窗口持有参与锁,Runner 必须保留给它们; /// 返回 `Ok(true)` 表示本窗口是最后一个界面进程,Runner 已请求关闭。 +#[cfg(not(test))] pub(crate) fn shutdown_external_agent_runner_for_gui_exit() -> Result { let _configure = lock_unpoisoned(external_agent_runner_configure_lock()); let Some(config_dir) = external_agent_runner_config_dir() else { @@ -1436,6 +1447,7 @@ fn reuse_or_retire_external_agent_runner_endpoint( Ok(None) } +#[cfg(not(test))] pub(crate) fn configure_external_agent_runner(config_dir: impl AsRef) -> Result<(), String> { let _configure = lock_unpoisoned(external_agent_runner_configure_lock()); let config_dir = normalize_external_agent_runner_config_dir(config_dir.as_ref())?; @@ -1443,6 +1455,7 @@ pub(crate) fn configure_external_agent_runner(config_dir: impl AsRef) -> R Ok(()) } +#[cfg(any(not(test), unix))] pub(crate) fn configure_external_agent_runner_read_only( config_dir: impl AsRef, ) -> Result<(), String> { diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs index 1bb9fcce0..b1ac77ef2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs @@ -1,17 +1,20 @@ use super::{endpoint::*, project_owner::*, protocol::*, state::*}; + +#[cfg(not(test))] +mod desktop; use crate::{ register_game_creator_manifest_invalidation_event_sink, validate_game_creator_manifest_invalidation_event_sink, }; +#[cfg(not(test))] +pub(super) use desktop::handle_external_agent_runner_connection; use serde::Deserialize; use serde_json::json; use sha2::{Digest as _, Sha256}; use std::fs; use std::io::{self, Read, Write}; -use std::net::TcpStream; use std::path::{Path, PathBuf}; use std::sync::atomic::Ordering; -use std::sync::Arc; pub(super) fn read_external_agent_runner_frame( reader: &mut R, @@ -1281,64 +1284,3 @@ pub(super) fn external_agent_runner_known_roots_are_idle( } Ok(true) } - -pub(super) fn write_external_agent_runner_response( - stream: &mut TcpStream, - response: &ExternalAgentRunnerResponse, -) -> Result<(), String> { - let payload = - serde_json::to_vec(response).map_err(|_| "序列化 Agent Runner 响应失败".to_string())?; - write_external_agent_runner_frame(stream, &payload) - .map_err(|error| format!("写入 Agent Runner 响应失败:{error}"))?; - stream - .flush() - .map_err(|error| format!("刷新 Agent Runner 响应失败:{error}")) -} - -pub(super) fn handle_external_agent_runner_connection( - mut stream: TcpStream, - state: Arc, -) -> Result<(), String> { - let _active = ExternalAgentRunnerActiveConnection { state: &state }; - stream - .set_read_timeout(Some(EXTERNAL_AGENT_RUNNER_IO_TIMEOUT)) - .and_then(|_| stream.set_write_timeout(Some(EXTERNAL_AGENT_RUNNER_IO_TIMEOUT))) - .map_err(|error| format!("配置 Agent Runner 连接超时失败:{error}"))?; - - let payload = match read_external_agent_runner_frame(&mut stream) { - Ok(payload) => payload, - Err(ExternalAgentRunnerFrameError::Oversize(_)) => { - let response = ExternalAgentRunnerResponse::failure( - "", - "frame-too-large", - "Agent Runner 请求超过 1 MiB 上限", - ); - return write_external_agent_runner_response(&mut stream, &response); - } - Err(ExternalAgentRunnerFrameError::Io(error)) - if matches!( - error.kind(), - io::ErrorKind::UnexpectedEof - | io::ErrorKind::ConnectionReset - | io::ErrorKind::TimedOut - | io::ErrorKind::WouldBlock - ) => - { - return Ok(()); - } - Err(error) => return Err(format!("读取 Agent Runner 请求失败:{error}")), - }; - let request = match serde_json::from_slice::(&payload) { - Ok(request) => request, - Err(_) => { - let response = ExternalAgentRunnerResponse::failure( - "", - "invalid-json", - "Agent Runner 请求 JSON 无效", - ); - return write_external_agent_runner_response(&mut stream, &response); - } - }; - let response = handle_external_agent_runner_request(request, &state); - write_external_agent_runner_response(&mut stream, &response) -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch/desktop.rs new file mode 100644 index 000000000..cce424ac5 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch/desktop.rs @@ -0,0 +1,64 @@ +use super::*; +use std::net::TcpStream; +use std::sync::Arc; + +pub(super) fn write_external_agent_runner_response( + stream: &mut TcpStream, + response: &ExternalAgentRunnerResponse, +) -> Result<(), String> { + let payload = + serde_json::to_vec(response).map_err(|_| "序列化 Agent Runner 响应失败".to_string())?; + write_external_agent_runner_frame(stream, &payload) + .map_err(|error| format!("写入 Agent Runner 响应失败:{error}"))?; + stream + .flush() + .map_err(|error| format!("刷新 Agent Runner 响应失败:{error}")) +} + +pub(in crate::runner) fn handle_external_agent_runner_connection( + mut stream: TcpStream, + state: Arc, +) -> Result<(), String> { + let _active = ExternalAgentRunnerActiveConnection { state: &state }; + stream + .set_read_timeout(Some(EXTERNAL_AGENT_RUNNER_IO_TIMEOUT)) + .and_then(|_| stream.set_write_timeout(Some(EXTERNAL_AGENT_RUNNER_IO_TIMEOUT))) + .map_err(|error| format!("配置 Agent Runner 连接超时失败:{error}"))?; + + let payload = match read_external_agent_runner_frame(&mut stream) { + Ok(payload) => payload, + Err(ExternalAgentRunnerFrameError::Oversize(_)) => { + let response = ExternalAgentRunnerResponse::failure( + "", + "frame-too-large", + "Agent Runner 请求超过 1 MiB 上限", + ); + return write_external_agent_runner_response(&mut stream, &response); + } + Err(ExternalAgentRunnerFrameError::Io(error)) + if matches!( + error.kind(), + io::ErrorKind::UnexpectedEof + | io::ErrorKind::ConnectionReset + | io::ErrorKind::TimedOut + | io::ErrorKind::WouldBlock + ) => + { + return Ok(()); + } + Err(error) => return Err(format!("读取 Agent Runner 请求失败:{error}")), + }; + let request = match serde_json::from_slice::(&payload) { + Ok(request) => request, + Err(_) => { + let response = ExternalAgentRunnerResponse::failure( + "", + "invalid-json", + "Agent Runner 请求 JSON 无效", + ); + return write_external_agent_runner_response(&mut stream, &response); + } + }; + let response = handle_external_agent_runner_request(request, &state); + write_external_agent_runner_response(&mut stream, &response) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs index 267943ae2..92d30e90a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs @@ -21,6 +21,7 @@ pub(super) fn external_agent_runner_configure_lock() -> &'static Mutex<()> { EXTERNAL_AGENT_RUNNER_CONFIGURE_LOCK.get_or_init(|| Mutex::new(())) } +#[cfg(any(not(test), unix))] pub(super) fn set_external_agent_runner_config_dir(config_dir: PathBuf) { *lock_unpoisoned(external_agent_runner_config_dir_lock()) = Some(config_dir); } @@ -290,12 +291,14 @@ pub(super) fn redact_external_agent_runner_runtime_error( crate::redact_agent_runtime_error(root, &redacted, 500) } +#[cfg(not(test))] pub(super) fn normalize_external_agent_runner_config_dir( config_dir: &Path, ) -> Result { crate::prepare_game_creator_runtime_config_dir(config_dir) } +#[cfg(any(not(test), unix))] pub(super) fn inspect_external_agent_runner_config_dir( config_dir: &Path, ) -> Result { diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs index 434c1c0a9..0fcd9cac9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs @@ -28,6 +28,7 @@ pub(super) const EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_DIAGNOSTIC_PATH: &str = pub(super) const EXTERNAL_AGENT_RUNNER_MAX_FRAME_BYTES: usize = 4 * 1024 * 1024; pub(super) const EXTERNAL_AGENT_RUNNER_MAX_ENDPOINT_BYTES: u64 = 64 * 1024; pub(super) const EXTERNAL_AGENT_RUNNER_MAX_OWNER_BYTES: u64 = 16 * 1024; +#[cfg(not(test))] pub(super) const EXTERNAL_AGENT_RUNNER_MAX_CONNECTIONS: usize = 32; pub(super) const EXTERNAL_AGENT_RUNNER_MAX_CACHED_REQUESTS: usize = 512; pub(super) const EXTERNAL_AGENT_RUNNER_RETRYABLE_WAKE_ERROR_CODE: &str = "runtime-wake-retryable"; @@ -47,11 +48,14 @@ pub(super) const EXTERNAL_AGENT_RUNNER_GUI_SHUTDOWN_EXIT_TIMEOUT: Duration = Dur pub(super) const EXTERNAL_AGENT_RUNNER_CONTEXT_COMPACTION_IO_TIMEOUT: Duration = Duration::from_secs(6 * 60); pub(super) const EXTERNAL_AGENT_RUNNER_START_TIMEOUT: Duration = Duration::from_secs(30); +#[cfg(not(test))] pub(super) const EXTERNAL_AGENT_RUNNER_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(2); +#[cfg(not(test))] pub(super) const EXTERNAL_AGENT_RUNNER_GUI_OWNER_CHECK_INTERVAL: Duration = Duration::from_millis(100); pub(super) const EXTERNAL_AGENT_RUNNER_GUI_OWNER_WATCHDOG_HARD_EXIT_TIMEOUT: Duration = Duration::from_millis(1_750); +#[cfg(not(test))] pub(super) const EXTERNAL_AGENT_RUNNER_LOOP_INTERVAL: Duration = Duration::from_millis(25); #[cfg(target_os = "linux")] pub(super) const EXTERNAL_AGENT_RUNNER_LINUX_EPHEMERAL_PORT_RANGE_PATH: &str = diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs index 1605e5be0..8f0c445a8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs @@ -1,26 +1,27 @@ -use super::{dispatch::*, endpoint::*, protocol::*, state::*}; +#[cfg(not(test))] +use super::dispatch::*; +#[cfg(test)] +use super::dispatch::{ + request_external_agent_runner_forced_shutdown, + validate_external_agent_runner_gui_owner_claim_current, +}; +#[cfg(any(not(test), target_os = "linux"))] +use super::protocol::*; +use super::{endpoint::*, state::*}; + +#[cfg(not(test))] +mod desktop; +#[cfg(not(test))] +pub(crate) use desktop::run_external_agent_runner_server; #[cfg(target_os = "linux")] use sha2::{Digest as _, Sha256}; #[cfg(target_os = "linux")] use std::fs; use std::io; use std::net::{Ipv4Addr, SocketAddrV4, TcpListener}; -use std::path::{Path, PathBuf}; +#[cfg(not(test))] +use std::path::Path; use std::sync::atomic::Ordering; -use std::sync::Arc; -use std::thread; -use std::time::{Duration, Instant}; - -pub(super) fn refresh_external_agent_runner_heartbeat( - state: &ExternalAgentRunnerServerState, -) -> Result<(), String> { - let endpoint = { - let mut endpoint = lock_unpoisoned(&state.endpoint); - endpoint.heartbeat_at = unix_millis(); - endpoint.clone() - }; - write_external_agent_runner_endpoint_atomic(&state.endpoint_path, &endpoint) -} #[cfg(any(target_os = "linux", test))] pub(super) fn bind_external_agent_runner_listener_with( @@ -178,27 +179,6 @@ pub(super) fn external_agent_runner_shutdown_if_gui_owner_lost( Ok(external_agent_runner_watchdog_tick(state)) } -pub(super) fn spawn_external_agent_runner_gui_owner_watchdog( - state: Arc, - endpoint_path: PathBuf, - boot_id: String, -) -> Result<(), String> { - thread::Builder::new() - .name("agent-runner-gui-owner-watchdog".to_string()) - .spawn(move || loop { - if !external_agent_runner_watchdog_tick(&state) { - thread::sleep(EXTERNAL_AGENT_RUNNER_GUI_OWNER_CHECK_INTERVAL); - continue; - } - thread::sleep(EXTERNAL_AGENT_RUNNER_GUI_OWNER_WATCHDOG_HARD_EXIT_TIMEOUT); - let _ = crate::agent::shutdown_game_creator_codex_app_servers(); - remove_external_agent_runner_endpoint_if_boot_matches(&endpoint_path, &boot_id); - std::process::exit(1); - }) - .map(|_| ()) - .map_err(|error| format!("启动 Agent Runner GUI owner watchdog 失败:{error}")) -} - pub(super) fn resolve_external_agent_runner_initial_gui_owner( gui_owner_required: bool, gui_owner_present: bool, @@ -208,166 +188,3 @@ pub(super) fn resolve_external_agent_runner_initial_gui_owner( } Ok(gui_owner_present) } - -pub(crate) fn run_external_agent_runner_server( - config_dir: impl AsRef, - gui_owner_required: bool, -) -> Result<(), String> { - let config_dir = normalize_external_agent_runner_config_dir(config_dir.as_ref())?; - EXTERNAL_AGENT_RUNNER_SERVER_PROCESS.store(true, Ordering::Release); - crate::set_game_creator_runtime_config_dir(config_dir.clone()); - crate::editor_adapters::configure_unity_helper_for_runtime()?; - crate::editor_adapters::configure_godot_payload_for_runtime(&config_dir)?; - set_external_agent_runner_config_dir(config_dir.clone()); - - let boot_id = random_identifier(b"genarrative-agent-runner-boot-id")?; - crate::initialize_process_session_boot_id(&boot_id)?; - let token = random_identifier(b"genarrative-agent-runner-token")?; - let _instance_lock = acquire_external_agent_runner_instance_lock( - &external_agent_runner_lock_path(&config_dir), - &boot_id, - )?; - let gui_owner_present_at_start = resolve_external_agent_runner_initial_gui_owner( - gui_owner_required, - external_agent_runner_lock_is_held(&external_agent_runner_gui_participant_lock_path( - &config_dir, - ))?, - )?; - let executable_fingerprint = current_external_agent_runner_executable_fingerprint()?; - let listener = bind_loopback_listener_with_linux_fallback(&boot_id) - .map_err(|error| format!("绑定 Agent Runner loopback 端口失败:{error}"))?; - listener - .set_nonblocking(true) - .map_err(|error| format!("配置 Agent Runner listener 失败:{error}"))?; - let port = listener - .local_addr() - .map_err(|error| format!("读取 Agent Runner loopback 地址失败:{error}"))? - .port(); - let endpoint = ExternalAgentRunnerEndpoint { - protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, - pid: std::process::id(), - boot_id: boot_id.clone(), - port, - token, - heartbeat_at: unix_millis(), - executable_fingerprint: Some(executable_fingerprint), - process_start_identity: external_agent_runner_process_start_identity(std::process::id())?, - }; - let endpoint_path = external_agent_runner_endpoint_path(&config_dir); - write_external_agent_runner_endpoint_atomic(&endpoint_path, &endpoint)?; - let _endpoint_guard = ExternalAgentRunnerEndpointGuard { - path: endpoint_path.clone(), - boot_id, - }; - let state = Arc::new(ExternalAgentRunnerServerState::new(endpoint_path, endpoint)); - state - .gui_owner_attached - .store(gui_owner_present_at_start, Ordering::Release); - spawn_external_agent_runner_gui_owner_watchdog( - Arc::clone(&state), - state.endpoint_path.clone(), - state.endpoint_snapshot().boot_id, - )?; - let mut last_heartbeat = Instant::now(); - let mut server_error = None; - - loop { - if state.shutdown_requested.load(Ordering::Acquire) { - if state.force_shutdown_requested.load(Ordering::Acquire) - || state.active_connections.load(Ordering::Acquire) == 0 - { - break; - } - thread::sleep(EXTERNAL_AGENT_RUNNER_LOOP_INTERVAL); - continue; - } - - match listener.accept() { - Ok((stream, _)) => { - let previous = state.active_connections.fetch_add(1, Ordering::AcqRel); - if previous >= EXTERNAL_AGENT_RUNNER_MAX_CONNECTIONS { - state.active_connections.fetch_sub(1, Ordering::AcqRel); - drop(stream); - continue; - } - let worker_state = Arc::clone(&state); - if thread::Builder::new() - .name("agent-runner-connection".to_string()) - .spawn(move || { - let _ = handle_external_agent_runner_connection(stream, worker_state); - }) - .is_err() - { - state.active_connections.fetch_sub(1, Ordering::AcqRel); - } - } - Err(error) if error.kind() == io::ErrorKind::WouldBlock => {} - Err(error) => { - server_error = Some(format!("接受 Agent Runner 连接失败:{error}")); - break; - } - } - - if last_heartbeat.elapsed() >= EXTERNAL_AGENT_RUNNER_HEARTBEAT_INTERVAL { - if let Err(error) = refresh_external_agent_runner_heartbeat(&state) { - server_error = Some(error); - break; - } - last_heartbeat = Instant::now(); - } - thread::sleep(EXTERNAL_AGENT_RUNNER_LOOP_INTERVAL); - } - - state.shutdown_requested.store(true, Ordering::Release); - let forced = state.force_shutdown_requested.load(Ordering::Acquire); - let forced_deadline = - forced.then(|| Instant::now() + EXTERNAL_AGENT_RUNNER_FORCED_TOTAL_DRAIN_TIMEOUT); - let worker_deadline = Instant::now() - + if forced { - EXTERNAL_AGENT_RUNNER_FORCED_WORKER_DRAIN_TIMEOUT - } else { - EXTERNAL_AGENT_RUNNER_IO_TIMEOUT - }; - while state.active_connections.load(Ordering::Acquire) > 0 && Instant::now() < worker_deadline { - thread::sleep(EXTERNAL_AGENT_RUNNER_LOOP_INTERVAL); - } - let forced_roots = forced.then(|| { - let roots = state.known_roots_snapshot(); - crate::interrupt_game_creator_agent_runtime_provider_requests_for_roots(&roots); - roots - }); - let process_timeout = forced_deadline - .map(|deadline| deadline.saturating_duration_since(Instant::now())) - .unwrap_or(Duration::from_secs(3)); - let mut process_shutdown = crate::shutdown_all_process_sessions_and_wait(process_timeout); - if forced { - let roots = forced_roots.as_deref().unwrap_or_default(); - let provider_deadline = forced_deadline.expect("forced shutdown has a deadline"); - while crate::game_creator_agent_runtime_provider_request_count_for_roots(roots) > 0 - && Instant::now() < provider_deadline - { - thread::sleep(EXTERNAL_AGENT_RUNNER_LOOP_INTERVAL); - } - if crate::game_creator_agent_runtime_provider_request_count_for_roots(roots) > 0 { - let provider_error = "Runner 退出前未能中断全部 Provider 请求".to_string(); - process_shutdown = Err(match process_shutdown { - Ok(()) => provider_error, - Err(process_error) => format!("{process_error};{provider_error}"), - }); - } - } - if let Err(app_server_error) = crate::agent::shutdown_game_creator_codex_app_servers() { - process_shutdown = Err(match process_shutdown { - Ok(()) => app_server_error, - Err(process_error) => format!("{process_error};{app_server_error}"), - }); - } - if let Some(error) = server_error { - Err(match process_shutdown { - Ok(()) => error, - Err(process_error) => format!("{error};{process_error}"), - }) - } else { - process_shutdown - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/server/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/server/desktop.rs new file mode 100644 index 000000000..aafe14438 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/server/desktop.rs @@ -0,0 +1,200 @@ +use super::*; +use std::path::PathBuf; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant}; + +pub(super) fn refresh_external_agent_runner_heartbeat( + state: &ExternalAgentRunnerServerState, +) -> Result<(), String> { + let endpoint = { + let mut endpoint = lock_unpoisoned(&state.endpoint); + endpoint.heartbeat_at = unix_millis(); + endpoint.clone() + }; + write_external_agent_runner_endpoint_atomic(&state.endpoint_path, &endpoint) +} + +pub(super) fn spawn_external_agent_runner_gui_owner_watchdog( + state: Arc, + endpoint_path: PathBuf, + boot_id: String, +) -> Result<(), String> { + thread::Builder::new() + .name("agent-runner-gui-owner-watchdog".to_string()) + .spawn(move || loop { + if !external_agent_runner_watchdog_tick(&state) { + thread::sleep(EXTERNAL_AGENT_RUNNER_GUI_OWNER_CHECK_INTERVAL); + continue; + } + thread::sleep(EXTERNAL_AGENT_RUNNER_GUI_OWNER_WATCHDOG_HARD_EXIT_TIMEOUT); + let _ = crate::agent::shutdown_game_creator_codex_app_servers(); + remove_external_agent_runner_endpoint_if_boot_matches(&endpoint_path, &boot_id); + std::process::exit(1); + }) + .map(|_| ()) + .map_err(|error| format!("启动 Agent Runner GUI owner watchdog 失败:{error}")) +} + +pub(crate) fn run_external_agent_runner_server( + config_dir: impl AsRef, + gui_owner_required: bool, +) -> Result<(), String> { + let config_dir = normalize_external_agent_runner_config_dir(config_dir.as_ref())?; + EXTERNAL_AGENT_RUNNER_SERVER_PROCESS.store(true, Ordering::Release); + crate::set_game_creator_runtime_config_dir(config_dir.clone()); + crate::editor_adapters::configure_unity_helper_for_runtime()?; + crate::editor_adapters::configure_godot_payload_for_runtime(&config_dir)?; + set_external_agent_runner_config_dir(config_dir.clone()); + + let boot_id = random_identifier(b"genarrative-agent-runner-boot-id")?; + crate::initialize_process_session_boot_id(&boot_id)?; + let token = random_identifier(b"genarrative-agent-runner-token")?; + let _instance_lock = acquire_external_agent_runner_instance_lock( + &external_agent_runner_lock_path(&config_dir), + &boot_id, + )?; + let gui_owner_present_at_start = resolve_external_agent_runner_initial_gui_owner( + gui_owner_required, + external_agent_runner_lock_is_held(&external_agent_runner_gui_participant_lock_path( + &config_dir, + ))?, + )?; + let executable_fingerprint = current_external_agent_runner_executable_fingerprint()?; + let listener = bind_loopback_listener_with_linux_fallback(&boot_id) + .map_err(|error| format!("绑定 Agent Runner loopback 端口失败:{error}"))?; + listener + .set_nonblocking(true) + .map_err(|error| format!("配置 Agent Runner listener 失败:{error}"))?; + let port = listener + .local_addr() + .map_err(|error| format!("读取 Agent Runner loopback 地址失败:{error}"))? + .port(); + let endpoint = ExternalAgentRunnerEndpoint { + protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + pid: std::process::id(), + boot_id: boot_id.clone(), + port, + token, + heartbeat_at: unix_millis(), + executable_fingerprint: Some(executable_fingerprint), + process_start_identity: external_agent_runner_process_start_identity(std::process::id())?, + }; + let endpoint_path = external_agent_runner_endpoint_path(&config_dir); + write_external_agent_runner_endpoint_atomic(&endpoint_path, &endpoint)?; + let _endpoint_guard = ExternalAgentRunnerEndpointGuard { + path: endpoint_path.clone(), + boot_id, + }; + let state = Arc::new(ExternalAgentRunnerServerState::new(endpoint_path, endpoint)); + state + .gui_owner_attached + .store(gui_owner_present_at_start, Ordering::Release); + spawn_external_agent_runner_gui_owner_watchdog( + Arc::clone(&state), + state.endpoint_path.clone(), + state.endpoint_snapshot().boot_id, + )?; + let mut last_heartbeat = Instant::now(); + let mut server_error = None; + + loop { + if state.shutdown_requested.load(Ordering::Acquire) { + if state.force_shutdown_requested.load(Ordering::Acquire) + || state.active_connections.load(Ordering::Acquire) == 0 + { + break; + } + thread::sleep(EXTERNAL_AGENT_RUNNER_LOOP_INTERVAL); + continue; + } + + match listener.accept() { + Ok((stream, _)) => { + let previous = state.active_connections.fetch_add(1, Ordering::AcqRel); + if previous >= EXTERNAL_AGENT_RUNNER_MAX_CONNECTIONS { + state.active_connections.fetch_sub(1, Ordering::AcqRel); + drop(stream); + continue; + } + let worker_state = Arc::clone(&state); + if thread::Builder::new() + .name("agent-runner-connection".to_string()) + .spawn(move || { + let _ = handle_external_agent_runner_connection(stream, worker_state); + }) + .is_err() + { + state.active_connections.fetch_sub(1, Ordering::AcqRel); + } + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => {} + Err(error) => { + server_error = Some(format!("接受 Agent Runner 连接失败:{error}")); + break; + } + } + + if last_heartbeat.elapsed() >= EXTERNAL_AGENT_RUNNER_HEARTBEAT_INTERVAL { + if let Err(error) = refresh_external_agent_runner_heartbeat(&state) { + server_error = Some(error); + break; + } + last_heartbeat = Instant::now(); + } + thread::sleep(EXTERNAL_AGENT_RUNNER_LOOP_INTERVAL); + } + + state.shutdown_requested.store(true, Ordering::Release); + let forced = state.force_shutdown_requested.load(Ordering::Acquire); + let forced_deadline = + forced.then(|| Instant::now() + EXTERNAL_AGENT_RUNNER_FORCED_TOTAL_DRAIN_TIMEOUT); + let worker_deadline = Instant::now() + + if forced { + EXTERNAL_AGENT_RUNNER_FORCED_WORKER_DRAIN_TIMEOUT + } else { + EXTERNAL_AGENT_RUNNER_IO_TIMEOUT + }; + while state.active_connections.load(Ordering::Acquire) > 0 && Instant::now() < worker_deadline { + thread::sleep(EXTERNAL_AGENT_RUNNER_LOOP_INTERVAL); + } + let forced_roots = forced.then(|| { + let roots = state.known_roots_snapshot(); + crate::interrupt_game_creator_agent_runtime_provider_requests_for_roots(&roots); + roots + }); + let process_timeout = forced_deadline + .map(|deadline| deadline.saturating_duration_since(Instant::now())) + .unwrap_or(Duration::from_secs(3)); + let mut process_shutdown = crate::shutdown_all_process_sessions_and_wait(process_timeout); + if forced { + let roots = forced_roots.as_deref().unwrap_or_default(); + let provider_deadline = forced_deadline.expect("forced shutdown has a deadline"); + while crate::game_creator_agent_runtime_provider_request_count_for_roots(roots) > 0 + && Instant::now() < provider_deadline + { + thread::sleep(EXTERNAL_AGENT_RUNNER_LOOP_INTERVAL); + } + if crate::game_creator_agent_runtime_provider_request_count_for_roots(roots) > 0 { + let provider_error = "Runner 退出前未能中断全部 Provider 请求".to_string(); + process_shutdown = Err(match process_shutdown { + Ok(()) => provider_error, + Err(process_error) => format!("{process_error};{provider_error}"), + }); + } + } + if let Err(app_server_error) = crate::agent::shutdown_game_creator_codex_app_servers() { + process_shutdown = Err(match process_shutdown { + Ok(()) => app_server_error, + Err(process_error) => format!("{process_error};{app_server_error}"), + }); + } + if let Some(error) = server_error { + Err(match process_shutdown { + Ok(()) => error, + Err(process_error) => format!("{error};{process_error}"), + }) + } else { + process_shutdown + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs index 5991626b1..28af0306d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs @@ -3,7 +3,9 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fs::{self, File}; use std::panic::{catch_unwind, AssertUnwindSafe}; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +#[cfg(not(test))] +use std::sync::atomic::Ordering; +use std::sync::atomic::{AtomicBool, AtomicUsize}; use std::sync::{Condvar, Mutex, MutexGuard}; pub(super) struct ExternalAgentRunnerServerState { @@ -217,10 +219,12 @@ impl ExternalAgentRunnerServerState { } } +#[cfg(not(test))] pub(super) struct ExternalAgentRunnerActiveConnection<'a> { pub(super) state: &'a ExternalAgentRunnerServerState, } +#[cfg(not(test))] impl Drop for ExternalAgentRunnerActiveConnection<'_> { fn drop(&mut self) { self.state.active_connections.fetch_sub(1, Ordering::AcqRel); @@ -265,11 +269,13 @@ impl ExternalAgentRunnerProjectExecutionOwner { } } +#[cfg(not(test))] pub(super) struct ExternalAgentRunnerEndpointGuard { pub(super) path: PathBuf, pub(super) boot_id: String, } +#[cfg(not(test))] pub(super) fn remove_external_agent_runner_endpoint_if_boot_matches(path: &Path, boot_id: &str) { let Ok(endpoint) = read_external_agent_runner_endpoint(path) else { return; @@ -279,6 +285,7 @@ pub(super) fn remove_external_agent_runner_endpoint_if_boot_matches(path: &Path, } } +#[cfg(not(test))] impl Drop for ExternalAgentRunnerEndpointGuard { fn drop(&mut self) { remove_external_agent_runner_endpoint_if_boot_matches(&self.path, &self.boot_id); diff --git a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs index f986be1ec..65265250e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs @@ -4,6 +4,11 @@ //! 也不改写已存在项目。远端对象只允许来自受信任 OSS 主机下的 `templates/` 前缀; //! 清单、zip 与封面一律先校验再落盘,zip 解压只接受普通文件与目录。 +#[cfg(not(test))] +mod desktop; +#[cfg(not(test))] +pub(crate) use desktop::*; + use super::*; use crate::platform_session::{ current_platform_session, validate_platform_session_identity, @@ -18,7 +23,6 @@ const TEMPLATE_LIBRARY_OSS_HOST: &str = "agc-dev.oss-rg-china-mainland.aliyuncs. const DEFAULT_TEMPLATE_LIBRARY_BASE_URL: &str = "https://agc-dev.oss-rg-china-mainland.aliyuncs.com"; const TEMPLATE_LIBRARY_BASE_URL_ENV: &str = "AGC_TEMPLATE_LIBRARY_BASE_URL"; -const TEMPLATE_CACHE_DIRECTORY_NAME: &str = "templates"; const TEMPLATE_INSTALLED_DIRECTORY_NAME: &str = "installed"; const TEMPLATE_INSTALLED_MARKER_FILE: &str = "installed.json"; const TEMPLATE_INDEX_CACHE_FILE: &str = "index.json"; @@ -179,19 +183,6 @@ pub(crate) struct GameTemplateEntry { pub(crate) installed_at_millis: Option, } -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct GameTemplateLibrarySnapshot { - pub(crate) schema_version: String, - pub(crate) library: String, - pub(crate) library_version: u32, - pub(crate) updated_at: String, - pub(crate) fetched_at_millis: u64, - /// `network` 或 `cache`:命中本机缓存时前端应提示清单可能不是最新。 - pub(crate) source: String, - pub(crate) templates: Vec, -} - #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct InstalledGameTemplateRecord { @@ -203,17 +194,6 @@ struct InstalledGameTemplateRecord { project_dir: String, } -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct InstalledGameTemplate { - pub(crate) template_id: String, - pub(crate) template_version: String, - pub(crate) installed_at_millis: u64, - pub(crate) zip_sha256: String, - pub(crate) file_count: usize, - pub(crate) project_dir: String, -} - fn now_millis() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -345,13 +325,6 @@ pub(crate) fn parse_game_template_library_index( Ok((index.header, index.templates)) } -fn template_cache_root(app: &tauri::AppHandle) -> Result { - app.path() - .app_data_dir() - .map(|root| root.join(TEMPLATE_CACHE_DIRECTORY_NAME)) - .map_err(|error| format!("无法读取 AGC 应用数据目录:{error}")) -} - fn installed_templates_root(cache_root: &Path) -> PathBuf { cache_root.join(TEMPLATE_INSTALLED_DIRECTORY_NAME) } @@ -619,15 +592,6 @@ async fn fetch_limited_bytes( Ok(body) } -fn write_cached_index(cache_root: &Path, body: &str) { - let _ = ensure_game_creator_private_directory_tree(cache_root, "模板库缓存目录"); - let _ = write_game_creator_private_file( - &cache_root.join(TEMPLATE_INDEX_CACHE_FILE), - body.as_bytes(), - "模板库清单缓存", - ); -} - fn read_cached_index(cache_root: &Path) -> Option { fs::read_to_string(cache_root.join(TEMPLATE_INDEX_CACHE_FILE)).ok() } @@ -848,85 +812,6 @@ fn resolve_template_index( } } -#[tauri::command] -pub(crate) async fn fetch_game_template_library( - app: tauri::AppHandle, -) -> Result { - let identity = require_template_library_access().await?; - let cache_root = template_cache_root(&app)?; - ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?; - let index_url = format!( - "{}/{}", - template_library_base()?, - TEMPLATE_LIBRARY_INDEX_KEY - ); - let client = build_template_library_client(); - let remote = - match fetch_limited_bytes(&client, &index_url, TEMPLATE_LIBRARY_MAX_INDEX_BYTES).await { - // 响应回来了但正文不是合法 UTF-8 属于「远端答非所问」,不能用缓存掩盖。 - Ok(bytes) => { - Ok(String::from_utf8(bytes).map_err(|_| "模板库清单不是有效 UTF-8".to_string())?) - } - Err(error) => Err(error), - }; - let (body, source) = resolve_template_index(remote, read_cached_index(&cache_root))?; - if source == TemplateIndexSource::Network { - with_validated_platform_session_identity(&identity, || { - write_cached_index(&cache_root, &body); - Ok(()) - })?; - } - validate_platform_session_identity(&identity)?; - let (header, templates) = parse_game_template_library_index(&body)?; - let installed = collect_installed_records(&cache_root); - let entries = templates - .iter() - .map(|summary| { - to_entry( - summary, - installed_record_for(&installed, &summary.id).as_ref(), - ) - }) - .collect::, _>>()?; - let entries = apply_template_library_fixtures(entries); - Ok(GameTemplateLibrarySnapshot { - schema_version: header.schema_version, - library: header.library, - library_version: header.library_version, - updated_at: header.updated_at, - fetched_at_millis: now_millis(), - source: source.as_str().to_string(), - templates: entries, - }) -} - -#[tauri::command] -pub(crate) async fn download_game_template( - app: tauri::AppHandle, - template_id: String, - template_version: String, -) -> Result { - let identity = require_template_library_access().await?; - let cache_root = template_cache_root(&app)?; - ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?; - let record = ensure_template_installed( - &cache_root, - template_id.trim(), - template_version.trim(), - &identity, - ) - .await?; - validate_platform_session_identity(&identity)?; - Ok(InstalledGameTemplate { - template_id: record.template_id, - template_version: record.template_version, - installed_at_millis: record.installed_at_millis, - zip_sha256: record.zip_sha256, - file_count: record.file_count, - project_dir: record.project_dir, - }) -} - /// 用已安装模板在自动工作区根目录下建项目:先把模板文件铺进新目录,再走标准项目初始化。 pub(crate) fn create_project_from_installed_template_at( projects_root: &Path, @@ -1025,47 +910,6 @@ pub(crate) fn create_project_from_installed_template_at( Err("自动工作区命名冲突,请重试".to_string()) } -#[tauri::command] -pub(crate) async fn create_automatic_local_game_project_from_template( - app: tauri::AppHandle, - template_id: String, - template_version: String, - name: Option, - planning: Option, - projects_root: Option, -) -> Result { - let analytics_context = crate::analytics::gui::capture_analytics_context(); - let identity = require_template_library_access().await?; - let projects_root = crate::resolve_game_project_creation_root(&app, projects_root.as_deref())?; - let cache_root = template_cache_root(&app)?; - ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?; - let record = ensure_template_installed( - &cache_root, - template_id.trim(), - template_version.trim(), - &identity, - ) - .await?; - let result = with_validated_platform_session_identity(&identity, || { - create_project_from_installed_template_at( - &projects_root, - Path::new(&record.project_dir), - name.as_deref(), - planning.unwrap_or(false), - ) - }); - if let Ok(project) = &result { - crate::analytics::gui::created( - analytics_context, - Path::new(&project.project_path), - project.manifest.project_id.clone(), - crate::analytics::contract::CreationSource::Template, - Some(template_id), - ); - } - result -} - #[cfg(test)] mod tests { use super::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/template_library/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/template_library/desktop.rs new file mode 100644 index 000000000..d79a26f36 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/template_library/desktop.rs @@ -0,0 +1,164 @@ +// 桌面模板操作接入;授权、归档安装与建项规则保留在父模块供现有测试验证。 +use super::*; + +const TEMPLATE_CACHE_DIRECTORY_NAME: &str = "templates"; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GameTemplateLibrarySnapshot { + pub(crate) schema_version: String, + pub(crate) library: String, + pub(crate) library_version: u32, + pub(crate) updated_at: String, + pub(crate) fetched_at_millis: u64, + /// `network` 或 `cache`:命中本机缓存时前端应提示清单可能不是最新。 + pub(crate) source: String, + pub(crate) templates: Vec, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct InstalledGameTemplate { + pub(crate) template_id: String, + pub(crate) template_version: String, + pub(crate) installed_at_millis: u64, + pub(crate) zip_sha256: String, + pub(crate) file_count: usize, + pub(crate) project_dir: String, +} + +fn template_cache_root(app: &tauri::AppHandle) -> Result { + app.path() + .app_data_dir() + .map(|root| root.join(TEMPLATE_CACHE_DIRECTORY_NAME)) + .map_err(|error| format!("无法读取 AGC 应用数据目录:{error}")) +} + +fn write_cached_index(cache_root: &Path, body: &str) { + let _ = ensure_game_creator_private_directory_tree(cache_root, "模板库缓存目录"); + let _ = write_game_creator_private_file( + &cache_root.join(TEMPLATE_INDEX_CACHE_FILE), + body.as_bytes(), + "模板库清单缓存", + ); +} + +#[tauri::command] +pub(crate) async fn fetch_game_template_library( + app: tauri::AppHandle, +) -> Result { + let identity = require_template_library_access().await?; + let cache_root = template_cache_root(&app)?; + ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?; + let index_url = format!( + "{}/{}", + template_library_base()?, + TEMPLATE_LIBRARY_INDEX_KEY + ); + let client = build_template_library_client(); + let remote = + match fetch_limited_bytes(&client, &index_url, TEMPLATE_LIBRARY_MAX_INDEX_BYTES).await { + // 响应回来了但正文不是合法 UTF-8 属于「远端答非所问」,不能用缓存掩盖。 + Ok(bytes) => { + Ok(String::from_utf8(bytes).map_err(|_| "模板库清单不是有效 UTF-8".to_string())?) + } + Err(error) => Err(error), + }; + let (body, source) = resolve_template_index(remote, read_cached_index(&cache_root))?; + if source == TemplateIndexSource::Network { + with_validated_platform_session_identity(&identity, || { + write_cached_index(&cache_root, &body); + Ok(()) + })?; + } + validate_platform_session_identity(&identity)?; + let (header, templates) = parse_game_template_library_index(&body)?; + let installed = collect_installed_records(&cache_root); + let entries = templates + .iter() + .map(|summary| { + to_entry( + summary, + installed_record_for(&installed, &summary.id).as_ref(), + ) + }) + .collect::, _>>()?; + let entries = apply_template_library_fixtures(entries); + Ok(GameTemplateLibrarySnapshot { + schema_version: header.schema_version, + library: header.library, + library_version: header.library_version, + updated_at: header.updated_at, + fetched_at_millis: now_millis(), + source: source.as_str().to_string(), + templates: entries, + }) +} + +#[tauri::command] +pub(crate) async fn download_game_template( + app: tauri::AppHandle, + template_id: String, + template_version: String, +) -> Result { + let identity = require_template_library_access().await?; + let cache_root = template_cache_root(&app)?; + ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?; + let record = ensure_template_installed( + &cache_root, + template_id.trim(), + template_version.trim(), + &identity, + ) + .await?; + validate_platform_session_identity(&identity)?; + Ok(InstalledGameTemplate { + template_id: record.template_id, + template_version: record.template_version, + installed_at_millis: record.installed_at_millis, + zip_sha256: record.zip_sha256, + file_count: record.file_count, + project_dir: record.project_dir, + }) +} + +#[tauri::command] +pub(crate) async fn create_automatic_local_game_project_from_template( + app: tauri::AppHandle, + template_id: String, + template_version: String, + name: Option, + planning: Option, + projects_root: Option, +) -> Result { + let analytics_context = crate::analytics::gui::capture_analytics_context(); + let identity = require_template_library_access().await?; + let projects_root = crate::resolve_game_project_creation_root(&app, projects_root.as_deref())?; + let cache_root = template_cache_root(&app)?; + ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?; + let record = ensure_template_installed( + &cache_root, + template_id.trim(), + template_version.trim(), + &identity, + ) + .await?; + let result = with_validated_platform_session_identity(&identity, || { + create_project_from_installed_template_at( + &projects_root, + Path::new(&record.project_dir), + name.as_deref(), + planning.unwrap_or(false), + ) + }); + if let Ok(project) = &result { + crate::analytics::gui::created( + analytics_context, + Path::new(&project.project_path), + project.manifest.project_id.clone(), + crate::analytics::contract::CreationSource::Template, + Some(template_id), + ); + } + result +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs index 9d9b9bb3b..bbabd5c88 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs @@ -257,6 +257,7 @@ fn validate_and_materialize( }) } +#[cfg(not(test))] pub(crate) async fn bind_components_impl( project_path: String, state: State, diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs index 0a32724d6..35850a96f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs @@ -513,6 +513,7 @@ pub(crate) async fn merge_ui_impl_with_provider( Ok(MergeDTO { ui_tree }) } +#[cfg(not(test))] pub(crate) async fn merge_ui_impl(state: State) -> Result { merge_ui_impl_with_provider(String::new(), state, None).await } diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs index 963b056cd..ca90c3509 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs @@ -869,6 +869,7 @@ pub(crate) async fn recognize_ui_impl_with_provider( Ok(RecognitionDTO { ui_trees }) } +#[cfg(not(test))] pub(crate) async fn recognize_ui_impl( project_path: String, state: State, diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs index 431818316..cfd181064 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs @@ -548,4 +548,78 @@ mod tests { assert_eq!(batch.len(), 1); assert_eq!(batch[0].id.as_str(), "oversized"); } + + #[tokio::test(flavor = "current_thread")] + async fn restored_completed_separation_keeps_bound_artifacts_without_regeneration() { + let project = tempfile::tempdir().expect("create project fixture"); + let source = project.path().join("page.png"); + image::RgbaImage::from_pixel(100, 100, image::Rgba([20, 30, 40, 255])) + .save(&source) + .expect("write source image"); + let source_before = std::fs::read(&source).expect("read source image"); + + let asset_id = "ui-design-1"; + let sidecar = separation_sidecar_dir(project.path(), asset_id).expect("sidecar path"); + std::fs::create_dir_all(&sidecar).expect("create sidecar"); + let cut = sidecar.join("cut-existing.png"); + image::RgbaImage::from_pixel(8, 8, image::Rgba([20, 30, 40, 255])) + .save(&cut) + .expect("write existing cut image"); + let cut_before = std::fs::read(&cut).expect("read existing cut image"); + + let image = Component::Image(ImageComponent { + target_graphic: None, + image_type: ImageType::Simple { + preserve_aspect: false, + }, + }); + let state = state(node("root", None, vec![node("image", Some(image), vec![])])); + let mut persisted = construct_separation_state(&state); + let node_id = NodeId::new("image").unwrap(); + assert_eq!( + next_image_batch(&persisted, &persisted.trees[0]) + .iter() + .map(|node| node.id.as_str()) + .collect::>(), + vec!["image"] + ); + persisted.bound.push(BoundNode { + node_id, + cut_image_path: project_relative_path(project.path(), &cut).unwrap(), + }); + + let state_path = separation_state_path(project.path(), asset_id).unwrap(); + write_separation_state(state_path.clone(), &persisted) + .await + .expect("persist completed separation"); + let sidecar_files = || { + let mut names = std::fs::read_dir(&sidecar) + .expect("read sidecar") + .map(|entry| entry.expect("sidecar entry").file_name()) + .collect::>(); + names.sort(); + names + }; + let files_before = sidecar_files(); + + let _session = crate::platform_session::install_test_platform_session( + "separation-user", + "fixture-token", + "http://127.0.0.1:9", + ); + let result = workflow::separate_ui_impl( + project.path().to_string_lossy().into_owned(), + asset_id.to_string(), + state, + ) + .await + .expect("restore completed separation"); + + assert_eq!(result.bound_nodes, persisted.bound); + assert!(result.problematic_nodes.is_empty()); + assert_eq!(read_separation_state(&state_path).unwrap(), persisted); + assert_eq!(std::fs::read(&source).unwrap(), source_before); + assert_eq!(std::fs::read(&cut).unwrap(), cut_before); + assert_eq!(sidecar_files(), files_before); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/ui_design_suggestion.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/ui_design_suggestion.rs index 613d44b7b..2f11c2a51 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/ui_design_suggestion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/ui_design_suggestion.rs @@ -1,5 +1,5 @@ -use crate::config::build_game_creator_llm_client_from_llm_config; -use crate::config::load_game_creator_app_config; +#[cfg(not(test))] +use crate::config::{build_game_creator_llm_client_from_llm_config, load_game_creator_app_config}; use crate::ui_editor::commands::utils::{ parse_limited_llm_tool_arguments, read_ui_reference_image_data_url, request_ui_editor_llm, strict_json_schema, @@ -8,7 +8,7 @@ use crate::ui_editor::resource::ui_design_image::UIDesignImageRole; use crate::ui_editor::state::State; use crate::ui_editor::utils::UIDesignImageId; use platform_llm::{ - LlmFunctionTool, LlmMessage, LlmMessageContentPart, LlmRunRequest, LlmToolChoice, + LlmClient, LlmFunctionTool, LlmMessage, LlmMessageContentPart, LlmRunRequest, LlmToolChoice, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -131,11 +131,37 @@ fn validate_suggestions( Ok(()) } +#[cfg(not(test))] #[tauri::command] pub(crate) async fn suggest_ui_design_semantic_impl( project_path: String, state: State, ) -> Result, String> { + suggest_ui_design_semantic_with_client(project_path, state, || { + let llm = load_game_creator_app_config() + .map_err(|error| { + eprintln!("ui_design_suggestion.error stage=build_client error={error}"); + error + })? + .llm; + let client = + build_game_creator_llm_client_from_llm_config(&llm, "llm").map_err(|error| { + app_log!("ui_design_suggestion.error stage=build_client error={error}"); + error + })?; + Ok((client, llm)) + }) + .await +} + +async fn suggest_ui_design_semantic_with_client( + project_path: String, + state: State, + load_client: F, +) -> Result, String> +where + F: FnOnce() -> Result<(LlmClient, crate::GameCreatorLlmConfig), String>, +{ if state.ui_design_images.is_empty() { app_log!("ui_design_suggestion.error stage=validate reason=no_images"); return Err("请先导入界面图".to_string()); @@ -174,16 +200,7 @@ pub(crate) async fn suggest_ui_design_semantic_impl( }); parts.push(LlmMessageContentPart::InputImage { image_url }); } - let llm = load_game_creator_app_config() - .map_err(|error| { - eprintln!("ui_design_suggestion.error stage=build_client error={error}"); - error - })? - .llm; - let client = build_game_creator_llm_client_from_llm_config(&llm, "llm").map_err(|error| { - app_log!("ui_design_suggestion.error stage=build_client error={error}"); - error - })?; + let (client, llm) = load_client()?; let schema = ui_design_suggestion_json_schema().map_err(|error| { app_log!("ui_design_suggestion.error stage=build_schema error={error}"); error @@ -246,6 +263,12 @@ pub(crate) async fn suggest_ui_design_semantic_impl( #[cfg(test)] mod tests { use super::*; + use crate::ui_editor::resource::ui_design_image::{UIDesignImage, UIDesignImageMetadata}; + use nalgebra::Vector2; + use std::collections::HashMap; + use std::sync::mpsc; + use std::time::Duration; + use typed_floats::tf32::StrictlyPositiveFinite; fn id(value: &str) -> UIDesignImageId { UIDesignImageId::new(value).expect("valid image id") @@ -269,6 +292,109 @@ mod tests { values.iter().map(|value| id(value)).collect() } + #[tokio::test] + async fn suggests_page_tree_from_provider_tool_response() { + let root = tempfile::tempdir().expect("temporary project"); + let mut images = HashMap::new(); + for image_id in ["page", "section"] { + let path = format!("{image_id}.png"); + image::RgbaImage::from_pixel(2, 2, image::Rgba([32, 48, 64, 255])) + .save(root.path().join(&path)) + .expect("write reference image"); + images.insert( + id(image_id), + UIDesignImage { + metadata: UIDesignImageMetadata { + name: image_id.to_string(), + description: String::new(), + role: None, + slave_to: None, + }, + path, + pixel_size: Vector2::new(2.0, 2.0), + pixels_per_unit: StrictlyPositiveFinite::new(1.0) + .expect("positive pixels per unit"), + }, + ); + } + let state = State { + ui_trees: Vec::new(), + ui_design_images: images, + sprite_assets: HashMap::new(), + font_assets: HashMap::new(), + }; + let expected = vec![node( + "page", + UIDesignImageRole::Page, + vec![node("section", UIDesignImageRole::Section, Vec::new())], + )]; + let response_body = serde_json::json!({ + "id": "chatcmpl-ui-design-suggestion", + "model": "mock-game-model", + "choices": [{ + "message": { + "content": null, + "tool_calls": [{ + "id": "call_ui_design_suggestion", + "type": "function", + "function": { + "name": "suggest_ui_design_semantics", + "arguments": serde_json::json!({ "ui_designs": expected }).to_string() + } + }] + }, + "finish_reason": "tool_calls" + }] + }); + let (request_sender, request_receiver) = mpsc::channel(); + let base_url = crate::tests::spawn_mock_llm_raw_responses_with_capture( + vec![response_body], + Some(request_sender), + ); + + let suggestions = suggest_ui_design_semantic_with_client( + root.path().to_string_lossy().into_owned(), + state, + move || { + let llm = crate::GameCreatorLlmConfig { + custom_enabled: true, + api_key: "test-key".to_string(), + base_url, + model: "mock-game-model".to_string(), + api_kind: "openai_chat".to_string(), + stream: false, + max_retries: 0, + ..Default::default() + }; + let client = + crate::config::build_game_creator_llm_client_from_llm_config(&llm, "llm")?; + Ok((client, llm)) + }, + ) + .await + .expect("valid UI design suggestions"); + assert_eq!(suggestions, expected); + + let request = request_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("captured provider request"); + let (_, body) = request.split_once("\r\n\r\n").expect("request body"); + let body: serde_json::Value = serde_json::from_str(body).expect("provider request JSON"); + assert_eq!(body["tool_choice"], "required"); + assert_eq!( + body["tools"][0]["function"]["name"], + "suggest_ui_design_semantics" + ); + assert_eq!(body["tools"][0]["function"]["strict"], true); + assert_eq!( + body["tools"][0]["function"]["parameters"]["required"], + serde_json::json!(["ui_designs"]) + ); + assert!(body.to_string().contains("REFERENCE id:page")); + assert!(body.to_string().contains("REFERENCE id:section")); + assert!(body.to_string().contains("data:image/png;base64,")); + } + #[test] fn validates_page_tree() { let suggestions = vec![node( diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 23304b384..6b67dfa50 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -291,7 +291,15 @@ npm run check ### 编译告警的保留边界与待优化项 +- 设计会话的水合、重置和调试快进通过共享核心验证损坏会话隔离、不确定批次恢复和事件失败后的落盘状态;仅 Tauri 包装限定生产编译,资源与模型客户端仍在原校验阶段之后按需加载。UI 语义建议通过本地 HTTP 夹具验证真实图片请求、工具 schema 与返回身份;分离恢复测试验证已完成的绑定和图片不被重新生成,不代表首次生成全链路已覆盖。音频派发测试验证使用派发时的 revision、保留提交身份,并在远程上下文失败时保持 Prepared 账本及项目内容不变。 - AGC 纯生产接入按模块限定 `cfg(not(test))`:桌面启动、Tauri 命令注册和没有测试消费者的命令接入集中到对应生产模块;共享业务实现、被测试直接调用的入口和现有断言仍参与测试编译。拆分不改变命令名、注册集合、权限校验或启动/退出顺序,也不新增 `allow(dead_code)`、虚假引用或仅为消警的测试。每批同时检查 AGC 普通目标和测试目标,源码结构检查跟随真实接线位置更新;不能用测试目标通过代替生产目标检查,也不以清零告警为由排除已有测试覆盖。 +- 插件/客户端扩展、模板库和预览的桌面命令与所属模块的共享实现分离:插件进程及权限逻辑、扩展仓库、模板访问授权/归档校验/建项、预览注册表与生命周期继续由原测试验证。模板命令和普通建项命令共用的 AppData 根目录选择接入随生产模块编译,用户所选目录的安全校验仍保留在共享实现中。 +- 编辑器适配器注册、随包资源定位和平台会话 fixture 加载属于桌面/Runner 启动接入,按生产模块编译;fixture 的发行版拒绝、路径边界、文件大小及凭据校验不得随拆分改变。配置 setup、Windows 提权 helper 的票据消费和 Runner bootId 初始化随其生产调用方限定范围,配置迁移、ACL 安全规则、平台身份/凭据轮换、编辑器执行回执及进程归属的已有测试继续编译。 +- 项目快照的周期调度、窗口事件、退出等待和桌面命令与共享同步核心分离;Analytics 的服务初始化、窗口/退出事件、上传定时启动和启动时补投同样属于生产接入。窗口登记状态机、快照索引与同步事务、埋点状态机、上传轮次和补投资格处理继续参与测试,不能用移除这些测试的方式消除告警。 +- Runner 服务监听循环、连接读写接入、心跳和 GUI owner 监控按生产边界编译;共享 RPC 分发、endpoint 校验、请求幂等、项目 owner 和生命周期测试继续参与测试。只读配置入口有 Unix 专属测试,必须保留 `any(not(test), unix)` 的使用范围,不能直接归入仅生产模块。 +- MCP 的 STDIO/loopback 启停在原文件内精确限定生产编译,共享请求处理保留测试;Direct/设计工具及 UI 编辑器的纯命令包装随桌面入口编译。Thread Manager 的订阅/消费同时服务 Unix 集成测试与跨平台取消回合回归,不限定为生产或 Unix。项目资源的生产命令 DTO 与读取接入按实际消费者限定范围;分类更新测试从正式 camelCase 请求解码后验证同一身份与 revision 的写入,音频提交测试核对派发 operationId 与账本任务一致、幂等键不被替换。业务编排、网络鉴权和持久化安全逻辑不能仅因没有直接测试而整段排除。 +- Direct 取消入口必须验证错轮请求不取消、不释放、不发送终态;执行器退出后的残留守卫释放须返回原 clientTurnId,并发布对应 canonical userItemId 的 aborted 终态。旧守卫析构不能清掉随后启动的新回合。此类安全行为通过真实入口回归保留在测试构建,不以条件编译排除取消链。 +- 预览测试使用系统临时目录,不依赖 Windows 未必提供的 `HOME`。Cocos 插件 RPC 往返测试在注册完成后,通过只读状态查询确认插件已接收启动时的项目上下文,再执行原有隐式项目路径断言;Unity 跨项目测试先等待项目事件订阅,再切换项目。不能把注册完成等同于项目订阅快照已到达,也不能重试写操作掩盖初始化竞态。 - AGC 测试编译使用 `cargo check --locked --offline --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -p genarrative-ai-game-creator-shell --tests` 定向核对。GUI 主入口在测试构建中被排除,不能把测试目标的 `dead_code` 一律认定为废弃生产代码;仅生产入口使用的导入限定 `not(test)`,平台测试导入与对应 Unix/Linux 测试保持相同条件,失去调用方的测试 helper 和多余重导出直接删除。DirectHome 命令配置测试明确断言禁用 `shell_tool` 与 `unified_exec`;Windows 路径校验的测试分支不触发宿主提权修复,修复范围参数仅由生产分支消费。 - 测试 helper 的条件要覆盖真实使用范围:Unity delivery 观测 helper 限于 Windows x86_64 的 `unity-editor-execute` 测试,Agent DB 链接安全断言与 app-server 工作区启动包装限于 Unix 测试,模板假数据的环境变量读取限于 `template-library-fixtures` feature。底层生产实现与共享测试逻辑继续保留。仅检查完成阻塞协议固定中文措辞、且失去测试标记的遗留函数直接删除;现役自由执行请求使用短任务协议,不向其测试搬入标准模式的完成阻塞提示词要求,标准模式的生产协议保持不变。 - AGC 默认 Windows dev 构建在 2026-09-23 清理后的 `cargo check --locked --offline --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -p genarrative-ai-game-creator-shell` 复核通过,剩余 3 条 AuthBridge 相关 Rust warning 和 5 条 ts-rs 提示。这是当次配置的检查结果,不代表后续提交、正式 editor features、其它平台、test targets、release 链接或安装包均无告警;后续按改动范围定向验证。 diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 5c0f8af18..e50eece52 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -34,10 +34,13 @@ const aiGameCreatorProjectDevelopmentSource = fs.readFileSync( 'apps/ai-game-creator-shell/src/view/project-development/index.tsx', 'utf8', ); -const aiGameCreatorPreviewRustSource = fs.readFileSync( +// 同时检查共享预览实现和桌面命令接入,避免拆分后漏掉激活或外部浏览器边界。 +const aiGameCreatorPreviewRustSource = [ 'apps/ai-game-creator-shell/src-tauri/src/preview.rs', - 'utf8', -); + 'apps/ai-game-creator-shell/src-tauri/src/preview/desktop.rs', +] + .map((sourcePath) => fs.readFileSync(sourcePath, 'utf8')) + .join('\n'); const aiGameCreatorShellTauriConfig = JSON.parse( fs.readFileSync( 'apps/ai-game-creator-shell/src-tauri/tauri.conf.json',