From 126f8896b6fc3bc7213a36537f23fff9592706c4 Mon Sep 17 00:00:00 2001 From: zhongluofeng Date: Tue, 4 Aug 2026 13:37:42 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BF=AB=E9=80=9F=E9=9D=A2=E6=9D=BF=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 7 + package.json | 1 + src-tauri/Cargo.lock | 94 ++- src-tauri/Cargo.toml | 3 + src-tauri/capabilities/quick-panel.json | 20 + src-tauri/src/lib.rs | 44 + src-tauri/src/quickpanel/app_scanner.rs | 78 ++ src-tauri/src/quickpanel/commands.rs | 320 +++++++ src-tauri/src/quickpanel/file_index.rs | 356 ++++++++ src-tauri/src/quickpanel/icon_extractor.rs | 345 ++++++++ src-tauri/src/quickpanel/mod.rs | 22 + src-tauri/src/quickpanel/popup.rs | 311 +++++++ src/App.vue | 60 ++ src/main.ts | 8 +- src/modules/finder/FinderModule.vue | 24 - src/modules/finder/index.ts | 22 - src/modules/icons.ts | 4 +- src/modules/index.ts | 4 +- src/modules/quickpanel/QuickPanel.vue | 760 +++++++++++++++++ src/modules/quickpanel/QuickPanelModule.vue | 549 ++++++++++++ src/modules/quickpanel/engine.ts | 134 +++ src/modules/quickpanel/index.ts | 45 + src/modules/quickpanel/providers.ts | 889 ++++++++++++++++++++ 23 files changed, 4048 insertions(+), 52 deletions(-) create mode 100644 src-tauri/capabilities/quick-panel.json create mode 100644 src-tauri/src/quickpanel/app_scanner.rs create mode 100644 src-tauri/src/quickpanel/commands.rs create mode 100644 src-tauri/src/quickpanel/file_index.rs create mode 100644 src-tauri/src/quickpanel/icon_extractor.rs create mode 100644 src-tauri/src/quickpanel/mod.rs create mode 100644 src-tauri/src/quickpanel/popup.rs delete mode 100644 src/modules/finder/FinderModule.vue delete mode 100644 src/modules/finder/index.ts create mode 100644 src/modules/quickpanel/QuickPanel.vue create mode 100644 src/modules/quickpanel/QuickPanelModule.vue create mode 100644 src/modules/quickpanel/engine.ts create mode 100644 src/modules/quickpanel/index.ts create mode 100644 src/modules/quickpanel/providers.ts diff --git a/package-lock.json b/package-lock.json index 4969890..8a0ce18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "pinia": "^3.0.4", + "pinyin-pro": "^3.28.2", "reka-ui": "^2.10.1", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.2", @@ -2290,6 +2291,12 @@ } } }, + "node_modules/pinyin-pro": { + "version": "3.28.2", + "resolved": "https://registry.npmmirror.com/pinyin-pro/-/pinyin-pro-3.28.2.tgz", + "integrity": "sha512-jV38yxXHLfidirMC4hrXasLDozLCSq/4DfX88GnHcSEJ2+GpSedG6I9VOiEXJu6iQ5dbJC/RjmzyMuS5h/wH5A==", + "license": "MIT" + }, "node_modules/postcss": { "version": "8.5.19", "funding": [ diff --git a/package.json b/package.json index ee4c525..ed1b1ea 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "pinia": "^3.0.4", + "pinyin-pro": "^3.28.2", "reka-ui": "^2.10.1", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.2", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index baabe8b..375d804 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1227,6 +1227,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1306,6 +1316,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -2114,6 +2133,26 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.4" @@ -2275,6 +2314,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.0", + "libc", +] + [[package]] name = "libappindicator" version = "0.9.0" @@ -2454,6 +2513,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.2.2" @@ -2543,6 +2614,25 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.13.0", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + [[package]] name = "notify-rust" version = "4.18.0" @@ -4579,6 +4669,7 @@ dependencies = [ "dirs 5.0.1", "futures-util", "image", + "notify", "raw-window-handle", "reqwest 0.12.28", "rusqlite", @@ -4596,6 +4687,7 @@ dependencies = [ "tauri-plugin-snap-layout", "tokio", "url", + "walkdir", "windows-sys 0.52.0", "winreg 0.52.0", "zip", @@ -4704,7 +4796,7 @@ checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", - "mio", + "mio 1.2.2", "pin-project-lite", "socket2", "tokio-macros", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7ce683f..a795aa8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -39,6 +39,8 @@ sysinfo = "0.32" rusqlite = { version = "0.32", features = ["bundled"] } base64 = "0.22" image = { version = "0.25", default-features = false, features = ["png"] } +walkdir = "2" +notify = { version = "6", features = [] } [target.'cfg(windows)'.dependencies] winreg = "0.52" @@ -58,6 +60,7 @@ windows-sys = { version = "0.52", features = [ "Win32_Graphics_Gdi", "Win32_Graphics_Dwm", "Win32_Storage_Xps", + "Win32_Storage_FileSystem", ] } [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] diff --git a/src-tauri/capabilities/quick-panel.json b/src-tauri/capabilities/quick-panel.json new file mode 100644 index 0000000..2fe637c --- /dev/null +++ b/src-tauri/capabilities/quick-panel.json @@ -0,0 +1,20 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "quick-panel", + "description": "Capability for quick panel popup window", + "windows": ["quick-panel"], + "permissions": [ + "core:default", + "core:window:allow-hide", + "core:window:allow-show", + "core:window:allow-set-focus", + "core:window:allow-close", + "core:window:allow-set-theme", + "core:window:allow-set-effects", + "core:window:allow-set-background-color", + "core:event:allow-emit", + "core:event:allow-listen", + "opener:allow-open-url", + "snap-layout:default" + ] +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ff426aa..983523c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,6 +10,7 @@ mod monitor_kernel; mod network_monitor; mod osd_window; mod process_manager; +mod quickpanel; mod screenshot; mod snap_fix; mod tray_menu; @@ -61,6 +62,15 @@ use clipboard::{ clipboard_set_pinned, clipboard_show_popup, clipboard_show_window, clipboard_start, clipboard_status, clipboard_stop, clipboard_unregister_shortcut, }; +use quickpanel::{ + quickpanel_build_file_index, quickpanel_clear_app_icon_cache, quickpanel_delete_file, + quickpanel_file_index_stats, quickpanel_get_app_icon, quickpanel_get_settings, + quickpanel_hide_popup, quickpanel_init_file_index, quickpanel_lock_screen, + quickpanel_open_file, quickpanel_register_shortcut, quickpanel_reveal_in_explorer, + quickpanel_run_custom_command, quickpanel_run_system_command, quickpanel_save_settings, + quickpanel_scan_apps, quickpanel_search_files, quickpanel_show_popup, quickpanel_show_window, + quickpanel_unregister_shortcut, +}; use tray_menu::{tray_menu_action, tray_menu_hide, tray_menu_ready}; #[tauri::command] @@ -195,6 +205,26 @@ pub fn run() { clipboard_show_window, clipboard_hide_popup, clipboard_paste_to_target, + quickpanel_get_settings, + quickpanel_save_settings, + quickpanel_register_shortcut, + quickpanel_unregister_shortcut, + quickpanel_show_popup, + quickpanel_show_window, + quickpanel_hide_popup, + quickpanel_lock_screen, + quickpanel_init_file_index, + quickpanel_build_file_index, + quickpanel_search_files, + quickpanel_file_index_stats, + quickpanel_scan_apps, + quickpanel_get_app_icon, + quickpanel_clear_app_icon_cache, + quickpanel_reveal_in_explorer, + quickpanel_open_file, + quickpanel_delete_file, + quickpanel_run_custom_command, + quickpanel_run_system_command, tray_menu_action, tray_menu_hide, tray_menu_ready, @@ -286,6 +316,20 @@ pub fn run() { } app.manage(clipboard); + // 快速面板:应用启动时注册全局快捷键 + 预创建隐藏窗口。 + // defaultEnabled:true 假设启用;用户在设置页禁用模块时由前端 onDisable 钩子注销快捷键。 + let qp_settings = quickpanel::load_settings(&app.handle()); + if !qp_settings.shortcut.trim().is_empty() { + let app_handle = app.handle().clone(); + if let Err(e) = quickpanel::register_shortcut(&app_handle, &qp_settings.shortcut) { + eprintln!("[quickpanel] 快捷键注册失败: {}", e); + } + // 预创建弹窗窗口(隐藏),首次按快捷键时直接 show,避免首次创建时序问题 + quickpanel::ensure_window(&app_handle); + } + // 初始化文件索引数据库(不立即构建,由前端设置页或首次唤起时触发) + quickpanel::file_index::init(&app.handle()); + // 自定义托盘菜单(代理/OSD/Kernel/下载/设置/退出) tray_menu::create_tray_menu(app.handle())?; diff --git a/src-tauri/src/quickpanel/app_scanner.rs b/src-tauri/src/quickpanel/app_scanner.rs new file mode 100644 index 0000000..58afc7a --- /dev/null +++ b/src-tauri/src/quickpanel/app_scanner.rs @@ -0,0 +1,78 @@ +//! 应用扫描:Windows 开始菜单 .lnk + PATH 中的可执行文件。 +//! +//! 简化实现:扫描开始菜单目录(系统 + 用户)下的 .lnk 快捷方式, +//! 名称取文件名(去 .lnk 后缀)。PATH 可执行文件扫描可选(避免噪音过多)。 +//! 结果不持久化,每次唤起时按需刷新(数据量小,几十毫秒内完成)。 + +use std::path::PathBuf; +use serde::Serialize; +use walkdir::WalkDir; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AppRecord { + pub name: String, + pub path: String, +} + +/// 扫描开始菜单(系统 + 用户)。返回去重后的应用列表。 +pub fn scan_apps() -> Vec { + let mut apps = Vec::new(); + let mut seen = std::collections::HashSet::new(); + + // 开始菜单目录 + let mut dirs = Vec::new(); + + // 系统开始菜单:C:\ProgramData\Microsoft\Windows\Start Menu\Programs + if let Ok(prog_data) = std::env::var("ProgramData") { + dirs.push( + PathBuf::from(prog_data) + .join("Microsoft") + .join("Windows") + .join("Start Menu") + .join("Programs"), + ); + } + // 用户开始菜单:%APPDATA%\Microsoft\Windows\Start Menu\Programs + if let Ok(appdata) = std::env::var("APPDATA") { + dirs.push( + PathBuf::from(appdata) + .join("Microsoft") + .join("Windows") + .join("Start Menu") + .join("Programs"), + ); + } + + for dir in dirs { + if !dir.exists() { + continue; + } + for entry in WalkDir::new(&dir) + .max_depth(5) + .follow_links(false) + .into_iter() + .filter_map(|e| e.ok()) + { + let p = entry.path(); + if !p.is_file() { + continue; + } + let ext = p.extension().map(|e| e.to_string_lossy().to_lowercase()).unwrap_or_default(); + if ext != "lnk" { + continue; + } + let Some(name_os) = p.file_stem() else { continue }; + let name = name_os.to_string_lossy().to_string(); + let path_str = p.to_string_lossy().to_string(); + // 去重:同名应用保留第一个 + if seen.insert(name.to_lowercase()) { + apps.push(AppRecord { name, path: path_str }); + } + } + } + + // 按名称排序 + apps.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + apps +} diff --git a/src-tauri/src/quickpanel/commands.rs b/src-tauri/src/quickpanel/commands.rs new file mode 100644 index 0000000..40fae84 --- /dev/null +++ b/src-tauri/src/quickpanel/commands.rs @@ -0,0 +1,320 @@ +//! Tauri 命令:快速面板模块 + +use tauri::AppHandle; + +use super::popup::{self, QuickPanelSettings}; +use super::{file_index, app_scanner, icon_extractor}; + +/// 读取快速面板设置(快捷键等) +#[tauri::command] +pub async fn quickpanel_get_settings(app: AppHandle) -> Result { + Ok(popup::load_settings(&app)) +} + +/// 保存快速面板设置;快捷键变化时自动重新注册 + 预创建窗口 +#[tauri::command] +pub async fn quickpanel_save_settings( + settings: QuickPanelSettings, + app: AppHandle, +) -> Result<(), String> { + let prev_shortcut = popup::load_settings(&app).shortcut; + popup::save_settings(&app, &settings)?; + // 快捷键变化时重新注册 + if settings.shortcut != prev_shortcut { + popup::register_shortcut(&app, &settings.shortcut)?; + // 新快捷键非空时确保弹窗窗口已预创建 + if !settings.shortcut.trim().is_empty() { + popup::ensure_window(&app); + } + } + Ok(()) +} + +/// 注册(或切换)快速面板全局快捷键 +#[tauri::command] +pub async fn quickpanel_register_shortcut( + shortcut: String, + app: AppHandle, +) -> Result<(), String> { + popup::register_shortcut(&app, &shortcut) +} + +/// 注销快速面板全局快捷键 +#[tauri::command] +pub async fn quickpanel_unregister_shortcut(app: AppHandle) -> Result<(), String> { + popup::unregister_shortcut(&app); + Ok(()) +} + +/// 手动触发显示快速面板(供 UI 按钮调用) +#[tauri::command] +pub async fn quickpanel_show_popup(app: AppHandle) -> Result<(), String> { + popup::show_popup(&app); + Ok(()) +} + +/// 隐藏快速面板 +#[tauri::command] +pub async fn quickpanel_hide_popup(app: AppHandle) -> Result<(), String> { + popup::hide_popup(&app); + Ok(()) +} + +/// 显示已创建的弹窗窗口(前端 onMounted 后调用) +#[tauri::command] +pub async fn quickpanel_show_window(app: AppHandle) -> Result<(), String> { + popup::show_window(&app); + Ok(()) +} + +/// 锁定屏幕(Windows: rundll32 user32.dll,LockWorkStation,CREATE_NO_WINDOW 避免黑窗) +#[tauri::command] +pub fn quickpanel_lock_screen() -> Result<(), String> { + #[cfg(windows)] + { + use crate::process_manager::setup_creation_flags; + let mut cmd = std::process::Command::new("rundll32.exe"); + cmd.arg("user32.dll,LockWorkStation"); + setup_creation_flags(&mut cmd); + cmd.spawn().map_err(|e| format!("锁屏失败: {}", e))?; + } + #[cfg(not(windows))] + { + return Err("当前平台不支持锁屏".into()); + } + Ok(()) +} + +/// 初始化文件索引数据库(应用启动时调用) +#[tauri::command] +pub fn quickpanel_init_file_index(app: AppHandle) -> Result<(), String> { + file_index::init(&app); + Ok(()) +} + +/// 构建文件索引(全量重建,阻塞操作建议在 spawn_blocking 调用) +#[tauri::command] +pub async fn quickpanel_build_file_index(app: AppHandle) -> Result { + let settings = popup::load_settings(&app); + let dirs = if settings.index_dirs.is_empty() { + popup::QuickPanelSettings::default().index_dirs + } else { + settings.index_dirs + }; + // 阻塞操作放到 spawn_blocking + tauri::async_runtime::spawn_blocking(move || file_index::build_index(&dirs)) + .await + .map_err(|e| format!("索引任务失败: {}", e))? +} + +/// 搜索文件索引 +#[tauri::command] +pub fn quickpanel_search_files(query: String, limit: Option) -> Vec { + file_index::search(&query, limit.unwrap_or(50)) +} + +/// 获取索引状态 +#[tauri::command] +pub fn quickpanel_file_index_stats() -> file_index::IndexStats { + file_index::stats() +} + +/// 扫描已安装应用 +#[tauri::command] +pub fn quickpanel_scan_apps() -> Vec { + app_scanner::scan_apps() +} + +/// 获取应用图标(data URL)。命中内存/磁盘缓存时零 Windows API 调用。 +/// 前端按需为可见项调用,避免一次性加载全部图标。 +#[tauri::command] +pub fn quickpanel_get_app_icon(app: AppHandle, path: String) -> Option { + icon_extractor::get_icon_data_url(&app, &path) +} + +/// 清理图标缓存(磁盘 + 内存) +#[tauri::command] +pub fn quickpanel_clear_app_icon_cache(app: AppHandle) -> Result<(), String> { + icon_extractor::clear_cache(&app); + Ok(()) +} + +/// 在资源管理器中显示文件(选中) +#[tauri::command] +pub fn quickpanel_reveal_in_explorer(path: String) -> Result<(), String> { + #[cfg(windows)] + { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Foundation::HWND; + use windows_sys::Win32::UI::Shell::ShellExecuteW; + use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL; + + // 规范化路径分隔符 + let normalized = path.replace('/', "\\"); + // explorer.exe /select,"path" — 用 ShellExecuteW 直接传参, + // 避免 std::process::Command 的 arg 转义破坏 /select 语法。 + // 对 .lnk 文件也能正确选中(explorer 直接选中 .lnk 文件本身)。 + let params = format!("/select,\"{}\"", normalized); + let wide_exe: Vec = OsStr::new("explorer.exe") + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let wide_params: Vec = OsStr::new(¶ms) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + unsafe { + let hinst = ShellExecuteW( + 0 as HWND, + std::ptr::null(), + wide_exe.as_ptr(), + wide_params.as_ptr(), + std::ptr::null(), + SW_SHOWNORMAL, + ); + // ShellExecuteW 返回值 <= 32 表示错误 + if hinst <= 32 { + return Err(format!("打开资源管理器失败 (code: {})", hinst as i32)); + } + } + } + #[cfg(not(windows))] + { + let _ = path; + return Err("当前平台不支持".into()); + } + Ok(()) +} + +/// 用系统默认程序打开文件(ShellExecuteW)。 +/// 无关联应用时,自动 fallback 到「打开方式」对话框(verb: openas)。 +#[tauri::command] +pub fn quickpanel_open_file(path: String) -> Result<(), String> { + #[cfg(windows)] + { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Foundation::HWND; + use windows_sys::Win32::UI::Shell::ShellExecuteW; + use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL; + + let normalized = path.replace('/', "\\"); + let wide_path: Vec = OsStr::new(&normalized) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let wide_open: Vec = OsStr::new("open") + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let wide_openas: Vec = OsStr::new("openas") + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + unsafe { + let mut hinst = ShellExecuteW( + 0 as HWND, + wide_open.as_ptr(), + wide_path.as_ptr(), + std::ptr::null(), + std::ptr::null(), + SW_SHOWNORMAL, + ); + if hinst <= 32 { + hinst = ShellExecuteW( + 0 as HWND, + wide_openas.as_ptr(), + wide_path.as_ptr(), + std::ptr::null(), + std::ptr::null(), + SW_SHOWNORMAL, + ); + if hinst <= 32 { + return Err(format!("打开文件失败 (code: {})", hinst as i32)); + } + } + } + } + #[cfg(not(windows))] + { + let _ = path; + return Err(String::from("当前平台不支持")); + } + Ok(()) +} + +/// 删除文件(移到回收站) +#[tauri::command] +pub fn quickpanel_delete_file(path: String) -> Result<(), String> { + #[cfg(windows)] + { + use crate::process_manager::setup_creation_flags; + let p = std::path::Path::new(&path); + let is_dir = p.is_dir(); + // 用 PowerShell + Microsoft.VisualBasic 移到回收站 + let script = if is_dir { + format!( + "Add-Type -AssemblyName Microsoft.VisualBasic; [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteDirectory('{}','OnlyErrorDialogs','SendToRecycleBin')", + path.replace('\'', "''") + ) + } else { + format!( + "Add-Type -AssemblyName Microsoft.VisualBasic; [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile('{}','OnlyErrorDialogs','SendToRecycleBin')", + path.replace('\'', "''") + ) + }; + let mut cmd = std::process::Command::new("powershell.exe"); + cmd.args(["-NoProfile", "-NonInteractive", "-Command", &script]); + setup_creation_flags(&mut cmd); + let output = cmd.output().map_err(|e| format!("删除失败: {}", e))?; + if !output.status.success() { + return Err(format!( + "删除失败: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + } + #[cfg(not(windows))] + { + let _ = path; + return Err("当前平台不支持".into()); + } + Ok(()) +} + +/// 运行自定义命令(执行可执行文件 + 参数) +/// .lnk 快捷方式不能直接 spawn(os error 193),需通过 cmd /C 启动 +#[tauri::command] +pub fn quickpanel_run_custom_command(command: String, args: Vec) -> Result<(), String> { + use crate::process_manager::setup_creation_flags; + let is_lnk = command + .to_lowercase() + .ends_with(".lnk"); + let mut cmd = if is_lnk { + // cmd /C start "" "path.lnk" arg1 arg2 + let mut c = std::process::Command::new("cmd"); + c.args(["/C", "start", "", &command]); + c.args(&args); + c + } else { + let mut c = std::process::Command::new(&command); + c.args(&args); + c + }; + setup_creation_flags(&mut cmd); + cmd.spawn().map_err(|e| format!("运行命令失败: {}", e))?; + Ok(()) +} + +/// 运行系统命令(不设置 CREATE_NO_WINDOW,使 cmd/powershell/regedit 等显示自身窗口) +/// 适用于内置系统工具:regedit、shutdown、cmd、powershell、taskmgr 等。 +#[tauri::command] +pub fn quickpanel_run_system_command(command: String, args: Vec) -> Result<(), String> { + let mut cmd = std::process::Command::new(&command); + cmd.args(&args); + cmd.spawn().map_err(|e| format!("运行系统命令失败: {}", e))?; + Ok(()) +} diff --git a/src-tauri/src/quickpanel/file_index.rs b/src-tauri/src/quickpanel/file_index.rs new file mode 100644 index 0000000..6db625f --- /dev/null +++ b/src-tauri/src/quickpanel/file_index.rs @@ -0,0 +1,356 @@ +//! 文件索引:walkdir 遍历 + rusqlite 存储/搜索。 +//! +//! schema: +//! files(path TEXT PK, name TEXT, ext TEXT, size INT, mtime INT, is_dir INT) +//! 索引:name LIKE 搜索(name_lower 已预存为小写,避免 lower() 全表扫描)。 + +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::UNIX_EPOCH; + +use rusqlite::{params, Connection}; +use serde::Serialize; +use tauri::{AppHandle, Manager}; +use walkdir::WalkDir; + +/// 单个文件记录(返回给前端) +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileRecord { + pub path: String, + pub name: String, + pub ext: String, + pub size: i64, + pub is_dir: bool, +} + +/// 索引状态(返回给前端) +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct IndexStats { + pub total: i64, + pub last_built_at: i64, + pub last_built_dirs: Vec, +} + +struct Inner { + conn: Mutex, +} + +static INDEX: std::sync::OnceLock>> = std::sync::OnceLock::new(); + +fn index_slot() -> &'static Mutex> { + INDEX.get_or_init(|| Mutex::new(None)) +} + +/// 数据库路径:{app_data_dir}/quickpanel/files.db +fn db_path(app: &AppHandle) -> PathBuf { + app.path() + .app_data_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join("quickpanel") + .join("files.db") +} + +/// 初始化数据库连接(创建表 + 索引)。若已初始化则跳过。 +pub fn init(app: &AppHandle) { + let path = db_path(app); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).ok(); + } + let conn = match Connection::open(&path) { + Ok(c) => c, + Err(e) => { + eprintln!("[quickpanel] 文件索引 DB 初始化失败: {}", e); + return; + } + }; + conn.busy_timeout(std::time::Duration::from_secs(3)).ok(); + let _ = conn.execute_batch( + "CREATE TABLE IF NOT EXISTS files ( + path TEXT PRIMARY KEY, + name TEXT NOT NULL, + name_lower TEXT NOT NULL, + ext TEXT NOT NULL DEFAULT '', + size INTEGER NOT NULL DEFAULT 0, + mtime INTEGER NOT NULL DEFAULT 0, + is_dir INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_name_lower ON files(name_lower); + CREATE INDEX IF NOT EXISTS idx_ext ON files(ext); + CREATE TABLE IF NOT EXISTS files_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + );", + ); + + let mut guard = index_slot().lock().unwrap(); + *guard = Some(Inner { + conn: Mutex::new(conn), + }); + eprintln!("[quickpanel] 文件索引 DB 已就绪: {}", path.display()); +} + +/// 判断索引是否已初始化 +fn with_conn(f: F) -> Option +where + F: FnOnce(&Connection) -> R, +{ + let guard = index_slot().lock().unwrap(); + if let Some(inner) = guard.as_ref() { + if let Ok(conn) = inner.conn.lock() { + return Some(f(&conn)); + } + } + None +} + +/// 遍历指定目录列表建立索引(全量重建)。 +/// 返回索引条目数。在 spawn_blocking 中调用。 +/// 重建完成后自动启动 notify 监听器做增量更新。 +pub fn build_index(dirs: &[String]) -> Result { + // 清空旧数据 + let cleared = with_conn(|conn| { + conn.execute("DELETE FROM files", []).ok() + }).unwrap_or(None); + + if cleared.is_none() { + return Err("文件索引未初始化".into()); + } + + let mut count = 0i64; + for dir in dirs { + count += walk_and_index(dir); + } + + // 记录构建元信息 + let now = now_secs(); + let dirs_json = serde_json::to_string(dirs).unwrap_or_default(); + let _ = with_conn(|conn| { + conn.execute( + "INSERT OR REPLACE INTO files_meta (key, value) VALUES ('last_built_at', ?1)", + params![now.to_string()], + ).ok(); + conn.execute( + "INSERT OR REPLACE INTO files_meta (key, value) VALUES ('last_built_dirs', ?1)", + params![dirs_json], + ).ok() + }); + + // 启动/刷新 notify 监听器 + start_watcher(dirs); + + eprintln!("[quickpanel] 文件索引完成,共 {} 条", count); + Ok(count) +} + +/// 遍历单个目录并写入索引,返回新增条目数 +fn walk_and_index(dir: &str) -> i64 { + let path = Path::new(dir); + if !path.exists() { + return 0; + } + let mut count = 0i64; + for entry in WalkDir::new(path) + .max_depth(10) + .follow_links(false) + .into_iter() + .filter_map(|e| e.ok()) + { + let p = entry.path(); + if upsert_path(p) { + count += 1; + } + } + count +} + +/// 将单个路径写入索引(创建/修改)。返回 true 表示已写入。 +/// 跳过隐藏文件(. 开头)、不存在的路径。 +fn upsert_path(p: &Path) -> bool { + let Some(name_os) = p.file_name() else { return false }; + let name = name_os.to_string_lossy().to_string(); + if name.starts_with('.') { + return false; + } + let meta = match std::fs::metadata(p) { + Ok(m) => m, + Err(_) => return false, + }; + let name_lower = name.to_lowercase(); + let ext = p.extension() + .map(|e| e.to_string_lossy().to_lowercase()) + .unwrap_or_default(); + let size = meta.len() as i64; + let mtime = meta.modified().ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let is_dir = meta.is_dir() as i32; + let path_str = p.to_string_lossy().to_string(); + + let _ = with_conn(|conn| { + conn.execute( + "INSERT OR REPLACE INTO files (path, name, name_lower, ext, size, mtime, is_dir) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![path_str, name, name_lower, ext, size, mtime, is_dir], + ) + }); + true +} + +/// 从索引中删除指定路径 +fn remove_path(p: &Path) { + let path_str = p.to_string_lossy().to_string(); + let _ = with_conn(|conn| { + // 删除该路径及其子项(目录被删除时,子文件也失效) + conn.execute( + "DELETE FROM files WHERE path = ?1 OR path LIKE ?2", + params![path_str, format!("{}%", path_str)], + ) + }); +} + +/// 搜索文件:name_lower LIKE %query%,按名字长度升序(短名优先)。 +pub fn search(query: &str, limit: i64) -> Vec { + let like = format!("%{}%", query.to_lowercase()); + with_conn(|conn| { + let mut stmt = match conn.prepare( + "SELECT path, name, ext, size, is_dir FROM files + WHERE name_lower LIKE ?1 + ORDER BY LENGTH(name) ASC, name ASC LIMIT ?2", + ) { + Ok(s) => s, + Err(_) => return vec![], + }; + stmt.query_map(params![like, limit], |r| { + Ok(FileRecord { + path: r.get(0)?, + name: r.get(1)?, + ext: r.get(2)?, + size: r.get(3)?, + is_dir: r.get::<_, i32>(4)? != 0, + }) + }) + .map(|r| r.filter_map(|i| i.ok()).collect()) + .unwrap_or_default() + }) + .unwrap_or_default() +} + +/// 索引状态 +pub fn stats() -> IndexStats { + let total = with_conn(|conn| { + conn.query_row("SELECT COUNT(*) FROM files", [], |r| r.get::<_, i64>(0)) + .unwrap_or(0) + }) + .unwrap_or(0); + + let last_built_at = with_conn(|conn| { + // query_row 返回 Result,统一处理失败 + let res: rusqlite::Result = conn.query_row( + "SELECT value FROM files_meta WHERE key = 'last_built_at'", + [], + |r| r.get::<_, String>(0), + ); + res.ok().and_then(|s| s.parse().ok()).unwrap_or(0) + }) + .unwrap_or(0); + + let last_built_dirs = with_conn(|conn| { + conn.query_row( + "SELECT value FROM files_meta WHERE key = 'last_built_dirs'", + [], + |r| r.get::<_, String>(0), + ) + .ok() + .and_then(|s| serde_json::from_str::>(&s).ok()) + .unwrap_or_default() + }) + .unwrap_or_default(); + + IndexStats { + total, + last_built_at, + last_built_dirs, + } +} + +fn now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +// ===== notify 增量监听 ===== + +use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher}; + +static WATCHER: std::sync::OnceLock>> = std::sync::OnceLock::new(); + +fn watcher_slot() -> &'static Mutex> { + WATCHER.get_or_init(|| Mutex::new(None)) +} + +/// 启动/刷新 notify 监听器。重建索引或修改索引目录后调用。 +/// 会先停止旧监听器,再为新目录列表添加递归监听。 +pub fn start_watcher(dirs: &[String]) { + // 创建新 watcher(notify v6: recommended_watcher 只接受回调,Config 默认) + let mut watcher = match notify::recommended_watcher( + move |res: notify::Result| { + if let Ok(event) = res { + handle_fs_event(&event); + } + }, + ) { + Ok(w) => w, + Err(e) => { + eprintln!("[quickpanel] notify watcher 创建失败: {}", e); + return; + } + }; + + // 为每个目录添加递归监听 + for dir in dirs { + let path = Path::new(dir); + if !path.exists() { + continue; + } + if let Err(e) = watcher.watch(path, RecursiveMode::Recursive) { + eprintln!("[quickpanel] watch {} 失败: {}", dir, e); + } + } + + // 替换旧 watcher(drop 时自动 unwatch) + let mut guard = watcher_slot().lock().unwrap(); + *guard = Some(watcher); + eprintln!("[quickpanel] notify 监听已启动,监听 {} 个目录", dirs.len()); +} + +/// 处理文件系统事件:创建/修改 → upsert,删除 → remove,重命名 → remove + upsert +fn handle_fs_event(event: ¬ify::Event) { + match event.kind { + EventKind::Create(_) | EventKind::Modify(_) => { + for path in &event.paths { + if path.exists() { + upsert_path(path); + } + } + } + EventKind::Remove(_) => { + for path in &event.paths { + remove_path(path); + } + } + _ => { + // 忽略访问/其他事件 + } + } +} + +/// 停止 notify 监听器 +pub fn stop_watcher() { + let mut guard = watcher_slot().lock().unwrap(); + *guard = None; +} diff --git a/src-tauri/src/quickpanel/icon_extractor.rs b/src-tauri/src/quickpanel/icon_extractor.rs new file mode 100644 index 0000000..7d2fdf0 --- /dev/null +++ b/src-tauri/src/quickpanel/icon_extractor.rs @@ -0,0 +1,345 @@ +//! 应用图标提取:Windows SHGetFileInfo → HICON → RGBA → PNG,带磁盘 + 内存缓存。 +//! +//! 流程: +//! 1. 内存缓存命中 → 直接返回 data URL +//! 2. 磁盘缓存命中({app_data_dir}/quickpanel/icons/{hash}.png) → 读取并缓存 +//! 3. 调用 SHGetFileInfoW 提取 HICON → GetDIBits 取 32bit BGRA → 转 RGBA → PNG +//! 4. 写入磁盘缓存 + 内存缓存,返回 data URL +//! +//! 设计取舍: +//! - 返回 base64 data URL 而非文件路径,避免独立弹窗窗口的 asset 协议配置问题 +//! - 磁盘缓存避免重复 Windows API 调用(昂贵),内存缓存避免重复磁盘读取 + 编码 + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Mutex; + +use base64::Engine as _; +use tauri::{AppHandle, Manager}; + +// ===== 内存缓存 ===== +static MEM_CACHE: Mutex>> = Mutex::new(None); +const MEM_CACHE_MAX: usize = 512; + +fn mem_get(path: &str) -> Option { + let cache = MEM_CACHE.lock().ok()?; + cache.as_ref()?.get(path).cloned() +} + +fn mem_put(path: String, url: String) { + if let Ok(mut guard) = MEM_CACHE.lock() { + let map = guard.get_or_insert_with(HashMap::new); + if map.len() >= MEM_CACHE_MAX { + // 简单清理:丢弃一半(最早插入的,HashMap 无序,近似随机) + let keep = map.len() / 2; + let keys: Vec = map.keys().cloned().collect(); + for k in keys.iter().skip(keep) { + map.remove(k); + } + } + map.insert(path, url); + } +} + +// ===== 磁盘缓存路径 ===== +fn cache_dir(app: &AppHandle) -> PathBuf { + app.path() + .app_data_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join("quickpanel") + .join("icons") +} + +fn path_hash(path: &str) -> String { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + // 统一小写 + 正斜杠,避免大小写/分隔符差异导致缓存未命中 + let normalized = path.to_lowercase().replace('\\', "/"); + let mut hasher = DefaultHasher::new(); + normalized.hash(&mut hasher); + format!("{:016x}", hasher.finish()) +} + +// ===== 公共 API ===== + +/// 获取应用图标 data URL。命中缓存则零开销;未命中则提取 + 编码 + 落盘。 +/// 返回 Ok(None) 表示提取失败或不支持的平台。 +pub fn get_icon_data_url(app: &AppHandle, path: &str) -> Option { + if path.is_empty() { + return None; + } + // 规范化路径分隔符:混合 / 和 \ 会导致 SHGetFileInfoW 失败 + let normalized = path.replace('/', "\\"); + let path = normalized.as_str(); + + // 1. 内存缓存 + if let Some(url) = mem_get(path) { + return Some(url); + } + + let dir = cache_dir(app); + let hash = path_hash(path); + let cache_path = dir.join(format!("{}.png", hash)); + + // 2. 磁盘缓存 + if cache_path.exists() { + if let Ok(bytes) = std::fs::read(&cache_path) { + let url = png_to_data_url(&bytes); + mem_put(path.to_string(), url.clone()); + return Some(url); + } + } + + // 3. 提取 + let png = extract_icon_png(path)?; + + // 4. 落盘(失败不影响返回) + std::fs::create_dir_all(&dir).ok(); + std::fs::write(&cache_path, &png).ok(); + + // 5. 缓存 + 返回 + let url = png_to_data_url(&png); + mem_put(path.to_string(), url.clone()); + Some(url) +} + +/// 清理整个图标磁盘缓存(设置页可调用) +pub fn clear_cache(app: &AppHandle) { + let dir = cache_dir(app); + if dir.exists() { + std::fs::remove_dir_all(&dir).ok(); + std::fs::create_dir_all(&dir).ok(); + } + if let Ok(mut guard) = MEM_CACHE.lock() { + *guard = None; + } +} + +fn png_to_data_url(png: &[u8]) -> String { + let b64 = base64::engine::general_purpose::STANDARD.encode(png); + format!("data:image/png;base64,{}", b64) +} + +// ===== Windows 图标提取 ===== + +#[cfg(windows)] +fn extract_icon_png(path: &str) -> Option> { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::UI::Shell::{ + SHGetFileInfoW, SHFILEINFOW, SHGFI_ICON, SHGFI_LARGEICON, + }; + use windows_sys::Win32::UI::WindowsAndMessaging::DestroyIcon; + + unsafe { + let wide: Vec = OsStr::new(path) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + let mut shfi: SHFILEINFOW = std::mem::zeroed(); + let _ = SHGetFileInfoW( + wide.as_ptr(), + 0, + &mut shfi, + std::mem::size_of::() as u32, + SHGFI_ICON | SHGFI_LARGEICON, + ); + + // hIcon 为 0 表示无图标 + if shfi.hIcon == 0 { + return None; + } + + let result = hicon_to_png(shfi.hIcon); + let _ = DestroyIcon(shfi.hIcon); + result + } +} + +#[cfg(not(windows))] +fn extract_icon_png(_path: &str) -> Option> { + None +} + +/// HICON → PNG bytes +#[cfg(windows)] +fn hicon_to_png(hicon: windows_sys::Win32::UI::WindowsAndMessaging::HICON) -> Option> { + use windows_sys::Win32::Graphics::Gdi::DeleteObject; + use windows_sys::Win32::UI::WindowsAndMessaging::{GetIconInfo, ICONINFO}; + + unsafe { + let mut icon_info: ICONINFO = std::mem::zeroed(); + if GetIconInfo(hicon, &mut icon_info) == 0 { + return None; + } + + let has_color = icon_info.hbmColor != 0; + let has_mask = icon_info.hbmMask != 0; + + let result: Option<(Vec, u32, u32)> = if has_color { + // 32-bit BGRA → RGBA + let (mut rgba, w, h) = bitmap_to_rgba32(icon_info.hbmColor)?; + + // 检查 alpha 是否全 0(旧式无 alpha 通道图标) + let alpha_any = rgba.chunks_exact(4).any(|c| c[3] != 0); + if !alpha_any { + if has_mask { + // 用 mask 补 alpha(白=透明,黑=不透明) + let _ = apply_mask_alpha(&mut rgba, w, h, icon_info.hbmMask); + } else { + // 无 mask,设为全不透明 + for c in rgba.chunks_exact_mut(4) { + c[3] = 255; + } + } + } + Some((rgba, w, h)) + } else { + // 无颜色位图:monochrome 图标,罕见且无色,跳过 + None + }; + + // 清理 GDI 对象 + if has_color { + let _ = DeleteObject(icon_info.hbmColor); + } + if has_mask { + let _ = DeleteObject(icon_info.hbmMask); + } + + let (rgba, w, h) = result?; + encode_png(&rgba, w, h) + } +} + +/// 读取 32-bit 位图为 RGBA(top-down),BGRA→RGBA +#[cfg(windows)] +fn bitmap_to_rgba32( + hbm: windows_sys::Win32::Graphics::Gdi::HBITMAP, +) -> Option<(Vec, u32, u32)> { + use windows_sys::Win32::Foundation::HWND; + use windows_sys::Win32::Graphics::Gdi::{ + GetDC, GetDIBits, GetObjectW, BITMAP, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, + ReleaseDC, + }; + + unsafe { + // 取尺寸 + let mut bmp: BITMAP = std::mem::zeroed(); + let got = GetObjectW( + hbm, + std::mem::size_of::() as i32, + &mut bmp as *mut _ as *mut _, + ); + if got == 0 { + return None; + } + let w = bmp.bmWidth as u32; + let h = bmp.bmHeight as u32; + if w == 0 || h == 0 { + return None; + } + + // 32-bit top-down DIB + let mut bi: BITMAPINFO = std::mem::zeroed(); + bi.bmiHeader.biSize = std::mem::size_of::() as u32; + bi.bmiHeader.biWidth = w as i32; + bi.bmiHeader.biHeight = -(h as i32); // 负值 = top-down + bi.bmiHeader.biPlanes = 1; + bi.bmiHeader.biBitCount = 32; + bi.bmiHeader.biCompression = BI_RGB; + + let mut pixels = vec![0u8; (w * h * 4) as usize]; + let hdc = GetDC(0 as HWND); + if hdc == 0 { + return None; + } + let ret = GetDIBits( + hdc, + hbm, + 0, + h, + pixels.as_mut_ptr() as *mut _, + &mut bi, + DIB_RGB_COLORS, + ); + let _ = ReleaseDC(0 as HWND, hdc); + if ret == 0 { + return None; + } + + // BGRA → RGBA + for chunk in pixels.chunks_exact_mut(4) { + chunk.swap(0, 2); + } + Some((pixels, w, h)) + } +} + +/// 用 1bpp mask 设置 alpha:mask 白(1)=透明,黑(0)=不透明 +#[cfg(windows)] +fn apply_mask_alpha( + rgba: &mut [u8], + w: u32, + h: u32, + hbm_mask: windows_sys::Win32::Graphics::Gdi::HBITMAP, +) -> Result<(), ()> { + use windows_sys::Win32::Foundation::HWND; + use windows_sys::Win32::Graphics::Gdi::{ + GetDC, GetDIBits, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, ReleaseDC, + }; + + unsafe { + let mut bi: BITMAPINFO = std::mem::zeroed(); + bi.bmiHeader.biSize = std::mem::size_of::() as u32; + bi.bmiHeader.biWidth = w as i32; + bi.bmiHeader.biHeight = -(h as i32); + bi.bmiHeader.biPlanes = 1; + bi.bmiHeader.biBitCount = 1; + bi.bmiHeader.biCompression = BI_RGB; + + // 1bpp,每行 4 字节对齐 + let row_bytes = ((w + 31) / 32 * 4) as usize; + let mut mask = vec![0u8; row_bytes * h as usize]; + + let hdc = GetDC(0 as HWND); + if hdc == 0 { + return Err(()); + } + let ret = GetDIBits( + hdc, + hbm_mask, + 0, + h, + mask.as_mut_ptr() as *mut _, + &mut bi, + DIB_RGB_COLORS, + ); + let _ = ReleaseDC(0 as HWND, hdc); + if ret == 0 { + return Err(()); + } + + for y in 0..h as usize { + for x in 0..w as usize { + let byte_idx = y * row_bytes + x / 8; + let bit = (mask[byte_idx] >> (7 - (x % 8))) & 1; + let alpha = if bit == 1 { 0 } else { 255 }; + rgba[(y * w as usize + x) * 4 + 3] = alpha; + } + } + Ok(()) + } +} + +/// RGBA → PNG +fn encode_png(rgba: &[u8], w: u32, h: u32) -> Option> { + use image::{ImageBuffer, RgbaImage}; + let img: RgbaImage = ImageBuffer::from_raw(w, h, rgba.to_vec())?; + let mut buf = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(img) + .write_to(&mut buf, image::ImageFormat::Png) + .ok()?; + Some(buf.into_inner()) +} diff --git a/src-tauri/src/quickpanel/mod.rs b/src-tauri/src/quickpanel/mod.rs new file mode 100644 index 0000000..6164ed4 --- /dev/null +++ b/src-tauri/src/quickpanel/mod.rs @@ -0,0 +1,22 @@ +//! 快速面板模块:全局快捷键唤起的多源命令面板。 +//! +//! Phase 1:窗口骨架(预创建隐藏窗口 + 快捷键 + 失焦隐藏) +//! Phase 2:fuzzy + 拼音引擎,command/calc/web/system Provider +//! Phase 3:文件索引(walkdir + rusqlite)、应用扫描、剪贴板历史复用 + +pub mod app_scanner; +pub mod commands; +pub mod file_index; +pub mod icon_extractor; +pub mod popup; + +pub use commands::{ + quickpanel_build_file_index, quickpanel_clear_app_icon_cache, quickpanel_delete_file, + quickpanel_file_index_stats, quickpanel_get_app_icon, quickpanel_get_settings, + quickpanel_hide_popup, quickpanel_init_file_index, quickpanel_lock_screen, + quickpanel_open_file, quickpanel_register_shortcut, quickpanel_reveal_in_explorer, + quickpanel_run_custom_command, quickpanel_run_system_command, quickpanel_save_settings, + quickpanel_scan_apps, quickpanel_search_files, quickpanel_show_popup, quickpanel_show_window, + quickpanel_unregister_shortcut, +}; +pub use popup::{ensure_window, load_settings, register_shortcut}; diff --git a/src-tauri/src/quickpanel/popup.rs b/src-tauri/src/quickpanel/popup.rs new file mode 100644 index 0000000..258b055 --- /dev/null +++ b/src-tauri/src/quickpanel/popup.rs @@ -0,0 +1,311 @@ +//! 快速面板弹窗:全局快捷键唤起的多源命令面板。 +//! +//! 流程(与剪贴板弹窗同构): +//! 1. 应用启动 → `ensure_window` 预创建隐藏窗口(屏幕外) +//! 2. 全局快捷键按下 → `show_popup` 在鼠标所在显示器中央定位并显示 +//! 3. 前端 Vue 挂载完成、主题应用后调用 `quickpanel_show_window` 显示窗口 +//! 4. 前端监听 `quickpanel-show` 事件刷新数据/聚焦输入 +//! 5. 窗口失焦自动隐藏(保留窗口复用,不销毁) +//! +//! Win32 API(鼠标/显示器/DPI)复用 clipboard::popup 已 pub use 的实现, +//! 避免重复封装。 + +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; +use tauri::window::{Effect, EffectsBuilder}; +use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; + +use crate::clipboard::popup::{get_cursor_pos, get_work_area_at_point, get_dpi_for_point}; + +/// 弹窗窗口标签 +pub const POPUP_LABEL: &str = "quick-panel"; + +/// 窗口尺寸(逻辑像素) +const WIN_W: f64 = 600.0; +const WIN_H: f64 = 420.0; + +/// 当前注册的快捷键(用于注销旧快捷键) +static CURRENT_SHORTCUT: Mutex> = Mutex::new(None); + +/// 标志:show_popup 兜底创建路径设为 true,前端 onMounted 回调 show_window 时据此判断是否显示。 +/// 预创建路径不设置,避免应用启动时弹窗自动弹出。 +static POPUP_PENDING_SHOW: AtomicBool = AtomicBool::new(false); + +/// 自定义命令 +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CustomCommand { + pub id: String, + pub title: String, + pub command: String, + #[serde(default)] + pub args: Vec, +} + +/// 快速面板设置 +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QuickPanelSettings { + /// 全局快捷键(如 "Alt+Space"),空字符串表示不注册。 + #[serde(default = "default_shortcut")] + pub shortcut: String, + /// 唤起位置:center(鼠标所在显示器中央)| cursor(鼠标位置) + #[serde(default = "default_popup_position")] + pub popup_position: String, + /// 默认搜索引擎:google | bing | baidu + #[serde(default = "default_search_engine")] + pub search_engine: String, + /// 文件索引目录列表(空列表表示使用默认:桌面/文档/下载) + #[serde(default = "default_index_dirs")] + pub index_dirs: Vec, + /// 自定义命令列表 + #[serde(default)] + pub custom_commands: Vec, +} + +fn default_shortcut() -> String { + "Alt+Space".to_string() +} +fn default_popup_position() -> String { + "center".to_string() +} +fn default_search_engine() -> String { + "bing".to_string() +} +fn default_index_dirs() -> Vec { + // 桌面/文档/下载目录(延迟到实际使用时解析,避免启动时失败) + let mut dirs = Vec::new(); + if let Some(d) = dirs::desktop_dir() { + dirs.push(d.to_string_lossy().to_string()); + } + if let Some(d) = dirs::document_dir() { + dirs.push(d.to_string_lossy().to_string()); + } + if let Some(d) = dirs::download_dir() { + dirs.push(d.to_string_lossy().to_string()); + } + dirs +} + +impl Default for QuickPanelSettings { + fn default() -> Self { + Self { + shortcut: default_shortcut(), + popup_position: default_popup_position(), + search_engine: default_search_engine(), + index_dirs: default_index_dirs(), + custom_commands: Vec::new(), + } + } +} + +/// 设置文件路径:{app_data_dir}/quickpanel/settings.json +fn settings_path(app: &AppHandle) -> PathBuf { + app.path() + .app_data_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join("quickpanel") + .join("settings.json") +} + +/// 读取设置,文件不存在或解析失败返回默认值 +pub fn load_settings(app: &AppHandle) -> QuickPanelSettings { + let path = settings_path(app); + std::fs::read_to_string(&path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() +} + +/// 保存设置到磁盘 +pub fn save_settings(app: &AppHandle, settings: &QuickPanelSettings) -> Result<(), String> { + let path = settings_path(app); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("创建设置目录失败: {}", e))?; + } + let json = serde_json::to_string_pretty(settings).map_err(|e| format!("序列化设置失败: {}", e))?; + std::fs::write(&path, json).map_err(|e| format!("写入设置文件失败: {}", e)) +} + +/// 解析快捷键字符串为 Shortcut(格式如 "Alt+Space"、"Ctrl+Shift+P") +/// 失败返回 None。 +pub fn parse_shortcut(s: &str) -> Option { + s.trim().parse::().ok() +} + +/// 注册全局快捷键。重复调用会先注销旧快捷键。 +/// 传入空字符串则仅注销不注册。 +pub fn register_shortcut(app: &AppHandle, shortcut_str: &str) -> Result<(), String> { + // 先注销旧快捷键 + unregister_shortcut(app); + + if shortcut_str.trim().is_empty() { + return Ok(()); + } + + let shortcut = parse_shortcut(shortcut_str) + .ok_or_else(|| format!("无效的快捷键: {}", shortcut_str))?; + + let app_handle = app.clone(); + app.global_shortcut() + .on_shortcut(shortcut, move |_app, _shortcut, event| { + // 仅在按下时触发(松开不触发) + if event.state == ShortcutState::Pressed { + show_popup(&app_handle); + } + }) + .map_err(|e| format!("注册快捷键失败: {}", e))?; + + if let Ok(mut cur) = CURRENT_SHORTCUT.lock() { + *cur = Some(shortcut_str.to_string()); + } + eprintln!("[quickpanel] 已注册快捷键: {}", shortcut_str); + Ok(()) +} + +/// 注销当前快捷键 +pub fn unregister_shortcut(app: &AppHandle) { + if let Ok(cur) = CURRENT_SHORTCUT.lock() { + if let Some(ref s) = *cur { + if let Some(shortcut) = parse_shortcut(s) { + let _ = app.global_shortcut().unregister(shortcut); + } + } + } + if let Ok(mut cur) = CURRENT_SHORTCUT.lock() { + *cur = None; + } +} + +/// 创建弹窗窗口(隐藏状态)并注册失焦监听。 +/// 位置默认在屏幕外,show_popup 时会重新定位到鼠标所在显示器中央。 +/// 预创建后首次按快捷键走"窗口已存在"分支直接 show,避免首次创建的时序问题。 +fn create_popup_window(app: &AppHandle) { + let win = match WebviewWindowBuilder::new( + app, + POPUP_LABEL, + WebviewUrl::App("index.html#quick-panel".into()), + ) + .title("快速面板") + .inner_size(WIN_W, WIN_H) + .position(-10000.0, -10000.0) // 屏幕外,避免隐藏时一闪 + .decorations(false) + .transparent(true) + .shadow(true) + .always_on_top(true) + .skip_taskbar(true) + .resizable(false) + .visible(false) + .focused(false) // 不抢占焦点,避免创建即触发 Focused(false) + .effects(EffectsBuilder::new().effects(vec![Effect::Mica]).build()) + .build() + { + Ok(w) => w, + Err(e) => { + eprintln!("[quickpanel] 创建弹窗失败: {}", e); + return; + } + }; + + // 监听窗口失焦:自动隐藏 + let app_handle = app.clone(); + let win_handle = win.clone(); + win.on_window_event(move |event| { + if let tauri::WindowEvent::Focused(false) = event { + let _ = win_handle.hide(); + let _ = app_handle.emit("quickpanel-hide", ()); + } + }); + + eprintln!("[quickpanel] 弹窗窗口已预创建(隐藏状态)"); +} + +/// 应用启动时预创建弹窗窗口(隐藏)。 +/// 这样首次按快捷键时窗口已存在,直接 show + 定位,避免首次创建时序问题。 +pub fn ensure_window(app: &AppHandle) { + if app.get_webview_window(POPUP_LABEL).is_some() { + return; + } + create_popup_window(app); +} + +/// 在指定位置显示弹窗。 +/// popup_position = "cursor" 时在鼠标位置附近显示,否则在鼠标所在显示器中央显示。 +/// 窗口不存在则创建(隐藏状态,等前端挂载后调用 show_window 显示)。 +pub fn show_popup(app: &AppHandle) { + let settings = load_settings(app); + let cursor_mode = settings.popup_position == "cursor"; + + // 获取鼠标位置(物理像素) + let (mx, my) = match get_cursor_pos() { + Some(p) => p, + None => return, + }; + + // 获取光标所在显示器的工作区(物理像素,与 get_cursor_pos 同一坐标系) + let (wa_left, wa_top, wa_right, wa_bottom) = get_work_area_at_point(mx, my) + .unwrap_or((0, 0, 1920, 1040)); + + // 获取光标所在显示器的 DPI,将物理坐标转为逻辑坐标(DIP) + let dpi = get_dpi_for_point(mx, my).unwrap_or(96); + let scale = dpi as f64 / 96.0; + + let wa_left_l = wa_left as f64 / scale; + let wa_top_l = wa_top as f64 / scale; + let wa_right_l = wa_right as f64 / scale; + let wa_bottom_l = wa_bottom as f64 / scale; + + let (x, y) = if cursor_mode { + // 鼠标位置模式:以鼠标为基准偏移,clamp 到工作区内 + let mx_l = mx as f64 / scale; + let my_l = my as f64 / scale; + let x = (mx_l + 12.0).min(wa_right_l - WIN_W).max(wa_left_l); + let y = (my_l + 12.0).min(wa_bottom_l - WIN_H).max(wa_top_l); + (x, y) + } else { + // 中央模式:窗口居中于鼠标所在显示器工作区 + let wa_w = wa_right_l - wa_left_l; + let wa_h = wa_bottom_l - wa_top_l; + (wa_left_l + (wa_w - WIN_W) / 2.0, wa_top_l + (wa_h - WIN_H) / 2.0) + }; + + // 窗口已存在:移动 + 显示 + 请求焦点 + if let Some(win) = app.get_webview_window(POPUP_LABEL) { + let _ = win.set_position(tauri::Position::Logical(tauri::LogicalPosition { x, y })); + let _ = win.show(); + let _ = win.set_focus(); + // 通知前端刷新数据 + let _ = app.emit("quickpanel-show", ()); + return; + } + + // 兜底:窗口被销毁时重新创建(隐藏),等前端 onMounted 回调 show_window + POPUP_PENDING_SHOW.store(true, Ordering::SeqCst); + create_popup_window(app); +} + +/// 显示已创建的弹窗窗口(由前端 onMounted 后调用)。 +/// 预创建路径下前端 onMounted 也会调用此函数,但 POPUP_PENDING_SHOW 为 false 时直接跳过, +/// 避免应用启动时弹窗自动弹出。仅 show_popup 兜底创建路径才真正显示。 +pub fn show_window(app: &AppHandle) { + if !POPUP_PENDING_SHOW.swap(false, Ordering::SeqCst) { + return; + } + if let Some(win) = app.get_webview_window(POPUP_LABEL) { + let _ = win.show(); + let _ = win.set_focus(); + // 通知前端刷新数据 + let _ = app.emit("quickpanel-show", ()); + } +} + +/// 隐藏弹窗(不销毁,保留复用) +pub fn hide_popup(app: &AppHandle) { + if let Some(win) = app.get_webview_window(POPUP_LABEL) { + let _ = win.hide(); + } +} diff --git a/src/App.vue b/src/App.vue index c8d66bb..f1fce6e 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,6 +1,8 @@ - - \ No newline at end of file diff --git a/src/modules/finder/index.ts b/src/modules/finder/index.ts deleted file mode 100644 index be8f6c9..0000000 --- a/src/modules/finder/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { ModuleConfig } from '@/types/module' -import type { SearchIndexItem } from '@/stores/searchIndex' - -const searchItems: SearchIndexItem[] = [ - { - title: '文件搜索', - description: '搜索本地文件', - keywords: ['文件', '搜索', 'finder', 'search', 'file'] - } -] - -export const moduleConfig: ModuleConfig = { - id: 'finder', - name: '文件搜索', - icon: 'finder', - description: '快速文件搜索、拼音模糊匹配', - category: 'tool', - defaultEnabled: true, - loader: () => import('./FinderModule.vue'), - searchItems, - order: 60 -} diff --git a/src/modules/icons.ts b/src/modules/icons.ts index 6a736aa..dab666e 100644 --- a/src/modules/icons.ts +++ b/src/modules/icons.ts @@ -6,7 +6,7 @@ import { Camera, Activity, Download, - Search + Command } from '@lucide/vue' /** @@ -23,7 +23,7 @@ export const moduleIconMap: Record = { screenshot: Camera, monitor: Activity, downloader: Download, - finder: Search + quickpanel: Command } /** 获取模块图标组件,未找到时回退到 Settings 图标 */ diff --git a/src/modules/index.ts b/src/modules/index.ts index cf9dc0d..cfe781f 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -7,7 +7,7 @@ import { moduleConfig as clipboard } from './clipboard' import { moduleConfig as screenshot } from './screenshot' import { moduleConfig as monitor } from './monitor' import { moduleConfig as downloader } from './downloader' -import { moduleConfig as finder } from './finder' +import { moduleConfig as quickpanel } from './quickpanel' import { moduleConfig as general } from './general' const allModules: ModuleConfig[] = [ @@ -16,7 +16,7 @@ const allModules: ModuleConfig[] = [ screenshot, monitor, downloader, - finder, + quickpanel, general ] diff --git a/src/modules/quickpanel/QuickPanel.vue b/src/modules/quickpanel/QuickPanel.vue new file mode 100644 index 0000000..26aa207 --- /dev/null +++ b/src/modules/quickpanel/QuickPanel.vue @@ -0,0 +1,760 @@ + + + + + diff --git a/src/modules/quickpanel/QuickPanelModule.vue b/src/modules/quickpanel/QuickPanelModule.vue new file mode 100644 index 0000000..216e79f --- /dev/null +++ b/src/modules/quickpanel/QuickPanelModule.vue @@ -0,0 +1,549 @@ + + + diff --git a/src/modules/quickpanel/engine.ts b/src/modules/quickpanel/engine.ts new file mode 100644 index 0000000..7511470 --- /dev/null +++ b/src/modules/quickpanel/engine.ts @@ -0,0 +1,134 @@ +/** + * 快速面板匹配引擎 + * + * 策略:子序列 fuzzy 匹配 + 拼音全拼/首字母多形态。 + * - 对每个文本生成三种匹配形态:原文、拼音全拼(连写)、拼音首字母 + * - query 对每种形态做子序列匹配,连续命中 + 首字母命中加权 + * - 取最高分作为该 item 的得分 + * + * 拼音形态惰性计算并缓存(WeakMap),避免每次输入重算。 + */ + +import { pinyin } from 'pinyin-pro' + +/** 一组待匹配的文本形态(原文 / 全拼或原文 / 首字母 / 多单词首字母) */ +export type TextForms = readonly [string, string, string, string] + +const formsCache = new WeakMap() + +/** 判断字符串是否含 CJK 字符(需转拼音) */ +function hasCJK(s: string): boolean { + return /[\u4e00-\u9fff]/.test(s) +} + +/** 提取英文字符串中各单词的首字母(如 "Visual Studio Code" → "vsc")。 + * 单词边界:空格、连字符、下划线、点号;仅对以字母开头的单词取首字母。 */ +function extractWordInitials(text: string): string { + const parts = text.split(/[\s\-_.]+/).filter(Boolean) + let initials = '' + for (const p of parts) { + // 跳过非字母开头的 token(如数字开头、纯符号) + if (/^[a-zA-Z]/.test(p)) { + initials += p.charAt(0).toLowerCase() + } + } + return initials +} + +/** + * 为文本生成匹配形态:[原文(小写), 拼音全拼(小写连写), 拼音首字母(小写), 多单词首字母(小写)]。 + * 非中文文本:全拼与首字母回退为原文,多单词首字母仍独立计算(用于 "Visual Studio Code" → "vsc")。 + * 结果按 host 对象缓存,避免重复计算。 + */ +export function getTextForms(text: string, host: object): TextForms { + const cached = formsCache.get(host) + if (cached) return cached + + const lower = text.toLowerCase() + // 多单词首字母:无论中英文都计算,与拼音形态互补 + const initials = extractWordInitials(text) + + let forms: TextForms + if (!hasCJK(text)) { + // 纯英文:全拼与首字母回退为原文,多单词首字母独立 + forms = [lower, lower, lower, initials] + } else { + // 拼音全拼数组,toneType:none 去声调 + const full = pinyin(text, { toneType: 'none', type: 'array' }) as string[] + const fullStr = full.join('').toLowerCase() + const firstStr = full.map(s => s.charAt(0)).join('').toLowerCase() + forms = [lower, fullStr, firstStr, initials] + } + formsCache.set(host, forms) + return forms +} + +/** + * 子序列匹配评分。 + * - 不匹配返回 -1 + * - 基础分 = 命中字符数 / target 长度(越紧凑越高) + * - 连续命中加权(每段连续命中 +0.15) + * - 首字母命中加权(target[i] === query[0] 且 i==0 或前一个字符非字母 +0.1) + * - query 完全等于 target 时返回 1.5(精确匹配优先) + */ +export function fuzzyScore(query: string, target: string): number { + if (!query) return 0 + if (!target) return -1 + + const q = query.toLowerCase() + const t = target.toLowerCase() + + // 精确匹配 + if (q === t) return 1.5 + // 前缀匹配 + if (t.startsWith(q)) return 1.2 + // 包含匹配 + if (t.includes(q)) return 1.0 + + // 子序列匹配 + let qi = 0 + let prevMatched = false + let score = 0 + let consecutiveBonus = 0 + + for (let ti = 0; ti < t.length && qi < q.length; ti++) { + if (t[ti] === q[qi]) { + // 命中 + score += 1 + // 连续命中加权 + if (prevMatched) { + consecutiveBonus += 0.15 + } + // 首字母命中加权(target 开头或前一字符为非字母) + if (qi === 0 && (ti === 0 || !/[a-z0-9]/.test(t[ti - 1]))) { + score += 0.1 + } + prevMatched = true + qi++ + } else { + prevMatched = false + } + } + + // 未完全匹配 + if (qi < q.length) return -1 + + // 命中密度:命中字符占 target 比例(越短 target 越优先) + const density = q.length / t.length + // 归一化到 0~1 区间(基础命中分 + 连续加权 + 密度) + const finalScore = 0.5 + density * 0.3 + (score - q.length) * 0.05 + consecutiveBonus * 0.1 + + return Math.min(finalScore, 0.99) +} + +/** + * 对一组文本形态取最高匹配分。 + */ +export function bestScore(query: string, forms: TextForms): number { + let best = -1 + for (const form of forms) { + const s = fuzzyScore(query, form) + if (s > best) best = s + } + return best +} diff --git a/src/modules/quickpanel/index.ts b/src/modules/quickpanel/index.ts new file mode 100644 index 0000000..24bc00d --- /dev/null +++ b/src/modules/quickpanel/index.ts @@ -0,0 +1,45 @@ +import type { ModuleConfig } from '@/types/module' +import type { SearchIndexItem } from '@/stores/searchIndex' + +const searchItems: SearchIndexItem[] = [ + { + title: '快速面板', + description: '全局快捷键唤起命令面板', + keywords: ['快速面板', '快速启动', '搜索', '命令', 'quickpanel', 'launcher', 'spotlight'] + } +] + +export const moduleConfig: ModuleConfig = { + id: 'quickpanel', + name: '快速面板', + icon: 'quickpanel', + description: '全局快捷键唤起的多源命令面板(命令/应用/文件/计算)', + category: 'tool', + defaultEnabled: true, + loader: () => import('./QuickPanelModule.vue'), + searchItems, + lifecycle: { + // 模块启用:读取设置并注册全局快捷键 + onEnable: async () => { + const { invoke } = await import('@tauri-apps/api/core') + try { + const settings = await invoke<{ shortcut: string }>('quickpanel_get_settings') + if (settings.shortcut) { + await invoke('quickpanel_register_shortcut', { shortcut: settings.shortcut }) + } + } catch (e) { + console.error('[quickpanel] onEnable 注册快捷键失败:', e) + } + }, + // 模块禁用:注销全局快捷键 + onDisable: async () => { + const { invoke } = await import('@tauri-apps/api/core') + try { + await invoke('quickpanel_unregister_shortcut') + } catch (e) { + console.error('[quickpanel] onDisable 注销快捷键失败:', e) + } + } + }, + order: 60 +} diff --git a/src/modules/quickpanel/providers.ts b/src/modules/quickpanel/providers.ts new file mode 100644 index 0000000..81f66ba --- /dev/null +++ b/src/modules/quickpanel/providers.ts @@ -0,0 +1,889 @@ +/** + * 快速面板 Provider:多源搜索结果聚合。 + * + * 每个 Provider 实现统一 search(query) 接口,返回带 group 的 QPItem 列表。 + * 引擎对结果统一打分排序,action 执行后由调用方隐藏窗口。 + * + * 独立窗口约束:不加载主应用 store。 + * - command Provider 从 localStorage 读取主应用写入的命令缓存, + * 执行时 emit `quickpanel-execute-command` 事件通知主窗口切换模块。 + * - system/web/calc Provider 纯前端 + Rust invoke。 + */ + +import { invoke } from '@tauri-apps/api/core' +import { emit } from '@tauri-apps/api/event' +import { getTextForms, bestScore, type TextForms } from './engine' +import { openUrl } from '@tauri-apps/plugin-opener' + +// ===== 结果项与 Provider 接口 ===== + +/** 子动作(项的右键/展开菜单) */ +export interface QPSubAction { + id: string + label: string + action: () => void | Promise +} + +export interface QPItem { + id: string + title: string + subtitle?: string + group: string + score?: number + /** 应用图标 data URL('' = 加载中,undefined = 无图标项) */ + iconUrl?: string + /** 应用路径(仅 app 项设置,用于按需获取图标) */ + iconPath?: string + /** 执行动作(调用方在执行后负责隐藏窗口) */ + action: () => void | Promise + /** 子动作菜单(可选)。执行子动作后同样隐藏窗口 */ + subActions?: QPSubAction[] + /** 用于历史记录的查询文本(仅历史项设置,点击历史时用此重新搜索恢复 action) */ + historyQuery?: string +} + +export interface QPProvider { + id: string + label: string + priority: number + /** 返回当前 query 的候选结果(引擎尚未打分,score 可留空) */ + search(query: string): QPItem[] | Promise +} + +// ===== 工具:为 item 构建匹配形态(用于引擎打分) ===== + +/** 由 title + keywords 组合出待匹配文本形态(host 对象用于缓存) */ +function buildItemForms(title: string, keywords: string[] = []): TextForms { + const host = { title, keywords } + const combined = [title, ...keywords].join(' ') + return getTextForms(combined, host) +} + +// ===== command Provider:复用主应用模块搜索项 ===== + +const COMMANDS_KEY = 'thing_quickpanel_commands' + +interface CachedCommand { + moduleId: string + moduleName: string + title: string + description?: string + keywords: string[] +} + +function loadCommands(): CachedCommand[] { + try { + const raw = localStorage.getItem(COMMANDS_KEY) + if (!raw) return [] + return JSON.parse(raw) as CachedCommand[] + } catch { + return [] + } +} + +class CommandProvider implements QPProvider { + id = 'command' + label = '命令' + priority = 100 + + search(query: string): QPItem[] { + const commands = loadCommands() + if (!query.trim() || !commands.length) { + // 无输入时返回前几条命令作为快捷入口 + if (!query.trim()) { + return commands.slice(0, 6).map((c, i) => this.toItem(c, i)) + } + return [] + } + + const results: Array<{ item: QPItem; score: number }> = [] + commands.forEach((c, idx) => { + const forms = buildItemForms(c.title, c.keywords) + const score = bestScore(query, forms) + if (score >= 0) { + const item = this.toItem(c, idx) + results.push({ item, score }) + } + }) + results.sort((a, b) => b.score - a.score) + return results.map(r => ({ ...r.item, score: r.score })) + } + + private toItem(c: CachedCommand, idx: number): QPItem { + return { + id: `cmd-${c.moduleId}-${idx}`, + title: c.title, + subtitle: c.description || c.moduleName, + group: '命令', + action: async () => { + // 通知主窗口切换到对应模块 + await emit('quickpanel-execute-command', { moduleId: c.moduleId }) + }, + } + } +} + +// ===== calc Provider:输入即算 ===== + +const CALC_RE = /^[\d\s+\-*/().%]+$/ + +class CalcProvider implements QPProvider { + id = 'calc' + label = '计算' + priority = 90 + + search(query: string): QPItem[] { + const trimmed = query.trim() + if (!trimmed) return [] + // 必须至少包含一个运算符和一个数字 + if (!CALC_RE.test(trimmed)) return [] + if (!/[\d]/.test(trimmed) || !/[+\-*/%]/.test(trimmed)) return [] + + try { + // 限制字符已由正则保证,用 Function 计算避免 eval 作用域污染 + // eslint-disable-next-line no-new-func + const result = Function(`"use strict"; return (${trimmed})`)() + if (typeof result !== 'number' || !isFinite(result)) return [] + const display = String(result) + return [{ + id: 'calc-result', + title: display, + subtitle: `= ${trimmed}`, + group: '计算', + score: 0.95, + action: async () => { + try { + await navigator.clipboard.writeText(display) + } catch { + /* 忽略剪贴板失败 */ + } + }, + }] + } catch { + return [] + } + } +} + +// ===== web Provider:默认搜索建议 ===== + +type SearchEngine = 'google' | 'bing' | 'baidu' +const ENGINE_URL: Record = { + google: 'https://www.google.com/search?q=', + bing: 'https://www.bing.com/search?q=', + baidu: 'https://www.baidu.com/s?wd=', +} + +function getSearchEngine(): SearchEngine { + try { + const raw = localStorage.getItem('thing_quickpanel_settings') + if (raw) { + const s = JSON.parse(raw) + if (s.searchEngine && ENGINE_URL[s.searchEngine as SearchEngine]) { + return s.searchEngine + } + } + } catch { + /* 忽略 */ + } + return 'bing' +} + +class WebProvider implements QPProvider { + id = 'web' + label = '网页' + priority = 50 + + search(query: string): QPItem[] { + const trimmed = query.trim() + if (!trimmed) return [] + const engine = getSearchEngine() + return [{ + id: 'web-search', + title: `搜索「${trimmed}」`, + subtitle: `在 ${engine} 中打开`, + group: '网页', + score: 0.3, + action: async () => { + try { + await openUrl(ENGINE_URL[engine] + encodeURIComponent(trimmed)) + } catch { + /* 忽略 */ + } + }, + }] + } +} + +// ===== system Provider:系统操作 ===== + +interface SystemCommandDef { + id: string + title: string + subtitle: string + /** 额外关键词(英文命令名、中文别名等,用于匹配) */ + keywords: string[] + command: string + args: string[] +} + +/** 内置系统命令。title 为中文主名,keywords 补充英文/别名, + * 拼音全拼与首字母由引擎从 title 的 CJK 部分自动推导。 */ +const SYSTEM_COMMANDS: SystemCommandDef[] = [ + { + id: 'sys-regedit', + title: '注册表编辑器', + subtitle: 'regedit', + keywords: ['regedit', '注册表', 'registry'], + command: 'regedit', + args: [], + }, + { + id: 'sys-cmd', + title: '命令提示符', + subtitle: 'cmd', + keywords: ['cmd', '命令行', '终端', 'command'], + command: 'cmd', + args: [], + }, + { + id: 'sys-powershell', + title: 'PowerShell', + subtitle: 'powershell', + keywords: ['powershell', 'pwsh'], + command: 'powershell', + args: [], + }, + { + id: 'sys-taskmgr', + title: '任务管理器', + subtitle: 'taskmgr', + keywords: ['taskmgr', '任务管理', '进程'], + command: 'taskmgr', + args: [], + }, + { + id: 'sys-explorer', + title: '资源管理器', + subtitle: 'explorer', + keywords: ['explorer', '文件管理器', '资源管理'], + command: 'explorer', + args: [], + }, + { + id: 'sys-control', + title: '控制面板', + subtitle: 'control', + keywords: ['control', '控制面板', '设置'], + command: 'control', + args: [], + }, + { + id: 'sys-shutdown', + title: '关机', + subtitle: 'shutdown /s /t 0', + keywords: ['shutdown', '关闭计算机', '关闭电脑', 'guanji'], + command: 'shutdown', + args: ['/s', '/t', '0'], + }, + { + id: 'sys-restart', + title: '重启', + subtitle: 'shutdown /r /t 0', + keywords: ['restart', 'reboot', '重新启动', '重启电脑', 'chongqi'], + command: 'shutdown', + args: ['/r', '/t', '0'], + }, + { + id: 'sys-shutdown-cancel', + title: '取消关机/重启', + subtitle: 'shutdown /a', + keywords: ['cancel', '取消', 'quxiao', 'abort'], + command: 'shutdown', + args: ['/a'], + }, + { + id: 'sys-hibernate', + title: '休眠', + subtitle: 'shutdown /h', + keywords: ['hibernate', '睡眠', 'xiu', 'mian'], + command: 'shutdown', + args: ['/h'], + }, +] + +class SystemProvider implements QPProvider { + id = 'system' + label = '系统' + priority = 40 + + private buildItems(): QPItem[] { + const items: QPItem[] = SYSTEM_COMMANDS.map(def => ({ + id: def.id, + title: def.title, + subtitle: def.subtitle, + group: '系统', + action: async () => { + try { + await invoke('quickpanel_run_system_command', { + command: def.command, + args: def.args, + }) + } catch (e) { + console.error('[quickpanel] 系统命令失败:', e) + } + }, + })) + // 锁屏 + 退出 应用本身 + items.push( + { + id: 'sys-lock', + title: '锁定屏幕', + subtitle: '立即锁定计算机', + group: '系统', + action: async () => { + try { + await invoke('quickpanel_lock_screen') + } catch (e) { + console.error('[quickpanel] 锁屏失败:', e) + } + }, + }, + { + id: 'sys-quit', + title: '退出 Thing', + subtitle: '关闭应用程序', + group: '系统', + action: async () => { + try { + await invoke('quit_app') + } catch (e) { + console.error('[quickpanel] 退出失败:', e) + } + }, + }, + ) + return items + } + + /** 为带 keywords 的 item 构建匹配形态(title + keywords 合并) */ + private itemForms(item: QPItem): TextForms { + const def = SYSTEM_COMMANDS.find(d => d.id === item.id) + return buildItemForms(item.title, def?.keywords ?? []) + } + + search(query: string): QPItem[] { + const items = this.buildItems() + + if (!query.trim()) return items + const scored: Array<{ item: QPItem; score: number }> = [] + for (const item of items) { + const forms = this.itemForms(item) + const score = bestScore(query, forms) + if (score >= 0) scored.push({ item, score }) + } + scored.sort((a, b) => b.score - a.score) + return scored.map(s => ({ ...s.item, score: s.score })) + } +} + +// ===== app Provider:扫描开始菜单应用 ===== + +interface AppRecord { + name: string + path: string +} + +let appCache: AppRecord[] | null = null +let appCacheTime = 0 +const APP_CACHE_TTL = 60_000 // 1 分钟缓存 + +async function loadApps(): Promise { + if (appCache && Date.now() - appCacheTime < APP_CACHE_TTL) { + return appCache + } + try { + const apps = await invoke('quickpanel_scan_apps') + appCache = apps + appCacheTime = Date.now() + return apps + } catch (e) { + console.error('[quickpanel] 扫描应用失败:', e) + return [] + } +} + +class AppProvider implements QPProvider { + id = 'app' + label = '应用' + priority = 95 + + async search(query: string): Promise { + const apps = await loadApps() + if (!query.trim()) { + // 空查询:不显示应用(避免列表过长),由命令入口承担 + return [] + } + const results: Array<{ item: QPItem; score: number }> = [] + let idx = 0 + for (const app of apps) { + const forms = buildItemForms(app.name) + const score = bestScore(query, forms) + if (score >= 0) { + const launch = async () => { + try { + // .lnk 文件不能用 openUrl 打开,需直接 spawn + await invoke('quickpanel_run_custom_command', { + command: app.path, + args: [], + }) + } catch (e) { + console.error('[quickpanel] 启动应用失败:', e) + } + } + results.push({ + item: { + id: `app-${idx}`, + title: app.name, + subtitle: app.path, + group: '应用', + iconPath: app.path, + action: launch, + subActions: [ + { id: 'launch', label: '启动', action: launch }, + { + id: 'reveal', + label: '在资源管理器中显示', + action: async () => { + try { + await invoke('quickpanel_reveal_in_explorer', { path: app.path }) + } catch (e) { + console.error('[quickpanel] 资源管理器显示失败:', e) + } + }, + }, + { + id: 'copy-path', + label: '复制路径', + action: async () => { + try { + await navigator.clipboard.writeText(app.path) + } catch { + /* 忽略 */ + } + }, + }, + ], + }, + score, + }) + } + idx++ + } + results.sort((a, b) => b.score - a.score) + return results.slice(0, 15).map(r => ({ ...r.item, score: r.score })) + } +} + +// ===== 应用图标按需加载 ===== +// 前端缓存(path -> dataUrl)。Rust 侧另有内存 + 磁盘缓存,此处仅避免重复 IPC。 + +const appIconCache = new Map() // path -> dataUrl('' = 无图标) + +/** 为搜索结果中带 iconPath 的项(应用、历史中的应用)按需加载图标(data URL), + * 并写入 item.iconUrl 触发响应式更新。 + * 命中前端缓存时同步返回;否则异步调用 Rust 命令(命中 Rust 缓存则零开销)。 */ +export async function loadAppIconsForResults(items: QPItem[]): Promise { + const toLoad: QPItem[] = [] + for (const item of items) { + if (!item.iconPath) continue + if (item.iconUrl !== undefined) continue // 已设置(含加载中) + const cached = appIconCache.get(item.iconPath) + if (cached !== undefined) { + item.iconUrl = cached + } else { + item.iconUrl = '' // 标记加载中,避免重复请求 + toLoad.push(item) + } + } + if (!toLoad.length) return + await Promise.all( + toLoad.map(async item => { + const path = item.iconPath! + try { + const url = await invoke('quickpanel_get_app_icon', { path }) + const u = url ?? '' + appIconCache.set(path, u) + item.iconUrl = u + } catch { + appIconCache.set(path, '') + item.iconUrl = '' + } + }), + ) +} + +/** 清空前端图标缓存(Rust 端清理命令 quickpanel_clear_app_icon_cache 调用后可一并清空) */ +export function invalidateAppIconCache() { + appIconCache.clear() +} + +// ===== file Provider:文件索引搜索 ===== + +interface FileRecord { + path: string + name: string + ext: string + size: number + isDir: boolean +} + +let fileIndexReady = false + +class FileProvider implements QPProvider { + id = 'file' + label = '文件' + priority = 85 + + async search(query: string): Promise { + if (!query.trim() || query.trim().length < 2) return [] + if (!fileIndexReady) return [] + try { + const files = await invoke('quickpanel_search_files', { + query: query.trim(), + limit: 20, + }) + return files.map((f, idx) => { + const openFile = async () => { + try { + // 用系统默认程序打开;无关联应用时 Rust 端会 fallback 到「打开方式」对话框 + await invoke('quickpanel_open_file', { path: f.path }) + } catch (e) { + console.error('[quickpanel] 打开文件失败:', e) + } + } + return { + id: `file-${idx}`, + title: f.name, + subtitle: f.path, + group: '文件', + score: 0.6, + action: openFile, + subActions: [ + { + id: 'open', + label: f.isDir ? '打开文件夹' : '打开', + action: openFile, + }, + { + id: 'reveal', + label: '在资源管理器中显示', + action: async () => { + try { + await invoke('quickpanel_reveal_in_explorer', { path: f.path }) + } catch (e) { + console.error('[quickpanel] 资源管理器显示失败:', e) + } + }, + }, + { + id: 'copy-path', + label: '复制路径', + action: async () => { + try { + await navigator.clipboard.writeText(f.path) + } catch { + /* 忽略 */ + } + }, + }, + { + id: 'delete', + label: '删除', + action: async () => { + try { + // 移到回收站:explorer.exe 不直接支持,用 PowerShell 或直接删除 + // 这里用 Rust 命令删除(简化实现,实际移到回收站需 SHFileOperation) + await invoke('quickpanel_delete_file', { path: f.path }) + } catch (e) { + console.error('[quickpanel] 删除失败:', e) + } + }, + }, + ], + } + }) + } catch (e) { + console.error('[quickpanel] 文件搜索失败:', e) + return [] + } + } +} + +/** 由设置页在索引构建完成后调用,启用 file Provider */ +export function setFileIndexReady(ready: boolean) { + fileIndexReady = ready +} + +// ===== clipboard Provider:复用剪贴板历史 ===== + +interface ClipboardSearchItem { + id: number + kind: string + preview: string + createdAt: number +} + +class ClipboardProvider implements QPProvider { + id = 'clipboard' + label = '剪贴板' + priority = 70 + + async search(query: string): Promise { + if (!query.trim() || query.trim().length < 2) return [] + try { + const items = await invoke('clipboard_search', { + query: query.trim(), + limit: 8, + offset: 0, + }) + return items.map((c) => ({ + id: `clip-${c.id}`, + title: c.preview.slice(0, 80), + subtitle: `${c.kind === 'text' ? '文本' : c.kind === 'image' ? '图片' : '文件'}`, + group: '剪贴板', + score: 0.5, + action: async () => { + try { + await invoke('clipboard_copy_back', { id: c.id }) + } catch (e) { + console.error('[quickpanel] 复制失败:', e) + } + }, + })) + } catch { + // 剪贴板模块可能未启用,静默忽略 + return [] + } + } +} + +// ===== customCommand Provider:用户自定义命令 ===== + +interface CustomCommandConfig { + id: string + title: string + command: string + args: string[] +} + +let customCommandsCache: CustomCommandConfig[] | null = null + +async function loadCustomCommands(): Promise { + if (customCommandsCache) return customCommandsCache + try { + const s = await invoke<{ customCommands: CustomCommandConfig[] }>('quickpanel_get_settings') + customCommandsCache = s.customCommands || [] + return customCommandsCache + } catch { + return [] + } +} + +/** 设置页保存后调用,清除缓存使下次搜索重新加载 */ +export function invalidateCustomCommandsCache() { + customCommandsCache = null +} + +class CustomCommandProvider implements QPProvider { + id = 'custom' + label = '自定义' + priority = 92 + + async search(query: string): Promise { + const commands = await loadCustomCommands() + if (!query.trim()) return [] + const results: Array<{ item: QPItem; score: number }> = [] + for (const cmd of commands) { + const forms = buildItemForms(cmd.title) + const score = bestScore(query, forms) + if (score >= 0) { + results.push({ + item: { + id: `custom-${cmd.id}`, + title: cmd.title, + subtitle: cmd.command, + group: '自定义', + action: async () => { + try { + await invoke('quickpanel_run_custom_command', { + command: cmd.command, + args: cmd.args, + }) + } catch (e) { + console.error('[quickpanel] 自定义命令执行失败:', e) + } + }, + }, + score, + }) + } + } + results.sort((a, b) => b.score - a.score) + return results.map(r => ({ ...r.item, score: r.score })) + } +} + +// ===== history Provider:最近交互记录 ===== + +interface HistoryEntry { + id: string + title: string + subtitle?: string + group: string + iconPath?: string + /** 记录时的查询文本,用于点击历史项时重新搜索恢复 action */ + query: string + timestamp: number +} + +const HISTORY_ITEMS_KEY = 'thing_quickpanel_history_items' +const HISTORY_MAX = 50 + +/** 空查询时默认展示的历史条数(置顶部分) */ +export const HISTORY_PREVIEW_COUNT = 3 + +function loadHistoryEntries(): HistoryEntry[] { + try { + const raw = localStorage.getItem(HISTORY_ITEMS_KEY) + if (!raw) return [] + return JSON.parse(raw) as HistoryEntry[] + } catch { + return [] + } +} + +function saveHistoryEntries(entries: HistoryEntry[]) { + localStorage.setItem(HISTORY_ITEMS_KEY, JSON.stringify(entries.slice(0, HISTORY_MAX))) +} + +/** 将一条历史记录转换为可执行的 QPItem */ +function buildHistoryItem(e: HistoryEntry): QPItem { + return { + id: `history-${e.id}`, + title: e.title, + subtitle: e.subtitle, + group: '历史', + iconPath: e.iconPath, + historyQuery: e.query, + action: async () => { + // 重新搜索恢复 action 并执行 + try { + const results = await aggregateSearch(e.query) + // 按 id 精确匹配原 item + const target = results.find(r => r.id === e.id) ?? results.find(r => r.title === e.title) + if (target) { + await target.action() + } + } catch (err) { + console.error('[quickpanel] 历史项执行失败:', err) + } + }, + } +} + +/** 记录一次交互。在 QuickPanel.vue 执行 item 时调用。 + * query 为执行时的搜索文本(用于后续重建 action)。 */ +export function recordHistoryItem(item: QPItem, query: string) { + if (!item.id || item.group === '历史') return // 历史项自身不重复记录 + const entries = loadHistoryEntries() + // 去重:同 id 移除旧的,插到头部 + const filtered = entries.filter(e => e.id !== item.id) + filtered.unshift({ + id: item.id, + title: item.title, + subtitle: item.subtitle, + group: item.group, + iconPath: item.iconPath, + query: query || item.title, + timestamp: Date.now(), + }) + saveHistoryEntries(filtered.slice(0, HISTORY_MAX)) +} + +/** 清空历史记录 */ +export function clearHistory() { + localStorage.removeItem(HISTORY_ITEMS_KEY) +} + +/** 获取置顶的历史项(最近 N 条),用于空查询时在结果列表顶部显示 */ +export function getTopHistoryItems(): QPItem[] { + const entries = loadHistoryEntries() + return entries.slice(0, HISTORY_PREVIEW_COUNT).map(buildHistoryItem) +} + +/** 获取置顶历史之后的剩余历史项,用于 Accordion 折叠显示 */ +export function getMoreHistoryItems(): QPItem[] { + const entries = loadHistoryEntries() + return entries.slice(HISTORY_PREVIEW_COUNT).map(buildHistoryItem) +} + +/** 获取剩余历史数量(用于 Accordion 标题显示) */ +export function getMoreHistoryCount(): number { + const entries = loadHistoryEntries() + return Math.max(0, entries.length - HISTORY_PREVIEW_COUNT) +} + +class HistoryProvider implements QPProvider { + id = 'history' + label = '历史' + priority = 99 // 最高优先级,空查询时显示在最前 + + async search(query: string): Promise { + if (query.trim()) return [] // 历史只在空查询时显示 + // 只返回置顶3条,剩余由 Accordion 承载 + return getTopHistoryItems() + } +} + +// ===== Provider 注册 ===== + +let providers: QPProvider[] | null = null + +export function getProviders(): QPProvider[] { + if (!providers) { + providers = [ + new HistoryProvider(), + new CommandProvider(), + new CustomCommandProvider(), + new AppProvider(), + new FileProvider(), + new ClipboardProvider(), + new CalcProvider(), + new SystemProvider(), + new WebProvider(), + ] + } + return providers +} + +/** + * 聚合搜索:并行调用各 Provider,合并结果,按 score 降序排序。 + * 空查询时返回 command Provider 的快捷入口 + system Provider 的固定项。 + */ +export async function aggregateSearch(query: string): Promise { + const all = getProviders() + const results = await Promise.all(all.map(p => Promise.resolve(p.search(query)))) + const merged: QPItem[] = [] + results.forEach((items, idx) => { + items.forEach(item => { + // 未打分的项赋予基础分(按 provider 优先级递减) + if (item.score === undefined) { + item.score = (10 - idx) * 0.01 + } + merged.push(item) + }) + }) + merged.sort((a, b) => (b.score ?? 0) - (a.score ?? 0)) + return merged +}