Files
ctms/frontend/src-tauri/src/lib.rs
T
Cheng Zhou ab59476d10
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
feat(桌面与监控): 完善工作台导航和登录活动定位
- 优化桌面标签、上下文标题、前进后退、导航栏隐藏和原生菜单体验

- 补充登录会话 IP 采集、地理位置回退、管理端展示及数据库迁移

- 更新桌面发布检查、运维文档和前后端测试覆盖
2026-07-13 16:03:20 +08:00

415 lines
14 KiB
Rust

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<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>> {
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<R: Runtime>(app: &tauri::AppHandle<R>, command: &str) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.emit(DESKTOP_MENU_COMMAND_EVENT, command);
}
}
fn restore_main_window<R: Runtime>(app: &tauri::AppHandle<R>) -> 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<String, String> {
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::<Vec<_>>();
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<R: Runtime>(
app: &tauri::AppHandle<R>,
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<R: Runtime>(
app: tauri::AppHandle<R>,
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<R: Runtime>(
window: WebviewWindow<R>,
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());
}
}