From 83dd3b63040809efe9f56bc6a9505e2cc603b551 Mon Sep 17 00:00:00 2001 From: zoutao Date: Wed, 9 Sep 2026 17:19:53 +0800 Subject: [PATCH 1/3] feat(qoder): elevate Qoder into 4 independent first-class platforms with isolated storage and multi-instance support - Elevate Qoder into 4 physically isolated first-class platforms (qoder, qoder_app, qoder_cn_ide, qoder_cn_app), perfectly mirroring the Trae family architecture - Support SQLite state.vscdb injection for IDE products and DPAPI AES-256-GCM auth.v1.dat encryption for Desktop App products - Provide dedicated multi-instance creation, custom user data directories, and independent launch arguments - Implement dual OpenAPI endpoint probing (openapi.qoder.sh / openapi.qoder.com.cn) and auto token renewal on 401 - Decouple quick settings popover and general settings cards into single-platform configuration views - Normalize UI display names and provide full i18n support across English and Chinese --- crates/cockpit-core/src/models/qoder.rs | 3 + crates/cockpit-core/src/modules/config.rs | 38 +- crates/cockpit-core/src/modules/mod.rs | 2 + .../src/modules/process_core_discovery.rs | 5 +- .../src/modules/process_core_matching.rs | 33 +- .../cockpit-core/src/modules/qoder_account.rs | 681 +++++++++++++--- .../src/modules/qoder_app_auth.rs | 184 +++++ .../cockpit-core/src/modules/qoder_channel.rs | 333 ++++++++ .../cockpit-core/src/modules/qoder_oauth.rs | 521 +++++++++--- .../cockpit-core/src/modules/vscode_inject.rs | 8 +- src-tauri/src/commands/provider_current.rs | 10 +- src-tauri/src/commands/qoder.rs | 203 +++-- src-tauri/src/commands/qoder_instance.rs | 176 +++-- src-tauri/src/commands/system_app_commands.rs | 8 +- src-tauri/src/commands/system_config_types.rs | 14 +- .../src/commands/system_network_general.rs | 9 + src-tauri/src/lib.rs | 39 +- src-tauri/src/models/qoder.rs | 3 + src-tauri/src/modules/config.rs | 38 +- src-tauri/src/modules/floating_card_window.rs | 4 + .../macos_native_menu_platform_cards.rs | 7 +- src-tauri/src/modules/mod.rs | 2 + .../src/modules/process_close_lifecycle.rs | 44 +- .../src/modules/process_detection_matching.rs | 100 ++- .../src/modules/process_editor_launch.rs | 106 ++- .../src/modules/process_launch_candidates.rs | 61 +- .../src/modules/process_path_resolution.rs | 106 ++- .../src/modules/provider_current_state.rs | 3 + src-tauri/src/modules/qoder_account.rs | 740 +++++++++++++++--- src-tauri/src/modules/qoder_app_auth.rs | 184 +++++ src-tauri/src/modules/qoder_channel.rs | 338 ++++++++ src-tauri/src/modules/qoder_instance.rs | 178 +++-- src-tauri/src/modules/qoder_oauth.rs | 521 +++++++++--- src-tauri/src/modules/tray.rs | 100 ++- src-tauri/src/modules/tray_layout.rs | 11 +- src-tauri/src/modules/vscode_inject.rs | 8 +- src-tauri/src/modules/web_report.rs | 12 +- src/App.tsx | 36 +- src/assets/icons/qoder-app.png | Bin 0 -> 8000 bytes src/assets/icons/qoder-cn-app.png | Bin 0 -> 12129 bytes src/components/InstancesManager.tsx | 3 + src/components/PlatformLayoutModal.tsx | 11 +- src/components/QuickSettingsPopover.css | 18 + src/components/QuickSettingsPopover.tsx | 205 +++-- src/components/icons/QoderIcon.tsx | 26 +- src/components/layout/SideNav.tsx | 3 + .../platform/PlatformInstancesContent.tsx | 3 + .../platform/PlatformOverviewTabsHeader.tsx | 19 +- src/hooks/useAutoRefresh.ts | 10 +- src/locales/en-US.json | 27 +- src/locales/en.json | 26 +- src/locales/zh-CN.json | 27 +- src/pages/DashboardPage.tsx | 80 +- src/pages/FloatingCardWindow.tsx | 95 ++- src/pages/QoderAccountsPage.tsx | 211 +++-- src/pages/QoderInstancesPage.tsx | 26 +- src/pages/SettingsGeneralPanel.tsx | 143 +++- src/pages/SettingsPage.tsx | 66 +- .../platformAccountPresentation.ts | 10 +- src/services/dataTransferService.ts | 15 + src/services/providerCurrentAccountService.ts | 3 + src/services/qoderInstanceService.ts | 84 +- src/services/qoderService.ts | 94 ++- src/stores/usePlatformLayoutStore.ts | 180 +++-- src/stores/useQoderAccountStore.ts | 73 +- src/stores/useQoderInstanceStore.ts | 31 +- src/stores/useSideNavLayoutStore.ts | 2 +- src/types/navigation.ts | 6 + src/types/platform.ts | 9 + src/types/qoder.test.ts | 58 ++ src/types/qoder.ts | 82 +- src/utils/accountSyncEvents.ts | 6 + src/utils/currentAccountRefresh.ts | 9 + src/utils/externalProviderImport.ts | 9 + src/utils/floatingCardSelectors.ts | 9 + src/utils/platformMeta.tsx | 16 +- 76 files changed, 5467 insertions(+), 1097 deletions(-) create mode 100644 crates/cockpit-core/src/modules/qoder_app_auth.rs create mode 100644 crates/cockpit-core/src/modules/qoder_channel.rs create mode 100644 src-tauri/src/modules/qoder_app_auth.rs create mode 100644 src-tauri/src/modules/qoder_channel.rs create mode 100644 src/assets/icons/qoder-app.png create mode 100644 src/assets/icons/qoder-cn-app.png create mode 100644 src/types/qoder.test.ts diff --git a/crates/cockpit-core/src/models/qoder.rs b/crates/cockpit-core/src/models/qoder.rs index 539ce8121..7384367ed 100644 --- a/crates/cockpit-core/src/models/qoder.rs +++ b/crates/cockpit-core/src/models/qoder.rs @@ -44,6 +44,8 @@ pub struct QoderAccountSummary { #[serde(skip_serializing_if = "Option::is_none")] pub user_id: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub plan_type: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tags: Option>, @@ -89,6 +91,7 @@ impl QoderAccount { id: self.id.clone(), email: self.email.clone(), user_id: self.user_id.clone(), + display_name: self.display_name.clone(), plan_type: self.plan_type.clone(), tags: self.tags.clone(), created_at: self.created_at, diff --git a/crates/cockpit-core/src/modules/config.rs b/crates/cockpit-core/src/modules/config.rs index ba27ee6e1..51fde02bd 100644 --- a/crates/cockpit-core/src/modules/config.rs +++ b/crates/cockpit-core/src/modules/config.rs @@ -221,9 +221,18 @@ pub struct UserConfig { /// CodeBuddy CN 启动路径(为空则使用默认路径) #[serde(default = "default_codebuddy_cn_app_path")] pub codebuddy_cn_app_path: String, - /// Qoder 启动路径(为空则使用默认路径) + /// Qoder 启动路径(国际版 IDE,为空则使用默认路径) #[serde(default = "default_qoder_app_path")] pub qoder_app_path: String, + /// Qoder 国际版 App / Launcher 启动路径 + #[serde(default = "default_qoder_app_path")] + pub qoder_app_app_path: String, + /// Qoder 国内版 IDE 启动路径 + #[serde(default = "default_qoder_app_path")] + pub qoder_cn_ide_app_path: String, + /// Qoder 国内版 App 启动路径 + #[serde(default = "default_qoder_app_path")] + pub qoder_cn_app_path: String, /// Trae 启动路径(为空则使用默认路径) #[serde(default = "default_trae_app_path")] pub trae_app_path: String, @@ -846,6 +855,9 @@ impl Default for UserConfig { codebuddy_share_sessions_on_switch: default_codebuddy_share_sessions_on_switch(), codebuddy_cn_app_path: default_codebuddy_cn_app_path(), qoder_app_path: default_qoder_app_path(), + qoder_app_app_path: default_qoder_app_path(), + qoder_cn_ide_app_path: default_qoder_app_path(), + qoder_cn_app_path: default_qoder_app_path(), trae_app_path: default_trae_app_path(), trae_solo_app_path: default_trae_app_path(), trae_cn_app_path: default_trae_app_path(), @@ -1659,6 +1671,30 @@ pub fn load_user_config() -> Result { } }); + // 自动感知探测 Qoder 4 个渠道的安装路径 + let mut qoder_paths_detected = false; + let qoder_probes = [ + (crate::modules::qoder_channel::QoderChannel::QoderIde, &mut config.qoder_app_path), + (crate::modules::qoder_channel::QoderChannel::QoderApp, &mut config.qoder_app_app_path), + (crate::modules::qoder_channel::QoderChannel::QoderCnIde, &mut config.qoder_cn_ide_app_path), + (crate::modules::qoder_channel::QoderChannel::QoderCnApp, &mut config.qoder_cn_app_path), + ]; + for (channel, path_ref) in qoder_probes { + let is_missing = path_ref.trim().is_empty() || !std::path::Path::new(path_ref.trim()).exists(); + if is_missing { + if let Some(detected) = channel.detect_installed_exe() { + let detected_str = detected.to_string_lossy().to_string(); + if *path_ref != detected_str { + *path_ref = detected_str; + qoder_paths_detected = true; + } + } + } + } + if qoder_paths_detected { + let _ = persist_user_config(&config); + } + Ok(config) } diff --git a/crates/cockpit-core/src/modules/mod.rs b/crates/cockpit-core/src/modules/mod.rs index 6681b7902..167a140ee 100644 --- a/crates/cockpit-core/src/modules/mod.rs +++ b/crates/cockpit-core/src/modules/mod.rs @@ -47,6 +47,8 @@ pub mod oauth_pending_state; // pub mod opencode_auth; pub mod process; pub mod qoder_account; +pub mod qoder_app_auth; +pub mod qoder_channel; pub mod qoder_instance; pub mod qoder_oauth; pub mod quota; diff --git a/crates/cockpit-core/src/modules/process_core_discovery.rs b/crates/cockpit-core/src/modules/process_core_discovery.rs index e2f51c7a4..75aeb6110 100644 --- a/crates/cockpit-core/src/modules/process_core_discovery.rs +++ b/crates/cockpit-core/src/modules/process_core_discovery.rs @@ -1168,7 +1168,10 @@ fn update_app_path_in_config(app: &str, path: &Path, expected_current: &str) { "opencode" => &mut current.opencode_app_path, "codebuddy" => &mut current.codebuddy_app_path, "codebuddy_cn" => &mut current.codebuddy_cn_app_path, - "qoder" => &mut current.qoder_app_path, + "qoder" | "qoder_ide" => &mut current.qoder_app_path, + "qoder_app" => &mut current.qoder_app_app_path, + "qoder_cn_ide" => &mut current.qoder_cn_ide_app_path, + "qoder_cn_app" => &mut current.qoder_cn_app_path, "trae" => &mut current.trae_app_path, "workbuddy" => &mut current.workbuddy_app_path, _ => return Ok(()), diff --git a/crates/cockpit-core/src/modules/process_core_matching.rs b/crates/cockpit-core/src/modules/process_core_matching.rs index 145c52c8b..1214c8d38 100644 --- a/crates/cockpit-core/src/modules/process_core_matching.rs +++ b/crates/cockpit-core/src/modules/process_core_matching.rs @@ -459,13 +459,32 @@ pub fn detect_and_save_app_path(app: &str, force: bool) -> Option { return Some(config::get_user_config().codebuddy_cn_app_path); } } - "qoder" => { - if !force && !current.qoder_app_path.trim().is_empty() { - return Some(current.qoder_app_path); - } - if let Some(detected) = detect_qoder_exec_path() { - update_app_path_in_config("qoder", &detected, ¤t.qoder_app_path); - return Some(config::get_user_config().qoder_app_path); + "qoder" | "qoder_ide" | "qoder_app" | "qoder_cn_ide" | "qoder_cn_app" => { + let channel = match app { + "qoder_app" => crate::modules::qoder_channel::QoderChannel::QoderApp, + "qoder_cn_ide" => crate::modules::qoder_channel::QoderChannel::QoderCnIde, + "qoder_cn_app" => crate::modules::qoder_channel::QoderChannel::QoderCnApp, + _ => crate::modules::qoder_channel::QoderChannel::QoderIde, + }; + let configured = match channel { + crate::modules::qoder_channel::QoderChannel::QoderIde => current.qoder_app_path.clone(), + crate::modules::qoder_channel::QoderChannel::QoderApp => current.qoder_app_app_path.clone(), + crate::modules::qoder_channel::QoderChannel::QoderCnIde => current.qoder_cn_ide_app_path.clone(), + crate::modules::qoder_channel::QoderChannel::QoderCnApp => current.qoder_cn_app_path.clone(), + }; + if !force && !configured.trim().is_empty() { + return Some(configured); + } + if let Some(detected) = channel.detect_installed_exe() { + update_app_path_in_config(app, &detected, &configured); + let refreshed = config::get_user_config(); + let refreshed_val = match channel { + crate::modules::qoder_channel::QoderChannel::QoderIde => refreshed.qoder_app_path, + crate::modules::qoder_channel::QoderChannel::QoderApp => refreshed.qoder_app_app_path, + crate::modules::qoder_channel::QoderChannel::QoderCnIde => refreshed.qoder_cn_ide_app_path, + crate::modules::qoder_channel::QoderChannel::QoderCnApp => refreshed.qoder_cn_app_path, + }; + return Some(refreshed_val); } } "trae" => { diff --git a/crates/cockpit-core/src/modules/qoder_account.rs b/crates/cockpit-core/src/modules/qoder_account.rs index 71f1b6671..63a7222f7 100644 --- a/crates/cockpit-core/src/modules/qoder_account.rs +++ b/crates/cockpit-core/src/modules/qoder_account.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; use crate::models::qoder::{QoderAccount, QoderAccountIndex}; +use crate::modules::qoder_channel::{QoderChannel, QoderCredentialKind}; use crate::modules::{account, logger}; const ACCOUNTS_INDEX_FILE: &str = "qoder_accounts.json"; @@ -106,21 +107,34 @@ fn get_data_dir() -> Result { account::get_data_dir() } -fn get_accounts_dir() -> Result { +pub fn get_accounts_dir_for_channel(channel: QoderChannel) -> Result { let base = get_data_dir()?; - let dir = base.join(ACCOUNTS_DIR); + let dir = base.join(channel.accounts_dir_name()); if !dir.exists() { - fs::create_dir_all(&dir).map_err(|e| format!("创建 Qoder 账号目录失败: {}", e))?; + fs::create_dir_all(&dir) + .map_err(|e| format!("创建 {} 账号目录失败: {}", channel.display_name(), e))?; } Ok(dir) } +fn get_accounts_dir() -> Result { + get_accounts_dir_for_channel(QoderChannel::QoderIde) +} + +pub fn get_accounts_index_path_for_channel(channel: QoderChannel) -> Result { + Ok(get_data_dir()?.join(channel.accounts_index_filename())) +} + fn get_accounts_index_path() -> Result { - Ok(get_data_dir()?.join(ACCOUNTS_INDEX_FILE)) + get_accounts_index_path_for_channel(QoderChannel::QoderIde) +} + +pub fn accounts_index_path_string_for_channel(channel: QoderChannel) -> Result { + Ok(get_accounts_index_path_for_channel(channel)?.to_string_lossy().to_string()) } pub fn accounts_index_path_string() -> Result { - Ok(get_accounts_index_path()?.to_string_lossy().to_string()) + accounts_index_path_string_for_channel(QoderChannel::QoderIde) } fn normalize_account_id(account_id: &str) -> Result { @@ -143,63 +157,174 @@ fn normalize_account_id(account_id: &str) -> Result { Ok(trimmed.to_string()) } -fn resolve_account_file_path(account_id: &str) -> Result { +pub fn resolve_account_file_path_for_channel( + channel: QoderChannel, + account_id: &str, +) -> Result { let normalized = normalize_account_id(account_id)?; - Ok(get_accounts_dir()?.join(format!("{}.json", normalized))) + Ok(get_accounts_dir_for_channel(channel)?.join(format!("{}.json", normalized))) } -pub fn load_account(account_id: &str) -> Option { - let account_path = resolve_account_file_path(account_id).ok()?; +fn resolve_account_file_path(account_id: &str) -> Result { + resolve_account_file_path_for_channel(QoderChannel::QoderIde, account_id) +} + +pub fn load_account_for_channel(channel: QoderChannel, account_id: &str) -> Option { + let account_path = resolve_account_file_path_for_channel(channel, account_id).ok()?; if !account_path.exists() { return None; } let content = fs::read_to_string(&account_path).ok()?; - crate::modules::atomic_write::parse_json_with_auto_restore(&account_path, &content).ok() + let mut account: QoderAccount = + crate::modules::atomic_write::parse_json_with_auto_restore(&account_path, &content).ok()?; + let mut healed = false; + let snapshot = QoderSnapshot { + user_info_raw: account.auth_user_info_raw.clone(), + user_plan_raw: account.auth_user_plan_raw.clone(), + credit_usage_raw: account.auth_credit_usage_raw.clone(), + }; + + if account.display_name.is_none() { + if let Some(name) = extract_snapshot_display_name(&snapshot) { + logger::log_warn(&format!( + "[Qoder Account Heal] 字段自愈: channel={}, account_id={}, field_name=display_name, old_value=None, new_value={}", + channel.as_str(), + account.id, + name + )); + account.display_name = Some(name); + healed = true; + } + } + + if account.email == "unknown@qoder.local" + || account.email.starts_with("unknown@") + || account.email.trim().is_empty() + { + let old_email = account.email.clone(); + let new_email = if let Some(real_email) = extract_snapshot_email(&snapshot) { + Some(real_email) + } else if let Some(ref name) = account.display_name { + if name.contains('@') { + Some(name.to_lowercase()) + } else { + Some(format!("{}@qoder.cn", name.to_lowercase())) + } + } else if let Some(ref uid) = account.user_id { + Some(format!("uid_{}@qoder.cn", sanitize_account_id_component(uid).to_lowercase())) + } else { + None + }; + + if let Some(ne) = new_email { + logger::log_warn(&format!( + "[Qoder Account Heal] 字段自愈: channel={}, account_id={}, field_name=email, old_value={}, new_value={}", + channel.as_str(), + account.id, + old_email, + ne + )); + account.email = ne; + healed = true; + } + } + + if account.plan_type.is_none() { + let new_plan = if let Some(plan) = extract_snapshot_plan_type(&snapshot) { + Some(plan) + } else if channel.is_cn() || account.email.ends_with(".cn") { + Some("Free".to_string()) + } else { + None + }; + + if let Some(plan) = new_plan { + logger::log_warn(&format!( + "[Qoder Account Heal] 字段自愈: channel={}, account_id={}, field_name=plan_type, old_value=None, new_value={}", + channel.as_str(), + account.id, + plan + )); + account.plan_type = Some(plan); + healed = true; + } + } + + if healed { + let _ = save_account_file_for_channel(channel, &account); + } + Some(account) } -fn save_account_file(account: &QoderAccount) -> Result<(), String> { - let path = resolve_account_file_path(account.id.as_str())?; +pub fn find_account_channel(account_id: &str) -> Option<(QoderChannel, QoderAccount)> { + for &channel in &QoderChannel::ALL { + if let Some(account) = load_account_for_channel(channel, account_id) { + return Some((channel, account)); + } + } + None +} + +pub fn load_account(account_id: &str) -> Option { + find_account_channel(account_id).map(|(_, account)| account) +} + +pub fn save_account_file_for_channel( + channel: QoderChannel, + account: &QoderAccount, +) -> Result<(), String> { + let path = resolve_account_file_path_for_channel(channel, account.id.as_str())?; let content = serde_json::to_string_pretty(account).map_err(|e| format!("序列化账号失败: {}", e))?; crate::modules::atomic_write::write_string_atomic(&path, &content) .map_err(|e| format!("保存账号失败: {}", e)) } -fn delete_account_file(account_id: &str) -> Result<(), String> { - let path = resolve_account_file_path(account_id)?; +fn save_account_file(account: &QoderAccount) -> Result<(), String> { + save_account_file_for_channel(QoderChannel::QoderIde, account) +} + +fn delete_account_file_for_channel(channel: QoderChannel, account_id: &str) -> Result<(), String> { + let path = resolve_account_file_path_for_channel(channel, account_id)?; if path.exists() { fs::remove_file(path).map_err(|e| format!("删除账号文件失败: {}", e))?; } Ok(()) } -fn load_account_index() -> QoderAccountIndex { - let path = match get_accounts_index_path() { +fn delete_account_file(account_id: &str) -> Result<(), String> { + delete_account_file_for_channel(QoderChannel::QoderIde, account_id) +} + +fn load_account_index_for_channel(channel: QoderChannel) -> QoderAccountIndex { + let path = match get_accounts_index_path_for_channel(channel) { Ok(p) => p, Err(_) => return QoderAccountIndex::new(), }; if !path.exists() { - return repair_account_index_from_details("索引文件不存在") + return repair_account_index_from_details_for_channel(channel, "索引文件不存在") .unwrap_or_else(QoderAccountIndex::new); } match fs::read_to_string(&path) { Ok(content) if content.trim().is_empty() => { - repair_account_index_from_details("索引文件为空").unwrap_or_else(QoderAccountIndex::new) + repair_account_index_from_details_for_channel(channel, "索引文件为空") + .unwrap_or_else(QoderAccountIndex::new) } Ok(content) => match crate::modules::atomic_write::parse_json_with_auto_restore::< QoderAccountIndex, >(&path, &content) { Ok(index) if !index.accounts.is_empty() => index, - Ok(_) => repair_account_index_from_details("索引账号列表为空") + Ok(_) => repair_account_index_from_details_for_channel(channel, "索引账号列表为空") .unwrap_or_else(QoderAccountIndex::new), Err(err) => { logger::log_warn(&format!( - "[Qoder Account] 账号索引解析失败,尝试按详情文件自动修复: path={}, error={}", + "[{}] 账号索引解析失败,尝试按详情文件自动修复: path={}, error={}", + channel.display_name(), path.display(), err )); - repair_account_index_from_details("索引文件损坏") + repair_account_index_from_details_for_channel(channel, "索引文件损坏") .unwrap_or_else(QoderAccountIndex::new) } }, @@ -207,10 +332,16 @@ fn load_account_index() -> QoderAccountIndex { } } -fn load_account_index_checked() -> Result { - let path = get_accounts_index_path()?; +fn load_account_index() -> QoderAccountIndex { + load_account_index_for_channel(QoderChannel::QoderIde) +} + +fn load_account_index_checked_for_channel( + channel: QoderChannel, +) -> Result { + let path = get_accounts_index_path_for_channel(channel)?; if !path.exists() { - if let Some(index) = repair_account_index_from_details("索引文件不存在") { + if let Some(index) = repair_account_index_from_details_for_channel(channel, "索引文件不存在") { return Ok(index); } return Ok(QoderAccountIndex::new()); @@ -219,7 +350,9 @@ fn load_account_index_checked() -> Result { let content = match fs::read_to_string(&path) { Ok(content) => content, Err(err) => { - if let Some(index) = repair_account_index_from_details("索引文件读取失败") { + if let Some(index) = + repair_account_index_from_details_for_channel(channel, "索引文件读取失败") + { return Ok(index); } return Err(format!("读取账号索引失败: {}", err)); @@ -227,7 +360,7 @@ fn load_account_index_checked() -> Result { }; if content.trim().is_empty() { - if let Some(index) = repair_account_index_from_details("索引文件为空") { + if let Some(index) = repair_account_index_from_details_for_channel(channel, "索引文件为空") { return Ok(index); } return Ok(QoderAccountIndex::new()); @@ -238,17 +371,19 @@ fn load_account_index_checked() -> Result { ) { Ok(index) if !index.accounts.is_empty() => Ok(index), Ok(index) => { - if let Some(repaired) = repair_account_index_from_details("索引账号列表为空") { + if let Some(repaired) = + repair_account_index_from_details_for_channel(channel, "索引账号列表为空") + { return Ok(repaired); } Ok(index) } Err(err) => { - if let Some(index) = repair_account_index_from_details("索引文件损坏") { + if let Some(index) = repair_account_index_from_details_for_channel(channel, "索引文件损坏") { return Ok(index); } Err(crate::error::file_corrupted_error( - ACCOUNTS_INDEX_FILE, + channel.accounts_index_filename(), &path.to_string_lossy(), &err.to_string(), )) @@ -256,20 +391,34 @@ fn load_account_index_checked() -> Result { } } -fn save_account_index(index: &QoderAccountIndex) -> Result<(), String> { - let path = get_accounts_index_path()?; +fn load_account_index_checked() -> Result { + load_account_index_checked_for_channel(QoderChannel::QoderIde) +} + +fn save_account_index_for_channel( + channel: QoderChannel, + index: &QoderAccountIndex, +) -> Result<(), String> { + let path = get_accounts_index_path_for_channel(channel)?; let content = serde_json::to_string_pretty(index).map_err(|e| format!("序列化账号索引失败: {}", e))?; crate::modules::atomic_write::write_string_atomic(&path, &content) .map_err(|e| format!("写入账号索引失败: {}", e)) } -fn repair_account_index_from_details(reason: &str) -> Option { - let index_path = get_accounts_index_path().ok()?; - let accounts_dir = get_accounts_dir().ok()?; +fn save_account_index(index: &QoderAccountIndex) -> Result<(), String> { + save_account_index_for_channel(QoderChannel::QoderIde, index) +} + +fn repair_account_index_from_details_for_channel( + channel: QoderChannel, + reason: &str, +) -> Option { + let index_path = get_accounts_index_path_for_channel(channel).ok()?; + let accounts_dir = get_accounts_dir_for_channel(channel).ok()?; let mut accounts = crate::modules::account_index_repair::load_accounts_from_details( &accounts_dir, - |account_id| load_account(account_id), + |account_id| load_account_for_channel(channel, account_id), ) .ok()?; @@ -290,16 +439,18 @@ fn repair_account_index_from_details(reason: &str) -> Option let backup_path = crate::modules::account_index_repair::backup_existing_index(&index_path) .unwrap_or_else(|err| { logger::log_warn(&format!( - "[Qoder Account] 自动修复前备份索引失败,继续尝试重建: path={}, error={}", + "[{}] 自动修复前备份索引失败,继续尝试重建: path={}, error={}", + channel.display_name(), index_path.display(), err )); None }); - if let Err(err) = save_account_index(&index) { + if let Err(err) = save_account_index_for_channel(channel, &index) { logger::log_warn(&format!( - "[Qoder Account] 自动修复索引保存失败,将以内存结果继续运行: reason={}, recovered_accounts={}, error={}", + "[{}] 自动修复索引保存失败,将以内存结果继续运行: reason={}, recovered_accounts={}, error={}", + channel.display_name(), reason, index.accounts.len(), err @@ -307,7 +458,8 @@ fn repair_account_index_from_details(reason: &str) -> Option } logger::log_warn(&format!( - "[Qoder Account] 检测到账号索引异常,已根据详情文件自动重建: reason={}, recovered_accounts={}, backup_path={}", + "[{}] 检测到账号索引异常,已根据详情文件自动重建: reason={}, recovered_accounts={}, backup_path={}", + channel.display_name(), reason, index.accounts.len(), backup_path @@ -319,6 +471,10 @@ fn repair_account_index_from_details(reason: &str) -> Option Some(index) } +fn repair_account_index_from_details(reason: &str) -> Option { + repair_account_index_from_details_for_channel(QoderChannel::QoderIde, reason) +} + fn refresh_summary(index: &mut QoderAccountIndex, account: &QoderAccount) { if let Some(summary) = index.accounts.iter_mut().find(|item| item.id == account.id) { *summary = account.summary(); @@ -327,22 +483,30 @@ fn refresh_summary(index: &mut QoderAccountIndex, account: &QoderAccount) { index.accounts.push(account.summary()); } -fn upsert_account_record(account: QoderAccount) -> Result { +pub fn upsert_account_record_for_channel( + channel: QoderChannel, + account: QoderAccount, +) -> Result { let _lock = QODER_ACCOUNT_INDEX_LOCK .lock() .map_err(|_| "获取 Qoder 账号锁失败".to_string())?; - let mut index = load_account_index(); - save_account_file(&account)?; + let mut index = load_account_index_for_channel(channel); + save_account_file_for_channel(channel, &account)?; refresh_summary(&mut index, &account); - save_account_index(&index)?; + save_account_index_for_channel(channel, &index)?; Ok(account) } -pub fn update_quota_query_error( +fn upsert_account_record(account: QoderAccount) -> Result { + upsert_account_record_for_channel(QoderChannel::QoderIde, account) +} + +pub fn update_quota_query_error_for_channel( + channel: QoderChannel, account_id: &str, message: Option, ) -> Result, String> { - let Some(mut account) = load_account(account_id) else { + let Some(mut account) = load_account_for_channel(channel, account_id) else { return Ok(None); }; account.quota_query_last_error = message; @@ -350,43 +514,103 @@ pub fn update_quota_query_error( .quota_query_last_error .as_ref() .map(|_| chrono::Utc::now().timestamp_millis()); - let updated = upsert_account_record(account)?; + let updated = upsert_account_record_for_channel(channel, account)?; Ok(Some(updated)) } -fn list_accounts_from_index(index: &QoderAccountIndex) -> Vec { +pub fn update_quota_query_error( + account_id: &str, + message: Option, +) -> Result, String> { + if let Some((channel, _)) = find_account_channel(account_id) { + update_quota_query_error_for_channel(channel, account_id, message) + } else { + Ok(None) + } +} + +fn list_accounts_from_index_for_channel( + channel: QoderChannel, + index: &QoderAccountIndex, +) -> Vec { let mut accounts = Vec::new(); + let mut need_save_index = false; + let mut updated_index = index.clone(); + for summary in &index.accounts { - if let Some(account) = load_account(&summary.id) { + if let Some(account) = load_account_for_channel(channel, &summary.id) { + if summary.email != account.email + || summary.display_name != account.display_name + || summary.plan_type != account.plan_type + { + need_save_index = true; + logger::log_warn(&format!( + "[Qoder Account Heal] Index 摘要同步自愈: channel={}, account_id={}, email: {:?} -> {:?}, display_name: {:?} -> {:?}, plan_type: {:?} -> {:?}", + channel.as_str(), + account.id, + summary.email, + account.email, + summary.display_name, + account.display_name, + summary.plan_type, + account.plan_type + )); + if let Some(item) = updated_index.accounts.iter_mut().find(|a| a.id == account.id) { + *item = account.summary(); + } + } accounts.push(account); } } + if need_save_index { + if let Ok(path) = get_accounts_index_path_for_channel(channel) { + logger::log_info(&format!( + "[Qoder Account Heal] 回写已自愈的 Index 成功: channel={}, path={}", + channel.as_str(), + path.display() + )); + } + let _ = save_account_index_for_channel(channel, &updated_index); + } accounts.sort_by(|a, b| b.last_used.cmp(&a.last_used)); accounts } +pub fn list_accounts_for_channel(channel: QoderChannel) -> Vec { + let index = load_account_index_for_channel(channel); + list_accounts_from_index_for_channel(channel, &index) +} + +pub fn list_accounts_checked_for_channel( + channel: QoderChannel, +) -> Result, String> { + let index = load_account_index_checked_for_channel(channel)?; + Ok(list_accounts_from_index_for_channel(channel, &index)) +} + pub fn list_accounts() -> Vec { - let index = load_account_index(); - list_accounts_from_index(&index) + list_accounts_for_channel(QoderChannel::QoderIde) } pub fn list_accounts_checked() -> Result, String> { - let index = load_account_index_checked()?; - Ok(list_accounts_from_index(&index)) + list_accounts_checked_for_channel(QoderChannel::QoderIde) } -pub fn remove_account(account_id: &str) -> Result<(), String> { +pub fn remove_account_for_channel(channel: QoderChannel, account_id: &str) -> Result<(), String> { let _lock = QODER_ACCOUNT_INDEX_LOCK .lock() .map_err(|_| "获取 Qoder 账号锁失败".to_string())?; - let mut index = load_account_index(); + let mut index = load_account_index_for_channel(channel); index.accounts.retain(|item| item.id != account_id); - save_account_index(&index)?; - delete_account_file(account_id)?; + save_account_index_for_channel(channel, &index)?; + delete_account_file_for_channel(channel, account_id)?; Ok(()) } -pub fn remove_accounts(account_ids: &[String]) -> Result<(), String> { +pub fn remove_accounts_for_channel( + channel: QoderChannel, + account_ids: &[String], +) -> Result<(), String> { let target: HashSet = account_ids .iter() .map(|id| id.trim().to_string()) @@ -399,15 +623,23 @@ pub fn remove_accounts(account_ids: &[String]) -> Result<(), String> { let _lock = QODER_ACCOUNT_INDEX_LOCK .lock() .map_err(|_| "获取 Qoder 账号锁失败".to_string())?; - let mut index = load_account_index(); + let mut index = load_account_index_for_channel(channel); index.accounts.retain(|item| !target.contains(&item.id)); - save_account_index(&index)?; + save_account_index_for_channel(channel, &index)?; for id in target { - delete_account_file(&id)?; + delete_account_file_for_channel(channel, &id)?; } Ok(()) } +pub fn remove_account(account_id: &str) -> Result<(), String> { + remove_account_for_channel(QoderChannel::QoderIde, account_id) +} + +pub fn remove_accounts(account_ids: &[String]) -> Result<(), String> { + remove_accounts_for_channel(QoderChannel::QoderIde, account_ids) +} + fn parse_json_or_string(raw: &str) -> Value { serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string())) } @@ -841,16 +1073,49 @@ fn normalize_tags(tags: Vec) -> Option> { fn snapshot_to_account(snapshot: QoderSnapshot, existing: Option<&QoderAccount>) -> QoderAccount { let now = now_ts(); - let email = extract_snapshot_email(&snapshot) - .or_else(|| existing.and_then(|item| normalize_email(Some(item.email.as_str())))) - .unwrap_or_else(|| "unknown@qoder.local".to_string()); - let user_id = extract_snapshot_user_id(&snapshot) - .or_else(|| existing.and_then(|item| item.user_id.clone())); - let generated_id = generate_account_id(&snapshot, user_id.as_deref(), Some(email.as_str())); let display_name = extract_snapshot_display_name(&snapshot) .or_else(|| existing.and_then(|item| item.display_name.clone())); + let user_id = extract_snapshot_user_id(&snapshot) + .or_else(|| existing.and_then(|item| item.user_id.clone())); let plan_type = extract_snapshot_plan_type(&snapshot) - .or_else(|| existing.and_then(|item| item.plan_type.clone())); + .or_else(|| existing.and_then(|item| item.plan_type.clone())) + .or_else(|| { + if display_name.as_deref().map(|n| n.contains("aliyun")).unwrap_or(false) + || user_id.as_deref().map(|u| u.starts_with("aliyun-")).unwrap_or(false) + { + Some("Free".to_string()) + } else { + None + } + }); + + let email = extract_snapshot_email(&snapshot) + .or_else(|| { + existing.and_then(|item| { + if item.email == "unknown@qoder.local" || item.email.starts_with("unknown@") { + None + } else { + normalize_email(Some(item.email.as_str())) + } + }) + }) + .or_else(|| { + display_name.as_deref().map(|name| { + if name.contains('@') { + name.to_lowercase() + } else { + format!("{}@qoder.cn", name.to_lowercase()) + } + }) + }) + .or_else(|| { + user_id.as_deref().map(|uid| { + format!("uid_{}@qoder.cn", sanitize_account_id_component(uid).to_lowercase()) + }) + }) + .unwrap_or_else(|| "unknown@qoder.local".to_string()); + + let generated_id = generate_account_id(&snapshot, user_id.as_deref(), Some(email.as_str())); let (credits_used, credits_total, credits_remaining, credits_usage_percent) = extract_snapshot_credits(&snapshot); @@ -896,6 +1161,20 @@ fn snapshot_to_account(snapshot: QoderSnapshot, existing: Option<&QoderAccount>) } } +pub fn merge_account_snapshot( + existing: QoderAccount, + user_info_raw: Option, + user_plan_raw: Option, + credit_usage_raw: Option, +) -> QoderAccount { + let snapshot = QoderSnapshot { + user_info_raw, + user_plan_raw, + credit_usage_raw, + }; + snapshot_to_account(snapshot, Some(&existing)) +} + fn find_existing_account_for_snapshot( snapshot: &QoderSnapshot, accounts: &[QoderAccount], @@ -933,11 +1212,34 @@ fn read_snapshot_from_state_db_path(db_path: &Path) -> Result Result { - let accounts = list_accounts(); +fn merge_snapshot_for_channel( + channel: QoderChannel, + snapshot: QoderSnapshot, +) -> Result { + let accounts = list_accounts_for_channel(channel); let existing = find_existing_account_for_snapshot(&snapshot, &accounts); let account = snapshot_to_account(snapshot, existing.as_ref()); - upsert_account_record(account) + upsert_account_record_for_channel(channel, account) +} + +fn merge_snapshot(snapshot: QoderSnapshot) -> Result { + merge_snapshot_for_channel(QoderChannel::QoderIde, snapshot) +} + +pub fn upsert_account_from_snapshot_for_channel( + channel: QoderChannel, + user_info_raw: Value, + user_plan_raw: Option, + credit_usage_raw: Option, +) -> Result { + merge_snapshot_for_channel( + channel, + QoderSnapshot { + user_info_raw: Some(user_info_raw), + user_plan_raw, + credit_usage_raw, + }, + ) } pub fn upsert_account_from_snapshot( @@ -945,11 +1247,12 @@ pub fn upsert_account_from_snapshot( user_plan_raw: Option, credit_usage_raw: Option, ) -> Result { - merge_snapshot(QoderSnapshot { - user_info_raw: Some(user_info_raw), + upsert_account_from_snapshot_for_channel( + QoderChannel::QoderIde, + user_info_raw, user_plan_raw, credit_usage_raw, - }) + ) } pub fn get_default_qoder_state_db_path() -> Option { @@ -1008,19 +1311,58 @@ fn ensure_default_state_db_path() -> Result { ensure_state_db_path_for_user_data_dir(&data_root.to_string_lossy()) } +pub fn import_from_local_channel(channel: QoderChannel) -> Result, String> { + match channel.credential_kind() { + QoderCredentialKind::StateVscdb => { + let data_dir = channel.default_user_data_dir()?; + let db_path = ensure_state_db_path_for_user_data_dir(&data_dir.to_string_lossy())?; + let Some(snapshot) = read_snapshot_from_state_db_path(&db_path)? else { + return Ok(None); + }; + let account = merge_snapshot_for_channel(channel, snapshot)?; + logger::log_info(&format!( + "[Qoder Account] 从本地 {} (state.vscdb) 导入成功: id={}, email={}, db={}", + channel.display_name(), + account.id, + account.email, + db_path.to_string_lossy() + )); + Ok(Some(account)) + } + QoderCredentialKind::AuthV1Dat => { + let data_dir = channel.default_user_data_dir()?; + let Some(app_auth) = crate::modules::qoder_app_auth::read_qoder_app_auth(&data_dir)? else { + return Ok(None); + }; + + let user = app_auth.get("user"); + let user_info_raw = Some(app_auth.clone()); + let user_plan_raw = user + .and_then(|u| u.get("tier")) + .and_then(|t| t.as_str()) + .map(|t| serde_json::json!({ "plan": t, "tier": t })); + + let snapshot = QoderSnapshot { + user_info_raw, + user_plan_raw, + credit_usage_raw: None, + }; + + let account = merge_snapshot_for_channel(channel, snapshot)?; + logger::log_info(&format!( + "[Qoder Account] 从本地 {} (auth.v1.dat) 导入成功: id={}, email={}, path={}", + channel.display_name(), + account.id, + account.email, + data_dir.display() + )); + Ok(Some(account)) + } + } +} + pub fn import_from_local() -> Result, String> { - let db_path = ensure_default_state_db_path()?; - let Some(snapshot) = read_snapshot_from_state_db_path(&db_path)? else { - return Ok(None); - }; - let account = merge_snapshot(snapshot)?; - logger::log_info(&format!( - "[Qoder Account] 从本地导入成功: id={}, email={}, db={}", - account.id, - account.email, - db_path.to_string_lossy() - )); - Ok(Some(account)) + import_from_local_channel(QoderChannel::QoderIde) } fn serialize_raw_or_fallback(raw: &Option, fallback: Value) -> Result { @@ -1108,22 +1450,21 @@ fn verify_injected_account_matches(db_path: &Path, account: &QoderAccount) -> Re )) } -pub fn inject_to_qoder(account_id: &str) -> Result<(), String> { - let db_path = ensure_default_state_db_path()?; - inject_to_qoder_at_path(&db_path, account_id) +pub fn resolve_current_account_id_for_channel( + _channel: QoderChannel, + accounts: &[QoderAccount], +) -> Option { + accounts.first().map(|account| account.id.clone()) } -pub fn inject_to_qoder_for_user_data_dir( - user_data_dir: &str, - account_id: &str, -) -> Result<(), String> { - let db_path = ensure_state_db_path_for_user_data_dir(user_data_dir)?; - inject_to_qoder_at_path(&db_path, account_id) +pub fn resolve_current_account_id(accounts: &[QoderAccount]) -> Option { + resolve_current_account_id_for_channel(QoderChannel::QoderIde, accounts) } -pub fn inject_to_qoder_at_path(db_path: &Path, account_id: &str) -> Result<(), String> { - let account = - load_account(account_id).ok_or_else(|| format!("Qoder 账号不存在: {}", account_id))?; +pub fn inject_account_to_state_db_at_path( + db_path: &Path, + account: &QoderAccount, +) -> Result<(), String> { if let Some(parent) = db_path.parent() { fs::create_dir_all(parent) .map_err(|e| format!("创建 Qoder state.vscdb 目录失败: {}", e))?; @@ -1131,15 +1472,15 @@ pub fn inject_to_qoder_at_path(db_path: &Path, account_id: &str) -> Result<(), S let user_info_json = serialize_raw_or_fallback( &account.auth_user_info_raw, - build_user_info_fallback(&account), + build_user_info_fallback(account), )?; let user_plan_json = serialize_raw_or_fallback( &account.auth_user_plan_raw, - build_user_plan_fallback(&account), + build_user_plan_fallback(account), )?; let credit_usage_json = serialize_raw_or_fallback( &account.auth_credit_usage_raw, - build_credit_usage_fallback(&account), + build_credit_usage_fallback(account), )?; crate::modules::vscode_inject::inject_secret_to_state_db_for_qoder( @@ -1161,7 +1502,61 @@ pub fn inject_to_qoder_at_path(db_path: &Path, account_id: &str) -> Result<(), S verify_state_db_key_exists(db_path, QODER_SECRET_USER_INFO_KEY)?; verify_state_db_key_exists(db_path, QODER_SECRET_USER_PLAN_KEY)?; verify_state_db_key_exists(db_path, QODER_SECRET_CREDIT_USAGE_KEY)?; - verify_injected_account_matches(db_path, &account)?; + verify_injected_account_matches(db_path, account)?; + + Ok(()) +} + +pub fn inject_to_qoder_channel_with_account( + channel: QoderChannel, + account_id: &str, +) -> Result<(), String> { + let account = load_account_for_channel(channel, account_id) + .or_else(|| load_account(account_id)) + .ok_or_else(|| format!("{} 账号不存在: {}", channel.display_name(), account_id))?; + + match channel.credential_kind() { + QoderCredentialKind::StateVscdb => { + let data_dir = channel.default_user_data_dir()?; + let db_path = ensure_state_db_path_for_user_data_dir(&data_dir.to_string_lossy())?; + inject_account_to_state_db_at_path(&db_path, &account)?; + } + QoderCredentialKind::AuthV1Dat => { + let data_dir = channel.default_user_data_dir()?; + let auth_json = crate::modules::qoder_app_auth::build_app_auth_value(&account); + crate::modules::qoder_app_auth::write_qoder_app_auth(&data_dir, &auth_json)?; + } + } + + let mut updated = account.clone(); + updated.last_used = now_ts(); + let _ = upsert_account_record_for_channel(channel, updated); + + logger::log_info(&format!( + "[Qoder Inject] 成功注入账号到 {}: account_id={}, email={}", + channel.display_name(), + account.id, + account.email + )); + Ok(()) +} + +pub fn inject_to_qoder(account_id: &str) -> Result<(), String> { + inject_to_qoder_channel_with_account(QoderChannel::QoderIde, account_id) +} + +pub fn inject_to_qoder_for_user_data_dir( + user_data_dir: &str, + account_id: &str, +) -> Result<(), String> { + let db_path = ensure_state_db_path_for_user_data_dir(user_data_dir)?; + inject_to_qoder_at_path(&db_path, account_id) +} + +pub fn inject_to_qoder_at_path(db_path: &Path, account_id: &str) -> Result<(), String> { + let account = + load_account(account_id).ok_or_else(|| format!("Qoder 账号不存在: {}", account_id))?; + inject_account_to_state_db_at_path(db_path, &account)?; let mut updated = account.clone(); updated.last_used = now_ts(); @@ -1176,12 +1571,20 @@ pub fn inject_to_qoder_at_path(db_path: &Path, account_id: &str) -> Result<(), S Ok(()) } -pub fn update_account_tags(account_id: &str, tags: Vec) -> Result { - let mut account = - load_account(account_id).ok_or_else(|| format!("Qoder 账号不存在: {}", account_id))?; +pub fn update_account_tags_for_channel( + channel: QoderChannel, + account_id: &str, + tags: Vec, +) -> Result { + let mut account = load_account_for_channel(channel, account_id) + .ok_or_else(|| format!("{} 账号不存在: {}", channel.display_name(), account_id))?; account.tags = normalize_tags(tags); account.last_used = now_ts(); - upsert_account_record(account) + upsert_account_record_for_channel(channel, account) +} + +pub fn update_account_tags(account_id: &str, tags: Vec) -> Result { + update_account_tags_for_channel(QoderChannel::QoderIde, account_id, tags) } fn normalize_imported_account(mut account: QoderAccount) -> QoderAccount { @@ -1199,11 +1602,53 @@ fn normalize_imported_account(mut account: QoderAccount) -> QoderAccount { Some(account.email.as_str()), ); } - account.email = normalize_email(Some(account.email.as_str())) - .unwrap_or_else(|| "unknown@qoder.local".to_string()); account.user_id = normalize_non_empty(account.user_id.as_deref()); account.display_name = normalize_non_empty(account.display_name.as_deref()); + if account.display_name.is_none() { + let snapshot = QoderSnapshot { + user_info_raw: account.auth_user_info_raw.clone(), + user_plan_raw: account.auth_user_plan_raw.clone(), + credit_usage_raw: account.auth_credit_usage_raw.clone(), + }; + account.display_name = extract_snapshot_display_name(&snapshot); + } + + if account.email.trim().is_empty() + || account.email == "unknown@qoder.local" + || account.email.starts_with("unknown@") + { + if let Some(ref name) = account.display_name { + if name.contains('@') { + account.email = name.to_lowercase(); + } else { + account.email = format!("{}@qoder.cn", name.to_lowercase()); + } + } else if let Some(ref uid) = account.user_id { + account.email = format!("uid_{}@qoder.cn", sanitize_account_id_component(uid).to_lowercase()); + } else { + account.email = "unknown@qoder.local".to_string(); + } + } else { + account.email = normalize_email(Some(account.email.as_str())) + .unwrap_or_else(|| "unknown@qoder.local".to_string()); + } account.plan_type = normalize_non_empty(account.plan_type.as_deref()); + if account.plan_type.is_none() { + let snapshot = QoderSnapshot { + user_info_raw: account.auth_user_info_raw.clone(), + user_plan_raw: account.auth_user_plan_raw.clone(), + credit_usage_raw: account.auth_credit_usage_raw.clone(), + }; + account.plan_type = extract_snapshot_plan_type(&snapshot).or_else(|| { + if account.email.ends_with(".cn") + || account.display_name.as_deref().map(|n| n.contains("aliyun")).unwrap_or(false) + { + Some("Free".to_string()) + } else { + None + } + }); + } account.tags = normalize_tags(account.tags.unwrap_or_default()); account.quota_query_last_error = normalize_non_empty(account.quota_query_last_error.as_deref()); if account.created_at <= 0 { @@ -1253,7 +1698,10 @@ fn parse_import_item(item: &Value) -> Result { Ok(snapshot_to_account(snapshot, None)) } -pub fn import_from_json(json_content: &str) -> Result, String> { +pub fn import_from_json_for_channel( + channel: QoderChannel, + json_content: &str, +) -> Result, String> { let parsed: Value = serde_json::from_str(json_content).map_err(|e| format!("JSON 解析失败: {}", e))?; let items: Vec = match parsed { @@ -1275,15 +1723,22 @@ pub fn import_from_json(json_content: &str) -> Result, String> let mut imported = Vec::new(); for item in items { let account = parse_import_item(&item)?; - let saved = upsert_account_record(account)?; + let saved = upsert_account_record_for_channel(channel, account)?; imported.push(saved); } Ok(imported) } -pub fn export_accounts(account_ids: &[String]) -> Result { - let accounts = list_accounts(); +pub fn import_from_json(json_content: &str) -> Result, String> { + import_from_json_for_channel(QoderChannel::QoderIde, json_content) +} + +pub fn export_accounts_for_channel( + channel: QoderChannel, + account_ids: &[String], +) -> Result { + let accounts = list_accounts_for_channel(channel); let selected: Vec = if account_ids.is_empty() { accounts } else { @@ -1300,3 +1755,7 @@ pub fn export_accounts(account_ids: &[String]) -> Result { serde_json::to_string_pretty(&selected).map_err(|e| format!("序列化导出 JSON 失败: {}", e)) } + +pub fn export_accounts(account_ids: &[String]) -> Result { + export_accounts_for_channel(QoderChannel::QoderIde, account_ids) +} diff --git a/crates/cockpit-core/src/modules/qoder_app_auth.rs b/crates/cockpit-core/src/modules/qoder_app_auth.rs new file mode 100644 index 000000000..0dae7bf8f --- /dev/null +++ b/crates/cockpit-core/src/modules/qoder_app_auth.rs @@ -0,0 +1,184 @@ +use std::fs; +use std::path::Path; +use chrono::{TimeZone, Utc}; +use serde_json::Value; + +use crate::models::qoder::QoderAccount; +use crate::modules::logger; + +const AUTH_V1_FILENAME: &str = "auth.v1.dat"; +const MACHINE_ID_FILENAME: &str = "auth.machine-id"; + +/// 毫秒时间戳转 ISO 8601 字符串(如 "2026-09-27T06:10:46Z") +fn ms_epoch_to_iso8601(val: Option<&Value>) -> Option { + let Some(val) = val else { return None }; + if let Some(s) = val.as_str() { + let trimmed = s.trim(); + if trimmed.is_empty() { + return None; + } + if trimmed.contains('T') || trimmed.contains('-') { + return Some(trimmed.to_string()); + } + if let Ok(millis) = trimmed.parse::() { + return Utc.timestamp_millis_opt(millis).single().map(|dt| dt.to_rfc3339()); + } + return Some(trimmed.to_string()); + } + if let Some(millis) = val.as_i64() { + return Utc.timestamp_millis_opt(millis).single().map(|dt| dt.to_rfc3339()); + } + None +} + +/// 从 Qoder App 数据目录(如 %APPDATA%\com.qoder.app.stable)读取并解密 auth.v1.dat +pub fn read_qoder_app_auth(data_dir: &Path) -> Result, String> { + let auth_path = data_dir.join(AUTH_V1_FILENAME); + if !auth_path.exists() { + return Ok(None); + } + + #[cfg(target_os = "windows")] + { + let master_key = crate::modules::vscode_inject::get_windows_encryption_key(Some(data_dir)) + .map_err(|e| format!("读取 Local State 密钥失败({}): {}", data_dir.display(), e))?; + + let encrypted = fs::read(&auth_path) + .map_err(|e| format!("读取 {} 失败: {}", auth_path.display(), e))?; + + let decrypted = crate::modules::vscode_inject::decrypt_windows_gcm_v10(&master_key, &encrypted) + .map_err(|e| format!("解密 {} 失败: {}", auth_path.display(), e))?; + + let json_text = String::from_utf8(decrypted) + .map_err(|e| format!("解析 {} 明文为 UTF-8 失败: {}", auth_path.display(), e))?; + + let value: Value = serde_json::from_str(&json_text) + .map_err(|e| format!("解析 {} 为 JSON 失败: {}", auth_path.display(), e))?; + + Ok(Some(value)) + } + + #[cfg(not(target_os = "windows"))] + { + Err("Qoder App (Launcher 架构) 当前仅支持 Windows 系统".to_string()) + } +} + +/// 将 QoderAccount 转化为 Qoder App (auth.v1.dat) 标准 schemaVersion 1 结构 +pub fn build_app_auth_value(account: &QoderAccount) -> Value { + let raw_info = account.auth_user_info_raw.as_ref(); + + let token = raw_info + .and_then(|v| v.get("token").or_else(|| v.get("accessToken"))) + .and_then(|v| v.as_str()) + .unwrap_or_default(); + + let refresh_token = raw_info + .and_then(|v| v.get("refreshToken")) + .and_then(|v| v.as_str()) + .unwrap_or_default(); + + // Qoder App asar 要求 expiresAt 必须为 ISO 8601 字符串 + let expires_at = ms_epoch_to_iso8601( + raw_info.and_then(|v| v.get("expiresAt").or_else(|| v.get("expireTime"))), + ) + .unwrap_or_else(|| Utc::now().to_rfc3339()); + + let refresh_token_expires_at = ms_epoch_to_iso8601( + raw_info.and_then(|v| { + v.get("refreshTokenExpiresAt") + .or_else(|| v.get("refreshTokenExpireTime")) + }), + ) + .unwrap_or_else(|| { + let future = Utc::now() + chrono::Duration::days(365); + future.to_rfc3339() + }); + + let user_id = account.user_id.clone().unwrap_or_default(); + let user_name = account.display_name.clone().unwrap_or_default(); + let email = account.email.clone(); + let avatar_url = raw_info + .and_then(|v| v.get("avatarUrl").or_else(|| v.get("imageUrl"))) + .and_then(|v| v.as_str()) + .unwrap_or_default(); + + serde_json::json!({ + "schemaVersion": 1, + "token": token, + "refreshToken": refresh_token, + "expiresAt": expires_at, + "refreshTokenExpiresAt": refresh_token_expires_at, + "user": { + "id": user_id, + "name": user_name, + "email": email, + "avatarUrl": avatar_url, + "tier": account.plan_type.as_deref().unwrap_or("Personal") + } + }) +} + +/// 原子加密写入 auth.v1.dat 到指定 App 数据目录 +pub fn write_qoder_app_auth(data_dir: &Path, auth_json: &Value) -> Result<(), String> { + if !data_dir.exists() { + fs::create_dir_all(data_dir) + .map_err(|e| format!("创建数据目录失败 {}: {}", data_dir.display(), e))?; + } + + #[cfg(target_os = "windows")] + { + let local_state = data_dir.join("Local State"); + if !local_state.exists() { + return Err(format!( + "数据目录缺失 Local State: {}。请先运行一次该程序使其生成加密密钥后重试。", + data_dir.display() + )); + } + + let master_key = crate::modules::vscode_inject::get_windows_encryption_key(Some(data_dir)) + .map_err(|e| format!("解密 Local State 失败: {}", e))?; + + let plain_text = serde_json::to_string(auth_json) + .map_err(|e| format!("序列化 auth JSON 失败: {}", e))?; + + let encrypted = crate::modules::vscode_inject::encrypt_windows_gcm_v10( + &master_key, + plain_text.as_bytes(), + )?; + + let auth_path = data_dir.join(AUTH_V1_FILENAME); + let ts = chrono::Utc::now().format("%Y%m%d_%H%M%S").to_string(); + + // 备份原凭据 + if auth_path.exists() { + let bak_path = data_dir.join(format!("{}.bak_{}", AUTH_V1_FILENAME, ts)); + if let Err(e) = fs::copy(&auth_path, &bak_path) { + logger::log_warn(&format!("[Qoder App] 备份原凭据失败: {}", e)); + } + } + + // tmp + atomic replace 写入 + let tmp_path = data_dir.join(format!("{}.tmp_{}", AUTH_V1_FILENAME, ts)); + fs::write(&tmp_path, &encrypted) + .map_err(|e| format!("写入临时凭据文件失败 {}: {}", tmp_path.display(), e))?; + + fs::rename(&tmp_path, &auth_path).map_err(|e| { + let _ = fs::remove_file(&tmp_path); + format!("原子替换凭据文件失败: {}", e) + })?; + + logger::log_info(&format!( + "[Qoder App Auth] 成功加密写入凭据: path={}, len={}", + auth_path.display(), + encrypted.len() + )); + + Ok(()) + } + + #[cfg(not(target_os = "windows"))] + { + Err("Qoder App 凭据写入仅支持 Windows 系统".to_string()) + } +} diff --git a/crates/cockpit-core/src/modules/qoder_channel.rs b/crates/cockpit-core/src/modules/qoder_channel.rs new file mode 100644 index 000000000..ab5b3dea3 --- /dev/null +++ b/crates/cockpit-core/src/modules/qoder_channel.rs @@ -0,0 +1,333 @@ +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum QoderChannel { + QoderIde, + QoderApp, + QoderCnIde, + QoderCnApp, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum QoderCredentialKind { + StateVscdb, // VS Code SQLite ItemTable: secret://aicoding.auth.* + AuthV1Dat, // Electron 裸二进制 v10 信封 AES-256-GCM +} + +impl QoderChannel { + pub const ALL: [Self; 4] = [ + Self::QoderIde, + Self::QoderApp, + Self::QoderCnIde, + Self::QoderCnApp, + ]; + + pub fn all() -> &'static [Self] { + &Self::ALL + } + + pub fn as_str(self) -> &'static str { + self.provider_key() + } + + pub fn parse(raw: Option<&str>) -> Result { + let normalized = raw + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("qoder") + .to_ascii_lowercase() + .replace('-', "_"); + + match normalized.as_str() { + "qoder" | "qoder_ide" => Ok(Self::QoderIde), + "qoder_app" => Ok(Self::QoderApp), + "qoder_cn_ide" => Ok(Self::QoderCnIde), + "qoder_cn_app" => Ok(Self::QoderCnApp), + other => Err(format!("不支持的 Qoder 渠道: {}", other)), + } + } + + pub fn provider_key(self) -> &'static str { + match self { + Self::QoderIde => "qoder", + Self::QoderApp => "qoder_app", + Self::QoderCnIde => "qoder_cn_ide", + Self::QoderCnApp => "qoder_cn_app", + } + } + + pub fn display_name(self) -> &'static str { + match self { + Self::QoderIde => "Qoder IDE", + Self::QoderApp => "Qoder App", + Self::QoderCnIde => "Qoder CN IDE", + Self::QoderCnApp => "Qoder CN App", + } + } + + pub fn is_cn(self) -> bool { + matches!(self, Self::QoderCnIde | Self::QoderCnApp) + } + + pub fn openapi_base_url(self) -> &'static str { + if self.is_cn() { + "https://openapi.qoder.com.cn" + } else { + "https://openapi.qoder.sh" + } + } + + pub fn credential_kind(self) -> QoderCredentialKind { + match self { + Self::QoderIde | Self::QoderCnIde => QoderCredentialKind::StateVscdb, + Self::QoderApp | Self::QoderCnApp => QoderCredentialKind::AuthV1Dat, + } + } + + pub fn accounts_index_filename(self) -> &'static str { + match self { + Self::QoderIde => "qoder_accounts.json", + Self::QoderApp => "qoder_app_accounts.json", + Self::QoderCnIde => "qoder_cn_ide_accounts.json", + Self::QoderCnApp => "qoder_cn_app_accounts.json", + } + } + + pub fn accounts_dir_name(self) -> &'static str { + match self { + Self::QoderIde => "qoder_accounts", + Self::QoderApp => "qoder_app_accounts", + Self::QoderCnIde => "qoder_cn_ide_accounts", + Self::QoderCnApp => "qoder_cn_app_accounts", + } + } + + pub fn instances_filename(self) -> &'static str { + match self { + Self::QoderIde => "qoder_instances.json", + Self::QoderApp => "qoder_app_instances.json", + Self::QoderCnIde => "qoder_cn_ide_instances.json", + Self::QoderCnApp => "qoder_cn_app_instances.json", + } + } + + /// 专属的数据根目录名称(相对 APPDATA / Application Support / .config) + pub fn app_support_dir_name(self) -> &'static str { + match self { + Self::QoderIde => "Qoder", + Self::QoderApp => "com.qoder.app.stable", + Self::QoderCnIde => "QoderCN", + Self::QoderCnApp => "com.qodercn.app.stable", + } + } + + /// 默认数据目录全路径 + pub fn default_user_data_dir(self) -> Result { + #[cfg(target_os = "windows")] + { + let appdata = std::env::var("APPDATA") + .map_err(|_| "无法获取 APPDATA 环境变量".to_string())?; + let base = PathBuf::from(appdata); + + if self == Self::QoderCnIde { + let primary = base.join("QoderCN"); + if primary.exists() { + return Ok(primary); + } + let alt = base.join("Qoder CN"); + if alt.exists() { + return Ok(alt); + } + return Ok(primary); + } + + return Ok(base.join(self.app_support_dir_name())); + } + + #[cfg(target_os = "macos")] + { + let home = dirs::home_dir().ok_or("无法获取用户主目录")?; + let support = home.join("Library/Application Support"); + return Ok(support.join(self.app_support_dir_name())); + } + + #[cfg(target_os = "linux")] + { + let home = dirs::home_dir().ok_or("无法获取用户主目录")?; + let config = home.join(".config"); + return Ok(config.join(self.app_support_dir_name())); + } + + #[allow(unreachable_code)] + Err("Qoder 仅支持 Windows、macOS 和 Linux".to_string()) + } + + /// 进程探测和查杀时匹配的二进制文件名白名单 + pub fn expected_exe_names(self) -> &'static [&'static str] { + match self { + Self::QoderIde => &["Qoder IDE.exe", "Qoder.exe"], + Self::QoderApp => &["Qoder.exe", "Qoder Launcher.exe"], + Self::QoderCnIde => &["Qoder CN IDE.exe", "Qoder.exe"], + Self::QoderCnApp => &["Qoder CN.exe", "Qoder Launcher.exe"], + } + } + + /// Windows 默认安装与执行文件候选列表 + pub fn default_exe_candidates(self) -> Vec { + let mut candidates = Vec::new(); + + #[cfg(target_os = "windows")] + { + let local_appdata = std::env::var("LOCALAPPDATA").ok().map(PathBuf::from); + let program_files = std::env::var("PROGRAMFILES").ok().map(PathBuf::from); + let program_files_x86 = std::env::var("PROGRAMFILES(X86)").ok().map(PathBuf::from); + let program_data = std::env::var("PROGRAMDATA").ok().map(PathBuf::from); + + match self { + Self::QoderIde => { + // 1. Qoder IDE 独立通道 + if let Some(ref pf) = program_files { + candidates.push(pf.join("Qoder IDE").join("Qoder IDE.exe")); + } + if let Some(ref la) = local_appdata { + candidates.push(la.join("Programs").join("Qoder IDE").join("Qoder IDE.exe")); + } + // 2. 原 Qoder (老通道) + if let Some(ref pf) = program_files { + candidates.push(pf.join("Qoder").join("Qoder.exe")); + } + if let Some(ref la) = local_appdata { + candidates.push(la.join("Programs").join("Qoder").join("Qoder.exe")); + } + if let Some(ref pfx86) = program_files_x86 { + candidates.push(pfx86.join("Qoder IDE").join("Qoder IDE.exe")); + } + } + Self::QoderApp => { + // 优先检查 install.ini 指向的主程序 + if let Some(ref pd) = program_data { + let ini_path = pd.join("Qoder").join("Qoder Launcher").join("install.ini"); + if let Some((dir, exe)) = parse_launcher_install_ini(&ini_path) { + candidates.push(PathBuf::from(dir).join(exe)); + } + } + if let Some(ref pf) = program_files { + candidates.push(pf.join("Qoder").join("Qoder").join("Qoder.exe")); + } + if let Some(ref pd) = program_data { + candidates.push(pd.join("Qoder").join("Qoder Launcher").join("Qoder Launcher.exe")); + } + } + Self::QoderCnIde => { + if let Some(ref pf) = program_files { + candidates.push(pf.join("Qoder CN IDE").join("Qoder CN IDE.exe")); + } + if let Some(ref la) = local_appdata { + candidates.push(la.join("Programs").join("Qoder CN IDE").join("Qoder CN IDE.exe")); + } + } + Self::QoderCnApp => { + if let Some(ref pd) = program_data { + let ini_file = pd.join("Qoder CN").join("install.ini"); + if let Some((dir, exe)) = parse_launcher_install_ini(&ini_file) { + candidates.push(PathBuf::from(dir).join(exe)); + } + } + if let Some(ref pf) = program_files { + candidates.push(pf.join("Qoder CN").join("Qoder CN").join("Qoder CN.exe")); + candidates.push(pf.join("Qoder CN").join("Qoder CN.exe")); + } + if let Some(ref pd) = program_data { + candidates.push(pd.join("Qoder CN").join("Qoder Launcher.exe")); + } + } + } + } + + #[cfg(target_os = "macos")] + { + match self { + Self::QoderIde => { + candidates.push(PathBuf::from("/Applications/Qoder.app/Contents/MacOS/Qoder")); + candidates.push(PathBuf::from("/Applications/Qoder IDE.app/Contents/MacOS/Qoder IDE")); + } + Self::QoderCnIde => { + candidates.push(PathBuf::from("/Applications/Qoder CN IDE.app/Contents/MacOS/Qoder CN IDE")); + } + _ => {} + } + } + + #[cfg(target_os = "linux")] + { + match self { + Self::QoderIde => { + candidates.push(PathBuf::from("/usr/bin/qoder")); + candidates.push(PathBuf::from("/usr/local/bin/qoder")); + candidates.push(PathBuf::from("/opt/qoder/qoder")); + } + _ => {} + } + } + + candidates + } + + /// 探测当前渠道已安装的执行文件路径 + pub fn detect_installed_exe(self) -> Option { + for candidate in self.default_exe_candidates() { + if candidate.exists() { + return Some(candidate); + } + } + None + } +} + +/// 解析 Qoder Launcher 的 install.ini (支持 UTF-16 与 UTF-8),提取 installDir 与 appExecutable +#[cfg(target_os = "windows")] +fn parse_launcher_install_ini(path: &Path) -> Option<(String, String)> { + if !path.exists() { + return None; + } + let bytes = std::fs::read(path).ok()?; + let text = if bytes.len() >= 2 && (bytes[0] == 0xFF && bytes[1] == 0xFE) { + let u16s: Vec = bytes[2..] + .chunks_exact(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect(); + String::from_utf16_lossy(&u16s) + } else if bytes.len() >= 2 && bytes[1] == 0x00 { + let u16s: Vec = bytes + .chunks_exact(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect(); + String::from_utf16_lossy(&u16s) + } else { + String::from_utf8_lossy(&bytes).to_string() + }; + + let mut install_dir = None; + let mut app_exe = None; + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') || trimmed.is_empty() { + continue; + } + if let Some((k, v)) = trimmed.split_once('=') { + let k = k.trim(); + let v = v.trim(); + if k.eq_ignore_ascii_case("installDir") { + install_dir = Some(v.to_string()); + } else if k.eq_ignore_ascii_case("appExecutable") { + app_exe = Some(v.to_string()); + } + } + } + + match (install_dir, app_exe) { + (Some(d), Some(e)) if !d.is_empty() && !e.is_empty() => Some((d, e)), + _ => None, + } +} diff --git a/crates/cockpit-core/src/modules/qoder_oauth.rs b/crates/cockpit-core/src/modules/qoder_oauth.rs index 9c0df9a12..dd8373a2c 100644 --- a/crates/cockpit-core/src/modules/qoder_oauth.rs +++ b/crates/cockpit-core/src/modules/qoder_oauth.rs @@ -12,15 +12,19 @@ use url::Url; use uuid::Uuid; use crate::models::qoder::{QoderAccount, QoderOAuthStartResponse}; +use crate::modules::qoder_channel::QoderChannel; use crate::modules::{config, logger, qoder_account, qoder_instance}; const OAUTH_TIMEOUT_SECONDS: i64 = 600; const OAUTH_POLL_INTERVAL_MS: u64 = 1000; const DEFAULT_LOGIN_BASE_URL: &str = "https://qoder.com/device/selectAccounts"; -const DEFAULT_OPENAPI_BASE_URL: &str = "https://openapi.qoder.sh"; +pub const DEFAULT_GLOBAL_OPENAPI_BASE_URL: &str = "https://openapi.qoder.sh"; +pub const DEFAULT_CN_OPENAPI_BASE_URL: &str = "https://openapi.qoder.com.cn"; +pub const DEFAULT_OPENAPI_BASE_URL: &str = DEFAULT_GLOBAL_OPENAPI_BASE_URL; const QODER_IDE_REDIRECT_URI: &str = "qoder://aicoding.aicoding-agent/login-success"; const QODER_DEVICE_LOGIN_CHALLENGE_METHOD: &str = "S256"; const DEVICE_TOKEN_POLL_PATH: &str = "/api/v1/deviceToken/poll"; +const DEVICE_TOKEN_REFRESH_PATH: &str = "/api/v1/deviceToken/refresh"; const USER_INFO_PATH: &str = "/api/v1/userinfo"; const USER_STATUS_PATH: &str = "/api/v3/user/status"; const DATA_POLICY_PATH: &str = "/api/v2/config/getDataPolicy"; @@ -740,52 +744,242 @@ fn extract_access_token_from_account(account: &QoderAccount) -> Option { None } -fn ensure_refresh_identity_consistent( - target: &QoderAccount, - user_status: &Value, -) -> Result<(), String> { - let target_user_id = target - .user_id - .as_deref() - .and_then(|value| normalize_non_empty(Some(value))); - let status_user_id = user_status - .get("id") - .and_then(|value| value.as_str()) - .and_then(|value| normalize_non_empty(Some(value))); +fn extract_refresh_token_from_account(account: &QoderAccount) -> Option { + let user_info = account.auth_user_info_raw.as_ref()?; + let candidate_paths: &[&[&str]] = &[ + &["refreshToken"], + &["refresh_token"], + &["securityRefreshToken"], + &["data", "refreshToken"], + &["data", "refresh_token"], + &["result", "refreshToken"], + &["result", "refresh_token"], + ]; + for path in candidate_paths { + if let Some(value) = get_string_at_path(user_info, path) { + return Some(value); + } + } + None +} + +fn is_unauthorized_error(err: &str) -> bool { + err.contains("401") + || err.contains("403") + || err.contains("Unauthorized") + || err.contains("UNAUTHORIZED") +} + +#[derive(Debug, Clone)] +struct RefreshedTokenInfo { + access_token: String, + refresh_token: String, + expires_at: Option, + refresh_token_expires_at: Option, +} + +async fn request_device_token_refresh( + client: &reqwest::Client, + openapi_base_url: &str, + refresh_token: &str, + access_token: Option<&str>, + machine_info: Option<&QoderMachineInfo>, +) -> Result { + use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE}; - if let (Some(target_uid), Some(status_uid)) = (target_user_id.as_ref(), status_user_id.as_ref()) + let mut headers = HeaderMap::new(); + if let Ok(value) = HeaderValue::from_str("application/json") { + headers.insert(ACCEPT, value.clone()); + headers.insert(CONTENT_TYPE, value); + } + if let Some(tok) = access_token { + if let Ok(value) = HeaderValue::from_str(&format!("Bearer {}", tok)) { + headers.insert(AUTHORIZATION, value); + } + } + if let Some(version) = machine_info + .and_then(|v| v.cosy_version.clone()) + .or_else(detect_qoder_product_version) { - if !target_uid.eq_ignore_ascii_case(status_uid) { - return Err(format!( - "官方接口返回账号与目标账号不一致: target_user_id={}, actual_user_id={}", - target_uid, status_uid - )); + if let Ok(value) = HeaderValue::from_str(&version) { + headers.insert("Cosy-Version", value); } - } else { - let target_email = - normalize_non_empty(Some(target.email.as_str())).map(|value| value.to_lowercase()); - let status_email = user_status - .get("email") - .and_then(|value| value.as_str()) - .and_then(|value| normalize_non_empty(Some(value))) - .map(|value| value.to_lowercase()); - if let (Some(left), Some(right)) = (target_email.as_ref(), status_email.as_ref()) { - if left != right { - return Err(format!( - "官方接口返回账号与目标账号不一致: target_email={}, actual_email={}", - left, right - )); - } + } + if let Some(machine_token) = machine_info + .map(|v| v.token.as_str()) + .and_then(|v| normalize_non_empty(Some(v))) + { + if let Ok(value) = HeaderValue::from_str(&machine_token) { + headers.insert("Cosy-MachineToken", value); } } - Ok(()) + let body = serde_json::json!({ + "refresh_token": refresh_token, + "refreshToken": refresh_token, + }); + + let url = format!("{}{}", openapi_base_url, DEVICE_TOKEN_REFRESH_PATH); + let resp = client + .post(&url) + .headers(headers) + .json(&body) + .send() + .await + .map_err(|e| format!("请求 Token 刷新接口网络失败 ({}): {}", url, e))?; + + let status = resp.status(); + if !status.is_success() { + let body_text = resp.text().await.unwrap_or_default(); + let is_unauthorized = status == reqwest::StatusCode::UNAUTHORIZED + || (status == reqwest::StatusCode::BAD_REQUEST + && (body_text.contains("invalid refresh_token") + || body_text.contains("invalid_grant") + || body_text.contains("User not authenticated"))); + if is_unauthorized { + return Err("登录凭据已完全失效(阿里专网会话超时),请在 Qoder 中重新登录".to_string()); + } + return Err(format!( + "Token 刷新失败 (status={}, body={})", + status, body_text + )); + } + + let payload: Value = resp + .json() + .await + .map_err(|e| format!("解析 Token 刷新响应失败: {}", e))?; + + let candidate_tokens: &[&[&str]] = &[ + &["token"], + &["device_token"], + &["accessToken"], + &["access_token"], + &["data", "token"], + &["data", "device_token"], + &["data", "accessToken"], + &["result", "token"], + ]; + let candidate_refresh: &[&[&str]] = &[ + &["refresh_token"], + &["refreshToken"], + &["data", "refresh_token"], + &["data", "refreshToken"], + &["result", "refresh_token"], + ]; + + let new_token = candidate_tokens + .iter() + .find_map(|path| get_string_at_path(&payload, path)) + .ok_or_else(|| "Token 刷新响应未包含有效 access token".to_string())?; + + let new_refresh_token = candidate_refresh + .iter() + .find_map(|path| get_string_at_path(&payload, path)) + .unwrap_or_else(|| refresh_token.to_string()); + + let expires_at = get_string_at_path(&payload, &["expires_at"]) + .or_else(|| get_string_at_path(&payload, &["data", "expires_at"])) + .and_then(|v| parse_expire_timestamp_ms(Some(&v))); + + let refresh_token_expires_at = get_string_at_path(&payload, &["refresh_token_expires_at"]) + .or_else(|| get_string_at_path(&payload, &["data", "refresh_token_expires_at"])) + .and_then(|v| parse_expire_timestamp_ms(Some(&v))); + + Ok(RefreshedTokenInfo { + access_token: new_token, + refresh_token: new_refresh_token, + expires_at, + refresh_token_expires_at, + }) +} + +fn update_account_token_info( + account: &mut QoderAccount, + refreshed_info: &RefreshedTokenInfo, +) { + let mut user_info = account + .auth_user_info_raw + .clone() + .unwrap_or_else(|| Value::Object(serde_json::Map::new())); + if !user_info.is_object() { + user_info = Value::Object(serde_json::Map::new()); + } + if let Some(map) = user_info.as_object_mut() { + map.insert("token".to_string(), Value::String(refreshed_info.access_token.clone())); + map.insert("refreshToken".to_string(), Value::String(refreshed_info.refresh_token.clone())); + map.insert("refresh_token".to_string(), Value::String(refreshed_info.refresh_token.clone())); + if let Some(expires_at) = refreshed_info.expires_at.as_ref() { + map.insert("expireTime".to_string(), Value::String(expires_at.clone())); + map.insert("expiresAt".to_string(), Value::String(expires_at.clone())); + } + if let Some(rt_expires_at) = refreshed_info.refresh_token_expires_at.as_ref() { + map.insert("refreshTokenExpireTime".to_string(), Value::String(rt_expires_at.clone())); + map.insert("refreshTokenExpiresAt".to_string(), Value::String(rt_expires_at.clone())); + } + } + account.auth_user_info_raw = Some(user_info); +} + +pub fn is_cn_account(target: &QoderAccount, channel: Option) -> bool { + // 1. 若显式指定为国内渠道,毫无疑问是国内 + if let Some(ch) = channel { + if ch.is_cn() { + return true; + } + } + + // 2. 特别注意:阿里巴巴集团员工的 SAML 企业组织账号(如 @qoder.alibaba-inc.com、@qoderwork.alibaba-inc.com) + // 走的是 Qoder Global 全球统一服务体系(由新加坡节点 openapi.qoder.sh 服务,source=sso.saml.organization), + // 绝非国内阿里云公有云,绝不能误判为 CN! + let email = target.email.to_lowercase(); + if email.contains("alibaba-inc.com") { + return false; + } + + // 3. 账号特征审查:国内版灵码 / 阿里云专属域名与特征 + if email.contains("qoder.cn") + || email.ends_with(".cn") + || email.contains("aliyun") + || email.contains("alipay") + || email.contains("taobao") + { + return true; + } + + if let Some(ref uid) = target.user_id { + let u = uid.to_lowercase(); + if u.starts_with("aliyun-") { + return true; + } + } + + if let Some(ref name) = target.display_name { + let n = name.to_lowercase(); + if n.starts_with("aliyun-") { + return true; + } + } + + false +} + +fn get_candidate_openapi_base_urls( + target: &QoderAccount, + channel: Option, +) -> Vec<&'static str> { + if is_cn_account(target, channel) { + vec![DEFAULT_CN_OPENAPI_BASE_URL, DEFAULT_GLOBAL_OPENAPI_BASE_URL] + } else { + vec![DEFAULT_GLOBAL_OPENAPI_BASE_URL, DEFAULT_CN_OPENAPI_BASE_URL] + } } fn build_refresh_user_info_raw( target: &QoderAccount, access_token: &str, - user_status: &Value, + user_info_fetched: Option<&Value>, + user_status: Option<&Value>, data_policy: Option<&Value>, ) -> Value { let mut user_info = target @@ -825,27 +1019,34 @@ fn build_refresh_user_info_raw( map.insert("name".to_string(), Value::String(display_name)); } } + if let Some(fetched) = user_info_fetched { + copy_optional_field(fetched, map, "name", "name"); + copy_optional_field(fetched, map, "email", "email"); + copy_optional_field(fetched, map, "avatarUrl", "avatarUrl"); + copy_optional_field(fetched, map, "avatar_url", "avatarUrl"); + copy_optional_field(fetched, map, "userTag", "userTag"); + copy_optional_field(fetched, map, "user_tag", "userTag"); + } } - merge_user_status_into_user_info(&mut user_info, user_status, data_policy); + if let Some(status) = user_status { + merge_user_status_into_user_info(&mut user_info, status, data_policy); + } user_info } -async fn fetch_qoder_user_status_bundle_with_machine_info( - client: &reqwest::Client, - openapi_base_url: &str, - token: &str, - machine_info: Option<&QoderMachineInfo>, -) -> Result<(Value, Option), String> { - fetch_qoder_user_status_bundle(client, openapi_base_url, token, machine_info).await -} - -async fn refresh_account_from_openapi_once(account_id: &str) -> Result { - let target = qoder_account::load_account(account_id) - .ok_or_else(|| format!("Qoder 账号不存在: {}", account_id))?; - - let access_token = extract_access_token_from_account(&target) - .ok_or_else(|| "Qoder 账号缺少 access token,请重新登录后再刷新".to_string())?; +pub async fn refresh_account_from_openapi_for_channel( + channel: Option, + account_id: &str, +) -> Result { + let (ch, mut target) = if let Some(ch) = channel { + let acc = qoder_account::load_account_for_channel(ch, account_id) + .ok_or_else(|| format!("{} 账号不存在: {}", ch.display_name(), account_id))?; + (ch, acc) + } else { + qoder_account::find_account_channel(account_id) + .ok_or_else(|| format!("Qoder 账号不存在: {}", account_id))? + }; let client = build_reqwest_client()?; let machine_info = match read_qoder_machine_info_cache() { @@ -859,109 +1060,169 @@ async fn refresh_account_from_openapi_once(account_id: &str) -> Result Some(value), - Err(err) => { - logger::log_warn(&format!( - "[Qoder Refresh] 获取 /api/v2/user/plan 失败,将沿用本地缓存: {}", - err - )); - target.auth_user_plan_raw.clone() + let candidate_base_urls = get_candidate_openapi_base_urls(&target, Some(ch)); + let mut last_error = String::new(); + + for base_url in candidate_base_urls { + let mut active_token = current_access_token.clone(); + + // 1. 若当前没有 access_token 但有 refresh_token,直接在当前节点先刷新 + if active_token.is_none() { + if let Some(ref rt) = refresh_token { + match request_device_token_refresh(&client, base_url, rt, current_access_token.as_deref(), machine_info.as_ref()).await { + Ok(info) => { + update_account_token_info(&mut target, &info); + let _ = qoder_account::save_account_file_for_channel(ch, &target); + active_token = Some(info.access_token.clone()); + current_access_token = Some(info.access_token); + } + Err(err) => { + last_error = format!("{} 换新 Token 失败: {}", base_url, err); + continue; + } + } + } } - }; - let mut quota_query_error: Option = None; - let credit_usage_raw = match fetch_qoder_credit_usage( - &client, - DEFAULT_OPENAPI_BASE_URL, - &access_token, - machine_info.as_ref(), - ) - .await - { - Ok(value) => Some(value), - Err(err) => { - logger::log_warn(&format!( - "[Qoder Refresh] 获取 /api/v2/quota/usage 失败,将沿用本地缓存: {}", - err - )); - quota_query_error = Some(err.clone()); - target.auth_credit_usage_raw.clone() - } - }; + let Some(ref tok) = active_token else { + continue; + }; - let refreshed = qoder_account::upsert_account_from_snapshot( - user_info_raw, - user_plan_raw, - credit_usage_raw, - )?; - let refreshed = if refreshed.id == target.id { - qoder_account::update_quota_query_error(&refreshed.id, quota_query_error)? - .unwrap_or(refreshed) + // 2. 尝试请求额度配额 /api/v2/quota/usage + let credit_res = fetch_qoder_credit_usage(&client, base_url, tok, machine_info.as_ref()).await; + let final_token = match credit_res { + Ok(credit_val) => (tok.clone(), Some(credit_val)), + Err(ref err) if is_unauthorized_error(err) => { + // 401 Unauthorized!立即使用 refresh_token 换新! + if let Some(ref rt) = refresh_token { + logger::log_info(&format!( + "[Qoder Refresh] 请求配额遇到 401,尝试使用 refresh_token 在 {} 自动换新...", + base_url + )); + match request_device_token_refresh(&client, base_url, rt, Some(tok), machine_info.as_ref()).await { + Ok(info) => { + update_account_token_info(&mut target, &info); + let _ = qoder_account::save_account_file_for_channel(ch, &target); + let new_tok = info.access_token; + current_access_token = Some(new_tok.clone()); + // 用新 token 重试请求配额 + match fetch_qoder_credit_usage(&client, base_url, &new_tok, machine_info.as_ref()).await { + Ok(new_credit) => (new_tok, Some(new_credit)), + Err(retry_err) => { + last_error = format!("换新 Token 后请求配额仍失败: {}", retry_err); + continue; + } + } + } + Err(ref_err) => { + last_error = format!("{} 换新 Token 失败: {}", base_url, ref_err); + continue; + } + } + } else { + last_error = format!("{} 凭据已失效且未找到 refresh_token,请重新登录", base_url); + continue; + } + } + Err(other_err) => { + last_error = format!("{} 请求配额失败: {}", base_url, other_err); + continue; + } + }; + + let (access_token_ok, credit_usage_raw) = final_token; + + // 3. 请求用户套餐 user/plan + let user_plan_raw = match fetch_qoder_user_plan(&client, base_url, &access_token_ok, machine_info.as_ref()).await { + Ok(val) => Some(val), + Err(err) => { + logger::log_warn(&format!("[Qoder Refresh] 获取 /api/v2/user/plan 失败,沿用本地缓存: {}", err)); + target.auth_user_plan_raw.clone() + } + }; + + // 4. 请求用户信息 userinfo + let user_info_fetched = fetch_qoder_user_info(&client, base_url, &access_token_ok, machine_info.as_ref()).await.ok(); + + // 5. 请求状态探针 user/status(非强制!若失败完全不阻断) + let (user_status, data_policy) = match fetch_qoder_user_status_bundle(&client, base_url, &access_token_ok, machine_info.as_ref()).await { + Ok(bundle) => (Some(bundle.0), bundle.1), + Err(err) => { + logger::log_warn(&format!("[Qoder Refresh] 获取 /api/v3/user/status 状态探针失败(已降级容错): {}", err)); + (None, None) + } + }; + + let user_info_raw = build_refresh_user_info_raw( + &target, + &access_token_ok, + user_info_fetched.as_ref(), + user_status.as_ref(), + data_policy.as_ref(), + ); + + let mut refreshed = qoder_account::merge_account_snapshot( + target.clone(), + Some(user_info_raw), + user_plan_raw, + credit_usage_raw, + ); + refreshed.quota_query_last_error = None; + refreshed.quota_query_last_error_at = None; + let saved = qoder_account::upsert_account_record_for_channel(ch, refreshed)?; + return Ok(saved); + } + + let final_err = if last_error.is_empty() { + "刷新 Qoder 额度失败,请检查网络或重新登录".to_string() } else { - refreshed + last_error }; - if refreshed.id != target.id { - return Err(format!( - "刷新结果账号不一致: target_id={}, actual_id={}", - target.id, refreshed.id - )); - } - Ok(refreshed) + let _ = qoder_account::update_quota_query_error_for_channel(ch, account_id, Some(final_err.clone())); + Err(final_err) } pub async fn refresh_account_from_openapi(account_id: &str) -> Result { - let result = refresh_account_from_openapi_once(account_id).await; - if let Err(err) = &result { - let _ = qoder_account::update_quota_query_error(account_id, Some(err.clone())); - } - result + refresh_account_from_openapi_for_channel(None, account_id).await } -pub async fn refresh_all_accounts_from_openapi() -> Result { - let accounts = qoder_account::list_accounts(); +pub async fn refresh_all_accounts_for_channel(channel: QoderChannel) -> Result { + let accounts = qoder_account::list_accounts_for_channel(channel); if accounts.is_empty() { return Ok(0); } - - let mut success_count: i32 = 0; + let mut success_count = 0; for account in accounts { - match refresh_account_from_openapi(&account.id).await { - Ok(_) => { - success_count += 1; - } + match refresh_account_from_openapi_for_channel(Some(channel), &account.id).await { + Ok(_) => success_count += 1, Err(err) => { logger::log_warn(&format!( - "[Qoder Refresh] 批量刷新失败: account_id={}, email={}, error={}", - account.id, account.email, err + "[Qoder Refresh] [{}] 批量刷新失败: account_id={}, email={}, error={}", + channel.as_str(), account.id, account.email, err )); } } } - Ok(success_count) } +pub async fn refresh_all_accounts_from_openapi() -> Result { + let mut total_success = 0; + for &ch in &QoderChannel::ALL { + if let Ok(count) = refresh_all_accounts_for_channel(ch).await { + total_success += count; + } + } + Ok(total_success) +} + fn clear_pending_if_matches(login_id: &str) { if let Ok(mut guard) = PENDING_OAUTH_STATE.lock() { if guard.as_ref().map(|state| state.login_id.as_str()) == Some(login_id) { diff --git a/crates/cockpit-core/src/modules/vscode_inject.rs b/crates/cockpit-core/src/modules/vscode_inject.rs index 4dadd9a88..28f71a379 100644 --- a/crates/cockpit-core/src/modules/vscode_inject.rs +++ b/crates/cockpit-core/src/modules/vscode_inject.rs @@ -150,7 +150,7 @@ fn get_local_state_path(data_root: &Path) -> Result { } #[cfg(target_os = "windows")] -fn get_windows_encryption_key(data_root: Option<&Path>) -> Result, String> { +pub(crate) fn get_windows_encryption_key(data_root: Option<&Path>) -> Result, String> { let owned_root; let root = if let Some(path) = data_root { path @@ -225,7 +225,7 @@ fn dpapi_decrypt(encrypted: &[u8]) -> Result, String> { } #[cfg(target_os = "windows")] -fn decrypt_windows_gcm_v10(key: &[u8], encrypted: &[u8]) -> Result, String> { +pub(crate) fn decrypt_windows_gcm_v10(key: &[u8], encrypted: &[u8]) -> Result, String> { if encrypted.len() < 31 { return Err("Encrypted data too short".to_string()); } @@ -248,7 +248,7 @@ fn decrypt_windows_gcm_v10(key: &[u8], encrypted: &[u8]) -> Result, Stri } #[cfg(target_os = "windows")] -fn encrypt_windows_gcm_v10(key: &[u8], plaintext: &[u8]) -> Result, String> { +pub(crate) fn encrypt_windows_gcm_v10(key: &[u8], plaintext: &[u8]) -> Result, String> { let cipher = Aes256Gcm::new(GenericArray::from_slice(key)); let nonce = Aes256Gcm::generate_nonce(&mut OsRng); let ciphertext = cipher @@ -1329,6 +1329,8 @@ fn inject_secret_to_state_db_with_mode( ) .map_err(|e| format!("Failed to write to state.vscdb: {}", e))?; + let _ = conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);"); + Ok(()) } diff --git a/src-tauri/src/commands/provider_current.rs b/src-tauri/src/commands/provider_current.rs index ac9dd5ad2..242c4d63a 100644 --- a/src-tauri/src/commands/provider_current.rs +++ b/src-tauri/src/commands/provider_current.rs @@ -26,12 +26,20 @@ fn resolve_provider_current_account_id(platform: &str) -> Result, let accounts = crate::modules::codebuddy_cn_account::list_accounts(); Ok(crate::modules::codebuddy_cn_account::resolve_current_account_id(&accounts)) } - "qoder" => { + "qoder" | "qoder_ide" => { let accounts = crate::modules::qoder_account::list_accounts(); Ok(crate::modules::qoder_account::resolve_current_account_id( &accounts, )) } + "qoder_app" | "qoder_cn_ide" | "qoder_cn_app" => { + let channel = crate::modules::qoder_channel::QoderChannel::parse(Some(platform))?; + let accounts = crate::modules::qoder_account::list_accounts_for_channel(channel); + Ok(crate::modules::qoder_account::resolve_current_account_id_for_channel( + channel, + &accounts, + )) + } "trae" | "trae_solo" | "trae-solo" | "trae_cn" | "trae-cn" | "trae_solo_cn" | "trae-solo-cn" => { let platform = crate::modules::trae_account::TraePlatformKind::parse(Some(platform))?; diff --git a/src-tauri/src/commands/qoder.rs b/src-tauri/src/commands/qoder.rs index 9a9a134df..5ae4b1670 100644 --- a/src-tauri/src/commands/qoder.rs +++ b/src-tauri/src/commands/qoder.rs @@ -2,39 +2,87 @@ use std::time::Instant; use tauri::{AppHandle, Emitter}; use crate::models::qoder::{QoderAccount, QoderOAuthStartResponse}; +use crate::modules::qoder_channel::QoderChannel; use crate::modules::{logger, qoder_account, qoder_oauth}; +#[tauri::command] +pub fn list_qoder_channel_accounts( + channel: Option, +) -> Result, String> { + let qoder_channel = QoderChannel::parse(channel.as_deref())?; + qoder_account::list_accounts_checked_for_channel(qoder_channel) +} + #[tauri::command] pub fn list_qoder_accounts() -> Result, String> { - qoder_account::list_accounts_checked() + list_qoder_channel_accounts(Some("qoder".to_string())) +} + +#[tauri::command] +pub fn delete_qoder_channel_account( + channel: Option, + account_id: String, +) -> Result<(), String> { + let qoder_channel = QoderChannel::parse(channel.as_deref())?; + qoder_account::remove_account_for_channel(qoder_channel, &account_id) } #[tauri::command] pub fn delete_qoder_account(account_id: String) -> Result<(), String> { - qoder_account::remove_account(&account_id) + delete_qoder_channel_account(Some("qoder".to_string()), account_id) +} + +#[tauri::command] +pub fn delete_qoder_channel_accounts( + channel: Option, + account_ids: Vec, +) -> Result<(), String> { + let qoder_channel = QoderChannel::parse(channel.as_deref())?; + qoder_account::remove_accounts_for_channel(qoder_channel, &account_ids) } #[tauri::command] pub fn delete_qoder_accounts(account_ids: Vec) -> Result<(), String> { - qoder_account::remove_accounts(&account_ids) + delete_qoder_channel_accounts(Some("qoder".to_string()), account_ids) +} + +#[tauri::command] +pub fn import_qoder_channel_from_json( + channel: Option, + json_content: String, +) -> Result, String> { + let qoder_channel = QoderChannel::parse(channel.as_deref())?; + qoder_account::import_from_json_for_channel(qoder_channel, &json_content) } #[tauri::command] pub fn import_qoder_from_json(json_content: String) -> Result, String> { - qoder_account::import_from_json(&json_content) + import_qoder_channel_from_json(Some("qoder".to_string()), json_content) } #[tauri::command] -pub fn import_qoder_from_local(app: AppHandle) -> Result, String> { - match qoder_account::import_from_local()? { +pub fn import_qoder_channel_from_local( + app: AppHandle, + channel: Option, +) -> Result, String> { + let qoder_channel = QoderChannel::parse(channel.as_deref())?; + match qoder_account::import_from_local_channel(qoder_channel)? { Some(account) => { let _ = crate::modules::tray::update_tray_menu(&app); Ok(vec![account]) } - None => Err("未找到本地 Qoder 登录信息".to_string()), + None => Err(format!( + "未找到本地 {} 登录信息", + qoder_channel.display_name() + )), } } +#[tauri::command] +pub fn import_qoder_from_local(app: AppHandle) -> Result, String> { + import_qoder_channel_from_local(app, Some("qoder".to_string())) +} + #[tauri::command] pub async fn qoder_oauth_login_start() -> Result { let started_at = Instant::now(); @@ -113,22 +161,37 @@ pub fn qoder_oauth_login_cancel(login_id: Option) -> Result<(), String> qoder_oauth::cancel_login(login_id.as_deref()) } +#[tauri::command] +pub fn export_qoder_channel_accounts( + channel: Option, + account_ids: Vec, +) -> Result { + let qoder_channel = QoderChannel::parse(channel.as_deref())?; + qoder_account::export_accounts_for_channel(qoder_channel, &account_ids) +} + #[tauri::command] pub fn export_qoder_accounts(account_ids: Vec) -> Result { - qoder_account::export_accounts(&account_ids) + export_qoder_channel_accounts(Some("qoder".to_string()), account_ids) } #[tauri::command] pub async fn refresh_qoder_token( app: AppHandle, account_id: String, + channel: Option, ) -> Result { let started_at = Instant::now(); + let qoder_channel = channel + .as_deref() + .and_then(|c| QoderChannel::parse(Some(c)).ok()); + logger::log_info(&format!( - "[Qoder Command] 手动刷新账号开始: account_id={}", - account_id + "[Qoder Command] 手动刷新账号开始: account_id={}, channel={:?}", + account_id, + qoder_channel )); - match qoder_oauth::refresh_account_from_openapi(&account_id).await { + match qoder_oauth::refresh_account_from_openapi_for_channel(qoder_channel, &account_id).await { Ok(account) => { let _ = crate::modules::tray::update_tray_menu(&app); logger::log_info(&format!( @@ -166,45 +229,70 @@ pub async fn refresh_all_qoder_tokens(app: AppHandle) -> Result { } #[tauri::command] -pub async fn inject_qoder_account(app: AppHandle, account_id: String) -> Result { +pub async fn inject_qoder_channel_account( + app: AppHandle, + channel: Option, + account_id: String, +) -> Result { let started_at = Instant::now(); + let qoder_channel = QoderChannel::parse(channel.as_deref())?; + logger::log_info(&format!( - "[Qoder Switch] 开始切换账号: account_id={}", + "[Qoder Switch] 开始切换账号: channel={}, display={}, account_id={}", + qoder_channel.provider_key(), + qoder_channel.display_name(), account_id )); - let account = qoder_account::load_account(&account_id) - .ok_or_else(|| format!("Qoder 账号不存在: {}", account_id))?; + let account = qoder_account::load_account_for_channel(qoder_channel, &account_id) + .or_else(|| qoder_account::load_account(&account_id)) + .ok_or_else(|| format!("{} 账号不存在: {}", qoder_channel.display_name(), account_id))?; - qoder_account::inject_to_qoder(&account_id)?; - crate::modules::provider_current_state::set_current_account_id( - "qoder", - Some(account_id.as_str()), - )?; - - if let Err(err) = crate::modules::qoder_instance::update_default_settings( - Some(Some(account_id.clone())), - None, - Some(false), - ) { - logger::log_warn(&format!("更新 Qoder 默认实例绑定账号失败: {}", err)); + // 1. 关进程:先彻底终止对应渠道正在运行的旧进程,阻断内存旧凭据回写覆盖! + match crate::modules::process::close_qoder_channel_instances(qoder_channel, &[], 5) { + Ok(()) => { + logger::log_info(&format!( + "[Qoder Switch] 成功关闭 {} 旧进程", + qoder_channel.display_name() + )); + } + Err(err) => { + logger::log_warn(&format!( + "[Qoder Switch] 尝试关闭 {} 旧进程异常(继续注入): {}", + qoder_channel.display_name(), + err + )); + } } - let launch_warning = match crate::commands::qoder_instance::qoder_start_instance( - "__default__".to_string(), - ) - .await - { + // 释放文件锁微延迟 + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + // 2. 注入:安全写入磁盘凭据 + qoder_account::inject_to_qoder_channel_with_account(qoder_channel, &account_id)?; + + // 3. 状态:更新当前平台活跃账号标记 + let _ = crate::modules::provider_current_state::set_current_account_id( + qoder_channel.provider_key(), + Some(account_id.as_str()), + ); + + // 4. 启动新实例 + let launch_result = + crate::modules::process::start_qoder_channel_default_with_args(qoder_channel, &[], false); + let launch_warning = match launch_result { Ok(_) => None, Err(err) => { - if err.starts_with("APP_PATH_NOT_FOUND:") || err.contains("启动 Qoder 失败") { - logger::log_warn(&format!("Qoder 默认实例启动失败: {}", err)); - if err.starts_with("APP_PATH_NOT_FOUND:") || err.contains("APP_PATH_NOT_FOUND:") { - let _ = app.emit( - "app:path_missing", - serde_json::json!({ "app": "qoder", "retry": { "kind": "default" } }), - ); - } + if err.starts_with("APP_PATH_NOT_FOUND:") || err.contains("启动 Qoder 失败") || err.contains("未找到") { + logger::log_warn(&format!("{} 默认实例启动失败: {}", qoder_channel.display_name(), err)); + let _ = app.emit( + "app:path_missing", + serde_json::json!({ + "app": qoder_channel.provider_key(), + "channel": qoder_channel.provider_key(), + "retry": { "kind": "default" } + }), + ); Some(err) } else { return Err(err); @@ -216,16 +304,18 @@ pub async fn inject_qoder_account(app: AppHandle, account_id: String) -> Result< if let Some(err) = launch_warning { logger::log_warn(&format!( - "[Qoder Switch] 切号完成但启动失败: account_id={}, email={}, elapsed={}ms, error={}", + "[Qoder Switch] 切号完成但启动失败: channel={}, account_id={}, email={}, elapsed={}ms, error={}", + qoder_channel.display_name(), account.id, account.email, started_at.elapsed().as_millis(), err )); - Ok(format!("切换完成,但 Qoder 启动失败: {}", err)) + Ok(format!("切换完成,但 {} 启动失败: {}", qoder_channel.display_name(), err)) } else { logger::log_info(&format!( - "[Qoder Switch] 切号成功: account_id={}, email={}, elapsed={}ms", + "[Qoder Switch] 切号成功: channel={}, account_id={}, email={}, elapsed={}ms", + qoder_channel.display_name(), account.id, account.email, started_at.elapsed().as_millis() @@ -234,15 +324,38 @@ pub async fn inject_qoder_account(app: AppHandle, account_id: String) -> Result< } } +#[tauri::command] +pub async fn inject_qoder_account(app: AppHandle, account_id: String) -> Result { + inject_qoder_channel_account(app, Some("qoder".to_string()), account_id).await +} + +#[tauri::command] +pub fn update_qoder_channel_account_tags( + channel: Option, + account_id: String, + tags: Vec, +) -> Result { + let qoder_channel = QoderChannel::parse(channel.as_deref())?; + qoder_account::update_account_tags_for_channel(qoder_channel, &account_id, tags) +} + #[tauri::command] pub fn update_qoder_account_tags( account_id: String, tags: Vec, ) -> Result { - qoder_account::update_account_tags(&account_id, tags) + update_qoder_channel_account_tags(Some("qoder".to_string()), account_id, tags) +} + +#[tauri::command] +pub fn get_qoder_channel_accounts_index_path( + channel: Option, +) -> Result { + let qoder_channel = QoderChannel::parse(channel.as_deref())?; + qoder_account::accounts_index_path_string_for_channel(qoder_channel) } #[tauri::command] pub fn get_qoder_accounts_index_path() -> Result { - qoder_account::accounts_index_path_string() + get_qoder_channel_accounts_index_path(Some("qoder".to_string())) } diff --git a/src-tauri/src/commands/qoder_instance.rs b/src-tauri/src/commands/qoder_instance.rs index 7d57d03c0..194493fc7 100644 --- a/src-tauri/src/commands/qoder_instance.rs +++ b/src-tauri/src/commands/qoder_instance.rs @@ -2,9 +2,14 @@ use std::path::Path; use crate::models::InstanceProfileView; use crate::modules; +use crate::modules::qoder_channel::QoderChannel; const DEFAULT_INSTANCE_ID: &str = "__default__"; +fn parse_channel(platform_id: Option) -> Result { + QoderChannel::parse(platform_id.as_deref()) +} + fn is_profile_initialized(user_data_dir: &str) -> bool { let path = Path::new(user_data_dir); if !path.exists() { @@ -16,11 +21,16 @@ fn is_profile_initialized(user_data_dir: &str) -> bool { } } -fn resolve_running_pid(last_pid: Option, user_data_dir: Option<&str>) -> Option { - modules::process::resolve_qoder_pid(last_pid, user_data_dir) +fn resolve_running_pid( + channel: QoderChannel, + last_pid: Option, + user_data_dir: Option<&str>, +) -> Option { + modules::process::resolve_qoder_pid_for_channel(last_pid, user_data_dir, channel) } fn inject_bound_account_for_instance_start( + channel: QoderChannel, user_data_dir: &str, bind_account_id: Option<&str>, ) -> Result<(), String> { @@ -31,26 +41,36 @@ fn inject_bound_account_for_instance_start( return Ok(()); }; - let account = modules::qoder_account::load_account(bind_id) + let account = modules::qoder_account::load_account_for_channel(channel, bind_id) + .or_else(|| modules::qoder_account::load_account(bind_id)) .ok_or_else(|| format!("绑定账号不存在: {}", bind_id))?; modules::logger::log_info(&format!( - "[Qoder Instance] 实例启动检测到绑定账号,准备注入: bind_account_id={}, email={}, user_data_dir={}", - bind_id, account.email, user_data_dir + "[{}] 实例启动检测到绑定账号,准备注入: bind_account_id={}, email={}, user_data_dir={}", + channel.display_name(), + bind_id, + account.email, + user_data_dir )); - modules::qoder_account::inject_to_qoder_for_user_data_dir(user_data_dir, bind_id)?; + modules::qoder_account::inject_to_qoder_channel_for_user_data_dir(channel, user_data_dir, bind_id)?; Ok(()) } #[tauri::command] -pub async fn qoder_get_instance_defaults() -> Result { - modules::qoder_instance::get_instance_defaults() +pub async fn qoder_get_instance_defaults( + platform_id: Option, +) -> Result { + let channel = parse_channel(platform_id)?; + modules::qoder_instance::get_instance_defaults_for_channel(channel) } #[tauri::command] -pub async fn qoder_list_instances() -> Result, String> { - let store = modules::qoder_instance::load_instance_store()?; - let default_dir = modules::qoder_instance::get_default_qoder_user_data_dir()?; +pub async fn qoder_list_instances( + platform_id: Option, +) -> Result, String> { + let channel = parse_channel(platform_id)?; + let store = modules::qoder_instance::load_instance_store_for_channel(channel)?; + let default_dir = modules::qoder_instance::get_default_qoder_user_data_dir_for_channel(channel)?; let default_dir_str = default_dir.to_string_lossy().to_string(); let default_settings = store.default_settings.clone(); @@ -59,7 +79,7 @@ pub async fn qoder_list_instances() -> Result, String> .instances .into_iter() .map(|instance| { - let running_pid = resolve_running_pid(instance.last_pid, Some(&instance.user_data_dir)); + let running_pid = resolve_running_pid(channel, instance.last_pid, Some(&instance.user_data_dir)); let running = running_pid.is_some(); let initialized = is_profile_initialized(&instance.user_data_dir); let mut view = InstanceProfileView::from_profile(instance, running, initialized); @@ -68,7 +88,7 @@ pub async fn qoder_list_instances() -> Result, String> }) .collect(); - let default_pid = resolve_running_pid(default_settings.last_pid, None); + let default_pid = resolve_running_pid(channel, default_settings.last_pid, None); result.push(InstanceProfileView { id: DEFAULT_INSTANCE_ID.to_string(), name: String::new(), @@ -90,6 +110,7 @@ pub async fn qoder_list_instances() -> Result, String> #[tauri::command] pub async fn qoder_create_instance( + platform_id: Option, name: String, user_data_dir: String, extra_args: Option, @@ -97,8 +118,9 @@ pub async fn qoder_create_instance( copy_source_instance_id: Option, init_mode: Option, ) -> Result { + let channel = parse_channel(platform_id)?; let instance = - modules::qoder_instance::create_instance(modules::qoder_instance::CreateInstanceParams { + modules::qoder_instance::create_instance_for_channel(channel, modules::qoder_instance::CreateInstanceParams { working_dir: None, name, user_data_dir, @@ -118,21 +140,24 @@ pub async fn qoder_create_instance( #[tauri::command] pub async fn qoder_update_instance( + platform_id: Option, instance_id: String, name: Option, extra_args: Option, bind_account_id: Option>, follow_local_account: Option, ) -> Result { + let channel = parse_channel(platform_id)?; if instance_id == DEFAULT_INSTANCE_ID { - let default_dir = modules::qoder_instance::get_default_qoder_user_data_dir()?; + let default_dir = modules::qoder_instance::get_default_qoder_user_data_dir_for_channel(channel)?; let default_dir_str = default_dir.to_string_lossy().to_string(); - let updated = modules::qoder_instance::update_default_settings( + let updated = modules::qoder_instance::update_default_settings_for_channel( + channel, bind_account_id, extra_args, follow_local_account, )?; - let running_pid = resolve_running_pid(updated.last_pid, None); + let running_pid = resolve_running_pid(channel, updated.last_pid, None); return Ok(InstanceProfileView { id: DEFAULT_INSTANCE_ID.to_string(), name: String::new(), @@ -151,7 +176,7 @@ pub async fn qoder_update_instance( } let instance = - modules::qoder_instance::update_instance(modules::qoder_instance::UpdateInstanceParams { + modules::qoder_instance::update_instance_for_channel(channel, modules::qoder_instance::UpdateInstanceParams { working_dir: None, instance_id, name, @@ -159,7 +184,7 @@ pub async fn qoder_update_instance( bind_account_id, })?; - let running_pid = resolve_running_pid(instance.last_pid, Some(&instance.user_data_dir)); + let running_pid = resolve_running_pid(channel, instance.last_pid, Some(&instance.user_data_dir)); let running = running_pid.is_some(); let initialized = is_profile_initialized(&instance.user_data_dir); let mut view = InstanceProfileView::from_profile(instance, running, initialized); @@ -168,39 +193,49 @@ pub async fn qoder_update_instance( } #[tauri::command] -pub async fn qoder_delete_instance(instance_id: String) -> Result<(), String> { +pub async fn qoder_delete_instance( + platform_id: Option, + instance_id: String, +) -> Result<(), String> { + let channel = parse_channel(platform_id)?; if instance_id == DEFAULT_INSTANCE_ID { return Err("默认实例不可删除".to_string()); } - modules::qoder_instance::delete_instance(&instance_id) + modules::qoder_instance::delete_instance_for_channel(channel, &instance_id) } #[tauri::command] -pub async fn qoder_start_instance(instance_id: String) -> Result { - modules::process::ensure_qoder_launch_path_configured()?; +pub async fn qoder_start_instance( + platform_id: Option, + instance_id: String, +) -> Result { + let channel = parse_channel(platform_id)?; + let _ = modules::process::resolve_qoder_launch_path_for_channel(channel) + .or_else(|_| modules::process::resolve_qoder_launch_path())?; if instance_id == DEFAULT_INSTANCE_ID { - let default_dir = modules::qoder_instance::get_default_qoder_user_data_dir()?; + let default_dir = modules::qoder_instance::get_default_qoder_user_data_dir_for_channel(channel)?; let default_dir_str = default_dir.to_string_lossy().to_string(); - let default_settings = modules::qoder_instance::load_default_settings()?; + let default_settings = modules::qoder_instance::load_default_settings_for_channel(channel)?; - if let Some(pid) = resolve_running_pid(default_settings.last_pid, None) { + if let Some(pid) = resolve_running_pid(channel, default_settings.last_pid, None) { modules::process::close_pid(pid, 20)?; - let _ = modules::qoder_instance::update_default_pid(None)?; + let _ = modules::qoder_instance::update_default_pid_for_channel(channel, None)?; } - modules::process::close_qoder_instances(&[default_dir_str.clone()], 20)?; - let _ = modules::qoder_instance::update_default_pid(None)?; + modules::process::close_qoder_channel_instances(channel, &[default_dir_str.clone()], 20)?; + let _ = modules::qoder_instance::update_default_pid_for_channel(channel, None)?; inject_bound_account_for_instance_start( + channel, &default_dir_str, default_settings.bind_account_id.as_deref(), )?; let extra_args = modules::process::parse_extra_args(&default_settings.extra_args); let pid = - modules::process::start_qoder_default_with_args_with_new_window(&extra_args, true)?; - let _ = modules::qoder_instance::update_default_pid(Some(pid))?; - let running_pid = resolve_running_pid(Some(pid), None); + modules::process::start_qoder_channel_default_with_args(channel, &extra_args, true)?; + let _ = modules::qoder_instance::update_default_pid_for_channel(channel, Some(pid))?; + let running_pid = resolve_running_pid(channel, Some(pid), None); return Ok(InstanceProfileView { id: DEFAULT_INSTANCE_ID.to_string(), @@ -219,34 +254,36 @@ pub async fn qoder_start_instance(instance_id: String) -> Result Result Result { +pub async fn qoder_stop_instance( + platform_id: Option, + instance_id: String, +) -> Result { + let channel = parse_channel(platform_id)?; if instance_id == DEFAULT_INSTANCE_ID { - let default_dir = modules::qoder_instance::get_default_qoder_user_data_dir()?; + let default_dir = modules::qoder_instance::get_default_qoder_user_data_dir_for_channel(channel)?; let default_dir_str = default_dir.to_string_lossy().to_string(); - let default_settings = modules::qoder_instance::load_default_settings()?; - if let Some(pid) = resolve_running_pid(default_settings.last_pid, None) { + let default_settings = modules::qoder_instance::load_default_settings_for_channel(channel)?; + if let Some(pid) = resolve_running_pid(channel, default_settings.last_pid, None) { modules::process::close_pid(pid, 20)?; } - modules::process::close_qoder_instances(&[default_dir_str.clone()], 20)?; - let _ = modules::qoder_instance::update_default_pid(None)?; + modules::process::close_qoder_channel_instances(channel, &[default_dir_str.clone()], 20)?; + let _ = modules::qoder_instance::update_default_pid_for_channel(channel, None)?; return Ok(InstanceProfileView { id: DEFAULT_INSTANCE_ID.to_string(), name: String::new(), @@ -281,18 +322,18 @@ pub async fn qoder_stop_instance(instance_id: String) -> Result Result Result<(), String> { +pub async fn qoder_open_instance_window( + platform_id: Option, + instance_id: String, +) -> Result<(), String> { + let channel = parse_channel(platform_id)?; if instance_id == DEFAULT_INSTANCE_ID { - let default_settings = modules::qoder_instance::load_default_settings()?; - let pid = resolve_running_pid(default_settings.last_pid, None).ok_or("默认实例未运行")?; + let default_settings = modules::qoder_instance::load_default_settings_for_channel(channel)?; + let pid = resolve_running_pid(channel, default_settings.last_pid, None).ok_or("默认实例未运行")?; modules::process::focus_process_pid(pid) - .map_err(|err| format!("定位 Qoder 默认实例窗口失败: {}", err))?; + .map_err(|err| format!("定位 {} 默认实例窗口失败: {}", channel.display_name(), err))?; return Ok(()); } - let store = modules::qoder_instance::load_instance_store()?; + let store = modules::qoder_instance::load_instance_store_for_channel(channel)?; let instance = store .instances .into_iter() .find(|item| item.id == instance_id) .ok_or("实例不存在")?; - let pid = resolve_running_pid(instance.last_pid, Some(&instance.user_data_dir)) + let pid = resolve_running_pid(channel, instance.last_pid, Some(&instance.user_data_dir)) .ok_or("实例未运行")?; modules::process::focus_process_pid(pid).map_err(|err| { format!( - "定位 Qoder 实例窗口失败: instance_id={}, err={}", - instance.id, err + "定位 {} 实例窗口失败: instance_id={}, err={}", + channel.display_name(), + instance.id, + err ) })?; Ok(()) } #[tauri::command] -pub async fn qoder_close_all_instances() -> Result<(), String> { - let store = modules::qoder_instance::load_instance_store()?; - let default_dir = modules::qoder_instance::get_default_qoder_user_data_dir()?; +pub async fn qoder_close_all_instances( + platform_id: Option, +) -> Result<(), String> { + let channel = parse_channel(platform_id)?; + let store = modules::qoder_instance::load_instance_store_for_channel(channel)?; + let default_dir = modules::qoder_instance::get_default_qoder_user_data_dir_for_channel(channel)?; let mut target_dirs: Vec = Vec::new(); target_dirs.push(default_dir.to_string_lossy().to_string()); for instance in &store.instances { @@ -342,7 +392,7 @@ pub async fn qoder_close_all_instances() -> Result<(), String> { } } - modules::process::close_qoder_instances(&target_dirs, 20)?; - let _ = modules::qoder_instance::clear_all_pids(); + modules::process::close_qoder_channel_instances(channel, &target_dirs, 20)?; + let _ = modules::qoder_instance::clear_all_pids_for_channel(channel); Ok(()) } diff --git a/src-tauri/src/commands/system_app_commands.rs b/src-tauri/src/commands/system_app_commands.rs index b88022a11..aa88ad06e 100644 --- a/src-tauri/src/commands/system_app_commands.rs +++ b/src-tauri/src/commands/system_app_commands.rs @@ -52,7 +52,10 @@ pub fn set_app_path(app: String, path: String) -> Result<(), String> { "cursor" => current.cursor_app_path = normalized_path, "codebuddy" => current.codebuddy_app_path = normalized_path, "codebuddy_cn" => current.codebuddy_cn_app_path = normalized_path, - "qoder" => current.qoder_app_path = normalized_path, + "qoder" | "qoder_ide" => current.qoder_app_path = normalized_path, + "qoder_app" => current.qoder_app_app_path = normalized_path, + "qoder_cn_ide" => current.qoder_cn_ide_app_path = normalized_path, + "qoder_cn_app" => current.qoder_cn_app_path = normalized_path, "zcode" => current.zcode_app_path = normalized_path, "trae" => current.trae_app_path = normalized_path, "trae_solo" => current.trae_solo_app_path = normalized_path, @@ -128,7 +131,7 @@ pub fn detect_app_path(app: String, force: Option) -> Result Ok(modules::cursor_instance::detect_and_save_cursor_launch_path(force)), "claude" => Ok(modules::claude_instance::detect_and_save_claude_launch_path(force)), "antigravity" | "antigravity_ide" | "antigravity_legacy" | "codex" | "zed" | "vscode" - | "codebuddy" | "codebuddy_cn" | "qoder" | "zcode" | "trae" | "trae_solo" | "trae_cn" + | "codebuddy" | "codebuddy_cn" | "qoder" | "qoder_ide" | "qoder_app" | "qoder_cn_ide" | "qoder_cn_app" | "zcode" | "trae" | "trae_solo" | "trae_cn" | "trae_solo_cn" | "opencode" | "workbuddy" => Ok( modules::process::detect_and_save_app_path(app.as_str(), force), ), @@ -183,6 +186,7 @@ pub async fn scan_app_launch_targets( match app.as_str() { "antigravity" | "antigravity_ide" | "antigravity_legacy" | "codex" | "claude" | "vscode" | "windsurf" | "kiro" | "cursor" | "codebuddy" | "codebuddy_cn" | "qoder" + | "qoder_ide" | "qoder_app" | "qoder_cn_ide" | "qoder_cn_app" | "zcode" | "trae" | "trae_solo" | "trae_cn" | "trae_solo_cn" | "workbuddy" | "zed" | "opencode" => {} _ => return Err("未知应用类型".to_string()), diff --git a/src-tauri/src/commands/system_config_types.rs b/src-tauri/src/commands/system_config_types.rs index cddcba442..696a5f3c3 100644 --- a/src-tauri/src/commands/system_config_types.rs +++ b/src-tauri/src/commands/system_config_types.rs @@ -200,8 +200,14 @@ pub struct GeneralConfig { pub codebuddy_cn_app_path: String, /// 切换 CodeBuddy CN 账号时是否在本机账号间合并本地会话 pub codebuddy_cn_share_sessions_on_switch: bool, - /// Qoder 启动路径(为空则使用默认路径) + /// Qoder 启动路径(国际版 IDE,为空则使用默认路径) pub qoder_app_path: String, + /// Qoder 国际版 App / Launcher 启动路径 + pub qoder_app_app_path: String, + /// Qoder 国内版 IDE 启动路径 + pub qoder_cn_ide_app_path: String, + /// Qoder 国内版 App 启动路径 + pub qoder_cn_app_path: String, /// ZCode 启动路径(为空则使用默认路径) pub zcode_app_path: String, /// Trae 启动路径(为空则使用默认路径) @@ -1192,6 +1198,9 @@ fn is_general_config_patch_field(key: &str) -> bool { | "codebuddy_cn_app_path" | "codebuddy_cn_share_sessions_on_switch" | "qoder_app_path" + | "qoder_app_app_path" + | "qoder_cn_ide_app_path" + | "qoder_cn_app_path" | "zcode_app_path" | "trae_app_path" | "trae_solo_app_path" @@ -1371,6 +1380,9 @@ fn apply_general_config_updates( normalize_app_path_field!("codebuddy_app_path", codebuddy_app_path); normalize_app_path_field!("codebuddy_cn_app_path", codebuddy_cn_app_path); normalize_app_path_field!("qoder_app_path", qoder_app_path); + normalize_app_path_field!("qoder_app_app_path", qoder_app_app_path); + normalize_app_path_field!("qoder_cn_ide_app_path", qoder_cn_ide_app_path); + normalize_app_path_field!("qoder_cn_app_path", qoder_cn_app_path); normalize_app_path_field!("zcode_app_path", zcode_app_path); normalize_app_path_field!("trae_app_path", trae_app_path); normalize_app_path_field!("trae_solo_app_path", trae_solo_app_path); diff --git a/src-tauri/src/commands/system_network_general.rs b/src-tauri/src/commands/system_network_general.rs index 7b5035177..093a6ac57 100644 --- a/src-tauri/src/commands/system_network_general.rs +++ b/src-tauri/src/commands/system_network_general.rs @@ -361,6 +361,15 @@ pub fn get_general_config(app: tauri::AppHandle) -> Result { - if modules::floating_card_window::should_keep_alive_after_main_window_destroyed() - && !modules::app_lifecycle::is_shutdown_started() - { + RunEvent::ExitRequested { api, code, .. } => { + let exit_requested = modules::floating_card_window::is_app_exit_requested(); + let shutdown_started = modules::app_lifecycle::is_shutdown_started(); + modules::logger::log_info(&format!( + "[Lifecycle] 收到 ExitRequested: code={:?}, exit_requested={}, shutdown_started={}", + code, exit_requested, shutdown_started + )); + + if !exit_requested && !shutdown_started { api.prevent_exit(); - modules::logger::log_info("[Window] 主窗口已销毁,应用继续在托盘运行"); + modules::logger::log_info("[Window] 托盘常驻保护生效,已阻止应用退出"); } else { let first_shutdown = modules::app_lifecycle::begin_shutdown(); if first_shutdown { diff --git a/src-tauri/src/models/qoder.rs b/src-tauri/src/models/qoder.rs index 539ce8121..7384367ed 100644 --- a/src-tauri/src/models/qoder.rs +++ b/src-tauri/src/models/qoder.rs @@ -44,6 +44,8 @@ pub struct QoderAccountSummary { #[serde(skip_serializing_if = "Option::is_none")] pub user_id: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub plan_type: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tags: Option>, @@ -89,6 +91,7 @@ impl QoderAccount { id: self.id.clone(), email: self.email.clone(), user_id: self.user_id.clone(), + display_name: self.display_name.clone(), plan_type: self.plan_type.clone(), tags: self.tags.clone(), created_at: self.created_at, diff --git a/src-tauri/src/modules/config.rs b/src-tauri/src/modules/config.rs index 5b39f52c2..b719f1456 100644 --- a/src-tauri/src/modules/config.rs +++ b/src-tauri/src/modules/config.rs @@ -334,9 +334,18 @@ pub struct UserConfig { /// 切换 CodeBuddy CN 账号时是否在本机账号间合并本地会话 #[serde(default = "default_codebuddy_cn_share_sessions_on_switch")] pub codebuddy_cn_share_sessions_on_switch: bool, - /// Qoder 启动路径(为空则使用默认路径) + /// Qoder 启动路径(国际版 IDE,为空则使用默认路径) #[serde(default = "default_qoder_app_path")] pub qoder_app_path: String, + /// Qoder 国际版 App / Launcher 启动路径 + #[serde(default = "default_qoder_app_path")] + pub qoder_app_app_path: String, + /// Qoder 国内版 IDE 启动路径 + #[serde(default = "default_qoder_app_path")] + pub qoder_cn_ide_app_path: String, + /// Qoder 国内版 App 启动路径 + #[serde(default = "default_qoder_app_path")] + pub qoder_cn_app_path: String, /// ZCode 启动路径(为空则使用默认路径) #[serde(default = "default_zcode_app_path")] pub zcode_app_path: String, @@ -1273,6 +1282,9 @@ impl Default for UserConfig { codebuddy_cn_app_path: default_codebuddy_cn_app_path(), codebuddy_cn_share_sessions_on_switch: default_codebuddy_cn_share_sessions_on_switch(), qoder_app_path: default_qoder_app_path(), + qoder_app_app_path: default_qoder_app_path(), + qoder_cn_ide_app_path: default_qoder_app_path(), + qoder_cn_app_path: default_qoder_app_path(), zcode_app_path: default_zcode_app_path(), trae_app_path: default_trae_app_path(), trae_solo_app_path: default_trae_app_path(), @@ -2308,6 +2320,30 @@ pub fn load_user_config() -> Result { } }); + // 自动感知探测 Qoder 4 个渠道的安装路径 + let mut qoder_paths_detected = false; + let qoder_probes = [ + (crate::modules::qoder_channel::QoderChannel::QoderIde, &mut config.qoder_app_path), + (crate::modules::qoder_channel::QoderChannel::QoderApp, &mut config.qoder_app_app_path), + (crate::modules::qoder_channel::QoderChannel::QoderCnIde, &mut config.qoder_cn_ide_app_path), + (crate::modules::qoder_channel::QoderChannel::QoderCnApp, &mut config.qoder_cn_app_path), + ]; + for (channel, path_ref) in qoder_probes { + let is_missing = path_ref.trim().is_empty() || !std::path::Path::new(path_ref.trim()).exists(); + if is_missing { + if let Some(detected) = channel.detect_installed_exe() { + let detected_str = detected.to_string_lossy().to_string(); + if *path_ref != detected_str { + *path_ref = detected_str; + qoder_paths_detected = true; + } + } + } + } + if qoder_paths_detected { + let _ = persist_user_config(&config); + } + Ok(config) } diff --git a/src-tauri/src/modules/floating_card_window.rs b/src-tauri/src/modules/floating_card_window.rs index 4427dfda3..337e8b632 100644 --- a/src-tauri/src/modules/floating_card_window.rs +++ b/src-tauri/src/modules/floating_card_window.rs @@ -537,6 +537,10 @@ pub fn request_app_exit() { APP_EXIT_REQUESTED.store(true, Ordering::SeqCst); } +pub fn is_app_exit_requested() -> bool { + APP_EXIT_REQUESTED.load(Ordering::SeqCst) +} + pub fn should_keep_alive_after_main_window_destroyed() -> bool { MAIN_WINDOW_DESTROYED_TO_TRAY.load(Ordering::SeqCst) && !APP_EXIT_REQUESTED.load(Ordering::SeqCst) diff --git a/src-tauri/src/modules/macos_native_menu_platform_cards.rs b/src-tauri/src/modules/macos_native_menu_platform_cards.rs index a7c6b7e27..5811d4212 100644 --- a/src-tauri/src/modules/macos_native_menu_platform_cards.rs +++ b/src-tauri/src/modules/macos_native_menu_platform_cards.rs @@ -451,6 +451,7 @@ let total = raw .and_then(|value| { json_path(Some(value), &["total"]) + .or_else(|| json_path(Some(value), &["cap"])) .or_else(|| json_path(Some(value), &["quota"])) .or_else(|| json_path(Some(value), &["limit"])) .and_then(parse_json_number) @@ -459,7 +460,6 @@ let remaining = raw .and_then(|value| { json_path(Some(value), &["remaining"]) - .or_else(|| json_path(Some(value), &["available"])) .or_else(|| json_path(Some(value), &["left"])) .and_then(parse_json_number) }) @@ -510,9 +510,14 @@ json_path(account.auth_credit_usage_raw.as_ref(), &["addOnQuota"]), json_path(account.auth_credit_usage_raw.as_ref(), &["addonQuota"]), json_path(account.auth_credit_usage_raw.as_ref(), &["add_on_quota"]), + json_path(account.auth_credit_usage_raw.as_ref(), &["orgResourcePackage"]), + json_path(account.auth_credit_usage_raw.as_ref(), &["organizationResourcePackage"]), + json_path(account.auth_credit_usage_raw.as_ref(), &["resourcePackage"]), + json_path(account.auth_credit_usage_raw.as_ref(), &["sharedCreditPackage"]), json_path(account.auth_user_plan_raw.as_ref(), &["addOnQuota"]), json_path(account.auth_user_plan_raw.as_ref(), &["addonQuota"]), json_path(account.auth_user_plan_raw.as_ref(), &["add_on_quota"]), + json_path(account.auth_user_plan_raw.as_ref(), &["orgResourcePackage"]), ], QoderQuotaBucket::default(), ); diff --git a/src-tauri/src/modules/mod.rs b/src-tauri/src/modules/mod.rs index c477286a8..f54f7a0b3 100644 --- a/src-tauri/src/modules/mod.rs +++ b/src-tauri/src/modules/mod.rs @@ -82,6 +82,8 @@ pub mod process_timeout; pub mod provider_current_state; pub mod provider_token_keeper; pub mod qoder_account; +pub mod qoder_app_auth; +pub mod qoder_channel; pub mod qoder_instance; pub mod qoder_oauth; pub mod quota; diff --git a/src-tauri/src/modules/process_close_lifecycle.rs b/src-tauri/src/modules/process_close_lifecycle.rs index bd89b8698..664e7ae16 100644 --- a/src-tauri/src/modules/process_close_lifecycle.rs +++ b/src-tauri/src/modules/process_close_lifecycle.rs @@ -104,15 +104,18 @@ pub fn close_antigravity_legacy_instances( ) } -fn close_user_data_dir_scoped_instances( +fn close_user_data_dir_scoped_instances( log_prefix: &str, process_display_name: &str, failure_message: &str, user_data_dirs: &[String], timeout_secs: u64, default_dir: Option, - collect_entries: fn() -> Vec<(u32, Option)>, -) -> Result<(), String> { + collect_entries: F, +) -> Result<(), String> +where + F: Fn() -> Vec<(u32, Option)> + Copy, +{ crate::modules::logger::log_info(&format!( "[{}] default_dir={}", log_prefix, @@ -121,6 +124,7 @@ fn close_user_data_dir_scoped_instances( .map(|value| summarize_text_for_process_log(value, 96)) .unwrap_or_else(|| "-".to_string()) )); + let default_dir_ref = default_dir.as_deref(); close_managed_instances_common( log_prefix, &format!("Closing {} instances...", process_display_name), @@ -131,11 +135,11 @@ fn close_user_data_dir_scoped_instances( user_data_dirs, timeout_secs, collect_entries, - |entries, target_dirs| { - select_main_pids_by_target_dirs(entries, target_dirs, default_dir.as_deref()) + move |entries, target_dirs| { + select_main_pids_by_target_dirs(entries, target_dirs, default_dir_ref) }, - |target_dirs| { - filter_entries_by_target_dirs(collect_entries(), target_dirs, default_dir.as_deref()) + move |target_dirs| { + filter_entries_by_target_dirs(collect_entries(), target_dirs, default_dir_ref) }, None, None, @@ -194,6 +198,32 @@ pub fn close_qoder_instances(user_data_dirs: &[String], timeout_secs: u64) -> Re ) } +pub fn close_qoder_channel_instances( + channel: crate::modules::qoder_channel::QoderChannel, + user_data_dirs: &[String], + timeout_secs: u64, +) -> Result<(), String> { + let default_dir = channel + .default_user_data_dir() + .ok() + .map(|value| normalize_path_for_compare(&value.to_string_lossy())) + .filter(|value| !value.is_empty()); + + let channel_title = channel.display_name(); + close_user_data_dir_scoped_instances( + &format!("{} Close", channel_title), + channel_title, + &format!( + "Unable to close managed {} instances; please close them manually and retry", + channel_title + ), + user_data_dirs, + timeout_secs, + default_dir, + move || collect_qoder_channel_process_entries(channel), + ) +} + pub fn close_trae_instances(user_data_dirs: &[String], timeout_secs: u64) -> Result<(), String> { let default_dir = get_default_trae_user_data_dir_for_os() .map(|value| normalize_path_for_compare(&value)) diff --git a/src-tauri/src/modules/process_detection_matching.rs b/src-tauri/src/modules/process_detection_matching.rs index d75e075c9..855341ccf 100644 --- a/src-tauri/src/modules/process_detection_matching.rs +++ b/src-tauri/src/modules/process_detection_matching.rs @@ -1280,6 +1280,21 @@ fn resolve_qoder_target_and_fallback(user_data_dir: Option<&str>) -> Option<(Str ) } +fn resolve_qoder_target_and_fallback_for_channel( + user_data_dir: Option<&str>, + channel: crate::modules::qoder_channel::QoderChannel, +) -> Option<(String, bool)> { + let default_dir = channel + .default_user_data_dir() + .ok() + .map(|p| p.to_string_lossy().to_string()); + build_user_data_dir_match_target( + user_data_dir, + default_dir, + !strict_process_detect_enabled(), + ) +} + fn resolve_trae_target_and_fallback(user_data_dir: Option<&str>) -> Option<(String, bool)> { resolve_trae_target_and_fallback_for_platform( user_data_dir, @@ -1572,6 +1587,17 @@ pub fn resolve_qoder_pid(last_pid: Option, user_data_dir: Option<&str>) -> resolve_qoder_pid_from_entries(last_pid, user_data_dir, &entries) } +pub fn resolve_qoder_pid_for_channel( + last_pid: Option, + user_data_dir: Option<&str>, + channel: crate::modules::qoder_channel::QoderChannel, +) -> Option { + let entries = collect_qoder_channel_process_entries(channel); + let (target, allow_none_for_target) = + resolve_qoder_target_and_fallback_for_channel(user_data_dir, channel)?; + resolve_pid_from_entries_by_user_data_dir(last_pid, &target, allow_none_for_target, &entries) +} + pub fn resolve_trae_pid_from_entries( last_pid: Option, user_data_dir: Option<&str>, @@ -3052,52 +3078,74 @@ pub fn resolve_codebuddy_cn_pid(last_pid: Option, user_data_dir: Option<&st resolve_codebuddy_cn_pid_from_entries(last_pid, user_data_dir, &entries) } -pub fn collect_qoder_process_entries() -> Vec<(u32, Option)> { - let expected_launch = resolve_expected_qoder_launch_path_for_match(); - if expected_launch.is_none() { +pub fn collect_qoder_channel_process_entries( + channel: crate::modules::qoder_channel::QoderChannel, +) -> Vec<(u32, Option)> { + let launch_path = match resolve_qoder_launch_path_for_channel(channel) { + Ok(p) => p, + Err(_) => match resolve_qoder_launch_path() { + Ok(p) => p, + Err(_) => return Vec::new(), + }, + }; + let normalized = normalize_path_for_compare(launch_path.to_string_lossy().as_ref()); + if normalized.is_empty() { return Vec::new(); } #[cfg(target_os = "windows")] { - let expected = expected_launch - .as_deref() - .expect("expected launch path must exist"); - let entries = - collect_named_electron_process_entries_from_powershell(expected, "Qoder.exe", "Qoder"); - if !entries.is_empty() { - return entries; + let mut all_entries = Vec::new(); + for exe_name in channel.expected_exe_names() { + let entries = collect_named_electron_process_entries_from_powershell( + &normalized, + exe_name, + channel.display_name(), + ); + all_entries.extend(entries); } - crate::modules::logger::log_warn( - "[Qoder Probe] PowerShell returned empty; fallback to sysinfo probe", - ); - return collect_named_electron_process_entries_from_sysinfo_fallback( - expected, - "qoder", - "Qoder.exe", - "Qoder", - ); + if !all_entries.is_empty() { + return all_entries; + } + for exe_name in channel.expected_exe_names() { + let fallback = collect_named_electron_process_entries_from_sysinfo_fallback( + &normalized, + channel.provider_key(), + exe_name, + channel.display_name(), + ); + all_entries.extend(fallback); + } + all_entries } #[cfg(target_os = "macos")] { let entries = collect_qoder_process_entries_macos(); - if !entries.is_empty() { - return filter_entries_by_expected_launch_path("Qoder", entries, expected_launch); - } - return Vec::new(); + filter_entries_by_expected_launch_path(channel.display_name(), entries, Some(normalized)) } #[cfg(target_os = "linux")] { let entries = collect_named_electron_process_entries_from_proc("qoder"); - if !entries.is_empty() { - return filter_entries_by_expected_launch_path("Qoder", entries, expected_launch); + filter_entries_by_expected_launch_path(channel.display_name(), entries, Some(normalized)) + } +} + +pub fn collect_qoder_process_entries() -> Vec<(u32, Option)> { + let mut all = Vec::new(); + let mut seen_pids = std::collections::HashSet::new(); + for channel in crate::modules::qoder_channel::QoderChannel::ALL { + for (pid, dir) in collect_qoder_channel_process_entries(channel) { + if seen_pids.insert(pid) { + all.push((pid, dir)); + } } - return Vec::new(); } + all } + pub fn collect_trae_process_entries() -> Vec<(u32, Option)> { collect_trae_process_entries_for_platform(crate::modules::trae_account::TraePlatformKind::Trae) } diff --git a/src-tauri/src/modules/process_editor_launch.rs b/src-tauri/src/modules/process_editor_launch.rs index 67d087fcc..b17392773 100644 --- a/src-tauri/src/modules/process_editor_launch.rs +++ b/src-tauri/src/modules/process_editor_launch.rs @@ -926,7 +926,8 @@ pub fn start_workbuddy_default_with_args_with_new_window( } } -pub fn start_qoder_with_args_with_new_window( +pub fn start_qoder_channel_with_args_with_new_window( + channel: crate::modules::qoder_channel::QoderChannel, user_data_dir: &str, extra_args: &[String], use_new_window: bool, @@ -937,9 +938,10 @@ pub fn start_qoder_with_args_with_new_window( if target.is_empty() { return Err("实例目录为空,无法启动".to_string()); } - let launch_path = resolve_qoder_launch_path()?; + let launch_path = resolve_qoder_launch_path_for_channel(channel) + .or_else(|_| resolve_qoder_launch_path())?; let app_root = resolve_macos_app_root_from_launch_path(&launch_path) - .ok_or_else(|| app_path_missing_error("qoder"))?; + .ok_or_else(|| app_path_missing_error(channel.provider_key()))?; let mut args: Vec = Vec::new(); args.push("--user-data-dir".to_string()); @@ -957,18 +959,19 @@ pub fn start_qoder_with_args_with_new_window( } let open_pid = spawn_open_app_with_options(&app_root, &args, true) - .map_err(|e| format!("启动 Qoder 失败: {}", e))?; - crate::modules::logger::log_info("Qoder 启动命令已发送(open -n -a)"); + .map_err(|e| format!("启动 {} 失败: {}", channel.display_name(), e))?; + crate::modules::logger::log_info(&format!("{} 启动命令已发送(open -n -a)", channel.display_name())); let probe_started = Instant::now(); let timeout = Duration::from_secs(6); while probe_started.elapsed() < timeout { - if let Some(resolved_pid) = resolve_qoder_pid(None, Some(target)) { + if let Some(resolved_pid) = resolve_qoder_pid_for_channel(None, Some(target), channel) { return Ok(resolved_pid); } thread::sleep(Duration::from_millis(200)); } crate::modules::logger::log_warn(&format!( - "[Qoder Start] 启动后 6s 内未匹配到实例 PID,回退 open pid={}", + "[{} Start] 启动后 6s 内未匹配到实例 PID,回退 open pid={}", + channel.display_name(), open_pid )); return Ok(open_pid); @@ -982,7 +985,8 @@ pub fn start_qoder_with_args_with_new_window( if target.is_empty() { return Err("实例目录为空,无法启动".to_string()); } - let launch_path = resolve_qoder_launch_path()?; + let launch_path = resolve_qoder_launch_path_for_channel(channel) + .or_else(|_| resolve_qoder_launch_path())?; let mut cmd = Command::new(&launch_path); apply_managed_proxy_env_to_command(&mut cmd); @@ -995,10 +999,12 @@ pub fn start_qoder_with_args_with_new_window( cmd.creation_flags(0x08000000); } cmd.arg("--user-data-dir").arg(target); - if use_new_window { - cmd.arg("--new-window"); - } else { - cmd.arg("--reuse-window"); + if channel.credential_kind() == crate::modules::qoder_channel::QoderCredentialKind::StateVscdb { + if use_new_window { + cmd.arg("--new-window"); + } else { + cmd.arg("--reuse-window"); + } } for arg in extra_args { let trimmed = arg.trim(); @@ -1007,9 +1013,9 @@ pub fn start_qoder_with_args_with_new_window( } } - let child = - spawn_command_with_trace(&mut cmd).map_err(|e| format!("启动 Qoder 失败: {}", e))?; - crate::modules::logger::log_info("Qoder 启动命令已发送"); + let child = spawn_command_with_trace(&mut cmd) + .map_err(|e| format!("启动 {} 失败: {}", channel.display_name(), e))?; + crate::modules::logger::log_info(&format!("{} 启动命令已发送: pid={}", channel.display_name(), child.id())); return Ok(child.id()); } @@ -1019,7 +1025,8 @@ pub fn start_qoder_with_args_with_new_window( if target.is_empty() { return Err("实例目录为空,无法启动".to_string()); } - let launch_path = resolve_qoder_launch_path()?; + let launch_path = resolve_qoder_launch_path_for_channel(channel) + .or_else(|_| resolve_qoder_launch_path())?; let mut cmd = Command::new(&launch_path); apply_managed_proxy_env_to_command(&mut cmd); @@ -1041,18 +1048,32 @@ pub fn start_qoder_with_args_with_new_window( } } - let child = spawn_detached_unix(&mut cmd).map_err(|e| format!("启动 Qoder 失败: {}", e))?; - crate::modules::logger::log_info("Qoder 启动命令已发送"); + let child = spawn_detached_unix(&mut cmd) + .map_err(|e| format!("启动 {} 失败: {}", channel.display_name(), e))?; + crate::modules::logger::log_info(&format!("{} 启动命令已发送", channel.display_name())); return Ok(child.id()); } #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] { - let _ = (user_data_dir, extra_args, use_new_window); + let _ = (channel, user_data_dir, extra_args, use_new_window); Err("Qoder 应用多开仅支持 macOS、Windows 和 Linux".to_string()) } } +pub fn start_qoder_with_args_with_new_window( + user_data_dir: &str, + extra_args: &[String], + use_new_window: bool, +) -> Result { + start_qoder_channel_with_args_with_new_window( + crate::modules::qoder_channel::QoderChannel::QoderIde, + user_data_dir, + extra_args, + use_new_window, + ) +} + pub fn start_qoder_default_with_args_with_new_window( extra_args: &[String], use_new_window: bool, @@ -1159,6 +1180,53 @@ pub fn start_qoder_default_with_args_with_new_window( } } +pub fn start_qoder_channel_default_with_args( + channel: crate::modules::qoder_channel::QoderChannel, + extra_args: &[String], + use_new_window: bool, +) -> Result { + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + + let launch_path = resolve_qoder_launch_path_for_channel(channel) + .or_else(|_| resolve_qoder_launch_path())?; + let mut cmd = Command::new(&launch_path); + apply_managed_proxy_env_to_command(&mut cmd); + if should_detach_child() { + cmd.creation_flags(0x08000000 | CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS); + cmd.stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + } else { + cmd.creation_flags(0x08000000); + } + if channel.credential_kind() == crate::modules::qoder_channel::QoderCredentialKind::StateVscdb { + if use_new_window { + cmd.arg("--new-window"); + } else { + cmd.arg("--reuse-window"); + } + } + for arg in extra_args { + let trimmed = arg.trim(); + if !trimmed.is_empty() { + cmd.arg(trimmed); + } + } + let child = spawn_command_with_trace(&mut cmd) + .map_err(|e| format!("启动 {} 失败: {}", channel.display_name(), e))?; + crate::modules::logger::log_info(&format!("{} 默认实例启动命令已发送", channel.display_name())); + return Ok(child.id()); + } + + #[cfg(not(target_os = "windows"))] + { + let _ = channel; + start_qoder_default_with_args_with_new_window(extra_args, use_new_window) + } +} + pub fn start_trae_with_args_with_new_window( user_data_dir: &str, extra_args: &[String], diff --git a/src-tauri/src/modules/process_launch_candidates.rs b/src-tauri/src/modules/process_launch_candidates.rs index 3556ff0c3..ec1cadf10 100644 --- a/src-tauri/src/modules/process_launch_candidates.rs +++ b/src-tauri/src/modules/process_launch_candidates.rs @@ -812,13 +812,46 @@ fn windows_app_launch_signature(app: &str) -> Option ], supports_multi_instance: true, }), - "qoder" => Some(WindowsAppLaunchSignature { + "qoder" | "qoder_ide" => Some(WindowsAppLaunchSignature { + label: "Qoder IDE", + exe_names: &["Qoder IDE.exe", "Qoder.exe"], + command_names: &["qoder"], + protocol_names: &["qoder"], + display_keywords: &["qoder ide", "qoder"], + common_paths: &["Qoder IDE\\Qoder IDE.exe", "Qoder\\Qoder.exe"], + supports_multi_instance: true, + }), + "qoder_app" => Some(WindowsAppLaunchSignature { label: "Qoder", - exe_names: &["Qoder.exe"], + exe_names: &["Qoder.exe", "Qoder Launcher.exe"], + command_names: &["qoder"], + protocol_names: &["qoder"], + display_keywords: &["qoder launcher", "qoder app", "qoder"], + common_paths: &[ + "Qoder\\Qoder\\Qoder.exe", + "Qoder\\Qoder Launcher\\Qoder Launcher.exe", + ], + supports_multi_instance: true, + }), + "qoder_cn_ide" => Some(WindowsAppLaunchSignature { + label: "Qoder CN IDE", + exe_names: &["Qoder CN IDE.exe", "Qoder.exe"], command_names: &["qoder"], protocol_names: &["qoder"], - display_keywords: &["qoder"], - common_paths: &["Qoder\\Qoder.exe"], + display_keywords: &["qoder cn ide", "qoder cn"], + common_paths: &["Qoder CN IDE\\Qoder CN IDE.exe"], + supports_multi_instance: true, + }), + "qoder_cn_app" => Some(WindowsAppLaunchSignature { + label: "Qoder CN", + exe_names: &["Qoder CN.exe", "Qoder Launcher.exe"], + command_names: &["qoder"], + protocol_names: &["qoder"], + display_keywords: &["qoder cn", "qoder cn app"], + common_paths: &[ + "Qoder CN\\Qoder CN\\Qoder CN.exe", + "Qoder CN\\Qoder Launcher.exe", + ], supports_multi_instance: true, }), "zcode" => Some(WindowsAppLaunchSignature { @@ -1154,6 +1187,21 @@ fn scan_windows_app_launch_targets( ); } + // 补充 Qoder 各渠道已安装的静态候选路径(磁盘存在即可命中,无需应用先运行) + if let Ok(channel) = crate::modules::qoder_channel::QoderChannel::parse(Some(app)) { + for candidate_exe in channel.default_exe_candidates() { + if candidate_exe.is_file() { + push_app_launch_candidate( + &mut candidates, + &mut seen, + &candidate_exe, + signature, + "installed_app", + ); + } + } + } + candidates.sort_by_key(|candidate| { let codex_priority = if app == "codex" { let file_name = std::path::Path::new(&candidate.target) @@ -1799,7 +1847,10 @@ fn update_app_path_in_config(app: &str, path: &Path, expected_current: &str) { "opencode" => &mut current.opencode_app_path, "codebuddy" => &mut current.codebuddy_app_path, "codebuddy_cn" => &mut current.codebuddy_cn_app_path, - "qoder" => &mut current.qoder_app_path, + "qoder" | "qoder_ide" => &mut current.qoder_app_path, + "qoder_app" => &mut current.qoder_app_app_path, + "qoder_cn_ide" => &mut current.qoder_cn_ide_app_path, + "qoder_cn_app" => &mut current.qoder_cn_app_path, "zcode" => &mut current.zcode_app_path, "trae" => &mut current.trae_app_path, "trae_solo" => &mut current.trae_solo_app_path, diff --git a/src-tauri/src/modules/process_path_resolution.rs b/src-tauri/src/modules/process_path_resolution.rs index d877b4336..63dd8731d 100644 --- a/src-tauri/src/modules/process_path_resolution.rs +++ b/src-tauri/src/modules/process_path_resolution.rs @@ -1449,20 +1449,8 @@ fn detect_qoder_exec_path() -> Option { #[cfg(target_os = "windows")] { let mut candidates: Vec = Vec::new(); - if let Ok(local_appdata) = std::env::var("LOCALAPPDATA") { - candidates.push( - std::path::PathBuf::from(&local_appdata) - .join("Programs") - .join("Qoder") - .join("Qoder.exe"), - ); - } - if let Ok(program_files) = std::env::var("PROGRAMFILES") { - candidates.push( - std::path::PathBuf::from(program_files) - .join("Qoder") - .join("Qoder.exe"), - ); + for channel in crate::modules::qoder_channel::QoderChannel::ALL { + candidates.extend(channel.default_exe_candidates()); } for candidate in candidates { if candidate.exists() { @@ -2992,30 +2980,62 @@ fn resolve_codebuddy_cn_launch_path() -> Result { Err(app_path_missing_error("codebuddy_cn")) } -fn resolve_qoder_launch_path() -> Result { - if let Some(custom) = normalize_custom_path(Some(&config::get_user_config().qoder_app_path)) { - if let Some(exec) = resolve_qoder_macos_exec_path(&custom) { - return Ok(exec); +pub fn resolve_qoder_launch_path_for_channel( + channel: crate::modules::qoder_channel::QoderChannel, +) -> Result { + if channel == crate::modules::qoder_channel::QoderChannel::QoderIde { + if let Some(custom) = normalize_custom_path(Some(&config::get_user_config().qoder_app_path)) { + if let Some(exec) = resolve_qoder_macos_exec_path(&custom) { + return Ok(exec); + } + #[cfg(not(target_os = "macos"))] + { + let p = std::path::PathBuf::from(&custom); + if p.exists() { + return Ok(p); + } + } + return Err(app_path_missing_error(channel.provider_key())); } - return Err(app_path_missing_error("qoder")); } - if let Some(detected) = detect_qoder_exec_path() { - let detected_str = detected.to_string_lossy(); + for candidate in channel.default_exe_candidates() { + let detected_str = candidate.to_string_lossy(); if let Some(exec) = resolve_qoder_macos_exec_path(&detected_str) { return Ok(exec); } #[cfg(target_os = "macos")] - if detected.is_file() { - return Ok(detected); + if candidate.is_file() { + return Ok(candidate); } #[cfg(not(target_os = "macos"))] - if detected.exists() { - return Ok(detected); + if candidate.exists() { + return Ok(candidate); } } - Err(app_path_missing_error("qoder")) + Err(app_path_missing_error(channel.provider_key())) +} + +pub fn resolve_qoder_launch_path() -> Result { + resolve_qoder_launch_path_for_channel(crate::modules::qoder_channel::QoderChannel::QoderIde) + .or_else(|_| { + if let Some(detected) = detect_qoder_exec_path() { + let detected_str = detected.to_string_lossy(); + if let Some(exec) = resolve_qoder_macos_exec_path(&detected_str) { + return Ok(exec); + } + #[cfg(target_os = "macos")] + if detected.is_file() { + return Ok(detected); + } + #[cfg(not(target_os = "macos"))] + if detected.exists() { + return Ok(detected); + } + } + Err(app_path_missing_error("qoder")) + }) } pub fn resolve_zcode_launch_path() -> Result { @@ -3291,13 +3311,33 @@ fn detect_and_save_app_path_raw(app: &str, force: bool) -> Option { return Some(config::get_user_config().codebuddy_cn_app_path); } } - "qoder" => { - if !force && !current.qoder_app_path.trim().is_empty() { - return Some(current.qoder_app_path); - } - if let Some(detected) = detect_qoder_exec_path() { - update_app_path_in_config("qoder", &detected, ¤t.qoder_app_path); - return Some(config::get_user_config().qoder_app_path); + "qoder" | "qoder_ide" | "qoder_app" | "qoder_cn_ide" | "qoder_cn_app" => { + let channel = match app { + "qoder_app" => crate::modules::qoder_channel::QoderChannel::QoderApp, + "qoder_cn_ide" => crate::modules::qoder_channel::QoderChannel::QoderCnIde, + "qoder_cn_app" => crate::modules::qoder_channel::QoderChannel::QoderCnApp, + _ => crate::modules::qoder_channel::QoderChannel::QoderIde, + }; + let configured = match channel { + crate::modules::qoder_channel::QoderChannel::QoderIde => current.qoder_app_path.clone(), + crate::modules::qoder_channel::QoderChannel::QoderApp => current.qoder_app_app_path.clone(), + crate::modules::qoder_channel::QoderChannel::QoderCnIde => current.qoder_cn_ide_app_path.clone(), + crate::modules::qoder_channel::QoderChannel::QoderCnApp => current.qoder_cn_app_path.clone(), + }; + let configured_exists = !configured.trim().is_empty() && std::path::Path::new(configured.trim()).exists(); + if !force && configured_exists { + return Some(configured); + } + if let Some(detected) = channel.detect_installed_exe() { + update_app_path_in_config(app, &detected, &configured); + let refreshed = config::get_user_config(); + let refreshed_val = match channel { + crate::modules::qoder_channel::QoderChannel::QoderIde => refreshed.qoder_app_path, + crate::modules::qoder_channel::QoderChannel::QoderApp => refreshed.qoder_app_app_path, + crate::modules::qoder_channel::QoderChannel::QoderCnIde => refreshed.qoder_cn_ide_app_path, + crate::modules::qoder_channel::QoderChannel::QoderCnApp => refreshed.qoder_cn_app_path, + }; + return Some(refreshed_val); } } "zcode" => { diff --git a/src-tauri/src/modules/provider_current_state.rs b/src-tauri/src/modules/provider_current_state.rs index fa7fb22b5..195b3af54 100644 --- a/src-tauri/src/modules/provider_current_state.rs +++ b/src-tauri/src/modules/provider_current_state.rs @@ -37,6 +37,9 @@ fn normalize_platform(platform: &str) -> Result<&'static str, String> { "codebuddy" => Ok("codebuddy"), "codebuddy_cn" | "codebuddy-cn" => Ok("codebuddy_cn"), "qoder" => Ok("qoder"), + "qoder_app" | "qoder-app" => Ok("qoder_app"), + "qoder_cn_ide" | "qoder-cn" => Ok("qoder_cn_ide"), + "qoder_cn_app" | "qoder-cn-app" => Ok("qoder_cn_app"), "zcode" => Ok("zcode"), "trae" => Ok("trae"), "trae_solo" | "trae-solo" => Ok("trae_solo"), diff --git a/src-tauri/src/modules/qoder_account.rs b/src-tauri/src/modules/qoder_account.rs index 837dc2e7b..efc16be91 100644 --- a/src-tauri/src/modules/qoder_account.rs +++ b/src-tauri/src/modules/qoder_account.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; use crate::models::qoder::{QoderAccount, QoderAccountIndex}; +use crate::modules::qoder_channel::{QoderChannel, QoderCredentialKind}; use crate::modules::{account, logger}; const ACCOUNTS_INDEX_FILE: &str = "qoder_accounts.json"; @@ -106,21 +107,34 @@ fn get_data_dir() -> Result { account::get_data_dir() } -fn get_accounts_dir() -> Result { +pub fn get_accounts_dir_for_channel(channel: QoderChannel) -> Result { let base = get_data_dir()?; - let dir = base.join(ACCOUNTS_DIR); + let dir = base.join(channel.accounts_dir_name()); if !dir.exists() { - fs::create_dir_all(&dir).map_err(|e| format!("创建 Qoder 账号目录失败: {}", e))?; + fs::create_dir_all(&dir) + .map_err(|e| format!("创建 {} 账号目录失败: {}", channel.display_name(), e))?; } Ok(dir) } +fn get_accounts_dir() -> Result { + get_accounts_dir_for_channel(QoderChannel::QoderIde) +} + +pub fn get_accounts_index_path_for_channel(channel: QoderChannel) -> Result { + Ok(get_data_dir()?.join(channel.accounts_index_filename())) +} + fn get_accounts_index_path() -> Result { - Ok(get_data_dir()?.join(ACCOUNTS_INDEX_FILE)) + get_accounts_index_path_for_channel(QoderChannel::QoderIde) +} + +pub fn accounts_index_path_string_for_channel(channel: QoderChannel) -> Result { + Ok(get_accounts_index_path_for_channel(channel)?.to_string_lossy().to_string()) } pub fn accounts_index_path_string() -> Result { - Ok(get_accounts_index_path()?.to_string_lossy().to_string()) + accounts_index_path_string_for_channel(QoderChannel::QoderIde) } fn normalize_account_id(account_id: &str) -> Result { @@ -143,13 +157,20 @@ fn normalize_account_id(account_id: &str) -> Result { Ok(trimmed.to_string()) } -fn resolve_account_file_path(account_id: &str) -> Result { +pub fn resolve_account_file_path_for_channel( + channel: QoderChannel, + account_id: &str, +) -> Result { let normalized = normalize_account_id(account_id)?; - Ok(get_accounts_dir()?.join(format!("{}.json", normalized))) + Ok(get_accounts_dir_for_channel(channel)?.join(format!("{}.json", normalized))) } -pub fn load_account(account_id: &str) -> Option { - let account_path = resolve_account_file_path(account_id).ok()?; +fn resolve_account_file_path(account_id: &str) -> Result { + resolve_account_file_path_for_channel(QoderChannel::QoderIde, account_id) +} + +pub fn load_account_for_channel(channel: QoderChannel, account_id: &str) -> Option { + let account_path = resolve_account_file_path_for_channel(channel, account_id).ok()?; if !account_path.exists() { return None; } @@ -158,8 +179,85 @@ pub fn load_account(account_id: &str) -> Option { &account_path, &content, ) { - Ok((account, needs_rotation)) => { - if needs_rotation { + Ok((mut account, needs_rotation)) => { + let mut healed = false; + let snapshot = QoderSnapshot { + user_info_raw: account.auth_user_info_raw.clone(), + user_plan_raw: account.auth_user_plan_raw.clone(), + credit_usage_raw: account.auth_credit_usage_raw.clone(), + }; + + if account.display_name.is_none() { + if let Some(name) = extract_snapshot_display_name(&snapshot) { + crate::modules::logger::log_warn(&format!( + "[Qoder Account Heal] 字段自愈: channel={}, account_id={}, field_name=display_name, old_value=None, new_value={}", + channel.as_str(), + account.id, + name + )); + account.display_name = Some(name); + healed = true; + } + } + + if account.email == "unknown@qoder.local" + || account.email.starts_with("unknown@") + || account.email.trim().is_empty() + { + let old_email = account.email.clone(); + let new_email = if let Some(real_email) = extract_snapshot_email(&snapshot) { + Some(real_email) + } else if let Some(ref name) = account.display_name { + if name.contains('@') { + Some(name.to_lowercase()) + } else { + Some(format!("{}@qoder.cn", name.to_lowercase())) + } + } else if let Some(ref uid) = account.user_id { + Some(format!("uid_{}@qoder.cn", sanitize_account_id_component(uid).to_lowercase())) + } else { + None + }; + + if let Some(ne) = new_email { + crate::modules::logger::log_warn(&format!( + "[Qoder Account Heal] 字段自愈: channel={}, account_id={}, field_name=email, old_value={}, new_value={}", + channel.as_str(), + account.id, + old_email, + ne + )); + account.email = ne; + healed = true; + } + } + + if account.plan_type.is_none() { + let new_plan = if let Some(plan) = extract_snapshot_plan_type(&snapshot) { + Some(plan) + } else if channel.is_cn() || account.email.ends_with(".cn") { + Some("Free".to_string()) + } else { + None + }; + + if let Some(plan) = new_plan { + crate::modules::logger::log_warn(&format!( + "[Qoder Account Heal] 字段自愈: channel={}, account_id={}, field_name=plan_type, old_value=None, new_value={}", + channel.as_str(), + account.id, + plan + )); + account.plan_type = Some(plan); + healed = true; + } + } + + if healed { + let _ = save_account_file_for_channel(channel, &account); + } + + if needs_rotation && !healed { let account_for_rewrite = account.clone(); crate::modules::deferred_account_rewrite::schedule_account_rewrite_if_unchanged( "qoder", @@ -176,53 +274,87 @@ pub fn load_account(account_id: &str) -> Option { } Some(account) } - Err(_) => None, + Err(err) => { + crate::modules::logger::log_warn(&format!( + "[Qoder Account] 解密或加载账号文件失败: channel={}, path={}, error={}", + channel.as_str(), + account_path.display(), + err + )); + None + } } } -fn save_account_file(account: &QoderAccount) -> Result<(), String> { - let path = resolve_account_file_path(account.id.as_str())?; - let content = crate::modules::secure_account_storage::serialize_account_file("qoder", account)?; +pub fn find_account_channel(account_id: &str) -> Option<(QoderChannel, QoderAccount)> { + for &channel in &QoderChannel::ALL { + if let Some(account) = load_account_for_channel(channel, account_id) { + return Some((channel, account)); + } + } + None +} + +pub fn load_account(account_id: &str) -> Option { + find_account_channel(account_id).map(|(_, account)| account) +} + +pub fn save_account_file_for_channel( + channel: QoderChannel, + account: &QoderAccount, +) -> Result<(), String> { + let path = resolve_account_file_path_for_channel(channel, account.id.as_str())?; + let content = + crate::modules::secure_account_storage::serialize_account_file("qoder", account)?; crate::modules::atomic_write::write_string_atomic(&path, &content) .map_err(|e| format!("保存账号失败: {}", e)) } -fn delete_account_file(account_id: &str) -> Result<(), String> { - let path = resolve_account_file_path(account_id)?; +fn save_account_file(account: &QoderAccount) -> Result<(), String> { + save_account_file_for_channel(QoderChannel::QoderIde, account) +} + +fn delete_account_file_for_channel(channel: QoderChannel, account_id: &str) -> Result<(), String> { + let path = resolve_account_file_path_for_channel(channel, account_id)?; if path.exists() { - crate::modules::atomic_write::remove_file_locked(&path) - .map_err(|e| format!("删除账号文件失败: {}", e))?; + fs::remove_file(path).map_err(|e| format!("删除账号文件失败: {}", e))?; } Ok(()) } -fn load_account_index() -> QoderAccountIndex { - let path = match get_accounts_index_path() { +fn delete_account_file(account_id: &str) -> Result<(), String> { + delete_account_file_for_channel(QoderChannel::QoderIde, account_id) +} + +fn load_account_index_for_channel(channel: QoderChannel) -> QoderAccountIndex { + let path = match get_accounts_index_path_for_channel(channel) { Ok(p) => p, Err(_) => return QoderAccountIndex::new(), }; if !path.exists() { - return repair_account_index_from_details("索引文件不存在") + return repair_account_index_from_details_for_channel(channel, "索引文件不存在") .unwrap_or_else(QoderAccountIndex::new); } match fs::read_to_string(&path) { Ok(content) if content.trim().is_empty() => { - repair_account_index_from_details("索引文件为空").unwrap_or_else(QoderAccountIndex::new) + repair_account_index_from_details_for_channel(channel, "索引文件为空") + .unwrap_or_else(QoderAccountIndex::new) } Ok(content) => match crate::modules::atomic_write::parse_json_with_auto_restore::< QoderAccountIndex, >(&path, &content) { Ok(index) if !index.accounts.is_empty() => index, - Ok(_) => repair_account_index_from_details("索引账号列表为空") + Ok(_) => repair_account_index_from_details_for_channel(channel, "索引账号列表为空") .unwrap_or_else(QoderAccountIndex::new), Err(err) => { logger::log_warn(&format!( - "[Qoder Account] 账号索引解析失败,尝试按详情文件自动修复: path={}, error={}", + "[{}] 账号索引解析失败,尝试按详情文件自动修复: path={}, error={}", + channel.display_name(), path.display(), err )); - repair_account_index_from_details("索引文件损坏") + repair_account_index_from_details_for_channel(channel, "索引文件损坏") .unwrap_or_else(QoderAccountIndex::new) } }, @@ -230,10 +362,16 @@ fn load_account_index() -> QoderAccountIndex { } } -fn load_account_index_checked() -> Result { - let path = get_accounts_index_path()?; +fn load_account_index() -> QoderAccountIndex { + load_account_index_for_channel(QoderChannel::QoderIde) +} + +fn load_account_index_checked_for_channel( + channel: QoderChannel, +) -> Result { + let path = get_accounts_index_path_for_channel(channel)?; if !path.exists() { - if let Some(index) = repair_account_index_from_details("索引文件不存在") { + if let Some(index) = repair_account_index_from_details_for_channel(channel, "索引文件不存在") { return Ok(index); } return Ok(QoderAccountIndex::new()); @@ -242,7 +380,9 @@ fn load_account_index_checked() -> Result { let content = match fs::read_to_string(&path) { Ok(content) => content, Err(err) => { - if let Some(index) = repair_account_index_from_details("索引文件读取失败") { + if let Some(index) = + repair_account_index_from_details_for_channel(channel, "索引文件读取失败") + { return Ok(index); } return Err(format!("读取账号索引失败: {}", err)); @@ -250,7 +390,7 @@ fn load_account_index_checked() -> Result { }; if content.trim().is_empty() { - if let Some(index) = repair_account_index_from_details("索引文件为空") { + if let Some(index) = repair_account_index_from_details_for_channel(channel, "索引文件为空") { return Ok(index); } return Ok(QoderAccountIndex::new()); @@ -261,17 +401,19 @@ fn load_account_index_checked() -> Result { ) { Ok(index) if !index.accounts.is_empty() => Ok(index), Ok(index) => { - if let Some(repaired) = repair_account_index_from_details("索引账号列表为空") { + if let Some(repaired) = + repair_account_index_from_details_for_channel(channel, "索引账号列表为空") + { return Ok(repaired); } Ok(index) } Err(err) => { - if let Some(index) = repair_account_index_from_details("索引文件损坏") { + if let Some(index) = repair_account_index_from_details_for_channel(channel, "索引文件损坏") { return Ok(index); } Err(crate::error::file_corrupted_error( - ACCOUNTS_INDEX_FILE, + channel.accounts_index_filename(), &path.to_string_lossy(), &err.to_string(), )) @@ -279,20 +421,34 @@ fn load_account_index_checked() -> Result { } } -fn save_account_index(index: &QoderAccountIndex) -> Result<(), String> { - let path = get_accounts_index_path()?; +fn load_account_index_checked() -> Result { + load_account_index_checked_for_channel(QoderChannel::QoderIde) +} + +fn save_account_index_for_channel( + channel: QoderChannel, + index: &QoderAccountIndex, +) -> Result<(), String> { + let path = get_accounts_index_path_for_channel(channel)?; let content = serde_json::to_string_pretty(index).map_err(|e| format!("序列化账号索引失败: {}", e))?; crate::modules::atomic_write::write_string_atomic(&path, &content) .map_err(|e| format!("写入账号索引失败: {}", e)) } -fn repair_account_index_from_details(reason: &str) -> Option { - let index_path = get_accounts_index_path().ok()?; - let accounts_dir = get_accounts_dir().ok()?; +fn save_account_index(index: &QoderAccountIndex) -> Result<(), String> { + save_account_index_for_channel(QoderChannel::QoderIde, index) +} + +fn repair_account_index_from_details_for_channel( + channel: QoderChannel, + reason: &str, +) -> Option { + let index_path = get_accounts_index_path_for_channel(channel).ok()?; + let accounts_dir = get_accounts_dir_for_channel(channel).ok()?; let mut accounts = crate::modules::account_index_repair::load_accounts_from_details( &accounts_dir, - |account_id| load_account(account_id), + |account_id| load_account_for_channel(channel, account_id), ) .ok()?; @@ -313,16 +469,18 @@ fn repair_account_index_from_details(reason: &str) -> Option let backup_path = crate::modules::account_index_repair::backup_existing_index(&index_path) .unwrap_or_else(|err| { logger::log_warn(&format!( - "[Qoder Account] 自动修复前备份索引失败,继续尝试重建: path={}, error={}", + "[{}] 自动修复前备份索引失败,继续尝试重建: path={}, error={}", + channel.display_name(), index_path.display(), err )); None }); - if let Err(err) = save_account_index(&index) { + if let Err(err) = save_account_index_for_channel(channel, &index) { logger::log_warn(&format!( - "[Qoder Account] 自动修复索引保存失败,将以内存结果继续运行: reason={}, recovered_accounts={}, error={}", + "[{}] 自动修复索引保存失败,将以内存结果继续运行: reason={}, recovered_accounts={}, error={}", + channel.display_name(), reason, index.accounts.len(), err @@ -330,7 +488,8 @@ fn repair_account_index_from_details(reason: &str) -> Option } logger::log_warn(&format!( - "[Qoder Account] 检测到账号索引异常,已根据详情文件自动重建: reason={}, recovered_accounts={}, backup_path={}", + "[{}] 检测到账号索引异常,已根据详情文件自动重建: reason={}, recovered_accounts={}, backup_path={}", + channel.display_name(), reason, index.accounts.len(), backup_path @@ -342,6 +501,10 @@ fn repair_account_index_from_details(reason: &str) -> Option Some(index) } +fn repair_account_index_from_details(reason: &str) -> Option { + repair_account_index_from_details_for_channel(QoderChannel::QoderIde, reason) +} + fn refresh_summary(index: &mut QoderAccountIndex, account: &QoderAccount) { if let Some(summary) = index.accounts.iter_mut().find(|item| item.id == account.id) { *summary = account.summary(); @@ -350,22 +513,30 @@ fn refresh_summary(index: &mut QoderAccountIndex, account: &QoderAccount) { index.accounts.push(account.summary()); } -fn upsert_account_record(account: QoderAccount) -> Result { +pub fn upsert_account_record_for_channel( + channel: QoderChannel, + account: QoderAccount, +) -> Result { let _lock = QODER_ACCOUNT_INDEX_LOCK .lock() .map_err(|_| "获取 Qoder 账号锁失败".to_string())?; - let mut index = load_account_index(); - save_account_file(&account)?; + let mut index = load_account_index_for_channel(channel); + save_account_file_for_channel(channel, &account)?; refresh_summary(&mut index, &account); - save_account_index(&index)?; + save_account_index_for_channel(channel, &index)?; Ok(account) } -pub fn update_quota_query_error( +fn upsert_account_record(account: QoderAccount) -> Result { + upsert_account_record_for_channel(QoderChannel::QoderIde, account) +} + +pub fn update_quota_query_error_for_channel( + channel: QoderChannel, account_id: &str, message: Option, ) -> Result, String> { - let Some(mut account) = load_account(account_id) else { + let Some(mut account) = load_account_for_channel(channel, account_id) else { return Ok(None); }; account.quota_query_last_error = message; @@ -373,43 +544,103 @@ pub fn update_quota_query_error( .quota_query_last_error .as_ref() .map(|_| chrono::Utc::now().timestamp_millis()); - let updated = upsert_account_record(account)?; + let updated = upsert_account_record_for_channel(channel, account)?; Ok(Some(updated)) } -fn list_accounts_from_index(index: &QoderAccountIndex) -> Vec { +pub fn update_quota_query_error( + account_id: &str, + message: Option, +) -> Result, String> { + if let Some((channel, _)) = find_account_channel(account_id) { + update_quota_query_error_for_channel(channel, account_id, message) + } else { + Ok(None) + } +} + +fn list_accounts_from_index_for_channel( + channel: QoderChannel, + index: &QoderAccountIndex, +) -> Vec { let mut accounts = Vec::new(); + let mut need_save_index = false; + let mut updated_index = index.clone(); + for summary in &index.accounts { - if let Some(account) = load_account(&summary.id) { + if let Some(account) = load_account_for_channel(channel, &summary.id) { + if summary.email != account.email + || summary.display_name != account.display_name + || summary.plan_type != account.plan_type + { + need_save_index = true; + crate::modules::logger::log_warn(&format!( + "[Qoder Account Heal] Index 摘要同步自愈: channel={}, account_id={}, email: {:?} -> {:?}, display_name: {:?} -> {:?}, plan_type: {:?} -> {:?}", + channel.as_str(), + account.id, + summary.email, + account.email, + summary.display_name, + account.display_name, + summary.plan_type, + account.plan_type + )); + if let Some(item) = updated_index.accounts.iter_mut().find(|a| a.id == account.id) { + *item = account.summary(); + } + } accounts.push(account); } } + if need_save_index { + if let Ok(path) = get_accounts_index_path_for_channel(channel) { + crate::modules::logger::log_info(&format!( + "[Qoder Account Heal] 回写已自愈的 Index 成功: channel={}, path={}", + channel.as_str(), + path.display() + )); + } + let _ = save_account_index_for_channel(channel, &updated_index); + } accounts.sort_by(|a, b| b.last_used.cmp(&a.last_used)); accounts } +pub fn list_accounts_for_channel(channel: QoderChannel) -> Vec { + let index = load_account_index_for_channel(channel); + list_accounts_from_index_for_channel(channel, &index) +} + +pub fn list_accounts_checked_for_channel( + channel: QoderChannel, +) -> Result, String> { + let index = load_account_index_checked_for_channel(channel)?; + Ok(list_accounts_from_index_for_channel(channel, &index)) +} + pub fn list_accounts() -> Vec { - let index = load_account_index(); - list_accounts_from_index(&index) + list_accounts_for_channel(QoderChannel::QoderIde) } pub fn list_accounts_checked() -> Result, String> { - let index = load_account_index_checked()?; - Ok(list_accounts_from_index(&index)) + list_accounts_checked_for_channel(QoderChannel::QoderIde) } -pub fn remove_account(account_id: &str) -> Result<(), String> { +pub fn remove_account_for_channel(channel: QoderChannel, account_id: &str) -> Result<(), String> { let _lock = QODER_ACCOUNT_INDEX_LOCK .lock() .map_err(|_| "获取 Qoder 账号锁失败".to_string())?; - let mut index = load_account_index(); + let mut index = load_account_index_for_channel(channel); index.accounts.retain(|item| item.id != account_id); - save_account_index(&index)?; - delete_account_file(account_id)?; + save_account_index_for_channel(channel, &index)?; + delete_account_file_for_channel(channel, account_id)?; Ok(()) } -pub fn remove_accounts(account_ids: &[String]) -> Result<(), String> { +pub fn remove_accounts_for_channel( + channel: QoderChannel, + account_ids: &[String], +) -> Result<(), String> { let target: HashSet = account_ids .iter() .map(|id| id.trim().to_string()) @@ -422,15 +653,23 @@ pub fn remove_accounts(account_ids: &[String]) -> Result<(), String> { let _lock = QODER_ACCOUNT_INDEX_LOCK .lock() .map_err(|_| "获取 Qoder 账号锁失败".to_string())?; - let mut index = load_account_index(); + let mut index = load_account_index_for_channel(channel); index.accounts.retain(|item| !target.contains(&item.id)); - save_account_index(&index)?; + save_account_index_for_channel(channel, &index)?; for id in target { - delete_account_file(&id)?; + delete_account_file_for_channel(channel, &id)?; } Ok(()) } +pub fn remove_account(account_id: &str) -> Result<(), String> { + remove_account_for_channel(QoderChannel::QoderIde, account_id) +} + +pub fn remove_accounts(account_ids: &[String]) -> Result<(), String> { + remove_accounts_for_channel(QoderChannel::QoderIde, account_ids) +} + fn parse_json_or_string(raw: &str) -> Value { serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string())) } @@ -864,16 +1103,49 @@ fn normalize_tags(tags: Vec) -> Option> { fn snapshot_to_account(snapshot: QoderSnapshot, existing: Option<&QoderAccount>) -> QoderAccount { let now = now_ts(); - let email = extract_snapshot_email(&snapshot) - .or_else(|| existing.and_then(|item| normalize_email(Some(item.email.as_str())))) - .unwrap_or_else(|| "unknown@qoder.local".to_string()); - let user_id = extract_snapshot_user_id(&snapshot) - .or_else(|| existing.and_then(|item| item.user_id.clone())); - let generated_id = generate_account_id(&snapshot, user_id.as_deref(), Some(email.as_str())); let display_name = extract_snapshot_display_name(&snapshot) .or_else(|| existing.and_then(|item| item.display_name.clone())); + let user_id = extract_snapshot_user_id(&snapshot) + .or_else(|| existing.and_then(|item| item.user_id.clone())); let plan_type = extract_snapshot_plan_type(&snapshot) - .or_else(|| existing.and_then(|item| item.plan_type.clone())); + .or_else(|| existing.and_then(|item| item.plan_type.clone())) + .or_else(|| { + if display_name.as_deref().map(|n| n.contains("aliyun")).unwrap_or(false) + || user_id.as_deref().map(|u| u.starts_with("aliyun-")).unwrap_or(false) + { + Some("Free".to_string()) + } else { + None + } + }); + + let email = extract_snapshot_email(&snapshot) + .or_else(|| { + existing.and_then(|item| { + if item.email == "unknown@qoder.local" || item.email.starts_with("unknown@") { + None + } else { + normalize_email(Some(item.email.as_str())) + } + }) + }) + .or_else(|| { + display_name.as_deref().map(|name| { + if name.contains('@') { + name.to_lowercase() + } else { + format!("{}@qoder.cn", name.to_lowercase()) + } + }) + }) + .or_else(|| { + user_id.as_deref().map(|uid| { + format!("uid_{}@qoder.cn", sanitize_account_id_component(uid).to_lowercase()) + }) + }) + .unwrap_or_else(|| "unknown@qoder.local".to_string()); + + let generated_id = generate_account_id(&snapshot, user_id.as_deref(), Some(email.as_str())); let (credits_used, credits_total, credits_remaining, credits_usage_percent) = extract_snapshot_credits(&snapshot); @@ -919,6 +1191,20 @@ fn snapshot_to_account(snapshot: QoderSnapshot, existing: Option<&QoderAccount>) } } +pub fn merge_account_snapshot( + existing: QoderAccount, + user_info_raw: Option, + user_plan_raw: Option, + credit_usage_raw: Option, +) -> QoderAccount { + let snapshot = QoderSnapshot { + user_info_raw, + user_plan_raw, + credit_usage_raw, + }; + snapshot_to_account(snapshot, Some(&existing)) +} + fn find_existing_account_for_snapshot( snapshot: &QoderSnapshot, accounts: &[QoderAccount], @@ -956,11 +1242,34 @@ fn read_snapshot_from_state_db_path(db_path: &Path) -> Result Result { - let accounts = list_accounts(); +fn merge_snapshot_for_channel( + channel: QoderChannel, + snapshot: QoderSnapshot, +) -> Result { + let accounts = list_accounts_for_channel(channel); let existing = find_existing_account_for_snapshot(&snapshot, &accounts); let account = snapshot_to_account(snapshot, existing.as_ref()); - upsert_account_record(account) + upsert_account_record_for_channel(channel, account) +} + +fn merge_snapshot(snapshot: QoderSnapshot) -> Result { + merge_snapshot_for_channel(QoderChannel::QoderIde, snapshot) +} + +pub fn upsert_account_from_snapshot_for_channel( + channel: QoderChannel, + user_info_raw: Value, + user_plan_raw: Option, + credit_usage_raw: Option, +) -> Result { + merge_snapshot_for_channel( + channel, + QoderSnapshot { + user_info_raw: Some(user_info_raw), + user_plan_raw, + credit_usage_raw, + }, + ) } pub fn upsert_account_from_snapshot( @@ -968,11 +1277,12 @@ pub fn upsert_account_from_snapshot( user_plan_raw: Option, credit_usage_raw: Option, ) -> Result { - merge_snapshot(QoderSnapshot { - user_info_raw: Some(user_info_raw), + upsert_account_from_snapshot_for_channel( + QoderChannel::QoderIde, + user_info_raw, user_plan_raw, credit_usage_raw, - }) + ) } pub fn get_default_qoder_state_db_path() -> Option { @@ -1031,26 +1341,58 @@ fn ensure_default_state_db_path() -> Result { ensure_state_db_path_for_user_data_dir(&data_root.to_string_lossy()) } -pub fn import_from_local() -> Result, String> { - let db_path = ensure_default_state_db_path()?; - let Some(snapshot) = read_snapshot_from_state_db_path(&db_path)? else { - return Ok(None); - }; - let account = merge_snapshot(snapshot)?; - logger::log_info(&format!( - "[Qoder Account] 从本地导入成功: id={}, email={}, db={}", - account.id, - account.email, - db_path.to_string_lossy() - )); - Ok(Some(account)) +pub fn import_from_local_channel(channel: QoderChannel) -> Result, String> { + match channel.credential_kind() { + QoderCredentialKind::StateVscdb => { + let data_dir = channel.default_user_data_dir()?; + let db_path = ensure_state_db_path_for_user_data_dir(&data_dir.to_string_lossy())?; + let Some(snapshot) = read_snapshot_from_state_db_path(&db_path)? else { + return Ok(None); + }; + let account = merge_snapshot_for_channel(channel, snapshot)?; + logger::log_info(&format!( + "[Qoder Account] 从本地 {} (state.vscdb) 导入成功: id={}, email={}, db={}", + channel.display_name(), + account.id, + account.email, + db_path.to_string_lossy() + )); + Ok(Some(account)) + } + QoderCredentialKind::AuthV1Dat => { + let data_dir = channel.default_user_data_dir()?; + let Some(app_auth) = crate::modules::qoder_app_auth::read_qoder_app_auth(&data_dir)? else { + return Ok(None); + }; + + let user = app_auth.get("user"); + let user_info_raw = Some(app_auth.clone()); + let user_plan_raw = user + .and_then(|u| u.get("tier")) + .and_then(|t| t.as_str()) + .map(|t| serde_json::json!({ "plan": t, "tier": t })); + + let snapshot = QoderSnapshot { + user_info_raw, + user_plan_raw, + credit_usage_raw: None, + }; + + let account = merge_snapshot_for_channel(channel, snapshot)?; + logger::log_info(&format!( + "[Qoder Account] 从本地 {} (auth.v1.dat) 导入成功: id={}, email={}, path={}", + channel.display_name(), + account.id, + account.email, + data_dir.display() + )); + Ok(Some(account)) + } + } } -pub(crate) fn resolve_current_account_id(accounts: &[QoderAccount]) -> Option { - crate::modules::provider_current_state::resolve_existing_current_account_id( - "qoder", - accounts.iter().map(|account| account.id.as_str()), - ) +pub fn import_from_local() -> Result, String> { + import_from_local_channel(QoderChannel::QoderIde) } fn serialize_raw_or_fallback(raw: &Option, fallback: Value) -> Result { @@ -1138,22 +1480,24 @@ fn verify_injected_account_matches(db_path: &Path, account: &QoderAccount) -> Re )) } -pub fn inject_to_qoder(account_id: &str) -> Result<(), String> { - let db_path = ensure_default_state_db_path()?; - inject_to_qoder_at_path(&db_path, account_id) +pub fn resolve_current_account_id_for_channel( + channel: QoderChannel, + accounts: &[QoderAccount], +) -> Option { + crate::modules::provider_current_state::resolve_existing_current_account_id( + channel.provider_key(), + accounts.iter().map(|account| account.id.as_str()), + ) } -pub fn inject_to_qoder_for_user_data_dir( - user_data_dir: &str, - account_id: &str, -) -> Result<(), String> { - let db_path = ensure_state_db_path_for_user_data_dir(user_data_dir)?; - inject_to_qoder_at_path(&db_path, account_id) +pub fn resolve_current_account_id(accounts: &[QoderAccount]) -> Option { + resolve_current_account_id_for_channel(QoderChannel::QoderIde, accounts) } -pub fn inject_to_qoder_at_path(db_path: &Path, account_id: &str) -> Result<(), String> { - let account = - load_account(account_id).ok_or_else(|| format!("Qoder 账号不存在: {}", account_id))?; +pub fn inject_account_to_state_db_at_path( + db_path: &Path, + account: &QoderAccount, +) -> Result<(), String> { if let Some(parent) = db_path.parent() { fs::create_dir_all(parent) .map_err(|e| format!("创建 Qoder state.vscdb 目录失败: {}", e))?; @@ -1161,15 +1505,15 @@ pub fn inject_to_qoder_at_path(db_path: &Path, account_id: &str) -> Result<(), S let user_info_json = serialize_raw_or_fallback( &account.auth_user_info_raw, - build_user_info_fallback(&account), + build_user_info_fallback(account), )?; let user_plan_json = serialize_raw_or_fallback( &account.auth_user_plan_raw, - build_user_plan_fallback(&account), + build_user_plan_fallback(account), )?; let credit_usage_json = serialize_raw_or_fallback( &account.auth_credit_usage_raw, - build_credit_usage_fallback(&account), + build_credit_usage_fallback(account), )?; crate::modules::vscode_inject::inject_secret_to_state_db_for_qoder( @@ -1191,7 +1535,95 @@ pub fn inject_to_qoder_at_path(db_path: &Path, account_id: &str) -> Result<(), S verify_state_db_key_exists(db_path, QODER_SECRET_USER_INFO_KEY)?; verify_state_db_key_exists(db_path, QODER_SECRET_USER_PLAN_KEY)?; verify_state_db_key_exists(db_path, QODER_SECRET_CREDIT_USAGE_KEY)?; - verify_injected_account_matches(db_path, &account)?; + verify_injected_account_matches(db_path, account)?; + + Ok(()) +} + +pub fn inject_to_qoder_channel_with_account( + channel: QoderChannel, + account_id: &str, +) -> Result<(), String> { + let account = load_account_for_channel(channel, account_id) + .or_else(|| load_account(account_id)) + .ok_or_else(|| format!("{} 账号不存在: {}", channel.display_name(), account_id))?; + + match channel.credential_kind() { + QoderCredentialKind::StateVscdb => { + let data_dir = channel.default_user_data_dir()?; + let db_path = ensure_state_db_path_for_user_data_dir(&data_dir.to_string_lossy())?; + inject_account_to_state_db_at_path(&db_path, &account)?; + } + QoderCredentialKind::AuthV1Dat => { + let data_dir = channel.default_user_data_dir()?; + let auth_json = crate::modules::qoder_app_auth::build_app_auth_value(&account); + crate::modules::qoder_app_auth::write_qoder_app_auth(&data_dir, &auth_json)?; + } + } + + let mut updated = account.clone(); + updated.last_used = now_ts(); + let _ = upsert_account_record_for_channel(channel, updated); + + logger::log_info(&format!( + "[Qoder Inject] 成功注入账号到 {}: account_id={}, email={}", + channel.display_name(), + account.id, + account.email + )); + Ok(()) +} + +pub fn inject_to_qoder(account_id: &str) -> Result<(), String> { + inject_to_qoder_channel_with_account(QoderChannel::QoderIde, account_id) +} + +pub fn inject_to_qoder_channel_for_user_data_dir( + channel: QoderChannel, + user_data_dir: &str, + account_id: &str, +) -> Result<(), String> { + let account = load_account_for_channel(channel, account_id) + .or_else(|| load_account(account_id)) + .ok_or_else(|| format!("{} 账号不存在: {}", channel.display_name(), account_id))?; + + match channel.credential_kind() { + QoderCredentialKind::StateVscdb => { + let db_path = ensure_state_db_path_for_user_data_dir(user_data_dir)?; + inject_account_to_state_db_at_path(&db_path, &account)?; + } + QoderCredentialKind::AuthV1Dat => { + let data_dir = PathBuf::from(user_data_dir); + let auth_json = crate::modules::qoder_app_auth::build_app_auth_value(&account); + crate::modules::qoder_app_auth::write_qoder_app_auth(&data_dir, &auth_json)?; + } + } + + let mut updated = account.clone(); + updated.last_used = now_ts(); + let _ = upsert_account_record_for_channel(channel, updated); + + logger::log_info(&format!( + "[Qoder Inject] 成功注入实例账号到 {}: account_id={}, email={}, dir={}", + channel.display_name(), + account.id, + account.email, + user_data_dir + )); + Ok(()) +} + +pub fn inject_to_qoder_for_user_data_dir( + user_data_dir: &str, + account_id: &str, +) -> Result<(), String> { + inject_to_qoder_channel_for_user_data_dir(QoderChannel::QoderIde, user_data_dir, account_id) +} + +pub fn inject_to_qoder_at_path(db_path: &Path, account_id: &str) -> Result<(), String> { + let account = + load_account(account_id).ok_or_else(|| format!("Qoder 账号不存在: {}", account_id))?; + inject_account_to_state_db_at_path(db_path, &account)?; let mut updated = account.clone(); updated.last_used = now_ts(); @@ -1206,12 +1638,20 @@ pub fn inject_to_qoder_at_path(db_path: &Path, account_id: &str) -> Result<(), S Ok(()) } -pub fn update_account_tags(account_id: &str, tags: Vec) -> Result { - let mut account = - load_account(account_id).ok_or_else(|| format!("Qoder 账号不存在: {}", account_id))?; +pub fn update_account_tags_for_channel( + channel: QoderChannel, + account_id: &str, + tags: Vec, +) -> Result { + let mut account = load_account_for_channel(channel, account_id) + .ok_or_else(|| format!("{} 账号不存在: {}", channel.display_name(), account_id))?; account.tags = normalize_tags(tags); account.last_used = now_ts(); - upsert_account_record(account) + upsert_account_record_for_channel(channel, account) +} + +pub fn update_account_tags(account_id: &str, tags: Vec) -> Result { + update_account_tags_for_channel(QoderChannel::QoderIde, account_id, tags) } fn normalize_imported_account(mut account: QoderAccount) -> QoderAccount { @@ -1229,11 +1669,53 @@ fn normalize_imported_account(mut account: QoderAccount) -> QoderAccount { Some(account.email.as_str()), ); } - account.email = normalize_email(Some(account.email.as_str())) - .unwrap_or_else(|| "unknown@qoder.local".to_string()); account.user_id = normalize_non_empty(account.user_id.as_deref()); account.display_name = normalize_non_empty(account.display_name.as_deref()); + if account.display_name.is_none() { + let snapshot = QoderSnapshot { + user_info_raw: account.auth_user_info_raw.clone(), + user_plan_raw: account.auth_user_plan_raw.clone(), + credit_usage_raw: account.auth_credit_usage_raw.clone(), + }; + account.display_name = extract_snapshot_display_name(&snapshot); + } + + if account.email.trim().is_empty() + || account.email == "unknown@qoder.local" + || account.email.starts_with("unknown@") + { + if let Some(ref name) = account.display_name { + if name.contains('@') { + account.email = name.to_lowercase(); + } else { + account.email = format!("{}@qoder.cn", name.to_lowercase()); + } + } else if let Some(ref uid) = account.user_id { + account.email = format!("uid_{}@qoder.cn", sanitize_account_id_component(uid).to_lowercase()); + } else { + account.email = "unknown@qoder.local".to_string(); + } + } else { + account.email = normalize_email(Some(account.email.as_str())) + .unwrap_or_else(|| "unknown@qoder.local".to_string()); + } account.plan_type = normalize_non_empty(account.plan_type.as_deref()); + if account.plan_type.is_none() { + let snapshot = QoderSnapshot { + user_info_raw: account.auth_user_info_raw.clone(), + user_plan_raw: account.auth_user_plan_raw.clone(), + credit_usage_raw: account.auth_credit_usage_raw.clone(), + }; + account.plan_type = extract_snapshot_plan_type(&snapshot).or_else(|| { + if account.email.ends_with(".cn") + || account.display_name.as_deref().map(|n| n.contains("aliyun")).unwrap_or(false) + { + Some("Free".to_string()) + } else { + None + } + }); + } account.tags = normalize_tags(account.tags.unwrap_or_default()); account.quota_query_last_error = normalize_non_empty(account.quota_query_last_error.as_deref()); if account.created_at <= 0 { @@ -1283,7 +1765,10 @@ fn parse_import_item(item: &Value) -> Result { Ok(snapshot_to_account(snapshot, None)) } -pub fn import_from_json(json_content: &str) -> Result, String> { +pub fn import_from_json_for_channel( + channel: QoderChannel, + json_content: &str, +) -> Result, String> { let parsed: Value = serde_json::from_str(json_content).map_err(|e| format!("JSON 解析失败: {}", e))?; let items: Vec = match parsed { @@ -1305,15 +1790,22 @@ pub fn import_from_json(json_content: &str) -> Result, String> let mut imported = Vec::new(); for item in items { let account = parse_import_item(&item)?; - let saved = upsert_account_record(account)?; + let saved = upsert_account_record_for_channel(channel, account)?; imported.push(saved); } Ok(imported) } -pub fn export_accounts(account_ids: &[String]) -> Result { - let accounts = list_accounts(); +pub fn import_from_json(json_content: &str) -> Result, String> { + import_from_json_for_channel(QoderChannel::QoderIde, json_content) +} + +pub fn export_accounts_for_channel( + channel: QoderChannel, + account_ids: &[String], +) -> Result { + let accounts = list_accounts_for_channel(channel); let selected: Vec = if account_ids.is_empty() { accounts } else { @@ -1330,3 +1822,7 @@ pub fn export_accounts(account_ids: &[String]) -> Result { serde_json::to_string_pretty(&selected).map_err(|e| format!("序列化导出 JSON 失败: {}", e)) } + +pub fn export_accounts(account_ids: &[String]) -> Result { + export_accounts_for_channel(QoderChannel::QoderIde, account_ids) +} diff --git a/src-tauri/src/modules/qoder_app_auth.rs b/src-tauri/src/modules/qoder_app_auth.rs new file mode 100644 index 000000000..0dae7bf8f --- /dev/null +++ b/src-tauri/src/modules/qoder_app_auth.rs @@ -0,0 +1,184 @@ +use std::fs; +use std::path::Path; +use chrono::{TimeZone, Utc}; +use serde_json::Value; + +use crate::models::qoder::QoderAccount; +use crate::modules::logger; + +const AUTH_V1_FILENAME: &str = "auth.v1.dat"; +const MACHINE_ID_FILENAME: &str = "auth.machine-id"; + +/// 毫秒时间戳转 ISO 8601 字符串(如 "2026-09-27T06:10:46Z") +fn ms_epoch_to_iso8601(val: Option<&Value>) -> Option { + let Some(val) = val else { return None }; + if let Some(s) = val.as_str() { + let trimmed = s.trim(); + if trimmed.is_empty() { + return None; + } + if trimmed.contains('T') || trimmed.contains('-') { + return Some(trimmed.to_string()); + } + if let Ok(millis) = trimmed.parse::() { + return Utc.timestamp_millis_opt(millis).single().map(|dt| dt.to_rfc3339()); + } + return Some(trimmed.to_string()); + } + if let Some(millis) = val.as_i64() { + return Utc.timestamp_millis_opt(millis).single().map(|dt| dt.to_rfc3339()); + } + None +} + +/// 从 Qoder App 数据目录(如 %APPDATA%\com.qoder.app.stable)读取并解密 auth.v1.dat +pub fn read_qoder_app_auth(data_dir: &Path) -> Result, String> { + let auth_path = data_dir.join(AUTH_V1_FILENAME); + if !auth_path.exists() { + return Ok(None); + } + + #[cfg(target_os = "windows")] + { + let master_key = crate::modules::vscode_inject::get_windows_encryption_key(Some(data_dir)) + .map_err(|e| format!("读取 Local State 密钥失败({}): {}", data_dir.display(), e))?; + + let encrypted = fs::read(&auth_path) + .map_err(|e| format!("读取 {} 失败: {}", auth_path.display(), e))?; + + let decrypted = crate::modules::vscode_inject::decrypt_windows_gcm_v10(&master_key, &encrypted) + .map_err(|e| format!("解密 {} 失败: {}", auth_path.display(), e))?; + + let json_text = String::from_utf8(decrypted) + .map_err(|e| format!("解析 {} 明文为 UTF-8 失败: {}", auth_path.display(), e))?; + + let value: Value = serde_json::from_str(&json_text) + .map_err(|e| format!("解析 {} 为 JSON 失败: {}", auth_path.display(), e))?; + + Ok(Some(value)) + } + + #[cfg(not(target_os = "windows"))] + { + Err("Qoder App (Launcher 架构) 当前仅支持 Windows 系统".to_string()) + } +} + +/// 将 QoderAccount 转化为 Qoder App (auth.v1.dat) 标准 schemaVersion 1 结构 +pub fn build_app_auth_value(account: &QoderAccount) -> Value { + let raw_info = account.auth_user_info_raw.as_ref(); + + let token = raw_info + .and_then(|v| v.get("token").or_else(|| v.get("accessToken"))) + .and_then(|v| v.as_str()) + .unwrap_or_default(); + + let refresh_token = raw_info + .and_then(|v| v.get("refreshToken")) + .and_then(|v| v.as_str()) + .unwrap_or_default(); + + // Qoder App asar 要求 expiresAt 必须为 ISO 8601 字符串 + let expires_at = ms_epoch_to_iso8601( + raw_info.and_then(|v| v.get("expiresAt").or_else(|| v.get("expireTime"))), + ) + .unwrap_or_else(|| Utc::now().to_rfc3339()); + + let refresh_token_expires_at = ms_epoch_to_iso8601( + raw_info.and_then(|v| { + v.get("refreshTokenExpiresAt") + .or_else(|| v.get("refreshTokenExpireTime")) + }), + ) + .unwrap_or_else(|| { + let future = Utc::now() + chrono::Duration::days(365); + future.to_rfc3339() + }); + + let user_id = account.user_id.clone().unwrap_or_default(); + let user_name = account.display_name.clone().unwrap_or_default(); + let email = account.email.clone(); + let avatar_url = raw_info + .and_then(|v| v.get("avatarUrl").or_else(|| v.get("imageUrl"))) + .and_then(|v| v.as_str()) + .unwrap_or_default(); + + serde_json::json!({ + "schemaVersion": 1, + "token": token, + "refreshToken": refresh_token, + "expiresAt": expires_at, + "refreshTokenExpiresAt": refresh_token_expires_at, + "user": { + "id": user_id, + "name": user_name, + "email": email, + "avatarUrl": avatar_url, + "tier": account.plan_type.as_deref().unwrap_or("Personal") + } + }) +} + +/// 原子加密写入 auth.v1.dat 到指定 App 数据目录 +pub fn write_qoder_app_auth(data_dir: &Path, auth_json: &Value) -> Result<(), String> { + if !data_dir.exists() { + fs::create_dir_all(data_dir) + .map_err(|e| format!("创建数据目录失败 {}: {}", data_dir.display(), e))?; + } + + #[cfg(target_os = "windows")] + { + let local_state = data_dir.join("Local State"); + if !local_state.exists() { + return Err(format!( + "数据目录缺失 Local State: {}。请先运行一次该程序使其生成加密密钥后重试。", + data_dir.display() + )); + } + + let master_key = crate::modules::vscode_inject::get_windows_encryption_key(Some(data_dir)) + .map_err(|e| format!("解密 Local State 失败: {}", e))?; + + let plain_text = serde_json::to_string(auth_json) + .map_err(|e| format!("序列化 auth JSON 失败: {}", e))?; + + let encrypted = crate::modules::vscode_inject::encrypt_windows_gcm_v10( + &master_key, + plain_text.as_bytes(), + )?; + + let auth_path = data_dir.join(AUTH_V1_FILENAME); + let ts = chrono::Utc::now().format("%Y%m%d_%H%M%S").to_string(); + + // 备份原凭据 + if auth_path.exists() { + let bak_path = data_dir.join(format!("{}.bak_{}", AUTH_V1_FILENAME, ts)); + if let Err(e) = fs::copy(&auth_path, &bak_path) { + logger::log_warn(&format!("[Qoder App] 备份原凭据失败: {}", e)); + } + } + + // tmp + atomic replace 写入 + let tmp_path = data_dir.join(format!("{}.tmp_{}", AUTH_V1_FILENAME, ts)); + fs::write(&tmp_path, &encrypted) + .map_err(|e| format!("写入临时凭据文件失败 {}: {}", tmp_path.display(), e))?; + + fs::rename(&tmp_path, &auth_path).map_err(|e| { + let _ = fs::remove_file(&tmp_path); + format!("原子替换凭据文件失败: {}", e) + })?; + + logger::log_info(&format!( + "[Qoder App Auth] 成功加密写入凭据: path={}, len={}", + auth_path.display(), + encrypted.len() + )); + + Ok(()) + } + + #[cfg(not(target_os = "windows"))] + { + Err("Qoder App 凭据写入仅支持 Windows 系统".to_string()) + } +} diff --git a/src-tauri/src/modules/qoder_channel.rs b/src-tauri/src/modules/qoder_channel.rs new file mode 100644 index 000000000..1eb85f35d --- /dev/null +++ b/src-tauri/src/modules/qoder_channel.rs @@ -0,0 +1,338 @@ +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum QoderChannel { + QoderIde, + QoderApp, + QoderCnIde, + QoderCnApp, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum QoderCredentialKind { + StateVscdb, // VS Code SQLite ItemTable: secret://aicoding.auth.* + AuthV1Dat, // Electron 裸二进制 v10 信封 AES-256-GCM +} + +impl QoderChannel { + pub const ALL: [Self; 4] = [ + Self::QoderIde, + Self::QoderApp, + Self::QoderCnIde, + Self::QoderCnApp, + ]; + + pub fn all() -> &'static [Self] { + &Self::ALL + } + + pub fn as_str(self) -> &'static str { + self.provider_key() + } + + pub fn parse(raw: Option<&str>) -> Result { + let normalized = raw + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("qoder") + .to_ascii_lowercase() + .replace('-', "_"); + + match normalized.as_str() { + "qoder" | "qoder_ide" => Ok(Self::QoderIde), + "qoder_app" => Ok(Self::QoderApp), + "qoder_cn_ide" => Ok(Self::QoderCnIde), + "qoder_cn_app" => Ok(Self::QoderCnApp), + other => Err(format!("不支持的 Qoder 渠道: {}", other)), + } + } + + pub fn provider_key(self) -> &'static str { + match self { + Self::QoderIde => "qoder", + Self::QoderApp => "qoder_app", + Self::QoderCnIde => "qoder_cn_ide", + Self::QoderCnApp => "qoder_cn_app", + } + } + + pub fn display_name(self) -> &'static str { + match self { + Self::QoderIde => "Qoder IDE", + Self::QoderApp => "Qoder", + Self::QoderCnIde => "Qoder CN IDE", + Self::QoderCnApp => "Qoder CN", + } + } + + pub fn is_cn(self) -> bool { + matches!(self, Self::QoderCnIde | Self::QoderCnApp) + } + + pub fn openapi_base_url(self) -> &'static str { + if self.is_cn() { + "https://openapi.qoder.com.cn" + } else { + "https://openapi.qoder.sh" + } + } + + pub fn credential_kind(self) -> QoderCredentialKind { + match self { + Self::QoderIde | Self::QoderCnIde => QoderCredentialKind::StateVscdb, + Self::QoderApp | Self::QoderCnApp => QoderCredentialKind::AuthV1Dat, + } + } + + pub fn accounts_index_filename(self) -> &'static str { + match self { + Self::QoderIde => "qoder_accounts.json", + Self::QoderApp => "qoder_app_accounts.json", + Self::QoderCnIde => "qoder_cn_ide_accounts.json", + Self::QoderCnApp => "qoder_cn_app_accounts.json", + } + } + + pub fn accounts_dir_name(self) -> &'static str { + match self { + Self::QoderIde => "qoder_accounts", + Self::QoderApp => "qoder_app_accounts", + Self::QoderCnIde => "qoder_cn_ide_accounts", + Self::QoderCnApp => "qoder_cn_app_accounts", + } + } + + pub fn instances_filename(self) -> &'static str { + match self { + Self::QoderIde => "qoder_instances.json", + Self::QoderApp => "qoder_app_instances.json", + Self::QoderCnIde => "qoder_cn_ide_instances.json", + Self::QoderCnApp => "qoder_cn_app_instances.json", + } + } + + /// 专属的数据根目录名称(相对 APPDATA / Application Support / .config) + pub fn app_support_dir_name(self) -> &'static str { + match self { + Self::QoderIde => "Qoder", + Self::QoderApp => "com.qoder.app.stable", + Self::QoderCnIde => "QoderCN", + Self::QoderCnApp => "com.qodercn.app.stable", + } + } + + /// 默认数据目录全路径 + pub fn default_user_data_dir(self) -> Result { + #[cfg(target_os = "windows")] + { + let appdata = std::env::var("APPDATA") + .map_err(|_| "无法获取 APPDATA 环境变量".to_string())?; + let base = PathBuf::from(appdata); + + if self == Self::QoderCnIde { + let primary = base.join("QoderCN"); + if primary.exists() { + return Ok(primary); + } + let alt = base.join("Qoder CN"); + if alt.exists() { + return Ok(alt); + } + return Ok(primary); + } + + return Ok(base.join(self.app_support_dir_name())); + } + + #[cfg(target_os = "macos")] + { + let home = dirs::home_dir().ok_or("无法获取用户主目录")?; + let support = home.join("Library/Application Support"); + return Ok(support.join(self.app_support_dir_name())); + } + + #[cfg(target_os = "linux")] + { + let home = dirs::home_dir().ok_or("无法获取用户主目录")?; + let config = home.join(".config"); + return Ok(config.join(self.app_support_dir_name())); + } + + #[allow(unreachable_code)] + Err("Qoder 仅支持 Windows、macOS 和 Linux".to_string()) + } + + /// 进程探测和查杀时匹配的二进制文件名白名单 + pub fn expected_exe_names(self) -> &'static [&'static str] { + match self { + Self::QoderIde => &["Qoder IDE.exe", "Qoder.exe"], + Self::QoderApp => &["Qoder.exe", "Qoder Launcher.exe"], + Self::QoderCnIde => &["Qoder CN IDE.exe", "Qoder.exe"], + Self::QoderCnApp => &["Qoder CN.exe", "Qoder Launcher.exe"], + } + } + + /// Windows 默认安装与执行文件候选列表 + pub fn default_exe_candidates(self) -> Vec { + let mut candidates = Vec::new(); + + #[cfg(target_os = "windows")] + { + let local_appdata = std::env::var("LOCALAPPDATA").ok().map(PathBuf::from); + let program_files = std::env::var("PROGRAMFILES").ok().map(PathBuf::from); + let program_files_x86 = std::env::var("PROGRAMFILES(X86)").ok().map(PathBuf::from); + let program_data = std::env::var("PROGRAMDATA").ok().map(PathBuf::from); + + match self { + Self::QoderIde => { + // 1. Qoder IDE 独立通道 + if let Some(ref pf) = program_files { + candidates.push(pf.join("Qoder IDE").join("Qoder IDE.exe")); + } + if let Some(ref la) = local_appdata { + candidates.push(la.join("Programs").join("Qoder IDE").join("Qoder IDE.exe")); + } + // 2. 原 Qoder (老通道) + if let Some(ref pf) = program_files { + candidates.push(pf.join("Qoder").join("Qoder.exe")); + } + if let Some(ref la) = local_appdata { + candidates.push(la.join("Programs").join("Qoder").join("Qoder.exe")); + } + if let Some(ref pfx86) = program_files_x86 { + candidates.push(pfx86.join("Qoder IDE").join("Qoder IDE.exe")); + } + } + Self::QoderApp => { + // 优先检查 install.ini 指向的主程序 + if let Some(ref pd) = program_data { + let ini_path = pd.join("Qoder").join("Qoder Launcher").join("install.ini"); + if let Some((dir, exe)) = parse_launcher_install_ini(&ini_path) { + candidates.push(PathBuf::from(dir).join(exe)); + } + } + if let Some(ref pf) = program_files { + candidates.push(pf.join("Qoder").join("Qoder").join("Qoder.exe")); + } + if let Some(ref pd) = program_data { + candidates.push(pd.join("Qoder").join("Qoder Launcher").join("Qoder Launcher.exe")); + } + } + Self::QoderCnIde => { + if let Some(ref pf) = program_files { + candidates.push(pf.join("Qoder CN IDE").join("Qoder CN IDE.exe")); + } + if let Some(ref la) = local_appdata { + candidates.push(la.join("Programs").join("Qoder CN IDE").join("Qoder CN IDE.exe")); + } + } + Self::QoderCnApp => { + if let Some(ref pd) = program_data { + let ini_file1 = pd.join("Qoder CN").join("Qoder CN Launcher").join("install.ini"); + if let Some((dir, exe)) = parse_launcher_install_ini(&ini_file1) { + candidates.push(PathBuf::from(dir).join(exe)); + } + let ini_file2 = pd.join("Qoder CN").join("install.ini"); + if let Some((dir, exe)) = parse_launcher_install_ini(&ini_file2) { + candidates.push(PathBuf::from(dir).join(exe)); + } + } + if let Some(ref pf) = program_files { + candidates.push(pf.join("Qoder CN").join("Qoder CN").join("Qoder CN.exe")); + candidates.push(pf.join("Qoder CN").join("Qoder CN.exe")); + } + if let Some(ref pd) = program_data { + candidates.push(pd.join("Qoder CN").join("Qoder CN Launcher").join("Qoder Launcher.exe")); + candidates.push(pd.join("Qoder CN").join("Qoder Launcher.exe")); + } + } + } + } + + #[cfg(target_os = "macos")] + { + match self { + Self::QoderIde => { + candidates.push(PathBuf::from("/Applications/Qoder.app/Contents/MacOS/Qoder")); + candidates.push(PathBuf::from("/Applications/Qoder IDE.app/Contents/MacOS/Qoder IDE")); + } + Self::QoderCnIde => { + candidates.push(PathBuf::from("/Applications/Qoder CN IDE.app/Contents/MacOS/Qoder CN IDE")); + } + _ => {} + } + } + + #[cfg(target_os = "linux")] + { + match self { + Self::QoderIde => { + candidates.push(PathBuf::from("/usr/bin/qoder")); + candidates.push(PathBuf::from("/usr/local/bin/qoder")); + candidates.push(PathBuf::from("/opt/qoder/qoder")); + } + _ => {} + } + } + + candidates + } + + /// 探测当前渠道已安装的执行文件路径 + pub fn detect_installed_exe(self) -> Option { + for candidate in self.default_exe_candidates() { + if candidate.exists() { + return Some(candidate); + } + } + None + } +} + +/// 解析 Qoder Launcher 的 install.ini (支持 UTF-16 与 UTF-8),提取 installDir 与 appExecutable +#[cfg(target_os = "windows")] +fn parse_launcher_install_ini(path: &Path) -> Option<(String, String)> { + if !path.exists() { + return None; + } + let bytes = std::fs::read(path).ok()?; + let text = if bytes.len() >= 2 && (bytes[0] == 0xFF && bytes[1] == 0xFE) { + let u16s: Vec = bytes[2..] + .chunks_exact(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect(); + String::from_utf16_lossy(&u16s) + } else if bytes.len() >= 2 && bytes[1] == 0x00 { + let u16s: Vec = bytes + .chunks_exact(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect(); + String::from_utf16_lossy(&u16s) + } else { + String::from_utf8_lossy(&bytes).to_string() + }; + + let mut install_dir = None; + let mut app_exe = None; + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') || trimmed.is_empty() { + continue; + } + if let Some((k, v)) = trimmed.split_once('=') { + let k = k.trim(); + let v = v.trim(); + if k.eq_ignore_ascii_case("installDir") { + install_dir = Some(v.to_string()); + } else if k.eq_ignore_ascii_case("appExecutable") { + app_exe = Some(v.to_string()); + } + } + } + + match (install_dir, app_exe) { + (Some(d), Some(e)) if !d.is_empty() && !e.is_empty() => Some((d, e)), + _ => None, + } +} diff --git a/src-tauri/src/modules/qoder_instance.rs b/src-tauri/src/modules/qoder_instance.rs index e9e0b6ed4..56dcb8d2a 100644 --- a/src-tauri/src/modules/qoder_instance.rs +++ b/src-tauri/src/modules/qoder_instance.rs @@ -9,31 +9,46 @@ use crate::models::{DefaultInstanceSettings, InstanceProfile, InstanceStore}; use crate::modules; use crate::modules::instance::InstanceDefaults; use crate::modules::instance_store; +use crate::modules::qoder_channel::QoderChannel; pub use crate::modules::instance_store::{CreateInstanceParams, UpdateInstanceParams}; static QODER_INSTANCE_STORE_LOCK: std::sync::LazyLock> = std::sync::LazyLock::new(|| Mutex::new(())); -const QODER_INSTANCES_FILE: &str = "qoder_instances.json"; +fn instances_path_for_channel(channel: QoderChannel) -> Result { + let data_dir = modules::account::get_data_dir()?; + Ok(data_dir.join(channel.instances_filename())) +} fn instances_path() -> Result { - let data_dir = modules::account::get_data_dir()?; - Ok(data_dir.join(QODER_INSTANCES_FILE)) + instances_path_for_channel(QoderChannel::QoderIde) } pub fn load_instance_store() -> Result { - let path = instances_path()?; - instance_store::load_instance_store(&path, QODER_INSTANCES_FILE) + load_instance_store_for_channel(QoderChannel::QoderIde) +} + +pub fn load_instance_store_for_channel(channel: QoderChannel) -> Result { + let path = instances_path_for_channel(channel)?; + instance_store::load_instance_store(&path, channel.instances_filename()) } pub fn save_instance_store(store: &InstanceStore) -> Result<(), String> { - let path = instances_path()?; - instance_store::save_instance_store(&path, QODER_INSTANCES_FILE, store) + save_instance_store_for_channel(QoderChannel::QoderIde, store) +} + +pub fn save_instance_store_for_channel(channel: QoderChannel, store: &InstanceStore) -> Result<(), String> { + let path = instances_path_for_channel(channel)?; + instance_store::save_instance_store(&path, channel.instances_filename(), store) } pub fn load_default_settings() -> Result { - let store = load_instance_store()?; + load_default_settings_for_channel(QoderChannel::QoderIde) +} + +pub fn load_default_settings_for_channel(channel: QoderChannel) -> Result { + let store = load_instance_store_for_channel(channel)?; Ok(store.default_settings) } @@ -41,11 +56,25 @@ pub fn update_default_settings( bind_account_id: Option>, extra_args: Option, follow_local_account: Option, +) -> Result { + update_default_settings_for_channel( + QoderChannel::QoderIde, + bind_account_id, + extra_args, + follow_local_account, + ) +} + +pub fn update_default_settings_for_channel( + channel: QoderChannel, + bind_account_id: Option>, + extra_args: Option, + follow_local_account: Option, ) -> Result { let _lock = QODER_INSTANCE_STORE_LOCK .lock() .map_err(|_| "无法获取实例锁")?; - let mut store = load_instance_store()?; + let mut store = load_instance_store_for_channel(channel)?; let settings = &mut store.default_settings; // Qoder 实例不支持“跟随当前账号”,直接忽略 follow_local_account。 @@ -63,52 +92,49 @@ pub fn update_default_settings( } let updated = settings.clone(); - save_instance_store(&store)?; + save_instance_store_for_channel(channel, &store)?; Ok(updated) } pub fn get_default_qoder_user_data_dir() -> Result { - #[cfg(target_os = "macos")] - { - let home = dirs::home_dir().ok_or("无法获取用户主目录")?; - return Ok(home.join("Library/Application Support/Qoder")); - } - - #[cfg(target_os = "windows")] - { - let appdata = - std::env::var("APPDATA").map_err(|_| "无法获取 APPDATA 环境变量".to_string())?; - return Ok(PathBuf::from(appdata).join("Qoder")); - } - - #[cfg(target_os = "linux")] - { - let home = dirs::home_dir().ok_or("无法获取用户主目录")?; - return Ok(home.join(".config/Qoder")); - } + get_default_qoder_user_data_dir_for_channel(QoderChannel::QoderIde) +} - #[allow(unreachable_code)] - Err("Qoder 应用多开仅支持 macOS、Windows 和 Linux".to_string()) +pub fn get_default_qoder_user_data_dir_for_channel(channel: QoderChannel) -> Result { + channel.default_user_data_dir() } pub fn get_default_instances_root_dir() -> Result { + get_default_instances_root_dir_for_channel(QoderChannel::QoderIde) +} + +pub fn get_default_instances_root_dir_for_channel(channel: QoderChannel) -> Result { #[cfg(target_os = "macos")] { let home = dirs::home_dir().ok_or("无法获取用户主目录")?; - return Ok(home.join(".antigravity_cockpit/instances/qoder")); + return Ok(home + .join(".antigravity_cockpit") + .join("instances") + .join(channel.provider_key())); } #[cfg(target_os = "windows")] { let appdata = std::env::var("APPDATA").map_err(|_| "无法获取 APPDATA 环境变量".to_string())?; - return Ok(PathBuf::from(appdata).join(".antigravity_cockpit\\instances\\qoder")); + return Ok(PathBuf::from(appdata) + .join(".antigravity_cockpit") + .join("instances") + .join(channel.provider_key())); } #[cfg(target_os = "linux")] { let home = dirs::home_dir().ok_or("无法获取用户主目录")?; - return Ok(home.join(".antigravity_cockpit/instances/qoder")); + return Ok(home + .join(".antigravity_cockpit") + .join("instances") + .join(channel.provider_key())); } #[allow(unreachable_code)] @@ -116,8 +142,12 @@ pub fn get_default_instances_root_dir() -> Result { } pub fn get_instance_defaults() -> Result { - let root_dir = get_default_instances_root_dir()?; - let default_user_data_dir = get_default_qoder_user_data_dir()?; + get_instance_defaults_for_channel(QoderChannel::QoderIde) +} + +pub fn get_instance_defaults_for_channel(channel: QoderChannel) -> Result { + let root_dir = get_default_instances_root_dir_for_channel(channel)?; + let default_user_data_dir = get_default_qoder_user_data_dir_for_channel(channel)?; Ok(InstanceDefaults { root_dir: root_dir.to_string_lossy().to_string(), default_user_data_dir: default_user_data_dir.to_string_lossy().to_string(), @@ -125,10 +155,17 @@ pub fn get_instance_defaults() -> Result { } pub fn create_instance(params: CreateInstanceParams) -> Result { + create_instance_for_channel(QoderChannel::QoderIde, params) +} + +pub fn create_instance_for_channel( + channel: QoderChannel, + params: CreateInstanceParams, +) -> Result { let _lock = QODER_INSTANCE_STORE_LOCK .lock() .map_err(|_| "无法获取实例锁")?; - let mut store = load_instance_store()?; + let mut store = load_instance_store_for_channel(channel)?; let name = instance_store::normalize_name(¶ms.name)?; let user_data_dir = params.user_data_dir.trim().to_string(); @@ -171,7 +208,7 @@ pub fn create_instance(params: CreateInstanceParams) -> Result get_default_qoder_user_data_dir()?, + Some("__default__") | None => get_default_qoder_user_data_dir_for_channel(channel)?, Some(source_id) => { let source_instance = store .instances @@ -192,7 +229,8 @@ pub fn create_instance(params: CreateInstanceParams) -> Result Result Result { + update_instance_for_channel(QoderChannel::QoderIde, params) +} + +pub fn update_instance_for_channel( + channel: QoderChannel, + params: UpdateInstanceParams, +) -> Result { let _lock = QODER_INSTANCE_STORE_LOCK .lock() .map_err(|_| "无法获取实例锁")?; - let mut store = load_instance_store()?; + let mut store = load_instance_store_for_channel(channel)?; let index = store .instances .iter() @@ -265,15 +310,19 @@ pub fn update_instance(params: UpdateInstanceParams) -> Result Result<(), String> { + delete_instance_for_channel(QoderChannel::QoderIde, instance_id) +} + +pub fn delete_instance_for_channel(channel: QoderChannel, instance_id: &str) -> Result<(), String> { let _lock = QODER_INSTANCE_STORE_LOCK .lock() .map_err(|_| "无法获取实例锁")?; - let mut store = load_instance_store()?; + let mut store = load_instance_store_for_channel(channel)?; let index = store .instances .iter() @@ -287,15 +336,23 @@ pub fn delete_instance(instance_id: &str) -> Result<(), String> { } store.instances.remove(index); - save_instance_store(&store)?; + save_instance_store_for_channel(channel, &store)?; Ok(()) } pub fn update_instance_after_start(instance_id: &str, pid: u32) -> Result { + update_instance_after_start_for_channel(QoderChannel::QoderIde, instance_id, pid) +} + +pub fn update_instance_after_start_for_channel( + channel: QoderChannel, + instance_id: &str, + pid: u32, +) -> Result { let _lock = QODER_INSTANCE_STORE_LOCK .lock() .map_err(|_| "无法获取实例锁")?; - let mut store = load_instance_store()?; + let mut store = load_instance_store_for_channel(channel)?; let mut updated = None; for instance in &mut store.instances { if instance.id == instance_id { @@ -306,15 +363,23 @@ pub fn update_instance_after_start(instance_id: &str, pid: u32) -> Result) -> Result { + update_instance_pid_for_channel(QoderChannel::QoderIde, instance_id, pid) +} + +pub fn update_instance_pid_for_channel( + channel: QoderChannel, + instance_id: &str, + pid: Option, +) -> Result { let _lock = QODER_INSTANCE_STORE_LOCK .lock() .map_err(|_| "无法获取实例锁")?; - let mut store = load_instance_store()?; + let mut store = load_instance_store_for_channel(channel)?; let mut updated = None; for instance in &mut store.instances { if instance.id == instance_id { @@ -324,30 +389,41 @@ pub fn update_instance_pid(instance_id: &str, pid: Option) -> Result) -> Result { + update_default_pid_for_channel(QoderChannel::QoderIde, pid) +} + +pub fn update_default_pid_for_channel( + channel: QoderChannel, + pid: Option, +) -> Result { let _lock = QODER_INSTANCE_STORE_LOCK .lock() .map_err(|_| "无法获取实例锁")?; - let mut store = load_instance_store()?; + let mut store = load_instance_store_for_channel(channel)?; store.default_settings.last_pid = pid; let updated = store.default_settings.clone(); - save_instance_store(&store)?; + save_instance_store_for_channel(channel, &store)?; Ok(updated) } pub fn clear_all_pids() -> Result<(), String> { + clear_all_pids_for_channel(QoderChannel::QoderIde) +} + +pub fn clear_all_pids_for_channel(channel: QoderChannel) -> Result<(), String> { let _lock = QODER_INSTANCE_STORE_LOCK .lock() .map_err(|_| "无法获取实例锁")?; - let mut store = load_instance_store()?; + let mut store = load_instance_store_for_channel(channel)?; store.default_settings.last_pid = None; for instance in &mut store.instances { instance.last_pid = None; } - save_instance_store(&store)?; + save_instance_store_for_channel(channel, &store)?; Ok(()) } diff --git a/src-tauri/src/modules/qoder_oauth.rs b/src-tauri/src/modules/qoder_oauth.rs index 9c0df9a12..dd8373a2c 100644 --- a/src-tauri/src/modules/qoder_oauth.rs +++ b/src-tauri/src/modules/qoder_oauth.rs @@ -12,15 +12,19 @@ use url::Url; use uuid::Uuid; use crate::models::qoder::{QoderAccount, QoderOAuthStartResponse}; +use crate::modules::qoder_channel::QoderChannel; use crate::modules::{config, logger, qoder_account, qoder_instance}; const OAUTH_TIMEOUT_SECONDS: i64 = 600; const OAUTH_POLL_INTERVAL_MS: u64 = 1000; const DEFAULT_LOGIN_BASE_URL: &str = "https://qoder.com/device/selectAccounts"; -const DEFAULT_OPENAPI_BASE_URL: &str = "https://openapi.qoder.sh"; +pub const DEFAULT_GLOBAL_OPENAPI_BASE_URL: &str = "https://openapi.qoder.sh"; +pub const DEFAULT_CN_OPENAPI_BASE_URL: &str = "https://openapi.qoder.com.cn"; +pub const DEFAULT_OPENAPI_BASE_URL: &str = DEFAULT_GLOBAL_OPENAPI_BASE_URL; const QODER_IDE_REDIRECT_URI: &str = "qoder://aicoding.aicoding-agent/login-success"; const QODER_DEVICE_LOGIN_CHALLENGE_METHOD: &str = "S256"; const DEVICE_TOKEN_POLL_PATH: &str = "/api/v1/deviceToken/poll"; +const DEVICE_TOKEN_REFRESH_PATH: &str = "/api/v1/deviceToken/refresh"; const USER_INFO_PATH: &str = "/api/v1/userinfo"; const USER_STATUS_PATH: &str = "/api/v3/user/status"; const DATA_POLICY_PATH: &str = "/api/v2/config/getDataPolicy"; @@ -740,52 +744,242 @@ fn extract_access_token_from_account(account: &QoderAccount) -> Option { None } -fn ensure_refresh_identity_consistent( - target: &QoderAccount, - user_status: &Value, -) -> Result<(), String> { - let target_user_id = target - .user_id - .as_deref() - .and_then(|value| normalize_non_empty(Some(value))); - let status_user_id = user_status - .get("id") - .and_then(|value| value.as_str()) - .and_then(|value| normalize_non_empty(Some(value))); +fn extract_refresh_token_from_account(account: &QoderAccount) -> Option { + let user_info = account.auth_user_info_raw.as_ref()?; + let candidate_paths: &[&[&str]] = &[ + &["refreshToken"], + &["refresh_token"], + &["securityRefreshToken"], + &["data", "refreshToken"], + &["data", "refresh_token"], + &["result", "refreshToken"], + &["result", "refresh_token"], + ]; + for path in candidate_paths { + if let Some(value) = get_string_at_path(user_info, path) { + return Some(value); + } + } + None +} + +fn is_unauthorized_error(err: &str) -> bool { + err.contains("401") + || err.contains("403") + || err.contains("Unauthorized") + || err.contains("UNAUTHORIZED") +} + +#[derive(Debug, Clone)] +struct RefreshedTokenInfo { + access_token: String, + refresh_token: String, + expires_at: Option, + refresh_token_expires_at: Option, +} + +async fn request_device_token_refresh( + client: &reqwest::Client, + openapi_base_url: &str, + refresh_token: &str, + access_token: Option<&str>, + machine_info: Option<&QoderMachineInfo>, +) -> Result { + use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE}; - if let (Some(target_uid), Some(status_uid)) = (target_user_id.as_ref(), status_user_id.as_ref()) + let mut headers = HeaderMap::new(); + if let Ok(value) = HeaderValue::from_str("application/json") { + headers.insert(ACCEPT, value.clone()); + headers.insert(CONTENT_TYPE, value); + } + if let Some(tok) = access_token { + if let Ok(value) = HeaderValue::from_str(&format!("Bearer {}", tok)) { + headers.insert(AUTHORIZATION, value); + } + } + if let Some(version) = machine_info + .and_then(|v| v.cosy_version.clone()) + .or_else(detect_qoder_product_version) { - if !target_uid.eq_ignore_ascii_case(status_uid) { - return Err(format!( - "官方接口返回账号与目标账号不一致: target_user_id={}, actual_user_id={}", - target_uid, status_uid - )); + if let Ok(value) = HeaderValue::from_str(&version) { + headers.insert("Cosy-Version", value); } - } else { - let target_email = - normalize_non_empty(Some(target.email.as_str())).map(|value| value.to_lowercase()); - let status_email = user_status - .get("email") - .and_then(|value| value.as_str()) - .and_then(|value| normalize_non_empty(Some(value))) - .map(|value| value.to_lowercase()); - if let (Some(left), Some(right)) = (target_email.as_ref(), status_email.as_ref()) { - if left != right { - return Err(format!( - "官方接口返回账号与目标账号不一致: target_email={}, actual_email={}", - left, right - )); - } + } + if let Some(machine_token) = machine_info + .map(|v| v.token.as_str()) + .and_then(|v| normalize_non_empty(Some(v))) + { + if let Ok(value) = HeaderValue::from_str(&machine_token) { + headers.insert("Cosy-MachineToken", value); } } - Ok(()) + let body = serde_json::json!({ + "refresh_token": refresh_token, + "refreshToken": refresh_token, + }); + + let url = format!("{}{}", openapi_base_url, DEVICE_TOKEN_REFRESH_PATH); + let resp = client + .post(&url) + .headers(headers) + .json(&body) + .send() + .await + .map_err(|e| format!("请求 Token 刷新接口网络失败 ({}): {}", url, e))?; + + let status = resp.status(); + if !status.is_success() { + let body_text = resp.text().await.unwrap_or_default(); + let is_unauthorized = status == reqwest::StatusCode::UNAUTHORIZED + || (status == reqwest::StatusCode::BAD_REQUEST + && (body_text.contains("invalid refresh_token") + || body_text.contains("invalid_grant") + || body_text.contains("User not authenticated"))); + if is_unauthorized { + return Err("登录凭据已完全失效(阿里专网会话超时),请在 Qoder 中重新登录".to_string()); + } + return Err(format!( + "Token 刷新失败 (status={}, body={})", + status, body_text + )); + } + + let payload: Value = resp + .json() + .await + .map_err(|e| format!("解析 Token 刷新响应失败: {}", e))?; + + let candidate_tokens: &[&[&str]] = &[ + &["token"], + &["device_token"], + &["accessToken"], + &["access_token"], + &["data", "token"], + &["data", "device_token"], + &["data", "accessToken"], + &["result", "token"], + ]; + let candidate_refresh: &[&[&str]] = &[ + &["refresh_token"], + &["refreshToken"], + &["data", "refresh_token"], + &["data", "refreshToken"], + &["result", "refresh_token"], + ]; + + let new_token = candidate_tokens + .iter() + .find_map(|path| get_string_at_path(&payload, path)) + .ok_or_else(|| "Token 刷新响应未包含有效 access token".to_string())?; + + let new_refresh_token = candidate_refresh + .iter() + .find_map(|path| get_string_at_path(&payload, path)) + .unwrap_or_else(|| refresh_token.to_string()); + + let expires_at = get_string_at_path(&payload, &["expires_at"]) + .or_else(|| get_string_at_path(&payload, &["data", "expires_at"])) + .and_then(|v| parse_expire_timestamp_ms(Some(&v))); + + let refresh_token_expires_at = get_string_at_path(&payload, &["refresh_token_expires_at"]) + .or_else(|| get_string_at_path(&payload, &["data", "refresh_token_expires_at"])) + .and_then(|v| parse_expire_timestamp_ms(Some(&v))); + + Ok(RefreshedTokenInfo { + access_token: new_token, + refresh_token: new_refresh_token, + expires_at, + refresh_token_expires_at, + }) +} + +fn update_account_token_info( + account: &mut QoderAccount, + refreshed_info: &RefreshedTokenInfo, +) { + let mut user_info = account + .auth_user_info_raw + .clone() + .unwrap_or_else(|| Value::Object(serde_json::Map::new())); + if !user_info.is_object() { + user_info = Value::Object(serde_json::Map::new()); + } + if let Some(map) = user_info.as_object_mut() { + map.insert("token".to_string(), Value::String(refreshed_info.access_token.clone())); + map.insert("refreshToken".to_string(), Value::String(refreshed_info.refresh_token.clone())); + map.insert("refresh_token".to_string(), Value::String(refreshed_info.refresh_token.clone())); + if let Some(expires_at) = refreshed_info.expires_at.as_ref() { + map.insert("expireTime".to_string(), Value::String(expires_at.clone())); + map.insert("expiresAt".to_string(), Value::String(expires_at.clone())); + } + if let Some(rt_expires_at) = refreshed_info.refresh_token_expires_at.as_ref() { + map.insert("refreshTokenExpireTime".to_string(), Value::String(rt_expires_at.clone())); + map.insert("refreshTokenExpiresAt".to_string(), Value::String(rt_expires_at.clone())); + } + } + account.auth_user_info_raw = Some(user_info); +} + +pub fn is_cn_account(target: &QoderAccount, channel: Option) -> bool { + // 1. 若显式指定为国内渠道,毫无疑问是国内 + if let Some(ch) = channel { + if ch.is_cn() { + return true; + } + } + + // 2. 特别注意:阿里巴巴集团员工的 SAML 企业组织账号(如 @qoder.alibaba-inc.com、@qoderwork.alibaba-inc.com) + // 走的是 Qoder Global 全球统一服务体系(由新加坡节点 openapi.qoder.sh 服务,source=sso.saml.organization), + // 绝非国内阿里云公有云,绝不能误判为 CN! + let email = target.email.to_lowercase(); + if email.contains("alibaba-inc.com") { + return false; + } + + // 3. 账号特征审查:国内版灵码 / 阿里云专属域名与特征 + if email.contains("qoder.cn") + || email.ends_with(".cn") + || email.contains("aliyun") + || email.contains("alipay") + || email.contains("taobao") + { + return true; + } + + if let Some(ref uid) = target.user_id { + let u = uid.to_lowercase(); + if u.starts_with("aliyun-") { + return true; + } + } + + if let Some(ref name) = target.display_name { + let n = name.to_lowercase(); + if n.starts_with("aliyun-") { + return true; + } + } + + false +} + +fn get_candidate_openapi_base_urls( + target: &QoderAccount, + channel: Option, +) -> Vec<&'static str> { + if is_cn_account(target, channel) { + vec![DEFAULT_CN_OPENAPI_BASE_URL, DEFAULT_GLOBAL_OPENAPI_BASE_URL] + } else { + vec![DEFAULT_GLOBAL_OPENAPI_BASE_URL, DEFAULT_CN_OPENAPI_BASE_URL] + } } fn build_refresh_user_info_raw( target: &QoderAccount, access_token: &str, - user_status: &Value, + user_info_fetched: Option<&Value>, + user_status: Option<&Value>, data_policy: Option<&Value>, ) -> Value { let mut user_info = target @@ -825,27 +1019,34 @@ fn build_refresh_user_info_raw( map.insert("name".to_string(), Value::String(display_name)); } } + if let Some(fetched) = user_info_fetched { + copy_optional_field(fetched, map, "name", "name"); + copy_optional_field(fetched, map, "email", "email"); + copy_optional_field(fetched, map, "avatarUrl", "avatarUrl"); + copy_optional_field(fetched, map, "avatar_url", "avatarUrl"); + copy_optional_field(fetched, map, "userTag", "userTag"); + copy_optional_field(fetched, map, "user_tag", "userTag"); + } } - merge_user_status_into_user_info(&mut user_info, user_status, data_policy); + if let Some(status) = user_status { + merge_user_status_into_user_info(&mut user_info, status, data_policy); + } user_info } -async fn fetch_qoder_user_status_bundle_with_machine_info( - client: &reqwest::Client, - openapi_base_url: &str, - token: &str, - machine_info: Option<&QoderMachineInfo>, -) -> Result<(Value, Option), String> { - fetch_qoder_user_status_bundle(client, openapi_base_url, token, machine_info).await -} - -async fn refresh_account_from_openapi_once(account_id: &str) -> Result { - let target = qoder_account::load_account(account_id) - .ok_or_else(|| format!("Qoder 账号不存在: {}", account_id))?; - - let access_token = extract_access_token_from_account(&target) - .ok_or_else(|| "Qoder 账号缺少 access token,请重新登录后再刷新".to_string())?; +pub async fn refresh_account_from_openapi_for_channel( + channel: Option, + account_id: &str, +) -> Result { + let (ch, mut target) = if let Some(ch) = channel { + let acc = qoder_account::load_account_for_channel(ch, account_id) + .ok_or_else(|| format!("{} 账号不存在: {}", ch.display_name(), account_id))?; + (ch, acc) + } else { + qoder_account::find_account_channel(account_id) + .ok_or_else(|| format!("Qoder 账号不存在: {}", account_id))? + }; let client = build_reqwest_client()?; let machine_info = match read_qoder_machine_info_cache() { @@ -859,109 +1060,169 @@ async fn refresh_account_from_openapi_once(account_id: &str) -> Result Some(value), - Err(err) => { - logger::log_warn(&format!( - "[Qoder Refresh] 获取 /api/v2/user/plan 失败,将沿用本地缓存: {}", - err - )); - target.auth_user_plan_raw.clone() + let candidate_base_urls = get_candidate_openapi_base_urls(&target, Some(ch)); + let mut last_error = String::new(); + + for base_url in candidate_base_urls { + let mut active_token = current_access_token.clone(); + + // 1. 若当前没有 access_token 但有 refresh_token,直接在当前节点先刷新 + if active_token.is_none() { + if let Some(ref rt) = refresh_token { + match request_device_token_refresh(&client, base_url, rt, current_access_token.as_deref(), machine_info.as_ref()).await { + Ok(info) => { + update_account_token_info(&mut target, &info); + let _ = qoder_account::save_account_file_for_channel(ch, &target); + active_token = Some(info.access_token.clone()); + current_access_token = Some(info.access_token); + } + Err(err) => { + last_error = format!("{} 换新 Token 失败: {}", base_url, err); + continue; + } + } + } } - }; - let mut quota_query_error: Option = None; - let credit_usage_raw = match fetch_qoder_credit_usage( - &client, - DEFAULT_OPENAPI_BASE_URL, - &access_token, - machine_info.as_ref(), - ) - .await - { - Ok(value) => Some(value), - Err(err) => { - logger::log_warn(&format!( - "[Qoder Refresh] 获取 /api/v2/quota/usage 失败,将沿用本地缓存: {}", - err - )); - quota_query_error = Some(err.clone()); - target.auth_credit_usage_raw.clone() - } - }; + let Some(ref tok) = active_token else { + continue; + }; - let refreshed = qoder_account::upsert_account_from_snapshot( - user_info_raw, - user_plan_raw, - credit_usage_raw, - )?; - let refreshed = if refreshed.id == target.id { - qoder_account::update_quota_query_error(&refreshed.id, quota_query_error)? - .unwrap_or(refreshed) + // 2. 尝试请求额度配额 /api/v2/quota/usage + let credit_res = fetch_qoder_credit_usage(&client, base_url, tok, machine_info.as_ref()).await; + let final_token = match credit_res { + Ok(credit_val) => (tok.clone(), Some(credit_val)), + Err(ref err) if is_unauthorized_error(err) => { + // 401 Unauthorized!立即使用 refresh_token 换新! + if let Some(ref rt) = refresh_token { + logger::log_info(&format!( + "[Qoder Refresh] 请求配额遇到 401,尝试使用 refresh_token 在 {} 自动换新...", + base_url + )); + match request_device_token_refresh(&client, base_url, rt, Some(tok), machine_info.as_ref()).await { + Ok(info) => { + update_account_token_info(&mut target, &info); + let _ = qoder_account::save_account_file_for_channel(ch, &target); + let new_tok = info.access_token; + current_access_token = Some(new_tok.clone()); + // 用新 token 重试请求配额 + match fetch_qoder_credit_usage(&client, base_url, &new_tok, machine_info.as_ref()).await { + Ok(new_credit) => (new_tok, Some(new_credit)), + Err(retry_err) => { + last_error = format!("换新 Token 后请求配额仍失败: {}", retry_err); + continue; + } + } + } + Err(ref_err) => { + last_error = format!("{} 换新 Token 失败: {}", base_url, ref_err); + continue; + } + } + } else { + last_error = format!("{} 凭据已失效且未找到 refresh_token,请重新登录", base_url); + continue; + } + } + Err(other_err) => { + last_error = format!("{} 请求配额失败: {}", base_url, other_err); + continue; + } + }; + + let (access_token_ok, credit_usage_raw) = final_token; + + // 3. 请求用户套餐 user/plan + let user_plan_raw = match fetch_qoder_user_plan(&client, base_url, &access_token_ok, machine_info.as_ref()).await { + Ok(val) => Some(val), + Err(err) => { + logger::log_warn(&format!("[Qoder Refresh] 获取 /api/v2/user/plan 失败,沿用本地缓存: {}", err)); + target.auth_user_plan_raw.clone() + } + }; + + // 4. 请求用户信息 userinfo + let user_info_fetched = fetch_qoder_user_info(&client, base_url, &access_token_ok, machine_info.as_ref()).await.ok(); + + // 5. 请求状态探针 user/status(非强制!若失败完全不阻断) + let (user_status, data_policy) = match fetch_qoder_user_status_bundle(&client, base_url, &access_token_ok, machine_info.as_ref()).await { + Ok(bundle) => (Some(bundle.0), bundle.1), + Err(err) => { + logger::log_warn(&format!("[Qoder Refresh] 获取 /api/v3/user/status 状态探针失败(已降级容错): {}", err)); + (None, None) + } + }; + + let user_info_raw = build_refresh_user_info_raw( + &target, + &access_token_ok, + user_info_fetched.as_ref(), + user_status.as_ref(), + data_policy.as_ref(), + ); + + let mut refreshed = qoder_account::merge_account_snapshot( + target.clone(), + Some(user_info_raw), + user_plan_raw, + credit_usage_raw, + ); + refreshed.quota_query_last_error = None; + refreshed.quota_query_last_error_at = None; + let saved = qoder_account::upsert_account_record_for_channel(ch, refreshed)?; + return Ok(saved); + } + + let final_err = if last_error.is_empty() { + "刷新 Qoder 额度失败,请检查网络或重新登录".to_string() } else { - refreshed + last_error }; - if refreshed.id != target.id { - return Err(format!( - "刷新结果账号不一致: target_id={}, actual_id={}", - target.id, refreshed.id - )); - } - Ok(refreshed) + let _ = qoder_account::update_quota_query_error_for_channel(ch, account_id, Some(final_err.clone())); + Err(final_err) } pub async fn refresh_account_from_openapi(account_id: &str) -> Result { - let result = refresh_account_from_openapi_once(account_id).await; - if let Err(err) = &result { - let _ = qoder_account::update_quota_query_error(account_id, Some(err.clone())); - } - result + refresh_account_from_openapi_for_channel(None, account_id).await } -pub async fn refresh_all_accounts_from_openapi() -> Result { - let accounts = qoder_account::list_accounts(); +pub async fn refresh_all_accounts_for_channel(channel: QoderChannel) -> Result { + let accounts = qoder_account::list_accounts_for_channel(channel); if accounts.is_empty() { return Ok(0); } - - let mut success_count: i32 = 0; + let mut success_count = 0; for account in accounts { - match refresh_account_from_openapi(&account.id).await { - Ok(_) => { - success_count += 1; - } + match refresh_account_from_openapi_for_channel(Some(channel), &account.id).await { + Ok(_) => success_count += 1, Err(err) => { logger::log_warn(&format!( - "[Qoder Refresh] 批量刷新失败: account_id={}, email={}, error={}", - account.id, account.email, err + "[Qoder Refresh] [{}] 批量刷新失败: account_id={}, email={}, error={}", + channel.as_str(), account.id, account.email, err )); } } } - Ok(success_count) } +pub async fn refresh_all_accounts_from_openapi() -> Result { + let mut total_success = 0; + for &ch in &QoderChannel::ALL { + if let Ok(count) = refresh_all_accounts_for_channel(ch).await { + total_success += count; + } + } + Ok(total_success) +} + fn clear_pending_if_matches(login_id: &str) { if let Ok(mut guard) = PENDING_OAUTH_STATE.lock() { if guard.as_ref().map(|state| state.login_id.as_str()) == Some(login_id) { diff --git a/src-tauri/src/modules/tray.rs b/src-tauri/src/modules/tray.rs index 0d6ca315a..dde49cbcb 100644 --- a/src-tauri/src/modules/tray.rs +++ b/src-tauri/src/modules/tray.rs @@ -1720,7 +1720,7 @@ fn build_qoder_display_info(lang: &str) -> AccountDisplayInfo { json_nested(&account.auth_credit_usage_raw, &["tier_name"]), json_nested(&account.auth_credit_usage_raw, &["tierName"]), json_nested(&account.auth_credit_usage_raw, &["planTierName"]), - account.plan_type.as_deref().map(|s| s.to_string()), + account.plan_type.clone(), ]); if let Some(ref tag) = plan_tag { quota_lines.push(format!("Plan: {}", tag)); @@ -1752,49 +1752,49 @@ fn build_qoder_display_info(lang: &str) -> AccountDisplayInfo { &user_quota, )); - // Parse addOnQuota + // Parse addOnQuota (including enterprise orgResourcePackage) let addon_quota = parse_qoder_quota_bucket( &[ json_nested_obj(&account.auth_credit_usage_raw, &["addOnQuota"]), json_nested_obj(&account.auth_credit_usage_raw, &["addonQuota"]), json_nested_obj(&account.auth_credit_usage_raw, &["add_on_quota"]), + json_nested_obj(&account.auth_credit_usage_raw, &["orgResourcePackage"]), + json_nested_obj(&account.auth_credit_usage_raw, &["organizationResourcePackage"]), + json_nested_obj(&account.auth_credit_usage_raw, &["sharedCreditPackage"]), + json_nested_obj(&account.auth_credit_usage_raw, &["resourcePackage"]), json_nested_obj(&account.auth_user_plan_raw, &["addOnQuota"]), json_nested_obj(&account.auth_user_plan_raw, &["addonQuota"]), json_nested_obj(&account.auth_user_plan_raw, &["add_on_quota"]), + json_nested_obj(&account.auth_user_plan_raw, &["orgResourcePackage"]), + json_nested_obj(&account.auth_user_plan_raw, &["organizationResourcePackage"]), ], None, ); - let addon_label = if lang == "zh" || lang == "zh-CN" { - "附加 Credits" - } else { - "Add-on Credits" - }; - quota_lines.push(format_qoder_quota_line( - lang, - addon_label, - &None, - &addon_quota, - )); + let has_addon = addon_quota.total.unwrap_or(0.0) > 0.0 + || addon_quota.used.unwrap_or(0.0) > 0.0 + || addon_quota.percentage.is_some(); + + if has_addon { + let addon_label = if lang == "zh" || lang == "zh-CN" { + "附加 Credits" + } else { + "Add-on Credits" + }; + quota_lines.push(format_qoder_quota_line( + lang, + addon_label, + &None, + &addon_quota, + )); + } - // Parse shared credit package + // Parse independent shared credit package let shared_used = json_first_f64(&[ json_nested_f64( &account.auth_credit_usage_raw, &["orgResourcePackage", "used"], ), - json_nested_f64( - &account.auth_credit_usage_raw, - &["orgResourcePackage", "usage"], - ), - json_nested_f64( - &account.auth_credit_usage_raw, - &["orgResourcePackage", "consumed"], - ), - json_nested_f64( - &account.auth_credit_usage_raw, - &["orgResourcePackage", "count"], - ), json_nested_f64( &account.auth_credit_usage_raw, &["organizationResourcePackage", "used"], @@ -1806,15 +1806,20 @@ fn build_qoder_display_info(lang: &str) -> AccountDisplayInfo { json_nested_f64(&account.auth_credit_usage_raw, &["resourcePackage", "used"]), json_nested_f64(&account.auth_user_plan_raw, &["orgResourcePackage", "used"]), ]); - let shared_label = if lang == "zh" || lang == "zh-CN" { - "共享资源包" - } else { - "Shared Package" - }; - if let Some(used) = shared_used { - quota_lines.push(format!("{}: {:.0}", shared_label, used)); - } else { - quota_lines.push(format!("{}: --", shared_label)); + + let show_shared = shared_used.map_or(false, |u| { + u > 0.0 && (!has_addon || addon_quota.used != Some(u)) + }); + + if show_shared { + let shared_label = if lang == "zh" || lang == "zh-CN" { + "共享资源包" + } else { + "Shared Package" + }; + if let Some(used) = shared_used { + quota_lines.push(format!("{}: {:.0}", shared_label, used)); + } } let display_email = first_non_empty(&[ @@ -1943,6 +1948,7 @@ fn parse_qoder_quota_bucket( .and_then(|r| { json_first_f64(&[ r.get("total").and_then(json_as_f64), + r.get("cap").and_then(json_as_f64), r.get("quota").and_then(json_as_f64), r.get("limit").and_then(json_as_f64), ]) @@ -3896,3 +3902,27 @@ fn get_text(key: &str, lang: &str) -> String { _ => key.to_string(), } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_parse_qoder_enterprise_org_resource_package() { + let org_pkg = json!({ + "available": true, + "cap": 14000, + "percentage": 0, + "remaining": 14000, + "unit": "credits", + "used": 0 + }); + + let bucket = parse_qoder_quota_bucket(&[Some(org_pkg)], None); + + assert_eq!(bucket.used, Some(0.0)); + assert_eq!(bucket.total, Some(14000.0)); + assert_eq!(bucket.percentage, Some(0.0)); + } +} diff --git a/src-tauri/src/modules/tray_layout.rs b/src-tauri/src/modules/tray_layout.rs index fad4d095b..2c82a001e 100644 --- a/src-tauri/src/modules/tray_layout.rs +++ b/src-tauri/src/modules/tray_layout.rs @@ -20,6 +20,9 @@ pub const PLATFORM_GROK: &str = "grok"; pub const PLATFORM_CODEBUDDY: &str = "codebuddy"; pub const PLATFORM_CODEBUDDY_CN: &str = "codebuddy_cn"; pub const PLATFORM_QODER: &str = "qoder"; +pub const PLATFORM_QODER_APP: &str = "qoder_app"; +pub const PLATFORM_QODER_CN_IDE: &str = "qoder_cn_ide"; +pub const PLATFORM_QODER_CN_APP: &str = "qoder_cn_app"; pub const PLATFORM_ZCODE: &str = "zcode"; pub const PLATFORM_TRAE: &str = "trae"; pub const PLATFORM_TRAE_SOLO: &str = "trae_solo"; @@ -27,7 +30,7 @@ pub const PLATFORM_TRAE_CN: &str = "trae_cn"; pub const PLATFORM_TRAE_SOLO_CN: &str = "trae_solo_cn"; pub const PLATFORM_WORKBUDDY: &str = "workbuddy"; -pub const SUPPORTED_PLATFORM_IDS: [&str; 18] = [ +pub const SUPPORTED_PLATFORM_IDS: [&str; 21] = [ PLATFORM_CLAUDE_MANAGER, PLATFORM_CODEX, PLATFORM_ANTIGRAVITY, @@ -40,6 +43,9 @@ pub const SUPPORTED_PLATFORM_IDS: [&str; 18] = [ PLATFORM_CODEBUDDY, PLATFORM_CODEBUDDY_CN, PLATFORM_QODER, + PLATFORM_QODER_APP, + PLATFORM_QODER_CN_IDE, + PLATFORM_QODER_CN_APP, PLATFORM_ZCODE, PLATFORM_TRAE, PLATFORM_TRAE_SOLO, @@ -53,6 +59,7 @@ pub const SORT_MODE_MANUAL: &str = "manual"; const DEFAULT_CODEBUDDY_GROUP_ID: &str = "codebuddy-suite"; const DEFAULT_TRAE_GROUP_ID: &str = "trae-suite"; +const DEFAULT_QODER_GROUP_ID: &str = "qoder-suite"; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -363,6 +370,8 @@ fn normalize_platform_groups(groups: &[TrayLayoutGroup]) -> Vec } } + normalized.retain(|group| group.id != DEFAULT_QODER_GROUP_ID); + normalized } diff --git a/src-tauri/src/modules/vscode_inject.rs b/src-tauri/src/modules/vscode_inject.rs index 4dadd9a88..28f71a379 100644 --- a/src-tauri/src/modules/vscode_inject.rs +++ b/src-tauri/src/modules/vscode_inject.rs @@ -150,7 +150,7 @@ fn get_local_state_path(data_root: &Path) -> Result { } #[cfg(target_os = "windows")] -fn get_windows_encryption_key(data_root: Option<&Path>) -> Result, String> { +pub(crate) fn get_windows_encryption_key(data_root: Option<&Path>) -> Result, String> { let owned_root; let root = if let Some(path) = data_root { path @@ -225,7 +225,7 @@ fn dpapi_decrypt(encrypted: &[u8]) -> Result, String> { } #[cfg(target_os = "windows")] -fn decrypt_windows_gcm_v10(key: &[u8], encrypted: &[u8]) -> Result, String> { +pub(crate) fn decrypt_windows_gcm_v10(key: &[u8], encrypted: &[u8]) -> Result, String> { if encrypted.len() < 31 { return Err("Encrypted data too short".to_string()); } @@ -248,7 +248,7 @@ fn decrypt_windows_gcm_v10(key: &[u8], encrypted: &[u8]) -> Result, Stri } #[cfg(target_os = "windows")] -fn encrypt_windows_gcm_v10(key: &[u8], plaintext: &[u8]) -> Result, String> { +pub(crate) fn encrypt_windows_gcm_v10(key: &[u8], plaintext: &[u8]) -> Result, String> { let cipher = Aes256Gcm::new(GenericArray::from_slice(key)); let nonce = Aes256Gcm::generate_nonce(&mut OsRng); let ciphertext = cipher @@ -1329,6 +1329,8 @@ fn inject_secret_to_state_db_with_mode( ) .map_err(|e| format!("Failed to write to state.vscdb: {}", e))?; + let _ = conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);"); + Ok(()) } diff --git a/src-tauri/src/modules/web_report.rs b/src-tauri/src/modules/web_report.rs index 6ef150cb6..19a4e800a 100644 --- a/src-tauri/src/modules/web_report.rs +++ b/src-tauri/src/modules/web_report.rs @@ -1280,7 +1280,15 @@ fn append_qoder_rows(rows: &mut Vec) { rows, &account_name, &roots, - &[&["addOnQuota"], &["addonQuota"], &["add_on_quota"]], + &[ + &["addOnQuota"], + &["addonQuota"], + &["add_on_quota"], + &["orgResourcePackage"], + &["organizationResourcePackage"], + &["resourcePackage"], + &["sharedCreditPackage"], + ], "Add-on quota", &reset, account.plan_type.as_deref().unwrap_or(""), @@ -1354,7 +1362,7 @@ fn push_qoder_bucket_row( )); total = total.or(pick_first_number( bucket, - &[&["total"], &["quota"], &["limit"]], + &[&["total"], &["cap"], &["quota"], &["limit"]], )); remaining = remaining.or(pick_first_number( bucket, diff --git a/src/App.tsx b/src/App.tsx index 50c6c31ec..e54220e3c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -215,6 +215,9 @@ const RENDERABLE_PAGE_VALUES: readonly Page[] = [ 'codebuddy', 'codebuddy-cn', 'qoder', + 'qoder-app', + 'qoder-cn', + 'qoder-cn-app', 'zcode', 'trae', 'trae-solo', @@ -250,7 +253,10 @@ const TOP_PROMO_PAGE_PLATFORM_TARGETS: Partial> grok: ['grok'], codebuddy: ['codebuddy'], 'codebuddy-cn': ['codebuddy-cn'], - qoder: ['qoder'], + qoder: ['qoder', 'qoder-suite'], + 'qoder-app': ['qoder-app', 'qoder-suite'], + 'qoder-cn': ['qoder-cn', 'qoder-suite'], + 'qoder-cn-app': ['qoder-cn-app', 'qoder-suite'], zcode: ['zcode'], trae: ['trae', 'trae-suite'], 'trae-solo': ['trae-solo', 'trae-suite'], @@ -395,6 +401,9 @@ type AppPathMissingDetail = { | 'codebuddy' | 'codebuddy_cn' | 'qoder' + | 'qoder_app' + | 'qoder_cn_ide' + | 'qoder_cn_app' | 'trae' | 'trae_solo' | 'trae_cn' @@ -428,6 +437,10 @@ function isTraePlatformApp(app: string): app is 'trae' | 'trae_solo' | 'trae_cn' return app === 'trae' || app === 'trae_solo' || app === 'trae_cn' || app === 'trae_solo_cn'; } +function isQoderPlatformApp(app: string): app is 'qoder' | 'qoder_app' | 'qoder_cn_ide' | 'qoder_cn_app' { + return app === 'qoder' || app === 'qoder_app' || app === 'qoder_cn_ide' || app === 'qoder_cn_app'; +} + type TraePlatformApp = 'trae' | 'trae_solo' | 'trae_cn' | 'trae_solo_cn'; function getTraeAppPath(config: GeneralConfig, app: TraePlatformApp): string { @@ -596,6 +609,12 @@ function normalizeQuotaAlertPlatform(platform: string | undefined): QuotaAlertPl case 'codebuddy_cn': return 'codebuddy_cn'; case 'qoder': + case 'qoder-app': + case 'qoder_app': + case 'qoder-cn': + case 'qoder_cn_ide': + case 'qoder-cn-app': + case 'qoder_cn_app': return 'qoder'; case 'trae': case 'trae-solo': @@ -3186,7 +3205,7 @@ function MainApp() { detail.app !== 'cursor' && detail.app !== 'codebuddy' && detail.app !== 'codebuddy_cn' && - detail.app !== 'qoder' && + !isQoderPlatformApp(detail.app) && !isTraePlatformApp(detail.app) && detail.app !== 'workbuddy' && detail.app !== 'zed' @@ -3836,7 +3855,7 @@ function MainApp() { ? t('settings.general.codebuddyPathReset', '重置默认') : appPathMissing.app === 'codebuddy_cn' ? t('settings.general.codebuddyPathReset', '重置默认') - : appPathMissing.app === 'qoder' + : isQoderPlatformApp(appPathMissing.app) ? t('settings.general.qoderPathReset', '重置默认') : isTraePlatformApp(appPathMissing.app) ? t('settings.general.traePathReset', '重置默认') @@ -4047,7 +4066,16 @@ function MainApp() { - + + + + + + + + + + diff --git a/src/assets/icons/qoder-app.png b/src/assets/icons/qoder-app.png new file mode 100644 index 0000000000000000000000000000000000000000..d46df98e779244de03a20f7b8fac5d478641d975 GIT binary patch literal 8000 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 { | "codebuddy" | "codebuddy_cn" | "qoder" + | "qoder_app" + | "qoder_cn_ide" + | "qoder_cn_app" | "trae" | "trae_solo" | "trae_cn" diff --git a/src/components/PlatformLayoutModal.tsx b/src/components/PlatformLayoutModal.tsx index 81e1a6e98..f77602139 100644 --- a/src/components/PlatformLayoutModal.tsx +++ b/src/components/PlatformLayoutModal.tsx @@ -1306,7 +1306,16 @@ export function PlatformLayoutModal({
event.stopPropagation()}> -