Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b109d75d5 | |||
| 5755265f74 | |||
| 1424c3ee5a |
@@ -98,10 +98,13 @@ const appInvokeSources = readSourceFiles(
|
|||||||
new URL('../src/', import.meta.url),
|
new URL('../src/', import.meta.url),
|
||||||
new Set(['.ts', '.tsx']),
|
new Set(['.ts', '.tsx']),
|
||||||
);
|
);
|
||||||
const tauriHandlerSource = fs.readFileSync(
|
// 生产接线在 desktop,受测的启动诊断实现仍在 main;两者共同构成启动检查范围。
|
||||||
|
const tauriHandlerSource = [
|
||||||
new URL('../src-tauri/src/main.rs', import.meta.url),
|
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(
|
const tauriRustSource = readSourceTree(
|
||||||
new URL('../src-tauri/src/', import.meta.url),
|
new URL('../src-tauri/src/', import.meta.url),
|
||||||
'.rs',
|
'.rs',
|
||||||
|
|||||||
@@ -45,16 +45,19 @@ mod runtime_protocol;
|
|||||||
mod runtime_state;
|
mod runtime_state;
|
||||||
mod runtime_tools;
|
mod runtime_tools;
|
||||||
mod skill_pack;
|
mod skill_pack;
|
||||||
|
pub(crate) use codex_app_server::direct_game_creator_codex_chat_at;
|
||||||
use codex_app_server::*;
|
use codex_app_server::*;
|
||||||
|
#[cfg(not(test))]
|
||||||
pub(crate) use codex_app_server::{
|
pub(crate) use codex_app_server::{
|
||||||
cancel_direct_codex_turn_at, direct_game_creator_codex_chat_at,
|
cancel_direct_codex_turn_at, direct_game_creator_home_codex_chat, direct_thread_id_for_project,
|
||||||
direct_game_creator_home_codex_chat, direct_thread_id_for_project, DirectTurnCancelView,
|
DirectTurnCancelView,
|
||||||
};
|
};
|
||||||
use codex_cli::*;
|
use codex_cli::*;
|
||||||
pub(crate) use codex_cli::{
|
pub(crate) use codex_cli::{
|
||||||
game_creator_codex_cli_executable_path, game_creator_codex_cli_version_identity,
|
game_creator_codex_cli_executable_path, game_creator_codex_cli_version_identity,
|
||||||
};
|
};
|
||||||
pub(crate) use codex_provider_proxy::*;
|
pub(crate) use codex_provider_proxy::*;
|
||||||
|
#[cfg(not(test))]
|
||||||
pub(crate) use design_runtime::*;
|
pub(crate) use design_runtime::*;
|
||||||
pub(crate) use direct_codex_attachments::*;
|
pub(crate) use direct_codex_attachments::*;
|
||||||
pub(crate) use direct_codex_user_item::*;
|
pub(crate) use direct_codex_user_item::*;
|
||||||
|
|||||||
@@ -2480,42 +2480,7 @@ impl CodexAppServerConnection {
|
|||||||
Ok(connection)
|
Ok(connection)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(all(test, unix))]
|
||||||
async fn spawn(
|
|
||||||
llm: &GameCreatorLlmConfig,
|
|
||||||
credential: &CodexAppServerCredential,
|
|
||||||
) -> Result<Self, platform_llm::LlmError> {
|
|
||||||
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)]
|
|
||||||
async fn spawn_with_executable(
|
|
||||||
llm: &GameCreatorLlmConfig,
|
|
||||||
executable: &std::ffi::OsStr,
|
|
||||||
) -> Result<Self, platform_llm::LlmError> {
|
|
||||||
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, platform_llm::LlmError> {
|
|
||||||
Self::spawn_with_executable_and_credential_at_workspace(
|
|
||||||
llm,
|
|
||||||
credential,
|
|
||||||
executable,
|
|
||||||
None,
|
|
||||||
CodexAppServerWorkspaceMode::ToolHost,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
async fn spawn_with_executable_and_credential_at_workspace(
|
async fn spawn_with_executable_and_credential_at_workspace(
|
||||||
llm: &GameCreatorLlmConfig,
|
llm: &GameCreatorLlmConfig,
|
||||||
credential: &CodexAppServerCredential,
|
credential: &CodexAppServerCredential,
|
||||||
@@ -7095,6 +7060,8 @@ while IFS= read -r line; do :; done
|
|||||||
.map(|argument| argument.to_string_lossy().into_owned())
|
.map(|argument| argument.to_string_lossy().into_owned())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
assert!(home_arguments.contains("--disable\nshell_tool"));
|
||||||
|
assert!(home_arguments.contains("--disable\nunified_exec"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
|
|||||||
@@ -1688,7 +1688,6 @@ pub(crate) fn read_design_workspace_file(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod fake_provider {
|
mod fake_provider {
|
||||||
use super::*;
|
|
||||||
use std::cell::{Cell, RefCell};
|
use std::cell::{Cell, RefCell};
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use std::sync::{Mutex, OnceLock};
|
|||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
mod user_input;
|
mod user_input;
|
||||||
|
#[cfg(not(test))]
|
||||||
pub(crate) use user_input::chat_with_game_creator_direct_codex;
|
pub(crate) use user_input::chat_with_game_creator_direct_codex;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) use user_input::normalize_direct_client_turn_id;
|
pub(crate) use user_input::normalize_direct_client_turn_id;
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ pub(in crate::agent) use external_generation_state::{
|
|||||||
};
|
};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) use external_generation_state::{
|
pub(crate) use external_generation_state::{
|
||||||
platform_art_generation_external_configuration_fingerprint,
|
|
||||||
platform_art_generation_legacy_external_configuration_fingerprint,
|
platform_art_generation_legacy_external_configuration_fingerprint,
|
||||||
setup_platform_art_generation_runtime_accepted_for_recovery_test,
|
setup_platform_art_generation_runtime_accepted_for_recovery_test,
|
||||||
write_platform_art_generation_runtime_accepted_for_test,
|
write_platform_art_generation_runtime_accepted_for_test,
|
||||||
|
|||||||
+8
-47
@@ -817,24 +817,17 @@ mod tests {
|
|||||||
use super::{
|
use super::{
|
||||||
acquire_game_creator_agent_provider_plan_project_write_lock_with_wait,
|
acquire_game_creator_agent_provider_plan_project_write_lock_with_wait,
|
||||||
agent_runtime_root_source_at, bind_game_creator_agent_runtime_run_profile_at,
|
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,
|
build_game_creator_agent_background_tool_plan_request_locked,
|
||||||
game_creator_agent_context_preload_notice,
|
game_creator_agent_context_preload_notice, init_local_game_project_at,
|
||||||
game_creator_agent_runtime_run_profile_binding_path,
|
new_game_creation_app_seed_tasks,
|
||||||
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,
|
|
||||||
render_relaxed_autonomous_manifest_ready_task_background_prompt,
|
render_relaxed_autonomous_manifest_ready_task_background_prompt,
|
||||||
required_runtime_prompt_section, start_game_creator_agent_runtime_task_at,
|
start_game_creator_agent_runtime_task_at, AgentRuntimeGoalContractAcceptanceNodeDraft,
|
||||||
AgentRuntimeGoalContractAcceptanceNodeDraft, AgentRuntimeGoalContractDraft,
|
AgentRuntimeGoalContractDraft, AgentRuntimeTaskLink, AgentRuntimeToolObservation,
|
||||||
AgentRuntimeTaskLink, AgentRuntimeToolObservation, AgentRuntimeToolPlan,
|
|
||||||
AgentRuntimeToolPlanRequestSnapshot, GameCreatorLlmConfig,
|
AgentRuntimeToolPlanRequestSnapshot, GameCreatorLlmConfig,
|
||||||
AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT,
|
AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT, AGENT_RUNTIME_RESPOND_FUNCTION_NAME,
|
||||||
AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL, AGENT_RUNTIME_RESPOND_FUNCTION_NAME,
|
AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
|
||||||
AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_RUN_PROFILE_STANDARD,
|
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME,
|
||||||
AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE,
|
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||||
AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
|
||||||
RUNTIME_PROMPT_SUPERVISOR_CHAT_COMPOSITION,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
fn build_game_creator_agent_background_tool_plan_request_for_test(
|
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<String> {
|
|
||||||
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]
|
#[test]
|
||||||
fn relaxed_request_keeps_general_catalog_after_plan_rejection() {
|
fn relaxed_request_keeps_general_catalog_after_plan_rejection() {
|
||||||
let directory = crate::tests::canonical_test_tempdir("provider-plan-rejection-repair-");
|
let directory = crate::tests::canonical_test_tempdir("provider-plan-rejection-repair-");
|
||||||
@@ -1437,20 +1412,6 @@ mod tests {
|
|||||||
assert!(!prompt.contains("本轮唯一可用工具是 agent.goal_contract"));
|
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]
|
#[test]
|
||||||
fn supervisor_request_snapshot_preserves_prompt_visible_running_sibling_after_manifest_failure()
|
fn supervisor_request_snapshot_preserves_prompt_visible_running_sibling_after_manifest_failure()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -302,10 +302,7 @@ pub(crate) use entrypoints::{
|
|||||||
pub(crate) use finalization::resume_game_creator_agent_finalization_for_test_at;
|
pub(crate) use finalization::resume_game_creator_agent_finalization_for_test_at;
|
||||||
pub(crate) use finalization::AgentRuntimePendingActionResume;
|
pub(crate) use finalization::AgentRuntimePendingActionResume;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) use interaction::{
|
pub(crate) use interaction::agent_runtime_tool_requires_repository_context_fingerprint_gate;
|
||||||
acquire_game_creator_agent_runtime_user_input_answer_locks_for_test,
|
|
||||||
agent_runtime_tool_requires_repository_context_fingerprint_gate,
|
|
||||||
};
|
|
||||||
pub(crate) use interaction::{
|
pub(crate) use interaction::{
|
||||||
answer_game_creator_agent_runtime_user_input_at, confirm_game_creator_agent_runtime_task_at,
|
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,
|
pending_repository_context_drift_observation, reject_game_creator_agent_runtime_task_at,
|
||||||
|
|||||||
@@ -477,20 +477,6 @@ fn resolve_game_creator_agent_runtime_user_input_action_with_ordered_locks(
|
|||||||
Ok((None, runtime_lock, resolved))
|
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<ProjectWriteLock>, 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(
|
pub(crate) fn answer_game_creator_agent_runtime_user_input_at(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
agent_id: &str,
|
agent_id: &str,
|
||||||
|
|||||||
-5
@@ -11943,11 +11943,6 @@ impl ExternalGameplayJavascript {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|unit| unit.to_ascii_lowercase().contains(&marker))
|
.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(
|
fn normalize_javascript_module_analysis_sources(
|
||||||
|
|||||||
@@ -44,9 +44,7 @@ pub(in crate::agent) use ui_workflow::*;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) use delegation::observe_agent_runtime_agent_delegate_at_locked;
|
pub(crate) use delegation::observe_agent_runtime_agent_delegate_at_locked;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) use delivery::{
|
pub(crate) use delivery::build_static_delegate_result_for_child_at;
|
||||||
build_static_delegate_result_for_child_at, wake_waiting_static_delegate_parent_run_for_test_at,
|
|
||||||
};
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) use isolated_joins::render_isolated_join_status_batch_with_limit;
|
pub(crate) use isolated_joins::render_isolated_join_status_batch_with_limit;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -408,14 +408,6 @@ pub(in crate::agent) fn wake_waiting_static_delegate_parent_run_at(
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) fn wake_waiting_static_delegate_parent_run_for_test_at(
|
|
||||||
root: &Path,
|
|
||||||
parent_task: &AgentRuntimeTaskRecord,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
wake_waiting_static_delegate_parent_run_at(root, parent_task)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(in crate::agent) fn wake_waiting_autonomous_manifest_parent_run_at(
|
pub(in crate::agent) fn wake_waiting_autonomous_manifest_parent_run_at(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
parent_task: &AgentRuntimeTaskRecord,
|
parent_task: &AgentRuntimeTaskRecord,
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ use crate::agent::{
|
|||||||
AgentRuntimeToolPlan, AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT,
|
AgentRuntimeToolPlan, AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT,
|
||||||
AGENT_RUNTIME_CANVAS_ASSET_KINDS, AGENT_RUNTIME_PLAN_STEP_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_UPDATE_PLAN_FUNCTION_NAME: &str = "update_agent_plan";
|
||||||
pub(crate) const AGENT_RUNTIME_RESPOND_FUNCTION_NAME: &str = "respond_to_user";
|
pub(crate) const AGENT_RUNTIME_RESPOND_FUNCTION_NAME: &str = "respond_to_user";
|
||||||
|
|||||||
@@ -25,17 +25,20 @@ use std::sync::{Mutex, OnceLock};
|
|||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use crate::agent::{
|
use crate::agent::{
|
||||||
generate_platform_art_asset_with_options_at, read_agent_runtime_json_sidecar_with_max_bytes,
|
read_agent_runtime_json_sidecar_with_max_bytes, write_agent_runtime_json_sidecar_with_max_bytes,
|
||||||
write_agent_runtime_json_sidecar_with_max_bytes, PlatformArtAssetGenerationOptions,
|
|
||||||
};
|
};
|
||||||
use crate::commands::prepare_local_project_asset_generation;
|
|
||||||
use crate::project::{
|
use crate::project::{
|
||||||
enforce_project_permission_policy, prepare_local_project_audio_generation,
|
enforce_project_permission_policy, prepare_local_project_audio_generation,
|
||||||
read_existing_manifest_for_project, run_local_project_audio_generation_at,
|
read_existing_manifest_for_project, LocalProjectAudioGenerationRequest,
|
||||||
LocalProjectAudioGenerationRequest, LocalProjectResourceEditKind,
|
LocalProjectResourceEditKind,
|
||||||
};
|
};
|
||||||
use shared_contracts::game_creation_app::GameCreationAppAssetKind;
|
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_SCHEMA_VERSION: &str = "agc-asset-generation-task.v1";
|
||||||
pub(crate) const ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH: &str =
|
pub(crate) const ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH: &str =
|
||||||
".agent/runtime/asset-generation-tasks/tasks.json";
|
".agent/runtime/asset-generation-tasks/tasks.json";
|
||||||
@@ -45,12 +48,10 @@ 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_ID_MAX_CHARS: usize = 128;
|
||||||
|
|
||||||
pub(crate) const ASSET_GENERATION_TASK_STATUS_QUEUED: &str = "queued";
|
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_COMPLETED: &str = "completed";
|
||||||
pub(crate) const ASSET_GENERATION_TASK_STATUS_FAILED: &str = "failed";
|
pub(crate) const ASSET_GENERATION_TASK_STATUS_FAILED: &str = "failed";
|
||||||
|
|
||||||
const ASSET_GENERATION_TASK_PHASE_QUEUED: &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_COMPLETED: &str = "生成已完成。";
|
||||||
/// 中断收口:清单里已经能查到这次请求的目标素材 → 按事实收口为已完成。
|
/// 中断收口:清单里已经能查到这次请求的目标素材 → 按事实收口为已完成。
|
||||||
const ASSET_GENERATION_TASK_PHASE_INTERRUPTED_SETTLED: &str =
|
const ASSET_GENERATION_TASK_PHASE_INTERRUPTED_SETTLED: &str =
|
||||||
@@ -67,9 +68,6 @@ const ASSET_GENERATION_TASK_INTERRUPTED_INCOMPLETE_ERROR: &str =
|
|||||||
"应用退出时生成任务仍在进行,目标素材未登记";
|
"应用退出时生成任务仍在进行,目标素材未登记";
|
||||||
const ASSET_GENERATION_TASK_INTERRUPTED_UNKNOWN_ERROR: &str =
|
const ASSET_GENERATION_TASK_INTERRUPTED_UNKNOWN_ERROR: &str =
|
||||||
"应用退出时生成任务仍在进行,未能在清单里确认结果";
|
"应用退出时生成任务仍在进行,未能在清单里确认结果";
|
||||||
/// 音频任务收口:通道跑完但没有登记出素材(`derive` 在有源 / 无源两条路上都必须登记 assets)。
|
|
||||||
const ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR: &str = "生成完成但未登记素材";
|
|
||||||
|
|
||||||
/// 一条生成任务的权威记录。字段名与前端一一对应(camelCase)。
|
/// 一条生成任务的权威记录。字段名与前端一一对应(camelCase)。
|
||||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -357,57 +355,6 @@ fn register_live_task_id(task_id: &str) -> bool {
|
|||||||
.unwrap_or(false)
|
.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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 音频(音效 / 背景音乐)提交:校验入参 → 落**同一份**排队记录 → 返回记录与派发所需的请求。
|
/// 音频(音效 / 背景音乐)提交:校验入参 → 落**同一份**排队记录 → 返回记录与派发所需的请求。
|
||||||
///
|
///
|
||||||
/// 与图片类分支的差异只有三处,且都不改变账本形状:
|
/// 与图片类分支的差异只有三处,且都不改变账本形状:
|
||||||
@@ -455,167 +402,6 @@ fn begin_local_project_audio_generation_task(
|
|||||||
Ok((record, request))
|
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<String>,
|
|
||||||
image_size: Option<String>,
|
|
||||||
asset_name: Option<String>,
|
|
||||||
output_path: Option<String>,
|
|
||||||
// 前端 IPC 字段 `referenceAssetIds`:当前项目 manifest 里的图片素材 id,只做参考输入,
|
|
||||||
// 不进任务账本(重试由调用方继续用同一份引用提交,账本本身不新增字段)。
|
|
||||||
reference_asset_ids: Option<Vec<String>>,
|
|
||||||
// 前端 IPC 字段 `targetCategory`:完成登记时要落盘的正式栏目分类。同样不进任务账本:
|
|
||||||
// 它与引用一样属于「同一次提交的本地落点」,重试由调用方继续用同一个栏目提交。
|
|
||||||
target_category: Option<String>,
|
|
||||||
// 前端 IPC 字段 `idempotencyKey`:**音频**生成才带——音频请求身份是一对 operation / 幂等键,
|
|
||||||
// 重试必须复用同一对,否则就变成第二次付费生成。图片类通道的载荷逐字不变,这个字段对
|
|
||||||
// 图片 kind 不参与任何校验。
|
|
||||||
idempotency_key: Option<String>,
|
|
||||||
) -> Result<AssetGenerationTaskRecord, String> {
|
|
||||||
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]
|
#[tauri::command]
|
||||||
pub(crate) fn list_local_project_asset_generations(
|
pub(crate) fn list_local_project_asset_generations(
|
||||||
|
|||||||
@@ -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<String>,
|
||||||
|
image_size: Option<String>,
|
||||||
|
asset_name: Option<String>,
|
||||||
|
output_path: Option<String>,
|
||||||
|
// 前端 IPC 字段 `referenceAssetIds`:当前项目 manifest 里的图片素材 id,只做参考输入,
|
||||||
|
// 不进任务账本(重试由调用方继续用同一份引用提交,账本本身不新增字段)。
|
||||||
|
reference_asset_ids: Option<Vec<String>>,
|
||||||
|
// 前端 IPC 字段 `targetCategory`:完成登记时要落盘的正式栏目分类。同样不进任务账本:
|
||||||
|
// 它与引用一样属于「同一次提交的本地落点」,重试由调用方继续用同一个栏目提交。
|
||||||
|
target_category: Option<String>,
|
||||||
|
// 前端 IPC 字段 `idempotencyKey`:**音频**生成才带——音频请求身份是一对 operation / 幂等键,
|
||||||
|
// 重试必须复用同一对,否则就变成第二次付费生成。图片类通道的载荷逐字不变,这个字段对
|
||||||
|
// 图片 kind 不参与任何校验。
|
||||||
|
idempotency_key: Option<String>,
|
||||||
|
) -> Result<AssetGenerationTaskRecord, String> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ use super::playtest::{
|
|||||||
GENERIC_PLAYTEST_START_OPPORTUNITY_MIN_STABILITY_SAMPLES,
|
GENERIC_PLAYTEST_START_OPPORTUNITY_MIN_STABILITY_SAMPLES,
|
||||||
GENERIC_PLAYTEST_START_OPPORTUNITY_WINDOW, PLAYABLE_GAME_STATE_SCHEMA_VERSION,
|
GENERIC_PLAYTEST_START_OPPORTUNITY_WINDOW, PLAYABLE_GAME_STATE_SCHEMA_VERSION,
|
||||||
};
|
};
|
||||||
|
#[cfg(unix)]
|
||||||
use super::process::create_browser_process_temp_dir;
|
use super::process::create_browser_process_temp_dir;
|
||||||
|
|
||||||
static PATH_TEST_LOCK: Mutex<()> = Mutex::new(());
|
static PATH_TEST_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1291,6 +1291,8 @@ fn validate_game_creator_private_path_ancestors_with_auto_elevation_scoped(
|
|||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
{
|
{
|
||||||
|
// 测试只校验路径,不触发宿主提权修复,因此不消费修复范围。
|
||||||
|
let _ = scope;
|
||||||
return validate_game_creator_private_path_ancestors(path, label);
|
return validate_game_creator_private_path_ancestors(path, label);
|
||||||
}
|
}
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use tauri::{
|
#[cfg(not(test))]
|
||||||
plugin::{Builder, TauriPlugin},
|
mod runtime;
|
||||||
Runtime,
|
#[cfg(not(test))]
|
||||||
};
|
pub(crate) use runtime::init;
|
||||||
|
|
||||||
/// Release 客户端只取消浏览器的默认上下文菜单。
|
/// 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)
|
is_release.then_some(RELEASE_CONTEXT_MENU_INIT_SCRIPT)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(windows, not(debug_assertions)))]
|
|
||||||
fn disable_windows_default_context_menus<R: Runtime>(webview: tauri::Webview<R>) {
|
|
||||||
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<R: Runtime>() -> TauriPlugin<R> {
|
|
||||||
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -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<R: Runtime>(webview: tauri::Webview<R>) {
|
||||||
|
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<R: Runtime>() -> TauriPlugin<R> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -525,11 +525,21 @@ pub(crate) fn disconnect_unity_editor_connection() {
|
|||||||
let _ = disconnect_managed_editor_connection(ManagedEditor::Unity);
|
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<Option<PendingDelivery>> {
|
pub(super) fn unity_pending_delivery() -> &'static Mutex<Option<PendingDelivery>> {
|
||||||
pending_delivery(ManagedEditor::Unity)
|
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 {
|
pub(crate) fn unity_delivery_requires_ack(id: &str) -> bool {
|
||||||
pending_delivery(ManagedEditor::Unity)
|
pending_delivery(ManagedEditor::Unity)
|
||||||
.try_lock()
|
.try_lock()
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ use std::io::Read;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
#[cfg(not(test))]
|
||||||
|
pub(crate) use web_creation::preflight_web_game_creation;
|
||||||
pub(crate) use web_creation::{
|
pub(crate) use web_creation::{
|
||||||
host_web_creation_preflight, preflight_web_game_creation, prepare_new_web_project_at,
|
host_web_creation_preflight, prepare_new_web_project_at, record_new_web_scaffold_at,
|
||||||
record_new_web_scaffold_at,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|||||||
@@ -6,24 +6,20 @@
|
|||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
fs::{self, File},
|
fs::{self, File},
|
||||||
io::{Read, Seek, SeekFrom, Write},
|
io::Write,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
time::Duration,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
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";
|
#[cfg(not(test))]
|
||||||
|
mod runtime;
|
||||||
const AGC_CLIENT_MARKER_HEADER: &str = "X-Genarrative-Client";
|
#[cfg(not(test))]
|
||||||
const AGC_CLIENT_MARKER_VALUE: &str = "agc";
|
pub(crate) use runtime::{
|
||||||
const UPLOAD_OFFSET_HEADER: &str = "x-genarrative-upload-offset";
|
game_package_upload_staging_dir, progress_event_payload, upload_staged_game_package,
|
||||||
/// 服务端下发的分片大小上限;客户端只按服务端给的值发,超过它必然被拒。
|
GamePackageUploadOutcome, GamePackageUploadRequest, GAME_PACKAGE_UPLOAD_PROGRESS_EVENT,
|
||||||
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)]
|
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
|
||||||
@@ -35,36 +31,12 @@ pub(crate) struct StagedGamePackage {
|
|||||||
pub(crate) package_file_count: u32,
|
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)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
struct ChunkPlan {
|
struct ChunkPlan {
|
||||||
offset: u64,
|
offset: u64,
|
||||||
length: 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(
|
pub(crate) fn stage_game_package_bytes(
|
||||||
staging_dir: &Path,
|
staging_dir: &Path,
|
||||||
@@ -134,15 +106,6 @@ fn next_chunk_plan(received: u64, total: u64, chunk_bytes: u64) -> Option<ChunkP
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_chunk(file: &mut File, plan: ChunkPlan) -> Result<Vec<u8>, 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 {
|
fn upload_url(base_url: &str, version_id: &str, suffix: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
"{}/api/game-distribution/versions/{version_id}/package{suffix}",
|
"{}/api/game-distribution/versions/{version_id}/package{suffix}",
|
||||||
@@ -184,270 +147,6 @@ fn parse_received_bytes(body: &str) -> Option<u64> {
|
|||||||
.and_then(Value::as_u64)
|
.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<PackageUploadState, String> {
|
|
||||||
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<u8>,
|
|
||||||
) -> Result<u64, ChunkUploadError> {
|
|
||||||
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<GamePackageUploadOutcome, String> {
|
|
||||||
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) async fn upload_staged_game_package(
|
|
||||||
client: &reqwest::Client,
|
|
||||||
request: GamePackageUploadRequest<'_>,
|
|
||||||
mut on_progress: impl FnMut(u64, u64),
|
|
||||||
) -> Result<GamePackageUploadOutcome, String> {
|
|
||||||
let total_bytes = fs::metadata(request.staging_path)
|
|
||||||
.map_err(|error| format!("读取发行包暂存文件失败:{error}"))?
|
|
||||||
.len();
|
|
||||||
if total_bytes == 0 {
|
|
||||||
return Err("发行包暂存文件为空,请重新导出试玩包".to_string());
|
|
||||||
}
|
|
||||||
let state = read_upload_state(
|
|
||||||
client,
|
|
||||||
request.api_base_url,
|
|
||||||
request.version_id,
|
|
||||||
request.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 {
|
|
||||||
match upload_chunk(
|
|
||||||
client,
|
|
||||||
request.api_base_url,
|
|
||||||
request.version_id,
|
|
||||||
request.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 authoritative = read_upload_state(
|
|
||||||
client,
|
|
||||||
request.api_base_url,
|
|
||||||
request.version_id,
|
|
||||||
request.access_token,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
received = authoritative.received_bytes.min(total_bytes);
|
|
||||||
on_progress(received, total_bytes);
|
|
||||||
}
|
|
||||||
let mut outcome = complete_upload(
|
|
||||||
client,
|
|
||||||
request.api_base_url,
|
|
||||||
request.version_id,
|
|
||||||
request.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user