ab59476d10
- 优化桌面标签、上下文标题、前进后退、导航栏隐藏和原生菜单体验 - 补充登录会话 IP 采集、地理位置回退、管理端展示及数据库迁移 - 更新桌面发布检查、运维文档和前后端测试覆盖
415 lines
14 KiB
Rust
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());
|
|
}
|
|
}
|