From b94fc6e62f9c4f3ba5131784c97a7f24f0d3b8c4 Mon Sep 17 00:00:00 2001 From: coso Date: Fri, 19 Dec 2025 02:01:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=B3=BB=E7=BB=9F=E6=89=98=E7=9B=98?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=20+=20=E5=85=81=E8=AE=B8=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=88=AA=E6=96=AD=E5=87=AD=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现完整的系统托盘功能 - 托盘图标状态显示(运行/警告/错误/停止) - 托盘菜单:服务器控制、凭证状态、快捷工具 - 单击/双击托盘图标交互 - 状态同步机制 - 修复:允许添加被截断的 refreshToken 凭证,刷新时才提示 版本更新: 0.12.5 -> 0.13.0 --- package-lock.json | 4 +- package.json | 2 +- scripts/generate-tray-icons.mjs | 124 ++++++ src-tauri/Cargo.lock | 21 +- src-tauri/Cargo.toml | 4 +- src-tauri/icons/tray/tray-error.png | Bin 0 -> 653 bytes src-tauri/icons/tray/tray-running.png | Bin 0 -> 727 bytes src-tauri/icons/tray/tray-stopped.png | Bin 0 -> 715 bytes src-tauri/icons/tray/tray-warning.png | Bin 0 -> 715 bytes src-tauri/icons/tray/trayTemplate.png | Bin 0 -> 378 bytes src-tauri/icons/tray/trayTemplate@2x.png | Bin 0 -> 731 bytes src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/provider_pool_cmd.rs | 18 +- src-tauri/src/commands/tray_cmd.rs | 263 ++++++++++++ src-tauri/src/lib.rs | 75 +++- src-tauri/src/tray/events.rs | 141 +++++++ src-tauri/src/tray/format.rs | 120 ++++++ src-tauri/src/tray/manager.rs | 445 ++++++++++++++++++++ src-tauri/src/tray/menu.rs | 410 ++++++++++++++++++ src-tauri/src/tray/menu_handler.rs | 348 +++++++++++++++ src-tauri/src/tray/mod.rs | 24 ++ src-tauri/src/tray/state.rs | 214 ++++++++++ src-tauri/src/tray/sync.rs | 277 ++++++++++++ src-tauri/tauri.conf.json | 5 +- 24 files changed, 2478 insertions(+), 18 deletions(-) create mode 100644 scripts/generate-tray-icons.mjs create mode 100644 src-tauri/icons/tray/tray-error.png create mode 100644 src-tauri/icons/tray/tray-running.png create mode 100644 src-tauri/icons/tray/tray-stopped.png create mode 100644 src-tauri/icons/tray/tray-warning.png create mode 100644 src-tauri/icons/tray/trayTemplate.png create mode 100644 src-tauri/icons/tray/trayTemplate@2x.png create mode 100644 src-tauri/src/commands/tray_cmd.rs create mode 100644 src-tauri/src/tray/events.rs create mode 100644 src-tauri/src/tray/format.rs create mode 100644 src-tauri/src/tray/manager.rs create mode 100644 src-tauri/src/tray/menu.rs create mode 100644 src-tauri/src/tray/menu_handler.rs create mode 100644 src-tauri/src/tray/mod.rs create mode 100644 src-tauri/src/tray/state.rs create mode 100644 src-tauri/src/tray/sync.rs diff --git a/package-lock.json b/package-lock.json index 1de734b81..b2ca695bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 9de4a769a..d62fe425b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.12.5", + "version": "0.13.0", "type": "module", "scripts": { "dev": "vite", diff --git a/scripts/generate-tray-icons.mjs b/scripts/generate-tray-icons.mjs new file mode 100644 index 000000000..31f5040cd --- /dev/null +++ b/scripts/generate-tray-icons.mjs @@ -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 ` + + + + `.trim(); +} + +/** + * 创建带边框的圆形图标 SVG(更好的可见性) + * @param {string} fillColor - 填充颜色 + * @param {string} strokeColor - 边框颜色 + */ +function createCircleWithBorderSvg(fillColor, strokeColor) { + return ` + + + + `.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); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ebfd84398..12123f63c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 08a7a30ac..c28fa7a9c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/icons/tray/tray-error.png b/src-tauri/icons/tray/tray-error.png new file mode 100644 index 0000000000000000000000000000000000000000..b33cbfb7019cbbeb69e31b3bb97ec1ed0cf932a7 GIT binary patch literal 653 zcmV;80&@L{P){em-e#N=k7*s;>0Gl+Y2L&wA%IVjKBHWZyo1ZGKzMb7phXJOQN@m z=vWe+DWXTH8Ds89x^As-!;*Pl5`~IcXHk4Bg?NL#xr`4y&sEH)_TcmNb$wm0>rtzv z@7isB-|a?Jy&-^M& z!hN=Xp38X5i767yAD1SkWZt(iUj_DM;jqa$^t`>re@}l(?WWl*%(zt~u}R9aLe!Gdn45HsdiLKbe$T z0fz&Pn$mLLkFHcSlyQufV~jb}`Cqi0=p>Z~rU{Y;G%9-iWPh08rL>#-VKOUVG9VLZ nrN+nvim{nHOk_LH;w+ZmBRAv*%?*{P00000NkvXXu0mjfkSi;{ literal 0 HcmV?d00001 diff --git a/src-tauri/icons/tray/tray-running.png b/src-tauri/icons/tray/tray-running.png new file mode 100644 index 0000000000000000000000000000000000000000..856fbd780ede269e22f708fbe3e8c3d87ed5f93a GIT binary patch literal 727 zcmV;|0x127P)<;=*EjMH%&B8=Ix#pE8GVA7wF7gPijAJs zt|3l7sD*LAAxf?-Dz95&{b*NI{kC-YhJU3&Q(Spk4}r+JJ*&D{@NVrU;`B2t1dEnm zI>H=xL^aJv9Z`AJ68Kpo0Kw>2%*dU&g5!O9+u-yUEO7PtwlGKS^uRN2`fag#ussDq zeqwHI&6D<1m{=Ova!2z9Kc9`YfvrG%!K`5>M;F=(V&tRR`BK5pfe16f$-BhD)Pri5 zXTC_rA1cPgCvk(B_CAX<+;XxDtBc4qEn^s`}fUE)l^e_UzVB`}FN`@;40IVI<0X>NU zK+j+>&+3oV!KkeOoJ9d}@?8S(3k2YY0KUZlpp$=h1Tj|;G4l4m79nC}Px8NO7X|>< zp0?`yQ6l2?4MoF66A{{{*W;;}(UX9Ro!kL{o=Bu(FpyDUGx7^+MqS#}lG`zTFWd1! z{_WV{WSpuKjhy!Cm0xu<~_GKR?vj$2A!~{^O1Oy#ax_qV`!dR@@xisx!`wd`~Z@bW%T%-U1002ov JPDHLkV1gJaOius+ literal 0 HcmV?d00001 diff --git a/src-tauri/icons/tray/tray-stopped.png b/src-tauri/icons/tray/tray-stopped.png new file mode 100644 index 0000000000000000000000000000000000000000..ef7949a7094bc926eb5715b240dace33574355df GIT binary patch literal 715 zcmV;+0yO=JP)bq&z{%2`EpHmN!7a0m20?i@w+!s>F$%#1@y1G?JB#XU6mM&9|0y zDe)y^yM+}lsKA8kGNF1*&@W%{th!7pk2|!SxMSwf8WZZAOQqu|{+pHFeZ0Gn=kwAwk)H#rwUgia7mT1&+o7eooHI!`xlmtV$jLv| zrM;T2wT^Y|)!RsYzTePdbKB2`RNu2M-I>X`Exg-Y(jORj?Ct8KMkkizi+b4X4Bib& z@|(M~8awSw(l@+_bkFK|RAIv}hx(dzi&vxkXP8u<%$G0f+~_b9l0HmrOynGZehiYTD6s|^MSUGwFt7XE(7-Dl;I>K0Q!u>O6_%O0UT7@0ZP(YSPA+#OtSk&6}AJI z?vDo`=qC;_GHC^H*mz^$`A1j}ockqcrxpN~*2GcdlHUE_CX~ucdknFzgE4$Eo&krJ zeHcz3h**xUhe9!zbml{GE%5+gb1D==K%>kuN^%;|{jdq!wo7-dh;{tYU&rOd0)VZ6 z6TFY)iMB&aAP;E}k_S1uV#N{e(&|M@HcvT__D8{&gOb_8yJgKwN^=`Uy;H8nCZ(m9 z^?r3)EzgZfb|wVuy&c!>5!+{d06DfxSDd0A(oy&Mj z*jFTnrrEe%X1ZHr`e@hIpP%OSu07tTbi2aFj<{en;dRRj=yMw&SuU%WfH@G>Y$$2olOr7Dez?9f#t-o zD}l$fuvcSK8$PYQs_z8HuT96k9G`Fkq&fM##*G>z6o=PmCZ?op_hUW_>}Q4JP1z(T ztVW(*`K8w1u~Rc{w7+ee%IJnCUWqM@PoJ`JQmUY~L7~L11sqTU?x&S_QA&NnM`0}( ze~t~BJbIiy{6-IVy)Y<_FTxObN0ZdmvT}g-_WADTk&=Vt#~V@6_XH+GVOSf_GR?Ihrb9bR+MPEkOb_}k8d~9%Z1K{fgWDP@eU1qnxk?7TZ)>4!=Ul94`3?4NWpHFp9Torp002ovPDHLkV1kV_NTC1# literal 0 HcmV?d00001 diff --git a/src-tauri/icons/tray/trayTemplate.png b/src-tauri/icons/tray/trayTemplate.png new file mode 100644 index 0000000000000000000000000000000000000000..8a22bf131b7968f50a572216e8da5e8fdf977701 GIT binary patch literal 378 zcmV-=0fqjFP)3b;c;^acun+o;I7fQ`|30ZYAs z#ErlPHkwKPCPs#K%5;7b<4fLVlJ>>b09@w*L{g(dg+IFV#FK!9#B@w2 zb)`mC6>i?up`_XsmQvF(4^k{O%4gOt-qw&KcH5n zTSd?iSMDs>zo6Kqv~*wX__5NOiq(a2Gcs@rWgzCx%sn$_GV{U%A)oJi$b6i857eqv ztCp@BKnGX^`oLRY8`uT@0z-McEwASKcbeBp;6Cs@ zL+DXN-?;>wz@Ux+%fN4i;BlhgcnBQDh~|LLIRuaM3Aj;Y@Wa5P0)oeR0krZCe+Jkz z5PqKxU?#8d=YjVILdW?C%xZ_f2z)aTb`)QM3tHh%1A&2%tQs)vn-$ma4Oxl`CyXD!luH4w8ZfKHWtRdh8ZfHG zEtdj3H(*qYsHYjLH3LSqc;ixlz<^OLqRp4F8W=FDc|$Z-Ljy*&_){0aL4&Fv0)YXe zg`Nc03>Yo+GT1j@REuXW1-Na%s1^$@1?U(ss>LOj0yL`L4kKeJ;e_#$ifo32J~m03 z6SgQC?Pwn&p_U97_r)Djs9p7q5o8)GEIIecpjOp4N0Cvqu%ukWlb#v~la$AJ9+BBF z5XzNzzDHz`IlQ1GJ^v|`G%KTMVHL;Z_H_=}G7vUSkjJ3p!NXzQKl%|ag zOZPNRcuCZiIo%>ocHXY@S=EwhtUP*$J-NL-fTO98+CQZDS?_Yld~dAQE5Z( z%1TZ*O+Rl%Q@|A&PUy+n{0G^A8VUn>?zOyUL8dvJ*YIl9s#S{=`UeVxlC-Gkjurp_ N002ovPDHLkV1fd~M=JmT literal 0 HcmV?d00001 diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 656d134da..e92085258 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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; diff --git a/src-tauri/src/commands/provider_pool_cmd.rs b/src-tauri/src/commands/provider_pool_cmd.rs index 257169fcf..574008582 100644 --- a/src-tauri/src/commands/provider_pool_cmd.rs +++ b/src-tauri/src/commands/provider_pool_cmd.rs @@ -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 字段"); } diff --git a/src-tauri/src/commands/tray_cmd.rs b/src-tauri/src/commands/tray_cmd.rs new file mode 100644 index 000000000..e00e23d28 --- /dev/null +++ b/src-tauri/src/commands/tray_cmd.rs @@ -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>, + 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>, + 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>, + 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>, +) -> Result { + 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>, +) -> 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>, + 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(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b738d9f2c..cc0073800 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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); +/// TrayManager 状态封装 +/// +/// 用于在 Tauri 状态管理中存储托盘管理器 +pub struct TrayManagerState(pub Arc>>>); + #[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 = + TrayManagerState(Arc::new(tokio::sync::RwLock::new(Some(tray_manager)))); + app.manage(tray_state); + } + Err(e) => { + tracing::error!("[启动] 托盘管理器初始化失败: {}", e); + // 即使托盘初始化失败,应用仍然可以运行 + let tray_state: TrayManagerState = + 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::>() { + 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"); diff --git a/src-tauri/src/tray/events.rs b/src-tauri/src/tray/events.rs new file mode 100644 index 000000000..50b581586 --- /dev/null +++ b/src-tauri/src/tray/events.rs @@ -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(app: &AppHandle, 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(app: &AppHandle) { + 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(app: &AppHandle) { + if let Some(window) = app.get_webview_window("main") { + show_and_focus_window(&window); + } else { + tracing::warn!("未找到主窗口"); + } +} + +/// 显示并聚焦窗口的辅助函数 +fn show_and_focus_window(window: &tauri::WebviewWindow) { + // 取消最小化 + 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); + } +} diff --git a/src-tauri/src/tray/format.rs b/src-tauri/src/tray/format.rs new file mode 100644 index 000000000..bf7d27175 --- /dev/null +++ b/src-tauri/src/tray/format.rs @@ -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"); + } +} diff --git a/src-tauri/src/tray/manager.rs b/src-tauri/src/tray/manager.rs new file mode 100644 index 000000000..ee5c9338f --- /dev/null +++ b/src-tauri/src/tray/manager.rs @@ -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 for TrayError { + fn from(e: tauri::Error) -> Self { + TrayError::TauriError(e.to_string()) + } +} + +/// 托盘图标资源 +/// +/// 存储不同状态对应的图标 +pub struct TrayIcons { + /// 正常运行状态图标(绿色) + pub running: Option>, + /// 警告状态图标(黄色) + pub warning: Option>, + /// 错误状态图标(红色) + pub error: Option>, + /// 停止状态图标(灰色) + pub stopped: Option>, + /// 默认图标(后备) + pub default: Image<'static>, +} + +impl TrayIcons { + /// 加载托盘图标资源 + /// + /// 尝试从 `src-tauri/icons/tray/` 目录加载状态图标, + /// 如果加载失败则使用默认应用图标作为后备 + pub fn load(app: &AppHandle) -> 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(app: &AppHandle) -> 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> { + 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 { + /// Tauri 托盘图标句柄 + tray: TrayIcon, + /// 当前状态 + state: Arc>, + /// 图标资源 + icons: TrayIcons, + /// AppHandle 引用 + app: AppHandle, +} + +impl TrayManager { + /// 创建托盘管理器 + /// + /// 初始化托盘图标,设置初始状态为 Stopped + /// + /// # Requirements + /// - 1.4: 应用启动时显示停止状态图标 + pub fn new(app: &AppHandle) -> Result { + 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 { + &self.tray + } +} + +/// 简化版托盘管理器(用于测试和无 Tauri 环境) +pub struct SimpleTrayManager { + /// 当前状态 + state: Arc>, +} + +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("状态更新失败")); + } +} diff --git a/src-tauri/src/tray/menu.rs b/src-tauri/src/tray/menu.rs new file mode 100644 index 000000000..d4d4827c7 --- /dev/null +++ b/src-tauri/src/tray/menu.rs @@ -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( + app: &AppHandle, + state: &TrayStateSnapshot, +) -> Result, 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::() { + 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::(), + _available in 0usize..100, + _total in 0usize..100, + _requests in 0u64..1000000, + _auto_start in any::() + ) { + // 验证 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 应该唯一"); + } + } +} diff --git a/src-tauri/src/tray/menu_handler.rs b/src-tauri/src/tray/menu_handler.rs new file mode 100644 index 000000000..12b69c988 --- /dev/null +++ b/src-tauri/src/tray/menu_handler.rs @@ -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(app: &AppHandle, 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(app: &AppHandle) { + 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(app: &AppHandle) { + 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(app: &AppHandle) { + 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(app: &AppHandle) { + info!("[托盘] 用户请求执行健康检查"); + + // 发送事件到前端,由前端调用凭证池服务执行健康检查 + if let Err(e) = app.emit(menu_events::HEALTH_CHECK, ()) { + error!("[托盘] 发送健康检查事件失败: {}", e); + } +} + +/// 处理打开主窗口事件 +/// +/// # Requirements +/// - 4.1: WHEN 用户点击托盘菜单中的"打开主窗口" +/// THEN 系统托盘 SHALL 显示并聚焦主应用程序窗口 +fn handle_open_window(app: &AppHandle) { + 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(app: &AppHandle) { + info!("[托盘] 用户请求复制 API 地址"); + + // 获取托盘状态以获取服务器地址 + let app_clone = app.clone(); + tauri::async_runtime::spawn(async move { + if let Some(tray_state) = app_clone.try_state::>() { + 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(app: &AppHandle) { + 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(app: &AppHandle) { + 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(app: &AppHandle) { + 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(), "事件常量应该唯一"); + } +} diff --git a/src-tauri/src/tray/mod.rs b/src-tauri/src/tray/mod.rs new file mode 100644 index 000000000..b118ca6be --- /dev/null +++ b/src-tauri/src/tray/mod.rs @@ -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::*; diff --git a/src-tauri/src/tray/state.rs b/src-tauri/src/tray/state.rs new file mode 100644 index 000000000..ff6abf6af --- /dev/null +++ b/src-tauri/src/tray/state.rs @@ -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 { + (any::(), any::(), any::()).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::(), + 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()); + } +} diff --git a/src-tauri/src/tray/sync.rs b/src-tauri/src/tray/sync.rs new file mode 100644 index 000000000..7ed91a87f --- /dev/null +++ b/src-tauri/src/tray/sync.rs @@ -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 { + /// AppHandle 引用 + app: AppHandle, + /// 托盘管理器引用 + tray_manager: Arc>>>, +} + +impl TraySynchronizer { + /// 创建托盘状态同步器 + pub fn new(app: AppHandle, tray_manager: Arc>>>) -> 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 { + 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()); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 69d3ce296..40b00dc94 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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": {