feat(windows): add system tray management and close confirmation
This commit is contained in:
1 parent
f740d109ea
commit
43593d5149
7 files changed
+758
-77
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "cpa-gui",
|
||||
"private": true,
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.4",
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.14",
|
||||
"scripts": {
|
||||
|
||||
Generated
+1
-1
@@ -582,7 +582,7 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
|
||||
|
||||
[[package]]
|
||||
name = "cpa-gui"
|
||||
version = "0.2.3"
|
||||
version = "0.2.4"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cpa-gui"
|
||||
version = "0.2.3"
|
||||
version = "0.2.4"
|
||||
description = "CPA desktop management shell"
|
||||
authors = ["lzt404"]
|
||||
edition = "2021"
|
||||
|
||||
+470
-19
@@ -10,6 +10,8 @@ use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy, NSEvent};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::sync::Arc;
|
||||
use std::{
|
||||
@@ -24,10 +26,14 @@ use std::{
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use tar::Archive;
|
||||
#[cfg(target_os = "windows")]
|
||||
use tauri::menu::PredefinedMenuItem;
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri::tray::{MouseButtonState, TrayIcon};
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent},
|
||||
tray::{MouseButton, TrayIconBuilder, TrayIconEvent},
|
||||
};
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
@@ -42,6 +48,8 @@ const APP_RELEASE_PAGE_URL: &str =
|
||||
"https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest";
|
||||
const CORE_INSTALL_PROGRESS_EVENT: &str = "core-install-progress";
|
||||
const CORE_STATUS_EVENT: &str = "core-status-changed";
|
||||
#[cfg(target_os = "windows")]
|
||||
const WINDOWS_CLOSE_REQUEST_EVENT: &str = "windows-close-requested";
|
||||
const CORE_METADATA_FILE: &str = "cpa-gui-meta.json";
|
||||
const CORE_CONFIG_FILE: &str = "config.yaml";
|
||||
const CORE_EXAMPLE_CONFIG_FILE: &str = "config.example.yaml";
|
||||
@@ -225,6 +233,13 @@ struct GuiConfigFile {
|
||||
routing_strategy: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
enum WindowsCloseAction {
|
||||
Exit,
|
||||
MinimizeToTray,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
struct GuiApiKeyEntry {
|
||||
key: String,
|
||||
@@ -897,6 +912,8 @@ fn get_core_status(
|
||||
}
|
||||
|
||||
fn emit_core_status(app: &tauri::AppHandle, status: &CoreStatus) {
|
||||
#[cfg(target_os = "windows")]
|
||||
update_windows_tray_status(app, status);
|
||||
let _ = app.emit(CORE_STATUS_EVENT, status);
|
||||
}
|
||||
|
||||
@@ -908,6 +925,26 @@ fn get_gui_settings(
|
||||
Ok(GuiSettings::from(&config))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn resolve_windows_close_request(
|
||||
app: tauri::AppHandle,
|
||||
action: WindowsCloseAction,
|
||||
) -> Result<(), String> {
|
||||
match action {
|
||||
WindowsCloseAction::Exit => app.exit(0),
|
||||
WindowsCloseAction::MinimizeToTray => {
|
||||
let window = app
|
||||
.get_webview_window("main")
|
||||
.ok_or_else(|| "主窗口不存在".to_string())?;
|
||||
window
|
||||
.hide()
|
||||
.map_err(|error| format!("隐藏主窗口失败: {error}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn inspect_agent_config_statuses(
|
||||
app: &tauri::AppHandle,
|
||||
port: u16,
|
||||
@@ -4860,48 +4897,69 @@ fn start_core_process(
|
||||
process_state: tauri::State<'_, CoreProcessState>,
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
) -> Result<CoreStatus, String> {
|
||||
let config = gui_config_state.snapshot()?;
|
||||
start_core_process_inner(process_state.inner(), &config)?;
|
||||
if let Err(error) = gui_config_state.set_run_on_startup(true) {
|
||||
let _ = stop_core_process_inner(process_state.inner());
|
||||
return Err(error);
|
||||
}
|
||||
let status = current_core_status(Some(process_state.inner()), Some(config.port))?;
|
||||
let status = start_core_process_with_state(process_state.inner(), gui_config_state.inner())?;
|
||||
emit_core_status(&app, &status);
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
fn start_core_process_with_state(
|
||||
process_state: &CoreProcessState,
|
||||
gui_config_state: &GuiConfigState,
|
||||
) -> Result<CoreStatus, String> {
|
||||
let config = gui_config_state.snapshot()?;
|
||||
start_core_process_inner(process_state, &config)?;
|
||||
if let Err(error) = gui_config_state.set_run_on_startup(true) {
|
||||
let _ = stop_core_process_inner(process_state);
|
||||
return Err(error);
|
||||
}
|
||||
current_core_status(Some(process_state), Some(config.port))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn stop_core_process(
|
||||
app: tauri::AppHandle,
|
||||
process_state: tauri::State<'_, CoreProcessState>,
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
) -> Result<CoreStatus, String> {
|
||||
stop_core_process_inner(process_state.inner())?;
|
||||
let config = gui_config_state.set_run_on_startup(false)?;
|
||||
let status = current_core_status(Some(process_state.inner()), Some(config.port))?;
|
||||
let status = stop_core_process_with_state(process_state.inner(), gui_config_state.inner())?;
|
||||
emit_core_status(&app, &status);
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
fn stop_core_process_with_state(
|
||||
process_state: &CoreProcessState,
|
||||
gui_config_state: &GuiConfigState,
|
||||
) -> Result<CoreStatus, String> {
|
||||
stop_core_process_inner(process_state)?;
|
||||
let config = gui_config_state.set_run_on_startup(false)?;
|
||||
current_core_status(Some(process_state), Some(config.port))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn restart_core_process(
|
||||
app: tauri::AppHandle,
|
||||
process_state: tauri::State<'_, CoreProcessState>,
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
) -> Result<CoreStatus, String> {
|
||||
let config = gui_config_state.snapshot()?;
|
||||
let _ = stop_core_process_inner(process_state.inner());
|
||||
start_core_process_inner(process_state.inner(), &config)?;
|
||||
if let Err(error) = gui_config_state.set_run_on_startup(true) {
|
||||
let _ = stop_core_process_inner(process_state.inner());
|
||||
return Err(error);
|
||||
}
|
||||
let status = current_core_status(Some(process_state.inner()), Some(config.port))?;
|
||||
let status = restart_core_process_with_state(process_state.inner(), gui_config_state.inner())?;
|
||||
emit_core_status(&app, &status);
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
fn restart_core_process_with_state(
|
||||
process_state: &CoreProcessState,
|
||||
gui_config_state: &GuiConfigState,
|
||||
) -> Result<CoreStatus, String> {
|
||||
let config = gui_config_state.snapshot()?;
|
||||
let _ = stop_core_process_inner(process_state);
|
||||
start_core_process_inner(process_state, &config)?;
|
||||
if let Err(error) = gui_config_state.set_run_on_startup(true) {
|
||||
let _ = stop_core_process_inner(process_state);
|
||||
return Err(error);
|
||||
}
|
||||
current_core_status(Some(process_state), Some(config.port))
|
||||
}
|
||||
|
||||
async fn install_core_version_inner(
|
||||
window: &tauri::Window,
|
||||
state: &CoreDownloadState,
|
||||
@@ -8764,6 +8822,11 @@ fn show_main_window_on_main_thread(app_handle: &tauri::AppHandle) {
|
||||
set_macos_dock_visible(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}");
|
||||
}
|
||||
@@ -8887,6 +8950,341 @@ fn setup_macos_tray(app: &mut tauri::App<tauri::Wry>) -> tauri::Result<()> {
|
||||
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)]
|
||||
enum WindowsTrayCoreAction {
|
||||
Toggle,
|
||||
Restart,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
struct WindowsTrayPresentation {
|
||||
status_text: &'static str,
|
||||
toggle_text: &'static str,
|
||||
toggle_enabled: bool,
|
||||
restart_enabled: bool,
|
||||
tooltip: &'static str,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_tray_presentation(status: &CoreStatus, busy: bool) -> WindowsTrayPresentation {
|
||||
let status_text = if busy {
|
||||
"内核状态:处理中"
|
||||
} else if !status.installed {
|
||||
"内核状态:未安装"
|
||||
} else if status.running {
|
||||
"内核状态:运行中"
|
||||
} else {
|
||||
"内核状态:已停止"
|
||||
};
|
||||
let toggle_text = if busy {
|
||||
"处理中..."
|
||||
} else if status.running {
|
||||
"停止内核"
|
||||
} else {
|
||||
"启动内核"
|
||||
};
|
||||
let tooltip = if busy {
|
||||
"EasyCLIProxyAPI · 内核处理中"
|
||||
} else if !status.installed {
|
||||
"EasyCLIProxyAPI · 内核未安装"
|
||||
} else if status.running {
|
||||
"EasyCLIProxyAPI · 内核运行中"
|
||||
} else {
|
||||
"EasyCLIProxyAPI · 内核已停止"
|
||||
};
|
||||
|
||||
WindowsTrayPresentation {
|
||||
status_text,
|
||||
toggle_text,
|
||||
toggle_enabled: status.installed && !busy,
|
||||
restart_enabled: status.installed && status.running && !busy,
|
||||
tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
struct WindowsTrayState {
|
||||
status_item: MenuItem<tauri::Wry>,
|
||||
toggle_core_item: MenuItem<tauri::Wry>,
|
||||
restart_core_item: MenuItem<tauri::Wry>,
|
||||
busy: AtomicBool,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
impl WindowsTrayState {
|
||||
fn begin_action(&self) -> bool {
|
||||
if self
|
||||
.busy
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Err(error) = self.status_item.set_text("内核状态:处理中") {
|
||||
eprintln!("更新 Windows 托盘内核状态失败: {error}");
|
||||
}
|
||||
if let Err(error) = self.toggle_core_item.set_text("处理中...") {
|
||||
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) -> &'static str {
|
||||
let presentation = windows_tray_presentation(status, self.busy.load(Ordering::Acquire));
|
||||
if let Err(error) = self.status_item.set_text(presentation.status_text) {
|
||||
eprintln!("更新 Windows 托盘内核状态失败: {error}");
|
||||
}
|
||||
if let Err(error) = self.toggle_core_item.set_text(presentation.toggle_text) {
|
||||
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('…');
|
||||
}
|
||||
if let Err(update_error) = self.status_item.set_text(format!("操作失败:{summary}")) {
|
||||
eprintln!("更新 Windows 托盘错误状态失败: {update_error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
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")]
|
||||
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).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")]
|
||||
fn show_windows_tray_action_error(app_handle: &tauri::AppHandle, error: &str) {
|
||||
eprintln!("Windows 托盘内核操作失败: {error}");
|
||||
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("EasyCLIProxyAPI · 内核操作失败")) {
|
||||
eprintln!("更新 Windows 托盘错误提示失败: {update_error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
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("EasyCLIProxyAPI · 内核处理中")) {
|
||||
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")]
|
||||
fn setup_windows_tray(app: &mut tauri::App<tauri::Wry>) -> tauri::Result<()> {
|
||||
let open_main_window = MenuItem::with_id(
|
||||
app,
|
||||
WINDOWS_TRAY_OPEN_MENU_ID,
|
||||
"打开主界面",
|
||||
true,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let status_item = MenuItem::with_id(
|
||||
app,
|
||||
WINDOWS_TRAY_STATUS_MENU_ID,
|
||||
"内核状态:正在检查",
|
||||
false,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let toggle_core_item = MenuItem::with_id(
|
||||
app,
|
||||
WINDOWS_TRAY_TOGGLE_CORE_MENU_ID,
|
||||
"启动内核",
|
||||
false,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let restart_core_item = MenuItem::with_id(
|
||||
app,
|
||||
WINDOWS_TRAY_RESTART_CORE_MENU_ID,
|
||||
"重启内核",
|
||||
false,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let quit = MenuItem::with_id(app, WINDOWS_TRAY_QUIT_MENU_ID, "退出", 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 {
|
||||
status_item,
|
||||
toggle_core_item,
|
||||
restart_core_item,
|
||||
busy: AtomicBool::new(false),
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let gui_config = match load_or_create_gui_config() {
|
||||
Ok(config) => config,
|
||||
@@ -8928,10 +9326,24 @@ fn main() {
|
||||
}
|
||||
});
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let app = app.on_window_event(|window, event| {
|
||||
if window.label() == "main" {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
if let Err(error) = window.emit(WINDOWS_CLOSE_REQUEST_EVENT, ()) {
|
||||
eprintln!("显示 Windows 关闭确认失败: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let app = app
|
||||
.setup(|app| {
|
||||
#[cfg(target_os = "macos")]
|
||||
setup_macos_tray(app)?;
|
||||
#[cfg(target_os = "windows")]
|
||||
setup_windows_tray(app)?;
|
||||
|
||||
let usage_app = app.handle().clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
@@ -8982,6 +9394,7 @@ fn main() {
|
||||
detect_core_platform,
|
||||
get_core_status,
|
||||
get_gui_settings,
|
||||
resolve_windows_close_request,
|
||||
get_agent_config_statuses,
|
||||
refresh_agent_config_statuses,
|
||||
get_agent_models,
|
||||
@@ -9497,6 +9910,44 @@ mod tests {
|
||||
assert_eq!(native.get_args().count(), 0);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_tray_presentation_tracks_core_state_and_busy_actions() {
|
||||
let mut status = CoreStatus {
|
||||
installed: false,
|
||||
running: false,
|
||||
managed: false,
|
||||
process_id: None,
|
||||
current_version: None,
|
||||
install_dir: String::new(),
|
||||
binary_path: None,
|
||||
message: String::new(),
|
||||
};
|
||||
|
||||
let missing = windows_tray_presentation(&status, false);
|
||||
assert_eq!(missing.status_text, "内核状态:未安装");
|
||||
assert!(!missing.toggle_enabled);
|
||||
assert!(!missing.restart_enabled);
|
||||
|
||||
status.installed = true;
|
||||
let stopped = windows_tray_presentation(&status, false);
|
||||
assert_eq!(stopped.toggle_text, "启动内核");
|
||||
assert!(stopped.toggle_enabled);
|
||||
assert!(!stopped.restart_enabled);
|
||||
|
||||
status.running = true;
|
||||
let running = windows_tray_presentation(&status, false);
|
||||
assert_eq!(running.status_text, "内核状态:运行中");
|
||||
assert_eq!(running.toggle_text, "停止内核");
|
||||
assert!(running.toggle_enabled);
|
||||
assert!(running.restart_enabled);
|
||||
|
||||
let busy = windows_tray_presentation(&status, true);
|
||||
assert_eq!(busy.status_text, "内核状态:处理中");
|
||||
assert!(!busy.toggle_enabled);
|
||||
assert!(!busy.restart_enabled);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_chatgpt_discovery_parser_accepts_registered_app_and_executable() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "EasyCLIProxyAPI",
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.4",
|
||||
"identifier": "com.cpa.gui",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
|
||||
+188
-54
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import {
|
||||
Bot,
|
||||
ExternalLink,
|
||||
@@ -85,6 +86,12 @@ const pages = [
|
||||
] as const;
|
||||
|
||||
type PageId = (typeof pages)[number]['id'];
|
||||
type WindowsCloseAction = 'exit' | 'minimize-to-tray';
|
||||
|
||||
type WindowsClosePrompt = {
|
||||
resolvingAction: WindowsCloseAction | null;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -96,6 +103,8 @@ function App() {
|
||||
|
||||
function AppContent() {
|
||||
const [active, setActive] = useState<PageId>('kernel');
|
||||
const [windowsClosePrompt, setWindowsClosePrompt] = useState<WindowsClosePrompt | null>(null);
|
||||
const closeDialogRef = useRef<HTMLElement>(null);
|
||||
const { status } = useCoreRuntime();
|
||||
const coreRunning = Boolean(status?.running);
|
||||
const activePage = pages.find((page) => page.id === active) ?? pages[0];
|
||||
@@ -107,6 +116,46 @@ function AppContent() {
|
||||
}
|
||||
}, [active, coreRunning]);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let stopListening: (() => void) | undefined;
|
||||
|
||||
void listen('windows-close-requested', () => {
|
||||
setWindowsClosePrompt((current) =>
|
||||
current ?? {
|
||||
resolvingAction: null,
|
||||
error: null,
|
||||
},
|
||||
);
|
||||
})
|
||||
.then((stop) => {
|
||||
if (disposed) {
|
||||
stop();
|
||||
} else {
|
||||
stopListening = stop;
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('监听 Windows 关闭确认事件失败', error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
stopListening?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!windowsClosePrompt || windowsClosePrompt.resolvingAction) {
|
||||
return;
|
||||
}
|
||||
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
closeDialogRef.current?.focus();
|
||||
});
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [windowsClosePrompt]);
|
||||
|
||||
const select = (pageId: PageId) => {
|
||||
if (pageId !== 'kernel' && !coreRunning) {
|
||||
return;
|
||||
@@ -122,63 +171,148 @@ function AppContent() {
|
||||
}
|
||||
};
|
||||
|
||||
const resolveWindowsCloseRequest = async (action: WindowsCloseAction) => {
|
||||
setWindowsClosePrompt((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
resolvingAction: action,
|
||||
error: null,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
|
||||
try {
|
||||
await invoke('resolve_windows_close_request', { action });
|
||||
setWindowsClosePrompt(null);
|
||||
} catch (error) {
|
||||
setWindowsClosePrompt((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
resolvingAction: null,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
: current,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-brand" title="CLI Proxy API GUI">
|
||||
<img src={appLogo} alt="" className="brand-mark brand-logo" />
|
||||
<div>
|
||||
<strong>EasyCLIProxyAPI</strong>
|
||||
<span>Desktop Console</span>
|
||||
<>
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-brand" title="CLI Proxy API GUI">
|
||||
<img src={appLogo} alt="" className="brand-mark brand-logo" />
|
||||
<div>
|
||||
<strong>EasyCLIProxyAPI</strong>
|
||||
<span>Desktop Console</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="nav-section" aria-label="主导航">
|
||||
{pages.map((page) => {
|
||||
const Icon = page.icon;
|
||||
const locked = page.id !== 'kernel' && !coreRunning;
|
||||
return (
|
||||
<button
|
||||
key={page.id}
|
||||
type="button"
|
||||
className={[page.id === active ? 'active' : '', locked ? 'locked' : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
disabled={locked}
|
||||
title={locked ? '请先启动内核' : undefined}
|
||||
onClick={() => select(page.id)}
|
||||
>
|
||||
<Icon size={17} aria-hidden="true" />
|
||||
<span>{page.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-bottom">
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-contact"
|
||||
title="通过 QQ 联系我们"
|
||||
onClick={() => void openContact()}
|
||||
>
|
||||
<MessageCircle size={16} aria-hidden="true" />
|
||||
<span>联系我们</span>
|
||||
<ExternalLink size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="workspace">
|
||||
<main className="content">
|
||||
{activePage.id === 'kernel' || coreRunning ? (
|
||||
<ActivePage />
|
||||
) : (
|
||||
<CoreLockedPage />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<nav className="nav-section" aria-label="主导航">
|
||||
{pages.map((page) => {
|
||||
const Icon = page.icon;
|
||||
const locked = page.id !== 'kernel' && !coreRunning;
|
||||
return (
|
||||
<button
|
||||
key={page.id}
|
||||
type="button"
|
||||
className={[page.id === active ? 'active' : '', locked ? 'locked' : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
disabled={locked}
|
||||
title={locked ? '请先启动内核' : undefined}
|
||||
onClick={() => select(page.id)}
|
||||
>
|
||||
<Icon size={17} aria-hidden="true" />
|
||||
<span>{page.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-bottom">
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-contact"
|
||||
title="通过 QQ 联系我们"
|
||||
onClick={() => void openContact()}
|
||||
>
|
||||
<MessageCircle size={16} aria-hidden="true" />
|
||||
<span>联系我们</span>
|
||||
<ExternalLink size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="workspace">
|
||||
<main className="content">
|
||||
{activePage.id === 'kernel' || coreRunning ? (
|
||||
<ActivePage />
|
||||
) : (
|
||||
<CoreLockedPage />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{windowsClosePrompt ? (
|
||||
<div className="close-dialog-backdrop">
|
||||
<section
|
||||
ref={closeDialogRef}
|
||||
className="close-dialog"
|
||||
role="alertdialog"
|
||||
tabIndex={-1}
|
||||
aria-modal="true"
|
||||
aria-labelledby="close-dialog-title"
|
||||
aria-describedby="close-dialog-description"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="close-dialog-heading">
|
||||
<span>关闭窗口</span>
|
||||
<h2 id="close-dialog-title">请选择后续操作</h2>
|
||||
</div>
|
||||
<p id="close-dialog-description">
|
||||
你可以退出程序,或让 EasyCLIProxyAPI 继续在系统托盘中运行。
|
||||
</p>
|
||||
{windowsClosePrompt.error ? (
|
||||
<div className="close-dialog-error" role="alert">
|
||||
{windowsClosePrompt.error}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="close-dialog-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="close-choice-button primary-button"
|
||||
disabled={windowsClosePrompt.resolvingAction !== null}
|
||||
onClick={() => void resolveWindowsCloseRequest('minimize-to-tray')}
|
||||
>
|
||||
<span>
|
||||
{windowsClosePrompt.resolvingAction === 'minimize-to-tray'
|
||||
? '正在最小化...'
|
||||
: '最小化到托盘'}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="close-choice-button danger-button"
|
||||
disabled={windowsClosePrompt.resolvingAction !== null}
|
||||
onClick={() => void resolveWindowsCloseRequest('exit')}
|
||||
>
|
||||
<span>
|
||||
{windowsClosePrompt.resolvingAction === 'exit' ? '正在退出...' : '退出程序'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1364,6 +1364,102 @@ input:focus-visible {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.close-dialog-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 110;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(45, 42, 38, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.close-dialog {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
width: min(430px, 100%);
|
||||
padding: 24px;
|
||||
border: 1px solid #d5d2cb;
|
||||
border-radius: 10px;
|
||||
outline: none;
|
||||
background: #fffdf8;
|
||||
box-shadow: 0 22px 60px rgba(45, 42, 38, 0.28);
|
||||
}
|
||||
|
||||
.close-dialog-heading {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.close-dialog-heading span {
|
||||
color: #7a746c;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.close-dialog-heading h2 {
|
||||
margin: 0;
|
||||
color: #2d2a26;
|
||||
font-size: 20px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.close-dialog > p {
|
||||
margin: 0;
|
||||
color: #5f5a53;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.close-dialog-error {
|
||||
padding: 9px 11px;
|
||||
border: 1px solid #dfb7b0;
|
||||
border-radius: 6px;
|
||||
background: #fff1ee;
|
||||
color: #9f2d20;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.close-dialog-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.close-choice-button {
|
||||
display: grid;
|
||||
min-height: 58px;
|
||||
gap: 3px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.close-choice-button span {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.close-choice-button:focus-visible {
|
||||
outline: 3px solid rgba(66, 99, 127, 0.36);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.close-dialog-backdrop {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.close-dialog {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.close-dialog-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.config-delete-dialog {
|
||||
grid-template-rows: 40px 52px 40px;
|
||||
height: 202px;
|
||||
|
||||
Reference in new issue
Block a user