AGC 原生侧新增平台 facade,退役渲染层 HTTP 能力

- 新增 account_api、auth_session、platform_asset_upload、game_distribution_publish、llm_catalog、workspace_preferences 与 error_report::submit:凭据、origin、HTTP、队列等待、幂等键与导入队列全部由 Rust 持有。
- 删除 capabilities/main.json 的 http:default 授权与主窗口的 tauri-plugin-http 注册,Cargo.toml / Cargo.lock / AGC package.json 与根 package-lock.json 同步移除该依赖。
- 修复 workspace_preferences 重复 #[test] 造成的分片门禁「同一用例被分到两片」误判,并同步该模块的会话存储与偏好读写用例。
This commit is contained in:
kdletters
2026-09-28 14:15:16 +08:00
parent 8701f43f75
commit d6c939fa31
25 changed files with 4716 additions and 195 deletions
-1
View File
@@ -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",
-126
View File
@@ -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"
@@ -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"
@@ -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",
@@ -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<PlatformSessionSnapshot, String> {
current_platform_session()
.ok_or_else(|| "authentication-required: 请先登录后再继续".to_string())
}
fn build_client() -> Result<reqwest::Client, String> {
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<String, String> {
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<String, String> {
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<String> {
let value = serde_json::from_str::<Value>(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<Value, String> {
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<T: DeserializeOwned>(
client: &reqwest::Client,
snapshot: &PlatformSessionSnapshot,
method: Method,
segments: &[&str],
body: Option<Value>,
fallback: &str,
) -> Result<T, String> {
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<ProfileRechargeCenterResponse, String>
{
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<ProfileWalletLedgerResponse, String> {
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<CreateProfileRechargeOrderResponse, String> {
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<ConfirmWechatProfileRechargeOrderResponse, String> {
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<RedeemProfileRewardCodeResponse, String> {
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());
}
}
@@ -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::<DirectCodexUserItem>(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::<DirectCodexUserItem>(json!({
@@ -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(
@@ -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<crate::auth_session::ClientSessionKeepalive>,
}
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<String, String> {
@@ -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");
@@ -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);
@@ -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,
@@ -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;
@@ -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<tauri::AppHandle> = 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, &registered) {
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)
}
File diff suppressed because it is too large Load Diff
@@ -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<PathBuf, String> {
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<GameCreatorAppConfigView, String> {
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<u64>,
}
#[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<String>,
pub(crate) width: Option<u32>,
pub(crate) height: Option<u32>,
pub(crate) size_bytes: Option<u64>,
pub(crate) preview_available: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct EditorAssetLibrarySnapshot {
pub(crate) folders: Vec<EditorAssetLibraryFolderView>,
pub(crate) assets: Vec<EditorAssetLibraryAssetView>,
}
#[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<String, String> {
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::<String, String>::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<String, String> {
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<String, String> {
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<EditorAssetLibrarySnapshot, String> {
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<EditorAssetPreview, String> {
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<serde_json::Value, String> {
@@ -4714,6 +4995,28 @@ async fn download_ui_editor_remote_asset(url: &str, max_bytes: u64) -> Result<Ve
if max_bytes == 0 {
return Err("图片批次总量已达到服务端限制".to_string());
}
if let Some(data_url) = url.strip_prefix("data:") {
let (header, encoded) = data_url
.split_once(',')
.ok_or_else(|| "平台素材 data URL 格式无效".to_string())?;
let media_type = header
.strip_suffix(";base64")
.filter(|value| {
matches!(
value.to_ascii_lowercase().as_str(),
"image/png" | "image/jpeg" | "image/webp"
)
})
.ok_or_else(|| "平台素材 data URL 类型不受支持".to_string())?;
let bytes = base64::engine::general_purpose::STANDARD
.decode(encoded)
.map_err(|_| "平台素材 data URL 内容无效".to_string())?;
if bytes.is_empty() || bytes.len() as u64 > 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(
@@ -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;
@@ -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("<url>"));
assert!(!sanitized.contains("/home/alice"));
assert!(!sanitized.contains("deadbeef12"));
}
@@ -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<PlatformSessionSnapshot, String> {
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<String, String> {
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::<Vec<_>>();
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::<Value>(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<Value, String> {
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<Event>,
logs: Vec<ErrorReportLogInput>,
user_description: Option<String>,
) -> Result<Value, String> {
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::<Vec<_>>();
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"));
}
}
File diff suppressed because it is too large Load Diff
@@ -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<String, String> {
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?;
@@ -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<WireModelSummary>,
revision: u64,
}
#[derive(Debug, Deserialize)]
struct ApiErrorPayload {
code: Option<String>,
message: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ApiEnvelope {
ok: bool,
data: Option<WireModelCatalog>,
error: Option<ApiErrorPayload>,
}
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<LlmModelsResponse, String> {
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::<WireModelCatalog>(value)
.map(map_catalog)
.map_err(|_| "模型列表响应格式无效,请稍后重试".to_string())
}
async fn read_bounded_body(
response: reqwest::Response,
max_bytes: usize,
) -> Result<Vec<u8>, 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<LlmModelsResponse, String> {
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(&current_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<LlmModelsResponse, String> {
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<String> {
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: 请先登录陶泥儿账号"
);
}
}
@@ -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,
@@ -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<String>,
pub(crate) entity_id: String,
pub(crate) bytes: Vec<u8>,
#[serde(default)]
pub(crate) metadata: BTreeMap<String, String>,
}
#[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<String, String>,
}
fn require_session() -> Result<PlatformSessionSnapshot, String> {
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<PlatformSessionSnapshot, String> {
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<String, String> {
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<PlatformAssetUploadInput, String> {
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::<Result<Vec<_>, _>>()?;
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::<Result<BTreeMap<_, String>, String>>()?;
Ok(PlatformAssetUploadInput {
file_name,
content_type,
asset_kind,
path_segments,
entity_id,
bytes: input.bytes,
metadata,
})
}
fn endpoint(snapshot: &PlatformSessionSnapshot, route: &str) -> Result<String, String> {
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::<Value>(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<UploadTicket, String> {
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::<Value>(&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::<Result<BTreeMap<_, _>, _>>()?;
Ok(UploadTicket {
host,
bucket,
object_key,
success_action_status,
form_fields,
})
}
fn validate_upload_host(host: &str) -> Result<Url, String> {
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<PlatformAssetUploadResult, String> {
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::<Value>(&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<PlatformAssetUploadResult, String> {
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"));
}
}
@@ -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<PluginSummary, String> {
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<PluginSummary, String> {
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<PluginSummary, String> {
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<Vec<PluginSummary>, 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::<PluginHost>().set_active_project(project_path)
let host = app.state::<PluginHost>();
let result = host.set_active_project(project_path);
if result.is_ok() {
emit_plugin_state_changed(&app, &host);
}
result
})
.await
.map_err(|_| "切换插件项目上下文任务失败".to_string())?
File diff suppressed because it is too large Load Diff
-19
View File
@@ -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",