mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: 系统托盘功能 + 允许添加截断凭证
- 实现完整的系统托盘功能 - 托盘图标状态显示(运行/警告/错误/停止) - 托盘菜单:服务器控制、凭证状态、快捷工具 - 单击/双击托盘图标交互 - 状态同步机制 - 修复:允许添加被截断的 refreshToken 凭证,刷新时才提示 版本更新: 0.12.5 -> 0.13.0
This commit is contained in:
Generated
+2
-2
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"private": true,
|
||||
"version": "0.12.5",
|
||||
"version": "0.13.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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);
|
||||
Generated
+20
-1
@@ -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",
|
||||
|
||||
@@ -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 |
@@ -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;
|
||||
|
||||
@@ -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 字段");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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("状态更新失败"));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
©_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 应该唯一");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(), "事件常量应该唯一");
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user