清理 AGC 测试编译死代码告警

分离桌面启动与命令包装,按实际消费者限定编译范围
保留共享业务实现并补充设计会话、取消回合、UI 与音频派发定向回归
同步壳结构检查和编译告警保留边界文档
This commit is contained in:
2026-09-24 10:23:21 +00:00
parent 9b109d75d5
commit 6a507dc4ce
64 changed files with 2508 additions and 1775 deletions
@@ -5057,6 +5057,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
/// fresh isolated read-only workspace and a stable in-process thread so a
/// normal conversation can continue without creating a project, assets, a
/// preview, or a version record.
#[cfg(not(test))]
pub(crate) async fn direct_game_creator_home_codex_chat(
system_prompt: String,
user_prompt: String,
@@ -5261,6 +5262,117 @@ mod tests {
assert!(table.select(&key, None).is_err());
}
fn exited_direct_turn_cancellation() -> Arc<CodexTurnStartCancellation> {
Arc::new(CodexTurnStartCancellation {
inner: Weak::new(),
thread_id: String::new(),
turn_id: std::sync::Mutex::new(None),
cancelled: AtomicBool::new(false),
interrupt_sent: AtomicBool::new(false),
execution: None,
})
}
#[test]
fn cancel_direct_codex_turn_rejects_another_turn_without_releasing_it() {
let root = tempfile::tempdir().expect("direct turn project");
let invocation = crate::agent::DirectTaonierActiveInvocationGuard::enter(
root.path(),
"client-turn-current",
)
.expect("active invocation");
let cancellation = exited_direct_turn_cancellation();
let key = direct_codex_active_turn_key(root.path());
let _registered = register_active_direct_codex_turn(
key.clone(),
"client-turn-current",
Arc::clone(&cancellation),
);
let thread_id = direct_thread_id_for_project(root.path());
let subscription = crate::agent::subscribe_direct_thread(&thread_id);
let error = cancel_direct_codex_turn_at(root.path(), Some("client-turn-other"))
.expect_err("another turn must not be interrupted or released");
assert_eq!(error, DIRECT_CODEX_ANOTHER_TURN_RUNNING_MESSAGE);
assert!(!cancellation.cancelled.load(Ordering::Acquire));
assert_eq!(
direct_codex_active_turns()
.lock()
.expect("active turn table")
.registered_client_turn_id(&key),
Some("client-turn-current")
);
assert_eq!(
crate::agent::read_direct_taonier_active_invocation_at(root.path())
.expect("read active invocation")
.expect("original turn remains active")
.client_turn_id,
"client-turn-current"
);
assert!(
crate::agent::consume_direct_thread(&subscription.subscription_id)
.expect("consume turn events")
.events
.is_empty(),
"wrong-turn rejection must not publish a terminal event"
);
drop(invocation);
}
#[test]
fn cancel_direct_codex_turn_releases_exited_executor_and_completes_original_turn() {
let root = tempfile::tempdir().expect("direct turn project");
let invocation = crate::agent::DirectTaonierActiveInvocationGuard::enter(
root.path(),
"client-turn-exited",
)
.expect("active invocation");
let _registered = register_active_direct_codex_turn(
direct_codex_active_turn_key(root.path()),
"client-turn-exited",
exited_direct_turn_cancellation(),
);
let thread_id = direct_thread_id_for_project(root.path());
let subscription = crate::agent::subscribe_direct_thread(&thread_id);
let view = cancel_direct_codex_turn_at(root.path(), Some("client-turn-exited"))
.expect("exited executor releases the residual invocation");
assert_eq!(view.outcome, DIRECT_TURN_CANCEL_OUTCOME_RELEASED);
assert_eq!(view.client_turn_id, "client-turn-exited");
assert!(view.message.contains("执行进程已退出"));
assert_eq!(
crate::agent::read_direct_taonier_active_invocation_at(root.path())
.expect("read released invocation"),
None
);
let events = crate::agent::consume_direct_thread(&subscription.subscription_id)
.expect("consume terminal event")
.events;
assert!(matches!(
events.as_slice(),
[DirectThreadEvent::TurnCompleted {
status,
at: Some(_),
user_item_id: Some(user_item_id),
}] if status == "aborted" && user_item_id == "direct-codex:client-turn-exited:user"
));
let next = crate::agent::DirectTaonierActiveInvocationGuard::enter(
root.path(),
"client-turn-next",
)
.expect("new turn can start after stale release");
drop(invocation);
assert_eq!(
crate::agent::read_direct_taonier_active_invocation_at(root.path())
.expect("read next invocation")
.expect("old guard must not clear next turn")
.client_turn_id,
"client-turn-next"
);
drop(next);
}
/// 终止路径会从同步命令线程和 `Drop` 里派发 app-server 任务:那些线程没有 tokio
/// runtime 上下文。`tokio::spawn` 在那里 panic,panic 跨不过 IPC 回调边界就把整个
/// 进程 abort(0xC0000409,"点终止就闪退")。这条用例把派发入口钉在没有 runtime
@@ -8,6 +8,7 @@ use std::fs::File;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::time::{Duration, Instant};
#[cfg(not(test))]
use tauri::Emitter;
use uuid::Uuid;
@@ -1401,12 +1402,23 @@ fn ensure_design_runtime_active(root: &Path) -> Result<(), String> {
Ok(())
}
#[cfg(not(test))]
#[tauri::command]
pub(crate) async fn hydrate_design_agent_session(
app: tauri::AppHandle,
project_path: String,
) -> Result<Option<DesignView>, 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<DesignResources, String> + Send,
) -> Result<Option<DesignView>, String> {
enforce_project_permission_policy(root, "conversation.read")?;
if read_design_runtime_mode(root)?
.as_ref()
@@ -1435,7 +1447,7 @@ pub(crate) async fn hydrate_design_agent_session(
return Ok(Some(design_view(&session, true)));
};
if design_session_has_uncertain_batch(&session) {
let resources = DesignResources::new(resolve_design_resources_root(&app)?)?;
let resources = load_resources()?;
let view = recover_uncertain_design_batch(root, &resources, session, active).await?;
return Ok(Some(view));
}
@@ -1479,9 +1491,14 @@ fn quarantine_corrupted_design_session(root: &Path) -> Result<(), String> {
std::fs::rename(&path, &backup).map_err(|error| format!("隔离损坏的策划会话失败:{error}"))
}
#[cfg(not(test))]
#[tauri::command]
pub(crate) fn reset_design_agent_session(project_path: String) -> Result<(), String> {
let root = Path::new(project_path.trim());
reset_design_agent_session_at(root)
}
fn reset_design_agent_session_at(root: &Path) -> Result<(), String> {
enforce_project_permission_policy(root, "conversation.write")?;
design_project_id(root)?;
let path = crate::agent::runtime_protocol::design_session_path(root);
@@ -1491,6 +1508,7 @@ pub(crate) fn reset_design_agent_session(project_path: String) -> Result<(), Str
Ok(())
}
#[cfg(not(test))]
#[tauri::command]
pub(crate) fn get_design_agent_runtime_mode(
project_path: String,
@@ -1518,8 +1536,13 @@ fn design_debug_enabled() -> bool {
== Some("1")
}
#[cfg(not(test))]
#[tauri::command]
pub(crate) fn is_design_agent_debug_enabled() -> bool {
design_debug_command_enabled()
}
fn design_debug_command_enabled() -> bool {
cfg!(debug_assertions) && design_debug_enabled()
}
@@ -1543,16 +1566,27 @@ pub(crate) fn set_design_agent_runtime_mode(
write_design_runtime_mode(root, active_runtime.trim())
}
#[cfg(not(test))]
#[tauri::command]
pub(crate) fn debug_fast_forward_design_session(
app: tauri::AppHandle,
project_path: String,
target_phase: String,
) -> Result<DesignRuntimeMode, String> {
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<DesignRuntimeMode, String> {
if !design_debug_command_enabled() {
return Err("策划 Agent 快速推进仅可用于 Debug 构建".to_string());
}
let root = Path::new(project_path.trim());
let project_id = design_project_id(root)?;
let target_index = design_phase_index(target_phase.trim())?;
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
@@ -1600,21 +1634,18 @@ pub(crate) fn debug_fast_forward_design_session(
session.updated_at = unix_timestamp();
write_design_session(root, &session)?;
let mode = write_design_runtime_mode(root, "design")?;
app.emit(
"design-agent-update",
design_event(
root,
"debug-fast-forward",
"state",
None,
None,
Some(design_view(&session, false)),
),
)
.map_err(|error| format!("刷新策划调试状态失败:{error}"))?;
emit(design_event(
root,
"debug-fast-forward",
"state",
None,
None,
Some(design_view(&session, false)),
))?;
Ok(mode)
}
#[cfg(not(test))]
#[tauri::command]
pub(crate) async fn continue_design_agent_session(
app: tauri::AppHandle,
@@ -1639,6 +1670,7 @@ pub(crate) async fn continue_design_agent_session(
.await
}
#[cfg(not(test))]
#[tauri::command]
pub(crate) async fn decide_design_phase(
app: tauri::AppHandle,
@@ -1665,6 +1697,7 @@ pub(crate) async fn decide_design_phase(
.await
}
#[cfg(not(test))]
#[tauri::command]
pub(crate) fn list_design_workspace(
project_path: String,
@@ -1675,6 +1708,7 @@ pub(crate) fn list_design_workspace(
list_design_workspace_files(root)
}
#[cfg(not(test))]
#[tauri::command]
pub(crate) fn read_design_workspace_file(
project_path: String,
@@ -3239,6 +3273,105 @@ mod tests {
assert!(next.session.last_error.is_none());
}
#[tokio::test(flavor = "current_thread")]
async fn hydrate_quarantines_a_corrupted_session_without_loading_resources() {
let (_temp, root, _resources) = init_design_project();
let path = crate::agent::runtime_protocol::design_session_path(&root);
fs::create_dir_all(path.parent().expect("session directory")).expect("create session dir");
fs::write(&path, b"{broken").expect("write malformed session");
let view = hydrate_design_agent_session_at(&root, || -> Result<DesignResources, String> {
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::<Vec<_>>();
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::<Vec<_>>();
assert_eq!(backups.len(), 1);
reset_design_agent_session_at(&root).expect("repeat reset without active session");
}
#[cfg(debug_assertions)]
#[test]
fn debug_fast_forward_checks_the_gate_and_persists_before_event_delivery() {
let (_temp, root, _resources) = init_design_project();
let _debug_env = enable_design_debug_for_test();
std::env::remove_var("GENARRATIVE_AGC_DESIGN_DEBUG");
let error = debug_fast_forward_design_session_at(&root, "architecture", |_| {
panic!("debug gate must reject before emitting")
})
.expect_err("debug gate should be closed");
assert!(error.contains("Debug 构建"));
assert!(read_design_session(&root).expect("read session").is_none());
std::env::set_var("GENARRATIVE_AGC_DESIGN_DEBUG", "1");
let existing = root.join("design_artifacts/project/00_concept/design.md");
fs::create_dir_all(existing.parent().expect("artifact directory"))
.expect("create artifact directory");
fs::write(&existing, "已有真实内容").expect("write existing artifact");
let error = debug_fast_forward_design_session_at(&root, "architecture", |event| {
assert_eq!(event.kind, "state");
assert_eq!(event.client_turn_id, "debug-fast-forward");
Err("event delivery failed".to_string())
})
.expect_err("event failure should be reported");
assert_eq!(error, "event delivery failed");
assert_eq!(
fs::read_to_string(existing).expect("read existing artifact"),
"已有真实内容"
);
assert!(root
.join("design_artifacts/project/02_architecture/design.md")
.is_file());
let session = read_design_session(&root)
.expect("read fast-forwarded session")
.expect("fast-forwarded session");
assert_eq!(session.current_phase, "architecture");
assert_eq!(session.approved_phases, vec!["concept", "top_design"]);
assert!(session.pending_batch.is_none() && session.turn.is_none());
assert_eq!(
read_design_runtime_mode(&root)
.expect("read runtime mode")
.expect("runtime mode")
.active_runtime,
"design"
);
}
#[tokio::test(flavor = "current_thread")]
async fn uncertain_batch_hydrate_continues_the_original_turn_without_replaying_file_tools() {
let (_temp, root, resources) = init_design_project();
@@ -3294,16 +3427,10 @@ mod tests {
))],
0,
);
let view = recover_uncertain_design_batch(&root, &resources, session, {
try_open_game_creator_agent_runtime_task_lock_file(
&root,
".agent/design-agent/active.lock",
)
.expect("open active lock")
.expect("active lock is free")
})
.await
.expect("recover uncertain batch");
let view = hydrate_design_agent_session_at(&root, move || Ok(resources))
.await
.expect("hydrate uncertain batch")
.expect("restored session");
assert!(!view.running);
assert!(view.session.last_error.is_none());
@@ -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<PathBuf, String> {
let mut candidates = vec![PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("design-agent")];
if let Ok(dir) = app.path().resource_dir() {
@@ -549,6 +551,7 @@ pub(crate) fn read_design_workspace_file_at(root: &Path, path: &str) -> Result<S
fs::read_to_string(&target).map_err(|error| format!("读取失败:{error}"))
}
#[cfg(not(test))]
#[tauri::command]
pub(crate) fn import_design_workspace_file(
app: tauri::AppHandle,
@@ -5,8 +5,10 @@ mod validation;
mod wire;
pub(crate) use model::DirectCodexUserItem;
#[cfg(not(test))]
pub(crate) use validation::validate_direct_codex_user_item;
#[cfg(not(test))]
pub(crate) use wire::direct_codex_user_item_to_prompt;
pub(crate) use wire::{
direct_codex_user_item_to_codex_turn_input, direct_codex_user_item_to_prompt,
direct_codex_user_item_to_response_item,
direct_codex_user_item_to_codex_turn_input, direct_codex_user_item_to_response_item,
};
@@ -737,6 +737,7 @@ pub(crate) fn read_direct_project_history_items_slice_at(
/// 最新一条可显示条目的 itemId:首屏历史锚点。
///
/// 与"最近一屏"共用尾部回扫,读一行就能返回,不回读整份历史。
#[cfg(not(test))]
pub(crate) fn read_direct_project_last_item_id_at(root: &Path) -> Result<Option<String>, String> {
Ok(read_direct_project_history_items_slice_at(root, DirectProjectHistoryAnchor::Newest, 1)?.3)
}
@@ -27,6 +27,7 @@ pub(crate) fn normalize_direct_client_turn_id(
Ok(client_turn_id.to_string())
}
#[cfg(not(test))]
#[tauri::command]
pub(crate) async fn chat_with_game_creator_direct_codex(
project_path: String,
@@ -369,6 +369,7 @@ fn global_direct_thread_manager() -> &'static Mutex<DirectThreadManager> {
DIRECT_THREAD_MANAGER.get_or_init(|| Mutex::new(DirectThreadManager::new()))
}
#[cfg(not(test))]
pub(crate) fn set_direct_thread_manager_app_handle(app: tauri::AppHandle) {
let _ = DIRECT_THREAD_MANAGER_APP_HANDLE.set(app);
}
@@ -3047,6 +3047,7 @@ pub(crate) async fn start_direct_tool_bridge(
start_tool_bridge_for_source(root, controlled_web_search, true).await
}
#[cfg(not(test))]
pub(crate) async fn start_external_client_tool_bridge(
root: &Path,
controlled_web_search: bool,
@@ -1,8 +1,13 @@
use super::*;
use axum::extract::{DefaultBodyLimit, State as AxumState};
#[cfg(not(test))]
use axum::extract::DefaultBodyLimit;
use axum::extract::State as AxumState;
use axum::http::{HeaderMap, StatusCode};
#[cfg(not(test))]
use axum::routing::post;
use axum::{Json, Router};
use axum::Json;
#[cfg(not(test))]
use axum::Router;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::io::{BufRead, BufReader, Write};
@@ -25,16 +30,19 @@ const EXTERNAL_MCP_RESPONSE_SUMMARY_MAX_CHARS: usize = 240;
const EXTERNAL_MCP_JOURNAL_MAX_BYTES: u64 = 8 * 1024 * 1024;
const EXTERNAL_MCP_JOURNAL_RELATIVE_PATH: &str = ".agent/conversations/codex-responses.jsonl";
static EXTERNAL_MCP_JOURNAL_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
#[cfg(not(test))]
static EXTERNAL_MCP_SERVER: OnceLock<Mutex<Option<ExternalMcpServer>>> = OnceLock::new();
tokio::task_local! {
static EXTERNAL_MCP_BRIDGE_URL: String;
}
#[cfg(not(test))]
pub(crate) struct ExternalMcpServer {
_bridge: super::direct_tool_bridge::DirectToolBridge,
task: tokio::task::JoinHandle<()>,
}
#[cfg(not(test))]
impl Drop for ExternalMcpServer {
fn drop(&mut self) {
self.task.abort();
@@ -55,6 +63,7 @@ pub(crate) fn direct_tools_mcp_mode_requested(args: &[String]) -> bool {
args == [DIRECT_TOOLS_MCP_MODE_FLAG]
}
#[cfg(not(test))]
pub(crate) fn run_direct_tools_mcp_if_requested(args: &[String]) -> Option<i32> {
if !direct_tools_mcp_mode_requested(args) {
return None;
@@ -1977,6 +1986,7 @@ fn write_direct_tools_mcp_response(
.map_err(|_| "写入 MCP 响应失败".to_string())
}
#[cfg(not(test))]
fn read_direct_tools_mcp_requests(
mut reader: impl BufRead,
requests: tokio::sync::mpsc::Sender<Result<Vec<u8>, String>>,
@@ -2055,6 +2065,7 @@ where
terminal_error.map_or(Ok(()), Err)
}
#[cfg(not(test))]
async fn run_direct_tools_mcp_stdio() -> Result<(), String> {
let root = validate_direct_tools_project_root(
&std::env::current_dir().map_err(|_| "读取 MCP 工作目录失败".to_string())?,
@@ -2114,6 +2125,7 @@ async fn handle_external_mcp_http_request(
Ok(Json(response))
}
#[cfg(not(test))]
pub(crate) async fn start_external_mcp_loopback(
root: &Path,
controlled_web_search: bool,
@@ -2162,6 +2174,7 @@ pub(crate) async fn start_external_mcp_loopback(
Ok((url, token))
}
#[cfg(not(test))]
pub(crate) fn stop_external_mcp_loopback() {
if let Some(registry) = EXTERNAL_MCP_SERVER.get() {
if let Ok(mut guard) = registry.lock() {
@@ -2170,6 +2183,7 @@ pub(crate) fn stop_external_mcp_loopback() {
}
}
#[cfg(not(test))]
#[tauri::command]
pub(crate) async fn start_game_creator_external_mcp(project_path: String) -> Result<Value, String> {
let root = Path::new(project_path.trim());
@@ -2178,6 +2192,7 @@ pub(crate) async fn start_game_creator_external_mcp(project_path: String) -> Res
Ok(json!({ "url": url, "token": token, "transport": "streamable-http" }))
}
#[cfg(not(test))]
#[tauri::command]
pub(crate) fn stop_game_creator_external_mcp() -> Result<(), String> {
stop_external_mcp_loopback();
@@ -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,
@@ -294,9 +294,12 @@ pub(crate) use entrypoints::{
game_creator_agent_runtime_update_event, generate_local_game_draft_at,
read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_for_session_at,
read_game_creator_agent_runtimes_at, register_game_creator_manifest_invalidation_event_sink,
validate_game_creator_manifest_invalidation_event_sink,
};
#[cfg(not(test))]
pub(crate) use entrypoints::{
set_game_creator_agent_runtime_update_app_handle,
start_game_creator_manifest_invalidation_event_sink,
validate_game_creator_manifest_invalidation_event_sink,
};
#[cfg(test)]
pub(crate) use finalization::resume_game_creator_agent_finalization_for_test_at;
@@ -11,6 +11,7 @@ fn lock_game_creator_manifest_invalidation_event_sinks(
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
#[cfg(not(test))]
pub(crate) fn set_game_creator_agent_runtime_update_app_handle(app: tauri::AppHandle) {
let _ = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.set(app);
}
@@ -37,6 +38,7 @@ pub(crate) struct DirectGameCreatorTurnUpdateEmitter {
}
impl DirectGameCreatorTurnUpdateEmitter {
#[cfg(not(test))]
pub(crate) fn new(root: &Path, turn_id: String) -> Self {
Self {
project_path: root.to_string_lossy().into_owned(),
@@ -169,6 +171,7 @@ impl DirectGameCreatorTurnUpdateEmitter {
}
}
#[cfg(not(test))]
pub(crate) fn start_game_creator_manifest_invalidation_event_sink(
app: tauri::AppHandle,
) -> Result<GameCreatorManifestInvalidationEventSink, String> {
@@ -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,
@@ -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> {
@@ -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 });
}
}
@@ -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<Service> = OnceLock::new();
// 仅串行化发布服务与退出;后台等待认证状态时不占此锁。
static EXITING: Mutex<bool> = 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<T>(f: impl FnOnce(&mut GuiState) -> T) -> Option<T> {
let gui = GUI.get()?;
// 临界区只更新内存并 try_send,不做磁盘/网络操作,也不重新取得业务锁。
@@ -444,11 +401,6 @@ pub(crate) fn capture_writer_context() -> Option<(Context, AnalyticsWriter)> {
Some((capture_analytics_context()?, GUI.get()?.writer.clone()))
}
#[tauri::command]
pub(crate) fn settle_direct_run_analytics(attempt_id: String, discard: bool) {
super::run::settle(capture_writer_context(), &attempt_id, discard);
}
fn valid_context(context: &Context) -> bool {
GUI.get().is_some_and(|gui| {
context.editor_session_id == gui.session_id
@@ -485,164 +437,6 @@ pub(crate) fn created(
}
}
#[tauri::command]
pub(crate) async fn record_analytics_project_open(
window: tauri::Window,
context: Context,
project_path: String,
operation_id: String,
open_source: OpenSource,
event_time: String,
) {
if !valid_context(&context)
|| Uuid::parse_str(&operation_id).is_err()
|| project_path.len() > 32768
|| !valid_time(&event_time)
{
return;
}
let label = window.label().to_string();
if with_state(|state| {
state.reserve_open(&label, &operation_id, &project_path, &event_time);
})
.is_none()
{
return;
}
// 目录/manifest 读取不占用 GUI 线程,也不会把读失败返回给业务操作。
let _ = tauri::async_runtime::spawn_blocking(move || {
let Ok(root) = crate::validated_local_project_directory_path(&project_path) else {
return;
};
let Ok(manifest) = crate::read_existing_manifest_for_project(&root) else {
return;
};
let project = OpenProject {
id: manifest.project_id,
path: project_path,
operation_id,
};
with_state(|state| state.project_opened(label, context, project, open_source, event_time));
})
.await;
}
#[tauri::command]
pub(crate) fn record_analytics_project_leave(window: tauri::Window, project_path: String) {
with_state(|state| state.leave(window.label(), Some(&project_path)));
}
#[tauri::command]
pub(crate) async fn record_analytics_ui_save(
context: Context,
project_path: String,
operation_id: String,
save_source: SaveSource,
changed: bool,
event_time: String,
) {
if !valid_context(&context)
|| Uuid::parse_str(&operation_id).is_err()
|| project_path.len() > 32768
|| !valid_time(&event_time)
|| !matches!(save_source, SaveSource::Manual | SaveSource::Auto)
|| (save_source == SaveSource::Auto && !changed)
{
return;
}
let Some(gui) = GUI.get() else { return };
let writer = gui.writer.clone();
let _ = tauri::async_runtime::spawn_blocking(move || {
let Ok(root) = crate::validated_local_project_directory_path(&project_path) else {
return;
};
let Ok(manifest) = crate::read_existing_manifest_for_project(&root) else {
return;
};
super::project::saved(
Some((context, writer)),
&manifest.project_id,
Source::UiEditor,
&operation_id,
ProjectSaved {
save_source,
revision_id: None,
},
Some(&event_time),
);
})
.await;
}
pub(crate) fn mark_restart() {
with_state(|state| state.restart = true);
}
pub(crate) fn page_loading(window: &str) {
with_state(|state| state.leave(window, None));
}
pub(crate) fn exit() {
if let Ok(mut exiting) = EXITING.lock() {
*exiting = true;
}
with_state(GuiState::exit);
}
pub(crate) fn observe_windows(
app: &tauri::AppHandle,
excluded: Option<&str>,
minimized: bool,
initial: bool,
) {
if GUI.get().is_none() {
return;
}
let mut focused = None;
let mut unknown = false;
for (label, window) in app.webview_windows() {
if excluded == Some(label.as_str()) {
continue;
}
match (
window.is_focused(),
window.is_minimized(),
window.is_visible(),
) {
(Ok(true), Ok(false), Ok(true)) => {
focused = Some(label);
break;
}
(Ok(_), Ok(_), Ok(_)) => {}
_ => unknown = true,
}
}
if focused.is_none() && unknown {
return;
}
with_state(|state| state.windows(focused, minimized, initial));
}
pub(crate) fn window_event(window: &tauri::Window, event: &tauri::WindowEvent) {
let destroyed = matches!(event, tauri::WindowEvent::Destroyed);
if destroyed {
with_state(|state| state.leave(window.label(), None));
}
if destroyed
|| matches!(
event,
tauri::WindowEvent::Focused(_) | tauri::WindowEvent::Resized(_)
)
{
observe_windows(
window.app_handle(),
destroyed.then_some(window.label()),
window.is_minimized().unwrap_or(false),
false,
);
}
}
#[cfg(test)]
#[path = "gui_tests.rs"]
mod tests;
@@ -0,0 +1,213 @@
use super::*;
use std::path::PathBuf;
use tauri::Manager;
// 仅串行化发布服务与退出;后台等待认证状态时不占此锁。
static EXITING: Mutex<bool> = Mutex::new(false);
pub(crate) fn initialize(app: tauri::AppHandle, config_dir: PathBuf, version: String) {
tauri::async_runtime::spawn_blocking(move || {
crate::platform_session::initialize_analytics_identity(|route, sequence| {
let Ok(exiting) = EXITING.lock() else {
return;
};
if !*exiting {
initialize_with_identity(config_dir, version, route, sequence);
}
});
if let Some(gui) = GUI.get() {
super::super::goal::retry_pending(&gui.writer);
}
let handle = app.clone();
let _ = app.run_on_main_thread(move || observe_windows(&handle, None, false, true));
});
}
fn initialize_with_identity(config_dir: PathBuf, version: String, route: Route, sequence: u64) {
if GUI.get().is_some() {
return;
}
let session_id = Uuid::new_v4().to_string();
let writer = AnalyticsWriter::start(config_dir.clone(), session_id.clone());
let context = Context {
route,
editor_session_id: session_id.clone(),
client_version: version.clone(),
};
let state = GuiState::new(context, sequence, writer.clone());
if GUI
.set(Service {
session_id,
version,
writer,
state: Mutex::new(state),
})
.is_ok()
{
with_state(|state| state.start());
super::super::upload::start(config_dir);
}
}
#[tauri::command]
pub(crate) fn settle_direct_run_analytics(attempt_id: String, discard: bool) {
super::super::run::settle(capture_writer_context(), &attempt_id, discard);
}
#[tauri::command]
pub(crate) async fn record_analytics_project_open(
window: tauri::Window,
context: Context,
project_path: String,
operation_id: String,
open_source: OpenSource,
event_time: String,
) {
if !valid_context(&context)
|| Uuid::parse_str(&operation_id).is_err()
|| project_path.len() > 32768
|| !valid_time(&event_time)
{
return;
}
let label = window.label().to_string();
if with_state(|state| {
state.reserve_open(&label, &operation_id, &project_path, &event_time);
})
.is_none()
{
return;
}
// 目录/manifest 读取不占用 GUI 线程,也不会把读失败返回给业务操作。
let _ = tauri::async_runtime::spawn_blocking(move || {
let Ok(root) = crate::validated_local_project_directory_path(&project_path) else {
return;
};
let Ok(manifest) = crate::read_existing_manifest_for_project(&root) else {
return;
};
let project = OpenProject {
id: manifest.project_id,
path: project_path,
operation_id,
};
with_state(|state| state.project_opened(label, context, project, open_source, event_time));
})
.await;
}
#[tauri::command]
pub(crate) fn record_analytics_project_leave(window: tauri::Window, project_path: String) {
with_state(|state| state.leave(window.label(), Some(&project_path)));
}
#[tauri::command]
pub(crate) async fn record_analytics_ui_save(
context: Context,
project_path: String,
operation_id: String,
save_source: SaveSource,
changed: bool,
event_time: String,
) {
if !valid_context(&context)
|| Uuid::parse_str(&operation_id).is_err()
|| project_path.len() > 32768
|| !valid_time(&event_time)
|| !matches!(save_source, SaveSource::Manual | SaveSource::Auto)
|| (save_source == SaveSource::Auto && !changed)
{
return;
}
let Some(gui) = GUI.get() else { return };
let writer = gui.writer.clone();
let _ = tauri::async_runtime::spawn_blocking(move || {
let Ok(root) = crate::validated_local_project_directory_path(&project_path) else {
return;
};
let Ok(manifest) = crate::read_existing_manifest_for_project(&root) else {
return;
};
super::super::project::saved(
Some((context, writer)),
&manifest.project_id,
Source::UiEditor,
&operation_id,
ProjectSaved {
save_source,
revision_id: None,
},
Some(&event_time),
);
})
.await;
}
pub(crate) fn mark_restart() {
with_state(|state| state.restart = true);
}
pub(crate) fn page_loading(window: &str) {
with_state(|state| state.leave(window, None));
}
pub(crate) fn exit() {
if let Ok(mut exiting) = EXITING.lock() {
*exiting = true;
}
with_state(GuiState::exit);
}
pub(crate) fn observe_windows(
app: &tauri::AppHandle,
excluded: Option<&str>,
minimized: bool,
initial: bool,
) {
if GUI.get().is_none() {
return;
}
let mut focused = None;
let mut unknown = false;
for (label, window) in app.webview_windows() {
if excluded == Some(label.as_str()) {
continue;
}
match (
window.is_focused(),
window.is_minimized(),
window.is_visible(),
) {
(Ok(true), Ok(false), Ok(true)) => {
focused = Some(label);
break;
}
(Ok(_), Ok(_), Ok(_)) => {}
_ => unknown = true,
}
}
if focused.is_none() && unknown {
return;
}
with_state(|state| state.windows(focused, minimized, initial));
}
pub(crate) fn window_event(window: &tauri::Window, event: &tauri::WindowEvent) {
let destroyed = matches!(event, tauri::WindowEvent::Destroyed);
if destroyed {
with_state(|state| state.leave(window.label(), None));
}
if destroyed
|| matches!(
event,
tauri::WindowEvent::Focused(_) | tauri::WindowEvent::Resized(_)
)
{
observe_windows(
window.app_handle(),
destroyed.then_some(window.label()),
window.is_minimized().unwrap_or(false),
false,
);
}
}
@@ -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;
@@ -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;
}
}
});
}
@@ -869,6 +869,11 @@ mod asset_generation_task_tests {
assert_eq!(record.phase_detail, ASSET_GENERATION_TASK_PHASE_QUEUED);
assert!(record.output_path.is_none());
assert_eq!(record.asset_name, "新背景音乐");
assert_eq!(request.operation_id, record.task_id);
assert_eq!(
request.idempotency_key,
"9a1b2c3d-4e5f-4a1b-8c2d-3e4f5a6b7c8d"
);
assert_eq!(request.prompt, "一段平静的钢琴曲");
assert_eq!(
request.edit_kind,
@@ -2026,6 +2026,7 @@ fn register_local_asset_entry_with_change(
))
}
#[cfg(not(test))]
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct ReadLocalProjectAssetReferencesInput {
@@ -2093,6 +2094,7 @@ pub(crate) fn read_manifest_asset_references_at(
})
}
#[cfg(not(test))]
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct DeleteLocalProjectAssetInput {
@@ -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<Option<String>, 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<Option<String>, 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<Vec<ClientExtensionItem>, String> {
let root = extensions_root()?;
list_client_extensions_at(&root)
}
pub(crate) fn list_client_extensions_at(root: &Path) -> Result<Vec<ClientExtensionItem>, 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<ClientExtensionImportResult, String> {
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<ClientExtensionItem, String> {
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<ClientExtensionItem, String> {
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| {
@@ -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<Option<String>, 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<Option<String>, 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<Vec<ClientExtensionItem>, 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<ClientExtensionImportResult, String> {
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<ClientExtensionItem, String> {
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<ClientExtensionItem, String> {
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(())
}
@@ -376,21 +376,6 @@ pub(crate) fn closest_existing_project_picker_directory(path: &Path) -> Option<P
None
}
const AUTOMATIC_PROJECTS_DIRECTORY_NAME: &str = "projects";
fn automatic_local_game_projects_root(app: &tauri::AppHandle) -> Result<PathBuf, String> {
app.path()
.app_data_dir()
.map(|app_data_root| {
// Automatic workspaces are AGC-managed data. Keeping them below
// the hardened per-user app-data root avoids applying the strict
// private-DACL gate to a user Documents directory whose inherited
// ACL AGC is not allowed to rewrite.
app_data_root.join(AUTOMATIC_PROJECTS_DIRECTORY_NAME)
})
.map_err(|error| format!("无法读取 AGC 应用数据目录:{error}"))
}
/// 校验用户选择的项目创建目录。
///
/// 目录必须已经存在(原生目录选择器返回的结果),并且先过 AGC 私有路径门禁:门禁失败时
@@ -417,17 +402,6 @@ pub(crate) fn validate_requested_game_project_creation_root(
Ok(root.to_path_buf())
}
/// 解析本次建项要使用的根目录:没选就用 AGC 管理的默认目录,选了就用用户指定的目录。
pub(crate) fn resolve_game_project_creation_root(
app: &tauri::AppHandle,
requested: Option<&str>,
) -> Result<PathBuf, String> {
match requested.map(str::trim).filter(|value| !value.is_empty()) {
Some(requested) => validate_requested_game_project_creation_root(requested),
None => automatic_local_game_projects_root(app),
}
}
pub(crate) fn create_automatic_local_game_project_at(
projects_root: &Path,
requested_name: Option<&str>,
@@ -1661,3 +1661,29 @@ pub(crate) fn write_project_permission_policy(
let _lock = acquire_project_write_lock(root, "project.policy_write")?;
write_project_permission_policy_at(root, policy)
}
const AUTOMATIC_PROJECTS_DIRECTORY_NAME: &str = "projects";
fn automatic_local_game_projects_root(app: &tauri::AppHandle) -> Result<PathBuf, String> {
app.path()
.app_data_dir()
.map(|app_data_root| {
// Automatic workspaces are AGC-managed data. Keeping them below
// the hardened per-user app-data root avoids applying the strict
// private-DACL gate to a user Documents directory whose inherited
// ACL AGC is not allowed to rewrite.
app_data_root.join(AUTOMATIC_PROJECTS_DIRECTORY_NAME)
})
.map_err(|error| format!("无法读取 AGC 应用数据目录:{error}"))
}
/// 解析本次建项要使用的根目录:没选就用 AGC 管理的默认目录,选了就用用户指定的目录。
pub(crate) fn resolve_game_project_creation_root(
app: &tauri::AppHandle,
requested: Option<&str>,
) -> Result<PathBuf, String> {
match requested.map(str::trim).filter(|value| !value.is_empty()) {
Some(requested) => validate_requested_game_project_creation_root(requested),
None => automatic_local_game_projects_root(app),
}
}

Some files were not shown because too many files have changed in this diff Show More