From d0bd5a890a7f47d6dd402cae478cb6320f739ce3 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sun, 23 Aug 2026 16:19:03 +0800 Subject: [PATCH] Dev (#162) * chore(release): stop publishing legacy Windows update packages * feat(agents): support OpenCode Desktop across platforms Detect CLI and desktop installations on Windows, macOS, and Linux, and add separate launch and restart controls. Closes #156 * fix(codex): align model display names and show total session count - preserve runtime model identity in generated catalogs - count distinct sessions across database and rollout sources - update session count labels in all supported languages * fix(codex): keep config and auth files in sync - generate auth.json for API key configuration - preserve and validate OAuth credentials - include auth.json in transactional updates and rollback - retain compatibility with legacy Codex configuration states * fix(cpa-gui): update version to 0.2.59 in Cargo files --- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/agents/configuration.rs | 27 ++++++++++ src-tauri/src/agents/discovery.rs | 27 +++++++++- src-tauri/src/agents/state.rs | 8 +++ src-tauri/src/codex_catalog.rs | 32 +++++++++--- src-tauri/src/codex_sessions.rs | 61 +++++++++++++++++++--- src-tauri/src/tests/agent_configuration.rs | 29 ++++++++++ src-tauri/src/tests/agent_state.rs | 23 ++++++++ src-tauri/src/tests/agent_transactions.rs | 5 +- src/i18n/ja.ts | 2 +- src/i18n/locales/en.ts | 2 +- src/i18n/locales/zh-CN.ts | 2 +- src/pages/CodexSessionsPanel.tsx | 2 +- src/services/codexSessionState.ts | 1 + 15 files changed, 205 insertions(+), 20 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c717eab..5b0597b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -593,7 +593,7 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" [[package]] name = "cpa-gui" -version = "0.2.26" +version = "0.2.59" dependencies = [ "base64 0.22.1", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e83af1f..c1c66a9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "cpa-gui" # Single source of truth for the desktop app version. -version = "0.2.26" +version = "0.2.59" description = "CPA desktop management shell" authors = ["lzt404"] edition = "2021" diff --git a/src-tauri/src/agents/configuration.rs b/src-tauri/src/agents/configuration.rs index 81f1c34..d6a8b6c 100644 --- a/src-tauri/src/agents/configuration.rs +++ b/src-tauri/src/agents/configuration.rs @@ -145,6 +145,7 @@ pub(crate) fn build_agent_updates_with_oauth( path: codex_model_catalog_path(home), after: catalog.to_string(), }); + updates.push(build_codex_auth_update(home, api_key, oauth_configuration)?); Ok(updates) } AgentClient::OpenCode => { @@ -2524,6 +2525,32 @@ pub(crate) fn build_codex_agent_config_with_oauth( Ok(rendered) } +pub(crate) fn build_codex_api_auth(api_key: &str) -> Result { + let mut rendered = serde_json::to_string_pretty(&serde_json::json!({ + "auth_mode": "apikey", + "OPENAI_API_KEY": api_key, + })) + .map_err(|error| format!("生成 Codex auth.json 失败: {error}"))?; + rendered.push('\n'); + Ok(rendered) +} + +pub(crate) fn build_codex_auth_update( + home: &Path, + api_key: &str, + oauth_configuration: bool, +) -> Result { + let path = codex_configuration_directory(home).join("auth.json"); + let after = if oauth_configuration { + validate_codex_oauth_login_at(&path)?; + read_optional_text(&path)? + .ok_or_else(|| "Codex OAuth 登录凭据在应用配置前已被删除,请重新登录".to_string())? + } else { + build_codex_api_auth(api_key)? + }; + Ok(AgentFileUpdate { path, after }) +} + const CODEX_MANAGED_ROOT_KEYS: [&str; 3] = ["model_provider", "model", "model_catalog_json"]; const CODEX_MANAGED_PROVIDER_KEYS: [&str; 5] = [ "name", diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index 9871954..e5d71c7 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -686,6 +686,23 @@ pub(crate) fn codex_auth_file_has_oauth_tokens(path: &Path) -> bool { }) } +pub(crate) fn codex_auth_file_has_api_key(path: &Path, api_key: &str) -> bool { + let Ok(content) = fs::read_to_string(path) else { + return false; + }; + let Ok(root) = serde_json::from_str::(&content) else { + return false; + }; + let api_key_matches = root + .get("OPENAI_API_KEY") + .and_then(serde_json::Value::as_str) + .is_some_and(|value| value == api_key); + let mode_matches = root + .get("auth_mode") + .is_none_or(|value| value.as_str() == Some("apikey")); + api_key_matches && mode_matches +} + pub(crate) fn validate_codex_oauth_login(home: &Path) -> Result<(), String> { validate_codex_oauth_login_at(&codex_configuration_directory(home).join("auth.json")) } @@ -738,6 +755,7 @@ pub(crate) fn expected_agent_record_paths(client: AgentClient, paths: &[PathBuf] if client == AgentClient::Codex && paths.len() == 1 { let mut expected = paths.to_vec(); expected.push(paths[0].with_file_name(CODEX_MODEL_CATALOG_FILE)); + expected.push(paths[0].with_file_name("auth.json")); expected } else { paths.to_vec() @@ -3187,7 +3205,13 @@ pub(crate) fn inspect_codex_agent_config( .and_then(|provider| provider.get("requires_openai_auth")) .and_then(toml::Value::as_bool) == Some(true); - let configured = root.get("model_provider").and_then(toml::Value::as_str) + let auth_path = path.with_file_name("auth.json"); + let auth_configured = if oauth_configuration { + codex_auth_file_has_oauth_tokens(&auth_path) + } else { + codex_auth_file_has_api_key(&auth_path, api_key) + }; + let configured_without_auth = root.get("model_provider").and_then(toml::Value::as_str) == Some(MANAGED_AGENT_PROVIDER_ID) && root.get("model_catalog_json").and_then(toml::Value::as_str) == Some(CODEX_MODEL_CATALOG_FILE) @@ -3204,6 +3228,7 @@ pub(crate) fn inspect_codex_agent_config( .and_then(|provider| provider.get("wire_api")) .and_then(toml::Value::as_str) == Some("responses"); + let configured = configured_without_auth && auth_configured; let model = root .get("model") .and_then(toml::Value::as_str) diff --git a/src-tauri/src/agents/state.rs b/src-tauri/src/agents/state.rs index d1eed97..f0febf7 100644 --- a/src-tauri/src/agents/state.rs +++ b/src-tauri/src/agents/state.rs @@ -189,7 +189,14 @@ pub(crate) fn validate_agent_applied_state( .iter() .map(|file| file.path.clone()) .collect::>(); + let codex_paths_without_auth = (client == AgentClient::Codex + && expected_paths + .last() + .and_then(|path| path.file_name()) + .is_some_and(|name| name == "auth.json")) + .then(|| &expected_paths[..expected_paths.len().saturating_sub(1)]); let valid_paths = state_paths == expected_paths + || codex_paths_without_auth.is_some_and(|expected| state_paths == expected) || (client == AgentClient::Codex && state_paths.as_slice() == paths) || (client == AgentClient::ZCode && paths @@ -1686,6 +1693,7 @@ pub(crate) fn reset_agent_configuration_to_default_with_oauth( path: codex_model_catalog_path(home), after: catalog.to_string(), }); + updates.push(build_codex_auth_update(home, api_key, oauth_configuration)?); } commit_agent_configuration( client, diff --git a/src-tauri/src/codex_catalog.rs b/src-tauri/src/codex_catalog.rs index fa6109c..3a201c4 100644 --- a/src-tauri/src/codex_catalog.rs +++ b/src-tauri/src/codex_catalog.rs @@ -145,7 +145,7 @@ pub(crate) fn parse_runtime_models(payload: &Value) -> Result>(), vec!["gpt-5.5", "gpt-5.5-xhigh", "visible-alias"] ); - assert_eq!(models[1].display_name.as_deref(), Some("gpt-5.5")); + assert_eq!(models[1].display_name, None); assert_eq!(models[1].context_window, Some(200_000)); - assert_eq!(models[2].display_name.as_deref(), Some("hidden-source")); + assert_eq!(models[2].display_name, None); let catalog = prepare_catalog_with_sources(&models, &test_sources()).unwrap(); assert_eq!( @@ -890,7 +899,7 @@ mod tests { .collect::>(), vec!["gpt-5.5", "gpt-5.5-xhigh", "visible-alias"] ); - assert_eq!(catalog.models[1].alias.as_deref(), Some("gpt-5.5")); + assert_eq!(catalog.models[1].alias, None); } #[test] @@ -975,7 +984,7 @@ mod tests { } #[test] - fn known_template_is_preserved_except_for_runtime_slug() { + fn known_template_is_preserved_except_for_runtime_identity() { let sources = test_sources(); let runtime = runtime(serde_json::json!({"data":[{ "id":"a", @@ -989,12 +998,13 @@ mod tests { for (key, expected) in template { if !matches!( key.as_str(), - "slug" | "service_tiers" | "additional_speed_tiers" + "slug" | "display_name" | "service_tiers" | "additional_speed_tiers" ) { assert_eq!(model.get(key), Some(expected), "changed field {key}"); } } assert_eq!(model["slug"], "a"); + assert_eq!(model["display_name"], "Overwrite"); assert_eq!(model["nested"]["unknown"], true); assert_eq!( model["service_tiers"], @@ -1007,6 +1017,16 @@ mod tests { assert_eq!(model["additional_speed_tiers"], serde_json::json!(["fast"])); } + #[test] + fn known_template_uses_runtime_slug_when_display_name_is_missing() { + let runtime = runtime(serde_json::json!({"models":[{"id":"A"}]})); + let catalog = prepare_catalog_with_sources(&runtime, &test_sources()).unwrap(); + let model = &output_models(&catalog)[0]; + + assert_eq!(model["slug"], "A"); + assert_eq!(model["display_name"], "A"); + } + #[test] fn fallback_accepts_only_safe_valid_metadata() { let runtime = runtime(serde_json::json!({"models":[{ diff --git a/src-tauri/src/codex_sessions.rs b/src-tauri/src/codex_sessions.rs index f7bfcb3..e2d53d2 100644 --- a/src-tauri/src/codex_sessions.rs +++ b/src-tauri/src/codex_sessions.rs @@ -71,6 +71,7 @@ pub(crate) struct CodexSessionPage { codex_home: String, database_paths: Vec, sessions: Vec, + total_count: usize, offset: usize, limit: usize, has_more: bool, @@ -446,15 +447,26 @@ fn list_codex_sessions_from_home( let fetch_limit = offset.saturating_add(limit).saturating_add(1); let (database_paths, mut warnings) = discover_database_paths(codex_home, false); let mut sessions = Vec::new(); + let mut total_session_ids = HashSet::new(); for path in &database_paths { match list_sessions_from_database(path, fetch_limit) { Ok(mut items) => sessions.append(&mut items), Err(error) => warnings.push(error), } + match list_session_ids_from_database(path) { + Ok(ids) => total_session_ids.extend(ids), + Err(error) => warnings.push(error), + } } let (mut rollout_sessions, rollout_warnings) = list_sessions_from_rollouts(codex_home); sessions.append(&mut rollout_sessions); warnings.extend(rollout_warnings); + total_session_ids.extend( + sessions + .iter() + .map(|session| session_identity_key(&session.id)) + .filter(|id| !id.is_empty()), + ); let mut sessions = merge_session_summaries(sessions); sessions.sort_by(|left, right| { right @@ -472,6 +484,7 @@ fn list_codex_sessions_from_home( .map(|path| path.to_string_lossy().to_string()) .collect(), sessions, + total_count: total_session_ids.len(), offset, limit, has_more, @@ -483,12 +496,7 @@ fn merge_session_summaries(sessions: Vec) -> Vec::new(); let mut positions = HashMap::::new(); for session in sessions { - let key = normalize_thread_id(&session.id); - let key = if key.is_empty() { - session.id.clone() - } else { - key - }; + let key = session_identity_key(&session.id); let Some(index) = positions.get(&key).copied() else { positions.insert(key, merged.len()); merged.push(session); @@ -719,6 +727,36 @@ fn list_sessions_from_database( Ok(sessions) } +fn list_session_ids_from_database(path: &Path) -> Result, String> { + let connection = open_read_only(path)?; + let mut ids = HashSet::new(); + for (table, column) in [("threads", "id"), ("automation_runs", "thread_id")] { + let columns = table_columns(&connection, table)?; + if !columns.contains(column) { + continue; + } + let sql = format!( + "SELECT DISTINCT \"{column}\" FROM \"{table}\" WHERE COALESCE(\"{column}\", '') <> ''" + ); + let mut statement = connection + .prepare(&sql) + .map_err(|error| format!("统计会话总数失败 {}: {error}", path.display()))?; + let rows = statement + .query_map([], |row| row.get::<_, String>(0)) + .map_err(|error| format!("统计会话总数失败 {}: {error}", path.display()))?; + for id in rows + .collect::>>() + .map_err(|error| format!("统计会话总数失败 {}: {error}", path.display()))? + { + let id = session_identity_key(&id); + if !id.is_empty() { + ids.insert(id); + } + } + } + Ok(ids) +} + fn list_thread_rows( connection: &Connection, path: &Path, @@ -822,6 +860,15 @@ fn normalize_thread_id(value: &str) -> String { .to_string() } +fn session_identity_key(value: &str) -> String { + let normalized = normalize_thread_id(value); + if normalized.is_empty() { + value.trim().to_string() + } else { + normalized + } +} + fn delete_codex_sessions_from_home( codex_home: &Path, session_ids: Vec, @@ -2421,6 +2468,7 @@ mod tests { let page = list_codex_sessions_from_home(&root, 0, 50).unwrap(); assert_eq!(page.sessions.len(), 2); + assert_eq!(page.total_count, 2); assert_eq!(page.sessions[0].id, "thread-1"); assert_eq!(page.sessions[0].title, "First new"); assert_eq!(page.sessions[0].updated_at_ms, Some(4000)); @@ -2428,6 +2476,7 @@ mod tests { assert!(page.sessions[1].archived); let second_page = list_codex_sessions_from_home(&root, 1, 1).unwrap(); assert_eq!(second_page.sessions.len(), 1); + assert_eq!(second_page.total_count, 2); assert_eq!(second_page.sessions[0].id, "thread-2"); assert!(!second_page.has_more); fs::remove_dir_all(root).unwrap(); diff --git a/src-tauri/src/tests/agent_configuration.rs b/src-tauri/src/tests/agent_configuration.rs index 2b125e5..5d71d6e 100644 --- a/src-tauri/src/tests/agent_configuration.rs +++ b/src-tauri/src/tests/agent_configuration.rs @@ -332,6 +332,11 @@ fn codex_oauth_configuration_uses_openai_auth_with_bearer_token() { fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(&path, rendered).unwrap(); + fs::write( + path.with_file_name("auth.json"), + r#"{"tokens":{"access_token":"oauth-access-token"}}"#, + ) + .unwrap(); let (configured, model, oauth_configuration) = inspect_codex_agent_config(&path, 8317, DEFAULT_API_KEY).unwrap(); assert!(configured); @@ -343,6 +348,12 @@ fn codex_oauth_configuration_uses_openai_auth_with_bearer_token() { #[test] fn codex_api_and_oauth_modes_write_the_same_catalog() { let home = agent_test_home("codex-auth-mode-catalog"); + fs::create_dir_all(home.join(".codex")).unwrap(); + fs::write( + home.join(".codex/auth.json"), + r#"{"tokens":{"access_token":"oauth-access-token"}}"#, + ) + .unwrap(); let models = test_agent_models(&["gpt-5.5", "third-party-model"]); let catalog = test_codex_models(&["gpt-5.5", "third-party-model"]); let build = |oauth_configuration| { @@ -366,6 +377,19 @@ fn codex_api_and_oauth_modes_write_the_same_catalog() { let oauth_updates = build(true); assert_eq!(api_updates[1].after, oauth_updates[1].after); + assert_eq!( + serde_json::from_str::(&api_updates[2].after).unwrap()["OPENAI_API_KEY"], + DEFAULT_API_KEY + ); + assert_eq!( + serde_json::from_str::(&api_updates[2].after).unwrap()["auth_mode"], + "apikey" + ); + assert!( + serde_json::from_str::(&oauth_updates[2].after).unwrap()["tokens"] + ["access_token"] + .is_string() + ); assert!(api_updates[0].after.contains("model_catalog_json")); assert!(oauth_updates[0].after.contains("model_catalog_json")); assert!(!api_updates[0].after.contains("service_tier")); @@ -394,6 +418,11 @@ fn codex_status_requires_a_valid_catalog_containing_the_default_model() { .unwrap(), ) .unwrap(); + fs::write( + config_path.with_file_name("auth.json"), + build_codex_api_auth(DEFAULT_API_KEY).unwrap(), + ) + .unwrap(); let missing = inspect_agent_config(AgentClient::Codex, &home, 8317, DEFAULT_API_KEY); assert!(!missing.configured); diff --git a/src-tauri/src/tests/agent_state.rs b/src-tauri/src/tests/agent_state.rs index aec01db..90be442 100644 --- a/src-tauri/src/tests/agent_state.rs +++ b/src-tauri/src/tests/agent_state.rs @@ -1324,6 +1324,11 @@ fn direct_agent_apply_preserves_unrelated_fields_and_default_reset_removes_them( assert!(applied.contains("[model_providers.other]")); assert!(applied.contains("model = \"gpt-test\"")); assert!(applied.contains("experimental_bearer_token = \"custom-agent-key\"")); + let auth_path = home.join(".codex/auth.json"); + let auth = serde_json::from_str::(&fs::read_to_string(&auth_path).unwrap()) + .unwrap(); + assert_eq!(auth["OPENAI_API_KEY"], api_key); + assert_eq!(auth["auth_mode"], "apikey"); assert!(!agent_backup_path(&path).unwrap().exists()); assert!(!agent_backup_path(&codex_model_catalog_path(&home)) .unwrap() @@ -1400,6 +1405,24 @@ fn clear_codex_config_removes_only_requested_config_files_and_application_state( assert!(!state_path.exists()); assert_eq!(fs::read_to_string(&preserved_path).unwrap(), "keep"); assert!(clear_codex_config_files(&home).unwrap().is_empty()); + + let models = test_agent_models(&["gpt-test"]); + let catalog = test_codex_models(&["gpt-test"]); + apply_agent_configuration( + AgentClient::Codex, + &home, + 8317, + "reapplied-api-key", + "gpt-test", + &models, + Some(&catalog), + ) + .unwrap(); + assert!(config_path.is_file()); + let auth = serde_json::from_str::(&fs::read_to_string(&auth_path).unwrap()) + .unwrap(); + assert_eq!(auth["OPENAI_API_KEY"], "reapplied-api-key"); + assert_eq!(auth["auth_mode"], "apikey"); fs::remove_dir_all(home).unwrap(); } diff --git a/src-tauri/src/tests/agent_transactions.rs b/src-tauri/src/tests/agent_transactions.rs index f770b55..46f2a40 100644 --- a/src-tauri/src/tests/agent_transactions.rs +++ b/src-tauri/src/tests/agent_transactions.rs @@ -384,7 +384,7 @@ fn updating_legacy_codex_state_adds_catalog_without_replacing_original_backup() .unwrap() .unwrap(); assert_eq!(upgraded.version, AGENT_MODIFICATION_STATE_VERSION); - assert_eq!(upgraded.files.len(), 2); + assert_eq!(upgraded.files.len(), 3); assert_eq!( fs::read(agent_backup_path(&path).unwrap()).unwrap(), original_config @@ -394,10 +394,13 @@ fn updating_legacy_codex_state_adds_catalog_without_replacing_original_backup() original_catalog ); fs::write(&catalog_path, b"{\"models\":[]}\n").unwrap(); + let auth_path = path.with_file_name("auth.json"); + assert!(auth_path.is_file()); disable_agent_modification(AgentClient::Codex, &home, 8317, false).unwrap(); assert_eq!(fs::read(&path).unwrap(), original_config); assert_eq!(fs::read(&catalog_path).unwrap(), original_catalog); + assert!(!auth_path.exists()); fs::remove_dir_all(home).unwrap(); } diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 83a46ad..83cf406 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -541,7 +541,7 @@ export const jaOverrides = { 'agents.sessions.title': 'Codex セッション管理', 'agents.sessions.description': 'ローカルの Codex セッションをスキャンし、過去のセッションの復元、バックアップ、削除を安全に行います。', 'agents.sessions.refresh': '更新', - 'agents.sessions.currentPage': '現在のページ', + 'agents.sessions.total': 'セッション総数', 'agents.sessions.active': '未アーカイブ', 'agents.sessions.archived': 'アーカイブ済み', 'agents.sessions.database': 'データベース', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 2867d89..f455ed4 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -541,7 +541,7 @@ export const en: Record = { 'agents.sessions.title': 'Codex Session Management', 'agents.sessions.description': 'Scan local Codex sessions and safely restore historical sessions, back them up, or delete them.', 'agents.sessions.refresh': 'Refresh', - 'agents.sessions.currentPage': 'Current page', + 'agents.sessions.total': 'Total sessions', 'agents.sessions.active': 'Active', 'agents.sessions.archived': 'Archived', 'agents.sessions.database': 'Database source', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index e9552de..f3582f8 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -539,7 +539,7 @@ export const zhCN = { 'agents.sessions.title': 'Codex 会话管理', 'agents.sessions.description': '扫描本地 Codex 会话,并安全地恢复历史会话、备份或删除。', 'agents.sessions.refresh': '刷新', - 'agents.sessions.currentPage': '当前页', + 'agents.sessions.total': '会话总数', 'agents.sessions.active': '未归档', 'agents.sessions.archived': '已归档', 'agents.sessions.database': '数据库来源', diff --git a/src/pages/CodexSessionsPanel.tsx b/src/pages/CodexSessionsPanel.tsx index a892e1a..82fb472 100644 --- a/src/pages/CodexSessionsPanel.tsx +++ b/src/pages/CodexSessionsPanel.tsx @@ -287,7 +287,7 @@ export function CodexSessionsPanel() {
-
{t('agents.sessions.currentPage')}{sessions.length}
+
{t('agents.sessions.total')}{page?.totalCount ?? sessions.length}
{t('agents.sessions.active')}{pageCounts.active}
{t('agents.sessions.archived')}{pageCounts.archived}