Files
CC-Switch/src-tauri/src/commands/profile.rs
T
Jason 9f7642e29c refactor(profiles): drop manual snapshot update now that switching autosaves
Since switching projects automatically saves the current configuration
back to the project being left, the per-project "update from current"
button is redundant and even harmful: it allowed overwriting another
project's snapshot with the current state, breaking the invariant that
a project holds the state you last left it in.

- Remove the resnapshot button and confirm dialog from the manage
  dialog; drop the now-unused scope prop and PROFILE_SCOPE_LABELS
- Add a footer Close button to the manage dialog (overlay click is
  disabled app-wide, so it previously could only be closed via Esc)
- Update manageDescription in all four locales to describe the
  autosave behavior; remove the two dead i18n keys
- Fix a shadowed `warnings` vec in ProfileService::apply that silently
  dropped autosave-failure warnings before they reached the frontend
- Mention auto-capture on next switch in the "no snapshot for this
  scope yet" warning and doc comments

The backend update command keeps its resnapshot parameter; it is now
only exercised by the pre-switch autosave path.
2026-07-07 10:49:32 +08:00

196 lines
6.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 项目 Profile 管理命令
use serde::Serialize;
use tauri::{Emitter, Manager, State};
use crate::database::Profile;
use crate::services::profile::{ProfilePayload, ProfileScope, ProfileService};
use crate::store::AppState;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileDto {
pub id: String,
pub name: String,
pub payload: ProfilePayload,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<i64>,
}
impl From<Profile> for ProfileDto {
fn from(profile: Profile) -> Self {
// 单条 payload 损坏不应拖垮整个列表:降级为默认值并记日志
let payload = serde_json::from_str(&profile.payload).unwrap_or_else(|e| {
log::warn!(
"解析 profile '{}' payload 失败,使用默认值: {e}",
profile.id
);
ProfilePayload::default()
});
Self {
id: profile.id,
name: profile.name,
payload,
created_at: profile.created_at,
updated_at: profile.updated_at,
}
}
}
/// 每个分组当前激活的项目 id(未使用项目时为 null)
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CurrentProfileIds {
pub claude: Option<String>,
pub claude_desktop: Option<String>,
pub codex: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfilesResponse {
pub profiles: Vec<ProfileDto>,
pub current_ids: CurrentProfileIds,
}
/// Profile 应用完成后的统一收尾:发事件 + 重建托盘菜单
///
/// 只对项目所属分组内的应用发 provider-switched。UI 与托盘两个入口必须
/// 共用此函数,保证事件 payload 形状一致(前端 App.tsx 的
/// provider-switched 监听依赖该形状)。
pub fn emit_profile_apply_events(
app: &tauri::AppHandle,
state: &AppState,
profile_id: &str,
scope: ProfileScope,
) {
for app_type in scope.apps().iter() {
let app_str = app_type.as_str();
let (proxy_enabled, auto_failover_enabled) = state.db.get_proxy_flags_sync(app_str);
let provider_id = crate::settings::get_effective_current_provider(&state.db, app_type)
.ok()
.flatten()
.unwrap_or_default();
let event_data = serde_json::json!({
"appType": app_str,
"proxyEnabled": proxy_enabled,
"autoFailoverEnabled": auto_failover_enabled,
"providerId": provider_id,
});
if let Err(e) = app.emit("provider-switched", event_data) {
log::error!("发射 provider-switched 事件失败: {e}");
}
}
if let Err(e) = app.emit(
"profile-applied",
serde_json::json!({ "profileId": profile_id, "scope": scope.as_str() }),
) {
log::error!("发射 profile-applied 事件失败: {e}");
}
crate::tray::refresh_tray_menu(app);
}
#[tauri::command]
pub fn list_profiles(state: State<'_, AppState>) -> Result<ProfilesResponse, String> {
let profiles = ProfileService::list(&state).map_err(|e| e.to_string())?;
let current_ids = CurrentProfileIds {
claude: state
.db
.get_current_profile_id(ProfileScope::Claude.as_str())
.map_err(|e| e.to_string())?,
claude_desktop: state
.db
.get_current_profile_id(ProfileScope::ClaudeDesktop.as_str())
.map_err(|e| e.to_string())?,
codex: state
.db
.get_current_profile_id(ProfileScope::Codex.as_str())
.map_err(|e| e.to_string())?,
};
Ok(ProfilesResponse {
profiles: profiles.into_iter().map(ProfileDto::from).collect(),
current_ids,
})
}
#[tauri::command]
pub fn create_profile(
state: State<'_, AppState>,
name: String,
scope: String,
) -> Result<ProfileDto, String> {
let scope = ProfileScope::parse(&scope).map_err(|e| e.to_string())?;
ProfileService::create(&state, &name, scope)
.map(ProfileDto::from)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn update_profile(
state: State<'_, AppState>,
id: String,
name: Option<String>,
resnapshot: Option<bool>,
scope: Option<String>,
) -> Result<ProfileDto, String> {
let scope = scope
.map(|s| ProfileScope::parse(&s))
.transpose()
.map_err(|e| e.to_string())?;
ProfileService::update(&state, &id, name, resnapshot.unwrap_or(false), scope)
.map(ProfileDto::from)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn delete_profile(state: State<'_, AppState>, id: String) -> Result<(), String> {
ProfileService::delete(&state, &id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn clear_current_profile(state: State<'_, AppState>, scope: String) -> Result<(), String> {
let scope = ProfileScope::parse(&scope).map_err(|e| e.to_string())?;
state
.db
.set_current_profile_id(scope.as_str(), None)
.map_err(|e| e.to_string())
}
/// 应用项目快照(只作用于发起页所属分组内的应用)。
///
/// 注意:必须保持同步命令(跑在 Tauri 线程池)——`ProviderService::switch`
/// 内部使用 block_on 获取切换锁,放进 async 命令会在运行时线程上 panic。
#[tauri::command]
pub fn apply_profile(
app: tauri::AppHandle,
state: State<'_, AppState>,
id: String,
scope: String,
) -> Result<Vec<String>, String> {
let scope = ProfileScope::parse(&scope).map_err(|e| e.to_string())?;
let (warnings, should_stop_proxy) =
ProfileService::apply(&state, &id, scope).map_err(|e| e.to_string())?;
if should_stop_proxy {
// sync 命令线程没有 Tokio runtime,无法直接 await stop()
// 把停止服务放到 Tauri async runtime,停止后再补发事件刷新 UI。
let app_handle = app.clone();
let profile_id = id.clone();
let proxy_service = state.proxy_service.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = proxy_service.stop().await {
log::warn!("切换项目后停止代理服务失败: {e}");
}
if let Some(app_state) = app_handle.try_state::<AppState>() {
emit_profile_apply_events(&app_handle, app_state.inner(), &profile_id, scope);
}
});
} else {
emit_profile_apply_events(&app, &state, &id, scope);
}
Ok(warnings)
}