Compare commits

...

8 Commits

Author SHA1 Message Date
oomeow
3bada1393e Merge remote-tracking branch 'origin/dev' into refactor/single-instance 2025-11-16 20:00:58 +08:00
oomeow
af0b0a28ca chore: cleanup code 2025-11-16 19:54:13 +08:00
oomeow
04bdc7c948 fix: app freeze when reopen app on windows 2025-11-14 19:44:42 +08:00
oomeow
391f6fe0b7 Merge branch 'dev' into refactor/single-instance 2025-11-13 20:31:08 +08:00
oomeow
ad6f302e21 chore: update 2025-11-12 21:54:44 +08:00
oomeow
f1fe2d7e8b chore: register deep link on debug mode 2025-11-12 21:47:51 +08:00
oomeow
07c5372dbb Merge remote-tracking branch 'origin/dev' into refactor/single-instance 2025-11-12 21:35:44 +08:00
oomeow
d6f9bb4256 refactor: single instance and handle deep links 2025-11-08 20:24:18 +08:00
7 changed files with 37 additions and 431 deletions

17
src-tauri/Cargo.lock generated
View File

@@ -1165,6 +1165,7 @@ dependencies = [
"tauri-plugin-notification",
"tauri-plugin-process",
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tauri-plugin-window-state",
"tokio",
@@ -7655,6 +7656,22 @@ dependencies = [
"tokio",
]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd707f8c86b4e3004e2c141fa24351f1909ba40ce1b8437e30d5ed5277dd3710"
dependencies = [
"serde",
"serde_json",
"tauri",
"tauri-plugin-deep-link",
"thiserror 2.0.17",
"tracing",
"windows-sys 0.60.2",
"zbus",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.9.0"

View File

@@ -114,6 +114,7 @@ signal-hook = "0.3.18"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-autostart = "2.5.1"
tauri-plugin-global-shortcut = "2.3.1"
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
tauri-plugin-updater = "2.9.0"
[dev-dependencies]

View File

@@ -13,11 +13,7 @@ pub mod utils;
use crate::constants::files;
#[cfg(target_os = "linux")]
use crate::utils::linux;
use crate::{
core::handle,
process::AsyncHandler,
utils::{resolve, server},
};
use crate::{core::handle, process::AsyncHandler, utils::resolve};
use anyhow::Result;
use once_cell::sync::OnceCell;
use rust_i18n::i18n;
@@ -32,21 +28,24 @@ i18n!("locales", fallback = "zh");
pub static APP_HANDLE: OnceCell<AppHandle> = OnceCell::new();
/// Application initialization helper functions
mod app_init {
use super::*;
use crate::{module::lightweight, utils::window_manager::WindowManager};
/// Initialize singleton monitoring for other instances
pub fn init_singleton_check() -> Result<()> {
AsyncHandler::block_on(async move {
logging!(info, Type::Setup, "开始检查单例实例...");
server::check_singleton().await?;
Ok(())
})
}
use super::*;
/// Setup plugins for the Tauri builder
pub fn setup_plugins(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
#[allow(unused_mut)]
let mut builder = builder
.plugin(tauri_plugin_single_instance::init(|_app, _args, _cwd| {
AsyncHandler::spawn(async || {
logging!(info, Type::Window, "检测到从单例模式恢复应用窗口");
if !lightweight::exit_lightweight_mode().await {
WindowManager::show_main_window().await;
} else {
logging!(error, Type::Window, "轻量模式退出失败,无法恢复应用窗口");
};
});
}))
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_clipboard_manager::init())
@@ -84,10 +83,7 @@ mod app_init {
/// Setup deep link handling
pub fn setup_deep_links(app: &tauri::App) {
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
{
logging!(info, Type::Setup, "注册深层链接...");
let _ = app.deep_link().register_all();
}
let _ = app.deep_link().register_all();
app.deep_link().on_open_url(|event| {
let urls = event.urls();
@@ -222,10 +218,6 @@ mod app_init {
}
pub fn run() {
if app_init::init_singleton_check().is_err() {
return;
}
let _ = utils::dirs::init_portable_flag();
#[cfg(target_os = "linux")]

View File

@@ -469,57 +469,6 @@ pub async fn init_resources() -> Result<()> {
Ok(())
}
/// initialize url scheme
#[cfg(target_os = "windows")]
pub fn init_scheme() -> Result<()> {
use tauri::utils::platform::current_exe;
use winreg::{RegKey, enums::HKEY_CURRENT_USER};
let app_exe = current_exe()?;
let app_exe = dunce::canonicalize(app_exe)?;
let app_exe = app_exe.to_string_lossy().into_owned();
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let (clash, _) = hkcu.create_subkey("Software\\Classes\\Clash")?;
clash.set_value("", &"Clash Verge")?;
clash.set_value("URL Protocol", &"Clash Verge URL Scheme Protocol")?;
let (default_icon, _) = hkcu.create_subkey("Software\\Classes\\Clash\\DefaultIcon")?;
default_icon.set_value("", &app_exe)?;
let (command, _) = hkcu.create_subkey("Software\\Classes\\Clash\\Shell\\Open\\Command")?;
command.set_value("", &format!("{app_exe} \"%1\""))?;
Ok(())
}
#[cfg(target_os = "linux")]
pub fn init_scheme() -> Result<()> {
const DESKTOP_FILE: &str = "clash-verge.desktop";
for scheme in DEEP_LINK_SCHEMES {
let handler = format!("x-scheme-handler/{scheme}");
let output = std::process::Command::new("xdg-mime")
.arg("default")
.arg(DESKTOP_FILE)
.arg(&handler)
.output()?;
if !output.status.success() {
return Err(anyhow::anyhow!(
"failed to set {handler}, {}",
String::from_utf8_lossy(&output.stderr)
));
}
}
crate::utils::linux::ensure_mimeapps_entries(DESKTOP_FILE, DEEP_LINK_SCHEMES)?;
Ok(())
}
#[cfg(target_os = "macos")]
pub const fn init_scheme() -> Result<()> {
Ok(())
}
#[cfg(target_os = "linux")]
const DEEP_LINK_SCHEMES: &[&str] = &["clash", "clash-verge"];
pub async fn startup_script() -> Result<()> {
let app_handle = handle::Handle::app_handle();
let script_path = {

View File

@@ -1,7 +1,6 @@
use crate::logging;
use crate::utils::logging::Type;
use anyhow::Result;
use std::collections::{HashMap, HashSet};
use std::collections::HashSet;
use std::env;
use std::fs;
use std::path::PathBuf;
@@ -475,273 +474,6 @@ fn extract_nvidia_driver_version(summary: &str) -> Option<&str> {
.find(|token| token.chars().all(|c| c.is_ascii_digit() || c == '.'))
}
pub fn ensure_mimeapps_entries(desktop_file: &str, schemes: &[&str]) -> Result<()> {
let Some(path) = mimeapps_list_path() else {
return Ok(());
};
if !path.exists() {
return Ok(());
}
let original = fs::read_to_string(&path)?;
let mut changed = false;
let mut output_lines: Vec<String> = Vec::new();
let mut current_section: Option<SectionKind> = None;
let mut section_buffer: Vec<String> = Vec::new();
let mut default_present = false;
for line in original.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
if let Some(kind) = current_section.take() {
flush_section(
&mut output_lines,
&mut section_buffer,
desktop_file,
schemes,
kind,
&mut changed,
);
}
if trimmed.eq_ignore_ascii_case("[Default Applications]") {
default_present = true;
current_section = Some(SectionKind::DefaultApplications);
output_lines.push("[Default Applications]".to_string());
continue;
} else if trimmed.eq_ignore_ascii_case("[Added Associations]") {
current_section = Some(SectionKind::AddedAssociations);
output_lines.push("[Added Associations]".to_string());
continue;
}
}
if current_section.is_some() {
section_buffer.push(line.to_string());
} else {
output_lines.push(line.to_string());
}
}
if let Some(kind) = current_section.take() {
flush_section(
&mut output_lines,
&mut section_buffer,
desktop_file,
schemes,
kind,
&mut changed,
);
}
if !default_present {
changed = true;
if output_lines.last().is_some_and(|line| !line.is_empty()) {
output_lines.push(String::new());
}
output_lines.push("[Default Applications]".to_string());
for &scheme in schemes {
output_lines.push(format!("x-scheme-handler/{scheme}={desktop_file};"));
}
}
if !changed {
return Ok(());
}
let mut new_content = output_lines.join("\n");
if !new_content.ends_with('\n') {
new_content.push('\n');
}
fs::write(path, new_content)?;
Ok(())
}
fn mimeapps_list_path() -> Option<PathBuf> {
let config_path = env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| {
env::var_os("HOME").map(PathBuf::from).map(|mut home| {
home.push(".config");
home
})
})
.map(|mut dir| {
dir.push("mimeapps.list");
dir
});
if config_path.as_ref().is_some_and(|path| path.exists()) {
return config_path;
}
let data_path = env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| {
env::var_os("HOME").map(PathBuf::from).map(|mut home| {
home.push(".local");
home.push("share");
home
})
})
.map(|mut dir| {
dir.push("applications");
dir.push("mimeapps.list");
dir
});
if data_path.as_ref().is_some_and(|path| path.exists()) {
return data_path;
}
config_path
}
#[derive(Clone, Copy)]
enum SectionKind {
DefaultApplications,
AddedAssociations,
}
fn flush_section(
output: &mut Vec<String>,
section: &mut Vec<String>,
desktop_file: &str,
schemes: &[&str],
kind: SectionKind,
changed: &mut bool,
) {
let mut seen: HashMap<&str, usize> = HashMap::new();
let mut processed: Vec<String> = Vec::with_capacity(section.len());
for line in section.drain(..) {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
processed.push(line);
continue;
}
let Some((raw_key, raw_value)) = trimmed.split_once('=') else {
processed.push(line);
continue;
};
if let Some(scheme) = match_scheme(raw_key.trim(), schemes) {
let mut values: Vec<String> = raw_value
.split(';')
.filter_map(|value| {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
})
.collect();
if let Some(&index) = seen.get(scheme) {
let existing_line = &mut processed[index];
let existing_prefix: String = existing_line
.chars()
.take_while(|c| c.is_whitespace())
.collect();
let Some((_, existing_raw_value)) = existing_line.trim().split_once('=') else {
processed.push(line);
continue;
};
let mut merged_values: Vec<String> = existing_raw_value
.split(';')
.filter_map(|value| {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
})
.collect();
for value in values {
if !merged_values.iter().any(|existing| existing == &value) {
merged_values.push(value);
}
}
if let Some(pos) = merged_values.iter().position(|value| value == desktop_file) {
if pos != 0 {
let moved = merged_values.remove(pos);
merged_values.insert(0, moved);
}
} else {
merged_values.insert(0, desktop_file.to_string());
}
let mut merged_line = format!("{existing_prefix}x-scheme-handler/{scheme}=");
merged_line.push_str(&merged_values.join(";"));
merged_line.push(';');
if *existing_line != merged_line {
*existing_line = merged_line;
}
// Dropping the duplicate entry alters the section even if nothing new was added.
*changed = true;
continue;
}
if let Some(pos) = values.iter().position(|value| value == desktop_file) {
if pos != 0 {
values.remove(pos);
values.insert(0, desktop_file.to_string());
*changed = true;
}
} else {
values.insert(0, desktop_file.to_string());
*changed = true;
}
let prefix = line
.chars()
.take_while(|c| c.is_whitespace())
.collect::<String>();
let mut new_line = format!("{prefix}x-scheme-handler/{scheme}=");
new_line.push_str(&values.join(";"));
new_line.push(';');
if new_line != line {
*changed = true;
}
let index = processed.len();
processed.push(new_line);
seen.insert(scheme, index);
continue;
}
processed.push(line);
}
let ensure_all = matches!(
kind,
SectionKind::DefaultApplications | SectionKind::AddedAssociations
);
if ensure_all {
for &scheme in schemes {
if !seen.contains_key(scheme) {
processed.push(format!("x-scheme-handler/{scheme}={desktop_file};"));
*changed = true;
}
}
}
output.extend(processed);
}
fn match_scheme<'a>(key: &str, schemes: &'a [&str]) -> Option<&'a str> {
if let Some(rest) = key.strip_prefix("x-scheme-handler/") {
return schemes.iter().copied().find(|candidate| *candidate == rest);
}
schemes.iter().copied().find(|candidate| *candidate == key)
}
pub fn configure_environment() {
let session = SessionEnv::gather();
let overrides = DmabufOverrides::gather();

View File

@@ -27,7 +27,6 @@ pub fn resolve_setup_handle() {
pub fn resolve_setup_sync() {
AsyncHandler::spawn(|| async {
AsyncHandler::spawn_blocking(init_scheme);
AsyncHandler::spawn_blocking(init_embed_server);
AsyncHandler::spawn_blocking(init_signal);
});
@@ -90,10 +89,6 @@ pub fn init_handle() {
handle::Handle::global().init();
}
pub(super) fn init_scheme() {
logging_error!(Type::Setup, init::init_scheme());
}
#[cfg(not(feature = "tauri-dev"))]
pub(super) async fn resolve_setup_logger() {
logging_error!(Type::Setup, init::init_logger().await);

View File

@@ -1,70 +1,18 @@
use super::resolve;
use crate::{
config::{Config, DEFAULT_PAC, IVerge},
logging, logging_error,
module::lightweight,
logging,
process::AsyncHandler,
utils::{logging::Type, window_manager::WindowManager},
utils::logging::Type,
};
use anyhow::{Result, bail};
use once_cell::sync::OnceCell;
use parking_lot::Mutex;
use port_scanner::local_port_available;
use reqwest::ClientBuilder;
use smartstring::alias::String;
use std::time::Duration;
use tokio::sync::oneshot;
use warp::Filter as _;
#[derive(serde::Deserialize, Debug)]
struct QueryParam {
param: String,
}
// 关闭 embedded server 的信号发送端
static SHUTDOWN_SENDER: OnceCell<Mutex<Option<oneshot::Sender<()>>>> = OnceCell::new();
/// check whether there is already exists
pub async fn check_singleton() -> Result<()> {
let port = IVerge::get_singleton_port();
if !local_port_available(port) {
let client = ClientBuilder::new()
.timeout(Duration::from_millis(500))
.build()?;
// 需要确保 Send
#[allow(clippy::needless_collect)]
let argvs: Vec<std::string::String> = std::env::args().collect();
if argvs.len() > 1 {
#[cfg(not(target_os = "macos"))]
{
let param = argvs[1].as_str();
if param.starts_with("clash:") {
client
.get(format!(
"http://127.0.0.1:{port}/commands/scheme?param={param}"
))
.send()
.await?;
}
}
} else {
client
.get(format!("http://127.0.0.1:{port}/commands/visible"))
.send()
.await?;
}
logging!(
error,
Type::Window,
"failed to setup singleton listen server"
);
bail!("app exists");
}
Ok(())
}
/// The embed server only be used to implement singleton process
/// maybe it can be used as pac server later
/// The embed server only be used as pac server
pub fn embed_server() {
let (shutdown_tx, shutdown_rx) = oneshot::channel();
#[allow(clippy::expect_used)]
@@ -73,19 +21,6 @@ pub fn embed_server() {
.expect("failed to set shutdown signal for embedded server");
let port = IVerge::get_singleton_port();
let visible = warp::path!("commands" / "visible").and_then(|| async {
logging!(info, Type::Window, "检测到从单例模式恢复应用窗口");
if !lightweight::exit_lightweight_mode().await {
WindowManager::show_main_window().await;
} else {
logging!(error, Type::Window, "轻量模式退出失败,无法恢复应用窗口");
};
Ok::<_, warp::Rejection>(warp::reply::with_status::<std::string::String>(
"ok".to_string(),
warp::http::StatusCode::OK,
))
});
let pac = warp::path!("commands" / "pac").and_then(|| async move {
let verge_config = Config::verge().await;
let clash_config = Config::clash().await;
@@ -109,23 +44,8 @@ pub fn embed_server() {
)
});
// Use map instead of and_then to avoid Send issues
let scheme = warp::path!("commands" / "scheme")
.and(warp::query::<QueryParam>())
.and_then(|query: QueryParam| async move {
AsyncHandler::spawn(|| async move {
logging_error!(Type::Setup, resolve::resolve_scheme(&query.param).await);
});
Ok::<_, warp::Rejection>(warp::reply::with_status::<std::string::String>(
"ok".to_string(),
warp::http::StatusCode::OK,
))
});
let commands = visible.or(scheme).or(pac);
AsyncHandler::spawn(move || async move {
warp::serve(commands)
warp::serve(pac)
.bind(([127, 0, 0, 1], port))
.await
.graceful(async {