Files
EasyCLIProxyAPI/src-tauri/src/tray.rs
T

621 lines
20 KiB
Rust

use super::*;
#[cfg(target_os = "macos")]
const MACOS_TRAY_ID: &str = "macos-tray";
#[cfg(target_os = "macos")]
#[derive(Default)]
pub(crate) struct MacosTrayClickState {
last_click: Option<Instant>,
sequence: u64,
}
#[cfg(target_os = "macos")]
pub(crate) fn set_macos_dock_visible(app_handle: &tauri::AppHandle, visible: bool) {
if let Err(error) = app_handle.set_dock_visibility(visible) {
eprintln!("更新 Dock 图标状态失败: {error}");
}
}
#[cfg(target_os = "macos")]
pub(crate) fn show_main_window_on_main_thread(app_handle: &tauri::AppHandle) {
let Some(window) = app_handle.get_webview_window("main") else {
return;
};
set_macos_dock_visible(app_handle, true);
if let Err(error) = window.show() {
eprintln!("显示主窗口失败: {error}");
set_macos_dock_visible(app_handle, false);
return;
}
if window.is_minimized().unwrap_or(false) {
if let Err(error) = window.unminimize() {
eprintln!("恢复主窗口失败: {error}");
}
}
if let Err(error) = window.set_focus() {
eprintln!("聚焦主窗口失败: {error}");
}
}
#[cfg(target_os = "macos")]
pub(crate) fn show_main_window(app_handle: &tauri::AppHandle) {
if MainThreadMarker::new().is_some() {
show_main_window_on_main_thread(app_handle);
return;
}
let app_handle = app_handle.clone();
if let Err(error) = app_handle.clone().run_on_main_thread(move || {
show_main_window_on_main_thread(&app_handle);
}) {
eprintln!("调度主窗口显示失败: {error}");
}
}
#[cfg(target_os = "macos")]
pub(crate) fn show_macos_tray_menu<R: tauri::Runtime>(tray: &TrayIcon<R>) {
let result = tray.with_inner_tray_icon(|tray_icon| {
let Some(status_item) = tray_icon.ns_status_item() else {
return;
};
let mtm = MainThreadMarker::new().expect("tray menu must be shown on the main thread");
if let Some(menu) = status_item.menu(mtm) {
#[allow(deprecated)]
status_item.popUpStatusItemMenu(&menu);
}
});
if let Err(error) = result {
eprintln!("显示托盘菜单失败: {error}");
}
}
#[cfg(target_os = "macos")]
pub(crate) fn setup_macos_tray(app: &mut tauri::App<tauri::Wry>) -> tauri::Result<()> {
let locale = app
.state::<GuiConfigState>()
.snapshot()
.map(|config| config.locale)
.unwrap_or_else(|_| "zh-CN".to_string());
let open_main_window = MenuItem::with_id(
app,
"open-main-window",
locale_text(&locale, "打开主界面", "Open Main Window"),
true,
None::<&str>,
)?;
let quit = MenuItem::with_id(
app,
"quit",
locale_text(&locale, "退出", "Quit"),
true,
None::<&str>,
)?;
let menu = Menu::with_items(app, &[&open_main_window, &quit])?;
let click_state = Arc::new(Mutex::new(MacosTrayClickState::default()));
let double_click_interval = Duration::from_secs_f64(NSEvent::doubleClickInterval());
TrayIconBuilder::with_id(MACOS_TRAY_ID)
.icon(
app.default_window_icon()
.cloned()
.expect("application icon is required for the tray"),
)
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(move |app_handle, event| match event.id().as_ref() {
"open-main-window" => show_main_window(app_handle),
"quit" => app_handle.exit(0),
_ => {}
})
.on_tray_icon_event(move |tray, event| {
if !matches!(
event,
TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
}
) {
return;
}
let now = Instant::now();
let Ok(mut state) = click_state.lock() else {
eprintln!("读取托盘点击状态失败");
return;
};
state.sequence += 1;
let sequence = state.sequence;
if state
.last_click
.is_some_and(|last_click| now.duration_since(last_click) <= double_click_interval)
{
state.last_click = None;
drop(state);
show_main_window(tray.app_handle());
return;
}
state.last_click = Some(now);
drop(state);
let app_handle = tray.app_handle().clone();
let click_state = Arc::clone(&click_state);
let tray_id = tray.id().clone();
thread::spawn(move || {
thread::sleep(double_click_interval);
let should_show_menu = match click_state.lock() {
Ok(mut state) if state.sequence == sequence => {
state.last_click = None;
true
}
Ok(_) => false,
Err(_) => {
eprintln!("读取托盘点击状态失败");
false
}
};
if !should_show_menu {
return;
}
if let Some(tray) = app_handle.tray_by_id(&tray_id) {
show_macos_tray_menu(&tray);
}
});
})
.build(app)?;
Ok(())
}
#[cfg(target_os = "windows")]
const WINDOWS_TRAY_ID: &str = "windows-tray";
#[cfg(target_os = "windows")]
const WINDOWS_TRAY_OPEN_MENU_ID: &str = "windows-tray-open-main-window";
#[cfg(target_os = "windows")]
const WINDOWS_TRAY_STATUS_MENU_ID: &str = "windows-tray-core-status";
#[cfg(target_os = "windows")]
const WINDOWS_TRAY_TOGGLE_CORE_MENU_ID: &str = "windows-tray-toggle-core";
#[cfg(target_os = "windows")]
const WINDOWS_TRAY_RESTART_CORE_MENU_ID: &str = "windows-tray-restart-core";
#[cfg(target_os = "windows")]
const WINDOWS_TRAY_QUIT_MENU_ID: &str = "windows-tray-quit";
#[cfg(target_os = "windows")]
#[derive(Clone, Copy)]
pub(crate) enum WindowsTrayCoreAction {
Toggle,
Restart,
}
#[cfg(target_os = "windows")]
pub(crate) struct WindowsTrayPresentation {
pub(crate) status_text: String,
pub(crate) toggle_text: String,
pub(crate) toggle_enabled: bool,
pub(crate) restart_enabled: bool,
pub(crate) tooltip: String,
}
#[cfg(target_os = "windows")]
pub(crate) fn windows_tray_presentation(
status: &CoreStatus,
busy: bool,
locale: &str,
) -> WindowsTrayPresentation {
let status_text = if busy {
locale_text(locale, "内核状态:处理中", "Core status: Working")
} else if !status.installed {
locale_text(locale, "内核状态:未安装", "Core status: Not installed")
} else if status.running {
locale_text(locale, "内核状态:运行中", "Core status: Running")
} else {
locale_text(locale, "内核状态:已停止", "Core status: Stopped")
};
let toggle_text = if busy {
locale_text(locale, "处理中...", "Working...")
} else if status.running {
locale_text(locale, "停止内核", "Stop Core")
} else {
locale_text(locale, "启动内核", "Start Core")
};
let tooltip = if busy {
locale_text(
locale,
"EasyCLIProxyAPI · 内核处理中",
"EasyCLIProxyAPI · Core working",
)
} else if !status.installed {
locale_text(
locale,
"EasyCLIProxyAPI · 内核未安装",
"EasyCLIProxyAPI · Core not installed",
)
} else if status.running {
locale_text(
locale,
"EasyCLIProxyAPI · 内核运行中",
"EasyCLIProxyAPI · Core running",
)
} else {
locale_text(
locale,
"EasyCLIProxyAPI · 内核已停止",
"EasyCLIProxyAPI · Core stopped",
)
};
WindowsTrayPresentation {
status_text: status_text.to_string(),
toggle_text: toggle_text.to_string(),
toggle_enabled: status.installed && !busy,
restart_enabled: status.installed && status.running && !busy,
tooltip: tooltip.to_string(),
}
}
#[cfg(target_os = "windows")]
pub(crate) struct WindowsTrayState {
open_main_window: MenuItem<tauri::Wry>,
status_item: MenuItem<tauri::Wry>,
toggle_core_item: MenuItem<tauri::Wry>,
restart_core_item: MenuItem<tauri::Wry>,
quit_item: MenuItem<tauri::Wry>,
locale: Mutex<String>,
busy: AtomicBool,
}
#[cfg(target_os = "windows")]
impl WindowsTrayState {
fn locale(&self) -> String {
self.locale
.lock()
.map(|locale| locale.clone())
.unwrap_or_else(|_| "zh-CN".to_string())
}
fn set_locale(&self, locale: &str) {
let normalized = normalize_app_locale(locale);
if let Ok(mut current) = self.locale.lock() {
*current = normalized.to_string();
}
let labels = [
(
&self.open_main_window,
locale_text(normalized, "打开主界面", "Open Main Window"),
),
(
&self.restart_core_item,
locale_text(normalized, "重启内核", "Restart Core"),
),
(&self.quit_item, locale_text(normalized, "退出", "Quit")),
];
for (item, label) in labels {
if let Err(error) = item.set_text(label) {
eprintln!("更新 Windows 托盘语言失败: {error}");
}
}
}
fn begin_action(&self) -> bool {
if self
.busy
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return false;
}
let locale = self.locale();
if let Err(error) = self.status_item.set_text(locale_text(
&locale,
"内核状态:处理中",
"Core status: Working",
)) {
eprintln!("更新 Windows 托盘内核状态失败: {error}");
}
if let Err(error) =
self.toggle_core_item
.set_text(locale_text(&locale, "处理中...", "Working..."))
{
eprintln!("更新 Windows 托盘操作文本失败: {error}");
}
if let Err(error) = self.toggle_core_item.set_enabled(false) {
eprintln!("更新 Windows 托盘操作状态失败: {error}");
}
if let Err(error) = self.restart_core_item.set_enabled(false) {
eprintln!("更新 Windows 托盘重启状态失败: {error}");
}
true
}
fn finish_action(&self) {
self.busy.store(false, Ordering::Release);
}
fn update(&self, status: &CoreStatus) -> String {
let presentation =
windows_tray_presentation(status, self.busy.load(Ordering::Acquire), &self.locale());
if let Err(error) = self.status_item.set_text(presentation.status_text.clone()) {
eprintln!("更新 Windows 托盘内核状态失败: {error}");
}
if let Err(error) = self
.toggle_core_item
.set_text(presentation.toggle_text.clone())
{
eprintln!("更新 Windows 托盘操作文本失败: {error}");
}
if let Err(error) = self
.toggle_core_item
.set_enabled(presentation.toggle_enabled)
{
eprintln!("更新 Windows 托盘操作状态失败: {error}");
}
if let Err(error) = self
.restart_core_item
.set_enabled(presentation.restart_enabled)
{
eprintln!("更新 Windows 托盘重启状态失败: {error}");
}
presentation.tooltip
}
fn show_error(&self, error: &str) {
let mut summary = error.chars().take(48).collect::<String>();
if error.chars().count() > 48 {
summary.push('…');
}
let text = match normalize_app_locale(&self.locale()) {
"en" => format!("Operation failed: {summary}"),
"zh-TW" => format!("操作失敗:{summary}"),
"ja" => format!("操作に失敗しました:{summary}"),
_ => format!("操作失败:{summary}"),
};
if let Err(update_error) = self.status_item.set_text(text) {
eprintln!("更新 Windows 托盘错误状态失败: {update_error}");
}
}
}
#[cfg(target_os = "windows")]
pub(crate) fn show_windows_main_window(app_handle: &tauri::AppHandle) {
let Some(window) = app_handle.get_webview_window("main") else {
return;
};
if let Err(error) = window.show() {
eprintln!("显示主窗口失败: {error}");
return;
}
if window.is_minimized().unwrap_or(false) {
if let Err(error) = window.unminimize() {
eprintln!("恢复主窗口失败: {error}");
}
}
if let Err(error) = window.set_focus() {
eprintln!("聚焦主窗口失败: {error}");
}
}
#[cfg(target_os = "windows")]
pub(crate) fn update_windows_tray_status(app_handle: &tauri::AppHandle, status: &CoreStatus) {
let tooltip = app_handle
.try_state::<WindowsTrayState>()
.map(|tray_state| tray_state.update(status))
.unwrap_or_else(|| windows_tray_presentation(status, false, "zh-CN").tooltip);
if let Some(tray) = app_handle.tray_by_id(WINDOWS_TRAY_ID) {
if let Err(error) = tray.set_tooltip(Some(&tooltip)) {
eprintln!("更新 Windows 托盘提示失败: {error}");
}
}
}
#[cfg(target_os = "windows")]
pub(crate) fn update_windows_tray_locale(
app_handle: &tauri::AppHandle,
locale: &str,
status: &CoreStatus,
) {
if let Some(tray_state) = app_handle.try_state::<WindowsTrayState>() {
tray_state.set_locale(locale);
}
update_windows_tray_status(app_handle, status);
}
#[cfg(target_os = "windows")]
pub(crate) fn show_windows_tray_action_error(app_handle: &tauri::AppHandle, error: &str) {
eprintln!("Windows 托盘内核操作失败: {error}");
let locale = app_handle
.try_state::<WindowsTrayState>()
.map(|state| state.locale())
.unwrap_or_else(|| "zh-CN".to_string());
if let Some(tray_state) = app_handle.try_state::<WindowsTrayState>() {
tray_state.show_error(error);
}
if let Some(tray) = app_handle.tray_by_id(WINDOWS_TRAY_ID) {
if let Err(update_error) = tray.set_tooltip(Some(locale_text(
&locale,
"EasyCLIProxyAPI · 内核操作失败",
"EasyCLIProxyAPI · Core operation failed",
))) {
eprintln!("更新 Windows 托盘错误提示失败: {update_error}");
}
}
}
#[cfg(target_os = "windows")]
pub(crate) fn run_windows_tray_core_action(
app_handle: &tauri::AppHandle,
action: WindowsTrayCoreAction,
) {
let Some(tray_state) = app_handle.try_state::<WindowsTrayState>() else {
return;
};
if !tray_state.begin_action() {
return;
}
if let Some(tray) = app_handle.tray_by_id(WINDOWS_TRAY_ID) {
if let Err(error) = tray.set_tooltip(Some(locale_text(
&tray_state.locale(),
"EasyCLIProxyAPI · 内核处理中",
"EasyCLIProxyAPI · Core working",
))) {
eprintln!("更新 Windows 托盘处理中提示失败: {error}");
}
}
let app_handle = app_handle.clone();
tauri::async_runtime::spawn_blocking(move || {
let process_state = app_handle.state::<CoreProcessState>();
let gui_config_state = app_handle.state::<GuiConfigState>();
let result = (|| -> Result<CoreStatus, String> {
match action {
WindowsTrayCoreAction::Toggle => {
let config = gui_config_state.snapshot()?;
let status =
current_core_status(Some(process_state.inner()), Some(config.port))?;
if status.running {
stop_core_process_with_state(
process_state.inner(),
gui_config_state.inner(),
)
} else {
start_core_process_with_state(
process_state.inner(),
gui_config_state.inner(),
)
}
}
WindowsTrayCoreAction::Restart => {
restart_core_process_with_state(process_state.inner(), gui_config_state.inner())
}
}
})();
if let Some(tray_state) = app_handle.try_state::<WindowsTrayState>() {
tray_state.finish_action();
}
match result {
Ok(status) => emit_core_status(&app_handle, &status),
Err(error) => {
if let Ok(config) = gui_config_state.snapshot() {
if let Ok(status) =
current_core_status(Some(process_state.inner()), Some(config.port))
{
emit_core_status(&app_handle, &status);
}
}
show_windows_tray_action_error(&app_handle, &error);
}
}
});
}
#[cfg(target_os = "windows")]
pub(crate) fn setup_windows_tray(app: &mut tauri::App<tauri::Wry>) -> tauri::Result<()> {
let locale = app
.state::<GuiConfigState>()
.snapshot()
.map(|config| config.locale)
.unwrap_or_else(|_| "zh-CN".to_string());
let open_main_window = MenuItem::with_id(
app,
WINDOWS_TRAY_OPEN_MENU_ID,
locale_text(&locale, "打开主界面", "Open Main Window"),
true,
None::<&str>,
)?;
let status_item = MenuItem::with_id(
app,
WINDOWS_TRAY_STATUS_MENU_ID,
locale_text(&locale, "内核状态:正在检查", "Core status: Checking"),
false,
None::<&str>,
)?;
let toggle_core_item = MenuItem::with_id(
app,
WINDOWS_TRAY_TOGGLE_CORE_MENU_ID,
locale_text(&locale, "启动内核", "Start Core"),
false,
None::<&str>,
)?;
let restart_core_item = MenuItem::with_id(
app,
WINDOWS_TRAY_RESTART_CORE_MENU_ID,
locale_text(&locale, "重启内核", "Restart Core"),
false,
None::<&str>,
)?;
let quit = MenuItem::with_id(
app,
WINDOWS_TRAY_QUIT_MENU_ID,
locale_text(&locale, "退出", "Quit"),
true,
None::<&str>,
)?;
let separator_one = PredefinedMenuItem::separator(app)?;
let separator_two = PredefinedMenuItem::separator(app)?;
let menu = Menu::with_items(
app,
&[
&open_main_window,
&separator_one,
&status_item,
&toggle_core_item,
&restart_core_item,
&separator_two,
&quit,
],
)?;
TrayIconBuilder::with_id(WINDOWS_TRAY_ID)
.icon(
app.default_window_icon()
.cloned()
.expect("application icon is required for the tray"),
)
.tooltip("EasyCLIProxyAPI")
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(move |app_handle, event| match event.id().as_ref() {
WINDOWS_TRAY_OPEN_MENU_ID => show_windows_main_window(app_handle),
WINDOWS_TRAY_TOGGLE_CORE_MENU_ID => {
run_windows_tray_core_action(app_handle, WindowsTrayCoreAction::Toggle)
}
WINDOWS_TRAY_RESTART_CORE_MENU_ID => {
run_windows_tray_core_action(app_handle, WindowsTrayCoreAction::Restart)
}
WINDOWS_TRAY_QUIT_MENU_ID => app_handle.exit(0),
_ => {}
})
.on_tray_icon_event(move |tray, event| {
if matches!(
event,
TrayIconEvent::DoubleClick {
button: MouseButton::Left,
..
}
) {
show_windows_main_window(tray.app_handle());
}
})
.build(app)?;
let _ = app.manage(WindowsTrayState {
open_main_window,
status_item,
toggle_core_item,
restart_core_item,
quit_item: quit,
locale: Mutex::new(locale),
busy: AtomicBool::new(false),
});
Ok(())
}