feat: sync current providers to live files after config import
Core Improvements: - Add sync_current_providers_live command to synchronize in-memory provider settings to corresponding live files (~/.claude/settings.json or ~/.codex/auth.json) - Introduce partial-success state to distinguish between 'import succeeded but sync failed' scenario, providing clear user feedback - Remove unused skip_live_backfill parameter from switch_provider command - Separate responsibilities: backend handles import/backup, frontend handles sync/error presentation Technical Details: - Codex: sync auth.json + config.toml with MCP configuration - Claude: sync settings.json - Bidirectional sync: read back after write to update in-memory settings_config - Full i18n support (English and Chinese) - Graceful handling when no current provider is active Affected Files: - Backend: import_export.rs, commands.rs, lib.rs - Frontend: useImportExport.ts, ImportExportSection.tsx, settings.ts - i18n: en.json, zh.json This ensures SSOT (Single Source of Truth) consistency between config.json and live configuration files after import operations.
This commit is contained in:
@@ -369,7 +369,6 @@ pub async fn switch_provider(
|
|||||||
AppType::Codex => {
|
AppType::Codex => {
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
// 回填:读取 live(auth.json + config.toml)写回当前供应商 settings_config
|
|
||||||
if !{
|
if !{
|
||||||
let cur = config
|
let cur = config
|
||||||
.get_manager_mut(&app_type)
|
.get_manager_mut(&app_type)
|
||||||
@@ -424,7 +423,6 @@ pub async fn switch_provider(
|
|||||||
|
|
||||||
let settings_path = get_claude_settings_path();
|
let settings_path = get_claude_settings_path();
|
||||||
|
|
||||||
// 回填:读取 live settings.json 写回当前供应商 settings_config
|
|
||||||
if settings_path.exists() {
|
if settings_path.exists() {
|
||||||
let cur_id = {
|
let cur_id = {
|
||||||
let m = config
|
let m = config
|
||||||
@@ -816,9 +814,7 @@ pub async fn query_provider_usage(
|
|||||||
use crate::provider::{UsageData, UsageResult};
|
use crate::provider::{UsageData, UsageResult};
|
||||||
|
|
||||||
// 解析参数
|
// 解析参数
|
||||||
let provider_id = provider_id
|
let provider_id = provider_id.or(providerId).ok_or("缺少 providerId 参数")?;
|
||||||
.or(providerId)
|
|
||||||
.ok_or("缺少 providerId 参数")?;
|
|
||||||
|
|
||||||
let app_type = app_type
|
let app_type = app_type
|
||||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||||
@@ -832,14 +828,9 @@ pub async fn query_provider_usage(
|
|||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||||
|
|
||||||
let manager = config
|
let manager = config.get_manager(&app_type).ok_or("应用类型不存在")?;
|
||||||
.get_manager(&app_type)
|
|
||||||
.ok_or("应用类型不存在")?;
|
|
||||||
|
|
||||||
let provider = manager
|
let provider = manager.providers.get(&provider_id).ok_or("供应商不存在")?;
|
||||||
.providers
|
|
||||||
.get(&provider_id)
|
|
||||||
.ok_or("供应商不存在")?;
|
|
||||||
|
|
||||||
// 2. 检查脚本配置
|
// 2. 检查脚本配置
|
||||||
let usage_script = provider
|
let usage_script = provider
|
||||||
@@ -864,13 +855,9 @@ pub async fn query_provider_usage(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 5. 执行脚本
|
// 5. 执行脚本
|
||||||
let result = crate::usage_script::execute_usage_script(
|
let result =
|
||||||
&usage_script_code,
|
crate::usage_script::execute_usage_script(&usage_script_code, &api_key, &base_url, timeout)
|
||||||
&api_key,
|
.await;
|
||||||
&base_url,
|
|
||||||
timeout,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// 6. 构建结果(支持单对象或数组)
|
// 6. 构建结果(支持单对象或数组)
|
||||||
match result {
|
match result {
|
||||||
@@ -878,12 +865,11 @@ pub async fn query_provider_usage(
|
|||||||
// 尝试解析为数组
|
// 尝试解析为数组
|
||||||
let usage_list: Vec<UsageData> = if data.is_array() {
|
let usage_list: Vec<UsageData> = if data.is_array() {
|
||||||
// 直接解析为数组
|
// 直接解析为数组
|
||||||
serde_json::from_value(data)
|
serde_json::from_value(data).map_err(|e| format!("数据格式错误: {}", e))?
|
||||||
.map_err(|e| format!("数据格式错误: {}", e))?
|
|
||||||
} else {
|
} else {
|
||||||
// 单对象包装为数组(向后兼容)
|
// 单对象包装为数组(向后兼容)
|
||||||
let single: UsageData = serde_json::from_value(data)
|
let single: UsageData =
|
||||||
.map_err(|e| format!("数据格式错误: {}", e))?;
|
serde_json::from_value(data).map_err(|e| format!("数据格式错误: {}", e))?;
|
||||||
vec![single]
|
vec![single]
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -893,13 +879,11 @@ pub async fn query_provider_usage(
|
|||||||
error: None,
|
error: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => Ok(UsageResult {
|
||||||
Ok(UsageResult {
|
success: false,
|
||||||
success: false,
|
data: None,
|
||||||
data: None,
|
error: Some(e),
|
||||||
error: Some(e),
|
}),
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1539,4 +1523,4 @@ pub async fn update_providers_sort_order(
|
|||||||
state.save()?;
|
state.save()?;
|
||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use crate::app_config::{AppType, MultiAppConfig};
|
||||||
|
use crate::provider::Provider;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -79,6 +81,113 @@ fn cleanup_old_backups(backup_dir: &PathBuf, retain: usize) -> Result<(), String
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sync_current_providers_to_live(config: &mut MultiAppConfig) -> Result<(), String> {
|
||||||
|
sync_current_provider_for_app(config, &AppType::Claude)?;
|
||||||
|
sync_current_provider_for_app(config, &AppType::Codex)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_current_provider_for_app(
|
||||||
|
config: &mut MultiAppConfig,
|
||||||
|
app_type: &AppType,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let (current_id, provider) = {
|
||||||
|
let manager = match config.get_manager(app_type) {
|
||||||
|
Some(manager) => manager,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if manager.current.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_id = manager.current.clone();
|
||||||
|
let provider = match manager.providers.get(¤t_id) {
|
||||||
|
Some(provider) => provider.clone(),
|
||||||
|
None => {
|
||||||
|
log::warn!(
|
||||||
|
"当前应用 {:?} 的供应商 {} 不存在,跳过 live 同步",
|
||||||
|
app_type,
|
||||||
|
current_id
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(current_id, provider)
|
||||||
|
};
|
||||||
|
|
||||||
|
match app_type {
|
||||||
|
AppType::Codex => sync_codex_live(config, ¤t_id, &provider)?,
|
||||||
|
AppType::Claude => sync_claude_live(config, ¤t_id, &provider)?,
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_codex_live(
|
||||||
|
config: &mut MultiAppConfig,
|
||||||
|
provider_id: &str,
|
||||||
|
provider: &Provider,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
let settings = provider
|
||||||
|
.settings_config
|
||||||
|
.as_object()
|
||||||
|
.ok_or_else(|| format!("供应商 {} 的 Codex 配置必须是对象", provider_id))?;
|
||||||
|
let auth = settings
|
||||||
|
.get("auth")
|
||||||
|
.ok_or_else(|| format!("供应商 {} 的 Codex 配置缺少 auth 字段", provider_id))?;
|
||||||
|
if !auth.is_object() {
|
||||||
|
return Err(format!(
|
||||||
|
"供应商 {} 的 Codex auth 配置必须是 JSON 对象",
|
||||||
|
provider_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let cfg_text = settings.get("config").and_then(Value::as_str);
|
||||||
|
|
||||||
|
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
|
||||||
|
crate::mcp::sync_enabled_to_codex(config)?;
|
||||||
|
|
||||||
|
let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?;
|
||||||
|
if let Some(manager) = config.get_manager_mut(&AppType::Codex) {
|
||||||
|
if let Some(target) = manager.providers.get_mut(provider_id) {
|
||||||
|
if let Some(obj) = target.settings_config.as_object_mut() {
|
||||||
|
obj.insert(
|
||||||
|
"config".to_string(),
|
||||||
|
serde_json::Value::String(cfg_text_after),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_claude_live(
|
||||||
|
config: &mut MultiAppConfig,
|
||||||
|
provider_id: &str,
|
||||||
|
provider: &Provider,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
use crate::config::{read_json_file, write_json_file};
|
||||||
|
|
||||||
|
let settings_path = crate::config::get_claude_settings_path();
|
||||||
|
if let Some(parent) = settings_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).map_err(|e| format!("创建 Claude 配置目录失败: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
write_json_file(&settings_path, &provider.settings_config)?;
|
||||||
|
|
||||||
|
let live_after = read_json_file::<serde_json::Value>(&settings_path)?;
|
||||||
|
if let Some(manager) = config.get_manager_mut(&AppType::Claude) {
|
||||||
|
if let Some(target) = manager.providers.get_mut(provider_id) {
|
||||||
|
target.settings_config = live_after;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 导出配置文件
|
/// 导出配置文件
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn export_config_to_file(file_path: String) -> Result<Value, String> {
|
pub async fn export_config_to_file(file_path: String) -> Result<Value, String> {
|
||||||
@@ -135,6 +244,25 @@ pub async fn import_config_from_file(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 同步当前供应商配置到对应的 live 文件
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn sync_current_providers_live(
|
||||||
|
state: tauri::State<'_, crate::store::AppState>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
{
|
||||||
|
let mut config_state = state
|
||||||
|
.config
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| format!("Failed to lock config: {}", e))?;
|
||||||
|
sync_current_providers_to_live(&mut config_state)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"success": true,
|
||||||
|
"message": "Live configuration synchronized"
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
/// 保存文件对话框
|
/// 保存文件对话框
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn save_file_dialog<R: tauri::Runtime>(
|
pub async fn save_file_dialog<R: tauri::Runtime>(
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ mod migration;
|
|||||||
mod provider;
|
mod provider;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod speedtest;
|
mod speedtest;
|
||||||
mod usage_script;
|
|
||||||
mod store;
|
mod store;
|
||||||
|
mod usage_script;
|
||||||
|
|
||||||
use store::AppState;
|
use store::AppState;
|
||||||
use tauri::{
|
use tauri::{
|
||||||
@@ -509,6 +509,7 @@ pub fn run() {
|
|||||||
import_export::import_config_from_file,
|
import_export::import_config_from_file,
|
||||||
import_export::save_file_dialog,
|
import_export::save_file_dialog,
|
||||||
import_export::open_file_dialog,
|
import_export::open_file_dialog,
|
||||||
|
import_export::sync_current_providers_live,
|
||||||
update_tray_menu,
|
update_tray_menu,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -537,4 +538,4 @@ pub fn run() {
|
|||||||
let _ = (app_handle, event);
|
let _ = (app_handle, event);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,6 +167,20 @@ function ImportStatusMessage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status === "partial-success") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${baseClass} border-yellow-200 bg-yellow-100/70 text-yellow-700`}
|
||||||
|
>
|
||||||
|
<AlertCircle className="mt-0.5 h-4 w-4" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-medium">{t("settings.importPartialSuccess")}</p>
|
||||||
|
<p>{t("settings.importPartialHint")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const message = errorMessage || t("settings.importFailed");
|
const message = errorMessage || t("settings.importFailed");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { settingsApi } from "@/lib/api";
|
import { settingsApi } from "@/lib/api";
|
||||||
|
|
||||||
export type ImportStatus = "idle" | "importing" | "success" | "error";
|
export type ImportStatus =
|
||||||
|
| "idle"
|
||||||
|
| "importing"
|
||||||
|
| "success"
|
||||||
|
| "partial-success"
|
||||||
|
| "error";
|
||||||
|
|
||||||
export interface UseImportExportOptions {
|
export interface UseImportExportOptions {
|
||||||
onImportSuccess?: () => void | Promise<void>;
|
onImportSuccess?: () => void | Promise<void>;
|
||||||
@@ -86,8 +91,22 @@ export function useImportExport(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await settingsApi.importConfigFromFile(selectedFile);
|
const result = await settingsApi.importConfigFromFile(selectedFile);
|
||||||
if (result.success) {
|
if (!result.success) {
|
||||||
setBackupId(result.backupId ?? null);
|
setStatus("error");
|
||||||
|
const message =
|
||||||
|
result.message ||
|
||||||
|
t("settings.configCorrupted", {
|
||||||
|
defaultValue: "配置文件已损坏或格式不正确",
|
||||||
|
});
|
||||||
|
setErrorMessage(message);
|
||||||
|
toast.error(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBackupId(result.backupId ?? null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await settingsApi.syncCurrentProvidersLive();
|
||||||
setStatus("success");
|
setStatus("success");
|
||||||
toast.success(
|
toast.success(
|
||||||
t("settings.importSuccess", {
|
t("settings.importSuccess", {
|
||||||
@@ -98,15 +117,15 @@ export function useImportExport(
|
|||||||
successTimerRef.current = window.setTimeout(() => {
|
successTimerRef.current = window.setTimeout(() => {
|
||||||
void onImportSuccess?.();
|
void onImportSuccess?.();
|
||||||
}, 1500);
|
}, 1500);
|
||||||
} else {
|
} catch (error) {
|
||||||
setStatus("error");
|
console.error("[useImportExport] Failed to sync live config", error);
|
||||||
const message =
|
setStatus("partial-success");
|
||||||
result.message ||
|
toast.warning(
|
||||||
t("settings.configCorrupted", {
|
t("settings.importPartialSuccess", {
|
||||||
defaultValue: "配置文件已损坏或格式不正确",
|
defaultValue:
|
||||||
});
|
"配置已导入,但同步到当前供应商失败。请手动重新选择一次供应商。",
|
||||||
setErrorMessage(message);
|
}),
|
||||||
toast.error(message);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[useImportExport] Failed to import config", error);
|
console.error("[useImportExport] Failed to import config", error);
|
||||||
|
|||||||
@@ -123,6 +123,9 @@
|
|||||||
"importing": "Importing...",
|
"importing": "Importing...",
|
||||||
"importSuccess": "Import Successful!",
|
"importSuccess": "Import Successful!",
|
||||||
"importFailed": "Import Failed",
|
"importFailed": "Import Failed",
|
||||||
|
"syncLiveFailed": "Imported, but failed to sync to the current provider. Please reselect the provider manually.",
|
||||||
|
"importPartialSuccess": "Config imported, but failed to sync to the current provider.",
|
||||||
|
"importPartialHint": "Please manually reselect the provider to refresh the live configuration.",
|
||||||
"configExported": "Config exported to:",
|
"configExported": "Config exported to:",
|
||||||
"exportFailed": "Export failed",
|
"exportFailed": "Export failed",
|
||||||
"selectFileFailed": "Failed to select file",
|
"selectFileFailed": "Failed to select file",
|
||||||
|
|||||||
@@ -123,6 +123,9 @@
|
|||||||
"importing": "导入中...",
|
"importing": "导入中...",
|
||||||
"importSuccess": "导入成功!",
|
"importSuccess": "导入成功!",
|
||||||
"importFailed": "导入失败",
|
"importFailed": "导入失败",
|
||||||
|
"syncLiveFailed": "已导入,但同步到当前供应商失败,请手动重新选择一次供应商。",
|
||||||
|
"importPartialSuccess": "配置已导入,但同步到当前供应商失败。",
|
||||||
|
"importPartialHint": "请手动重新选择一次供应商以刷新对应配置。",
|
||||||
"configExported": "配置已导出到:",
|
"configExported": "配置已导出到:",
|
||||||
"exportFailed": "导出失败",
|
"exportFailed": "导出失败",
|
||||||
"selectFileFailed": "选择文件失败",
|
"selectFileFailed": "选择文件失败",
|
||||||
|
|||||||
@@ -97,6 +97,16 @@ export const settingsApi = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async syncCurrentProvidersLive(): Promise<void> {
|
||||||
|
const result = (await invoke("sync_current_providers_live")) as {
|
||||||
|
success?: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
if (!result?.success) {
|
||||||
|
throw new Error(result?.message || "Sync current providers failed");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async openExternal(url: string): Promise<void> {
|
async openExternal(url: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const u = new URL(url);
|
const u = new URL(url);
|
||||||
|
|||||||
Reference in New Issue
Block a user