Compare commits

...

2 Commits

Author SHA1 Message Date
renovate[bot]
11030ffd32
chore(deps): update github/gh-aw-actions action to v0.67.4 2026-04-09 18:16:12 +00:00
GrainFull
9e5da1a851
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>
2026-04-09 14:40:32 +00:00
14 changed files with 723 additions and 9 deletions

View File

@ -60,7 +60,7 @@ jobs:
title: ${{ steps.sanitized.outputs.title }} title: ${{ steps.sanitized.outputs.title }}
steps: steps:
- name: Setup Scripts - name: Setup Scripts
uses: github/gh-aw-actions/setup@742ca9c12baa13667ac53db8eb95f48414f60792 # v0.65.7 uses: github/gh-aw-actions/setup@2b3c275b3652caa01c2ebe31cbab50ec2df0f927 # v0.67.4
with: with:
destination: ${{ runner.temp }}/gh-aw/actions destination: ${{ runner.temp }}/gh-aw/actions
- name: Generate agentic run info - name: Generate agentic run info
@ -271,7 +271,7 @@ jobs:
output_types: ${{ steps.collect_output.outputs.output_types }} output_types: ${{ steps.collect_output.outputs.output_types }}
steps: steps:
- name: Setup Scripts - name: Setup Scripts
uses: github/gh-aw-actions/setup@742ca9c12baa13667ac53db8eb95f48414f60792 # v0.65.7 uses: github/gh-aw-actions/setup@2b3c275b3652caa01c2ebe31cbab50ec2df0f927 # v0.67.4
with: with:
destination: ${{ runner.temp }}/gh-aw/actions destination: ${{ runner.temp }}/gh-aw/actions
- name: Set runtime paths - name: Set runtime paths
@ -888,7 +888,7 @@ jobs:
total_count: ${{ steps.missing_tool.outputs.total_count }} total_count: ${{ steps.missing_tool.outputs.total_count }}
steps: steps:
- name: Setup Scripts - name: Setup Scripts
uses: github/gh-aw-actions/setup@742ca9c12baa13667ac53db8eb95f48414f60792 # v0.65.7 uses: github/gh-aw-actions/setup@2b3c275b3652caa01c2ebe31cbab50ec2df0f927 # v0.67.4
with: with:
destination: ${{ runner.temp }}/gh-aw/actions destination: ${{ runner.temp }}/gh-aw/actions
- name: Download agent output artifact - name: Download agent output artifact
@ -999,7 +999,7 @@ jobs:
process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }}
steps: steps:
- name: Setup Scripts - name: Setup Scripts
uses: github/gh-aw-actions/setup@742ca9c12baa13667ac53db8eb95f48414f60792 # v0.65.7 uses: github/gh-aw-actions/setup@2b3c275b3652caa01c2ebe31cbab50ec2df0f927 # v0.67.4
with: with:
destination: ${{ runner.temp }}/gh-aw/actions destination: ${{ runner.temp }}/gh-aw/actions
- name: Download agent output artifact - name: Download agent output artifact

67
Cargo.lock generated
View File

@ -1140,6 +1140,9 @@ dependencies = [
"log", "log",
"nanoid", "nanoid",
"network-interface", "network-interface",
"objc2",
"objc2-app-kit",
"objc2-foundation",
"once_cell", "once_cell",
"open", "open",
"parking_lot", "parking_lot",
@ -4673,9 +4676,38 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.0",
"block2", "block2",
"libc",
"objc2", "objc2",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-core-graphics", "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", "objc2-foundation",
] ]
@ -4703,6 +4735,41 @@ dependencies = [
"objc2-io-surface", "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]] [[package]]
name = "objc2-encode" name = "objc2-encode"
version = "4.1.0" version = "4.1.0"

View File

@ -13,6 +13,7 @@
### ✨ 新增功能 ### ✨ 新增功能
- 新增 macOS 托盘速率显示
- 快捷键操作通知操作结果 - 快捷键操作通知操作结果
### 🚀 优化改进 ### 🚀 优化改进

View File

@ -109,6 +109,28 @@ rust_iso3166 = "0.1.14"
# Use the git repo until the next release after v2.0.0. # Use the git repo until the next release after v2.0.0.
dark-light = { git = "https://github.com/rust-dark-light/dark-light" } 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] [target.'cfg(windows)'.dependencies]
deelevate = { workspace = true } deelevate = { workspace = true }
runas = "=1.2.0" runas = "=1.2.0"

View File

@ -233,7 +233,7 @@ pub struct IVerge {
)] )]
pub webdav_password: Option<String>, pub webdav_password: Option<String>,
#[serde(skip)] #[cfg(target_os = "macos")]
pub enable_tray_speed: Option<bool>, pub enable_tray_speed: Option<bool>,
// pub enable_tray_icon: Option<bool>, // pub enable_tray_icon: Option<bool>,
@ -438,6 +438,7 @@ impl IVerge {
webdav_url: None, webdav_url: None,
webdav_username: None, webdav_username: None,
webdav_password: None, webdav_password: None,
#[cfg(target_os = "macos")]
enable_tray_speed: Some(false), enable_tray_speed: Some(false),
// enable_tray_icon: Some(true), // enable_tray_icon: Some(true),
tray_proxy_groups_display_mode: Some("default".into()), tray_proxy_groups_display_mode: Some("default".into()),
@ -543,6 +544,7 @@ impl IVerge {
patch!(webdav_url); patch!(webdav_url);
patch!(webdav_username); patch!(webdav_username);
patch!(webdav_password); patch!(webdav_password);
#[cfg(target_os = "macos")]
patch!(enable_tray_speed); patch!(enable_tray_speed);
// patch!(enable_tray_icon); // patch!(enable_tray_icon);
patch!(tray_proxy_groups_display_mode); patch!(tray_proxy_groups_display_mode);

View File

@ -25,7 +25,10 @@ use tauri::{
AppHandle, Wry, AppHandle, Wry,
menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}, menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu},
}; };
mod menu_def; mod menu_def;
#[cfg(target_os = "macos")]
mod speed_task;
use menu_def::{MenuIds, MenuTexts}; use menu_def::{MenuIds, MenuTexts};
// TODO: 是否需要将可变菜单抽离存储起来,后续直接更新对应菜单实例,无需重新创建菜单(待考虑) // TODO: 是否需要将可变菜单抽离存储起来,后续直接更新对应菜单实例,无需重新创建菜单(待考虑)
@ -45,6 +48,8 @@ enum IconKind {
pub struct Tray { pub struct Tray {
limiter: SystemLimiter, limiter: SystemLimiter,
#[cfg(target_os = "macos")]
speed_controller: speed_task::TraySpeedController,
} }
impl TrayState { impl TrayState {
@ -113,6 +118,8 @@ impl Default for Tray {
fn default() -> Self { fn default() -> Self {
Self { Self {
limiter: Limiter::new(Duration::from_millis(TRAY_CLICK_DEBOUNCE_MS), SystemClock), 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(); let verge = Config::verge().await.data_arc();
self.update_menu().await?; self.update_menu().await?;
self.update_icon(&verge).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?; self.update_tooltip().await?;
Ok(()) Ok(())
} }
@ -382,6 +391,12 @@ impl Tray {
} }
allow 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<Vec<String>>) -> HashMap<String, String> { fn create_hotkeys(hotkeys: &Option<Vec<String>>) -> HashMap<String, String> {

View File

@ -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<Mutex<Option<JoinHandle<()>>>>,
speed_connection_id: Arc<Mutex<Option<ConnectionId>>>,
}
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<Mutex<Option<ConnectionId>>>,
connection_id: Option<ConnectionId>,
) {
*speed_connection_id.lock() = connection_id;
}
fn take_speed_connection_id(speed_connection_id: &Arc<Mutex<Option<ConnectionId>>>) -> Option<ConnectionId> {
speed_connection_id.lock().take()
}
async fn disconnect_speed_connection(speed_connection_id: &Arc<Mutex<Option<ConnectionId>>>) {
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}");
}
}
}
}

View File

@ -96,7 +96,10 @@ fn determine_update_flags(patch: &IVerge) -> UpdateFlags {
let socks_port = patch.verge_socks_port; let socks_port = patch.verge_socks_port;
let http_enabled = patch.verge_http_enabled; let http_enabled = patch.verge_http_enabled;
let http_port = patch.verge_port; let http_port = patch.verge_port;
#[cfg(target_os = "macos")]
let enable_tray_speed = patch.enable_tray_speed; let enable_tray_speed = patch.enable_tray_speed;
#[cfg(not(target_os = "macos"))]
let enable_tray_speed: Option<bool> = None;
// let enable_tray_icon = patch.enable_tray_icon; // let enable_tray_icon = patch.enable_tray_icon;
let enable_global_hotkey = patch.enable_global_hotkey; let enable_global_hotkey = patch.enable_global_hotkey;
let tray_event = &patch.tray_event; let tray_event = &patch.tray_event;
@ -235,6 +238,10 @@ async fn process_terminated_flags(update_flags: UpdateFlags, patch: &IVerge) ->
tray::Tray::global() tray::Tray::global()
.update_icon(&Config::verge().await.latest_arc()) .update_icon(&Config::verge().await.latest_arc())
.await?; .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) { if update_flags.contains(UpdateFlags::SYSTRAY_TOOLTIP) {
tray::Tray::global().update_tooltip().await?; tray::Tray::global().update_tooltip().await?;

View File

@ -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}");
}
}

View File

@ -1,3 +1,5 @@
#[cfg(target_os = "macos")]
pub mod connections_stream;
pub mod dirs; pub mod dirs;
pub mod help; pub mod help;
pub mod init; pub mod init;
@ -10,5 +12,8 @@ pub mod resolve;
pub mod schtasks; pub mod schtasks;
pub mod server; pub mod server;
pub mod singleton; pub mod singleton;
pub mod speed;
pub mod tmpl; pub mod tmpl;
#[cfg(target_os = "macos")]
pub mod tray_speed;
pub mod window_manager; pub mod window_manager;

View File

@ -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/srounded_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");
}
}

View File

@ -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, &para_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);
}

View File

@ -405,9 +405,13 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
</GuardState> </GuardState>
</Item> </Item>
)} )}
{/* {OS === "macos" && ( {OS === 'macos' && (
<Item> <Item>
<ListItemText primary={t("settings.components.verge.layout.fields.enableTraySpeed")} /> <ListItemText
primary={t(
'settings.components.verge.layout.fields.enableTraySpeed',
)}
/>
<GuardState <GuardState
value={verge?.enable_tray_speed ?? false} value={verge?.enable_tray_speed ?? false}
valueProps="checked" valueProps="checked"
@ -419,7 +423,7 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
<Switch edge="end" /> <Switch edge="end" />
</GuardState> </GuardState>
</Item> </Item>
)} */} )}
{/* {OS === "macos" && ( {/* {OS === "macos" && (
<Item> <Item>
<ListItemText primary={t("settings.components.verge.layout.fields.enableTrayIcon")} /> <ListItemText primary={t("settings.components.verge.layout.fields.enableTrayIcon")} />

View File

@ -912,7 +912,7 @@ interface IVergeConfig {
common_tray_icon?: boolean common_tray_icon?: boolean
sysproxy_tray_icon?: boolean sysproxy_tray_icon?: boolean
tun_tray_icon?: boolean tun_tray_icon?: boolean
// enable_tray_speed?: boolean; enable_tray_speed?: boolean
// enable_tray_icon?: boolean; // enable_tray_icon?: boolean;
tray_proxy_groups_display_mode?: 'default' | 'inline' | 'disable' tray_proxy_groups_display_mode?: 'default' | 'inline' | 'disable'
tray_inline_outbound_modes?: boolean tray_inline_outbound_modes?: boolean