feat(tray): add Gemini support to system tray menu (#209)
Refactor tray menu system to support three applications (Claude/Codex/Gemini): - Introduce generic TrayAppSection structure and TRAY_SECTIONS array - Implement append_provider_section and handle_provider_tray_event helper functions - Enhance Gemini provider service with .env config read/write support - Implement Gemini LiveSnapshot for atomic operations and rollback - Update README documentation to reflect Gemini tray quick switching feature
This commit is contained in:
12
README.md
12
README.md
@@ -47,7 +47,7 @@ Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJ
|
|||||||
|
|
||||||
**Core Capabilities**
|
**Core Capabilities**
|
||||||
|
|
||||||
- **Provider Management**: One-click switching between Claude Code & Codex API configurations
|
- **Provider Management**: One-click switching between Claude Code, Codex, and Gemini API configurations
|
||||||
- **MCP Integration**: Centralized MCP server management with stdio/http support and real-time sync
|
- **MCP Integration**: Centralized MCP server management with stdio/http support and real-time sync
|
||||||
- **Speed Testing**: Measure API endpoint latency with visual quality indicators
|
- **Speed Testing**: Measure API endpoint latency with visual quality indicators
|
||||||
- **Import/Export**: Backup and restore configs with auto-rotation (keep 10 most recent)
|
- **Import/Export**: Backup and restore configs with auto-rotation (keep 10 most recent)
|
||||||
@@ -115,8 +115,8 @@ Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{ver
|
|||||||
2. **Switch Provider**:
|
2. **Switch Provider**:
|
||||||
- Main UI: Select provider → Click "Enable"
|
- Main UI: Select provider → Click "Enable"
|
||||||
- System Tray: Click provider name directly (instant effect)
|
- System Tray: Click provider name directly (instant effect)
|
||||||
3. **Takes Effect**: Restart terminal or Claude Code/Codex to apply changes
|
3. **Takes Effect**: Restart your terminal or Claude Code / Codex / Gemini clients to apply changes
|
||||||
4. **Back to Official**: Select "Official Login" preset, restart terminal, then use `/login` (Claude) or official login flow (Codex)
|
4. **Back to Official**: Select the "Official Login" preset (Claude/Codex) or "Google Official" preset (Gemini), restart the corresponding client, then follow its login/OAuth flow
|
||||||
|
|
||||||
### MCP Management
|
### MCP Management
|
||||||
|
|
||||||
@@ -139,6 +139,12 @@ Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{ver
|
|||||||
- API key field: `OPENAI_API_KEY` in `auth.json`
|
- API key field: `OPENAI_API_KEY` in `auth.json`
|
||||||
- MCP servers: `~/.codex/config.toml` → `[mcp.servers]`
|
- MCP servers: `~/.codex/config.toml` → `[mcp.servers]`
|
||||||
|
|
||||||
|
**Gemini**
|
||||||
|
|
||||||
|
- Live config: `~/.gemini/.env` (API key) + `~/.gemini/settings.json` (auth type for quick switching)
|
||||||
|
- API key field: `GEMINI_API_KEY` inside `.env`
|
||||||
|
- Tray quick switch: each provider switch rewrites `~/.gemini/.env` so the Gemini CLI picks up the new credentials immediately
|
||||||
|
|
||||||
**CC Switch Storage**
|
**CC Switch Storage**
|
||||||
|
|
||||||
- Main config (SSOT): `~/.cc-switch/config.json`
|
- Main config (SSOT): `~/.cc-switch/config.json`
|
||||||
|
|||||||
12
README_ZH.md
12
README_ZH.md
@@ -47,7 +47,7 @@ CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编
|
|||||||
|
|
||||||
**核心功能**
|
**核心功能**
|
||||||
|
|
||||||
- **供应商管理**:一键切换 Claude Code 与 Codex 的 API 配置
|
- **供应商管理**:一键切换 Claude Code、Codex 与 Gemini 的 API 配置
|
||||||
- **MCP 集成**:集中管理 MCP 服务器,支持 stdio/http 类型和实时同步
|
- **MCP 集成**:集中管理 MCP 服务器,支持 stdio/http 类型和实时同步
|
||||||
- **速度测试**:测量 API 端点延迟,可视化连接质量指示器
|
- **速度测试**:测量 API 端点延迟,可视化连接质量指示器
|
||||||
- **导入导出**:备份和恢复配置,自动轮换(保留最近 10 个)
|
- **导入导出**:备份和恢复配置,自动轮换(保留最近 10 个)
|
||||||
@@ -115,8 +115,8 @@ brew upgrade --cask cc-switch
|
|||||||
2. **切换供应商**:
|
2. **切换供应商**:
|
||||||
- 主界面:选择供应商 → 点击"启用"
|
- 主界面:选择供应商 → 点击"启用"
|
||||||
- 系统托盘:直接点击供应商名称(立即生效)
|
- 系统托盘:直接点击供应商名称(立即生效)
|
||||||
3. **生效方式**:重启终端或 Claude Code/Codex 以应用更改
|
3. **生效方式**:重启终端或 Claude Code / Codex / Gemini 客户端以应用更改
|
||||||
4. **恢复官方登录**:选择"官方登录"预设,重启终端后使用 `/login`(Claude)或官方登录流程(Codex)
|
4. **恢复官方登录**:选择"官方登录"预设(Claude/Codex)或"Google 官方"预设(Gemini),重启对应客户端后按照其登录/OAuth 流程操作
|
||||||
|
|
||||||
### MCP 管理
|
### MCP 管理
|
||||||
|
|
||||||
@@ -139,6 +139,12 @@ brew upgrade --cask cc-switch
|
|||||||
- API key 字段:`auth.json` 中的 `OPENAI_API_KEY`
|
- API key 字段:`auth.json` 中的 `OPENAI_API_KEY`
|
||||||
- MCP 服务器:`~/.codex/config.toml` → `[mcp.servers]`
|
- MCP 服务器:`~/.codex/config.toml` → `[mcp.servers]`
|
||||||
|
|
||||||
|
**Gemini**
|
||||||
|
|
||||||
|
- Live 配置:`~/.gemini/.env`(API Key)+ `~/.gemini/settings.json`(保存认证模式,支持托盘快速切换)
|
||||||
|
- API key 字段:`.env` 文件中的 `GEMINI_API_KEY`
|
||||||
|
- 托盘快速切换:每次切换供应商都会重写 `~/.gemini/.env`,Gemini CLI 无需额外操作即可使用新配置
|
||||||
|
|
||||||
**CC Switch 存储**
|
**CC Switch 存储**
|
||||||
|
|
||||||
- 主配置(SSOT):`~/.cc-switch/config.json`
|
- 主配置(SSOT):`~/.cc-switch/config.json`
|
||||||
|
|||||||
@@ -150,11 +150,14 @@ impl MultiAppConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 解析 v2 结构
|
// 解析 v2 结构
|
||||||
let mut config: Self = serde_json::from_value(value).map_err(|e| AppError::json(&config_path, e))?;
|
let mut config: Self =
|
||||||
|
serde_json::from_value(value).map_err(|e| AppError::json(&config_path, e))?;
|
||||||
|
|
||||||
// 确保 gemini 应用存在(兼容旧配置文件)
|
// 确保 gemini 应用存在(兼容旧配置文件)
|
||||||
if !config.apps.contains_key("gemini") {
|
if !config.apps.contains_key("gemini") {
|
||||||
config.apps.insert("gemini".to_string(), ProviderManager::default());
|
config
|
||||||
|
.apps
|
||||||
|
.insert("gemini".to_string(), ProviderManager::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(config)
|
Ok(config)
|
||||||
|
|||||||
@@ -56,9 +56,7 @@ fn read_override_from_store(app: &tauri::AppHandle) -> Option<PathBuf> {
|
|||||||
Some(path)
|
Some(path)
|
||||||
}
|
}
|
||||||
Some(_) => {
|
Some(_) => {
|
||||||
log::warn!(
|
log::warn!("Store 中的 {STORE_KEY_APP_CONFIG_DIR} 类型不正确,应为字符串");
|
||||||
"Store 中的 {STORE_KEY_APP_CONFIG_DIR} 类型不正确,应为字符串"
|
|
||||||
);
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
None => None,
|
None => None,
|
||||||
|
|||||||
@@ -149,8 +149,7 @@ pub fn read_gemini_env() -> Result<HashMap<String, String>, AppError> {
|
|||||||
return Ok(HashMap::new());
|
return Ok(HashMap::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = fs::read_to_string(&path)
|
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||||
.map_err(|e| AppError::io(&path, e))?;
|
|
||||||
|
|
||||||
Ok(parse_env_file(&content))
|
Ok(parse_env_file(&content))
|
||||||
}
|
}
|
||||||
@@ -161,8 +160,7 @@ pub fn write_gemini_env_atomic(map: &HashMap<String, String>) -> Result<(), AppE
|
|||||||
|
|
||||||
// 确保目录存在
|
// 确保目录存在
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent)
|
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
.map_err(|e| AppError::io(parent, e))?;
|
|
||||||
|
|
||||||
// 设置目录权限为 700(仅所有者可读写执行)
|
// 设置目录权限为 700(仅所有者可读写执行)
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
@@ -172,8 +170,7 @@ pub fn write_gemini_env_atomic(map: &HashMap<String, String>) -> Result<(), AppE
|
|||||||
.map_err(|e| AppError::io(parent, e))?
|
.map_err(|e| AppError::io(parent, e))?
|
||||||
.permissions();
|
.permissions();
|
||||||
perms.set_mode(0o700);
|
perms.set_mode(0o700);
|
||||||
fs::set_permissions(parent, perms)
|
fs::set_permissions(parent, perms).map_err(|e| AppError::io(parent, e))?;
|
||||||
.map_err(|e| AppError::io(parent, e))?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,8 +185,7 @@ pub fn write_gemini_env_atomic(map: &HashMap<String, String>) -> Result<(), AppE
|
|||||||
.map_err(|e| AppError::io(&path, e))?
|
.map_err(|e| AppError::io(&path, e))?
|
||||||
.permissions();
|
.permissions();
|
||||||
perms.set_mode(0o600);
|
perms.set_mode(0o600);
|
||||||
fs::set_permissions(&path, perms)
|
fs::set_permissions(&path, perms).map_err(|e| AppError::io(&path, e))?;
|
||||||
.map_err(|e| AppError::io(&path, e))?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -263,33 +259,33 @@ fn update_selected_type(selected_type: &str) -> Result<(), AppError> {
|
|||||||
|
|
||||||
// 确保目录存在
|
// 确保目录存在
|
||||||
if let Some(parent) = settings_path.parent() {
|
if let Some(parent) = settings_path.parent() {
|
||||||
fs::create_dir_all(parent)
|
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
.map_err(|e| AppError::io(parent, e))?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 读取现有的 settings.json(如果存在)
|
// 读取现有的 settings.json(如果存在)
|
||||||
let mut settings_content = if settings_path.exists() {
|
let mut settings_content = if settings_path.exists() {
|
||||||
let content = fs::read_to_string(&settings_path)
|
let content =
|
||||||
.map_err(|e| AppError::io(&settings_path, e))?;
|
fs::read_to_string(&settings_path).map_err(|e| AppError::io(&settings_path, e))?;
|
||||||
serde_json::from_str::<Value>(&content)
|
serde_json::from_str::<Value>(&content).unwrap_or_else(|_| serde_json::json!({}))
|
||||||
.unwrap_or_else(|_| serde_json::json!({}))
|
|
||||||
} else {
|
} else {
|
||||||
serde_json::json!({})
|
serde_json::json!({})
|
||||||
};
|
};
|
||||||
|
|
||||||
// 只更新 security.auth.selectedType 字段
|
// 只更新 security.auth.selectedType 字段
|
||||||
if let Some(obj) = settings_content.as_object_mut() {
|
if let Some(obj) = settings_content.as_object_mut() {
|
||||||
let security = obj.entry("security")
|
let security = obj
|
||||||
|
.entry("security")
|
||||||
.or_insert_with(|| serde_json::json!({}));
|
.or_insert_with(|| serde_json::json!({}));
|
||||||
|
|
||||||
if let Some(security_obj) = security.as_object_mut() {
|
if let Some(security_obj) = security.as_object_mut() {
|
||||||
let auth = security_obj.entry("auth")
|
let auth = security_obj
|
||||||
|
.entry("auth")
|
||||||
.or_insert_with(|| serde_json::json!({}));
|
.or_insert_with(|| serde_json::json!({}));
|
||||||
|
|
||||||
if let Some(auth_obj) = auth.as_object_mut() {
|
if let Some(auth_obj) = auth.as_object_mut() {
|
||||||
auth_obj.insert(
|
auth_obj.insert(
|
||||||
"selectedType".to_string(),
|
"selectedType".to_string(),
|
||||||
Value::String(selected_type.to_string())
|
Value::String(selected_type.to_string()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -355,7 +351,10 @@ GEMINI_MODEL=gemini-2.5-pro
|
|||||||
let map = parse_env_file(content);
|
let map = parse_env_file(content);
|
||||||
|
|
||||||
assert_eq!(map.len(), 3);
|
assert_eq!(map.len(), 3);
|
||||||
assert_eq!(map.get("GOOGLE_GEMINI_BASE_URL"), Some(&"https://example.com".to_string()));
|
assert_eq!(
|
||||||
|
map.get("GOOGLE_GEMINI_BASE_URL"),
|
||||||
|
Some(&"https://example.com".to_string())
|
||||||
|
);
|
||||||
assert_eq!(map.get("GEMINI_API_KEY"), Some(&"sk-test123".to_string()));
|
assert_eq!(map.get("GEMINI_API_KEY"), Some(&"sk-test123".to_string()));
|
||||||
assert_eq!(map.get("GEMINI_MODEL"), Some(&"gemini-2.5-pro".to_string()));
|
assert_eq!(map.get("GEMINI_MODEL"), Some(&"gemini-2.5-pro".to_string()));
|
||||||
}
|
}
|
||||||
@@ -380,7 +379,10 @@ GEMINI_MODEL=gemini-2.5-pro
|
|||||||
let json = env_to_json(&env_map);
|
let json = env_to_json(&env_map);
|
||||||
let converted = json_to_env(&json).unwrap();
|
let converted = json_to_env(&json).unwrap();
|
||||||
|
|
||||||
assert_eq!(converted.get("GEMINI_API_KEY"), Some(&"test-key".to_string()));
|
assert_eq!(
|
||||||
|
converted.get("GEMINI_API_KEY"),
|
||||||
|
Some(&"test-key".to_string())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -400,7 +402,10 @@ GEMINI_MODEL=gemini-2.5-pro
|
|||||||
|
|
||||||
let map = result.unwrap();
|
let map = result.unwrap();
|
||||||
assert_eq!(map.len(), 3);
|
assert_eq!(map.len(), 3);
|
||||||
assert_eq!(map.get("GOOGLE_GEMINI_BASE_URL"), Some(&"https://example.com".to_string()));
|
assert_eq!(
|
||||||
|
map.get("GOOGLE_GEMINI_BASE_URL"),
|
||||||
|
Some(&"https://example.com".to_string())
|
||||||
|
);
|
||||||
assert_eq!(map.get("GEMINI_API_KEY"), Some(&"sk-test123".to_string()));
|
assert_eq!(map.get("GEMINI_API_KEY"), Some(&"sk-test123".to_string()));
|
||||||
assert_eq!(map.get("GEMINI_MODEL"), Some(&"gemini-2.5-pro".to_string()));
|
assert_eq!(map.get("GEMINI_MODEL"), Some(&"gemini-2.5-pro".to_string()));
|
||||||
}
|
}
|
||||||
@@ -502,17 +507,19 @@ KEY_WITH-DASH=value";
|
|||||||
|
|
||||||
// 模拟更新 selectedType
|
// 模拟更新 selectedType
|
||||||
if let Some(obj) = existing_settings.as_object_mut() {
|
if let Some(obj) = existing_settings.as_object_mut() {
|
||||||
let security = obj.entry("security")
|
let security = obj
|
||||||
|
.entry("security")
|
||||||
.or_insert_with(|| serde_json::json!({}));
|
.or_insert_with(|| serde_json::json!({}));
|
||||||
|
|
||||||
if let Some(security_obj) = security.as_object_mut() {
|
if let Some(security_obj) = security.as_object_mut() {
|
||||||
let auth = security_obj.entry("auth")
|
let auth = security_obj
|
||||||
|
.entry("auth")
|
||||||
.or_insert_with(|| serde_json::json!({}));
|
.or_insert_with(|| serde_json::json!({}));
|
||||||
|
|
||||||
if let Some(auth_obj) = auth.as_object_mut() {
|
if let Some(auth_obj) = auth.as_object_mut() {
|
||||||
auth_obj.insert(
|
auth_obj.insert(
|
||||||
"selectedType".to_string(),
|
"selectedType".to_string(),
|
||||||
Value::String("gemini-api-key".to_string())
|
Value::String("gemini-api-key".to_string()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -521,8 +528,14 @@ KEY_WITH-DASH=value";
|
|||||||
// 验证所有字段都被保留
|
// 验证所有字段都被保留
|
||||||
assert_eq!(existing_settings["otherField"], "should-be-kept");
|
assert_eq!(existing_settings["otherField"], "should-be-kept");
|
||||||
assert_eq!(existing_settings["security"]["otherSetting"], "also-kept");
|
assert_eq!(existing_settings["security"]["otherSetting"], "also-kept");
|
||||||
assert_eq!(existing_settings["security"]["auth"]["otherAuth"], "preserved");
|
assert_eq!(
|
||||||
assert_eq!(existing_settings["security"]["auth"]["selectedType"], "gemini-api-key");
|
existing_settings["security"]["auth"]["otherAuth"],
|
||||||
|
"preserved"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
existing_settings["security"]["auth"]["selectedType"],
|
||||||
|
"gemini-api-key"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -63,6 +63,129 @@ impl TrayTexts {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct TrayAppSection {
|
||||||
|
app_type: AppType,
|
||||||
|
prefix: &'static str,
|
||||||
|
header_id: &'static str,
|
||||||
|
empty_id: &'static str,
|
||||||
|
header_label: &'static str,
|
||||||
|
log_name: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRAY_SECTIONS: [TrayAppSection; 3] = [
|
||||||
|
TrayAppSection {
|
||||||
|
app_type: AppType::Claude,
|
||||||
|
prefix: "claude_",
|
||||||
|
header_id: "claude_header",
|
||||||
|
empty_id: "claude_empty",
|
||||||
|
header_label: "─── Claude ───",
|
||||||
|
log_name: "Claude",
|
||||||
|
},
|
||||||
|
TrayAppSection {
|
||||||
|
app_type: AppType::Codex,
|
||||||
|
prefix: "codex_",
|
||||||
|
header_id: "codex_header",
|
||||||
|
empty_id: "codex_empty",
|
||||||
|
header_label: "─── Codex ───",
|
||||||
|
log_name: "Codex",
|
||||||
|
},
|
||||||
|
TrayAppSection {
|
||||||
|
app_type: AppType::Gemini,
|
||||||
|
prefix: "gemini_",
|
||||||
|
header_id: "gemini_header",
|
||||||
|
empty_id: "gemini_empty",
|
||||||
|
header_label: "─── Gemini ───",
|
||||||
|
log_name: "Gemini",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
fn append_provider_section<'a>(
|
||||||
|
app: &'a tauri::AppHandle,
|
||||||
|
mut menu_builder: MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>,
|
||||||
|
manager: Option<&crate::provider::ProviderManager>,
|
||||||
|
section: &TrayAppSection,
|
||||||
|
tray_texts: &TrayTexts,
|
||||||
|
) -> Result<MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>, AppError> {
|
||||||
|
let Some(manager) = manager else {
|
||||||
|
return Ok(menu_builder);
|
||||||
|
};
|
||||||
|
|
||||||
|
let header = MenuItem::with_id(
|
||||||
|
app,
|
||||||
|
section.header_id,
|
||||||
|
section.header_label,
|
||||||
|
false,
|
||||||
|
None::<&str>,
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Message(format!("创建{}标题失败: {e}", section.log_name)))?;
|
||||||
|
menu_builder = menu_builder.item(&header);
|
||||||
|
|
||||||
|
if manager.providers.is_empty() {
|
||||||
|
let empty_hint = MenuItem::with_id(
|
||||||
|
app,
|
||||||
|
section.empty_id,
|
||||||
|
tray_texts.no_provider_hint,
|
||||||
|
false,
|
||||||
|
None::<&str>,
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Message(format!("创建{}空提示失败: {e}", section.log_name)))?;
|
||||||
|
return Ok(menu_builder.item(&empty_hint));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sorted_providers: Vec<_> = manager.providers.iter().collect();
|
||||||
|
sorted_providers.sort_by(|(_, a), (_, b)| {
|
||||||
|
match (a.sort_index, b.sort_index) {
|
||||||
|
(Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b),
|
||||||
|
(Some(_), None) => return std::cmp::Ordering::Less,
|
||||||
|
(None, Some(_)) => return std::cmp::Ordering::Greater,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
match (a.created_at, b.created_at) {
|
||||||
|
(Some(time_a), Some(time_b)) => return time_a.cmp(&time_b),
|
||||||
|
(Some(_), None) => return std::cmp::Ordering::Greater,
|
||||||
|
(None, Some(_)) => return std::cmp::Ordering::Less,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.name.cmp(&b.name)
|
||||||
|
});
|
||||||
|
|
||||||
|
for (id, provider) in sorted_providers {
|
||||||
|
let is_current = manager.current == *id;
|
||||||
|
let item = CheckMenuItem::with_id(
|
||||||
|
app,
|
||||||
|
format!("{}{}", section.prefix, id),
|
||||||
|
&provider.name,
|
||||||
|
true,
|
||||||
|
is_current,
|
||||||
|
None::<&str>,
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Message(format!("创建{}菜单项失败: {e}", section.log_name)))?;
|
||||||
|
menu_builder = menu_builder.item(&item);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(menu_builder)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_provider_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool {
|
||||||
|
for section in TRAY_SECTIONS.iter() {
|
||||||
|
if let Some(provider_id) = event_id.strip_prefix(section.prefix) {
|
||||||
|
log::info!("切换到{}供应商: {provider_id}", section.log_name);
|
||||||
|
let app_handle = app.clone();
|
||||||
|
let provider_id = provider_id.to_string();
|
||||||
|
let app_type = section.app_type.clone();
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
if let Err(e) = switch_provider_internal(&app_handle, app_type, provider_id) {
|
||||||
|
log::error!("切换{}供应商失败: {e}", section.log_name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
/// 创建动态托盘菜单
|
/// 创建动态托盘菜单
|
||||||
fn create_tray_menu(
|
fn create_tray_menu(
|
||||||
app: &tauri::AppHandle,
|
app: &tauri::AppHandle,
|
||||||
@@ -82,116 +205,14 @@ fn create_tray_menu(
|
|||||||
menu_builder = menu_builder.item(&show_main_item).separator();
|
menu_builder = menu_builder.item(&show_main_item).separator();
|
||||||
|
|
||||||
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
|
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
|
||||||
if let Some(claude_manager) = config.get_manager(&crate::app_config::AppType::Claude) {
|
for section in TRAY_SECTIONS.iter() {
|
||||||
// 添加Claude标题(禁用状态,仅作为分组标识)
|
menu_builder = append_provider_section(
|
||||||
let claude_header =
|
|
||||||
MenuItem::with_id(app, "claude_header", "─── Claude ───", false, None::<&str>)
|
|
||||||
.map_err(|e| AppError::Message(format!("创建Claude标题失败: {e}")))?;
|
|
||||||
menu_builder = menu_builder.item(&claude_header);
|
|
||||||
|
|
||||||
if !claude_manager.providers.is_empty() {
|
|
||||||
// Sort providers by sortIndex, then by createdAt, then by name
|
|
||||||
let mut sorted_providers: Vec<_> = claude_manager.providers.iter().collect();
|
|
||||||
sorted_providers.sort_by(|(_, a), (_, b)| {
|
|
||||||
// Priority 1: sortIndex
|
|
||||||
match (a.sort_index, b.sort_index) {
|
|
||||||
(Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b),
|
|
||||||
(Some(_), None) => return std::cmp::Ordering::Less,
|
|
||||||
(None, Some(_)) => return std::cmp::Ordering::Greater,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
// Priority 2: createdAt
|
|
||||||
match (a.created_at, b.created_at) {
|
|
||||||
(Some(time_a), Some(time_b)) => return time_a.cmp(&time_b),
|
|
||||||
(Some(_), None) => return std::cmp::Ordering::Greater,
|
|
||||||
(None, Some(_)) => return std::cmp::Ordering::Less,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
// Priority 3: name
|
|
||||||
a.name.cmp(&b.name)
|
|
||||||
});
|
|
||||||
|
|
||||||
for (id, provider) in sorted_providers {
|
|
||||||
let is_current = claude_manager.current == *id;
|
|
||||||
let item = CheckMenuItem::with_id(
|
|
||||||
app,
|
app,
|
||||||
format!("claude_{id}"),
|
menu_builder,
|
||||||
&provider.name,
|
config.get_manager(§ion.app_type),
|
||||||
true,
|
section,
|
||||||
is_current,
|
&tray_texts,
|
||||||
None::<&str>,
|
)?;
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Message(format!("创建菜单项失败: {e}")))?;
|
|
||||||
menu_builder = menu_builder.item(&item);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 没有供应商时显示提示
|
|
||||||
let empty_hint = MenuItem::with_id(
|
|
||||||
app,
|
|
||||||
"claude_empty",
|
|
||||||
tray_texts.no_provider_hint,
|
|
||||||
false,
|
|
||||||
None::<&str>,
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Message(format!("创建Claude空提示失败: {e}")))?;
|
|
||||||
menu_builder = menu_builder.item(&empty_hint);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(codex_manager) = config.get_manager(&crate::app_config::AppType::Codex) {
|
|
||||||
// 添加Codex标题(禁用状态,仅作为分组标识)
|
|
||||||
let codex_header =
|
|
||||||
MenuItem::with_id(app, "codex_header", "─── Codex ───", false, None::<&str>)
|
|
||||||
.map_err(|e| AppError::Message(format!("创建Codex标题失败: {e}")))?;
|
|
||||||
menu_builder = menu_builder.item(&codex_header);
|
|
||||||
|
|
||||||
if !codex_manager.providers.is_empty() {
|
|
||||||
// Sort providers by sortIndex, then by createdAt, then by name
|
|
||||||
let mut sorted_providers: Vec<_> = codex_manager.providers.iter().collect();
|
|
||||||
sorted_providers.sort_by(|(_, a), (_, b)| {
|
|
||||||
// Priority 1: sortIndex
|
|
||||||
match (a.sort_index, b.sort_index) {
|
|
||||||
(Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b),
|
|
||||||
(Some(_), None) => return std::cmp::Ordering::Less,
|
|
||||||
(None, Some(_)) => return std::cmp::Ordering::Greater,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
// Priority 2: createdAt
|
|
||||||
match (a.created_at, b.created_at) {
|
|
||||||
(Some(time_a), Some(time_b)) => return time_a.cmp(&time_b),
|
|
||||||
(Some(_), None) => return std::cmp::Ordering::Greater,
|
|
||||||
(None, Some(_)) => return std::cmp::Ordering::Less,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
// Priority 3: name
|
|
||||||
a.name.cmp(&b.name)
|
|
||||||
});
|
|
||||||
|
|
||||||
for (id, provider) in sorted_providers {
|
|
||||||
let is_current = codex_manager.current == *id;
|
|
||||||
let item = CheckMenuItem::with_id(
|
|
||||||
app,
|
|
||||||
format!("codex_{id}"),
|
|
||||||
&provider.name,
|
|
||||||
true,
|
|
||||||
is_current,
|
|
||||||
None::<&str>,
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Message(format!("创建菜单项失败: {e}")))?;
|
|
||||||
menu_builder = menu_builder.item(&item);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 没有供应商时显示提示
|
|
||||||
let empty_hint = MenuItem::with_id(
|
|
||||||
app,
|
|
||||||
"codex_empty",
|
|
||||||
tray_texts.no_provider_hint,
|
|
||||||
false,
|
|
||||||
None::<&str>,
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Message(format!("创建Codex空提示失败: {e}")))?;
|
|
||||||
menu_builder = menu_builder.item(&empty_hint);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分隔符和退出菜单
|
// 分隔符和退出菜单
|
||||||
@@ -246,47 +267,10 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
|||||||
log::info!("退出应用");
|
log::info!("退出应用");
|
||||||
app.exit(0);
|
app.exit(0);
|
||||||
}
|
}
|
||||||
id if id.starts_with("claude_") => {
|
|
||||||
let Some(provider_id) = id.strip_prefix("claude_") else {
|
|
||||||
log::error!("无效的 Claude 菜单项 ID: {id}");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
log::info!("切换到Claude供应商: {provider_id}");
|
|
||||||
|
|
||||||
// 执行切换
|
|
||||||
let app_handle = app.clone();
|
|
||||||
let provider_id = provider_id.to_string();
|
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
|
||||||
if let Err(e) = switch_provider_internal(
|
|
||||||
&app_handle,
|
|
||||||
crate::app_config::AppType::Claude,
|
|
||||||
provider_id,
|
|
||||||
) {
|
|
||||||
log::error!("切换Claude供应商失败: {e}");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
id if id.starts_with("codex_") => {
|
|
||||||
let Some(provider_id) = id.strip_prefix("codex_") else {
|
|
||||||
log::error!("无效的 Codex 菜单项 ID: {id}");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
log::info!("切换到Codex供应商: {provider_id}");
|
|
||||||
|
|
||||||
// 执行切换
|
|
||||||
let app_handle = app.clone();
|
|
||||||
let provider_id = provider_id.to_string();
|
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
|
||||||
if let Err(e) = switch_provider_internal(
|
|
||||||
&app_handle,
|
|
||||||
crate::app_config::AppType::Codex,
|
|
||||||
provider_id,
|
|
||||||
) {
|
|
||||||
log::error!("切换Codex供应商失败: {e}");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
_ => {
|
_ => {
|
||||||
|
if handle_provider_tray_event(app, event_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
log::warn!("未处理的菜单事件: {event_id}");
|
log::warn!("未处理的菜单事件: {event_id}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,9 +136,7 @@ fn normalize_server_keys(map: &mut HashMap<String, Value>) -> usize {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if map.contains_key(&new_key) {
|
if map.contains_key(&new_key) {
|
||||||
log::warn!(
|
log::warn!("MCP 条目 '{old_key}' 的内部 id '{new_key}' 与现有键冲突,回退为原键");
|
||||||
"MCP 条目 '{old_key}' 的内部 id '{new_key}' 与现有键冲突,回退为原键"
|
|
||||||
);
|
|
||||||
if let Some(value) = map.get_mut(&old_key) {
|
if let Some(value) = map.get_mut(&old_key) {
|
||||||
if let Some(obj) = value.as_object_mut() {
|
if let Some(obj) = value.as_object_mut() {
|
||||||
if obj
|
if obj
|
||||||
|
|||||||
@@ -173,9 +173,7 @@ impl ConfigService {
|
|||||||
AppError::Config(format!("供应商 {provider_id} 的 Codex 配置必须是对象"))
|
AppError::Config(format!("供应商 {provider_id} 的 Codex 配置必须是对象"))
|
||||||
})?;
|
})?;
|
||||||
let auth = settings.get("auth").ok_or_else(|| {
|
let auth = settings.get("auth").ok_or_else(|| {
|
||||||
AppError::Config(format!(
|
AppError::Config(format!("供应商 {provider_id} 的 Codex 配置缺少 auth 字段"))
|
||||||
"供应商 {provider_id} 的 Codex 配置缺少 auth 字段"
|
|
||||||
))
|
|
||||||
})?;
|
})?;
|
||||||
if !auth.is_object() {
|
if !auth.is_object() {
|
||||||
return Err(AppError::Config(format!(
|
return Err(AppError::Config(format!(
|
||||||
@@ -231,7 +229,9 @@ impl ConfigService {
|
|||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
provider: &Provider,
|
provider: &Provider,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
use crate::gemini_config::{json_to_env, write_gemini_env_atomic, read_gemini_env, env_to_json};
|
use crate::gemini_config::{
|
||||||
|
env_to_json, json_to_env, read_gemini_env, write_gemini_env_atomic,
|
||||||
|
};
|
||||||
|
|
||||||
let env_path = crate::gemini_config::get_gemini_env_path();
|
let env_path = crate::gemini_config::get_gemini_env_path();
|
||||||
if let Some(parent) = env_path.parent() {
|
if let Some(parent) = env_path.parent() {
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ impl McpService {
|
|||||||
match app {
|
match app {
|
||||||
AppType::Claude => mcp::sync_enabled_to_claude(&snapshot)?,
|
AppType::Claude => mcp::sync_enabled_to_claude(&snapshot)?,
|
||||||
AppType::Codex => mcp::sync_enabled_to_codex(&snapshot)?,
|
AppType::Codex => mcp::sync_enabled_to_codex(&snapshot)?,
|
||||||
AppType::Gemini => {}, // Gemini 暂不支持 MCP 同步
|
AppType::Gemini => {} // Gemini 暂不支持 MCP 同步
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,7 +148,7 @@ impl McpService {
|
|||||||
match app {
|
match app {
|
||||||
AppType::Claude => mcp::sync_enabled_to_claude(&snapshot)?,
|
AppType::Claude => mcp::sync_enabled_to_claude(&snapshot)?,
|
||||||
AppType::Codex => mcp::sync_enabled_to_codex(&snapshot)?,
|
AppType::Codex => mcp::sync_enabled_to_codex(&snapshot)?,
|
||||||
AppType::Gemini => {}, // Gemini 暂不支持 MCP 同步
|
AppType::Gemini => {} // Gemini 暂不支持 MCP 同步
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,7 +168,7 @@ impl McpService {
|
|||||||
match app {
|
match app {
|
||||||
AppType::Claude => mcp::sync_enabled_to_claude(&snapshot)?,
|
AppType::Claude => mcp::sync_enabled_to_claude(&snapshot)?,
|
||||||
AppType::Codex => mcp::sync_enabled_to_codex(&snapshot)?,
|
AppType::Codex => mcp::sync_enabled_to_codex(&snapshot)?,
|
||||||
AppType::Gemini => {}, // Gemini 暂不支持 MCP 同步
|
AppType::Gemini => {} // Gemini 暂不支持 MCP 同步
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ impl LiveSnapshot {
|
|||||||
delete_file(&config_path)?;
|
delete_file(&config_path)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LiveSnapshot::Gemini { env } => { // 新增
|
LiveSnapshot::Gemini { env } => {
|
||||||
|
// 新增
|
||||||
use crate::gemini_config::{get_gemini_env_path, write_gemini_env_atomic};
|
use crate::gemini_config::{get_gemini_env_path, write_gemini_env_atomic};
|
||||||
let path = get_gemini_env_path();
|
let path = get_gemini_env_path();
|
||||||
if let Some(env_map) = env {
|
if let Some(env_map) = env {
|
||||||
@@ -502,9 +503,7 @@ impl ProviderService {
|
|||||||
return Err(AppError::localized(
|
return Err(AppError::localized(
|
||||||
"config.save.rollback_failed",
|
"config.save.rollback_failed",
|
||||||
format!("保存配置失败: {save_err};回滚失败: {rollback_err}"),
|
format!("保存配置失败: {save_err};回滚失败: {rollback_err}"),
|
||||||
format!(
|
format!("Failed to save config: {save_err}; rollback failed: {rollback_err}"),
|
||||||
"Failed to save config: {save_err}; rollback failed: {rollback_err}"
|
|
||||||
),
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return Err(save_err);
|
return Err(save_err);
|
||||||
@@ -518,9 +517,7 @@ impl ProviderService {
|
|||||||
return Err(AppError::localized(
|
return Err(AppError::localized(
|
||||||
"post_commit.rollback_failed",
|
"post_commit.rollback_failed",
|
||||||
format!("后置操作失败: {err};回滚失败: {rollback_err}"),
|
format!("后置操作失败: {err};回滚失败: {rollback_err}"),
|
||||||
format!(
|
format!("Post-commit step failed: {err}; rollback failed: {rollback_err}"),
|
||||||
"Post-commit step failed: {err}; rollback failed: {rollback_err}"
|
|
||||||
),
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return Err(err);
|
return Err(err);
|
||||||
@@ -618,7 +615,7 @@ impl ProviderService {
|
|||||||
state.save()?;
|
state.save()?;
|
||||||
}
|
}
|
||||||
AppType::Gemini => {
|
AppType::Gemini => {
|
||||||
use crate::gemini_config::{get_gemini_env_path, read_gemini_env, env_to_json};
|
use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};
|
||||||
|
|
||||||
let env_path = get_gemini_env_path();
|
let env_path = get_gemini_env_path();
|
||||||
if !env_path.exists() {
|
if !env_path.exists() {
|
||||||
@@ -674,7 +671,8 @@ impl ProviderService {
|
|||||||
};
|
};
|
||||||
Ok(LiveSnapshot::Codex { auth, config })
|
Ok(LiveSnapshot::Codex { auth, config })
|
||||||
}
|
}
|
||||||
AppType::Gemini => { // 新增
|
AppType::Gemini => {
|
||||||
|
// 新增
|
||||||
use crate::gemini_config::{get_gemini_env_path, read_gemini_env};
|
use crate::gemini_config::{get_gemini_env_path, read_gemini_env};
|
||||||
let path = get_gemini_env_path();
|
let path = get_gemini_env_path();
|
||||||
let env = if path.exists() {
|
let env = if path.exists() {
|
||||||
@@ -851,8 +849,9 @@ impl ProviderService {
|
|||||||
let _ = Self::normalize_claude_models_in_value(&mut v);
|
let _ = Self::normalize_claude_models_in_value(&mut v);
|
||||||
v
|
v
|
||||||
}
|
}
|
||||||
AppType::Gemini => { // 新增
|
AppType::Gemini => {
|
||||||
use crate::gemini_config::{get_gemini_env_path, read_gemini_env, env_to_json};
|
// 新增
|
||||||
|
use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};
|
||||||
|
|
||||||
let path = get_gemini_env_path();
|
let path = get_gemini_env_path();
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
@@ -917,8 +916,9 @@ impl ProviderService {
|
|||||||
}
|
}
|
||||||
read_json_file(&path)
|
read_json_file(&path)
|
||||||
}
|
}
|
||||||
AppType::Gemini => { // 新增
|
AppType::Gemini => {
|
||||||
use crate::gemini_config::{get_gemini_env_path, read_gemini_env, env_to_json};
|
// 新增
|
||||||
|
use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};
|
||||||
|
|
||||||
let path = get_gemini_env_path();
|
let path = get_gemini_env_path();
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
@@ -1429,7 +1429,7 @@ impl ProviderService {
|
|||||||
config: &mut MultiAppConfig,
|
config: &mut MultiAppConfig,
|
||||||
next_provider: &str,
|
next_provider: &str,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
use crate::gemini_config::{get_gemini_env_path, read_gemini_env, env_to_json};
|
use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};
|
||||||
|
|
||||||
let env_path = get_gemini_env_path();
|
let env_path = get_gemini_env_path();
|
||||||
if !env_path.exists() {
|
if !env_path.exists() {
|
||||||
@@ -1464,7 +1464,9 @@ impl ProviderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
|
fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
|
||||||
use crate::gemini_config::{json_to_env, validate_gemini_settings, write_gemini_env_atomic};
|
use crate::gemini_config::{
|
||||||
|
json_to_env, validate_gemini_settings, write_gemini_env_atomic,
|
||||||
|
};
|
||||||
|
|
||||||
// 一次性检测认证类型,避免重复检测
|
// 一次性检测认证类型,避免重复检测
|
||||||
let auth_type = Self::detect_gemini_auth_type(provider);
|
let auth_type = Self::detect_gemini_auth_type(provider);
|
||||||
@@ -1553,7 +1555,8 @@ impl ProviderService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AppType::Gemini => { // 新增
|
AppType::Gemini => {
|
||||||
|
// 新增
|
||||||
use crate::gemini_config::validate_gemini_settings;
|
use crate::gemini_config::validate_gemini_settings;
|
||||||
validate_gemini_settings(&provider.settings_config)?
|
validate_gemini_settings(&provider.settings_config)?
|
||||||
}
|
}
|
||||||
@@ -1667,19 +1670,19 @@ impl ProviderService {
|
|||||||
|
|
||||||
Ok((api_key, base_url))
|
Ok((api_key, base_url))
|
||||||
}
|
}
|
||||||
AppType::Gemini => { // 新增
|
AppType::Gemini => {
|
||||||
|
// 新增
|
||||||
use crate::gemini_config::json_to_env;
|
use crate::gemini_config::json_to_env;
|
||||||
|
|
||||||
let env_map = json_to_env(&provider.settings_config)?;
|
let env_map = json_to_env(&provider.settings_config)?;
|
||||||
|
|
||||||
let api_key = env_map
|
let api_key = env_map.get("GEMINI_API_KEY").cloned().ok_or_else(|| {
|
||||||
.get("GEMINI_API_KEY")
|
AppError::localized(
|
||||||
.cloned()
|
|
||||||
.ok_or_else(|| AppError::localized(
|
|
||||||
"gemini.missing_api_key",
|
"gemini.missing_api_key",
|
||||||
"缺少 GEMINI_API_KEY",
|
"缺少 GEMINI_API_KEY",
|
||||||
"Missing GEMINI_API_KEY",
|
"Missing GEMINI_API_KEY",
|
||||||
))?;
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let base_url = env_map
|
let base_url = env_map
|
||||||
.get("GOOGLE_GEMINI_BASE_URL")
|
.get("GOOGLE_GEMINI_BASE_URL")
|
||||||
|
|||||||
@@ -220,9 +220,7 @@ fn packycode_partner_meta_triggers_security_flag_even_without_keywords() {
|
|||||||
partner_promotion_key: Some("packycode".to_string()),
|
partner_promotion_key: Some("packycode".to_string()),
|
||||||
..ProviderMeta::default()
|
..ProviderMeta::default()
|
||||||
});
|
});
|
||||||
manager
|
manager.providers.insert("packy-meta".to_string(), provider);
|
||||||
.providers
|
|
||||||
.insert("packy-meta".to_string(), provider);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
|
|||||||
Reference in New Issue
Block a user