refactor(types): introduce Settings and apply in API

- style(prettier): format src files
- style(rustfmt): format Rust sources
- refactor(tauri-api): type-safe getSettings/saveSettings
- refactor(d.ts): declare window.api with Settings

[skip ci]
This commit is contained in:
Jason
2025-09-07 11:36:09 +08:00
parent 77bdeb02fb
commit 02bfc97ee6
11 changed files with 210 additions and 106 deletions

View File

@@ -4,9 +4,9 @@ use std::path::PathBuf;
use crate::config::{
atomic_write, delete_file, sanitize_provider_name, write_json_file, write_text_file,
};
use serde_json::Value;
use std::fs;
use std::path::Path;
use serde_json::Value;
/// 获取 Codex 配置目录路径
pub fn get_codex_config_dir() -> PathBuf {
@@ -77,7 +77,8 @@ pub fn write_codex_live_atomic(auth: &Value, config_text_opt: Option<&str>) -> R
None => String::new(),
};
if !cfg_text.trim().is_empty() {
toml::from_str::<toml::Table>(&cfg_text).map_err(|e| format!("config.toml 格式错误: {}", e))?;
toml::from_str::<toml::Table>(&cfg_text)
.map_err(|e| format!("config.toml 格式错误: {}", e))?;
}
// 第一步:写 auth.json
@@ -121,7 +122,9 @@ pub fn validate_config_toml(text: &str) -> Result<(), String> {
if text.trim().is_empty() {
return Ok(());
}
toml::from_str::<toml::Table>(text).map(|_| ()).map_err(|e| format!("config.toml 语法错误: {}", e))
toml::from_str::<toml::Table>(text)
.map(|_| ())
.map_err(|e| format!("config.toml 语法错误: {}", e))
}
/// 读取并校验 `~/.codex/config.toml`,返回文本(可能为空)

View File

@@ -6,7 +6,7 @@ use tauri_plugin_opener::OpenerExt;
use crate::app_config::AppType;
use crate::codex_config;
use crate::config::{ConfigStatus, get_claude_settings_path};
use crate::config::{get_claude_settings_path, ConfigStatus};
use crate::provider::Provider;
use crate::store::AppState;
@@ -116,7 +116,9 @@ pub async fn add_provider(
let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
manager.providers.insert(provider.id.clone(), provider.clone());
manager
.providers
.insert(provider.id.clone(), provider.clone());
}
state.save()?;
@@ -146,7 +148,10 @@ pub async fn update_provider(
let manager = config
.get_manager(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
(manager.providers.contains_key(&provider.id), manager.current == provider.id)
(
manager.providers.contains_key(&provider.id),
manager.current == provider.id,
)
};
if !exists {
return Err(format!("供应商不存在: {}", provider.id));
@@ -182,7 +187,9 @@ pub async fn update_provider(
let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
manager.providers.insert(provider.id.clone(), provider.clone());
manager
.providers
.insert(provider.id.clone(), provider.clone());
}
state.save()?;
@@ -390,7 +397,8 @@ pub async fn import_default_config(
if !auth_path.exists() {
return Err("Codex 配置文件不存在".to_string());
}
let auth: serde_json::Value = crate::config::read_json_file::<serde_json::Value>(&auth_path)?;
let auth: serde_json::Value =
crate::config::read_json_file::<serde_json::Value>(&auth_path)?;
let config_str = match crate::codex_config::read_and_validate_codex_config_text() {
Ok(s) => s,
Err(e) => return Err(e),
@@ -488,7 +496,7 @@ pub async fn open_config_folder(
.or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
let config_dir = match app_type {
AppType::Claude => crate::config::get_claude_config_dir(),
AppType::Codex => crate::codex_config::get_codex_config_dir(),
@@ -500,7 +508,8 @@ pub async fn open_config_folder(
}
// 使用 opener 插件打开文件夹
handle.opener()
handle
.opener()
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
.map_err(|e| format!("打开文件夹失败: {}", e))?;
@@ -524,3 +533,68 @@ pub async fn open_external(app: tauri::AppHandle, url: String) -> Result<bool, S
Ok(true)
}
/// 获取应用配置文件路径
#[tauri::command]
pub async fn get_app_config_path() -> Result<String, String> {
use crate::config::get_app_config_path;
let config_path = get_app_config_path();
Ok(config_path.to_string_lossy().to_string())
}
/// 打开应用配置文件夹
#[tauri::command]
pub async fn open_app_config_folder(handle: tauri::AppHandle) -> Result<bool, String> {
use crate::config::get_app_config_dir;
let config_dir = get_app_config_dir();
// 确保目录存在
if !config_dir.exists() {
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {}", e))?;
}
// 使用 opener 插件打开文件夹
handle
.opener()
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
.map_err(|e| format!("打开文件夹失败: {}", e))?;
Ok(true)
}
/// 获取设置
#[tauri::command]
pub async fn get_settings(_state: State<'_, AppState>) -> Result<serde_json::Value, String> {
// 暂时返回默认设置
Ok(serde_json::json!({
"showInDock": true
}))
}
/// 保存设置
#[tauri::command]
pub async fn save_settings(
_state: State<'_, AppState>,
settings: serde_json::Value,
) -> Result<bool, String> {
// TODO: 实现设置保存逻辑
log::info!("保存设置: {:?}", settings);
Ok(true)
}
/// 检查更新
#[tauri::command]
pub async fn check_for_updates(handle: tauri::AppHandle) -> Result<bool, String> {
// 打开 GitHub releases 页面
handle
.opener()
.open_url(
"https://github.com/yungookim/cc-switch/releases",
None::<String>,
)
.map_err(|e| format!("打开更新页面失败: {}", e))?;
Ok(true)
}

View File

@@ -88,7 +88,6 @@ pub fn archive_file(ts: u64, category: &str, src: &Path) -> Result<Option<PathBu
Ok(Some(dest))
}
/// 清理供应商名称,确保文件名安全
pub fn sanitize_provider_name(name: &str) -> String {
name.chars()

View File

@@ -326,6 +326,11 @@ pub fn run() {
commands::get_claude_code_config_path,
commands::open_config_folder,
commands::open_external,
commands::get_app_config_path,
commands::open_app_config_folder,
commands::get_settings,
commands::save_settings,
commands::check_for_updates,
update_tray_menu,
])
.run(tauri::generate_context!())

View File

@@ -47,7 +47,10 @@ fn extract_claude_api_key(value: &Value) -> Option<String> {
fn extract_codex_api_key(value: &Value) -> Option<String> {
value
.get("auth")
.and_then(|auth| auth.get("OPENAI_API_KEY").or_else(|| auth.get("openai_api_key")))
.and_then(|auth| {
auth.get("OPENAI_API_KEY")
.or_else(|| auth.get("openai_api_key"))
})
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
@@ -77,7 +80,9 @@ fn scan_claude_copies() -> Vec<(String, PathBuf, Value)> {
if !fname.starts_with("settings-") || !fname.ends_with(".json") {
continue;
}
let name = fname.trim_start_matches("settings-").trim_end_matches(".json");
let name = fname
.trim_start_matches("settings-")
.trim_end_matches(".json");
if let Ok(val) = crate::config::read_json_file::<Value>(&p) {
items.push((name.to_string(), p, val));
}
@@ -104,7 +109,9 @@ fn scan_codex_copies() -> Vec<(String, Option<PathBuf>, Option<PathBuf>, Value)>
let entry = by_name.entry(name.to_string()).or_default();
entry.0 = Some(p);
} else if fname.starts_with("config-") && fname.ends_with(".toml") {
let name = fname.trim_start_matches("config-").trim_end_matches(".toml");
let name = fname
.trim_start_matches("config-")
.trim_end_matches(".toml");
let entry = by_name.entry(name.to_string()).or_default();
entry.1 = Some(p);
}
@@ -183,17 +190,14 @@ pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, S
if let Some((name, value)) = &live_claude {
let cand_key = extract_claude_api_key(value);
let exist_id = manager
.providers
.iter()
.find_map(|(id, p)| {
let pk = extract_claude_api_key(&p.settings_config);
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
Some(id.clone())
} else {
None
}
});
let exist_id = manager.providers.iter().find_map(|(id, p)| {
let pk = extract_claude_api_key(&p.settings_config);
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
Some(id.clone())
} else {
None
}
});
if let Some(exist_id) = exist_id {
if let Some(prov) = manager.providers.get_mut(&exist_id) {
log::info!("合并到已存在 Claude 供应商 '{}' (by name+key)", name);
@@ -203,43 +207,36 @@ pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, S
} else {
let id = next_unique_id(&ids, name);
ids.insert(id.clone());
let provider = crate::provider::Provider::with_id(
id.clone(),
name.clone(),
value.clone(),
None,
);
let provider =
crate::provider::Provider::with_id(id.clone(), name.clone(), value.clone(), None);
manager.providers.insert(provider.id.clone(), provider);
live_claude_id = Some(id);
}
}
for (name, path, value) in claude_items.iter() {
let cand_key = extract_claude_api_key(value);
let exist_id = manager
.providers
.iter()
.find_map(|(id, p)| {
let pk = extract_claude_api_key(&p.settings_config);
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
Some(id.clone())
} else {
None
}
});
let exist_id = manager.providers.iter().find_map(|(id, p)| {
let pk = extract_claude_api_key(&p.settings_config);
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
Some(id.clone())
} else {
None
}
});
if let Some(exist_id) = exist_id {
if let Some(prov) = manager.providers.get_mut(&exist_id) {
log::info!("覆盖 Claude 供应商 '{}' 来自 {} (by name+key)", name, path.display());
log::info!(
"覆盖 Claude 供应商 '{}' 来自 {} (by name+key)",
name,
path.display()
);
prov.settings_config = value.clone();
}
} else {
let id = next_unique_id(&ids, name);
ids.insert(id.clone());
let provider = crate::provider::Provider::with_id(
id.clone(),
name.clone(),
value.clone(),
None,
);
let provider =
crate::provider::Provider::with_id(id.clone(), name.clone(), value.clone(), None);
manager.providers.insert(provider.id.clone(), provider);
}
}
@@ -257,7 +254,10 @@ pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, S
String::new()
}
};
Some(("default".to_string(), serde_json::json!({"auth": auth, "config": cfg})))
Some((
"default".to_string(),
serde_json::json!({"auth": auth, "config": cfg}),
))
}
Err(e) => {
log::warn!("读取 Codex live auth.json 失败: {}", e);
@@ -277,17 +277,14 @@ pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, S
if let Some((name, value)) = &live_codex {
let cand_key = extract_codex_api_key(value);
let exist_id = manager
.providers
.iter()
.find_map(|(id, p)| {
let pk = extract_codex_api_key(&p.settings_config);
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
Some(id.clone())
} else {
None
}
});
let exist_id = manager.providers.iter().find_map(|(id, p)| {
let pk = extract_codex_api_key(&p.settings_config);
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
Some(id.clone())
} else {
None
}
});
if let Some(exist_id) = exist_id {
if let Some(prov) = manager.providers.get_mut(&exist_id) {
log::info!("合并到已存在 Codex 供应商 '{}' (by name+key)", name);
@@ -297,43 +294,37 @@ pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, S
} else {
let id = next_unique_id(&ids, name);
ids.insert(id.clone());
let provider = crate::provider::Provider::with_id(
id.clone(),
name.clone(),
value.clone(),
None,
);
let provider =
crate::provider::Provider::with_id(id.clone(), name.clone(), value.clone(), None);
manager.providers.insert(provider.id.clone(), provider);
live_codex_id = Some(id);
}
}
for (name, authp, cfgp, value) in codex_items.iter() {
let cand_key = extract_codex_api_key(value);
let exist_id = manager
.providers
.iter()
.find_map(|(id, p)| {
let pk = extract_codex_api_key(&p.settings_config);
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
Some(id.clone())
} else {
None
}
});
let exist_id = manager.providers.iter().find_map(|(id, p)| {
let pk = extract_codex_api_key(&p.settings_config);
if norm_name(&p.name) == norm_name(name) && pk == cand_key {
Some(id.clone())
} else {
None
}
});
if let Some(exist_id) = exist_id {
if let Some(prov) = manager.providers.get_mut(&exist_id) {
log::info!("覆盖 Codex 供应商 '{}' 来自 {:?}/{:?} (by name+key)", name, authp, cfgp);
log::info!(
"覆盖 Codex 供应商 '{}' 来自 {:?}/{:?} (by name+key)",
name,
authp,
cfgp
);
prov.settings_config = value.clone();
}
} else {
let id = next_unique_id(&ids, name);
ids.insert(id.clone());
let provider = crate::provider::Provider::with_id(
id.clone(),
name.clone(),
value.clone(),
None,
);
let provider =
crate::provider::Provider::with_id(id.clone(), name.clone(), value.clone(), None);
manager.providers.insert(provider.id.clone(), provider);
}
}
@@ -370,13 +361,17 @@ pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, S
for (_, ap, cp, _) in codex_items.into_iter() {
if let Some(ap) = ap {
match archive_file(ts, "codex", &ap) {
Ok(Some(_)) => { let _ = delete_file(&ap); }
Ok(Some(_)) => {
let _ = delete_file(&ap);
}
_ => {}
}
}
if let Some(cp) = cp {
match archive_file(ts, "codex", &cp) {
Ok(Some(_)) => { let _ = delete_file(&cp); }
Ok(Some(_)) => {
let _ = delete_file(&cp);
}
_ => {}
}
}
@@ -404,7 +399,11 @@ pub fn dedupe_config(config: &mut MultiAppConfig) -> usize {
let mut keep: Map<String, String> = Map::new(); // key -> id 保留
let mut remove: Vec<String> = Vec::new();
for (id, p) in mgr.providers.iter() {
let k = format!("{}|{}", norm_name(&p.name), extract_key(&p.settings_config).unwrap_or_default());
let k = format!(
"{}|{}",
norm_name(&p.name),
extract_key(&p.settings_config).unwrap_or_default()
);
if let Some(exist_id) = keep.get(&k) {
// 若当前是正在使用的,则用当前替换之前的,反之丢弃当前
if *id == mgr.current {

View File

@@ -330,9 +330,7 @@ function App() {
)}
{isSettingsOpen && (
<SettingsModal
onClose={() => setIsSettingsOpen(false)}
/>
<SettingsModal onClose={() => setIsSettingsOpen(false)} />
)}
</div>
);

View File

@@ -100,7 +100,10 @@ const ProviderList: React.FC<ProviderListProps> = ({
{provider.websiteUrl}
</button>
) : (
<span className="text-[var(--color-text-secondary)]" title={apiUrl}>
<span
className="text-[var(--color-text-secondary)]"
title={apiUrl}
>
{apiUrl}
</span>
)}

View File

@@ -1,10 +1,7 @@
import { useState, useEffect } from "react";
import { X, Info, RefreshCw, FolderOpen } from "lucide-react";
import "../lib/tauri-api";
interface Settings {
showInDock: boolean;
}
import type { Settings } from "../types";
interface SettingsModalProps {
onClose: () => void;
@@ -36,9 +33,9 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
const loadConfigPath = async () => {
try {
const status = await window.api.getConfigStatus("claude");
if (status?.path) {
setConfigPath(status.path.replace("/claude_code_config.json", ""));
const path = await window.api.getAppConfigPath();
if (path) {
setConfigPath(path);
}
} catch (error) {
console.error("获取配置路径失败:", error);
@@ -67,7 +64,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
const handleOpenConfigFolder = async () => {
try {
await window.api.openConfigFolder("claude");
await window.api.openAppConfigFolder();
} catch (error) {
console.error("打开配置文件夹失败:", error);
}

View File

@@ -1,6 +1,6 @@
import { invoke } from "@tauri-apps/api/core";
import { listen, UnlistenFn } from "@tauri-apps/api/event";
import { Provider } from "../types";
import { Provider, Settings } from "../types";
// 应用类型
export type AppType = "claude" | "codex";
@@ -196,7 +196,7 @@ export const tauriAPI = {
},
// 获取设置
getSettings: async (): Promise<any> => {
getSettings: async (): Promise<Settings> => {
try {
return await invoke("get_settings");
} catch (error) {
@@ -206,7 +206,7 @@ export const tauriAPI = {
},
// 保存设置
saveSettings: async (settings: any): Promise<boolean> => {
saveSettings: async (settings: Settings): Promise<boolean> => {
try {
return await invoke("save_settings", { settings });
} catch (error) {
@@ -223,6 +223,25 @@ export const tauriAPI = {
console.error("检查更新失败:", error);
}
},
// 获取应用配置文件路径
getAppConfigPath: async (): Promise<string> => {
try {
return await invoke("get_app_config_path");
} catch (error) {
console.error("获取应用配置路径失败:", error);
return "";
}
},
// 打开应用配置文件夹
openAppConfigFolder: async (): Promise<void> => {
try {
await invoke("open_app_config_folder");
} catch (error) {
console.error("打开应用配置文件夹失败:", error);
}
},
};
// 创建全局 API 对象,兼容现有代码

View File

@@ -9,3 +9,8 @@ export interface AppConfig {
providers: Record<string, Provider>;
current: string;
}
// 应用设置类型(用于 SettingsModal 与 Tauri API
export interface Settings {
showInDock: boolean;
}

8
src/vite-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="vite/client" />
import { Provider } from "./types";
import { Provider, Settings } from "./types";
import { AppType } from "./lib/tauri-api";
import type { UnlistenFn } from "@tauri-apps/api/event";
@@ -35,9 +35,11 @@ declare global {
onProviderSwitched: (
callback: (data: { appType: string; providerId: string }) => void,
) => Promise<UnlistenFn>;
getSettings: () => Promise<any>;
saveSettings: (settings: any) => Promise<boolean>;
getSettings: () => Promise<Settings>;
saveSettings: (settings: Settings) => Promise<boolean>;
checkForUpdates: () => Promise<void>;
getAppConfigPath: () => Promise<string>;
openAppConfigFolder: () => Promise<void>;
};
platform: {
isMac: boolean;