Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e695cdf499 | |||
| 4ca93a6f16 | |||
| 2f1da79f9c | |||
| c59a912eef | |||
| 3d145379f2 | |||
| ae5c2d8821 | |||
| e455cd593c | |||
| 38ae9d7d07 | |||
| abb3912530 | |||
| 8b4ec2c4d4 | |||
| ced4b56dee | |||
| 95e34b8760 | |||
| c51d88fd19 | |||
| 4d1c9d9b1b | |||
| 90cfdfdf97 | |||
| f65a813768 | |||
| b5dc99e067 | |||
| c94bb6d3ed | |||
| 101fd952d5 | |||
| 2c85ea0b83 | |||
| 13afdd7a3b | |||
| 05f0e2db06 | |||
| e368145f9d | |||
| 0123f3db76 | |||
| d6cb68cf13 | |||
| 132bf7db35 | |||
| 1575afc92c |
@@ -16,5 +16,9 @@
|
||||
"retryBackoffMs": 500
|
||||
},
|
||||
"agentLlm": {},
|
||||
"editorApi": {
|
||||
"baseUrl": "http://127.0.0.1:8082",
|
||||
"apiKey": ""
|
||||
},
|
||||
"mcpServers": {}
|
||||
}
|
||||
|
||||
@@ -1207,9 +1207,9 @@ for (const [agentId, agentConfig] of Object.entries(
|
||||
}
|
||||
}
|
||||
|
||||
if (defaultAppConfig.editorApi !== undefined) {
|
||||
if (defaultAppConfig.editorApi?.apiKey !== '') {
|
||||
throw new Error(
|
||||
'AI game creator shell ordinary default config must not contain editorApi',
|
||||
'AI game creator shell default editorApi.apiKey must stay empty',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -133,9 +133,6 @@ pub(crate) fn needs_platform_art_asset_generation(root: &Path, briefs: &[AgentGr
|
||||
}
|
||||
|
||||
pub(crate) fn editor_api_key_is_configured() -> bool {
|
||||
if editor_api_mode() == EditorApiMode::PlatformAccount {
|
||||
return platform_session_is_available();
|
||||
}
|
||||
load_game_creator_app_config()
|
||||
.ok()
|
||||
.and_then(|config| trim_config_string(&config.editor_api.api_key))
|
||||
@@ -386,19 +383,6 @@ pub(crate) fn classify_external_generation_initial_response(
|
||||
match status {
|
||||
reqwest::StatusCode::OK => {
|
||||
let generated = external_editor_response_data(payload);
|
||||
if let Some(queue_state) = generated
|
||||
.get("queueState")
|
||||
.filter(|queue_state| queue_state.is_object())
|
||||
{
|
||||
if let Some(operation_id) = json_string_field(queue_state, "operationId") {
|
||||
return Ok(ExternalGenerationInitialResponse::AsyncSubmission(
|
||||
serde_json::json!({
|
||||
"operationId": operation_id,
|
||||
"pollAfterMs": 2_000,
|
||||
}),
|
||||
));
|
||||
}
|
||||
}
|
||||
if !external_generation_result_has_download_reference(generated) {
|
||||
return Err(format!(
|
||||
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台旧同步图片生成响应缺少可下载结果"
|
||||
@@ -483,10 +467,9 @@ pub(crate) async fn wait_for_external_generation_result(
|
||||
let submission = external_editor_response_data(submission_payload);
|
||||
let operation_id = json_string_field(submission, "operationId")
|
||||
.ok_or_else(|| "外部图片生成提交响应缺少 operationId".to_string())?;
|
||||
let status_url = format!(
|
||||
"{api_base_url}{}",
|
||||
resolve_platform_generation_status_route(&operation_id)
|
||||
);
|
||||
let operation_id_path =
|
||||
url::form_urlencoded::byte_serialize(operation_id.as_bytes()).collect::<String>();
|
||||
let status_url = format!("{api_base_url}/api/external/v1/generations/{operation_id_path}");
|
||||
let started_at = tokio::time::Instant::now();
|
||||
let mut poll_after_ms = external_generation_poll_after_ms(submission_payload);
|
||||
|
||||
@@ -521,7 +504,7 @@ pub(crate) async fn wait_for_external_generation_result(
|
||||
));
|
||||
}
|
||||
};
|
||||
let generation = platform_generation_status_data(&payload);
|
||||
let generation = external_editor_response_data(&payload);
|
||||
match json_string_field(generation, "status").as_deref() {
|
||||
Some("completed") => {
|
||||
let result = generation
|
||||
@@ -573,32 +556,12 @@ pub(crate) async fn submit_external_generation_request(
|
||||
idempotency_key: &str,
|
||||
request_body_json: &str,
|
||||
) -> Result<reqwest::Response, String> {
|
||||
let mut request_body = serde_json::from_str::<serde_json::Value>(request_body_json)
|
||||
.map_err(|error| format!("解析平台图片生成请求失败:{error}"))?;
|
||||
if editor_api_mode() == EditorApiMode::PlatformAccount {
|
||||
let inputs = request_body.as_object_mut().and_then(|body| {
|
||||
body.entry("generationInputs")
|
||||
.or_insert_with(|| serde_json::json!({}))
|
||||
.as_object_mut()
|
||||
});
|
||||
if let Some(inputs) = inputs {
|
||||
inputs.insert(
|
||||
"source".to_string(),
|
||||
serde_json::Value::String("ai-game-creator-client".to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
let request_body_json = serde_json::to_string(&request_body)
|
||||
.map_err(|error| format!("序列化平台图片生成请求失败:{error}"))?;
|
||||
client
|
||||
.post(format!(
|
||||
"{api_base_url}{}",
|
||||
resolve_platform_editor_api_route(endpoint)
|
||||
))
|
||||
.post(format!("{api_base_url}{endpoint}"))
|
||||
.bearer_auth(api_key)
|
||||
.header("Idempotency-Key", idempotency_key)
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||
.body(request_body_json)
|
||||
.body(request_body_json.to_string())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
@@ -689,10 +652,7 @@ pub(crate) async fn prepare_external_canvas_generation_context(
|
||||
};
|
||||
let projects_payload = external_editor_json_request(
|
||||
client
|
||||
.get(format!(
|
||||
"{api_base_url}{}",
|
||||
resolve_platform_editor_api_route("/api/external/v1/editor/projects")
|
||||
))
|
||||
.get(format!("{api_base_url}/api/external/v1/editor/projects"))
|
||||
.bearer_auth(api_key),
|
||||
"读取外部画布项目",
|
||||
)
|
||||
@@ -710,10 +670,7 @@ pub(crate) async fn prepare_external_canvas_generation_context(
|
||||
None => {
|
||||
let payload = external_editor_json_request(
|
||||
client
|
||||
.post(format!(
|
||||
"{api_base_url}{}",
|
||||
resolve_platform_editor_api_route("/api/external/v1/editor/projects")
|
||||
))
|
||||
.post(format!("{api_base_url}/api/external/v1/editor/projects"))
|
||||
.bearer_auth(api_key)
|
||||
.json(&serde_json::json!({ "title": canvas_name })),
|
||||
"创建外部画布项目",
|
||||
@@ -729,8 +686,7 @@ pub(crate) async fn prepare_external_canvas_generation_context(
|
||||
let library_payload = external_editor_json_request(
|
||||
client
|
||||
.get(format!(
|
||||
"{api_base_url}{}",
|
||||
resolve_platform_editor_api_route("/api/external/v1/editor/assets/library")
|
||||
"{api_base_url}/api/external/v1/editor/assets/library"
|
||||
))
|
||||
.bearer_auth(api_key),
|
||||
"读取外部素材库",
|
||||
@@ -751,8 +707,7 @@ pub(crate) async fn prepare_external_canvas_generation_context(
|
||||
let payload = external_editor_json_request(
|
||||
client
|
||||
.post(format!(
|
||||
"{api_base_url}{}",
|
||||
resolve_platform_editor_api_route("/api/external/v1/editor/assets/folders")
|
||||
"{api_base_url}/api/external/v1/editor/assets/folders"
|
||||
))
|
||||
.bearer_auth(api_key)
|
||||
.json(&serde_json::json!({ "label": canvas_name })),
|
||||
|
||||
+1
-5
@@ -134,11 +134,7 @@ fn request_body_json_and_sha256(
|
||||
|
||||
pub(crate) fn platform_art_generation_external_service_fingerprint(api_base_url: &str) -> String {
|
||||
let normalized_base_url = api_base_url.trim().trim_end_matches('/');
|
||||
let account_identity = platform_session_service_identity().unwrap_or_default();
|
||||
format!(
|
||||
"{:x}",
|
||||
Sha256::digest(format!("{normalized_base_url}\n{account_identity}").as_bytes())
|
||||
)
|
||||
format!("{:x}", Sha256::digest(normalized_base_url.as_bytes()))
|
||||
}
|
||||
|
||||
pub(crate) fn platform_art_generation_external_service_origin(
|
||||
|
||||
@@ -247,12 +247,9 @@ pub(crate) async fn sync_canvas_project_assets_at(
|
||||
let api_key = resolve_canvas_sync_api_key(api_key)?;
|
||||
let client = reqwest::Client::new();
|
||||
let project_url = format!(
|
||||
"{}{}",
|
||||
"{}/api/external/v1/editor/projects/{}",
|
||||
api_base_url,
|
||||
resolve_platform_editor_api_route(&format!(
|
||||
"/api/external/v1/editor/projects/{}",
|
||||
percent_encode_query_component(canvas_project_id)
|
||||
))
|
||||
percent_encode_query_component(canvas_project_id)
|
||||
);
|
||||
let project_response = client
|
||||
.get(project_url)
|
||||
@@ -618,7 +615,7 @@ pub(crate) async fn resolve_canvas_resource_download_with_limit(
|
||||
api_key,
|
||||
resource,
|
||||
max_bytes,
|
||||
&resolve_platform_editor_api_route("/api/external/v1/assets/read-url"),
|
||||
"/api/external/v1/assets/read-url",
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -779,11 +776,6 @@ pub(crate) async fn resolve_external_asset_signed_url(
|
||||
pub(crate) fn resolve_canvas_sync_api_base_url(
|
||||
api_base_url: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
if editor_api_mode() == EditorApiMode::PlatformAccount {
|
||||
return current_platform_session()
|
||||
.map(|session| session.api_base_url)
|
||||
.ok_or_else(|| "authentication-required: 请先登录陶泥儿账号".to_string());
|
||||
}
|
||||
let config = load_game_creator_app_config()?;
|
||||
let value = trim_optional_string(api_base_url)
|
||||
.or_else(|| trim_config_string(&config.editor_api.base_url))
|
||||
@@ -797,113 +789,17 @@ pub(crate) fn resolve_canvas_sync_api_base_url(
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_canvas_sync_api_key(api_key: Option<String>) -> Result<String, String> {
|
||||
if editor_api_mode() == EditorApiMode::PlatformAccount {
|
||||
return current_platform_session()
|
||||
.map(|session| session.access_token)
|
||||
.ok_or_else(|| "authentication-required: 请先登录陶泥儿账号".to_string());
|
||||
}
|
||||
let config = load_game_creator_app_config()?;
|
||||
trim_optional_string(api_key)
|
||||
.or_else(|| trim_config_string(&config.editor_api.api_key))
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"请在 {} 的 editorApi.apiKey 中设置开发者 API Key",
|
||||
"画板同步需要在 {} 的 editorApi.apiKey 中设置 API Key",
|
||||
game_creator_config_file_label(GAME_CREATOR_CONFIG_FILE_NAME)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_canvas_sync_api_credentials(
|
||||
api_base_url: Option<String>,
|
||||
api_key: Option<String>,
|
||||
) -> Result<(String, String, Option<PlatformSessionSnapshot>), String> {
|
||||
if editor_api_mode() == EditorApiMode::PlatformAccount {
|
||||
let session = current_platform_session()
|
||||
.ok_or_else(|| "authentication-required: 请先登录陶泥儿账号".to_string())?;
|
||||
return Ok((
|
||||
session.api_base_url.clone(),
|
||||
session.access_token.clone(),
|
||||
Some(session),
|
||||
));
|
||||
}
|
||||
Ok((
|
||||
resolve_canvas_sync_api_base_url(api_base_url)?,
|
||||
resolve_canvas_sync_api_key(api_key)?,
|
||||
None,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_platform_editor_api_route(external_route: &str) -> String {
|
||||
resolve_platform_editor_api_route_for_mode(
|
||||
external_route,
|
||||
editor_api_mode() == EditorApiMode::PlatformAccount,
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_platform_editor_api_route_for_mode(
|
||||
external_route: &str,
|
||||
platform_session_available: bool,
|
||||
) -> String {
|
||||
if !platform_session_available {
|
||||
return external_route.to_string();
|
||||
}
|
||||
if let Some(suffix) = external_route.strip_prefix("/api/external/v1/editor/") {
|
||||
return format!("/api/editor/{suffix}");
|
||||
}
|
||||
if let Some(suffix) = external_route.strip_prefix("/api/external/v1/assets/") {
|
||||
return format!("/api/assets/{suffix}");
|
||||
}
|
||||
external_route.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_platform_generation_status_route(operation_id: &str) -> String {
|
||||
resolve_platform_generation_status_route_for_mode(
|
||||
operation_id,
|
||||
editor_api_mode() == EditorApiMode::PlatformAccount,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn editor_api_authentication_error() -> String {
|
||||
match editor_api_mode() {
|
||||
EditorApiMode::PlatformAccount => {
|
||||
"authentication-required: 陶泥儿登录已失效,请重新登录".to_string()
|
||||
}
|
||||
EditorApiMode::ExternalDeveloper => {
|
||||
"authentication-required: External Editor API Key 无效或权限不足".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn editor_api_authorization_error() -> String {
|
||||
match editor_api_mode() {
|
||||
EditorApiMode::PlatformAccount => {
|
||||
"permission-denied: 当前陶泥儿账号没有执行此操作的权限".to_string()
|
||||
}
|
||||
EditorApiMode::ExternalDeveloper => {
|
||||
"permission-denied: External Editor API Key 权限不足".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_platform_generation_status_route_for_mode(
|
||||
operation_id: &str,
|
||||
platform_session_available: bool,
|
||||
) -> String {
|
||||
let operation_id = percent_encode_query_component(operation_id);
|
||||
if platform_session_available {
|
||||
format!("/api/runtime/external-generation/jobs/{operation_id}")
|
||||
} else {
|
||||
format!("/api/external/v1/generations/{operation_id}")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn platform_generation_status_data(payload: &serde_json::Value) -> &serde_json::Value {
|
||||
let data = payload.get("data").unwrap_or(payload);
|
||||
data.get("job")
|
||||
.filter(|job| job.is_object())
|
||||
.unwrap_or(data)
|
||||
}
|
||||
|
||||
pub(crate) fn json_string_field(value: &serde_json::Value, field: &str) -> Option<String> {
|
||||
value
|
||||
.get(field)
|
||||
@@ -1214,51 +1110,6 @@ mod tests {
|
||||
use super::*;
|
||||
use std::io::{Read, Write};
|
||||
|
||||
#[test]
|
||||
fn platform_session_maps_external_shaped_calls_to_first_party_routes() {
|
||||
assert_eq!(
|
||||
resolve_platform_editor_api_route_for_mode("/api/external/v1/editor/projects", true,),
|
||||
"/api/editor/projects"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_platform_editor_api_route_for_mode("/api/external/v1/assets/read-url", true,),
|
||||
"/api/assets/read-url"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_platform_generation_status_route_for_mode("operation/a b", true),
|
||||
"/api/runtime/external-generation/jobs/operation%2Fa%20b"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_platform_generation_status_route_for_mode("operation/a b", false),
|
||||
"/api/external/v1/generations/operation%2Fa%20b"
|
||||
);
|
||||
let payload = serde_json::json!({
|
||||
"data": {
|
||||
"job": {
|
||||
"operationId": "operation-1",
|
||||
"status": "completed",
|
||||
"result": { "objectKey": "generated/result.png" },
|
||||
}
|
||||
}
|
||||
});
|
||||
assert_eq!(
|
||||
platform_generation_status_data(&payload)["result"]["objectKey"],
|
||||
"generated/result.png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_external_mode_keeps_external_routes_without_platform_session() {
|
||||
assert_eq!(
|
||||
resolve_platform_editor_api_route_for_mode("/api/external/v1/editor/projects", false,),
|
||||
"/api/external/v1/editor/projects"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_platform_generation_status_route_for_mode("operation-1", false),
|
||||
"/api/external/v1/generations/operation-1"
|
||||
);
|
||||
}
|
||||
|
||||
fn read_asset_test_request(stream: &mut std::net::TcpStream) {
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
|
||||
@@ -1270,30 +1270,6 @@ pub(crate) fn check_game_creator_llm_config() -> GameCreatorLlmConfigStatus {
|
||||
check_game_creator_llm_config_from_config()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn install_platform_account_session(
|
||||
user_id: String,
|
||||
access_token: String,
|
||||
api_base_url: String,
|
||||
generation: u64,
|
||||
) -> Result<(), String> {
|
||||
install_platform_session(&user_id, &access_token, &api_base_url, generation)?;
|
||||
let _ = install_external_agent_runner_platform_session(
|
||||
&user_id,
|
||||
&access_token,
|
||||
&api_base_url,
|
||||
generation,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn clear_platform_account_session(generation: u64) -> Result<(), String> {
|
||||
clear_platform_session(generation);
|
||||
let _ = clear_external_agent_runner_platform_session(generation);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_game_creator_app_config() -> Result<GameCreatorAppConfigView, String> {
|
||||
game_creator_app_config_view(load_game_creator_app_config()?)
|
||||
@@ -1305,7 +1281,8 @@ pub(crate) fn write_game_creator_app_config(
|
||||
) -> Result<GameCreatorAppConfigView, String> {
|
||||
let config = normalize_game_creator_app_config(config)?;
|
||||
let path = writable_game_creator_config_path()?;
|
||||
let content = serialize_game_creator_app_config_for_renderer_write(&config)?;
|
||||
let content = serde_json::to_string_pretty(&config)
|
||||
.map_err(|error| format!("序列化客户端配置失败:{error}"))?;
|
||||
write_game_creator_config_atomically(&path, &format!("{content}\n"))?;
|
||||
game_creator_app_config_view(load_game_creator_app_config()?)
|
||||
}
|
||||
|
||||
@@ -1328,31 +1328,14 @@ pub(crate) fn load_game_creator_app_config() -> Result<GameCreatorAppConfig, Str
|
||||
}
|
||||
|
||||
pub(crate) fn game_creator_app_config_view(
|
||||
mut config: GameCreatorAppConfig,
|
||||
config: GameCreatorAppConfig,
|
||||
) -> Result<GameCreatorAppConfigView, String> {
|
||||
if editor_api_mode() == EditorApiMode::PlatformAccount {
|
||||
config.editor_api = GameCreatorEditorApiConfig::default();
|
||||
}
|
||||
Ok(GameCreatorAppConfigView {
|
||||
path: writable_game_creator_config_path()?.display().to_string(),
|
||||
config,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn serialize_game_creator_app_config_for_renderer_write(
|
||||
config: &GameCreatorAppConfig,
|
||||
) -> Result<String, String> {
|
||||
let mut value =
|
||||
serde_json::to_value(config).map_err(|error| format!("序列化客户端配置失败:{error}"))?;
|
||||
if editor_api_mode() == EditorApiMode::PlatformAccount {
|
||||
value
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| "客户端配置必须是 JSON object".to_string())?
|
||||
.remove("editorApi");
|
||||
}
|
||||
serde_json::to_string_pretty(&value).map_err(|error| format!("序列化客户端配置失败:{error}"))
|
||||
}
|
||||
|
||||
pub(crate) fn writable_game_creator_config_path() -> Result<PathBuf, String> {
|
||||
if let Some(config_dir) = game_creator_runtime_config_dir() {
|
||||
return Ok(config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME));
|
||||
|
||||
@@ -67,7 +67,6 @@ mod image_inspect;
|
||||
mod isolated_agent;
|
||||
mod mcp;
|
||||
mod patchset;
|
||||
mod platform_session;
|
||||
mod preview;
|
||||
mod process_session;
|
||||
mod process_session_bridge;
|
||||
@@ -102,7 +101,6 @@ use image_inspect::*;
|
||||
use isolated_agent::*;
|
||||
use mcp::*;
|
||||
use patchset::*;
|
||||
use platform_session::*;
|
||||
use preview::*;
|
||||
use process_session::*;
|
||||
use project::*;
|
||||
@@ -1241,7 +1239,7 @@ fn default_game_creator_llm_auto_compact_token_limit() -> u64 {
|
||||
fn default_game_creator_llm_tool_output_token_limit() -> u64 {
|
||||
DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT
|
||||
}
|
||||
const DEFAULT_CANVAS_SYNC_API_BASE_URL: &str = "https://dev.genarrative.world";
|
||||
const DEFAULT_CANVAS_SYNC_API_BASE_URL: &str = "http://127.0.0.1:8082";
|
||||
const DEFAULT_GAME_CREATOR_APP_CONFIG_JSON: &str = include_str!("../../game-creator.config.json");
|
||||
const GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS: u32 = 320000;
|
||||
const GAME_CREATOR_CHAT_AGENT_MAX_OUTPUT_TOKENS: u32 = 1800;
|
||||
@@ -2216,8 +2214,6 @@ fn main() {
|
||||
confirm_resume_game_creator_agent_runtime_tasks,
|
||||
schedule_game_creator_agent_ready_tasks,
|
||||
check_game_creator_llm_config,
|
||||
install_platform_account_session,
|
||||
clear_platform_account_session,
|
||||
read_game_creator_app_config,
|
||||
write_game_creator_app_config,
|
||||
read_game_creator_mcp_catalog,
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct PlatformSessionSnapshot {
|
||||
pub(crate) user_id: String,
|
||||
pub(crate) access_token: String,
|
||||
pub(crate) api_base_url: String,
|
||||
pub(crate) generation: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum EditorApiMode {
|
||||
PlatformAccount,
|
||||
ExternalDeveloper,
|
||||
}
|
||||
|
||||
pub(crate) fn editor_api_mode_for_build(
|
||||
debug_assertions: bool,
|
||||
game_chat_release_feature: bool,
|
||||
) -> EditorApiMode {
|
||||
if !debug_assertions && game_chat_release_feature {
|
||||
EditorApiMode::ExternalDeveloper
|
||||
} else {
|
||||
EditorApiMode::PlatformAccount
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn editor_api_mode() -> EditorApiMode {
|
||||
editor_api_mode_for_build(cfg!(debug_assertions), cfg!(feature = "game-chat-release"))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PlatformSessionState {
|
||||
generation: u64,
|
||||
snapshot: Option<PlatformSessionSnapshot>,
|
||||
}
|
||||
|
||||
static PLATFORM_SESSION: OnceLock<Mutex<PlatformSessionState>> = OnceLock::new();
|
||||
|
||||
fn platform_session() -> &'static Mutex<PlatformSessionState> {
|
||||
PLATFORM_SESSION.get_or_init(|| Mutex::new(PlatformSessionState::default()))
|
||||
}
|
||||
|
||||
fn install_platform_session_in(
|
||||
current: &mut PlatformSessionState,
|
||||
user_id: &str,
|
||||
access_token: &str,
|
||||
api_base_url: &str,
|
||||
generation: u64,
|
||||
) {
|
||||
if generation < current.generation {
|
||||
return;
|
||||
}
|
||||
if generation == current.generation {
|
||||
if current.snapshot.as_ref().is_some_and(|snapshot| {
|
||||
snapshot.user_id == user_id
|
||||
&& snapshot.access_token == access_token
|
||||
&& snapshot.api_base_url == api_base_url
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
// Equal-generation retries may only repeat the exact committed snapshot. In
|
||||
// particular, a late install cannot revive a generation that was cleared.
|
||||
return;
|
||||
}
|
||||
current.generation = generation;
|
||||
current.snapshot = Some(PlatformSessionSnapshot {
|
||||
user_id: user_id.to_string(),
|
||||
access_token: access_token.to_string(),
|
||||
api_base_url: api_base_url.to_string(),
|
||||
generation,
|
||||
});
|
||||
}
|
||||
|
||||
fn clear_platform_session_in(current: &mut PlatformSessionState, generation: u64) {
|
||||
if generation < current.generation {
|
||||
return;
|
||||
}
|
||||
current.generation = generation;
|
||||
current.snapshot = None;
|
||||
}
|
||||
|
||||
pub(crate) fn install_platform_session(
|
||||
user_id: &str,
|
||||
access_token: &str,
|
||||
api_base_url: &str,
|
||||
generation: u64,
|
||||
) -> Result<(), String> {
|
||||
if editor_api_mode() == EditorApiMode::ExternalDeveloper {
|
||||
return Err("独立 game-chat 高级模式不接受陶泥儿网站登录态".to_string());
|
||||
}
|
||||
let user_id = user_id.trim();
|
||||
let access_token = access_token.trim();
|
||||
let api_base_url = normalize_platform_api_base_url(api_base_url)?;
|
||||
if user_id.is_empty() || user_id.len() > 256 {
|
||||
return Err("陶泥儿登录用户身份无效".to_string());
|
||||
}
|
||||
if access_token.is_empty() || access_token.len() > 16 * 1024 {
|
||||
return Err("陶泥儿登录凭据无效".to_string());
|
||||
}
|
||||
let mut current = platform_session()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
install_platform_session_in(
|
||||
&mut current,
|
||||
user_id,
|
||||
access_token,
|
||||
&api_base_url,
|
||||
generation,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_platform_api_base_url(value: &str) -> Result<String, String> {
|
||||
let value = value.trim().trim_end_matches('/');
|
||||
let parsed = url::Url::parse(value).map_err(|_| "陶泥儿服务地址无效".to_string())?;
|
||||
if !matches!(parsed.scheme(), "http" | "https")
|
||||
|| !parsed.username().is_empty()
|
||||
|| parsed.password().is_some()
|
||||
|| parsed.query().is_some()
|
||||
|| parsed.fragment().is_some()
|
||||
|| parsed.path() != "/"
|
||||
{
|
||||
return Err("陶泥儿服务地址必须是纯 HTTP(S) origin".to_string());
|
||||
}
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| "陶泥儿服务地址缺少 host".to_string())?;
|
||||
let production = matches!(host, "www.genarrative.world" | "dev.genarrative.world");
|
||||
let loopback = host == "localhost"
|
||||
|| host == "127.0.0.1"
|
||||
|| host
|
||||
.parse::<std::net::IpAddr>()
|
||||
.is_ok_and(|ip| ip.is_loopback());
|
||||
if !production && !(cfg!(debug_assertions) && loopback && parsed.scheme() == "http") {
|
||||
return Err("陶泥儿服务地址不在受信任白名单内".to_string());
|
||||
}
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn clear_platform_session(generation: u64) {
|
||||
let mut current = platform_session()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
clear_platform_session_in(&mut current, generation);
|
||||
}
|
||||
|
||||
pub(crate) fn current_platform_session() -> Option<PlatformSessionSnapshot> {
|
||||
platform_session()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.snapshot
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn validate_platform_session_snapshot(
|
||||
expected: &PlatformSessionSnapshot,
|
||||
) -> Result<(), String> {
|
||||
if platform_session_snapshot_matches(current_platform_session().as_ref(), expected) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(
|
||||
"authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试"
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn platform_session_snapshot_matches(
|
||||
current: Option<&PlatformSessionSnapshot>,
|
||||
expected: &PlatformSessionSnapshot,
|
||||
) -> bool {
|
||||
current == Some(expected)
|
||||
}
|
||||
|
||||
pub(crate) fn platform_session_is_available() -> bool {
|
||||
current_platform_session().is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn platform_session_service_identity() -> Option<String> {
|
||||
current_platform_session()
|
||||
.map(|snapshot| format!("{}\nuser:{}", snapshot.api_base_url, snapshot.user_id))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cleared_generation_rejects_late_install_and_older_clear() {
|
||||
let mut state = PlatformSessionState::default();
|
||||
install_platform_session_in(
|
||||
&mut state,
|
||||
"user-a",
|
||||
"token-a",
|
||||
"https://dev.genarrative.world",
|
||||
1,
|
||||
);
|
||||
clear_platform_session_in(&mut state, 2);
|
||||
install_platform_session_in(
|
||||
&mut state,
|
||||
"user-a",
|
||||
"late-token-a",
|
||||
"https://dev.genarrative.world",
|
||||
1,
|
||||
);
|
||||
install_platform_session_in(
|
||||
&mut state,
|
||||
"user-a",
|
||||
"same-generation-token",
|
||||
"https://dev.genarrative.world",
|
||||
2,
|
||||
);
|
||||
assert!(state.snapshot.is_none());
|
||||
assert_eq!(state.generation, 2);
|
||||
|
||||
install_platform_session_in(
|
||||
&mut state,
|
||||
"user-b",
|
||||
"token-b",
|
||||
"https://dev.genarrative.world",
|
||||
3,
|
||||
);
|
||||
clear_platform_session_in(&mut state, 2);
|
||||
assert_eq!(
|
||||
state.snapshot.as_ref().map(|value| value.user_id.as_str()),
|
||||
Some("user-b")
|
||||
);
|
||||
assert_eq!(state.generation, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_generation_only_accepts_the_exact_idempotent_snapshot() {
|
||||
let mut state = PlatformSessionState::default();
|
||||
install_platform_session_in(
|
||||
&mut state,
|
||||
"user-a",
|
||||
"token-a",
|
||||
"https://dev.genarrative.world",
|
||||
4,
|
||||
);
|
||||
install_platform_session_in(
|
||||
&mut state,
|
||||
"user-a",
|
||||
"token-a",
|
||||
"https://dev.genarrative.world",
|
||||
4,
|
||||
);
|
||||
install_platform_session_in(
|
||||
&mut state,
|
||||
"user-b",
|
||||
"token-b",
|
||||
"https://dev.genarrative.world",
|
||||
4,
|
||||
);
|
||||
assert_eq!(
|
||||
state.snapshot.as_ref().map(|value| value.user_id.as_str()),
|
||||
Some("user-a")
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.snapshot
|
||||
.as_ref()
|
||||
.map(|value| value.access_token.as_str()),
|
||||
Some("token-a")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_api_mode_is_fixed_by_the_trusted_build_flavor() {
|
||||
assert_eq!(
|
||||
editor_api_mode_for_build(true, false),
|
||||
EditorApiMode::PlatformAccount
|
||||
);
|
||||
assert_eq!(
|
||||
editor_api_mode_for_build(true, true),
|
||||
EditorApiMode::PlatformAccount
|
||||
);
|
||||
assert_eq!(
|
||||
editor_api_mode_for_build(false, false),
|
||||
EditorApiMode::PlatformAccount
|
||||
);
|
||||
assert_eq!(
|
||||
editor_api_mode_for_build(false, true),
|
||||
EditorApiMode::ExternalDeveloper
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_platform_session_rejects_logout_account_switch_and_token_rotation() {
|
||||
let expected = PlatformSessionSnapshot {
|
||||
user_id: "user-a".to_string(),
|
||||
access_token: "token-a".to_string(),
|
||||
api_base_url: "https://dev.genarrative.world".to_string(),
|
||||
generation: 4,
|
||||
};
|
||||
assert!(platform_session_snapshot_matches(
|
||||
Some(&expected),
|
||||
&expected
|
||||
));
|
||||
|
||||
for current in [
|
||||
None,
|
||||
Some(PlatformSessionSnapshot {
|
||||
user_id: "user-b".to_string(),
|
||||
..expected.clone()
|
||||
}),
|
||||
Some(PlatformSessionSnapshot {
|
||||
access_token: "token-b".to_string(),
|
||||
generation: 5,
|
||||
..expected.clone()
|
||||
}),
|
||||
] {
|
||||
assert!(!platform_session_snapshot_matches(
|
||||
current.as_ref(),
|
||||
&expected
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -9,10 +9,9 @@ mod state;
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use client::{
|
||||
attach_external_agent_runner_gui_owner, cancel_external_agent_runner_goal,
|
||||
clear_external_agent_runner_platform_session, compact_external_agent_runner_context,
|
||||
configure_external_agent_runner, configure_external_agent_runner_read_only,
|
||||
continue_external_agent_runner_action, ensure_external_agent_runner_started,
|
||||
ensure_external_agent_runner_started_for_gui, install_external_agent_runner_platform_session,
|
||||
compact_external_agent_runner_context, configure_external_agent_runner,
|
||||
configure_external_agent_runner_read_only, continue_external_agent_runner_action,
|
||||
ensure_external_agent_runner_started, ensure_external_agent_runner_started_for_gui,
|
||||
interrupt_external_agent_runner_provider_for_steer_decision, notify_external_agent_runner,
|
||||
pause_external_agent_runner, read_external_agent_runner_mcp_catalog,
|
||||
read_external_agent_runner_status,
|
||||
|
||||
@@ -106,7 +106,7 @@ fn redact_url_queries(line: &str) -> String {
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
pub(super) fn sanitize_agent_runner_output(line: &str, config_dir: &Path) -> String {
|
||||
fn sanitize_agent_runner_output(line: &str, config_dir: &Path) -> String {
|
||||
let lowercase = line.to_ascii_lowercase();
|
||||
if [
|
||||
"authorization",
|
||||
@@ -123,9 +123,6 @@ pub(super) fn sanitize_agent_runner_output(line: &str, config_dir: &Path) -> Str
|
||||
"set-cookie",
|
||||
"secret",
|
||||
"access_token",
|
||||
"accesstoken",
|
||||
"platform_access_token",
|
||||
"platformaccesstoken",
|
||||
"refresh_token",
|
||||
"\"token\"",
|
||||
"'token'",
|
||||
@@ -996,125 +993,18 @@ pub(crate) fn attach_external_agent_runner_gui_owner(
|
||||
let _configure = lock_unpoisoned(external_agent_runner_configure_lock());
|
||||
let config_dir = external_agent_runner_config_dir()
|
||||
.ok_or_else(|| "外部 Agent Runner 尚未配置 AppData;请显式传入 --config-dir".to_string())?;
|
||||
let platform_session = crate::current_platform_session();
|
||||
register_external_agent_runner_gui_owner_attachment(
|
||||
external_agent_runner_gui_owner_attachment_state(),
|
||||
&config_dir,
|
||||
ExternalAgentRunnerRequestParams {
|
||||
event_sink_port: Some(event_sink.port),
|
||||
event_sink_token: Some(event_sink.token.clone()),
|
||||
platform_user_id: platform_session
|
||||
.as_ref()
|
||||
.map(|session| session.user_id.clone()),
|
||||
platform_access_token: platform_session
|
||||
.as_ref()
|
||||
.map(|session| session.access_token.clone()),
|
||||
platform_api_base_url: platform_session
|
||||
.as_ref()
|
||||
.map(|session| session.api_base_url.clone()),
|
||||
platform_auth_generation: platform_session.map(|session| session.generation),
|
||||
..ExternalAgentRunnerRequestParams::default()
|
||||
},
|
||||
);
|
||||
ensure_external_agent_runner(&config_dir).map(|_| ())
|
||||
}
|
||||
|
||||
pub(crate) fn install_external_agent_runner_platform_session(
|
||||
user_id: &str,
|
||||
access_token: &str,
|
||||
api_base_url: &str,
|
||||
generation: u64,
|
||||
) -> Result<(), String> {
|
||||
remember_external_agent_runner_platform_session(
|
||||
external_agent_runner_gui_owner_attachment_state(),
|
||||
Some((user_id, access_token, api_base_url)),
|
||||
generation,
|
||||
);
|
||||
let config_dir = external_agent_runner_config_dir()
|
||||
.ok_or_else(|| "外部 Agent Runner 尚未配置 AppData".to_string())?;
|
||||
let endpoint = {
|
||||
let _configure = lock_unpoisoned(external_agent_runner_configure_lock());
|
||||
ensure_external_agent_runner(&config_dir)?
|
||||
};
|
||||
send_external_agent_runner_request(
|
||||
&endpoint,
|
||||
"platform.session.install",
|
||||
ExternalAgentRunnerRequestParams {
|
||||
platform_user_id: Some(user_id.to_string()),
|
||||
platform_access_token: Some(access_token.to_string()),
|
||||
platform_api_base_url: Some(api_base_url.to_string()),
|
||||
platform_auth_generation: Some(generation),
|
||||
..ExternalAgentRunnerRequestParams::default()
|
||||
},
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub(crate) fn clear_external_agent_runner_platform_session(generation: u64) -> Result<(), String> {
|
||||
remember_external_agent_runner_platform_session(
|
||||
external_agent_runner_gui_owner_attachment_state(),
|
||||
None,
|
||||
generation,
|
||||
);
|
||||
let Some(config_dir) = external_agent_runner_config_dir() else {
|
||||
return Ok(());
|
||||
};
|
||||
let endpoint_path = external_agent_runner_endpoint_path(&config_dir);
|
||||
let Ok(endpoint) = read_external_agent_runner_endpoint(&endpoint_path) else {
|
||||
return Ok(());
|
||||
};
|
||||
send_external_agent_runner_request(
|
||||
&endpoint,
|
||||
"platform.session.clear",
|
||||
ExternalAgentRunnerRequestParams {
|
||||
platform_auth_generation: Some(generation),
|
||||
..ExternalAgentRunnerRequestParams::default()
|
||||
},
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub(super) fn remember_external_agent_runner_platform_session(
|
||||
state: &Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
|
||||
session: Option<(&str, &str, &str)>,
|
||||
generation: u64,
|
||||
) {
|
||||
let mut state = lock_unpoisoned(state);
|
||||
let Some(registration) = state.registration.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let current_generation = registration.params.platform_auth_generation.unwrap_or(0);
|
||||
if generation < current_generation {
|
||||
return;
|
||||
}
|
||||
if generation == current_generation {
|
||||
match session {
|
||||
Some((user_id, access_token, api_base_url))
|
||||
if registration.params.platform_user_id.as_deref() == Some(user_id)
|
||||
&& registration.params.platform_access_token.as_deref()
|
||||
== Some(access_token)
|
||||
&& registration.params.platform_api_base_url.as_deref()
|
||||
== Some(api_base_url) =>
|
||||
{
|
||||
return;
|
||||
}
|
||||
Some(_) => return,
|
||||
None if registration.params.platform_user_id.is_none()
|
||||
&& registration.params.platform_access_token.is_none() =>
|
||||
{
|
||||
return;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
registration.params.platform_user_id = session.map(|(user_id, _, _)| user_id.to_string());
|
||||
registration.params.platform_access_token =
|
||||
session.map(|(_, access_token, _)| access_token.to_string());
|
||||
registration.params.platform_api_base_url =
|
||||
session.map(|(_, _, api_base_url)| api_base_url.to_string());
|
||||
registration.params.platform_auth_generation = Some(generation);
|
||||
}
|
||||
|
||||
pub(super) fn validate_external_agent_runner_gui_owner_attachment_result(
|
||||
result: &Value,
|
||||
) -> Result<(), String> {
|
||||
@@ -1405,10 +1295,6 @@ pub(super) fn send_external_agent_runner_runtime_request_with_stable_identity(
|
||||
steer_id: steer_id.map(str::to_string),
|
||||
event_sink_port: None,
|
||||
event_sink_token: None,
|
||||
platform_user_id: None,
|
||||
platform_access_token: None,
|
||||
platform_api_base_url: None,
|
||||
platform_auth_generation: None,
|
||||
};
|
||||
match stable_identity {
|
||||
Some(stable_identity) => {
|
||||
|
||||
@@ -620,30 +620,6 @@ pub(super) fn dispatch_external_agent_runner_runtime_request(
|
||||
redact_runner_secret(&error, &token),
|
||||
);
|
||||
}
|
||||
if let (
|
||||
Some(user_id),
|
||||
Some(access_token),
|
||||
Some(api_base_url),
|
||||
Some(generation),
|
||||
) = (
|
||||
request.params.platform_user_id.as_deref(),
|
||||
request.params.platform_access_token.as_deref(),
|
||||
request.params.platform_api_base_url.as_deref(),
|
||||
request.params.platform_auth_generation,
|
||||
) {
|
||||
if let Err(error) = crate::install_platform_session(
|
||||
user_id,
|
||||
access_token,
|
||||
api_base_url,
|
||||
generation,
|
||||
) {
|
||||
return ExternalAgentRunnerResponse::failure(
|
||||
&request.request_id,
|
||||
"platform-session-invalid",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
state.gui_owner_attached.store(true, Ordering::Release);
|
||||
ExternalAgentRunnerResponse::success(
|
||||
&request.request_id,
|
||||
@@ -662,41 +638,6 @@ pub(super) fn dispatch_external_agent_runner_runtime_request(
|
||||
),
|
||||
}
|
||||
}
|
||||
"platform.session.install" => {
|
||||
let result = request
|
||||
.params
|
||||
.platform_user_id
|
||||
.as_deref()
|
||||
.zip(request.params.platform_access_token.as_deref())
|
||||
.zip(request.params.platform_api_base_url.as_deref())
|
||||
.zip(request.params.platform_auth_generation)
|
||||
.ok_or_else(|| "平台登录态同步参数不完整".to_string())
|
||||
.and_then(|(((user_id, access_token), api_base_url), generation)| {
|
||||
crate::install_platform_session(user_id, access_token, api_base_url, generation)
|
||||
});
|
||||
match result {
|
||||
Ok(()) => ExternalAgentRunnerResponse::success(
|
||||
&request.request_id,
|
||||
json!({ "installed": true }),
|
||||
),
|
||||
Err(error) => ExternalAgentRunnerResponse::failure(
|
||||
&request.request_id,
|
||||
"platform-session-invalid",
|
||||
error,
|
||||
),
|
||||
}
|
||||
}
|
||||
"platform.session.clear" => {
|
||||
let Some(generation) = request.params.platform_auth_generation else {
|
||||
return ExternalAgentRunnerResponse::failure(
|
||||
&request.request_id,
|
||||
"platform-session-invalid",
|
||||
"平台登录态清除缺少 authGeneration",
|
||||
);
|
||||
};
|
||||
crate::clear_platform_session(generation);
|
||||
ExternalAgentRunnerResponse::success(&request.request_id, json!({ "cleared": true }))
|
||||
}
|
||||
"runner.shutdown" | "shutdown" => {
|
||||
let provider_requests_interrupted =
|
||||
request_external_agent_runner_forced_shutdown(state);
|
||||
@@ -924,8 +865,6 @@ pub(super) fn handle_external_agent_runner_request(
|
||||
| "runtime.cancel"
|
||||
| "runtime.compact"
|
||||
| "runner.attach_gui_owner"
|
||||
| "platform.session.install"
|
||||
| "platform.session.clear"
|
||||
| "runner.shutdown"
|
||||
| "shutdown"
|
||||
| "runner.shutdown_for_client_exit"
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 6;
|
||||
pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 5;
|
||||
|
||||
pub(super) const EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME: &str = "agent-runner.endpoint.json";
|
||||
pub(super) const EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME: &str = "agent-runner.lock";
|
||||
@@ -268,14 +268,6 @@ pub(super) struct ExternalAgentRunnerRequestParams {
|
||||
pub(super) event_sink_port: Option<u16>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(super) event_sink_token: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(super) platform_user_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(super) platform_access_token: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(super) platform_api_base_url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(super) platform_auth_generation: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
|
||||
@@ -85,7 +85,7 @@ fn context_compaction_client_uses_long_response_timeout_without_widening_other_m
|
||||
assert!(
|
||||
external_agent_runner_client_read_timeout("mcp.status") > EXTERNAL_AGENT_RUNNER_IO_TIMEOUT
|
||||
);
|
||||
assert_eq!(EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, 6);
|
||||
assert_eq!(EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, 5);
|
||||
}
|
||||
|
||||
fn test_endpoint(token: &str, boot_id: &str, port: u16) -> ExternalAgentRunnerEndpoint {
|
||||
@@ -639,80 +639,6 @@ fn gui_owner_registration_replays_once_for_each_runner_boot() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gui_owner_registration_replays_only_the_latest_platform_session() {
|
||||
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
|
||||
let config_dir = PathBuf::from("platform-session-replay-appdata");
|
||||
register_external_agent_runner_gui_owner_attachment(
|
||||
&state,
|
||||
&config_dir,
|
||||
ExternalAgentRunnerRequestParams {
|
||||
event_sink_port: Some(31_330),
|
||||
event_sink_token: Some("f".repeat(64)),
|
||||
..ExternalAgentRunnerRequestParams::default()
|
||||
},
|
||||
);
|
||||
remember_external_agent_runner_platform_session(
|
||||
&state,
|
||||
Some(("user-a", "token-a", "https://dev.genarrative.world")),
|
||||
4,
|
||||
);
|
||||
remember_external_agent_runner_platform_session(&state, None, 5);
|
||||
remember_external_agent_runner_platform_session(
|
||||
&state,
|
||||
Some(("user-a", "late-token-a", "https://dev.genarrative.world")),
|
||||
4,
|
||||
);
|
||||
remember_external_agent_runner_platform_session(
|
||||
&state,
|
||||
Some((
|
||||
"user-a",
|
||||
"same-generation-token",
|
||||
"https://dev.genarrative.world",
|
||||
)),
|
||||
5,
|
||||
);
|
||||
remember_external_agent_runner_platform_session(
|
||||
&state,
|
||||
Some(("user-b", "token-b", "https://dev.genarrative.world")),
|
||||
6,
|
||||
);
|
||||
|
||||
let endpoint = test_endpoint(
|
||||
"platform-session-replay-runner-token",
|
||||
"platform-session-replay-boot",
|
||||
31_330,
|
||||
);
|
||||
attach_registered_external_agent_runner_gui_owner_if_needed_with(
|
||||
&state,
|
||||
&config_dir,
|
||||
&endpoint,
|
||||
|_, params| {
|
||||
assert_eq!(params.platform_user_id.as_deref(), Some("user-b"));
|
||||
assert_eq!(params.platform_access_token.as_deref(), Some("token-b"));
|
||||
assert_eq!(
|
||||
params.platform_api_base_url.as_deref(),
|
||||
Some("https://dev.genarrative.world")
|
||||
);
|
||||
assert_eq!(params.platform_auth_generation, Some(6));
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.expect("latest platform session is replayed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runner_output_redacts_camel_case_platform_access_token_fields() {
|
||||
let config_dir = Path::new("private-appdata");
|
||||
assert_eq!(
|
||||
sanitize_agent_runner_output(
|
||||
r#"request={"platformAccessToken":"must-not-leak"}"#,
|
||||
config_dir,
|
||||
),
|
||||
"<sensitive runner output redacted>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gui_owner_registration_failed_replay_remains_pending_for_same_boot() {
|
||||
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
|
||||
|
||||
@@ -606,12 +606,8 @@ fn app_config_commands_write_runtime_config_file() {
|
||||
Some(false)
|
||||
);
|
||||
assert!(!saved.config.agent_llm.contains_key("generator"));
|
||||
assert_eq!(saved.config.editor_api.api_key, "");
|
||||
assert_eq!(saved.config.editor_api.api_key, "editor-key");
|
||||
assert!(root.join(GAME_CREATOR_CONFIG_FILE_NAME).is_file());
|
||||
let persisted = fs::read_to_string(root.join(GAME_CREATOR_CONFIG_FILE_NAME))
|
||||
.expect("read persisted runtime config");
|
||||
assert!(!persisted.contains("editorApi"));
|
||||
assert!(!persisted.contains("editor-key"));
|
||||
|
||||
let read_back = read_game_creator_app_config().expect("read runtime config");
|
||||
assert_eq!(read_back.config.llm.model, "runtime-model");
|
||||
|
||||
@@ -549,18 +549,15 @@ export function WorkspaceLauncher(props: WorkspaceLauncherProps) {
|
||||
export function GameChatReleaseApp({
|
||||
initialProjectPath = '',
|
||||
initialSupervisorMessage = '',
|
||||
allowAdvancedExternalEditorConfig = false,
|
||||
}: {
|
||||
initialProjectPath?: string;
|
||||
initialSupervisorMessage?: string;
|
||||
allowAdvancedExternalEditorConfig?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<App
|
||||
initialProjectPath={initialProjectPath}
|
||||
projectSupervisorOnly
|
||||
gameChatOnly
|
||||
allowAdvancedExternalEditorConfig={allowAdvancedExternalEditorConfig}
|
||||
initialSupervisorMessage={initialSupervisorMessage}
|
||||
/>
|
||||
);
|
||||
@@ -573,7 +570,6 @@ type AppProps = {
|
||||
projectSupervisorOnly?: boolean;
|
||||
supervisorChatOnly?: boolean;
|
||||
gameChatOnly?: boolean;
|
||||
allowAdvancedExternalEditorConfig?: boolean;
|
||||
initialSupervisorMessage?: string;
|
||||
onManifestChange?: (
|
||||
projectPath: string,
|
||||
@@ -594,7 +590,6 @@ export function App({
|
||||
projectSupervisorOnly = false,
|
||||
supervisorChatOnly = false,
|
||||
gameChatOnly = false,
|
||||
allowAdvancedExternalEditorConfig = false,
|
||||
initialSupervisorMessage = '',
|
||||
onManifestChange,
|
||||
onPreviewChange,
|
||||
@@ -10976,61 +10971,49 @@ export function App({
|
||||
if (projectSupervisorOnly && gameChatOnly) {
|
||||
const gameChatProjectPath = localProject?.projectPath ?? '';
|
||||
return (
|
||||
<>
|
||||
<SupervisorChatOnlyView
|
||||
chatAgentBusy={chatAgentBusy}
|
||||
chatInput={chatInput}
|
||||
gameChatMode
|
||||
messagesRef={supervisorChatMessagesRef}
|
||||
onCancelConfirmation={cancelUiCommandConfirmation}
|
||||
onChatInputChange={setChatInput}
|
||||
onCloseRuntimeConfig={() => setRuntimeConfigOpen(false)}
|
||||
onConfirmConfirmation={confirmUiCommand}
|
||||
onOpenRuntimeConfig={() => setRuntimeConfigOpen(true)}
|
||||
onProjectPick={() => void handleGameChatProjectPick()}
|
||||
onScroll={handleSupervisorChatScroll}
|
||||
onShowEarlierMessages={showEarlierConversationMessages}
|
||||
onSubmit={handleProjectSupervisorOnlySubmit}
|
||||
onToolAction={handleProjectSupervisorToolAction}
|
||||
onUserInput={handleProjectSupervisorUserInput}
|
||||
pendingConfirmation={pendingUiConfirmation}
|
||||
pendingCommand={pendingCommand}
|
||||
onCancelPendingCommand={handlePendingCommandCancel}
|
||||
onConfirmPendingCommand={() => void handlePendingCommandConfirm()}
|
||||
pendingNonEmptyProjectCreate={pendingNonEmptyProjectCreate}
|
||||
onCancelNonEmptyProjectCreate={cancelProjectCreateInNonEmptyFolder}
|
||||
onConfirmNonEmptyProjectCreate={confirmProjectCreateInNonEmptyFolder}
|
||||
preview={preview}
|
||||
previewRevision={gameChatPreviewRevision}
|
||||
previewStatus={previewStatus}
|
||||
projectPath={gameChatProjectPath}
|
||||
projectReady={Boolean(localProject)}
|
||||
projectSelectionBusy={gameChatProjectSelectionBusy}
|
||||
runtime={projectSupervisorRuntime}
|
||||
runtimeByAgentId={agentRuntimeById}
|
||||
manifest={manifest}
|
||||
runtimeConfigOpen={
|
||||
allowAdvancedExternalEditorConfig ? false : runtimeConfigOpen
|
||||
}
|
||||
runtimeError={projectSupervisorRuntimeError}
|
||||
transientReply={projectSupervisorTransientReply}
|
||||
transientReplyUpdatedAt={projectSupervisorResponseStream?.updatedAt}
|
||||
hasConversationControls={projectSupervisorHasConversationControls}
|
||||
hiddenConversationCount={hiddenConversationCount}
|
||||
needsUserInput={projectSupervisorNeedsUserInput}
|
||||
visibleMessages={visibleMessages}
|
||||
workspaceStatus={workspaceStatus}
|
||||
expectedRunId={projectSupervisorExpectedRunId}
|
||||
/>
|
||||
{runtimeConfigOpen && allowAdvancedExternalEditorConfig ? (
|
||||
<RuntimeConfigDialog
|
||||
allowAdvancedExternalEditorConfig
|
||||
projectPath={gameChatProjectPath}
|
||||
onClose={() => setRuntimeConfigOpen(false)}
|
||||
onLog={(entry) => setCommandLog((current) => [...current, entry])}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
<SupervisorChatOnlyView
|
||||
chatAgentBusy={chatAgentBusy}
|
||||
chatInput={chatInput}
|
||||
gameChatMode
|
||||
messagesRef={supervisorChatMessagesRef}
|
||||
onCancelConfirmation={cancelUiCommandConfirmation}
|
||||
onChatInputChange={setChatInput}
|
||||
onCloseRuntimeConfig={() => setRuntimeConfigOpen(false)}
|
||||
onConfirmConfirmation={confirmUiCommand}
|
||||
onOpenRuntimeConfig={() => setRuntimeConfigOpen(true)}
|
||||
onProjectPick={() => void handleGameChatProjectPick()}
|
||||
onScroll={handleSupervisorChatScroll}
|
||||
onShowEarlierMessages={showEarlierConversationMessages}
|
||||
onSubmit={handleProjectSupervisorOnlySubmit}
|
||||
onToolAction={handleProjectSupervisorToolAction}
|
||||
onUserInput={handleProjectSupervisorUserInput}
|
||||
pendingConfirmation={pendingUiConfirmation}
|
||||
pendingCommand={pendingCommand}
|
||||
onCancelPendingCommand={handlePendingCommandCancel}
|
||||
onConfirmPendingCommand={() => void handlePendingCommandConfirm()}
|
||||
pendingNonEmptyProjectCreate={pendingNonEmptyProjectCreate}
|
||||
onCancelNonEmptyProjectCreate={cancelProjectCreateInNonEmptyFolder}
|
||||
onConfirmNonEmptyProjectCreate={confirmProjectCreateInNonEmptyFolder}
|
||||
preview={preview}
|
||||
previewRevision={gameChatPreviewRevision}
|
||||
previewStatus={previewStatus}
|
||||
projectPath={gameChatProjectPath}
|
||||
projectReady={Boolean(localProject)}
|
||||
projectSelectionBusy={gameChatProjectSelectionBusy}
|
||||
runtime={projectSupervisorRuntime}
|
||||
runtimeByAgentId={agentRuntimeById}
|
||||
manifest={manifest}
|
||||
runtimeConfigOpen={runtimeConfigOpen}
|
||||
runtimeError={projectSupervisorRuntimeError}
|
||||
transientReply={projectSupervisorTransientReply}
|
||||
transientReplyUpdatedAt={projectSupervisorResponseStream?.updatedAt}
|
||||
hasConversationControls={projectSupervisorHasConversationControls}
|
||||
hiddenConversationCount={hiddenConversationCount}
|
||||
needsUserInput={projectSupervisorNeedsUserInput}
|
||||
visibleMessages={visibleMessages}
|
||||
workspaceStatus={workspaceStatus}
|
||||
expectedRunId={projectSupervisorExpectedRunId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,16 +18,9 @@ import {
|
||||
loginClientWithPhoneCode,
|
||||
logoutClientAuthSession,
|
||||
normalizeAuthPhoneInput,
|
||||
refreshClientAuthAccessToken,
|
||||
sendClientPhoneLoginCode,
|
||||
} from '../services/clientAuth';
|
||||
import {
|
||||
beginPlatformSessionTransition,
|
||||
clearCommittedPlatformSession,
|
||||
commitAuthenticatedPlatformSession,
|
||||
currentPlatformSessionGeneration,
|
||||
refreshPlatformSessionForGeneration,
|
||||
subscribePlatformSessionRefresh,
|
||||
} from '../services/platformSession';
|
||||
|
||||
type ClientRuntimeErrorBoundaryProps = {
|
||||
children: ReactNode;
|
||||
@@ -99,30 +92,15 @@ export function AuthenticatedClient({
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
async function hydrateAuth() {
|
||||
const hydrationGeneration = currentPlatformSessionGeneration();
|
||||
try {
|
||||
if (!getStoredAuthAccessToken()) {
|
||||
const refreshed =
|
||||
await refreshPlatformSessionForGeneration(hydrationGeneration);
|
||||
if (!refreshed) {
|
||||
return;
|
||||
}
|
||||
await refreshClientAuthAccessToken();
|
||||
}
|
||||
const user = await getCurrentClientAuthUser();
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
if (user) {
|
||||
const committedGeneration = await commitAuthenticatedPlatformSession(
|
||||
user,
|
||||
hydrationGeneration,
|
||||
);
|
||||
if (committedGeneration === null) {
|
||||
return;
|
||||
}
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
setAuthUser(user);
|
||||
setAuthStatus('authenticated');
|
||||
return;
|
||||
@@ -145,27 +123,12 @@ export function AuthenticatedClient({
|
||||
}
|
||||
if (getStoredAuthAccessToken()) {
|
||||
try {
|
||||
const refreshed =
|
||||
await refreshPlatformSessionForGeneration(hydrationGeneration);
|
||||
if (!refreshed) {
|
||||
return;
|
||||
}
|
||||
await refreshClientAuthAccessToken();
|
||||
const user = await getCurrentClientAuthUser();
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
if (user) {
|
||||
const committedGeneration =
|
||||
await commitAuthenticatedPlatformSession(
|
||||
user,
|
||||
hydrationGeneration,
|
||||
);
|
||||
if (committedGeneration === null) {
|
||||
return;
|
||||
}
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
setAuthUser(user);
|
||||
setAuthStatus('authenticated');
|
||||
return;
|
||||
@@ -196,25 +159,6 @@ export function AuthenticatedClient({
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
subscribePlatformSessionRefresh((result) => {
|
||||
if (result.status === 'refreshed') {
|
||||
setAuthUser((current) =>
|
||||
current?.id === result.user.id ? result.user : current,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (result.status === 'failed') {
|
||||
clearStoredAuthAccessToken();
|
||||
setAuthUser(null);
|
||||
setAuthStatus('unauthenticated');
|
||||
setLoginStatus('登录已失效,请重新登录');
|
||||
}
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (codeCooldownSeconds <= 0) {
|
||||
return;
|
||||
@@ -267,19 +211,11 @@ export function AuthenticatedClient({
|
||||
}
|
||||
setLoginBusy(true);
|
||||
setLoginStatus('正在登录');
|
||||
const loginGeneration = beginPlatformSessionTransition();
|
||||
try {
|
||||
const user =
|
||||
loginMode === 'code'
|
||||
? await loginClientWithPhoneCode(normalizedPhone, code)
|
||||
: await loginClientWithPassword(normalizedPhone, password);
|
||||
const committedGeneration = await commitAuthenticatedPlatformSession(
|
||||
user,
|
||||
loginGeneration,
|
||||
);
|
||||
if (committedGeneration === null) {
|
||||
return;
|
||||
}
|
||||
setAuthUser(user);
|
||||
setAuthStatus('authenticated');
|
||||
setCode('');
|
||||
@@ -292,13 +228,11 @@ export function AuthenticatedClient({
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
const logoutGeneration = beginPlatformSessionTransition();
|
||||
try {
|
||||
await logoutClientAuthSession();
|
||||
} catch {
|
||||
clearStoredAuthAccessToken();
|
||||
}
|
||||
await clearCommittedPlatformSession(logoutGeneration);
|
||||
setAuthUser(null);
|
||||
setAuthStatus('unauthenticated');
|
||||
setLoginStatus('已退出登录');
|
||||
|
||||
+2
-30
@@ -16,7 +16,6 @@ import type {
|
||||
GameCreationAppAssetManifestEntry,
|
||||
GameCreationAppManifest,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { requestPlatformSessionRefresh } from '../../services/platformSession';
|
||||
|
||||
const MAX_SAFE_REVISION = Number.MAX_SAFE_INTEGER;
|
||||
export const LOCAL_ASSET_COMMITTED_EVENT =
|
||||
@@ -191,14 +190,6 @@ function failure(error: unknown): ImageCanvasHostResult<never> {
|
||||
};
|
||||
}
|
||||
|
||||
function isAuthenticationRequiredError(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return (
|
||||
message.includes('authentication-required') ||
|
||||
message.includes('登录已失效')
|
||||
);
|
||||
}
|
||||
|
||||
function conflict(
|
||||
kind: 'project-identity' | 'host-revision' | 'draft-revision',
|
||||
draft: ImageCanvasDraft | null,
|
||||
@@ -246,25 +237,6 @@ export function createTauriImageCanvasHostAdapter(input: {
|
||||
}): TauriImageCanvasHostAdapter {
|
||||
const invokeInput = <T>(command: string, value: Record<string, unknown>) =>
|
||||
input.invoke<T>(command, { input: value });
|
||||
const invokeAuthenticatedInput = async <T>(
|
||||
command: string,
|
||||
value: Record<string, unknown>,
|
||||
) => {
|
||||
try {
|
||||
return await invokeInput<T>(command, value);
|
||||
} catch (error) {
|
||||
if (!isAuthenticationRequiredError(error)) throw error;
|
||||
const refresh = await requestPlatformSessionRefresh();
|
||||
if (refresh.status !== 'refreshed') {
|
||||
throw new Error(
|
||||
refresh.status === 'stale'
|
||||
? '登录账号已变化,原生成操作已停止'
|
||||
: '登录已失效,请重新登录',
|
||||
);
|
||||
}
|
||||
return invokeInput<T>(command, value);
|
||||
}
|
||||
};
|
||||
const baseScope = (scope: ImageCanvasHostScope) => {
|
||||
if (scope.projectId !== input.expectedProjectId) {
|
||||
throw new Error(
|
||||
@@ -314,7 +286,7 @@ export function createTauriImageCanvasHostAdapter(input: {
|
||||
generationInput.generationId,
|
||||
generationInput.onProgress,
|
||||
);
|
||||
const result = await invokeAuthenticatedInput<GenerationCommandResult>(
|
||||
const result = await invokeInput<GenerationCommandResult>(
|
||||
'generate_local_project_asset_canvas_image',
|
||||
{
|
||||
...baseScope(generationInput.scope),
|
||||
@@ -354,7 +326,7 @@ export function createTauriImageCanvasHostAdapter(input: {
|
||||
null,
|
||||
recoverInput.onProgress,
|
||||
);
|
||||
const result = await invokeAuthenticatedInput<{
|
||||
const result = await invokeInput<{
|
||||
resumedGenerationIds: string[];
|
||||
serviceIdentityConfirmations: ImageCanvasGenerationServiceIdentityConfirmation[];
|
||||
}>(
|
||||
|
||||
@@ -84,7 +84,7 @@ const defaultRuntimeConfigDraft: GameCreatorAppConfig = {
|
||||
},
|
||||
agentLlm: {},
|
||||
editorApi: {
|
||||
baseUrl: 'https://dev.genarrative.world',
|
||||
baseUrl: 'http://127.0.0.1:8082',
|
||||
apiKey: '',
|
||||
},
|
||||
mcpServers: {},
|
||||
@@ -501,7 +501,6 @@ function normalizeRuntimeMcpServerConfig(
|
||||
|
||||
function normalizeRuntimeConfigDraft(
|
||||
config: GameCreatorAppConfig,
|
||||
allowAdvancedExternalEditorConfig: boolean,
|
||||
): GameCreatorAppConfig {
|
||||
const apiKind: GameCreatorLlmApiKind = [
|
||||
'openai_responses',
|
||||
@@ -565,21 +564,16 @@ function normalizeRuntimeConfigDraft(
|
||||
retryBackoffMs: clampRuntimeConfigNumber(config.llm.retryBackoffMs, 1),
|
||||
},
|
||||
agentLlm,
|
||||
editorApi: allowAdvancedExternalEditorConfig
|
||||
? { ...defaultRuntimeConfigDraft.editorApi, ...config.editorApi }
|
||||
: { ...defaultRuntimeConfigDraft.editorApi },
|
||||
mcpServers,
|
||||
};
|
||||
}
|
||||
|
||||
export function RuntimeConfigDialog({
|
||||
projectPath,
|
||||
allowAdvancedExternalEditorConfig = false,
|
||||
onClose,
|
||||
onLog,
|
||||
}: {
|
||||
projectPath?: string;
|
||||
allowAdvancedExternalEditorConfig?: boolean;
|
||||
onClose: () => void;
|
||||
onLog?: (entry: string) => void;
|
||||
}) {
|
||||
@@ -700,6 +694,18 @@ export function RuntimeConfigDialog({
|
||||
}));
|
||||
}
|
||||
|
||||
function updateRuntimeEditorConfig<
|
||||
K extends keyof GameCreatorAppConfig['editorApi'],
|
||||
>(key: K, value: GameCreatorAppConfig['editorApi'][K]) {
|
||||
setRuntimeConfigDraft((current) => ({
|
||||
...current,
|
||||
editorApi: {
|
||||
...current.editorApi,
|
||||
[key]: value,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function updateRuntimeMcpServer<K extends keyof GameCreatorMcpServerConfig>(
|
||||
serverId: string,
|
||||
key: K,
|
||||
@@ -795,10 +801,7 @@ export function RuntimeConfigDialog({
|
||||
'read_game_creator_app_config',
|
||||
);
|
||||
setRuntimeConfigPath(result.path);
|
||||
const config = normalizeRuntimeConfigDraft(
|
||||
result.config,
|
||||
allowAdvancedExternalEditorConfig,
|
||||
);
|
||||
const config = normalizeRuntimeConfigDraft(result.config);
|
||||
setRuntimeConfigDraft(config);
|
||||
setMcpStructuredDrafts(runtimeMcpStructuredDrafts(config.mcpServers));
|
||||
setMcpCatalog(null);
|
||||
@@ -829,28 +832,19 @@ export function RuntimeConfigDialog({
|
||||
setRuntimeConfigBusy(true);
|
||||
setRuntimeConfigStatus('正在保存');
|
||||
try {
|
||||
const config = normalizeRuntimeConfigDraft(
|
||||
{
|
||||
...runtimeConfigDraft,
|
||||
editorApi: allowAdvancedExternalEditorConfig
|
||||
? runtimeConfigDraft.editorApi
|
||||
: defaultRuntimeConfigDraft.editorApi,
|
||||
mcpServers: materializeRuntimeMcpServers(
|
||||
runtimeConfigDraft.mcpServers,
|
||||
mcpStructuredDrafts,
|
||||
),
|
||||
},
|
||||
allowAdvancedExternalEditorConfig,
|
||||
);
|
||||
const config = normalizeRuntimeConfigDraft({
|
||||
...runtimeConfigDraft,
|
||||
mcpServers: materializeRuntimeMcpServers(
|
||||
runtimeConfigDraft.mcpServers,
|
||||
mcpStructuredDrafts,
|
||||
),
|
||||
});
|
||||
const result = await invoke<GameCreatorAppConfigView>(
|
||||
'write_game_creator_app_config',
|
||||
{ config },
|
||||
);
|
||||
setRuntimeConfigPath(result.path);
|
||||
const savedConfig = normalizeRuntimeConfigDraft(
|
||||
result.config,
|
||||
allowAdvancedExternalEditorConfig,
|
||||
);
|
||||
const savedConfig = normalizeRuntimeConfigDraft(result.config);
|
||||
setRuntimeConfigDraft(savedConfig);
|
||||
setMcpStructuredDrafts(
|
||||
runtimeMcpStructuredDrafts(savedConfig.mcpServers),
|
||||
@@ -1552,49 +1546,43 @@ export function RuntimeConfigDialog({
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{activeSection === 'connections' ? (
|
||||
<>
|
||||
<label>
|
||||
External Editor Base URL
|
||||
<input
|
||||
aria-label="External Editor Base URL"
|
||||
value={runtimeConfigDraft.editorApi.baseUrl}
|
||||
onChange={(event) =>
|
||||
updateRuntimeEditorConfig(
|
||||
'baseUrl',
|
||||
event.currentTarget.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
External Editor API Key
|
||||
<input
|
||||
aria-label="External Editor API Key"
|
||||
autoComplete="off"
|
||||
type="password"
|
||||
value={runtimeConfigDraft.editorApi.apiKey}
|
||||
onChange={(event) =>
|
||||
updateRuntimeEditorConfig(
|
||||
'apiKey',
|
||||
event.currentTarget.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
{activeSection === 'connections' ? (
|
||||
<section
|
||||
className="runtime-mcp-section"
|
||||
aria-label="MCP servers"
|
||||
>
|
||||
{allowAdvancedExternalEditorConfig ? (
|
||||
<div className="runtime-mcp-grid">
|
||||
<label className="runtime-mcp-field-wide">
|
||||
External Editor Base URL
|
||||
<input
|
||||
aria-label="External Editor Base URL"
|
||||
value={runtimeConfigDraft.editorApi.baseUrl}
|
||||
onChange={(event) =>
|
||||
setRuntimeConfigDraft((current) => ({
|
||||
...current,
|
||||
editorApi: {
|
||||
...current.editorApi,
|
||||
baseUrl: event.currentTarget.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="runtime-mcp-field-wide">
|
||||
External Editor API Key
|
||||
<input
|
||||
aria-label="External Editor API Key"
|
||||
autoComplete="off"
|
||||
type="password"
|
||||
value={runtimeConfigDraft.editorApi.apiKey}
|
||||
onChange={(event) =>
|
||||
setRuntimeConfigDraft((current) => ({
|
||||
...current,
|
||||
editorApi: {
|
||||
...current.editorApi,
|
||||
apiKey: event.currentTarget.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
<header className="runtime-mcp-header">
|
||||
<div>
|
||||
<h3>MCP servers</h3>
|
||||
|
||||
@@ -38,7 +38,6 @@ const gameChatApp = (
|
||||
<GameChatReleaseApp
|
||||
initialProjectPath={supervisorChatProjectPath}
|
||||
initialSupervisorMessage={initialGameChatMessage}
|
||||
allowAdvancedExternalEditorConfig={gameChatReleaseMode}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
import type { AuthUser } from '../../../../packages/shared/src/contracts/auth';
|
||||
import { resolveTauriInvoke } from '../app/tauri';
|
||||
import {
|
||||
getCurrentClientAuthUser,
|
||||
getStoredAuthAccessToken,
|
||||
refreshClientAuthAccessToken,
|
||||
} from './clientAuth';
|
||||
|
||||
const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1';
|
||||
|
||||
type CommittedPlatformSession = {
|
||||
user: AuthUser;
|
||||
accessToken: string;
|
||||
apiBaseUrl: string;
|
||||
generation: number;
|
||||
};
|
||||
|
||||
export type PlatformSessionRefreshResult =
|
||||
| { status: 'refreshed'; user: AuthUser; generation: number }
|
||||
| { status: 'stale' }
|
||||
| { status: 'failed'; error: unknown };
|
||||
|
||||
type PlatformSessionRefreshListener = (
|
||||
result: PlatformSessionRefreshResult,
|
||||
) => void;
|
||||
|
||||
let platformAuthGeneration = 0;
|
||||
let committedPlatformSession: CommittedPlatformSession | null = null;
|
||||
let platformSessionRefreshPromise: Promise<PlatformSessionRefreshResult> | null =
|
||||
null;
|
||||
const platformSessionRefreshListeners =
|
||||
new Set<PlatformSessionRefreshListener>();
|
||||
|
||||
function restoreCommittedAccessToken() {
|
||||
if (committedPlatformSession?.accessToken) {
|
||||
window.localStorage.setItem(
|
||||
ACCESS_TOKEN_STORAGE_KEY,
|
||||
committedPlatformSession.accessToken,
|
||||
);
|
||||
return;
|
||||
}
|
||||
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
function notifyPlatformSessionRefresh(result: PlatformSessionRefreshResult) {
|
||||
for (const listener of platformSessionRefreshListeners) {
|
||||
listener(result);
|
||||
}
|
||||
}
|
||||
|
||||
async function installCommittedPlatformSession(
|
||||
session: CommittedPlatformSession,
|
||||
) {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) return;
|
||||
await invoke('install_platform_account_session', {
|
||||
userId: session.user.id,
|
||||
accessToken: session.accessToken,
|
||||
apiBaseUrl: session.apiBaseUrl,
|
||||
generation: session.generation,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolvePlatformApiBaseUrl() {
|
||||
if (!import.meta.env.DEV || import.meta.env.MODE === 'test') {
|
||||
return 'https://dev.genarrative.world';
|
||||
}
|
||||
const response = await fetch('/__agc_dev_server.json', {
|
||||
cache: 'no-store',
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('无法读取本地陶泥儿 API 服务地址');
|
||||
}
|
||||
const marker = (await response.json()) as { apiTarget?: unknown };
|
||||
const apiBaseUrl =
|
||||
typeof marker.apiTarget === 'string' ? marker.apiTarget.trim() : '';
|
||||
if (!/^http:\/\/(?:127\.0\.0\.1|localhost)(?::\d+)?$/u.test(apiBaseUrl)) {
|
||||
throw new Error('本地陶泥儿 API 服务地址不在受信任白名单内');
|
||||
}
|
||||
return apiBaseUrl;
|
||||
}
|
||||
|
||||
async function commitPlatformSession(
|
||||
user: AuthUser,
|
||||
expectedGeneration: number,
|
||||
): Promise<CommittedPlatformSession | null> {
|
||||
const accessToken = getStoredAuthAccessToken();
|
||||
if (!accessToken) {
|
||||
throw new Error('陶泥儿登录凭据缺失,请重新登录');
|
||||
}
|
||||
if (platformAuthGeneration !== expectedGeneration) {
|
||||
if (
|
||||
committedPlatformSession?.user.id === user.id &&
|
||||
committedPlatformSession.accessToken === accessToken
|
||||
) {
|
||||
return committedPlatformSession;
|
||||
}
|
||||
restoreCommittedAccessToken();
|
||||
return null;
|
||||
}
|
||||
platformAuthGeneration += 1;
|
||||
const apiBaseUrl = await resolvePlatformApiBaseUrl();
|
||||
committedPlatformSession = {
|
||||
user,
|
||||
accessToken,
|
||||
apiBaseUrl,
|
||||
generation: platformAuthGeneration,
|
||||
};
|
||||
return committedPlatformSession;
|
||||
}
|
||||
|
||||
export function currentPlatformSessionGeneration() {
|
||||
return platformAuthGeneration;
|
||||
}
|
||||
|
||||
export function beginPlatformSessionTransition() {
|
||||
platformAuthGeneration += 1;
|
||||
committedPlatformSession = null;
|
||||
return platformAuthGeneration;
|
||||
}
|
||||
|
||||
export async function commitAuthenticatedPlatformSession(
|
||||
user: AuthUser,
|
||||
expectedGeneration: number,
|
||||
) {
|
||||
const session = await commitPlatformSession(user, expectedGeneration);
|
||||
if (!session) return null;
|
||||
await installCommittedPlatformSession(session);
|
||||
return session.generation;
|
||||
}
|
||||
|
||||
export async function refreshPlatformSessionForGeneration(
|
||||
expectedGeneration: number,
|
||||
) {
|
||||
const token = await refreshClientAuthAccessToken();
|
||||
if (platformAuthGeneration !== expectedGeneration) {
|
||||
restoreCommittedAccessToken();
|
||||
return null;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
export function requestPlatformSessionRefresh(expectedUserId?: string) {
|
||||
if (platformSessionRefreshPromise) return platformSessionRefreshPromise;
|
||||
|
||||
const expectedGeneration = platformAuthGeneration;
|
||||
const expectedSessionUserId =
|
||||
expectedUserId?.trim() || committedPlatformSession?.user.id || '';
|
||||
platformSessionRefreshPromise =
|
||||
(async (): Promise<PlatformSessionRefreshResult> => {
|
||||
try {
|
||||
const refreshed =
|
||||
await refreshPlatformSessionForGeneration(expectedGeneration);
|
||||
if (!refreshed) return { status: 'stale' };
|
||||
const user = await getCurrentClientAuthUser();
|
||||
if (
|
||||
platformAuthGeneration !== expectedGeneration ||
|
||||
!user ||
|
||||
(expectedSessionUserId && user.id !== expectedSessionUserId)
|
||||
) {
|
||||
restoreCommittedAccessToken();
|
||||
return { status: 'stale' };
|
||||
}
|
||||
const session = await commitPlatformSession(user, expectedGeneration);
|
||||
if (!session) return { status: 'stale' };
|
||||
await installCommittedPlatformSession(session);
|
||||
return {
|
||||
status: 'refreshed',
|
||||
user,
|
||||
generation: session.generation,
|
||||
};
|
||||
} catch (error) {
|
||||
if (platformAuthGeneration === expectedGeneration) {
|
||||
const clearGeneration = beginPlatformSessionTransition();
|
||||
await clearCommittedPlatformSession(clearGeneration);
|
||||
}
|
||||
return { status: 'failed', error };
|
||||
}
|
||||
})().then((result) => {
|
||||
notifyPlatformSessionRefresh(result);
|
||||
return result;
|
||||
});
|
||||
platformSessionRefreshPromise.finally(() => {
|
||||
platformSessionRefreshPromise = null;
|
||||
});
|
||||
return platformSessionRefreshPromise;
|
||||
}
|
||||
|
||||
export function subscribePlatformSessionRefresh(
|
||||
listener: PlatformSessionRefreshListener,
|
||||
) {
|
||||
platformSessionRefreshListeners.add(listener);
|
||||
return () => {
|
||||
platformSessionRefreshListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export async function clearCommittedPlatformSession(generation: number) {
|
||||
restoreCommittedAccessToken();
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) return;
|
||||
await invoke('clear_platform_account_session', { generation });
|
||||
}
|
||||
|
||||
export function resetPlatformSessionStateForTests() {
|
||||
platformAuthGeneration = 0;
|
||||
committedPlatformSession = null;
|
||||
platformSessionRefreshPromise = null;
|
||||
platformSessionRefreshListeners.clear();
|
||||
}
|
||||
@@ -61,7 +61,6 @@ import {
|
||||
LocalGamePreviewFrame,
|
||||
resolveEmbeddedPreviewUrl,
|
||||
} from '../../features/project-workspace/LocalGamePreviewFrame';
|
||||
import { requestPlatformSessionRefresh } from '../../services/platformSession';
|
||||
import {
|
||||
type ProjectManifestSnapshotMetadata,
|
||||
resolveResourceFocusIntent,
|
||||
@@ -119,31 +118,6 @@ type ResourceSortMode = ProjectResourceCanvasLayoutMode;
|
||||
type WorkbenchMode = 'resources' | 'run';
|
||||
type ApprovalMode = 'strict' | 'risk' | 'none';
|
||||
|
||||
function isPlatformAuthenticationRequired(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return (
|
||||
message.includes('authentication-required') ||
|
||||
message.includes('登录已失效')
|
||||
);
|
||||
}
|
||||
|
||||
async function withPlatformSessionRefresh<T>(operation: () => Promise<T>) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (!isPlatformAuthenticationRequired(error)) throw error;
|
||||
const refresh = await requestPlatformSessionRefresh();
|
||||
if (refresh.status !== 'refreshed') {
|
||||
throw new Error(
|
||||
refresh.status === 'stale'
|
||||
? '登录账号已变化,原资源操作已停止'
|
||||
: '登录已失效,请重新登录',
|
||||
);
|
||||
}
|
||||
return operation();
|
||||
}
|
||||
}
|
||||
|
||||
type WebKitGestureEvent = Event & {
|
||||
clientX?: number;
|
||||
clientY?: number;
|
||||
@@ -1421,8 +1395,7 @@ export default function ProjectDevelopmentView({
|
||||
}
|
||||
const viewport = resolveViewport(event);
|
||||
const category = viewport?.dataset.resourceSectionScroll as
|
||||
| ResourceCategory
|
||||
| undefined;
|
||||
ResourceCategory | undefined;
|
||||
if (!viewport || !category) {
|
||||
return;
|
||||
}
|
||||
@@ -1441,8 +1414,7 @@ export default function ProjectDevelopmentView({
|
||||
const event = rawEvent as WebKitGestureEvent;
|
||||
const viewport = resolveViewport(event);
|
||||
const category = viewport?.dataset.resourceSectionScroll as
|
||||
| ResourceCategory
|
||||
| undefined;
|
||||
ResourceCategory | undefined;
|
||||
if (!viewport || !category) {
|
||||
return;
|
||||
}
|
||||
@@ -1530,8 +1502,7 @@ export default function ProjectDevelopmentView({
|
||||
.querySelectorAll<HTMLElement>('[data-resource-section-scroll]')
|
||||
.forEach((viewport) => {
|
||||
const category = viewport.dataset.resourceSectionScroll as
|
||||
| ResourceCategory
|
||||
| undefined;
|
||||
ResourceCategory | undefined;
|
||||
if (!category) {
|
||||
return;
|
||||
}
|
||||
@@ -1551,8 +1522,7 @@ export default function ProjectDevelopmentView({
|
||||
?.querySelectorAll<HTMLElement>('[data-resource-section-scroll]')
|
||||
.forEach((viewport) => {
|
||||
const category = viewport.dataset.resourceSectionScroll as
|
||||
| ResourceCategory
|
||||
| undefined;
|
||||
ResourceCategory | undefined;
|
||||
if (!category) {
|
||||
return;
|
||||
}
|
||||
@@ -2320,17 +2290,15 @@ export default function ProjectDevelopmentView({
|
||||
setPendingResourceEditsError('');
|
||||
setAssetCanvasNotice(`正在继续“${pending.assetName}”的原生成 operation…`);
|
||||
try {
|
||||
const result = await withPlatformSessionRefresh(() =>
|
||||
invoke<DeriveLocalProjectResourceResult>(
|
||||
'resume_local_project_resource_edit',
|
||||
{
|
||||
input: {
|
||||
projectPath: actionProject.projectPath,
|
||||
expectedProjectId: actionProject.projectId,
|
||||
operationId: pending.operationId,
|
||||
},
|
||||
const result = await invoke<DeriveLocalProjectResourceResult>(
|
||||
'resume_local_project_resource_edit',
|
||||
{
|
||||
input: {
|
||||
projectPath: actionProject.projectPath,
|
||||
expectedProjectId: actionProject.projectId,
|
||||
operationId: pending.operationId,
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
if (!isCurrentRecoveryProject(actionProject)) return;
|
||||
if (result.manifest.projectId !== actionProject.projectId) {
|
||||
@@ -2627,32 +2595,30 @@ export default function ProjectDevelopmentView({
|
||||
);
|
||||
}
|
||||
const resource = route.resource;
|
||||
const result = await withPlatformSessionRefresh(() =>
|
||||
invoke<DeriveLocalProjectResourceResult>(
|
||||
'derive_local_project_resource',
|
||||
{
|
||||
input: {
|
||||
projectPath,
|
||||
expectedProjectId: manifest.projectId,
|
||||
expectedProjectRevision,
|
||||
operationId: route.operationId,
|
||||
idempotencyKey: route.idempotencyKey,
|
||||
editKind: route.capability.editKind,
|
||||
sourceResourceId: resource.id,
|
||||
sourceAssetId: resource.manifestAssetId,
|
||||
sourcePath:
|
||||
resource.version || resource.subtype === 'agent-result'
|
||||
? null
|
||||
: resource.path,
|
||||
sourceMediaType: route.capability.sourceMediaType,
|
||||
sourceSubtype: resource.subtype,
|
||||
producerTaskId: resource.producerTaskId,
|
||||
sourceVersionId: resource.version?.versionId ?? null,
|
||||
prompt,
|
||||
assetName,
|
||||
},
|
||||
const result = await invoke<DeriveLocalProjectResourceResult>(
|
||||
'derive_local_project_resource',
|
||||
{
|
||||
input: {
|
||||
projectPath,
|
||||
expectedProjectId: manifest.projectId,
|
||||
expectedProjectRevision,
|
||||
operationId: route.operationId,
|
||||
idempotencyKey: route.idempotencyKey,
|
||||
editKind: route.capability.editKind,
|
||||
sourceResourceId: resource.id,
|
||||
sourceAssetId: resource.manifestAssetId,
|
||||
sourcePath:
|
||||
resource.version || resource.subtype === 'agent-result'
|
||||
? null
|
||||
: resource.path,
|
||||
sourceMediaType: route.capability.sourceMediaType,
|
||||
sourceSubtype: resource.subtype,
|
||||
producerTaskId: resource.producerTaskId,
|
||||
sourceVersionId: resource.version?.versionId ?? null,
|
||||
prompt,
|
||||
assetName,
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
if (
|
||||
result.manifest.projectId !== manifest.projectId ||
|
||||
@@ -2850,16 +2816,16 @@ export default function ProjectDevelopmentView({
|
||||
dependencyLayoutSettled: dependencyLayout.settled,
|
||||
dependencyPositioned: Boolean(
|
||||
intent.resourceId &&
|
||||
dependencyLayout.layout.positions.some(
|
||||
(position) => position.resourceId === intent.resourceId,
|
||||
),
|
||||
dependencyLayout.layout.positions.some(
|
||||
(position) => position.resourceId === intent.resourceId,
|
||||
),
|
||||
),
|
||||
typeLayoutSettled: typeLayout.settled,
|
||||
typePositioned: Boolean(
|
||||
intent.resourceId &&
|
||||
typeLayout.layout.positions.some(
|
||||
(position) => position.resourceId === intent.resourceId,
|
||||
),
|
||||
typeLayout.layout.positions.some(
|
||||
(position) => position.resourceId === intent.resourceId,
|
||||
),
|
||||
),
|
||||
visible: targetVisible,
|
||||
domRendered: Boolean(card),
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
import { afterEach } from 'vitest';
|
||||
|
||||
import {
|
||||
beginPlatformSessionTransition,
|
||||
commitAuthenticatedPlatformSession,
|
||||
currentPlatformSessionGeneration,
|
||||
requestPlatformSessionRefresh,
|
||||
resetPlatformSessionStateForTests,
|
||||
} from '../../src/services/platformSession';
|
||||
import {
|
||||
AuthenticatedClient,
|
||||
expect,
|
||||
@@ -17,182 +8,9 @@ import {
|
||||
screen,
|
||||
testAuthUser,
|
||||
vi,
|
||||
waitFor,
|
||||
} from './harness';
|
||||
|
||||
export function registerAuthTests() {
|
||||
afterEach(() => {
|
||||
resetPlatformSessionStateForTests();
|
||||
delete window.__TAURI__;
|
||||
});
|
||||
|
||||
it('keeps the client at login when the native platform session cannot be installed', async () => {
|
||||
window.__TAURI__ = {
|
||||
core: {
|
||||
invoke: vi.fn(async (command: string) => {
|
||||
if (command === 'install_platform_account_session') {
|
||||
throw new Error('runner unavailable');
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
},
|
||||
};
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(
|
||||
async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === '/api/auth/refresh') {
|
||||
return new Response('', { status: 401 });
|
||||
}
|
||||
if (url === '/api/auth/phone/login') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
token: 'phone-token',
|
||||
user: { ...testAuthUser, loginMethod: 'phone' },
|
||||
created: false,
|
||||
referral: null,
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
},
|
||||
);
|
||||
|
||||
render(
|
||||
React.createElement(AuthenticatedClient, null, ({ user }) =>
|
||||
React.createElement('main', { 'aria-label': '已登录' }, user.id),
|
||||
),
|
||||
);
|
||||
await screen.findByRole('main', { name: '登录' });
|
||||
fireEvent.change(screen.getByLabelText('手机号'), {
|
||||
target: { value: '13800000000' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('验证码'), {
|
||||
target: { value: '123456' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '登录' }));
|
||||
|
||||
expect(await screen.findByText('runner unavailable')).not.toBeNull();
|
||||
expect(screen.queryByLabelText('已登录')).toBeNull();
|
||||
expect(
|
||||
window.localStorage.getItem('genarrative.auth.access-token.v1'),
|
||||
).toBe('phone-token');
|
||||
});
|
||||
|
||||
it('singleflights 401 refresh, installs the new token, and rejects a late old-account refresh', async () => {
|
||||
const invoke = vi.fn(async () => null);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
const initialGeneration = beginPlatformSessionTransition();
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'account-a-token',
|
||||
);
|
||||
await commitAuthenticatedPlatformSession(testAuthUser, initialGeneration);
|
||||
|
||||
let resolveRefresh: ((response: Response) => void) | null = null;
|
||||
let refreshCalls = 0;
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(
|
||||
async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === '/api/auth/refresh') {
|
||||
refreshCalls += 1;
|
||||
return await new Promise<Response>((resolve) => {
|
||||
resolveRefresh = resolve;
|
||||
});
|
||||
}
|
||||
if (url === '/api/auth/me') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
user: testAuthUser,
|
||||
availableLoginMethods: ['password'],
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
},
|
||||
);
|
||||
|
||||
const first = requestPlatformSessionRefresh(testAuthUser.id);
|
||||
const second = requestPlatformSessionRefresh(testAuthUser.id);
|
||||
expect(first).toBe(second);
|
||||
expect(refreshCalls).toBe(1);
|
||||
|
||||
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
|
||||
const accountBGeneration = beginPlatformSessionTransition();
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'account-b-token',
|
||||
);
|
||||
await commitAuthenticatedPlatformSession(accountB, accountBGeneration);
|
||||
resolveRefresh?.(
|
||||
new Response(JSON.stringify({ token: 'late-account-a-token' }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(first).resolves.toEqual({ status: 'stale' });
|
||||
expect(
|
||||
window.localStorage.getItem('genarrative.auth.access-token.v1'),
|
||||
).toBe('account-b-token');
|
||||
expect(invoke).toHaveBeenLastCalledWith(
|
||||
'install_platform_account_session',
|
||||
expect.objectContaining({
|
||||
userId: 'user-b',
|
||||
accessToken: 'account-b-token',
|
||||
generation: currentPlatformSessionGeneration(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('refreshes the current account once and synchronizes the replacement token', async () => {
|
||||
const invoke = vi.fn(async () => null);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
const generation = beginPlatformSessionTransition();
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'expired-token',
|
||||
);
|
||||
await commitAuthenticatedPlatformSession(testAuthUser, generation);
|
||||
let refreshCalls = 0;
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(
|
||||
async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === '/api/auth/refresh') {
|
||||
refreshCalls += 1;
|
||||
return new Response(JSON.stringify({ token: 'replacement-token' }), {
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
if (url === '/api/auth/me') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
user: testAuthUser,
|
||||
availableLoginMethods: ['password'],
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
},
|
||||
);
|
||||
|
||||
const result = await requestPlatformSessionRefresh(testAuthUser.id);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({ status: 'refreshed', user: testAuthUser }),
|
||||
);
|
||||
expect(refreshCalls).toBe(1);
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenLastCalledWith(
|
||||
'install_platform_account_session',
|
||||
expect.objectContaining({
|
||||
userId: testAuthUser.id,
|
||||
accessToken: 'replacement-token',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('deduplicates startup auth refresh when React StrictMode hydrates twice', async () => {
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user