feat: macos system tray addition rate display

This commit is contained in:
huzibaca
2024-12-25 02:11:07 +08:00
parent 2d2521e434
commit fb41c915cc
13 changed files with 611 additions and 334 deletions

View File

@@ -73,7 +73,7 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult {
match CoreManager::global().update_config().await {
Ok(_) => {
handle::Handle::refresh_clash();
let _ = handle::Handle::update_systray_tooltip();
let _ = tray::Tray::global().update_tooltip();
Config::profiles().apply();
wrap_err!(Config::profiles().data().save_file())?;
Ok(())

View File

@@ -156,17 +156,20 @@ impl IClashTemp {
}
pub fn guard_mixed_port(config: &Mapping) -> u16 {
let mut port = config
.get("mixed-port")
let raw_value = config.get("mixed-port");
let mut port = raw_value
.and_then(|value| match value {
Value::String(val_str) => val_str.parse().ok(),
Value::Number(val_num) => val_num.as_u64().map(|u| u as u16),
_ => None,
})
.unwrap_or(7897);
if port == 0 {
port = 7897;
}
port
}

View File

@@ -123,6 +123,12 @@ pub fn parse_check_output(log: String) -> String {
log
}
pub fn get_traffic_ws_url() -> Result<String> {
let (url, _) = clash_client_info()?;
let ws_url = url.replace("http://", "ws://") + "/traffic";
Ok(ws_url)
}
#[test]
fn test_parse_check_output() {
let str1 = r#"xxxx\n time="2022-11-18T20:42:58+08:00" level=error msg="proxy 0: 'alpn' expected type 'string', got unconvertible type '[]interface {}'""#;

View File

@@ -76,7 +76,6 @@ impl CoreManager {
service::stop_core_by_service().await?;
}
*running = false;
Ok(())
}
@@ -96,6 +95,7 @@ impl CoreManager {
service::run_core_by_service(&config_path).await?;
*running = true;
}
Ok(())
}

View File

@@ -1,6 +1,4 @@
use super::tray::Tray;
use crate::log_err;
use anyhow::Result;
use once_cell::sync::OnceCell;
use parking_lot::RwLock;
use std::sync::Arc;
@@ -65,27 +63,6 @@ impl Handle {
}
}
/// update the system tray state
pub fn update_systray_part() -> Result<()> {
Tray::update_part()?;
Ok(())
}
pub fn update_systray_menu() -> Result<()> {
Tray::update_menu()?;
Ok(())
}
pub fn update_systray_icon() -> Result<()> {
Tray::update_icon()?;
Ok(())
}
pub fn update_systray_tooltip() -> Result<()> {
Tray::update_tooltip()?;
Ok(())
}
pub fn set_is_exiting(&self) {
let mut is_exiting = self.is_exiting.write();
*is_exiting = true;

View File

@@ -1,3 +1,7 @@
pub mod speed_rate;
use speed_rate::Rate;
pub use speed_rate::{SpeedRate, Traffic};
use crate::utils::dirs;
use crate::{
cmds,
@@ -6,6 +10,10 @@ use crate::{
utils::resolve::{self, VERSION},
};
use anyhow::Result;
use futures::StreamExt;
use once_cell::sync::OnceCell;
use parking_lot::{Mutex, RwLock};
use std::sync::Arc;
use tauri::AppHandle;
use tauri::{
menu::CheckMenuItem,
@@ -15,12 +23,33 @@ use tauri::{
menu::{MenuEvent, MenuItem, PredefinedMenuItem, Submenu},
Wry,
};
use tokio::sync::broadcast;
use super::handle;
pub struct Tray {}
pub struct Tray {
pub speed_rate: Arc<Mutex<Option<SpeedRate>>>,
#[cfg(target_os = "macos")]
shutdown_tx: Arc<RwLock<Option<broadcast::Sender<()>>>>,
}
impl Tray {
pub fn create_systray() -> Result<()> {
pub fn global() -> &'static Tray {
static TRAY: OnceCell<Tray> = OnceCell::new();
TRAY.get_or_init(|| Tray {
speed_rate: Arc::new(Mutex::new(None)),
#[cfg(target_os = "macos")]
shutdown_tx: Arc::new(RwLock::new(None)),
})
}
pub fn init(&self) -> Result<()> {
let mut speed_rate = self.speed_rate.lock();
*speed_rate = Some(SpeedRate::new());
Ok(())
}
pub fn create_systray(&self) -> Result<()> {
let app_handle = handle::Handle::global().app_handle().unwrap();
let tray_incon_id = TrayIconId::new("main");
let tray = app_handle.tray_by_id(&tray_incon_id).unwrap();
@@ -64,7 +93,7 @@ impl Tray {
}
/// 更新托盘菜单
pub fn update_menu() -> Result<()> {
pub fn update_menu(&self) -> Result<()> {
let app_handle = handle::Handle::global().app_handle().unwrap();
let verge = Config::verge().latest().clone();
let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false);
@@ -91,7 +120,7 @@ impl Tray {
}
/// 更新托盘图标
pub fn update_icon() -> Result<()> {
pub fn update_icon(&self, rate: Option<Rate>) -> Result<()> {
let app_handle = handle::Handle::global().app_handle().unwrap();
let verge = Config::verge().latest().clone();
let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false);
@@ -109,12 +138,12 @@ impl Tray {
let icon_bytes = if *system_proxy && !*tun_mode {
#[cfg(target_os = "macos")]
let mut icon = match tray_icon.as_str() {
"colorful" => include_bytes!("../../icons/tray-icon-sys.ico").to_vec(),
_ => include_bytes!("../../icons/tray-icon-sys-mono.ico").to_vec(),
"colorful" => include_bytes!("../../../icons/tray-icon-sys.ico").to_vec(),
_ => include_bytes!("../../../icons/tray-icon-sys-mono.ico").to_vec(),
};
#[cfg(not(target_os = "macos"))]
let mut icon = include_bytes!("../../icons/tray-icon-sys.ico").to_vec();
let mut icon = include_bytes!("../../../icons/tray-icon-sys.ico").to_vec();
if *sysproxy_tray_icon {
let icon_dir_path = dirs::app_home_dir()?.join("icons");
let png_path = icon_dir_path.join("sysproxy.png");
@@ -129,8 +158,8 @@ impl Tray {
} else if *tun_mode {
#[cfg(target_os = "macos")]
let mut icon = match tray_icon.as_str() {
"colorful" => include_bytes!("../../icons/tray-icon-tun.ico").to_vec(),
_ => include_bytes!("../../icons/tray-icon-tun-mono.ico").to_vec(),
"colorful" => include_bytes!("../../../icons/tray-icon-tun.ico").to_vec(),
_ => include_bytes!("../../../icons/tray-icon-tun-mono.ico").to_vec(),
};
#[cfg(not(target_os = "macos"))]
@@ -149,8 +178,8 @@ impl Tray {
} else {
#[cfg(target_os = "macos")]
let mut icon = match tray_icon.as_str() {
"colorful" => include_bytes!("../../icons/tray-icon.ico").to_vec(),
_ => include_bytes!("../../icons/tray-icon-mono.ico").to_vec(),
"colorful" => include_bytes!("../../../icons/tray-icon.ico").to_vec(),
_ => include_bytes!("../../../icons/tray-icon-mono.ico").to_vec(),
};
#[cfg(not(target_os = "macos"))]
@@ -172,6 +201,14 @@ impl Tray {
{
let is_template =
crate::utils::help::is_monochrome_image_from_bytes(&icon_bytes).unwrap_or(false);
// 如果rate为None获取当前速率
let rate = rate.or_else(|| {
self.speed_rate
.lock()
.as_ref()
.and_then(|speed_rate| speed_rate.get_curent_rate())
});
let icon_bytes = SpeedRate::add_speed_text(icon_bytes, rate)?;
let _ = tray.set_icon(Some(tauri::image::Image::from_bytes(&icon_bytes)?));
let _ = tray.set_icon_as_template(is_template);
}
@@ -183,7 +220,7 @@ impl Tray {
}
/// 更新托盘提示
pub fn update_tooltip() -> Result<()> {
pub fn update_tooltip(&self) -> Result<()> {
let app_handle = handle::Handle::global().app_handle().unwrap();
let use_zh = { Config::verge().latest().language == Some("zh".into()) };
let version = VERSION.get().unwrap();
@@ -223,12 +260,55 @@ impl Tray {
Ok(())
}
pub fn update_part() -> Result<()> {
Self::update_menu()?;
Self::update_icon()?;
Self::update_tooltip()?;
pub fn update_part(&self) -> Result<()> {
self.update_menu()?;
self.update_icon(None)?;
self.update_tooltip()?;
Ok(())
}
/// 订阅流量数据
#[cfg(target_os = "macos")]
pub async fn subscribe_traffic(&self) -> Result<()> {
log::info!(target: "app", "subscribe traffic");
let (shutdown_tx, shutdown_rx) = broadcast::channel(1);
*self.shutdown_tx.write() = Some(shutdown_tx);
let speed_rate = Arc::clone(&self.speed_rate);
tauri::async_runtime::spawn(async move {
let mut shutdown = shutdown_rx;
if let Ok(mut stream) = Traffic::get_traffic_stream().await {
loop {
tokio::select! {
Some(traffic) = stream.next() => {
if let Ok(traffic) = traffic {
let guard = speed_rate.lock();
if let Some(sr) = guard.as_ref() {
if let Some(rate) = sr.update_and_check_changed(traffic.up, traffic.down) {
let _ = Tray::global().update_icon(Some(rate));
}
}
}
}
_ = shutdown.recv() => break,
}
}
}
});
Ok(())
}
/// 取消订阅 traffic 数据
#[cfg(target_os = "macos")]
#[allow(unused)]
pub fn unsubscribe_traffic(&self) {
log::info!(target: "app", "unsubscribe traffic");
if let Some(tx) = self.shutdown_tx.write().take() {
drop(tx); // 发送端被丢弃时会自动发送关闭信号
}
}
}
fn create_tray_menu(

View File

@@ -0,0 +1,189 @@
use crate::core::clash_api::get_traffic_ws_url;
use crate::utils::help::format_bytes_speed;
use anyhow::Result;
use futures::Stream;
use image::{ImageBuffer, Rgba};
use imageproc::drawing::draw_text_mut;
use parking_lot::Mutex;
use rusttype::{Font, Scale};
use std::io::Cursor;
use std::sync::Arc;
use tokio_tungstenite::tungstenite::Message;
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Rate {
pub up: u64,
pub down: u64,
}
#[derive(Debug, Clone)]
pub struct SpeedRate {
rate: Arc<Mutex<(Rate, Rate)>>,
}
impl SpeedRate {
pub fn new() -> Self {
Self {
rate: Arc::new(Mutex::new((Rate::default(), Rate::default()))),
}
}
/// 更新流量数据并返回变化后的速率(如果有变化)
pub fn update_and_check_changed(&self, up: u64, down: u64) -> Option<Rate> {
let mut rates = self.rate.lock();
let (current, previous) = &mut *rates;
*previous = current.clone();
current.up = up;
current.down = down;
if previous != current {
Some(current.clone())
} else {
None
}
}
// 获取当前的速率
pub fn get_curent_rate(&self) -> Option<Rate> {
let rates = self.rate.lock();
let (current, _) = &*rates;
Some(current.clone())
}
/// 在图标上添加速率显示
pub fn add_speed_text(icon: Vec<u8>, rate: Option<Rate>) -> Result<Vec<u8>> {
let rate = rate.unwrap_or(Rate { up: 0, down: 0 });
let img = image::load_from_memory(&icon)?;
let (width, height) = (img.width(), img.height());
let mut image = ImageBuffer::new((width as f32 * 4.0) as u32, height);
image::imageops::replace(&mut image, &img, 0, 0);
let font =
Font::try_from_bytes(include_bytes!("../../../assets/fonts/SFCompact.ttf")).unwrap();
// 修改颜色和阴影参数
let text_color = Rgba([255u8, 255u8, 255u8, 255u8]); // 纯白色
let shadow_color = Rgba([0u8, 0u8, 0u8, 180u8]); // 半透明黑色阴影
let base_size = (height as f32 * 0.5) as f32;
let scale = Scale::uniform(base_size);
let up_text = format_bytes_speed(rate.up);
let down_text = format_bytes_speed(rate.down);
// 计算文本位置(保持不变)
let up_width = font
.layout(&up_text, scale, rusttype::Point { x: 0.0, y: 0.0 })
.map(|g| g.position().x + g.unpositioned().h_metrics().advance_width)
.last()
.unwrap_or(0.0);
let down_width = font
.layout(&down_text, scale, rusttype::Point { x: 0.0, y: 0.0 })
.map(|g| g.position().x + g.unpositioned().h_metrics().advance_width)
.last()
.unwrap_or(0.0);
let right_margin = 8;
let canvas_width = width * 4;
let up_x = canvas_width as f32 - up_width - right_margin as f32;
let down_x = canvas_width as f32 - down_width - right_margin as f32;
// 添加阴影效果
let shadow_offset = 1; // 阴影偏移量
// 绘制上行速率(先画阴影,再画文字)
draw_text_mut(
&mut image,
shadow_color,
up_x as i32 + shadow_offset,
1 + shadow_offset,
scale,
&font,
&up_text,
);
draw_text_mut(
&mut image,
text_color,
up_x as i32,
1,
scale,
&font,
&up_text,
);
// 绘制下行速率(先画阴影,再画文字)
draw_text_mut(
&mut image,
shadow_color,
down_x as i32 + shadow_offset,
height as i32 - (base_size as i32) - 1 + shadow_offset,
scale,
&font,
&down_text,
);
draw_text_mut(
&mut image,
text_color,
down_x as i32,
height as i32 - (base_size as i32) - 1,
scale,
&font,
&down_text,
);
let mut bytes: Vec<u8> = Vec::new();
let mut cursor = Cursor::new(&mut bytes);
image.write_to(&mut cursor, image::ImageFormat::Png)?;
Ok(bytes)
}
}
#[derive(Debug, Clone)]
pub struct Traffic {
pub up: u64,
pub down: u64,
}
impl Traffic {
pub async fn get_traffic_stream() -> Result<impl Stream<Item = Result<Traffic, anyhow::Error>>>
{
use futures::stream::{self, StreamExt};
use std::time::Duration;
let stream = Box::pin(
stream::unfold((), |_| async {
loop {
let ws_url = get_traffic_ws_url().unwrap();
match tokio_tungstenite::connect_async(&ws_url).await {
Ok((ws_stream, _)) => {
log::info!(target: "app", "traffic ws connection established");
return Some((
ws_stream.map(|msg| {
msg.map_err(anyhow::Error::from).and_then(|msg: Message| {
let data = msg.into_text()?;
let json: serde_json::Value = serde_json::from_str(&data)?;
Ok(Traffic {
up: json["up"].as_u64().unwrap_or(0),
down: json["down"].as_u64().unwrap_or(0),
})
})
}),
(),
));
}
Err(e) => {
log::error!(target: "app", "traffic ws connection failed: {e}");
tokio::time::sleep(Duration::from_secs(5)).await;
continue;
}
}
}
})
.flatten(),
);
Ok(stream)
}
}

View File

@@ -76,9 +76,9 @@ pub fn change_clash_mode(mode: String) {
if Config::clash().data().save_config().is_ok() {
handle::Handle::refresh_clash();
log_err!(handle::Handle::update_systray_menu());
log_err!(tray::Tray::global().update_menu());
#[cfg(target_os = "macos")]
log_err!(handle::Handle::update_systray_icon());
log_err!(tray::Tray::global().update_icon(None));
}
}
Err(err) => log::error!(target: "app", "{err}"),
@@ -141,9 +141,9 @@ pub async fn patch_clash(patch: Mapping) -> Result<()> {
CoreManager::global().restart_core().await?;
} else {
if patch.get("mode").is_some() {
log_err!(handle::Handle::update_systray_menu());
log_err!(tray::Tray::global().update_menu());
#[cfg(target_os = "macos")]
log_err!(handle::Handle::update_systray_icon());
log_err!(tray::Tray::global().update_icon(None));
}
Config::runtime().latest().patch_config(patch);
CoreManager::global().update_config().await?;
@@ -267,7 +267,7 @@ pub async fn patch_verge(patch: IVerge) -> Result<()> {
}
if should_update_systray_icon {
handle::Handle::update_systray_icon()?;
tray::Tray::global().update_icon(None)?;
}
<Result<()>>::Ok(())

View File

@@ -201,22 +201,34 @@ macro_rules! t {
};
}
#[test]
fn test_parse_value() {
let test_1 = "upload=111; download=2222; total=3333; expire=444";
let test_2 = "attachment; filename=Clash.yaml";
assert_eq!(parse_str::<usize>(test_1, "upload").unwrap(), 111);
assert_eq!(parse_str::<usize>(test_1, "download").unwrap(), 2222);
assert_eq!(parse_str::<usize>(test_1, "total").unwrap(), 3333);
assert_eq!(parse_str::<usize>(test_1, "expire").unwrap(), 444);
assert_eq!(
parse_str::<String>(test_2, "filename").unwrap(),
format!("Clash.yaml")
);
assert_eq!(parse_str::<usize>(test_1, "aaa"), None);
assert_eq!(parse_str::<usize>(test_1, "upload1"), None);
assert_eq!(parse_str::<usize>(test_1, "expire1"), None);
assert_eq!(parse_str::<usize>(test_2, "attachment"), None);
/// 将字节数转换为可读的流量字符串
/// 支持 B/s、KB/s、MB/s、GB/s 的自动转换
///
/// # Examples
/// ```
/// assert_eq!(format_bytes_speed(1000), "1000B/s");
/// assert_eq!(format_bytes_speed(1024), "1.0KB/s");
/// assert_eq!(format_bytes_speed(1024 * 1024), "1.0MB/s");
/// ```
pub fn format_bytes_speed(speed: u64) -> String {
if speed < 1024 {
format!("{}B/s", speed)
} else if speed < 1024 * 1024 {
format!("{:.1}KB/s", speed as f64 / 1024.0)
} else if speed < 1024 * 1024 * 1024 {
format!("{:.1}MB/s", speed as f64 / 1024.0 / 1024.0)
} else {
format!("{:.1}GB/s", speed as f64 / 1024.0 / 1024.0 / 1024.0)
}
}
#[test]
fn test_format_bytes_speed() {
assert_eq!(format_bytes_speed(0), "0B/s");
assert_eq!(format_bytes_speed(1023), "1023B/s");
assert_eq!(format_bytes_speed(1024), "1.0KB/s");
assert_eq!(format_bytes_speed(1024 * 1024), "1.0MB/s");
assert_eq!(format_bytes_speed(1024 * 1024 * 1024), "1.0GB/s");
assert_eq!(format_bytes_speed(1024 * 500), "500.0KB/s");
assert_eq!(format_bytes_speed(1024 * 1024 * 2), "2.0MB/s");
}

View File

@@ -92,7 +92,8 @@ pub async fn resolve_setup(app: &mut App) {
server::embed_server();
log::trace!(target: "app", "init system tray");
log_err!(tray::Tray::create_systray());
log_err!(tray::Tray::global().init());
log_err!(tray::Tray::global().create_systray());
let silent_start = { Config::verge().data().enable_silent_start };
if !silent_start.unwrap_or(false) {
@@ -102,14 +103,21 @@ pub async fn resolve_setup(app: &mut App) {
log_err!(sysopt::Sysopt::global().update_sysproxy().await);
log_err!(sysopt::Sysopt::global().init_guard_sysproxy());
log_err!(handle::Handle::update_systray_part());
log_err!(tray::Tray::global().update_part());
log_err!(hotkey::Hotkey::global().init());
log_err!(timer::Timer::global().init());
// 流量订阅
#[cfg(target_os = "macos")]
log_err!(tray::Tray::global().subscribe_traffic().await);
}
/// reset system proxy
pub fn resolve_reset() {
tauri::async_runtime::block_on(async move {
#[cfg(target_os = "macos")]
tray::Tray::global().unsubscribe_traffic();
log_err!(sysopt::Sysopt::global().reset_sysproxy().await);
log_err!(CoreManager::global().stop_core().await);
#[cfg(target_os = "macos")]