release(main): 同步 dev 最新候选改动
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
@@ -9,6 +9,7 @@
|
||||
"dialog:allow-save",
|
||||
"fs:allow-read-file",
|
||||
"fs:allow-write-file",
|
||||
"fs:allow-mkdir",
|
||||
"fs:allow-remove",
|
||||
{
|
||||
"identifier": "fs:scope",
|
||||
@@ -16,7 +17,9 @@
|
||||
{ "path": "$TEMP/ctms-desktop/**" }
|
||||
]
|
||||
},
|
||||
"notification:default",
|
||||
"notification:allow-is-permission-granted",
|
||||
"notification:allow-request-permission",
|
||||
"notification:allow-notify",
|
||||
{
|
||||
"identifier": "opener:allow-open-path",
|
||||
"allow": [
|
||||
|
||||
|
Before Width: | Height: | Size: 293 B After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 659 B After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 99 B After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 154 B After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 233 B After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 322 B After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 341 B After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 756 B After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 96 B After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 846 B After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 114 B After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 166 B After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 194 B After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 123 B After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 374 KiB |
@@ -1,7 +1,8 @@
|
||||
use sha2::{Digest, Sha256};
|
||||
use url::Url;
|
||||
|
||||
const CREDENTIAL_SERVICE: &str = "cn.huapont.ctms.desktop.session";
|
||||
const SESSION_CREDENTIAL_SERVICE: &str = "cn.huapont.ctms.desktop.session";
|
||||
const LOGIN_CREDENTIAL_SERVICE: &str = "cn.huapont.ctms.desktop.login";
|
||||
|
||||
fn credential_account(server_origin: &str) -> Result<String, String> {
|
||||
let parsed = Url::parse(server_origin).map_err(|_| "服务器地址格式不正确".to_string())?;
|
||||
@@ -23,9 +24,9 @@ fn credential_account(server_origin: &str) -> Result<String, String> {
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
fn get_entry(server_origin: &str) -> Result<keyring::Entry, String> {
|
||||
fn get_entry(service: &str, server_origin: &str) -> Result<keyring::Entry, String> {
|
||||
let account = credential_account(server_origin)?;
|
||||
keyring::Entry::new(CREDENTIAL_SERVICE, &account)
|
||||
keyring::Entry::new(service, &account)
|
||||
.map_err(|error| format!("无法访问系统凭据库:{error}"))
|
||||
}
|
||||
|
||||
@@ -34,7 +35,7 @@ pub async fn credential_get(server_origin: String) -> Result<Option<String>, Str
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
{
|
||||
let entry = get_entry(&server_origin)?;
|
||||
let entry = get_entry(SESSION_CREDENTIAL_SERVICE, &server_origin)?;
|
||||
return match entry.get_password() {
|
||||
Ok(token) => Ok(Some(token)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
@@ -59,7 +60,7 @@ pub async fn credential_set(server_origin: String, token: String) -> Result<(),
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
{
|
||||
return get_entry(&server_origin)?
|
||||
return get_entry(SESSION_CREDENTIAL_SERVICE, &server_origin)?
|
||||
.set_password(&token)
|
||||
.map_err(|error| format!("保存系统凭据失败:{error}"));
|
||||
}
|
||||
@@ -78,7 +79,7 @@ pub async fn credential_delete(server_origin: String) -> Result<(), String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
{
|
||||
let entry = get_entry(&server_origin)?;
|
||||
let entry = get_entry(SESSION_CREDENTIAL_SERVICE, &server_origin)?;
|
||||
return match entry.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(error) => Err(format!("删除系统凭据失败:{error}")),
|
||||
@@ -94,9 +95,74 @@ pub async fn credential_delete(server_origin: String) -> Result<(), String> {
|
||||
.map_err(|error| format!("删除系统凭据任务失败:{error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn login_credential_get(server_origin: String) -> Result<Option<String>, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
{
|
||||
let entry = get_entry(LOGIN_CREDENTIAL_SERVICE, &server_origin)?;
|
||||
return match entry.get_password() {
|
||||
Ok(credential) => Ok(Some(credential)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(error) => Err(format!("读取登录凭据失败:{error}")),
|
||||
};
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
{
|
||||
let _ = credential_account(&server_origin)?;
|
||||
Err("当前平台不支持系统凭据存储".to_string())
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("读取登录凭据任务失败:{error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn login_credential_set(server_origin: String, credential: String) -> Result<(), String> {
|
||||
if credential.trim().is_empty() {
|
||||
return Err("拒绝保存空登录凭据".to_string());
|
||||
}
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
{
|
||||
return get_entry(LOGIN_CREDENTIAL_SERVICE, &server_origin)?
|
||||
.set_password(&credential)
|
||||
.map_err(|error| format!("保存登录凭据失败:{error}"));
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
{
|
||||
let _ = credential_account(&server_origin)?;
|
||||
Err("当前平台不支持系统凭据存储".to_string())
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("保存登录凭据任务失败:{error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn login_credential_delete(server_origin: String) -> Result<(), String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
{
|
||||
let entry = get_entry(LOGIN_CREDENTIAL_SERVICE, &server_origin)?;
|
||||
return match entry.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(error) => Err(format!("删除登录凭据失败:{error}")),
|
||||
};
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
{
|
||||
let _ = credential_account(&server_origin)?;
|
||||
Err("当前平台不支持系统凭据存储".to_string())
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("删除登录凭据任务失败:{error}"))?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::credential_account;
|
||||
use super::{credential_account, LOGIN_CREDENTIAL_SERVICE, SESSION_CREDENTIAL_SERVICE};
|
||||
|
||||
#[test]
|
||||
fn account_is_stable_for_same_origin() {
|
||||
@@ -111,4 +177,22 @@ mod tests {
|
||||
assert!(credential_account("http://ctms.example.com").is_err());
|
||||
assert!(credential_account("http://localhost:8000").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_origins_with_embedded_credentials() {
|
||||
assert!(credential_account("https://user:secret@ctms.example.com").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_does_not_expose_server_origin() {
|
||||
let account = credential_account("https://ctms.example.com/path").unwrap();
|
||||
|
||||
assert!(!account.contains("ctms.example.com"));
|
||||
assert!(!account.contains("https"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_credentials_use_a_separate_keyring_service() {
|
||||
assert_ne!(SESSION_CREDENTIAL_SERVICE, LOGIN_CREDENTIAL_SERVICE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,91 @@
|
||||
mod credentials;
|
||||
mod updates;
|
||||
|
||||
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu};
|
||||
use tauri::{Emitter, Manager, Runtime};
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde::Deserialize;
|
||||
use tauri::menu::{
|
||||
AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, WINDOW_SUBMENU_ID,
|
||||
};
|
||||
use tauri::{
|
||||
Emitter, Manager, RunEvent, Runtime, Theme, WebviewWindow, WebviewWindowBuilder, WindowEvent,
|
||||
};
|
||||
|
||||
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";
|
||||
const DESKTOP_FULLSCREEN_CHANGED_EVENT: &str = "ctms:desktop-fullscreen-changed";
|
||||
|
||||
#[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,
|
||||
"文件",
|
||||
@@ -22,15 +98,17 @@ fn desktop_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<
|
||||
true,
|
||||
Some("CmdOrCtrl+K"),
|
||||
)?,
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
&MenuItem::with_id(
|
||||
handle,
|
||||
"ctms.desktop.serverSettings",
|
||||
"服务器设置",
|
||||
"ctms.desktop.preferences",
|
||||
"设置",
|
||||
true,
|
||||
None::<&str>,
|
||||
Some("CmdOrCtrl+,"),
|
||||
)?,
|
||||
&PredefinedMenuItem::separator(handle)?,
|
||||
&PredefinedMenuItem::close_window(handle, None)?,
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
&PredefinedMenuItem::quit(handle, None)?,
|
||||
],
|
||||
)?,
|
||||
@@ -48,44 +126,49 @@ fn desktop_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<
|
||||
&PredefinedMenuItem::select_all(handle, None)?,
|
||||
],
|
||||
)?,
|
||||
&Submenu::with_items(
|
||||
&Submenu::with_id_and_items(
|
||||
handle,
|
||||
"视图",
|
||||
VIEW_MENU_ID,
|
||||
"显示",
|
||||
true,
|
||||
&[
|
||||
&MenuItem::with_id(
|
||||
handle,
|
||||
"ctms.desktop.refresh",
|
||||
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_items(
|
||||
&Submenu::with_id_and_items(
|
||||
handle,
|
||||
NAVIGATION_MENU_ID,
|
||||
"导航",
|
||||
true,
|
||||
&[
|
||||
&MenuItem::with_id(handle, BACK_COMMAND_ID, "后退", true, Some("CmdOrCtrl+["))?,
|
||||
&MenuItem::with_id(
|
||||
handle,
|
||||
"ctms.desktop.back",
|
||||
"返回",
|
||||
true,
|
||||
Some("CmdOrCtrl+["),
|
||||
)?,
|
||||
&MenuItem::with_id(
|
||||
handle,
|
||||
"ctms.desktop.forward",
|
||||
FORWARD_COMMAND_ID,
|
||||
"前进",
|
||||
true,
|
||||
Some("CmdOrCtrl+]"),
|
||||
)?,
|
||||
],
|
||||
)?,
|
||||
&Submenu::with_items(
|
||||
&Submenu::with_id_and_items(
|
||||
handle,
|
||||
WINDOW_SUBMENU_ID,
|
||||
"窗口",
|
||||
true,
|
||||
&[
|
||||
@@ -94,17 +177,20 @@ fn desktop_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<
|
||||
&PredefinedMenuItem::close_window(handle, None)?,
|
||||
],
|
||||
)?,
|
||||
&Submenu::with_items(
|
||||
&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.preferences",
|
||||
"桌面偏好",
|
||||
"ctms.desktop.help",
|
||||
"CTMS 命令与导航",
|
||||
true,
|
||||
Some("CmdOrCtrl+,"),
|
||||
None::<&str>,
|
||||
)?,
|
||||
],
|
||||
)?,
|
||||
@@ -118,8 +204,167 @@ fn emit_menu_command<R: Runtime>(app: &tauri::AppHandle<R>, command: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
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}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn desktop_window_get_fullscreen<R: Runtime>(window: WebviewWindow<R>) -> Result<bool, String> {
|
||||
window
|
||||
.is_fullscreen()
|
||||
.map_err(|error| format!("读取桌面窗口全屏状态失败:{error}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn desktop_window_set_fullscreen<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
fullscreen: bool,
|
||||
) -> Result<bool, String> {
|
||||
window
|
||||
.set_fullscreen(fullscreen)
|
||||
.map_err(|error| format!("切换桌面窗口全屏状态失败:{error}"))?;
|
||||
let _ = window.emit(DESKTOP_FULLSCREEN_CHANGED_EVENT, fullscreen);
|
||||
Ok(fullscreen)
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
let app = tauri::Builder::default()
|
||||
.menu(desktop_menu)
|
||||
.on_menu_event(|app, event| {
|
||||
let command = event.id().as_ref();
|
||||
@@ -128,11 +373,7 @@ pub fn run() {
|
||||
}
|
||||
})
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
let _ = restore_main_window(app);
|
||||
}))
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
@@ -144,9 +385,64 @@ pub fn run() {
|
||||
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,
|
||||
desktop_window_get_fullscreen,
|
||||
desktop_window_set_fullscreen,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running CTMS desktop application");
|
||||
.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}");
|
||||
}
|
||||
}
|
||||
if let RunEvent::WindowEvent {
|
||||
label,
|
||||
event: WindowEvent::Resized(_),
|
||||
..
|
||||
} = &event
|
||||
{
|
||||
if label == "main" {
|
||||
if let Some(window) = app.get_webview_window(label) {
|
||||
if let Ok(fullscreen) = window.is_fullscreen() {
|
||||
let _ = window.emit(DESKTOP_FULLSCREEN_CHANGED_EVENT, fullscreen);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,17 +17,30 @@
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"minWidth": 1180,
|
||||
"minHeight": 760
|
||||
"minHeight": 760,
|
||||
"decorations": true,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
"backgroundColor": "#f9fafb",
|
||||
"backgroundThrottling": "disabled",
|
||||
"trafficLightPosition": { "x": 16, "y": 18 }
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self' customprotocol: asset:; connect-src 'self' ipc: http://ipc.localhost https: http://localhost:* http://127.0.0.1:*; img-src 'self' asset: blob: data: https: http://localhost:* http://127.0.0.1:*; style-src 'self' 'unsafe-inline'; font-src 'self' data:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'"
|
||||
"csp": "default-src 'self' customprotocol: asset:; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost https: http://localhost:* http://127.0.0.1:*; img-src 'self' asset: blob: data: https: http://localhost:* http://127.0.0.1:*; style-src 'self' 'unsafe-inline'; font-src 'self' data:; frame-src 'self' blob: https: http://localhost:* http://127.0.0.1:*; object-src 'none'; base-uri 'self'; form-action 'self'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["app", "dmg"],
|
||||
"createUpdaterArtifacts": true
|
||||
"createUpdaterArtifacts": true,
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
|
||||