diff --git a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json index 50d312f47..c8255bc4c 100644 --- a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json +++ b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json @@ -11,7 +11,13 @@ "core:resources:allow-close", { "identifier": "http:default", - "allow": [{ "url": "https://dev.genarrative.world/api/*" }] + "allow": [ + { "url": "https://dev.genarrative.world/api/*" }, + { "url": "https://www.genarrative.world/api/*" }, + { "url": "https://*/api/*" }, + { "url": "http://localhost:*/*" }, + { "url": "http://127.0.0.1:*/*" } + ] }, "opener:default" ] diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index 6b7143b93..fa776c2eb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -1,7 +1,8 @@ use super::*; use std::future::Future; +use sha2::{Digest as _, Sha256}; -const PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_NAME: &str = "external-editor-api.json"; +const PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_PREFIX: &str = "external-editor-api-"; const PRIVATE_EXTERNAL_EDITOR_API_KEY_MAX_BYTES: u64 = 8 * 1024; const DIRECT_EXTERNAL_EDITOR_API_KEY_NAME: &str = "陶泥儿 AGC 直连客户端(本机)"; const PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_STORAGE_PREPARATION_FAILURE: &str = @@ -46,16 +47,22 @@ pub(crate) async fn with_external_editor_api_credentials( .await } -fn private_external_editor_api_key_path() -> Result { +fn private_external_editor_api_key_directory() -> Result { let home = std::env::var_os("USERPROFILE") .or_else(|| std::env::var_os("HOME")) .map(PathBuf::from) .filter(|path| path.is_absolute()) .ok_or_else(|| "无法定位当前用户的私有开发者 Key 目录".to_string())?; - Ok(home - .join(".config") - .join("genarrative") - .join(PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_NAME)) + Ok(home.join(".config").join("genarrative")) +} + +fn private_external_editor_api_key_path_for_base_url(api_base_url: &str) -> Result { + let api_base_url = normalize_external_editor_api_base_url(api_base_url)?; + let fingerprint = format!("{:x}", Sha256::digest(api_base_url.as_bytes())); + Ok(private_external_editor_api_key_directory()?.join(format!( + "{PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_PREFIX}{}.json", + &fingerprint[..16] + ))) } pub(crate) fn normalize_external_editor_api_base_url(value: &str) -> Result { @@ -74,13 +81,12 @@ pub(crate) fn normalize_external_editor_api_base_url(value: &str) -> Result() .is_ok_and(|ip| ip.is_loopback()); - if !production && !(cfg!(debug_assertions) && loopback && parsed.scheme() == "http") { + if parsed.scheme() == "http" && !loopback { return Err("陶泥儿 External Editor 地址不在受信任白名单内".to_string()); } Ok(value.to_string()) @@ -227,8 +233,12 @@ fn write_private_external_editor_api_credentials_at( base_url: Some(credentials.api_base_url.clone()), }) .map_err(|error| format!("序列化本机陶泥儿开发者 Key 配置失败:{error}"))?; + let file_name = path + .file_name() + .and_then(std::ffi::OsStr::to_str) + .ok_or_else(|| "本机陶泥儿开发者 Key 配置缺少文件名".to_string())?; let temporary = parent.join(format!( - ".{PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_NAME}.tmp.{}.{}", + ".{file_name}.tmp.{}.{}", std::process::id(), unix_millis(), )); @@ -326,7 +336,11 @@ async fn create_private_external_editor_api_credentials_from_platform_session( /// copied into the file or passed to the later isolated Runtime. pub(crate) async fn ensure_private_external_editor_api_credentials( ) -> Result { - let path = private_external_editor_api_key_path()?; + let session = current_platform_session().ok_or_else(|| { + "authentication-required: 未找到本机陶泥儿开发者 Key;请先在客户端登录一次以创建本机 Key" + .to_string() + })?; + let path = private_external_editor_api_key_path_for_base_url(&session.api_base_url)?; if let Some(credentials) = private_external_editor_api_credentials_from_file_at(&path)? { return Ok(credentials); } @@ -1637,7 +1651,15 @@ mod tests { .expect("trusted dev origin"), "https://dev.genarrative.world" ); - assert!(normalize_external_editor_api_base_url("https://untrusted.example.test").is_err()); + assert_eq!( + normalize_external_editor_api_base_url("http://127.0.0.1:8085/"), + Ok("http://127.0.0.1:8085".to_string()) + ); + assert!(normalize_external_editor_api_base_url("http://staging.example.com").is_err()); + assert_eq!( + normalize_external_editor_api_base_url("https://untrusted.example.test"), + Ok("https://untrusted.example.test".to_string()) + ); assert!( normalize_external_editor_api_base_url("https://dev.genarrative.world/path").is_err() ); @@ -1649,6 +1671,26 @@ mod tests { assert!(normalize_external_editor_api_key("tnr_sk_has whitespace").is_err()); } + #[test] + fn private_external_editor_credentials_are_isolated_by_server_origin() { + let local = private_external_editor_api_key_path_for_base_url("http://127.0.0.1:8082") + .expect("local credential path"); + let previous = + private_external_editor_api_key_path_for_base_url("http://127.0.0.1:8085") + .expect("previous local credential path"); + let dev = private_external_editor_api_key_path_for_base_url( + "https://dev.genarrative.world", + ) + .expect("dev credential path"); + + assert_ne!(local, previous); + assert_ne!(local, dev); + assert!(local + .file_name() + .and_then(std::ffi::OsStr::to_str) + .is_some_and(|name| name.starts_with(PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_PREFIX))); + } + #[test] fn private_external_editor_credentials_storage_failure_markers_are_closed_and_stable() { assert!( @@ -1675,7 +1717,7 @@ mod tests { .path() .join("config") .join("genarrative") - .join(PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_NAME); + .join("external-editor-api-test.json"); prepare_private_external_editor_api_credentials_parent_dir_at(&path) .expect("prepare private credential directory"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs index 359ad50f5..eb7b6ba5b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs @@ -131,7 +131,7 @@ fn normalize_platform_api_base_url(value: &str) -> Result { || host .parse::() .is_ok_and(|ip| ip.is_loopback()); - if parsed.scheme() == "http" && !(cfg!(debug_assertions) && loopback) { + if parsed.scheme() == "http" && !loopback { return Err("陶泥儿服务地址不在受信任白名单内".to_string()); } Ok(value.to_string()) diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index d7efba85a..459b16bf5 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -21,8 +21,6 @@ import { sendClientPhoneLoginCode, } from '../services/clientAuth'; import { - AGC_DEVELOPMENT_API_BASE_URL, - AGC_RELEASE_API_BASE_URL, type ClientServerPreset, type ClientServerSelection, getClientServerSelection, @@ -111,20 +109,13 @@ export function AuthenticatedClient({ const [customServerUrl, setCustomServerUrl] = useState( initialServerSelection.customBaseUrl, ); - const selectedServerAddress = - serverSelection.preset === 'release' - ? AGC_RELEASE_API_BASE_URL - : serverSelection.preset === 'dev' - ? AGC_DEVELOPMENT_API_BASE_URL - : customServerUrl || '请输入自定义服务器地址'; - function applyServerSelection() { + function persistServerSelection() { try { - const next = setClientServerSelection({ + setClientServerSelection({ preset: serverSelection.preset, customBaseUrl: customServerUrl, }); - setServerSelection(next); return true; } catch (error) { setLoginStatus(error instanceof Error ? error.message : String(error)); @@ -278,7 +269,7 @@ export function AuthenticatedClient({ if (codeBusy || codeCooldownSeconds > 0) { return; } - if (!applyServerSelection()) { + if (!persistServerSelection()) { return; } const normalizedPhone = normalizeAuthPhoneInput(phone); @@ -317,7 +308,7 @@ export function AuthenticatedClient({ setLoginStatus('请输入密码'); return; } - if (!applyServerSelection()) { + if (!persistServerSelection()) { return; } setLoginBusy(true); @@ -394,9 +385,6 @@ export function AuthenticatedClient({ - - {selectedServerAddress} - {serverSelection.preset === 'custom' ? (