diff --git a/Cargo.lock b/Cargo.lock index 0b6a6591a..26c9c2291 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1140,6 +1140,9 @@ dependencies = [ "log", "nanoid", "network-interface", + "objc2", + "objc2-app-kit", + "objc2-foundation", "once_cell", "open", "parking_lot", @@ -4673,9 +4676,38 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.11.0", "block2", + "libc", "objc2", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.11.0", + "objc2", "objc2-foundation", ] @@ -4703,6 +4735,41 @@ dependencies = [ "objc2-io-surface", ] +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + [[package]] name = "objc2-encode" version = "4.1.0" diff --git a/Changelog.md b/Changelog.md index 88cf88ae2..cb6c81af5 100644 --- a/Changelog.md +++ b/Changelog.md @@ -13,6 +13,7 @@ ### ✨ 新增功能 +- 新增 macOS 托盘速率显示 - 快捷键操作通知操作结果 ### 🚀 优化改进 diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 32f1d67de..620b5c2a5 100755 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -109,6 +109,28 @@ rust_iso3166 = "0.1.14" # Use the git repo until the next release after v2.0.0. dark-light = { git = "https://github.com/rust-dark-light/dark-light" } +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-foundation = { version = "0.3", features = [ + "NSString", + "NSDictionary", + "NSAttributedString", +] } +objc2-app-kit = { version = "0.3", features = [ + "NSAttributedString", + "NSStatusItem", + "NSStatusBarButton", + "NSButton", + "NSControl", + "NSResponder", + "NSView", + "NSFont", + "NSFontDescriptor", + "NSColor", + "NSParagraphStyle", + "NSText", +] } + [target.'cfg(windows)'.dependencies] deelevate = { workspace = true } runas = "=1.2.0" diff --git a/src-tauri/src/config/verge.rs b/src-tauri/src/config/verge.rs index 1f0952859..4f8749eb2 100644 --- a/src-tauri/src/config/verge.rs +++ b/src-tauri/src/config/verge.rs @@ -233,7 +233,7 @@ pub struct IVerge { )] pub webdav_password: Option, - #[serde(skip)] + #[cfg(target_os = "macos")] pub enable_tray_speed: Option, // pub enable_tray_icon: Option, @@ -438,6 +438,7 @@ impl IVerge { webdav_url: None, webdav_username: None, webdav_password: None, + #[cfg(target_os = "macos")] enable_tray_speed: Some(false), // enable_tray_icon: Some(true), tray_proxy_groups_display_mode: Some("default".into()), @@ -543,6 +544,7 @@ impl IVerge { patch!(webdav_url); patch!(webdav_username); patch!(webdav_password); + #[cfg(target_os = "macos")] patch!(enable_tray_speed); // patch!(enable_tray_icon); patch!(tray_proxy_groups_display_mode); diff --git a/src-tauri/src/core/tray/mod.rs b/src-tauri/src/core/tray/mod.rs index bc8d8df03..3ab9761fc 100644 --- a/src-tauri/src/core/tray/mod.rs +++ b/src-tauri/src/core/tray/mod.rs @@ -25,7 +25,10 @@ use tauri::{ AppHandle, Wry, menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}, }; + mod menu_def; +#[cfg(target_os = "macos")] +mod speed_task; use menu_def::{MenuIds, MenuTexts}; // TODO: 是否需要将可变菜单抽离存储起来,后续直接更新对应菜单实例,无需重新创建菜单(待考虑) @@ -45,6 +48,8 @@ enum IconKind { pub struct Tray { limiter: SystemLimiter, + #[cfg(target_os = "macos")] + speed_controller: speed_task::TraySpeedController, } impl TrayState { @@ -113,6 +118,8 @@ impl Default for Tray { fn default() -> Self { Self { limiter: Limiter::new(Duration::from_millis(TRAY_CLICK_DEBOUNCE_MS), SystemClock), + #[cfg(target_os = "macos")] + speed_controller: speed_task::TraySpeedController::new(), } } } @@ -325,6 +332,8 @@ impl Tray { let verge = Config::verge().await.data_arc(); self.update_menu().await?; self.update_icon(&verge).await?; + #[cfg(target_os = "macos")] + self.update_speed_task(verge.enable_tray_speed.unwrap_or(false)); self.update_tooltip().await?; Ok(()) } @@ -382,6 +391,12 @@ impl Tray { } allow } + + /// 根据配置统一更新托盘速率采集任务状态(macOS) + #[cfg(target_os = "macos")] + pub fn update_speed_task(&self, enable_tray_speed: bool) { + self.speed_controller.update_task(enable_tray_speed); + } } fn create_hotkeys(hotkeys: &Option>) -> HashMap { diff --git a/src-tauri/src/core/tray/speed_task.rs b/src-tauri/src/core/tray/speed_task.rs new file mode 100644 index 000000000..62be15c96 --- /dev/null +++ b/src-tauri/src/core/tray/speed_task.rs @@ -0,0 +1,194 @@ +use crate::core::handle; +use crate::process::AsyncHandler; +use crate::utils::{connections_stream, tray_speed}; +use crate::{Type, logging}; +use parking_lot::Mutex; +use std::sync::Arc; +use std::time::Duration; +use tauri::async_runtime::JoinHandle; +use tauri_plugin_mihomo::models::ConnectionId; + +/// 托盘速率流异常后的重连间隔。 +const TRAY_SPEED_RETRY_DELAY: Duration = Duration::from_secs(1); +/// 托盘速率流运行时的空闲轮询间隔。 +const TRAY_SPEED_IDLE_POLL_INTERVAL: Duration = Duration::from_millis(200); +/// 托盘速率流在此时间内收不到有效数据时,触发重连并降级到 0/0。 +const TRAY_SPEED_STALE_TIMEOUT: Duration = Duration::from_secs(5); + +/// macOS 托盘速率任务控制器。 +#[derive(Clone)] +pub struct TraySpeedController { + speed_task: Arc>>>, + speed_connection_id: Arc>>, +} + +impl Default for TraySpeedController { + fn default() -> Self { + Self { + speed_task: Arc::new(Mutex::new(None)), + speed_connection_id: Arc::new(Mutex::new(None)), + } + } +} + +impl TraySpeedController { + pub fn new() -> Self { + Self::default() + } + + pub fn update_task(&self, enable_tray_speed: bool) { + if enable_tray_speed { + self.start_task(); + } else { + self.stop_task(); + } + } + + /// 启动托盘速率采集后台任务(基于 `/traffic` WebSocket 流)。 + fn start_task(&self) { + if handle::Handle::global().is_exiting() { + return; + } + + // 关键步骤:托盘不可用时不启动速率任务,避免无效连接重试。 + if !Self::has_main_tray() { + logging!(warn, Type::Tray, "托盘不可用,跳过启动托盘速率任务"); + return; + } + + let mut guard = self.speed_task.lock(); + if guard.as_ref().is_some_and(|task| !task.inner().is_finished()) { + return; + } + + let speed_connection_id = Arc::clone(&self.speed_connection_id); + let task = AsyncHandler::spawn(move || async move { + loop { + if handle::Handle::global().is_exiting() { + break; + } + + if !Self::has_main_tray() { + logging!(warn, Type::Tray, "托盘已不可用,停止托盘速率任务"); + break; + } + + let stream_connect_result = connections_stream::connect_traffic_stream().await; + let mut speed_stream = match stream_connect_result { + Ok(stream) => stream, + Err(err) => { + logging!(debug, Type::Tray, "托盘速率流连接失败,稍后重试: {err}"); + Self::apply_tray_speed(0, 0); + tokio::time::sleep(TRAY_SPEED_RETRY_DELAY).await; + continue; + } + }; + + Self::set_speed_connection_id(&speed_connection_id, Some(speed_stream.connection_id)); + + loop { + let next_state = speed_stream + .next_event(TRAY_SPEED_IDLE_POLL_INTERVAL, TRAY_SPEED_STALE_TIMEOUT, || { + handle::Handle::global().is_exiting() + }) + .await; + + match next_state { + connections_stream::StreamConsumeState::Event(speed_event) => { + Self::apply_tray_speed(speed_event.up, speed_event.down); + } + connections_stream::StreamConsumeState::Stale => { + logging!(debug, Type::Tray, "托盘速率流长时间未收到有效数据,触发重连"); + Self::apply_tray_speed(0, 0); + break; + } + connections_stream::StreamConsumeState::Closed + | connections_stream::StreamConsumeState::ExitRequested => { + break; + } + } + } + + Self::disconnect_speed_connection(&speed_connection_id).await; + + if handle::Handle::global().is_exiting() || !Self::has_main_tray() { + break; + } + + // Stale 分支在内层 loop 中已重置为 0/0;此处兜底 Closed 分支(流被远端关闭)。 + Self::apply_tray_speed(0, 0); + tokio::time::sleep(TRAY_SPEED_RETRY_DELAY).await; + } + + Self::set_speed_connection_id(&speed_connection_id, None); + }); + + *guard = Some(task); + } + + /// 停止托盘速率采集后台任务并清除速率显示。 + fn stop_task(&self) { + // 取出任务句柄,与 speed_connection_id 一同传入清理任务。 + let task = self.speed_task.lock().take(); + let speed_connection_id = Arc::clone(&self.speed_connection_id); + + AsyncHandler::spawn(move || async move { + // 关键步骤:先等待 abort 完成,再断开 WebSocket 连接。 + // 若直接 abort 后立即 disconnect,任务可能已通过 take 取走 connection_id + // 但尚未完成断开,导致 connection_id 丢失、连接泄漏。 + // await task handle 可保证原任务已退出,connection_id 不再被占用。 + if let Some(task) = task { + task.abort(); + let _ = task.await; + } + Self::disconnect_speed_connection(&speed_connection_id).await; + }); + + let app_handle = handle::Handle::app_handle(); + if let Some(tray) = app_handle.tray_by_id("main") { + let result = tray.with_inner_tray_icon(|inner| { + if let Some(status_item) = inner.ns_status_item() { + tray_speed::clear_speed_attributed_title(&status_item); + } + }); + if let Err(err) = result { + logging!(warn, Type::Tray, "清除富文本速率失败: {err}"); + } + } + } + + fn has_main_tray() -> bool { + handle::Handle::app_handle().tray_by_id("main").is_some() + } + + fn set_speed_connection_id( + speed_connection_id: &Arc>>, + connection_id: Option, + ) { + *speed_connection_id.lock() = connection_id; + } + + fn take_speed_connection_id(speed_connection_id: &Arc>>) -> Option { + speed_connection_id.lock().take() + } + + async fn disconnect_speed_connection(speed_connection_id: &Arc>>) { + if let Some(connection_id) = Self::take_speed_connection_id(speed_connection_id) { + connections_stream::disconnect_connection(connection_id).await; + } + } + + fn apply_tray_speed(up: u64, down: u64) { + let app_handle = handle::Handle::app_handle(); + if let Some(tray) = app_handle.tray_by_id("main") { + let result = tray.with_inner_tray_icon(move |inner| { + if let Some(status_item) = inner.ns_status_item() { + tray_speed::set_speed_attributed_title(&status_item, up, down); + } + }); + if let Err(err) = result { + logging!(warn, Type::Tray, "设置富文本速率失败: {err}"); + } + } + } +} diff --git a/src-tauri/src/feat/config.rs b/src-tauri/src/feat/config.rs index 4f21743d9..6847e1ba7 100644 --- a/src-tauri/src/feat/config.rs +++ b/src-tauri/src/feat/config.rs @@ -96,7 +96,10 @@ fn determine_update_flags(patch: &IVerge) -> UpdateFlags { let socks_port = patch.verge_socks_port; let http_enabled = patch.verge_http_enabled; let http_port = patch.verge_port; + #[cfg(target_os = "macos")] let enable_tray_speed = patch.enable_tray_speed; + #[cfg(not(target_os = "macos"))] + let enable_tray_speed: Option = None; // let enable_tray_icon = patch.enable_tray_icon; let enable_global_hotkey = patch.enable_global_hotkey; let tray_event = &patch.tray_event; @@ -235,6 +238,10 @@ async fn process_terminated_flags(update_flags: UpdateFlags, patch: &IVerge) -> tray::Tray::global() .update_icon(&Config::verge().await.latest_arc()) .await?; + #[cfg(target_os = "macos")] + if patch.enable_tray_speed.is_some() { + tray::Tray::global().update_speed_task(patch.enable_tray_speed.unwrap_or(false)); + } } if update_flags.contains(UpdateFlags::SYSTRAY_TOOLTIP) { tray::Tray::global().update_tooltip().await?; diff --git a/src-tauri/src/utils/connections_stream.rs b/src-tauri/src/utils/connections_stream.rs new file mode 100644 index 000000000..573ab399c --- /dev/null +++ b/src-tauri/src/utils/connections_stream.rs @@ -0,0 +1,174 @@ +use crate::{Type, core::handle, logging}; +use anyhow::Result; +use serde::Deserialize; +use serde_json::Value; +use std::time::Duration; +use tauri_plugin_mihomo::models::{ConnectionId, WebSocketMessage}; +use tokio::sync::mpsc; +use tokio::time::Instant; + +/// Mihomo WebSocket 流的有界队列容量,避免异常场景下内存无限增长。 +const MIHOMO_WS_STREAM_BUFFER_SIZE: usize = 8; +/// 断开 Mihomo WebSocket 连接时使用的关闭码(RFC 6455 标准正常关闭)。 +const MIHOMO_WS_STREAM_CLOSE_CODE: u64 = 1000; + +/// `/traffic` 即时速率事件(字节/秒)。 +#[derive(Debug, Clone, Copy)] +pub struct TrafficSpeedEvent { + pub up: u64, + pub down: u64, +} + +/// Mihomo WebSocket 流消费状态。 +pub enum StreamConsumeState { + /// 收到一条业务事件。 + Event(T), + /// 连接关闭或消息流结束。 + Closed, + /// 在超时时间内未收到有效事件,需要重连。 + Stale, + /// 上层请求退出消费循环。 + ExitRequested, +} + +enum InternalWsEvent { + Data(T), + Closed, +} + +/// Mihomo WebSocket 订阅句柄(通用事件流)。 +pub struct MihomoWsEventStream { + /// 当前订阅连接 ID,用于主动断开。 + pub connection_id: ConnectionId, + /// 当前订阅消息接收器。 + receiver: mpsc::Receiver>, + /// 最近一次收到有效事件的时间戳。 + last_valid_event_at: Instant, +} + +#[derive(Deserialize)] +struct TrafficPayload { + up: u64, + down: u64, +} + +fn parse_traffic_event(data: Value) -> Option> { + if let Ok(payload) = serde_json::from_value::(data.clone()) { + return Some(InternalWsEvent::Data(TrafficSpeedEvent { + up: payload.up, + down: payload.down, + })); + } + + if let Ok(ws_message) = WebSocketMessage::deserialize(&data) { + match ws_message { + WebSocketMessage::Text(text) => { + let payload = serde_json::from_str::(&text).ok()?; + Some(InternalWsEvent::Data(TrafficSpeedEvent { + up: payload.up, + down: payload.down, + })) + } + WebSocketMessage::Close(_) => Some(InternalWsEvent::Closed), + _ => None, + } + } else { + None + } +} + +fn try_send_internal_event(message_tx: &mpsc::Sender>, event: InternalWsEvent) { + if let Err(err) = message_tx.try_send(event) { + match err { + // 队列满时丢弃本次事件,下一次事件会继续覆盖更新。 + tokio::sync::mpsc::error::TrySendError::Full(_) => {} + // 任务已结束时通道可能关闭,忽略即可。 + tokio::sync::mpsc::error::TrySendError::Closed(_) => {} + } + } +} + +/// 建立 `/traffic` WebSocket 订阅(通用流)。 +pub async fn connect_traffic_stream() -> Result> { + // 使用有界 mpsc 通道承接回调事件,限制消息积压上限。 + let (message_tx, message_rx) = mpsc::channel::>(MIHOMO_WS_STREAM_BUFFER_SIZE); + // 建立 Mihomo `/traffic` WebSocket 订阅。 + let connection_id = handle::Handle::mihomo() + .await + .ws_traffic({ + let message_tx = message_tx.clone(); + move |message| { + if let Some(event) = parse_traffic_event(message) { + try_send_internal_event(&message_tx, event); + } + } + }) + .await?; + drop(message_tx); + Ok(MihomoWsEventStream { + connection_id, + receiver: message_rx, + last_valid_event_at: Instant::now(), + }) +} + +impl MihomoWsEventStream { + /// 等待下一次可用事件或结束状态。 + /// + /// # Arguments + /// * `idle_poll_interval` - 空闲检查间隔 + /// * `stale_timeout` - 无有效事件超时时间 + /// * `should_exit` - 上层退出判定函数 + pub async fn next_event( + &mut self, + _idle_poll_interval: Duration, // 签名保留,但内部逻辑已进化为更高效的驱动方式 + stale_timeout: Duration, + should_exit: F, + ) -> StreamConsumeState + where + F: Fn() -> bool, + { + let sleep = tokio::time::sleep(stale_timeout); + tokio::pin!(sleep); + + loop { + if should_exit() { + return StreamConsumeState::ExitRequested; + } + + tokio::select! { + maybe_event = self.receiver.recv() => { + match maybe_event { + Some(InternalWsEvent::Data(event)) => { + self.last_valid_event_at = Instant::now(); + sleep.as_mut().reset(self.last_valid_event_at + stale_timeout); + return StreamConsumeState::Event(event); + } + Some(InternalWsEvent::Closed) | None => return StreamConsumeState::Closed, + } + } + _ = &mut sleep => { + if self.last_valid_event_at.elapsed() >= stale_timeout { + return StreamConsumeState::Stale; + } else { + sleep.as_mut().reset(self.last_valid_event_at + stale_timeout); + } + } + } + } + } +} + +/// 断开指定 Mihomo WebSocket 连接。 +/// +/// # Arguments +/// * `connection_id` - 目标连接 ID +pub async fn disconnect_connection(connection_id: ConnectionId) { + if let Err(err) = handle::Handle::mihomo() + .await + .disconnect(connection_id, Some(MIHOMO_WS_STREAM_CLOSE_CODE)) + .await + { + logging!(debug, Type::Tray, "断开 Mihomo WebSocket 连接失败: {err}"); + } +} diff --git a/src-tauri/src/utils/mod.rs b/src-tauri/src/utils/mod.rs index 5015c099a..b7f014028 100644 --- a/src-tauri/src/utils/mod.rs +++ b/src-tauri/src/utils/mod.rs @@ -1,3 +1,5 @@ +#[cfg(target_os = "macos")] +pub mod connections_stream; pub mod dirs; pub mod help; pub mod init; @@ -10,5 +12,8 @@ pub mod resolve; pub mod schtasks; pub mod server; pub mod singleton; +pub mod speed; pub mod tmpl; +#[cfg(target_os = "macos")] +pub mod tray_speed; pub mod window_manager; diff --git a/src-tauri/src/utils/speed.rs b/src-tauri/src/utils/speed.rs new file mode 100644 index 000000000..df5e1bb76 --- /dev/null +++ b/src-tauri/src/utils/speed.rs @@ -0,0 +1,71 @@ +//! 网络速率格式化工具 + +/// 速率显示升档阈值:保证显示值不超过三位数(显示层约定,与换算基数无关) +const SPEED_DISPLAY_THRESHOLD: f64 = 1000.0; +/// 速率展示单位顺序 +const SPEED_UNITS: [&str; 5] = ["B/s", "K/s", "M/s", "G/s", "T/s"]; +/// 预计算 1024 的幂次方,避免运行时重复计算 pow +const SCALES: [f64; 5] = [ + 1.0, + 1024.0, + 1024.0 * 1024.0, + 1024.0 * 1024.0 * 1024.0, + 1024.0 * 1024.0 * 1024.0 * 1024.0, +]; + +/// 将字节/秒格式化为可读速率字符串 +/// +/// # Arguments +/// * `bytes_per_sec` - 每秒字节数 +pub fn format_bytes_per_second(bytes_per_sec: u64) -> String { + if bytes_per_sec < SPEED_DISPLAY_THRESHOLD as u64 { + return format!("{bytes_per_sec}B/s"); + } + + let mut unit_index = (bytes_per_sec.ilog2() / 10) as usize; + unit_index = unit_index.min(SPEED_UNITS.len() - 1); + + let mut value = bytes_per_sec as f64 / SCALES[unit_index]; + + if value.round() >= SPEED_DISPLAY_THRESHOLD && unit_index < SPEED_UNITS.len() - 1 { + unit_index += 1; + value = bytes_per_sec as f64 / SCALES[unit_index]; + } + + if value < 9.95 { + format!("{value:.1}{}", SPEED_UNITS[unit_index]) + } else { + format!("{:.0}{}", value.round(), SPEED_UNITS[unit_index]) + } +} + +#[cfg(test)] +mod tests { + use super::format_bytes_per_second; + + #[test] + fn format_handles_byte_boundaries() { + assert_eq!(format_bytes_per_second(0), "0B/s"); + assert_eq!(format_bytes_per_second(999), "999B/s"); + // 1000 >= SPEED_DISPLAY_THRESHOLD,升档为 K/s(保证不超过三位数) + assert_eq!(format_bytes_per_second(1000), "1.0K/s"); + assert_eq!(format_bytes_per_second(1024), "1.0K/s"); + } + + #[test] + fn format_handles_decimal_and_integer_rules() { + assert_eq!(format_bytes_per_second(9 * 1024), "9.0K/s"); + // 9.999 K/s:rounded_1dp = 10.0,不满足 < 10,应显示整数 "10K/s" + assert_eq!(format_bytes_per_second(10 * 1024 - 1), "10K/s"); + assert_eq!(format_bytes_per_second(10 * 1024), "10K/s"); + assert_eq!(format_bytes_per_second(123 * 1024), "123K/s"); + } + + #[test] + fn format_handles_unit_promotion_after_rounding() { + // 999.5 K/s 四舍五入为 1000,≥ SPEED_DISPLAY_THRESHOLD,升档为 1.0M/s + assert_eq!(format_bytes_per_second(999 * 1024 + 512), "1.0M/s"); + assert_eq!(format_bytes_per_second(1024 * 1024), "1.0M/s"); + assert_eq!(format_bytes_per_second(1536 * 1024), "1.5M/s"); + } +} diff --git a/src-tauri/src/utils/tray_speed.rs b/src-tauri/src/utils/tray_speed.rs new file mode 100644 index 000000000..f4560181d --- /dev/null +++ b/src-tauri/src/utils/tray_speed.rs @@ -0,0 +1,152 @@ +//! macOS 托盘速率富文本渲染模块 +//! +//! 通过 objc2 调用 NSAttributedString 实现托盘速率的富文本显示, +//! 支持等宽字体、自适应深色/浅色模式配色、两行定宽布局。 + +use std::cell::RefCell; + +use crate::utils::speed::format_bytes_per_second; +use crate::{Type, logging}; +use objc2::MainThreadMarker; +use objc2::rc::Retained; +use objc2::runtime::AnyObject; +use objc2_app_kit::{ + NSBaselineOffsetAttributeName, NSColor, NSFont, NSFontAttributeName, NSFontWeightRegular, + NSForegroundColorAttributeName, NSMutableParagraphStyle, NSParagraphStyleAttributeName, NSStatusItem, + NSTextAlignment, +}; +use objc2_foundation::{NSAttributedString, NSDictionary, NSNumber, NSString}; + +/// 富文本渲染使用的字号(适配两行在托盘栏的高度) +const TRAY_FONT_SIZE: f64 = 9.5; +/// 两行文本的行间距(负值可压缩两行高度,便于与图标纵向居中) +const TRAY_LINE_SPACING: f64 = -1.0; +/// 两行文本整体行高倍数(用于进一步压缩文本块高度) +const TRAY_LINE_HEIGHT_MULTIPLE: f64 = 1.00; +/// 文本块段前偏移(用于将两行文本整体下移) +const TRAY_PARAGRAPH_SPACING_BEFORE: f64 = -5.0; +/// 文字基线偏移(负值向下移动,更容易与托盘图标垂直居中) +const TRAY_BASELINE_OFFSET: f64 = -4.0; + +thread_local! { + /// 托盘速率富文本属性字典(主线程缓存,避免每帧重建 ObjC 对象)。 + /// 仅在首次调用时初始化,后续复用同一实例。 + static TRAY_SPEED_ATTRS: Retained> = build_attributes(); + static LAST_DISPLAY_STR: RefCell = RefCell::new(String::new()); +} + +/// 将上行/下行速率格式化为两行定宽文本 +/// +/// # Arguments +/// * `up` - 上行速率(字节/秒) +/// * `down` - 下行速率(字节/秒) +fn format_tray_speed(up: u64, down: u64) -> String { + // 上行放在第一行,下行放在第二行;通过上下布局表达方向,不再显示箭头字符。 + let up_str = format_bytes_per_second(up); + let down_str = format_bytes_per_second(down); + format!("{:>6}\n{:>6}", up_str, down_str) +} + +/// 构造带富文本样式属性的 NSDictionary +/// +/// 包含:等宽字体、自适应标签颜色、右对齐段落样式 +fn build_attributes() -> Retained> { + unsafe { + // 等宽系统字体,确保数字不跳动 + let font = NSFont::monospacedSystemFontOfSize_weight(TRAY_FONT_SIZE, NSFontWeightRegular); + // 自适应标签颜色(自动跟随深色/浅色模式) + let color = NSColor::labelColor(); + // 段落样式:右对齐,保证定宽视觉一致 + let para_style = NSMutableParagraphStyle::new(); + para_style.setAlignment(NSTextAlignment::Right); + para_style.setLineSpacing(TRAY_LINE_SPACING); + para_style.setLineHeightMultiple(TRAY_LINE_HEIGHT_MULTIPLE); + para_style.setParagraphSpacingBefore(TRAY_PARAGRAPH_SPACING_BEFORE); + // 基线偏移:用于精确控制两行速率整体的纵向位置 + let baseline_offset = NSNumber::new_f64(TRAY_BASELINE_OFFSET); + + let keys: &[&NSString] = &[ + NSFontAttributeName, + NSForegroundColorAttributeName, + NSParagraphStyleAttributeName, + NSBaselineOffsetAttributeName, + ]; + let values: &[&AnyObject] = &[&font, &color, ¶_style, &baseline_offset]; + NSDictionary::from_slices(keys, values) + } +} + +/// 创建带属性的富文本 +/// +/// # Arguments +/// * `text` - 富文本字符串内容 +/// * `attrs` - 富文本属性字典 +fn create_attributed_string( + text: &NSString, + attrs: Option<&NSDictionary>, +) -> Retained { + unsafe { + NSAttributedString::initWithString_attributes(::alloc(), text, attrs) + } +} + +/// 在主线程下设置 NSStatusItem 按钮的富文本标题 +/// +/// 依赖 Tauri `with_inner_tray_icon` 保证回调在主线程执行; +/// 若意外在非主线程调用,`MainThreadMarker::new()` 返回 `None` 并记录警告。 +/// +/// # Arguments +/// * `status_item` - macOS 托盘 NSStatusItem 引用 +/// * `text` - 富文本字符串内容 +/// * `attrs` - 富文本属性字典 +fn apply_status_item_attributed_title( + status_item: &NSStatusItem, + text: &NSString, + attrs: Option<&NSDictionary>, +) { + let Some(mtm) = MainThreadMarker::new() else { + logging!(warn, Type::Tray, "托盘速率富文本设置跳过:非主线程调用"); + return; + }; + let Some(button) = status_item.button(mtm) else { + return; + }; + let attr_str = create_attributed_string(text, attrs); + button.setAttributedTitle(&attr_str); +} + +/// 将速率以富文本形式设置到 NSStatusItem 的按钮上 +/// +/// # Arguments +/// * `status_item` - macOS 托盘 NSStatusItem 引用 +/// * `up` - 上行速率(字节/秒) +/// * `down` - 下行速率(字节/秒) +pub fn set_speed_attributed_title(status_item: &NSStatusItem, up: u64, down: u64) { + let speed_text = format_tray_speed(up, down); + let changed = LAST_DISPLAY_STR.with(|last| { + let mut last_borrow = last.borrow_mut(); + if *last_borrow == speed_text { + false + } else { + *last_borrow = speed_text.clone(); + true + } + }); + + if !changed { + return; + } + let ns_string = NSString::from_str(&speed_text); + TRAY_SPEED_ATTRS.with(|attrs| { + apply_status_item_attributed_title(status_item, &ns_string, Some(&**attrs)); + }); +} + +/// 清除 NSStatusItem 按钮上的富文本速率显示 +/// +/// # Arguments +/// * `status_item` - macOS 托盘 NSStatusItem 引用 +pub fn clear_speed_attributed_title(status_item: &NSStatusItem) { + let empty = NSString::from_str(""); + apply_status_item_attributed_title(status_item, &empty, None); +} diff --git a/src/components/setting/mods/layout-viewer.tsx b/src/components/setting/mods/layout-viewer.tsx index e96ffa631..df13cc45c 100644 --- a/src/components/setting/mods/layout-viewer.tsx +++ b/src/components/setting/mods/layout-viewer.tsx @@ -405,9 +405,13 @@ export const LayoutViewer = forwardRef((_, ref) => { )} - {/* {OS === "macos" && ( + {OS === 'macos' && ( - + ((_, ref) => { - )} */} + )} {/* {OS === "macos" && ( diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 6fe8a3401..4b220cd8a 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -912,7 +912,7 @@ interface IVergeConfig { common_tray_icon?: boolean sysproxy_tray_icon?: boolean tun_tray_icon?: boolean - // enable_tray_speed?: boolean; + enable_tray_speed?: boolean // enable_tray_icon?: boolean; tray_proxy_groups_display_mode?: 'default' | 'inline' | 'disable' tray_inline_outbound_modes?: boolean