diff --git a/build.ps1 b/build.ps1 index 498ba48..5b2e281 100644 --- a/build.ps1 +++ b/build.ps1 @@ -58,7 +58,7 @@ if (-not (Test-Path -LiteralPath $AppBin -PathType Leaf)) { throw "Build finished, but executable not found: $AppBin" } -& bun $PreparePortable --binary $AppBin --output $BinDir +& bun $PreparePortable --binary $AppBin --output $BinDir --download true if ($LASTEXITCODE -ne 0) { throw "Portable preparation failed with exit code $LASTEXITCODE." } diff --git a/build.sh b/build.sh index 7e9ee50..10146c8 100755 --- a/build.sh +++ b/build.sh @@ -26,7 +26,8 @@ fi mkdir -p "$BIN_DIR" bun "$ROOT_DIR/scripts/prepare-portable.mjs" \ --binary "$APP_BIN" \ - --output "$BIN_DIR" + --output "$BIN_DIR" \ + --download true chmod +x "$BIN_OUT" echo "Built: $APP_BIN" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8510449..09c6e98 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -437,6 +437,8 @@ dependencies = [ "futures-util", "json5", "libc", + "objc2", + "objc2-app-kit", "reqwest 0.12.28", "rusqlite", "semver", @@ -2029,9 +2031,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.0", "block2", + "libc", "objc2", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", "objc2-foundation", + "objc2-quartz-core", ] [[package]] @@ -2051,6 +2061,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ + "bitflags 2.13.0", "objc2", "objc2-foundation", ] @@ -2111,6 +2122,19 @@ dependencies = [ "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.13.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + [[package]] name = "objc2-encode" version = "4.1.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 67aa8fe..0142622 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,7 +23,7 @@ serde_norway = "0.9" serde_yaml = "0.9" sha2 = "0.10" tar = "0.4" -tauri = { version = "2", features = [] } +tauri = { version = "~2.11", features = ["tray-icon"] } tokio = { version = "1", features = ["macros", "sync", "time"] } tokio-util = "0.7" toml = "0.8" @@ -35,6 +35,10 @@ zip = { version = "2", default-features = false, features = ["deflate"] } [target.'cfg(target_os = "linux")'.dependencies] libc = "0.2" +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSEvent", "NSRunningApplication"] } + [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.59", features = [ "Win32_Foundation", diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 6803661..d3ede68 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -4,8 +4,14 @@ mod usage; use flate2::read::GzDecoder; use futures_util::StreamExt; +#[cfg(target_os = "macos")] +use objc2::MainThreadMarker; +#[cfg(target_os = "macos")] +use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy, NSEvent}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +#[cfg(target_os = "macos")] +use std::sync::Arc; use std::{ env, fs, fs::File, @@ -18,6 +24,11 @@ use std::{ time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use tar::Archive; +#[cfg(target_os = "macos")] +use tauri::{ + menu::{Menu, MenuItem}, + tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent}, +}; use tauri::{Emitter, Manager}; use tokio_util::sync::CancellationToken; use zip::ZipArchive; @@ -8605,6 +8616,170 @@ fn path_to_string(path: &Path) -> String { path.to_string_lossy().into_owned() } +#[cfg(target_os = "macos")] +const MACOS_TRAY_ID: &str = "macos-tray"; + +#[cfg(target_os = "macos")] +#[derive(Default)] +struct MacosTrayClickState { + last_click: Option, + sequence: u64, +} + +#[cfg(target_os = "macos")] +fn set_macos_dock_visible(visible: bool) -> bool { + let Some(mtm) = MainThreadMarker::new() else { + eprintln!("更新 Dock 图标状态必须在主线程执行"); + return false; + }; + let policy = if visible { + NSApplicationActivationPolicy::Regular + } else { + NSApplicationActivationPolicy::Accessory + }; + if !NSApplication::sharedApplication(mtm).setActivationPolicy(policy) { + eprintln!("更新 Dock 图标状态失败"); + return false; + } + true +} + +#[cfg(target_os = "macos")] +fn show_main_window_on_main_thread(app_handle: &tauri::AppHandle) { + let Some(window) = app_handle.get_webview_window("main") else { + return; + }; + if !set_macos_dock_visible(true) { + return; + } + if let Err(error) = window.show() { + eprintln!("显示主窗口失败: {error}"); + set_macos_dock_visible(false); + return; + } + if let Err(error) = window.set_focus() { + eprintln!("聚焦主窗口失败: {error}"); + } +} + +#[cfg(target_os = "macos")] +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")] +fn show_macos_tray_menu(tray: &TrayIcon) { + 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")] +fn setup_macos_tray(app: &mut tauri::App) -> tauri::Result<()> { + let open_main_window = + MenuItem::with_id(app, "open-main-window", "打开主界面", true, None::<&str>)?; + let quit = MenuItem::with_id(app, "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(()) +} + fn main() { let gui_config = match load_or_create_gui_config() { Ok(config) => config, @@ -8622,8 +8797,29 @@ fn main() { .manage(CoreDownloadState::default()) .manage(CoreProcessState::default()) .manage(usage::UsageCollectorState::default()) - .manage(GuiConfigState::new(gui_config)) + .manage(GuiConfigState::new(gui_config)); + + #[cfg(target_os = "macos")] + let app = app.on_window_event(|window, event| { + if window.label() == "main" { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + api.prevent_close(); + if !set_macos_dock_visible(false) { + return; + } + if let Err(error) = window.hide() { + eprintln!("隐藏主窗口失败: {error}"); + set_macos_dock_visible(true); + } + } + } + }); + + let app = app .setup(|app| { + #[cfg(target_os = "macos")] + setup_macos_tray(app)?; + let usage_app = app.handle().clone(); tauri::async_runtime::spawn_blocking(move || { if let Err(error) = usage::initialize_usage_storage() {