mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-13 14:22:08 +08:00
feat(tray): 恢复并重构托盘显示速率功能 (#6487)
* feat(tray): 恢复并重构托盘显示速率功能 * docs(changelog): add tray speed feature entry for v2.4.7 * refactor(tray): 将托盘速率显示限制为仅 macOS * chore(style): 统一托盘速率设置相关代码风格 * refactor(tray): 统一 speed 任务调度并移除循环内配置轮询 * chore(tauri): enable createUpdaterArtifacts for updater support Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(tray): refine macOS tray speed formatting and two-line alignment * refactor(tray): move to utils * refactor(tray): improve macOS speed display formatting, alignment, and structure * chore: 降级 Node.js 版本至 21.7.1 * refactor(tray): 优化 macOS 托盘速率流与显示逻辑 * refactor(tray): 将速率任务重构为独立控制器并切换至 /traffic 流 * refactor(tray): 缩短速率宽度 * refactor(tray): 收敛测速流抽象并修正停止清理时序 * docs(changelog): 更新变更日志 * refactor(tray): simplify speed formatting logic and remove redundant functions * refactor(tray): optimize speed display logic and reduce redundant attribute initialization * refactor(tray): enhance traffic event parsing and improve stale event handling --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
Tunglies
parent
805ec3ef6e
commit
9e5da1a851
@@ -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<T> {
|
||||
/// 收到一条业务事件。
|
||||
Event(T),
|
||||
/// 连接关闭或消息流结束。
|
||||
Closed,
|
||||
/// 在超时时间内未收到有效事件,需要重连。
|
||||
Stale,
|
||||
/// 上层请求退出消费循环。
|
||||
ExitRequested,
|
||||
}
|
||||
|
||||
enum InternalWsEvent<T> {
|
||||
Data(T),
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// Mihomo WebSocket 订阅句柄(通用事件流)。
|
||||
pub struct MihomoWsEventStream<T> {
|
||||
/// 当前订阅连接 ID,用于主动断开。
|
||||
pub connection_id: ConnectionId,
|
||||
/// 当前订阅消息接收器。
|
||||
receiver: mpsc::Receiver<InternalWsEvent<T>>,
|
||||
/// 最近一次收到有效事件的时间戳。
|
||||
last_valid_event_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TrafficPayload {
|
||||
up: u64,
|
||||
down: u64,
|
||||
}
|
||||
|
||||
fn parse_traffic_event(data: Value) -> Option<InternalWsEvent<TrafficSpeedEvent>> {
|
||||
if let Ok(payload) = serde_json::from_value::<TrafficPayload>(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::<TrafficPayload>(&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<T>(message_tx: &mpsc::Sender<InternalWsEvent<T>>, event: InternalWsEvent<T>) {
|
||||
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<MihomoWsEventStream<TrafficSpeedEvent>> {
|
||||
// 使用有界 mpsc 通道承接回调事件,限制消息积压上限。
|
||||
let (message_tx, message_rx) = mpsc::channel::<InternalWsEvent<TrafficSpeedEvent>>(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<T> MihomoWsEventStream<T> {
|
||||
/// 等待下一次可用事件或结束状态。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `idle_poll_interval` - 空闲检查间隔
|
||||
/// * `stale_timeout` - 无有效事件超时时间
|
||||
/// * `should_exit` - 上层退出判定函数
|
||||
pub async fn next_event<F>(
|
||||
&mut self,
|
||||
_idle_poll_interval: Duration, // 签名保留,但内部逻辑已进化为更高效的驱动方式
|
||||
stale_timeout: Duration,
|
||||
should_exit: F,
|
||||
) -> StreamConsumeState<T>
|
||||
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}");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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<NSDictionary<NSString, AnyObject>> = build_attributes();
|
||||
static LAST_DISPLAY_STR: RefCell<String> = 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<NSDictionary<NSString, AnyObject>> {
|
||||
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<NSString, AnyObject>>,
|
||||
) -> Retained<NSAttributedString> {
|
||||
unsafe {
|
||||
NSAttributedString::initWithString_attributes(<NSAttributedString as objc2::AnyThread>::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<NSString, AnyObject>>,
|
||||
) {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user