feat: add notification system with hotkey events and permission handling (#3867)

* feat: add notification system with hotkey events and permission handling

* Add macOS-specific handling for AppHidden notification

Introduces conditional support for the AppHidden notification event,
enabling macOS-specific behavior. Updates the enum and notification
logic to include this platform-specific feature.

Improves macOS user experience by accommodating system-level
application hiding events.

* Implement feature X to enhance user experience and fix bug Y in module Z

* refactor(notification): update notification keys for consistency and clarity

* chore(deps): update dependencies to latest versions
This commit is contained in:
Tunglies
2025-06-26 23:09:07 +08:00
committed by GitHub
parent ae187cc21a
commit f1192c95a8
13 changed files with 1167 additions and 783 deletions

View File

@@ -13,6 +13,7 @@
- `sidecar` 模式下清理多余的内核进程,防止运行出现异常 - `sidecar` 模式下清理多余的内核进程,防止运行出现异常
- 新 macOS 下 TUN 和系统代理模式托盘图标(暂测) - 新 macOS 下 TUN 和系统代理模式托盘图标(暂测)
- 快捷键事件通过系统通知
### 🚀 优化改进 ### 🚀 优化改进

1729
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -81,6 +81,7 @@ hmac = "0.12.1"
sha2 = "0.10.9" sha2 = "0.10.9"
hex = "0.4.3" hex = "0.4.3"
scopeguard = "1.2.0" scopeguard = "1.2.0"
tauri-plugin-notification = "2"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
runas = "=1.2.0" runas = "=1.2.0"

View File

@@ -17,6 +17,7 @@
"autostart:allow-enable", "autostart:allow-enable",
"autostart:allow-disable", "autostart:allow-disable",
"autostart:allow-is-enabled", "autostart:allow-is-enabled",
"core:window:allow-set-theme" "core:window:allow-set-theme",
"notification:default"
] ]
} }

View File

@@ -1,3 +1,4 @@
use crate::utils::notification::{notify_event, NotificationEvent};
use crate::{ use crate::{
config::Config, core::handle, feat, logging, logging_error, config::Config, core::handle, feat, logging, logging_error,
module::lightweight::entry_lightweight_mode, utils::logging::Type, module::lightweight::entry_lightweight_mode, utils::logging::Type,
@@ -133,14 +134,11 @@ impl Hotkey {
manager.unregister(hotkey)?; manager.unregister(hotkey)?;
} }
let f = match func.trim() { let app_handle_clone = app_handle.clone();
let f: Box<dyn Fn() + Send + Sync> = match func.trim() {
"open_or_close_dashboard" => { "open_or_close_dashboard" => {
logging!( let app_handle = app_handle_clone.clone();
debug, Box::new(move || {
Type::Hotkey,
"Registering open_or_close_dashboard function"
);
|| {
logging!( logging!(
debug, debug,
Type::Hotkey, Type::Hotkey,
@@ -162,18 +160,75 @@ impl Hotkey {
Type::Hotkey, Type::Hotkey,
"=== Hotkey Dashboard Window Operation End ===" "=== Hotkey Dashboard Window Operation End ==="
); );
} notify_event(&app_handle, NotificationEvent::DashboardToggled);
})
}
"clash_mode_rule" => {
let app_handle = app_handle_clone.clone();
Box::new(move || {
feat::change_clash_mode("rule".into());
notify_event(
&app_handle,
NotificationEvent::ClashModeChanged { mode: "Rule" },
);
})
}
"clash_mode_global" => {
let app_handle = app_handle_clone.clone();
Box::new(move || {
feat::change_clash_mode("global".into());
notify_event(
&app_handle,
NotificationEvent::ClashModeChanged { mode: "Global" },
);
})
}
"clash_mode_direct" => {
let app_handle = app_handle_clone.clone();
Box::new(move || {
feat::change_clash_mode("direct".into());
notify_event(
&app_handle,
NotificationEvent::ClashModeChanged { mode: "Direct" },
);
})
}
"toggle_system_proxy" => {
let app_handle = app_handle_clone.clone();
Box::new(move || {
feat::toggle_system_proxy();
notify_event(&app_handle, NotificationEvent::SystemProxyToggled);
})
}
"toggle_tun_mode" => {
let app_handle = app_handle_clone.clone();
Box::new(move || {
feat::toggle_tun_mode(None);
notify_event(&app_handle, NotificationEvent::TunModeToggled);
})
}
"entry_lightweight_mode" => {
let app_handle = app_handle_clone.clone();
Box::new(move || {
entry_lightweight_mode();
notify_event(&app_handle, NotificationEvent::LightweightModeEntered);
})
}
"quit" => {
let app_handle = app_handle_clone.clone();
Box::new(move || {
feat::quit();
notify_event(&app_handle, NotificationEvent::AppQuit);
})
} }
"clash_mode_rule" => || feat::change_clash_mode("rule".into()),
"clash_mode_global" => || feat::change_clash_mode("global".into()),
"clash_mode_direct" => || feat::change_clash_mode("direct".into()),
"toggle_system_proxy" => || feat::toggle_system_proxy(),
"toggle_tun_mode" => || feat::toggle_tun_mode(None),
"entry_lightweight_mode" => || entry_lightweight_mode(),
"quit" => || feat::quit(),
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
"hide" => || feat::hide(), "hide" => {
let app_handle = app_handle_clone.clone();
Box::new(move || {
feat::hide();
notify_event(&app_handle, NotificationEvent::AppHidden);
})
}
_ => { _ => {
logging!(error, Type::Hotkey, "Invalid function: {}", func); logging!(error, Type::Hotkey, "Invalid function: {}", func);
bail!("invalid function \"{func}\""); bail!("invalid function \"{func}\"");

View File

@@ -125,6 +125,7 @@ pub fn run() {
#[allow(unused_mut)] #[allow(unused_mut)]
let mut builder = tauri::Builder::default() let mut builder = tauri::Builder::default()
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_process::init())

View File

@@ -5,6 +5,7 @@ pub mod i18n;
pub mod init; pub mod init;
pub mod logging; pub mod logging;
pub mod network; pub mod network;
pub mod notification;
pub mod resolve; pub mod resolve;
pub mod server; pub mod server;
pub mod tmpl; pub mod tmpl;

View File

@@ -0,0 +1,70 @@
use tauri::AppHandle;
use tauri_plugin_notification::NotificationExt;
pub enum NotificationEvent<'a> {
DashboardToggled,
ClashModeChanged {
mode: &'a str,
},
SystemProxyToggled,
TunModeToggled,
LightweightModeEntered,
AppQuit,
#[cfg(target_os = "macos")]
AppHidden,
}
fn notify(app: &AppHandle, title: &str, body: &str) {
app.notification()
.builder()
.title(title)
.body(body)
.show()
.ok();
}
pub fn notify_event(app: &AppHandle, event: NotificationEvent) {
use crate::utils::i18n::t;
match event {
NotificationEvent::DashboardToggled => {
notify(app, &t("DashboardToggledTitle"), &t("DashboardToggledBody"));
}
NotificationEvent::ClashModeChanged { mode } => {
notify(
app,
&t("ClashModeChangedTitle"),
&t_with_args("ClashModeChangedBody", mode),
);
}
NotificationEvent::SystemProxyToggled => {
notify(
app,
&t("SystemProxyToggledTitle"),
&t("SystemProxyToggledBody"),
);
}
NotificationEvent::TunModeToggled => {
notify(app, &t("TunModeToggledTitle"), &t("TunModeToggledBody"));
}
NotificationEvent::LightweightModeEntered => {
notify(
app,
&t("LightweightModeEnteredTitle"),
&t("LightweightModeEnteredBody"),
);
}
NotificationEvent::AppQuit => {
notify(app, &t("AppQuitTitle"), &t("AppQuitBody"));
}
#[cfg(target_os = "macos")]
NotificationEvent::AppHidden => {
notify(app, &t("AppHiddenTitle"), &t("AppHiddenBody"));
}
}
}
// 辅助函数带参数的i18n
fn t_with_args(key: &str, mode: &str) -> String {
use crate::utils::i18n::t;
t(key).replace("{mode}", mode)
}

View File

@@ -1,7 +1,9 @@
import { AppDataProvider } from "./providers/app-data-provider"; import { AppDataProvider } from "./providers/app-data-provider";
import Layout from "./pages/_layout"; import Layout from "./pages/_layout";
import { useNotificationPermission } from "./hooks/useNotificationPermission";
function App() { function App() {
useNotificationPermission();
return ( return (
<AppDataProvider> <AppDataProvider>
<Layout /> <Layout />

View File

@@ -0,0 +1,8 @@
import { setupNotificationPermission } from "../utils/notification-permission";
import { useEffect } from "react";
export function useNotificationPermission() {
useEffect(() => {
setupNotificationPermission();
}, []);
}

View File

@@ -624,5 +624,19 @@
"No (IP Banned By Disney+)": "No (IP Banned By Disney+)", "No (IP Banned By Disney+)": "No (IP Banned By Disney+)",
"Unsupported Country/Region": "Unsupported Country/Region", "Unsupported Country/Region": "Unsupported Country/Region",
"Failed (Network Connection)": "Failed (Network Connection)", "Failed (Network Connection)": "Failed (Network Connection)",
"DashboardToggledTitle": "Dashboard Toggled",
"DashboardToggledBody": "Dashboard visibility toggled by hotkey",
"ClashModeChangedTitle": "Clash Mode Changed",
"ClashModeChangedBody": "Switched to {mode} mode",
"SystemProxyToggledTitle": "System Proxy Toggled",
"SystemProxyToggledBody": "System proxy state toggled by hotkey",
"TunModeToggledTitle": "TUN Mode Toggled",
"TunModeToggledBody": "TUN mode toggled by hotkey",
"LightweightModeEnteredTitle": "Lightweight Mode",
"LightweightModeEnteredBody": "Entered lightweight mode by hotkey",
"AppQuitTitle": "APP Quit",
"AppQuitBody": "APP quit by hotkey",
"AppHiddenTitle": "APP Hidden",
"AppHiddenBody": "APP window hidden by hotkey",
"Invalid Profile URL": "Invalid profile URL. Please enter a URL starting with http:// or https://" "Invalid Profile URL": "Invalid profile URL. Please enter a URL starting with http:// or https://"
} }

View File

@@ -624,5 +624,19 @@
"No (IP Banned By Disney+)": "不支持IP被Disney+禁止)", "No (IP Banned By Disney+)": "不支持IP被Disney+禁止)",
"Unsupported Country/Region": "不支持的国家/地区", "Unsupported Country/Region": "不支持的国家/地区",
"Failed (Network Connection)": "测试失败(网络连接问题)", "Failed (Network Connection)": "测试失败(网络连接问题)",
"DashboardToggledTitle": "仪表盘已切换",
"DashboardToggledBody": "已通过快捷键切换仪表盘显示状态",
"ClashModeChangedTitle": "Clash 模式切换",
"ClashModeChangedBody": "已切换为 {mode} 模式",
"SystemProxyToggledTitle": "系统代理切换",
"SystemProxyToggledBody": "已通过快捷键切换系统代理状态",
"TunModeToggledTitle": "TUN 模式切换",
"TunModeToggledBody": "已通过快捷键切换 TUN 模式",
"LightweightModeEnteredTitle": "轻量模式",
"LightweightModeEnteredBody": "已通过快捷键进入轻量模式",
"AppQuitTitle": "应用退出",
"AppQuitBody": "已通过快捷键退出应用",
"AppHiddenTitle": "应用隐藏",
"AppHiddenBody": "已通过快捷键隐藏应用窗口",
"Invalid Profile URL": "无效的订阅链接,请输入以 http:// 或 https:// 开头的地址" "Invalid Profile URL": "无效的订阅链接,请输入以 http:// 或 https:// 开头的地址"
} }

View File

@@ -0,0 +1,17 @@
import {
isPermissionGranted,
requestPermission,
} from "@tauri-apps/plugin-notification";
export async function setupNotificationPermission() {
let permission = await isPermissionGranted();
if (!permission) {
const result = await requestPermission();
permission = result === "granted";
}
if (permission) {
console.log("通知权限已授予");
} else {
console.log("通知权限被拒绝");
}
}