feat: 系统托盘功能 + 允许添加截断凭证

- 实现完整的系统托盘功能
- 托盘图标状态显示(运行/警告/错误/停止)
- 托盘菜单:服务器控制、凭证状态、快捷工具
- 单击/双击托盘图标交互
- 状态同步机制
- 修复:允许添加被截断的 refreshToken 凭证,刷新时才提示

版本更新: 0.12.5 -> 0.13.0
This commit is contained in:
coso
2025-12-19 02:01:10 +08:00
parent 1fe662d1e3
commit b94fc6e62f
24 changed files with 2478 additions and 18 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "proxycast",
"version": "0.12.3",
"version": "0.12.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "proxycast",
"version": "0.12.3",
"version": "0.12.5",
"dependencies": {
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.12.5",
"version": "0.13.0",
"type": "module",
"scripts": {
"dev": "vite",
+124
View File
@@ -0,0 +1,124 @@
/**
* 生成托盘图标脚本
*
* 创建四种状态的托盘图标:
* - tray-running.png: 绿色圆形(正常运行)
* - tray-warning.png: 黄色圆形(警告状态)
* - tray-error.png: 红色圆形(错误状态)
* - tray-stopped.png: 灰色圆形(停止状态)
*
* 对于 macOS,还会生成模板图标以适应深色/浅色模式
*/
import sharp from 'sharp';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { mkdirSync, existsSync } from 'fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const ICON_SIZE = 32;
const OUTPUT_DIR = join(__dirname, '..', 'src-tauri', 'icons', 'tray');
// 确保输出目录存在
if (!existsSync(OUTPUT_DIR)) {
mkdirSync(OUTPUT_DIR, { recursive: true });
}
/**
* 创建圆形图标的 SVG
* @param {string} color - 填充颜色
* @param {boolean} isTemplate - 是否为模板图标(macOS 深色/浅色模式)
*/
function createCircleSvg(color, isTemplate = false) {
const fillColor = isTemplate ? '#000000' : color;
const opacity = isTemplate ? '1' : '1';
return `
<svg width="${ICON_SIZE}" height="${ICON_SIZE}" viewBox="0 0 ${ICON_SIZE} ${ICON_SIZE}" xmlns="http://www.w3.org/2000/svg">
<circle cx="${ICON_SIZE / 2}" cy="${ICON_SIZE / 2}" r="${ICON_SIZE / 2 - 2}" fill="${fillColor}" fill-opacity="${opacity}"/>
</svg>
`.trim();
}
/**
* 创建带边框的圆形图标 SVG(更好的可见性)
* @param {string} fillColor - 填充颜色
* @param {string} strokeColor - 边框颜色
*/
function createCircleWithBorderSvg(fillColor, strokeColor) {
return `
<svg width="${ICON_SIZE}" height="${ICON_SIZE}" viewBox="0 0 ${ICON_SIZE} ${ICON_SIZE}" xmlns="http://www.w3.org/2000/svg">
<circle cx="${ICON_SIZE / 2}" cy="${ICON_SIZE / 2}" r="${ICON_SIZE / 2 - 3}" fill="${fillColor}" stroke="${strokeColor}" stroke-width="2"/>
</svg>
`.trim();
}
/**
* 生成图标文件
* @param {string} name - 文件名(不含扩展名)
* @param {string} svg - SVG 内容
*/
async function generateIcon(name, svg) {
const outputPath = join(OUTPUT_DIR, `${name}.png`);
await sharp(Buffer.from(svg))
.resize(ICON_SIZE, ICON_SIZE)
.png()
.toFile(outputPath);
console.log(`✓ 生成图标: ${outputPath}`);
}
/**
* 生成 macOS 模板图标(@2x 版本)
* @param {string} name - 文件名(不含扩展名)
* @param {string} svg - SVG 内容
*/
async function generateTemplateIcon(name, svg) {
// 标准尺寸
const outputPath = join(OUTPUT_DIR, `${name}Template.png`);
await sharp(Buffer.from(svg))
.resize(ICON_SIZE, ICON_SIZE)
.png()
.toFile(outputPath);
console.log(`✓ 生成模板图标: ${outputPath}`);
// @2x 版本
const output2xPath = join(OUTPUT_DIR, `${name}Template@2x.png`);
await sharp(Buffer.from(svg))
.resize(ICON_SIZE * 2, ICON_SIZE * 2)
.png()
.toFile(output2xPath);
console.log(`✓ 生成模板图标 @2x: ${output2xPath}`);
}
async function main() {
console.log('开始生成托盘图标...\n');
// 定义图标颜色
const icons = [
{ name: 'tray-running', fill: '#22c55e', stroke: '#16a34a' }, // 绿色
{ name: 'tray-warning', fill: '#eab308', stroke: '#ca8a04' }, // 黄色
{ name: 'tray-error', fill: '#ef4444', stroke: '#dc2626' }, // 红色
{ name: 'tray-stopped', fill: '#9ca3af', stroke: '#6b7280' }, // 灰色
];
// 生成彩色图标
for (const icon of icons) {
const svg = createCircleWithBorderSvg(icon.fill, icon.stroke);
await generateIcon(icon.name, svg);
}
console.log('\n生成 macOS 模板图标...\n');
// 生成 macOS 模板图标(黑色,系统会自动适应深色/浅色模式)
const templateSvg = createCircleSvg('#000000', true);
await generateTemplateIcon('tray', templateSvg);
console.log('\n✅ 所有图标生成完成!');
console.log(`📁 输出目录: ${OUTPUT_DIR}`);
}
main().catch(console.error);
+20 -1
View File
@@ -432,6 +432,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.11.0"
@@ -2066,6 +2072,18 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db35664ce6b9810857a38a906215e75a9c879f0696556a39f59c62829710251a"
dependencies = [
"bytemuck",
"byteorder-lite",
"num-traits",
"png",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -3349,7 +3367,7 @@ dependencies = [
[[package]]
name = "proxycast"
version = "0.12.5"
version = "0.13.0"
dependencies = [
"anyhow",
"async-stream",
@@ -4534,6 +4552,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"image",
"jni",
"libc",
"log",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "proxycast"
version = "0.12.5"
version = "0.13.0"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
@@ -13,7 +13,7 @@ crate-type = ["lib", "cdylib", "staticlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-shell = "2"
tauri-plugin-autostart = "2"
tauri-plugin-dialog = "2"
Binary file not shown.

After

Width:  |  Height:  |  Size: 653 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 727 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 715 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 715 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 B

+1
View File
@@ -11,5 +11,6 @@ pub mod router_cmd;
pub mod skill_cmd;
pub mod switch_cmd;
pub mod telemetry_cmd;
pub mod tray_cmd;
pub mod usage_cmd;
pub mod websocket_cmd;
+8 -10
View File
@@ -87,8 +87,9 @@ fn copy_and_rename_credential_file(
let mut creds: serde_json::Value =
serde_json::from_str(&content).map_err(|e| format!("解析凭证文件失败: {}", e))?;
// 检测 refreshToken 是否被截断
// 检测 refreshToken 是否被截断(仅记录警告,不阻止添加)
// 正常的 refreshToken 长度应该在 500+ 字符,如果小于 100 字符则可能被截断
// 注意:即使 refreshToken 被截断,也允许添加凭证,在刷新时才会提示错误
if let Some(refresh_token) = creds.get("refreshToken").and_then(|v| v.as_str()) {
let token_len = refresh_token.len();
@@ -97,19 +98,16 @@ fn copy_and_rename_credential_file(
token_len < 100 || refresh_token.ends_with("...") || refresh_token.contains("...");
if is_truncated {
tracing::error!(
"[KIRO] 检测到 refreshToken 被截断!长度: {}, 内容: {}",
tracing::warn!(
"[KIRO] 检测到 refreshToken 可能被截断!长度: {}, 内容: {}... (仍允许添加,刷新时会提示)",
token_len,
&refresh_token[..std::cmp::min(50, token_len)]
);
return Err(format!(
"凭证文件中的 refreshToken 已被截断(长度: {} 字符)。\n\n⚠️ 这通常是 Kiro IDE 为了防止凭证被第三方工具使用而故意截断的。\n\n💡 解决方案:\n1. 使用 Kir-Manager 工具获取完整的凭证\n2. 或者使用其他方式获取未截断的凭证文件\n3. 正常的 refreshToken 长度应该在 500+ 字符\n\n当前 refreshToken: {}...",
token_len,
&refresh_token[..std::cmp::min(30, token_len)]
));
// 不再阻止添加,只记录警告
// 在刷新 Token 时会检测并提示用户
} else {
tracing::info!("[KIRO] refreshToken 长度检查通过: {} 字符", token_len);
}
tracing::info!("[KIRO] refreshToken 长度检查通过: {} 字符", token_len);
} else {
tracing::warn!("[KIRO] 凭证文件中没有 refreshToken 字段");
}
+263
View File
@@ -0,0 +1,263 @@
//! 托盘相关命令
//!
//! 提供托盘状态同步和更新的 Tauri 命令
//!
//! # Requirements
//! - 7.1: API 服务器状态变化时在 1 秒内更新托盘图标
//! - 7.2: 凭证健康状态变化时在 1 秒内更新托盘图标
//! - 7.3: 托盘菜单打开时获取并显示最新信息
use crate::tray::{CredentialHealth, TrayIconStatus, TrayStateSnapshot};
use crate::TrayManagerState;
use tauri::State;
use tracing::{debug, error, info};
/// 同步托盘状态
///
/// 从前端或其他模块调用,更新托盘的完整状态
///
/// # Requirements
/// - 7.1: API 服务器状态变化时更新托盘图标
/// - 7.2: 凭证健康状态变化时更新托盘图标
#[tauri::command]
pub async fn sync_tray_state(
tray_state: State<'_, TrayManagerState<tauri::Wry>>,
server_running: bool,
server_address: String,
available_credentials: usize,
total_credentials: usize,
today_requests: u64,
auto_start_enabled: bool,
) -> Result<(), String> {
let tray_guard = tray_state.0.read().await;
let tray_manager = tray_guard
.as_ref()
.ok_or_else(|| "托盘管理器未初始化".to_string())?;
// 计算图标状态
let icon_status = if !server_running {
TrayIconStatus::Stopped
} else if available_credentials == 0 && total_credentials > 0 {
TrayIconStatus::Error
} else if available_credentials < total_credentials {
TrayIconStatus::Warning
} else {
TrayIconStatus::Running
};
let snapshot = TrayStateSnapshot {
icon_status,
server_running,
server_address,
available_credentials,
total_credentials,
today_requests,
auto_start_enabled,
};
tray_manager
.update_state(snapshot)
.await
.map_err(|e| e.to_string())?;
debug!(
"托盘状态已同步: server_running={}, icon_status={:?}",
server_running, icon_status
);
Ok(())
}
/// 更新托盘服务器状态
///
/// 仅更新服务器运行状态
///
/// # Requirements
/// - 7.1: API 服务器状态变化时在 1 秒内更新托盘图标
#[tauri::command]
pub async fn update_tray_server_status(
tray_state: State<'_, TrayManagerState<tauri::Wry>>,
server_running: bool,
server_host: String,
server_port: u16,
) -> Result<(), String> {
let tray_guard = tray_state.0.read().await;
let tray_manager = tray_guard
.as_ref()
.ok_or_else(|| "托盘管理器未初始化".to_string())?;
// 获取当前状态
let mut current_state = tray_manager.get_state().await;
// 更新服务器相关字段
current_state.server_running = server_running;
current_state.server_address = if server_running {
format!("{}:{}", server_host, server_port)
} else {
String::new()
};
// 重新计算图标状态
if !server_running {
current_state.icon_status = TrayIconStatus::Stopped;
} else if current_state.icon_status == TrayIconStatus::Stopped {
current_state.icon_status = TrayIconStatus::Running;
}
tray_manager
.update_state(current_state)
.await
.map_err(|e| e.to_string())?;
info!(
"托盘服务器状态已更新: running={}, address={}:{}",
server_running, server_host, server_port
);
Ok(())
}
/// 更新托盘凭证状态
///
/// 仅更新凭证健康状态
///
/// # Requirements
/// - 7.2: 凭证健康状态变化时在 1 秒内更新托盘图标
#[tauri::command]
pub async fn update_tray_credential_status(
tray_state: State<'_, TrayManagerState<tauri::Wry>>,
available_credentials: usize,
total_credentials: usize,
has_warning: bool,
) -> Result<(), String> {
let tray_guard = tray_state.0.read().await;
let tray_manager = tray_guard
.as_ref()
.ok_or_else(|| "托盘管理器未初始化".to_string())?;
// 获取当前状态
let mut current_state = tray_manager.get_state().await;
// 更新凭证相关字段
current_state.available_credentials = available_credentials;
current_state.total_credentials = total_credentials;
// 重新计算图标状态
if current_state.server_running {
if available_credentials == 0 && total_credentials > 0 {
current_state.icon_status = TrayIconStatus::Error;
} else if has_warning || available_credentials < total_credentials {
current_state.icon_status = TrayIconStatus::Warning;
} else {
current_state.icon_status = TrayIconStatus::Running;
}
}
tray_manager
.update_state(current_state)
.await
.map_err(|e| e.to_string())?;
info!(
"托盘凭证状态已更新: available={}/{}, has_warning={}",
available_credentials, total_credentials, has_warning
);
Ok(())
}
/// 获取托盘当前状态
///
/// 返回托盘的当前状态快照
#[tauri::command]
pub async fn get_tray_state(
tray_state: State<'_, TrayManagerState<tauri::Wry>>,
) -> Result<TrayStateSnapshot, String> {
let tray_guard = tray_state.0.read().await;
let tray_manager = tray_guard
.as_ref()
.ok_or_else(|| "托盘管理器未初始化".to_string())?;
Ok(tray_manager.get_state().await)
}
/// 刷新托盘菜单
///
/// 强制刷新托盘菜单内容
///
/// # Requirements
/// - 7.3: 托盘菜单打开时获取并显示最新信息
#[tauri::command]
pub async fn refresh_tray_menu(
tray_state: State<'_, TrayManagerState<tauri::Wry>>,
) -> Result<(), String> {
let tray_guard = tray_state.0.read().await;
let tray_manager = tray_guard
.as_ref()
.ok_or_else(|| "托盘管理器未初始化".to_string())?;
tray_manager
.refresh_menu()
.await
.map_err(|e| e.to_string())?;
debug!("托盘菜单已刷新");
Ok(())
}
/// 刷新托盘菜单并更新统计数据
///
/// 在菜单打开时调用,获取最新的统计数据并刷新菜单
///
/// # Requirements
/// - 7.3: 托盘菜单打开时获取并显示最新信息
#[tauri::command]
pub async fn refresh_tray_with_stats(
tray_state: State<'_, TrayManagerState<tauri::Wry>>,
server_running: bool,
server_address: String,
available_credentials: usize,
total_credentials: usize,
today_requests: u64,
auto_start_enabled: bool,
) -> Result<(), String> {
let tray_guard = tray_state.0.read().await;
let tray_manager = tray_guard
.as_ref()
.ok_or_else(|| "托盘管理器未初始化".to_string())?;
// 计算图标状态
let icon_status = if !server_running {
TrayIconStatus::Stopped
} else if available_credentials == 0 && total_credentials > 0 {
TrayIconStatus::Error
} else if available_credentials < total_credentials {
TrayIconStatus::Warning
} else {
TrayIconStatus::Running
};
let snapshot = TrayStateSnapshot {
icon_status,
server_running,
server_address,
available_credentials,
total_credentials,
today_requests,
auto_start_enabled,
};
// 更新状态并刷新菜单
tray_manager
.update_state(snapshot)
.await
.map_err(|e| e.to_string())?;
debug!(
"托盘菜单已刷新: server_running={}, requests={}, credentials={}/{}",
server_running, today_requests, available_credentials, total_credentials
);
Ok(())
}
+74 -1
View File
@@ -16,10 +16,12 @@ pub mod router;
mod server;
mod services;
pub mod telemetry;
pub mod tray;
pub mod websocket;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::{Manager, Runtime};
use tokio::sync::RwLock;
use commands::provider_pool_cmd::{CredentialSyncServiceState, ProviderPoolServiceState};
@@ -29,10 +31,18 @@ use commands::skill_cmd::SkillServiceState;
use services::provider_pool_service::ProviderPoolService;
use services::skill_service::SkillService;
use services::token_cache_service::TokenCacheService;
use tray::{
calculate_icon_status, CredentialHealth, TrayIconStatus, TrayManager, TrayStateSnapshot,
};
/// TokenCacheService 状态封装
pub struct TokenCacheServiceState(pub Arc<TokenCacheService>);
/// TrayManager 状态封装
///
/// 用于在 Tauri 状态管理中存储托盘管理器
pub struct TrayManagerState<R: Runtime>(pub Arc<tokio::sync::RwLock<Option<TrayManager<R>>>>);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProviderType {
@@ -1417,7 +1427,25 @@ pub fn run() {
.manage(router_config_state)
.manage(resilience_config_state)
.manage(telemetry_state)
.setup(move |_app| {
.setup(move |app| {
// 初始化托盘管理器
// Requirements 1.4: 应用启动时显示停止状态图标
match TrayManager::new(app.handle()) {
Ok(tray_manager) => {
tracing::info!("[启动] 托盘管理器初始化成功");
// 将托盘管理器存储到应用状态中
let tray_state: TrayManagerState<tauri::Wry> =
TrayManagerState(Arc::new(tokio::sync::RwLock::new(Some(tray_manager))));
app.manage(tray_state);
}
Err(e) => {
tracing::error!("[启动] 托盘管理器初始化失败: {}", e);
// 即使托盘初始化失败,应用仍然可以运行
let tray_state: TrayManagerState<tauri::Wry> =
TrayManagerState(Arc::new(tokio::sync::RwLock::new(None)));
app.manage(tray_state);
}
}
// 自动启动服务器
let state = state_clone.clone();
let logs = logs_clone.clone();
@@ -1427,6 +1455,7 @@ pub fn run() {
let shared_stats = shared_stats_clone.clone();
let shared_tokens = shared_tokens_clone.clone();
let shared_logger = shared_logger_clone.clone();
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
// 先加载凭证
{
@@ -1440,6 +1469,8 @@ pub fn run() {
}
}
// 启动服务器(使用共享的遥测实例)
let server_started;
let server_address;
{
let mut s = state.write().await;
logs.write()
@@ -1463,11 +1494,46 @@ pub fn run() {
logs.write()
.await
.add("info", &format!("[启动] 服务器已启动: {host}:{port}"));
server_started = true;
server_address = format!("{}:{}", host, port);
}
Err(e) => {
logs.write()
.await
.add("error", &format!("[启动] 服务器启动失败: {e}"));
server_started = false;
server_address = String::new();
}
}
}
// 更新托盘状态
// Requirements 7.1: API 服务器状态变化时更新托盘图标
if let Some(tray_state) = app_handle.try_state::<TrayManagerState<tauri::Wry>>() {
let tray_guard = tray_state.0.read().await;
if let Some(tray_manager) = tray_guard.as_ref() {
// 计算初始图标状态
// 服务器刚启动时,假设凭证健康(后续会通过状态同步更新)
let icon_status = if server_started {
TrayIconStatus::Running
} else {
TrayIconStatus::Stopped
};
let snapshot = TrayStateSnapshot {
icon_status,
server_running: server_started,
server_address,
available_credentials: 0, // 初始值,后续通过状态同步更新
total_credentials: 0,
today_requests: 0,
auto_start_enabled: false, // 后续通过状态同步更新
};
if let Err(e) = tray_manager.update_state(snapshot).await {
tracing::error!("[启动] 更新托盘状态失败: {}", e);
} else {
tracing::info!("[启动] 托盘状态已更新");
}
}
}
@@ -1650,6 +1716,13 @@ pub fn run() {
commands::injection_cmd::update_injection_rule,
// Usage commands
commands::usage_cmd::get_kiro_usage,
// Tray commands
commands::tray_cmd::sync_tray_state,
commands::tray_cmd::update_tray_server_status,
commands::tray_cmd::update_tray_credential_status,
commands::tray_cmd::get_tray_state,
commands::tray_cmd::refresh_tray_menu,
commands::tray_cmd::refresh_tray_with_stats,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+141
View File
@@ -0,0 +1,141 @@
//! 托盘事件处理模块
//!
//! 处理托盘图标的点击事件
//!
//! # Requirements
//! - 6.1: 单击托盘图标切换主窗口可见性
//! - 6.2: 双击托盘图标显示并聚焦主窗口
//! - 7.3: 托盘菜单打开时获取并显示最新信息
use tauri::{
tray::{MouseButton, MouseButtonState, TrayIconEvent},
AppHandle, Emitter, Manager, Runtime,
};
use tracing::{debug, info};
/// 托盘菜单即将打开的事件名称
pub const TRAY_MENU_WILL_OPEN_EVENT: &str = "tray-menu-will-open";
/// 处理托盘图标事件
///
/// # Requirements
/// - 6.1: 单击切换主窗口可见性
/// - 6.2: 双击显示并聚焦主窗口
/// - 7.3: 右键点击时触发菜单刷新
pub fn handle_tray_icon_event<R: Runtime>(app: &AppHandle<R>, event: TrayIconEvent) {
match event {
// 单击事件 - 切换主窗口可见性
// Requirements 6.1: WHEN 用户单击托盘图标 THEN 系统托盘 SHALL 切换主应用程序窗口的可见性
TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} => {
debug!("托盘图标单击事件");
toggle_main_window_visibility(app);
}
// 右键点击事件 - 菜单即将打开,触发数据刷新
// Requirements 7.3: WHEN 托盘菜单被打开时 THEN 系统托盘 SHALL 获取并显示最新的统计信息和状态信息
TrayIconEvent::Click {
button: MouseButton::Right,
button_state: MouseButtonState::Up,
..
} => {
debug!("托盘图标右键点击事件 - 菜单即将打开");
// 发送事件通知前端刷新托盘数据
if let Err(e) = app.emit(TRAY_MENU_WILL_OPEN_EVENT, ()) {
tracing::error!("发送托盘菜单打开事件失败: {}", e);
}
}
// 双击事件 - 显示并聚焦主窗口
// Requirements 6.2: WHEN 用户双击托盘图标 THEN 系统托盘 SHALL 显示并聚焦主应用程序窗口
TrayIconEvent::DoubleClick {
button: MouseButton::Left,
..
} => {
debug!("托盘图标双击事件");
show_and_focus_main_window(app);
}
// 其他事件忽略
_ => {}
}
}
/// 切换主窗口可见性
///
/// 如果窗口可见则隐藏,如果隐藏则显示并聚焦
///
/// # Requirements
/// - 6.1: 单击托盘图标切换主窗口可见性
fn toggle_main_window_visibility<R: Runtime>(app: &AppHandle<R>) {
if let Some(window) = app.get_webview_window("main") {
match window.is_visible() {
Ok(true) => {
// 窗口可见,隐藏它
if let Err(e) = window.hide() {
tracing::error!("隐藏主窗口失败: {}", e);
} else {
info!("主窗口已隐藏");
}
}
Ok(false) => {
// 窗口隐藏,显示并聚焦
show_and_focus_window(&window);
}
Err(e) => {
tracing::error!("获取窗口可见性失败: {}", e);
// 尝试显示窗口
show_and_focus_window(&window);
}
}
} else {
tracing::warn!("未找到主窗口");
}
}
/// 显示并聚焦主窗口
///
/// # Requirements
/// - 6.2: 双击托盘图标显示并聚焦主窗口
fn show_and_focus_main_window<R: Runtime>(app: &AppHandle<R>) {
if let Some(window) = app.get_webview_window("main") {
show_and_focus_window(&window);
} else {
tracing::warn!("未找到主窗口");
}
}
/// 显示并聚焦窗口的辅助函数
fn show_and_focus_window<R: Runtime>(window: &tauri::WebviewWindow<R>) {
// 取消最小化
if let Err(e) = window.unminimize() {
tracing::error!("取消最小化窗口失败: {}", e);
}
// 显示窗口
if let Err(e) = window.show() {
tracing::error!("显示主窗口失败: {}", e);
} else {
info!("主窗口已显示");
}
// 聚焦窗口
if let Err(e) = window.set_focus() {
tracing::error!("聚焦主窗口失败: {}", e);
}
}
#[cfg(test)]
mod tests {
// 由于这些函数依赖 Tauri 运行时,单元测试需要模拟环境
// 这里只测试基本的模块结构
#[test]
fn test_module_compiles() {
// 确保模块可以编译
assert!(true);
}
}
+120
View File
@@ -0,0 +1,120 @@
//! 菜单文本格式化模块
//!
//! 提供托盘菜单文本的格式化函数
/// 格式化服务器状态文本
///
/// # 示例输出
/// - "🟢 API 服务器: 运行中 (127.0.0.1:8080)"
/// - "⚪ API 服务器: 已停止"
pub fn format_server_status(running: bool, host: &str, port: u16) -> String {
if running {
format!("🟢 API 服务器: 运行中 ({host}:{port})")
} else {
"⚪ API 服务器: 已停止".to_string()
}
}
/// 格式化凭证状态文本
///
/// # 示例输出
/// - "🔑 可用凭证: 3/5"
pub fn format_credential_status(available: usize, total: usize) -> String {
format!("🔑 可用凭证: {available}/{total}")
}
/// 格式化请求统计文本
///
/// # 示例输出
/// - "📊 今日请求: 128 次"
pub fn format_request_count(count: u64) -> String {
format!("📊 今日请求: {count} 次")
}
/// 格式化 API 地址
///
/// # 示例输出
/// - "http://127.0.0.1:8080"
pub fn format_api_address(host: &str, port: u16) -> String {
format!("http://{host}:{port}")
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
proptest! {
/// **Feature: system-tray, Property 2: 菜单内容格式化正确性**
/// **Validates: Requirements 2.2, 2.3, 2.4**
#[test]
fn prop_menu_content_formatting(
host in "[a-z0-9.]{1,50}",
port in 1024u16..65535,
available in 0usize..100,
total in 0usize..100,
requests in 0u64..1000000
) {
// 测试服务器状态格式化 - 运行中
let running_status = format_server_status(true, &host, port);
prop_assert!(running_status.contains(&host), "运行状态应包含 host");
prop_assert!(running_status.contains(&port.to_string()), "运行状态应包含 port");
prop_assert!(running_status.contains("运行中"), "运行状态应包含'运行中'");
// 测试服务器状态格式化 - 已停止
let stopped_status = format_server_status(false, &host, port);
prop_assert!(stopped_status.contains("已停止"), "停止状态应包含'已停止'");
// 测试凭证状态格式化
let cred_status = format_credential_status(available, total);
prop_assert!(cred_status.contains(&available.to_string()), "凭证状态应包含可用数");
prop_assert!(cred_status.contains(&total.to_string()), "凭证状态应包含总数");
// 测试请求统计格式化
let req_status = format_request_count(requests);
prop_assert!(req_status.contains(&requests.to_string()), "请求统计应包含请求次数");
}
/// **Feature: system-tray, Property 4: API 地址格式化正确性**
/// **Validates: Requirements 4.2**
#[test]
fn prop_api_address_formatting(
host in "[a-z0-9.]{1,50}",
port in 1024u16..65535
) {
let address = format_api_address(&host, port);
let expected = format!("http://{host}:{port}");
prop_assert_eq!(address, expected, "API 地址格式应为 http://{{host}}:{{port}}");
}
}
#[test]
fn test_format_server_status_running() {
let status = format_server_status(true, "127.0.0.1", 8080);
assert_eq!(status, "🟢 API 服务器: 运行中 (127.0.0.1:8080)");
}
#[test]
fn test_format_server_status_stopped() {
let status = format_server_status(false, "127.0.0.1", 8080);
assert_eq!(status, "⚪ API 服务器: 已停止");
}
#[test]
fn test_format_credential_status() {
let status = format_credential_status(3, 5);
assert_eq!(status, "🔑 可用凭证: 3/5");
}
#[test]
fn test_format_request_count() {
let status = format_request_count(128);
assert_eq!(status, "📊 今日请求: 128 次");
}
#[test]
fn test_format_api_address() {
let address = format_api_address("127.0.0.1", 8080);
assert_eq!(address, "http://127.0.0.1:8080");
}
}
+445
View File
@@ -0,0 +1,445 @@
//! 托盘管理器模块
//!
//! 提供托盘图标和菜单的管理功能
//!
//! # Requirements
//! - 1.1: 服务器运行且凭证健康时显示正常状态图标
//! - 1.4: 应用启动时显示停止状态图标
//! - 7.1, 7.2, 7.3: 状态变化时更新托盘
use super::events::handle_tray_icon_event;
use super::menu::build_tray_menu;
use super::menu_handler::handle_menu_event;
use super::state::{TrayIconStatus, TrayStateSnapshot};
use std::path::PathBuf;
use std::sync::Arc;
use tauri::{
image::Image,
tray::{TrayIcon, TrayIconBuilder},
AppHandle, Manager, Runtime,
};
use tokio::sync::RwLock;
use tracing::{info, warn};
/// 托盘初始化错误
#[derive(Debug, thiserror::Error)]
pub enum TrayError {
#[error("无法加载托盘图标: {0}")]
IconLoadError(String),
#[error("无法创建托盘菜单: {0}")]
MenuBuildError(String),
#[error("无法创建托盘图标: {0}")]
TrayBuildError(String),
#[error("状态更新失败: {0}")]
StateUpdateError(String),
#[error("Tauri 错误: {0}")]
TauriError(String),
}
impl From<tauri::Error> for TrayError {
fn from(e: tauri::Error) -> Self {
TrayError::TauriError(e.to_string())
}
}
/// 托盘图标资源
///
/// 存储不同状态对应的图标
pub struct TrayIcons {
/// 正常运行状态图标(绿色)
pub running: Option<Image<'static>>,
/// 警告状态图标(黄色)
pub warning: Option<Image<'static>>,
/// 错误状态图标(红色)
pub error: Option<Image<'static>>,
/// 停止状态图标(灰色)
pub stopped: Option<Image<'static>>,
/// 默认图标(后备)
pub default: Image<'static>,
}
impl TrayIcons {
/// 加载托盘图标资源
///
/// 尝试从 `src-tauri/icons/tray/` 目录加载状态图标,
/// 如果加载失败则使用默认应用图标作为后备
pub fn load<R: Runtime>(app: &AppHandle<R>) -> Self {
let resource_path = app
.path()
.resource_dir()
.unwrap_or_else(|_| PathBuf::from("."));
// 加载默认图标(必须成功)
let default = Self::load_default_icon(app);
// 尝试加载各状态图标
let tray_icons_dir = resource_path.join("icons").join("tray");
info!("托盘图标目录: {:?}", tray_icons_dir);
let running = Self::load_png_file(&tray_icons_dir.join("tray-running.png"));
let warning = Self::load_png_file(&tray_icons_dir.join("tray-warning.png"));
let error = Self::load_png_file(&tray_icons_dir.join("tray-error.png"));
let stopped = Self::load_png_file(&tray_icons_dir.join("tray-stopped.png"));
if running.is_none() || warning.is_none() || error.is_none() || stopped.is_none() {
warn!("部分托盘图标加载失败,将使用默认图标作为后备");
// 尝试从内嵌资源加载
return Self::load_embedded();
}
Self {
running,
warning,
error,
stopped,
default,
}
}
/// 从内嵌资源加载图标
fn load_embedded() -> Self {
info!("从内嵌资源加载托盘图标");
let default = Image::from_bytes(include_bytes!("../../icons/32x32.png"))
.expect("内嵌默认图标加载失败");
// 尝试加载内嵌的托盘图标
let running = Image::from_bytes(include_bytes!("../../icons/tray/tray-running.png")).ok();
let warning = Image::from_bytes(include_bytes!("../../icons/tray/tray-warning.png")).ok();
let error = Image::from_bytes(include_bytes!("../../icons/tray/tray-error.png")).ok();
let stopped = Image::from_bytes(include_bytes!("../../icons/tray/tray-stopped.png")).ok();
Self {
running,
warning,
error,
stopped,
default,
}
}
/// 加载默认应用图标
fn load_default_icon<R: Runtime>(app: &AppHandle<R>) -> Image<'static> {
// 尝试从资源目录加载
let resource_path = app
.path()
.resource_dir()
.unwrap_or_else(|_| PathBuf::from("."));
let icon_path = resource_path.join("icons").join("32x32.png");
if let Some(icon) = Self::load_png_file(&icon_path) {
return icon;
}
// 使用内嵌的默认图标(PNG 格式)
info!("使用内嵌默认图标");
Image::from_bytes(include_bytes!("../../icons/32x32.png")).expect("内嵌默认图标加载失败")
}
/// 从 PNG 文件加载图标
fn load_png_file(path: &PathBuf) -> Option<Image<'static>> {
match Image::from_path(path) {
Ok(image) => {
info!("成功加载图标: {:?}", path);
Some(image)
}
Err(e) => {
// 文件不存在是正常情况(图标尚未创建)
warn!("无法加载图标文件 {:?}: {}", path, e);
None
}
}
}
/// 根据状态获取对应的图标
pub fn get_icon_for_status(&self, status: TrayIconStatus) -> &Image<'static> {
match status {
TrayIconStatus::Running => self.running.as_ref().unwrap_or(&self.default),
TrayIconStatus::Warning => self.warning.as_ref().unwrap_or(&self.default),
TrayIconStatus::Error => self.error.as_ref().unwrap_or(&self.default),
TrayIconStatus::Stopped => self.stopped.as_ref().unwrap_or(&self.default),
}
}
}
/// 托盘管理器
///
/// 管理系统托盘图标和菜单的生命周期
///
/// # Requirements
/// - 1.1, 1.4: 托盘图标状态管理
/// - 7.1, 7.2, 7.3: 状态同步和更新
pub struct TrayManager<R: Runtime> {
/// Tauri 托盘图标句柄
tray: TrayIcon<R>,
/// 当前状态
state: Arc<RwLock<TrayStateSnapshot>>,
/// 图标资源
icons: TrayIcons,
/// AppHandle 引用
app: AppHandle<R>,
}
impl<R: Runtime> TrayManager<R> {
/// 创建托盘管理器
///
/// 初始化托盘图标,设置初始状态为 Stopped
///
/// # Requirements
/// - 1.4: 应用启动时显示停止状态图标
pub fn new(app: &AppHandle<R>) -> Result<Self, TrayError> {
info!("初始化托盘管理器...");
// 加载图标资源
let icons = TrayIcons::load(app);
// 创建初始状态
let initial_state = TrayStateSnapshot::default();
// 构建初始菜单
let menu = build_tray_menu(app, &initial_state)
.map_err(|e| TrayError::MenuBuildError(e.to_string()))?;
// 获取初始图标
let initial_icon = icons.get_icon_for_status(TrayIconStatus::Stopped);
// 创建托盘图标
// Requirements 6.1, 6.2: 注册托盘图标点击事件处理器
// Requirements 3.1-3.4, 4.1-4.4, 5.1-5.2: 注册菜单事件处理器
let tray = TrayIconBuilder::with_id("main-tray")
.icon(initial_icon.clone())
.menu(&menu)
.show_menu_on_left_click(false)
.tooltip("ProxyCast - AI API 代理")
.on_tray_icon_event(|tray, event| {
let app = tray.app_handle();
handle_tray_icon_event(&app, event);
})
.on_menu_event(|app, event| {
handle_menu_event(app, event.id().as_ref());
})
.build(app)
.map_err(|e| TrayError::TrayBuildError(e.to_string()))?;
info!("托盘管理器初始化完成");
Ok(Self {
tray,
state: Arc::new(RwLock::new(initial_state)),
icons,
app: app.clone(),
})
}
/// 获取当前状态快照
pub async fn get_state(&self) -> TrayStateSnapshot {
self.state.read().await.clone()
}
/// 获取当前图标状态
pub async fn get_icon_status(&self) -> TrayIconStatus {
self.state.read().await.icon_status
}
/// 更新托盘状态
///
/// 更新内部状态并刷新图标和菜单
///
/// # Requirements
/// - 7.1: API 服务器状态变化时更新托盘图标
/// - 7.2: 凭证健康状态变化时更新托盘图标
pub async fn update_state(&self, snapshot: TrayStateSnapshot) -> Result<(), TrayError> {
let old_status = {
let state = self.state.read().await;
state.icon_status
};
// 更新内部状态
{
let mut state = self.state.write().await;
*state = snapshot.clone();
}
// 如果图标状态变化,更新图标
if old_status != snapshot.icon_status {
self.set_icon(snapshot.icon_status)?;
info!(
"托盘图标状态更新: {:?} -> {:?}",
old_status, snapshot.icon_status
);
}
// 刷新菜单
self.refresh_menu().await?;
Ok(())
}
/// 刷新菜单内容
///
/// 根据当前状态重新构建菜单
///
/// # Requirements
/// - 7.3: 托盘菜单打开时获取并显示最新信息
pub async fn refresh_menu(&self) -> Result<(), TrayError> {
let state = self.state.read().await;
let menu = build_tray_menu(&self.app, &state)
.map_err(|e| TrayError::MenuBuildError(e.to_string()))?;
self.tray
.set_menu(Some(menu))
.map_err(|e| TrayError::StateUpdateError(e.to_string()))?;
Ok(())
}
/// 设置托盘图标
///
/// 根据状态切换图标
///
/// # Requirements
/// - 1.1: 正常状态显示绿色图标
/// - 1.2: 警告状态显示黄色图标
/// - 1.3: 错误状态显示红色图标
/// - 1.4: 停止状态显示灰色图标
pub fn set_icon(&self, status: TrayIconStatus) -> Result<(), TrayError> {
let icon = self.icons.get_icon_for_status(status);
self.tray
.set_icon(Some(icon.clone()))
.map_err(|e| TrayError::StateUpdateError(e.to_string()))?;
Ok(())
}
/// 设置托盘提示文本
pub fn set_tooltip(&self, tooltip: &str) -> Result<(), TrayError> {
self.tray
.set_tooltip(Some(tooltip))
.map_err(|e| TrayError::StateUpdateError(e.to_string()))?;
Ok(())
}
/// 获取托盘图标 ID
pub fn id(&self) -> &str {
"main-tray"
}
/// 获取 Tauri TrayIcon 引用
pub fn tray_icon(&self) -> &TrayIcon<R> {
&self.tray
}
}
/// 简化版托盘管理器(用于测试和无 Tauri 环境)
pub struct SimpleTrayManager {
/// 当前状态
state: Arc<RwLock<TrayStateSnapshot>>,
}
impl SimpleTrayManager {
/// 创建简化版托盘管理器
pub fn new() -> Self {
Self {
state: Arc::new(RwLock::new(TrayStateSnapshot::default())),
}
}
/// 获取当前状态快照
pub async fn get_state(&self) -> TrayStateSnapshot {
self.state.read().await.clone()
}
/// 更新托盘状态
pub async fn update_state(&self, snapshot: TrayStateSnapshot) {
let mut state = self.state.write().await;
*state = snapshot;
}
/// 获取当前图标状态
pub async fn get_icon_status(&self) -> TrayIconStatus {
self.state.read().await.icon_status
}
}
impl Default for SimpleTrayManager {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_simple_tray_manager_new() {
let manager = SimpleTrayManager::new();
let state = manager.get_state().await;
assert_eq!(state.icon_status, TrayIconStatus::Stopped);
assert!(!state.server_running);
}
#[tokio::test]
async fn test_simple_tray_manager_update_state() {
let manager = SimpleTrayManager::new();
let new_state = TrayStateSnapshot {
icon_status: TrayIconStatus::Running,
server_running: true,
server_address: "127.0.0.1:8080".to_string(),
available_credentials: 3,
total_credentials: 5,
today_requests: 100,
auto_start_enabled: true,
};
manager.update_state(new_state.clone()).await;
let state = manager.get_state().await;
assert_eq!(state.icon_status, TrayIconStatus::Running);
assert!(state.server_running);
assert_eq!(state.server_address, "127.0.0.1:8080");
assert_eq!(state.available_credentials, 3);
assert_eq!(state.total_credentials, 5);
assert_eq!(state.today_requests, 100);
assert!(state.auto_start_enabled);
}
#[tokio::test]
async fn test_simple_tray_manager_get_icon_status() {
let manager = SimpleTrayManager::new();
// 初始状态应该是 Stopped
assert_eq!(manager.get_icon_status().await, TrayIconStatus::Stopped);
// 更新状态后应该反映新状态
let new_state = TrayStateSnapshot {
icon_status: TrayIconStatus::Warning,
..Default::default()
};
manager.update_state(new_state).await;
assert_eq!(manager.get_icon_status().await, TrayIconStatus::Warning);
}
#[test]
fn test_tray_error_display() {
let err = TrayError::IconLoadError("test error".to_string());
assert!(err.to_string().contains("无法加载托盘图标"));
let err = TrayError::MenuBuildError("menu error".to_string());
assert!(err.to_string().contains("无法创建托盘菜单"));
let err = TrayError::TrayBuildError("tray error".to_string());
assert!(err.to_string().contains("无法创建托盘图标"));
let err = TrayError::StateUpdateError("state error".to_string());
assert!(err.to_string().contains("状态更新失败"));
}
}
+410
View File
@@ -0,0 +1,410 @@
//! 托盘菜单模块
//!
//! 定义菜单项 ID 和菜单构建函数
use super::format::{format_credential_status, format_request_count, format_server_status};
use super::state::TrayStateSnapshot;
use tauri::{
menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem},
AppHandle, Runtime,
};
/// 菜单项 ID 常量
pub mod menu_ids {
/// 状态信息
pub const STATUS_INFO: &str = "status_info";
/// 凭证信息
pub const CREDENTIAL_INFO: &str = "credential_info";
/// 请求信息
pub const REQUEST_INFO: &str = "request_info";
/// 分隔符 1
pub const SEPARATOR_1: &str = "sep_1";
/// 启动服务器
pub const START_SERVER: &str = "start_server";
/// 停止服务器
pub const STOP_SERVER: &str = "stop_server";
/// 刷新所有 Token
pub const REFRESH_TOKENS: &str = "refresh_tokens";
/// 健康检查
pub const HEALTH_CHECK: &str = "health_check";
/// 分隔符 2
pub const SEPARATOR_2: &str = "sep_2";
/// 打开主窗口
pub const OPEN_WINDOW: &str = "open_window";
/// 复制 API 地址
pub const COPY_API_ADDRESS: &str = "copy_api_address";
/// 打开日志目录
pub const OPEN_LOG_DIR: &str = "open_log_dir";
/// 分隔符 3
pub const SEPARATOR_3: &str = "sep_3";
/// 开机自启
pub const AUTO_START: &str = "auto_start";
/// 分隔符 4
pub const SEPARATOR_4: &str = "sep_4";
/// 退出
pub const QUIT: &str = "quit";
/// 获取所有必需的菜单项 ID 列表
pub fn all_required_ids() -> Vec<&'static str> {
vec![
STATUS_INFO,
CREDENTIAL_INFO,
REQUEST_INFO,
START_SERVER,
STOP_SERVER,
REFRESH_TOKENS,
HEALTH_CHECK,
OPEN_WINDOW,
COPY_API_ADDRESS,
OPEN_LOG_DIR,
AUTO_START,
QUIT,
]
}
}
/// 托盘菜单构建错误
#[derive(Debug, thiserror::Error)]
pub enum MenuBuildError {
#[error("无法创建菜单项: {0}")]
MenuItemError(String),
#[error("无法创建菜单: {0}")]
MenuError(String),
}
/// 构建托盘菜单
///
/// 根据当前状态快照构建完整的托盘菜单,包含:
/// - 状态信息(服务器状态、凭证状态、请求统计)
/// - 服务器控制(启动/停止、刷新 Token、健康检查)
/// - 快捷工具(打开主窗口、复制 API 地址、打开日志目录)
/// - 设置(开机自启)
/// - 退出
///
/// # Requirements
/// - 2.1: 右键点击托盘图标显示包含所有可用操作的托盘菜单
/// - 2.2: 显示当前服务器状态,包括运行状态和端口号
/// - 2.3: 显示凭证池状态,包括可用凭证数和总凭证数
/// - 2.4: 显示今日请求次数
/// - 3.1, 3.2, 3.3, 3.4: 服务器控制菜单项
/// - 4.1, 4.2, 4.3, 4.4: 快捷工具菜单项
/// - 5.1, 5.2: 开机自启设置
pub fn build_tray_menu<R: Runtime>(
app: &AppHandle<R>,
state: &TrayStateSnapshot,
) -> Result<Menu<R>, MenuBuildError> {
// 解析服务器地址
let (host, port) = parse_server_address(&state.server_address);
// === 状态信息区域 ===
let status_text = format_server_status(state.server_running, &host, port);
let status_info = MenuItem::with_id(
app,
menu_ids::STATUS_INFO,
&status_text,
false,
None::<&str>,
)
.map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?;
let credential_text =
format_credential_status(state.available_credentials, state.total_credentials);
let credential_info = MenuItem::with_id(
app,
menu_ids::CREDENTIAL_INFO,
&credential_text,
false,
None::<&str>,
)
.map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?;
let request_text = format_request_count(state.today_requests);
let request_info = MenuItem::with_id(
app,
menu_ids::REQUEST_INFO,
&request_text,
false,
None::<&str>,
)
.map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?;
// === 分隔符 1 ===
let separator_1 = PredefinedMenuItem::separator(app)
.map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?;
// === 服务器控制区域 ===
// 启动服务器(服务器未运行时可用)
let start_server = MenuItem::with_id(
app,
menu_ids::START_SERVER,
"▶️ 启动服务器",
!state.server_running,
None::<&str>,
)
.map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?;
// 停止服务器(服务器运行时可用)
let stop_server = MenuItem::with_id(
app,
menu_ids::STOP_SERVER,
"⏹️ 停止服务器",
state.server_running,
None::<&str>,
)
.map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?;
// 刷新所有 Token
let refresh_tokens = MenuItem::with_id(
app,
menu_ids::REFRESH_TOKENS,
"🔄 刷新所有 Token",
true,
None::<&str>,
)
.map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?;
// 健康检查
let health_check = MenuItem::with_id(
app,
menu_ids::HEALTH_CHECK,
"🩺 健康检查",
true,
None::<&str>,
)
.map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?;
// === 分隔符 2 ===
let separator_2 = PredefinedMenuItem::separator(app)
.map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?;
// === 快捷工具区域 ===
let open_window = MenuItem::with_id(
app,
menu_ids::OPEN_WINDOW,
"🖥️ 打开主窗口",
true,
None::<&str>,
)
.map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?;
let copy_api_address = MenuItem::with_id(
app,
menu_ids::COPY_API_ADDRESS,
"📋 复制 API 地址",
state.server_running,
None::<&str>,
)
.map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?;
let open_log_dir = MenuItem::with_id(
app,
menu_ids::OPEN_LOG_DIR,
"📁 打开日志目录",
true,
None::<&str>,
)
.map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?;
// === 分隔符 3 ===
let separator_3 = PredefinedMenuItem::separator(app)
.map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?;
// === 设置区域 ===
let auto_start = CheckMenuItem::with_id(
app,
menu_ids::AUTO_START,
"🚀 开机自启",
true,
state.auto_start_enabled,
None::<&str>,
)
.map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?;
// === 分隔符 4 ===
let separator_4 = PredefinedMenuItem::separator(app)
.map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?;
// === 退出 ===
let quit = MenuItem::with_id(app, menu_ids::QUIT, "❌ 退出", true, None::<&str>)
.map_err(|e| MenuBuildError::MenuItemError(e.to_string()))?;
// 构建菜单
Menu::with_items(
app,
&[
&status_info,
&credential_info,
&request_info,
&separator_1,
&start_server,
&stop_server,
&refresh_tokens,
&health_check,
&separator_2,
&open_window,
&copy_api_address,
&open_log_dir,
&separator_3,
&auto_start,
&separator_4,
&quit,
],
)
.map_err(|e| MenuBuildError::MenuError(e.to_string()))
}
/// 解析服务器地址字符串为 host 和 port
///
/// 支持格式:
/// - "host:port" -> (host, port)
/// - "host" -> (host, 8080)
/// - "" -> ("127.0.0.1", 8080)
fn parse_server_address(address: &str) -> (String, u16) {
if address.is_empty() {
return ("127.0.0.1".to_string(), 8080);
}
if let Some((host, port_str)) = address.rsplit_once(':') {
if let Ok(port) = port_str.parse::<u16>() {
return (host.to_string(), port);
}
}
(address.to_string(), 8080)
}
/// 获取菜单中包含的所有菜单项 ID
///
/// 用于验证菜单构建的完整性
pub fn get_menu_item_ids() -> Vec<&'static str> {
menu_ids::all_required_ids()
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn test_all_required_ids_not_empty() {
let ids = menu_ids::all_required_ids();
assert!(!ids.is_empty(), "必需的菜单项 ID 列表不应为空");
}
#[test]
fn test_all_required_ids_unique() {
let ids = menu_ids::all_required_ids();
let mut unique_ids = ids.clone();
unique_ids.sort();
unique_ids.dedup();
assert_eq!(ids.len(), unique_ids.len(), "菜单项 ID 应该唯一");
}
/// **Feature: system-tray, Property 3: 菜单项完整性**
/// **Validates: Requirements 2.1**
#[test]
fn test_menu_ids_completeness() {
let ids = menu_ids::all_required_ids();
// 验证所有预定义的菜单项 ID 都在列表中
assert!(ids.contains(&menu_ids::STATUS_INFO), "应包含 STATUS_INFO");
assert!(
ids.contains(&menu_ids::CREDENTIAL_INFO),
"应包含 CREDENTIAL_INFO"
);
assert!(ids.contains(&menu_ids::REQUEST_INFO), "应包含 REQUEST_INFO");
assert!(ids.contains(&menu_ids::START_SERVER), "应包含 START_SERVER");
assert!(ids.contains(&menu_ids::STOP_SERVER), "应包含 STOP_SERVER");
assert!(
ids.contains(&menu_ids::REFRESH_TOKENS),
"应包含 REFRESH_TOKENS"
);
assert!(ids.contains(&menu_ids::HEALTH_CHECK), "应包含 HEALTH_CHECK");
assert!(ids.contains(&menu_ids::OPEN_WINDOW), "应包含 OPEN_WINDOW");
assert!(
ids.contains(&menu_ids::COPY_API_ADDRESS),
"应包含 COPY_API_ADDRESS"
);
assert!(ids.contains(&menu_ids::OPEN_LOG_DIR), "应包含 OPEN_LOG_DIR");
assert!(ids.contains(&menu_ids::AUTO_START), "应包含 AUTO_START");
assert!(ids.contains(&menu_ids::QUIT), "应包含 QUIT");
}
#[test]
fn test_parse_server_address_with_port() {
let (host, port) = parse_server_address("127.0.0.1:8080");
assert_eq!(host, "127.0.0.1");
assert_eq!(port, 8080);
}
#[test]
fn test_parse_server_address_without_port() {
let (host, port) = parse_server_address("localhost");
assert_eq!(host, "localhost");
assert_eq!(port, 8080);
}
#[test]
fn test_parse_server_address_empty() {
let (host, port) = parse_server_address("");
assert_eq!(host, "127.0.0.1");
assert_eq!(port, 8080);
}
#[test]
fn test_parse_server_address_ipv6() {
let (host, port) = parse_server_address("[::1]:9000");
assert_eq!(host, "[::1]");
assert_eq!(port, 9000);
}
#[test]
fn test_get_menu_item_ids() {
let ids = get_menu_item_ids();
assert_eq!(ids.len(), 12, "应有 12 个必需的菜单项");
}
proptest! {
/// **Feature: system-tray, Property 3: 菜单项完整性(属性测试)**
/// **Validates: Requirements 2.1**
///
/// 验证对于任意托盘菜单构建,生成的菜单 SHALL 包含所有预定义的菜单项 ID
#[test]
fn prop_menu_ids_completeness(
_server_running in any::<bool>(),
_available in 0usize..100,
_total in 0usize..100,
_requests in 0u64..1000000,
_auto_start in any::<bool>()
) {
// 验证 all_required_ids 返回的列表包含所有必需的菜单项
let ids = menu_ids::all_required_ids();
// 必须包含所有预定义的 ID
let required = vec![
menu_ids::STATUS_INFO,
menu_ids::CREDENTIAL_INFO,
menu_ids::REQUEST_INFO,
menu_ids::START_SERVER,
menu_ids::STOP_SERVER,
menu_ids::REFRESH_TOKENS,
menu_ids::HEALTH_CHECK,
menu_ids::OPEN_WINDOW,
menu_ids::COPY_API_ADDRESS,
menu_ids::OPEN_LOG_DIR,
menu_ids::AUTO_START,
menu_ids::QUIT,
];
for id in required {
prop_assert!(ids.contains(&id), "菜单项列表应包含 {}", id);
}
// 验证没有重复的 ID
let mut sorted_ids = ids.clone();
sorted_ids.sort();
sorted_ids.dedup();
prop_assert_eq!(ids.len(), sorted_ids.len(), "菜单项 ID 应该唯一");
}
}
}
+348
View File
@@ -0,0 +1,348 @@
//! 托盘菜单事件处理模块
//!
//! 处理托盘菜单项的点击事件
//!
//! # Requirements
//! - 3.1, 3.2, 3.3, 3.4: 服务器控制事件处理
//! - 4.1, 4.2, 4.3, 4.4: 快捷工具事件处理
//! - 5.1, 5.2: 设置切换事件处理
use super::menu::menu_ids;
use tauri::{AppHandle, Emitter, Manager, Runtime};
use tauri_plugin_autostart::ManagerExt;
use tracing::{debug, error, info, warn};
/// 菜单事件类型
///
/// 用于前端监听的事件名称
pub mod menu_events {
/// 启动服务器事件
pub const START_SERVER: &str = "tray-start-server";
/// 停止服务器事件
pub const STOP_SERVER: &str = "tray-stop-server";
/// 刷新所有 Token 事件
pub const REFRESH_TOKENS: &str = "tray-refresh-tokens";
/// 健康检查事件
pub const HEALTH_CHECK: &str = "tray-health-check";
/// 自启动状态变更事件
pub const AUTO_START_CHANGED: &str = "tray-auto-start-changed";
}
/// 处理菜单事件
///
/// 根据菜单项 ID 执行相应的操作
///
/// # Requirements
/// - 3.1, 3.2, 3.3, 3.4: 服务器控制
/// - 4.1, 4.2, 4.3, 4.4: 快捷工具
/// - 5.1, 5.2: 设置切换
pub fn handle_menu_event<R: Runtime>(app: &AppHandle<R>, menu_id: &str) {
debug!("处理托盘菜单事件: {}", menu_id);
match menu_id {
// === 服务器控制 ===
menu_ids::START_SERVER => handle_start_server(app),
menu_ids::STOP_SERVER => handle_stop_server(app),
menu_ids::REFRESH_TOKENS => handle_refresh_tokens(app),
menu_ids::HEALTH_CHECK => handle_health_check(app),
// === 快捷工具 ===
menu_ids::OPEN_WINDOW => handle_open_window(app),
menu_ids::COPY_API_ADDRESS => handle_copy_api_address(app),
menu_ids::OPEN_LOG_DIR => handle_open_log_dir(app),
menu_ids::QUIT => handle_quit(app),
// === 设置 ===
menu_ids::AUTO_START => handle_auto_start_toggle(app),
// 忽略信息类菜单项和分隔符
menu_ids::STATUS_INFO | menu_ids::CREDENTIAL_INFO | menu_ids::REQUEST_INFO => {
debug!("忽略信息类菜单项: {}", menu_id);
}
_ => {
warn!("未知的菜单项 ID: {}", menu_id);
}
}
}
/// 处理启动服务器事件
///
/// # Requirements
/// - 3.1: WHEN API 服务器已停止且用户点击托盘菜单中的"启动服务器"
/// THEN 系统托盘 SHALL 启动 API 服务器并更新托盘图标以反映运行状态
fn handle_start_server<R: Runtime>(app: &AppHandle<R>) {
info!("[托盘] 用户请求启动服务器");
// 发送事件到前端,由前端调用 start_server 命令
if let Err(e) = app.emit(menu_events::START_SERVER, ()) {
error!("[托盘] 发送启动服务器事件失败: {}", e);
}
}
/// 处理停止服务器事件
///
/// # Requirements
/// - 3.2: WHEN API 服务器正在运行且用户点击托盘菜单中的"停止服务器"
/// THEN 系统托盘 SHALL 停止 API 服务器并更新托盘图标以反映停止状态
fn handle_stop_server<R: Runtime>(app: &AppHandle<R>) {
info!("[托盘] 用户请求停止服务器");
// 发送事件到前端,由前端调用 stop_server 命令
if let Err(e) = app.emit(menu_events::STOP_SERVER, ()) {
error!("[托盘] 发送停止服务器事件失败: {}", e);
}
}
/// 处理刷新所有 Token 事件
///
/// # Requirements
/// - 3.3: WHEN 用户点击托盘菜单中的"刷新所有 Token"
/// THEN 系统托盘 SHALL 触发凭证池中所有凭证的 Token 刷新
fn handle_refresh_tokens<R: Runtime>(app: &AppHandle<R>) {
info!("[托盘] 用户请求刷新所有 Token");
// 发送事件到前端,由前端调用凭证池服务刷新 Token
if let Err(e) = app.emit(menu_events::REFRESH_TOKENS, ()) {
error!("[托盘] 发送刷新 Token 事件失败: {}", e);
}
}
/// 处理健康检查事件
///
/// # Requirements
/// - 3.4: WHEN 用户点击托盘菜单中的"健康检查"
/// THEN 系统托盘 SHALL 对所有凭证执行健康检查并更新健康状态
fn handle_health_check<R: Runtime>(app: &AppHandle<R>) {
info!("[托盘] 用户请求执行健康检查");
// 发送事件到前端,由前端调用凭证池服务执行健康检查
if let Err(e) = app.emit(menu_events::HEALTH_CHECK, ()) {
error!("[托盘] 发送健康检查事件失败: {}", e);
}
}
/// 处理打开主窗口事件
///
/// # Requirements
/// - 4.1: WHEN 用户点击托盘菜单中的"打开主窗口"
/// THEN 系统托盘 SHALL 显示并聚焦主应用程序窗口
fn handle_open_window<R: Runtime>(app: &AppHandle<R>) {
info!("[托盘] 用户请求打开主窗口");
if let Some(window) = app.get_webview_window("main") {
// 取消最小化
if let Err(e) = window.unminimize() {
error!("[托盘] 取消最小化窗口失败: {}", e);
}
// 显示窗口
if let Err(e) = window.show() {
error!("[托盘] 显示主窗口失败: {}", e);
}
// 聚焦窗口
if let Err(e) = window.set_focus() {
error!("[托盘] 聚焦主窗口失败: {}", e);
}
info!("[托盘] 主窗口已显示并聚焦");
} else {
warn!("[托盘] 未找到主窗口");
}
}
/// 处理复制 API 地址事件
///
/// # Requirements
/// - 4.2: WHEN 用户点击托盘菜单中的"复制 API 地址"
/// THEN 系统托盘 SHALL 将当前 API 服务器地址复制到系统剪贴板
fn handle_copy_api_address<R: Runtime>(app: &AppHandle<R>) {
info!("[托盘] 用户请求复制 API 地址");
// 获取托盘状态以获取服务器地址
let app_clone = app.clone();
tauri::async_runtime::spawn(async move {
if let Some(tray_state) = app_clone.try_state::<crate::TrayManagerState<R>>() {
let tray_guard = tray_state.0.read().await;
if let Some(tray_manager) = tray_guard.as_ref() {
let state = tray_manager.get_state().await;
if state.server_running && !state.server_address.is_empty() {
let api_address = format!("http://{}", state.server_address);
// 使用剪贴板 API 复制地址
#[cfg(target_os = "macos")]
{
let _ = std::process::Command::new("pbcopy")
.stdin(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
if let Some(stdin) = child.stdin.as_mut() {
stdin.write_all(api_address.as_bytes())?;
}
child.wait()
});
info!("[托盘] API 地址已复制到剪贴板: {}", api_address);
}
#[cfg(target_os = "windows")]
{
let _ = std::process::Command::new("cmd")
.args(["/C", &format!("echo {} | clip", api_address)])
.spawn();
info!("[托盘] API 地址已复制到剪贴板: {}", api_address);
}
#[cfg(target_os = "linux")]
{
let _ = std::process::Command::new("xclip")
.args(["-selection", "clipboard"])
.stdin(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
if let Some(stdin) = child.stdin.as_mut() {
stdin.write_all(api_address.as_bytes())?;
}
child.wait()
});
info!("[托盘] API 地址已复制到剪贴板: {}", api_address);
}
} else {
warn!("[托盘] 服务器未运行,无法复制 API 地址");
}
}
}
});
}
/// 处理打开日志目录事件
///
/// # Requirements
/// - 4.3: WHEN 用户点击托盘菜单中的"打开日志目录"
/// THEN 系统托盘 SHALL 在系统文件管理器中打开应用程序日志目录
fn handle_open_log_dir<R: Runtime>(app: &AppHandle<R>) {
info!("[托盘] 用户请求打开日志目录");
// 获取日志目录路径
let log_dir = if let Ok(data_dir) = app.path().app_data_dir() {
data_dir.join("logs")
} else if let Some(home) = dirs::home_dir() {
home.join(".proxycast").join("logs")
} else {
error!("[托盘] 无法确定日志目录路径");
return;
};
// 确保目录存在
if !log_dir.exists() {
if let Err(e) = std::fs::create_dir_all(&log_dir) {
error!("[托盘] 创建日志目录失败: {}", e);
return;
}
}
// 使用 open crate 打开目录
if let Err(e) = open::that(&log_dir) {
error!("[托盘] 打开日志目录失败: {}", e);
} else {
info!("[托盘] 已打开日志目录: {}", log_dir.display());
}
}
/// 处理退出事件
///
/// # Requirements
/// - 4.4: WHEN 用户点击托盘菜单中的"退出"
/// THEN 系统托盘 SHALL 优雅地停止 API 服务器并终止应用程序
fn handle_quit<R: Runtime>(app: &AppHandle<R>) {
info!("[托盘] 用户请求退出应用");
// 先发送停止服务器事件
if let Err(e) = app.emit(menu_events::STOP_SERVER, ()) {
warn!("[托盘] 发送停止服务器事件失败: {}", e);
}
// 退出应用
app.exit(0);
}
/// 处理自启动切换事件
///
/// # Requirements
/// - 5.1: WHEN 用户在托盘菜单中切换"开机自启"
/// THEN 系统托盘 SHALL 启用或禁用应用程序的登录时启动设置
/// - 5.2: WHEN 托盘菜单显示时
/// THEN 系统托盘 SHALL 使用勾选标记显示"开机自启"切换的当前状态
fn handle_auto_start_toggle<R: Runtime>(app: &AppHandle<R>) {
info!("[托盘] 用户请求切换开机自启状态");
let autostart_manager = app.autolaunch();
// 获取当前状态并切换
match autostart_manager.is_enabled() {
Ok(is_enabled) => {
let new_state = !is_enabled;
let result = if new_state {
autostart_manager.enable()
} else {
autostart_manager.disable()
};
match result {
Ok(_) => {
info!(
"[托盘] 开机自启已{}",
if new_state { "启用" } else { "禁用" }
);
// 发送状态变更事件到前端
if let Err(e) = app.emit(menu_events::AUTO_START_CHANGED, new_state) {
error!("[托盘] 发送自启动状态变更事件失败: {}", e);
}
}
Err(e) => {
error!("[托盘] 切换开机自启失败: {}", e);
}
}
}
Err(e) => {
error!("[托盘] 获取开机自启状态失败: {}", e);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_menu_events_constants() {
// 验证事件常量不为空
assert!(!menu_events::START_SERVER.is_empty());
assert!(!menu_events::STOP_SERVER.is_empty());
assert!(!menu_events::REFRESH_TOKENS.is_empty());
assert!(!menu_events::HEALTH_CHECK.is_empty());
assert!(!menu_events::AUTO_START_CHANGED.is_empty());
}
#[test]
fn test_menu_events_unique() {
// 验证事件常量唯一
let events = vec![
menu_events::START_SERVER,
menu_events::STOP_SERVER,
menu_events::REFRESH_TOKENS,
menu_events::HEALTH_CHECK,
menu_events::AUTO_START_CHANGED,
];
let mut unique_events = events.clone();
unique_events.sort();
unique_events.dedup();
assert_eq!(events.len(), unique_events.len(), "事件常量应该唯一");
}
}
+24
View File
@@ -0,0 +1,24 @@
//! 系统托盘模块
//!
//! 提供系统托盘功能,包括:
//! - 托盘图标状态管理
//! - 托盘菜单构建
//! - 菜单事件处理
//! - 托盘图标点击事件处理
//! - 状态同步
mod events;
mod format;
mod manager;
mod menu;
mod menu_handler;
mod state;
mod sync;
pub use events::*;
pub use format::*;
pub use manager::*;
pub use menu::*;
pub use menu_handler::*;
pub use state::*;
pub use sync::*;
+214
View File
@@ -0,0 +1,214 @@
//! 托盘状态模块
//!
//! 定义托盘图标状态和状态快照结构
use serde::{Deserialize, Serialize};
/// 托盘图标状态枚举
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TrayIconStatus {
/// 正常运行(绿色)- 服务器运行且凭证健康
Running,
/// 警告状态(黄色)- 有凭证即将过期或余额不足
Warning,
/// 错误状态(红色)- 服务器停止或所有凭证无效
Error,
/// 停止状态(灰色)- 服务器未启动
Stopped,
}
impl Default for TrayIconStatus {
fn default() -> Self {
Self::Stopped
}
}
/// 凭证健康状态
#[derive(Debug, Clone, Default)]
pub struct CredentialHealth {
/// 凭证是否有效
pub is_valid: bool,
/// 是否即将过期
pub is_expiring_soon: bool,
/// 是否余额不足
pub is_low_balance: bool,
}
impl CredentialHealth {
/// 创建健康的凭证状态
pub fn healthy() -> Self {
Self {
is_valid: true,
is_expiring_soon: false,
is_low_balance: false,
}
}
/// 创建无效的凭证状态
pub fn invalid() -> Self {
Self {
is_valid: false,
is_expiring_soon: false,
is_low_balance: false,
}
}
/// 检查凭证是否有警告
pub fn has_warning(&self) -> bool {
self.is_valid && (self.is_expiring_soon || self.is_low_balance)
}
}
/// 托盘状态快照
#[derive(Debug, Clone, Serialize)]
pub struct TrayStateSnapshot {
/// 图标状态
pub icon_status: TrayIconStatus,
/// 服务器是否运行
pub server_running: bool,
/// 服务器地址
pub server_address: String,
/// 可用凭证数
pub available_credentials: usize,
/// 总凭证数
pub total_credentials: usize,
/// 今日请求数
pub today_requests: u64,
/// 是否开机自启
pub auto_start_enabled: bool,
}
impl Default for TrayStateSnapshot {
fn default() -> Self {
Self {
icon_status: TrayIconStatus::Stopped,
server_running: false,
server_address: String::new(),
available_credentials: 0,
total_credentials: 0,
today_requests: 0,
auto_start_enabled: false,
}
}
}
/// 根据服务器状态和凭证健康状态计算托盘图标状态
///
/// # 规则
/// - 服务器未运行 -> Stopped
/// - 服务器运行 + 所有凭证无效 -> Error
/// - 服务器运行 + 有凭证警告 -> Warning
/// - 服务器运行 + 所有凭证健康 -> Running
pub fn calculate_icon_status(
server_running: bool,
credentials: &[CredentialHealth],
) -> TrayIconStatus {
if !server_running {
return TrayIconStatus::Stopped;
}
// 如果没有凭证,视为错误状态
if credentials.is_empty() {
return TrayIconStatus::Error;
}
// 检查是否所有凭证都无效
let all_invalid = credentials.iter().all(|c| !c.is_valid);
if all_invalid {
return TrayIconStatus::Error;
}
// 检查是否有任何凭证有警告
let has_warning = credentials.iter().any(|c| c.has_warning());
if has_warning {
return TrayIconStatus::Warning;
}
TrayIconStatus::Running
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
// 生成任意的 CredentialHealth
fn arb_credential_health() -> impl Strategy<Value = CredentialHealth> {
(any::<bool>(), any::<bool>(), any::<bool>()).prop_map(
|(is_valid, is_expiring_soon, is_low_balance)| CredentialHealth {
is_valid,
is_expiring_soon,
is_low_balance,
},
)
}
proptest! {
/// **Feature: system-tray, Property 1: 状态到图标映射正确性**
/// **Validates: Requirements 1.1, 1.2, 1.3**
#[test]
fn prop_icon_status_mapping(
server_running in any::<bool>(),
credentials in prop::collection::vec(arb_credential_health(), 0..10)
) {
let status = calculate_icon_status(server_running, &credentials);
// 规则 1: 服务器未运行 -> Stopped
if !server_running {
prop_assert_eq!(status, TrayIconStatus::Stopped);
return Ok(());
}
// 规则 2: 没有凭证 -> Error
if credentials.is_empty() {
prop_assert_eq!(status, TrayIconStatus::Error);
return Ok(());
}
// 规则 3: 所有凭证无效 -> Error
let all_invalid = credentials.iter().all(|c| !c.is_valid);
if all_invalid {
prop_assert_eq!(status, TrayIconStatus::Error);
return Ok(());
}
// 规则 4: 有凭证警告 -> Warning
let has_warning = credentials.iter().any(|c| c.has_warning());
if has_warning {
prop_assert_eq!(status, TrayIconStatus::Warning);
return Ok(());
}
// 规则 5: 其他情况 -> Running
prop_assert_eq!(status, TrayIconStatus::Running);
}
}
#[test]
fn test_credential_health_healthy() {
let health = CredentialHealth::healthy();
assert!(health.is_valid);
assert!(!health.is_expiring_soon);
assert!(!health.is_low_balance);
assert!(!health.has_warning());
}
#[test]
fn test_credential_health_invalid() {
let health = CredentialHealth::invalid();
assert!(!health.is_valid);
assert!(!health.has_warning()); // 无效凭证不算警告
}
#[test]
fn test_credential_health_warning() {
let mut health = CredentialHealth::healthy();
health.is_expiring_soon = true;
assert!(health.has_warning());
let mut health2 = CredentialHealth::healthy();
health2.is_low_balance = true;
assert!(health2.has_warning());
}
}
+277
View File
@@ -0,0 +1,277 @@
//! 托盘状态同步模块
//!
//! 提供托盘状态与应用状态的同步功能
//!
//! # Requirements
//! - 7.1: API 服务器状态变化时在 1 秒内更新托盘图标
//! - 7.2: 凭证健康状态变化时在 1 秒内更新托盘图标
use super::state::{calculate_icon_status, CredentialHealth, TrayIconStatus, TrayStateSnapshot};
use super::TrayManager;
use std::sync::Arc;
use tauri::{AppHandle, Manager, Runtime};
use tokio::sync::RwLock;
use tracing::{debug, error, info};
/// 托盘状态同步器
///
/// 负责监听应用状态变化并更新托盘
pub struct TraySynchronizer<R: Runtime> {
/// AppHandle 引用
app: AppHandle<R>,
/// 托盘管理器引用
tray_manager: Arc<RwLock<Option<TrayManager<R>>>>,
}
impl<R: Runtime> TraySynchronizer<R> {
/// 创建托盘状态同步器
pub fn new(app: AppHandle<R>, tray_manager: Arc<RwLock<Option<TrayManager<R>>>>) -> Self {
Self { app, tray_manager }
}
/// 同步托盘状态
///
/// 从应用状态获取最新数据并更新托盘
///
/// # Requirements
/// - 7.1: API 服务器状态变化时更新托盘图标
/// - 7.2: 凭证健康状态变化时更新托盘图标
pub async fn sync_state(
&self,
server_running: bool,
server_host: &str,
server_port: u16,
credentials: &[CredentialHealth],
today_requests: u64,
auto_start_enabled: bool,
) -> Result<(), String> {
let tray_guard = self.tray_manager.read().await;
let tray_manager = tray_guard
.as_ref()
.ok_or_else(|| "托盘管理器未初始化".to_string())?;
// 计算图标状态
let icon_status = calculate_icon_status(server_running, credentials);
// 计算可用凭证数
let available_credentials = credentials.iter().filter(|c| c.is_valid).count();
let total_credentials = credentials.len();
// 构建状态快照
let snapshot = TrayStateSnapshot {
icon_status,
server_running,
server_address: if server_running {
format!("{}:{}", server_host, server_port)
} else {
String::new()
},
available_credentials,
total_credentials,
today_requests,
auto_start_enabled,
};
// 更新托盘状态
tray_manager
.update_state(snapshot)
.await
.map_err(|e| e.to_string())?;
debug!(
"托盘状态已同步: server_running={}, icon_status={:?}, credentials={}/{}",
server_running, icon_status, available_credentials, total_credentials
);
Ok(())
}
/// 仅更新服务器状态
///
/// # Requirements
/// - 7.1: API 服务器状态变化时在 1 秒内更新托盘图标
pub async fn update_server_status(
&self,
server_running: bool,
server_host: &str,
server_port: u16,
) -> Result<(), String> {
let tray_guard = self.tray_manager.read().await;
let tray_manager = tray_guard
.as_ref()
.ok_or_else(|| "托盘管理器未初始化".to_string())?;
// 获取当前状态
let mut current_state = tray_manager.get_state().await;
// 更新服务器相关字段
current_state.server_running = server_running;
current_state.server_address = if server_running {
format!("{}:{}", server_host, server_port)
} else {
String::new()
};
// 重新计算图标状态
// 如果服务器停止,图标状态为 Stopped
// 否则保持当前状态(凭证状态未变)
if !server_running {
current_state.icon_status = TrayIconStatus::Stopped;
} else if current_state.icon_status == TrayIconStatus::Stopped {
// 服务器启动,但之前是停止状态,设为 Running
current_state.icon_status = TrayIconStatus::Running;
}
// 更新托盘状态
tray_manager
.update_state(current_state)
.await
.map_err(|e| e.to_string())?;
info!(
"托盘服务器状态已更新: running={}, address={}:{}",
server_running, server_host, server_port
);
Ok(())
}
/// 仅更新凭证健康状态
///
/// # Requirements
/// - 7.2: 凭证健康状态变化时在 1 秒内更新托盘图标
pub async fn update_credential_health(
&self,
credentials: &[CredentialHealth],
) -> Result<(), String> {
let tray_guard = self.tray_manager.read().await;
let tray_manager = tray_guard
.as_ref()
.ok_or_else(|| "托盘管理器未初始化".to_string())?;
// 获取当前状态
let mut current_state = tray_manager.get_state().await;
// 更新凭证相关字段
current_state.available_credentials = credentials.iter().filter(|c| c.is_valid).count();
current_state.total_credentials = credentials.len();
// 重新计算图标状态
current_state.icon_status =
calculate_icon_status(current_state.server_running, credentials);
// 保存日志所需的值
let available = current_state.available_credentials;
let total = current_state.total_credentials;
let icon_status = current_state.icon_status;
// 更新托盘状态
tray_manager
.update_state(current_state)
.await
.map_err(|e| e.to_string())?;
info!(
"托盘凭证状态已更新: available={}/{}, icon_status={:?}",
available, total, icon_status
);
Ok(())
}
/// 更新今日请求数
pub async fn update_request_count(&self, today_requests: u64) -> Result<(), String> {
let tray_guard = self.tray_manager.read().await;
let tray_manager = tray_guard
.as_ref()
.ok_or_else(|| "托盘管理器未初始化".to_string())?;
// 获取当前状态
let mut current_state = tray_manager.get_state().await;
// 更新请求数
current_state.today_requests = today_requests;
// 更新托盘状态(不改变图标)
tray_manager
.update_state(current_state)
.await
.map_err(|e| e.to_string())?;
debug!("托盘请求数已更新: {}", today_requests);
Ok(())
}
/// 更新自启动状态
pub async fn update_auto_start(&self, enabled: bool) -> Result<(), String> {
let tray_guard = self.tray_manager.read().await;
let tray_manager = tray_guard
.as_ref()
.ok_or_else(|| "托盘管理器未初始化".to_string())?;
// 获取当前状态
let mut current_state = tray_manager.get_state().await;
// 更新自启动状态
current_state.auto_start_enabled = enabled;
// 更新托盘状态(不改变图标)
tray_manager
.update_state(current_state)
.await
.map_err(|e| e.to_string())?;
debug!("托盘自启动状态已更新: {}", enabled);
Ok(())
}
}
/// 从 ProviderPoolService 获取凭证健康状态
///
/// 将 ProviderPoolService 中的凭证状态转换为 CredentialHealth 列表
pub fn get_credential_health_from_pool(
pool_credentials: &[(String, bool, bool, bool)], // (id, is_valid, is_expiring_soon, is_low_balance)
) -> Vec<CredentialHealth> {
pool_credentials
.iter()
.map(
|(_, is_valid, is_expiring_soon, is_low_balance)| CredentialHealth {
is_valid: *is_valid,
is_expiring_soon: *is_expiring_soon,
is_low_balance: *is_low_balance,
},
)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_credential_health_from_pool() {
let pool_data = vec![
("cred1".to_string(), true, false, false),
("cred2".to_string(), true, true, false),
("cred3".to_string(), false, false, false),
];
let health = get_credential_health_from_pool(&pool_data);
assert_eq!(health.len(), 3);
assert!(health[0].is_valid);
assert!(!health[0].is_expiring_soon);
assert!(health[1].is_valid);
assert!(health[1].is_expiring_soon);
assert!(!health[2].is_valid);
}
#[test]
fn test_get_credential_health_empty() {
let pool_data: Vec<(String, bool, bool, bool)> = vec![];
let health = get_credential_health_from_pool(&pool_data);
assert!(health.is_empty());
}
}
+4 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ProxyCast",
"version": "0.12.5",
"version": "0.13.0",
"identifier": "com.proxycast.app",
"build": {
"beforeDevCommand": "npm run dev",
@@ -33,6 +33,9 @@
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"resources": [
"icons/tray/*"
]
},
"plugins": {