diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c566a53..8c10277 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -927,6 +927,7 @@ dependencies = [ "tracing-subscriber", "url", "urlencoding", + "winreg 0.52.0", ] [[package]] @@ -952,7 +953,7 @@ dependencies = [ "rustc_version", "toml 0.9.8", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -6054,6 +6055,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "winreg" version = "0.55.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 756af04..5e4d36f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -70,6 +70,9 @@ serial_test = "3" cocoa = "0.26" objc = "0.2" +[target.'cfg(target_os = "windows")'.dependencies] +winreg = "0.52" + [features] default = ["custom-protocol"] custom-protocol = ["tauri/custom-protocol"] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 8f5fca2..50c7d74 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -6,6 +6,7 @@ pub mod onboarding; pub mod profile_commands; // Profile 管理命令(v2.0) pub mod proxy_commands; pub mod session_commands; +pub mod startup_commands; // 开机自启动管理命令 pub mod stats_commands; pub mod tool_commands; pub mod tool_management; @@ -22,6 +23,7 @@ pub use onboarding::*; pub use profile_commands::*; // Profile 管理命令(v2.0) pub use proxy_commands::*; pub use session_commands::*; +pub use startup_commands::*; // 开机自启动管理命令 pub use stats_commands::*; pub use tool_commands::*; pub use tool_management::*; diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index 572fc0c..22fa9d3 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -27,6 +27,7 @@ fn create_minimal_config() -> GlobalConfig { external_watch_enabled: true, external_poll_interval_ms: 5000, single_instance_enabled: true, + startup_enabled: false, } } diff --git a/src-tauri/src/commands/startup_commands.rs b/src-tauri/src/commands/startup_commands.rs new file mode 100644 index 0000000..24b1dde --- /dev/null +++ b/src-tauri/src/commands/startup_commands.rs @@ -0,0 +1,99 @@ +// filepath: src-tauri/src/commands/startup_commands.rs + +//! 开机自启动管理命令 +//! +//! 提供前端调用的开机自启动配置管理接口 + +use duckcoding::utils::auto_startup::{ + disable_auto_startup, enable_auto_startup, is_auto_startup_enabled, +}; +use duckcoding::utils::config::{read_global_config, write_global_config}; + +/// 获取开机自启动配置 +/// +/// 返回当前配置状态,并自动同步系统实际状态 +#[tauri::command] +pub async fn get_startup_config() -> Result { + // 读取配置文件中的状态 + let config_opt = read_global_config().map_err(|e| e.to_string())?; + + // 检查系统实际状态 + let system_enabled = is_auto_startup_enabled().map_err(|e| e.to_string())?; + + // 如果配置不存在,返回系统状态 + let Some(mut config) = config_opt else { + return Ok(system_enabled); + }; + + // 如果配置与系统状态不一致,以系统状态为准并更新配置 + if config.startup_enabled != system_enabled { + config.startup_enabled = system_enabled; + write_global_config(&config).map_err(|e| e.to_string())?; + } + + Ok(config.startup_enabled) +} + +/// 更新开机自启动配置 +/// +/// # 参数 +/// - `enabled`: true 表示启用自启动,false 表示禁用 +/// +/// # 返回 +/// - 成功返回 Ok(()) +/// - 失败返回 Err(错误信息) +#[tauri::command] +pub async fn update_startup_config(enabled: bool) -> Result<(), String> { + // 根据参数调用系统API + if enabled { + enable_auto_startup().map_err(|e| e.to_string())?; + } else { + disable_auto_startup().map_err(|e| e.to_string())?; + } + + // 更新配置文件 + let config_opt = read_global_config().map_err(|e| e.to_string())?; + + // 如果配置不存在,只更新系统设置不保存到配置文件 + let Some(mut config) = config_opt else { + return Ok(()); + }; + + config.startup_enabled = enabled; + write_global_config(&config).map_err(|e| e.to_string())?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_get_startup_config() { + // 测试读取配置(不进行实际系统操作) + let result = get_startup_config().await; + // 应该能正常读取,无论启用与否 + assert!(result.is_ok()); + } + + #[tokio::test] + #[ignore] // 需要手动测试,避免污染系统 + async fn test_update_startup_config() { + // 测试启用 + let result = update_startup_config(true).await; + assert!(result.is_ok()); + + // 检查状态 + let enabled = get_startup_config().await.unwrap(); + assert!(enabled); + + // 测试禁用 + let result = update_startup_config(false).await; + assert!(result.is_ok()); + + // 再次检查状态 + let enabled = get_startup_config().await.unwrap(); + assert!(!enabled); + } +} diff --git a/src-tauri/src/core/http.rs b/src-tauri/src/core/http.rs index 309a4a2..1f42c43 100644 --- a/src-tauri/src/core/http.rs +++ b/src-tauri/src/core/http.rs @@ -113,6 +113,7 @@ mod tests { external_watch_enabled: true, external_poll_interval_ms: 5000, single_instance_enabled: true, + startup_enabled: false, }; let url = build_proxy_url(&config).unwrap(); @@ -141,6 +142,7 @@ mod tests { external_watch_enabled: true, external_poll_interval_ms: 5000, single_instance_enabled: true, + startup_enabled: false, }; let url = build_proxy_url(&config).unwrap(); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 1be6abb..55e1552 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -351,6 +351,9 @@ fn main() { // 单实例模式配置命令 get_single_instance_config, update_single_instance_config, + // 开机自启动管理命令 + get_startup_config, + update_startup_config, // Profile 管理命令(v2.0) pm_list_all_profiles, pm_list_tool_profiles, diff --git a/src-tauri/src/models/config.rs b/src-tauri/src/models/config.rs index 7e03d4b..d5a02c8 100644 --- a/src-tauri/src/models/config.rs +++ b/src-tauri/src/models/config.rs @@ -179,6 +179,9 @@ pub struct GlobalConfig { /// 单实例模式开关(默认启用,仅生产环境生效) #[serde(default = "default_single_instance_enabled")] pub single_instance_enabled: bool, + /// 开机自启动开关(默认关闭) + #[serde(default)] + pub startup_enabled: bool, } fn default_proxy_configs() -> HashMap { diff --git a/src-tauri/src/services/migration_manager/manager.rs b/src-tauri/src/services/migration_manager/manager.rs index 1be6239..cbcb1f3 100644 --- a/src-tauri/src/services/migration_manager/manager.rs +++ b/src-tauri/src/services/migration_manager/manager.rs @@ -190,6 +190,7 @@ impl MigrationManager { external_watch_enabled: true, external_poll_interval_ms: 5000, single_instance_enabled: true, + startup_enabled: false, }); config.version = Some(new_version.to_string()); diff --git a/src-tauri/src/services/proxy/proxy_service.rs b/src-tauri/src/services/proxy/proxy_service.rs index 2c13be8..42280bc 100644 --- a/src-tauri/src/services/proxy/proxy_service.rs +++ b/src-tauri/src/services/proxy/proxy_service.rs @@ -229,6 +229,7 @@ mod tests { external_watch_enabled: true, external_poll_interval_ms: 5000, single_instance_enabled: true, + startup_enabled: false, }; let url = ProxyService::build_proxy_url(&config); @@ -257,6 +258,7 @@ mod tests { external_watch_enabled: true, external_poll_interval_ms: 5000, single_instance_enabled: true, + startup_enabled: false, }; let url = ProxyService::build_proxy_url(&config); @@ -288,6 +290,7 @@ mod tests { external_watch_enabled: true, external_poll_interval_ms: 5000, single_instance_enabled: true, + startup_enabled: false, }; let url = ProxyService::build_proxy_url(&config); diff --git a/src-tauri/src/utils/auto_startup.rs b/src-tauri/src/utils/auto_startup.rs new file mode 100644 index 0000000..4d48037 --- /dev/null +++ b/src-tauri/src/utils/auto_startup.rs @@ -0,0 +1,345 @@ +// filepath: src-tauri/src/utils/auto_startup.rs + +//! 跨平台开机自启动功能模块 +//! +//! 支持平台: +//! - Windows: 通过注册表 HKCU\Software\Microsoft\Windows\CurrentVersion\Run +//! - macOS: 通过 LaunchAgents plist 文件 +//! - Linux: 通过 XDG autostart desktop 文件 + +use crate::core::error::AppError; +use std::env; +use std::path::PathBuf; + +/// 启用开机自启动 +pub fn enable_auto_startup() -> Result<(), AppError> { + #[cfg(target_os = "windows")] + { + enable_windows_startup() + } + + #[cfg(target_os = "macos")] + { + enable_macos_startup() + } + + #[cfg(target_os = "linux")] + { + enable_linux_startup() + } + + #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + { + Err(AppError::Internal { + message: "当前平台不支持自启动功能".to_string(), + }) + } +} + +/// 禁用开机自启动 +pub fn disable_auto_startup() -> Result<(), AppError> { + #[cfg(target_os = "windows")] + { + disable_windows_startup() + } + + #[cfg(target_os = "macos")] + { + disable_macos_startup() + } + + #[cfg(target_os = "linux")] + { + disable_linux_startup() + } + + #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + { + Err(AppError::Internal { + message: "当前平台不支持自启动功能".to_string(), + }) + } +} + +/// 检查是否已启用开机自启动 +pub fn is_auto_startup_enabled() -> Result { + #[cfg(target_os = "windows")] + { + is_windows_startup_enabled() + } + + #[cfg(target_os = "macos")] + { + is_macos_startup_enabled() + } + + #[cfg(target_os = "linux")] + { + is_linux_startup_enabled() + } + + #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + { + Ok(false) + } +} + +/// 获取当前可执行文件路径 +fn get_executable_path() -> Result { + env::current_exe().map_err(|e| AppError::Internal { + message: format!("无法获取可执行文件路径: {}", e), + }) +} + +// ==================== Windows 实现 ==================== + +#[cfg(target_os = "windows")] +fn enable_windows_startup() -> Result<(), AppError> { + use winreg::enums::*; + use winreg::RegKey; + + let exe_path = get_executable_path()?; + let exe_path_str = exe_path.to_str().ok_or_else(|| AppError::Internal { + message: "无法转换可执行文件路径为字符串".to_string(), + })?; + + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + let run_key = hkcu + .open_subkey_with_flags( + "Software\\Microsoft\\Windows\\CurrentVersion\\Run", + KEY_WRITE, + ) + .map_err(|e| AppError::Internal { + message: format!("无法打开注册表启动项: {}", e), + })?; + + run_key + .set_value("DuckCoding", &exe_path_str) + .map_err(|e| AppError::Internal { + message: format!("无法写入注册表启动项: {}", e), + })?; + + Ok(()) +} + +#[cfg(target_os = "windows")] +fn disable_windows_startup() -> Result<(), AppError> { + use winreg::enums::*; + use winreg::RegKey; + + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + let run_key = hkcu + .open_subkey_with_flags( + "Software\\Microsoft\\Windows\\CurrentVersion\\Run", + KEY_WRITE, + ) + .map_err(|e| AppError::Internal { + message: format!("无法打开注册表启动项: {}", e), + })?; + + // 删除键值,如果不存在则忽略错误 + run_key.delete_value("DuckCoding").ok(); + + Ok(()) +} + +#[cfg(target_os = "windows")] +fn is_windows_startup_enabled() -> Result { + use winreg::enums::*; + use winreg::RegKey; + + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + let run_key = hkcu + .open_subkey_with_flags( + "Software\\Microsoft\\Windows\\CurrentVersion\\Run", + KEY_READ, + ) + .map_err(|e| AppError::Internal { + message: format!("无法打开注册表启动项: {}", e), + })?; + + let value: Result = run_key.get_value("DuckCoding"); + Ok(value.is_ok()) +} + +// ==================== macOS 实现 ==================== + +#[cfg(target_os = "macos")] +fn get_macos_plist_path() -> Result { + let home = dirs::home_dir().ok_or_else(|| AppError::Internal { + message: "无法获取用户主目录".to_string(), + })?; + + let plist_dir = home.join("Library").join("LaunchAgents"); + Ok(plist_dir.join("com.duckcoding.app.plist")) +} + +#[cfg(target_os = "macos")] +fn enable_macos_startup() -> Result<(), AppError> { + use std::fs; + + let exe_path = get_executable_path()?; + let exe_path_str = exe_path.to_str().ok_or_else(|| AppError::Internal { + message: "无法转换可执行文件路径为字符串".to_string(), + })?; + + let plist_path = get_macos_plist_path()?; + + // 确保目录存在 + if let Some(parent) = plist_path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::Internal { + message: format!("无法创建 LaunchAgents 目录: {}", e), + })?; + } + + let plist_content = format!( + r#" + + + + Label + com.duckcoding.app + ProgramArguments + + {} + + RunAtLoad + + +"#, + exe_path_str + ); + + fs::write(&plist_path, plist_content).map_err(|e| AppError::Internal { + message: format!("无法写入 plist 文件: {}", e), + })?; + + Ok(()) +} + +#[cfg(target_os = "macos")] +fn disable_macos_startup() -> Result<(), AppError> { + use std::fs; + + let plist_path = get_macos_plist_path()?; + + // 如果文件存在则删除,不存在则忽略 + if plist_path.exists() { + fs::remove_file(&plist_path).map_err(|e| AppError::Internal { + message: format!("无法删除 plist 文件: {}", e), + })?; + } + + Ok(()) +} + +#[cfg(target_os = "macos")] +fn is_macos_startup_enabled() -> Result { + let plist_path = get_macos_plist_path()?; + Ok(plist_path.exists()) +} + +// ==================== Linux 实现 ==================== + +#[cfg(target_os = "linux")] +fn get_linux_desktop_path() -> Result { + let home = dirs::home_dir().ok_or_else(|| AppError::Internal { + message: "无法获取用户主目录".to_string(), + })?; + + let autostart_dir = home.join(".config").join("autostart"); + Ok(autostart_dir.join("duckcoding.desktop")) +} + +#[cfg(target_os = "linux")] +fn enable_linux_startup() -> Result<(), AppError> { + use std::fs; + + let exe_path = get_executable_path()?; + let exe_path_str = exe_path.to_str().ok_or_else(|| AppError::Internal { + message: "无法转换可执行文件路径为字符串".to_string(), + })?; + + let desktop_path = get_linux_desktop_path()?; + + // 确保目录存在 + if let Some(parent) = desktop_path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::Internal { + message: format!("无法创建 autostart 目录: {}", e), + })?; + } + + let desktop_content = format!( + r#"[Desktop Entry] +Type=Application +Name=DuckCoding +Exec={} +Hidden=false +NoDisplay=false +X-GNOME-Autostart-enabled=true +Comment=DuckCoding AI Tools Configuration Manager +"#, + exe_path_str + ); + + fs::write(&desktop_path, desktop_content).map_err(|e| AppError::Internal { + message: format!("无法写入 desktop 文件: {}", e), + })?; + + Ok(()) +} + +#[cfg(target_os = "linux")] +fn disable_linux_startup() -> Result<(), AppError> { + use std::fs; + + let desktop_path = get_linux_desktop_path()?; + + // 如果文件存在则删除,不存在则忽略 + if desktop_path.exists() { + fs::remove_file(&desktop_path).map_err(|e| AppError::Internal { + message: format!("无法删除 desktop 文件: {}", e), + })?; + } + + Ok(()) +} + +#[cfg(target_os = "linux")] +fn is_linux_startup_enabled() -> Result { + let desktop_path = get_linux_desktop_path()?; + Ok(desktop_path.exists()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_executable_path() { + let result = get_executable_path(); + assert!(result.is_ok()); + let path = result.unwrap(); + assert!(path.exists() || cfg!(test)); // 测试环境可能路径不同 + } + + #[test] + #[ignore] // 需要手动测试,避免污染系统 + fn test_enable_disable_startup() { + // 测试启用 + let result = enable_auto_startup(); + assert!(result.is_ok()); + + // 检查状态 + let enabled = is_auto_startup_enabled().unwrap(); + assert!(enabled); + + // 测试禁用 + let result = disable_auto_startup(); + assert!(result.is_ok()); + + // 再次检查状态 + let enabled = is_auto_startup_enabled().unwrap(); + assert!(!enabled); + } +} diff --git a/src-tauri/src/utils/mod.rs b/src-tauri/src/utils/mod.rs index 93b292a..dd44c99 100644 --- a/src-tauri/src/utils/mod.rs +++ b/src-tauri/src/utils/mod.rs @@ -1,3 +1,4 @@ +pub mod auto_startup; pub mod command; pub mod config; pub mod file_helpers; @@ -6,6 +7,7 @@ pub mod platform; pub mod version; pub mod wsl_executor; +pub use auto_startup::*; pub use command::*; pub use config::*; pub use file_helpers::*; diff --git a/src/components/Onboarding/steps/v1/CompleteStep.tsx b/src/components/Onboarding/steps/v1/CompleteStep.tsx index 964842e..69e2d66 100644 --- a/src/components/Onboarding/steps/v1/CompleteStep.tsx +++ b/src/components/Onboarding/steps/v1/CompleteStep.tsx @@ -62,6 +62,7 @@ export default function CompleteStep({ onNext, onPrevious, isFirst }: StepProps)

温馨提示

    +
  • 推荐在「设置 → 应用设置」中启用开机自启动,方便快速访问
  • 您可以随时在「设置」中修改全局代理配置
  • 在「使用统计」页面查看 API 使用量和配额
  • 遇到问题?查看「帮助」或访问我们的 GitHub 仓库
  • diff --git a/src/lib/tauri-commands/config.ts b/src/lib/tauri-commands/config.ts index c548d2c..dc8509c 100644 --- a/src/lib/tauri-commands/config.ts +++ b/src/lib/tauri-commands/config.ts @@ -252,3 +252,21 @@ export async function getSingleInstanceConfig(): Promise { export async function updateSingleInstanceConfig(enabled: boolean): Promise { return await invoke('update_single_instance_config', { enabled }); } + +// ==================== 开机自启动配置 ==================== + +/** + * 获取开机自启动配置状态 + * @returns 开机自启动是否启用 + */ +export async function getStartupConfig(): Promise { + return await invoke('get_startup_config'); +} + +/** + * 更新开机自启动配置 + * @param enabled - 是否启用开机自启动 + */ +export async function updateStartupConfig(enabled: boolean): Promise { + return await invoke('update_startup_config', { enabled }); +} diff --git a/src/pages/SettingsPage/components/ApplicationSettingsTab.tsx b/src/pages/SettingsPage/components/ApplicationSettingsTab.tsx index 137435f..3bbe945 100644 --- a/src/pages/SettingsPage/components/ApplicationSettingsTab.tsx +++ b/src/pages/SettingsPage/components/ApplicationSettingsTab.tsx @@ -5,10 +5,16 @@ import { Separator } from '@/components/ui/separator'; import { Button } from '@/components/ui/button'; import { Settings as SettingsIcon, Info, RefreshCw, Loader2 } from 'lucide-react'; import { useToast } from '@/hooks/use-toast'; -import { getSingleInstanceConfig, updateSingleInstanceConfig } from '@/lib/tauri-commands'; +import { + getSingleInstanceConfig, + updateSingleInstanceConfig, + getStartupConfig, + updateStartupConfig, +} from '@/lib/tauri-commands'; export function ApplicationSettingsTab() { const [singleInstanceEnabled, setSingleInstanceEnabled] = useState(true); + const [startupEnabled, setStartupEnabled] = useState(false); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const { toast } = useToast(); @@ -18,10 +24,14 @@ export function ApplicationSettingsTab() { const loadConfig = async () => { setLoading(true); try { - const enabled = await getSingleInstanceConfig(); - setSingleInstanceEnabled(enabled); + const [singleInstance, startup] = await Promise.all([ + getSingleInstanceConfig(), + getStartupConfig(), + ]); + setSingleInstanceEnabled(singleInstance); + setStartupEnabled(startup); } catch (error) { - console.error('加载单实例配置失败:', error); + console.error('加载配置失败:', error); toast({ title: '加载失败', description: String(error), @@ -35,8 +45,8 @@ export function ApplicationSettingsTab() { loadConfig(); }, [toast]); - // 保存配置 - const handleToggle = async (checked: boolean) => { + // 保存单实例配置 + const handleSingleInstanceToggle = async (checked: boolean) => { setSaving(true); try { await updateSingleInstanceConfig(checked); @@ -70,6 +80,28 @@ export function ApplicationSettingsTab() { } }; + // 保存开机自启动配置 + const handleStartupToggle = async (checked: boolean) => { + setSaving(true); + try { + await updateStartupConfig(checked); + setStartupEnabled(checked); + toast({ + title: '设置已保存', + description: checked ? '已启用开机自启动' : '已禁用开机自启动', + }); + } catch (error) { + console.error('保存开机自启动配置失败:', error); + toast({ + title: '保存失败', + description: String(error), + variant: 'destructive', + }); + } finally { + setSaving(false); + } + }; + return (
    @@ -89,7 +121,7 @@ export function ApplicationSettingsTab() {
    @@ -117,6 +149,44 @@ export function ApplicationSettingsTab() {
+ + +
+
+ +

开机时自动启动 DuckCoding 应用

+
+ +
+ +
+
+ +
+

关于开机自启动

+
    +
  • + 启用:系统启动时自动运行应用,方便快速访问 +
  • +
  • + 禁用:需要手动启动应用 +
  • +
  • + 跨平台支持:Windows、macOS、Linux 均支持此功能 +
  • +
  • + 生效方式:更改后立即生效,无需重启应用 +
  • +
+
+
+
+ {(loading || saving) && (