diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index c9c7a2d3a..ce79c75a1 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -36,7 +36,6 @@ "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-clipboard-manager": "2.3.2", "@tauri-apps/plugin-dialog": "^2.7.2", - "@tauri-apps/plugin-http": "^2.5.9", "@tauri-apps/plugin-opener": "~2", "@tauri-apps/plugin-updater": "2.11.0", "@vitejs/plugin-react": "^5.0.4", diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index 01d477568..8f641dfe6 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -832,29 +832,10 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ - "percent-encoding", "time", "version_check", ] -[[package]] -name = "cookie_store" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" -dependencies = [ - "cookie", - "document-features", - "idna", - "log", - "publicsuffix", - "serde", - "serde_derive", - "serde_json", - "time", - "url", -] - [[package]] name = "core-foundation" version = "0.9.4" @@ -1048,12 +1029,6 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" -[[package]] -name = "data-url" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" - [[package]] name = "dbus" version = "0.9.11" @@ -1189,15 +1164,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - [[package]] name = "dom_query" version = "0.27.0" @@ -1319,15 +1285,6 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "endi" version = "1.1.1" @@ -1833,7 +1790,6 @@ dependencies = [ "tauri-build", "tauri-plugin-clipboard-manager", "tauri-plugin-dialog", - "tauri-plugin-http", "tauri-plugin-opener", "tauri-plugin-updater", "tempfile", @@ -2095,25 +2051,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "h2" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap 2.14.0", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "half" version = "2.7.1" @@ -2240,7 +2177,6 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", "http", "http-body", "httparse", @@ -2266,7 +2202,6 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", ] [[package]] @@ -2821,12 +2756,6 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - [[package]] name = "lock_api" version = "0.4.14" @@ -4185,22 +4114,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "psl-types" -version = "2.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" - -[[package]] -name = "publicsuffix" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" -dependencies = [ - "idna", - "psl-types", -] - [[package]] name = "pxfm" version = "0.1.30" @@ -4460,13 +4373,9 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", - "cookie", - "cookie_store", - "encoding_rs", "futures-channel", "futures-core", "futures-util", - "h2", "http", "http-body", "http-body-util", @@ -4476,7 +4385,6 @@ dependencies = [ "hyper-util", "js-sys", "log", - "mime", "mime_guess", "native-tls", "percent-encoding", @@ -4501,7 +4409,6 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams 0.4.2", "web-sys", - "webpki-roots", ] [[package]] @@ -5670,30 +5577,6 @@ dependencies = [ "url", ] -[[package]] -name = "tauri-plugin-http" -version = "2.5.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5bd512048e1985b7ec78f96d99083e2ddaf7e0d906b2b63c44ce5bb8b894067" -dependencies = [ - "bytes", - "cookie_store", - "data-url", - "http", - "regex", - "reqwest 0.12.28", - "schemars 0.8.22", - "serde", - "serde_json", - "tauri", - "tauri-plugin", - "tauri-plugin-fs", - "thiserror 2.0.18", - "tokio", - "url", - "urlpattern", -] - [[package]] name = "tauri-plugin-opener" version = "2.5.4" @@ -6906,15 +6789,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "webpki-roots" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "webview2-com" version = "0.38.2" diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index f28a5ebc0..fbe737fec 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -69,7 +69,6 @@ regex = "1" shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false, features = ["ts-bindings"] } tauri = { version = "2.11.2", features = [] } tauri-plugin-dialog = "2.7.1" -tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] } tauri-plugin-opener = "2" tauri-plugin-updater = "2.11.0" tempfile = "3" 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 753d89477..0d4150039 100644 --- a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json +++ b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json @@ -1,7 +1,7 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "main", - "description": "AI 游戏创作主窗口允许读取系统剪贴板图片,用于粘贴素材附件;允许弹出原生打开/保存对话框用于素材上传与导出。", + "description": "AI 游戏创作主窗口允许读取系统剪贴板图片,用于粘贴素材附件;允许弹出原生打开/保存对话框用于素材上传与导出。渲染层是离线前端:这里不授予任何 HTTP 权限,平台接口、OSS 直传、Provider 与更新清单的网络 IO 全部由 Rust 侧承担。", "windows": ["client"], "permissions": [ "clipboard-manager:allow-read-image", @@ -9,17 +9,6 @@ "core:image:allow-rgba", "core:image:allow-size", "core:resources:allow-close", - { - "identifier": "http:default", - "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:*/*" }, - { "url": "https://*.aliyuncs.com/*" } - ] - }, "opener:default", "updater:default", "dialog:allow-open", diff --git a/apps/ai-game-creator-shell/src-tauri/src/account_api.rs b/apps/ai-game-creator-shell/src-tauri/src/account_api.rs new file mode 100644 index 000000000..d14848d88 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/account_api.rs @@ -0,0 +1,316 @@ +//! 账户与钱包 typed facade。 +//! +//! 渲染层只提交结构化业务参数(商品 ID、订单 ID、兑换码);平台 origin、Bearer、 +//! 响应 envelope 解析与错误分类都在 Rust 内完成。access token 过期时返回稳定的 +//! `authentication-required`,由调用方按既有会话续期策略决定是否重试一次。 + +use crate::http_client::agc_main_site_client_builder; +use crate::platform_session::{current_platform_session, PlatformSessionSnapshot}; +use reqwest::{Method, StatusCode}; +use serde::de::DeserializeOwned; +use serde_json::Value; +use shared_contracts::runtime::{ + ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderResponse, + ProfileRechargeCenterResponse, ProfileWalletLedgerResponse, RedeemProfileRewardCodeResponse, +}; +use std::time::Duration; +use url::Url; + +const HTTP_TIMEOUT: Duration = Duration::from_secs(30); +const API_RESPONSE_ENVELOPE_HEADER: &str = "x-genarrative-response-envelope"; +const API_RESPONSE_ENVELOPE_VERSION: &str = "v1"; +const AGC_CLIENT_MARKER_HEADER: &str = "x-genarrative-client"; +const AGC_CLIENT_MARKER_VALUE: &str = "agc"; +const MAX_BUSINESS_ID_CHARS: usize = 128; +const MAX_REDEEM_CODE_CHARS: usize = 128; + +fn require_session() -> Result { + current_platform_session() + .ok_or_else(|| "authentication-required: 请先登录后再继续".to_string()) +} + +fn build_client() -> Result { + agc_main_site_client_builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(HTTP_TIMEOUT) + .build() + .map_err(|_| "创建账户网络客户端失败".to_string()) +} + +fn endpoint(snapshot: &PlatformSessionSnapshot, segments: &[&str]) -> Result { + let mut url = Url::parse(&format!("{}/", snapshot.api_base_url.trim_end_matches('/'))) + .map_err(|_| "陶泥儿服务地址无效".to_string())?; + { + let mut path = url + .path_segments_mut() + .map_err(|_| "陶泥儿服务地址无效".to_string())?; + for segment in segments { + if segment.is_empty() || segment.contains(['/', '?', '#']) { + return Err("账户请求路径无效".to_string()); + } + path.push(segment); + } + } + Ok(url.to_string()) +} + +fn bounded_business_id(value: &str, label: &str) -> Result { + let value = value.trim(); + if value.is_empty() + || value.chars().count() > MAX_BUSINESS_ID_CHARS + || value.chars().any(char::is_control) + { + return Err(format!("{label}无效")); + } + Ok(value.to_string()) +} + +fn error_message(body: &str) -> Option { + let value = serde_json::from_str::(body).ok()?; + let error = value.get("error").unwrap_or(&value); + error + .get("message") + .and_then(Value::as_str) + .map(str::trim) + .filter(|message| !message.is_empty()) + .map(ToString::to_string) +} + +fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String { + if status == StatusCode::UNAUTHORIZED { + return "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(); + } + if status == StatusCode::FORBIDDEN { + return format!( + "permission-denied: {}", + error_message(body).unwrap_or_else(|| "当前账号无权执行此操作".to_string()) + ); + } + let detail = error_message(body).unwrap_or_else(|| format!("HTTP {}", status.as_u16())); + format!("{fallback}:{detail}") +} + +fn unwrap_envelope(body: &str, fallback: &str) -> Result { + let value: Value = + serde_json::from_str(body).map_err(|_| format!("{fallback}:服务端响应不是合法 JSON"))?; + if value.get("ok").and_then(Value::as_bool) == Some(false) { + return Err(format!( + "{fallback}:{}", + error_message(body).unwrap_or_else(|| "服务端请求失败".to_string()) + )); + } + Ok(value.get("data").cloned().unwrap_or(value)) +} + +async fn request_json( + client: &reqwest::Client, + snapshot: &PlatformSessionSnapshot, + method: Method, + segments: &[&str], + body: Option, + fallback: &str, +) -> Result { + let url = endpoint(snapshot, segments)?; + let mut request = client + .request(method, &url) + .bearer_auth(&snapshot.access_token) + .header(AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_VALUE) + .header(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION) + .header(reqwest::header::ACCEPT, "application/json"); + if let Some(body) = body { + request = request.json(&body); + } + let response = request.send().await.map_err(|error| { + if error.is_timeout() { + format!("{fallback}:请求超时,请稍后重试") + } else { + format!("{fallback}:无法连接登录服务,请确认配套后端或 API 代理已启动后重试") + } + })?; + let status = response.status(); + let text = response + .text() + .await + .map_err(|_| format!("{fallback}:读取响应失败"))?; + if !status.is_success() { + return Err(map_http_error(status, &text, fallback)); + } + let data = unwrap_envelope(&text, fallback)?; + serde_json::from_value(data).map_err(|_| format!("{fallback}:响应格式无效")) +} + +#[tauri::command] +pub(crate) async fn read_profile_recharge_center() -> Result +{ + let snapshot = require_session()?; + let client = build_client()?; + request_json( + &client, + &snapshot, + Method::GET, + &["api", "profile", "recharge-center"], + None, + "读取泥点明细失败", + ) + .await +} + +#[tauri::command] +pub(crate) async fn read_profile_wallet_ledger() -> Result { + let snapshot = require_session()?; + let client = build_client()?; + request_json( + &client, + &snapshot, + Method::GET, + &["api", "profile", "wallet-ledger"], + None, + "读取泥点账单失败", + ) + .await +} + +#[tauri::command] +pub(crate) async fn create_profile_recharge_order( + product_id: String, +) -> Result { + let product_id = bounded_business_id(&product_id, "充值商品标识")?; + let snapshot = require_session()?; + let client = build_client()?; + request_json( + &client, + &snapshot, + Method::POST, + &["api", "profile", "recharge", "orders"], + Some(serde_json::json!({ + "productId": product_id, + "paymentChannel": "wechat_native", + })), + "充值失败", + ) + .await +} + +#[tauri::command] +pub(crate) async fn confirm_wechat_profile_recharge_order( + order_id: String, +) -> Result { + let order_id = bounded_business_id(&order_id, "充值订单标识")?; + let snapshot = require_session()?; + let client = build_client()?; + request_json( + &client, + &snapshot, + Method::POST, + &[ + "api", + "profile", + "recharge", + "orders", + order_id.as_str(), + "wechat", + "confirm", + ], + None, + "确认微信支付订单失败", + ) + .await +} + +#[tauri::command] +pub(crate) async fn redeem_profile_reward_code( + code: String, +) -> Result { + let code = code.trim(); + if code.is_empty() || code.chars().count() > MAX_REDEEM_CODE_CHARS { + return Err("兑换码无效".to_string()); + } + let snapshot = require_session()?; + let client = build_client()?; + request_json( + &client, + &snapshot, + Method::POST, + &["api", "profile", "redeem-codes", "redeem"], + Some(serde_json::json!({ "code": code })), + "兑换失败", + ) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + + fn snapshot() -> PlatformSessionSnapshot { + PlatformSessionSnapshot { + user_id: "user-1".to_string(), + access_token: "token".to_string(), + api_base_url: "https://dev.genarrative.world".to_string(), + identity_generation: 1, + revision: 1, + } + } + + #[test] + fn endpoint_encodes_path_segments_and_keeps_origin() { + assert_eq!( + endpoint( + &snapshot(), + &["api", "profile", "recharge", "orders", "order-1", "wechat", "confirm"] + ) + .expect("endpoint"), + "https://dev.genarrative.world/api/profile/recharge/orders/order-1/wechat/confirm" + ); + assert!(endpoint(&snapshot(), &["api", "bad/id"]).is_err()); + } + + #[test] + fn expires_and_permissions_keep_stable_categories() { + assert_eq!( + map_http_error(StatusCode::UNAUTHORIZED, "{}", "读取失败"), + "authentication-required: 陶泥儿登录态已过期,请重新登录后重试" + ); + assert_eq!( + map_http_error( + StatusCode::FORBIDDEN, + r#"{"error":{"message":"无权"}}"#, + "读取失败" + ), + "permission-denied: 无权" + ); + assert_eq!( + map_http_error( + StatusCode::BAD_REQUEST, + r#"{"error":{"message":"余额不足"}}"#, + "充值失败" + ), + "充值失败:余额不足" + ); + } + + #[test] + fn envelope_unwrap_reports_in_band_failures() { + assert_eq!( + unwrap_envelope(r#"{"ok":true,"data":{"walletBalance":1}}"#, "读取失败") + .expect("envelope")["walletBalance"], + 1 + ); + assert_eq!( + unwrap_envelope( + r#"{"ok":false,"error":{"message":"登录已失效"}}"#, + "读取失败" + ) + .expect_err("in-band failure"), + "读取失败:登录已失效" + ); + } + + #[test] + fn business_identifiers_are_bounded_before_network() { + assert!(bounded_business_id(" ", "充值商品标识").is_err()); + assert!(bounded_business_id("product-1", "充值商品标识").is_ok()); + assert!(bounded_business_id(&"x".repeat(MAX_BUSINESS_ID_CHARS + 1), "订单").is_err()); + assert!(bounded_business_id("bad\nid", "订单").is_err()); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs index b65f25ba4..24c8eade9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs @@ -122,6 +122,40 @@ mod tests { assert!(error.to_string().contains("unknown field"), "{error}"); } + #[test] + fn skill_reference_serializes_with_only_the_stable_name() { + let item: DirectCodexUserItem = serde_json::from_value(json!({ + "type": "message", + "role": "user", + "content": [{"type": "agc_skill_reference", "name": "agc-web-game-development"}], + "id": "turn-1" + })) + .expect("skill reference should parse"); + assert_eq!( + serde_json::to_value(item).expect("serialize skill reference")["content"][0], + json!({"type": "agc_skill_reference", "name": "agc-web-game-development"}) + ); + + // canonical part 不接受正文、路径或凭据类附加字段:它们只可能来自宿主私密状态。 + for forbidden in ["path", "body", "content", "token", "apiKey"] { + let error = serde_json::from_value::(json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "agc_skill_reference", + "name": "agc-web-game-development", + forbidden: "x" + }], + "id": "turn-1" + })) + .expect_err("extra skill reference fields must be rejected"); + assert!( + error.to_string().contains("unknown field"), + "{forbidden}: {error}" + ); + } + } + #[test] fn unknown_content_part_fails_closed() { serde_json::from_value::(json!({ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs index d3e94388a..7fb52396d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs @@ -282,8 +282,9 @@ fn render_ui_design_code_context( #[cfg(test)] mod tests { use super::{ - direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item, - direct_codex_user_item_to_wire_input, validate_direct_codex_user_item, + direct_codex_user_item_to_codex_turn_input, direct_codex_user_item_to_prompt, + direct_codex_user_item_to_response_item, direct_codex_user_item_to_wire_input, + validate_direct_codex_user_item, }; use crate::agent::direct_codex_user_item::model::DirectCodexUserItem; use crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE; @@ -423,6 +424,47 @@ mod tests { assert!(prompt.contains("生成代码遇到错误"), "{prompt}"); } + #[test] + fn multiple_references_keep_order_when_one_ui_design_doc_generation_fails() { + let (project, failing_ui_doc_id) = ui_design_doc_fixture(false); + let hero_id = register_fixture_asset( + project.path(), + "assets/hero.png", + GameCreationAppAssetKind::Character, + "image/png", + ); + let item: super::DirectCodexUserItem = serde_json::from_value(json!({ + "type": "message", + "role": "user", + "id": "turn-multi-1:user", + "content": [ + {"type": "agc_resource_reference", "resourceId": failing_ui_doc_id}, + {"type": "input_text", "text": "顺带看看主角"}, + {"type": "agc_resource_reference", "resourceId": hero_id}, + ], + })) + .expect("canonical user item"); + let prompt = + direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection"); + + let ui_doc_at = prompt + .find(&failing_ui_doc_id) + .unwrap_or_else(|| panic!("失败的 UI 文档引用也必须保留:{prompt}")); + let hero_at = prompt + .find(&hero_id) + .unwrap_or_else(|| panic!("同一轮里的其它引用不能被丢掉:{prompt}")); + assert!( + ui_doc_at < hero_at, + "引用顺序必须与 canonical content 顺序一致:{prompt}" + ); + assert!(prompt.contains("顺带看看主角"), "{prompt}"); + assert!(prompt.contains("生成代码遇到错误"), "{prompt}"); + assert!( + !prompt.contains("请先阅读生成的带有文档的代码片段"), + "生成失败时不能出现代码片段指引:{prompt}" + ); + } + #[test] fn other_asset_kind_reference_does_not_generate_ui_design_code() { let project = prompt_context_project(); @@ -613,6 +655,62 @@ mod tests { } } + #[test] + fn unavailable_skill_reference_fails_closed_before_any_turn_input() { + let root = prompt_context_project(); + let item = json!({ + "type": "message", + "role": "user", + "id": "turn-skill:user", + "content": [ + {"type": "input_text", "text": "请使用 Skill"}, + {"type": "agc_skill_reference", "name": "missing-skill"} + ] + }); + let user_item: DirectCodexUserItem = + serde_json::from_value(item).expect("parse canonical item"); + // 已启用目录里没有这个 Skill:转换必须在启动回合前失败关闭, + // 不能把不可用的引用降级成正文放行。 + let error = direct_codex_user_item_to_codex_turn_input( + root.path(), + &user_item, + &[root.path().join("skills")], + ) + .expect_err("unavailable skill must fail closed"); + assert!( + error.contains("当前不可用"), + "错误文案要能指导用户重新选择:{error}" + ); + + // 目录里存在时按受控路径解析成 Codex 原生 skill 输入项,顺序与 canonical content 一致。 + let skill_root = root.path().join("skills"); + let skill_path = skill_root.join("present-skill").join("SKILL.md"); + std::fs::create_dir_all(skill_path.parent().expect("skill directory")) + .expect("create skill directory"); + std::fs::write(&skill_path, "# 测试 Skill").expect("write skill"); + let item = json!({ + "type": "message", + "role": "user", + "id": "turn-skill-ok:user", + "content": [ + {"type": "agc_skill_reference", "name": "present-skill"}, + {"type": "input_text", "text": "然后创建菜单"} + ] + }); + let user_item: DirectCodexUserItem = + serde_json::from_value(item).expect("parse canonical item"); + let input = + direct_codex_user_item_to_codex_turn_input(root.path(), &user_item, &[skill_root]) + .expect("available skill should convert"); + assert_eq!( + input, + json!([ + {"type": "skill", "name": "present-skill", "path": skill_path}, + {"type": "text", "text": "然后创建菜单"} + ]) + ); + } + #[test] fn history_item_without_type_fails_closed() { let error = direct_codex_user_item_to_response_item( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 4db66f9f7..48c6f6c63 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -512,6 +512,8 @@ fn direct_taonier_active_now_millis() -> u64 { pub(crate) struct DirectTaonierActiveInvocationGuard { root: PathBuf, invocation_id: String, + /// Rust 侧会话保活:只在一条 Direct 回合存活期间运行,渲染层不再定时刷新。 + _session_keepalive: Option, } impl DirectTaonierActiveInvocationGuard { @@ -557,9 +559,12 @@ impl DirectTaonierActiveInvocationGuard { ); } } + drop(active); + crate::agent::emit_direct_active_turns_changed(); Ok(Self { root, invocation_id: invocation_id.to_string(), + _session_keepalive: crate::auth_session::spawn_client_session_keepalive(), }) } } @@ -578,6 +583,10 @@ impl Drop for DirectTaonierActiveInvocationGuard { if remove { active.remove(&self.root); } + drop(active); + if remove { + crate::agent::emit_direct_active_turns_changed(); + } } } @@ -626,10 +635,15 @@ pub(crate) fn update_direct_active_turn( if invocation.invocation_id != turn_id || sequence < invocation.sequence { return; } + let changed = invocation.status != status || invocation.activity.as_deref() != activity; invocation.status = status.to_string(); invocation.activity = activity.map(str::to_string); invocation.updated_at = updated_at; invocation.sequence = sequence; + drop(active); + if changed { + crate::agent::emit_direct_active_turns_changed(); + } } pub(crate) fn direct_taonier_active_invocation_id_at(root: &Path) -> Result { @@ -746,6 +760,8 @@ pub(crate) fn release_stale_direct_taonier_active_invocation( } let released = existing.invocation_id.clone(); active.remove(&root); + drop(active); + crate::agent::emit_direct_active_turns_changed(); Ok(released) } @@ -6113,8 +6129,14 @@ mod tests { fn active_turn_snapshot_tracks_progress_and_is_removed_after_drop() { let root = tempfile::tempdir().expect("active snapshot root"); let turn_id = "client-turn-snapshot-0001"; + let events_before_enter = crate::agent::direct_active_turns_event_test_count(); let guard = DirectTaonierActiveInvocationGuard::enter(root.path(), turn_id) .expect("active snapshot turn"); + assert_eq!( + crate::agent::direct_active_turns_event_test_count(), + events_before_enter + 1, + "enter publishes one active-turn notification" + ); update_direct_active_turn( root.path(), turn_id, @@ -6123,6 +6145,21 @@ mod tests { 3, 42, ); + let events_after_progress = crate::agent::direct_active_turns_event_test_count(); + assert_eq!(events_after_progress, events_before_enter + 2); + update_direct_active_turn( + root.path(), + turn_id, + "streaming", + Some("response-finalization"), + 4, + 43, + ); + assert_eq!( + crate::agent::direct_active_turns_event_test_count(), + events_after_progress, + "text/progress sequence changes do not publish duplicate active-turn notifications" + ); let snapshot = list_direct_active_turns() .expect("list active turns") .into_iter() @@ -6130,9 +6167,14 @@ mod tests { .expect("snapshot entry"); assert_eq!(snapshot.status, "streaming"); assert_eq!(snapshot.activity.as_deref(), Some("response-finalization")); - assert_eq!(snapshot.sequence, 3); - assert_eq!(snapshot.updated_at, 42); + assert_eq!(snapshot.sequence, 4); + assert_eq!(snapshot.updated_at, 43); drop(guard); + assert_eq!( + crate::agent::direct_active_turns_event_test_count(), + events_after_progress + 1, + "drop publishes the removal notification" + ); assert!(list_direct_active_turns() .expect("list after completion") .into_iter() @@ -8128,6 +8170,45 @@ mod tests { assert!(!diagnostic.contains("provider.example")); } + #[test] + fn direct_codex_failure_text_keeps_the_detail_ref_marker_for_the_renderer() { + let parent = tempfile::tempdir().expect("temp dir"); + let root = parent.path().join("project"); + init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); + let error = record_direct_codex_turn_failure( + &root, + DirectCodexTurnFailure::new( + DirectCodexFailureStage::CodeGeneration, + "入口模块语法错误", + ), + Some("client-turn-detail-ref"), + ); + + // 渲染层按这个后缀取回 `detailRef` 再读有界诊断;标记或后缀变化等于跨层协议变化, + // 必须同时改前端解析(`src/services/agentRuntimeErrorDetail.ts`)与两侧用例。 + let marker = ";详情:.agent/runtime/errors/"; + let ref_start = error + .find(marker) + .unwrap_or_else(|| panic!("失败文案缺少诊断引用标记:{error}")) + + marker.len(); + let detail_ref = &error[ref_start..]; + assert!( + detail_ref.ends_with(".json"), + "诊断引用必须以 .json 结尾:{error}" + ); + assert!( + !detail_ref[..detail_ref.len() - ".json".len()].contains('/'), + "诊断引用只能是错误目录下的单个文件名:{error}" + ); + assert!( + detail_ref.starts_with("error-"), + "诊断引用沿用统一错误事件的 eventId:{error}" + ); + // 引用指向的文件名就是事件身份,且渲染层读得到它。 + let detail_path = root.join(format!(".agent/runtime/errors/{detail_ref}")); + assert!(detail_path.is_file(), "诊断 sidecar 必须真的落盘:{error}"); + } + #[test] fn direct_failure_diagnostic_marks_project_history_shape_failure_as_not_retryable() { let parent = tempfile::tempdir().expect("temp dir"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index ee3cfcd41..481ba5f54 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -208,6 +208,23 @@ impl DirectThreadManager { Ok(result) } + /// 移除订阅者并立即释放它钉住的队列前缀。重复移除是幂等空操作,允许前端 + /// cleanup 与迟到的 bootstrap 回执安全竞争。 + pub(crate) fn unsubscribe(&mut self, subscription_id: &str) -> bool { + let Some((_, thread)) = self + .threads + .iter_mut() + .find(|(_, thread)| thread.subscribers.contains_key(subscription_id)) + else { + return false; + }; + let removed = thread.subscribers.remove(subscription_id).is_some(); + if removed { + Self::trim_prefix(thread); + } + removed + } + #[cfg(test)] fn thread_debug(&self, thread_id: &str) -> Option<(usize, usize, usize)> { self.threads.get(thread_id).map(|thread| { @@ -413,6 +430,13 @@ pub(crate) fn consume_direct_thread( .consume(subscription_id) } +pub(crate) fn unsubscribe_direct_thread(subscription_id: &str) { + let mut manager = global_direct_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + manager.unsubscribe(subscription_id); +} + #[cfg(test)] mod tests { use super::*; @@ -475,6 +499,34 @@ mod tests { .is_empty()); } + #[test] + fn unsubscribe_releases_the_subscriber_and_is_idempotent() { + let mut manager = DirectThreadManager::with_limits(100, 100_000); + manager.append("thread-1", item_started("item-1")); + let subscription = manager.subscribe("thread-1"); + manager.append("thread-1", item_delta("item-1")); + + assert!(manager.unsubscribe(&subscription.subscription_id)); + assert!(!manager.unsubscribe(&subscription.subscription_id)); + assert_eq!( + manager.consume(&subscription.subscription_id), + Err(SUBSCRIPTION_EXPIRED.to_string()) + ); + } + + #[test] + fn unsubscribe_allows_a_cleanable_prefix_to_be_trimmed() { + let mut manager = DirectThreadManager::with_limits(100, 100_000); + manager.append("thread-1", item_started("item-1")); + let subscription = manager.subscribe("thread-1"); + manager.append("thread-1", item_completed("item-1")); + assert_eq!(manager.thread_debug("thread-1").unwrap().0, 1); + + manager.unsubscribe(&subscription.subscription_id); + + assert_eq!(manager.thread_debug("thread-1").unwrap().0, 0); + } + #[test] fn bootstrap_contains_lifecycle_anchor_and_unfinished_events_only() { let mut manager = DirectThreadManager::with_limits(100, 100_000); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index faec487c1..99433634b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -284,6 +284,8 @@ pub(in crate::agent) use task_start::*; #[cfg(test)] pub(crate) use entrypoints::acquire_game_creator_manifest_invalidation_event_sink_test_guard; +#[cfg(test)] +pub(crate) use entrypoints::direct_active_turns_event_test_count; #[allow(unused_imports)] pub(crate) use entrypoints::{ chat_with_game_creator_agent_at, chat_with_game_creator_role_agent_at, @@ -291,11 +293,12 @@ pub(crate) use entrypoints::{ chat_with_game_creator_role_agent_runtime_for_session_at, chat_with_game_creator_role_agent_stream_at, chat_with_game_creator_role_agent_stream_for_session_at, - configure_game_creator_manifest_invalidation_event_sink, emit_direct_game_creator_progress, - emit_game_creator_agent_runtime_update, emit_game_creator_manifest_invalidated, - game_creator_agent_runtime_update_event, generate_local_game_draft_at, - read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_for_session_at, - read_game_creator_agent_runtimes_at, register_game_creator_manifest_invalidation_event_sink, + configure_game_creator_manifest_invalidation_event_sink, emit_direct_active_turns_changed, + emit_direct_game_creator_progress, emit_game_creator_agent_runtime_update, + emit_game_creator_manifest_invalidated, game_creator_agent_runtime_update_event, + generate_local_game_draft_at, read_game_creator_agent_runtime_at, + read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at, + register_game_creator_manifest_invalidation_event_sink, set_game_creator_agent_runtime_update_app_handle, start_game_creator_manifest_invalidation_event_sink, validate_game_creator_manifest_invalidation_event_sink, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index 0d4a11336..71b0017c1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs @@ -1,5 +1,11 @@ use super::*; +pub(crate) const DIRECT_ACTIVE_TURNS_CHANGED_EVENT: &str = + "game-creator-direct-active-turns-changed"; +static DIRECT_ACTIVE_TURNS_EVENT_REVISION: AtomicU64 = AtomicU64::new(0); +#[cfg(test)] +static DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT: AtomicU64 = AtomicU64::new(0); + const GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES: u64 = 64 * 1024; const GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX: usize = 16; @@ -15,6 +21,29 @@ pub(crate) fn set_game_creator_agent_runtime_update_app_handle(app: tauri::AppHa let _ = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.set(app); } +/// Notify the GUI that the Rust-owned Direct active-turn registry changed. +/// +/// The payload is intentionally only a monotonic revision. The GUI must read +/// the authoritative snapshot through `list_game_creator_direct_active_turns` +/// and coalesce duplicate notifications while a read is in flight. +pub(crate) fn emit_direct_active_turns_changed() { + let revision = DIRECT_ACTIVE_TURNS_EVENT_REVISION.fetch_add(1, Ordering::AcqRel) + 1; + #[cfg(test)] + DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.fetch_add(1, Ordering::AcqRel); + let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else { + return; + }; + let _ = app.emit( + DIRECT_ACTIVE_TURNS_CHANGED_EVENT, + serde_json::json!({ "revision": revision }), + ); +} + +#[cfg(test)] +pub(crate) fn direct_active_turns_event_test_count() -> u64 { + DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.load(Ordering::Acquire) +} + pub(crate) fn emit_direct_game_creator_progress(root: &Path, stage: &str, message: &str) { let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else { return; diff --git a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs index 0fd3f96d6..745535122 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs @@ -48,6 +48,28 @@ pub(crate) const ASSET_GENERATION_TASK_STATUS_QUEUED: &str = "queued"; pub(crate) const ASSET_GENERATION_TASK_STATUS_RUNNING: &str = "running"; pub(crate) const ASSET_GENERATION_TASK_STATUS_COMPLETED: &str = "completed"; pub(crate) const ASSET_GENERATION_TASK_STATUS_FAILED: &str = "failed"; +pub(crate) const ASSET_GENERATION_TASK_CHANGED_EVENT: &str = + "game-creator-asset-generation-task-changed"; + +static ASSET_GENERATION_TASK_APP_HANDLE: OnceLock = OnceLock::new(); + +pub(crate) fn set_asset_generation_task_app_handle(app: tauri::AppHandle) { + let _ = ASSET_GENERATION_TASK_APP_HANDLE.set(app); +} + +fn emit_asset_generation_task_changed(root: &Path, task_id: &str) { + let Some(app) = ASSET_GENERATION_TASK_APP_HANDLE.get() else { + return; + }; + let _ = tauri::Emitter::emit( + app, + ASSET_GENERATION_TASK_CHANGED_EVENT, + serde_json::json!({ + "projectPath": root.to_string_lossy(), + "taskId": task_id, + }), + ); +} const ASSET_GENERATION_TASK_PHASE_QUEUED: &str = "排队中。"; const ASSET_GENERATION_TASK_PHASE_RUNNING: &str = "正在生成。"; @@ -261,6 +283,10 @@ pub(crate) fn list_local_project_asset_generation_tasks( let registered = registered_asset_ids_by_local_path(root); if repair_interrupted_tasks(&mut tasks, ®istered) { write_ledger(root, &tasks)?; + drop(_guard); + for task in tasks.iter() { + emit_asset_generation_task_changed(root, &task.task_id); + } } Ok(tasks) } @@ -282,6 +308,8 @@ where mutate(record); let snapshot = record.clone(); write_ledger(root, &tasks)?; + drop(_guard); + emit_asset_generation_task_changed(root, task_id); Ok(snapshot) } @@ -333,6 +361,8 @@ pub(crate) fn begin_local_project_asset_generation_task( tasks.push(record.clone()); trim_ledger(&mut tasks); write_ledger(root, &tasks)?; + drop(_guard); + emit_asset_generation_task_changed(root, task_id); Ok(record) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs new file mode 100644 index 000000000..11c7807a2 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs @@ -0,0 +1,1352 @@ +//! AGC 认证会话:凭据持有、登录/续期/登出 HTTP 与本地运行时会话安装。 +//! +//! 渲染层只表达「用哪个手机号/密码/验证码登录」「哪个 origin」这类用户意图;access token +//! 只在 Rust 内存与本进程会话快照里,refresh 凭据只写在 AppData 私有文件里。换号、登出或 +//! origin 变化都会让旧身份的在途请求失败关闭;同一身份的凭据轮换不改变身份代次。 + +use super::*; + +use crate::http_client::agc_main_site_client_builder; +use crate::platform_session::{current_platform_session, PlatformSessionSnapshot}; +use reqwest::{header::SET_COOKIE, Method, StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use shared_contracts::auth::AuthUserPayload; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::Duration; +use tauri::{Emitter, Manager}; +use url::Url; + +pub(crate) const CLIENT_AUTH_STATE_CHANGED_EVENT: &str = "agc-client-auth-state-changed"; + +const SESSION_FILE_NAME: &str = "client-session.json"; +const SESSION_SCHEMA_VERSION: &str = "agc-client-session.v1"; +const RELEASE_ORIGIN: &str = "https://www.genarrative.world"; +const DEVELOPMENT_ORIGIN: &str = "https://dev.genarrative.world"; +const HTTP_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_ORIGIN_CHARS: usize = 2_048; +const MAX_PHONE_CHARS: usize = 32; +const MAX_SECRET_CHARS: usize = 8_192; +const API_RESPONSE_ENVELOPE_HEADER: &str = "x-genarrative-response-envelope"; +const API_RESPONSE_ENVELOPE_VERSION: &str = "v1"; +const AGC_CLIENT_MARKER_HEADER: &str = "x-genarrative-client"; +const AGC_CLIENT_MARKER_VALUE: &str = "agc"; +const AUTH_NETWORK_ERROR: &str = + "network-error: 无法连接登录服务,请确认配套后端或 API 代理已启动后重试"; +const AUTH_NETWORK_TIMEOUT: &str = "network-error: 登录服务响应超时,请检查服务器地址和网络后重试"; +const AUTH_AUTHORITY_ERROR: &str = "authentication-required: 登录状态已失效,请重新登录"; + +/// 认证态投影:只含状态、用户展示字段与 origin,不含 token 或 refresh 凭据。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ClientAuthStateView { + /// `authenticated` / `unauthenticated` / `unavailable`。 + pub(crate) status: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) user: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) api_base_url: Option, + /// 失败分类:`network` / `authority` / `contract`;成功或未登录时为 `None`。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) error_kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) error_message: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ClientLoginCodeView { + pub(crate) cooldown_seconds: u64, + pub(crate) expires_in_seconds: u64, +} + +/// 续期结果。`stale` 表示续期期间身份已经变化,调用方不得重放旧身份请求。 +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ClientAuthRefreshView { + /// `refreshed` / `unauthenticated` / `stale` / `failed`。 + pub(crate) status: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) user: Option, + #[serde(default)] + pub(crate) authoritative: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) error_message: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct ClientSessionFile { + #[serde(default = "session_schema_version")] + schema_version: String, + #[serde(default)] + api_base_url: String, + #[serde(default)] + user_id: String, + #[serde(default)] + refresh_cookie_name: String, + #[serde(default)] + refresh_cookie_value: String, +} + +impl ClientSessionFile { + fn is_complete(&self) -> bool { + !self.api_base_url.is_empty() + && !self.user_id.is_empty() + && !self.refresh_cookie_name.is_empty() + && !self.refresh_cookie_value.is_empty() + } +} + +fn session_schema_version() -> String { + SESSION_SCHEMA_VERSION.to_string() +} + +/// 进程内的会话代次与最近一次登录身份。 +/// +/// access token 不在这里:它只在 `platform_session` 快照中,避免出现两份凭据副本。 +#[derive(Default)] +struct AuthState { + session: Option, + identity_generation: u64, + revision: u64, + counters_seeded: bool, + /// 当前 access token 的签发时间(本进程视角),用于按到期时间自助续期。 + access_token_issued_at: Option, +} + +static AUTH_STATE: std::sync::OnceLock> = std::sync::OnceLock::new(); +static REFRESH_LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); +static AUTH_APP_HANDLE: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// 会话保活:Direct 回合可能横跨图片生成、构建和浏览器验证,出站调用前必须保证 access +/// token 仍然新鲜。保持时间比旧的「渲染层每 5 分钟刷新一次」更短,避免边界抖动。 +const CLIENT_SESSION_REFRESH_AFTER_MILLIS: u64 = 4 * 60 * 1_000; +const CLIENT_SESSION_KEEPALIVE_TICK: Duration = Duration::from_secs(30); + +/// 保存 AppHandle,供 Rust 自己发起的会话保活使用(渲染层不再参与保活调度)。 +pub(crate) fn initialize_auth_session(app: &tauri::AppHandle) { + let _ = AUTH_APP_HANDLE.set(app.clone()); +} + +fn unix_millis_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64) + .unwrap_or_default() +} + +/// 是否需要为当前会话续期:不知道签发时间时按「需要」处理。 +fn session_needs_refresh(access_token_issued_at: Option, now_millis: u64) -> bool { + match access_token_issued_at { + None => true, + Some(issued_at) => { + now_millis.saturating_sub(issued_at) >= CLIENT_SESSION_REFRESH_AFTER_MILLIS + } + } +} + +fn client_session_needs_refresh() -> bool { + let issued_at = auth_state() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .access_token_issued_at; + // 没有本进程会话(未登录 / 已登出)时不触发续期。 + current_platform_session().is_some() && session_needs_refresh(issued_at, unix_millis_now()) +} + +/// Direct 回合期间由 Rust 持有的会话保活任务;drop 即停止。 +#[derive(Debug)] +pub(crate) struct ClientSessionKeepalive { + task: tauri::async_runtime::JoinHandle<()>, +} + +impl Drop for ClientSessionKeepalive { + fn drop(&mut self) { + self.task.abort(); + } +} + +/// 启动 Rust 侧会话保活;没有 AppHandle(未初始化)时返回 `None`。 +pub(crate) fn spawn_client_session_keepalive() -> Option { + let app = AUTH_APP_HANDLE.get()?.clone(); + let task = tauri::async_runtime::spawn(async move { + let mut ticker = tokio::time::interval(CLIENT_SESSION_KEEPALIVE_TICK); + loop { + ticker.tick().await; + if !client_session_needs_refresh() { + continue; + } + let _ = refresh_session_inner(&app, None).await; + } + }); + Some(ClientSessionKeepalive { task }) +} + +fn auth_state() -> &'static Mutex { + AUTH_STATE.get_or_init(|| Mutex::new(AuthState::default())) +} + +fn refresh_lock() -> &'static tokio::sync::Mutex<()> { + REFRESH_LOCK.get_or_init(|| tokio::sync::Mutex::new(())) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct TokenUserResponse { + token: String, + user: AuthUserPayload, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct TokenResponse { + token: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct MeResponse { + #[serde(default)] + user: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct SendCodeResponse { + #[serde(default)] + cooldown_seconds: u64, + #[serde(default)] + expires_in_seconds: u64, +} + +fn session_file_path(app: &tauri::AppHandle) -> Result { + app.path() + .app_data_dir() + .map(|root| root.join(SESSION_FILE_NAME)) + .map_err(|error| format!("无法读取 AGC 应用数据目录:{error}")) +} + +/// 校验并归一化平台服务 origin。 +/// +/// 只接受纯 origin:不允许凭据、路径、查询或 fragment;非本机必须 HTTPS。发布构建额外 +/// 限制在已知渠道 origin 内,避免调试期写入的自定义地址在正式包里被继续使用。 +pub(crate) fn validate_client_api_base_url(value: &str) -> Result { + let trimmed = value.trim().trim_end_matches('/'); + if trimmed.is_empty() || trimmed.chars().count() > MAX_ORIGIN_CHARS { + return Err("陶泥儿服务地址无效".to_string()); + } + let parsed = Url::parse(trimmed).map_err(|_| "陶泥儿服务地址无效".to_string())?; + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err("陶泥儿服务地址不能包含凭据".to_string()); + } + if !matches!(parsed.path(), "" | "/") || parsed.query().is_some() || parsed.fragment().is_some() + { + return Err("陶泥儿服务地址必须是纯地址,不能带路径或参数".to_string()); + } + let host = parsed.host_str().unwrap_or_default().to_ascii_lowercase(); + match parsed.scheme() { + "https" => {} + "http" if matches!(host.as_str(), "localhost" | "127.0.0.1" | "[::1]") => {} + "http" => return Err("非本机服务器必须使用 HTTPS".to_string()), + _ => return Err("陶泥儿服务地址必须是 HTTP(S) 地址".to_string()), + } + if !cfg!(debug_assertions) + && !matches!(trimmed, RELEASE_ORIGIN | DEVELOPMENT_ORIGIN) + && !matches!(host.as_str(), "localhost" | "127.0.0.1" | "[::1]") + { + return Err("服务器地址不在当前构建渠道范围内".to_string()); + } + Ok(trimmed.to_string()) +} + +fn read_session_file_at(path: &Path) -> Option { + let metadata = fs::symlink_metadata(path).ok()?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return None; + } + let content = fs::read_to_string(path).ok()?; + let file = serde_json::from_str::(&content).ok()?; + file.is_complete().then_some(file) +} + +fn write_session_file_at(path: &Path, session: Option<&ClientSessionFile>) -> Result<(), String> { + let Some(session) = session else { + if fs::symlink_metadata(path).is_ok() { + fs::remove_file(path).map_err(|error| format!("清除客户端登录凭据失败:{error}"))?; + } + return Ok(()); + }; + let parent = path + .parent() + .ok_or_else(|| "客户端登录凭据缺少父目录".to_string())?; + crate::ensure_game_creator_private_directory_tree(parent, "客户端登录凭据目录")?; + let content = serde_json::to_string_pretty(session) + .map_err(|error| format!("序列化客户端登录凭据失败:{error}"))?; + let temp_path = path.with_file_name(format!( + ".{}.tmp.{}.{}", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or(SESSION_FILE_NAME), + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + let mut options = OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } + let mut handle = options + .open(&temp_path) + .map_err(|error| format!("创建客户端登录凭据临时文件失败:{error}"))?; + if let Err(error) = + crate::harden_new_game_creator_private_path(&temp_path, false, "客户端登录凭据") + { + drop(handle); + let _ = fs::remove_file(&temp_path); + return Err(error); + } + let write_result = handle + .write_all(format!("{content}\n").as_bytes()) + .and_then(|_| handle.sync_all()); + drop(handle); + if let Err(error) = write_result { + let _ = fs::remove_file(&temp_path); + return Err(format!("写入客户端登录凭据失败:{error}")); + } + if fs::symlink_metadata(path).is_ok() { + crate::prepare_game_creator_private_path_for_read(path, false, "客户端登录凭据")?; + #[cfg(windows)] + fs::remove_file(path).map_err(|error| { + let _ = fs::remove_file(&temp_path); + format!("替换客户端登录凭据失败:{error}") + })?; + } + if let Err(error) = fs::rename(&temp_path, path) { + let _ = fs::remove_file(&temp_path); + return Err(format!("提交客户端登录凭据失败:{error}")); + } + Ok(()) +} +fn require_app_session(app: &tauri::AppHandle) -> Result, String> { + let path = session_file_path(app)?; + let mut state = auth_state() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if state.session.is_none() { + state.session = read_session_file_at(&path); + } + Ok(state.session.clone()) +} + +/// 推进下一次原生写入代次:换号与登出推进身份代次,同一身份续期只推进 revision。 +fn reserve_session_write(identity_change: bool) -> (u64, u64) { + let mut state = auth_state() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !state.counters_seeded { + let floor = crate::platform_session::current_platform_session_write_state(); + state.identity_generation = state.identity_generation.max(floor.identity_generation); + state.revision = state.revision.max(floor.revision); + state.counters_seeded = true; + } + state.revision = state.revision.saturating_add(1); + if identity_change { + state.identity_generation = state.identity_generation.saturating_add(1); + } + (state.identity_generation, state.revision) +} + +fn current_session_origin() -> Option { + current_platform_session().map(|snapshot| snapshot.api_base_url) +} + +fn endpoint(origin: &str, route: &str) -> Result { + let mut url = Url::parse(&format!("{}/", origin.trim_end_matches('/'))) + .map_err(|_| "陶泥儿服务地址无效".to_string())?; + { + let mut segments = url + .path_segments_mut() + .map_err(|_| "陶泥儿服务地址无效".to_string())?; + for segment in route.trim_start_matches('/').split('/') { + if segment.is_empty() { + continue; + } + segments.push(segment); + } + } + Ok(url.to_string()) +} + +fn build_client() -> Result { + agc_main_site_client_builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(HTTP_TIMEOUT) + .build() + .map_err(|_| "创建登录网络客户端失败".to_string()) +} + +fn network_error_message(error: &reqwest::Error) -> String { + if error.is_timeout() { + AUTH_NETWORK_TIMEOUT.to_string() + } else { + AUTH_NETWORK_ERROR.to_string() + } +} + +fn error_message(body: &str) -> Option { + let value = serde_json::from_str::(body).ok()?; + let error = value.get("error").unwrap_or(&value); + error + .get("message") + .and_then(Value::as_str) + .map(str::trim) + .filter(|message| !message.is_empty()) + .map(ToString::to_string) +} + +/// 认证路由语义:会话路由的 401/403 是权威失效,登录路由的 401/403 是用户可修正的输入问题。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AuthRouteKind { + Login, + Session, +} + +fn auth_route_kind(route: &str) -> AuthRouteKind { + if route.ends_with("/me") || route.ends_with("/refresh") { + AuthRouteKind::Session + } else { + AuthRouteKind::Login + } +} + +/// 把一次认证 HTTP 响应归类成稳定文案。 +/// +/// 会话路由的 `401/403` 带 `authentication-required` / `permission-denied` 前缀,调用方 +/// 可以据此清会话;登录路由保留服务端原因(「手机号或密码错误」),不能被改写成登录失效。 +/// 网络、5xx 与契约异常必须保留会话。 +fn map_auth_failure(status: StatusCode, body: &str, fallback: &str, kind: AuthRouteKind) -> String { + if status == StatusCode::UNAUTHORIZED { + return match (kind, error_message(body)) { + (AuthRouteKind::Session, Some(message)) => { + format!("authentication-required: {message}") + } + (AuthRouteKind::Session, None) => AUTH_AUTHORITY_ERROR.to_string(), + (AuthRouteKind::Login, Some(message)) => message, + (AuthRouteKind::Login, None) => fallback.to_string(), + }; + } + if status == StatusCode::FORBIDDEN { + return match (kind, error_message(body)) { + (AuthRouteKind::Session, Some(message)) => format!("permission-denied: {message}"), + (AuthRouteKind::Session, None) => { + "permission-denied: 当前陶泥儿账号没有执行此操作的权限".to_string() + } + (AuthRouteKind::Login, Some(message)) => message, + (AuthRouteKind::Login, None) => fallback.to_string(), + }; + } + let detail = error_message(body).unwrap_or_else(|| format!("HTTP {}", status.as_u16())); + format!("{fallback}:{detail}") +} + +fn is_authority_failure(message: &str) -> bool { + message.starts_with("authentication-required") || message.starts_with("permission-denied") +} + +fn response_data(body: &str, fallback: &str) -> Result { + let value: Value = + serde_json::from_str(body).map_err(|_| format!("{fallback}:登录服务响应不是合法 JSON"))?; + if value.get("ok").and_then(Value::as_bool) == Some(false) { + return Err(format!( + "{fallback}:{}", + error_message(body).unwrap_or_else(|| "登录服务请求失败".to_string()) + )); + } + Ok(value.get("data").cloned().unwrap_or(value)) +} + +/// 从响应的 `Set-Cookie` 里取 refresh 凭据。 +/// +/// 服务端在登出时下发空值清理 cookie;空值表示「没有 refresh 凭据」,不能当成有效凭据。 +fn refresh_cookie_from_response(response: &reqwest::Response) -> Option<(String, String)> { + for value in response.headers().get_all(SET_COOKIE) { + let Ok(raw) = value.to_str() else { continue }; + let Some((name, remainder)) = raw.split_once('=') else { + continue; + }; + let name = name.trim(); + if name.is_empty() { + continue; + } + let cookie_value = remainder.split(';').next().unwrap_or_default().trim(); + if cookie_value.is_empty() { + return None; + } + return Some((name.to_string(), cookie_value.to_string())); + } + None +} + +#[derive(Clone, Copy)] +enum CookiePolicy { + Capture, + Require, + Ignore, +} + +struct AuthResponse { + data: Value, + refresh_cookie: Option<(String, String)>, +} + +async fn request_auth( + client: &reqwest::Client, + origin: &str, + route: &str, + body: Option, + bearer: Option<&str>, + refresh_cookie: Option<&(String, String)>, + policy: CookiePolicy, + fallback: &str, +) -> Result { + let method = match policy { + // 读取类路由用 GET;写入类路由是 POST。 + CookiePolicy::Ignore if route.ends_with("/me") => Method::GET, + _ => Method::POST, + }; + let mut request = client + .request(method, endpoint(origin, route)?) + .header(AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_VALUE) + .header(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION) + .header(reqwest::header::ACCEPT, "application/json"); + if let Some(bearer) = bearer { + request = request.bearer_auth(bearer); + } + if let Some((name, value)) = refresh_cookie { + request = request.header(reqwest::header::COOKIE, format!("{name}={value}")); + } + if let Some(body) = body { + request = request.json(&body); + } + let response = request + .send() + .await + .map_err(|error| network_error_message(&error))?; + let status = response.status(); + let captured = match policy { + CookiePolicy::Capture | CookiePolicy::Require => refresh_cookie_from_response(&response), + CookiePolicy::Ignore => None, + }; + let text = response + .text() + .await + .map_err(|_| format!("{fallback}:读取响应失败"))?; + if !status.is_success() { + return Err(map_auth_failure( + status, + &text, + fallback, + auth_route_kind(route), + )); + } + if matches!(policy, CookiePolicy::Require) && captured.is_none() { + return Err("result-unknown: 登录服务未返回新的续期凭据,已停止使用旧凭据".to_string()); + } + Ok(AuthResponse { + data: response_data(&text, fallback)?, + refresh_cookie: captured, + }) +} +/// 持久化新凭据并安装本进程会话。 +async fn commit_authenticated_session( + app: &tauri::AppHandle, + origin: &str, + known_user: Option, + user_id: String, + token: String, + refresh_cookie: (String, String), + identity_change: bool, +) -> Result { + if token.chars().count() > MAX_SECRET_CHARS { + return Err("登录服务返回的凭据无效".to_string()); + } + let session = ClientSessionFile { + schema_version: session_schema_version(), + api_base_url: origin.to_string(), + user_id: user_id.clone(), + refresh_cookie_name: refresh_cookie.0, + refresh_cookie_value: refresh_cookie.1, + }; + let path = session_file_path(app)?; + write_session_file_at(&path, Some(&session))?; + { + let mut state = auth_state() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.session = Some(session); + state.access_token_issued_at = Some(unix_millis_now()); + } + let (identity_generation, revision) = reserve_session_write(identity_change); + crate::commands::install_client_session_locally( + user_id.clone(), + token, + origin.to_string(), + identity_generation, + revision, + ) + .await?; + // 续期路径只知道 user_id:展示字段随后会用新 token 通过 /api/auth/me 复核。 + Ok(known_user.unwrap_or_else(|| AuthUserPayload { + id: user_id, + public_user_code: String::new(), + display_name: String::new(), + avatar_url: None, + phone_number: None, + phone_number_masked: None, + login_method: String::new(), + binding_status: String::new(), + wechat_bound: false, + wechat_display_name: None, + wechat_account: None, + })) +} + +async fn clear_authenticated_session(app: &tauri::AppHandle) -> Result<(), String> { + let path = session_file_path(app)?; + write_session_file_at(&path, None)?; + { + let mut state = auth_state() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.session = None; + state.access_token_issued_at = None; + } + let (identity_generation, revision) = reserve_session_write(true); + crate::commands::clear_client_session_locally(identity_generation, revision).await +} + +fn auth_state_view( + status: &str, + user: Option, + api_base_url: Option, +) -> ClientAuthStateView { + ClientAuthStateView { + status: status.to_string(), + user, + api_base_url, + error_kind: None, + error_message: None, + } +} + +fn emit_auth_state(app: &tauri::AppHandle, view: &ClientAuthStateView) { + let _ = app.emit(CLIENT_AUTH_STATE_CHANGED_EVENT, view.clone()); +} + +fn phone_is_valid(phone: &str) -> bool { + !phone.is_empty() + && phone.chars().count() <= MAX_PHONE_CHARS + && phone.chars().all(|character| character.is_ascii_digit()) +} + +/// 等锁期间别的调用者已经轮换过凭据:复用最新会话,不再轮换第二次。 +fn credential_rotated_elsewhere( + before: &ClientSessionFile, + latest: Option<&ClientSessionFile>, +) -> bool { + latest.is_some_and(|latest| { + latest.api_base_url == before.api_base_url + && latest.user_id == before.user_id + && latest.refresh_cookie_value != before.refresh_cookie_value + }) +} + +async fn fetch_current_user( + client: &reqwest::Client, + snapshot: &PlatformSessionSnapshot, +) -> Result, String> { + let response = request_auth( + client, + &snapshot.api_base_url, + "/api/auth/me", + None, + Some(&snapshot.access_token), + None, + CookiePolicy::Ignore, + "读取当前用户失败", + ) + .await?; + let me: MeResponse = serde_json::from_value(response.data) + .map_err(|_| "读取当前用户失败:响应格式无效".to_string())?; + Ok(me.user) +} + +/// 用当前 refresh 凭据换一次新 access token,并刷新本进程会话。 +/// +/// 并发调用复用同一次续期:等锁期间身份代次已经推进时直接返回最新会话,不再轮换第二次 +/// refresh 凭据。 +async fn refresh_session_inner( + app: &tauri::AppHandle, + expected_user_id: Option<&str>, +) -> Result { + let Some(session) = require_app_session(app)? else { + return Ok(ClientAuthRefreshView { + status: "unauthenticated".to_string(), + user: None, + authoritative: true, + error_message: None, + }); + }; + let _guard = refresh_lock().lock().await; + let client = build_client()?; + // 双检:等锁期间另一个调用者可能已经完成续期,此时直接复用新会话, + // 不再用刚轮换过的凭据再换一次。 + if credential_rotated_elsewhere(&session, require_app_session(app)?.as_ref()) { + if let Some(snapshot) = current_platform_session() { + if snapshot.api_base_url == session.api_base_url && snapshot.user_id == session.user_id + { + if let Ok(Some(user)) = fetch_current_user(&client, &snapshot).await { + return Ok(ClientAuthRefreshView { + status: "refreshed".to_string(), + user: Some(user), + authoritative: false, + error_message: None, + }); + } + } + } + } + let cookie = ( + session.refresh_cookie_name.clone(), + session.refresh_cookie_value.clone(), + ); + let refreshed = request_auth( + &client, + &session.api_base_url, + "/api/auth/refresh", + None, + None, + Some(&cookie), + CookiePolicy::Require, + "刷新登录状态失败", + ) + .await; + let refreshed = match refreshed { + Ok(response) => response, + Err(error) => { + if is_authority_failure(&error) { + clear_authenticated_session(app).await?; + let view = auth_state_view("unauthenticated", None, None); + emit_auth_state(app, &view); + return Ok(ClientAuthRefreshView { + status: "unauthenticated".to_string(), + user: None, + authoritative: true, + error_message: None, + }); + } + return Ok(ClientAuthRefreshView { + status: "failed".to_string(), + user: None, + authoritative: false, + error_message: Some(error), + }); + } + }; + let token: TokenResponse = serde_json::from_value(refreshed.data) + .map_err(|_| "刷新登录状态失败:凭据响应格式无效".to_string())?; + let install_token = token.token.clone(); + let new_cookie = refreshed + .refresh_cookie + .ok_or_else(|| "刷新登录状态失败:缺少新的续期凭据".to_string())?; + commit_authenticated_session( + app, + &session.api_base_url, + None, + session.user_id.clone(), + install_token, + new_cookie, + false, + ) + .await?; + // 续期只换凭据:立刻用新 token 复核身份展示字段,避免把空投影当成用户信息。 + let resolved = match current_platform_session() { + Some(snapshot) => fetch_current_user(&client, &snapshot).await, + None => Ok(None), + }; + match resolved { + Ok(Some(user)) => { + if expected_user_id.is_some_and(|expected| expected != user.id) { + return Ok(ClientAuthRefreshView { + status: "stale".to_string(), + user: None, + authoritative: false, + error_message: None, + }); + } + let view = auth_state_view( + "authenticated", + Some(user.clone()), + Some(session.api_base_url.clone()), + ); + emit_auth_state(app, &view); + Ok(ClientAuthRefreshView { + status: "refreshed".to_string(), + user: Some(user), + authoritative: false, + error_message: None, + }) + } + Ok(None) => { + clear_authenticated_session(app).await?; + let view = auth_state_view("unauthenticated", None, None); + emit_auth_state(app, &view); + Ok(ClientAuthRefreshView { + status: "unauthenticated".to_string(), + user: None, + authoritative: true, + error_message: None, + }) + } + Err(error) if is_authority_failure(&error) => { + clear_authenticated_session(app).await?; + let view = auth_state_view("unauthenticated", None, None); + emit_auth_state(app, &view); + Ok(ClientAuthRefreshView { + status: "unauthenticated".to_string(), + user: None, + authoritative: true, + error_message: None, + }) + } + Err(error) => Ok(ClientAuthRefreshView { + status: "failed".to_string(), + user: None, + authoritative: false, + error_message: Some(error), + }), + } +} + +/// 读取登录态投影:没有凭据就直接报告未登录,有凭据则先续期再复核当前用户。 +#[tauri::command] +pub(crate) async fn read_client_auth_state( + app: tauri::AppHandle, + expected_api_base_url: Option, +) -> Result { + let Some(session) = require_app_session(&app)? else { + if let Some(snapshot) = current_platform_session() { + // 凭据文件缺失但本进程仍有会话(例如同一次启动内刚登录):以会话为准。 + let client = build_client()?; + if let Ok(Some(user)) = fetch_current_user(&client, &snapshot).await { + return Ok(auth_state_view( + "authenticated", + Some(user), + Some(snapshot.api_base_url), + )); + } + } + return Ok(auth_state_view("unauthenticated", None, None)); + }; + let expected = match expected_api_base_url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + Some(value) => validate_client_api_base_url(value)?, + None => session.api_base_url.clone(), + }; + if expected != session.api_base_url { + // 凭据属于另一个 origin:不跨 origin 续期,也不删除文件(用户可能只是切了渠道)。 + return Ok(auth_state_view("unauthenticated", None, None)); + } + match refresh_session_inner(&app, Some(session.user_id.as_str())).await { + Ok(refresh) => match refresh.status.as_str() { + "refreshed" => Ok(auth_state_view( + "authenticated", + refresh.user, + Some(session.api_base_url), + )), + "stale" | "unauthenticated" => Ok(auth_state_view("unauthenticated", None, None)), + _ => Ok(ClientAuthStateView { + status: "unavailable".to_string(), + user: None, + api_base_url: Some(session.api_base_url), + error_kind: Some("network".to_string()), + error_message: refresh.error_message, + }), + }, + Err(error) => Ok(ClientAuthStateView { + status: "unavailable".to_string(), + user: None, + api_base_url: Some(session.api_base_url), + error_kind: Some("network".to_string()), + error_message: Some(error), + }), + } +} + +#[tauri::command] +pub(crate) async fn refresh_client_auth_session( + app: tauri::AppHandle, + expected_user_id: Option, +) -> Result { + let expected = expected_user_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + refresh_session_inner(&app, expected.as_deref()).await +} + +#[tauri::command] +pub(crate) async fn send_client_phone_login_code( + api_base_url: String, + phone: String, +) -> Result { + let origin = validate_client_api_base_url(&api_base_url)?; + let phone = phone.trim(); + if !phone_is_valid(phone) { + return Err("请输入正确的手机号".to_string()); + } + let client = build_client()?; + let response = request_auth( + &client, + &origin, + "/api/auth/phone/send-code", + Some(serde_json::json!({ + "countryCode": "86", + "purePhoneNumber": phone, + "scene": "login", + })), + None, + None, + CookiePolicy::Ignore, + "发送验证码失败", + ) + .await?; + let payload: SendCodeResponse = serde_json::from_value(response.data) + .map_err(|_| "发送验证码失败:响应格式无效".to_string())?; + Ok(ClientLoginCodeView { + cooldown_seconds: payload.cooldown_seconds, + expires_in_seconds: payload.expires_in_seconds, + }) +} + +async fn complete_login( + app: &tauri::AppHandle, + origin: &str, + response: AuthResponse, +) -> Result { + let payload: TokenUserResponse = serde_json::from_value(response.data) + .map_err(|_| "登录失败:登录服务响应格式无效".to_string())?; + let cookie = response + .refresh_cookie + .ok_or_else(|| "登录失败:登录服务未返回续期凭据".to_string())?; + let user = commit_authenticated_session( + app, + origin, + Some(payload.user), + String::new(), + payload.token, + cookie, + true, + ) + .await?; + let view = auth_state_view( + "authenticated", + Some(user.clone()), + Some(origin.to_string()), + ); + emit_auth_state(app, &view); + Ok(user) +} + +#[tauri::command] +pub(crate) async fn login_client_with_password( + app: tauri::AppHandle, + api_base_url: String, + phone: String, + password: String, +) -> Result { + let origin = validate_client_api_base_url(&api_base_url)?; + let phone = phone.trim(); + if !phone_is_valid(phone) { + return Err("请输入正确的手机号".to_string()); + } + if password.trim().is_empty() { + return Err("请输入密码".to_string()); + } + let client = build_client()?; + let response = request_auth( + &client, + &origin, + "/api/auth/entry", + Some(serde_json::json!({ + "countryCode": "86", + "purePhoneNumber": phone, + "password": password.trim(), + })), + None, + None, + CookiePolicy::Capture, + "登录失败", + ) + .await?; + complete_login(&app, &origin, response).await +} + +#[tauri::command] +pub(crate) async fn login_client_with_phone_code( + app: tauri::AppHandle, + api_base_url: String, + phone: String, + code: String, +) -> Result { + let origin = validate_client_api_base_url(&api_base_url)?; + let phone = phone.trim(); + if !phone_is_valid(phone) { + return Err("请输入正确的手机号".to_string()); + } + if code.trim().is_empty() { + return Err("请输入验证码".to_string()); + } + let client = build_client()?; + let response = request_auth( + &client, + &origin, + "/api/auth/phone/login", + Some(serde_json::json!({ + "countryCode": "86", + "purePhoneNumber": phone, + "code": code.trim(), + })), + None, + None, + CookiePolicy::Capture, + "登录失败", + ) + .await?; + complete_login(&app, &origin, response).await +} + +/// 登出:先尝试服务端撤销,再无条件清掉本地凭据与本进程会话。 +#[tauri::command] +pub(crate) async fn logout_client_session(app: tauri::AppHandle) -> Result<(), String> { + let origin = current_session_origin() + .or_else(|| { + auth_state() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .session + .as_ref() + .map(|session| session.api_base_url.clone()) + }) + .unwrap_or_else(|| DEVELOPMENT_ORIGIN.to_string()); + if let Ok(client) = build_client() { + if let Some(snapshot) = current_platform_session() { + let _ = request_auth( + &client, + &origin, + "/api/auth/logout", + None, + Some(&snapshot.access_token), + None, + CookiePolicy::Ignore, + "退出登录失败", + ) + .await; + } + } + clear_authenticated_session(&app).await?; + let view = auth_state_view("unauthenticated", None, None); + emit_auth_state(&app, &view); + Ok(()) +} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn channel_origins_are_always_accepted_and_normalized() { + assert_eq!( + validate_client_api_base_url("https://dev.genarrative.world/").expect("dev origin"), + DEVELOPMENT_ORIGIN + ); + assert_eq!( + validate_client_api_base_url(RELEASE_ORIGIN).expect("release origin"), + RELEASE_ORIGIN + ); + } + + #[test] + fn origins_reject_credentials_paths_and_plain_remote_http() { + assert!(validate_client_api_base_url("https://user:pass@example.com").is_err()); + assert!(validate_client_api_base_url("https://example.com/api").is_err()); + assert!(validate_client_api_base_url("https://example.com?a=1").is_err()); + assert!(validate_client_api_base_url("http://example.com").is_err()); + assert!(validate_client_api_base_url("ftp://example.com").is_err()); + assert!(validate_client_api_base_url(" ").is_err()); + assert_eq!( + validate_client_api_base_url("http://127.0.0.1:10001").expect("loopback debug"), + "http://127.0.0.1:10001" + ); + } + + #[test] + fn phones_must_be_digits_within_the_bound() { + assert!(phone_is_valid("13800000000")); + assert!(!phone_is_valid("")); + assert!(!phone_is_valid("138 0000 0000")); + assert!(!phone_is_valid("13800000000x")); + assert!(!phone_is_valid(&"9".repeat(MAX_PHONE_CHARS + 1))); + } + + #[test] + fn session_routes_classify_401_403_as_authority_failures() { + assert_eq!(auth_route_kind("/api/auth/refresh"), AuthRouteKind::Session); + assert_eq!(auth_route_kind("/api/auth/me"), AuthRouteKind::Session); + assert_eq!(auth_route_kind("/api/auth/entry"), AuthRouteKind::Login); + + assert!(is_authority_failure(&map_auth_failure( + StatusCode::UNAUTHORIZED, + "{}", + "刷新失败", + AuthRouteKind::Session + ))); + assert!(is_authority_failure(&map_auth_failure( + StatusCode::FORBIDDEN, + r#"{"error":{"message":"无权"}}"#, + "刷新失败", + AuthRouteKind::Session + ))); + let transient = map_auth_failure( + StatusCode::INTERNAL_SERVER_ERROR, + "{}", + "刷新失败", + AuthRouteKind::Session, + ); + assert!(!is_authority_failure(&transient)); + assert!(transient.starts_with("刷新失败")); + assert!(!is_authority_failure(AUTH_NETWORK_ERROR)); + assert!(is_authority_failure(AUTH_AUTHORITY_ERROR)); + } + + #[test] + fn login_routes_keep_the_server_reason_instead_of_reporting_expiry() { + let wrong_password = map_auth_failure( + StatusCode::UNAUTHORIZED, + r#"{"error":{"message":"手机号或密码错误"}}"#, + "登录失败", + AuthRouteKind::Login, + ); + assert_eq!(wrong_password, "手机号或密码错误"); + assert!(!is_authority_failure(&wrong_password)); + + let missing_reason = map_auth_failure( + StatusCode::UNAUTHORIZED, + "{}", + "登录失败", + AuthRouteKind::Login, + ); + assert_eq!(missing_reason, "登录失败"); + } + + #[test] + fn keepalive_only_refreshes_after_the_freshness_window() { + let issued = 1_000_000_u64; + assert!(!session_needs_refresh( + Some(issued), + issued + CLIENT_SESSION_REFRESH_AFTER_MILLIS - 1 + )); + assert!(session_needs_refresh( + Some(issued), + issued + CLIENT_SESSION_REFRESH_AFTER_MILLIS + )); + // 未知签发时间按「需要续期」处理,不能把过期凭据当成新鲜凭据。 + assert!(session_needs_refresh(None, issued)); + // 时钟回拨按「新鲜」处理(饱和减法不下溢,不会造成刷新风暴); + // 真过期时仍有 401 续期链路兜底。 + assert!(!session_needs_refresh(Some(issued), 0)); + } + + #[test] + fn session_keepalive_does_not_start_without_an_app_handle() { + // 测试进程从未调用 `initialize_auth_session`:此时不能启动后台循环, + // 更不能留下一个没人能停止的保活任务。 + assert!(AUTH_APP_HANDLE.get().is_none()); + assert!(spawn_client_session_keepalive().is_none()); + } + + #[test] + fn concurrent_refresh_reuses_a_session_rotated_while_waiting() { + let before = ClientSessionFile { + schema_version: session_schema_version(), + api_base_url: DEVELOPMENT_ORIGIN.to_string(), + user_id: "user-1".to_string(), + refresh_cookie_name: "genarrative_refresh_session".to_string(), + refresh_cookie_value: "cookie-a".to_string(), + }; + let rotated = ClientSessionFile { + refresh_cookie_value: "cookie-b".to_string(), + ..before.clone() + }; + assert!(credential_rotated_elsewhere(&before, Some(&rotated))); + assert!(!credential_rotated_elsewhere(&before, Some(&before))); + assert!(!credential_rotated_elsewhere(&before, None)); + let other_user = ClientSessionFile { + user_id: "user-2".to_string(), + ..rotated.clone() + }; + assert!(!credential_rotated_elsewhere(&before, Some(&other_user))); + let other_origin = ClientSessionFile { + api_base_url: RELEASE_ORIGIN.to_string(), + ..rotated + }; + assert!(!credential_rotated_elsewhere(&before, Some(&other_origin))); + } + + #[test] + fn auth_state_events_never_carry_credentials() { + let view = ClientAuthStateView { + status: "authenticated".to_string(), + user: Some(AuthUserPayload { + id: "user-1".to_string(), + public_user_code: "tn-1".to_string(), + display_name: "测试".to_string(), + avatar_url: None, + phone_number: None, + phone_number_masked: Some("138****0000".to_string()), + login_method: "phone".to_string(), + binding_status: "active".to_string(), + wechat_bound: false, + wechat_display_name: None, + wechat_account: None, + }), + api_base_url: Some(DEVELOPMENT_ORIGIN.to_string()), + error_kind: None, + error_message: None, + }; + let serialized = serde_json::to_string(&view).expect("serialize auth state"); + for forbidden in [ + "token", + "Token", + "cookie", + "Cookie", + "refresh", + "accessToken", + "client-session.json", + ] { + assert!( + !serialized.contains(forbidden), + "登录态事件不能包含 {forbidden}:{serialized}" + ); + } + assert!(serialized.contains("138****0000")); + assert!(serialized.contains(DEVELOPMENT_ORIGIN)); + } + + #[test] + fn session_files_must_be_complete_and_round_trip_atomically() { + let directory = tempfile::tempdir().expect("session fixture"); + let path = directory.path().join(SESSION_FILE_NAME); + let incomplete = ClientSessionFile { + schema_version: session_schema_version(), + api_base_url: DEVELOPMENT_ORIGIN.to_string(), + user_id: String::new(), + refresh_cookie_name: "genarrative_refresh_session".to_string(), + refresh_cookie_value: "value".to_string(), + }; + assert!(!incomplete.is_complete()); + write_session_file_at(&path, Some(&incomplete)).expect("write incomplete session"); + assert!(read_session_file_at(&path).is_none()); + assert!(fs::read_to_string(&path) + .expect("incomplete file stays for diagnosis") + .contains(SESSION_SCHEMA_VERSION)); + + let complete = ClientSessionFile { + user_id: "user-1".to_string(), + ..incomplete + }; + write_session_file_at(&path, Some(&complete)).expect("write session"); + assert_eq!(read_session_file_at(&path), Some(complete)); + write_session_file_at(&path, None).expect("clear session"); + assert!(read_session_file_at(&path).is_none()); + assert!(!path.exists()); + } + + #[test] + fn corrupt_or_foreign_session_files_never_authenticate() { + let directory = tempfile::tempdir().expect("session fixture"); + let path = directory.path().join(SESSION_FILE_NAME); + + // 不存在:未登录。 + assert!(read_session_file_at(&path).is_none()); + // 坏 JSON:不能被当成凭据。 + fs::write(&path, b"{not json").expect("malformed session fixture"); + assert!(read_session_file_at(&path).is_none()); + // 字段缺失:整份凭据按无效处理,不允许"半个会话"进入续期链路。 + fs::write( + &path, + br#"{"schemaVersion":"agc-client-session.v1","apiBaseUrl":"https://dev.genarrative.world","userId":"user-1"}"#, + ) + .expect("incomplete session fixture"); + assert!(read_session_file_at(&path).is_none()); + // 空值 refresh cookie(登出清理形态):同样不算凭据。 + fs::write( + &path, + br#"{"schemaVersion":"agc-client-session.v1","apiBaseUrl":"https://dev.genarrative.world","userId":"user-1","refreshCookieName":"genarrative_refresh_session","refreshCookieValue":""}"#, + ) + .expect("cleared session fixture"); + assert!(read_session_file_at(&path).is_none()); + // 符号链接形式没有覆盖在这里:它由 `write_session_file_at` 的私有路径门禁拒绝, + // 且 Windows 上无法用普通文件 API 构造。 + } + + #[test] + fn refresh_cookie_parsing_keeps_name_and_value_and_ignores_clears() { + let with_cookie = http::Response::builder() + .status(200) + .header( + SET_COOKIE, + "genarrative_refresh_session=abc123; Path=/; HttpOnly; SameSite=Lax", + ) + .body(String::new()) + .expect("fixture response"); + let response = reqwest::Response::from(with_cookie); + assert_eq!( + refresh_cookie_from_response(&response), + Some(( + "genarrative_refresh_session".to_string(), + "abc123".to_string() + )) + ); + + let cleared = http::Response::builder() + .status(200) + .header( + SET_COOKIE, + "genarrative_refresh_session=; Path=/; Max-Age=0; HttpOnly", + ) + .body(String::new()) + .expect("clear response"); + assert_eq!( + refresh_cookie_from_response(&reqwest::Response::from(cleared)), + None + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 4dbb39ad7..497ad78a9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -4,6 +4,7 @@ use crate::agent::{ DirectProjectHistoryAnchor, }; use crate::ui_editor::resource::font::FontAsset; +use base64::Engine as _; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, HashSet}; @@ -17,6 +18,7 @@ const UI_EDITOR_IMAGE_MAX_COUNT: usize = 100; // imageSrc 与签名地址只在本文件的客户端下载阶段存在,绝不进入 Agent observation。 const AGENT_EDITOR_ASSET_LIBRARY_MAX_ITEMS: usize = 500; const AGENT_EDITOR_ASSET_ID_MAX_CHARS: usize = 512; +const EDITOR_ASSET_PREVIEW_MAX_BYTES: usize = 8 * 1024 * 1024; const AUTOMATIC_PROJECT_NAME_MAX_PROMPT_CHARS: usize = 8_000; const AUTOMATIC_PROJECT_NAME_MAX_OUTPUT_TOKENS: u32 = 64; // Project naming is an optional homepage enhancement. It must never hold the @@ -508,12 +510,17 @@ pub(crate) fn validate_requested_game_project_creation_root( Ok(root.to_path_buf()) } -/// 解析本次建项要使用的根目录:没选就用 AGC 管理的默认目录,选了就用用户指定的目录。 +/// 解析本次建项要使用的根目录:显式参数优先,其次读取 Rust 工作区偏好,最后回落 AGC 默认目录。 pub(crate) fn resolve_game_project_creation_root( app: &tauri::AppHandle, requested: Option<&str>, ) -> Result { - match requested.map(str::trim).filter(|value| !value.is_empty()) { + let stored = requested + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(|| crate::workspace_preferences::stored_project_creation_directory(app)); + match stored.as_deref() { Some(requested) => validate_requested_game_project_creation_root(requested), None => automatic_local_game_projects_root(app), } @@ -1813,14 +1820,11 @@ pub(crate) async fn polish_local_project_prompt( .map_err(|error| redact_agent_runtime_error(Path::new("."), &error, 320)) } -#[tauri::command] -pub(crate) fn read_platform_account_session_state( -) -> crate::platform_session::PlatformSessionWriteState { - crate::platform_session::current_platform_session_write_state() -} - -#[tauri::command] -pub(crate) async fn install_platform_account_session( +/// 安装客户端会话:先传播到本地 Runner,再写本进程会话快照。 +/// +/// Tauri 命令与 Rust 认证会话(`auth_session`)共用这一条路径,避免出现「命令装好了、 +/// 认证链路没装」这种半状态。 +pub(crate) async fn install_client_session_locally( user_id: String, access_token: String, api_base_url: String, @@ -1854,8 +1858,8 @@ pub(crate) async fn install_platform_account_session( .map_err(|error| format!("安装本地运行时会话任务意外终止:{error}"))? } -#[tauri::command] -pub(crate) async fn clear_platform_account_session( +/// 清除客户端会话:先停掉本机会话相关的 app-server,再清 Runner 与本进程快照。 +pub(crate) async fn clear_client_session_locally( identity_generation: u64, revision: u64, ) -> Result<(), String> { @@ -1869,6 +1873,9 @@ pub(crate) async fn clear_platform_account_session( .map_err(|error| format!("清除本地运行时会话任务意外终止:{error}"))? } +// 会话安装/清除只由 Rust 认证会话(`auth_session`)调用 `install_client_session_locally` +// 与 `clear_client_session_locally`;渲染层不再持有写入入口。 + #[tauri::command] pub(crate) fn read_game_creator_app_config() -> Result { game_creator_app_config_view(load_game_creator_app_config()?) @@ -2983,6 +2990,98 @@ mod agent_asset_import_tests { assert!(parse_agent_editor_project_resources(&payload, "project-other").is_err()); } + #[test] + fn editor_asset_library_snapshot_exposes_only_ui_safe_projection() { + let records = vec![AgentEditorAssetRecord { + asset_id: "asset-1".to_string(), + origin: AgentEditorAssetOrigin::AccountLibrary, + canvas_project_id: None, + folder_id: Some("folder-1".to_string()), + folder_label: Some("角色".to_string()), + label: "英雄".to_string(), + object_key: Some("private/hero.png".to_string()), + image_src: Some("/private/hero.png".to_string()), + asset_object_id: Some("object-1".to_string()), + asset_kind: Some("character".to_string()), + source_type: Some("uploaded".to_string()), + width: Some(64), + height: Some(64), + size_bytes: Some(128), + }]; + + let snapshot = editor_asset_library_snapshot_from_records(&records); + assert_eq!(snapshot.folders[0].folder_id, "folder-1"); + assert_eq!(snapshot.assets[0].asset_id, "asset-1"); + assert!(snapshot.assets[0].preview_available); + let serialized = serde_json::to_string(&snapshot).expect("serialize snapshot"); + assert!(!serialized.contains("private/hero.png")); + assert!(!serialized.contains("object-1")); + assert!(!serialized.contains("token")); + } + + #[test] + fn editor_asset_read_url_uses_stable_object_reference_and_account_route() { + let session = PlatformSessionSnapshot { + user_id: "user-1".to_string(), + access_token: "token-1".to_string(), + api_base_url: "https://dev.genarrative.world".to_string(), + identity_generation: 1, + revision: 1, + }; + let access = ExternalEditorBindingAccess::for_platform(&session).expect("platform access"); + let record = AgentEditorAssetRecord { + asset_id: "asset-1".to_string(), + origin: AgentEditorAssetOrigin::AccountLibrary, + canvas_project_id: None, + folder_id: None, + folder_label: None, + label: "英雄".to_string(), + object_key: Some("private/hero image.png".to_string()), + image_src: None, + asset_object_id: None, + asset_kind: Some("image".to_string()), + source_type: None, + width: None, + height: None, + size_bytes: None, + }; + assert_eq!( + editor_asset_read_url(&access, &record).expect("read URL"), + "https://dev.genarrative.world/api/assets/read-url?objectKey=private%2Fhero%20image.png" + ); + } + + #[test] + fn editor_asset_preview_data_url_is_bounded_and_typed() { + assert_eq!( + editor_asset_preview_data_url("image/png", &tiny_png()).expect("data URL"), + "data:image/png;base64,iVBORw0KGgo=" + ); + assert!(editor_asset_preview_data_url("application/octet-stream", b"x").is_err()); + assert!(editor_asset_preview_data_url( + "image/png", + &vec![0_u8; EDITOR_ASSET_PREVIEW_MAX_BYTES + 1] + ) + .is_err()); + } + + #[tokio::test] + async fn legacy_remote_import_accepts_only_bounded_image_data_urls() { + let bytes = download_ui_editor_remote_asset( + "data:image/png;base64,iVBORw0KGgo=", + EDITOR_ASSET_PREVIEW_MAX_BYTES as u64, + ) + .await + .expect("decode preview data URL"); + assert_eq!(bytes, tiny_png()); + assert!(download_ui_editor_remote_asset( + "data:text/plain;base64,eA==", + EDITOR_ASSET_PREVIEW_MAX_BYTES as u64, + ) + .await + .is_err()); + } + #[test] fn local_project_asset_import_registers_multiple_types_and_is_idempotent() { let project = crate::tests::canonical_test_tempdir("agent-local-import-"); @@ -3380,6 +3479,39 @@ struct AgentEditorAssetRecord { size_bytes: Option, } +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct EditorAssetLibraryFolderView { + pub(crate) folder_id: String, + pub(crate) label: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct EditorAssetLibraryAssetView { + pub(crate) asset_id: String, + pub(crate) folder_id: String, + pub(crate) label: String, + pub(crate) asset_kind: Option, + pub(crate) width: Option, + pub(crate) height: Option, + pub(crate) size_bytes: Option, + pub(crate) preview_available: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct EditorAssetLibrarySnapshot { + pub(crate) folders: Vec, + pub(crate) assets: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct EditorAssetPreview { + pub(crate) preview_url: String, +} + fn bounded_agent_editor_asset_id(value: &str) -> Result { let value = value.trim(); if value.is_empty() @@ -3761,6 +3893,155 @@ async fn fetch_agent_editor_asset_library() -> Result< fetch_agent_editor_asset_records(None).await } +fn editor_asset_library_snapshot_from_records( + records: &[AgentEditorAssetRecord], +) -> EditorAssetLibrarySnapshot { + let mut folders = BTreeMap::::new(); + for record in records { + let folder_id = record + .folder_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or("__uncategorized__") + .to_string(); + let label = record + .folder_label + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or("未分类") + .to_string(); + folders.entry(folder_id).or_insert(label); + } + let folders = folders + .into_iter() + .map(|(folder_id, label)| EditorAssetLibraryFolderView { folder_id, label }) + .collect(); + let assets = records + .iter() + .map(|record| EditorAssetLibraryAssetView { + asset_id: record.asset_id.clone(), + folder_id: record + .folder_id + .clone() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "__uncategorized__".to_string()), + label: record.label.clone(), + asset_kind: record.asset_kind.clone(), + width: record.width, + height: record.height, + size_bytes: record.size_bytes, + preview_available: record.object_key.is_some() + || record + .image_src + .as_deref() + .is_some_and(|value| value.starts_with('/')), + }) + .collect(); + EditorAssetLibrarySnapshot { folders, assets } +} + +fn editor_asset_read_url( + access: &ExternalEditorBindingAccess<'_>, + record: &AgentEditorAssetRecord, +) -> Result { + let (field, value) = if let Some(object_key) = record + .object_key + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + ("objectKey", object_key) + } else if let Some(image_src) = record + .image_src + .as_deref() + .filter(|value| value.starts_with('/')) + { + ("legacyPublicPath", image_src) + } else { + return Err("平台素材缺少稳定预览引用".to_string()); + }; + let route = access.api_route("/api/external/v1/assets/read-url"); + Ok(format!( + "{}{}?{}={}", + access.api_base_url(), + route, + field, + percent_encode_query_component(value) + )) +} + +fn editor_asset_preview_data_url(media_type: &str, bytes: &[u8]) -> Result { + let media_type = media_type + .split(';') + .next() + .unwrap_or(media_type) + .trim() + .to_ascii_lowercase(); + if !media_type.starts_with("image/") { + return Err("平台素材预览不是受支持的图片类型".to_string()); + } + if bytes.is_empty() || bytes.len() > EDITOR_ASSET_PREVIEW_MAX_BYTES { + return Err("平台素材预览超过安全大小限制".to_string()); + } + let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); + Ok(format!("data:{media_type};base64,{encoded}")) +} + +/// 返回账户素材库给渲染层的安全展示投影;不返回 objectKey、imageSrc、签名 URL、 +/// access token 或本地绝对路径。 +#[tauri::command] +pub(crate) async fn read_editor_asset_library() -> Result { + let (_api_base_url, _bearer_token, _session, records) = + fetch_agent_editor_asset_library().await?; + Ok(editor_asset_library_snapshot_from_records(&records)) +} + +/// 按当前登录账号的稳定 assetId 换取有界 data URL。渲染层不自行拼 read-url, +/// 不接触对象键或 bearer token,也不会再由 WebView 直接请求签名地址;远程导入暂时 +/// 仍复用旧 downloadUrl 入参,但地址来自本命令返回的 data URL。 +#[tauri::command] +pub(crate) async fn read_editor_asset_preview( + asset_id: String, +) -> Result { + let asset_id = bounded_agent_editor_asset_id(&asset_id)?; + let (api_base_url, bearer_token, frozen_session, records) = + fetch_agent_editor_asset_library().await?; + let record = records + .into_iter() + .find(|record| record.asset_id == asset_id) + .ok_or_else(|| "账户素材不存在、已删除或不属于当前登录账号".to_string())?; + let access = + ExternalEditorBindingAccess::new(&api_base_url, &bearer_token, frozen_session.as_ref())?; + let client = crate::http_client::agc_main_site_client_builder() + .connect_timeout(std::time::Duration::from_secs(10)) + .timeout(std::time::Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| format!("创建素材预览客户端失败:{error}"))?; + access.validate_frozen_session()?; + // Validate that this record has an owner-scoped stable read reference + // before handing it to the bounded downloader below. + let _ = editor_asset_read_url(&access, &record)?; + let source = serde_json::json!({ + "objectKey": record.object_key.as_deref(), + "imageSrc": record.image_src.as_deref(), + }); + let read_url_route = access.api_route("/api/external/v1/assets/read-url"); + let download = crate::assets::resolve_canvas_resource_download_with_limit_route_and_fence( + &client, + access.api_base_url(), + access.bearer_token(), + &source, + EDITOR_ASSET_PREVIEW_MAX_BYTES, + &read_url_route, + || access.validate_frozen_session(), + ) + .await? + .ok_or_else(|| "平台素材预览内容为空".to_string())?; + let preview_url = editor_asset_preview_data_url(&download.media_type, &download.bytes)?; + access.validate_frozen_session()?; + Ok(EditorAssetPreview { preview_url }) +} + /// 给普通 Agent/Direct Codex 的账户素材安全投影。只返回业务 ID 与展示元数据, /// 不返回 objectKey、imageSrc、signedUrl、绝对路径、provider 或凭据。 pub(crate) async fn list_account_editor_assets_for_agent() -> Result { @@ -4714,6 +4995,28 @@ async fn download_ui_editor_remote_asset(url: &str, max_bytes: u64) -> Result max_bytes { + return Err("平台素材超过当前图片下载限制".to_string()); + } + let _ = media_type; + return Ok(bytes); + } let parsed = validate_external_asset_download_url(url, "", false)?; let client = build_external_asset_download_client(&parsed, "", false).await?; let mut response = client @@ -5760,7 +6063,17 @@ pub(crate) async fn subscribe_direct_project_thread( let thread_id = direct_thread_id_for_project(root); let mut bootstrap = subscribe_direct_thread(&thread_id); if bootstrap.last_completed_item_id.is_none() { - bootstrap.last_completed_item_id = read_direct_project_last_item_id_at(root)?; + match read_direct_project_last_item_id_at(root) { + Ok(last_completed_item_id) => { + bootstrap.last_completed_item_id = last_completed_item_id; + } + Err(error) => { + // 订阅已经登记,后续锚点读取失败时也必须回收 native + // subscriber;否则前端拿不到 subscriptionId,无法自行清理。 + unsubscribe_direct_thread(&bootstrap.subscription_id); + return Err(error); + } + } } Ok(bootstrap) }) @@ -5775,6 +6088,12 @@ pub(crate) fn consume_direct_project_thread( consume_direct_thread(subscription_id.trim()) } +#[tauri::command] +pub(crate) fn unsubscribe_direct_project_thread(subscription_id: String) -> Result<(), String> { + unsubscribe_direct_thread(subscription_id.trim()); + Ok(()) +} + /// 读一屏项目对话历史。 /// /// 窗口两端各由一个锚点给出,两者互斥(都传会报错):`before_item_id` 是**旧端**边界(不含 @@ -5988,6 +6307,7 @@ pub(crate) async fn upload_local_project_game_package( api_base_url: api_base_url.trim(), access_token: access_token.trim(), idempotency_key: idempotency_key.trim(), + session_identity: None, }, move |received_bytes, total_bytes| { let _ = emit_handle.emit( diff --git a/apps/ai-game-creator-shell/src-tauri/src/error_report/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/error_report/mod.rs index 1d3db7c14..040917f6a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/error_report/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/error_report/mod.rs @@ -2,7 +2,9 @@ mod commands; mod notifications; mod queue; mod sanitize; +mod submit; pub use commands::{ack_error_reports, get_pending_error_reports, report_client_error}; pub use notifications::initialize_notifications; pub use queue::{report_agent_runtime_error, report_diagnostic_error}; +pub use submit::submit_error_report; diff --git a/apps/ai-game-creator-shell/src-tauri/src/error_report/sanitize.rs b/apps/ai-game-creator-shell/src-tauri/src/error_report/sanitize.rs index d9a928a5e..5fe43cac2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/error_report/sanitize.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/error_report/sanitize.rs @@ -91,6 +91,10 @@ mod tests { let sanitized = sanitize(value, 512); assert!(!sanitized.contains("secret")); assert!(!sanitized.contains("example.test")); + // 这条断言专门钉住 URL 规则本身:没有它时,禁用 redact_urls 也能靠 + // PATH_PATTERN 的大小写不敏感盘符分支(`http://` 里的 `p:`)把 URL 吞掉, + // 于是「URL 被替换成占位符」这件事其实没有被断言到。 + assert!(sanitized.contains("")); assert!(!sanitized.contains("/home/alice")); assert!(!sanitized.contains("deadbeef12")); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/error_report/submit.rs b/apps/ai-game-creator-shell/src-tauri/src/error_report/submit.rs new file mode 100644 index 000000000..54698314f --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/error_report/submit.rs @@ -0,0 +1,222 @@ +//! 错误报告提交 facade。 +//! +//! 渲染层只提交脱敏后的错误事件、可选的用户说明与诊断日志;平台会话、origin、 +//! 提交幂等标识、响应 envelope 解析和错误分类都在 Rust 内完成。React 不再读取 +//! access token、拼接远端 URL 或自行生成稳定 submissionId。 + +use crate::http_client::agc_main_site_client_builder; +use crate::platform_session::{ + current_platform_session, validate_platform_session_identity, PlatformSessionSnapshot, +}; +use reqwest::StatusCode; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use shared_contracts::error_reports::{CreateErrorReportBatchRequest, ErrorReportLogInput, Event}; +use std::time::Duration; +use url::Url; + +const HTTP_TIMEOUT: Duration = Duration::from_secs(60); +/// 与 api-server 的请求上限同口径,避免把明显超限的载荷发到网络层。 +const MAX_EVENTS: usize = 100; +const MAX_LOGS: usize = 5; +const MAX_DESCRIPTION_CHARS: usize = 2_000; +const ERROR_REPORT_PATH: &[&str] = &["api", "error-reports"]; +const API_RESPONSE_ENVELOPE_HEADER: &str = "x-genarrative-response-envelope"; +const API_RESPONSE_ENVELOPE_VERSION: &str = "v1"; +const AGC_CLIENT_MARKER_HEADER: &str = "x-genarrative-client"; +const AGC_CLIENT_MARKER_VALUE: &str = "agc"; + +fn require_session() -> Result { + current_platform_session() + .ok_or_else(|| "authentication-required: 请先登录后再提交错误报告".to_string()) +} + +fn validate_session(snapshot: &PlatformSessionSnapshot) -> Result<(), String> { + validate_platform_session_identity(&snapshot.identity()) +} + +fn endpoint(snapshot: &PlatformSessionSnapshot) -> Result { + let mut url = Url::parse(&format!("{}/", snapshot.api_base_url.trim_end_matches('/'))) + .map_err(|_| "陶泥儿服务地址无效".to_string())?; + { + let mut segments = url + .path_segments_mut() + .map_err(|_| "陶泥儿服务地址无效".to_string())?; + for segment in ERROR_REPORT_PATH { + segments.push(segment); + } + } + Ok(url.to_string()) +} + +/// 稳定 submissionId:只由本批事件的稳定 ID 决定。 +/// +/// 同一批事件反复提交必须命中服务端 `userId + submissionId` 幂等键;事件集合变化 +/// 就是另一批,不会误判成重放。该标识不进入任何用户可见文案。 +fn stable_submission_id(events: &[Event]) -> String { + let mut ids = events + .iter() + .map(|event| event.event_id.as_str()) + .collect::>(); + ids.sort_unstable(); + let mut digest = Sha256::new(); + for id in ids { + digest.update(id.as_bytes()); + digest.update([0]); + } + let hex = format!("{:x}", digest.finalize()); + format!("agc-error-report-{}", &hex[..32]) +} + +fn response_error(status: StatusCode, body: &str) -> String { + if status == StatusCode::UNAUTHORIZED { + return "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(); + } + if status == StatusCode::FORBIDDEN { + return "permission-denied: 当前账号没有提交错误报告的权限".to_string(); + } + let detail = serde_json::from_str::(body) + .ok() + .and_then(|value| { + value + .get("error") + .and_then(|error| error.get("message")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + }) + .unwrap_or_else(|| format!("HTTP {}", status.as_u16())); + format!("错误报告提交失败:{detail}") +} + +fn response_data(body: &str) -> Result { + let value: Value = serde_json::from_str(body) + .map_err(|_| "错误报告提交失败:响应不是合法 JSON".to_string())?; + Ok(value.get("data").cloned().unwrap_or(value)) +} + +fn validate_payload( + events: &[Event], + logs: &[ErrorReportLogInput], + user_description: Option<&str>, +) -> Result<(), String> { + if events.is_empty() || events.len() > MAX_EVENTS { + return Err(format!("报告事件数量必须在 1 到 {MAX_EVENTS} 之间")); + } + if logs.len() > MAX_LOGS { + return Err(format!("日志附件最多 {MAX_LOGS} 个")); + } + if user_description.is_some_and(|value| value.chars().count() > MAX_DESCRIPTION_CHARS) { + return Err(format!("补充说明不能超过 {MAX_DESCRIPTION_CHARS} 个字符")); + } + Ok(()) +} + +#[tauri::command] +pub async fn submit_error_report( + events: Vec, + logs: Vec, + user_description: Option, +) -> Result { + validate_payload(&events, &logs, user_description.as_deref())?; + let snapshot = require_session()?; + let payload = CreateErrorReportBatchRequest { + schema_version: 1, + submission_id: stable_submission_id(&events), + events, + user_description: user_description + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + logs, + }; + let client = agc_main_site_client_builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(HTTP_TIMEOUT) + .build() + .map_err(|_| "创建错误报告客户端失败".to_string())?; + validate_session(&snapshot)?; + let response = client + .post(endpoint(&snapshot)?) + .bearer_auth(&snapshot.access_token) + .header(AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_VALUE) + .header(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION) + .json(&payload) + .send() + .await + .map_err(|_| "错误报告提交失败:无法连接登录服务,请稍后重试".to_string())?; + let status = response.status(); + let body = response + .text() + .await + .map_err(|_| "错误报告提交失败:读取响应失败".to_string())?; + validate_session(&snapshot)?; + if !status.is_success() { + return Err(response_error(status, &body)); + } + response_data(&body) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn event(event_id: &str) -> Event { + Event { + event_id: event_id.to_string(), + fingerprint: format!("fingerprint-{event_id}"), + source: "test".to_string(), + message: "boom".to_string(), + stack: None, + occurred_at: "1".to_string(), + count: 1, + } + } + + #[test] + fn submission_id_is_stable_and_order_independent() { + let first = stable_submission_id(&[event("a"), event("b")]); + let second = stable_submission_id(&[event("b"), event("a")]); + assert_eq!(first, second); + assert!(first.starts_with("agc-error-report-")); + assert_ne!(first, stable_submission_id(&[event("a")])); + } + + #[test] + fn payload_bounds_are_rejected_before_network() { + assert!(validate_payload(&[], &[], None).is_err()); + assert!(validate_payload(&[event("a")], &[], None).is_ok()); + let logs = (0..MAX_LOGS + 1) + .map(|index| ErrorReportLogInput { + name: format!("log-{index}.log"), + content: String::new(), + }) + .collect::>(); + assert!(validate_payload(&[event("a")], &logs, None).is_err()); + let long = "字".repeat(MAX_DESCRIPTION_CHARS + 1); + assert!(validate_payload(&[event("a")], &[], Some(&long)).is_err()); + } + + #[test] + fn endpoint_keeps_origin_and_route() { + let snapshot = PlatformSessionSnapshot { + user_id: "user-1".to_string(), + access_token: "token".to_string(), + api_base_url: "https://dev.genarrative.world".to_string(), + identity_generation: 1, + revision: 1, + }; + assert_eq!( + endpoint(&snapshot).expect("endpoint"), + "https://dev.genarrative.world/api/error-reports" + ); + } + + #[test] + fn envelope_data_is_unwrapped_without_credentials() { + let value = + response_data(r#"{"ok":true,"data":{"batchId":"batch-1"}}"#).expect("envelope data"); + assert_eq!(value["batchId"], "batch-1"); + assert!(!value.to_string().contains("token")); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs new file mode 100644 index 000000000..5bd5eef83 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs @@ -0,0 +1,739 @@ +//! AGC 游戏分发发布 facade。 +//! +//! React 只表达发布/资料生成意图;认证、origin、HTTP、队列等待、幂等键和 +//! 发行包编排都在这里完成。此模块不改变网页发布路径,也不承接账户登录或 +//! 其它素材上传链路。 + +use crate::game_package_upload::{ + game_package_upload_staging_dir, stage_game_package_bytes, upload_staged_game_package, + GamePackageUploadOutcome, GamePackageUploadRequest, StagedGamePackage, +}; +use crate::http_client::agc_main_site_client_builder; +use crate::platform_session::{ + current_platform_session, validate_platform_session_identity, PlatformSessionSnapshot, +}; +use reqwest::header::{HeaderName, HeaderValue, CONTENT_TYPE}; +use reqwest::{Method, StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use shared_contracts::api::API_RESPONSE_ENVELOPE_HEADER; +use shared_contracts::game_distribution::{ + GameDistributionCreateGameRequest, GameDistributionCreateVersionRequest, + GameDistributionPublishMetadataSuggestion, GameDistributionPublishMetadataSuggestionRequest, +}; +use std::path::Path; +use std::time::Duration; +use tauri::{AppHandle, Emitter, Manager}; +use url::Url; + +const API_RESPONSE_ENVELOPE_VERSION: &str = "v1"; +const AGC_CLIENT_MARKER_HEADER: &str = "x-genarrative-client"; +const AGC_CLIENT_MARKER_VALUE: &str = "agc"; +const HTTP_TIMEOUT: Duration = Duration::from_secs(60); +const COVER_QUEUE_POLL_INTERVAL: Duration = Duration::from_millis(1_600); +const COVER_QUEUE_TIMEOUT: Duration = Duration::from_secs(20 * 60); +const COVER_QUEUE_MAX_POLLS: usize = 800; +const PACKAGE_UPLOAD_PROGRESS_EVENT: &str = "game-package-upload-progress"; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GameDistributionPublishResult { + pub(crate) game_id: String, + pub(crate) version_id: String, + pub(crate) version_number: u64, + pub(crate) status: String, + pub(crate) package_sha256: String, + pub(crate) package_size_bytes: u64, + pub(crate) file_count: u32, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GameDistributionCoverGenerationResult { + pub(crate) asset_object_id: String, + pub(crate) preview_url: String, + pub(crate) task_id: String, + pub(crate) model: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CoverGenerationPayload { + #[serde(default)] + image_src: Option, + #[serde(default)] + asset_object_id: Option, + #[serde(default)] + asset: Option, + #[serde(default)] + task_id: Option, + #[serde(default)] + model: Option, + #[serde(default)] + queue_state: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CoverAssetPayload { + #[serde(default)] + asset_object_id: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CoverQueueState { + #[serde(default)] + operation_id: Option, + #[serde(default)] + status: Option, + #[serde(default)] + error: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CoverJobStatus { + status: String, + #[serde(default)] + error: Option, + #[serde(default)] + result: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CreatedGame { + id: String, + #[serde(default)] + publication_revision: u64, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CreatedVersion { + version_id: String, + version_number: u64, + #[serde(default)] + status: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SubmittedVersion { + #[serde(default)] + version: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SubmittedVersionSummary { + #[serde(default)] + status: Option, +} + +fn require_platform_session() -> Result { + current_platform_session() + .ok_or_else(|| "authentication-required: 陶泥儿登录态缺失,请重新登录后重试".to_string()) +} + +fn validate_session(snapshot: &PlatformSessionSnapshot) -> Result<(), String> { + validate_platform_session_identity(&snapshot.identity()) +} + +fn current_scoped_session( + snapshot: &PlatformSessionSnapshot, +) -> Result { + let current = require_platform_session()?; + if current.identity() != snapshot.identity() { + return Err( + "authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试" + .to_string(), + ); + } + Ok(current) +} + +fn endpoint(snapshot: &PlatformSessionSnapshot, segments: &[&str]) -> Result { + let mut url = Url::parse(&format!("{}/", snapshot.api_base_url.trim_end_matches('/'))) + .map_err(|_| "陶泥儿服务地址无效".to_string())?; + { + let mut path = url + .path_segments_mut() + .map_err(|_| "陶泥儿服务地址无效".to_string())?; + for segment in segments { + let segment = segment.trim(); + if segment.is_empty() || segment.contains(['/', '?', '#']) { + return Err("网络请求路径无效".to_string()); + } + path.push(segment); + } + } + Ok(url.to_string()) +} + +fn parse_error_payload(body: &str) -> (Option, Option) { + let value = serde_json::from_str::(body).ok(); + let error = value + .as_ref() + .and_then(|value| value.get("error")) + .unwrap_or_else(|| value.as_ref().unwrap_or(&Value::Null)); + let code = error + .get("code") + .and_then(Value::as_str) + .map(str::to_string); + let message = error + .get("message") + .and_then(Value::as_str) + .map(str::to_string); + (code, message) +} + +fn response_data(body: &str) -> Result { + let value: Value = + serde_json::from_str(body).map_err(|error| format!("服务端响应不是合法 JSON:{error}"))?; + if value.get("ok").and_then(Value::as_bool) == Some(false) { + let (_, message) = parse_error_payload(body); + return Err(message.unwrap_or_else(|| "服务端请求失败".to_string())); + } + Ok(value.get("data").cloned().unwrap_or(value)) +} + +fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String { + let (code, message) = parse_error_payload(body); + if status == StatusCode::UNAUTHORIZED { + return "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(); + } + if status == StatusCode::FORBIDDEN { + return format!( + "permission-denied: {}", + message.unwrap_or_else(|| "当前账号无权执行此操作".to_string()) + ); + } + let detail = message + .or(code) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| format!("HTTP {}", status.as_u16())); + format!("{fallback}:{detail}") +} + +async fn request_json( + client: &reqwest::Client, + snapshot: &PlatformSessionSnapshot, + method: Method, + segments: &[&str], + body: Option, + idempotency_key: Option<&str>, + fallback: &str, +) -> Result { + let current = current_scoped_session(snapshot)?; + let url = endpoint(snapshot, segments)?; + let mut request = client + .request(method, &url) + .bearer_auth(¤t.access_token) + .header( + HeaderName::from_static(AGC_CLIENT_MARKER_HEADER), + HeaderValue::from_static(AGC_CLIENT_MARKER_VALUE), + ) + .header( + HeaderName::from_static(API_RESPONSE_ENVELOPE_HEADER), + HeaderValue::from_static(API_RESPONSE_ENVELOPE_VERSION), + ) + .timeout(HTTP_TIMEOUT); + if let Some(key) = idempotency_key { + request = request.header("Idempotency-Key", key); + } + if let Some(body) = body { + request = request.header(CONTENT_TYPE, "application/json").json(&body); + } + let response = request.send().await.map_err(|error| { + if error.is_timeout() { + format!("{fallback}:请求超时,请稍后重试") + } else { + format!("{fallback}:无法连接登录服务,请确认配套后端或 API 代理已启动后重试") + } + })?; + let status = response.status(); + let text = response + .text() + .await + .map_err(|error| format!("{fallback}:读取响应失败:{error}"))?; + validate_session(snapshot)?; + if !status.is_success() { + return Err(map_http_error(status, &text, fallback)); + } + response_data(&text).map_err(|error| format!("{fallback}:{error}")) +} + +fn build_client() -> Result { + agc_main_site_client_builder() + .timeout(HTTP_TIMEOUT) + .build() + .map_err(|error| format!("创建陶泥儿网络客户端失败:{error}")) +} + +fn read_cover_asset_id(payload: &CoverGenerationPayload) -> Option { + payload + .asset_object_id + .clone() + .or_else(|| { + payload + .asset + .as_ref() + .and_then(|asset| asset.asset_object_id.clone()) + }) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +async fn resolve_cover_queue( + client: &reqwest::Client, + snapshot: &PlatformSessionSnapshot, + initial: CoverGenerationPayload, +) -> Result { + let Some(queue) = initial.queue_state.as_ref() else { + return Ok(initial); + }; + let Some(operation_id) = queue + .operation_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Ok(initial); + }; + if !matches!(queue.status.as_deref(), Some("queued" | "running")) { + if queue.status.as_deref() == Some("failed") { + return Err(queue + .error + .clone() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "生成游戏封面失败".to_string())); + } + return Ok(initial); + } + let started_at = tokio::time::Instant::now(); + for _ in 0..COVER_QUEUE_MAX_POLLS { + if started_at.elapsed() > COVER_QUEUE_TIMEOUT { + return Err("生成游戏封面超时,请稍后重试".to_string()); + } + tokio::time::sleep(COVER_QUEUE_POLL_INTERVAL).await; + let status = request_json( + client, + snapshot, + Method::GET, + &[ + "api", + "runtime", + "external-generation", + "jobs", + operation_id, + ], + None, + None, + "读取封面生成任务失败", + ) + .await?; + let status: CoverJobStatus = + serde_json::from_value(status.get("job").cloned().unwrap_or(status)) + .map_err(|error| format!("封面生成任务响应缺少状态:{error}"))?; + if status.status == "failed" { + return Err(status + .error + .unwrap_or_else(|| "生成游戏封面失败".to_string())); + } + if status.status == "completed" { + let result = status.result.unwrap_or(Value::Null); + let completed: CoverGenerationPayload = serde_json::from_value(result) + .map_err(|error| format!("封面生成结果格式无效:{error}"))?; + return Ok(CoverGenerationPayload { + image_src: completed.image_src.or(initial.image_src), + asset_object_id: completed.asset_object_id.or(initial.asset_object_id), + asset: completed.asset.or(initial.asset), + task_id: completed.task_id.or(initial.task_id), + model: completed.model.or(initial.model), + queue_state: None, + }); + } + } + Err("生成游戏封面超时,请稍后重试".to_string()) +} + +#[tauri::command] +pub(crate) async fn read_game_publish_availability() -> Result { + let snapshot = require_platform_session()?; + let client = build_client()?; + let value = request_json( + &client, + &snapshot, + Method::GET, + &["api", "runtime", "frontend-config"], + None, + None, + "读取发布灰度配置失败", + ) + .await?; + Ok(value + .get("gameDistributionPublishEnabled") + .and_then(Value::as_bool) + .unwrap_or(false)) +} + +#[tauri::command] +pub(crate) async fn suggest_game_distribution_publish_metadata( + name: String, + goal: Option, + context: Option, +) -> Result { + let snapshot = require_platform_session()?; + let client = build_client()?; + let payload = GameDistributionPublishMetadataSuggestionRequest { + name: name.trim().to_string(), + goal: goal.map(|value| value.trim().to_string()), + context: context.map(|value| value.trim().to_string()), + }; + let value = request_json( + &client, + &snapshot, + Method::POST, + &[ + "api", + "game-distribution", + "publish-metadata", + "suggestions", + ], + Some( + serde_json::to_value(payload) + .map_err(|error| format!("生成发布资料请求无效:{error}"))?, + ), + None, + "生成发布简介和分类失败", + ) + .await?; + serde_json::from_value(value).map_err(|error| format!("发布资料建议响应无效:{error}")) +} + +#[tauri::command] +pub(crate) async fn read_game_cover_generation_price( + model: String, + image_size: String, +) -> Result { + let snapshot = require_platform_session()?; + let client = build_client()?; + let value = request_json( + &client, + &snapshot, + Method::GET, + &["api", "editor", "generation-pricing"], + None, + None, + "读取封面生成价格失败", + ) + .await?; + let price = value + .get("models") + .and_then(|models| models.get(model.trim())) + .and_then(|entry| { + entry + .get("prices") + .and_then(|prices| prices.get(image_size.trim())) + .or_else(|| entry.get("price")) + }) + .and_then(Value::as_f64) + .filter(|price| price.is_finite() && *price >= 0.0) + .ok_or_else(|| "封面生成价格暂不可用".to_string())?; + Ok(price) +} + +#[tauri::command] +pub(crate) async fn generate_game_distribution_cover( + prompt: String, + model: String, + aspect_ratio: Option, + image_size: Option, + asset_label: Option, +) -> Result { + let snapshot = require_platform_session()?; + let client = build_client()?; + let value = request_json( + &client, + &snapshot, + Method::POST, + &["api", "editor", "images", "generations"], + Some(json!({ + "prompt": prompt.trim(), + "kind": "publication-material", + "assetKind": "publication-material", + "model": model.trim(), + "aspectRatio": aspect_ratio.as_deref().unwrap_or("16:9"), + "imageSize": image_size.as_deref().unwrap_or("2K"), + "assetLabel": asset_label.as_deref().unwrap_or("游戏封面").trim(), + })), + None, + "生成游戏封面失败", + ) + .await?; + let initial: CoverGenerationPayload = + serde_json::from_value(value).map_err(|error| format!("生成游戏封面响应无效:{error}"))?; + let payload = resolve_cover_queue(&client, &snapshot, initial).await?; + let asset_object_id = + read_cover_asset_id(&payload).ok_or_else(|| "生成游戏封面未返回平台素材 ID".to_string())?; + Ok(GameDistributionCoverGenerationResult { + asset_object_id, + preview_url: payload.image_src.unwrap_or_default().trim().to_string(), + task_id: payload.task_id.unwrap_or_default().trim().to_string(), + model: payload + .model + .unwrap_or_else(|| model.trim().to_string()) + .trim() + .to_string(), + }) +} + +fn stage_publish_package( + app: &AppHandle, + project_path: &str, + package_relative_path: &str, +) -> Result { + let root = Path::new(project_path.trim()); + crate::project::enforce_project_permission_policy(root, "project.export_package")?; + let payload = + crate::project::read_local_project_export_package_at(root, package_relative_path.trim())?; + let app_data_dir = app + .path() + .app_data_dir() + .map_err(|error| format!("无法读取 AGC 应用数据目录:{error}"))?; + let staging_dir = game_package_upload_staging_dir(&app_data_dir); + let mut staged = stage_game_package_bytes( + &staging_dir, + &payload.package_sha256, + &payload.package_bytes, + )?; + staged.package_file_count = u32::try_from(payload.files.len()).unwrap_or(u32::MAX); + Ok(staged) +} + +#[tauri::command] +pub(crate) async fn publish_local_project_game( + app: AppHandle, + project_path: String, + package_relative_path: String, + metadata: GameDistributionCreateGameRequest, + idempotency_key: Option, +) -> Result { + let snapshot = require_platform_session()?; + let staged = stage_publish_package(&app, &project_path, &package_relative_path)?; + let local_project_id = metadata + .local_project_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "发布需要本地项目标识,请重新打开项目后再试".to_string())? + .to_string(); + if metadata + .cover_asset_id + .as_deref() + .map(str::trim) + .unwrap_or_default() + .is_empty() + { + return Err("请先选择游戏封面(JPG/PNG/WebP),再发布到游戏广场".to_string()); + } + if metadata.screenshots.len() > 6 { + return Err("游戏截图最多 6 张".to_string()); + } + let root_key = idempotency_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("agc-publish-{}", uuid::Uuid::new_v4())); + let client = build_client()?; + let game_value = request_json( + &client, + &snapshot, + Method::POST, + &["api", "game-distribution", "games"], + Some( + serde_json::to_value(&metadata) + .map_err(|error| format!("创建平台游戏请求无效:{error}"))?, + ), + Some(&format!("{root_key}:game")), + "创建平台游戏失败", + ) + .await?; + let game: CreatedGame = serde_json::from_value(game_value) + .map_err(|error| format!("创建平台游戏响应无效:{error}"))?; + if game.id.trim().is_empty() { + return Err("创建平台游戏未返回游戏 ID".to_string()); + } + let version_request = GameDistributionCreateVersionRequest { + local_project_id: Some(local_project_id), + package_sha256: staged.package_sha256.clone(), + package_bytes: staged.package_size_bytes, + package_file_count: staged.package_file_count, + package_entry_path: "index.html".to_string(), + game_metadata: metadata, + }; + let version_value = request_json( + &client, + &snapshot, + Method::POST, + &[ + "api", + "game-distribution", + "games", + game.id.as_str(), + "versions", + ], + Some( + serde_json::to_value(version_request) + .map_err(|error| format!("创建游戏发行版本请求无效:{error}"))?, + ), + Some(&format!("{root_key}:version")), + "创建游戏发行版本失败", + ) + .await?; + let version: CreatedVersion = serde_json::from_value(version_value) + .map_err(|error| format!("创建发行版本响应无效:{error}"))?; + if version.version_id.trim().is_empty() { + return Err("创建发行版本未返回版本 ID".to_string()); + } + validate_session(&snapshot)?; + let progress_version_id = version.version_id.clone(); + let emit_handle = app.clone(); + let session_identity = snapshot.identity(); + let uploaded: GamePackageUploadOutcome = upload_staged_game_package( + &client, + GamePackageUploadRequest { + staging_path: Path::new(&staged.staging_path), + version_id: &version.version_id, + api_base_url: &snapshot.api_base_url, + access_token: &snapshot.access_token, + idempotency_key: &format!("{root_key}:upload"), + session_identity: Some(&session_identity), + }, + move |received_bytes, total_bytes| { + let _ = emit_handle.emit( + PACKAGE_UPLOAD_PROGRESS_EVENT, + crate::game_package_upload::progress_event_payload( + &progress_version_id, + received_bytes, + total_bytes, + ), + ); + }, + ) + .await?; + validate_session(&snapshot)?; + let submit_value = request_json( + &client, + &snapshot, + Method::POST, + &[ + "api", + "game-distribution", + "versions", + version.version_id.as_str(), + "submit", + ], + Some(json!({ + "expectedPublicationRevision": game.publication_revision, + })), + Some(&format!("{root_key}:submit")), + "提交审核失败", + ) + .await?; + let submitted: SubmittedVersion = + serde_json::from_value(submit_value).unwrap_or(SubmittedVersion { version: None }); + Ok(GameDistributionPublishResult { + game_id: game.id, + version_id: version.version_id, + version_number: version.version_number, + status: submitted + .version + .and_then(|version| version.status) + .filter(|status| !status.trim().is_empty()) + .unwrap_or_else(|| { + if uploaded.status.trim().is_empty() { + version.status + } else { + uploaded.status + } + }), + package_sha256: staged.package_sha256, + package_size_bytes: staged.package_size_bytes, + file_count: staged.package_file_count, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn endpoint_encodes_only_safe_path_segments_and_keeps_origin() { + let snapshot = PlatformSessionSnapshot { + user_id: "user-1".to_string(), + access_token: "token".to_string(), + api_base_url: "https://dev.genarrative.world".to_string(), + identity_generation: 1, + revision: 1, + }; + assert_eq!( + endpoint(&snapshot, &["api", "game-distribution", "games", "game-1"]) + .expect("endpoint"), + "https://dev.genarrative.world/api/game-distribution/games/game-1" + ); + assert!(endpoint(&snapshot, &["api", "bad/id"]).is_err()); + assert!(endpoint(&snapshot, &["api", "bad?query"]).is_err()); + } + + #[test] + fn response_data_unwraps_v1_envelope_and_legacy_payload() { + assert_eq!( + response_data(r#"{"ok":true,"data":{"enabled":true}}"#) + .expect("envelope") + .get("enabled") + .and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + response_data(r#"{"enabled":true}"#) + .expect("legacy") + .get("enabled") + .and_then(Value::as_bool), + Some(true) + ); + } + + #[test] + fn auth_and_server_errors_keep_stable_user_facing_categories() { + assert_eq!( + map_http_error(StatusCode::UNAUTHORIZED, "{}", "读取失败"), + "authentication-required: 陶泥儿登录态已过期,请重新登录后重试" + ); + assert_eq!( + map_http_error( + StatusCode::BAD_REQUEST, + r#"{"error":{"code":"BAD_REQUEST","message":"资料无效"}}"#, + "发布失败" + ), + "发布失败:资料无效" + ); + } + + #[test] + fn cover_payload_prefers_registered_asset_id() { + let payload: CoverGenerationPayload = serde_json::from_value(json!({ + "imageSrc": "https://asset.test/cover.png", + "asset": { "assetObjectId": "asset-from-nested" }, + "assetObjectId": "asset-from-top-level" + })) + .expect("cover payload"); + assert_eq!( + read_cover_asset_id(&payload).as_deref(), + Some("asset-from-top-level") + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs index c52745b45..a9a395c17 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs @@ -11,6 +11,9 @@ use std::{ time::Duration, }; +use crate::platform_session::{ + current_platform_session, validate_platform_session_identity, PlatformSessionIdentity, +}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -346,6 +349,20 @@ pub(crate) struct GamePackageUploadRequest<'a> { pub(crate) api_base_url: &'a str, pub(crate) access_token: &'a str, pub(crate) idempotency_key: &'a str, + pub(crate) session_identity: Option<&'a PlatformSessionIdentity>, +} + +impl<'a> GamePackageUploadRequest<'a> { + fn current_access_token(&self) -> Result { + let Some(expected) = self.session_identity else { + return Ok(self.access_token.to_string()); + }; + let session = current_platform_session().ok_or_else(|| { + "authentication-required: 陶泥儿登录态缺失,请重新登录后重试".to_string() + })?; + validate_platform_session_identity(expected)?; + Ok(session.access_token) + } } /// 分片续传主循环:权威偏移来自服务端,失败按可重试分类退避,偏移不符立即按权威偏移继续。 @@ -360,11 +377,12 @@ pub(crate) async fn upload_staged_game_package( if total_bytes == 0 { return Err("发行包暂存文件为空,请重新导出试玩包".to_string()); } + let access_token = request.current_access_token()?; let state = read_upload_state( client, request.api_base_url, request.version_id, - request.access_token, + &access_token, ) .await?; if state.declared_package_bytes != 0 && state.declared_package_bytes != total_bytes { @@ -386,11 +404,12 @@ pub(crate) async fn upload_staged_game_package( let body = read_chunk(&mut file, plan)?; let mut attempt = 1_usize; loop { + let access_token = request.current_access_token()?; match upload_chunk( client, request.api_base_url, request.version_id, - request.access_token, + &access_token, request.idempotency_key, plan, body.clone(), @@ -413,21 +432,23 @@ pub(crate) async fn upload_staged_game_package( } } // 权威偏移可能在重试期间前进(例如响应丢失后服务端已写入),按服务端口径对齐。 + let access_token = request.current_access_token()?; let authoritative = read_upload_state( client, request.api_base_url, request.version_id, - request.access_token, + &access_token, ) .await?; received = authoritative.received_bytes.min(total_bytes); on_progress(received, total_bytes); } + let access_token = request.current_access_token()?; let mut outcome = complete_upload( client, request.api_base_url, request.version_id, - request.access_token, + &access_token, request.idempotency_key, ) .await?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs b/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs new file mode 100644 index 000000000..b46f9e52d --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs @@ -0,0 +1,333 @@ +use crate::http_client::{agc_main_site_client_builder, with_agc_main_site_marker}; +use crate::platform_session::{ + current_platform_session, validate_platform_session_identity, PlatformSessionSnapshot, +}; +use futures::StreamExt; +use serde::Deserialize; +use serde_json::Value; +use shared_contracts::api::API_RESPONSE_ENVELOPE_HEADER; +use shared_contracts::llm::{LlmModelSummary, LlmModelsResponse}; +use std::time::Duration; + +const API_RESPONSE_ENVELOPE_VERSION: &str = "v1"; +const MODEL_CATALOG_MAX_BYTES: usize = 1024 * 1024; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WireModelSummary { + id: String, + display_name: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WireModelCatalog { + default_model_id: String, + models: Vec, + revision: u64, +} + +#[derive(Debug, Deserialize)] +struct ApiErrorPayload { + code: Option, + message: Option, +} + +#[derive(Debug, Deserialize)] +struct ApiEnvelope { + ok: bool, + data: Option, + error: Option, +} + +fn map_catalog(catalog: WireModelCatalog) -> LlmModelsResponse { + LlmModelsResponse { + default_model_id: catalog.default_model_id, + models: catalog + .models + .into_iter() + .map(|model| LlmModelSummary { + id: model.id, + display_name: model.display_name, + }) + .collect(), + revision: catalog.revision, + } +} + +fn parse_catalog_payload(bytes: &[u8]) -> Result { + let value: Value = serde_json::from_slice(bytes) + .map_err(|_| "模型列表响应不是有效 JSON,请稍后重试".to_string())?; + + // The platform API normally returns the versioned response envelope. Keep + // accepting the raw payload for older dev proxies and local fixtures, but + // never accept an error envelope as a successful catalog. + if value.get("ok").is_some() { + let envelope: ApiEnvelope = serde_json::from_value(value) + .map_err(|_| "模型列表响应格式无效,请稍后重试".to_string())?; + if !envelope.ok { + let message = envelope + .error + .as_ref() + .and_then(|error| error.message.as_deref()) + .filter(|message| !message.trim().is_empty()) + .or_else(|| { + envelope + .error + .as_ref() + .and_then(|error| error.code.as_deref()) + .filter(|code| !code.trim().is_empty()) + }) + .unwrap_or("模型列表读取失败"); + return Err(message.to_string()); + } + return envelope + .data + .map(map_catalog) + .ok_or_else(|| "模型列表响应缺少 data,请稍后重试".to_string()); + } + + serde_json::from_value::(value) + .map(map_catalog) + .map_err(|_| "模型列表响应格式无效,请稍后重试".to_string()) +} + +async fn read_bounded_body( + response: reqwest::Response, + max_bytes: usize, +) -> Result, String> { + if response + .content_length() + .is_some_and(|length| length > max_bytes as u64) + { + return Err("模型列表响应超过 1 MiB 上限".to_string()); + } + let mut body = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream + .next() + .await + .transpose() + .map_err(|_| "读取模型列表响应失败或超时,请重试".to_string())? + { + if body.len().saturating_add(chunk.len()) > max_bytes { + return Err("模型列表响应超过 1 MiB 上限".to_string()); + } + body.extend_from_slice(&chunk); + } + Ok(body) +} + +async fn fetch_game_creator_llm_models( + client: &reqwest::Client, + session: &PlatformSessionSnapshot, +) -> Result { + let api_base_url = session.api_base_url.trim_end_matches('/'); + if api_base_url.is_empty() { + return Err("authentication-required: 平台服务地址缺失,请重新登录".to_string()); + } + let current_session = current_platform_session() + .ok_or_else(|| "authentication-required: 请先登录陶泥儿账号".to_string())?; + validate_platform_session_identity(&session.identity())?; + let response = with_agc_main_site_marker( + client + .get(format!("{api_base_url}/api/llm/models")) + .bearer_auth(¤t_session.access_token) + .header(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION), + ) + .send() + .await + .map_err(|error| { + if error.is_timeout() { + "模型列表请求超时,请重试".to_string() + } else { + "无法连接模型服务,请检查网络后重试".to_string() + } + })?; + + let status = response.status(); + let body = read_bounded_body(response, MODEL_CATALOG_MAX_BYTES).await?; + // A session switch during an in-flight request invalidates the old result; + // token rotation within the same identity remains valid. + validate_platform_session_identity(&session.identity())?; + if !status.is_success() { + let detail = parse_catalog_payload(&body).err(); + return Err(detail + .filter(|message| !message.trim().is_empty()) + .map(|message| format!("模型列表读取失败(HTTP {}):{message}", status.as_u16())) + .unwrap_or_else(|| format!("模型列表读取失败(HTTP {})", status.as_u16()))); + } + parse_catalog_payload(&body) +} + +/// Read the official account-scoped model catalog in Rust. +/// +/// React receives a typed snapshot through Tauri and does not hold the +/// platform token or issue the HTTP request itself. Custom model catalogs are +/// still handled by the local configuration path in llmModelCatalog.ts. +#[tauri::command] +pub(crate) async fn load_game_creator_llm_models() -> Result { + let session = current_platform_session() + .ok_or_else(|| "authentication-required: 请先登录陶泥儿账号".to_string())?; + let client = agc_main_site_client_builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(15)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|_| "初始化模型列表请求失败".to_string())?; + fetch_game_creator_llm_models(&client, &session).await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::platform_session::{current_platform_session, install_test_platform_session}; + use std::io::{Read, Write}; + use std::net::{TcpListener, TcpStream}; + use std::thread; + + fn read_request(stream: &mut TcpStream) -> String { + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("set fixture timeout"); + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 4096]; + loop { + let count = stream.read(&mut buffer).expect("read fixture request"); + assert!(count > 0, "fixture request closed before headers"); + bytes.extend_from_slice(&buffer[..count]); + if bytes.windows(4).any(|part| part == b"\r\n\r\n") { + break; + } + } + String::from_utf8_lossy(&bytes).into_owned() + } + + fn fixture_response( + listener: TcpListener, + status: &'static str, + body: String, + ) -> thread::JoinHandle { + thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept fixture request"); + let request = read_request(&mut stream); + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(response.as_bytes()) + .expect("write fixture response"); + request + }) + } + + fn test_client() -> reqwest::Client { + agc_main_site_client_builder() + .connect_timeout(Duration::from_secs(2)) + .timeout(Duration::from_secs(2)) + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .build() + .expect("build fixture client") + } + + #[tokio::test] + async fn official_catalog_uses_platform_session_and_unwraps_envelope() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture"); + let address = listener.local_addr().expect("fixture address"); + let body = serde_json::json!({ + "ok": true, + "data": { + "defaultModelId": "quality", + "models": [{"id": "quality", "displayName": "高质量"}], + "revision": 7 + }, + "error": null, + "meta": {"apiVersion": "2026-06-16"} + }) + .to_string(); + let server = fixture_response(listener, "200 OK", body); + let _session = install_test_platform_session( + "catalog-user", + "catalog-token", + &format!("http://{address}"), + ); + + let session = current_platform_session().expect("test platform session"); + let catalog = fetch_game_creator_llm_models(&test_client(), &session) + .await + .expect("catalog request"); + assert_eq!(catalog.default_model_id, "quality"); + assert_eq!(catalog.revision, 7); + assert_eq!(catalog.models[0].display_name, "高质量"); + let request = server.join().expect("join fixture"); + assert!(request.starts_with("GET /api/llm/models HTTP/1.1")); + assert!(request + .to_ascii_lowercase() + .contains("authorization: bearer catalog-token")); + assert!(request + .to_ascii_lowercase() + .contains("x-genarrative-client: agc")); + assert!(request + .to_ascii_lowercase() + .contains("x-genarrative-response-envelope: v1")); + } + + #[tokio::test] + async fn official_catalog_fails_closed_on_http_error_without_accepting_error_as_data() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture"); + let address = listener.local_addr().expect("fixture address"); + let body = serde_json::json!({ + "ok": false, + "data": null, + "error": {"code": "UNAUTHORIZED", "message": "登录态已失效"}, + "meta": {"apiVersion": "2026-06-16"} + }) + .to_string(); + let server = fixture_response(listener, "401 Unauthorized", body); + let _session = install_test_platform_session( + "catalog-user", + "catalog-token", + &format!("http://{address}"), + ); + + let session = current_platform_session().expect("test platform session"); + let error = fetch_game_creator_llm_models(&test_client(), &session) + .await + .expect_err("unauthorized catalog must fail"); + assert!(error.contains("HTTP 401"), "{error}"); + assert!(error.contains("登录态已失效"), "{error}"); + server.join().expect("join fixture"); + } + + #[tokio::test] + async fn official_catalog_rejects_oversized_response() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture"); + let address = listener.local_addr().expect("fixture address"); + let body = "x".repeat(MODEL_CATALOG_MAX_BYTES + 1); + let server = fixture_response(listener, "200 OK", body); + let _session = install_test_platform_session( + "catalog-user", + "catalog-token", + &format!("http://{address}"), + ); + + let session = current_platform_session().expect("test platform session"); + let error = fetch_game_creator_llm_models(&test_client(), &session) + .await + .expect_err("oversized catalog must fail"); + assert!(error.contains("1 MiB"), "{error}"); + server.join().expect("join fixture"); + } + + #[test] + fn missing_platform_session_is_explicit() { + let _session = crate::platform_session::clear_test_platform_session(); + let result = tauri::async_runtime::block_on(load_game_creator_llm_models()); + assert_eq!( + result.expect_err("missing session must fail"), + "authentication-required: 请先登录陶泥儿账号" + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 3b356fcd6..a921caa42 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -107,11 +107,13 @@ fn register_non_canonical_asset_kind_reporter() { // 用 #[cfg] 编译期门控:仅开发(debug)且非测试构建编入;生产 release 与 cargo test 下整体剔除。 include!(concat!(env!("OUT_DIR"), "/agent_runtime_prompt_bundle.rs")); +mod account_api; mod agent; mod agent_native_tools; mod analytics; mod asset_generation_tasks; mod assets; +mod auth_session; mod browser; mod builtin_plugins; mod cli; @@ -132,13 +134,16 @@ mod editor_adapter; mod editor_adapters; mod environment_check; pub mod error_report; +mod game_distribution_publish; mod game_package_upload; mod git_inspect; mod goal; mod http_client; mod image_inspect; mod isolated_agent; +mod llm_catalog; mod patchset; +mod platform_asset_upload; mod platform_session; mod plugin_host; mod preview; @@ -156,12 +161,15 @@ mod template_library; mod tool_plan_handoff; mod user_input; mod windows; +mod workspace_preferences; +use account_api::*; use agent::design_tools::*; use agent::*; use agent_native_tools::*; use asset_generation_tasks::*; use assets::*; +use auth_session::*; use browser::*; use cli::*; use client_extensions::*; @@ -174,11 +182,14 @@ use config::*; use context_compaction::*; use delegation::*; use error_report::*; +use game_distribution_publish::*; use git_inspect::*; use goal::*; use image_inspect::*; use isolated_agent::*; +use llm_catalog::load_game_creator_llm_models; use patchset::*; +use platform_asset_upload::upload_platform_media_asset; use platform_session::*; use plugin_host::{ call_agc_plugin, list_agc_extensions, list_agc_plugins, read_agc_plugin_panel, @@ -196,6 +207,7 @@ use runner::*; use template_library::*; use user_input::*; use windows::*; +use workspace_preferences::*; #[tauri::command] async fn suggest_ui_design_semantic( project_path: String, @@ -2465,7 +2477,6 @@ fn main() { let app = tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(context_menu::init()) @@ -2582,6 +2593,8 @@ fn main() { setup_log.append("startup.runner.start.begin"); set_game_creator_agent_runtime_update_app_handle(app.handle().clone()); set_direct_thread_manager_app_handle(app.handle().clone()); + auth_session::initialize_auth_session(app.handle()); + set_asset_generation_task_app_handle(app.handle().clone()); let manifest_event_sink = start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?; attach_external_agent_runner_gui_owner(&manifest_event_sink) @@ -2683,13 +2696,37 @@ fn main() { confirm_resume_game_creator_agent_runtime_tasks, schedule_game_creator_agent_ready_tasks, check_game_creator_llm_config, - read_platform_account_session_state, - install_platform_account_session, - clear_platform_account_session, + read_workspace_preferences, + set_project_creation_directory, + remember_recent_workspace, + remove_recent_workspace, + migrate_workspace_preferences, + set_chat_prompt_polish_reminder_disabled, + set_client_server_selection, + read_profile_recharge_center, + read_profile_wallet_ledger, + create_profile_recharge_order, + confirm_wechat_profile_recharge_order, + redeem_profile_reward_code, + read_client_auth_state, + refresh_client_auth_session, + send_client_phone_login_code, + login_client_with_password, + login_client_with_phone_code, + logout_client_session, + read_game_publish_availability, + suggest_game_distribution_publish_metadata, + read_game_cover_generation_price, + generate_game_distribution_cover, + publish_local_project_game, read_game_creator_app_config, write_game_creator_app_config, select_game_creator_model, + load_game_creator_llm_models, discover_game_creator_llm_models, + read_editor_asset_library, + read_editor_asset_preview, + upload_platform_media_asset, upload_local_asset, register_local_asset, create_ui_design_resource, @@ -2752,6 +2789,7 @@ fn main() { list_game_creator_direct_active_turns, subscribe_direct_project_thread, consume_direct_project_thread, + unsubscribe_direct_project_thread, read_direct_project_history_slice, append_local_conversation_message, append_direct_project_conversation_message, @@ -2788,6 +2826,7 @@ fn main() { report_client_error, get_pending_error_reports, ack_error_reports, + submit_error_report, sync_local_project_snapshot, read_local_project_snapshot_state, set_active_project_snapshot_workspace, diff --git a/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs b/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs new file mode 100644 index 000000000..e3da2f81e --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs @@ -0,0 +1,452 @@ +//! 平台素材直传 facade。 +//! +//! 渲染层只提交用户选择的文件字节和结构化素材元数据。凭证申请、对象存储表单直传、 +//! confirm、会话身份围栏和错误分类全部在 Rust 内完成,避免 React 持有 token 或自行 +//! 发起平台网络请求。 + +use crate::http_client::agc_main_site_client_builder; +use crate::platform_session::{ + current_platform_session, validate_platform_session_identity, PlatformSessionSnapshot, +}; +use reqwest::multipart::{Form, Part}; +use reqwest::StatusCode; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::time::Duration; +use url::Url; + +const HTTP_TIMEOUT: Duration = Duration::from_secs(60); +const MAX_UPLOAD_BYTES: usize = 6 * 1024 * 1024; +const MAX_FILE_NAME_CHARS: usize = 255; +const MAX_ASSET_KIND_CHARS: usize = 128; +const MAX_ENTITY_ID_CHARS: usize = 256; +const MAX_PATH_SEGMENTS: usize = 8; +const MAX_PATH_SEGMENT_CHARS: usize = 128; +const API_RESPONSE_ENVELOPE_HEADER: &str = "x-genarrative-response-envelope"; +const API_RESPONSE_ENVELOPE_VERSION: &str = "v1"; +const AGC_CLIENT_MARKER_HEADER: &str = "x-genarrative-client"; +const AGC_CLIENT_MARKER_VALUE: &str = "agc"; + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlatformAssetUploadInput { + pub(crate) file_name: String, + pub(crate) content_type: String, + pub(crate) asset_kind: String, + pub(crate) path_segments: Vec, + pub(crate) entity_id: String, + pub(crate) bytes: Vec, + #[serde(default)] + pub(crate) metadata: BTreeMap, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PlatformAssetUploadResult { + pub(crate) asset_object_id: String, + pub(crate) object_key: String, +} + +#[derive(Clone, Debug)] +struct UploadTicket { + host: String, + bucket: String, + object_key: String, + success_action_status: u16, + form_fields: BTreeMap, +} + +fn require_session() -> Result { + current_platform_session() + .ok_or_else(|| "authentication-required: 陶泥儿登录态缺失,请重新登录后重试".to_string()) +} + +fn validate_session(snapshot: &PlatformSessionSnapshot) -> Result<(), String> { + validate_platform_session_identity(&snapshot.identity()) +} + +fn current_scoped_session( + snapshot: &PlatformSessionSnapshot, +) -> Result { + let current = require_session()?; + if current.identity() != snapshot.identity() { + return Err( + "authentication-required: 陶泥儿登录态已变化,旧账号素材上传已停止,请重试".to_string(), + ); + } + Ok(current) +} + +fn validate_text(value: &str, label: &str, max_chars: usize) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err(format!("{label}不能为空")); + } + if value.chars().count() > max_chars || value.chars().any(char::is_control) { + return Err(format!("{label}无效")); + } + Ok(value.to_string()) +} + +fn validate_input(input: PlatformAssetUploadInput) -> Result { + if input.bytes.is_empty() { + return Err("素材文件为空".to_string()); + } + if input.bytes.len() > MAX_UPLOAD_BYTES { + return Err(format!( + "素材文件超过 {} MiB 限制", + MAX_UPLOAD_BYTES / 1024 / 1024 + )); + } + let file_name = validate_text(&input.file_name, "素材文件名", MAX_FILE_NAME_CHARS)?; + if file_name.contains(['/', '\\']) || file_name == "." || file_name == ".." { + return Err("素材文件名不能包含路径".to_string()); + } + let content_type = validate_text(&input.content_type, "素材媒体类型", 255)?; + let asset_kind = validate_text(&input.asset_kind, "素材类型", MAX_ASSET_KIND_CHARS)?; + let entity_id = validate_text(&input.entity_id, "素材实体标识", MAX_ENTITY_ID_CHARS)?; + if input.path_segments.is_empty() || input.path_segments.len() > MAX_PATH_SEGMENTS { + return Err("素材上传路径无效".to_string()); + } + let path_segments = input + .path_segments + .into_iter() + .map(|segment| validate_text(&segment, "素材上传路径", MAX_PATH_SEGMENT_CHARS)) + .collect::, _>>()?; + if path_segments + .iter() + .any(|segment| segment.contains(['/', '\\', '?', '#'])) + { + return Err("素材上传路径无效".to_string()); + } + let metadata = input + .metadata + .into_iter() + .map(|(key, value)| { + let key = validate_text(&key, "素材元数据键", 128)?; + let value = validate_text(&value, "素材元数据值", 512)?; + Ok((key, value)) + }) + .collect::, String>>()?; + Ok(PlatformAssetUploadInput { + file_name, + content_type, + asset_kind, + path_segments, + entity_id, + bytes: input.bytes, + metadata, + }) +} + +fn endpoint(snapshot: &PlatformSessionSnapshot, route: &str) -> Result { + let mut url = Url::parse(&format!("{}/", snapshot.api_base_url.trim_end_matches('/'))) + .map_err(|_| "陶泥儿服务地址无效".to_string())?; + { + let mut segments = url + .path_segments_mut() + .map_err(|_| "陶泥儿服务地址无效".to_string())?; + for segment in route.trim_start_matches('/').split('/') { + if segment.is_empty() { + continue; + } + segments.push(segment); + } + } + Ok(url.to_string()) +} + +fn response_data(payload: Value) -> Value { + payload.get("data").cloned().unwrap_or(payload) +} + +fn response_error(status: StatusCode, body: &str, action: &str) -> String { + if status == StatusCode::UNAUTHORIZED { + return "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(); + } + if status == StatusCode::FORBIDDEN { + return format!("permission-denied: {action}权限不足"); + } + let detail = serde_json::from_str::(body) + .ok() + .and_then(|value| { + value + .get("error") + .and_then(|error| error.get("message")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + }) + .unwrap_or_else(|| format!("HTTP {}", status.as_u16())); + format!("{action}失败:{detail}") +} + +fn upload_route() -> &'static str { + "/api/assets/direct-upload-tickets" +} + +fn confirm_route() -> &'static str { + "/api/assets/objects/confirm" +} + +async fn request_ticket( + client: &reqwest::Client, + snapshot: &PlatformSessionSnapshot, + input: &PlatformAssetUploadInput, +) -> Result { + let current = current_scoped_session(snapshot)?; + let url = endpoint(snapshot, upload_route())?; + let mut metadata = input.metadata.clone(); + metadata.insert("asset_kind".to_string(), input.asset_kind.clone()); + let response = client + .post(url) + .bearer_auth(current.access_token) + .header(AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_VALUE) + .header(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION) + .json(&json!({ + "legacyPrefix": "generated-character-drafts", + "pathSegments": input.path_segments, + "fileName": input.file_name, + "contentType": input.content_type, + "access": "private", + "maxSizeBytes": input.bytes.len(), + "metadata": metadata, + })) + .send() + .await + .map_err(|_| "创建素材上传凭证失败:无法连接登录服务,请稍后重试".to_string())?; + let status = response.status(); + let body = response + .text() + .await + .map_err(|_| "创建素材上传凭证失败:读取响应失败".to_string())?; + validate_session(snapshot)?; + if !status.is_success() { + return Err(response_error(status, &body, "创建素材上传凭证")); + } + let payload = serde_json::from_str::(&body) + .map(response_data) + .map_err(|_| "创建素材上传凭证失败:响应格式无效".to_string())?; + let upload = payload + .get("upload") + .ok_or_else(|| "创建素材上传凭证失败:响应缺少 upload".to_string())?; + let host = upload + .get("host") + .or_else(|| upload.get("endpoint")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "创建素材上传凭证失败:响应缺少 host".to_string())? + .to_string(); + let bucket = upload + .get("bucket") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "创建素材上传凭证失败:响应缺少 bucket".to_string())? + .to_string(); + let object_key = upload + .get("objectKey") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "创建素材上传凭证失败:响应缺少 objectKey".to_string())? + .to_string(); + let success_action_status = upload + .get("successActionStatus") + .and_then(Value::as_u64) + .and_then(|value| u16::try_from(value).ok()) + .unwrap_or(204); + if !matches!(success_action_status, 200 | 201 | 204) { + return Err("创建素材上传凭证失败:successActionStatus 无效".to_string()); + } + let form_fields = upload + .get("formFields") + .and_then(Value::as_object) + .ok_or_else(|| "创建素材上传凭证失败:响应缺少 formFields".to_string())? + .iter() + .map(|(key, value)| { + value + .as_str() + .map(|value| (key.clone(), value.to_string())) + .ok_or_else(|| "创建素材上传凭证失败:formFields 格式无效".to_string()) + }) + .collect::, _>>()?; + Ok(UploadTicket { + host, + bucket, + object_key, + success_action_status, + form_fields, + }) +} + +fn validate_upload_host(host: &str) -> Result { + let url = Url::parse(host.trim()).map_err(|_| "素材上传地址无效".to_string())?; + let hostname = url.host_str().unwrap_or_default().to_ascii_lowercase(); + let is_local = url.scheme() == "http" && matches!(hostname.as_str(), "127.0.0.1" | "localhost"); + let is_oss = url.scheme() == "https" && hostname.ends_with(".aliyuncs.com"); + if !is_local && !is_oss { + return Err("素材上传地址不属于平台素材存储,已终止上传".to_string()); + } + Ok(url) +} + +async fn upload_object( + snapshot: &PlatformSessionSnapshot, + ticket: &UploadTicket, + input: &PlatformAssetUploadInput, +) -> Result<(), String> { + let upload_url = validate_upload_host(&ticket.host)?; + validate_session(snapshot)?; + let client = agc_main_site_client_builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(HTTP_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|_| "创建素材上传客户端失败".to_string())?; + let mut form = Form::new(); + for (key, value) in &ticket.form_fields { + form = form.text(key.clone(), value.clone()); + } + let part = Part::bytes(input.bytes.clone()) + .file_name(input.file_name.clone()) + .mime_str(&input.content_type) + .map_err(|_| "素材媒体类型无效".to_string())?; + let response = client + .post(upload_url) + .multipart(form.part("file", part)) + .send() + .await + .map_err(|_| "上传素材失败:无法访问素材存储,请检查网络后重试".to_string())?; + validate_session(snapshot)?; + if response.status().as_u16() != ticket.success_action_status { + return Err(format!( + "上传素材到对象存储失败(HTTP {}),请重试", + response.status().as_u16() + )); + } + Ok(()) +} + +async fn confirm_object( + client: &reqwest::Client, + snapshot: &PlatformSessionSnapshot, + ticket: &UploadTicket, + input: &PlatformAssetUploadInput, +) -> Result { + let current = current_scoped_session(snapshot)?; + let url = endpoint(snapshot, confirm_route())?; + let response = client + .post(url) + .bearer_auth(current.access_token) + .header(AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_VALUE) + .header(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION) + .json(&json!({ + "bucket": ticket.bucket, + "objectKey": ticket.object_key, + "contentType": input.content_type, + "contentLength": input.bytes.len(), + "assetKind": input.asset_kind, + "accessPolicy": "private", + "entityId": input.entity_id, + })) + .send() + .await + .map_err(|_| "确认素材资产失败:无法连接登录服务,请稍后重试".to_string())?; + let status = response.status(); + let body = response + .text() + .await + .map_err(|_| "确认素材资产失败:读取响应失败".to_string())?; + validate_session(snapshot)?; + if !status.is_success() { + return Err(response_error(status, &body, "确认素材资产")); + } + let payload = serde_json::from_str::(&body) + .map(response_data) + .map_err(|_| "确认素材资产失败:响应格式无效".to_string())?; + let asset = payload + .get("assetObject") + .ok_or_else(|| "确认素材资产失败:响应缺少 assetObject".to_string())?; + let asset_object_id = asset + .get("assetObjectId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "确认素材资产失败:响应缺少 assetObjectId".to_string())?; + let object_key = asset + .get("objectKey") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "确认素材资产失败:响应缺少 objectKey".to_string())?; + if object_key != ticket.object_key { + return Err("result-unknown: 确认素材资产返回 objectKey 不一致".to_string()); + } + Ok(PlatformAssetUploadResult { + asset_object_id: asset_object_id.to_string(), + object_key: object_key.to_string(), + }) +} + +#[tauri::command] +pub(crate) async fn upload_platform_media_asset( + input: PlatformAssetUploadInput, +) -> Result { + let input = validate_input(input)?; + let snapshot = require_session()?; + let client = agc_main_site_client_builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(HTTP_TIMEOUT) + .build() + .map_err(|_| "创建素材上传客户端失败".to_string())?; + let ticket = request_ticket(&client, &snapshot, &input).await?; + upload_object(&snapshot, &ticket, &input).await?; + confirm_object(&client, &snapshot, &ticket, &input).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_upload_hosts_without_accepting_arbitrary_origins() { + assert!(validate_upload_host("http://127.0.0.1:9000/upload").is_ok()); + assert!(validate_upload_host("https://assets.oss-cn-shanghai.aliyuncs.com/upload").is_ok()); + assert!(validate_upload_host("https://evil.example.com/upload").is_err()); + } + + #[test] + fn rejects_paths_and_oversized_payloads_before_network() { + let input = PlatformAssetUploadInput { + file_name: "../cover.png".to_string(), + content_type: "image/png".to_string(), + asset_kind: "game_distribution_cover".to_string(), + path_segments: vec!["game-distribution".to_string()], + entity_id: "cover".to_string(), + bytes: vec![1], + metadata: BTreeMap::new(), + }; + assert!(validate_input(input).is_err()); + let input = PlatformAssetUploadInput { + file_name: "cover.png".to_string(), + content_type: "image/png".to_string(), + asset_kind: "game_distribution_cover".to_string(), + path_segments: vec!["game-distribution".to_string()], + entity_id: "cover".to_string(), + bytes: vec![0; MAX_UPLOAD_BYTES + 1], + metadata: BTreeMap::new(), + }; + assert!(validate_input(input).is_err()); + } + + #[test] + fn parses_data_envelope_without_exposing_credentials() { + let payload = response_data(json!({ "data": { "assetObjectId": "asset-1" } })); + assert_eq!(payload["assetObjectId"], "asset-1"); + assert!(!payload.to_string().contains("token")); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs index bdfc59f4c..a64d239ef 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs @@ -19,7 +19,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use tauri::{Manager, State}; +use tauri::{Emitter, Manager, State}; use crate::editor_adapter::{EditorAdapter, EditorConnectionInfo}; @@ -39,6 +39,7 @@ const RPC_TIMEOUT: Duration = Duration::from_secs(10); const EDITOR_RPC_TIMEOUT: Duration = Duration::from_secs(90); const MAX_MANIFEST_BYTES: u64 = 1024 * 1024; const MAX_RPC_BYTES: usize = 2 * 1024 * 1024; +pub(crate) const AGC_PLUGIN_STATE_CHANGED_EVENT: &str = "game-creator-plugin-state-changed"; const KNOWN_PERMISSIONS: &[&str] = &[ "events.subscribe", @@ -1997,6 +1998,12 @@ pub(crate) fn list_agc_extensions( host.list_extensions() } +fn emit_plugin_state_changed(app: &tauri::AppHandle, host: &PluginHost) { + if let Ok(extensions) = host.list_extensions() { + let _ = app.emit(AGC_PLUGIN_STATE_CHANGED_EVENT, extensions); + } +} + #[tauri::command] pub(crate) fn refresh_agc_plugins( host: State<'_, PluginHost>, @@ -2008,24 +2015,39 @@ pub(crate) fn refresh_agc_plugins( pub(crate) fn start_agc_plugin( id: String, host: State<'_, PluginHost>, + app: tauri::AppHandle, ) -> Result { - host.start(id.trim()) + let result = host.start(id.trim()); + if result.is_ok() { + emit_plugin_state_changed(&app, &host); + } + result } #[tauri::command] pub(crate) fn stop_agc_plugin( id: String, host: State<'_, PluginHost>, + app: tauri::AppHandle, ) -> Result { - host.stop(id.trim()) + let result = host.stop(id.trim()); + if result.is_ok() { + emit_plugin_state_changed(&app, &host); + } + result } #[tauri::command] pub(crate) fn reload_agc_plugin( id: String, host: State<'_, PluginHost>, + app: tauri::AppHandle, ) -> Result { - host.reload(id.trim()) + let result = host.reload(id.trim()); + if result.is_ok() { + emit_plugin_state_changed(&app, &host); + } + result } #[tauri::command] @@ -2033,8 +2055,13 @@ pub(crate) fn set_agc_plugin_enabled( id: String, enabled: bool, host: State<'_, PluginHost>, + app: tauri::AppHandle, ) -> Result, String> { - host.set_enabled(id.trim(), enabled) + let result = host.set_enabled(id.trim(), enabled); + if result.is_ok() { + emit_plugin_state_changed(&app, &host); + } + result } #[tauri::command] @@ -2069,7 +2096,12 @@ pub(crate) async fn set_agc_plugin_project_path( app: tauri::AppHandle, ) -> Result<(), String> { tauri::async_runtime::spawn_blocking(move || { - app.state::().set_active_project(project_path) + let host = app.state::(); + let result = host.set_active_project(project_path); + if result.is_ok() { + emit_plugin_state_changed(&app, &host); + } + result }) .await .map_err(|_| "切换插件项目上下文任务失败".to_string())? diff --git a/apps/ai-game-creator-shell/src-tauri/src/workspace_preferences.rs b/apps/ai-game-creator-shell/src-tauri/src/workspace_preferences.rs new file mode 100644 index 000000000..34ba1be97 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/workspace_preferences.rs @@ -0,0 +1,520 @@ +//! AGC 客户端偏好存储。 +//! +//! 这里持有渲染层不该自行持久化的数据:工作区偏好(项目创建目录、最近工作区)以及 +//! 本地 UI 偏好(发送前提醒是否已关闭)。React 只读取投影并通过 typed command 表达 +//! 变更意图,不再直接访问 `localStorage`。 + +use super::*; + +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use tauri::{Emitter, Manager}; + +pub(crate) const WORKSPACE_PREFERENCES_CHANGED_EVENT: &str = + "game-creator-workspace-preferences-changed"; + +const WORKSPACE_PREFERENCES_SCHEMA_VERSION: &str = "agc-workspace-preferences.v1"; +const WORKSPACE_PREFERENCES_FILE_NAME: &str = "workspace-preferences.json"; +const MAX_RECENT_WORKSPACES: usize = 8; + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct WorkspacePreferencesFile { + #[serde(default = "workspace_preferences_schema_version")] + schema_version: String, + #[serde(default)] + project_creation_directory: Option, + #[serde(default)] + recent_workspaces: Vec, + /// 「发送前提醒」是否已被用户关闭。`None` 表示从未选择过,按未关闭处理。 + #[serde(default)] + chat_prompt_polish_reminder_disabled: Option, + /// 本地 debug 的服务器选择;打包产物不读它,始终跟随构建渠道。 + #[serde(default)] + client_server_selection: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ClientServerSelectionPreference { + preset: String, + #[serde(default)] + custom_base_url: String, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct WorkspacePreferencesSnapshot { + pub(crate) project_creation_directory: Option, + pub(crate) recent_workspaces: Vec, + pub(crate) chat_prompt_polish_reminder_disabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) client_server_selection: Option, +} + +fn workspace_preferences_schema_version() -> String { + WORKSPACE_PREFERENCES_SCHEMA_VERSION.to_string() +} + +fn workspace_preferences_path(app: &tauri::AppHandle) -> Result { + app.path() + .app_data_dir() + .map(|root| root.join(WORKSPACE_PREFERENCES_FILE_NAME)) + .map_err(|error| format!("无法读取 AGC 应用数据目录:{error}")) +} + +fn normalize_absolute_path(value: &str) -> Option { + let trimmed = value.trim(); + let path = Path::new(trimmed); + if trimmed.is_empty() || !path.is_absolute() || project_path_has_control_chars(path) { + return None; + } + Some(path.to_string_lossy().into_owned()) +} + +fn normalize_project_creation_directory(value: Option<&str>) -> Option { + value.and_then(normalize_absolute_path) +} + +fn normalize_recent_workspaces(values: impl IntoIterator) -> Vec { + let mut recent = Vec::with_capacity(MAX_RECENT_WORKSPACES); + for value in values { + let Some(path) = normalize_absolute_path(&value) else { + continue; + }; + if recent.iter().any(|existing| existing == &path) { + continue; + } + recent.push(path); + if recent.len() >= MAX_RECENT_WORKSPACES { + break; + } + } + recent +} + +fn normalize_server_selection( + selection: Option, +) -> Option { + let selection = selection?; + let preset = selection.preset.trim().to_ascii_lowercase(); + let custom_base_url = selection.custom_base_url.trim().to_string(); + match preset.as_str() { + "release" | "dev" => Some(ClientServerSelectionPreference { + preset, + custom_base_url: String::new(), + }), + "custom" => crate::auth_session::validate_client_api_base_url(&custom_base_url) + .ok() + .map(|custom_base_url| ClientServerSelectionPreference { + preset, + custom_base_url, + }), + _ => None, + } +} + +fn normalize_file(file: WorkspacePreferencesFile) -> WorkspacePreferencesFile { + WorkspacePreferencesFile { + schema_version: WORKSPACE_PREFERENCES_SCHEMA_VERSION.to_string(), + project_creation_directory: normalize_project_creation_directory( + file.project_creation_directory.as_deref(), + ), + recent_workspaces: normalize_recent_workspaces(file.recent_workspaces), + chat_prompt_polish_reminder_disabled: file.chat_prompt_polish_reminder_disabled, + client_server_selection: normalize_server_selection(file.client_server_selection), + } +} + +fn snapshot_from_file(file: WorkspacePreferencesFile) -> WorkspacePreferencesSnapshot { + let file = normalize_file(file); + WorkspacePreferencesSnapshot { + project_creation_directory: file.project_creation_directory, + recent_workspaces: file.recent_workspaces, + chat_prompt_polish_reminder_disabled: file + .chat_prompt_polish_reminder_disabled + .unwrap_or(false), + client_server_selection: file.client_server_selection, + } +} + +fn file_from_snapshot(snapshot: WorkspacePreferencesSnapshot) -> WorkspacePreferencesFile { + WorkspacePreferencesFile { + schema_version: WORKSPACE_PREFERENCES_SCHEMA_VERSION.to_string(), + project_creation_directory: normalize_project_creation_directory( + snapshot.project_creation_directory.as_deref(), + ), + recent_workspaces: normalize_recent_workspaces(snapshot.recent_workspaces), + chat_prompt_polish_reminder_disabled: Some(snapshot.chat_prompt_polish_reminder_disabled), + client_server_selection: normalize_server_selection(snapshot.client_server_selection), + } +} + +fn read_preferences_at(path: &Path) -> WorkspacePreferencesSnapshot { + let Ok(metadata) = fs::symlink_metadata(path) else { + return WorkspacePreferencesSnapshot::default(); + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return WorkspacePreferencesSnapshot::default(); + } + let Ok(content) = fs::read_to_string(path) else { + return WorkspacePreferencesSnapshot::default(); + }; + let Ok(file) = serde_json::from_str::(&content) else { + return WorkspacePreferencesSnapshot::default(); + }; + snapshot_from_file(file) +} + +fn write_preferences_at( + path: &Path, + snapshot: WorkspacePreferencesSnapshot, +) -> Result { + let file = file_from_snapshot(snapshot); + let content = serde_json::to_string_pretty(&file) + .map_err(|error| format!("序列化工作区偏好失败:{error}"))?; + let parent = path + .parent() + .ok_or_else(|| "工作区偏好缺少父目录".to_string())?; + fs::create_dir_all(parent) + .map_err(|error| format!("创建工作区偏好目录失败:{}: {error}", parent.display()))?; + + if let Ok(metadata) = fs::symlink_metadata(path) { + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("工作区偏好文件必须是普通文件".to_string()); + } + } + + let temp_path = path.with_file_name(format!( + ".{}.tmp.{}.{}", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or(WORKSPACE_PREFERENCES_FILE_NAME), + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + let mut file_handle = OpenOptions::new() + .create_new(true) + .write(true) + .open(&temp_path) + .map_err(|error| format!("创建工作区偏好临时文件失败:{error}"))?; + let write_result = file_handle + .write_all(format!("{content}\n").as_bytes()) + .and_then(|_| file_handle.sync_all()); + drop(file_handle); + if let Err(error) = write_result { + let _ = fs::remove_file(&temp_path); + return Err(format!("写入工作区偏好失败:{error}")); + } + + #[cfg(windows)] + if path.exists() { + fs::remove_file(path).map_err(|error| { + let _ = fs::remove_file(&temp_path); + format!("替换工作区偏好失败:{error}") + })?; + } + if let Err(error) = fs::rename(&temp_path, path) { + let _ = fs::remove_file(&temp_path); + return Err(format!("提交工作区偏好失败:{error}")); + } + Ok(snapshot_from_file(file)) +} + +fn with_preferences_lock(f: impl FnOnce() -> Result) -> Result { + static LOCK: OnceLock> = OnceLock::new(); + let _guard = LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .map_err(|_| "工作区偏好锁不可用".to_string())?; + f() +} + +fn read_preferences_for_app( + app: &tauri::AppHandle, +) -> Result { + let path = workspace_preferences_path(app)?; + with_preferences_lock(|| Ok(read_preferences_at(&path))) +} + +fn update_preferences_for_app( + app: &tauri::AppHandle, + update: impl FnOnce(WorkspacePreferencesSnapshot) -> WorkspacePreferencesSnapshot, +) -> Result { + let path = workspace_preferences_path(app)?; + let snapshot = with_preferences_lock(|| { + let current = read_preferences_at(&path); + let next = update(current.clone()); + if next == current { + return Ok(current); + } + write_preferences_at(&path, next) + })?; + let _ = app.emit(WORKSPACE_PREFERENCES_CHANGED_EVENT, snapshot.clone()); + Ok(snapshot) +} + +pub(crate) fn stored_project_creation_directory(app: &tauri::AppHandle) -> Option { + read_preferences_for_app(app) + .ok() + .and_then(|snapshot| snapshot.project_creation_directory) +} + +#[tauri::command] +pub(crate) fn read_workspace_preferences( + app: tauri::AppHandle, +) -> Result { + read_preferences_for_app(&app) +} + +#[tauri::command] +pub(crate) fn set_project_creation_directory( + app: tauri::AppHandle, + directory: Option, +) -> Result { + let directory = match directory + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + None => None, + Some(value) => Some( + normalize_absolute_path(value) + .ok_or_else(|| "项目创建目录必须是绝对路径且不能包含控制字符".to_string())?, + ), + }; + update_preferences_for_app(&app, |mut snapshot| { + snapshot.project_creation_directory = directory; + snapshot + }) +} + +/// 写入本地 debug 的服务器选择。打包产物不提供该入口,因此这里也不做渠道放行。 +#[tauri::command] +pub(crate) fn set_client_server_selection( + app: tauri::AppHandle, + preset: String, + custom_base_url: Option, +) -> Result { + let selection = normalize_server_selection(Some(ClientServerSelectionPreference { + preset, + custom_base_url: custom_base_url.unwrap_or_default(), + })) + .ok_or_else(|| "服务器选择无效".to_string())?; + update_preferences_for_app(&app, |mut snapshot| { + snapshot.client_server_selection = Some(selection); + snapshot + }) +} + +/// 写入「发送前提醒」的本地偏好。返回最新偏好投影,事件由更新入口统一发送。 +#[tauri::command] +pub(crate) fn set_chat_prompt_polish_reminder_disabled( + app: tauri::AppHandle, + disabled: bool, +) -> Result { + update_preferences_for_app(&app, |mut snapshot| { + snapshot.chat_prompt_polish_reminder_disabled = disabled; + snapshot + }) +} + +#[tauri::command] +pub(crate) fn remember_recent_workspace( + app: tauri::AppHandle, + project_path: String, +) -> Result { + let Some(project_path) = normalize_absolute_path(&project_path) else { + return read_preferences_for_app(&app); + }; + update_preferences_for_app(&app, |snapshot| WorkspacePreferencesSnapshot { + project_creation_directory: snapshot.project_creation_directory, + recent_workspaces: normalize_recent_workspaces( + std::iter::once(project_path).chain(snapshot.recent_workspaces), + ), + chat_prompt_polish_reminder_disabled: snapshot.chat_prompt_polish_reminder_disabled, + client_server_selection: snapshot.client_server_selection, + }) +} + +#[tauri::command] +pub(crate) fn remove_recent_workspace( + app: tauri::AppHandle, + project_path: String, +) -> Result { + let project_path = normalize_absolute_path(&project_path); + update_preferences_for_app(&app, |snapshot| WorkspacePreferencesSnapshot { + project_creation_directory: snapshot.project_creation_directory, + recent_workspaces: snapshot + .recent_workspaces + .into_iter() + .filter(|path| project_path.as_deref() != Some(path.as_str())) + .collect(), + chat_prompt_polish_reminder_disabled: snapshot.chat_prompt_polish_reminder_disabled, + client_server_selection: snapshot.client_server_selection, + }) +} + +#[tauri::command] +pub(crate) fn migrate_workspace_preferences( + app: tauri::AppHandle, + project_creation_directory: Option, + recent_workspaces: Vec, +) -> Result { + update_preferences_for_app(&app, |current| { + if current.project_creation_directory.is_some() || !current.recent_workspaces.is_empty() { + return current; + } + WorkspacePreferencesSnapshot { + project_creation_directory: normalize_project_creation_directory( + project_creation_directory.as_deref(), + ), + recent_workspaces: normalize_recent_workspaces(recent_workspaces), + chat_prompt_polish_reminder_disabled: current.chat_prompt_polish_reminder_disabled, + client_server_selection: current.client_server_selection, + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const DEVELOPMENT_ORIGIN_FOR_TEST: &str = "https://dev.genarrative.world"; + + #[test] + fn normalizes_recent_paths_with_a_strict_eight_item_limit() { + let root = tempfile::tempdir().expect("recent workspace fixture"); + let paths = (0..10) + .map(|index| { + root.path() + .join(format!("project-{index}")) + .to_string_lossy() + .into_owned() + }) + .chain(std::iter::once("relative/project".to_string())) + .collect::>(); + let normalized = normalize_recent_workspaces(paths); + assert_eq!(normalized.len(), 8); + assert!(normalized[0].ends_with("project-0")); + assert!(!normalized.iter().any(|path| path == "relative/project")); + } + + #[test] + fn malformed_preference_file_falls_back_to_empty_snapshot() { + let directory = tempfile::tempdir().expect("preference fixture"); + let path = directory.path().join(WORKSPACE_PREFERENCES_FILE_NAME); + fs::write(&path, b"{not json").expect("malformed preference fixture"); + assert_eq!( + read_preferences_at(&path), + WorkspacePreferencesSnapshot::default() + ); + } + + #[test] + fn chat_prompt_polish_reminder_round_trips_and_defaults_to_enabled() { + let directory = tempfile::tempdir().expect("preference fixture"); + let path = directory.path().join(WORKSPACE_PREFERENCES_FILE_NAME); + assert!( + !WorkspacePreferencesSnapshot::default().chat_prompt_polish_reminder_disabled, + "缺失值必须按「仍然提醒」处理" + ); + let snapshot = WorkspacePreferencesSnapshot { + project_creation_directory: None, + recent_workspaces: Vec::new(), + chat_prompt_polish_reminder_disabled: true, + client_server_selection: Some(ClientServerSelectionPreference { + preset: "custom".to_string(), + custom_base_url: DEVELOPMENT_ORIGIN_FOR_TEST.to_string(), + }), + }; + write_preferences_at(&path, snapshot.clone()).expect("write snapshot"); + assert_eq!(read_preferences_at(&path), snapshot); + fs::write( + &path, + br#"{"schemaVersion":"agc-workspace-preferences.v1"}"#, + ) + .expect("legacy file without the new field"); + assert!(!read_preferences_at(&path).chat_prompt_polish_reminder_disabled); + } + + #[test] + fn client_server_selection_is_normalized_and_validated() { + let directory = tempfile::tempdir().expect("preference fixture"); + let path = directory.path().join(WORKSPACE_PREFERENCES_FILE_NAME); + let snapshot = WorkspacePreferencesSnapshot { + project_creation_directory: None, + recent_workspaces: Vec::new(), + chat_prompt_polish_reminder_disabled: false, + client_server_selection: normalize_server_selection(Some( + ClientServerSelectionPreference { + preset: " custom ".to_string(), + custom_base_url: " http://127.0.0.1:10001/ ".to_string(), + }, + )), + }; + assert_eq!( + snapshot.client_server_selection.as_ref().map(|selection| ( + selection.preset.as_str(), + selection.custom_base_url.as_str() + )), + Some(("custom", "http://127.0.0.1:10001")) + ); + write_preferences_at(&path, snapshot.clone()).expect("write snapshot"); + assert_eq!(read_preferences_at(&path), snapshot); + + // 非法预设与非法地址都不落盘。 + assert!( + normalize_server_selection(Some(ClientServerSelectionPreference { + preset: "staging".to_string(), + custom_base_url: String::new(), + })) + .is_none() + ); + assert!( + normalize_server_selection(Some(ClientServerSelectionPreference { + preset: "custom".to_string(), + custom_base_url: "http://example.com".to_string(), + })) + .is_none() + ); + assert!( + normalize_server_selection(Some(ClientServerSelectionPreference { + preset: "release".to_string(), + custom_base_url: "https://evil.example".to_string(), + })) + .is_some_and(|selection| selection.custom_base_url.is_empty()) + ); + } + + #[test] + fn writes_and_reads_a_versioned_snapshot_atomically() { + let directory = tempfile::tempdir().expect("preference fixture"); + let path = directory.path().join(WORKSPACE_PREFERENCES_FILE_NAME); + let snapshot = WorkspacePreferencesSnapshot { + project_creation_directory: Some( + directory + .path() + .join("games") + .to_string_lossy() + .into_owned(), + ), + recent_workspaces: vec![ + directory.path().join("one").to_string_lossy().into_owned(), + directory.path().join("two").to_string_lossy().into_owned(), + ], + chat_prompt_polish_reminder_disabled: true, + client_server_selection: None, + }; + write_preferences_at(&path, snapshot.clone()).expect("write snapshot"); + assert_eq!(read_preferences_at(&path), snapshot); + let content = fs::read_to_string(path).expect("read snapshot"); + assert!(content.contains(WORKSPACE_PREFERENCES_SCHEMA_VERSION)); + } +} diff --git a/package-lock.json b/package-lock.json index 77bd6d2e4..3f2c813ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -108,7 +108,6 @@ "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-clipboard-manager": "2.3.2", "@tauri-apps/plugin-dialog": "^2.7.2", - "@tauri-apps/plugin-http": "^2.5.9", "@tauri-apps/plugin-opener": "~2", "@tauri-apps/plugin-updater": "2.11.0", "@vitejs/plugin-react": "^5.0.4", @@ -8094,15 +8093,6 @@ "@tauri-apps/api": "^2.11.0" } }, - "node_modules/@tauri-apps/plugin-http": { - "version": "2.5.9", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.9.tgz", - "integrity": "sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.11.0" - } - }, "node_modules/@tauri-apps/plugin-opener": { "version": "2.5.4", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz", @@ -26547,7 +26537,6 @@ "@tauri-apps/cli": "^2.11.2", "@tauri-apps/plugin-clipboard-manager": "2.3.2", "@tauri-apps/plugin-dialog": "^2.7.2", - "@tauri-apps/plugin-http": "^2.5.9", "@tauri-apps/plugin-opener": "~2", "@tauri-apps/plugin-updater": "2.11.0", "@testing-library/react": "^16.3.2", @@ -28304,14 +28293,6 @@ "@tauri-apps/api": "^2.11.0" } }, - "@tauri-apps/plugin-http": { - "version": "2.5.9", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.9.tgz", - "integrity": "sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA==", - "requires": { - "@tauri-apps/api": "^2.11.0" - } - }, "@tauri-apps/plugin-opener": { "version": "2.5.4", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",