refactor: windows autostart (#5941)

* refactor(windows-autostart): switch to scheduled tasks with user/admin split

* fix(schtasks): decode stdout/stderr using Windows OEM/ANSI code pages

* refactor(ui): remove admin auto-launch warning and clean i18n

* feat(windows): user-level auto-launch via task XML

* docs: Changelog.md
This commit is contained in:
Sline
2025-12-26 14:42:21 +08:00
committed by GitHub
parent a67abda72d
commit 65b4d8713d
36 changed files with 438 additions and 273 deletions

View File

@@ -113,10 +113,12 @@ winapi = { version = "0.3.9", features = [
"errhandlingapi",
"minwindef",
"winerror",
"stringapiset",
"tlhelp32",
"processthreadsapi",
"winhttp",
"winreg",
"winnls",
] }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]

View File

@@ -1,12 +1,14 @@
#[cfg(target_os = "windows")]
use crate::utils::autostart as startup_shortcut;
use crate::utils::schtasks as startup_task;
use crate::{
config::{Config, IVerge},
core::handle::Handle,
singleton,
};
use anyhow::Result;
use clash_verge_logging::{Type, logging, logging_error};
#[cfg(not(target_os = "windows"))]
use clash_verge_logging::logging_error;
use clash_verge_logging::{Type, logging};
use parking_lot::RwLock;
use scopeguard::defer;
use smartstring::alias::String;
@@ -18,7 +20,10 @@ use std::{
time::Duration,
};
use sysproxy::{Autoproxy, GuardMonitor, GuardType, Sysproxy};
#[cfg(not(target_os = "windows"))]
use tauri_plugin_autostart::ManagerExt as _;
#[cfg(target_os = "windows")]
use tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin;
pub struct Sysopt {
update_sysproxy: AtomicBool,
@@ -230,35 +235,21 @@ impl Sysopt {
let is_enable = enable_auto_launch.unwrap_or(false);
logging!(info, Type::System, "Setting auto-launch state to: {:?}", is_enable);
// 首先尝试使用快捷方式方法
#[cfg(target_os = "windows")]
{
if is_enable {
if let Err(e) = startup_shortcut::create_shortcut().await {
logging!(error, Type::Setup, "创建启动快捷方式失败: {e}");
// 如果快捷方式创建失败,回退到原来的方法
self.try_original_autostart_method(is_enable);
} else {
return Ok(());
}
} else if let Err(e) = startup_shortcut::remove_shortcut().await {
logging!(error, Type::Setup, "删除启动快捷方式失败: {e}");
self.try_original_autostart_method(is_enable);
} else {
return Ok(());
}
let is_admin = is_current_app_handle_admin(Handle::app_handle());
startup_task::set_auto_launch(is_enable, is_admin).await
}
#[cfg(not(target_os = "windows"))]
{
// 非Windows平台使用原来的方法
self.try_original_autostart_method(is_enable);
Ok(())
}
Ok(())
}
/// 尝试使用原来的自启动方法
#[cfg(not(target_os = "windows"))]
fn try_original_autostart_method(&self, is_enable: bool) {
let app_handle = Handle::app_handle();
let autostart_manager = app_handle.autolaunch();
@@ -272,32 +263,28 @@ impl Sysopt {
/// 获取当前自启动的实际状态
pub fn get_launch_status(&self) -> Result<bool> {
// 首先尝试检查快捷方式是否存在
#[cfg(target_os = "windows")]
{
match startup_shortcut::is_shortcut_enabled() {
Ok(enabled) => {
logging!(info, Type::System, "快捷方式自启动状态: {enabled}");
return Ok(enabled);
}
Err(e) => {
logging!(error, Type::System, "检查快捷方式失败,尝试原来的方法: {e}");
}
let enabled = startup_task::is_auto_launch_enabled();
if let Ok(status) = enabled {
logging!(info, Type::System, "Auto launch status (scheduled task): {status}");
}
enabled
}
// 回退到原来的方法
let app_handle = Handle::app_handle();
let autostart_manager = app_handle.autolaunch();
match autostart_manager.is_enabled() {
Ok(status) => {
logging!(info, Type::System, "Auto launch status: {status}");
Ok(status)
}
Err(e) => {
logging!(error, Type::System, "Failed to get auto launch status: {e}");
Err(anyhow::anyhow!("Failed to get auto launch status: {}", e))
#[cfg(not(target_os = "windows"))]
{
let app_handle = Handle::app_handle();
let autostart_manager = app_handle.autolaunch();
match autostart_manager.is_enabled() {
Ok(status) => {
logging!(info, Type::System, "Auto launch status: {status}");
Ok(status)
}
Err(e) => {
logging!(error, Type::System, "Failed to get auto launch status: {e}");
Err(anyhow::anyhow!("Failed to get auto launch status: {}", e))
}
}
}
}

View File

@@ -1,132 +0,0 @@
#[cfg(target_os = "windows")]
use anyhow::{Result, anyhow};
#[cfg(target_os = "windows")]
use clash_verge_logging::{Type, logging};
#[cfg(target_os = "windows")]
use std::{os::windows::process::CommandExt as _, path::Path, path::PathBuf};
/// Windows 下的开机启动文件夹路径
#[cfg(target_os = "windows")]
pub fn get_startup_dir() -> Result<PathBuf> {
let appdata = std::env::var("APPDATA").map_err(|_| anyhow!("无法获取 APPDATA 环境变量"))?;
let startup_dir = Path::new(&appdata)
.join("Microsoft")
.join("Windows")
.join("Start Menu")
.join("Programs")
.join("Startup");
if !startup_dir.exists() {
return Err(anyhow!("Startup 目录不存在: {:?}", startup_dir));
}
Ok(startup_dir)
}
/// 获取当前可执行文件路径
#[cfg(target_os = "windows")]
pub fn get_exe_path() -> Result<PathBuf> {
let exe_path = std::env::current_exe().map_err(|e| anyhow!("无法获取当前可执行文件路径: {}", e))?;
Ok(exe_path)
}
/// 创建快捷方式
#[cfg(target_os = "windows")]
pub async fn create_shortcut() -> Result<()> {
use crate::utils::dirs::PathBufExec as _;
let exe_path = get_exe_path()?;
let startup_dir = get_startup_dir()?;
let old_shortcut_path = startup_dir.join("Clash-Verge.lnk");
let new_shortcut_path = startup_dir.join("Clash Verge.lnk");
// 移除旧的快捷方式
let _ = old_shortcut_path
.remove_if_exists()
.await
.inspect(|_| {
logging!(info, Type::Setup, "成功移除旧启动快捷方式");
})
.inspect_err(|err| {
logging!(error, Type::Setup, "移除旧启动快捷方式失败: {err}");
});
// 如果新快捷方式已存在,直接返回成功
if new_shortcut_path.exists() {
logging!(info, Type::Setup, "启动快捷方式已存在");
return Ok(());
}
// 使用 PowerShell 创建快捷方式
let powershell_command = format!(
"$WshShell = New-Object -ComObject WScript.Shell; \
$Shortcut = $WshShell.CreateShortcut('{}'); \
$Shortcut.TargetPath = '{}'; \
$Shortcut.Save()",
new_shortcut_path.to_string_lossy().replace("\\", "\\\\"),
exe_path.to_string_lossy().replace("\\", "\\\\")
);
let output = std::process::Command::new("powershell")
.args(["-Command", &powershell_command])
// 隐藏 PowerShell 窗口
.creation_flags(0x08000000) // CREATE_NO_WINDOW
.output()
.map_err(|e| anyhow!("执行 PowerShell 命令失败: {}", e))?;
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("创建快捷方式失败: {}", error_msg));
}
logging!(info, Type::Setup, "成功创建启动快捷方式");
Ok(())
}
/// 删除快捷方式
#[cfg(target_os = "windows")]
pub async fn remove_shortcut() -> Result<()> {
use crate::utils::dirs::PathBufExec as _;
let startup_dir = get_startup_dir()?;
let old_shortcut_path = startup_dir.join("Clash-Verge.lnk");
let new_shortcut_path = startup_dir.join("Clash Verge.lnk");
let mut removed_any = false;
let _ = old_shortcut_path
.remove_if_exists()
.await
.inspect(|_| {
logging!(info, Type::Setup, "成功删除旧启动快捷方式");
removed_any = true;
})
.inspect_err(|err| {
logging!(error, Type::Setup, "删除旧启动快捷方式失败: {err}");
});
let _ = new_shortcut_path
.remove_if_exists()
.await
.inspect(|_| {
logging!(info, Type::Setup, "成功删除启动快捷方式");
removed_any = true;
})
.inspect_err(|err| {
logging!(error, Type::Setup, "删除启动快捷方式失败: {err}");
});
Ok(())
}
/// 检查快捷方式是否存在
#[cfg(target_os = "windows")]
pub fn is_shortcut_enabled() -> Result<bool> {
let startup_dir = get_startup_dir()?;
let new_shortcut_path = startup_dir.join("Clash Verge.lnk");
Ok(new_shortcut_path.exists())
}

View File

@@ -1,4 +1,3 @@
pub mod autostart;
pub mod dirs;
pub mod format;
pub mod help;
@@ -9,6 +8,8 @@ pub mod linux;
pub mod network;
pub mod notification;
pub mod resolve;
#[cfg(target_os = "windows")]
pub mod schtasks;
pub mod server;
pub mod singleton;
pub mod tmpl;

View File

@@ -0,0 +1,403 @@
use crate::utils::dirs::{self, PathBufExec as _};
use anyhow::{Result, anyhow};
use clash_verge_logging::{Type, logging};
use std::fs;
use std::os::windows::process::CommandExt as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use winapi::um::stringapiset::MultiByteToWideChar;
use winapi::um::winnls::{GetACP, GetOEMCP};
const CREATE_NO_WINDOW: u32 = 0x08000000;
const TASK_NAME_USER: &str = "Clash Verge";
const TASK_NAME_ADMIN: &str = "Clash Verge (Admin)";
const TASK_XML_DIR: &str = "tasks";
const TASK_XML_USER: &str = "clash-verge-task-user.xml";
const TASK_XML_ADMIN: &str = "clash-verge-task-admin.xml";
#[derive(Clone, Copy)]
pub enum TaskMode {
User,
Admin,
}
impl TaskMode {
const fn name(self) -> &'static str {
match self {
Self::User => TASK_NAME_USER,
Self::Admin => TASK_NAME_ADMIN,
}
}
const fn label(self) -> &'static str {
match self {
Self::User => "user",
Self::Admin => "admin",
}
}
const fn xml_run_level(self) -> &'static str {
match self {
Self::User => "LeastPrivilege",
Self::Admin => "HighestAvailable",
}
}
const fn xml_file_name(self) -> &'static str {
match self {
Self::User => TASK_XML_USER,
Self::Admin => TASK_XML_ADMIN,
}
}
}
fn get_exe_path() -> Result<PathBuf> {
let exe_path = std::env::current_exe().map_err(|e| anyhow!("failed to get exe path: {}", e))?;
Ok(exe_path)
}
fn get_task_user_id() -> Result<String> {
let username = std::env::var_os("USERNAME")
.or_else(|| std::env::var_os("USER"))
.ok_or_else(|| anyhow!("failed to get current user name"))?;
let username = username.to_string_lossy();
let username = username.trim();
if username.is_empty() {
return Err(anyhow!("current user name is empty"));
}
let domain = std::env::var_os("USERDOMAIN")
.or_else(|| std::env::var_os("COMPUTERNAME"))
.map(|value| value.to_string_lossy().to_string());
if let Some(domain) = domain {
let domain = domain.trim();
if !domain.is_empty() {
return Ok(format!("{domain}\\{username}"));
}
}
Ok(username.to_string())
}
fn get_startup_dir() -> Result<PathBuf> {
let appdata = std::env::var("APPDATA").map_err(|_| anyhow!("failed to read APPDATA env var"))?;
let startup_dir = Path::new(&appdata)
.join("Microsoft")
.join("Windows")
.join("Start Menu")
.join("Programs")
.join("Startup");
if !startup_dir.exists() {
return Err(anyhow!("startup folder does not exist: {:?}", startup_dir));
}
Ok(startup_dir)
}
async fn cleanup_legacy_shortcuts() -> Result<()> {
let startup_dir = get_startup_dir()?;
let old_shortcut = startup_dir.join("Clash-Verge.lnk");
let new_shortcut = startup_dir.join("Clash Verge.lnk");
old_shortcut.remove_if_exists().await?;
new_shortcut.remove_if_exists().await?;
Ok(())
}
fn task_xml_path(mode: TaskMode) -> Result<PathBuf> {
let dir = dirs::app_home_dir()?.join(TASK_XML_DIR);
fs::create_dir_all(&dir).map_err(|e| anyhow!("failed to create task xml dir: {}", e))?;
Ok(dir.join(mode.xml_file_name()))
}
fn xml_escape(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'&' => escaped.push_str("&amp;"),
'<' => escaped.push_str("&lt;"),
'>' => escaped.push_str("&gt;"),
'"' => escaped.push_str("&quot;"),
'\'' => escaped.push_str("&apos;"),
_ => escaped.push(ch),
}
}
escaped
}
fn build_task_xml(mode: TaskMode) -> Result<String> {
let exe_path = get_exe_path()?.to_string_lossy().to_string();
let exe_path = xml_escape(&exe_path);
let user_id = xml_escape(&get_task_user_id()?);
Ok(format!(
r#"<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<Triggers>
<LogonTrigger>
<Enabled>true</Enabled>
<Delay>PT3S</Delay>
<UserId>{}</UserId>
</LogonTrigger>
</Triggers>
<Principals>
<Principal id="Author">
<UserId>{}</UserId>
<LogonType>InteractiveToken</LogonType>
<RunLevel>{}</RunLevel>
</Principal>
</Principals>
<Settings>
<MultipleInstancesPolicy>Parallel</MultipleInstancesPolicy>
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
<AllowHardTerminate>false</AllowHardTerminate>
<StartWhenAvailable>false</StartWhenAvailable>
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
<IdleSettings>
<StopOnIdleEnd>false</StopOnIdleEnd>
<RestartOnIdle>false</RestartOnIdle>
</IdleSettings>
<AllowStartOnDemand>true</AllowStartOnDemand>
<Enabled>true</Enabled>
<Hidden>false</Hidden>
<RunOnlyIfIdle>false</RunOnlyIfIdle>
<WakeToRun>false</WakeToRun>
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
<Priority>3</Priority>
</Settings>
<Actions Context="Author">
<Exec>
<Command>{}</Command>
</Exec>
</Actions>
</Task>
"#,
user_id,
user_id,
mode.xml_run_level(),
exe_path
))
}
fn encode_utf16le_with_bom(content: &str) -> Vec<u8> {
let mut bytes = Vec::with_capacity(2 + content.len() * 2);
bytes.extend_from_slice(&[0xFF, 0xFE]);
for unit in content.encode_utf16() {
bytes.extend_from_slice(&unit.to_le_bytes());
}
bytes
}
fn write_task_xml(mode: TaskMode) -> Result<PathBuf> {
let task_xml = build_task_xml(mode)?;
let task_xml_path = task_xml_path(mode)?;
let encoded = encode_utf16le_with_bom(&task_xml);
fs::write(&task_xml_path, encoded).map_err(|e| anyhow!("failed to write task xml: {}", e))?;
Ok(task_xml_path)
}
fn decode_with_code_page(bytes: &[u8], code_page: u32) -> Option<String> {
if bytes.is_empty() {
return Some(String::new());
}
let len = bytes.len();
if len > i32::MAX as usize {
return None;
}
let required = unsafe {
MultiByteToWideChar(
code_page,
0,
bytes.as_ptr() as *const i8,
len as i32,
std::ptr::null_mut(),
0,
)
};
if required == 0 {
return None;
}
let mut wide = vec![0u16; required as usize];
let written = unsafe {
MultiByteToWideChar(
code_page,
0,
bytes.as_ptr() as *const i8,
len as i32,
wide.as_mut_ptr(),
required,
)
};
if written == 0 {
return None;
}
wide.truncate(written as usize);
Some(String::from_utf16_lossy(&wide))
}
fn decode_console_output(bytes: &[u8]) -> String {
if let Ok(text) = std::str::from_utf8(bytes) {
return text.to_string();
}
let oem = unsafe { GetOEMCP() };
if let Some(text) = decode_with_code_page(bytes, oem) {
return text;
}
let acp = unsafe { GetACP() };
if let Some(text) = decode_with_code_page(bytes, acp) {
return text;
}
String::from_utf8_lossy(bytes).to_string()
}
fn output_message(output: &Output) -> String {
let stdout = decode_console_output(&output.stdout);
let stderr = decode_console_output(&output.stderr);
let stdout = stdout.trim();
let stderr = stderr.trim();
match (stdout.is_empty(), stderr.is_empty()) {
(true, true) => "unknown error".to_string(),
(false, true) => stdout.to_string(),
(true, false) => stderr.to_string(),
(false, false) => format!("{stdout} | {stderr}"),
}
}
fn schtasks_output(mut cmd: Command) -> Result<Output> {
cmd.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|e| anyhow!("failed to execute schtasks: {}", e))
}
pub fn is_task_enabled(mode: TaskMode) -> Result<bool> {
let output = schtasks_output({
let mut cmd = Command::new("schtasks");
cmd.args(["/Query", "/TN", mode.name()]);
cmd
})?;
Ok(output.status.success())
}
pub fn create_task(mode: TaskMode) -> Result<()> {
let task_xml_path = write_task_xml(mode)?;
let output = schtasks_output({
let mut cmd = Command::new("schtasks");
cmd.args(["/Create", "/TN", mode.name(), "/XML"]);
cmd.arg(&task_xml_path);
cmd.arg("/F");
cmd
})?;
if !output.status.success() {
return Err(anyhow!(
"failed to create {} task: {}",
mode.label(),
output_message(&output)
));
}
logging!(info, Type::Setup, "Created {} auto-launch task", mode.label());
Ok(())
}
pub fn remove_task(mode: TaskMode) -> Result<()> {
let output = schtasks_output({
let mut cmd = Command::new("schtasks");
cmd.args(["/Delete", "/TN", mode.name(), "/F"]);
cmd
})?;
if output.status.success() {
logging!(info, Type::Setup, "Removed {} auto-launch task", mode.label());
return Ok(());
}
if !is_task_enabled(mode)? {
logging!(
info,
Type::Setup,
"{} auto-launch task not found, skipping removal",
mode.label()
);
return Ok(());
}
Err(anyhow!(
"failed to remove {} task: {}",
mode.label(),
output_message(&output)
))
}
pub async fn set_auto_launch(is_enable: bool, is_admin: bool) -> Result<()> {
let target = if is_admin { TaskMode::Admin } else { TaskMode::User };
let other = if is_admin { TaskMode::User } else { TaskMode::Admin };
if let Err(err) = cleanup_legacy_shortcuts().await {
logging!(warn, Type::Setup, "Failed to cleanup legacy startup shortcuts: {}", err);
}
if is_enable {
if is_admin {
create_task(target)?;
if let Err(err) = remove_task(other) {
let _ = remove_task(target);
return Err(err);
}
} else {
if is_task_enabled(other)? {
return Err(anyhow!(
"admin auto-launch task exists; run the app as administrator to remove it before creating a user task"
));
}
create_task(target)?;
}
return Ok(());
}
if is_admin {
let mut errors = Vec::new();
if let Err(err) = remove_task(TaskMode::User) {
errors.push(err);
}
if let Err(err) = remove_task(TaskMode::Admin) {
errors.push(err);
}
if let Some(err) = errors.into_iter().next() {
return Err(err);
}
return Ok(());
}
remove_task(TaskMode::User)?;
if is_task_enabled(TaskMode::Admin)? {
return Err(anyhow!(
"admin auto-launch task exists; run the app as administrator to remove it"
));
}
Ok(())
}
pub fn is_auto_launch_enabled() -> Result<bool> {
if is_task_enabled(TaskMode::Admin)? {
return Ok(true);
}
is_task_enabled(TaskMode::User)
}