From 55375b14060b048f8e00552c6c02368775f5a376 Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 1 Oct 2026 15:29:23 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B8=85=E7=90=86=E6=B5=8B=E8=AF=95=E7=BC=96?= =?UTF-8?q?=E8=AF=91warning=20(#509)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewed-on: http://genarrative-station/git/GenarrativeAI/Genarrative/pulls/509 Co-authored-by: Linghong Co-committed-by: Linghong --- .../scripts/check-config.mjs | 9 +- .../src-tauri/src/agent.rs | 7 +- .../src/agent/codex_app_server/mod.rs | 126 +- .../src-tauri/src/agent/design_runtime.rs | 178 +- .../src-tauri/src/agent/design_tools.rs | 3 + .../src/agent/direct_project_history.rs | 1 + .../src-tauri/src/agent/direct_runtime/mod.rs | 1 + .../src/agent/direct_runtime/user_input.rs | 1 + .../src-tauri/src/agent/direct_tools_mcp.rs | 11 +- .../src-tauri/src/agent/generation.rs | 1 - .../agent/generation/loop_orchestration.rs | 1 + .../provider_request_builders.rs | 55 +- .../src-tauri/src/agent/runtime_driver.rs | 10 +- .../src/agent/runtime_driver/entrypoints.rs | 2 + .../src/agent/runtime_driver/interaction.rs | 14 - .../src-tauri/src/agent/runtime_protocol.rs | 3 +- .../runtime_protocol/autonomous_completion.rs | 5 - .../src-tauri/src/agent/runtime_tools.rs | 4 +- .../src/agent/runtime_tools/delivery.rs | 8 - .../src-tauri/src/agent_native_tools.rs | 2 - .../src-tauri/src/analytics/goal.rs | 17 +- .../src-tauri/src/analytics/goal/desktop.rs | 15 + .../src-tauri/src/analytics/gui.rs | 211 +- .../src-tauri/src/analytics/gui/desktop.rs | 208 ++ .../src-tauri/src/analytics/upload.rs | 28 +- .../src-tauri/src/analytics/upload/desktop.rs | 23 + .../src-tauri/src/asset_generation_tasks.rs | 235 +-- .../src/asset_generation_tasks/runtime.rs | 223 +++ .../src-tauri/src/assets.rs | 2 + .../src-tauri/src/browser/tests.rs | 1 + .../src-tauri/src/client_extensions.rs | 154 +- .../src/client_extensions/desktop.rs | 150 ++ .../src-tauri/src/commands.rs | 1716 +---------------- .../src-tauri/src/commands/desktop.rs | 1637 ++++++++++++++++ .../src-tauri/src/config.rs | 18 +- .../src-tauri/src/context_menu.rs | 41 +- .../src-tauri/src/context_menu/runtime.rs | 39 + .../src-tauri/src/desktop.rs | 800 ++++++++ .../src-tauri/src/editor_adapters.rs | 184 +- .../src/editor_adapters/execution.rs | 14 +- .../src-tauri/src/editor_adapters/startup.rs | 181 ++ .../src-tauri/src/environment_check.rs | 5 +- .../src-tauri/src/game_package_upload.rs | 338 +--- .../src/game_package_upload/runtime.rs | 335 ++++ .../src-tauri/src/main.rs | 831 +------- .../src-tauri/src/platform_session.rs | 228 +-- .../src-tauri/src/platform_session/startup.rs | 224 +++ .../src-tauri/src/plugin_host.rs | 189 +- .../src-tauri/src/plugin_host/desktop.rs | 159 ++ .../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.rs | 2 + .../src/project/agent_db/security_tests.rs | 1 + .../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 | 38 +- .../src-tauri/src/runner/client.rs | 12 +- .../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/runner/tests.rs | 1 + .../src-tauri/src/template_library.rs | 168 +- .../src-tauri/src/template_library/desktop.rs | 164 ++ .../src-tauri/src/tests/mod.rs | 120 +- .../src/tests/runtime_actions/support.rs | 22 +- .../src-tauri/src/tool_plan_handoff/tests.rs | 15 +- .../src-tauri/src/ui_editor/commands/mod.rs | 4 +- .../src/ui_editor/commands/recognition.rs | 1 + .../src/ui_editor/commands/separation/mod.rs | 74 + .../src-tauri/src/windows.rs | 45 +- .../src-tauri/src/windows/desktop.rs | 42 + .../tests/sessionPreview.test.ts | 6 +- .../shared-memory/development-workflow.md | 2 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 11 + scripts/check-native-shells.mjs | 9 +- 88 files changed, 5584 insertions(+), 5135 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/asset_generation_tasks/runtime.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/commands/desktop.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/context_menu/runtime.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/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/game_package_upload/runtime.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 create mode 100644 apps/ai-game-creator-shell/src-tauri/src/windows/desktop.rs diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index ca6897814..1b964573f 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -98,10 +98,13 @@ const appInvokeSources = readSourceFiles( new URL('../src/', import.meta.url), new Set(['.ts', '.tsx']), ); -const tauriHandlerSource = fs.readFileSync( +// 生产接线在 desktop,受测的启动诊断实现仍在 main;两者共同构成启动检查范围。 +const tauriHandlerSource = [ new URL('../src-tauri/src/main.rs', import.meta.url), - 'utf8', -); + new URL('../src-tauri/src/desktop.rs', import.meta.url), +] + .map((source) => fs.readFileSync(source, 'utf8')) + .join('\n'); const tauriRustSource = readSourceTree( new URL('../src-tauri/src/', import.meta.url), '.rs', diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 2e8e3ee12..14c1f7917 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -54,16 +54,19 @@ pub(crate) use claude_code_cli::{ direct_game_creator_claude_code_home_chat, game_creator_claude_code_cli_route_error, game_creator_claude_code_cli_version_identity, }; +pub(crate) use codex_app_server::direct_game_creator_codex_chat_at; use codex_app_server::*; +#[cfg(not(test))] pub(crate) use codex_app_server::{ - cancel_direct_codex_turn_at, direct_game_creator_codex_chat_at, - direct_game_creator_home_codex_chat, thread_id_for_project, DirectTurnCancelView, + cancel_direct_codex_turn_at, direct_game_creator_home_codex_chat, thread_id_for_project, + DirectTurnCancelView, }; use codex_cli::*; pub(crate) use codex_cli::{ game_creator_codex_cli_executable_path, game_creator_codex_cli_version_identity, }; pub(crate) use codex_provider_proxy::*; +#[cfg(not(test))] pub(crate) use design_runtime::*; pub(crate) use direct_codex_attachments::*; pub(crate) use direct_codex_user_item::*; 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 3c00d1410..3f28cb2a9 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 @@ -2553,34 +2553,15 @@ impl CodexAppServerConnection { Ok(connection) } - #[cfg(test)] - async fn spawn( - llm: &GameCreatorLlmConfig, - credential: &CodexAppServerCredential, - ) -> Result { - let executable = game_creator_codex_cli_executable_path() - .map_err(platform_llm::LlmError::InvalidConfig)?; - Self::spawn_with_executable_and_credential(llm, credential, executable.as_os_str()).await - } - - #[cfg(test)] + #[cfg(all(test, unix))] async fn spawn_with_executable( llm: &GameCreatorLlmConfig, executable: &std::ffi::OsStr, ) -> Result { let credential = resolve_game_creator_codex_app_server_credential(llm)?; - Self::spawn_with_executable_and_credential(llm, &credential, executable).await - } - - #[cfg(test)] - async fn spawn_with_executable_and_credential( - llm: &GameCreatorLlmConfig, - credential: &CodexAppServerCredential, - executable: &std::ffi::OsStr, - ) -> Result { Self::spawn_with_executable_and_credential_at_workspace( llm, - credential, + &credential, executable, None, CodexAppServerWorkspaceMode::ToolHost, @@ -2588,7 +2569,7 @@ impl CodexAppServerConnection { .await } - #[cfg(test)] + #[cfg(all(test, unix))] async fn spawn_with_executable_and_credential_at_workspace( llm: &GameCreatorLlmConfig, credential: &CodexAppServerCredential, @@ -5464,6 +5445,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, @@ -5675,6 +5657,104 @@ 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 thread_id = thread_id_for_project(root.path()); + let invocation = TurnReservation::accept_for_test(&thread_id, "client-turn-current"); + 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 subscription = crate::agent::subscribe_thread(&thread_id); + let _ = crate::agent::consume_thread(&subscription.subscription_id) + .expect("drain turn start event"); + + 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_turn_identity(&thread_id) + .expect("original turn remains active") + .client_turn_id, + "client-turn-current" + ); + assert!( + crate::agent::consume_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 thread_id = thread_id_for_project(root.path()); + let invocation = TurnReservation::accept_for_test(&thread_id, "client-turn-exited"); + let _registered = register_active_direct_codex_turn( + direct_codex_active_turn_key(root.path()), + "client-turn-exited", + exited_direct_turn_cancellation(), + ); + let subscription = crate::agent::subscribe_thread(&thread_id); + let _ = crate::agent::consume_thread(&subscription.subscription_id) + .expect("drain turn start event"); + + 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_turn_identity(&thread_id), None); + let events = crate::agent::consume_thread(&subscription.subscription_id) + .expect("consume terminal event") + .events; + assert!(matches!( + events.as_slice(), + [ThreadEvent::TurnCompleted { + status, + failure: None, + at: Some(_), + user_item_id: Some(user_item_id), + }] if status == "aborted" && user_item_id == "direct-codex:client-turn-exited:user" + )); + + let next = TurnReservation::accept_for_test(&thread_id, "client-turn-next"); + drop(invocation); + assert_eq!( + crate::agent::read_turn_identity(&thread_id) + .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 @@ -7574,6 +7654,8 @@ while IFS= read -r line; do :; done .map(|argument| argument.to_string_lossy().into_owned()) .collect::>() .join("\n"); + assert!(home_arguments.contains("--disable\nshell_tool")); + assert!(home_arguments.contains("--disable\nunified_exec")); } #[cfg(windows)] 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 71a086fc6..9cbbf7f80 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; @@ -1398,12 +1399,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() @@ -1432,7 +1444,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)); } @@ -1476,9 +1488,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); @@ -1488,6 +1505,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, @@ -1515,8 +1533,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() } @@ -1540,16 +1563,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( @@ -1597,21 +1631,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, @@ -1636,6 +1667,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, @@ -1662,6 +1694,7 @@ pub(crate) async fn decide_design_phase( .await } +#[cfg(not(test))] #[tauri::command] pub(crate) fn list_design_workspace( project_path: String, @@ -1672,6 +1705,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, @@ -1685,7 +1719,6 @@ pub(crate) fn read_design_workspace_file( #[cfg(test)] mod fake_provider { - use super::*; use std::cell::{Cell, RefCell}; use std::collections::VecDeque; @@ -3236,6 +3269,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(); @@ -3291,16 +3423,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 cc0d36ddc..addc431cf 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() { @@ -539,6 +541,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/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 42af654b8..25ac70b95 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -6,6 +6,7 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; mod user_input; +#[cfg(not(test))] pub(crate) use user_input::enqueue_direct_codex_turn; #[cfg(test)] pub(crate) use user_input::normalize_direct_client_turn_id; 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 7b7cc68d4..3295ef20d 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 @@ -43,6 +43,7 @@ pub(crate) fn normalize_direct_client_turn_id( /// 终端上)已经退役,不要再为"手工跑一轮"新增第二条直接起回合的路径。 /// /// 设计见 `docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md`。 +#[cfg(not(test))] #[tauri::command] pub(crate) async fn enqueue_direct_codex_turn( project_path: String, 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 5deb4efc2..35c166d53 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,10 @@ use super::*; -use axum::extract::{DefaultBodyLimit, State as AxumState}; +use axum::extract::DefaultBodyLimit; +use axum::extract::State as AxumState; use axum::http::{HeaderMap, StatusCode}; use axum::routing::post; -use axum::{Json, Router}; +use axum::Json; +use axum::Router; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; use std::io::{BufRead, BufReader, Write}; @@ -55,6 +57,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; @@ -1984,6 +1987,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>>, @@ -2062,6 +2066,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())?, @@ -2177,6 +2182,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()); @@ -2185,6 +2191,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.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs index fb384fe19..46bbbb771 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs @@ -48,7 +48,6 @@ pub(in crate::agent) use external_generation_state::{ }; #[cfg(test)] pub(crate) use external_generation_state::{ - platform_art_generation_external_configuration_fingerprint, platform_art_generation_legacy_external_configuration_fingerprint, setup_platform_art_generation_runtime_accepted_for_recovery_test, write_platform_art_generation_runtime_accepted_for_test, 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_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index 3647d5979..7e3327881 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -817,24 +817,17 @@ mod tests { use super::{ acquire_game_creator_agent_provider_plan_project_write_lock_with_wait, agent_runtime_root_source_at, bind_game_creator_agent_runtime_run_profile_at, - build_game_creator_agent_background_final_reply_request, build_game_creator_agent_background_tool_plan_request_locked, - game_creator_agent_context_preload_notice, - game_creator_agent_runtime_run_profile_binding_path, - game_creator_project_supervisor_chat_system_prompt, init_local_game_project_at, - new_game_creation_app_seed_tasks, provider_command_exec_contract, - provider_command_start_contract, + game_creator_agent_context_preload_notice, init_local_game_project_at, + new_game_creation_app_seed_tasks, render_relaxed_autonomous_manifest_ready_task_background_prompt, - required_runtime_prompt_section, start_game_creator_agent_runtime_task_at, - AgentRuntimeGoalContractAcceptanceNodeDraft, AgentRuntimeGoalContractDraft, - AgentRuntimeTaskLink, AgentRuntimeToolObservation, AgentRuntimeToolPlan, + start_game_creator_agent_runtime_task_at, AgentRuntimeGoalContractAcceptanceNodeDraft, + AgentRuntimeGoalContractDraft, AgentRuntimeTaskLink, AgentRuntimeToolObservation, AgentRuntimeToolPlanRequestSnapshot, GameCreatorLlmConfig, - AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT, - AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL, AGENT_RUNTIME_RESPOND_FUNCTION_NAME, - AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_RUN_PROFILE_STANDARD, - AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, - AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - RUNTIME_PROMPT_SUPERVISOR_CHAT_COMPOSITION, + AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT, AGENT_RUNTIME_RESPOND_FUNCTION_NAME, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, }; fn build_game_creator_agent_background_tool_plan_request_for_test( @@ -871,24 +864,6 @@ mod tests { ) } - fn native_input_required_fields( - request: &platform_llm::LlmRunRequest, - tool: &str, - ) -> Vec { - let function_name = crate::agent_native_tools::native_runtime_function_name(tool) - .expect("native runtime function name"); - request - .function_tools - .iter() - .find(|function| function.name == function_name) - .and_then(|function| function.parameters.pointer("/properties/input/required")) - .and_then(serde_json::Value::as_array) - .expect("native input required fields") - .iter() - .map(|field| field.as_str().expect("required field string").to_string()) - .collect() - } - #[test] fn relaxed_request_keeps_general_catalog_after_plan_rejection() { let directory = crate::tests::canonical_test_tempdir("provider-plan-rejection-repair-"); @@ -1437,20 +1412,6 @@ mod tests { assert!(!prompt.contains("本轮唯一可用工具是 agent.goal_contract")); } - fn completion_blocker_protocol_requires_tool_repair_before_response() { - let protocol = AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL; - - assert!(protocol.contains("最新 observation")); - assert!(protocol.contains("runtime.autonomous_completion")); - assert!(protocol.contains("status 为 blocked")); - assert!(protocol.contains("才能调用 respond_to_user")); - assert!(!protocol.contains("legacy response")); - assert!(protocol.contains("observation.detail 的 nextRequiredAction")); - assert!(protocol.contains("读取、修复和验证工具")); - assert!(protocol.contains("完成要求的动作、取得后续可信 observation")); - assert!(protocol.contains("通过完成门禁后")); - } - #[test] fn supervisor_request_snapshot_preserves_prompt_visible_running_sibling_after_manifest_failure() { 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 fffdc5158..17441b11c 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 @@ -297,18 +297,18 @@ pub(crate) use entrypoints::{ 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; pub(crate) use finalization::AgentRuntimePendingActionResume; #[cfg(test)] -pub(crate) use interaction::{ - acquire_game_creator_agent_runtime_user_input_answer_locks_for_test, - agent_runtime_tool_requires_repository_context_fingerprint_gate, -}; +pub(crate) use interaction::agent_runtime_tool_requires_repository_context_fingerprint_gate; pub(crate) use interaction::{ answer_game_creator_agent_runtime_user_input_at, confirm_game_creator_agent_runtime_task_at, pending_repository_context_drift_observation, reject_game_creator_agent_runtime_task_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 10dd259f3..c7c523cee 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 @@ -17,6 +17,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); } @@ -201,6 +202,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_driver/interaction.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs index a91344eda..d1f2aa58e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs @@ -477,20 +477,6 @@ fn resolve_game_creator_agent_runtime_user_input_action_with_ordered_locks( Ok((None, runtime_lock, resolved)) } -#[cfg(test)] -pub(crate) fn acquire_game_creator_agent_runtime_user_input_answer_locks_for_test( - root: &Path, - agent_id: &str, - run_id: &str, - action_id: &str, -) -> Result<(Option, AgentRuntimeTaskLock), String> { - let (project_lock, runtime_lock, _) = - resolve_game_creator_agent_runtime_user_input_action_with_ordered_locks( - root, agent_id, run_id, action_id, - )?; - Ok((project_lock, runtime_lock)) -} - pub(crate) fn answer_game_creator_agent_runtime_user_input_at( root: &Path, agent_id: &str, 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/agent/runtime_protocol/autonomous_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs index 3618ca475..57a094da1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs @@ -11943,11 +11943,6 @@ impl ExternalGameplayJavascript { .iter() .any(|unit| unit.to_ascii_lowercase().contains(&marker)) } - - #[cfg(test)] - pub(in crate::agent) fn module_units(&self) -> &[String] { - &self.module_units - } } fn normalize_javascript_module_analysis_sources( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs index 9d8648eb0..0a15b0460 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs @@ -44,9 +44,7 @@ pub(in crate::agent) use ui_design_doc::*; #[cfg(test)] pub(crate) use delegation::observe_agent_runtime_agent_delegate_at_locked; #[cfg(test)] -pub(crate) use delivery::{ - build_static_delegate_result_for_child_at, wake_waiting_static_delegate_parent_run_for_test_at, -}; +pub(crate) use delivery::build_static_delegate_result_for_child_at; #[cfg(test)] pub(crate) use isolated_joins::render_isolated_join_status_batch_with_limit; #[cfg(test)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs index 4e29d3356..709023e9d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs @@ -408,14 +408,6 @@ pub(in crate::agent) fn wake_waiting_static_delegate_parent_run_at( Ok(true) } -#[cfg(test)] -pub(crate) fn wake_waiting_static_delegate_parent_run_for_test_at( - root: &Path, - parent_task: &AgentRuntimeTaskRecord, -) -> Result { - wake_waiting_static_delegate_parent_run_at(root, parent_task) -} - pub(in crate::agent) fn wake_waiting_autonomous_manifest_parent_run_at( root: &Path, parent_task: &AgentRuntimeTaskRecord, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 8b24b0cfc..8262ccb0c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -13,8 +13,6 @@ use crate::agent::{ AgentRuntimeToolPlan, AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT, AGENT_RUNTIME_CANVAS_ASSET_KINDS, AGENT_RUNTIME_PLAN_STEP_LIMIT, }; -#[cfg(test)] -use crate::GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID; pub(crate) const AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME: &str = "update_agent_plan"; pub(crate) const AGENT_RUNTIME_RESPOND_FUNCTION_NAME: &str = "respond_to_user"; 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 df4800ce8..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,不做磁盘/网络操作,也不重新取得业务锁。 @@ -480,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..e81bb5de2 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/gui/desktop.rs @@ -0,0 +1,208 @@ +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) 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 745535122..44d7dc7a1 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 @@ -25,17 +25,20 @@ use std::sync::{Mutex, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; use crate::agent::{ - generate_platform_art_asset_with_options_at, read_agent_runtime_json_sidecar_with_max_bytes, - write_agent_runtime_json_sidecar_with_max_bytes, PlatformArtAssetGenerationOptions, + read_agent_runtime_json_sidecar_with_max_bytes, write_agent_runtime_json_sidecar_with_max_bytes, }; -use crate::commands::prepare_local_project_asset_generation; use crate::project::{ enforce_project_permission_policy, prepare_local_project_audio_generation, - read_existing_manifest_for_project, run_local_project_audio_generation_at, - LocalProjectAudioGenerationRequest, LocalProjectResourceEditKind, + read_existing_manifest_for_project, LocalProjectAudioGenerationRequest, + LocalProjectResourceEditKind, }; use shared_contracts::game_creation_app::GameCreationAppAssetKind; +#[cfg(not(test))] +mod runtime; +#[cfg(not(test))] +pub(crate) use runtime::start_local_project_asset_generation; + pub(crate) const ASSET_GENERATION_TASK_SCHEMA_VERSION: &str = "agc-asset-generation-task.v1"; pub(crate) const ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH: &str = ".agent/runtime/asset-generation-tasks/tasks.json"; @@ -45,7 +48,6 @@ pub(crate) const ASSET_GENERATION_TASK_LEDGER_MAX_RECORDS: usize = 50; pub(crate) const ASSET_GENERATION_TASK_ID_MAX_CHARS: usize = 128; pub(crate) const ASSET_GENERATION_TASK_STATUS_QUEUED: &str = "queued"; -pub(crate) const ASSET_GENERATION_TASK_STATUS_RUNNING: &str = "running"; pub(crate) const ASSET_GENERATION_TASK_STATUS_COMPLETED: &str = "completed"; pub(crate) const ASSET_GENERATION_TASK_STATUS_FAILED: &str = "failed"; pub(crate) const ASSET_GENERATION_TASK_CHANGED_EVENT: &str = @@ -72,7 +74,6 @@ fn emit_asset_generation_task_changed(root: &Path, task_id: &str) { } const ASSET_GENERATION_TASK_PHASE_QUEUED: &str = "排队中。"; -const ASSET_GENERATION_TASK_PHASE_RUNNING: &str = "正在生成。"; const ASSET_GENERATION_TASK_PHASE_COMPLETED: &str = "生成已完成。"; /// 中断收口:清单里已经能查到这次请求的目标素材 → 按事实收口为已完成。 const ASSET_GENERATION_TASK_PHASE_INTERRUPTED_SETTLED: &str = @@ -89,9 +90,6 @@ const ASSET_GENERATION_TASK_INTERRUPTED_INCOMPLETE_ERROR: &str = "应用退出时生成任务仍在进行,目标素材未登记"; const ASSET_GENERATION_TASK_INTERRUPTED_UNKNOWN_ERROR: &str = "应用退出时生成任务仍在进行,未能在清单里确认结果"; -/// 音频任务收口:通道跑完但没有登记出素材(`derive` 在有源 / 无源两条路上都必须登记 assets)。 -const ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR: &str = "生成完成但未登记素材"; - /// 一条生成任务的权威记录。字段名与前端一一对应(camelCase)。 #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] @@ -387,57 +385,6 @@ fn register_live_task_id(task_id: &str) -> bool { .unwrap_or(false) } -/// 后台执行:状态与阶段文案的每一次流转都由这里写账本。 -async fn run_local_project_asset_generation_task( - root: PathBuf, - task_id: String, - prompt: String, - options: PlatformArtAssetGenerationOptions, -) { - if update_task(&root, &task_id, |task| { - task.status = ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(); - task.phase_detail = ASSET_GENERATION_TASK_PHASE_RUNNING.to_string(); - task.started_at_millis = Some(now_millis()); - }) - .is_err() - { - remove_live_task_id(&task_id); - return; - } - let outcome = generate_platform_art_asset_with_options_at(&root, &prompt, &[], &options).await; - match outcome { - Ok(generated) => { - let _ = update_task(&root, &task_id, |task| { - task.status = ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string(); - task.phase_detail = ASSET_GENERATION_TASK_PHASE_COMPLETED.to_string(); - task.asset_id = Some(generated.asset.id.clone()); - task.finished_at_millis = Some(now_millis()); - task.error = None; - }); - } - Err(error) => { - let _ = update_task(&root, &task_id, |task| { - task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string(); - task.phase_detail = format!("生成失败:{error}"); - task.error = Some(error.clone()); - task.finished_at_millis = Some(now_millis()); - }); - } - } - remove_live_task_id(&task_id); -} - -/// 走音频无源生成链路的 kind:音效与背景音乐。 -/// -/// 这份判据是「同一命令两种通道」的唯一分叉点:它在白名单里只放这两个成员,其余 kind -/// (含图片类与 `unknown`)一律继续走图片通道的既有收口,不在这一层做兜底猜测。 -fn is_audio_asset_generation_kind(kind: GameCreationAppAssetKind) -> bool { - matches!( - kind, - GameCreationAppAssetKind::SoundEffect | GameCreationAppAssetKind::BackgroundMusic - ) -} - /// 音频(音效 / 背景音乐)提交:校验入参 → 落**同一份**排队记录 → 返回记录与派发所需的请求。 /// /// 与图片类分支的差异只有三处,且都不改变账本形状: @@ -485,167 +432,6 @@ fn begin_local_project_audio_generation_task( Ok((record, request)) } -/// 音频后台执行:状态与阶段文案的每一次流转都由这里写账本。 -/// -/// `run_local_project_audio_generation_at` 返回 `Ok(None)` 表示这次生成没有登记出素材:按失败 -/// 收口,不把一条没有 `assetId` 的记录标成「已完成」——那样前端既定位不到素材,也没有原因可看。 -async fn run_local_project_audio_generation_task( - project_path: String, - task_id: String, - request: LocalProjectAudioGenerationRequest, -) { - let root = PathBuf::from(project_path.trim()); - if update_task(&root, &task_id, |task| { - task.status = ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(); - task.phase_detail = ASSET_GENERATION_TASK_PHASE_RUNNING.to_string(); - task.started_at_millis = Some(now_millis()); - }) - .is_err() - { - remove_live_task_id(&task_id); - return; - } - let outcome = run_local_project_audio_generation_at(&project_path, &request).await; - match outcome { - Ok(Some(asset_id)) => { - let _ = update_task(&root, &task_id, |task| { - task.status = ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string(); - task.phase_detail = ASSET_GENERATION_TASK_PHASE_COMPLETED.to_string(); - task.asset_id = Some(asset_id); - task.finished_at_millis = Some(now_millis()); - task.error = None; - }); - } - Ok(None) => { - let _ = update_task(&root, &task_id, |task| { - task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string(); - task.phase_detail = - format!("生成失败:{ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR}"); - task.error = Some(ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR.to_string()); - task.finished_at_millis = Some(now_millis()); - }); - } - Err(error) => { - let _ = update_task(&root, &task_id, |task| { - task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string(); - task.phase_detail = format!("生成失败:{error}"); - task.error = Some(error.clone()); - task.finished_at_millis = Some(now_millis()); - }); - } - } - remove_live_task_id(&task_id); -} - -/// 提交即返回:校验入参 → 落排队记录 → 派发后台任务 → 返回记录。 -/// -/// 入参收口完全复用 `prepare_local_project_asset_generation`(与同步命令同一份白名单与边界), -/// 生成本身仍是 `generate_platform_art_asset_with_options_at`,本命令不复制任何生成逻辑。 -/// -/// 音频 kind(`sound-effect` / `background-music`)走同一条命令的音频分支:账本、阶段文案、 -/// 中断收口与本地排队全部共用,**只**把「怎么生成」换成既有音频无源生成链路(见 -/// `start_local_project_audio_generation_task`)。图片类载荷口径逐字不变。 -#[tauri::command] -pub(crate) async fn start_local_project_asset_generation( - project_path: String, - project_id: String, - task_id: String, - kind: String, - prompt: String, - aspect_ratio: Option, - image_size: Option, - asset_name: Option, - output_path: Option, - // 前端 IPC 字段 `referenceAssetIds`:当前项目 manifest 里的图片素材 id,只做参考输入, - // 不进任务账本(重试由调用方继续用同一份引用提交,账本本身不新增字段)。 - reference_asset_ids: Option>, - // 前端 IPC 字段 `targetCategory`:完成登记时要落盘的正式栏目分类。同样不进任务账本: - // 它与引用一样属于「同一次提交的本地落点」,重试由调用方继续用同一个栏目提交。 - target_category: Option, - // 前端 IPC 字段 `idempotencyKey`:**音频**生成才带——音频请求身份是一对 operation / 幂等键, - // 重试必须复用同一对,否则就变成第二次付费生成。图片类通道的载荷逐字不变,这个字段对 - // 图片 kind 不参与任何校验。 - idempotency_key: Option, -) -> Result { - let task_id = asset_generation_task_id(&task_id)?; - if is_audio_asset_generation_kind(GameCreationAppAssetKind::parse_with_context( - &kind, - "canvas.asset_kind", - )) { - let idempotency_key = idempotency_key.unwrap_or_default(); - if idempotency_key.trim().is_empty() { - return Err("音频生成缺少 idempotencyKey".to_string()); - } - // 先登记 live 再落账本:窗口期里并发 `list` 不许把这条排队记录判成上次运行的残留。 - let live_registered = register_live_task_id(&task_id); - let (record, request) = match begin_local_project_audio_generation_task( - &project_path, - &project_id, - &task_id, - &kind, - &prompt, - asset_name.as_deref().unwrap_or_default(), - &idempotency_key, - ) { - Ok(pair) => pair, - Err(error) => { - // 校验不过 / 同 id 已在跑 / 账本写不进去:这一轮什么都没派发,撤掉自己的登记。 - if live_registered { - remove_live_task_id(&task_id); - } - return Err(error); - } - }; - tauri::async_runtime::spawn(run_local_project_audio_generation_task( - project_path.trim().to_string(), - task_id, - request, - )); - return Ok(record); - } - let request = prepare_local_project_asset_generation( - &project_path, - &kind, - &prompt, - aspect_ratio.as_deref(), - image_size.as_deref(), - asset_name.as_deref(), - output_path.as_deref(), - reference_asset_ids.as_deref().unwrap_or_default(), - target_category.as_deref(), - )?; - enforce_project_permission_policy(&request.root, "canvas.asset_generate")?; - enforce_project_permission_policy(&request.root, "asset.register")?; - let asset_label = request.options.asset_label.clone(); - let asset_kind = request.options.asset_kind.clone(); - // 与音频分支同一条顺序约束:先登记 live 再落账本,中间不留「排队但还不 live」的窗口。 - let live_registered = register_live_task_id(&task_id); - let record = match begin_local_project_asset_generation_task( - &request.root, - &project_id, - &task_id, - asset_kind, - &asset_label, - request.options.output_path.as_deref(), - ) { - Ok(record) => record, - Err(error) => { - if live_registered { - remove_live_task_id(&task_id); - } - return Err(error); - } - }; - let root = request.root.clone(); - tauri::async_runtime::spawn(run_local_project_asset_generation_task( - root, - task_id, - request.prompt, - request.options, - )); - Ok(record) -} - /// 读回项目内账本(重开项目后仍能看到历史任务)。 #[tauri::command] pub(crate) fn list_local_project_asset_generations( @@ -1113,6 +899,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/asset_generation_tasks/runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks/runtime.rs new file mode 100644 index 000000000..4a3ccad0d --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks/runtime.rs @@ -0,0 +1,223 @@ +use super::*; +use crate::agent::{ + generate_platform_art_asset_with_options_at, PlatformArtAssetGenerationOptions, +}; +use crate::commands::prepare_local_project_asset_generation; +use crate::project::run_local_project_audio_generation_at; + +pub(crate) const ASSET_GENERATION_TASK_STATUS_RUNNING: &str = "running"; +const ASSET_GENERATION_TASK_PHASE_RUNNING: &str = "正在生成。"; +/// 音频任务收口:通道跑完但没有登记出素材(`derive` 在有源 / 无源两条路上都必须登记 assets)。 +const ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR: &str = "生成完成但未登记素材"; + +/// 后台执行:状态与阶段文案的每一次流转都由这里写账本。 +async fn run_local_project_asset_generation_task( + root: PathBuf, + task_id: String, + prompt: String, + options: PlatformArtAssetGenerationOptions, +) { + if update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(); + task.phase_detail = ASSET_GENERATION_TASK_PHASE_RUNNING.to_string(); + task.started_at_millis = Some(now_millis()); + }) + .is_err() + { + remove_live_task_id(&task_id); + return; + } + let outcome = generate_platform_art_asset_with_options_at(&root, &prompt, &[], &options).await; + match outcome { + Ok(generated) => { + let _ = update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string(); + task.phase_detail = ASSET_GENERATION_TASK_PHASE_COMPLETED.to_string(); + task.asset_id = Some(generated.asset.id.clone()); + task.finished_at_millis = Some(now_millis()); + task.error = None; + }); + } + Err(error) => { + let _ = update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string(); + task.phase_detail = format!("生成失败:{error}"); + task.error = Some(error.clone()); + task.finished_at_millis = Some(now_millis()); + }); + } + } + remove_live_task_id(&task_id); +} + +/// 走音频无源生成链路的 kind:音效与背景音乐。 +/// +/// 这份判据是「同一命令两种通道」的唯一分叉点:它在白名单里只放这两个成员,其余 kind +/// (含图片类与 `unknown`)一律继续走图片通道的既有收口,不在这一层做兜底猜测。 +fn is_audio_asset_generation_kind(kind: GameCreationAppAssetKind) -> bool { + matches!( + kind, + GameCreationAppAssetKind::SoundEffect | GameCreationAppAssetKind::BackgroundMusic + ) +} + +/// 音频后台执行:状态与阶段文案的每一次流转都由这里写账本。 +/// +/// `run_local_project_audio_generation_at` 返回 `Ok(None)` 表示这次生成没有登记出素材:按失败 +/// 收口,不把一条没有 `assetId` 的记录标成「已完成」——那样前端既定位不到素材,也没有原因可看。 +async fn run_local_project_audio_generation_task( + project_path: String, + task_id: String, + request: LocalProjectAudioGenerationRequest, +) { + let root = PathBuf::from(project_path.trim()); + if update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(); + task.phase_detail = ASSET_GENERATION_TASK_PHASE_RUNNING.to_string(); + task.started_at_millis = Some(now_millis()); + }) + .is_err() + { + remove_live_task_id(&task_id); + return; + } + let outcome = run_local_project_audio_generation_at(&project_path, &request).await; + match outcome { + Ok(Some(asset_id)) => { + let _ = update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string(); + task.phase_detail = ASSET_GENERATION_TASK_PHASE_COMPLETED.to_string(); + task.asset_id = Some(asset_id); + task.finished_at_millis = Some(now_millis()); + task.error = None; + }); + } + Ok(None) => { + let _ = update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string(); + task.phase_detail = + format!("生成失败:{ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR}"); + task.error = Some(ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR.to_string()); + task.finished_at_millis = Some(now_millis()); + }); + } + Err(error) => { + let _ = update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string(); + task.phase_detail = format!("生成失败:{error}"); + task.error = Some(error.clone()); + task.finished_at_millis = Some(now_millis()); + }); + } + } + remove_live_task_id(&task_id); +} + +/// 提交即返回:校验入参 → 落排队记录 → 派发后台任务 → 返回记录。 +/// +/// 入参收口完全复用 `prepare_local_project_asset_generation`(与同步命令同一份白名单与边界), +/// 生成本身仍是 `generate_platform_art_asset_with_options_at`,本命令不复制任何生成逻辑。 +/// +/// 音频 kind(`sound-effect` / `background-music`)走同一条命令的音频分支:账本、阶段文案、 +/// 中断收口与本地排队全部共用,**只**把「怎么生成」换成既有音频无源生成链路(见 +/// `start_local_project_audio_generation_task`)。图片类载荷口径逐字不变。 +#[tauri::command] +pub(crate) async fn start_local_project_asset_generation( + project_path: String, + project_id: String, + task_id: String, + kind: String, + prompt: String, + aspect_ratio: Option, + image_size: Option, + asset_name: Option, + output_path: Option, + // 前端 IPC 字段 `referenceAssetIds`:当前项目 manifest 里的图片素材 id,只做参考输入, + // 不进任务账本(重试由调用方继续用同一份引用提交,账本本身不新增字段)。 + reference_asset_ids: Option>, + // 前端 IPC 字段 `targetCategory`:完成登记时要落盘的正式栏目分类。同样不进任务账本: + // 它与引用一样属于「同一次提交的本地落点」,重试由调用方继续用同一个栏目提交。 + target_category: Option, + // 前端 IPC 字段 `idempotencyKey`:**音频**生成才带——音频请求身份是一对 operation / 幂等键, + // 重试必须复用同一对,否则就变成第二次付费生成。图片类通道的载荷逐字不变,这个字段对 + // 图片 kind 不参与任何校验。 + idempotency_key: Option, +) -> Result { + let task_id = asset_generation_task_id(&task_id)?; + if is_audio_asset_generation_kind(GameCreationAppAssetKind::parse_with_context( + &kind, + "canvas.asset_kind", + )) { + let idempotency_key = idempotency_key.unwrap_or_default(); + if idempotency_key.trim().is_empty() { + return Err("音频生成缺少 idempotencyKey".to_string()); + } + // 先登记 live 再落账本:窗口期里并发 `list` 不许把这条排队记录判成上次运行的残留。 + let live_registered = register_live_task_id(&task_id); + let (record, request) = match begin_local_project_audio_generation_task( + &project_path, + &project_id, + &task_id, + &kind, + &prompt, + asset_name.as_deref().unwrap_or_default(), + &idempotency_key, + ) { + Ok(pair) => pair, + Err(error) => { + // 校验不过 / 同 id 已在跑 / 账本写不进去:这一轮什么都没派发,撤掉自己的登记。 + if live_registered { + remove_live_task_id(&task_id); + } + return Err(error); + } + }; + tauri::async_runtime::spawn(run_local_project_audio_generation_task( + project_path.trim().to_string(), + task_id, + request, + )); + return Ok(record); + } + let request = prepare_local_project_asset_generation( + &project_path, + &kind, + &prompt, + aspect_ratio.as_deref(), + image_size.as_deref(), + asset_name.as_deref(), + output_path.as_deref(), + reference_asset_ids.as_deref().unwrap_or_default(), + target_category.as_deref(), + )?; + enforce_project_permission_policy(&request.root, "canvas.asset_generate")?; + enforce_project_permission_policy(&request.root, "asset.register")?; + let asset_label = request.options.asset_label.clone(); + let asset_kind = request.options.asset_kind.clone(); + // 与音频分支同一条顺序约束:先登记 live 再落账本,中间不留「排队但还不 live」的窗口。 + let live_registered = register_live_task_id(&task_id); + let record = match begin_local_project_asset_generation_task( + &request.root, + &project_id, + &task_id, + asset_kind, + &asset_label, + request.options.output_path.as_deref(), + ) { + Ok(record) => record, + Err(error) => { + if live_registered { + remove_live_task_id(&task_id); + } + return Err(error); + } + }; + let root = request.root.clone(); + tauri::async_runtime::spawn(run_local_project_asset_generation_task( + root, + task_id, + request.prompt, + request.options, + )); + Ok(record) +} 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 42280400d..01e556e37 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -2032,6 +2032,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 { @@ -2099,6 +2100,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/browser/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs index 41bf2cf64..0d98e7c97 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs @@ -32,6 +32,7 @@ use super::playtest::{ GENERIC_PLAYTEST_START_OPPORTUNITY_MIN_STABILITY_SAMPLES, GENERIC_PLAYTEST_START_OPPORTUNITY_WINDOW, PLAYABLE_GAME_STATE_SCHEMA_VERSION, }; +#[cfg(unix)] use super::process::create_browser_process_temp_dir; static PATH_TEST_LOCK: Mutex<()> = Mutex::new(()); 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 072750eca..d77fc38b5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1,8 +1,9 @@ +#[cfg(not(test))] +mod desktop; +#[cfg(not(test))] +pub(crate) use desktop::*; + use super::*; -use crate::agent::{ - read_direct_project_chat_history_at, read_direct_project_last_item_id_at, - DirectProjectHistoryAnchor, -}; use crate::ui_editor::resource::font::FontAsset; use base64::Engine as _; use sha2::{Digest, Sha256}; @@ -20,21 +21,8 @@ const AGENT_EDITOR_ASSET_LIBRARY_MAX_ITEMS: usize = 500; const AGENT_EDITOR_ASSET_ID_MAX_CHARS: usize = 512; const EDITOR_ASSET_PREVIEW_MAX_BYTES: usize = 8 * 1024 * 1024; const AUTOMATIC_PROJECT_NAME_MAX_PROMPT_CHARS: usize = 8_000; -const AUTOMATIC_PROJECT_NAME_MAX_OUTPUT_TOKENS: u32 = 64; -// Project naming is an optional homepage enhancement. It must never hold the -// actual project creation flow behind the normal (potentially three-minute) -// generation timeout. -const AUTOMATIC_PROJECT_NAME_TIMEOUT_MS: u64 = 15_000; -const AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT: &str = - include_str!("../prompts/automatic-project-name.md"); -// 聊天输入区的 AI 润色复用同一条短文本生成通道:只做一次单轮改写, -// 计费由平台 LLM 路由(/api/llm/chat/completions、/api/llm/responses)侧完成。 const LOCAL_PROJECT_PROMPT_POLISH_MAX_PROMPT_CHARS: usize = 4_000; const LOCAL_PROJECT_PROMPT_POLISH_MAX_CONTEXT_CHARS: usize = 1_000; -const LOCAL_PROJECT_PROMPT_POLISH_MAX_OUTPUT_TOKENS: u32 = 2_048; -const LOCAL_PROJECT_PROMPT_POLISH_SYSTEM_PROMPT: &str = - include_str!("../prompts/local-project-prompt-polish.md"); - fn is_chinese_project_name_character(value: char) -> bool { matches!( value, @@ -67,51 +55,6 @@ pub(crate) fn build_automatic_project_name_prompt(prompt: &str) -> Result Result, String> { - let user_prompt = build_automatic_project_name_prompt(prompt)?; - let app_config = load_game_creator_app_config()?; - if app_config.agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER { - let reply = tokio::time::timeout( - std::time::Duration::from_millis(AUTOMATIC_PROJECT_NAME_TIMEOUT_MS), - crate::agent::direct_game_creator_home_codex_chat( - AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT.trim().to_string(), - user_prompt, - ), - ) - .await - .map_err(|_| { - format!( - "自动项目命名超时(超过 {} 秒)", - AUTOMATIC_PROJECT_NAME_TIMEOUT_MS / 1_000 - ) - })??; - return Ok(normalize_suggested_project_name(reply.trim())); - } - let mut llm = app_config.llm.clone(); - llm.max_retries = 0; - let client = build_game_creator_llm_client_from_llm_config(&llm, "llm")?; - let request = - LlmRunRequest::single_turn(AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT.trim(), user_prompt) - .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) - .with_model(llm.model.clone()) - .with_request_timeout_ms(llm.request_timeout_ms) - .with_max_output_tokens(AUTOMATIC_PROJECT_NAME_MAX_OUTPUT_TOKENS) - .with_web_search(false); - let response = tokio::time::timeout( - std::time::Duration::from_millis(AUTOMATIC_PROJECT_NAME_TIMEOUT_MS), - client.run(request), - ) - .await - .map_err(|_| { - format!( - "自动项目命名超时(超过 {} 秒)", - AUTOMATIC_PROJECT_NAME_TIMEOUT_MS / 1_000 - ) - })? - .map_err(|error| format!("自动项目命名失败:{error}"))?; - Ok(normalize_suggested_project_name(response.text.trim())) -} - pub(crate) fn build_local_project_prompt_polish_prompt( prompt: &str, context: Option<&str>, @@ -145,40 +88,6 @@ fn normalize_polished_prompt(value: &str) -> Option { } } -async fn request_local_project_prompt_polish( - prompt: &str, - context: Option<&str>, -) -> Result { - let user_prompt = build_local_project_prompt_polish_prompt(prompt, context)?; - let app_config = load_game_creator_app_config()?; - if app_config.agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER { - let reply = crate::agent::direct_game_creator_home_codex_chat( - LOCAL_PROJECT_PROMPT_POLISH_SYSTEM_PROMPT.trim().to_string(), - user_prompt, - ) - .await?; - return normalize_polished_prompt(&reply) - .ok_or_else(|| "AI 润色没有返回可用文本".to_string()); - } - let mut llm = app_config.llm.clone(); - llm.max_retries = 0; - let client = build_game_creator_llm_client_from_llm_config(&llm, "llm")?; - let request = LlmRunRequest::single_turn( - LOCAL_PROJECT_PROMPT_POLISH_SYSTEM_PROMPT.trim(), - user_prompt, - ) - .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) - .with_model(llm.model.clone()) - .with_request_timeout_ms(llm.request_timeout_ms) - .with_max_output_tokens(LOCAL_PROJECT_PROMPT_POLISH_MAX_OUTPUT_TOKENS) - .with_web_search(false); - let response = client - .run(request) - .await - .map_err(|error| format!("AI 润色失败:{error}"))?; - normalize_polished_prompt(&response.text).ok_or_else(|| "AI 润色没有返回可用文本".to_string()) -} - #[cfg(test)] mod local_project_prompt_polish_tests { use super::*; @@ -469,21 +378,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 私有路径门禁:门禁失败时 @@ -510,22 +404,6 @@ pub(crate) fn validate_requested_game_project_creation_root( Ok(root.to_path_buf()) } -/// 解析本次建项要使用的根目录:显式参数优先,其次读取 Rust 工作区偏好,最后回落 AGC 默认目录。 -pub(crate) fn resolve_game_project_creation_root( - app: &tauri::AppHandle, - requested: Option<&str>, -) -> Result { - let stored = requested - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .or_else(|| crate::workspace_preferences::stored_project_creation_directory(app)); - match stored.as_deref() { - 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>, @@ -590,61 +468,6 @@ pub(crate) fn create_automatic_local_game_project_at( Err("自动工作区命名冲突,请重试".to_string()) } -#[tauri::command] -pub(crate) fn create_automatic_local_game_project( - app: tauri::AppHandle, - name: Option, - planning: Option, - projects_root: Option, -) -> Result { - let analytics_context = crate::analytics::gui::capture_analytics_context(); - let result = create_automatic_local_game_project_at( - &resolve_game_project_creation_root(&app, projects_root.as_deref())?, - 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(), - if planning.unwrap_or(false) { - crate::analytics::contract::CreationSource::HomeDesign - } else { - crate::analytics::contract::CreationSource::HomeGame - }, - None, - ); - } - result -} - -#[tauri::command] -pub(crate) fn init_local_game_project( - project_path: String, - project_id: String, - name: String, -) -> Result { - let analytics_context = crate::analytics::gui::capture_analytics_context(); - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.create")?; - let _lock = acquire_project_write_lock(root, "project.create")?; - let new_project = matches!(fs::symlink_metadata(root.join(".agent/manifest.json")), Err(error) if error.kind() == std::io::ErrorKind::NotFound); - let result = init_local_game_project_at(root, project_id.trim(), name.trim()); - if new_project { - 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::SelectedDirectory, - None, - ); - } - } - result -} - #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct RenameLocalProjectResult { @@ -708,67 +531,6 @@ pub(crate) fn import_local_godot_project( result } -#[tauri::command] -pub(crate) fn import_local_cocos_project( - project_path: String, - project_id: String, - name: String, -) -> Result { - let analytics_context = crate::analytics::gui::capture_analytics_context(); - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.create")?; - if discover_local_cocos_project_root(root)?.is_none() { - return Err( - "所选目录不是有效的 Cocos Creator 项目(需要 package.json.creator.version 和 assets/)" - .to_string(), - ); - } - let _lock = acquire_project_write_lock(root, "project.create")?; - let new_project = matches!(fs::symlink_metadata(root.join(".agent/manifest.json")), Err(error) if error.kind() == std::io::ErrorKind::NotFound); - let result = import_local_cocos_project_at(root, project_id.trim(), name.trim()); - if new_project { - 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::SelectedDirectory, - None, - ); - } - } - result -} - -#[tauri::command] -pub(crate) fn import_local_unity_project( - project_path: String, - project_id: String, - name: String, -) -> Result { - let analytics_context = crate::analytics::gui::capture_analytics_context(); - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.create")?; - if discover_local_unity_project_root(root)?.is_none() { - return Err("所选目录不是有效的 Unity 项目".to_string()); - } - let _lock = acquire_project_write_lock(root, "project.create")?; - let new_project = matches!(fs::symlink_metadata(root.join(".agent/manifest.json")), Err(error) if error.kind() == std::io::ErrorKind::NotFound); - let result = import_local_unity_project_at(root, project_id.trim(), name.trim()); - if new_project { - 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::SelectedDirectory, - None, - ); - } - } - result -} - #[tauri::command] pub(crate) fn is_local_project_directory_non_empty(project_path: String) -> Result { let root = Path::new(project_path.trim()); @@ -796,15 +558,6 @@ pub(crate) fn is_local_project_directory_non_empty(project_path: String) -> Resu .map(|entry| entry.is_some()) } -#[tauri::command] -pub(crate) async fn inspect_local_project_directory( - project_path: String, -) -> Result { - tauri::async_runtime::spawn_blocking(move || inspect_local_project_directory_sync(project_path)) - .await - .map_err(|error| format!("检查项目目录后台任务失败:{error}"))? -} - pub(crate) fn inspect_local_project_directory_sync( project_path: String, ) -> Result { @@ -898,84 +651,6 @@ pub(crate) fn recent_game_creator_run_trace(root: &Path) -> Option(&content).ok() } -/// 目录选择器标题:调用方只能给短标题,其余(超长、含控制字符、空白)一律回退默认文案。 -fn pick_project_directory_title(title: Option<&str>) -> &str { - const MAX_TITLE_CHARS: usize = 24; - title - .map(str::trim) - .filter(|value| { - !value.is_empty() - && value.chars().count() <= MAX_TITLE_CHARS - && !value.chars().any(char::is_control) - }) - .unwrap_or("选择游戏项目目录") -} - -#[tauri::command] -pub(crate) async fn pick_local_project_directory( - app: tauri::AppHandle, - initial_path: Option, - title: Option, -) -> Result, String> { - let (sender, receiver) = tokio::sync::oneshot::channel(); - let mut dialog = app - .dialog() - .file() - .set_title(pick_project_directory_title(title.as_deref())); - if let Some(initial_path) = initial_path - .as_deref() - .map(str::trim) - .filter(|path| !path.is_empty()) - { - let initial_path = Path::new(initial_path); - if initial_path.is_absolute() && !project_path_has_control_chars(initial_path) { - let starting_directory = closest_existing_project_picker_directory(initial_path); - if let Some(starting_directory) = starting_directory { - dialog = dialog.set_directory(starting_directory); - } - } - } - 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); - // The native picker is the explicit user-selection boundary. Prepare the - // selected root before returning it so inspect/create/open never races the - // first ACL read. - if let Err(error) = - crate::prepare_game_creator_project_root_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 open_local_project_directory( - app: tauri::AppHandle, - project_path: String, -) -> Result<(), String> { - let path = validated_local_project_directory_path(project_path.trim())?; - app.opener() - .open_path(path.to_string_lossy().into_owned(), None::<&str>) - .map_err(|error| format!("打开项目目录失败:{error}")) -} - pub(crate) fn validated_local_project_directory_path( project_path: &str, ) -> Result { @@ -999,18 +674,6 @@ pub(crate) fn validated_local_project_directory_path( Ok(path.to_path_buf()) } -#[tauri::command] -pub(crate) async fn get_local_game_manifest( - project_path: String, - command_id: Option, -) -> Result { - tauri::async_runtime::spawn_blocking(move || { - get_local_game_manifest_sync(project_path, command_id) - }) - .await - .map_err(|error| format!("读取项目 manifest 后台任务失败:{error}"))? -} - pub(crate) fn get_local_game_manifest_sync( project_path: String, command_id: Option, @@ -1059,17 +722,6 @@ pub(crate) fn read_local_project_resource_canvas_layout( read_project_resource_canvas_layout_at(root, mode) } -#[tauri::command] -pub(crate) fn read_local_project_resource_graph( - project_path: String, - expected_project_id: String, - resources: Vec, -) -> Result { - let root = validated_local_project_directory_path(project_path.trim())?; - enforce_project_auto_permission_policy(&root, "asset.list")?; - read_project_resource_graph_at(&root, expected_project_id.trim(), resources) -} - /// 保存资源画布的持久化布局。 /// /// 权限位取 `asset.register`,依据是同批「改动项目内资源相关持久化数据」的既有用法:本文件里 @@ -1098,154 +750,6 @@ pub(crate) fn update_local_project_resource_canvas_layout( ) } -/// 读取引用了某个素材的项目版本,供删除弹窗提示「被哪些版本使用」。 -#[tauri::command] -pub(crate) fn read_local_project_asset_references( - input: ReadLocalProjectAssetReferencesInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.list")?; - read_manifest_asset_references_at(root, &input.asset_id) -} - -/// 删除一个素材登记,并按用户确认决定是否连带删除引用它的游戏版本。 -/// -/// 素材不可变:只摘掉 manifest 登记,磁盘文件保留。 -#[tauri::command] -pub(crate) fn delete_local_project_asset( - input: DeleteLocalProjectAssetInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - delete_manifest_asset_at( - root, - &input.expected_project_id, - input.expected_project_revision, - &input.asset_id, - input.delete_referenced_versions, - ) -} - -/// 读取某个版本引用的源素材可用的替换候选,并给出后端权威的三项兼容性结论。 -/// -/// 只读:不改 manifest、不推进 revision。候选渲染但禁用,不在前端重算判据。 -#[tauri::command] -pub(crate) fn read_local_project_version_resource_replacement_candidates( - input: ReadLocalProjectVersionReplacementCandidatesInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.list")?; - read_local_project_version_replacement_candidates_at( - root, - &input.source_version_id, - &input.source_resource_id, - ) -} - -/// 用另一个已登记素材替换某个版本引用的素材:改 manifest 绑定,并**追加下一迭代版本**。 -/// -/// 可运行版本不可变(既有版本记录一个字节都不改)、不动资源文件、不建文件副本; -/// 三项兼容性必须同时为 true,否则拒绝并说明哪一项不等。CAS 失败时 manifest 与 revision 都不变。 -#[tauri::command] -pub(crate) fn replace_local_project_version_resource( - input: ReplaceLocalProjectVersionResourceInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - replace_local_project_version_resource_at( - root, - &input.expected_project_id, - input.expected_project_revision, - &input.source_version_id, - &input.source_resource_id, - &input.replacement_resource_id, - ) -} - -/// 重命名一个已登记素材:磁盘文件改名 + 更新 manifest 的 `localPath`,资产 `id` 不变。 -/// -/// 只允许在资产当前所在目录内改名,扩展名必须一致,同目录不得已有同名文件;manifest 写失败 -/// 时把文件改回原名,不留半成品。与删除 / 分类更新同口径:持项目写锁后按 `expectedProjectId` -/// 与当前 revision 做 CAS,失败时磁盘、manifest 与 revision 都不动。 -#[tauri::command] -pub(crate) fn rename_local_project_asset( - input: RenameLocalProjectAssetInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - rename_local_project_asset_at( - root, - &input.expected_project_id, - input.expected_project_revision, - &input.asset_id, - &input.new_file_name, - ) -} - -#[tauri::command] -pub(crate) async fn control_agent_run( - app: tauri::AppHandle, - project_path: String, - action: String, - detail: Option, -) -> Result { - let root = Path::new(project_path.trim()); - let command_id = match action.trim() { - "status" => "agent.run_status", - "kill" => "agent.kill", - "retry" => "agent.retry", - "resume" => "agent.resume", - _ => "agent.run_status", - }; - enforce_project_permission_policy(root, command_id)?; - if matches!(action.trim(), "retry" | "resume") { - enforce_project_permission_policy(root, "game.generate_draft")?; - } - let _lock = if command_id == "agent.run_status" { - None - } else { - Some(acquire_project_write_lock(root, command_id)?) - }; - control_agent_run_at( - root, - action.trim(), - detail - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()), - Some(&AgentProgressEmitter::new(&app, project_path.trim())), - ) - .await -} - -#[tauri::command] -pub(crate) async fn generate_local_game_draft( - app: tauri::AppHandle, - project_path: String, - prompt: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "game.generate_draft")?; - let _lock = acquire_project_write_lock(root, "game.generate_draft")?; - advance_agent_runtime_project_revision_locked(root)?; - generate_local_game_draft_at( - root, - prompt.trim(), - Some(&AgentProgressEmitter::new(&app, project_path.trim())), - ) - .await -} - -#[tauri::command] -pub(crate) async fn chat_with_game_creator_agent( - project_path: String, - prompt: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - chat_with_game_creator_agent_at(root, prompt.trim()).await -} - #[tauri::command] pub(crate) async fn chat_with_game_creator_role_agent( project_path: String, @@ -1275,206 +779,6 @@ pub(crate) async fn chat_with_game_creator_role_agent( } } -#[tauri::command] -pub(crate) async fn chat_with_game_creator_role_agent_stream( - app: tauri::AppHandle, - project_path: String, - agent_id: String, - session_id: Option, - prompt: String, - run_id: String, -) -> Result { - let project_path = project_path.trim().to_string(); - let agent_id = normalize_game_creator_runtime_agent_id(agent_id.trim())?; - let run_id = run_id.trim().to_string(); - let root = Path::new(project_path.as_str()); - let session_id = - resolve_agent_conversation_session_id_at(root, &agent_id, session_id.as_deref(), true)?; - enforce_project_permission_policy(root, "conversation.read")?; - enforce_project_permission_policy(root, "conversation.write")?; - let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(root, &agent_id)? - .ok_or_else(|| format!("Agent 正在执行其他前台或后台任务:{agent_id}"))?; - let emit_app = app.clone(); - let event_project_path = project_path.clone(); - let event_agent_id = agent_id.clone(); - let event_run_id = run_id.clone(); - let command_result = async { - let mut runtime_state = start_game_creator_agent_runtime_turn_for_session_at( - root, - agent_id.as_str(), - Some(&session_id), - prompt.trim(), - &run_id, - )?; - runtime_state = advance_game_creator_agent_runtime_turn_at( - root, - runtime_state, - "llm", - "请求 Agent LLM", - "已读取项目上下文,正在让 Agent 独立推理。", - )?; - let mut streaming_runtime_state = runtime_state.clone(); - streaming_runtime_state.current_action = "正在接收 Agent 回复".to_string(); - let _ = app.emit( - "game-creator-role-agent-chat-stream", - GameCreatorRoleAgentChatStreamEvent { - project_path: event_project_path.clone(), - agent_id: event_agent_id.clone(), - run_id: event_run_id.clone(), - status: "started".to_string(), - delta_text: String::new(), - accumulated_text: String::new(), - finish_reason: None, - session_id: Some(runtime_state.session_id.clone()), - runtime_status: Some(runtime_state.status.clone()), - runtime_phase: Some(runtime_state.phase.clone()), - runtime_summary: Some(runtime_state.current_action.clone()), - runtime_state: Some(runtime_state.clone()), - }, - ); - let result = chat_with_game_creator_role_agent_stream_for_session_at( - root, - agent_id.as_str(), - Some(&session_id), - prompt.trim(), - |delta| { - let _ = emit_app.emit( - "game-creator-role-agent-chat-stream", - GameCreatorRoleAgentChatStreamEvent { - project_path: event_project_path.clone(), - agent_id: event_agent_id.clone(), - run_id: event_run_id.clone(), - status: "delta".to_string(), - delta_text: delta.delta_text.clone(), - accumulated_text: delta.accumulated_text.clone(), - finish_reason: delta.finish_reason.clone(), - session_id: Some(streaming_runtime_state.session_id.clone()), - runtime_status: Some("running".to_string()), - runtime_phase: Some("llm".to_string()), - runtime_summary: Some("正在接收 Agent 回复".to_string()), - runtime_state: Some(streaming_runtime_state.clone()), - }, - ); - }, - ) - .await; - match result { - Ok(reply) => { - let completed_runtime = finish_game_creator_agent_runtime_turn_at( - root, - runtime_state, - &reply.reply_text, - )?; - let _ = app.emit( - "game-creator-role-agent-chat-stream", - GameCreatorRoleAgentChatStreamEvent { - project_path: project_path.clone(), - agent_id: agent_id.clone(), - run_id: run_id.clone(), - status: "completed".to_string(), - delta_text: String::new(), - accumulated_text: reply.reply_text.clone(), - finish_reason: None, - session_id: Some(completed_runtime.session_id.clone()), - runtime_status: Some(completed_runtime.status.clone()), - runtime_phase: Some(completed_runtime.phase.clone()), - runtime_summary: Some(completed_runtime.current_action.clone()), - runtime_state: Some(completed_runtime), - }, - ); - Ok(reply) - } - Err(error) => { - let failed_runtime = - fail_game_creator_agent_runtime_turn_at(root, runtime_state, &error).ok(); - let _ = app.emit( - "game-creator-role-agent-chat-stream", - GameCreatorRoleAgentChatStreamEvent { - project_path: project_path.clone(), - agent_id: agent_id.clone(), - run_id: run_id.clone(), - status: "failed".to_string(), - delta_text: String::new(), - accumulated_text: String::new(), - finish_reason: None, - session_id: failed_runtime - .as_ref() - .map(|runtime| runtime.session_id.clone()), - runtime_status: failed_runtime - .as_ref() - .map(|runtime| runtime.status.clone()), - runtime_phase: failed_runtime.as_ref().map(|runtime| runtime.phase.clone()), - runtime_summary: failed_runtime - .as_ref() - .map(|runtime| runtime.current_action.clone()), - runtime_state: failed_runtime, - }, - ); - Err(error) - } - } - } - .await; - spawn_next_game_creator_agent_background_task_drain_with_lock(root, &agent_id, runtime_lock); - match command_result { - Ok(reply) => Ok(reply), - Err(error) => Err(error), - } -} - -#[tauri::command] -pub(crate) fn start_game_creator_supervisor_runtime_task( - project_path: String, - session_id: Option, - task: String, - run_id: String, - run_profile: Option, - source: Option, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - enforce_project_permission_policy(root, "conversation.write")?; - enforce_project_permission_policy(root, "agent.run_status")?; - let run_profile = run_profile - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(AGENT_RUNTIME_RUN_PROFILE_STANDARD); - let source = source - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE); - if !agent_runtime_supervisor_source_is_trusted(source) { - return Err("Project Supervisor 提交 source 不受信任".to_string()); - } - start_game_creator_supervisor_background_task_for_session_at( - root, - session_id.as_deref(), - task.trim(), - run_id.trim(), - source, - run_profile, - ) -} - -#[tauri::command] -pub(crate) async fn compact_game_creator_agent_runtime_context( - project_path: String, - agent_id: String, - session_id: Option, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - enforce_project_permission_policy(root, "agent.compact")?; - if external_agent_runner_enabled() && !external_agent_runner_is_server_process() { - compact_external_agent_runner_context(root, agent_id.trim(), session_id.as_deref()) - } else { - compact_game_creator_agent_runtime_session_at(root, agent_id.trim(), session_id.as_deref()) - .await - } -} - #[tauri::command] pub(crate) fn read_game_creator_agent_goal( project_path: String, @@ -1603,19 +907,6 @@ pub(crate) fn clear_game_creator_agent_goal( ) } -#[tauri::command] -pub(crate) fn cancel_game_creator_agent_runtime_task( - project_path: String, - agent_id: String, - run_id: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - enforce_project_permission_policy(root, "conversation.write")?; - enforce_project_permission_policy(root, "agent.run_status")?; - cancel_game_creator_agent_runtime_task_at(root, agent_id.trim(), run_id.trim()) -} - #[tauri::command] pub(crate) fn retry_game_creator_agent_runtime_task( project_path: String, @@ -1703,52 +994,6 @@ pub(crate) fn reject_game_creator_agent_runtime_task( ) } -#[tauri::command] -pub(crate) fn answer_game_creator_agent_runtime_user_input( - project_path: String, - agent_id: String, - run_id: String, - action_id: String, - request_id: String, - response_id: String, - answers: BTreeMap, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - enforce_project_permission_policy(root, "conversation.write")?; - enforce_project_permission_policy(root, "agent.run_status")?; - enforce_project_permission_policy(root, "agent.resume")?; - answer_game_creator_agent_runtime_user_input_at( - root, - agent_id.trim(), - run_id.trim(), - action_id.trim(), - request_id.trim(), - response_id.trim(), - answers, - ) -} - -#[tauri::command] -pub(crate) fn read_game_creator_agent_runtime( - project_path: String, - agent_id: String, - session_id: Option, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - read_game_creator_agent_runtime_for_session_at(root, agent_id.trim(), session_id.as_deref()) -} - -#[tauri::command] -pub(crate) fn read_game_creator_agent_runtimes( - project_path: String, -) -> Result, String> { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - read_game_creator_agent_runtimes_at(root) -} - #[tauri::command] pub(crate) fn resume_game_creator_agent_runtime_tasks( project_path: String, @@ -1801,81 +1046,6 @@ pub(crate) async fn check_game_creator_llm_config() -> Result Result, String> { - request_automatic_project_name(prompt.trim()) - .await - .map_err(|error| redact_agent_runtime_error(Path::new("."), &error, 320)) -} - -#[tauri::command] -pub(crate) async fn polish_local_project_prompt( - prompt: String, - context: Option, -) -> Result { - request_local_project_prompt_polish(prompt.trim(), context.as_deref()) - .await - .map_err(|error| redact_agent_runtime_error(Path::new("."), &error, 320)) -} - -/// 安装客户端会话:先传播到本地 Runner,再写本进程会话快照。 -/// -/// Tauri 命令与 Rust 认证会话(`auth_session`)共用这一条路径,避免出现「命令装好了、 -/// 认证链路没装」这种半状态。 -pub(crate) async fn install_client_session_locally( - user_id: String, - access_token: String, - api_base_url: String, - identity_generation: u64, - revision: u64, -) -> Result<(), String> { - tokio::task::spawn_blocking(move || { - validate_platform_session_input( - &user_id, - &access_token, - &api_base_url, - identity_generation, - revision, - )?; - install_external_agent_runner_platform_session( - &user_id, - &access_token, - &api_base_url, - identity_generation, - revision, - )?; - install_platform_session( - &user_id, - &access_token, - &api_base_url, - identity_generation, - revision, - ) - }) - .await - .map_err(|error| format!("安装本地运行时会话任务意外终止:{error}"))? -} - -/// 清除客户端会话:先停掉本机会话相关的 app-server,再清 Runner 与本进程快照。 -pub(crate) async fn clear_client_session_locally( - identity_generation: u64, - revision: u64, -) -> Result<(), String> { - tokio::task::spawn_blocking(move || { - shutdown_game_creator_codex_app_servers()?; - clear_external_agent_runner_platform_session(identity_generation, revision)?; - clear_platform_session(identity_generation, revision); - Ok(()) - }) - .await - .map_err(|error| format!("清除本地运行时会话任务意外终止:{error}"))? -} - -// 会话安装/清除只由 Rust 认证会话(`auth_session`)调用 `install_client_session_locally` -// 与 `clear_client_session_locally`;渲染层不再持有写入入口。 - #[tauri::command] pub(crate) fn read_game_creator_app_config() -> Result { game_creator_app_config_view(load_game_creator_app_config()?) @@ -1904,29 +1074,6 @@ pub(crate) fn write_game_creator_app_config( persist_game_creator_app_config(config, overlays, false) } -#[tauri::command] -pub(crate) fn cancel_direct_codex_turn( - project_path: String, - client_turn_id: Option, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "agent.kill")?; - cancel_direct_codex_turn_at(root, client_turn_id.as_deref()) -} - -#[tauri::command] -pub(crate) fn select_game_creator_reasoning_effort( - effort: String, -) -> Result { - let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK - .lock() - .map_err(|_| "配置写入锁不可用")?; - let effort = game_creator_llm_reasoning_effort_name(&effort, "llm.reasoningEffort")?; - let (mut config, overlays) = load_game_creator_app_config_for_write()?; - config.llm.reasoning_effort = effort; - persist_game_creator_app_config(config, overlays, false) -} - fn select_game_creator_model_inner( model_id: String, is_default: bool, @@ -1969,16 +1116,6 @@ pub(crate) fn select_game_creator_model( select_game_creator_model_inner(model_id, is_default, agent_mode) } -#[tauri::command] -pub(crate) async fn discover_game_creator_llm_models( - llm: GameCreatorLlmConfig, -) -> Result, String> { - if !load_game_creator_app_config()?.llm.custom_enabled { - return Err("请先在本地配置中开启 llm.customEnabled".to_string()); - } - fetch_custom_llm_models(&llm).await -} - fn persist_game_creator_app_config( config: GameCreatorAppConfig, overlays: Vec<(PathBuf, serde_json::Value)>, @@ -2036,75 +1173,6 @@ fn update_existing_config_overlay_fields( } } -#[tauri::command] -pub(crate) fn upload_local_asset( - project_path: String, - file_name: String, - media_type: String, - bytes: Vec, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "asset.upload")?; - let _lock = acquire_project_write_lock(root, "asset.upload")?; - advance_agent_runtime_project_revision_locked(root)?; - upload_local_asset_at(root, file_name.trim(), media_type.trim(), &bytes) -} - -#[tauri::command] -pub(crate) fn register_local_asset( - project_path: String, - local_path: String, - kind: String, - media_type: String, - source_kind: String, - canvas_project_id: Option, - resource_id: Option, - asset_object_id: Option, - task_id: Option, - prompt: Option, - model: Option, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - let _lock = acquire_project_write_lock(root, "asset.register")?; - advance_agent_runtime_project_revision_locked(root)?; - register_local_asset_at( - root, - local_path.trim(), - crate::assets::registration_asset_kind(&kind, "asset.register"), - media_type.trim(), - source_kind.trim(), - GameCreationAppAssetSource { - kind: parse_asset_source_kind(source_kind.trim())?, - canvas_project_id: trim_optional_string(canvas_project_id), - resource_id: trim_optional_string(resource_id), - asset_object_id: trim_optional_string(asset_object_id), - task_id: trim_optional_string(task_id), - prompt: trim_optional_string(prompt), - model: trim_optional_string(model), - generation_route: None, - generation_kind: None, - reference_resource_ids: Vec::new(), - }, - ) -} - -#[tauri::command] -pub(crate) fn update_local_project_resource_classification( - input: UpdateLocalProjectResourceClassificationInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - update_manifest_asset_classification_at( - root, - &input.expected_project_id, - input.expected_project_revision, - &input.asset_id, - &input.category, - input.tags, - ) -} - /// 为一批已登记素材追加标签:整批一次校验、一次 manifest 写入、一次 revision 推进。 /// /// 权限位与单素材分类更新同口径取 `asset.register`(命令包装层只做权限门面, @@ -2126,131 +1194,6 @@ pub(crate) fn add_local_project_resource_tags( ) } -#[tauri::command] -pub(crate) async fn derive_local_project_resource( - input: DeriveLocalProjectResourceInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - derive_local_project_resource_at(input).await -} - -#[tauri::command] -pub(crate) fn list_pending_local_project_resource_edits( - input: ListPendingLocalProjectResourceEditsInput, -) -> Result, String> { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "file.list")?; - list_pending_local_project_resource_edits_at(input) -} - -#[tauri::command] -pub(crate) async fn resume_local_project_resource_edit( - input: ResumeLocalProjectResourceEditInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - resume_local_project_resource_edit_at(input).await -} - -#[tauri::command] -pub(crate) async fn request_local_project_resource_edit_service_identity_confirmation( - input: RequestResourceEditServiceIdentityConfirmationInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - request_resource_edit_service_identity_confirmation_at(input).await -} - -#[tauri::command] -pub(crate) async fn confirm_local_project_resource_edit_service_identity( - input: ConfirmResourceEditServiceIdentityInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - confirm_resource_edit_service_identity_at(input).await -} - -#[tauri::command] -pub(crate) async fn archive_failed_local_project_resource_edit( - input: ArchiveFailedLocalProjectResourceEditInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - archive_failed_local_project_resource_edit_at(input).await -} - -#[tauri::command] -pub(crate) fn normalize_local_project_raster_resource( - input: NormalizeLocalProjectRasterResourceInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - normalize_local_project_raster_resource_at(input) -} - -#[tauri::command] -pub(crate) fn import_canvas_asset( - project_path: String, - local_path: String, - kind: String, - media_type: String, - canvas_project_id: String, - resource_id: Option, - asset_object_id: Option, - task_id: Option, - prompt: Option, - model: Option, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "canvas.asset_import")?; - let _lock = acquire_project_write_lock(root, "canvas.asset_import")?; - advance_agent_runtime_project_revision_locked(root)?; - import_canvas_asset_at( - root, - local_path.trim(), - registration_asset_kind(kind.trim(), "canvas.asset_import"), - media_type.trim(), - canvas_project_id.trim(), - trim_optional_string(resource_id), - trim_optional_string(asset_object_id), - trim_optional_string(task_id), - trim_optional_string(prompt), - trim_optional_string(model), - ) -} - -#[tauri::command] -pub(crate) fn import_canvas_export( - project_path: String, - export_path: String, - canvas_project_id: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "canvas.export_import")?; - let _lock = acquire_project_write_lock(root, "canvas.export_import")?; - advance_agent_runtime_project_revision_locked(root)?; - import_canvas_export_at( - root, - Path::new(export_path.trim()), - canvas_project_id.trim(), - ) -} - -#[tauri::command] -pub(crate) async fn sync_canvas_project_assets( - project_path: String, - canvas_project_id: String, - api_base_url: Option, - api_key: Option, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "canvas.project_sync")?; - let _lock = acquire_project_write_lock(root, "canvas.project_sync")?; - advance_agent_runtime_project_revision_locked(root)?; - sync_canvas_project_assets_at(root, canvas_project_id.trim(), api_base_url, api_key).await -} - pub(crate) fn import_ui_editor_local_files( project_path: String, source_paths: Vec, @@ -2391,25 +1334,6 @@ fn find_registered_ui_editor_font<'a>( .ok_or_else(|| "字体不是当前项目已登记资产".to_string()) } -#[tauri::command] -pub(crate) fn prepare_ui_editor_project_fonts( - project_path: String, - assets: Vec, -) -> Result, String> { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "asset.list")?; - if assets.len() > UI_EDITOR_FONT_MAX_COUNT { - return Err("一次最多选择 64 个字体面".to_string()); - } - let manifest = read_existing_manifest_for_project(root)?; - let mut fonts = Vec::with_capacity(assets.len()); - for selected in assets { - let asset = find_registered_ui_editor_font(&manifest, &selected.id, &selected.local_path)?; - fonts.push(read_registered_ui_editor_font(root, asset)?.0); - } - Ok(fonts) -} - pub(crate) fn import_ui_editor_local_fonts( project_path: String, source_paths: Vec, @@ -2572,56 +1496,6 @@ pub(crate) fn import_ui_editor_local_fonts( }) } -fn read_checked_ui_editor_font( - project_path: &str, - asset_id: &str, - relative_path: &str, - expected_sha256: &str, -) -> Result<(FontAsset, Vec), String> { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "file.read")?; - let manifest = read_existing_manifest_for_project(root)?; - let asset = find_registered_ui_editor_font(&manifest, asset_id, relative_path)?; - let (font, bytes) = read_registered_ui_editor_font(root, asset)?; - if font.content_sha256 != expected_sha256 { - return Err("字体文件内容与资源摘要不一致".to_string()); - } - Ok((font, bytes)) -} - -#[tauri::command] -pub(crate) fn read_ui_editor_font_bytes( - project_path: String, - asset_id: String, - relative_path: String, - expected_sha256: String, -) -> Result { - read_checked_ui_editor_font( - &project_path, - asset_id.trim(), - relative_path.trim(), - expected_sha256.trim(), - ) - .map(|(_, bytes)| tauri::ipc::Response::new(bytes)) -} - -#[tauri::command] -pub(crate) fn check_ui_editor_font_glyph_coverage( - project_path: String, - asset_id: String, - relative_path: String, - expected_sha256: String, - content: String, -) -> Result { - let (font, bytes) = read_checked_ui_editor_font( - &project_path, - asset_id.trim(), - relative_path.trim(), - expected_sha256.trim(), - )?; - font.has_missing_glyphs(&bytes, &content) -} - #[cfg(test)] mod ui_editor_font_tests { use super::*; @@ -4939,17 +3813,6 @@ async fn download_ui_editor_remote_asset(url: &str, max_bytes: u64) -> Result Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "canvas.asset_generate")?; - let generated = generate_platform_art_asset_at(root, prompt.trim(), &[]).await?; - Ok(generated.asset) -} - /// 栏目画布底部工具栏使用的「无源生成图片类素材」入参边界。 /// kind 目录由 `canvas_generation::PLATFORM_ART_ASSET_GENERATION_KINDS` 统一维护, /// 这里只收口比例、尺寸与文本长度,且与 agent 侧同一条通道保持同一量级。 @@ -5388,19 +4251,6 @@ mod local_project_asset_generation_tests { } } -#[tauri::command] -pub(crate) fn open_canvas_project( - app: tauri::AppHandle, - canvas_project_id: Option, - editor_base_url: Option, -) -> Result { - let url = build_canvas_project_url(editor_base_url.as_deref(), canvas_project_id.as_deref())?; - app.opener() - .open_url(&url, None::<&str>) - .map_err(|error| format!("打开画板失败:{error}"))?; - Ok(OpenCanvasProjectResult { url }) -} - #[tauri::command] pub(crate) fn run_limited_local_command( project_path: String, @@ -5417,19 +4267,6 @@ pub(crate) fn run_limited_local_command( Ok(result) } -#[tauri::command] -pub(crate) fn append_local_permission_log( - project_path: String, - event: String, - command_id: String, -) -> Result<(), String> { - append_local_permission_log_at( - Path::new(project_path.trim()), - event.trim(), - command_id.trim(), - ) -} - #[tauri::command] pub(crate) fn list_local_project_files( project_path: String, @@ -5439,25 +4276,6 @@ pub(crate) fn list_local_project_files( list_local_project_files_at(root) } -/// 登记当前项目中已经存在、但尚未写入 manifest 的本地资源。 -/// -/// 该入口只接受项目根相对路径;实际文件签名、大小、敏感路径、项目锁和 -/// manifest/revision 更新统一复用 Agent 的受控导入实现。未登记文件在调用前 -/// 只能作为候选展示,不能由前端构造 asset ID。 -#[tauri::command] -pub(crate) async fn import_local_project_image_assets( - project_path: String, - relative_paths: Vec, -) -> Result { - let root = PathBuf::from(project_path.trim()); - tokio::task::spawn_blocking(move || { - import_local_project_assets_for_agent(&root, &relative_paths) - }) - .await - .map_err(|error| format!("项目内资源登记任务意外终止:{error}")) - .and_then(|result| result) -} - #[tauri::command] pub(crate) fn read_local_project_file( project_path: String, @@ -5482,27 +4300,6 @@ pub(crate) fn read_local_project_file( read_local_project_file_at(root, &normalized_path) } -#[tauri::command] -pub(crate) fn save_local_project_asset_file( - input: SaveLocalProjectAssetFileInput, -) -> Result { - save_local_project_asset_file_at(input) -} -#[tauri::command] -pub(crate) async fn read_local_project_image_preview( - preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, - project_path: String, - relative_path: String, - scope_id: String, - request_id: String, -) -> Result { - preview_manager - .run(&scope_id, &request_id, move |cancellation| { - read_local_project_image_preview_at(&project_path, &relative_path, cancellation) - }) - .await -} - pub(crate) fn read_local_project_image_preview_at( project_path: &str, relative_path: &str, @@ -5532,21 +4329,6 @@ pub(crate) fn read_local_project_image_preview_at( load_local_project_image_preview_with_cancellation(root, &normalized_path, cancellation) } -#[tauri::command] -pub(crate) async fn read_local_project_text_preview( - preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, - project_path: String, - relative_path: String, - scope_id: String, - request_id: String, -) -> Result { - preview_manager - .run(&scope_id, &request_id, move |cancellation| { - read_local_project_text_preview_at(&project_path, &relative_path, cancellation) - }) - .await -} - pub(crate) fn read_local_project_text_preview_at( project_path: &str, relative_path: &str, @@ -5598,78 +4380,6 @@ pub(crate) fn read_local_project_text_preview_at( * UI 编辑链路的准入;这里只服务资源画布的卡面预览,且允许非 UTF-8 的二进制变体 * 降级成类型卡(`content: null`),不把「不能预览」报成错误。 */ -#[tauri::command] -pub(crate) async fn read_local_project_structured_preview( - preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, - project_path: String, - relative_path: String, - scope_id: String, - request_id: String, -) -> Result { - preview_manager - .run(&scope_id, &request_id, move |cancellation| { - read_local_project_structured_preview_at(&project_path, &relative_path, cancellation) - }) - .await -} - -pub(crate) fn read_local_project_structured_preview_at( - project_path: &str, - relative_path: &str, - cancellation: &ProjectResourcePreviewScopeCancellation, -) -> Result { - cancellation.check()?; - let root = Path::new(project_path.trim()); - enforce_project_auto_permission_policy(root, "file.read")?; - cancellation.check()?; - let normalized_path = normalize_relative_path(relative_path.trim())?; - let manifest = read_manifest_cached_for_preview(&root.join(".agent/manifest.json"))?; - cancellation.check()?; - let registered_media_type = manifest - .assets - .iter() - .find(|asset| asset.local_path == normalized_path) - .map(|asset| asset.media_type.clone()); - let is_registered_structured = registered_media_type.as_deref().is_some_and(|media_type| { - is_supported_project_structured_resource(&normalized_path, media_type) - }) || manifest.tasks.iter().any(|task| { - task.status == GameCreationAppTaskStatus::Completed - && task.artifacts.iter().any(|path| path == &normalized_path) - && is_supported_project_structured_resource(&normalized_path, "") - }); - if !is_registered_structured { - return Err("只能读取当前项目已登记的引擎资源".to_string()); - } - cancellation.check()?; - load_local_project_structured_preview_with_cancellation( - root, - &normalized_path, - registered_media_type.as_deref().unwrap_or(""), - cancellation, - ) -} - -#[tauri::command] -pub(crate) async fn read_local_project_media_preview( - preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, - project_path: String, - relative_path: String, - category: String, - scope_id: String, - request_id: String, -) -> Result { - preview_manager - .run(&scope_id, &request_id, move |cancellation| { - read_local_project_media_preview_at( - &project_path, - &relative_path, - &category, - cancellation, - ) - }) - .await -} - pub(crate) fn read_local_project_media_preview_at( project_path: &str, relative_path: &str, @@ -5726,14 +4436,6 @@ pub(crate) fn read_local_project_media_preview_at( load_local_project_media_preview_with_cancellation(root, &normalized_path, kind, cancellation) } -#[tauri::command] -pub(crate) fn cancel_local_project_resource_preview_scope( - preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, - scope_id: String, -) -> Result<(), String> { - preview_manager.cancel_scope(&scope_id) -} - type ManualAnalyticsCapture = Option<( crate::analytics::contract::Context, crate::analytics::store::AnalyticsWriter, @@ -5791,79 +4493,6 @@ pub(crate) fn write_local_game_memory( write_local_game_memory_at(root, scope.trim(), &content) } -#[tauri::command] -pub(crate) fn list_game_creator_agent_sessions( - project_path: String, - agent_id: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - list_game_creator_agent_sessions_at(root, agent_id.trim()) -} - -#[tauri::command] -pub(crate) fn create_game_creator_agent_session( - project_path: String, - agent_id: String, - title: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - enforce_project_permission_policy(root, "conversation.write")?; - // 首轮策划消息可能紧跟项目初始化写入到达;对话保存应等待这段短暂的 - // 项目锁竞争,避免把可恢复的初始化竞态直接显示成保存失败。 - let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "conversation.write", - )?; - create_game_creator_agent_session_at(root, agent_id.trim(), title.trim()) -} - -#[tauri::command] -pub(crate) fn fork_game_creator_agent_session( - project_path: String, - agent_id: String, - source_session_id: String, - title: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - enforce_project_permission_policy(root, "conversation.write")?; - let _lock = acquire_project_write_lock(root, "conversation.write")?; - fork_game_creator_agent_session_at( - root, - agent_id.trim(), - source_session_id.trim(), - title.trim(), - ) -} - -#[tauri::command] -pub(crate) fn set_active_game_creator_agent_session( - project_path: String, - agent_id: String, - session_id: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - enforce_project_permission_policy(root, "conversation.write")?; - let _lock = acquire_project_write_lock(root, "conversation.write")?; - set_active_game_creator_agent_session_at(root, agent_id.trim(), session_id.trim()) -} - -#[tauri::command] -pub(crate) fn archive_game_creator_agent_session( - project_path: String, - agent_id: String, - session_id: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - enforce_project_permission_policy(root, "conversation.write")?; - let _lock = acquire_project_write_lock(root, "conversation.write")?; - archive_game_creator_agent_session_at(root, agent_id.trim(), session_id.trim()) -} - #[tauri::command] pub(crate) async fn read_local_conversation( project_path: String, @@ -5879,128 +4508,6 @@ pub(crate) async fn read_local_conversation( .map_err(|error| format!("读取项目对话后台任务失败:{error}"))? } -#[tauri::command] -pub(crate) async fn read_direct_project_conversation( - project_path: String, -) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - read_direct_project_chat_history_at(root) - }) - .await - .map_err(|error| format!("读取 DirectProject 历史后台任务失败:{error}"))? -} - -#[tauri::command] -pub(crate) fn list_game_creator_direct_active_turns() -> Result, String> { - list_active_turns() -} - -/// 取消一条**还没放行**的待发消息。 -/// -/// 结果分三种:真的从队列移除了(并以 `queue.removed{cancelled}` 告知所有订阅者)、这一条已经被 -/// 放行(正在跑的那一轮就是它,不能按待发消息取消)、队列里本来就没有这个身份。判据只看队列与占用, -/// 不看界面状态——同一份事实源在宿主。 -#[tauri::command] -pub(crate) async fn remove_direct_project_pending_turn( - project_path: String, - client_turn_id: String, -) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.write")?; - let thread_id = thread_id_for_project(root); - Ok(remove_pending_turn(&thread_id, client_turn_id.trim())) - }) - .await - .map_err(|error| format!("取消 DirectProject 待发消息后台任务失败:{error}"))? -} - -#[tauri::command] -pub(crate) async fn subscribe_direct_project_thread( - project_path: String, -) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - let thread_id = thread_id_for_project(root); - let mut bootstrap = subscribe_thread(&thread_id); - if bootstrap.last_completed_item_id.is_none() { - match read_direct_project_last_item_id_at(root) { - Ok(last_completed_item_id) => { - bootstrap.last_completed_item_id = last_completed_item_id; - } - Err(error) => { - // 订阅已经登记,后续锚点读取失败时也必须回收 native - // subscriber;否则前端拿不到 subscriptionId,无法自行清理。 - unsubscribe_thread(&bootstrap.subscription_id); - return Err(error); - } - } - } - Ok(bootstrap) - }) - .await - .map_err(|error| format!("订阅 DirectProject 线程后台任务失败:{error}"))? -} - -#[tauri::command] -pub(crate) fn consume_direct_project_thread( - subscription_id: String, -) -> Result { - consume_thread(subscription_id.trim()) -} - -#[tauri::command] -pub(crate) fn unsubscribe_direct_project_thread(subscription_id: String) -> Result<(), String> { - unsubscribe_thread(subscription_id.trim()); - Ok(()) -} - -/// 读一屏项目对话历史。 -/// -/// 窗口两端各由一个锚点给出,两者互斥(都传会报错):`before_item_id` 是**旧端**边界(不含 -/// 该条,向后翻页用),`through_item_id` 是**新端**边界(含该条,取 `subscribe` 回执里的 -/// `lastCompletedItemId`,比它更新的条目只从运行态事件来);都不传就是文件尾最近的一屏。 -#[tauri::command] -pub(crate) async fn read_direct_project_history_slice( - project_path: String, - before_item_id: Option, - through_item_id: Option, - limit: Option, -) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - let anchor = match (before_item_id.as_deref(), through_item_id.as_deref()) { - (Some(_), Some(_)) => { - return Err( - "DirectProject 历史切片只接受一个锚点(beforeItemId / throughItemId)" - .to_string(), - ); - } - (Some(before), None) => DirectProjectHistoryAnchor::Before(before), - (None, Some(through)) => DirectProjectHistoryAnchor::Through(through), - (None, None) => DirectProjectHistoryAnchor::Newest, - }; - let (raw_items, has_more, recorded_at_ms, first_item_id) = - read_direct_project_history_items_slice_at(root, anchor, limit.unwrap_or(20))?; - let items = thread_items_from_history(root, &raw_items, |item| { - thread_item_identity(item) - .and_then(|identity| recorded_at_ms.get(&identity).copied()) - .unwrap_or_default() - }); - Ok(HistorySlice { - items, - has_more, - first_item_id, - }) - }) - .await - .map_err(|error| format!("读取 DirectProject 历史切片后台任务失败:{error}"))? -} - #[tauri::command] pub(crate) fn append_local_conversation_message( project_path: String, @@ -6029,40 +4536,6 @@ pub(crate) fn append_local_conversation_message( } } -#[tauri::command] -pub(crate) fn append_direct_project_conversation_message( - project_path: String, - message: LocalConversationMessage, - message_id: Option, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.write")?; - let item = - direct_project_local_message_item(&message.role, &message.content, message_id.as_deref())?; - append_direct_project_history_item_at(root, &item)?; - read_direct_project_chat_history_at(root) -} - -#[tauri::command] -pub(crate) fn build_local_project_index( - project_path: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.index")?; - let _lock = acquire_project_write_lock(root, "project.index")?; - build_local_project_index_at(root) -} - -#[tauri::command] -pub(crate) fn create_local_project_checkpoint( - project_path: String, -) -> Result { - create_local_project_checkpoint_with_capture( - project_path, - crate::analytics::gui::capture_writer_context(), - ) -} - fn create_local_project_checkpoint_with_capture( project_path: String, capture: ManualAnalyticsCapture, @@ -6103,130 +4576,6 @@ pub(crate) fn checkpoint_with_capture_for_test( create_local_project_checkpoint_with_capture(project_path, capture) } -/// 为发布导出试玩包:项目还没有可玩入口时先跑项目自己的 `npm run build`。 -/// -/// 作者只点一次「发布」:已有 `game/index.html` 或 `dist/index.html` 直接打包;只有源码时 -/// 走 `project.verify` 的受控 npm 运行器构建后再打包,失败信息带构建日志尾部。 -#[tauri::command] -pub(crate) async fn export_local_project_package( - project_path: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.export_package")?; - let _lock = acquire_project_write_lock(root, "project.export_package")?; - advance_agent_runtime_project_revision_locked(root)?; - export_local_project_package_for_publish_at(root).await -} - -/// 把归一化后的发行包落到内容寻址的暂存文件,返回分片续传所需的元数据。 -/// -/// 发布链路从此只把「暂存路径 + 摘要 + 体积」交给渲染进程:整包字节不再经过 -/// WebView IPC,续传时也复用同一个暂存文件(同名同内容)。 -#[tauri::command] -pub(crate) fn prepare_local_project_game_package( - app: tauri::AppHandle, - project_path: String, - package_relative_path: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.export_package")?; - let payload = read_local_project_export_package_at(root, package_relative_path.trim())?; - let staging_dir = game_package_upload_staging_dir(&app)?; - let mut staged = crate::game_package_upload::stage_game_package_bytes( - &staging_dir, - &payload.package_sha256, - &payload.package_bytes, - )?; - staged.package_file_count = u32::try_from(payload.files.len()).unwrap_or(u32::MAX); - Ok(staged) -} - -/// 分片续传上传暂存的发行包;进度通过 `game-package-upload-progress` 事件回传。 -#[tauri::command] -pub(crate) async fn upload_local_project_game_package( - app: tauri::AppHandle, - staging_path: String, - version_id: String, - api_base_url: String, - access_token: String, - idempotency_key: String, -) -> Result { - let staging_dir = game_package_upload_staging_dir(&app)?; - let resolved_path = - crate::game_package_upload::ensure_staging_path_in_dir(&staging_dir, &staging_path)?; - let client = reqwest::Client::builder() - .build() - .map_err(|error| format!("创建上传客户端失败:{error}"))?; - let version_id = version_id.trim().to_string(); - if version_id.is_empty() { - return Err("缺少发行版本标识".to_string()); - } - let emit_handle = app.clone(); - let progress_version_id = version_id.clone(); - crate::game_package_upload::upload_staged_game_package( - &client, - crate::game_package_upload::GamePackageUploadRequest { - staging_path: &resolved_path, - version_id: &version_id, - api_base_url: api_base_url.trim(), - access_token: access_token.trim(), - idempotency_key: idempotency_key.trim(), - session_identity: None, - }, - move |received_bytes, total_bytes| { - let _ = emit_handle.emit( - crate::game_package_upload::GAME_PACKAGE_UPLOAD_PROGRESS_EVENT, - crate::game_package_upload::progress_event_payload( - &progress_version_id, - received_bytes, - total_bytes, - ), - ); - }, - ) - .await -} - -fn game_package_upload_staging_dir(app: &tauri::AppHandle) -> Result { - app.path() - .app_data_dir() - .map(|app_data_root| { - crate::game_package_upload::game_package_upload_staging_dir(&app_data_root) - }) - .map_err(|error| format!("无法读取 AGC 应用数据目录:{error}")) -} - -#[tauri::command] -pub(crate) fn list_local_project_export_packages( - project_path: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.export_list")?; - list_local_project_export_packages_at(root) -} - -#[tauri::command] -pub(crate) fn diff_local_project_checkpoint( - project_path: String, - checkpoint_id: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.diff")?; - diff_local_project_checkpoint_at(root, checkpoint_id.trim()) -} - -#[tauri::command] -pub(crate) fn restore_local_project_checkpoint( - project_path: String, - checkpoint_id: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.restore")?; - let _lock = acquire_project_write_lock(root, "project.restore")?; - advance_agent_runtime_project_revision_locked(root)?; - restore_local_project_checkpoint_at(root, checkpoint_id.trim()) -} - #[tauri::command] pub(crate) fn read_project_permission_policy( project_path: String, @@ -6236,13 +4585,50 @@ pub(crate) fn read_project_permission_policy( read_project_permission_policy_at(root) } -#[tauri::command] -pub(crate) fn write_project_permission_policy( - project_path: String, - policy: ProjectPermissionPolicy, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.policy_write")?; - let _lock = acquire_project_write_lock(root, "project.policy_write")?; - write_project_permission_policy_at(root, policy) +pub(crate) async fn install_client_session_locally( + user_id: String, + access_token: String, + api_base_url: String, + identity_generation: u64, + revision: u64, +) -> Result<(), String> { + tokio::task::spawn_blocking(move || { + validate_platform_session_input( + &user_id, + &access_token, + &api_base_url, + identity_generation, + revision, + )?; + install_external_agent_runner_platform_session( + &user_id, + &access_token, + &api_base_url, + identity_generation, + revision, + )?; + install_platform_session( + &user_id, + &access_token, + &api_base_url, + identity_generation, + revision, + ) + }) + .await + .map_err(|error| format!("安装本地运行时会话任务意外终止:{error}"))? +} + +pub(crate) async fn clear_client_session_locally( + identity_generation: u64, + revision: u64, +) -> Result<(), String> { + tokio::task::spawn_blocking(move || { + shutdown_game_creator_codex_app_servers()?; + clear_external_agent_runner_platform_session(identity_generation, revision)?; + clear_platform_session(identity_generation, revision); + Ok(()) + }) + .await + .map_err(|error| format!("清除本地运行时会话任务意外终止:{error}"))? } 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 new file mode 100644 index 000000000..7450d5ad1 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs @@ -0,0 +1,1637 @@ +use super::*; +use crate::agent::{ + read_direct_project_chat_history_at, read_direct_project_last_item_id_at, + DirectProjectHistoryAnchor, +}; +use tauri_plugin_dialog::DialogExt; +use tauri_plugin_opener::OpenerExt; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct GameCreatorRoleAgentChatStreamEvent { + project_path: String, + agent_id: String, + run_id: String, + status: String, + delta_text: String, + accumulated_text: String, + finish_reason: Option, + session_id: Option, + runtime_status: Option, + runtime_phase: Option, + runtime_summary: Option, + runtime_state: Option, +} + +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct OpenCanvasProjectResult { + url: String, +} + +const AUTOMATIC_PROJECT_NAME_MAX_OUTPUT_TOKENS: u32 = 64; +// Project naming is an optional homepage enhancement. It must never hold the +// actual project creation flow behind the normal (potentially three-minute) +// generation timeout. + +const AUTOMATIC_PROJECT_NAME_TIMEOUT_MS: u64 = 15_000; + +const AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT: &str = + include_str!("../../prompts/automatic-project-name.md"); +// 聊天输入区的 AI 润色复用同一条短文本生成通道:只做一次单轮改写, +// 计费由平台 LLM 路由(/api/llm/chat/completions、/api/llm/responses)侧完成。 + +const LOCAL_PROJECT_PROMPT_POLISH_MAX_OUTPUT_TOKENS: u32 = 2_048; + +const LOCAL_PROJECT_PROMPT_POLISH_SYSTEM_PROMPT: &str = + include_str!("../../prompts/local-project-prompt-polish.md"); + +async fn request_automatic_project_name(prompt: &str) -> Result, String> { + let user_prompt = build_automatic_project_name_prompt(prompt)?; + let app_config = load_game_creator_app_config()?; + if app_config.agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER { + let reply = tokio::time::timeout( + std::time::Duration::from_millis(AUTOMATIC_PROJECT_NAME_TIMEOUT_MS), + crate::agent::direct_game_creator_home_codex_chat( + AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT.trim().to_string(), + user_prompt, + ), + ) + .await + .map_err(|_| { + format!( + "自动项目命名超时(超过 {} 秒)", + AUTOMATIC_PROJECT_NAME_TIMEOUT_MS / 1_000 + ) + })??; + return Ok(normalize_suggested_project_name(reply.trim())); + } + let mut llm = app_config.llm.clone(); + llm.max_retries = 0; + let client = build_game_creator_llm_client_from_llm_config(&llm, "llm")?; + let request = + LlmRunRequest::single_turn(AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT.trim(), user_prompt) + .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) + .with_model(llm.model.clone()) + .with_request_timeout_ms(llm.request_timeout_ms) + .with_max_output_tokens(AUTOMATIC_PROJECT_NAME_MAX_OUTPUT_TOKENS) + .with_web_search(false); + let response = tokio::time::timeout( + std::time::Duration::from_millis(AUTOMATIC_PROJECT_NAME_TIMEOUT_MS), + client.run(request), + ) + .await + .map_err(|_| { + format!( + "自动项目命名超时(超过 {} 秒)", + AUTOMATIC_PROJECT_NAME_TIMEOUT_MS / 1_000 + ) + })? + .map_err(|error| format!("自动项目命名失败:{error}"))?; + Ok(normalize_suggested_project_name(response.text.trim())) +} + +async fn request_local_project_prompt_polish( + prompt: &str, + context: Option<&str>, +) -> Result { + let user_prompt = build_local_project_prompt_polish_prompt(prompt, context)?; + let app_config = load_game_creator_app_config()?; + if app_config.agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER { + let reply = crate::agent::direct_game_creator_home_codex_chat( + LOCAL_PROJECT_PROMPT_POLISH_SYSTEM_PROMPT.trim().to_string(), + user_prompt, + ) + .await?; + return normalize_polished_prompt(&reply) + .ok_or_else(|| "AI 润色没有返回可用文本".to_string()); + } + let mut llm = app_config.llm.clone(); + llm.max_retries = 0; + let client = build_game_creator_llm_client_from_llm_config(&llm, "llm")?; + let request = LlmRunRequest::single_turn( + LOCAL_PROJECT_PROMPT_POLISH_SYSTEM_PROMPT.trim(), + user_prompt, + ) + .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) + .with_model(llm.model.clone()) + .with_request_timeout_ms(llm.request_timeout_ms) + .with_max_output_tokens(LOCAL_PROJECT_PROMPT_POLISH_MAX_OUTPUT_TOKENS) + .with_web_search(false); + let response = client + .run(request) + .await + .map_err(|error| format!("AI 润色失败:{error}"))?; + normalize_polished_prompt(&response.text).ok_or_else(|| "AI 润色没有返回可用文本".to_string()) +} + +#[tauri::command] +pub(crate) fn create_automatic_local_game_project( + app: tauri::AppHandle, + name: Option, + planning: Option, + projects_root: Option, +) -> Result { + let analytics_context = crate::analytics::gui::capture_analytics_context(); + let result = create_automatic_local_game_project_at( + &resolve_game_project_creation_root(&app, projects_root.as_deref())?, + 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(), + if planning.unwrap_or(false) { + crate::analytics::contract::CreationSource::HomeDesign + } else { + crate::analytics::contract::CreationSource::HomeGame + }, + None, + ); + } + result +} + +#[tauri::command] +pub(crate) fn init_local_game_project( + project_path: String, + project_id: String, + name: String, +) -> Result { + let analytics_context = crate::analytics::gui::capture_analytics_context(); + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.create")?; + let _lock = acquire_project_write_lock(root, "project.create")?; + let new_project = matches!(fs::symlink_metadata(root.join(".agent/manifest.json")), Err(error) if error.kind() == std::io::ErrorKind::NotFound); + let result = init_local_game_project_at(root, project_id.trim(), name.trim()); + if new_project { + 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::SelectedDirectory, + None, + ); + } + } + result +} + +#[tauri::command] +pub(crate) fn import_local_cocos_project( + project_path: String, + project_id: String, + name: String, +) -> Result { + let analytics_context = crate::analytics::gui::capture_analytics_context(); + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.create")?; + if discover_local_cocos_project_root(root)?.is_none() { + return Err( + "所选目录不是有效的 Cocos Creator 项目(需要 package.json.creator.version 和 assets/)" + .to_string(), + ); + } + let _lock = acquire_project_write_lock(root, "project.create")?; + let new_project = matches!(fs::symlink_metadata(root.join(".agent/manifest.json")), Err(error) if error.kind() == std::io::ErrorKind::NotFound); + let result = import_local_cocos_project_at(root, project_id.trim(), name.trim()); + if new_project { + 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::SelectedDirectory, + None, + ); + } + } + result +} + +#[tauri::command] +pub(crate) fn import_local_unity_project( + project_path: String, + project_id: String, + name: String, +) -> Result { + let analytics_context = crate::analytics::gui::capture_analytics_context(); + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.create")?; + if discover_local_unity_project_root(root)?.is_none() { + return Err("所选目录不是有效的 Unity 项目".to_string()); + } + let _lock = acquire_project_write_lock(root, "project.create")?; + let new_project = matches!(fs::symlink_metadata(root.join(".agent/manifest.json")), Err(error) if error.kind() == std::io::ErrorKind::NotFound); + let result = import_local_unity_project_at(root, project_id.trim(), name.trim()); + if new_project { + 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::SelectedDirectory, + None, + ); + } + } + result +} + +#[tauri::command] +pub(crate) async fn inspect_local_project_directory( + project_path: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || inspect_local_project_directory_sync(project_path)) + .await + .map_err(|error| format!("检查项目目录后台任务失败:{error}"))? +} + +/// 目录选择器标题:调用方只能给短标题,其余(超长、含控制字符、空白)一律回退默认文案。 +fn pick_project_directory_title(title: Option<&str>) -> &str { + const MAX_TITLE_CHARS: usize = 24; + title + .map(str::trim) + .filter(|value| { + !value.is_empty() + && value.chars().count() <= MAX_TITLE_CHARS + && !value.chars().any(char::is_control) + }) + .unwrap_or("选择游戏项目目录") +} + +#[tauri::command] +pub(crate) async fn pick_local_project_directory( + app: tauri::AppHandle, + initial_path: Option, + title: Option, +) -> Result, String> { + let (sender, receiver) = tokio::sync::oneshot::channel(); + let mut dialog = app + .dialog() + .file() + .set_title(pick_project_directory_title(title.as_deref())); + if let Some(initial_path) = initial_path + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty()) + { + let initial_path = Path::new(initial_path); + if initial_path.is_absolute() && !project_path_has_control_chars(initial_path) { + let starting_directory = closest_existing_project_picker_directory(initial_path); + if let Some(starting_directory) = starting_directory { + dialog = dialog.set_directory(starting_directory); + } + } + } + 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); + // The native picker is the explicit user-selection boundary. Prepare the + // selected root before returning it so inspect/create/open never races the + // first ACL read. + if let Err(error) = + crate::prepare_game_creator_project_root_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 open_local_project_directory( + app: tauri::AppHandle, + project_path: String, +) -> Result<(), String> { + let path = validated_local_project_directory_path(project_path.trim())?; + app.opener() + .open_path(path.to_string_lossy().into_owned(), None::<&str>) + .map_err(|error| format!("打开项目目录失败:{error}")) +} + +#[tauri::command] +pub(crate) async fn get_local_game_manifest( + project_path: String, + command_id: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + get_local_game_manifest_sync(project_path, command_id) + }) + .await + .map_err(|error| format!("读取项目 manifest 后台任务失败:{error}"))? +} + +#[tauri::command] +pub(crate) fn read_local_project_resource_graph( + project_path: String, + expected_project_id: String, + resources: Vec, +) -> Result { + let root = validated_local_project_directory_path(project_path.trim())?; + enforce_project_auto_permission_policy(&root, "asset.list")?; + read_project_resource_graph_at(&root, expected_project_id.trim(), resources) +} + +/// 读取引用了某个素材的项目版本,供删除弹窗提示「被哪些版本使用」。 +#[tauri::command] +pub(crate) fn read_local_project_asset_references( + input: ReadLocalProjectAssetReferencesInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.list")?; + read_manifest_asset_references_at(root, &input.asset_id) +} + +/// 删除一个素材登记,并按用户确认决定是否连带删除引用它的游戏版本。 +/// +/// 素材不可变:只摘掉 manifest 登记,磁盘文件保留。 +#[tauri::command] +pub(crate) fn delete_local_project_asset( + input: DeleteLocalProjectAssetInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + delete_manifest_asset_at( + root, + &input.expected_project_id, + input.expected_project_revision, + &input.asset_id, + input.delete_referenced_versions, + ) +} + +/// 读取某个版本引用的源素材可用的替换候选,并给出后端权威的三项兼容性结论。 +/// +/// 只读:不改 manifest、不推进 revision。候选渲染但禁用,不在前端重算判据。 +#[tauri::command] +pub(crate) fn read_local_project_version_resource_replacement_candidates( + input: ReadLocalProjectVersionReplacementCandidatesInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.list")?; + read_local_project_version_replacement_candidates_at( + root, + &input.source_version_id, + &input.source_resource_id, + ) +} + +/// 用另一个已登记素材替换某个版本引用的素材:改 manifest 绑定,并**追加下一迭代版本**。 +/// +/// 可运行版本不可变(既有版本记录一个字节都不改)、不动资源文件、不建文件副本; +/// 三项兼容性必须同时为 true,否则拒绝并说明哪一项不等。CAS 失败时 manifest 与 revision 都不变。 +#[tauri::command] +pub(crate) fn replace_local_project_version_resource( + input: ReplaceLocalProjectVersionResourceInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + replace_local_project_version_resource_at( + root, + &input.expected_project_id, + input.expected_project_revision, + &input.source_version_id, + &input.source_resource_id, + &input.replacement_resource_id, + ) +} + +/// 重命名一个已登记素材:磁盘文件改名 + 更新 manifest 的 `localPath`,资产 `id` 不变。 +/// +/// 只允许在资产当前所在目录内改名,扩展名必须一致,同目录不得已有同名文件;manifest 写失败 +/// 时把文件改回原名,不留半成品。与删除 / 分类更新同口径:持项目写锁后按 `expectedProjectId` +/// 与当前 revision 做 CAS,失败时磁盘、manifest 与 revision 都不动。 +#[tauri::command] +pub(crate) fn rename_local_project_asset( + input: RenameLocalProjectAssetInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + rename_local_project_asset_at( + root, + &input.expected_project_id, + input.expected_project_revision, + &input.asset_id, + &input.new_file_name, + ) +} + +#[tauri::command] +pub(crate) async fn control_agent_run( + app: tauri::AppHandle, + project_path: String, + action: String, + detail: Option, +) -> Result { + let root = Path::new(project_path.trim()); + let command_id = match action.trim() { + "status" => "agent.run_status", + "kill" => "agent.kill", + "retry" => "agent.retry", + "resume" => "agent.resume", + _ => "agent.run_status", + }; + enforce_project_permission_policy(root, command_id)?; + if matches!(action.trim(), "retry" | "resume") { + enforce_project_permission_policy(root, "game.generate_draft")?; + } + let _lock = if command_id == "agent.run_status" { + None + } else { + Some(acquire_project_write_lock(root, command_id)?) + }; + control_agent_run_at( + root, + action.trim(), + detail + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()), + Some(&AgentProgressEmitter::new(&app, project_path.trim())), + ) + .await +} + +#[tauri::command] +pub(crate) async fn generate_local_game_draft( + app: tauri::AppHandle, + project_path: String, + prompt: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "game.generate_draft")?; + let _lock = acquire_project_write_lock(root, "game.generate_draft")?; + advance_agent_runtime_project_revision_locked(root)?; + generate_local_game_draft_at( + root, + prompt.trim(), + Some(&AgentProgressEmitter::new(&app, project_path.trim())), + ) + .await +} + +#[tauri::command] +pub(crate) async fn chat_with_game_creator_agent( + project_path: String, + prompt: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + chat_with_game_creator_agent_at(root, prompt.trim()).await +} + +#[tauri::command] +pub(crate) async fn chat_with_game_creator_role_agent_stream( + app: tauri::AppHandle, + project_path: String, + agent_id: String, + session_id: Option, + prompt: String, + run_id: String, +) -> Result { + let project_path = project_path.trim().to_string(); + let agent_id = normalize_game_creator_runtime_agent_id(agent_id.trim())?; + let run_id = run_id.trim().to_string(); + let root = Path::new(project_path.as_str()); + let session_id = + resolve_agent_conversation_session_id_at(root, &agent_id, session_id.as_deref(), true)?; + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "conversation.write")?; + let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(root, &agent_id)? + .ok_or_else(|| format!("Agent 正在执行其他前台或后台任务:{agent_id}"))?; + let emit_app = app.clone(); + let event_project_path = project_path.clone(); + let event_agent_id = agent_id.clone(); + let event_run_id = run_id.clone(); + let command_result = async { + let mut runtime_state = start_game_creator_agent_runtime_turn_for_session_at( + root, + agent_id.as_str(), + Some(&session_id), + prompt.trim(), + &run_id, + )?; + runtime_state = advance_game_creator_agent_runtime_turn_at( + root, + runtime_state, + "llm", + "请求 Agent LLM", + "已读取项目上下文,正在让 Agent 独立推理。", + )?; + let mut streaming_runtime_state = runtime_state.clone(); + streaming_runtime_state.current_action = "正在接收 Agent 回复".to_string(); + let _ = app.emit( + "game-creator-role-agent-chat-stream", + GameCreatorRoleAgentChatStreamEvent { + project_path: event_project_path.clone(), + agent_id: event_agent_id.clone(), + run_id: event_run_id.clone(), + status: "started".to_string(), + delta_text: String::new(), + accumulated_text: String::new(), + finish_reason: None, + session_id: Some(runtime_state.session_id.clone()), + runtime_status: Some(runtime_state.status.clone()), + runtime_phase: Some(runtime_state.phase.clone()), + runtime_summary: Some(runtime_state.current_action.clone()), + runtime_state: Some(runtime_state.clone()), + }, + ); + let result = chat_with_game_creator_role_agent_stream_for_session_at( + root, + agent_id.as_str(), + Some(&session_id), + prompt.trim(), + |delta| { + let _ = emit_app.emit( + "game-creator-role-agent-chat-stream", + GameCreatorRoleAgentChatStreamEvent { + project_path: event_project_path.clone(), + agent_id: event_agent_id.clone(), + run_id: event_run_id.clone(), + status: "delta".to_string(), + delta_text: delta.delta_text.clone(), + accumulated_text: delta.accumulated_text.clone(), + finish_reason: delta.finish_reason.clone(), + session_id: Some(streaming_runtime_state.session_id.clone()), + runtime_status: Some("running".to_string()), + runtime_phase: Some("llm".to_string()), + runtime_summary: Some("正在接收 Agent 回复".to_string()), + runtime_state: Some(streaming_runtime_state.clone()), + }, + ); + }, + ) + .await; + match result { + Ok(reply) => { + let completed_runtime = finish_game_creator_agent_runtime_turn_at( + root, + runtime_state, + &reply.reply_text, + )?; + let _ = app.emit( + "game-creator-role-agent-chat-stream", + GameCreatorRoleAgentChatStreamEvent { + project_path: project_path.clone(), + agent_id: agent_id.clone(), + run_id: run_id.clone(), + status: "completed".to_string(), + delta_text: String::new(), + accumulated_text: reply.reply_text.clone(), + finish_reason: None, + session_id: Some(completed_runtime.session_id.clone()), + runtime_status: Some(completed_runtime.status.clone()), + runtime_phase: Some(completed_runtime.phase.clone()), + runtime_summary: Some(completed_runtime.current_action.clone()), + runtime_state: Some(completed_runtime), + }, + ); + Ok(reply) + } + Err(error) => { + let failed_runtime = + fail_game_creator_agent_runtime_turn_at(root, runtime_state, &error).ok(); + let _ = app.emit( + "game-creator-role-agent-chat-stream", + GameCreatorRoleAgentChatStreamEvent { + project_path: project_path.clone(), + agent_id: agent_id.clone(), + run_id: run_id.clone(), + status: "failed".to_string(), + delta_text: String::new(), + accumulated_text: String::new(), + finish_reason: None, + session_id: failed_runtime + .as_ref() + .map(|runtime| runtime.session_id.clone()), + runtime_status: failed_runtime + .as_ref() + .map(|runtime| runtime.status.clone()), + runtime_phase: failed_runtime.as_ref().map(|runtime| runtime.phase.clone()), + runtime_summary: failed_runtime + .as_ref() + .map(|runtime| runtime.current_action.clone()), + runtime_state: failed_runtime, + }, + ); + Err(error) + } + } + } + .await; + spawn_next_game_creator_agent_background_task_drain_with_lock(root, &agent_id, runtime_lock); + match command_result { + Ok(reply) => Ok(reply), + Err(error) => Err(error), + } +} + +#[tauri::command] +pub(crate) fn start_game_creator_supervisor_runtime_task( + project_path: String, + session_id: Option, + task: String, + run_id: String, + run_profile: Option, + source: Option, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "conversation.write")?; + enforce_project_permission_policy(root, "agent.run_status")?; + let run_profile = run_profile + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(AGENT_RUNTIME_RUN_PROFILE_STANDARD); + let source = source + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE); + if !agent_runtime_supervisor_source_is_trusted(source) { + return Err("Project Supervisor 提交 source 不受信任".to_string()); + } + start_game_creator_supervisor_background_task_for_session_at( + root, + session_id.as_deref(), + task.trim(), + run_id.trim(), + source, + run_profile, + ) +} + +#[tauri::command] +pub(crate) async fn compact_game_creator_agent_runtime_context( + project_path: String, + agent_id: String, + session_id: Option, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "agent.compact")?; + if external_agent_runner_enabled() && !external_agent_runner_is_server_process() { + compact_external_agent_runner_context(root, agent_id.trim(), session_id.as_deref()) + } else { + compact_game_creator_agent_runtime_session_at(root, agent_id.trim(), session_id.as_deref()) + .await + } +} + +#[tauri::command] +pub(crate) fn cancel_game_creator_agent_runtime_task( + project_path: String, + agent_id: String, + run_id: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "conversation.write")?; + enforce_project_permission_policy(root, "agent.run_status")?; + cancel_game_creator_agent_runtime_task_at(root, agent_id.trim(), run_id.trim()) +} + +#[tauri::command] +pub(crate) fn answer_game_creator_agent_runtime_user_input( + project_path: String, + agent_id: String, + run_id: String, + action_id: String, + request_id: String, + response_id: String, + answers: BTreeMap, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "conversation.write")?; + enforce_project_permission_policy(root, "agent.run_status")?; + enforce_project_permission_policy(root, "agent.resume")?; + answer_game_creator_agent_runtime_user_input_at( + root, + agent_id.trim(), + run_id.trim(), + action_id.trim(), + request_id.trim(), + response_id.trim(), + answers, + ) +} + +#[tauri::command] +pub(crate) fn read_game_creator_agent_runtime( + project_path: String, + agent_id: String, + session_id: Option, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + read_game_creator_agent_runtime_for_session_at(root, agent_id.trim(), session_id.as_deref()) +} + +#[tauri::command] +pub(crate) fn read_game_creator_agent_runtimes( + project_path: String, +) -> Result, String> { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + read_game_creator_agent_runtimes_at(root) +} + +#[tauri::command] +pub(crate) async fn suggest_automatic_project_name( + prompt: String, +) -> Result, String> { + request_automatic_project_name(prompt.trim()) + .await + .map_err(|error| redact_agent_runtime_error(Path::new("."), &error, 320)) +} + +#[tauri::command] +pub(crate) async fn polish_local_project_prompt( + prompt: String, + context: Option, +) -> Result { + request_local_project_prompt_polish(prompt.trim(), context.as_deref()) + .await + .map_err(|error| redact_agent_runtime_error(Path::new("."), &error, 320)) +} + +#[tauri::command] +pub(crate) fn cancel_direct_codex_turn( + project_path: String, + client_turn_id: Option, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "agent.kill")?; + cancel_direct_codex_turn_at(root, client_turn_id.as_deref()) +} + +#[tauri::command] +pub(crate) fn select_game_creator_reasoning_effort( + effort: String, +) -> Result { + let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK + .lock() + .map_err(|_| "配置写入锁不可用")?; + let effort = game_creator_llm_reasoning_effort_name(&effort, "llm.reasoningEffort")?; + let (mut config, overlays) = load_game_creator_app_config_for_write()?; + config.llm.reasoning_effort = effort; + persist_game_creator_app_config(config, overlays, false) +} + +#[tauri::command] +pub(crate) async fn discover_game_creator_llm_models( + llm: GameCreatorLlmConfig, +) -> Result, String> { + if !load_game_creator_app_config()?.llm.custom_enabled { + return Err("请先在本地配置中开启 llm.customEnabled".to_string()); + } + fetch_custom_llm_models(&llm).await +} + +#[tauri::command] +pub(crate) fn upload_local_asset( + project_path: String, + file_name: String, + media_type: String, + bytes: Vec, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.upload")?; + let _lock = acquire_project_write_lock(root, "asset.upload")?; + advance_agent_runtime_project_revision_locked(root)?; + upload_local_asset_at(root, file_name.trim(), media_type.trim(), &bytes) +} + +#[tauri::command] +pub(crate) fn register_local_asset( + project_path: String, + local_path: String, + kind: String, + media_type: String, + source_kind: String, + canvas_project_id: Option, + resource_id: Option, + asset_object_id: Option, + task_id: Option, + prompt: Option, + model: Option, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + let _lock = acquire_project_write_lock(root, "asset.register")?; + advance_agent_runtime_project_revision_locked(root)?; + register_local_asset_at( + root, + local_path.trim(), + crate::assets::registration_asset_kind(&kind, "asset.register"), + media_type.trim(), + source_kind.trim(), + GameCreationAppAssetSource { + kind: parse_asset_source_kind(source_kind.trim())?, + canvas_project_id: trim_optional_string(canvas_project_id), + resource_id: trim_optional_string(resource_id), + asset_object_id: trim_optional_string(asset_object_id), + task_id: trim_optional_string(task_id), + prompt: trim_optional_string(prompt), + model: trim_optional_string(model), + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + ) +} + +#[tauri::command] +pub(crate) fn update_local_project_resource_classification( + input: UpdateLocalProjectResourceClassificationInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + update_manifest_asset_classification_at( + root, + &input.expected_project_id, + input.expected_project_revision, + &input.asset_id, + &input.category, + input.tags, + ) +} + +#[tauri::command] +pub(crate) async fn derive_local_project_resource( + input: DeriveLocalProjectResourceInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + derive_local_project_resource_at(input).await +} + +#[tauri::command] +pub(crate) fn list_pending_local_project_resource_edits( + input: ListPendingLocalProjectResourceEditsInput, +) -> Result, String> { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "file.list")?; + list_pending_local_project_resource_edits_at(input) +} + +#[tauri::command] +pub(crate) async fn resume_local_project_resource_edit( + input: ResumeLocalProjectResourceEditInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + resume_local_project_resource_edit_at(input).await +} + +#[tauri::command] +pub(crate) async fn request_local_project_resource_edit_service_identity_confirmation( + input: RequestResourceEditServiceIdentityConfirmationInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + request_resource_edit_service_identity_confirmation_at(input).await +} + +#[tauri::command] +pub(crate) async fn confirm_local_project_resource_edit_service_identity( + input: ConfirmResourceEditServiceIdentityInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + confirm_resource_edit_service_identity_at(input).await +} + +#[tauri::command] +pub(crate) async fn archive_failed_local_project_resource_edit( + input: ArchiveFailedLocalProjectResourceEditInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + archive_failed_local_project_resource_edit_at(input).await +} + +#[tauri::command] +pub(crate) fn normalize_local_project_raster_resource( + input: NormalizeLocalProjectRasterResourceInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + normalize_local_project_raster_resource_at(input) +} + +#[tauri::command] +pub(crate) fn import_canvas_asset( + project_path: String, + local_path: String, + kind: String, + media_type: String, + canvas_project_id: String, + resource_id: Option, + asset_object_id: Option, + task_id: Option, + prompt: Option, + model: Option, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "canvas.asset_import")?; + let _lock = acquire_project_write_lock(root, "canvas.asset_import")?; + advance_agent_runtime_project_revision_locked(root)?; + import_canvas_asset_at( + root, + local_path.trim(), + registration_asset_kind(kind.trim(), "canvas.asset_import"), + media_type.trim(), + canvas_project_id.trim(), + trim_optional_string(resource_id), + trim_optional_string(asset_object_id), + trim_optional_string(task_id), + trim_optional_string(prompt), + trim_optional_string(model), + ) +} + +#[tauri::command] +pub(crate) fn import_canvas_export( + project_path: String, + export_path: String, + canvas_project_id: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "canvas.export_import")?; + let _lock = acquire_project_write_lock(root, "canvas.export_import")?; + advance_agent_runtime_project_revision_locked(root)?; + import_canvas_export_at( + root, + Path::new(export_path.trim()), + canvas_project_id.trim(), + ) +} + +#[tauri::command] +pub(crate) async fn sync_canvas_project_assets( + project_path: String, + canvas_project_id: String, + api_base_url: Option, + api_key: Option, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "canvas.project_sync")?; + let _lock = acquire_project_write_lock(root, "canvas.project_sync")?; + advance_agent_runtime_project_revision_locked(root)?; + sync_canvas_project_assets_at(root, canvas_project_id.trim(), api_base_url, api_key).await +} + +#[tauri::command] +pub(crate) fn prepare_ui_editor_project_fonts( + project_path: String, + assets: Vec, +) -> Result, String> { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.list")?; + if assets.len() > UI_EDITOR_FONT_MAX_COUNT { + return Err("一次最多选择 64 个字体面".to_string()); + } + let manifest = read_existing_manifest_for_project(root)?; + let mut fonts = Vec::with_capacity(assets.len()); + for selected in assets { + let asset = find_registered_ui_editor_font(&manifest, &selected.id, &selected.local_path)?; + fonts.push(read_registered_ui_editor_font(root, asset)?.0); + } + Ok(fonts) +} + +fn read_checked_ui_editor_font( + project_path: &str, + asset_id: &str, + relative_path: &str, + expected_sha256: &str, +) -> Result<(FontAsset, Vec), String> { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "file.read")?; + let manifest = read_existing_manifest_for_project(root)?; + let asset = find_registered_ui_editor_font(&manifest, asset_id, relative_path)?; + let (font, bytes) = read_registered_ui_editor_font(root, asset)?; + if font.content_sha256 != expected_sha256 { + return Err("字体文件内容与资源摘要不一致".to_string()); + } + Ok((font, bytes)) +} + +#[tauri::command] +pub(crate) fn read_ui_editor_font_bytes( + project_path: String, + asset_id: String, + relative_path: String, + expected_sha256: String, +) -> Result { + read_checked_ui_editor_font( + &project_path, + asset_id.trim(), + relative_path.trim(), + expected_sha256.trim(), + ) + .map(|(_, bytes)| tauri::ipc::Response::new(bytes)) +} + +#[tauri::command] +pub(crate) fn check_ui_editor_font_glyph_coverage( + project_path: String, + asset_id: String, + relative_path: String, + expected_sha256: String, + content: String, +) -> Result { + let (font, bytes) = read_checked_ui_editor_font( + &project_path, + asset_id.trim(), + relative_path.trim(), + expected_sha256.trim(), + )?; + font.has_missing_glyphs(&bytes, &content) +} + +#[tauri::command] +pub(crate) async fn generate_platform_art_asset( + project_path: String, + prompt: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "canvas.asset_generate")?; + let generated = generate_platform_art_asset_at(root, prompt.trim(), &[]).await?; + Ok(generated.asset) +} + +#[tauri::command] +pub(crate) fn open_canvas_project( + app: tauri::AppHandle, + canvas_project_id: Option, + editor_base_url: Option, +) -> Result { + let url = build_canvas_project_url(editor_base_url.as_deref(), canvas_project_id.as_deref())?; + app.opener() + .open_url(&url, None::<&str>) + .map_err(|error| format!("打开画板失败:{error}"))?; + Ok(OpenCanvasProjectResult { url }) +} + +#[tauri::command] +pub(crate) fn append_local_permission_log( + project_path: String, + event: String, + command_id: String, +) -> Result<(), String> { + append_local_permission_log_at( + Path::new(project_path.trim()), + event.trim(), + command_id.trim(), + ) +} + +/// 登记当前项目中已经存在、但尚未写入 manifest 的本地资源。 +/// +/// 该入口只接受项目根相对路径;实际文件签名、大小、敏感路径、项目锁和 +/// manifest/revision 更新统一复用 Agent 的受控导入实现。未登记文件在调用前 +/// 只能作为候选展示,不能由前端构造 asset ID。 +#[tauri::command] +pub(crate) async fn import_local_project_image_assets( + project_path: String, + relative_paths: Vec, +) -> Result { + let root = PathBuf::from(project_path.trim()); + tokio::task::spawn_blocking(move || { + import_local_project_assets_for_agent(&root, &relative_paths) + }) + .await + .map_err(|error| format!("项目内资源登记任务意外终止:{error}")) + .and_then(|result| result) +} + +#[tauri::command] +pub(crate) fn save_local_project_asset_file( + input: SaveLocalProjectAssetFileInput, +) -> Result { + save_local_project_asset_file_at(input) +} + +#[tauri::command] +pub(crate) async fn read_local_project_image_preview( + preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, + project_path: String, + relative_path: String, + scope_id: String, + request_id: String, +) -> Result { + preview_manager + .run(&scope_id, &request_id, move |cancellation| { + read_local_project_image_preview_at(&project_path, &relative_path, cancellation) + }) + .await +} + +#[tauri::command] +pub(crate) async fn read_local_project_text_preview( + preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, + project_path: String, + relative_path: String, + scope_id: String, + request_id: String, +) -> Result { + preview_manager + .run(&scope_id, &request_id, move |cancellation| { + read_local_project_text_preview_at(&project_path, &relative_path, cancellation) + }) + .await +} + +#[tauri::command] +pub(crate) async fn read_local_project_structured_preview( + preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, + project_path: String, + relative_path: String, + scope_id: String, + request_id: String, +) -> Result { + preview_manager + .run(&scope_id, &request_id, move |cancellation| { + read_local_project_structured_preview_at(&project_path, &relative_path, cancellation) + }) + .await +} + +pub(crate) fn read_local_project_structured_preview_at( + project_path: &str, + relative_path: &str, + cancellation: &ProjectResourcePreviewScopeCancellation, +) -> Result { + cancellation.check()?; + let root = Path::new(project_path.trim()); + enforce_project_auto_permission_policy(root, "file.read")?; + cancellation.check()?; + let normalized_path = normalize_relative_path(relative_path.trim())?; + let manifest = read_manifest_cached_for_preview(&root.join(".agent/manifest.json"))?; + cancellation.check()?; + let registered_media_type = manifest + .assets + .iter() + .find(|asset| asset.local_path == normalized_path) + .map(|asset| asset.media_type.clone()); + let is_registered_structured = registered_media_type.as_deref().is_some_and(|media_type| { + is_supported_project_structured_resource(&normalized_path, media_type) + }) || manifest.tasks.iter().any(|task| { + task.status == GameCreationAppTaskStatus::Completed + && task.artifacts.iter().any(|path| path == &normalized_path) + && is_supported_project_structured_resource(&normalized_path, "") + }); + if !is_registered_structured { + return Err("只能读取当前项目已登记的引擎资源".to_string()); + } + cancellation.check()?; + load_local_project_structured_preview_with_cancellation( + root, + &normalized_path, + registered_media_type.as_deref().unwrap_or(""), + cancellation, + ) +} + +#[tauri::command] +pub(crate) async fn read_local_project_media_preview( + preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, + project_path: String, + relative_path: String, + category: String, + scope_id: String, + request_id: String, +) -> Result { + preview_manager + .run(&scope_id, &request_id, move |cancellation| { + read_local_project_media_preview_at( + &project_path, + &relative_path, + &category, + cancellation, + ) + }) + .await +} + +#[tauri::command] +pub(crate) fn cancel_local_project_resource_preview_scope( + preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, + scope_id: String, +) -> Result<(), String> { + preview_manager.cancel_scope(&scope_id) +} + +#[tauri::command] +pub(crate) fn list_game_creator_agent_sessions( + project_path: String, + agent_id: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + list_game_creator_agent_sessions_at(root, agent_id.trim()) +} + +#[tauri::command] +pub(crate) fn create_game_creator_agent_session( + project_path: String, + agent_id: String, + title: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "conversation.write")?; + // 首轮策划消息可能紧跟项目初始化写入到达;对话保存应等待这段短暂的 + // 项目锁竞争,避免把可恢复的初始化竞态直接显示成保存失败。 + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "conversation.write", + )?; + create_game_creator_agent_session_at(root, agent_id.trim(), title.trim()) +} + +#[tauri::command] +pub(crate) fn fork_game_creator_agent_session( + project_path: String, + agent_id: String, + source_session_id: String, + title: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "conversation.write")?; + let _lock = acquire_project_write_lock(root, "conversation.write")?; + fork_game_creator_agent_session_at( + root, + agent_id.trim(), + source_session_id.trim(), + title.trim(), + ) +} + +#[tauri::command] +pub(crate) fn set_active_game_creator_agent_session( + project_path: String, + agent_id: String, + session_id: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "conversation.write")?; + let _lock = acquire_project_write_lock(root, "conversation.write")?; + set_active_game_creator_agent_session_at(root, agent_id.trim(), session_id.trim()) +} + +#[tauri::command] +pub(crate) fn archive_game_creator_agent_session( + project_path: String, + agent_id: String, + session_id: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "conversation.write")?; + let _lock = acquire_project_write_lock(root, "conversation.write")?; + archive_game_creator_agent_session_at(root, agent_id.trim(), session_id.trim()) +} + +#[tauri::command] +pub(crate) async fn read_direct_project_conversation( + project_path: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + read_direct_project_chat_history_at(root) + }) + .await + .map_err(|error| format!("读取 DirectProject 历史后台任务失败:{error}"))? +} + +#[tauri::command] +pub(crate) fn list_game_creator_direct_active_turns() -> Result, String> { + list_active_turns() +} + +#[tauri::command] +pub(crate) async fn subscribe_direct_project_thread( + project_path: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + let thread_id = thread_id_for_project(root); + let mut bootstrap = subscribe_thread(&thread_id); + if bootstrap.last_completed_item_id.is_none() { + match read_direct_project_last_item_id_at(root) { + Ok(last_completed_item_id) => { + bootstrap.last_completed_item_id = last_completed_item_id; + } + Err(error) => { + // 订阅已经登记,后续锚点读取失败时也必须回收 native + // subscriber;否则前端拿不到 subscriptionId,无法自行清理。 + unsubscribe_thread(&bootstrap.subscription_id); + return Err(error); + } + } + } + Ok(bootstrap) + }) + .await + .map_err(|error| format!("订阅 DirectProject 线程后台任务失败:{error}"))? +} + +#[tauri::command] +pub(crate) fn consume_direct_project_thread( + subscription_id: String, +) -> Result { + consume_thread(subscription_id.trim()) +} + +/// 读一屏项目对话历史。 +/// +/// 窗口两端各由一个锚点给出,两者互斥(都传会报错):`before_item_id` 是**旧端**边界(不含 +/// 该条,向后翻页用),`through_item_id` 是**新端**边界(含该条,取 `subscribe` 回执里的 +/// `lastCompletedItemId`,比它更新的条目只从运行态事件来);都不传就是文件尾最近的一屏。 +#[tauri::command] +pub(crate) async fn read_direct_project_history_slice( + project_path: String, + before_item_id: Option, + through_item_id: Option, + limit: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + let anchor = match (before_item_id.as_deref(), through_item_id.as_deref()) { + (Some(_), Some(_)) => { + return Err( + "DirectProject 历史切片只接受一个锚点(beforeItemId / throughItemId)" + .to_string(), + ); + } + (Some(before), None) => DirectProjectHistoryAnchor::Before(before), + (None, Some(through)) => DirectProjectHistoryAnchor::Through(through), + (None, None) => DirectProjectHistoryAnchor::Newest, + }; + let (raw_items, has_more, recorded_at_ms, first_item_id) = + read_direct_project_history_items_slice_at(root, anchor, limit.unwrap_or(20))?; + let items = thread_items_from_history(root, &raw_items, |item| { + thread_item_identity(item) + .and_then(|identity| recorded_at_ms.get(&identity).copied()) + .unwrap_or_default() + }); + Ok(HistorySlice { + items, + has_more, + first_item_id, + }) + }) + .await + .map_err(|error| format!("读取 DirectProject 历史切片后台任务失败:{error}"))? +} + +#[tauri::command] +pub(crate) fn append_direct_project_conversation_message( + project_path: String, + message: LocalConversationMessage, + message_id: Option, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.write")?; + let item = + direct_project_local_message_item(&message.role, &message.content, message_id.as_deref())?; + append_direct_project_history_item_at(root, &item)?; + read_direct_project_chat_history_at(root) +} + +#[tauri::command] +pub(crate) fn build_local_project_index( + project_path: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.index")?; + let _lock = acquire_project_write_lock(root, "project.index")?; + build_local_project_index_at(root) +} + +#[tauri::command] +pub(crate) fn create_local_project_checkpoint( + project_path: String, +) -> Result { + create_local_project_checkpoint_with_capture( + project_path, + crate::analytics::gui::capture_writer_context(), + ) +} + +/// 为发布导出试玩包:项目还没有可玩入口时先跑项目自己的 `npm run build`。 +/// +/// 作者只点一次「发布」:已有 `game/index.html` 或 `dist/index.html` 直接打包;只有源码时 +/// 走 `project.verify` 的受控 npm 运行器构建后再打包,失败信息带构建日志尾部。 +#[tauri::command] +pub(crate) async fn export_local_project_package( + project_path: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.export_package")?; + let _lock = acquire_project_write_lock(root, "project.export_package")?; + advance_agent_runtime_project_revision_locked(root)?; + export_local_project_package_for_publish_at(root).await +} + +/// 把归一化后的发行包落到内容寻址的暂存文件,返回分片续传所需的元数据。 +/// +/// 发布链路从此只把「暂存路径 + 摘要 + 体积」交给渲染进程:整包字节不再经过 +/// WebView IPC,续传时也复用同一个暂存文件(同名同内容)。 +#[tauri::command] +pub(crate) fn prepare_local_project_game_package( + app: tauri::AppHandle, + project_path: String, + package_relative_path: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.export_package")?; + let payload = read_local_project_export_package_at(root, package_relative_path.trim())?; + let staging_dir = game_package_upload_staging_dir(&app)?; + let mut staged = crate::game_package_upload::stage_game_package_bytes( + &staging_dir, + &payload.package_sha256, + &payload.package_bytes, + )?; + staged.package_file_count = u32::try_from(payload.files.len()).unwrap_or(u32::MAX); + Ok(staged) +} + +/// 分片续传上传暂存的发行包;进度通过 `game-package-upload-progress` 事件回传。 +#[tauri::command] +pub(crate) async fn upload_local_project_game_package( + app: tauri::AppHandle, + staging_path: String, + version_id: String, + api_base_url: String, + access_token: String, + idempotency_key: String, +) -> Result { + let staging_dir = game_package_upload_staging_dir(&app)?; + let resolved_path = + crate::game_package_upload::ensure_staging_path_in_dir(&staging_dir, &staging_path)?; + let client = reqwest::Client::builder() + .build() + .map_err(|error| format!("创建上传客户端失败:{error}"))?; + let version_id = version_id.trim().to_string(); + if version_id.is_empty() { + return Err("缺少发行版本标识".to_string()); + } + let emit_handle = app.clone(); + let progress_version_id = version_id.clone(); + crate::game_package_upload::upload_staged_game_package( + &client, + crate::game_package_upload::GamePackageUploadRequest { + staging_path: &resolved_path, + version_id: &version_id, + api_base_url: api_base_url.trim(), + access_token: access_token.trim(), + idempotency_key: idempotency_key.trim(), + session_identity: None, + }, + move |received_bytes, total_bytes| { + let _ = emit_handle.emit( + crate::game_package_upload::GAME_PACKAGE_UPLOAD_PROGRESS_EVENT, + crate::game_package_upload::progress_event_payload( + &progress_version_id, + received_bytes, + total_bytes, + ), + ); + }, + ) + .await +} + +fn game_package_upload_staging_dir(app: &tauri::AppHandle) -> Result { + app.path() + .app_data_dir() + .map(|app_data_root| { + crate::game_package_upload::game_package_upload_staging_dir(&app_data_root) + }) + .map_err(|error| format!("无法读取 AGC 应用数据目录:{error}")) +} + +#[tauri::command] +pub(crate) fn list_local_project_export_packages( + project_path: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.export_list")?; + list_local_project_export_packages_at(root) +} + +#[tauri::command] +pub(crate) fn diff_local_project_checkpoint( + project_path: String, + checkpoint_id: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.diff")?; + diff_local_project_checkpoint_at(root, checkpoint_id.trim()) +} + +#[tauri::command] +pub(crate) fn restore_local_project_checkpoint( + project_path: String, + checkpoint_id: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.restore")?; + let _lock = acquire_project_write_lock(root, "project.restore")?; + advance_agent_runtime_project_revision_locked(root)?; + restore_local_project_checkpoint_at(root, checkpoint_id.trim()) +} + +#[tauri::command] +pub(crate) fn write_project_permission_policy( + project_path: String, + policy: ProjectPermissionPolicy, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.policy_write")?; + 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}")) +} + +/// 解析本次建项要使用的根目录:显式参数优先,其次读取 Rust 工作区偏好,最后回落 AGC 默认目录。 +pub(crate) fn resolve_game_project_creation_root( + app: &tauri::AppHandle, + requested: Option<&str>, +) -> Result { + let stored = requested + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(|| crate::workspace_preferences::stored_project_creation_directory(app)); + match stored.as_deref() { + Some(requested) => validate_requested_game_project_creation_root(requested), + None => automatic_local_game_projects_root(app), + } +} + +#[tauri::command] +pub(crate) async fn remove_direct_project_pending_turn( + project_path: String, + client_turn_id: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.write")?; + let thread_id = thread_id_for_project(root); + Ok(remove_pending_turn(&thread_id, client_turn_id.trim())) + }) + .await + .map_err(|error| format!("取消 DirectProject 待发消息后台任务失败:{error}"))? +} + +#[tauri::command] +pub(crate) fn unsubscribe_direct_project_thread(subscription_id: String) -> Result<(), String> { + unsubscribe_thread(subscription_id.trim()); + Ok(()) +} 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 edc753a2d..8a16b71a8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -1138,7 +1138,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), @@ -1311,6 +1311,8 @@ fn validate_game_creator_private_path_ancestors_with_auto_elevation_scoped( ) -> Result<(), String> { #[cfg(test)] { + // 测试只校验路径,不触发宿主提权修复,因此不消费修复范围。 + let _ = scope; return validate_game_creator_private_path_ancestors(path, label); } #[cfg(not(test))] @@ -2506,7 +2508,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, @@ -2574,16 +2576,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 } @@ -2594,7 +2596,7 @@ impl WindowsAclRepairAuthorizationCleanup { } } -#[cfg(windows)] +#[cfg(all(windows, not(test)))] impl Drop for WindowsAclRepairAuthorizationCleanup { fn drop(&mut self) { if self.armed { @@ -2678,7 +2680,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, @@ -3534,6 +3536,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/context_menu.rs b/apps/ai-game-creator-shell/src-tauri/src/context_menu.rs index df6c4ccc2..19ba517cb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/context_menu.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/context_menu.rs @@ -1,7 +1,7 @@ -use tauri::{ - plugin::{Builder, TauriPlugin}, - Runtime, -}; +#[cfg(not(test))] +mod runtime; +#[cfg(not(test))] +pub(crate) use runtime::init; /// Release 客户端只取消浏览器的默认上下文菜单。 /// @@ -22,39 +22,6 @@ fn context_menu_init_script(is_release: bool) -> Option<&'static str> { is_release.then_some(RELEASE_CONTEXT_MENU_INIT_SCRIPT) } -#[cfg(all(windows, not(debug_assertions)))] -fn disable_windows_default_context_menus(webview: tauri::Webview) { - let _ = webview.with_webview(|platform_webview| { - // WebView2 的原生设置只关闭浏览器菜单,不会取消 DOM 的 - // `contextmenu` 事件,因此业务右键菜单仍然可以正常工作。 - // SAFETY: Tauri 在 WebView2 UI 线程上提供当前存活的 controller, - // 这些 COM 接口调用只使用该闭包期间有效的句柄。 - let _ = unsafe { - platform_webview - .controller() - .CoreWebView2() - .and_then(|webview| webview.Settings()) - .and_then(|settings| settings.SetAreDefaultContextMenusEnabled(false)) - }; - }); -} - -/// 注册 release-only 的默认网页上下文菜单策略。 -pub(crate) fn init() -> TauriPlugin { - let mut builder = Builder::new("release-context-menu"); - - if let Some(script) = context_menu_init_script(!cfg!(debug_assertions)) { - builder = builder.js_init_script_on_all_frames(script); - } - - #[cfg(all(windows, not(debug_assertions)))] - { - builder = builder.on_webview_ready(disable_windows_default_context_menus); - } - - builder.build() -} - #[cfg(test)] mod tests { use super::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/context_menu/runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/context_menu/runtime.rs new file mode 100644 index 000000000..6bb451358 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/context_menu/runtime.rs @@ -0,0 +1,39 @@ +use tauri::{ + plugin::{Builder, TauriPlugin}, + Runtime, +}; + +use super::context_menu_init_script; + +#[cfg(all(windows, not(debug_assertions)))] +fn disable_windows_default_context_menus(webview: tauri::Webview) { + let _ = webview.with_webview(|platform_webview| { + // WebView2 的原生设置只关闭浏览器菜单,不会取消 DOM 的 + // `contextmenu` 事件,因此业务右键菜单仍然可以正常工作。 + // SAFETY: Tauri 在 WebView2 UI 线程上提供当前存活的 controller, + // 这些 COM 接口调用只使用该闭包期间有效的句柄。 + let _ = unsafe { + platform_webview + .controller() + .CoreWebView2() + .and_then(|webview| webview.Settings()) + .and_then(|settings| settings.SetAreDefaultContextMenusEnabled(false)) + }; + }); +} + +/// 注册 release-only 的默认网页上下文菜单策略。 +pub(crate) fn init() -> TauriPlugin { + let mut builder = Builder::new("release-context-menu"); + + if let Some(script) = context_menu_init_script(!cfg!(debug_assertions)) { + builder = builder.js_init_script_on_all_frames(script); + } + + #[cfg(all(windows, not(debug_assertions)))] + { + builder = builder.on_webview_ready(disable_windows_default_context_menus); + } + + builder.build() +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs new file mode 100644 index 000000000..29268e088 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs @@ -0,0 +1,800 @@ +// 桌面命令注册、GUI 生命周期与独立进程启动接线。 +// 共享业务、诊断与退出决策函数留在 crate 根及所属模块供现有测试使用。 +use super::*; +use account_api::*; +use agent::design_tools::*; +use asset_generation_tasks::*; +use client_extensions::*; +use environment_check::preflight_web_game_creation; +use error_report::*; +use game_distribution_publish::*; +use plugin_host::{ + call_agc_plugin, list_agc_extensions, list_agc_plugins, read_agc_plugin_panel, + refresh_agc_plugins, reload_agc_plugin, set_agc_plugin_enabled, set_agc_plugin_project_path, + start_agc_plugin, stop_agc_plugin, PluginHost, +}; +use shared_contracts::error_reports::ErrorReportLogInput; +use template_library::*; +use workspace_preferences::*; + +/// 更新完成后的进程重启:Windows 由 NSIS 安装程序代为重启,macOS / Linux 由客户端在安装后调用。 +#[tauri::command] +fn restart_agc_app(app: tauri::AppHandle) { + analytics::gui::mark_restart(); + app.restart(); +} + +/// 把 `shared-contracts` 的「非 canonical 资源 kind」留痕接到壳层日志上。 +/// +/// 解析边界(含 manifest 反序列化、画板 assetKind、平台 assetKind)认不出 canonical 值时, +/// 会把**原始输入串**与调用上下文交回来;这里落到 `app_log!` → AppData 日志里, +/// 以便回查非 canonical kind 的写入边界。kind 本身只接受严格值。 +/// +/// 按 `(原始串, 上下文)` 去重:manifest 读取极频繁,每次都落一行会把其它诊断刷掉; +/// 去重后"有哪些非 canonical 值、分别从哪个边界进来"仍然完整。需要具体资产身份时看 +/// `log_non_canonical_manifest_asset_kinds`(按资产去重,带 assetId / localPath)。 +fn register_non_canonical_asset_kind_reporter() { + static REPORTED: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + shared_contracts::game_creation_app::set_non_canonical_asset_kind_reporter( + |raw_kind: &str, context: &str| { + let key = format!("{context}\u{1}{raw_kind}"); + let first_seen = REPORTED + .get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new())) + .lock() + .map(|mut reported| reported.insert(key)) + .unwrap_or(true); + if !first_seen { + return; + } + app_log!( + "GameCreationApp 资源 kind 不是 canonical 值,已按严格口径收口为 unknown:rawKind={raw_kind} context={context}" + ); + }, + ); +} + +#[tauri::command] +async fn recognize_ui( + project_path: String, + state: ui_editor::state::State, +) -> Result { + ui_editor::commands::recognize_ui_impl(project_path, state).await +} + +#[tauri::command] +async fn separate_ui( + project_path: String, + asset_id: String, + state: ui_editor::state::State, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + ui_editor::commands::separate_ui_impl(project_path, asset_id, state).await +} + +#[tauri::command] +fn inspect_separation_recovery( + project_path: String, + asset_id: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.list")?; + ui_editor::commands::separation::inspect_separation_recovery(root, &asset_id) +} + +#[tauri::command] +fn finalize_separation(project_path: String, asset_id: String) -> Result<(), String> { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + ui_editor::commands::separation::finalize_separation(root, &asset_id) +} + +#[tauri::command] +fn discard_separation_recovery(project_path: String, asset_id: String) -> Result<(), String> { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + ui_editor::commands::separation::discard_separation_recovery(root, &asset_id) +} + +#[tauri::command] +fn load_ui_design_state( + input: ui_editor::persistence::LoadUiDesignStateInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.list")?; + ui_editor::persistence::load_ui_design_state_at(input) +} + +#[tauri::command] +fn save_ui_design_state( + input: ui_editor::persistence::SaveUiDesignStateInput, +) -> Result { + save_ui_design_state_with_capture(input, analytics::gui::capture_writer_context()) +} + +#[tauri::command] +fn generate_ui_design_code( + input: ui_editor::persistence::GenerateUiDesignCodeInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "file.write")?; + ui_editor::persistence::generate_ui_design_code_at(input) +} + +#[tauri::command] +fn append_application_log(level: String, source: String, message: String) -> Result<(), String> { + let config_dir = game_creator_runtime_config_dir() + .ok_or_else(|| "客户端 AppData 配置目录未初始化".to_string())?; + let level = sanitize_diagnostic_message(&level, Some(&config_dir)); + let source = sanitize_diagnostic_message(&source, Some(&config_dir)); + let message = sanitize_diagnostic_message(&message, Some(&config_dir)); + let line = format!("WEBVIEW {level} {source}: {message}"); + append_application_log_line(&line).map_err(|error| error.to_string()) +} + +#[tauri::command] +fn read_diagnostic_logs() -> Result, String> { + let Some(config_dir) = game_creator_runtime_config_dir() else { + return Ok(Vec::new()); + }; + let _guard = DIAGNOSTIC_LOG_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let directory = config_dir.join("diagnostics"); + let mut files = Vec::new(); + for name in ["application.log", "application.previous.log", "startup.log"] { + let path = directory.join(name); + let Ok(metadata) = fs::symlink_metadata(&path) else { + continue; + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + continue; + } + let Ok(content) = fs::read_to_string(&path) else { + continue; + }; + files.push(ErrorReportLogInput { + name: name.to_string(), + content, + }); + } + Ok(files) +} + +/// 客户端产品名跟随构建期渠道身份:dev 渠道是「陶泥儿开发版」,release 渠道复用 +/// 正式产品名「陶泥儿」,自定义渠道带渠道后缀。非默认渠道通过 identifier 隔离 +/// 本地数据与运行身份;默认渠道结果不变。 +pub(crate) fn game_creator_product_name(app: &tauri::AppHandle) -> String { + app.package_info().name.clone() +} + +/// 配置目录就绪前的启动日志路径:优先用已经生效的配置目录(例如 `--config-dir` +/// 已经设置好的目录),否则退到平台配置根。两者都不可用时返回 `None`,此时 +/// `StartupLogSlot::fail` 仍然必须给出用户可见提示。 +fn early_startup_log_path(identifier: &str) -> Option { + let configured_dir = game_creator_runtime_config_dir(); + resolve_early_startup_log_path(configured_dir.as_deref(), identifier) +} + +impl GameCreatorGuiRunnerShutdownFailure { + fn code(self) -> &'static str { + match self { + Self::EndpointUnavailable => "endpoint_unavailable", + Self::RunnerUnresponsive => "runner_unresponsive", + Self::ProcessIdentity => "process_identity", + Self::PlatformUnsupported => "platform_unsupported", + Self::LockTimeout => "lock_timeout", + Self::Other => "other", + } + } +} + +fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) { + if matches!(event, tauri::RunEvent::Exit) { + // 窗口关闭时已经按项目触发过一次快照同步;退出路径只负责在有界预算内 + // 等在途同步收尾,不重复发起(此刻窗口已销毁,重新枚举项目只会是空集)。 + wait_for_project_snapshot_syncs_on_exit(); + // 退出时统一收尾本地预览:进程内监听线程随进程消失,但 `.agent/manifest.json` + // 里的 preview 记录会留在 running 上,下次进项目就照着它渲染打不开的运行界面。 + if let Err(error) = + preview::stop_local_game_preview_on_exit(&game_creator_preview_registry()) + { + app_log!("preview.gui_exit.stop_failed: {error}"); + } + if let Err(error) = agent::shutdown_game_creator_codex_app_servers() { + app_log!("agent.direct_codex.gui_exit.shutdown_failed: {error}"); + } + } + match resolve_game_creator_gui_runner_shutdown( + event, + shutdown_external_agent_runner_for_gui_exit, + ) { + GameCreatorGuiRunnerShutdownOutcome::NotRequested => {} + GameCreatorGuiRunnerShutdownOutcome::Requested => { + app_log!("agent.runner.gui_exit.shutdown_requested") + } + GameCreatorGuiRunnerShutdownOutcome::Retained => { + app_log!("agent.runner.gui_exit.retained_for_other_windows") + } + GameCreatorGuiRunnerShutdownOutcome::Failed(failure) => { + app_log!("agent.runner.gui_exit.shutdown_failed.{}", failure.code()) + } + } +} + +fn install_agent_runtime_async_runtime_with_deep_stack() { + let runtime = match build_agent_runtime_async_runtime() { + Ok(runtime) => runtime, + Err(error) => { + app_log!("agent.runner.failed: {error}"); + std::process::exit(1); + } + }; + tauri::async_runtime::set(runtime.handle().clone()); + // 进程生命周期内必须持有,否则 handle 立即失效。 + Box::leak(Box::new(runtime)); +} + +#[cfg(windows)] +fn run_windows_acl_repair_if_requested(args: &[String]) -> Option { + let [command, path, target_user_sid_flag, target_user_sid, authorization_flag, nonce, scope_flag, scope_value] = + args + else { + if args.first().map(String::as_str) == Some("--repair-private-acl") { + eprintln!( + "用法:--repair-private-acl <路径> --target-user-sid --authorization <票据> --scope " + ); + return Some(1); + } + return None; + }; + if command != "--repair-private-acl" + || target_user_sid_flag != "--target-user-sid" + || authorization_flag != "--authorization" + || scope_flag != "--scope" + { + eprintln!( + "用法:--repair-private-acl <路径> --target-user-sid --authorization <票据> --scope " + ); + return Some(1); + } + let path = std::path::PathBuf::from(path); + let scope = match config::parse_windows_acl_repair_scope(scope_value) { + Ok(scope) => scope, + Err(error) => { + eprintln!("{error}"); + return Some(1); + } + }; + let result = + config::consume_windows_acl_repair_authorization(&path, target_user_sid, nonce, scope) + .and_then(|()| { + config::repair_game_creator_private_acl_for_user_sid(&path, target_user_sid) + }); + match result { + Ok(()) => Some(0), + Err(error) => { + eprintln!("AGC ACL 提权修复失败:{error}"); + Some(1) + } + } +} + +pub(super) fn run() { + install_agent_runtime_async_runtime_with_deep_stack(); + register_non_canonical_asset_kind_reporter(); + let mut args = std::env::args().skip(1).collect::>(); + if let Some(exit_code) = run_direct_tools_mcp_if_requested(&args) { + std::process::exit(exit_code); + } + #[cfg(windows)] + if let Some(exit_code) = run_windows_acl_repair_if_requested(&args) { + std::process::exit(exit_code); + } + #[cfg(target_os = "linux")] + if command_sandbox_trampoline::is_trampoline_mode(&args) { + match command_sandbox_trampoline::run_trampoline() { + Ok(exit_code) => std::process::exit(exit_code), + Err(_) => std::process::exit(125), + } + } + #[cfg(target_os = "linux")] + if command_sandbox_trampoline::is_process_session_trampoline_mode(&args) { + match command_sandbox_trampoline::run_process_session_trampoline() { + Ok(exit_code) => std::process::exit(exit_code), + Err(_) => std::process::exit(125), + } + } + #[cfg(target_os = "linux")] + if is_process_session_child_mode(&args) { + match run_process_session_child(&args) { + Ok(exit_code) => std::process::exit(exit_code), + Err(_) => std::process::exit(125), + } + } + let runtime_config_dir = match take_cli_runtime_config_dir(&mut args) { + Ok(config_dir) => config_dir, + Err(error) => { + app_log!("{error}"); + std::process::exit(1); + } + }; + if args.first().map(String::as_str) == Some("--agent-runner") { + let gui_owner_required = match args.as_slice() { + [_] => false, + [_, option] if option == "--gui-owner-required" => true, + _ => { + app_log!( + "用法:--agent-runner [--gui-owner-required] --config-dir " + ); + std::process::exit(1); + } + }; + let Some(config_dir) = runtime_config_dir else { + app_log!("Agent Runner 必须显式传入 --config-dir "); + std::process::exit(1); + }; + if let Err(error) = load_platform_session_fixture_from_env(&config_dir) { + app_log!("agent.runner.failed: {error}"); + std::process::exit(1); + } + set_game_creator_runtime_config_dir(config_dir.clone()); + if let Err(error) = run_external_agent_runner_server(config_dir, gui_owner_required) { + app_log!("agent.runner.failed: {error}"); + std::process::exit(1); + } + return; + } + match parse_cli_command(&args) { + Ok(Some(mut command)) => { + let config_dir = + match prepare_cli_command_paths(&mut command, runtime_config_dir.as_deref()) { + Ok(config_dir) => config_dir, + Err(error) => { + app_log!("agent.runner.failed: {error}"); + std::process::exit(1); + } + }; + if let Some(config_dir) = config_dir { + if let Err(error) = load_platform_session_fixture_from_env(&config_dir) { + app_log!("agent.runner.failed: {error}"); + std::process::exit(1); + } + if command.requires_external_agent_runner() || command.is_read_only_status() { + let configured = if command.is_read_only_status() { + configure_external_agent_runner_read_only(&config_dir) + } else { + configure_external_agent_runner(&config_dir) + }; + if let Err(error) = configured { + app_log!("agent.runner.failed: {error}"); + std::process::exit(1); + } + } + set_game_creator_runtime_config_dir(config_dir); + } + if command.requires_started_external_agent_runner() { + if let Err(error) = ensure_external_agent_runner_started() { + app_log!("agent.runner.failed: {error}"); + std::process::exit(1); + } + } + if let Err(error) = run_cli_command(command) { + app_log!("agent.run.failed: {error}"); + std::process::exit(1); + } + return; + } + Ok(None) => {} + Err(error) => { + app_log!("{error}"); + std::process::exit(1); + } + } + + if let Some(config_dir) = runtime_config_dir { + set_game_creator_runtime_config_dir(config_dir); + } + + let tauri_context = tauri::generate_context!(); + // 配置目录确定之前先推导启动日志路径:优先用已经生效的配置目录(例如 + // `--config-dir`),否则退到平台配置根,保证 + // `configure_game_creator_runtime_config_dir` 自身失败也有落点。 + let startup_log = Arc::new(StartupLogSlot::new(early_startup_log_path( + tauri_context.config().identifier.as_str(), + ))); + let setup_log = Arc::clone(&startup_log); + let app = tauri::Builder::default() + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_clipboard_manager::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(context_menu::init()) + .on_page_load(|webview, payload| { + if matches!(payload.event(), tauri::webview::PageLoadEvent::Started) { + analytics::gui::page_loading(webview.window().label()); + } + }) + .on_window_event(|window, event| { + analytics::gui::window_event(window, event); + handle_project_snapshot_window_event(window, event); + }) + .manage(game_creator_preview_registry()) + .manage(ProjectResourcePreviewReadManager::default()) + .manage(PluginHost::default()) + .setup(move |app| { + error_report::initialize_notifications(app.handle()); + setup_log.append("startup.setup.begin"); + setup_log.append("startup.appdata.configure.begin"); + configure_game_creator_runtime_config_dir(app.handle()).inspect_err(|error| { + // 日志路径未知也必须记录并提示:不能因为拿不到路径就静默失败。 + let config_dir = game_creator_runtime_config_dir(); + let details = + sanitize_diagnostic_message(&error.to_string(), config_dir.as_deref()); + setup_log.fail(&format!( + "startup.appdata.configure.failed details={details}" + )); + })?; + if let Some(directory) = game_creator_runtime_config_dir() { + setup_log.set(directory.join("diagnostics/startup.log")); + } + // 跨会话清扫陈旧的无头浏览器(上次异常退出/被杀留下的 ga-browser-*)。 + // 后台执行,不阻塞启动;杀树前按进程身份与可信浏览器路径双重校验。 + tauri::async_runtime::spawn(async move { + let notes = + tokio::task::spawn_blocking(crate::browser::sweep_stale_browser_processes) + .await + .unwrap_or_default(); + if !notes.is_empty() { + app_log!("startup.browser-sweep: {}", notes.join("; ")); + } + }); + setup_log.append("startup.appdata.configure.complete"); + let config_dir = game_creator_runtime_config_dir().ok_or_else(|| { + let error = std::io::Error::new( + std::io::ErrorKind::NotFound, + "客户端 AppData 配置目录未初始化", + ); + setup_log.fail("startup.appdata.resolve.failed details=config-dir-uninitialized"); + error + })?; + // 主窗口标题与产品名保持一致:配置里的标题来自基线配置,渠道后缀只 + // 由构建期身份决定,因此必须在这里按产品名覆盖。 + match app.get_webview_window("client") { + Some(window) => { + if let Err(error) = window.set_title(&game_creator_product_name(app.handle())) { + app_log!("startup.window-title.failed: {error}"); + } + } + None => app_log!("startup.window-title.failed: 缺少 client 主窗口"), + } + spawn_project_snapshot_scheduler(app.handle().clone()); + if let Err(error) = builtin_plugins::initialize(&config_dir) { + app_log!("startup.builtin-plugins.initialize.failed: {error}"); + } + if let Err(error) = app.state::().initialize(&config_dir) { + app_log!("startup.plugin-host.initialize.failed: {error}"); + } + if let Some(workspace) = plugin_host::resolve_plugin_workspace(app.handle()) { + if let Err(error) = app.state::().set_plugin_workspace(workspace) { + app_log!("startup.plugin-host.workspace.failed: {error}"); + } + } + if let Err(error) = editor_adapters::register_linked_editor_adapters( + app.handle(), + app.state::().inner(), + ) { + app_log!("startup.plugin-host.adapter.failed: {error}"); + } + load_platform_session_fixture_from_env(&config_dir).map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + format!("加载平台登录态测试 fixture 失败:{error}"), + ) + })?; + setup_log.append("startup.runner.configure.begin"); + configure_external_agent_runner(&config_dir) + .inspect_err(|error| { + let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); + setup_log.fail(&format!( + "startup.runner.configure.failed details={details}" + )); + }) + .map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("配置 Agent Runner 失败:{error}"), + ) + })?; + setup_log.append("startup.runner.configure.complete"); + hold_external_agent_runner_gui_participant_lock(&config_dir) + .inspect_err(|error| { + let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); + setup_log.fail(&format!( + "startup.runner.participant-lock.failed details={details}" + )); + }) + .map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + format!("建立 AGC 界面参与锁失败:{error}"), + ) + })?; + setup_log.append("startup.runner.start.begin"); + set_game_creator_agent_runtime_update_app_handle(app.handle().clone()); + set_thread_manager_app_handle(app.handle().clone()); + platform_maintenance::set_platform_maintenance_app_handle(app.handle().clone()); + auth_session::initialize_auth_session(app.handle()); + set_asset_generation_task_app_handle(app.handle().clone()); + let manifest_event_sink = + start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?; + attach_external_agent_runner_gui_owner(&manifest_event_sink) + .inspect_err(|error| { + let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); + setup_log.fail(&format!( + "startup.runner.attach-owner.failed details={details}" + )); + }) + .map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("绑定 Agent Runner GUI owner 失败:{error}"), + ) + })?; + setup_log.append("startup.runner.start.complete"); + setup_log.append("startup.setup.complete"); + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + analytics::gui::capture_analytics_context, + analytics::gui::record_analytics_project_open, + analytics::gui::record_analytics_project_leave, + analytics::gui::record_analytics_ui_save, + start_game_creator_external_mcp, + stop_game_creator_external_mcp, + create_automatic_local_game_project, + create_automatic_local_game_project_from_template, + init_local_game_project, + fetch_game_template_library, + get_game_template_library_access, + download_game_template, + import_local_godot_project, + import_local_cocos_project, + import_local_unity_project, + is_local_project_directory_non_empty, + inspect_local_project_directory, + pick_local_project_directory, + rename_local_game_project, + suggest_automatic_project_name, + polish_local_project_prompt, + pick_client_extension_file, + pick_client_extension_directory, + list_client_extensions, + list_agc_skill_catalog, + import_client_extension, + set_client_extension_enabled, + rename_client_extension, + remove_client_extension, + list_agc_plugins, + list_agc_extensions, + refresh_agc_plugins, + start_agc_plugin, + stop_agc_plugin, + reload_agc_plugin, + set_agc_plugin_enabled, + call_agc_plugin, + read_agc_plugin_panel, + set_agc_plugin_project_path, + open_local_project_directory, + control_agent_run, + generate_local_game_draft, + chat_with_game_creator_agent, + chat_with_game_creator_role_agent, + chat_with_game_creator_role_agent_stream, + enqueue_direct_codex_turn, + remove_direct_project_pending_turn, + preflight_web_game_creation, + cancel_direct_codex_turn, + select_game_creator_reasoning_effort, + hydrate_design_agent_session, + reset_design_agent_session, + get_design_agent_runtime_mode, + is_design_agent_debug_enabled, + set_design_agent_runtime_mode, + debug_fast_forward_design_session, + continue_design_agent_session, + decide_design_phase, + import_design_workspace_file, + list_design_workspace, + read_design_workspace_file, + start_game_creator_supervisor_runtime_task, + compact_game_creator_agent_runtime_context, + read_game_creator_agent_goal, + start_game_creator_agent_goal, + edit_game_creator_agent_goal, + pause_game_creator_agent_goal, + resume_game_creator_agent_goal, + clear_game_creator_agent_goal, + cancel_game_creator_agent_runtime_task, + retry_game_creator_agent_runtime_task, + confirm_retry_game_creator_agent_runtime_task, + confirm_game_creator_agent_runtime_task, + reject_game_creator_agent_runtime_task, + answer_game_creator_agent_runtime_user_input, + read_game_creator_agent_runtime, + read_game_creator_agent_runtimes, + resume_game_creator_agent_runtime_tasks, + confirm_resume_game_creator_agent_runtime_tasks, + schedule_game_creator_agent_ready_tasks, + check_game_creator_llm_config, + read_workspace_preferences, + set_project_creation_directory, + remember_recent_workspace, + remove_recent_workspace, + migrate_workspace_preferences, + set_chat_prompt_polish_reminder_disabled, + set_client_server_selection, + read_profile_recharge_center, + read_profile_wallet_ledger, + create_profile_recharge_order, + confirm_wechat_profile_recharge_order, + redeem_profile_reward_code, + read_client_auth_state, + refresh_client_auth_session, + send_client_phone_login_code, + login_client_with_password, + login_client_with_phone_code, + logout_client_session, + read_game_publish_availability, + suggest_game_distribution_publish_metadata, + read_game_cover_generation_price, + generate_game_distribution_cover, + publish_local_project_game, + read_game_creator_app_config, + clear_game_creator_acl_elevation_denials, + write_game_creator_app_config, + select_game_creator_model, + load_game_creator_llm_models, + discover_game_creator_llm_models, + read_editor_asset_library, + read_editor_asset_preview, + upload_platform_media_asset, + upload_local_asset, + register_local_asset, + create_ui_design_doc_from_images, + update_local_project_resource_classification, + add_local_project_resource_tags, + derive_local_project_resource, + list_pending_local_project_resource_edits, + resume_local_project_resource_edit, + request_local_project_resource_edit_service_identity_confirmation, + confirm_local_project_resource_edit_service_identity, + archive_failed_local_project_resource_edit, + normalize_local_project_raster_resource, + import_canvas_asset, + import_canvas_export, + sync_canvas_project_assets, + import_ui_editor_assets, + prepare_ui_editor_project_fonts, + read_ui_editor_font_bytes, + check_ui_editor_font_glyph_coverage, + recognize_ui, + separate_ui, + inspect_separation_recovery, + finalize_separation, + discard_separation_recovery, + load_ui_design_state, + save_ui_design_state, + generate_ui_design_code, + generate_platform_art_asset, + generate_local_project_asset, + start_local_project_asset_generation, + list_local_project_asset_generations, + open_canvas_project, + run_limited_local_command, + append_local_permission_log, + list_local_project_files, + import_local_project_image_assets, + read_local_project_file, + read_local_project_image_preview, + save_local_project_asset_file, + read_local_project_text_preview, + read_local_project_structured_preview, + read_local_project_media_preview, + cancel_local_project_resource_preview_scope, + read_local_game_memory, + read_local_agent_memory, + write_local_agent_memory, + write_local_game_memory, + list_game_creator_agent_sessions, + create_game_creator_agent_session, + fork_game_creator_agent_session, + set_active_game_creator_agent_session, + archive_game_creator_agent_session, + read_local_conversation, + read_direct_project_conversation, + list_game_creator_direct_active_turns, + subscribe_direct_project_thread, + consume_direct_project_thread, + unsubscribe_direct_project_thread, + read_direct_project_history_slice, + append_local_conversation_message, + append_direct_project_conversation_message, + build_local_project_index, + create_local_project_checkpoint, + export_local_project_package, + prepare_local_project_game_package, + upload_local_project_game_package, + list_local_project_export_packages, + diff_local_project_checkpoint, + restore_local_project_checkpoint, + read_project_permission_policy, + write_project_permission_policy, + open_game_creator_workspace_window, + open_game_creator_launcher_window, + start_local_game_preview, + activate_local_game_preview, + stop_local_game_preview, + stop_local_game_preview_if_matches, + get_local_game_preview_status, + read_local_project_resource_canvas_layout, + read_local_project_resource_graph, + update_local_project_resource_canvas_layout, + delete_local_project_asset, + read_local_project_asset_references, + rename_local_project_asset, + read_local_project_version_resource_replacement_candidates, + replace_local_project_version_resource, + get_local_game_project_revision, + get_local_game_manifest, + restart_agc_app, + append_application_log, + read_diagnostic_logs, + report_client_error, + get_pending_error_reports, + ack_error_reports, + submit_error_report, + sync_local_project_snapshot, + read_local_project_snapshot_state, + set_active_project_snapshot_workspace, + ]) + .build(tauri_context); + let app = match app { + Ok(app) => { + startup_log.append("startup.build.complete"); + app + } + Err(error) => { + if let Some(path) = startup_log.path() { + let details = sanitize_diagnostic_message(&error.to_string(), path.parent()); + startup_log.fail(&format!("startup.build.failed details={details}")); + } + app_log!("failed to build Genarrative AI Game Creator shell: {error}"); + std::process::exit(1); + } + }; + app.run(move |app_handle, event| { + if matches!(event, tauri::RunEvent::Ready) { + if let Some(directory) = game_creator_runtime_config_dir() { + analytics::gui::initialize( + app_handle.clone(), + directory, + app_handle.package_info().version.to_string(), + ); + } + } + if matches!(event, tauri::RunEvent::Exit) { + analytics::gui::exit(); + } + handle_game_creator_gui_run_event(&event); + }); +} + +#[tauri::command] +fn create_ui_design_doc_from_images( + input: ui_editor::agent_tools::CreateUiDesignDocFromImagesInput, +) -> Result { + ui_editor::agent_tools::create_ui_design_doc_from_images(input) +} 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/execution.rs b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters/execution.rs index 00d7f7dcb..f9b87d922 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters/execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters/execution.rs @@ -525,11 +525,21 @@ pub(crate) fn disconnect_unity_editor_connection() { let _ = disconnect_managed_editor_connection(ManagedEditor::Unity); } -#[cfg(test)] +#[cfg(all( + test, + windows, + target_arch = "x86_64", + feature = "unity-editor-execute" +))] pub(super) fn unity_pending_delivery() -> &'static Mutex> { pending_delivery(ManagedEditor::Unity) } -#[cfg(test)] +#[cfg(all( + test, + windows, + target_arch = "x86_64", + feature = "unity-editor-execute" +))] pub(crate) fn unity_delivery_requires_ack(id: &str) -> bool { pending_delivery(ManagedEditor::Unity) .try_lock() 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/environment_check.rs b/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs index 290594405..5025dc08c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs @@ -7,9 +7,10 @@ use std::io::Read; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::time::{Duration, Instant}; +#[cfg(not(test))] +pub(crate) use web_creation::preflight_web_game_creation; pub(crate) use web_creation::{ - host_web_creation_preflight, preflight_web_game_creation, prepare_new_web_project_at, - record_new_web_scaffold_at, + host_web_creation_preflight, prepare_new_web_project_at, record_new_web_scaffold_at, }; use serde::Deserialize; diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs index a9a395c17..42ede48ea 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs @@ -6,27 +6,18 @@ use std::{ fs::{self, File}, - io::{Read, Seek, SeekFrom, Write}, + io::Write, path::{Path, PathBuf}, - time::Duration, }; -use crate::platform_session::{ - current_platform_session, validate_platform_session_identity, PlatformSessionIdentity, -}; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +use serde_json::Value; -pub(crate) const GAME_PACKAGE_UPLOAD_PROGRESS_EVENT: &str = "game-package-upload-progress"; - -const AGC_CLIENT_MARKER_HEADER: &str = "X-Genarrative-Client"; -const AGC_CLIENT_MARKER_VALUE: &str = "agc"; -const UPLOAD_OFFSET_HEADER: &str = "x-genarrative-upload-offset"; -/// 服务端下发的分片大小上限;客户端只按服务端给的值发,超过它必然被拒。 -const MAX_CHUNK_BYTES: u64 = 8 * 1024 * 1024; -const CHUNK_MAX_ATTEMPTS: usize = 4; -const CHUNK_RETRY_DELAY: Duration = Duration::from_millis(500); -const UPLOAD_STAGING_DIR_NAME: &str = "game-package-staging"; +mod runtime; +pub(crate) use runtime::{ + game_package_upload_staging_dir, progress_event_payload, upload_staged_game_package, + GamePackageUploadOutcome, GamePackageUploadRequest, GAME_PACKAGE_UPLOAD_PROGRESS_EVENT, +}; /// 已暂存(归一化后)的发行包:内容寻址,重启后同一包复用同一个文件,续传才有意义。 #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] @@ -38,36 +29,12 @@ pub(crate) struct StagedGamePackage { pub(crate) package_file_count: u32, } -#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct GamePackageUploadOutcome { - pub(crate) version_id: String, - pub(crate) status: String, - pub(crate) uploaded_bytes: u64, -} - #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct ChunkPlan { offset: u64, length: u64, } -#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -struct PackageUploadState { - #[serde(default)] - received_bytes: u64, - #[serde(default)] - chunk_bytes: u64, - #[serde(default)] - declared_package_bytes: u64, -} - -/// 暂存目录:放在应用数据目录下,不写进项目、也不参与项目快照同步。 -pub(crate) fn game_package_upload_staging_dir(app_data_dir: &Path) -> PathBuf { - app_data_dir.join(UPLOAD_STAGING_DIR_NAME) -} - /// 把归一化后的发行包暂存到内容寻址文件,返回续传所需的元数据。 pub(crate) fn stage_game_package_bytes( staging_dir: &Path, @@ -137,15 +104,6 @@ fn next_chunk_plan(received: u64, total: u64, chunk_bytes: u64) -> Option Result, String> { - file.seek(SeekFrom::Start(plan.offset)) - .map_err(|error| format!("读取发行包失败:{error}"))?; - let mut buffer = vec![0_u8; usize::try_from(plan.length).unwrap_or(0)]; - file.read_exact(&mut buffer) - .map_err(|error| format!("读取发行包失败:{error}"))?; - Ok(buffer) -} - fn upload_url(base_url: &str, version_id: &str, suffix: &str) -> String { format!( "{}/api/game-distribution/versions/{version_id}/package{suffix}", @@ -187,288 +145,6 @@ fn parse_received_bytes(body: &str) -> Option { .and_then(Value::as_u64) } -fn platform_request( - client: &reqwest::Client, - access_token: &str, - method: reqwest::Method, - url: &str, -) -> reqwest::RequestBuilder { - client - .request(method, url) - .header(AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_VALUE) - .header( - reqwest::header::AUTHORIZATION, - format!("Bearer {access_token}"), - ) -} - -async fn read_upload_state( - client: &reqwest::Client, - base_url: &str, - version_id: &str, - access_token: &str, -) -> Result { - let response = platform_request( - client, - access_token, - reqwest::Method::GET, - &upload_url(base_url, version_id, "/upload-state"), - ) - .send() - .await - .map_err(|error| format!("无法连接登录服务,请确认配套后端或 API 代理已启动后重试:{error}"))?; - let status = response.status().as_u16(); - let body = response - .text() - .await - .map_err(|error| format!("读取上传状态失败:{error}"))?; - if status >= 400 { - let (_, message) = parse_server_error(status, &body); - return Err(message.unwrap_or_else(|| format!("读取上传状态失败(HTTP {status})"))); - } - let parsed: Value = serde_json::from_str(&body) - .map_err(|error| format!("上传状态响应不是合法 JSON:{error}"))?; - let payload = parsed.get("data").unwrap_or(&parsed); - serde_json::from_value(payload.clone()) - .map_err(|error| format!("上传状态响应缺少字段:{error}")) -} - -/// 上传一个分片;返回服务端确认后的已收字节。 -async fn upload_chunk( - client: &reqwest::Client, - base_url: &str, - version_id: &str, - access_token: &str, - idempotency_key: &str, - plan: ChunkPlan, - body: Vec, -) -> Result { - let response = platform_request( - client, - access_token, - reqwest::Method::PUT, - &upload_url(base_url, version_id, "/chunk"), - ) - .header(reqwest::header::CONTENT_TYPE, "application/octet-stream") - .header(UPLOAD_OFFSET_HEADER, plan.offset.to_string()) - .header("Idempotency-Key", format!("{idempotency_key}:chunk")) - .body(body) - .send() - .await - .map_err(|error| ChunkUploadError::Retryable(format!("发行包分片发送失败:{error}")))?; - let status = response.status().as_u16(); - let text = response - .text() - .await - .map_err(|error| ChunkUploadError::Retryable(format!("读取分片响应失败:{error}")))?; - if status == 409 { - let (code, message) = parse_server_error(status, &text); - if code.as_deref() == Some("PACKAGE_UPLOAD_OFFSET_MISMATCH") { - let received = parse_received_bytes(&text).ok_or_else(|| { - ChunkUploadError::Fatal("分片偏移不一致,但服务端未返回权威偏移".to_string()) - })?; - return Ok(received); - } - return Err(ChunkUploadError::Fatal( - message.unwrap_or_else(|| "发行包分片被拒绝".to_string()), - )); - } - if status >= 500 || status == 408 || status == 429 { - let (_, message) = parse_server_error(status, &text); - return Err(ChunkUploadError::Retryable( - message.unwrap_or_else(|| format!("发行包分片上传失败(HTTP {status})")), - )); - } - if status >= 400 { - let (_, message) = parse_server_error(status, &text); - return Err(ChunkUploadError::Fatal( - message.unwrap_or_else(|| format!("发行包分片被拒绝(HTTP {status})")), - )); - } - let parsed: Value = serde_json::from_str(&text) - .map_err(|error| ChunkUploadError::Retryable(format!("分片响应不是合法 JSON:{error}")))?; - let payload = parsed.get("data").unwrap_or(&parsed); - payload - .get("receivedBytes") - .and_then(Value::as_u64) - .ok_or_else(|| ChunkUploadError::Fatal("分片响应缺少 receivedBytes".to_string())) -} - -enum ChunkUploadError { - Retryable(String), - Fatal(String), -} - -async fn complete_upload( - client: &reqwest::Client, - base_url: &str, - version_id: &str, - access_token: &str, - idempotency_key: &str, -) -> Result { - let response = platform_request( - client, - access_token, - reqwest::Method::POST, - &upload_url(base_url, version_id, "/complete"), - ) - .header("Idempotency-Key", format!("{idempotency_key}:complete")) - .send() - .await - .map_err(|error| format!("完成发行包上传失败:{error}"))?; - let status = response.status().as_u16(); - let text = response - .text() - .await - .map_err(|error| format!("读取完成响应失败:{error}"))?; - if status >= 400 { - let (_, message) = parse_server_error(status, &text); - return Err(message.unwrap_or_else(|| format!("完成发行包上传失败(HTTP {status})"))); - } - let parsed: Value = - serde_json::from_str(&text).map_err(|error| format!("完成响应不是合法 JSON:{error}"))?; - let payload = parsed.get("data").unwrap_or(&parsed); - Ok(GamePackageUploadOutcome { - version_id: payload - .get("versionId") - .and_then(Value::as_str) - .unwrap_or(version_id) - .to_string(), - status: payload - .get("status") - .and_then(Value::as_str) - .unwrap_or("uploaded") - .to_string(), - uploaded_bytes: 0, - }) -} - -pub(crate) struct GamePackageUploadRequest<'a> { - pub(crate) staging_path: &'a Path, - pub(crate) version_id: &'a str, - pub(crate) api_base_url: &'a str, - pub(crate) access_token: &'a str, - pub(crate) idempotency_key: &'a str, - pub(crate) session_identity: Option<&'a PlatformSessionIdentity>, -} - -impl<'a> GamePackageUploadRequest<'a> { - fn current_access_token(&self) -> Result { - let Some(expected) = self.session_identity else { - return Ok(self.access_token.to_string()); - }; - let session = current_platform_session().ok_or_else(|| { - "authentication-required: 陶泥儿登录态缺失,请重新登录后重试".to_string() - })?; - validate_platform_session_identity(expected)?; - Ok(session.access_token) - } -} - -/// 分片续传主循环:权威偏移来自服务端,失败按可重试分类退避,偏移不符立即按权威偏移继续。 -pub(crate) async fn upload_staged_game_package( - client: &reqwest::Client, - request: GamePackageUploadRequest<'_>, - mut on_progress: impl FnMut(u64, u64), -) -> Result { - let total_bytes = fs::metadata(request.staging_path) - .map_err(|error| format!("读取发行包暂存文件失败:{error}"))? - .len(); - if total_bytes == 0 { - return Err("发行包暂存文件为空,请重新导出试玩包".to_string()); - } - let access_token = request.current_access_token()?; - let state = read_upload_state( - client, - request.api_base_url, - request.version_id, - &access_token, - ) - .await?; - if state.declared_package_bytes != 0 && state.declared_package_bytes != total_bytes { - return Err(format!( - "本地发行包与版本声明的体积不一致(本地 {} 字节,声明 {} 字节),请重新导出后再发布", - total_bytes, state.declared_package_bytes - )); - } - let chunk_bytes = if state.chunk_bytes == 0 { - MAX_CHUNK_BYTES - } else { - state.chunk_bytes.min(MAX_CHUNK_BYTES) - }; - let mut received = state.received_bytes.min(total_bytes); - on_progress(received, total_bytes); - let mut file = File::open(request.staging_path) - .map_err(|error| format!("打开发行包暂存文件失败:{error}"))?; - while let Some(plan) = next_chunk_plan(received, total_bytes, chunk_bytes) { - let body = read_chunk(&mut file, plan)?; - let mut attempt = 1_usize; - loop { - let access_token = request.current_access_token()?; - match upload_chunk( - client, - request.api_base_url, - request.version_id, - &access_token, - request.idempotency_key, - plan, - body.clone(), - ) - .await - { - Ok(next_received) => { - received = next_received.min(total_bytes); - on_progress(received, total_bytes); - break; - } - Err(ChunkUploadError::Fatal(error)) => return Err(error), - Err(ChunkUploadError::Retryable(error)) => { - if attempt >= CHUNK_MAX_ATTEMPTS { - return Err(format!("{error}(已尝试 {attempt} 次,可重新发布续传)")); - } - attempt += 1; - tokio::time::sleep(CHUNK_RETRY_DELAY).await; - } - } - } - // 权威偏移可能在重试期间前进(例如响应丢失后服务端已写入),按服务端口径对齐。 - let access_token = request.current_access_token()?; - let authoritative = read_upload_state( - client, - request.api_base_url, - request.version_id, - &access_token, - ) - .await?; - received = authoritative.received_bytes.min(total_bytes); - on_progress(received, total_bytes); - } - let access_token = request.current_access_token()?; - let mut outcome = complete_upload( - client, - request.api_base_url, - request.version_id, - &access_token, - request.idempotency_key, - ) - .await?; - outcome.uploaded_bytes = total_bytes; - Ok(outcome) -} - -/// 进度事件的载荷形状(渲染进程按它显示进度)。 -pub(crate) fn progress_event_payload( - version_id: &str, - received_bytes: u64, - total_bytes: u64, -) -> Value { - json!({ - "versionId": version_id, - "receivedBytes": received_bytes, - "totalBytes": total_bytes, - }) -} - #[cfg(test)] mod tests { use super::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs new file mode 100644 index 000000000..259757a5e --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs @@ -0,0 +1,335 @@ +use super::*; +use crate::platform_session::{ + current_platform_session, validate_platform_session_identity, PlatformSessionIdentity, +}; +use serde_json::json; +use std::{ + io::{Read, Seek, SeekFrom}, + time::Duration, +}; + +pub(crate) const GAME_PACKAGE_UPLOAD_PROGRESS_EVENT: &str = "game-package-upload-progress"; + +const AGC_CLIENT_MARKER_HEADER: &str = "X-Genarrative-Client"; +const AGC_CLIENT_MARKER_VALUE: &str = "agc"; +const UPLOAD_OFFSET_HEADER: &str = "x-genarrative-upload-offset"; +/// 服务端下发的分片大小上限;客户端只按服务端给的值发,超过它必然被拒。 +const MAX_CHUNK_BYTES: u64 = 8 * 1024 * 1024; +const CHUNK_MAX_ATTEMPTS: usize = 4; +const CHUNK_RETRY_DELAY: Duration = Duration::from_millis(500); +const UPLOAD_STAGING_DIR_NAME: &str = "game-package-staging"; + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GamePackageUploadOutcome { + pub(crate) version_id: String, + pub(crate) status: String, + pub(crate) uploaded_bytes: u64, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct PackageUploadState { + #[serde(default)] + received_bytes: u64, + #[serde(default)] + chunk_bytes: u64, + #[serde(default)] + declared_package_bytes: u64, +} + +/// 暂存目录:放在应用数据目录下,不写进项目、也不参与项目快照同步。 +pub(crate) fn game_package_upload_staging_dir(app_data_dir: &Path) -> PathBuf { + app_data_dir.join(UPLOAD_STAGING_DIR_NAME) +} + +fn read_chunk(file: &mut File, plan: ChunkPlan) -> Result, String> { + file.seek(SeekFrom::Start(plan.offset)) + .map_err(|error| format!("读取发行包失败:{error}"))?; + let mut buffer = vec![0_u8; usize::try_from(plan.length).unwrap_or(0)]; + file.read_exact(&mut buffer) + .map_err(|error| format!("读取发行包失败:{error}"))?; + Ok(buffer) +} + +fn platform_request( + client: &reqwest::Client, + access_token: &str, + method: reqwest::Method, + url: &str, +) -> reqwest::RequestBuilder { + client + .request(method, url) + .header(AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_VALUE) + .header( + reqwest::header::AUTHORIZATION, + format!("Bearer {access_token}"), + ) +} + +async fn read_upload_state( + client: &reqwest::Client, + base_url: &str, + version_id: &str, + access_token: &str, +) -> Result { + let response = platform_request( + client, + access_token, + reqwest::Method::GET, + &upload_url(base_url, version_id, "/upload-state"), + ) + .send() + .await + .map_err(|error| format!("无法连接登录服务,请确认配套后端或 API 代理已启动后重试:{error}"))?; + let status = response.status().as_u16(); + let body = response + .text() + .await + .map_err(|error| format!("读取上传状态失败:{error}"))?; + if status >= 400 { + let (_, message) = parse_server_error(status, &body); + return Err(message.unwrap_or_else(|| format!("读取上传状态失败(HTTP {status})"))); + } + let parsed: Value = serde_json::from_str(&body) + .map_err(|error| format!("上传状态响应不是合法 JSON:{error}"))?; + let payload = parsed.get("data").unwrap_or(&parsed); + serde_json::from_value(payload.clone()) + .map_err(|error| format!("上传状态响应缺少字段:{error}")) +} + +/// 上传一个分片;返回服务端确认后的已收字节。 +async fn upload_chunk( + client: &reqwest::Client, + base_url: &str, + version_id: &str, + access_token: &str, + idempotency_key: &str, + plan: ChunkPlan, + body: Vec, +) -> Result { + let response = platform_request( + client, + access_token, + reqwest::Method::PUT, + &upload_url(base_url, version_id, "/chunk"), + ) + .header(reqwest::header::CONTENT_TYPE, "application/octet-stream") + .header(UPLOAD_OFFSET_HEADER, plan.offset.to_string()) + .header("Idempotency-Key", format!("{idempotency_key}:chunk")) + .body(body) + .send() + .await + .map_err(|error| ChunkUploadError::Retryable(format!("发行包分片发送失败:{error}")))?; + let status = response.status().as_u16(); + let text = response + .text() + .await + .map_err(|error| ChunkUploadError::Retryable(format!("读取分片响应失败:{error}")))?; + if status == 409 { + let (code, message) = parse_server_error(status, &text); + if code.as_deref() == Some("PACKAGE_UPLOAD_OFFSET_MISMATCH") { + let received = parse_received_bytes(&text).ok_or_else(|| { + ChunkUploadError::Fatal("分片偏移不一致,但服务端未返回权威偏移".to_string()) + })?; + return Ok(received); + } + return Err(ChunkUploadError::Fatal( + message.unwrap_or_else(|| "发行包分片被拒绝".to_string()), + )); + } + if status >= 500 || status == 408 || status == 429 { + let (_, message) = parse_server_error(status, &text); + return Err(ChunkUploadError::Retryable( + message.unwrap_or_else(|| format!("发行包分片上传失败(HTTP {status})")), + )); + } + if status >= 400 { + let (_, message) = parse_server_error(status, &text); + return Err(ChunkUploadError::Fatal( + message.unwrap_or_else(|| format!("发行包分片被拒绝(HTTP {status})")), + )); + } + let parsed: Value = serde_json::from_str(&text) + .map_err(|error| ChunkUploadError::Retryable(format!("分片响应不是合法 JSON:{error}")))?; + let payload = parsed.get("data").unwrap_or(&parsed); + payload + .get("receivedBytes") + .and_then(Value::as_u64) + .ok_or_else(|| ChunkUploadError::Fatal("分片响应缺少 receivedBytes".to_string())) +} + +enum ChunkUploadError { + Retryable(String), + Fatal(String), +} + +async fn complete_upload( + client: &reqwest::Client, + base_url: &str, + version_id: &str, + access_token: &str, + idempotency_key: &str, +) -> Result { + let response = platform_request( + client, + access_token, + reqwest::Method::POST, + &upload_url(base_url, version_id, "/complete"), + ) + .header("Idempotency-Key", format!("{idempotency_key}:complete")) + .send() + .await + .map_err(|error| format!("完成发行包上传失败:{error}"))?; + let status = response.status().as_u16(); + let text = response + .text() + .await + .map_err(|error| format!("读取完成响应失败:{error}"))?; + if status >= 400 { + let (_, message) = parse_server_error(status, &text); + return Err(message.unwrap_or_else(|| format!("完成发行包上传失败(HTTP {status})"))); + } + let parsed: Value = + serde_json::from_str(&text).map_err(|error| format!("完成响应不是合法 JSON:{error}"))?; + let payload = parsed.get("data").unwrap_or(&parsed); + Ok(GamePackageUploadOutcome { + version_id: payload + .get("versionId") + .and_then(Value::as_str) + .unwrap_or(version_id) + .to_string(), + status: payload + .get("status") + .and_then(Value::as_str) + .unwrap_or("uploaded") + .to_string(), + uploaded_bytes: 0, + }) +} + +pub(crate) struct GamePackageUploadRequest<'a> { + pub(crate) staging_path: &'a Path, + pub(crate) version_id: &'a str, + pub(crate) api_base_url: &'a str, + pub(crate) access_token: &'a str, + pub(crate) idempotency_key: &'a str, + pub(crate) session_identity: Option<&'a PlatformSessionIdentity>, +} + +impl<'a> GamePackageUploadRequest<'a> { + fn current_access_token(&self) -> Result { + let Some(expected) = self.session_identity else { + return Ok(self.access_token.to_string()); + }; + let session = current_platform_session().ok_or_else(|| { + "authentication-required: 陶泥儿登录态缺失,请重新登录后重试".to_string() + })?; + validate_platform_session_identity(expected)?; + Ok(session.access_token) + } +} + +/// 分片续传主循环:权威偏移来自服务端,失败按可重试分类退避,偏移不符立即按权威偏移继续。 +pub(crate) async fn upload_staged_game_package( + client: &reqwest::Client, + request: GamePackageUploadRequest<'_>, + mut on_progress: impl FnMut(u64, u64), +) -> Result { + let total_bytes = fs::metadata(request.staging_path) + .map_err(|error| format!("读取发行包暂存文件失败:{error}"))? + .len(); + if total_bytes == 0 { + return Err("发行包暂存文件为空,请重新导出试玩包".to_string()); + } + let access_token = request.current_access_token()?; + let state = read_upload_state( + client, + request.api_base_url, + request.version_id, + &access_token, + ) + .await?; + if state.declared_package_bytes != 0 && state.declared_package_bytes != total_bytes { + return Err(format!( + "本地发行包与版本声明的体积不一致(本地 {} 字节,声明 {} 字节),请重新导出后再发布", + total_bytes, state.declared_package_bytes + )); + } + let chunk_bytes = if state.chunk_bytes == 0 { + MAX_CHUNK_BYTES + } else { + state.chunk_bytes.min(MAX_CHUNK_BYTES) + }; + let mut received = state.received_bytes.min(total_bytes); + on_progress(received, total_bytes); + let mut file = File::open(request.staging_path) + .map_err(|error| format!("打开发行包暂存文件失败:{error}"))?; + while let Some(plan) = next_chunk_plan(received, total_bytes, chunk_bytes) { + let body = read_chunk(&mut file, plan)?; + let mut attempt = 1_usize; + loop { + let access_token = request.current_access_token()?; + match upload_chunk( + client, + request.api_base_url, + request.version_id, + &access_token, + request.idempotency_key, + plan, + body.clone(), + ) + .await + { + Ok(next_received) => { + received = next_received.min(total_bytes); + on_progress(received, total_bytes); + break; + } + Err(ChunkUploadError::Fatal(error)) => return Err(error), + Err(ChunkUploadError::Retryable(error)) => { + if attempt >= CHUNK_MAX_ATTEMPTS { + return Err(format!("{error}(已尝试 {attempt} 次,可重新发布续传)")); + } + attempt += 1; + tokio::time::sleep(CHUNK_RETRY_DELAY).await; + } + } + } + // 权威偏移可能在重试期间前进(例如响应丢失后服务端已写入),按服务端口径对齐。 + let access_token = request.current_access_token()?; + let authoritative = read_upload_state( + client, + request.api_base_url, + request.version_id, + &access_token, + ) + .await?; + received = authoritative.received_bytes.min(total_bytes); + on_progress(received, total_bytes); + } + let access_token = request.current_access_token()?; + let mut outcome = complete_upload( + client, + request.api_base_url, + request.version_id, + &access_token, + request.idempotency_key, + ) + .await?; + outcome.uploaded_bytes = total_bytes; + Ok(outcome) +} + +/// 进度事件的载荷形状(渲染进程按它显示进度)。 +pub(crate) fn progress_event_payload( + version_id: &str, + received_bytes: u64, + total_bytes: u64, +) -> Value { + json!({ + "versionId": version_id, + "receivedBytes": received_bytes, + "totalBytes": total_bytes, + }) +} 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 31d9323e1..df1ffc84e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -34,7 +34,6 @@ use platform_llm::{ use reqwest::header; use serde::{Deserialize, Serialize}; use sha2::Digest; -use shared_contracts::error_reports::ErrorReportLogInput; use shared_contracts::game_creation_app::{ game_creation_app_asset_category_for_kind, game_creation_app_asset_category_from_str, game_creation_app_asset_effective_category, new_game_creation_app_manifest, @@ -55,17 +54,9 @@ use shared_contracts::game_creation_app::{ }; // `Emitter` 同时被 `use super::*` 的子模块依赖(通知、Agent 事件等都从 crate 根取该 trait), // 不要因为根模块自身不再直接 `.emit(..)` 就删掉它。 -use environment_check::preflight_web_game_creation; -use tauri::{Emitter, Manager}; -use tauri_plugin_dialog::DialogExt; -use tauri_plugin_opener::OpenerExt; - -/// 更新完成后的进程重启:Windows 由 NSIS 安装程序代为重启,macOS / Linux 由客户端在安装后调用。 -#[tauri::command] -fn restart_agc_app(app: tauri::AppHandle) { - analytics::gui::mark_restart(); - app.restart(); -} +use tauri::Emitter; +#[cfg(not(test))] +use tauri::Manager; /// Rust 侧普通文本日志:保留 stderr 输出,同时将同一行持久化到 AppData。 /// 诊断包只在用户主动提交时读取这些 raw log;结构化错误事件仍只留在内存。 @@ -77,36 +68,6 @@ macro_rules! app_log { }}; } -/// 把 `shared-contracts` 的「非 canonical 资源 kind」留痕接到壳层日志上。 -/// -/// 解析边界(含 manifest 反序列化、画板 assetKind、平台 assetKind)认不出 canonical 值时, -/// 会把**原始输入串**与调用上下文交回来;这里落到 `app_log!` → AppData 日志里, -/// 以便回查非 canonical kind 的写入边界。kind 本身只接受严格值。 -/// -/// 按 `(原始串, 上下文)` 去重:manifest 读取极频繁,每次都落一行会把其它诊断刷掉; -/// 去重后"有哪些非 canonical 值、分别从哪个边界进来"仍然完整。需要具体资产身份时看 -/// `log_non_canonical_manifest_asset_kinds`(按资产去重,带 assetId / localPath)。 -fn register_non_canonical_asset_kind_reporter() { - static REPORTED: std::sync::OnceLock>> = - std::sync::OnceLock::new(); - shared_contracts::game_creation_app::set_non_canonical_asset_kind_reporter( - |raw_kind: &str, context: &str| { - let key = format!("{context}\u{1}{raw_kind}"); - let first_seen = REPORTED - .get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new())) - .lock() - .map(|mut reported| reported.insert(key)) - .unwrap_or(true); - if !first_seen { - return; - } - app_log!( - "GameCreationApp 资源 kind 不是 canonical 值,已按严格口径收口为 unknown:rawKind={raw_kind} context={context}" - ); - }, - ); -} - // 调试落盘模块(保存 LLM 原始输出 / 失败输入,排查截断、空返回等)放在 debug_drafts.rs。 // 用 #[cfg] 编译期门控:仅开发(debug)且非测试构建编入;生产 release 与 cargo test 下整体剔除。 include!(concat!(env!("OUT_DIR"), "/agent_runtime_prompt_bundle.rs")); @@ -135,6 +96,11 @@ mod context_menu; #[cfg(all(debug_assertions, not(test)))] mod debug; mod delegation; +// 桌面与独立进程启动接线不参与单元测试;共享实现仍在各自模块编译。 +#[cfg(not(test))] +mod desktop; +#[cfg(not(test))] +pub(crate) use desktop::game_creator_product_name; mod editor_adapter; mod editor_adapters; mod environment_check; @@ -169,16 +135,12 @@ mod user_input; mod windows; mod workspace_preferences; -use account_api::*; -use agent::design_tools::*; use agent::*; use agent_native_tools::*; -use asset_generation_tasks::*; use assets::*; use auth_session::*; use browser::*; use cli::*; -use client_extensions::*; use collaboration::*; use command_exec::*; use command_output::*; @@ -187,8 +149,6 @@ use commands::*; use config::*; use context_compaction::*; use delegation::*; -use error_report::*; -use game_distribution_publish::*; use git_inspect::*; use goal::*; use image_inspect::*; @@ -197,82 +157,17 @@ use llm_catalog::load_game_creator_llm_models; use patchset::*; use platform_asset_upload::upload_platform_media_asset; use platform_session::*; -use plugin_host::{ - call_agc_plugin, list_agc_extensions, list_agc_plugins, read_agc_plugin_panel, - refresh_agc_plugins, reload_agc_plugin, set_agc_plugin_enabled, set_agc_plugin_project_path, - start_agc_plugin, stop_agc_plugin, PluginHost, -}; use preview::*; use process_session::*; use project::*; +#[cfg(not(test))] use project_snapshot::*; use repository_context::*; use resource_inspect::*; use resource_preview_scheduler::*; use runner::*; -use template_library::*; use user_input::*; use windows::*; -use workspace_preferences::*; -#[tauri::command] -async fn recognize_ui( - project_path: String, - state: ui_editor::state::State, -) -> Result { - ui_editor::commands::recognize_ui_impl(project_path, state).await -} - -#[tauri::command] -async fn separate_ui( - project_path: String, - asset_id: String, - state: ui_editor::state::State, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - ui_editor::commands::separate_ui_impl(project_path, asset_id, state).await -} - -#[tauri::command] -fn inspect_separation_recovery( - project_path: String, - asset_id: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "asset.list")?; - ui_editor::commands::separation::inspect_separation_recovery(root, &asset_id) -} - -#[tauri::command] -fn finalize_separation(project_path: String, asset_id: String) -> Result<(), String> { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - ui_editor::commands::separation::finalize_separation(root, &asset_id) -} - -#[tauri::command] -fn discard_separation_recovery(project_path: String, asset_id: String) -> Result<(), String> { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - ui_editor::commands::separation::discard_separation_recovery(root, &asset_id) -} - -#[tauri::command] -fn load_ui_design_state( - input: ui_editor::persistence::LoadUiDesignStateInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.list")?; - ui_editor::persistence::load_ui_design_state_at(input) -} - -#[tauri::command] -fn save_ui_design_state( - input: ui_editor::persistence::SaveUiDesignStateInput, -) -> Result { - save_ui_design_state_with_capture(input, analytics::gui::capture_writer_context()) -} - fn save_ui_design_state_with_capture( input: ui_editor::persistence::SaveUiDesignStateInput, capture: Option<( @@ -304,22 +199,6 @@ fn save_ui_design_state_with_capture( Ok(result) } -#[tauri::command] -fn generate_ui_design_code( - input: ui_editor::persistence::GenerateUiDesignCodeInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "file.write")?; - ui_editor::persistence::generate_ui_design_code_at(input) -} - -#[tauri::command] -fn create_ui_design_doc_from_images( - input: ui_editor::agent_tools::CreateUiDesignDocFromImagesInput, -) -> Result { - ui_editor::agent_tools::create_ui_design_doc_from_images(input) -} - #[derive(Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct InitLocalProjectResult { @@ -388,23 +267,6 @@ struct GameCreatorChatAgentReply { reply_text: String, } -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -struct GameCreatorRoleAgentChatStreamEvent { - project_path: String, - agent_id: String, - run_id: String, - status: String, - delta_text: String, - accumulated_text: String, - finish_reason: Option, - session_id: Option, - runtime_status: Option, - runtime_phase: Option, - runtime_summary: Option, - runtime_state: Option, -} - #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct AgentRuntimeState { @@ -1178,12 +1040,6 @@ struct GeneratedPlatformArtAsset { slice_warning: Option, } -#[derive(Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -struct OpenCanvasProjectResult { - url: String, -} - #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct CanvasExportMetadata { @@ -1720,47 +1576,6 @@ const DIAGNOSTIC_LOG_MAX_BYTES: u64 = 256 * 1024; static DIAGNOSTIC_LOG_LOCK: OnceLock> = OnceLock::new(); static STARTUP_ERROR_DIALOG_SHOWN: AtomicBool = AtomicBool::new(false); -#[tauri::command] -fn append_application_log(level: String, source: String, message: String) -> Result<(), String> { - let config_dir = game_creator_runtime_config_dir() - .ok_or_else(|| "客户端 AppData 配置目录未初始化".to_string())?; - let level = sanitize_diagnostic_message(&level, Some(&config_dir)); - let source = sanitize_diagnostic_message(&source, Some(&config_dir)); - let message = sanitize_diagnostic_message(&message, Some(&config_dir)); - let line = format!("WEBVIEW {level} {source}: {message}"); - append_application_log_line(&line).map_err(|error| error.to_string()) -} - -#[tauri::command] -fn read_diagnostic_logs() -> Result, String> { - let Some(config_dir) = game_creator_runtime_config_dir() else { - return Ok(Vec::new()); - }; - let _guard = DIAGNOSTIC_LOG_LOCK - .get_or_init(|| Mutex::new(())) - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let directory = config_dir.join("diagnostics"); - let mut files = Vec::new(); - for name in ["application.log", "application.previous.log", "startup.log"] { - let path = directory.join(name); - let Ok(metadata) = fs::symlink_metadata(&path) else { - continue; - }; - if metadata.file_type().is_symlink() || !metadata.is_file() { - continue; - } - let Ok(content) = fs::read_to_string(&path) else { - continue; - }; - files.push(ErrorReportLogInput { - name: name.to_string(), - content, - }); - } - Ok(files) -} - fn diagnostic_timestamp() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -2018,21 +1833,6 @@ fn show_startup_error_dialog(log_path: Option<&Path>) { } } -/// 客户端产品名跟随构建期渠道身份:dev 渠道是「陶泥儿开发版」,release 渠道复用 -/// 正式产品名「陶泥儿」,自定义渠道带渠道后缀。非默认渠道通过 identifier 隔离 -/// 本地数据与运行身份;默认渠道结果不变。 -pub(crate) fn game_creator_product_name(app: &tauri::AppHandle) -> String { - app.package_info().name.clone() -} - -/// 配置目录就绪前的启动日志路径:优先用已经生效的配置目录(例如 `--config-dir` -/// 已经设置好的目录),否则退到平台配置根。两者都不可用时返回 `None`,此时 -/// `StartupLogSlot::fail` 仍然必须给出用户可见提示。 -fn early_startup_log_path(identifier: &str) -> Option { - let configured_dir = game_creator_runtime_config_dir(); - resolve_early_startup_log_path(configured_dir.as_deref(), identifier) -} - fn resolve_early_startup_log_path( configured_dir: Option<&Path>, identifier: &str, @@ -2133,19 +1933,6 @@ enum GameCreatorGuiRunnerShutdownFailure { Other, } -impl GameCreatorGuiRunnerShutdownFailure { - fn code(self) -> &'static str { - match self { - Self::EndpointUnavailable => "endpoint_unavailable", - Self::RunnerUnresponsive => "runner_unresponsive", - Self::ProcessIdentity => "process_identity", - Self::PlatformUnsupported => "platform_unsupported", - Self::LockTimeout => "lock_timeout", - Self::Other => "other", - } - } -} - fn classify_game_creator_gui_runner_shutdown_error( error: &str, ) -> GameCreatorGuiRunnerShutdownFailure { @@ -2188,39 +1975,6 @@ where } } -fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) { - if matches!(event, tauri::RunEvent::Exit) { - // 窗口关闭时已经按项目触发过一次快照同步;退出路径只负责在有界预算内 - // 等在途同步收尾,不重复发起(此刻窗口已销毁,重新枚举项目只会是空集)。 - wait_for_project_snapshot_syncs_on_exit(); - // 退出时统一收尾本地预览:进程内监听线程随进程消失,但 `.agent/manifest.json` - // 里的 preview 记录会留在 running 上,下次进项目就照着它渲染打不开的运行界面。 - if let Err(error) = - preview::stop_local_game_preview_on_exit(&game_creator_preview_registry()) - { - app_log!("preview.gui_exit.stop_failed: {error}"); - } - if let Err(error) = agent::shutdown_game_creator_codex_app_servers() { - app_log!("agent.direct_codex.gui_exit.shutdown_failed: {error}"); - } - } - match resolve_game_creator_gui_runner_shutdown( - event, - shutdown_external_agent_runner_for_gui_exit, - ) { - GameCreatorGuiRunnerShutdownOutcome::NotRequested => {} - GameCreatorGuiRunnerShutdownOutcome::Requested => { - app_log!("agent.runner.gui_exit.shutdown_requested") - } - GameCreatorGuiRunnerShutdownOutcome::Retained => { - app_log!("agent.runner.gui_exit.retained_for_other_windows") - } - GameCreatorGuiRunnerShutdownOutcome::Failed(failure) => { - app_log!("agent.runner.gui_exit.shutdown_failed.{}", failure.code()) - } - } -} - /// Tauri 的全局异步 runtime 默认由 `TokioRuntime::new()` 建出来,worker 线程吃 /// tokio 默认栈。Runtime 的 agent turn 调用链深到本仓库另一处专门给自己的后台 /// 线程配了 AGENT_RUNTIME_BACKGROUND_WORKER_STACK_BYTES;但凡经 async_runtime::spawn @@ -2238,65 +1992,6 @@ fn build_agent_runtime_async_runtime() -> Result runtime, - Err(error) => { - app_log!("agent.runner.failed: {error}"); - std::process::exit(1); - } - }; - tauri::async_runtime::set(runtime.handle().clone()); - // 进程生命周期内必须持有,否则 handle 立即失效。 - Box::leak(Box::new(runtime)); -} - -#[cfg(windows)] -fn run_windows_acl_repair_if_requested(args: &[String]) -> Option { - let [command, path, target_user_sid_flag, target_user_sid, authorization_flag, nonce, scope_flag, scope_value] = - args - else { - if args.first().map(String::as_str) == Some("--repair-private-acl") { - eprintln!( - "用法:--repair-private-acl <路径> --target-user-sid --authorization <票据> --scope " - ); - return Some(1); - } - return None; - }; - if command != "--repair-private-acl" - || target_user_sid_flag != "--target-user-sid" - || authorization_flag != "--authorization" - || scope_flag != "--scope" - { - eprintln!( - "用法:--repair-private-acl <路径> --target-user-sid --authorization <票据> --scope " - ); - return Some(1); - } - let path = std::path::PathBuf::from(path); - let scope = match config::parse_windows_acl_repair_scope(scope_value) { - Ok(scope) => scope, - Err(error) => { - eprintln!("{error}"); - return Some(1); - } - }; - let result = - config::consume_windows_acl_repair_authorization(&path, target_user_sid, nonce, scope) - .and_then(|()| { - config::repair_game_creator_private_acl_for_user_sid(&path, target_user_sid) - }); - match result { - Ok(()) => Some(0), - Err(error) => { - eprintln!("AGC ACL 提权修复失败:{error}"); - Some(1) - } - } -} - #[cfg(test)] mod async_runtime_stack_tests { /// 每帧固定占 16 KiB,用 black_box 挡住优化,让递归深度直接换算成栈用量。 @@ -2329,513 +2024,7 @@ mod async_runtime_stack_tests { #[cfg(not(test))] fn main() { - install_agent_runtime_async_runtime_with_deep_stack(); - register_non_canonical_asset_kind_reporter(); - let mut args = std::env::args().skip(1).collect::>(); - if let Some(exit_code) = run_direct_tools_mcp_if_requested(&args) { - std::process::exit(exit_code); - } - #[cfg(windows)] - if let Some(exit_code) = run_windows_acl_repair_if_requested(&args) { - std::process::exit(exit_code); - } - #[cfg(target_os = "linux")] - if command_sandbox_trampoline::is_trampoline_mode(&args) { - match command_sandbox_trampoline::run_trampoline() { - Ok(exit_code) => std::process::exit(exit_code), - Err(_) => std::process::exit(125), - } - } - #[cfg(target_os = "linux")] - if command_sandbox_trampoline::is_process_session_trampoline_mode(&args) { - match command_sandbox_trampoline::run_process_session_trampoline() { - Ok(exit_code) => std::process::exit(exit_code), - Err(_) => std::process::exit(125), - } - } - #[cfg(target_os = "linux")] - if is_process_session_child_mode(&args) { - match run_process_session_child(&args) { - Ok(exit_code) => std::process::exit(exit_code), - Err(_) => std::process::exit(125), - } - } - let runtime_config_dir = match take_cli_runtime_config_dir(&mut args) { - Ok(config_dir) => config_dir, - Err(error) => { - app_log!("{error}"); - std::process::exit(1); - } - }; - if args.first().map(String::as_str) == Some("--agent-runner") { - let gui_owner_required = match args.as_slice() { - [_] => false, - [_, option] if option == "--gui-owner-required" => true, - _ => { - app_log!( - "用法:--agent-runner [--gui-owner-required] --config-dir " - ); - std::process::exit(1); - } - }; - let Some(config_dir) = runtime_config_dir else { - app_log!("Agent Runner 必须显式传入 --config-dir "); - std::process::exit(1); - }; - if let Err(error) = load_platform_session_fixture_from_env(&config_dir) { - app_log!("agent.runner.failed: {error}"); - std::process::exit(1); - } - set_game_creator_runtime_config_dir(config_dir.clone()); - if let Err(error) = run_external_agent_runner_server(config_dir, gui_owner_required) { - app_log!("agent.runner.failed: {error}"); - std::process::exit(1); - } - return; - } - match parse_cli_command(&args) { - Ok(Some(mut command)) => { - let config_dir = - match prepare_cli_command_paths(&mut command, runtime_config_dir.as_deref()) { - Ok(config_dir) => config_dir, - Err(error) => { - app_log!("agent.runner.failed: {error}"); - std::process::exit(1); - } - }; - if let Some(config_dir) = config_dir { - if let Err(error) = load_platform_session_fixture_from_env(&config_dir) { - app_log!("agent.runner.failed: {error}"); - std::process::exit(1); - } - if command.requires_external_agent_runner() || command.is_read_only_status() { - let configured = if command.is_read_only_status() { - configure_external_agent_runner_read_only(&config_dir) - } else { - configure_external_agent_runner(&config_dir) - }; - if let Err(error) = configured { - app_log!("agent.runner.failed: {error}"); - std::process::exit(1); - } - } - set_game_creator_runtime_config_dir(config_dir); - } - if command.requires_started_external_agent_runner() { - if let Err(error) = ensure_external_agent_runner_started() { - app_log!("agent.runner.failed: {error}"); - std::process::exit(1); - } - } - if let Err(error) = run_cli_command(command) { - app_log!("agent.run.failed: {error}"); - std::process::exit(1); - } - return; - } - Ok(None) => {} - Err(error) => { - app_log!("{error}"); - std::process::exit(1); - } - } - - if let Some(config_dir) = runtime_config_dir { - set_game_creator_runtime_config_dir(config_dir); - } - - let tauri_context = tauri::generate_context!(); - // 配置目录确定之前先推导启动日志路径:优先用已经生效的配置目录(例如 - // `--config-dir`),否则退到平台配置根,保证 - // `configure_game_creator_runtime_config_dir` 自身失败也有落点。 - let startup_log = Arc::new(StartupLogSlot::new(early_startup_log_path( - tauri_context.config().identifier.as_str(), - ))); - let setup_log = Arc::clone(&startup_log); - let app = tauri::Builder::default() - .plugin(tauri_plugin_opener::init()) - .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_clipboard_manager::init()) - .plugin(tauri_plugin_updater::Builder::new().build()) - .plugin(context_menu::init()) - .on_page_load(|webview, payload| { - if matches!(payload.event(), tauri::webview::PageLoadEvent::Started) { - analytics::gui::page_loading(webview.window().label()); - } - }) - .on_window_event(|window, event| { - analytics::gui::window_event(window, event); - handle_project_snapshot_window_event(window, event); - }) - .manage(game_creator_preview_registry()) - .manage(ProjectResourcePreviewReadManager::default()) - .manage(PluginHost::default()) - .setup(move |app| { - error_report::initialize_notifications(app.handle()); - setup_log.append("startup.setup.begin"); - setup_log.append("startup.appdata.configure.begin"); - configure_game_creator_runtime_config_dir(app.handle()).inspect_err(|error| { - // 日志路径未知也必须记录并提示:不能因为拿不到路径就静默失败。 - let config_dir = game_creator_runtime_config_dir(); - let details = - sanitize_diagnostic_message(&error.to_string(), config_dir.as_deref()); - setup_log.fail(&format!( - "startup.appdata.configure.failed details={details}" - )); - })?; - if let Some(directory) = game_creator_runtime_config_dir() { - setup_log.set(directory.join("diagnostics/startup.log")); - } - // 跨会话清扫陈旧的无头浏览器(上次异常退出/被杀留下的 ga-browser-*)。 - // 后台执行,不阻塞启动;杀树前按进程身份与可信浏览器路径双重校验。 - tauri::async_runtime::spawn(async move { - let notes = - tokio::task::spawn_blocking(crate::browser::sweep_stale_browser_processes) - .await - .unwrap_or_default(); - if !notes.is_empty() { - app_log!("startup.browser-sweep: {}", notes.join("; ")); - } - }); - setup_log.append("startup.appdata.configure.complete"); - let config_dir = game_creator_runtime_config_dir().ok_or_else(|| { - let error = std::io::Error::new( - std::io::ErrorKind::NotFound, - "客户端 AppData 配置目录未初始化", - ); - setup_log.fail("startup.appdata.resolve.failed details=config-dir-uninitialized"); - error - })?; - // 主窗口标题与产品名保持一致:配置里的标题来自基线配置,渠道后缀只 - // 由构建期身份决定,因此必须在这里按产品名覆盖。 - match app.get_webview_window("client") { - Some(window) => { - if let Err(error) = window.set_title(&game_creator_product_name(app.handle())) { - app_log!("startup.window-title.failed: {error}"); - } - } - None => app_log!("startup.window-title.failed: 缺少 client 主窗口"), - } - spawn_project_snapshot_scheduler(app.handle().clone()); - if let Err(error) = builtin_plugins::initialize(&config_dir) { - app_log!("startup.builtin-plugins.initialize.failed: {error}"); - } - if let Err(error) = app.state::().initialize(&config_dir) { - app_log!("startup.plugin-host.initialize.failed: {error}"); - } - if let Some(workspace) = plugin_host::resolve_plugin_workspace(app.handle()) { - if let Err(error) = app.state::().set_plugin_workspace(workspace) { - app_log!("startup.plugin-host.workspace.failed: {error}"); - } - } - if let Err(error) = editor_adapters::register_linked_editor_adapters( - app.handle(), - app.state::().inner(), - ) { - app_log!("startup.plugin-host.adapter.failed: {error}"); - } - load_platform_session_fixture_from_env(&config_dir).map_err(|error| { - std::io::Error::new( - std::io::ErrorKind::PermissionDenied, - format!("加载平台登录态测试 fixture 失败:{error}"), - ) - })?; - setup_log.append("startup.runner.configure.begin"); - configure_external_agent_runner(&config_dir) - .inspect_err(|error| { - let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); - setup_log.fail(&format!( - "startup.runner.configure.failed details={details}" - )); - }) - .map_err(|error| { - std::io::Error::new( - std::io::ErrorKind::Other, - format!("配置 Agent Runner 失败:{error}"), - ) - })?; - setup_log.append("startup.runner.configure.complete"); - hold_external_agent_runner_gui_participant_lock(&config_dir) - .inspect_err(|error| { - let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); - setup_log.fail(&format!( - "startup.runner.participant-lock.failed details={details}" - )); - }) - .map_err(|error| { - std::io::Error::new( - std::io::ErrorKind::AlreadyExists, - format!("建立 AGC 界面参与锁失败:{error}"), - ) - })?; - setup_log.append("startup.runner.start.begin"); - set_game_creator_agent_runtime_update_app_handle(app.handle().clone()); - set_thread_manager_app_handle(app.handle().clone()); - platform_maintenance::set_platform_maintenance_app_handle(app.handle().clone()); - auth_session::initialize_auth_session(app.handle()); - set_asset_generation_task_app_handle(app.handle().clone()); - let manifest_event_sink = - start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?; - attach_external_agent_runner_gui_owner(&manifest_event_sink) - .inspect_err(|error| { - let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); - setup_log.fail(&format!( - "startup.runner.attach-owner.failed details={details}" - )); - }) - .map_err(|error| { - std::io::Error::new( - std::io::ErrorKind::Other, - format!("绑定 Agent Runner GUI owner 失败:{error}"), - ) - })?; - setup_log.append("startup.runner.start.complete"); - setup_log.append("startup.setup.complete"); - Ok(()) - }) - .invoke_handler(tauri::generate_handler![ - analytics::gui::capture_analytics_context, - analytics::gui::record_analytics_project_open, - analytics::gui::record_analytics_project_leave, - analytics::gui::record_analytics_ui_save, - start_game_creator_external_mcp, - stop_game_creator_external_mcp, - create_automatic_local_game_project, - create_automatic_local_game_project_from_template, - init_local_game_project, - fetch_game_template_library, - get_game_template_library_access, - download_game_template, - import_local_godot_project, - import_local_cocos_project, - import_local_unity_project, - is_local_project_directory_non_empty, - inspect_local_project_directory, - pick_local_project_directory, - rename_local_game_project, - suggest_automatic_project_name, - polish_local_project_prompt, - pick_client_extension_file, - pick_client_extension_directory, - list_client_extensions, - list_agc_skill_catalog, - import_client_extension, - set_client_extension_enabled, - rename_client_extension, - remove_client_extension, - list_agc_plugins, - list_agc_extensions, - refresh_agc_plugins, - start_agc_plugin, - stop_agc_plugin, - reload_agc_plugin, - set_agc_plugin_enabled, - call_agc_plugin, - read_agc_plugin_panel, - set_agc_plugin_project_path, - open_local_project_directory, - control_agent_run, - generate_local_game_draft, - chat_with_game_creator_agent, - chat_with_game_creator_role_agent, - chat_with_game_creator_role_agent_stream, - enqueue_direct_codex_turn, - remove_direct_project_pending_turn, - preflight_web_game_creation, - cancel_direct_codex_turn, - select_game_creator_reasoning_effort, - hydrate_design_agent_session, - reset_design_agent_session, - get_design_agent_runtime_mode, - is_design_agent_debug_enabled, - set_design_agent_runtime_mode, - debug_fast_forward_design_session, - continue_design_agent_session, - decide_design_phase, - import_design_workspace_file, - list_design_workspace, - read_design_workspace_file, - start_game_creator_supervisor_runtime_task, - compact_game_creator_agent_runtime_context, - read_game_creator_agent_goal, - start_game_creator_agent_goal, - edit_game_creator_agent_goal, - pause_game_creator_agent_goal, - resume_game_creator_agent_goal, - clear_game_creator_agent_goal, - cancel_game_creator_agent_runtime_task, - retry_game_creator_agent_runtime_task, - confirm_retry_game_creator_agent_runtime_task, - confirm_game_creator_agent_runtime_task, - reject_game_creator_agent_runtime_task, - answer_game_creator_agent_runtime_user_input, - read_game_creator_agent_runtime, - read_game_creator_agent_runtimes, - resume_game_creator_agent_runtime_tasks, - confirm_resume_game_creator_agent_runtime_tasks, - schedule_game_creator_agent_ready_tasks, - check_game_creator_llm_config, - read_workspace_preferences, - set_project_creation_directory, - remember_recent_workspace, - remove_recent_workspace, - migrate_workspace_preferences, - set_chat_prompt_polish_reminder_disabled, - set_client_server_selection, - read_profile_recharge_center, - read_profile_wallet_ledger, - create_profile_recharge_order, - confirm_wechat_profile_recharge_order, - redeem_profile_reward_code, - read_client_auth_state, - refresh_client_auth_session, - send_client_phone_login_code, - login_client_with_password, - login_client_with_phone_code, - logout_client_session, - read_game_publish_availability, - suggest_game_distribution_publish_metadata, - read_game_cover_generation_price, - generate_game_distribution_cover, - publish_local_project_game, - read_game_creator_app_config, - clear_game_creator_acl_elevation_denials, - write_game_creator_app_config, - select_game_creator_model, - load_game_creator_llm_models, - discover_game_creator_llm_models, - read_editor_asset_library, - read_editor_asset_preview, - upload_platform_media_asset, - upload_local_asset, - register_local_asset, - create_ui_design_doc_from_images, - update_local_project_resource_classification, - add_local_project_resource_tags, - derive_local_project_resource, - list_pending_local_project_resource_edits, - resume_local_project_resource_edit, - request_local_project_resource_edit_service_identity_confirmation, - confirm_local_project_resource_edit_service_identity, - archive_failed_local_project_resource_edit, - normalize_local_project_raster_resource, - import_canvas_asset, - import_canvas_export, - sync_canvas_project_assets, - import_ui_editor_assets, - prepare_ui_editor_project_fonts, - read_ui_editor_font_bytes, - check_ui_editor_font_glyph_coverage, - recognize_ui, - separate_ui, - inspect_separation_recovery, - finalize_separation, - discard_separation_recovery, - load_ui_design_state, - save_ui_design_state, - generate_ui_design_code, - generate_platform_art_asset, - generate_local_project_asset, - start_local_project_asset_generation, - list_local_project_asset_generations, - open_canvas_project, - run_limited_local_command, - append_local_permission_log, - list_local_project_files, - import_local_project_image_assets, - read_local_project_file, - read_local_project_image_preview, - save_local_project_asset_file, - read_local_project_text_preview, - read_local_project_structured_preview, - read_local_project_media_preview, - cancel_local_project_resource_preview_scope, - read_local_game_memory, - read_local_agent_memory, - write_local_agent_memory, - write_local_game_memory, - list_game_creator_agent_sessions, - create_game_creator_agent_session, - fork_game_creator_agent_session, - set_active_game_creator_agent_session, - archive_game_creator_agent_session, - read_local_conversation, - read_direct_project_conversation, - list_game_creator_direct_active_turns, - subscribe_direct_project_thread, - consume_direct_project_thread, - unsubscribe_direct_project_thread, - read_direct_project_history_slice, - append_local_conversation_message, - append_direct_project_conversation_message, - build_local_project_index, - create_local_project_checkpoint, - export_local_project_package, - prepare_local_project_game_package, - upload_local_project_game_package, - list_local_project_export_packages, - diff_local_project_checkpoint, - restore_local_project_checkpoint, - read_project_permission_policy, - write_project_permission_policy, - open_game_creator_workspace_window, - open_game_creator_launcher_window, - start_local_game_preview, - activate_local_game_preview, - stop_local_game_preview, - stop_local_game_preview_if_matches, - get_local_game_preview_status, - read_local_project_resource_canvas_layout, - read_local_project_resource_graph, - update_local_project_resource_canvas_layout, - delete_local_project_asset, - read_local_project_asset_references, - rename_local_project_asset, - read_local_project_version_resource_replacement_candidates, - replace_local_project_version_resource, - get_local_game_project_revision, - get_local_game_manifest, - restart_agc_app, - append_application_log, - read_diagnostic_logs, - report_client_error, - get_pending_error_reports, - ack_error_reports, - submit_error_report, - sync_local_project_snapshot, - read_local_project_snapshot_state, - set_active_project_snapshot_workspace, - ]) - .build(tauri_context); - let app = match app { - Ok(app) => { - startup_log.append("startup.build.complete"); - app - } - Err(error) => { - if let Some(path) = startup_log.path() { - let details = sanitize_diagnostic_message(&error.to_string(), path.parent()); - startup_log.fail(&format!("startup.build.failed details={details}")); - } - app_log!("failed to build Genarrative AI Game Creator shell: {error}"); - std::process::exit(1); - } - }; - app.run(move |app_handle, event| { - if matches!(event, tauri::RunEvent::Ready) { - if let Some(directory) = game_creator_runtime_config_dir() { - analytics::gui::initialize( - app_handle.clone(), - directory, - app_handle.package_info().version.to_string(), - ); - } - } - if matches!(event, tauri::RunEvent::Exit) { - analytics::gui::exit(); - } - handle_game_creator_gui_run_event(&event); - }); + desktop::run(); } #[cfg(test)] 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..3e7ed9d46 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), ) { 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 48a354445..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::{Emitter, 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>>>>; @@ -39,7 +43,6 @@ const RPC_TIMEOUT: Duration = Duration::from_secs(10); const EDITOR_RPC_TIMEOUT: Duration = Duration::from_secs(90); const MAX_MANIFEST_BYTES: u64 = 1024 * 1024; const MAX_RPC_BYTES: usize = 2 * 1024 * 1024; -pub(crate) const AGC_PLUGIN_STATE_CHANGED_EVENT: &str = "game-creator-plugin-state-changed"; const KNOWN_PERMISSIONS: &[&str] = &[ "events.subscribe", @@ -477,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 { @@ -1214,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 工具 @@ -1903,127 +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() -} - -fn emit_plugin_state_changed(app: &tauri::AppHandle, host: &PluginHost) { - if let Ok(extensions) = host.list_extensions() { - let _ = app.emit(AGC_PLUGIN_STATE_CHANGED_EVENT, 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>, - app: tauri::AppHandle, -) -> Result { - let result = host.start(id.trim()); - if result.is_ok() { - emit_plugin_state_changed(&app, &host); - } - result -} - -#[tauri::command] -pub(crate) fn stop_agc_plugin( - id: String, - host: State<'_, PluginHost>, - app: tauri::AppHandle, -) -> Result { - let result = host.stop(id.trim()); - if result.is_ok() { - emit_plugin_state_changed(&app, &host); - } - result -} - -#[tauri::command] -pub(crate) fn reload_agc_plugin( - id: String, - host: State<'_, PluginHost>, - app: tauri::AppHandle, -) -> Result { - let result = host.reload(id.trim()); - if result.is_ok() { - emit_plugin_state_changed(&app, &host); - } - result -} - -#[tauri::command] -pub(crate) fn set_agc_plugin_enabled( - id: String, - enabled: bool, - host: State<'_, PluginHost>, - app: tauri::AppHandle, -) -> Result, String> { - let result = host.set_enabled(id.trim(), enabled); - if result.is_ok() { - emit_plugin_state_changed(&app, &host); - } - result -} - -#[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 || { - let host = app.state::(); - let result = host.set_active_project(project_path); - if result.is_ok() { - emit_plugin_state_changed(&app, &host); - } - result - }) - .await - .map_err(|_| "切换插件项目上下文任务失败".to_string())? -} - #[cfg(test)] mod tests { use super::*; @@ -2431,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); @@ -2691,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..b1856306d --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/plugin_host/desktop.rs @@ -0,0 +1,159 @@ +use super::*; +use tauri::{Emitter, Manager, State}; + +pub(crate) const AGC_PLUGIN_STATE_CHANGED_EVENT: &str = "game-creator-plugin-state-changed"; + +/// 解析插件工作区目录:环境变量优先,其次随包资源目录 `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>, + app: tauri::AppHandle, +) -> Result { + let result = host.start(id.trim()); + if result.is_ok() { + emit_plugin_state_changed(&app, &host); + } + result +} + +#[tauri::command] +pub(crate) fn stop_agc_plugin( + id: String, + host: State<'_, PluginHost>, + app: tauri::AppHandle, +) -> Result { + let result = host.stop(id.trim()); + if result.is_ok() { + emit_plugin_state_changed(&app, &host); + } + result +} + +#[tauri::command] +pub(crate) fn reload_agc_plugin( + id: String, + host: State<'_, PluginHost>, + app: tauri::AppHandle, +) -> Result { + let result = host.reload(id.trim()); + if result.is_ok() { + emit_plugin_state_changed(&app, &host); + } + result +} + +#[tauri::command] +pub(crate) fn set_agc_plugin_enabled( + id: String, + enabled: bool, + host: State<'_, PluginHost>, + app: tauri::AppHandle, +) -> Result, String> { + let result = host.set_enabled(id.trim(), enabled); + if result.is_ok() { + emit_plugin_state_changed(&app, &host); + } + result +} + +#[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 || { + let host = app.state::(); + let result = host.set_active_project(project_path); + if result.is_ok() { + emit_plugin_state_changed(&app, &host); + } + result + }) + .await + .map_err(|_| "切换插件项目上下文任务失败".to_string())? +} + +fn emit_plugin_state_changed(app: &tauri::AppHandle, host: &PluginHost) { + if let Ok(extensions) = host.list_extensions() { + let _ = app.emit(AGC_PLUGIN_STATE_CHANGED_EVENT, extensions); + } +} 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.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index b66b1de9d..85ab8fd04 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -23,6 +23,7 @@ mod version_resource_replacement; mod write_lock; pub(crate) use agent_db::*; +#[cfg(not(test))] pub(crate) use asset_export::*; pub(crate) use asset_rename::*; pub(crate) use bootstrap::*; @@ -34,6 +35,7 @@ pub(crate) use filesystem::*; pub(crate) use manifest::*; pub(crate) use memory::*; pub(crate) use model_usage::*; +#[cfg(not(test))] pub(crate) use resource_dependency_graph::*; pub(crate) use resource_editor::*; pub(crate) use resource_layout::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs index 43fe2df3e..7ebf7e7d1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs @@ -452,6 +452,7 @@ fn write_sparse_agent_db_with_complete_tail(root: &Path, length: u64) { } } +#[cfg(unix)] fn assert_agent_db_read_and_append_rejected(root: &Path) { let read_error = read_agent_db_records_bounded(root, 1024) .expect_err("linked Agent DB read must be rejected"); 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 f1097a975..ae36b9c59 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner.rs @@ -6,34 +6,36 @@ 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, configure_external_agent_runner, + hold_external_agent_runner_gui_participant_lock, 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; pub(crate) use endpoint::{external_agent_runner_enabled, external_agent_runner_is_server_process}; #[allow(unused_imports)] pub(crate) use protocol::{ExternalAgentRunnerStatus, EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION}; -pub(crate) use server::{ - bind_loopback_listener_with_linux_fallback, run_external_agent_runner_server, -}; +pub(crate) use server::bind_loopback_listener_with_linux_fallback; +#[cfg(not(test))] +pub(crate) use server::run_external_agent_runner_server; #[cfg(test)] pub(crate) fn simulate_external_agent_runner_cross_boot_owner_claim_for_test( @@ -71,3 +73,7 @@ pub(crate) fn simulate_external_agent_runner_cross_boot_owner_claim_for_test( } #[cfg(test)] mod tests; + +pub(crate) use client::{ + clear_external_agent_runner_platform_session, install_external_agent_runner_platform_session, +}; 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..3316feacf 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> { @@ -1310,6 +1317,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 +1444,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 +1452,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/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index 4f372d1f3..cd0e920c7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -3,6 +3,7 @@ use super::{ }; use serde_json::{json, Value}; use sha2::{Digest as _, Sha256}; +#[cfg(target_os = "linux")] use std::collections::BTreeSet; use std::ffi::OsString; use std::fs; 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 45f9b0330..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) } @@ -454,11 +427,13 @@ fn apply_template_library_fixtures(entries: Vec) -> Vec usize { parse_synthetic_count(std::env::var(SYNTHETIC_COUNT_ENV).ok().as_deref()) } @@ -617,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() } @@ -846,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, @@ -1023,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/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index cea4b4352..5e9fbc633 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -1,7 +1,7 @@ use super::*; use base64::Engine as _; use serde_json::Value; -use sha2::{Digest as _, Sha256}; +use sha2::Sha256; use std::collections::{BTreeMap, BTreeSet}; use std::io::{self, Read, Write}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -392,64 +392,6 @@ pub(crate) fn freeze_test_root_goal_contract_at( .expect("freeze test root Goal Contract") } -pub(crate) fn pass_test_root_acceptance_graph_at( - root: &Path, - state: &AgentRuntimeState, -) -> AgentRuntimeAcceptanceGraphState { - let contract = read_game_creator_agent_runtime_goal_contract_at( - root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &state.run_id, - ) - .expect("read test root Goal Contract") - .unwrap_or_else(|| freeze_test_root_goal_contract_at(root, &state.run_id)); - let action = AgentRuntimeToolAction { - tool: "file.list".to_string(), - reason: Some("提供测试验收图的当前 revision 机器证据".to_string()), - input: serde_json::json!({ "path": "" }), - }; - let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &state.current_task); - let action_id = - agent_runtime_tool_action_id(&state.run_id, u32::MAX - 1, 0, 0, &action_fingerprint); - let revision = read_game_creator_agent_runtime_project_revision(root) - .expect("read test project revision") - .revision; - append_agent_runtime_action_receipt_with_project_revision_before( - root, - state, - &action_id, - &action_fingerprint, - "file.list", - "auto", - None, - &AgentRuntimeToolObservation { - tool: "file.list".to_string(), - status: "ok".to_string(), - summary: "测试验收证据已持久化".to_string(), - detail: None, - }, - revision, - ) - .expect("append test acceptance receipt"); - update_game_creator_agent_runtime_acceptance_graph_at( - root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &state.run_id, - &contract.contract_fingerprint, - &[AgentRuntimeAcceptanceEvaluationDraft { - criterion_id: "test-acceptance".to_string(), - status: "passed".to_string(), - evidence: vec![AgentRuntimeAcceptanceEvidenceRef { - agent_id: state.agent_id.clone(), - run_id: state.run_id.clone(), - action_id, - }], - summary: "测试声明的验收条件已满足".to_string(), - }], - ) - .expect("pass test root Acceptance Graph") -} - pub(crate) fn canonical_test_tempdir(prefix: &str) -> tempfile::TempDir { let temp_root = std::env::temp_dir() .canonicalize() @@ -647,62 +589,6 @@ pub(crate) async fn wait_for_agent_runtime_terminal_and_lane_release_async( } } -pub(crate) async fn wait_for_agent_runtime_manifest_projection_async( - root: &Path, - agent_id: &str, - run_id: &str, - runtime_status: &str, - phase: &str, - manifest_status: GameCreationAppTaskStatus, -) -> AgentRuntimeResult { - let deadline = Instant::now() + AGENT_RUNTIME_TERMINAL_WAIT_BUDGET; - let mut terminal = wait_for_agent_runtime_terminal_and_lane_release_async( - root, - agent_id, - run_id, - runtime_status, - phase, - ) - .await; - let mut stable_samples = 0_u8; - loop { - if let Ok(project_lock) = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "test.wait_runtime_manifest_projection", - ) { - let manifest = read_manifest_for_project(root) - .expect("read manifest while waiting for terminal projection"); - let projected = manifest - .tasks - .iter() - .find(|task| task.id == agent_id) - .is_some_and(|task| task.status == manifest_status); - let reread = read_game_creator_agent_runtime_at(root, agent_id) - .expect("reread runtime while waiting for terminal projection"); - drop(project_lock); - if projected - && reread.state.run_id == run_id - && reread.state.status == runtime_status - && reread.state.phase == phase - { - stable_samples = stable_samples.saturating_add(1); - terminal = reread; - if stable_samples >= 2 { - return terminal; - } - } else { - stable_samples = 0; - terminal = reread; - } - } - assert!( - Instant::now() < deadline, - "runtime terminal manifest projection did not settle for {agent_id}/{run_id}" - ); - tokio::time::sleep(Duration::from_millis(20)).await; - } -} - async fn wait_for_agent_runtime_lane_release_async( root: &Path, agent_id: &str, @@ -3548,10 +3434,6 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( base_url } -fn spawn_mock_external_canvas_api_server() -> String { - spawn_mock_external_canvas_api_server_with_capture(3, None) -} - fn spawn_mock_external_canvas_generation_api_server( request_sender: Option>, ) -> String { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs index 36e81afb5..6e0b772d8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs @@ -14,13 +14,12 @@ pub(super) use super::super::{ append_auto_tool_action_audit_pair_for_test, assert_auto_tool_action_audit_pair, assert_pending_runtime_decision_revalidates_after_lock, assert_task_status, fake_llm_game_draft, final_tool_plan_response, mock_http_request_json, - native_agent_tool_plan_chat_response, observe_agent_runtime_agent_delegate, - pending_tool_action_for_test, persist_needs_reconciliation_runtime_for_test, - persist_project_verification_for_test, read_agent_db_records_for_test, - register_canvas_visual_asset_fixture, spawn_barrier_mock_llm_server, - spawn_interruptible_mock_llm_server_with_capture, spawn_mock_llm_raw_responses_with_capture, - spawn_mock_llm_server, spawn_mock_llm_server_responses, - spawn_mock_llm_server_responses_with_capture, + native_agent_tool_plan_chat_response, pending_tool_action_for_test, + persist_needs_reconciliation_runtime_for_test, persist_project_verification_for_test, + read_agent_db_records_for_test, register_canvas_visual_asset_fixture, + spawn_barrier_mock_llm_server, spawn_interruptible_mock_llm_server_with_capture, + spawn_mock_llm_raw_responses_with_capture, spawn_mock_llm_server, + spawn_mock_llm_server_responses, spawn_mock_llm_server_responses_with_capture, spawn_releasable_mock_llm_server_responses_with_capture, spawn_releasable_mock_llm_server_responses_with_capture_at, start_agent_runtime_steer_fixture, ui_prototype_assessment_fixture, unique_project_path, use_test_runtime_config_dir, @@ -38,10 +37,9 @@ pub(super) use crate::{ agent_runtime_background_worker_threads_for_test, agent_runtime_executable_tools, agent_runtime_read_only_delivery_completion_plan_update, agent_runtime_run_profile_identity_at, agent_runtime_tool_action_fingerprint, agent_runtime_tool_action_id, - agent_runtime_tool_allowed_for_agent, agent_runtime_tool_policy_snapshot_for_run_at, + agent_runtime_tool_policy_snapshot_for_run_at, agent_runtime_tool_requires_pending_revision_gate, - agent_runtime_tool_requires_repository_context_fingerprint_gate, - agent_runtime_verified_delivery_completion_plan_update, append_agent_db_record, + agent_runtime_tool_requires_repository_context_fingerprint_gate, append_agent_db_record, append_agent_db_terminal_observation_if_missing_for_action, append_agent_runtime_action_receipt, append_agent_runtime_tool_call_record, append_game_creator_agent_runtime_action_event, append_game_creator_agent_runtime_task, @@ -67,8 +65,8 @@ pub(super) use crate::{ game_creator_agent_runtime_tool_policy_rule_for_run, init_local_game_project_at, invalidate_agent_runtime_project_verification_after_preview_failure_at, native_runtime_function_name, observe_agent_runtime_action_history, - observe_agent_runtime_agent_message, observe_agent_runtime_agent_spawn_isolated, - plan_game_creation_agent_pass, prepare_agent_runtime_project_mutation_locked, + observe_agent_runtime_agent_message, plan_game_creation_agent_pass, + prepare_agent_runtime_project_mutation_locked, prepare_game_creator_agent_runtime_provider_action_batch, project_verification_completion_blocker_at, read_all_game_creator_agent_runtime_tasks, read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_context_bundle, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs index 2e7f6fe79..79d99c905 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs @@ -18,15 +18,18 @@ use super::identity_order_validation::{ parse_tool_plan_base_request_slot, request_slot_for_attempt, }; use super::ledger::response_for_persistence; +#[cfg(unix)] +use super::model::TOOL_PLAN_HANDOFF_RELATIVE_DIRECTORY; use super::model::{ INVALID_THINKING_CLOSE_MARKER, INVALID_THINKING_OPEN_MARKER, TOOL_PLAN_HANDOFF_ARGUMENTS_MAX_BYTES, TOOL_PLAN_HANDOFF_LABEL, TOOL_PLAN_HANDOFF_MAX_ENTRIES, - TOOL_PLAN_HANDOFF_MAX_TOOL_CALLS, TOOL_PLAN_HANDOFF_RELATIVE_DIRECTORY, - TOOL_PLAN_HANDOFF_REQUEST_RESERVE_BYTES, TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES, + TOOL_PLAN_HANDOFF_MAX_TOOL_CALLS, TOOL_PLAN_HANDOFF_REQUEST_RESERVE_BYTES, + TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES, }; +#[cfg(unix)] +use super::storage_common::serialize_ledger_for_storage; use super::storage_common::{ - path_key, response_fingerprint, serialize_ledger_for_storage, tool_plan_handoff_path, - tool_plan_handoff_relative_path, + path_key, response_fingerprint, tool_plan_handoff_path, tool_plan_handoff_relative_path, }; #[cfg(unix)] use super::storage_unix::{ @@ -43,7 +46,9 @@ use super::*; use crate::agent::{ agent_runtime_json_sidecar_backup_path, write_agent_runtime_json_sidecar_with_max_bytes, }; -use crate::provider_retry::{self, AgentRuntimeProviderRetryIdentity}; +#[cfg(unix)] +use crate::provider_retry; +use crate::provider_retry::AgentRuntimeProviderRetryIdentity; #[test] fn tool_plan_handoff_classifies_windows_handle_install_failure_as_storage() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/mod.rs index 512165949..0303b0a14 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/mod.rs @@ -2,7 +2,9 @@ pub mod recognition; pub mod separation; pub mod utils; +#[cfg(not(test))] +pub(crate) use recognition::recognize_ui_impl; +pub(crate) use recognition::recognize_ui_impl_with_provider; pub use recognition::RecognitionDTO; -pub(crate) use recognition::{recognize_ui_impl, recognize_ui_impl_with_provider}; pub(crate) use separation::separate_ui_impl; pub use separation::{SeparationDTO, SeparationRecoveryDTO}; 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 2e23b1aaa..3567bc732 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 @@ -828,6 +828,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 595dee76e..37c486af7 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 @@ -543,4 +543,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/windows.rs b/apps/ai-game-creator-shell/src-tauri/src/windows.rs index 4406f2e29..e64226359 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/windows.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/windows.rs @@ -146,43 +146,8 @@ fn percent_encode_query_value(value: &str) -> String { encoded } -#[tauri::command] -pub(crate) fn open_game_creator_workspace_window( - app: tauri::AppHandle, - window: tauri::Window, - project_path: String, -) -> Result<(), String> { - let project_path = validate_workspace_window_project_path(&project_path)?; - if let Some(existing) = app.get_webview_window("main") { - existing.close().map_err(|error| error.to_string())?; - } - tauri::WebviewWindowBuilder::new(&app, "main", workspace_window_url(project_path)) - .title(crate::game_creator_product_name(&app)) - .decorations(false) - .inner_size(1180.0, 820.0) - .min_inner_size(760.0, 560.0) - .build() - .map_err(|error| error.to_string())?; - window.close().map_err(|error| error.to_string())?; - Ok(()) -} - -#[tauri::command] -pub(crate) fn open_game_creator_launcher_window( - app: tauri::AppHandle, - window: tauri::Window, -) -> Result<(), String> { - if let Some(existing) = app.get_webview_window("launcher") { - existing.set_focus().map_err(|error| error.to_string())?; - } else { - tauri::WebviewWindowBuilder::new(&app, "launcher", launcher_window_url()) - .title(crate::game_creator_product_name(&app)) - .decorations(false) - .inner_size(820.0, 640.0) - .min_inner_size(720.0, 520.0) - .build() - .map_err(|error| error.to_string())?; - } - window.close().map_err(|error| error.to_string())?; - Ok(()) -} +// Webview 接入独立于受测的窗口 URL/路径校验及后台进程配置。 +#[cfg(not(test))] +mod desktop; +#[cfg(not(test))] +pub(crate) use desktop::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/windows/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/windows/desktop.rs new file mode 100644 index 000000000..2d4602d6e --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/windows/desktop.rs @@ -0,0 +1,42 @@ +use super::*; + +#[tauri::command] +pub(crate) fn open_game_creator_workspace_window( + app: tauri::AppHandle, + window: tauri::Window, + project_path: String, +) -> Result<(), String> { + let project_path = validate_workspace_window_project_path(&project_path)?; + if let Some(existing) = app.get_webview_window("main") { + existing.close().map_err(|error| error.to_string())?; + } + tauri::WebviewWindowBuilder::new(&app, "main", workspace_window_url(project_path)) + .title(crate::game_creator_product_name(&app)) + .decorations(false) + .inner_size(1180.0, 820.0) + .min_inner_size(760.0, 560.0) + .build() + .map_err(|error| error.to_string())?; + window.close().map_err(|error| error.to_string())?; + Ok(()) +} + +#[tauri::command] +pub(crate) fn open_game_creator_launcher_window( + app: tauri::AppHandle, + window: tauri::Window, +) -> Result<(), String> { + if let Some(existing) = app.get_webview_window("launcher") { + existing.set_focus().map_err(|error| error.to_string())?; + } else { + tauri::WebviewWindowBuilder::new(&app, "launcher", launcher_window_url()) + .title(crate::game_creator_product_name(&app)) + .decorations(false) + .inner_size(820.0, 640.0) + .min_inner_size(720.0, 520.0) + .build() + .map_err(|error| error.to_string())?; + } + window.close().map_err(|error| error.to_string())?; + Ok(()) +} diff --git a/apps/ai-game-creator-shell/tests/sessionPreview.test.ts b/apps/ai-game-creator-shell/tests/sessionPreview.test.ts index 90f8e6750..558a8a6a4 100644 --- a/apps/ai-game-creator-shell/tests/sessionPreview.test.ts +++ b/apps/ai-game-creator-shell/tests/sessionPreview.test.ts @@ -171,8 +171,8 @@ describe('进入项目时的预览活体核验', () => { }); describe('本地预览记录的生命周期(Rust 侧结构性守卫)', () => { - const mainSource = readFileSync( - repoPath('apps/ai-game-creator-shell/src-tauri/src/main.rs'), + const desktopSource = readFileSync( + repoPath('apps/ai-game-creator-shell/src-tauri/src/desktop.rs'), 'utf8', ); const previewSource = readFileSync( @@ -186,7 +186,7 @@ describe('本地预览记录的生命周期(Rust 侧结构性守卫)', () => // 打架),所以按本仓既有做法直接钉源码结构。 const exitBranch = /matches!\(event, tauri::RunEvent::Exit\)\s*\{([\s\S]*?)\n {8}\}/u.exec( - mainSource, + desktopSource, )?.[1] ?? ''; expect(exitBranch).toContain('preview::stop_local_game_preview_on_exit'); }); diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 29b6c172d..182e4656a 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -55,6 +55,8 @@ Windows 下的移动壳 smoke 通过 Node 启动从当前 workspace 包解析出的 Expo/EAS CLI,不直接 `spawnSync('npm.cmd')`;保留原配置与导出断言。具体入口和警告清理边界见本地开发运维文档。 +AGC 的纯桌面/生产接入按模块排除测试编译,共享实现与受测入口保持可测试;拆分后同时检查普通目标和测试目标,并保留原有行为断言。命令注册与退出接线的源码检查跟随真实模块位置更新,不能因移动文件而漏检;不通过全局允许死代码或虚假调用消除告警。具体边界见开发运维文档“编译告警的保留边界与待优化项”。 + 提示词外置变更运行 `runtime_prompt_bundle_build` 与 `prompt_source_boundaries` 两个 Rust 集成测试,验证编译期文本、目录登记和源码边界;现有 `agc-rust-shard-1` 本地/CI 入口先执行这组检查,再运行分片单测。 提示词测试验证实际请求中的片段来源、动态参数和工具结构;措辞不作为逐字契约。已有行为测试覆盖的限制不再另设整段文案检查。Direct 回合测试复用生产的消息转换和文件投影函数,不维护仅供测试调用的回合编排副本。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 10578f8af..ec8a2ef39 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -316,6 +316,17 @@ npm run check ### 编译告警的保留边界与待优化项 +- 设计会话的水合、重置和调试快进通过共享核心验证损坏会话隔离、不确定批次恢复和事件失败后的落盘状态;仅 Tauri 包装限定生产编译,资源与模型客户端仍在原校验阶段之后按需加载。旧 UI 绑定、合并及语义建议命令已随现役 UI 工作流替换而删除;分离恢复测试验证已完成的绑定和图片不被重新生成,不代表首次生成全链路已覆盖。音频派发测试验证使用派发时的 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 启动和 Tauri 命令包装限定生产编译;loopback 启停、服务注册表、析构与工具桥启动属于 Claude Direct 的共享依赖,必须参与测试编译。认证链路调用的 `validate_platform_session_input`、线程队列使用的 `DirectGameCreatorTurnUpdateEmitter::new` 同样保留在测试编译中;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 链接或安装包均无告警;后续按改动范围定向验证。 - AuthBridge 的 3 条 warning 暂缓处理,支持或退役 OAuth 的决策仍未确定。完整证据、保留边界和关闭条件以 [AGC 主方案“OAuth 认证路线的契约冲突与待决边界”](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#oauth-认证路线的契约冲突与待决边界)为准。 - ts-rs 12.0.1 的 5 条提示来自四个枚举(`DirectCodexUserItem`、`DirectCodexUserContentPart`、`DirectThreadItem`、`DirectThreadEvent`)的 `serde(deny_unknown_fields)` 和图片组件的 `serde(deserialize_with = "deserialize_fill_amount")`。已静态核对 TypeScript 类型形状及基础类型正确、Serde 运行时校验仍有效;暂保留提示,不引入依赖补丁、不改业务校验或全局屏蔽,待上游正式版本支持后再评估。 diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 1c08b8741..541e0c06e 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -31,10 +31,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',