diff --git a/package.json b/package.json index 4b2bf0bbbde10d2e830feb3b12cd74d09a3d2027..776ace5987a985b906c06e67f358b15d17cac420 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "one-click-launch", "private": true, - "version": "1.0.3", + "version": "1.0.4", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4b82c29240d35919a3b7f65eed4a23d59845c658..08ba68795e9a4e0dd15021cf13ec078c1dee096f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2732,14 +2732,16 @@ checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" [[package]] name = "one-click-launch" -version = "1.0.3" +version = "1.0.4" dependencies = [ "anyhow", + "dirs 5.0.1", "itertools 0.14.0", "lazy_static", "rand 0.8.5", "serde", "serde_json", + "shlex", "sqlx", "tauri", "tauri-build", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7293af520108282d66badafc27d711d339fb49bc..216ddb33870c91d5f287ee59de98a9540aaf266e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "one-click-launch" -version = "1.0.3" +version = "1.0.4" description = "One Click Launch" authors = ["Silwings"] edition = "2024" @@ -31,14 +31,16 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2.0.3" anyhow = "1.0.93" -tracing = "0.1" # 日志处理 -tracing-subscriber = "0.3" # 日志处理 +tracing = "0.1" +tracing-subscriber = "0.3" windows = "0.58.0" sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "sqlite"] } tokio = { version = "1.43.0", features = ["full"] } rand = "0.8.5" lazy_static = "1.5.0" itertools = "0.14.0" +dirs = "5.0.1" +shlex = "1.3.0" [features] portable = [] diff --git a/src-tauri/src/api/launcher_api.rs b/src-tauri/src/api/launcher_api.rs index 9f8a8880f609f2fd7c0aacd888783ef3c2133377..151a9cb57b986221ec029cff9840589fb556b1e3 100644 --- a/src-tauri/src/api/launcher_api.rs +++ b/src-tauri/src/api/launcher_api.rs @@ -1,12 +1,15 @@ +use std::{path::Path, process::Command}; + use anyhow::Result; use rand::{Rng, distributions::Alphanumeric}; use serde::Deserialize; -use tauri::{AppHandle, State}; +use tauri::{AppHandle, Manager, State}; use tauri_plugin_opener::OpenerExt; use tracing::info; use crate::{ DatabaseManager, + api::window_api, db::{ launcher, launcher_resource::{self, CreateResourceParam, LauncherResource}, @@ -283,6 +286,17 @@ pub async fn modify_resource_name( Ok(()) } +/// 修改资源路径 +#[tauri::command] +pub async fn modify_resource_path( + db: State<'_, DatabaseManager>, + resource_id: i64, + path: &str, +) -> Result<(), OneClickLaunchError> { + launcher_resource::modify_path(&db.pool, resource_id, path).await?; + Ok(()) +} + /// 删除启动器中的资源 #[tauri::command] pub async fn delete_resource( @@ -295,12 +309,26 @@ pub async fn delete_resource( /// 启动启动器 #[tauri::command] -pub async fn launch( - app: AppHandle, - db: State<'_, DatabaseManager>, - launcher_id: i64, -) -> Result<(), OneClickLaunchError> { - let resources = launcher_resource::query_by_launcher_id(&db.pool, launcher_id).await?; +pub async fn launch(app: AppHandle, launcher_id: i64) -> Result<(), OneClickLaunchError> { + let db: State<'_, DatabaseManager> = app.try_state().ok_or( + OneClickLaunchError::ExecutionError("Unable to get DatabaseManager".to_string()), + )?; + + let mut resources = launcher_resource::query_by_launcher_id(&db.pool, launcher_id).await?; + + tracing::debug!("启动编组原始资源列表: {resources:?}"); + + // 必须从启动资源中排除自己,防止出现死循环 + let app_path = current_exe_path_str()?; + resources.retain(|e| { + // 检查路径是否指向当前应用程序 + !e.path.starts_with(&app_path) + }); + + if resources.is_empty() { + tracing::debug!("资源列表为空"); + return Ok(()); + } launch_resources(&app, &resources); @@ -342,8 +370,78 @@ pub fn launch_resources(app: &AppHandle, resources: &[LauncherResource]) { /// - `Ok(())` 表示操作成功。 /// - `Err(OneClickLaunchError)` 表示操作失败。 pub fn open_using_default_program(app: &AppHandle, path: &str) -> Result<(), OneClickLaunchError> { + match try_open_as_command(path) { + Ok(true) => return Ok(()), // 已经作为程序执行 + Ok(false) => { + // 不是程序,交给 opener + open_path_with_opener(app, path)?; + } + Err(e) => { + tracing::debug!("作为命令执行失败: {e:?},尝试默认打开"); + open_path_with_opener(app, path)?; + } + } + + Ok(()) +} + +fn open_path_with_opener(app: &AppHandle, path: &str) -> Result<(), OneClickLaunchError> { app.opener() .open_path(path, None::<&str>) .map_err(|e| OneClickLaunchError::ExecutionError(e.to_string()))?; Ok(()) } + +fn try_open_as_command(path: &str) -> Result { + let parts = shlex::split(path) + .ok_or_else(|| OneClickLaunchError::ExecutionError("无法解析路径".to_string()))?; + + if parts.is_empty() { + return Ok(false); + } + + let program = &parts[0]; + let args = &parts[1..]; + + // 如果第一个部分是存在的文件(.exe/.bat/.sh 等),才当成命令执行 + if Path::new(program).exists() { + Command::new(program).args(args).spawn()?; + return Ok(true); + } + + Ok(false) +} + +#[tauri::command] +pub async fn create_handler_shortcut( + launcher_id: i64, + db: State<'_, DatabaseManager>, +) -> Result { + let launcher = launcher::find_by_id(&db.pool, launcher_id).await?; + + let app_path = current_exe_path_str()?; + + // 构建参数 + let args = Some(vec![format!("launch {}", launcher_id)]); + + window_api::create_shortcut( + &app_path, + &launcher.name, + args, + // None 表示保存到桌面 + None, + ) + .map(|path| path.to_string_lossy().to_string()) +} + +fn current_exe_path_str() -> Result { + // 获取当前应用程序的绝对路径 + let exe_path = std::env::current_exe()?; + + // 转换为 Windows 可识别的普通路径 + let mut app_path = exe_path.to_string_lossy().to_string(); + if app_path.starts_with(r"\\?\") { + app_path = app_path.trim_start_matches(r"\\?\").to_string(); + } + Ok(app_path) +} diff --git a/src-tauri/src/api/window_api.rs b/src-tauri/src/api/window_api.rs index 3b8e0a5f30b308db70c7cfa858c7f4e78031641c..0f43f53370e5d8635a560ac5982db962f3a01cb2 100644 --- a/src-tauri/src/api/window_api.rs +++ b/src-tauri/src/api/window_api.rs @@ -1,6 +1,5 @@ use std::{sync::Mutex, time::Instant}; -use anyhow::Result; use tauri::{ AppHandle, DragDropEvent, Manager, State, Theme, menu::{MenuBuilder, MenuItem}, @@ -104,7 +103,7 @@ pub fn handle_window_event(window: &tauri::Window, event: &tauri::WindowEvent) { if let Ok(ref mut last_reset) = lock { // 500ms防抖间隔 - if last_reset.map_or(true, |t| now.duration_since(t).as_millis() > 500) { + if last_reset.is_none_or(|t| now.duration_since(t).as_millis() > 500) { if let Ok(physical_size) = window.inner_size() { // 如果窗口大小异常,强制调整到正常大小 if physical_size.width != WINDOW_MIN_WIDTH @@ -140,7 +139,7 @@ pub fn handle_window_event(window: &tauri::Window, event: &tauri::WindowEvent) { } /// 初始化窗口 -pub fn setup_tray(app: &AppHandle) -> Result<()> { +pub fn setup_tray(app: &AppHandle) -> Result<(), OneClickLaunchError> { let tray_icon = TrayIconBuilder::new() // 设置系统托盘的提示,鼠标悬浮时会显示 .tooltip(constants::APPLICATION_NAME) @@ -179,9 +178,7 @@ pub fn setup_tray(app: &AppHandle) -> Result<()> { if let Ok(launcher_id) = id.parse::() { let app_cloned = app.clone(); tauri::async_runtime::spawn(async move { - let inner_app = app_cloned.clone(); - let db = inner_app.state(); - launcher_api::launch(app_cloned, db, launcher_id).await + launcher_api::launch(app_cloned, launcher_id).await }); } } @@ -202,3 +199,74 @@ pub fn setup_tray(app: &AppHandle) -> Result<()> { pub struct ScaleFactorChangedState { pub last_reset: Mutex>, } + +use std::os::windows::ffi::OsStrExt; +use std::path::PathBuf; +use windows::{ + Win32::{ + System::Com::{ + CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, CoCreateInstance, CoInitializeEx, + CoUninitialize, IPersistFile, + }, + UI::Shell::{IShellLinkW, ShellLink}, + }, + core::{HSTRING, Interface, PCWSTR}, +}; + +/// 创建 Windows 快捷方式 (.lnk 文件) +pub fn create_shortcut( + app_path: &str, + shortcut_name: &str, + args: Option>, + target_dir: Option<&str>, +) -> Result { + unsafe { + // 初始化 COM + CoInitializeEx(None, COINIT_APARTMENTTHREADED).ok()?; + + // 创建 IShellLink 实例 + let shell_link: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)?; + + // 设置应用路径 + shell_link.SetPath(&HSTRING::from(dbg!(app_path)))?; + + // 设置参数 + if let Some(arguments) = args { + let arg_str = arguments.join(" "); + shell_link.SetArguments(&HSTRING::from(arg_str))?; + } + + // 设置工作目录(使用 app_path 的父目录) + if let Some(parent) = std::path::Path::new(app_path).parent() { + shell_link.SetWorkingDirectory(&HSTRING::from(parent.to_string_lossy().to_string()))?; + } + + // 获取 IPersistFile 接口 + let persist_file: IPersistFile = shell_link.cast()?; + + // 目标目录(默认桌面) + let save_dir = if let Some(dir) = target_dir { + PathBuf::from(dir) + } else { + dirs::desktop_dir().ok_or_else(|| anyhow::anyhow!("无法获取桌面路径"))? + }; + + // 拼接快捷方式路径 + let lnk_path = save_dir.join(format!("{}.lnk", shortcut_name)); + + // 转换为宽字符串 + let wide: Vec = lnk_path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + // 保存 + persist_file.Save(PCWSTR::from_raw(wide.as_ptr()), true)?; + + // 释放 COM + CoUninitialize(); + + Ok(lnk_path) + } +} diff --git a/src-tauri/src/constants.rs b/src-tauri/src/constants.rs index eab8e4e84484f483dd420fd909439272ae211832..b7c05637ddc146fbe65d29361ab2062c6b410351 100644 --- a/src-tauri/src/constants.rs +++ b/src-tauri/src/constants.rs @@ -2,6 +2,7 @@ use lazy_static::lazy_static; lazy_static! { pub static ref AUTO_START_FLAG: String = "--auto".to_string(); + pub static ref LAUNCH_SPECIFIED_LAUNCHER_KEY: String = "launch".to_string(); } pub static APPLICATION_NAME: &str = "一键启动"; diff --git a/src-tauri/src/db/launcher_resource.rs b/src-tauri/src/db/launcher_resource.rs index b8b80e789a2c51f9fd28f043c92ae259ddc896a1..de1c07337c69f1935c5d5f2d97db9c6e6048547c 100644 --- a/src-tauri/src/db/launcher_resource.rs +++ b/src-tauri/src/db/launcher_resource.rs @@ -85,6 +85,19 @@ where Ok(()) } +// 修改路径 +pub async fn modify_path<'a, E>(executor: E, resource_id: i64, path: &str) -> Result<()> +where + E: Executor<'a, Database = Sqlite>, +{ + sqlx::query("UPDATE launcher_resource SET path = ? WHERE id = ?") + .bind(path) + .bind(resource_id) + .execute(executor) + .await?; + Ok(()) +} + // 按launcher_id删除 pub async fn delete_by_launcher<'a, E>(executor: E, launcher_id: i64) -> Result<()> where diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 26fb2bf7a8c4a8b225ecea1a6d58748be331b945..ece47bee1f82b883cb0daf96275e7a5ce5d6cd5a 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -14,13 +14,19 @@ pub enum OneClickLaunchError { AnyhowError(#[from] anyhow::Error), #[error("{0}")] - InfoError(#[from] std::io::Error), + IOError(#[from] std::io::Error), #[error("{0}")] TauriError(#[from] tauri::Error), #[error("Unable to convert from {0} to Event")] EventConvertError(String), + + #[error("{0}")] + WindowsCommonError(String), + + #[error("{0}")] + WindowsError(#[from] windows::core::Error), } // we must manually implement serde::Serialize diff --git a/src-tauri/src/events/system_listeners.rs b/src-tauri/src/events/system_listeners.rs index 3e77ea075be678db88a0b452173ba397c2d28447..c55b373daa859d43fe73b247a72213de813a307e 100644 --- a/src-tauri/src/events/system_listeners.rs +++ b/src-tauri/src/events/system_listeners.rs @@ -9,10 +9,12 @@ use crate::{ window_api, }, constants::{ - self, AUTO_START_FLAG, AUTO_START_LAUNCHER_IDS_KEY, HIDE_AFTER_AUTO_START_KEY, THEME_KEY, + self, AUTO_START_FLAG, AUTO_START_LAUNCHER_IDS_KEY, HIDE_AFTER_AUTO_START_KEY, + LAUNCH_SPECIFIED_LAUNCHER_KEY, THEME_KEY, }, db::{launcher_resource, settings}, events::EventDispatcher, + extract_arg_value, }; use super::{ @@ -59,6 +61,7 @@ fn register_application_startup_complete_listeners(app: &AppHandle) { hide_after_auto_start(&app_cloned, &payload); refresh_tray(&app_cloned); launch_auto_start_launchers(&app_cloned, &payload); + launch_specified_launcher(&app_cloned, &payload); debug!("application_startup_complete_listeners 处理完成"); }); } @@ -184,6 +187,20 @@ fn launch_auto_start_launchers(app: &AppHandle, payload: &ApplicationStartupComp } } +fn launch_specified_launcher(app: &AppHandle, payload: &ApplicationStartupCompletePayload) { + // 如果启动命令指定了LAUNCH_SPECIFIED_LAUNCHER_KEY, 需要启动指定的编组 + if let Some(Ok(launcher_id)) = extract_arg_value(&payload.args, &LAUNCH_SPECIFIED_LAUNCHER_KEY) + .map(|value| value.parse::()) + { + let app_cloned = app.clone(); + tokio::spawn(async move { + if let Err(e) = launcher_api::launch(app_cloned, launcher_id).await { + tracing::error!("launcher launch fail: {}", e); + } + }); + } +} + fn launch_then_exit(app: &AppHandle) { let app_cloned = app.clone(); tauri::async_runtime::spawn(async move { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b14d44b216f4a92e2e445a9b2e9eb71c96de63fb..43786f377b521bcdd609eca0b07213d9796de73b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -15,6 +15,8 @@ use tauri::tray::TrayIcon; use tauri::{AppHandle, Manager}; use tauri_plugin_autostart::MacosLauncher; use tracing::{debug, info}; + +use crate::constants::LAUNCH_SPECIFIED_LAUNCHER_KEY; mod api; mod constants; mod db; @@ -125,17 +127,29 @@ pub async fn run() -> Result<()> { .manage(ScaleFactorChangedState { last_reset: Mutex::new(None), }) - // 优先注册单例插件 + // 必须优先注册单例插件 .plugin(tauri_plugin_single_instance::init(|app, argv, cwd| { - info!("{}, {argv:?}, {cwd}", app.package_info().name); - let windows = app - .get_webview_window(constants::MAIN_WINDOW_LABEL) - .unwrap(); - if windows.is_visible().unwrap() { - let _ = windows.unmaximize(); + info!("run app: {}, {argv:?}, {cwd}", app.package_info().name); + + if let Some(Ok(launcher_id)) = extract_arg_value(&argv, &LAUNCH_SPECIFIED_LAUNCHER_KEY) + .map(|value| value.parse::()) + { + let app_cloned = app.clone(); + tokio::spawn(async move { + if let Err(e) = launcher_api::launch(app_cloned, launcher_id).await { + tracing::error!("launcher launch fail: {}", e); + } + }); + } else { + let windows = app + .get_webview_window(constants::MAIN_WINDOW_LABEL) + .unwrap(); + if windows.is_visible().unwrap() { + let _ = windows.unmaximize(); + } + let _ = windows.show(); + let _ = windows.set_focus(); } - let _ = windows.show(); - let _ = windows.set_focus(); app.emit("single-instance", Payload { args: argv, cwd }) .unwrap(); })) @@ -158,10 +172,12 @@ pub async fn run() -> Result<()> { launcher_api::add_resource, launcher_api::add_resources, launcher_api::modify_resource_name, + launcher_api::modify_resource_path, launcher_api::delete_resource, launcher_api::query_launchers, launcher_api::launch, launcher_api::open_path, + launcher_api::create_handler_shortcut, setting_api::save_setting, setting_api::read_setting, setting_api::read_all_setting, @@ -175,3 +191,15 @@ fn register_listeners(app: &AppHandle) { // 注册系统级别的监听器 register_system_listeners(app); } + +pub fn extract_arg_value(argv: &[String], key: &str) -> Option { + let mut iter = argv.iter(); + while let Some(arg) = iter.next() { + if arg == key { + if let Some(val) = iter.next() { + return Some(val.clone()); + } + } + } + None +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 49f2a24168486ce52e9c70ba7507509813892a28..086bc1487848d26dc1e174c17e7cdbaae3bad718 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "一键启动", - "version": "1.0.3", + "version": "1.0.4", "identifier": "one-click-launch", "build": { "beforeDevCommand": "yarn dev", diff --git a/src/home.vue b/src/home.vue index 90909f7e336e1948b72afa80437dda558eb573c4..52d0e5a482974c681c50ef0520d0eb91f33266dd 100644 --- a/src/home.vue +++ b/src/home.vue @@ -16,7 +16,8 @@
+ @launcher-updated="refreshLaunchers" @launcher-moved="moveLauncher" @settings-updated="refreshLaunchers" + @show-context-menu="toggleShowContextMenu" />
+ +
+ + +
@@ -67,12 +78,28 @@ export default { const showSetting = ref(false); const dragDropResourcePaths = ref([]); + // 右键菜单相关状态 + const showLauncherMenu = ref(false); + const launcherMenuPositionTop = ref(0); + const launcherMenuPositionLeft = ref(0); + const selectedLauncherMenuLauncher = ref(null); + + // 全局点击事件,关闭右键菜单 + const handleGlobalClick = (e) => { + // 如果点击的不是右键菜单本身,则关闭菜单 + if (!e.target.closest('.custom-contextmenu')) { + showLauncherMenu.value = false; + selectedLauncherMenuLauncher.value = null; + } + }; + const setupEventListener = async () => { listen('launcher:drag_drop_resource', async (event) => { if (dragDropResourcePaths.value.length == 0) { dragDropResourcePaths.value = Array.from(event.payload.paths); } }); + document.addEventListener('click', handleGlobalClick); }; const launch = async (launcherId) => { await invoke("launch", { launcherId: launcherId }); @@ -142,6 +169,35 @@ export default { await refreshLaunchers(); }; + // 显示右键菜单 + const toggleShowContextMenu = (launcherData, event) => { + event.preventDefault(); + event.stopPropagation(); + + // 设置菜单位置 + launcherMenuPositionTop.value = event.clientY; + launcherMenuPositionLeft.value = event.clientX; + + // 记录选中的launcher + selectedLauncherMenuLauncher.value = launcherData; + + // 显示菜单 + showLauncherMenu.value = true; + }; + + // 右键菜单操作 + const logLauncherName = () => { + console.log("当前Launcher名称: ", selectedLauncherMenuLauncher.value?.name); + showLauncherMenu.value = false; + }; + + // 创建launcher快捷键 + const createLauncherShortcut = async () => { + let res = await invoke("create_handler_shortcut",{launcherId: selectedLauncherMenuLauncher.value.id }); + showLauncherMenu.value = false; + toast.success("快捷方式创建成功\r\n位置: " + res); + } + // 在组件挂载时加载主题 onMounted(() => { setupEventListener(); @@ -150,6 +206,11 @@ export default { refreshLaunchers(); }); + // 清理全局事件监听 + const onUnmounted = () => { + document.removeEventListener('click', handleGlobalClick); + }; + return { theme, launchers, @@ -165,7 +226,14 @@ export default { closeSetting, dragDropResourcePaths, cleanDragDropResourcePaths, - confirmDragDrop + confirmDragDrop, + toggleShowContextMenu, + showLauncherMenu, + launcherMenuPositionTop, + launcherMenuPositionLeft, + selectedLauncherMenuLauncher, + logLauncherName, + createLauncherShortcut }; } }; @@ -427,4 +495,44 @@ button:hover { color: #f5ebeb; } +/* 自定义右键菜单样式 */ +.custom-contextmenu { + position: fixed; + background: #fff; + border: 1px solid #ddd; + border-radius: 4px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + padding: 5px 0; + min-width: 150px; + max-width: 300px; +} + +.dark .custom-contextmenu { + background-color: #2c2c2c; + border-color: #444; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); + color: #ccc; +} + +.menu-item { + padding: 8px 15px; + cursor: pointer; + transition: background-color 0.2s; + font-size: 14px; +} + +.menu-item:hover { + background-color: #f0f0f0; +} + +/* 深色模式 hover */ +.menu-item:hover.dark { + background-color: #444; + color: #fff; + /* hover 时文字更醒目 */ +} + +.dark .menu-item:hover { + background-color: #444; +} \ No newline at end of file diff --git a/src/launcher.vue b/src/launcher.vue index 9ba950d7650a16e4ca211dadba02b8de1164f7d9..f02b8b5570b4b8a9bc7db925019caefa75208011 100644 --- a/src/launcher.vue +++ b/src/launcher.vue @@ -1,7 +1,7 @@