diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8115458..f4651a6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -68,6 +68,7 @@ dependencies = [ "shlex", "tauri", "tauri-build", + "tauri-plugin-autostart", "tauri-plugin-process", "tauri-plugin-single-instance", "tauri-plugin-updater", @@ -236,6 +237,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auto-launch" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471" +dependencies = [ + "dirs 4.0.0", + "thiserror 1.0.69", + "winreg 0.10.1", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -706,13 +718,33 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", ] [[package]] @@ -723,7 +755,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -820,7 +852,7 @@ dependencies = [ "rustc_version", "toml 0.9.12+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -2838,6 +2870,17 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -3635,7 +3678,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -3685,7 +3728,7 @@ checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -3757,6 +3800,20 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-autostart" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459383cebc193cdd03d1ba4acc40f2c408a7abce419d64bdcd2d745bc2886f70" +dependencies = [ + "auto-launch", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-process" version = "2.3.1" @@ -3789,7 +3846,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61" dependencies = [ "base64 0.22.1", - "dirs", + "dirs 6.0.0", "flate2", "futures-util", "http", @@ -4245,7 +4302,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2", @@ -5141,6 +5198,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.55.0" @@ -5255,7 +5321,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dpi", "dunce", "gdkx11", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 65d7bfb..b94e599 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -21,6 +21,7 @@ serde_json = "1.0" semver = "1.0" shlex = "1.3" tauri = { version = "2.0", features = ["tray-icon"] } +tauri-plugin-autostart = "2.0" tauri-plugin-process = "2.0" tauri-plugin-single-instance = "2.0" tauri-plugin-updater = "2.0" diff --git a/src-tauri/src/app_runtime.rs b/src-tauri/src/app_runtime.rs index 5b18de4..227bd65 100644 --- a/src-tauri/src/app_runtime.rs +++ b/src-tauri/src/app_runtime.rs @@ -4,12 +4,17 @@ use tauri::{ }; use crate::{ - app_runtime_events, append_desktop_log, append_startup_log, bridge, lifecycle, startup_task, - tray, window, BackendState, DEFAULT_SHELL_LOCALE, DESKTOP_LOG_FILE, STARTUP_MODE_ENV, + app_runtime_events, append_desktop_log, append_startup_log, bridge, desktop_settings, + lifecycle, runtime_paths, startup_task, tray, window, BackendState, DesktopSettingsCache, + DEFAULT_SHELL_LOCALE, DESKTOP_LOG_FILE, STARTUP_MODE_ENV, }; fn configure_plugins(builder: Builder) -> Builder { builder + .plugin(tauri_plugin_autostart::init( + tauri_plugin_autostart::MacosLauncher::LaunchAgent, + None, + )) .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { @@ -21,6 +26,7 @@ fn configure_plugins(builder: Builder) -> Builder { fn configure_window_events(builder: Builder) -> Builder { builder.on_window_event(|window, event| { let is_quitting = window.app_handle().state::().is_quitting(); + let desktop_settings = window.app_handle().state::().get(); let action = match &event { WindowEvent::CloseRequested { .. } => app_runtime_events::main_window_action( window.label(), @@ -28,6 +34,7 @@ fn configure_window_events(builder: Builder) -> Builder false, true, false, + desktop_settings.close_to_tray, ), WindowEvent::Focused(false) => app_runtime_events::main_window_action( window.label(), @@ -35,6 +42,7 @@ fn configure_window_events(builder: Builder) -> Builder matches!(window.is_minimized(), Ok(true)), false, true, + desktop_settings.close_to_tray, ), _ => app_runtime_events::MainWindowAction::None, }; @@ -50,6 +58,12 @@ fn configure_window_events(builder: Builder) -> Builder append_desktop_log, ); } + app_runtime_events::MainWindowAction::PreventCloseAndExit => { + if let WindowEvent::CloseRequested { api, .. } = event { + api.prevent_close(); + } + lifecycle::events::handle_tray_quit(window.app_handle()); + } app_runtime_events::MainWindowAction::HideIfMinimized => { window::actions::hide_main_window( window.app_handle(), @@ -119,6 +133,16 @@ fn configure_setup(builder: Builder) -> Builder { } crate::windows_shutdown::install(&app_handle); + let desktop_settings = app_handle.state::().get(); + if desktop_settings.silent_launch { + append_startup_log("silent launch enabled, hiding main window"); + window::actions::hide_main_window( + &app_handle, + DEFAULT_SHELL_LOCALE, + append_desktop_log, + ); + } + startup_task::spawn_startup_task(app_handle.clone(), append_startup_log); Ok(()) }) @@ -161,6 +185,11 @@ pub(crate) fn run() { builder .manage(BackendState::default()) + .manage(DesktopSettingsCache::new( + desktop_settings::read_desktop_settings( + runtime_paths::default_packaged_root_dir().as_deref(), + ), + )) .invoke_handler(tauri::generate_handler![ crate::bridge::commands::desktop_bridge_is_desktop_runtime, crate::bridge::commands::desktop_bridge_get_backend_state, diff --git a/src-tauri/src/app_runtime_events.rs b/src-tauri/src/app_runtime_events.rs index d932706..867fc93 100644 --- a/src-tauri/src/app_runtime_events.rs +++ b/src-tauri/src/app_runtime_events.rs @@ -4,6 +4,7 @@ use tauri::{webview::PageLoadEvent, RunEvent}; pub(crate) enum MainWindowAction { None, PreventCloseAndHide, + PreventCloseAndExit, HideIfMinimized, } @@ -29,6 +30,7 @@ pub(crate) fn main_window_action( minimized_on_focus_lost: bool, is_close_requested: bool, is_focus_lost: bool, + close_to_tray: bool, ) -> MainWindowAction { if window_label != "main" { return MainWindowAction::None; @@ -37,8 +39,10 @@ pub(crate) fn main_window_action( if is_close_requested { return if is_quitting { MainWindowAction::None - } else { + } else if close_to_tray { MainWindowAction::PreventCloseAndHide + } else { + MainWindowAction::PreventCloseAndExit }; } @@ -101,7 +105,7 @@ mod tests { #[test] fn main_window_action_ignores_non_main_windows() { assert_eq!( - main_window_action("settings", false, false, true, false), + main_window_action("settings", false, false, true, false, true), MainWindowAction::None ); } @@ -109,15 +113,23 @@ mod tests { #[test] fn main_window_action_hides_on_close_when_not_quitting() { assert_eq!( - main_window_action("main", false, false, true, false), + main_window_action("main", false, false, true, false, true), MainWindowAction::PreventCloseAndHide ); } + #[test] + fn main_window_action_allows_close_when_close_to_tray_is_disabled() { + assert_eq!( + main_window_action("main", false, false, true, false, false), + MainWindowAction::PreventCloseAndExit + ); + } + #[test] fn main_window_action_hides_on_minimized_focus_loss() { assert_eq!( - main_window_action("main", false, true, false, true), + main_window_action("main", false, true, false, true, true), MainWindowAction::HideIfMinimized ); } diff --git a/src-tauri/src/app_types.rs b/src-tauri/src/app_types.rs index aea509a..fac9f58 100644 --- a/src-tauri/src/app_types.rs +++ b/src-tauri/src/app_types.rs @@ -8,7 +8,7 @@ use std::{ Arc, Mutex, }, }; -use tauri::menu::MenuItem; +use tauri::menu::{CheckMenuItem, MenuItem}; use crate::{backend, exit_state, DEFAULT_BACKEND_URL}; @@ -17,6 +17,9 @@ pub(crate) struct TrayMenuState { pub(crate) toggle_item: MenuItem, pub(crate) reload_item: MenuItem, pub(crate) restart_backend_item: MenuItem, + pub(crate) launch_at_login_item: CheckMenuItem, + pub(crate) silent_launch_item: CheckMenuItem, + pub(crate) close_to_tray_item: CheckMenuItem, pub(crate) quit_item: MenuItem, } diff --git a/src-tauri/src/desktop_settings.rs b/src-tauri/src/desktop_settings.rs new file mode 100644 index 0000000..5768949 --- /dev/null +++ b/src-tauri/src/desktop_settings.rs @@ -0,0 +1,369 @@ +use std::{fs, io::Write, path::Path, sync::Mutex}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +#[derive(Debug)] +pub(crate) struct DesktopSettingsCache { + settings: Mutex, +} + +impl DesktopSettingsCache { + pub(crate) fn new(settings: DesktopSettings) -> Self { + Self { + settings: Mutex::new(settings), + } + } + + pub(crate) fn get(&self) -> DesktopSettings { + self.settings + .lock() + .expect("desktop settings cache lock") + .clone() + } + + pub(crate) fn set(&self, settings: DesktopSettings) { + *self.settings.lock().expect("desktop settings cache lock") = settings; + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DesktopSettingKey { + LaunchAtLogin, + SilentLaunch, + CloseToTray, +} + +fn default_launch_at_login() -> bool { + false +} + +fn default_silent_launch() -> bool { + false +} + +fn default_close_to_tray() -> bool { + true +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct DesktopSettings { + #[serde(rename = "launchAtLogin", default = "default_launch_at_login")] + pub(crate) launch_at_login: bool, + #[serde(rename = "silentLaunch", default = "default_silent_launch")] + pub(crate) silent_launch: bool, + #[serde(rename = "closeToTray", default = "default_close_to_tray")] + pub(crate) close_to_tray: bool, + #[serde(flatten)] + other: Map, +} + +impl Default for DesktopSettings { + fn default() -> Self { + Self { + launch_at_login: default_launch_at_login(), + silent_launch: default_silent_launch(), + close_to_tray: default_close_to_tray(), + other: Map::new(), + } + } +} + +impl DesktopSettings { + fn set(&mut self, key: DesktopSettingKey, value: bool) { + match key { + DesktopSettingKey::LaunchAtLogin => self.launch_at_login = value, + DesktopSettingKey::SilentLaunch => self.silent_launch = value, + DesktopSettingKey::CloseToTray => self.close_to_tray = value, + } + } +} + +fn load_state(path: &Path) -> Result { + let raw = match fs::read_to_string(path) { + Ok(raw) => raw, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(DesktopSettings::default()); + } + Err(error) => { + return Err(format!( + "Failed to read desktop settings state {}: {}", + path.display(), + error + )); + } + }; + + match serde_json::from_str::(&raw) { + Ok(state) => Ok(state), + Err(error) => { + crate::append_desktop_log(&format!( + "failed to parse desktop settings state {}: {}. resetting state file", + path.display(), + error + )); + let default_state = DesktopSettings::default(); + if let Err(save_error) = save_state(path, &default_state) { + crate::append_desktop_log(&format!( + "failed to persist reset desktop settings state {}: {}", + path.display(), + save_error + )); + } + Ok(default_state) + } + } +} + +fn ensure_parent_dir(path: &Path) -> Result<(), String> { + if let Some(parent_dir) = path.parent() { + fs::create_dir_all(parent_dir).map_err(|error| { + format!( + "Failed to create desktop settings directory {}: {}", + parent_dir.display(), + error + ) + })?; + } + + Ok(()) +} + +fn save_state(path: &Path, state: &DesktopSettings) -> Result<(), String> { + ensure_parent_dir(path)?; + + let serialized = serde_json::to_string_pretty(state) + .map_err(|error| format!("Failed to serialize desktop settings state: {error}"))?; + let tmp_name = format!( + "{}.tmp", + path.file_name() + .map(|value| value.to_string_lossy()) + .unwrap_or_default() + ); + let tmp_path = path.with_file_name(tmp_name); + + let mut file = fs::File::create(&tmp_path).map_err(|error| { + format!( + "Failed to create temporary desktop settings state file {}: {}", + tmp_path.display(), + error + ) + })?; + file.write_all(serialized.as_bytes()) + .and_then(|_| file.sync_all()) + .map_err(|error| { + format!( + "Failed to write temporary desktop settings state file {}: {}", + tmp_path.display(), + error + ) + })?; + fs::rename(&tmp_path, path).map_err(|error| { + format!( + "Failed to atomically replace desktop settings state file {}: {}", + path.display(), + error + ) + }) +} + +pub(crate) fn read_desktop_settings(packaged_root_dir: Option<&Path>) -> DesktopSettings { + let Some(state_path) = crate::desktop_state::resolve_desktop_state_path(packaged_root_dir) + else { + return DesktopSettings::default(); + }; + + match load_state(&state_path) { + Ok(state) => state, + Err(error) => { + crate::append_desktop_log(&error); + DesktopSettings::default() + } + } +} + +pub(crate) fn write_desktop_setting( + packaged_root_dir: Option<&Path>, + key: DesktopSettingKey, + value: bool, +) -> Result { + let Some(state_path) = crate::desktop_state::resolve_desktop_state_path(packaged_root_dir) + else { + let message = + "Desktop settings state path is unavailable; cannot persist setting.".to_string(); + crate::append_desktop_log(&message); + return Err(message); + }; + + let mut state = load_state(&state_path)?; + state.set(key, value); + save_state(&state_path, &state)?; + Ok(state) +} + +#[cfg(test)] +mod tests { + use std::{fs, path::PathBuf}; + + use super::*; + + fn create_temp_case_dir(name: &str) -> PathBuf { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time before unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "astrbot-desktop-settings-test-{}-{}-{}", + std::process::id(), + name, + ts + )) + } + + fn state_path(root: &std::path::Path) -> PathBuf { + root.join("data").join("desktop_state.json") + } + + fn settings( + launch_at_login: bool, + silent_launch: bool, + close_to_tray: bool, + ) -> DesktopSettings { + DesktopSettings { + launch_at_login, + silent_launch, + close_to_tray, + ..DesktopSettings::default() + } + } + + #[test] + fn desktop_settings_default_preserves_existing_close_to_tray_behavior() { + assert_eq!(DesktopSettings::default(), settings(false, false, true)); + } + + #[test] + fn read_desktop_settings_maps_camel_case_fields() { + let root = create_temp_case_dir("read"); + let path = state_path(&root); + fs::create_dir_all(path.parent().expect("state parent")).expect("create state parent"); + fs::write( + &path, + r#"{"launchAtLogin":true,"silentLaunch":true,"closeToTray":false}"#, + ) + .expect("write state"); + + assert_eq!( + read_desktop_settings(Some(&root)), + settings(true, true, false) + ); + } + + #[test] + fn read_desktop_settings_applies_field_defaults_for_missing_values() { + let root = create_temp_case_dir("defaults"); + let path = state_path(&root); + fs::create_dir_all(path.parent().expect("state parent")).expect("create state parent"); + fs::write(&path, r#"{"silentLaunch":true}"#).expect("write state"); + + assert_eq!( + read_desktop_settings(Some(&root)), + settings(false, true, true) + ); + } + + #[test] + fn desktop_settings_cache_returns_updated_value_without_reloading_file() { + let cache = DesktopSettingsCache::new(DesktopSettings::default()); + let updated = settings(true, true, false); + + cache.set(updated.clone()); + + assert_eq!(cache.get(), updated); + } + + #[test] + fn write_desktop_setting_preserves_unknown_fields() { + let root = create_temp_case_dir("preserve"); + let path = state_path(&root); + fs::create_dir_all(path.parent().expect("state parent")).expect("create state parent"); + fs::write( + &path, + r#"{"locale":"zh-CN","updateChannel":"nightly","silentLaunch":false}"#, + ) + .expect("write state"); + + let updated = write_desktop_setting(Some(&root), DesktopSettingKey::SilentLaunch, true) + .expect("write setting"); + + assert!(updated.silent_launch); + let raw = fs::read_to_string(&path).expect("read state"); + let parsed: serde_json::Value = serde_json::from_str(&raw).expect("parse state"); + assert_eq!( + parsed.get("locale").and_then(|value| value.as_str()), + Some("zh-CN") + ); + assert_eq!( + parsed.get("updateChannel").and_then(|value| value.as_str()), + Some("nightly") + ); + assert_eq!( + parsed.get("silentLaunch").and_then(|value| value.as_bool()), + Some(true) + ); + } + + #[test] + fn invalid_state_falls_back_to_defaults_and_write_resets_object() { + let root = create_temp_case_dir("invalid"); + let path = state_path(&root); + fs::create_dir_all(path.parent().expect("state parent")).expect("create state parent"); + fs::write(&path, "not-json").expect("write invalid state"); + + assert_eq!( + read_desktop_settings(Some(&root)), + DesktopSettings::default() + ); + + let updated = write_desktop_setting(Some(&root), DesktopSettingKey::CloseToTray, false) + .expect("write setting"); + + assert!(!updated.close_to_tray); + let raw = fs::read_to_string(&path).expect("read state"); + let parsed: serde_json::Value = serde_json::from_str(&raw).expect("parse reset state"); + assert_eq!( + parsed.get("closeToTray").and_then(|value| value.as_bool()), + Some(false) + ); + } + + #[test] + fn invalid_state_is_rewritten_to_defaults_on_read() { + let root = create_temp_case_dir("invalid-read-reset"); + let path = state_path(&root); + fs::create_dir_all(path.parent().expect("state parent")).expect("create state parent"); + fs::write(&path, "not-json").expect("write invalid state"); + + assert_eq!( + read_desktop_settings(Some(&root)), + DesktopSettings::default() + ); + + let raw = fs::read_to_string(&path).expect("read reset state"); + let parsed: serde_json::Value = serde_json::from_str(&raw).expect("parse reset state"); + assert_eq!( + parsed + .get("launchAtLogin") + .and_then(|value| value.as_bool()), + Some(false) + ); + assert_eq!( + parsed.get("silentLaunch").and_then(|value| value.as_bool()), + Some(false) + ); + assert_eq!( + parsed.get("closeToTray").and_then(|value| value.as_bool()), + Some(true) + ); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index d29ec7b..d804b58 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -8,6 +8,7 @@ mod app_types; mod backend; mod bridge; +mod desktop_settings; mod desktop_state; mod exit_state; @@ -40,6 +41,7 @@ pub(crate) use app_types::{ AtomicFlagGuard, BackendBridgeResult, BackendBridgeState, BackendState, LaunchPlan, RuntimeManifest, TrayMenuState, }; +pub(crate) use desktop_settings::DesktopSettingsCache; fn main() { app_runtime::run(); diff --git a/src-tauri/src/shell_locale.rs b/src-tauri/src/shell_locale.rs index 981799f..9b36bed 100644 --- a/src-tauri/src/shell_locale.rs +++ b/src-tauri/src/shell_locale.rs @@ -17,6 +17,9 @@ pub struct ShellTexts { pub tray_show: &'static str, pub tray_reload: &'static str, pub tray_restart_backend: &'static str, + pub tray_launch_at_login: &'static str, + pub tray_silent_launch: &'static str, + pub tray_close_to_tray: &'static str, pub tray_quit: &'static str, } @@ -27,6 +30,9 @@ pub fn shell_texts_for_locale(locale: &str) -> ShellTexts { tray_show: "Show AstrBot", tray_reload: "Reload UI", tray_restart_backend: "Restart Backend", + tray_launch_at_login: "Launch at Login", + tray_silent_launch: "Silent Launch", + tray_close_to_tray: "Close to Tray", tray_quit: "Quit", }; } @@ -36,6 +42,9 @@ pub fn shell_texts_for_locale(locale: &str) -> ShellTexts { tray_show: "显示 AstrBot", tray_reload: "重载界面", tray_restart_backend: "重启后端", + tray_launch_at_login: "开机自启", + tray_silent_launch: "静默启动", + tray_close_to_tray: "关闭到托盘", tray_quit: "退出", } } @@ -192,6 +201,9 @@ mod tests { fn shell_texts_for_locale_returns_english_copy() { let texts = shell_texts_for_locale("en-US"); assert_eq!(texts.tray_hide, "Hide AstrBot"); + assert_eq!(texts.tray_launch_at_login, "Launch at Login"); + assert_eq!(texts.tray_silent_launch, "Silent Launch"); + assert_eq!(texts.tray_close_to_tray, "Close to Tray"); assert_eq!(texts.tray_quit, "Quit"); } @@ -199,6 +211,9 @@ mod tests { fn shell_texts_for_locale_falls_back_to_zh_cn_copy() { let texts = shell_texts_for_locale("zh-CN"); assert_eq!(texts.tray_hide, "隐藏 AstrBot"); + assert_eq!(texts.tray_launch_at_login, "开机自启"); + assert_eq!(texts.tray_silent_launch, "静默启动"); + assert_eq!(texts.tray_close_to_tray, "关闭到托盘"); assert_eq!(texts.tray_quit, "退出"); } diff --git a/src-tauri/src/tray/actions.rs b/src-tauri/src/tray/actions.rs index 7cfa0d0..24f2c92 100644 --- a/src-tauri/src/tray/actions.rs +++ b/src-tauri/src/tray/actions.rs @@ -1,6 +1,9 @@ pub const TRAY_MENU_TOGGLE_WINDOW: &str = "tray_toggle_window"; pub const TRAY_MENU_RELOAD_WINDOW: &str = "tray_reload_window"; pub const TRAY_MENU_RESTART_BACKEND: &str = "tray_restart_backend"; +pub const TRAY_MENU_LAUNCH_AT_LOGIN: &str = "tray_launch_at_login"; +pub const TRAY_MENU_SILENT_LAUNCH: &str = "tray_silent_launch"; +pub const TRAY_MENU_CLOSE_TO_TRAY: &str = "tray_close_to_tray"; pub const TRAY_MENU_QUIT: &str = "tray_quit"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -8,6 +11,9 @@ pub enum TrayMenuAction { ToggleWindow, ReloadWindow, RestartBackend, + LaunchAtLogin, + SilentLaunch, + CloseToTray, Quit, } @@ -16,6 +22,9 @@ pub fn action_from_menu_id(menu_id: &str) -> Option { TRAY_MENU_TOGGLE_WINDOW => Some(TrayMenuAction::ToggleWindow), TRAY_MENU_RELOAD_WINDOW => Some(TrayMenuAction::ReloadWindow), TRAY_MENU_RESTART_BACKEND => Some(TrayMenuAction::RestartBackend), + TRAY_MENU_LAUNCH_AT_LOGIN => Some(TrayMenuAction::LaunchAtLogin), + TRAY_MENU_SILENT_LAUNCH => Some(TrayMenuAction::SilentLaunch), + TRAY_MENU_CLOSE_TO_TRAY => Some(TrayMenuAction::CloseToTray), TRAY_MENU_QUIT => Some(TrayMenuAction::Quit), _ => None, } @@ -43,6 +52,18 @@ mod tests { action_from_menu_id(TRAY_MENU_QUIT), Some(TrayMenuAction::Quit) ); + assert_eq!( + action_from_menu_id(TRAY_MENU_LAUNCH_AT_LOGIN), + Some(TrayMenuAction::LaunchAtLogin) + ); + assert_eq!( + action_from_menu_id(TRAY_MENU_SILENT_LAUNCH), + Some(TrayMenuAction::SilentLaunch) + ); + assert_eq!( + action_from_menu_id(TRAY_MENU_CLOSE_TO_TRAY), + Some(TrayMenuAction::CloseToTray) + ); } #[test] diff --git a/src-tauri/src/tray/labels.rs b/src-tauri/src/tray/labels.rs index b127aec..c6a2b8f 100644 --- a/src-tauri/src/tray/labels.rs +++ b/src-tauri/src/tray/labels.rs @@ -1,4 +1,7 @@ -use tauri::{menu::MenuItem, AppHandle, Manager}; +use tauri::{ + menu::{CheckMenuItem, MenuItem}, + AppHandle, Manager, +}; use crate::{runtime_paths, shell_locale, tray::actions, TrayMenuState}; @@ -14,6 +17,22 @@ where } } +fn set_check_menu_text_safe( + item: &CheckMenuItem, + text: &str, + item_name: &str, + log: F, +) where + F: Fn(&str), +{ + if let Err(error) = item.set_text(text) { + log(&format!( + "failed to update tray menu text for {}: {}", + item_name, error + )); + } +} + pub fn update_tray_menu_labels( app_handle: &AppHandle, default_shell_locale: &'static str, @@ -74,6 +93,24 @@ pub fn update_tray_menu_labels_with_visibility( actions::TRAY_MENU_RESTART_BACKEND, &log, ); + set_check_menu_text_safe( + &tray_state.launch_at_login_item, + shell_texts.tray_launch_at_login, + actions::TRAY_MENU_LAUNCH_AT_LOGIN, + &log, + ); + set_check_menu_text_safe( + &tray_state.silent_launch_item, + shell_texts.tray_silent_launch, + actions::TRAY_MENU_SILENT_LAUNCH, + &log, + ); + set_check_menu_text_safe( + &tray_state.close_to_tray_item, + shell_texts.tray_close_to_tray, + actions::TRAY_MENU_CLOSE_TO_TRAY, + &log, + ); set_menu_text_safe( &tray_state.quit_item, shell_texts.tray_quit, diff --git a/src-tauri/src/tray/menu_handler.rs b/src-tauri/src/tray/menu_handler.rs index 808aecc..ed93b9d 100644 --- a/src-tauri/src/tray/menu_handler.rs +++ b/src-tauri/src/tray/menu_handler.rs @@ -1,9 +1,12 @@ use tauri::{AppHandle, Manager}; +use tauri_plugin_autostart::ManagerExt; use crate::{ - append_desktop_log, append_restart_log, lifecycle, restart_backend_flow, + append_desktop_log, append_restart_log, desktop_settings, lifecycle, restart_backend_flow, + runtime_paths, tray::{actions, bridge_event}, - ui_dispatch, window, BackendState, DEFAULT_SHELL_LOCALE, TRAY_RESTART_BACKEND_EVENT, + ui_dispatch, window, BackendState, DesktopSettingsCache, TrayMenuState, DEFAULT_SHELL_LOCALE, + TRAY_RESTART_BACKEND_EVENT, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -20,6 +23,123 @@ fn decide_tray_restart(backend_action_in_progress: bool) -> TrayRestartDecision } } +fn set_checked_safe(item: &tauri::menu::CheckMenuItem, checked: bool, item_name: &str) { + if let Err(error) = item.set_checked(checked) { + append_desktop_log(&format!( + "failed to update tray menu check state for {}: {}", + item_name, error + )); + } +} + +fn persist_bool_setting_and_update_tray( + app_handle: &AppHandle, + key: desktop_settings::DesktopSettingKey, + new_value: bool, + previous_value: bool, + item: &tauri::menu::CheckMenuItem, + item_name: &str, +) { + match desktop_settings::write_desktop_setting( + runtime_paths::default_packaged_root_dir().as_deref(), + key, + new_value, + ) { + Ok(updated_settings) => { + app_handle + .state::() + .set(updated_settings); + set_checked_safe(item, new_value, item_name); + } + Err(error) => { + append_desktop_log(&format!( + "failed to persist {} setting: {}", + item_name, error + )); + set_checked_safe(item, previous_value, item_name); + } + } +} + +fn handle_launch_at_login_toggle(app_handle: &AppHandle) { + let Some(tray_state) = app_handle.try_state::() else { + return; + }; + + let current_enabled = match app_handle.autolaunch().is_enabled() { + Ok(value) => value, + Err(error) => { + append_desktop_log(&format!( + "failed to read launch-at-login state, using cached setting: {error}" + )); + app_handle + .state::() + .get() + .launch_at_login + } + }; + let desired_enabled = !current_enabled; + + let operation_result = if desired_enabled { + app_handle.autolaunch().enable() + } else { + app_handle.autolaunch().disable() + }; + + if let Err(error) = operation_result { + append_desktop_log(&format!( + "failed to {} launch at login: {}", + if desired_enabled { "enable" } else { "disable" }, + error + )); + set_checked_safe( + &tray_state.launch_at_login_item, + current_enabled, + actions::TRAY_MENU_LAUNCH_AT_LOGIN, + ); + return; + } + + persist_bool_setting_and_update_tray( + app_handle, + desktop_settings::DesktopSettingKey::LaunchAtLogin, + desired_enabled, + current_enabled, + &tray_state.launch_at_login_item, + actions::TRAY_MENU_LAUNCH_AT_LOGIN, + ); +} + +fn handle_silent_launch_toggle(app_handle: &AppHandle) { + let Some(tray_state) = app_handle.try_state::() else { + return; + }; + let current_settings = app_handle.state::().get(); + persist_bool_setting_and_update_tray( + app_handle, + desktop_settings::DesktopSettingKey::SilentLaunch, + !current_settings.silent_launch, + current_settings.silent_launch, + &tray_state.silent_launch_item, + actions::TRAY_MENU_SILENT_LAUNCH, + ); +} + +fn handle_close_to_tray_toggle(app_handle: &AppHandle) { + let Some(tray_state) = app_handle.try_state::() else { + return; + }; + let current_settings = app_handle.state::().get(); + persist_bool_setting_and_update_tray( + app_handle, + desktop_settings::DesktopSettingKey::CloseToTray, + !current_settings.close_to_tray, + current_settings.close_to_tray, + &tray_state.close_to_tray_item, + actions::TRAY_MENU_CLOSE_TO_TRAY, + ); +} + pub fn handle_tray_menu_event(app_handle: &AppHandle, menu_id: &str) { match actions::action_from_menu_id(menu_id) { Some(actions::TrayMenuAction::ToggleWindow) => window::actions::toggle_main_window( @@ -71,6 +191,9 @@ pub fn handle_tray_menu_event(app_handle: &AppHandle, menu_id: &str) { } }); } + Some(actions::TrayMenuAction::LaunchAtLogin) => handle_launch_at_login_toggle(app_handle), + Some(actions::TrayMenuAction::SilentLaunch) => handle_silent_launch_toggle(app_handle), + Some(actions::TrayMenuAction::CloseToTray) => handle_close_to_tray_toggle(app_handle), Some(actions::TrayMenuAction::Quit) => { lifecycle::events::handle_tray_quit(app_handle); } diff --git a/src-tauri/src/tray/setup.rs b/src-tauri/src/tray/setup.rs index bf40108..6a36537 100644 --- a/src-tauri/src/tray/setup.rs +++ b/src-tauri/src/tray/setup.rs @@ -1,8 +1,9 @@ use tauri::{ - menu::{Menu, MenuItem, PredefinedMenuItem}, + menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, AppHandle, Manager, }; +use tauri_plugin_autostart::ManagerExt; use crate::{ append_desktop_log, runtime_paths, shell_locale, @@ -16,6 +17,11 @@ pub fn setup_tray(app_handle: &AppHandle) -> Result<(), String> { runtime_paths::default_packaged_root_dir(), ); let shell_texts = shell_locale::shell_texts_for_locale(locale); + let desktop_settings = app_handle.state::().get(); + let launch_at_login_checked = app_handle + .autolaunch() + .is_enabled() + .unwrap_or(desktop_settings.launch_at_login); let main_window_visible = app_handle .get_webview_window("main") .and_then(|window| window.is_visible().ok()) @@ -50,6 +56,33 @@ pub fn setup_tray(app_handle: &AppHandle) -> Result<(), String> { None::<&str>, ) .map_err(|error| format!("Failed to create tray restart menu item: {error}"))?; + let launch_at_login_item = CheckMenuItem::with_id( + app_handle, + actions::TRAY_MENU_LAUNCH_AT_LOGIN, + shell_texts.tray_launch_at_login, + true, + launch_at_login_checked, + None::<&str>, + ) + .map_err(|error| format!("Failed to create tray launch at login menu item: {error}"))?; + let silent_launch_item = CheckMenuItem::with_id( + app_handle, + actions::TRAY_MENU_SILENT_LAUNCH, + shell_texts.tray_silent_launch, + true, + desktop_settings.silent_launch, + None::<&str>, + ) + .map_err(|error| format!("Failed to create tray silent launch menu item: {error}"))?; + let close_to_tray_item = CheckMenuItem::with_id( + app_handle, + actions::TRAY_MENU_CLOSE_TO_TRAY, + shell_texts.tray_close_to_tray, + true, + desktop_settings.close_to_tray, + None::<&str>, + ) + .map_err(|error| format!("Failed to create tray close to tray menu item: {error}"))?; let quit_item = MenuItem::with_id( app_handle, actions::TRAY_MENU_QUIT, @@ -60,6 +93,8 @@ pub fn setup_tray(app_handle: &AppHandle) -> Result<(), String> { .map_err(|error| format!("Failed to create tray quit menu item: {error}"))?; let separator = PredefinedMenuItem::separator(app_handle) .map_err(|error| format!("Failed to create tray separator menu item: {error}"))?; + let settings_separator = PredefinedMenuItem::separator(app_handle) + .map_err(|error| format!("Failed to create tray settings separator menu item: {error}"))?; let menu = Menu::with_items( app_handle, @@ -67,6 +102,10 @@ pub fn setup_tray(app_handle: &AppHandle) -> Result<(), String> { &toggle_item, &reload_item, &restart_backend_item, + &settings_separator, + &launch_at_login_item, + &silent_launch_item, + &close_to_tray_item, &separator, &quit_item, ], @@ -77,6 +116,9 @@ pub fn setup_tray(app_handle: &AppHandle) -> Result<(), String> { toggle_item: toggle_item.clone(), reload_item: reload_item.clone(), restart_backend_item: restart_backend_item.clone(), + launch_at_login_item: launch_at_login_item.clone(), + silent_launch_item: silent_launch_item.clone(), + close_to_tray_item: close_to_tray_item.clone(), quit_item: quit_item.clone(), }) { append_desktop_log("tray menu state already exists, skipping manage");