mod credentials; mod updates; use std::collections::HashSet; use serde::Deserialize; use tauri::menu::{ AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, WINDOW_SUBMENU_ID, }; #[cfg(target_os = "macos")] use tauri::RunEvent; use tauri::{Emitter, Manager, Runtime, Theme, WebviewWindow, WebviewWindowBuilder}; const DESKTOP_MENU_COMMAND_EVENT: &str = "ctms:desktop-menu-command"; const VIEW_MENU_ID: &str = "ctms.menu.view"; const NAVIGATION_MENU_ID: &str = "ctms.menu.navigation"; const REFRESH_COMMAND_ID: &str = "ctms.desktop.refresh"; const TOGGLE_SIDEBAR_COMMAND_ID: &str = "ctms.desktop.toggleSidebar"; const BACK_COMMAND_ID: &str = "ctms.desktop.back"; const FORWARD_COMMAND_ID: &str = "ctms.desktop.forward"; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct DesktopMenuShortcuts { back: String, forward: String, refresh: String, } #[derive(Deserialize)] #[serde(rename_all = "lowercase")] enum DesktopThemePreference { System, Light, Dark, } fn desktop_menu(handle: &tauri::AppHandle) -> tauri::Result> { let package = handle.package_info(); let config = handle.config(); let about = AboutMetadata { name: Some(package.name.clone()), version: Some(package.version.to_string()), // macOS falls back to CFBundleVersion when this value is omitted, which // renders duplicated text such as `Version 0.1.0 (0.1.0)`. #[cfg(target_os = "macos")] short_version: Some(String::new()), copyright: config.bundle.copyright.clone(), authors: config .bundle .publisher .clone() .map(|publisher| vec![publisher]), // Supplying the icon explicitly keeps the native About panel from // falling back to the generic macOS application/folder icon. icon: handle.default_window_icon().cloned(), ..Default::default() }; Menu::with_items( handle, &[ #[cfg(target_os = "macos")] &Submenu::with_items( handle, package.name.clone(), true, &[ &PredefinedMenuItem::about(handle, Some("关于 CTMS"), Some(about.clone()))?, &PredefinedMenuItem::separator(handle)?, &MenuItem::with_id( handle, "ctms.desktop.preferences", "设置…", true, Some("CmdOrCtrl+,"), )?, &PredefinedMenuItem::separator(handle)?, &PredefinedMenuItem::services(handle, None)?, &PredefinedMenuItem::separator(handle)?, &PredefinedMenuItem::hide(handle, None)?, &PredefinedMenuItem::hide_others(handle, None)?, &PredefinedMenuItem::show_all(handle, None)?, &PredefinedMenuItem::separator(handle)?, &PredefinedMenuItem::quit(handle, None)?, ], )?, &Submenu::with_items( handle, "文件", true, &[ &MenuItem::with_id( handle, "ctms.desktop.commandPalette", "打开命令面板", true, Some("CmdOrCtrl+K"), )?, #[cfg(not(target_os = "macos"))] &MenuItem::with_id( handle, "ctms.desktop.preferences", "设置", true, Some("CmdOrCtrl+,"), )?, &PredefinedMenuItem::separator(handle)?, &PredefinedMenuItem::close_window(handle, None)?, #[cfg(not(target_os = "macos"))] &PredefinedMenuItem::quit(handle, None)?, ], )?, &Submenu::with_items( handle, "编辑", true, &[ &PredefinedMenuItem::undo(handle, None)?, &PredefinedMenuItem::redo(handle, None)?, &PredefinedMenuItem::separator(handle)?, &PredefinedMenuItem::cut(handle, None)?, &PredefinedMenuItem::copy(handle, None)?, &PredefinedMenuItem::paste(handle, None)?, &PredefinedMenuItem::select_all(handle, None)?, ], )?, &Submenu::with_id_and_items( handle, VIEW_MENU_ID, "显示", true, &[ &MenuItem::with_id( handle, REFRESH_COMMAND_ID, "刷新当前视图", true, Some("CmdOrCtrl+R"), )?, &MenuItem::with_id( handle, TOGGLE_SIDEBAR_COMMAND_ID, "显示/隐藏导航栏", true, None::<&str>, )?, &PredefinedMenuItem::separator(handle)?, &PredefinedMenuItem::fullscreen(handle, None)?, ], )?, &Submenu::with_id_and_items( handle, NAVIGATION_MENU_ID, "导航", true, &[ &MenuItem::with_id(handle, BACK_COMMAND_ID, "后退", true, Some("CmdOrCtrl+["))?, &MenuItem::with_id( handle, FORWARD_COMMAND_ID, "前进", true, Some("CmdOrCtrl+]"), )?, ], )?, &Submenu::with_id_and_items( handle, WINDOW_SUBMENU_ID, "窗口", true, &[ &PredefinedMenuItem::minimize(handle, None)?, &PredefinedMenuItem::maximize(handle, None)?, &PredefinedMenuItem::close_window(handle, None)?, ], )?, &Submenu::with_id_and_items( handle, HELP_SUBMENU_ID, "帮助", true, &[ #[cfg(not(target_os = "macos"))] &PredefinedMenuItem::about(handle, Some("关于 CTMS"), Some(about))?, &MenuItem::with_id( handle, "ctms.desktop.help", "CTMS 命令与导航", true, None::<&str>, )?, ], )?, ], ) } fn emit_menu_command(app: &tauri::AppHandle, command: &str) { if let Some(window) = app.get_webview_window("main") { let _ = window.emit(DESKTOP_MENU_COMMAND_EVENT, command); } } fn restore_main_window(app: &tauri::AppHandle) -> Result<(), String> { if let Some(window) = app.get_webview_window("main") { let _ = window.unminimize(); let _ = window.show(); let _ = window.set_focus(); return Ok(()); } let config = app .config() .app .windows .iter() .find(|config| config.label == "main") .cloned() .ok_or_else(|| "未找到主窗口配置".to_string())?; let window = WebviewWindowBuilder::from_config(app, &config) .map_err(|error| format!("读取主窗口配置失败:{error}"))? .build() .map_err(|error| format!("重建主窗口失败:{error}"))?; let _ = window.show(); let _ = window.set_focus(); Ok(()) } fn is_allowed_shortcut_key(value: &str) -> bool { matches!( value, "[" | "]" | "," | "." | "/" | ";" | "=" | "-" | "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" | "Home" | "End" | "PageUp" | "PageDown" ) || (value.len() == 1 && value .as_bytes() .first() .is_some_and(|value| value.is_ascii_uppercase() || value.is_ascii_digit())) } fn normalize_menu_shortcut(value: &str) -> Result { const RESERVED: &[&str] = &[ "Mod+K", "Mod+,", "Mod+W", "Mod+Q", "Mod+C", "Mod+V", "Mod+X", "Mod+A", "Mod+Z", "Mod+Shift+Z", ]; if RESERVED.contains(&value) { return Err("快捷键与系统或应用保留快捷键冲突".to_string()); } let parts = value.split('+').collect::>(); if parts.len() < 2 || parts[0] != "Mod" { return Err("快捷键必须以 Mod 开头".to_string()); } let key = parts.last().copied().unwrap_or_default(); if !is_allowed_shortcut_key(key) { return Err("快捷键按键不受支持".to_string()); } let mut modifiers = HashSet::new(); for modifier in &parts[1..parts.len() - 1] { if !matches!(*modifier, "Shift" | "Alt") || !modifiers.insert(*modifier) { return Err("快捷键修饰键不受支持或重复".to_string()); } } Ok(format!("CmdOrCtrl+{}", parts[1..].join("+"))) } fn set_menu_accelerator( app: &tauri::AppHandle, submenu_id: &str, item_id: &str, accelerator: &str, ) -> Result<(), String> { let menu = app.menu().ok_or_else(|| "桌面菜单尚未初始化".to_string())?; let submenu = menu .get(submenu_id) .and_then(|item| item.as_submenu().cloned()) .ok_or_else(|| format!("未找到桌面菜单:{submenu_id}"))?; let item = submenu .get(item_id) .and_then(|item| item.as_menuitem().cloned()) .ok_or_else(|| format!("未找到桌面菜单项:{item_id}"))?; item.set_accelerator(Some(accelerator)) .map_err(|error| format!("更新桌面菜单快捷键失败:{error}")) } #[tauri::command] fn desktop_menu_set_shortcuts( app: tauri::AppHandle, shortcuts: DesktopMenuShortcuts, ) -> Result<(), String> { let back = normalize_menu_shortcut(&shortcuts.back)?; let forward = normalize_menu_shortcut(&shortcuts.forward)?; let refresh = normalize_menu_shortcut(&shortcuts.refresh)?; if HashSet::from([back.as_str(), forward.as_str(), refresh.as_str()]).len() != 3 { return Err("桌面快捷键不能重复".to_string()); } set_menu_accelerator(&app, NAVIGATION_MENU_ID, BACK_COMMAND_ID, &back)?; set_menu_accelerator(&app, NAVIGATION_MENU_ID, FORWARD_COMMAND_ID, &forward)?; set_menu_accelerator(&app, VIEW_MENU_ID, REFRESH_COMMAND_ID, &refresh)?; Ok(()) } #[tauri::command] fn desktop_window_set_theme( window: WebviewWindow, theme: DesktopThemePreference, ) -> Result<(), String> { let theme = match theme { DesktopThemePreference::System => None, DesktopThemePreference::Light => Some(Theme::Light), DesktopThemePreference::Dark => Some(Theme::Dark), }; window .set_theme(theme) .map_err(|error| format!("更新桌面窗口主题失败:{error}")) } pub fn run() { let app = tauri::Builder::default() .menu(desktop_menu) .on_menu_event(|app, event| { let command = event.id().as_ref(); if command.starts_with("ctms.desktop.") { emit_menu_command(app, command); } }) .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { let _ = restore_main_window(app); })) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .manage(updates::PendingUpdate::default()) .invoke_handler(tauri::generate_handler![ credentials::credential_get, credentials::credential_set, credentials::credential_delete, credentials::login_credential_get, credentials::login_credential_set, credentials::login_credential_delete, updates::desktop_update_check, updates::desktop_update_install, desktop_menu_set_shortcuts, desktop_window_set_theme, ]) .build(tauri::generate_context!()) .expect("error while building CTMS desktop application"); app.run(|app, event| { #[cfg(target_os = "macos")] if let RunEvent::Reopen { .. } = event { if let Err(error) = restore_main_window(app) { eprintln!("{error}"); } } #[cfg(not(target_os = "macos"))] let _ = (app, event); }); } #[cfg(test)] mod tests { use super::normalize_menu_shortcut; #[test] fn converts_runtime_shortcuts_to_native_accelerators() { assert_eq!( normalize_menu_shortcut("Mod+Shift+[").unwrap(), "CmdOrCtrl+Shift+[" ); assert_eq!( normalize_menu_shortcut("Mod+Alt+ArrowLeft").unwrap(), "CmdOrCtrl+Alt+ArrowLeft" ); } #[test] fn rejects_reserved_or_malformed_shortcuts() { assert!(normalize_menu_shortcut("Mod+Q").is_err()); assert!(normalize_menu_shortcut("Ctrl+R").is_err()); assert!(normalize_menu_shortcut("Mod+Shift+Shift+R").is_err()); assert!(normalize_menu_shortcut("Mod+F12").is_err()); } }