From b0875f4cfd415546d98e21df0b3c9e119e4849b4 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 27 Sep 2026 19:00:21 +0800 Subject: [PATCH] fix(live): settle before acting, mark publishing before the first rename and keep live-owned fields out of new rows - A multi-file operation records that it has started publishing in live-state.json before the first file is replaced; if that cannot be recorded nothing is published. Recovery rolls such an operation forward even when a published file was changed again afterwards, so an unpublished Codex login stash is no longer discarded. - controller::lock_settled fails when the previous operation cannot be finished, and entry points refuse to save or switch instead of writing rows the leftover operation cannot restore. Startup, detach and exit log the failure per app and continue. - Saving, adding and syncing the current provider take the proxy switch lock before reading mode and pointer, so entering proxy mode can no longer be overwritten by direct key fields. A new proxy address is written to every attached app even if one of them fails. - Codex: the row-level model catalog pointer leaves with its provider; a profile selecting the built-in openai provider counts as the official route; exclusive fields the editor shows only because live has them are compared by value, so an untouched field changed by the client stays with the client. The add dialog sends the preset draft it projected, and the backend uses it as the field origin like an existing row. - Claude editor keeps a legacy Bedrock apiKey at the top level where older versions read it; the Gemini editor recognizes a legacy Google card the same way switching does. - The request path passes the provider it already resolved to the router instead of reading live-state.json twice per request. --- src-tauri/src/commands/provider.rs | 8 + src-tauri/src/commands/proxy.rs | 4 +- src-tauri/src/live/project/claude.rs | 65 ++ src-tauri/src/live/project/codex.rs | 20 +- src-tauri/src/mode/controller.rs | 564 ++++++++++++++++-- src-tauri/src/mode/operation.rs | 75 ++- src-tauri/src/mode/state.rs | 10 +- src-tauri/src/proxy/handler_context.rs | 7 +- src-tauri/src/proxy/provider_router.rs | 17 +- .../src/services/provider/claude_direct.rs | 8 - .../src/services/provider/claude_editor.rs | 4 + .../src/services/provider/codex_direct.rs | 25 +- .../src/services/provider/codex_editor.rs | 110 +++- .../src/services/provider/gemini_direct.rs | 8 - .../src/services/provider/grok_direct.rs | 8 - src-tauri/src/services/provider/live.rs | 12 +- src-tauri/src/services/provider/mod.rs | 99 +-- src-tauri/tests/provider_service.rs | 87 +++ .../providers/AddProviderDialog.tsx | 26 +- .../providers/EditProviderDialog.tsx | 1 + .../providers/forms/ProviderForm.tsx | 3 +- src/components/providers/forms/hooks/index.ts | 5 +- .../forms/hooks/useDraftEditorProjection.ts | 13 +- src/lib/api/providers.ts | 6 +- tests/components/AddProviderDialog.test.tsx | 13 +- tests/components/EditProviderDialog.test.tsx | 3 + tests/hooks/useDraftEditorProjection.test.tsx | 3 +- 27 files changed, 1014 insertions(+), 190 deletions(-) diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index 51d56b22c..dc1b83142 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -91,12 +91,20 @@ pub async fn get_provider_editor_view( app: String, #[allow(non_snake_case)] settingsConfig: serde_json::Value, category: Option, + #[allow(non_snake_case)] providerId: Option, ) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; tauri::async_runtime::spawn_blocking(move || { let state = app_handle .try_state::() .ok_or_else(|| "应用状态不可用".to_string())?; + let category = ProviderService::editor_category( + state.inner(), + &app_type, + providerId.as_deref(), + category, + ) + .map_err(|e| e.to_string())?; ProviderService::editor_view( state.inner(), app_type, diff --git a/src-tauri/src/commands/proxy.rs b/src-tauri/src/commands/proxy.rs index f0d687baa..631d73397 100644 --- a/src-tauri/src/commands/proxy.rs +++ b/src-tauri/src/commands/proxy.rs @@ -103,9 +103,7 @@ pub async fn update_proxy_config( ) -> Result<(), String> { if state.proxy_service.update_config(&config).await? { // 代理换了地址:按新地址重写接上代理的客户端。 - for app in crate::mode::controller::PROXY_APPS { - crate::mode::controller::resync_route(state.inner(), &app).await?; - } + crate::mode::controller::resync_routes(state.inner()).await?; } Ok(()) } diff --git a/src-tauri/src/live/project/claude.rs b/src-tauri/src/live/project/claude.rs index 8419845d7..b8653933f 100644 --- a/src-tauri/src/live/project/claude.rs +++ b/src-tauri/src/live/project/claude.rs @@ -128,7 +128,13 @@ pub fn project_onto( /// 把关键字段和独有字段存回供应商行:行里这两类键换成 `projection` 的,其余内容原样 /// 保留(降级后旧版会整份使用这些行)。 +/// +/// 存量 Bedrock API Key 行(顶层 `apiKey`,`env` 里没有 `AWS_BEARER_TOKEN_BEDROCK`)的 Key +/// 仍存回顶层:投影把它挪进了 `env`,编辑器显示的也是 `env` 里的,但旧版的代理只从顶层 +/// 读,存进 `env` 的话降级后代理模式就找不到 Key。 pub fn store_into_row(row: &Value, projection: &ClaudeProjection) -> Value { + let legacy = legacy_bedrock_shape(row, projection); + let projection = legacy.as_ref().unwrap_or(projection); let mut row = if row.is_object() { row.clone() } else { @@ -168,6 +174,27 @@ pub fn store_into_row(row: &Value, projection: &ClaudeProjection) -> Value { row } +/// 行是存量 Bedrock API Key 的写法、存回的内容还是 Bedrock 带 Key 时,把 Key 放回顶层 +/// `apiKey`(投影的反向转换)。 +fn legacy_bedrock_shape(row: &Value, projection: &ClaudeProjection) -> Option { + let legacy_row = row.get("apiKey").is_some() + && row + .get("env") + .and_then(|env| env.get(BEDROCK_BEARER_ENV)) + .is_none(); + let bedrock = projection + .env + .get("CLAUDE_CODE_USE_BEDROCK") + .is_some_and(is_truthy); + if !legacy_row || !bedrock || projection.top.contains_key("apiKey") { + return None; + } + let mut projection = projection.clone(); + let key = projection.env.shift_remove(BEDROCK_BEARER_ENV)?; + projection.top.insert("apiKey".to_string(), key); + Some(projection) +} + /// 代理模式下写进客户端的凭据占位符。旧版只认这个字面值来识别接管态,不能改。 pub const PROXY_TOKEN_PLACEHOLDER: &str = "PROXY_MANAGED"; @@ -555,4 +582,42 @@ mod tests { json!({ "ANTHROPIC_BASE_URL": "https://new.example", "ENABLE_TOOL_SEARCH": "true" }) ); } + + #[test] + fn a_legacy_bedrock_row_keeps_its_key_at_the_top_level() { + let row = json!({ + "apiKey": "old-key", + "env": { "CLAUDE_CODE_USE_BEDROCK": "1", "AWS_REGION": "us-east-1" } + }); + let projected = ClaudeProjection::of(&row); + assert_eq!(projected.env[BEDROCK_BEARER_ENV], "old-key"); + assert_eq!(store_into_row(&row, &projected), row, "round trip"); + + // 编辑器里改了 Key:新值还存回顶层。 + let mut edited = projected.clone(); + edited + .env + .insert(BEDROCK_BEARER_ENV.to_string(), json!("new-key")); + let stored = store_into_row(&row, &edited); + assert_eq!(stored["apiKey"], "new-key"); + assert!(stored["env"].get(BEDROCK_BEARER_ENV).is_none()); + + // 删了 Key、不再用 Bedrock、或者行本来就是 env 写法:按投影存。 + let mut removed = projected.clone(); + removed.env.shift_remove(BEDROCK_BEARER_ENV); + assert!(store_into_row(&row, &removed).get("apiKey").is_none()); + let mut not_bedrock = projected.clone(); + not_bedrock.env.shift_remove("CLAUDE_CODE_USE_BEDROCK"); + let stored = store_into_row(&row, ¬_bedrock); + assert!(stored.get("apiKey").is_none()); + assert_eq!(stored["env"][BEDROCK_BEARER_ENV], "old-key"); + let env_row = json!({ "env": { + "CLAUDE_CODE_USE_BEDROCK": "1", + BEDROCK_BEARER_ENV: "env-key" + }}); + assert_eq!( + store_into_row(&env_row, &ClaudeProjection::of(&env_row)), + env_row + ); + } } diff --git a/src-tauri/src/live/project/codex.rs b/src-tauri/src/live/project/codex.rs index 897e1f387..880a1ebfb 100644 --- a/src-tauri/src/live/project/codex.rs +++ b/src-tauri/src/live/project/codex.rs @@ -40,6 +40,8 @@ const BUILT_IN_IDS: &[&str] = &[ ]; /// 这几个 id 的表会让 Codex 0.148 起整份拒绝加载(bedrock 两个允许覆盖)。 const RESERVED_TABLE_IDS: &[&str] = &["openai", "ollama", "lmstudio"]; +/// 不写 `model_provider` 时 Codex 用的内置 provider。 +const DEFAULT_PROVIDER_ID: &str = "openai"; const BEDROCK_IDS: &[&str] = &["amazon-bedrock", "amazon-bedrock-runtime"]; /// 旧版把顶层 `openai_base_url` 归一成的表 id(`cc-switch`、`cc-switch-2`…)。 const LEGACY_REROUTE_ID: &str = "cc-switch"; @@ -504,7 +506,7 @@ pub struct CodexConfigPatch { pub nested: Vec<(Vec, TomlValue)>, /// 独有字段的目标值(含 `web_search`)。 pub exclusive: Vec<(String, TomlValue)>, - /// 上一家带进来的独有字段:live 里的值还相同才删。 + /// 上一家带进来的独有字段和它行里指定的模型目录指针:live 里的值还相同才删。 pub outgoing: Vec<(String, TomlValue)>, pub route: RouteWrite, /// 指向 CC Switch 生成的模型目录(用户自己的指针不认领、不删除)。 @@ -552,6 +554,13 @@ fn is_cc_switch_catalog(value: &str) -> bool { Path::new(value).file_name().and_then(|name| name.to_str()) == Some(CATALOG_FILENAME) } +/// 行里自己指定的模型目录指针(投影的 `top` 只收不是 CC Switch 的指针)。它和独有字段 +/// 一样跟着这一家走:切走时 live 里的值还相同就删(见 [`CodexConfigPatch::outgoing`]), +/// 否则第 1 步会把它当成用户的指针留下,之后每一家都用它的模型目录。 +pub fn row_catalog_pointer(top: &[(String, TomlValue)]) -> Option<&(String, TomlValue)> { + top.iter().find(|(key, _)| key == MODEL_CATALOG_JSON) +} + impl CodexConfigPatch { pub fn apply_to(&self, path: &Path, doc: &mut DocumentMut) -> Result<(), LiveWriteError> { let target_top: Vec<&str> = self @@ -563,7 +572,8 @@ impl CodexConfigPatch { let selector = self.route.selector(); let root = doc.as_table_mut(); - // 1. 清空顶层关键字段。目标里也有的留给后面原位改值;模型目录指针只认自己的。 + // 1. 清空顶层关键字段。目标里也有的留给后面原位改值;模型目录指针只认自己的, + // 上一家行里指定的指针在第 3 步按值删。 let doomed: Vec = root .iter() .map(|(key, _)| key.to_string()) @@ -607,7 +617,7 @@ impl CodexConfigPatch { } } - // 3. 上一家带进来的独有字段:值还相同才删。 + // 3. 上一家带进来的独有字段和模型目录指针:值还相同才删。 for (key, value) in &self.outgoing { if target_top.contains(&key.as_str()) { continue; @@ -885,7 +895,9 @@ fn check_effective_route(doc: &DocumentMut, selector: Option<&str>) -> Result<() .into_iter() .find(|key| match (*key, non_empty_str(profile.get(key))) { (_, None) => false, - ("model_provider", Some(id)) => Some(id.as_str()) != selector, + // 顶层不写 model_provider 时 Codex 用内置的 `openai`:profile 显式选它,请求去的 + // 还是官方卡要的地方。 + ("model_provider", Some(id)) => id != selector.unwrap_or(DEFAULT_PROVIDER_ID), _ => true, }); match overridden { diff --git a/src-tauri/src/mode/controller.rs b/src-tauri/src/mode/controller.rs index 5f7c1ef7c..4426e42e7 100644 --- a/src-tauri/src/mode/controller.rs +++ b/src-tauri/src/mode/controller.rs @@ -297,23 +297,7 @@ fn write_direct( match app { AppType::Claude => { let empty = ClaudeProjection::default(); - // 行里本身带着占位符(旧版接管期间被导入的残留)时不能照写,否则客户端会 - // 一直指着已经不在的本地代理:只清空关键字段。 - let projection = direct - .as_ref() - .filter(|provider| { - let polluted = crate::services::ProxyService::config_has_proxy_placeholder( - app, - &provider.settings_config, - ); - if polluted { - log::warn!( - "直连供应商 {} 的行里带着代理占位符,只清空关键字段", - provider.id - ); - } - !polluted - }) + let projection = usable_direct(app, direct.as_ref()) .map(|provider| ClaudeProjection::of(&provider.settings_config)); let patch = direct_patch( live_now.claude_exclusive_owner().as_ref(), @@ -332,13 +316,7 @@ fn write_direct( commit_state(state, app, &pending_target)?; return Ok(()); } - // 行里本身带着占位符(旧版接管期间被导入的残留)时不能照写:只清空关键字段。 - let target = direct.as_ref().filter(|provider| { - !crate::services::ProxyService::config_has_proxy_placeholder( - app, - &provider.settings_config, - ) - }); + let target = usable_direct(app, direct.as_ref()); let owner = live_now.codex_owner(); if let Err(error) = codex_direct::write_direct( &state.db, @@ -442,20 +420,48 @@ fn require_proxy_app(app: &AppType) -> Result<(), String> { /// 拿这个应用的代理切换锁,再补完它上一次没做完的写入。之后读到的模式、路由和直连 /// 指针都是落定过的。写入函数在写锁里发现还有没补完的操作会补完后拒绝这次写入(见 /// `operation::recover_before_write`),入口先补完,用户就不用重试一次。 -pub(crate) async fn lock_settled(state: &AppState, app: &AppType) -> OwnedMutexGuard<()> { +/// +/// 补不完(比如本机设置文件写不进去、改不了指针)就拒绝这次操作:这时读到的还是补完前的 +/// 指针和模式,照着做下去(比如只存了一行、以为它不是当前供应商),等那次操作补完就和刚 +/// 做的对不上了。 +pub(crate) async fn lock_settled( + state: &AppState, + app: &AppType, +) -> Result, AppError> { let guard = state.proxy_service.lock_switch_for_app(app.as_str()).await; - if let Err(error) = operation::settle(&state.db, app.as_str()) { - log::warn!("补完 {} 上一次没做完的写入失败: {error}", app.as_str()); + operation::settle(&state.db, app.as_str()).map_err(|error| { + AppError::localized( + "mode.unsettled", + format!( + "{} 上一次写配置文件的操作没做完,现在也补不完:{error}。本次什么都没做,请排查后重试", + app.as_str() + ), + format!( + "The previous write to {}'s config files is unfinished and cannot be completed now: {error}. Nothing was done; fix the cause and retry", + app.as_str() + ), + ) + })?; + Ok(guard) +} + +/// 同步代码里用的 [`lock_settled`]。不支持代理的应用没有模式可以被并发改掉,不拿锁。 +pub(crate) fn lock_settled_blocking( + state: &AppState, + app: &AppType, +) -> Result>, AppError> { + if !app.supports_local_proxy() { + return Ok(None); } - guard + futures::executor::block_on(lock_settled(state, app)).map(Some) } /// 进入代理模式。 pub async fn enter(state: &AppState, app: &AppType) -> Result<(), String> { require_proxy_app(app)?; - let result = { - let _guard = lock_settled(state, app).await; - enter_locked(state, app, op::ENTER).await + let result = match lock_settled(state, app).await { + Ok(_guard) => enter_locked(state, app, op::ENTER).await, + Err(error) => Err(error.to_string()), }; if result.is_err() { stop_server_if_unused(state).await; @@ -516,7 +522,7 @@ async fn warn_if_official_route(state: &AppState, app: &AppType, route: &Provide pub async fn exit(state: &AppState, app: &AppType) -> Result<(), String> { require_proxy_app(app)?; { - let _guard = lock_settled(state, app).await; + let _guard = lock_settled(state, app).await.map_err(|e| e.to_string())?; exit_locked(state, app, false)?; } if let Err(error) = state.db.clear_provider_health_for_app(app.as_str()).await { @@ -570,8 +576,11 @@ async fn stop_server_if_unused(state: &AppState) { pub async fn exit_all(state: &AppState) -> Result<(), String> { let mut errors = Vec::new(); for app in PROXY_APPS { - let _guard = lock_settled(state, &app).await; - if let Err(error) = exit_locked(state, &app, false) { + let result = match lock_settled(state, &app).await { + Ok(_guard) => exit_locked(state, &app, false), + Err(error) => Err(error.to_string()), + }; + if let Err(error) = result { errors.push(format!("{}: {error}", app.as_str())); } } @@ -594,8 +603,11 @@ pub async fn exit_all(state: &AppState) -> Result<(), String> { /// 代理服务。 pub async fn detach_all(state: &AppState) { for app in PROXY_APPS { - let _guard = lock_settled(state, &app).await; - if let Err(error) = exit_locked(state, &app, true) { + let result = match lock_settled(state, &app).await { + Ok(_guard) => exit_locked(state, &app, true), + Err(error) => Err(error.to_string()), + }; + if let Err(error) = result { log::error!("退出时把 {} 指回直连失败: {error}", app.as_str()); } } @@ -647,7 +659,7 @@ pub async fn switch_route( let target = provider(state, app, provider_id)?.ok_or_else(|| format!("供应商不存在: {provider_id}"))?; reject_unsupported_official(app, &target)?; - let _guard = lock_settled(state, app).await; + let _guard = lock_settled(state, app).await.map_err(|e| e.to_string())?; switch_route_locked(state, app, &target).await } @@ -678,10 +690,27 @@ pub async fn resync_route_locked(state: &AppState, app: &AppType) -> Result<(), } pub async fn resync_route(state: &AppState, app: &AppType) -> Result<(), String> { - let _guard = lock_settled(state, app).await; + let _guard = lock_settled(state, app).await.map_err(|e| e.to_string())?; resync_route_locked(state, app).await } +/// 代理换了地址之后,按新地址重写每个接上代理的应用。一个应用失败(比如配置文件解析 +/// 不了)不影响其余应用:旧地址已经没人监听,跳过的应用会一直连不上。失败的汇总报错。 +pub async fn resync_routes(state: &AppState) -> Result<(), String> { + let mut failures = Vec::new(); + for app in PROXY_APPS { + if let Err(error) = resync_route(state, &app).await { + log::warn!("按新的代理地址重写 {} 失败: {error}", app.as_str()); + failures.push(format!("{}: {error}", app.as_str())); + } + } + if failures.is_empty() { + Ok(()) + } else { + Err(failures.join("; ")) + } +} + /// 故障转移成功后记下新路由:只换代理的上游,不写客户端文件(契约兼容性本轮不检查)。 /// 返回路由是否真的变了。 pub async fn record_failover_route( @@ -689,7 +718,7 @@ pub async fn record_failover_route( app: &AppType, provider_id: &str, ) -> Result { - let _guard = lock_settled(state, app).await; + let _guard = lock_settled(state, app).await.map_err(|e| e.to_string())?; let mode = current::mode_state(app); if !mode.is_proxy() || mode.proxy_route.as_deref() == Some(provider_id) { return Ok(false); @@ -726,8 +755,11 @@ pub async fn record_failover_route( /// 为准,它还没有值就按 `enabled` 定。 pub async fn startup(state: &AppState) { for app in PROXY_APPS { - let _guard = lock_settled(state, &app).await; - if let Err(error) = startup_app(state, &app).await { + let result = match lock_settled(state, &app).await { + Ok(_guard) => startup_app(state, &app).await, + Err(error) => Err(error.to_string()), + }; + if let Err(error) = result { log::error!("启动时恢复 {} 的模式失败: {error}", app.as_str()); } } @@ -1643,6 +1675,141 @@ mod mode_tests { exit(&state, &AppType::Claude).await.expect("exit"); } + /// 保存当前供应商要等进入代理写完契约,之后按代理模式处理,不能拿进入前读到的直连 + /// 模式把关键字段写回 live。 + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn saving_the_current_provider_waits_for_entering_proxy_mode() { + let _home = Home::new(); + seed_settings(USER_SETTINGS); + let state: &'static AppState = Box::leak(Box::new( + state_with( + AppType::Claude, + &[claude("a", "https://a.example", json!({}))], + "a", + ) + .await, + )); + + let guard = lock_settled(state, &AppType::Claude).await.unwrap(); + let updater = std::thread::spawn(move || { + ProviderService::update( + state, + AppType::Claude, + None, + claude("a", "https://a2.example", json!({})), + ) + }); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + assert!(!updater.is_finished(), "the save waits for the switch lock"); + assert_eq!(settings()["env"]["ANTHROPIC_BASE_URL"], "https://a.example"); + + enter_locked(state, &AppType::Claude, op::ENTER) + .await + .expect("enter"); + drop(guard); + tokio::task::spawn_blocking(move || updater.join()) + .await + .unwrap() + .unwrap() + .expect("save"); + + let proxy_url = state.proxy_service.build_proxy_urls().await.unwrap().0; + let live = settings(); + assert_eq!(live["env"]["ANTHROPIC_BASE_URL"], proxy_url.as_str()); + assert_eq!(live["env"]["ANTHROPIC_AUTH_TOKEN"], PROXY_TOKEN_PLACEHOLDER); + + exit(state, &AppType::Claude).await.expect("exit"); + assert_eq!( + settings()["env"]["ANTHROPIC_BASE_URL"], + "https://a2.example", + "the saved row is what leaving proxy mode writes back" + ); + + // 同步当前供应商(导入、云同步、统一供应商)同样等进入代理写完。 + let guard = lock_settled(state, &AppType::Claude).await.unwrap(); + let syncer = std::thread::spawn(move || { + ProviderService::sync_current_provider_for_app(state, AppType::Claude) + }); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + assert!(!syncer.is_finished(), "the sync waits for the switch lock"); + enter_locked(state, &AppType::Claude, op::ENTER) + .await + .expect("enter"); + drop(guard); + tokio::task::spawn_blocking(move || syncer.join()) + .await + .unwrap() + .unwrap() + .expect("sync"); + assert_eq!( + settings()["env"]["ANTHROPIC_AUTH_TOKEN"], + PROXY_TOKEN_PLACEHOLDER + ); + exit(state, &AppType::Claude).await.expect("exit"); + } + + /// 上一次的操作补不完(这里是落定状态失败)时,保存编辑器直接拒绝、行不动。否则按补完 + /// 之前的指针判断「b 不是当前供应商」只存了行,等那次操作补完 b 成了当前供应商,live + /// 里却是它的旧 Key。 + #[tokio::test] + #[serial] + async fn nothing_is_saved_while_the_previous_write_cannot_be_finished() { + let _home = Home::new(); + seed_settings(USER_SETTINGS); + let state = state_with( + AppType::Claude, + &[ + claude("a", "https://a.example", json!({})), + claude("b", "https://b.example", json!({})), + ], + "a", + ) + .await; + failpoint::crash_at(Some("published:0")); + let interrupted = ProviderService::switch(&state, AppType::Claude, "b"); + failpoint::crash_at(None); + assert!(interrupted.is_err()); + + let mut row = state.db.get_provider_by_id("b", "claude").unwrap().unwrap(); + let base = + ProviderService::editor_view(&state, AppType::Claude, &row.settings_config, None) + .expect("view") + .settings; + let mut edited = base.clone(); + edited["env"]["ANTHROPIC_AUTH_TOKEN"] = json!("sk-b-new"); + row.settings_config = edited; + failpoint::crash_at(Some("recover:target")); + let refused = ProviderService::update_from_editor( + &state, + AppType::Claude, + None, + row, + Some(crate::services::provider::EditorSave { + base, + draft: None, + on_conflict: Default::default(), + }), + ); + failpoint::crash_at(None); + let error = refused.expect_err("refused while unsettled"); + assert!(error.to_string().contains("补不完"), "{error}"); + let b_token = |state: &AppState| { + state + .db + .get_provider_by_id("b", "claude") + .unwrap() + .unwrap() + .settings_config["env"]["ANTHROPIC_AUTH_TOKEN"] + .clone() + }; + assert_eq!(b_token(&state), "sk-b", "the row is untouched"); + + crate::mode::operation::recover_on_startup(&state.db); + assert_eq!(direct(&state, &AppType::Claude).as_deref(), Some("b")); + assert_eq!(settings()["env"]["ANTHROPIC_AUTH_TOKEN"], b_token(&state)); + } + #[tokio::test] #[serial] async fn failover_moves_the_route_without_writing_client_files() { @@ -1831,6 +1998,52 @@ mod mode_tests { assert!(!text.contains("PROXY_MANAGED"), "{text}"); } + /// 代理换了端口:一个应用重写失败(配置文件解析不了),其余接上代理的应用照样按新 + /// 地址重写,失败的应用在报错里。 + #[tokio::test] + #[serial] + async fn a_new_proxy_address_reaches_every_app_even_if_one_fails() { + let _home = Home::new(); + seed_settings(USER_SETTINGS); + seed_gemini( + "GEMINI_API_KEY=real-key\nGOOGLE_GEMINI_BASE_URL=https://g.example\n", + "{}", + ); + let state = state_with( + AppType::Claude, + &[claude("a", "https://a.example", json!({}))], + "a", + ) + .await; + let row = gemini( + "g", + json!({ "GEMINI_API_KEY": "real-key", "GOOGLE_GEMINI_BASE_URL": "https://g.example" }), + json!({}), + ); + state.db.save_provider("gemini", &row).unwrap(); + state.db.set_current_provider("gemini", "g").unwrap(); + crate::settings::set_current_provider(&AppType::Gemini, Some("g")).unwrap(); + enter(&state, &AppType::Claude).await.expect("enter claude"); + enter(&state, &AppType::Gemini).await.expect("enter gemini"); + let old_url = state.proxy_service.build_proxy_urls().await.unwrap().0; + + fs::write(settings_path(), "not json").unwrap(); + let mut config = state.db.get_proxy_config().await.unwrap(); + config.listen_port = 0; + assert!(state.proxy_service.update_config(&config).await.unwrap()); + let new_url = state.proxy_service.build_proxy_urls().await.unwrap().0; + assert_ne!(new_url, old_url); + + let error = resync_routes(&state).await.expect_err("claude fails"); + assert!(error.starts_with("claude:"), "{error}"); + assert!( + gemini_env().contains(&format!("GOOGLE_GEMINI_BASE_URL={new_url}\n")), + "{}", + gemini_env() + ); + state.proxy_service.stop().await.unwrap(); + } + #[tokio::test] #[serial] async fn codex_routes_between_official_and_third_party_contracts() { @@ -2108,6 +2321,55 @@ command = "fs-server" assert_eq!(codex_text(), on_a); } + /// 行里自己指定的模型目录指针跟着这一家走:切走时删掉,切到生成了目录的那家就换成 + /// CC Switch 自己的指针;代理契约带进来的,退出代理时同样删掉。用户直接写进 live 的 + /// 指针一直留着。 + #[tokio::test] + #[serial] + async fn codex_a_row_catalog_pointer_leaves_with_its_provider() { + let _home = Home::new(); + set_preservation(true); + seed_codex(CODEX_USER_LIVE, None); + let a = codex_row( + "a", + "https://a.example/v1", + "model_catalog_json = \"/work/a-catalog.json\"", + ); + let mut b = codex_row("b", "https://b.example/v1", ""); + b.settings_config["modelCatalog"] = json!({ "models": [{ "model": "gpt-b" }] }); + let c = codex_row("c", "https://c.example/v1", ""); + let state = state_with(AppType::Codex, &[a, b, c], "c").await; + let pointer = || { + codex_doc() + .get("model_catalog_json") + .and_then(|value| value.as_str().map(str::to_string)) + }; + let ours = crate::live::project::codex::CATALOG_FILENAME; + + ProviderService::switch(&state, AppType::Codex, "a").expect("to a"); + assert_eq!(pointer().as_deref(), Some("/work/a-catalog.json")); + ProviderService::switch(&state, AppType::Codex, "b").expect("to b"); + assert_eq!(pointer().as_deref(), Some(ours), "{}", codex_text()); + ProviderService::switch(&state, AppType::Codex, "a").expect("back to a"); + ProviderService::switch(&state, AppType::Codex, "c").expect("to c"); + assert_eq!(pointer(), None, "{}", codex_text()); + + // 代理模式:路由从 c 换到 a,契约带进 a 的指针;退出代理写回直连 c 时删掉。 + enter(&state, &AppType::Codex).await.expect("enter"); + ProviderService::switch(&state, AppType::Codex, "a").expect("route to a"); + assert_eq!(pointer().as_deref(), Some("/work/a-catalog.json")); + exit(&state, &AppType::Codex).await.expect("exit"); + assert_eq!(pointer(), None, "{}", codex_text()); + + // 用户自己写进 live 的指针不认领、不删,也不被 CC Switch 的指针替换。 + let with_user = format!("model_catalog_json = \"/work/mine.json\"\n{}", codex_text()); + fs::write(codex_config_path(), with_user).unwrap(); + ProviderService::switch(&state, AppType::Codex, "b").expect("to b"); + assert_eq!(pointer().as_deref(), Some("/work/mine.json")); + ProviderService::switch(&state, AppType::Codex, "c").expect("to c"); + assert_eq!(pointer().as_deref(), Some("/work/mine.json")); + } + #[tokio::test] #[serial] async fn codex_exclusive_fields_the_user_changed_stay() { @@ -2195,6 +2457,37 @@ command = "fs-server" assert_eq!(direct(&state, &AppType::Codex).as_deref(), Some("a")); } + /// 生效的 profile 显式选了内置的 `openai`:和官方卡不写 model_provider 去的是同一个 + /// 地方,切到官方卡不拒绝;切到第三方仍然拒绝。 + #[tokio::test] + #[serial] + async fn codex_a_profile_selecting_the_built_in_openai_allows_the_official_card() { + let _home = Home::new(); + set_preservation(true); + let live = format!( + "profile = \"work\"\n{CODEX_USER_LIVE}\n[profiles.work]\nmodel_provider = \"openai\"\n" + ); + seed_codex(&live, Some(&chatgpt_login("acct"))); + let [a, b] = codex_a_b(); + let state = state_with(AppType::Codex, &[a, b, codex_official()], "a").await; + + ProviderService::switch( + &state, + AppType::Codex, + crate::database::CODEX_OFFICIAL_PROVIDER_ID, + ) + .expect("switch to official"); + let doc = codex_doc(); + assert!(doc.get("model_provider").is_none()); + assert_eq!( + doc["profiles"]["work"]["model_provider"].as_str(), + Some("openai") + ); + + let err = ProviderService::switch(&state, AppType::Codex, "b").expect_err("refused"); + assert!(err.to_string().contains("work"), "{err}"); + } + #[tokio::test] #[serial] async fn codex_migration_retires_only_tables_cc_switch_wrote() { @@ -2405,6 +2698,28 @@ model_provider = "c" ); } + /// 发布过的文件在补完之前又被客户端改掉(这里是用户在 Codex 里重新登录、写了新的 + /// auth.json):单看文件分不出发布开始过没有,按 pending 里的「已开始发布」照样前滚, + /// 还没写出去的登录暂存不能丢,客户端写的 auth.json 不动。 + #[tokio::test] + #[serial] + async fn codex_an_interrupted_switch_keeps_the_stash_when_the_published_file_changed_again() { + let _home = Home::new(); + let (state, _official) = codex_switch_interrupted_after_auth_json().await; + fs::write(codex_auth_path(), chatgpt_login("other").to_string()).unwrap(); + + crate::mode::operation::recover_on_startup(&state.db); + assert_eq!(direct(&state, &AppType::Codex).as_deref(), Some("a")); + assert_eq!(codex_doc()["model"].as_str(), Some("gpt-a")); + let stash = fs::read_to_string(DeviceStore::for_device().file("codex-login-stash.json")) + .expect("the stash was published"); + assert!(stash.contains("refresh-acct"), "{stash}"); + assert_eq!( + serde_json::from_slice::(&fs::read(codex_auth_path()).unwrap()).unwrap(), + chatgpt_login("other") + ); + } + #[tokio::test] #[serial] async fn codex_an_unreadable_login_stash_is_never_overwritten() { @@ -2613,6 +2928,7 @@ model_provider = "c" row, Some(crate::services::provider::EditorSave { base, + draft: None, on_conflict: Default::default(), }), ) @@ -2724,6 +3040,7 @@ model_provider = "c" row, Some(crate::services::provider::EditorSave { base, + draft: None, on_conflict: Default::default(), }), ) @@ -2784,6 +3101,163 @@ model_provider = "c" assert!(live.get("model_context_window").is_none()); } + /// 打开编辑器之后客户端改了一个从 live 带进来的独有字段:用户没动它,保存时不收进行, + /// 也不把打开时的值写回去。live 里生效的 profile 选着路由表时,编辑和新增都照样能存。 + #[tokio::test] + #[serial] + async fn codex_editor_leaves_what_the_user_did_not_touch_to_the_client() { + let _home = Home::new(); + set_preservation(true); + let live = CODEX_USER_LIVE.replace( + "model = \"gpt-a\"\n", + "model = \"gpt-a\"\nmodel_verbosity = \"high\"\n", + ); + seed_codex( + &format!("profile = \"work\"\n{live}\n[profiles.work]\nmodel_provider = \"custom\"\n"), + None, + ); + let state = state_with(AppType::Codex, &codex_a_b(), "a").await; + + let mut row = state.db.get_provider_by_id("a", "codex").unwrap().unwrap(); + let base = ProviderService::editor_view(&state, AppType::Codex, &row.settings_config, None) + .expect("view") + .settings; + let changed = + codex_text().replace("model_verbosity = \"high\"", "model_verbosity = \"low\""); + fs::write(codex_config_path(), &changed).unwrap(); + row.name = "renamed".into(); + row.settings_config = base.clone(); + ProviderService::update_from_editor( + &state, + AppType::Codex, + None, + row, + Some(crate::services::provider::EditorSave { + base, + draft: None, + on_conflict: Default::default(), + }), + ) + .expect("save with the profile active"); + let doc = codex_doc(); + assert_eq!( + doc["model_verbosity"].as_str(), + Some("low"), + "{}", + codex_text() + ); + assert_eq!( + doc["profiles"]["work"]["model_provider"].as_str(), + Some("custom") + ); + let stored = state.db.get_provider_by_id("a", "codex").unwrap().unwrap(); + assert!(!stored.settings_config["config"] + .as_str() + .unwrap() + .contains("model_verbosity")); + + let draft = codex_row("c", "https://c.example/v1", ""); + let view = + ProviderService::editor_view(&state, AppType::Codex, &draft.settings_config, None) + .expect("draft view"); + add_from_editor( + &state, + AppType::Codex, + draft, + view.settings.clone(), + view.settings, + ) + .expect("add with the profile active"); + } + + /// 新增对话框打开之后,客户端改了一个从 live 带进来的独有字段:草稿里没有它,保存时不 + /// 收进新供应商,切到新供应商再切走,客户端改的值还在。预设自己带的独有字段照样归新 + /// 供应商。 + #[tokio::test] + #[serial] + async fn codex_add_dialog_leaves_a_live_field_changed_after_opening_to_the_client() { + let _home = Home::new(); + set_preservation(true); + seed_codex( + &CODEX_USER_LIVE.replace( + "model = \"gpt-a\"\n", + "model = \"gpt-a\"\nmodel_verbosity = \"high\"\n", + ), + None, + ); + let state = state_with(AppType::Codex, &codex_a_b(), "a").await; + let config_of = |id: &str| { + state + .db + .get_provider_by_id(id, "codex") + .unwrap() + .unwrap() + .settings_config["config"] + .as_str() + .unwrap() + .to_string() + }; + + let draft = codex_row( + "c", + "https://c.example/v1", + "model_auto_compact_token_limit = 90000", + ); + let view = + ProviderService::editor_view(&state, AppType::Codex, &draft.settings_config, None) + .expect("draft view"); + let changed = + codex_text().replace("model_verbosity = \"high\"", "model_verbosity = \"low\""); + fs::write(codex_config_path(), changed).unwrap(); + add_from_editor( + &state, + AppType::Codex, + draft, + view.settings.clone(), + view.settings, + ) + .expect("add c"); + let stored = config_of("c"); + assert!( + stored.contains("model_auto_compact_token_limit = 90000") + && !stored.contains("model_verbosity"), + "{stored}" + ); + ProviderService::switch(&state, AppType::Codex, "c").expect("to c"); + ProviderService::switch(&state, AppType::Codex, "b").expect("to b"); + assert_eq!(codex_doc()["model_verbosity"].as_str(), Some("low")); + + // 旧的调用方不带草稿:退回和 live 里用户自己的值比(live 没被改过时分得清)。 + let mut legacy = codex_row( + "d", + "https://d.example/v1", + "model_auto_compact_token_limit = 80000", + ); + let base = + ProviderService::editor_view(&state, AppType::Codex, &legacy.settings_config, None) + .expect("legacy view") + .settings; + legacy.settings_config = base.clone(); + ProviderService::add_from_editor( + &state, + AppType::Codex, + legacy, + true, + Some(crate::services::provider::EditorSave { + base, + draft: None, + on_conflict: Default::default(), + }), + ) + .expect("add d"); + let stored = config_of("d"); + assert!( + stored.contains("model_auto_compact_token_limit = 80000") + && !stored.contains("model_verbosity"), + "{stored}" + ); + } + /// 编辑器里把路由表从 custom 改名成别的表:那张表归供应商(按内容收成 custom 表), /// 不当成全局设置写进 live,切走后表和里面的 Key 都不会留下。 #[tokio::test] @@ -2817,6 +3291,7 @@ model_provider = "c" row, Some(crate::services::provider::EditorSave { base: view.settings, + draft: None, on_conflict: Default::default(), }), ) @@ -3095,6 +3570,7 @@ model_provider = "c" row, Some(crate::services::provider::EditorSave { base: view.settings.clone(), + draft: None, on_conflict: Default::default(), }), ) @@ -3136,6 +3612,7 @@ model_provider = "c" row, Some(crate::services::provider::EditorSave { base: view.settings, + draft: None, on_conflict: Default::default(), }), ) @@ -3421,6 +3898,7 @@ model_provider = "c" row, Some(crate::services::provider::EditorSave { base: view.settings, + draft: None, on_conflict: Default::default(), }), ) @@ -3465,7 +3943,8 @@ model_provider = "c" edited: Value, base: Value, ) -> Result { - row.settings_config = edited; + // 和新增对话框一样:`row` 是投影成 `base` 的草稿。 + let draft = std::mem::replace(&mut row.settings_config, edited); ProviderService::add_from_editor( state, app, @@ -3473,6 +3952,7 @@ model_provider = "c" true, Some(crate::services::provider::EditorSave { base, + draft: Some(draft), on_conflict: Default::default(), }), ) diff --git a/src-tauri/src/mode/operation.rs b/src-tauri/src/mode/operation.rs index 4a0265e5b..fa3a4d9e2 100644 --- a/src-tauri/src/mode/operation.rs +++ b/src-tauri/src/mode/operation.rs @@ -7,7 +7,8 @@ //! //! 只调换「先写文件、后改指针」的顺序不够:文件写成 B、指针更新失败,照样不一致。 //! 恢复规则(按文件比对当前内容和写前、写后的 hash。文件按顺序发布,有一个是写后内容, -//! 就说明已经开始发布了): +//! 就说明已经开始发布了;换进第一个文件之前 pending 里还会先记下「已开始发布」,发布过的 +//! 文件之后又被客户端改掉——比如 Codex 刷新了刚写的 `auth.json`——也认得出来): //! - 没有文件是写后内容:还没开始发布,丢弃。有文件被外部改过也一样,什么都不动; //! - 已经开始发布:用备好的临时文件补完还是写前内容的文件,再落定状态。被外部改过 //! (两者都不是)的文件以外部为准,不再动,状态照样落定:停在半路的话,已经发布的 @@ -109,6 +110,7 @@ pub fn run( .map(|((planned, _), staged)| pending_file(planned, staged.clone())) .collect(), target, + published: false, }; if let Err(err) = state::set_pending(store, guard.app(), Some(pending.clone())) { discard_all(&staged); @@ -126,6 +128,20 @@ pub fn run( let current = read_current(¤t_planned.file.path)?; if digest(current.as_deref()) == current_planned.pre { ensure_first_write_backup(store, ¤t_planned.file.path, current.as_deref())?; + // 换进第一个文件之前先记下「已开始发布」,记不下来就不发布:换进去之后才记的 + // 话,中间崩溃、这个文件又被客户端改掉(Codex 刷新登录),恢复时就分不出发布 + // 开始过没有,还没发布的登录暂存会被当成没用的丢掉。 + if !pending.published { + pending.published = true; + let marked = failpoint::hit("mark").and_then(|()| { + state::set_pending(store, guard.app(), Some(pending.clone())) + }); + if let Err(err) = marked { + drop_unpublished(store, guard, &pending); + return Err(err); + } + failpoint::hit("marked")?; + } // 替换失败(文件被占用、只读)时临时文件还在:已发布过就留着 pending 等前滚, // 还没发布过就整体放弃(什么都没改)。 if let Err(err) = publish(&pending.files[index]) { @@ -224,7 +240,7 @@ pub fn recover( .collect() }; - if !positions.iter().any(|at| matches!(at, At::Planned)) { + if !pending.published && !positions.iter().any(|at| matches!(at, At::Planned)) { discard_pending_files(&pending); state::set_pending(store, guard.app(), None)?; let paths = elsewhere(); @@ -257,6 +273,7 @@ pub fn recover( ensure_first_write_backup(store, &file.path, current.as_deref())?; publish(file)?; } + failpoint::hit("recover:target")?; commit_target(&pending.target)?; // 发布过的临时文件已经换进去了;剩下的(被外部改过、内容不对)不再有用。 discard_pending_files(&pending); @@ -275,6 +292,15 @@ pub fn recover( })) } +/// 这个应用有没有已经发布、还没落定的操作。写入失败后用来判断要不要撤回刚存的供应商行: +/// 有的话下次操作或启动时会按它补完,行要留着。读不了状态文件按没有算。 +pub(crate) fn has_pending(app: &str) -> bool { + state::pending(&DeviceStore::for_device(), app) + .ok() + .flatten() + .is_some() +} + /// 读指针、模式或「live 现在归谁」之前调用:先补完这个应用上一次没做完的操作,读到的 /// 才是落定过的状态。调用方不能持有这个应用的写锁(不可重入);要拿代理切换锁时先拿它。 pub fn settle(db: &Database, app: &str) -> Result, AppError> { @@ -817,6 +843,51 @@ mod tests { assert_eq!(state::pending(&fx.store, &fx.app).unwrap(), None); } + /// 「已开始发布」记不下来(这里模拟状态文件写失败)就不发布:什么都没改,意图也清掉。 + #[test] + fn nothing_is_published_when_the_publish_marker_cannot_be_recorded() { + let fx = Fixture::new(); + let pointer = RefCell::new(None); + failpoint::crash_at(Some("mark")); + switch(&fx, &pointer).expect_err("marker not recorded"); + failpoint::crash_at(None); + + assert_old(&fx); + assert_eq!(*pointer.borrow(), None); + assert!(fx.temp_files().is_empty()); + assert_eq!(state::pending(&fx.store, &fx.app).unwrap(), None); + } + + /// 「已开始发布」在换进第一个文件之前就记下了:这之后崩溃、第一个文件又被外部改掉, + /// 恢复时照样前滚,改掉的文件不动,其余文件的暂存内容照样发布,不会被当成「还没开始」 + /// 丢掉。 + #[test] + fn a_file_changed_after_the_publish_marker_still_rolls_forward() { + let fx = Fixture::new(); + let pointer = RefCell::new(None); + failpoint::crash_at(Some("marked")); + switch(&fx, &pointer).expect_err("crash"); + assert!( + state::pending(&fx.store, &fx.app) + .unwrap() + .unwrap() + .published + ); + fs::write(&fx.a, "{\"user\": 1, \"key\": \"client refresh\"}").unwrap(); + + assert_eq!( + recover_now(&fx, &pointer), + Some(RecoveryOutcome::RolledForwardExcept { + paths: vec![fx.a.clone()] + }) + ); + assert_eq!(fx.read(&fx.a), json!({"user": 1, "key": "client refresh"})); + assert_eq!(fx.read(&fx.b), json!({"key": "new"})); + assert_eq!(*pointer.borrow(), Some("B".into())); + assert!(fx.temp_files().is_empty()); + assert_eq!(state::pending(&fx.store, &fx.app).unwrap(), None); + } + /// 放弃的时候不能连带丢掉还没发布的文件:Codex 删掉 `auth.json` 之后,登录只在暂存 /// 的临时文件里。 #[test] diff --git a/src-tauri/src/mode/state.rs b/src-tauri/src/mode/state.rs index 813799c2f..dc34140ec 100644 --- a/src-tauri/src/mode/state.rs +++ b/src-tauri/src/mode/state.rs @@ -61,8 +61,8 @@ pub struct Contract { pub version: u32, /// 契约内容的摘要:切换路由时摘要相同,客户端文件就不用动。 pub key: String, - /// 契约写进客户端的独有字段。退出代理时按它删除(值相同才删):路由供应商的行 - /// 之后可能被编辑过,不能到时再按行重新计算。 + /// 契约写进客户端的独有字段(Codex 还有路由行里指定的模型目录指针)。退出代理时按它 + /// 删除(值相同才删):路由供应商的行之后可能被编辑过,不能到时再按行重新计算。 #[serde(default, skip_serializing_if = "Map::is_empty")] pub exclusive: Map, } @@ -176,6 +176,11 @@ pub struct Pending { pub files: Vec, #[serde(default)] pub target: PendingTarget, + /// 已经开始发布:换进第一个文件之前记下,所以只要有文件发布过它就一定在。发布过的 + /// 文件之后可能又被客户端改掉(Codex 刷新登录),单看文件内容就分不出发布开始过没有, + /// 恢复时靠它决定前滚还是丢弃。 + #[serde(default, skip_serializing_if = "is_false")] + pub published: bool, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -347,6 +352,7 @@ mod tests { pointer: Some("p1".to_string()), ..PendingTarget::default() }, + published: false, } } diff --git a/src-tauri/src/proxy/handler_context.rs b/src-tauri/src/proxy/handler_context.rs index ccf49c108..bc61ecdbd 100644 --- a/src-tauri/src/proxy/handler_context.rs +++ b/src-tauri/src/proxy/handler_context.rs @@ -113,8 +113,7 @@ impl RequestContext { crate::mode::current::Purpose::InUse, ) .ok() - .flatten() - .unwrap_or_default(); + .flatten(); // 从请求体提取模型名称 let request_model = body @@ -139,7 +138,7 @@ impl RequestContext { // 注意:只在这里调用一次,结果传递给 forwarder,避免重复消耗 HalfOpen 名额 let providers = state .provider_router - .select_providers(app_type_str) + .select_providers_with_current(app_type_str, current_provider_id.clone()) .await .map_err(|e| match e { crate::error::AppError::AllProvidersCircuitOpen => { @@ -168,7 +167,7 @@ impl RequestContext { app_config, provider, providers, - current_provider_id, + current_provider_id: current_provider_id.unwrap_or_default(), request_model, outbound_model: None, tag, diff --git a/src-tauri/src/proxy/provider_router.rs b/src-tauri/src/proxy/provider_router.rs index 781e82dde..789909a11 100644 --- a/src-tauri/src/proxy/provider_router.rs +++ b/src-tauri/src/proxy/provider_router.rs @@ -43,9 +43,6 @@ impl ProviderRouter { /// - 故障转移关闭时:仅返回当前供应商 /// - 故障转移开启时:仅使用故障转移队列,按队列顺序依次尝试(P1 → P2 → ...) pub async fn select_providers(&self, app_type: &str) -> Result, AppError> { - let mut result = Vec::new(); - let mut total_providers = 0usize; - let mut circuit_open_count = 0usize; // 代理模式下路由到代理路由那家,和直连指针无关。 let current_id = AppType::from_str(app_type).ok().and_then(|app_enum| { crate::mode::current::provider_for( @@ -56,6 +53,20 @@ impl ProviderRouter { .ok() .flatten() }); + self.select_providers_with_current(app_type, current_id) + .await + } + + /// 同 [`Self::select_providers`],正在用的那家由调用方给出:处理请求时上下文已经读过 + /// 一次(要读 `live-state.json`),不用每个请求再读一遍,两处用的也一定是同一家。 + pub async fn select_providers_with_current( + &self, + app_type: &str, + current_id: Option, + ) -> Result, AppError> { + let mut result = Vec::new(); + let mut total_providers = 0usize; + let mut circuit_open_count = 0usize; let current_provider = current_id .as_deref() .map(|id| self.db.get_provider_by_id(id, app_type)) diff --git a/src-tauri/src/services/provider/claude_direct.rs b/src-tauri/src/services/provider/claude_direct.rs index 2968a2c37..807173a6a 100644 --- a/src-tauri/src/services/provider/claude_direct.rs +++ b/src-tauri/src/services/provider/claude_direct.rs @@ -93,11 +93,3 @@ pub(crate) fn run( .collect(); operation::run(&store, &guard, op, &changes, target, &commit) } - -/// 有没有已经发布、还没落定的操作(写入失败后判断要不要撤回刚存的行)。 -pub(crate) fn has_pending() -> bool { - crate::mode::state::pending(&DeviceStore::for_device(), app()) - .ok() - .flatten() - .is_some() -} diff --git a/src-tauri/src/services/provider/claude_editor.rs b/src-tauri/src/services/provider/claude_editor.rs index a40b5bb54..722ffea32 100644 --- a/src-tauri/src/services/provider/claude_editor.rs +++ b/src-tauri/src/services/provider/claude_editor.rs @@ -71,6 +71,10 @@ pub enum ConflictPolicy { pub struct EditorSave { /// 打开编辑器时显示的完整配置(新增对话框里是还没套预设的 live)。 pub base: Value, + /// 新增对话框(Codex、Gemini CLI、Grok Build):投影成 `base` 的那份草稿(预设或模板)。 + /// 显示里有、草稿里没有的独有字段是从 live 带进来的,不归新供应商。 + #[serde(default)] + pub draft: Option, #[serde(default)] pub on_conflict: ConflictPolicy, } diff --git a/src-tauri/src/services/provider/codex_direct.rs b/src-tauri/src/services/provider/codex_direct.rs index b08fdf31b..f7f228f26 100644 --- a/src-tauri/src/services/provider/codex_direct.rs +++ b/src-tauri/src/services/provider/codex_direct.rs @@ -31,9 +31,9 @@ use crate::error::AppError; use crate::live::engine::{digest, lock_app, read_current, DeviceStore, LiveFile}; use crate::live::patch::{Guarded, LivePatch, WholeFile}; use crate::live::project::codex::{ - official_mirror_table, proxy_route_table, requires_openai_auth, CodexConfigPatch, - CodexProjection, KnownTable, Route, RouteAuth, RouteWrite, RowInput, ROUTE_ID, - WEB_SEARCH_DISABLED, + official_mirror_table, proxy_route_table, requires_openai_auth, row_catalog_pointer, + CodexConfigPatch, CodexProjection, KnownTable, Route, RouteAuth, RouteWrite, RowInput, + ROUTE_ID, WEB_SEARCH_DISABLED, }; use crate::mode::contract::CONTRACT_VERSION; use crate::mode::operation::{self, FileChange, OperationReport}; @@ -196,11 +196,15 @@ fn exclusive_of(provider: &Provider, projection: &CodexProjection) -> Vec<(Strin exclusive } -/// live 现在的独有字段:切走时值还相同就删。 -fn outgoing_exclusive(owner: &Owner<'_>) -> Vec<(String, TomlValue)> { +/// live 现在对应的那一家带进来的独有字段和行里指定的模型目录指针:切走时值还相同就删。 +pub(crate) fn outgoing_exclusive(owner: &Owner<'_>) -> Vec<(String, TomlValue)> { match owner { Owner::Provider(provider) => match project(provider) { - Ok(projection) => exclusive_of(provider, &projection), + Ok(projection) => { + let mut fields = exclusive_of(provider, &projection); + fields.extend(row_catalog_pointer(&projection.top).cloned()); + fields + } Err(err) => { log::warn!( "无法投影 Codex 供应商 {} 的独有字段,切走时不清理它们: {err}", @@ -564,6 +568,7 @@ fn contract_of( exclusive: config .exclusive .iter() + .chain(row_catalog_pointer(&config.top)) .map(|(key, value)| (key.clone(), Value::String(value_literal(value)))) .collect(), } @@ -843,11 +848,3 @@ pub(crate) fn preflight(db: &Database, provider: &Provider) -> Result<(), AppErr let target = Target::Direct(Some(provider)); plan(db, &Owner::None, &target, &Prepared::default()).map(|_| ()) } - -/// 上一次 Codex 写入还有没补完的部分(文件已经发布、状态还没落定)。 -pub(crate) fn has_pending() -> bool { - crate::mode::state::pending(&DeviceStore::for_device(), app()) - .ok() - .flatten() - .is_some() -} diff --git a/src-tauri/src/services/provider/codex_editor.rs b/src-tauri/src/services/provider/codex_editor.rs index 512818e82..1e89e0e72 100644 --- a/src-tauri/src/services/provider/codex_editor.rs +++ b/src-tauri/src/services/provider/codex_editor.rs @@ -125,23 +125,10 @@ pub fn view( let mut provider = Provider::with_id(String::new(), String::new(), settings_config.clone(), None); provider.category = category.map(str::to_string); - let mode = crate::mode::current::mode_state(&AppType::Codex); - let direct = crate::mode::current::provider_for( - &state.db, - &AppType::Codex, - crate::mode::current::Purpose::Direct, - )? - .and_then(|id| state.db.get_provider_by_id(&id, app()).ok().flatten()); - let owner = match (&mode.contract, mode.attached) { - (Some(contract), true) => Owner::Contract { - contract, - route: None, - }, - _ => direct.as_ref().map_or(Owner::None, Owner::Provider), - }; + let live_owner = LiveOwner::read(state)?; let planned = codex_direct::plan( &state.db, - &owner, + &live_owner.owner(), &Target::Direct(Some(&provider)), &Prepared::default(), )?; @@ -206,14 +193,55 @@ pub(crate) struct CodexEditorPlan { pub edits: TomlEdits, } -/// 不套任何供应商时 live 里的独有字段(live 现在对应的那家带进来的已经去掉):编辑器显示里的 -/// 独有字段和它一样、行里又没有的,是从 live 带进来的。新增对话框的底已经套了预设, -/// 只能这样和预设带的分开。 +/// live 现在归谁:接上代理时是契约,否则是直连指针那家。 +struct LiveOwner { + mode: crate::mode::state::ModeState, + direct: Option, +} + +impl LiveOwner { + fn read(state: &AppState) -> Result { + let direct = crate::mode::current::provider_for( + &state.db, + &AppType::Codex, + crate::mode::current::Purpose::Direct, + )? + .and_then(|id| state.db.get_provider_by_id(&id, app()).ok().flatten()); + Ok(Self { + mode: crate::mode::current::mode_state(&AppType::Codex), + direct, + }) + } + + fn owner(&self) -> Owner<'_> { + match (&self.mode.contract, self.mode.attached) { + (Some(contract), true) => Owner::Contract { + contract, + route: None, + }, + _ => self.direct.as_ref().map_or(Owner::None, Owner::Provider), + } + } +} + +/// live 里用户自己的独有字段:live 现在对应的那家带进来的(值还相同的)不算。只给不知道 +/// 草稿的新增用(见 [`Origin::Live`])。 +/// +/// 只读 live、按值去掉那一家的,不走投影:投影会校验生效的 profile,而空行不写 +/// `model_provider`,profile 选了路由表就会被当成覆盖路由拒绝。 pub(crate) fn live_exclusive(state: &AppState) -> Result, AppError> { - let empty = serde_json::json!({ "auth": {}, "config": "" }); - let view = view(state, &empty, None)?; - let doc = parse_text(config_text(&view.settings), "live")?; - Ok(exclusive_entries(&doc)) + let path = get_codex_config_path(); + let pre = read_current(&path)?; + let doc = parse(&path, pre.as_deref())?; + let owned = codex_direct::outgoing_exclusive(&LiveOwner::read(state)?.owner()); + Ok(exclusive_entries(&doc) + .into_iter() + .filter(|entry| { + !owned.iter().any(|(key, value)| { + entry.path[0] == *key && render(&entry.item) == render(&Item::Value(value.clone())) + }) + }) + .collect()) } fn exclusive_entries(doc: &DocumentMut) -> Vec { @@ -228,23 +256,40 @@ fn exclusive_entries(doc: &DocumentMut) -> Vec { .collect() } +/// 编辑器显示里的独有字段是从哪来的:显示的是某份配置投影到 live 上的样子,这份配置里没有 +/// 的独有字段就是从 live 带进来的。 +pub(crate) enum Origin { + /// 投影成底的那份配置:编辑已有供应商时是它的行,新增时是预设草稿。 + Row(DocumentMut), + /// 新增时不知道草稿(旧的调用方):和 live 里用户自己的独有字段比值,打开之后 live 被 + /// 改过就分不准。 + Live(Vec), +} + +impl Origin { + /// `settings` 是行或草稿的 `settings_config`。 + pub(crate) fn row(settings: &Value) -> Result { + Ok(Self::Row(parse_text(config_text(settings), "origin")?)) + } +} + /// 把编辑器里的完整配置拆开:关键字段、独有字段换进行(行里其余内容原样保留),其余部分 /// 和 `base` 比,得出用户改过的全局设置。行有问题(会把官方登录发给第三方等)在这里报错。 /// -/// `live_exclusive` 见 [`live_exclusive`]。从 live 带进来的独有字段不归这个供应商:用户 -/// 没动就不收进行(否则切走时会把用户自己的设置删掉),用户删了就从 live 删。 +/// 从 live 带进来的独有字段不归这个供应商:用户没动就不收进行、也不写(否则切走时会把 +/// 用户自己的设置删掉,打开编辑器之后客户端改的值也会被盖回去),用户删了就从 live 删。 +/// 哪些是从 live 带进来的见 [`Origin`]。 pub(crate) fn plan_save( stored_row: Option<&Value>, edited: &Value, base: &Value, - live_exclusive: &[Entry], + origin: &Origin, official: bool, proxy_injected_oauth: bool, on_conflict: ConflictPolicy, ) -> Result { let edited_doc = parse_text(config_text(edited), "edited")?; let base_doc = parse_text(config_text(base), "base")?; - let stored_doc = parse_text(stored_row.map(config_text).unwrap_or(""), "stored")?; let mut projection = CodexProjection::of(&RowInput { settings: edited, official, @@ -254,12 +299,11 @@ pub(crate) fn plan_save( let rendered = |doc: &DocumentMut, key: &str| doc.get(key).map(render); let from_live: Vec = exclusive_entries(&base_doc) .into_iter() - .filter(|entry| { - let key = entry.path[0].as_str(); - stored_doc.get(key).is_none() - && live_exclusive.iter().any(|live| { - live.path == entry.path && render(&live.item) == render(&entry.item) - }) + .filter(|entry| match origin { + Origin::Row(row) => row.get(&entry.path[0]).is_none(), + Origin::Live(live_exclusive) => live_exclusive + .iter() + .any(|live| live.path == entry.path && render(&live.item) == render(&entry.item)), }) .collect(); projection.exclusive.retain(|(key, _)| { @@ -508,7 +552,7 @@ mod tests { Some(&stored), &edited, &edited, - &[], + &Origin::row(&stored).unwrap(), false, false, ConflictPolicy::Refuse, diff --git a/src-tauri/src/services/provider/gemini_direct.rs b/src-tauri/src/services/provider/gemini_direct.rs index cce168438..b4c445882 100644 --- a/src-tauri/src/services/provider/gemini_direct.rs +++ b/src-tauri/src/services/provider/gemini_direct.rs @@ -118,11 +118,3 @@ pub(crate) fn run_with_edits( } operation::run(&store, &guard, op, &changes, target, &commit) } - -/// 有上一次没做完的操作(启动或下次操作时补完)。 -pub(crate) fn has_pending() -> bool { - crate::mode::state::pending(&DeviceStore::for_device(), app()) - .ok() - .flatten() - .is_some() -} diff --git a/src-tauri/src/services/provider/grok_direct.rs b/src-tauri/src/services/provider/grok_direct.rs index 97e5a60de..9e984e03d 100644 --- a/src-tauri/src/services/provider/grok_direct.rs +++ b/src-tauri/src/services/provider/grok_direct.rs @@ -169,14 +169,6 @@ pub(crate) fn run_with_edits( operation::run(&store, &guard, op, &changes, target, &commit) } -/// 有上一次没做完的操作(启动或下次操作时补完)。 -pub(crate) fn has_pending() -> bool { - crate::mode::state::pending(&DeviceStore::for_device(), app()) - .ok() - .flatten() - .is_some() -} - /// 写 `config.toml`:先按三方比较应用编辑器的改动,再换模型表。 struct GrokWrite<'a> { edits: Option<&'a TomlEdits>, diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 663baecb4..18906fd0c 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -892,8 +892,9 @@ pub(crate) enum LiveSyncOutcome { /// 就不动);其余供应商(包括直连指针那家)只在退出代理时写回,这里不碰 live。 /// /// `prev` 是 live 现在对应的那一版供应商行(编辑前的行),Claude 按它删上一版带进来的 -/// 独有字段;`None` 表示 live 对应的就是 `provider` 自己。调用方不能持有这个应用的 -/// 代理切换锁(代理模式下这里要拿它)。 +/// 独有字段;`None` 表示 live 对应的就是 `provider` 自己。调用方持有这个应用的代理切换锁 +/// (`controller::lock_settled_blocking`),并且在拿锁之后才读谁是当前供应商:不拿锁的 +/// 话,读完模式到写完 live 之间进入代理,直连的关键字段会盖掉刚写的代理契约。 pub(crate) fn sync_live_for_provider_respecting_mode( state: &AppState, app_type: &AppType, @@ -903,8 +904,10 @@ pub(crate) fn sync_live_for_provider_respecting_mode( let mode = crate::mode::current::mode_state(app_type); if mode.is_proxy() { if mode.proxy_route.as_deref() == Some(provider.id.as_str()) { - futures::executor::block_on(crate::mode::controller::resync_route(state, app_type)) - .map_err(AppError::Message)?; + futures::executor::block_on(crate::mode::controller::resync_route_locked( + state, app_type, + )) + .map_err(AppError::Message)?; } return Ok(LiveSyncOutcome::ProxyMode); } @@ -924,6 +927,7 @@ fn sync_current_provider_for_app_respecting_mode( state: &AppState, app_type: &AppType, ) -> Result<(), AppError> { + let _switch_guard = crate::mode::controller::lock_settled_blocking(state, app_type)?; let current_id = match crate::mode::current::provider_for( &state.db, app_type, diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 2e0d61339..6952000ec 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -68,6 +68,7 @@ pub fn official_provider_supports_proxy_takeover(app_type: &AppType, provider: & /// 使开关即时生效,无需等下一次切换。当前供应商非官方(或不存在)时为 no-op:开关只 /// 影响官方直连的选路。代理模式下 live 是代理契约,不受这个开关影响。 pub fn reapply_current_codex_official_live(state: &AppState) -> Result { + let _switch_guard = crate::mode::controller::lock_settled_blocking(state, &AppType::Codex)?; let current_id = ProviderService::current(state, AppType::Codex)?; if current_id.is_empty() { return Ok(false); @@ -4218,7 +4219,7 @@ wire_api = "responses" .get_provider_by_id(&provider.id, AppType::Codex.as_str()) .expect("query provider") .is_some()); - assert!(codex_direct::has_pending()); + assert!(crate::mode::operation::has_pending(AppType::Codex.as_str())); assert_ne!( crate::codex_config::CodexLiveStateSnapshot::capture().expect("capture live"), live_before @@ -4232,7 +4233,9 @@ wire_api = "responses" .execute_batch("DROP TRIGGER reject_first_managed_current_update;") .expect("drop trigger"); crate::mode::operation::recover_on_startup(&state.db); - assert!(!codex_direct::has_pending()); + assert!(!crate::mode::operation::has_pending( + AppType::Codex.as_str() + )); assert_eq!( state .db @@ -4467,7 +4470,7 @@ wire_api = "responses" .expect("check managed marker"), "auth.json and the marker belong to the target account" ); - assert!(codex_direct::has_pending()); + assert!(crate::mode::operation::has_pending(AppType::Codex.as_str())); state .db @@ -4477,7 +4480,9 @@ wire_api = "responses" .execute_batch("DROP TRIGGER reject_managed_b_current_update;") .expect("drop trigger"); crate::mode::operation::recover_on_startup(&state.db); - assert!(!codex_direct::has_pending()); + assert!(!crate::mode::operation::has_pending( + AppType::Codex.as_str() + )); assert_eq!( crate::settings::get_current_provider(&AppType::Codex).as_deref(), Some(provider_b.id.as_str()) @@ -4860,6 +4865,9 @@ impl ProviderService { return Self::save_mcode_provider(state, &provider, add_to_live); } + // 还没有当前供应商时这一家会被写进 live:和进入代理、切换互斥(同 `update`)。 + let _switch_guard = crate::mode::controller::lock_settled_blocking(state, &app_type)?; + // Save to database state.db.save_provider(app_type.as_str(), &provider)?; @@ -4921,7 +4929,7 @@ impl ProviderService { let app_type = AppType::Codex; // 和切换互斥:等着的切换不能看到只存了一半的托管账号绑定。 let _switch_guard = - futures::executor::block_on(crate::mode::controller::lock_settled(state, &app_type)); + futures::executor::block_on(crate::mode::controller::lock_settled(state, &app_type))?; let current = crate::mode::current::provider_for( &state.db, &app_type, @@ -4952,7 +4960,7 @@ impl ProviderService { if let Err(error) = written { // 文件已经发布、只是落定状态失败时,pending 会在下次操作或启动时补完指针, // 行要留着;还没发布就失败,撤回刚存的行。 - if codex_direct::has_pending() { + if crate::mode::operation::has_pending(AppType::Codex.as_str()) { return Err(error); } let rollback = match &previous { @@ -4990,6 +4998,34 @@ impl ProviderService { } } + /// 编辑已有供应商时给 [`Self::editor_view`] 的 `category`:按库里那一行,用切换时同一个 + /// 判断认官方卡。Gemini 还按合作方标记和名字认出旧版没有 `category` 的 Google 卡,Codex + /// 还认固定 id 和托管账号;只看 `category` 的话,这些卡预览里的登录方式和切换写的不同。 + /// 没有 `provider_id`(新增)或行不存在时原样用 `category`。 + pub fn editor_category( + state: &AppState, + app_type: &AppType, + provider_id: Option<&str>, + category: Option, + ) -> Result, AppError> { + let Some(id) = provider_id else { + return Ok(category); + }; + let Some(row) = state.db.get_provider_by_id(id, app_type.as_str())? else { + return Ok(category); + }; + let official = match app_type { + AppType::Codex => codex_direct::is_official(&row), + AppType::Gemini => gemini_direct::is_official(&row), + _ => false, + }; + Ok(if official { + Some("official".to_string()) + } else { + category + }) + } + /// 从编辑器新增供应商。Claude Code、Codex、Gemini CLI、Grok Build 按关键字段拆开保存 /// (见各自的 `*_editor`),其余应用和 `add` 一样。 pub fn add_from_editor( @@ -5065,7 +5101,7 @@ impl ProviderService { ) -> Result { let app_type = AppType::Codex; let _switch_guard = - futures::executor::block_on(crate::mode::controller::lock_settled(state, &app_type)); + futures::executor::block_on(crate::mode::controller::lock_settled(state, &app_type))?; let existing = state .db .get_provider_by_id(&provider.id, app_type.as_str())?; @@ -5074,7 +5110,11 @@ impl ProviderService { existing.as_ref().map(|row| &row.settings_config), &provider.settings_config, &editor.base, - &codex_editor::live_exclusive(state)?, + &match (existing.as_ref(), editor.draft.as_ref()) { + (Some(row), _) => codex_editor::Origin::row(&row.settings_config)?, + (None, Some(draft)) => codex_editor::Origin::row(draft)?, + (None, None) => codex_editor::Origin::Live(codex_editor::live_exclusive(state)?), + }, codex_direct::is_official(&provider), provider.uses_proxy_injected_oauth(), editor.on_conflict, @@ -5121,7 +5161,7 @@ impl ProviderService { }) }; if let Err(error) = written { - if !codex_direct::has_pending() { + if !crate::mode::operation::has_pending(AppType::Codex.as_str()) { Self::restore_row(state, &app_type, &provider.id, existing.as_ref()); } return Err(error); @@ -5141,7 +5181,7 @@ impl ProviderService { kind: EditorSaveKind, ) -> Result { let _switch_guard = - futures::executor::block_on(crate::mode::controller::lock_settled(state, &app_type)); + futures::executor::block_on(crate::mode::controller::lock_settled(state, &app_type))?; let existing = state .db .get_provider_by_id(&provider.id, app_type.as_str())?; @@ -5208,11 +5248,7 @@ impl ProviderService { } }); if let Err(error) = written { - let pending = match app_type { - AppType::Gemini => gemini_direct::has_pending(), - _ => grok_direct::has_pending(), - }; - if !pending { + if !crate::mode::operation::has_pending(app_type.as_str()) { Self::restore_row(state, &app_type, &provider.id, existing.as_ref()); } return Err(error); @@ -5248,7 +5284,7 @@ impl ProviderService { ) -> Result { let app_type = AppType::Claude; let _switch_guard = - futures::executor::block_on(crate::mode::controller::lock_settled(state, &app_type)); + futures::executor::block_on(crate::mode::controller::lock_settled(state, &app_type))?; let mut provider = provider; Self::normalize_provider_if_claude(&app_type, &mut provider); let plan = claude_editor::plan_save(None, &provider.settings_config, &editor.base)?; @@ -5276,7 +5312,7 @@ impl ProviderService { if let Err(error) = claude_editor::write_live(state.db.as_ref(), &plan, editor.on_conflict, key_fields) { - if !claude_direct::has_pending() { + if !crate::mode::operation::has_pending(AppType::Claude.as_str()) { Self::restore_row(state, &app_type, &provider.id, existing.as_ref()); } return Err(error); @@ -5293,7 +5329,7 @@ impl ProviderService { ) -> Result { let app_type = AppType::Claude; let _switch_guard = - futures::executor::block_on(crate::mode::controller::lock_settled(state, &app_type)); + futures::executor::block_on(crate::mode::controller::lock_settled(state, &app_type))?; let existing = state .db .get_provider_by_id(&provider.id, app_type.as_str())?; @@ -5340,7 +5376,7 @@ impl ProviderService { } }); if let Err(error) = written { - if !claude_direct::has_pending() { + if !crate::mode::operation::has_pending(AppType::Claude.as_str()) { Self::restore_row(state, &app_type, &provider.id, existing.as_ref()); } return Err(error); @@ -5362,19 +5398,10 @@ impl ProviderService { let mut provider = provider; let original_id = original_id.unwrap_or(provider.id.as_str()).to_string(); let provider_id_changed = original_id != provider.id; - // Serialize the read/decide/commit window for every Codex update. We do - // not yet know whether the stored row is managed (the request may be an - // unbind), so the existing row and effective current must both be read - // only after this lock is held. Non-managed Codex updates release it - // before entering the legacy path, whose proxy helpers take the lock - // themselves. - let codex_update_switch_guard = if matches!(app_type, AppType::Codex) { - Some(futures::executor::block_on( - crate::mode::controller::lock_settled(state, &app_type), - )) - } else { - None - }; + // 读旧行、判断谁是当前供应商、存行、写 live 都在切换锁里:进入代理和切换都等这次 + // 保存写完,这里也不会在读完模式之后、写 live 之前被它们改掉模式(否则直连的关键 + // 字段会盖掉刚写的代理契约)。Codex 还要在锁里读旧行,看它是不是托管账号的行。 + let _switch_guard = crate::mode::controller::lock_settled_blocking(state, &app_type)?; let existing_provider = state .db .get_provider_by_id(&original_id, app_type.as_str())?; @@ -5546,8 +5573,6 @@ impl ProviderService { ); } - drop(codex_update_switch_guard); - // Save to database state.db.save_provider(app_type.as_str(), &provider)?; @@ -5619,7 +5644,7 @@ impl ProviderService { .map(|_| ()) }; if let Err(error) = written { - if codex_direct::has_pending() { + if crate::mode::operation::has_pending(AppType::Codex.as_str()) { return Err(error); } if let Some(existing) = existing { @@ -5814,7 +5839,7 @@ impl ProviderService { let _switch_guard = if app_type.supports_local_proxy() { Some(futures::executor::block_on( crate::mode::controller::lock_settled(state, &app_type), - )) + )?) } else { None }; @@ -6074,6 +6099,7 @@ impl ProviderService { return sync_current_provider_for_app_to_live(state, &app_type); } + let switch_guard = crate::mode::controller::lock_settled_blocking(state, &app_type)?; let current_id = match crate::mode::current::provider_for( &state.db, &app_type, @@ -6090,6 +6116,7 @@ impl ProviderService { let outcome = live::sync_live_for_provider_respecting_mode(state, &app_type, provider, None)?; + drop(switch_guard); if outcome == LiveSyncOutcome::ProxyMode { return Ok(()); } diff --git a/src-tauri/tests/provider_service.rs b/src-tauri/tests/provider_service.rs index dcfa7ca5a..bca93f79b 100644 --- a/src-tauri/tests/provider_service.rs +++ b/src-tauri/tests/provider_service.rs @@ -3430,6 +3430,93 @@ fn claude_editor_leaves_exclusive_fields_from_live_to_the_user() { ); } +/// 存量 Bedrock API Key 行在编辑器里保存后,Key 还在顶层 `apiKey`:旧版的代理只从那里 +/// 读,降级后照样能用。 +#[test] +fn claude_editor_keeps_a_legacy_bedrock_key_where_older_versions_read_it() { + let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner()); + reset_test_fs(); + let _home = ensure_test_home(); + + let legacy = json!({ + "apiKey": "bedrock-key", + "env": { "CLAUDE_CODE_USE_BEDROCK": "1", "AWS_REGION": "us-east-1" } + }); + let state = seed_claude_switch_state(&[("bedrock", legacy.clone())], "bedrock", "{}"); + ProviderService::switch(&state, AppType::Claude, "bedrock").expect("switch"); + + let (row, base) = open_claude_editor(&state, "bedrock"); + assert_eq!( + base["env"]["AWS_BEARER_TOKEN_BEDROCK"], + json!("bedrock-key") + ); + save_claude_editor(&state, &row, &base, base.clone(), "refuse").expect("save as is"); + assert_eq!(claude_row(&state, "bedrock"), legacy); + + let (row, base) = open_claude_editor(&state, "bedrock"); + let mut edited = base.clone(); + edited["env"]["AWS_BEARER_TOKEN_BEDROCK"] = json!("rotated-key"); + save_claude_editor(&state, &row, &base, edited, "refuse").expect("rotate the key"); + let stored = claude_row(&state, "bedrock"); + assert_eq!(stored["apiKey"], json!("rotated-key"), "{stored}"); + assert!(stored["env"].get("AWS_BEARER_TOKEN_BEDROCK").is_none()); + assert_eq!( + claude_live()["env"]["AWS_BEARER_TOKEN_BEDROCK"], + json!("rotated-key") + ); +} + +/// 旧版没有 `category` 的 Google 卡:编辑器按库里那一行认出官方卡,预览的登录方式和切换 +/// 写的相同;新增时没有行,只看草稿的 `category`。 +#[test] +fn gemini_editor_recognizes_a_legacy_google_card_like_switching_does() { + let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner()); + reset_test_fs(); + let home = ensure_test_home(); + + let state = create_test_state().expect("create test state"); + let mut google = Provider::with_id( + "google".to_string(), + "Google".to_string(), + json!({ "env": {} }), + None, + ); + google.category = None; + state + .db + .save_provider(AppType::Gemini.as_str(), &google) + .expect("save legacy google card"); + + let category = ProviderService::editor_category(&state, &AppType::Gemini, Some("google"), None) + .expect("category"); + assert_eq!(category.as_deref(), Some("official")); + let shown = ProviderService::editor_view( + &state, + AppType::Gemini, + &google.settings_config, + category.as_deref(), + ) + .expect("view") + .settings; + ProviderService::switch(&state, AppType::Gemini, "google").expect("switch"); + let live = read_json_file::(&home.join(".gemini/settings.json")) + .expect("read gemini settings"); + assert_eq!( + shown.pointer("/config/security/auth/selectedType"), + live.pointer("/security/auth/selectedType"), + ); + assert_eq!( + live.pointer("/security/auth/selectedType"), + Some(&json!("oauth-personal")) + ); + + assert_eq!( + ProviderService::editor_category(&state, &AppType::Gemini, None, Some("third".into())) + .expect("category for a draft"), + Some("third".to_string()) + ); +} + /// 新增对话框:底是还没套预设的 live,预设带的独有字段归新供应商,live 里用户自己的 /// 独有字段不归它。 #[test] diff --git a/src/components/providers/AddProviderDialog.tsx b/src/components/providers/AddProviderDialog.tsx index 872fd92f2..adedffccd 100644 --- a/src/components/providers/AddProviderDialog.tsx +++ b/src/components/providers/AddProviderDialog.tsx @@ -89,11 +89,17 @@ export function AddProviderDialog({ > | null>(null); const [claudeBaseLoaded, setClaudeBaseLoaded] = useState(false); // Codex、Gemini CLI、Grok Build:表单把预设投影到当前配置文件上显示,投影结果就是保存时 - // 三方比较的底(投影进行中或失败时为 null,保存只存供应商)。 - const [draftEditorBase, setDraftEditorBase] = useState | null>(null); + // 三方比较的底(投影进行中或失败时为 null,保存只存供应商)。投影成它的草稿一起留着, + // 后端按草稿分开预设带的字段和从 live 带进来的字段。 + const [draftEditorBase, setDraftEditorBase] = useState<{ + base: Record; + draft?: Record; + } | null>(null); + const handleDraftEditorBase = useCallback( + (base: Record | null, draft?: Record) => + setDraftEditorBase(base ? { base, draft } : null), + [], + ); const projectsDraft = DRAFT_EDITOR_APPS.includes(appId); const [pendingConflict, setPendingConflict] = useState<{ keys: string[]; @@ -419,16 +425,14 @@ export function AddProviderDialog({ const editorBase = appId === "claude" - ? claudeLiveBase + ? claudeLiveBase && { base: claudeLiveBase } : projectsDraft ? draftEditorBase : null; const submit = async (onConflict: EditorConflictPolicy) => { await onSubmit({ ...providerData, - ...(editorBase - ? { editorSave: { base: editorBase, onConflict } } - : {}), + ...(editorBase ? { editorSave: { ...editorBase, onConflict } } : {}), }); closeDialog(); }; @@ -554,7 +558,7 @@ export function AddProviderDialog({ showButtons={false} claudeLiveBase={claudeLiveBase ?? undefined} onEditorBaseChange={ - projectsDraft ? setDraftEditorBase : undefined + projectsDraft ? handleDraftEditorBase : undefined } /> )} @@ -575,7 +579,7 @@ export function AddProviderDialog({ onSubmittingChange={setIsFormSubmitting} onSubmitReadyChange={handleSubmitReadyChange} showButtons={false} - onEditorBaseChange={projectsDraft ? setDraftEditorBase : undefined} + onEditorBaseChange={projectsDraft ? handleDraftEditorBase : undefined} /> )} diff --git a/src/components/providers/EditProviderDialog.tsx b/src/components/providers/EditProviderDialog.tsx index be5f3ca18..5199ab36d 100644 --- a/src/components/providers/EditProviderDialog.tsx +++ b/src/components/providers/EditProviderDialog.tsx @@ -147,6 +147,7 @@ export function EditProviderDialog({ appId, asRecord(provider.settingsConfig) ?? {}, provider.category, + provider.id, ); if (!cancelled) { setEditorView(view); diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 35a265435..706df6995 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -88,6 +88,7 @@ import { parseOmoOtherFieldsObject } from "@/types/omo"; import { useProviderCategory, useDraftEditorProjection, + type EditorBaseChange, useApiKeyState, useBaseUrlState, useModelState, @@ -275,7 +276,7 @@ export interface ProviderFormProps { * Codex、Gemini CLI、Grok Build 新增:预设或模板投影到当前配置文件上之后的内容,保存时 * 作为三方比较的底;投影进行中或失败时为 `null`。 */ - onEditorBaseChange?: (base: Record | null) => void; + onEditorBaseChange?: EditorBaseChange; } export function ProviderForm(props: ProviderFormProps) { diff --git a/src/components/providers/forms/hooks/index.ts b/src/components/providers/forms/hooks/index.ts index 1b5d72270..ec1188443 100644 --- a/src/components/providers/forms/hooks/index.ts +++ b/src/components/providers/forms/hooks/index.ts @@ -17,4 +17,7 @@ export { useHermesFormState } from "./useHermesFormState"; export { useCopilotAuth } from "./useCopilotAuth"; export { useCodexOauth } from "./useCodexOauth"; export { useXaiOauth } from "./useXaiOauth"; -export { useDraftEditorProjection } from "./useDraftEditorProjection"; +export { + useDraftEditorProjection, + type EditorBaseChange, +} from "./useDraftEditorProjection"; diff --git a/src/components/providers/forms/hooks/useDraftEditorProjection.ts b/src/components/providers/forms/hooks/useDraftEditorProjection.ts index e86363c1c..58dafab72 100644 --- a/src/components/providers/forms/hooks/useDraftEditorProjection.ts +++ b/src/components/providers/forms/hooks/useDraftEditorProjection.ts @@ -4,6 +4,15 @@ import { toast } from "sonner"; import { providersApi, type AppId } from "@/lib/api"; import { extractErrorMessage } from "@/utils/errorUtils"; +/** + * 投影出的底,和投影成它的那份草稿(预设或模板)。投影进行中或失败时底为 `null`。保存时 + * 两者一起交给后端:草稿里没有、底里有的字段是从 live 带进来的,不归新供应商。 + */ +export type EditorBaseChange = ( + base: Record | null, + draft?: Record, +) => void; + /** * 新增对话框(Codex、Gemini CLI、Grok Build):把预设或模板投影到当前配置文件上显示,和 * 编辑器同一套规则(投影在后端算)。投影结果交给 `onEditorBaseChange`,保存时作为三方 @@ -11,7 +20,7 @@ import { extractErrorMessage } from "@/utils/errorUtils"; */ export function useDraftEditorProjection( appId: AppId, - onEditorBaseChange?: (base: Record | null) => void, + onEditorBaseChange?: EditorBaseChange, ) { const { t } = useTranslation(); // 连续切换预设时只认最后一次请求。 @@ -38,7 +47,7 @@ export function useDraftEditorProjection( .then((view) => { if (current !== sequence.current) return; apply(view.settings); - onEditorBaseChange(view.settings); + onEditorBaseChange(view.settings, settings); }) .catch((error: unknown) => { if (current !== sequence.current) return; diff --git a/src/lib/api/providers.ts b/src/lib/api/providers.ts index cb833af04..9ff751bd1 100644 --- a/src/lib/api/providers.ts +++ b/src/lib/api/providers.ts @@ -46,6 +46,8 @@ export type EditorConflictPolicy = "refuse" | "keepMine" | "keepTheirs"; /** 编辑器保存随供应商一起提交:打开时显示的完整配置,用来三方比较。 */ export interface ProviderEditorSave { base: Record; + /** 新增对话框:投影成 `base` 的那份草稿(预设或模板)。后端按它区分预设带的和从 live 带进来的字段。 */ + draft?: Record; onConflict?: EditorConflictPolicy; } @@ -104,16 +106,18 @@ export const providersApi = { }); }, - /** 切换式应用(Claude Code、Codex、Gemini CLI、Grok Build)的编辑器显示内容。`settingsConfig` 是供应商的行,新增时传空对象。 */ + /** 切换式应用(Claude Code、Codex、Gemini CLI、Grok Build)的编辑器显示内容。`settingsConfig` 是供应商的行,新增时传空对象。编辑已有供应商时传 `providerId`:后端按库里那一行认官方卡,和切换的判断一致。 */ async getEditorView( appId: AppId, settingsConfig: Record, category?: string, + providerId?: string, ): Promise { return await invoke("get_provider_editor_view", { app: appId, settingsConfig, ...(category ? { category } : {}), + ...(providerId ? { providerId } : {}), }); }, diff --git a/tests/components/AddProviderDialog.test.tsx b/tests/components/AddProviderDialog.test.tsx index 1ab462494..eb99c0ebb 100644 --- a/tests/components/AddProviderDialog.test.tsx +++ b/tests/components/AddProviderDialog.test.tsx @@ -36,6 +36,8 @@ let mockFormValues: ProviderFormValues; let mockFormReady = true; // 表单把预设投影到配置文件上之后交给对话框的底(Codex、Gemini CLI、Grok Build)。 let mockProjectedBase: Record | null = null; +// 投影成那份底的草稿(预设或模板)。 +let mockProjectedDraft: Record | undefined; let submitReadyCallbacks: Array<(isReady: boolean) => void> = []; vi.mock("@/components/providers/forms/ProviderForm", () => ({ @@ -48,7 +50,10 @@ vi.mock("@/components/providers/forms/ProviderForm", () => ({ onSubmit: (values: ProviderFormValues) => void; onSubmitReadyChange?: (isReady: boolean) => void; onManageAuthAccounts?: (target: "codex_oauth") => void; - onEditorBaseChange?: (base: Record | null) => void; + onEditorBaseChange?: ( + base: Record | null, + draft?: Record, + ) => void; }) => { useEffect(() => { if (onSubmitReadyChange) { @@ -57,7 +62,7 @@ vi.mock("@/components/providers/forms/ProviderForm", () => ({ } }, [onSubmitReadyChange]); useEffect(() => { - onEditorBaseChange?.(mockProjectedBase); + onEditorBaseChange?.(mockProjectedBase, mockProjectedDraft); }, [onEditorBaseChange]); return (
{ beforeEach(() => { mockFormReady = true; mockProjectedBase = null; + mockProjectedDraft = undefined; submitReadyCallbacks = []; mockFormValues = { name: "Test Provider", @@ -180,7 +186,9 @@ describe("AddProviderDialog", () => { async (appId) => { const handleSubmit = vi.fn().mockResolvedValue(undefined); const projected = { config: "[ui]\ntheme = \"dark\"\n" }; + const draft = { config: "" }; mockProjectedBase = projected; + mockProjectedDraft = draft; mockFormValues = { name: "Draft", websiteUrl: "", @@ -202,6 +210,7 @@ describe("AddProviderDialog", () => { await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1)); expect(handleSubmit.mock.calls[0][0].editorSave).toEqual({ base: projected, + draft, onConflict: "refuse", }); }, diff --git a/tests/components/EditProviderDialog.test.tsx b/tests/components/EditProviderDialog.test.tsx index 6a3882740..c8a047eaa 100644 --- a/tests/components/EditProviderDialog.test.tsx +++ b/tests/components/EditProviderDialog.test.tsx @@ -196,6 +196,7 @@ describe("EditProviderDialog", () => { "codex", provider.settingsConfig, "aggregator", + provider.id, ); expect(apiMocks.getLiveProviderSettings).not.toHaveBeenCalled(); @@ -255,6 +256,7 @@ describe("EditProviderDialog", () => { appId, provider.settingsConfig, "custom", + provider.id, ); expect(apiMocks.getCurrent).not.toHaveBeenCalled(); expect(apiMocks.getLiveProviderSettings).not.toHaveBeenCalled(); @@ -340,6 +342,7 @@ describe("EditProviderDialog", () => { "codex", provider.settingsConfig, "custom", + provider.id, ); }); diff --git a/tests/hooks/useDraftEditorProjection.test.tsx b/tests/hooks/useDraftEditorProjection.test.tsx index f42a8ea2f..28e3bbb49 100644 --- a/tests/hooks/useDraftEditorProjection.test.tsx +++ b/tests/hooks/useDraftEditorProjection.test.tsx @@ -64,7 +64,8 @@ describe("useDraftEditorProjection", () => { expect(apply).toHaveBeenCalledTimes(1); expect(apply).toHaveBeenCalledWith({ config: "B" }); - expect(onBase).toHaveBeenLastCalledWith({ config: "B" }); + // 底和投影成它的草稿一起交出去,保存时后端按草稿分开预设带的字段。 + expect(onBase).toHaveBeenLastCalledWith({ config: "B" }, { config: "b" }); }); it("投影失败时提示并保持没有底", async () => {