Files
clash-verge-rev/src-tauri/src/lib.rs
T
wonfen 3d8d7731e1 fix: node selection persistence (#7620)
* fix(core): put the user's node choices back when the core starts

mihomo restores the node picked in each group from the `cache.db` it keeps
in the directory it was started against. The app relied on that entirely,
and it does not hold: `profile.store-selected` comes from a merge template
a user is free to replace, and a service older than the durable runtime
generation starts every core in a directory nothing has ever run in. Both
end the same way — every `select` group falls back to the first entry in
its `proxies:` list.

The app already knows better. It records each selection in the profile,
and `activate_selected_nodes` already reconciles that record against the
running core — it was only ever called when switching profiles, never when
a core came up. Call it from the three places a core process becomes
ready. Repeat calls supersede each other, so no coordination is needed
between the start paths.

That makes the profile authoritative, which turns any selection it does
not know about into one the next start silently undoes. The chain proxy
moved a group without recording it, so recording is now one shared hook
that both it and the proxy list go through.

Restoration is spawned, not awaited: it polls the core until its groups
are readable, and a start must not wait for that.

* fix(tray): record the node the tray switched to

Restoring the profile's selections when a core starts made the profile
authoritative, which turns any selection it never learned about into one
the next start silently undoes. The proxy list and the chain proxy both
record; the tray did not.

So switching node from the tray survived only until the next restart, and
then reverted to whatever the profile last knew — a regression the
restoration itself introduced, and only for the tray.

`record_selected_node` is the backend counterpart of the frontend's
`useRecordSelection`. Failing to record does not fail the switch: the core
is already on the new node by then, and reporting the switch as failed
would be wrong. It is logged instead.

* fix(core): restoring selections must not prune what the core has not loaded

Putting the profile's selections back when a core starts reused the
reconciliation written for profile switches, and that one also *repairs*:
a record whose group is absent from two snapshots a second apart is
dropped, and the reduced list is written to profiles.yaml.

After a profile switch the configuration is loaded, so absence is
evidence. After a core start it is not — a provider-backed group can be
empty or missing for seconds — and a record pruned on that evidence is
gone for good, because the provider finishing later cannot bring it back.
Restoring now keeps the records and only applies what it can match;
pruning stays with the caller entitled to conclude it.

The restore is also asynchronous: it reads the profile, polls the core
until its groups are readable, then puts its selections. A choice the user
makes inside that window is newer than what it captured, so letting it
finish pushed the core back to the older node while the profile already
held the newer one. Recording a selection now supersedes any activation in
flight — from the tray, and from the UI and chain proxy through
`patch_profile`.

* fix(core): activate the selections of the profile that is actually running

The activation worker read the profiles draft. A profile switch stages its
target there before validating it, so a switch that fails — leaving the
previous profile running — could still have handed an activation the
rejected profile's selections, which it would then apply to whichever
same-named groups the running profile has.

Reading the committed state removes the window. The switch that succeeds
commits before it activates, so nothing changes for that caller; what
changes is that a restore running at core start can no longer observe a
switch that never happened.

* fix(core): stop a restoration from undoing a choice made while it runs

Two gaps in making the profile authoritative, both found by adversarial
review of the commits that introduced it.

A restoration reads the profile's selections once, then polls the core
until its groups can be read — seconds, on a cold start. A selection made
inside that window is newer than what the restoration is carrying, and
nothing told the restoration so: it went on to apply the older choice over
the user's. The UI accepted the new node and traffic snapped back to the
old one, with the profile and the running core then disagreeing. Recording
a selection now abandons any restoration still in flight, through the same
generation counter every other superseding caller uses.

The other gap is the home card in global mode, which passed
`skipConfigSave` and so recorded nothing. That was harmless while nothing
re-applied the profile; now it means the next start undoes the choice. It
was the only caller that ever set the flag, so the flag is gone rather than
flipped — a parameter whose one use was a bug is not worth keeping.

* fix(core): wait for the selections that can be put back, keep trying for the rest

Two gaps in restoring the profile's selections when a core starts, both
found by adversarial review.

Restoring re-checked once, a second later, and stopped. That is enough
after a profile switch, which happens with the configuration already
loaded. A core start is the opposite case: a provider-backed group is
present but empty until its provider finishes loading, and a `select` can
fail while the core is still settling. Neither produces an activation, so
the restore reported success having applied nothing and the group stayed
on the first entry of its `proxies:` list — the outcome restoring exists
to prevent. It now keeps looking, on a deadline, at what the core is
actually on rather than at what it managed to send.

Restoring was also spawned, and the caller enables the system proxy the
moment the start returns — so traffic could be pointed at a core still on
the wrong node. The first pass is now awaited, bounded, and the rest keeps
settling in the background: a core that will not answer delays a start
rather than blocking it, because being proxied through the wrong node
beats not being proxied at all.

TUN is deliberately not covered, and cannot be by anything here: it is
configured in the core's own file, so it carries traffic from the moment
the process starts. What puts a TUN user on the right node from the first
packet is the core's own `cache.db`, which is why the service keeps an
owner's runtime generation across restarts.

`supersede_selected_activation` and `cancel_selected_node_activation` were
the same function under two names, added independently for the same
reason, and `patch_profile` called both. One survives, called once, after
the profile is written — the only placement that needs to exist, since an
activation that read the older profile is cancelled by it and one starting
afterwards reads the newer one.

* fix(core): record a selection by naming it, not by resending the list

Recording a node selection sent the whole selection list, built from the
profile the frontend had rendered. Two selections made before that list
refreshed were therefore built from the same stale snapshot, and the later
write dropped the earlier group's choice. Since a core start re-applies
whatever the profile holds, the dropped one came back on the next restart
— the profile and the running core disagreeing, in the direction the user
would least expect.

The tray already avoided this, because it went through
`record_selected_node`, which merges one group into the profile inside
`with_data_modify`. Exposing that as a command and pointing the frontend
at it leaves one way to record a selection instead of two, and removes the
list from the wire: what a caller knows is the group and the node, and
that is now all it says.

`useRecordSelection` no longer reads the profile at all, which also makes
it stable across renders rather than changing identity whenever the
profile does.

* test(core): make every way of declining to stage answer for itself

`CoreRestarted` was added to `StageRejection` upstream and reached
production without a test. It inherited the right verdict — every
`RestartRequired` is matched together — but nothing said so, and nothing
would have noticed if it had inherited the wrong one.

The list of rejections now goes through a wrapper whose `match` is
exhaustive, so the next variant added upstream fails to compile here. That
is the part worth having: it does not prove the list is complete, but it
does mean nobody can add a way to decline without being sent to this test
to say what it should lead to.

* build(deps): pin the service protocol to v2.6.1

The pin tracked `feat/stage-runtime` because the protocol change it
carried had no tag yet. It has one now, and a branch pin makes the build
irreproducible: an upstream force-push silently changes what this
compiles against.

v2.6.1 carries the runtime generation an owner keeps across restarts,
which is what lets the core hold on to the `cache.db` its node selections
and fake-ip leases live in. The protocol itself is untouched — epoch,
revision and both compatibility floors are byte-identical to v2.6.0 — so
this is a fix release in both directions: an older service still works
with this app, and this app still works with an older service, only
without the durable generation.

The lockfile change is the two lines that name it. Reaching for
`cargo update -p` instead re-resolved a subtree and quietly downgraded
`windows-sys`, `windows-core` and `getrandom` along the way.
2026-07-29 07:47:52 +08:00

486 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![allow(non_snake_case)]
#![recursion_limit = "512"]
mod cmd;
pub mod config;
mod constants;
mod core;
mod enhance;
mod feat;
mod module;
mod process;
pub mod utils;
use crate::constants::files;
use crate::{
core::handle,
process::AsyncHandler,
utils::{resolve, server},
};
use anyhow::Result;
use clash_verge_logging::{Type, logging};
use once_cell::sync::OnceCell;
use tauri::{AppHandle, Manager as _};
#[cfg(target_os = "macos")]
use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_deep_link::DeepLinkExt as _;
pub static APP_HANDLE: OnceCell<AppHandle> = OnceCell::new();
/// Application initialization helper functions
mod app_init {
use super::*;
/// 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(())
})
}
/// 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_clash_verge_sysinfo::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_http::init())
.plugin(
tauri_plugin_mihomo::Builder::new()
.protocol(tauri_plugin_mihomo::models::Protocol::LocalSocket)
.socket_path(crate::config::IClashTemp::guard_external_controller_ipc())
.build(),
);
// Devtools plugin only in debug mode with feature tauri-dev
// to avoid duplicated registering of logger since the devtools plugin also registers a logger
#[cfg(all(debug_assertions, not(feature = "tokio-trace"), feature = "tauri-dev"))]
{
builder = builder.plugin(tauri_plugin_devtools::init());
}
builder
}
/// 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();
}
app.deep_link().on_open_url(|event| {
let urls = event.urls();
AsyncHandler::spawn(move || async move {
if let Some(url) = urls.first()
&& let Err(e) = resolve::resolve_scheme(url.as_ref()).await
{
logging!(error, Type::Setup, "Failed to resolve scheme: {}", e);
}
});
});
}
/// Setup autostart plugin
pub fn setup_autostart(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
#[cfg(target_os = "macos")]
let mut auto_start_plugin_builder = tauri_plugin_autostart::Builder::new();
#[cfg(not(target_os = "macos"))]
let auto_start_plugin_builder = tauri_plugin_autostart::Builder::new();
#[cfg(target_os = "macos")]
{
auto_start_plugin_builder = auto_start_plugin_builder
.macos_launcher(MacosLauncher::LaunchAgent)
.app_name(&app.config().identifier);
}
app.handle().plugin(auto_start_plugin_builder.build())?;
Ok(())
}
/// Setup window state management
pub fn setup_window_state(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
logging!(info, Type::Setup, "初始化窗口状态管理...");
let window_state_plugin = tauri_plugin_window_state::Builder::new()
.with_filename(files::WINDOW_STATE)
.with_state_flags(tauri_plugin_window_state::StateFlags::default())
.build();
app.handle().plugin(window_state_plugin)?;
Ok(())
}
pub fn generate_handlers() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send + Sync + 'static {
tauri::generate_handler![
tauri_plugin_clash_verge_sysinfo::commands::get_system_info,
tauri_plugin_clash_verge_sysinfo::commands::get_app_uptime,
tauri_plugin_clash_verge_sysinfo::commands::app_is_admin,
tauri_plugin_clash_verge_sysinfo::commands::export_diagnostic_info,
cmd::probe_listener,
cmd::save_proxy_ports,
cmd::get_sys_proxy,
cmd::get_auto_proxy,
cmd::get_embedded_server_port,
cmd::open_app_dir,
cmd::open_logs_dir,
cmd::open_web_url,
cmd::open_core_dir,
cmd::get_portable_flag,
cmd::get_network_interfaces,
cmd::get_system_hostname,
cmd::restart_app,
cmd::start_core,
cmd::stop_core,
cmd::restart_core,
cmd::get_runtime_state,
cmd::get_auto_launch_status,
cmd::entry_lightweight_mode,
cmd::exit_lightweight_mode,
cmd::install_service,
cmd::uninstall_service,
cmd::reinstall_service,
cmd::repair_service,
cmd::continue_with_sidecar,
cmd::get_clash_info,
cmd::patch_clash_config,
cmd::patch_clash_mode,
cmd::get_clash_mode,
cmd::change_clash_core,
cmd::get_runtime_config,
cmd::get_proxy_view,
cmd::get_runtime_yaml,
cmd::get_runtime_exists,
cmd::get_runtime_logs,
cmd::get_runtime_proxy_chain_config,
cmd::update_proxy_chain_config_in_runtime,
cmd::invoke_uwp_tool,
cmd::copy_clash_env,
cmd::sync_tray_proxy_selection,
cmd::record_selected_node,
cmd::save_dns_config,
cmd::apply_dns_config,
cmd::check_dns_config_exists,
cmd::get_dns_config_content,
cmd::validate_dns_config,
cmd::get_clash_logs,
cmd::get_verge_config,
cmd::patch_verge_config,
cmd::test_delay,
cmd::get_app_dir,
cmd::copy_icon_file,
cmd::download_icon_cache,
cmd::open_devtools,
cmd::exit_app,
cmd::get_network_interfaces_info,
cmd::get_profiles,
cmd::enhance_profiles,
cmd::patch_profiles_config,
cmd::view_profile,
cmd::patch_profile,
cmd::create_profile,
cmd::import_profile,
cmd::reorder_profile,
cmd::update_profile,
cmd::delete_profile,
cmd::read_profile_file,
cmd::save_profile_file,
cmd::get_next_update_time,
cmd::script_validate_notice,
cmd::validate_script_file,
cmd::create_local_backup,
cmd::list_local_backup,
cmd::delete_local_backup,
cmd::restore_local_backup,
cmd::import_local_backup,
cmd::export_local_backup,
cmd::create_webdav_backup,
cmd::save_webdav_config,
cmd::list_webdav_backup,
cmd::delete_webdav_backup,
cmd::restore_webdav_backup,
cmd::get_unlock_items,
cmd::check_media_unlock,
]
}
}
pub fn run() {
#[cfg(all(target_os = "macos", not(debug_assertions), not(test), not(feature = "verge-dev")))]
if utils::macos_launch_guard::enforce_before_initialization() == utils::macos_launch_guard::LaunchDisposition::Exit
{
return;
}
let _ = utils::dirs::init_portable_flag();
if app_init::init_singleton_check().is_err() {
return;
}
#[cfg(target_os = "linux")]
utils::linux::workarounds::apply_nvidia_dmabuf_renderer_workaround();
#[cfg(target_os = "linux")]
utils::linux::workarounds::apply_wayland_webkit_fix();
let builder = app_init::setup_plugins(tauri::Builder::default())
.setup(|app| {
// Logger may not be ready yet, so mirror setup panics to stderr.
fn log_setup_panic(stage: &str, panic: Box<dyn std::any::Any + Send>) {
let msg = panic
.downcast_ref::<&str>()
.map(|s| (*s).to_string())
.or_else(|| panic.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "unknown panic payload".to_string());
eprintln!("[clash-verge] panic during app setup ({stage}), continuing in degraded mode: {msg}");
logging!(
error,
Type::Setup,
"setup 阶段 panic({})—— 降级继续启动: {}",
stage,
msg
);
}
// Prevent setup panics from aborting across macOS applicationDidFinishLaunching.
// Keep pre-init separate so window/core/tray startup is still scheduled after a panic.
if let Err(panic) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
#[allow(clippy::expect_used)]
APP_HANDLE
.set(app.app_handle().clone())
.expect("failed to set global app handle");
if let Err(e) = resolve::init_work_dir_and_logger() {
logging!(error, Type::Setup, "Failed to init work dir/logger: {}", e);
}
logging!(info, Type::Setup, "开始应用初始化...");
if let Err(e) = app_init::setup_autostart(app) {
logging!(error, Type::Setup, "Failed to setup autostart: {}", e);
}
app_init::setup_deep_links(app);
if let Err(e) = app_init::setup_window_state(app) {
logging!(error, Type::Setup, "Failed to setup window state: {}", e);
}
})) {
log_setup_panic("pre-init", panic);
}
// Always attempt the startup stage, even if pre-init degraded.
if let Err(panic) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
resolve::resolve_setup_async();
resolve::resolve_setup_sync();
resolve::init_signal();
logging!(info, Type::Setup, "初始化已启动");
})) {
log_setup_panic("window-core", panic);
}
Ok(())
})
.invoke_handler(app_init::generate_handlers());
// macOS 内存压力下 WKWebView 渲染进程可能被系统终止(表现为白屏),
// 注册恢复钩子:清理孤儿 WebSocket 订阅防止内存泄漏;窗口可见时立即 reload
// 恢复页面,不可见时延迟到用户下次打开窗口再 reload。
#[cfg(target_os = "macos")]
let builder = builder.on_web_content_process_terminate(resolve::window::on_web_content_process_terminated);
mod event_handlers {
#[cfg(target_os = "macos")]
use crate::module::lightweight;
use crate::utils::window_manager::WindowManager;
use crate::{
config::Config,
core::{self, handle, hotkey},
process::AsyncHandler,
};
use clash_verge_logging::{Type, logging};
use tauri::AppHandle;
#[cfg(target_os = "macos")]
use tauri::Manager as _;
pub fn handle_ready_resumed(_app_handle: &AppHandle) {
if handle::Handle::global().is_exiting() {
logging!(debug, Type::System, "应用正在退出,跳过处理");
return;
}
logging!(info, Type::System, "应用就绪");
crate::utils::server::set_commands_ready();
#[cfg(target_os = "macos")]
if let Some(window) = _app_handle.get_webview_window("main") {
let _ = window.set_title("Clash Verge");
}
}
#[cfg(target_os = "macos")]
pub async fn handle_reopen(has_visible_windows: bool) {
if lightweight::is_in_lightweight_mode() {
lightweight::exit_lightweight_mode().await;
return;
}
if !has_visible_windows {
handle::Handle::global().set_activation_policy_regular();
let _ = WindowManager::show_main_window().await;
}
}
pub fn handle_window_close(api: &tauri::WindowEvent) {
#[cfg(target_os = "macos")]
handle::Handle::global().set_activation_policy_accessory();
if core::handle::Handle::global().is_exiting() {
return;
}
if let tauri::WindowEvent::CloseRequested { api, .. } = api {
api.prevent_close();
if let Some(window) = WindowManager::get_main_window() {
let _ = window.hide();
}
}
}
pub fn handle_window_focus(focused: bool) {
AsyncHandler::spawn(move || async move {
let is_enable_global_hotkey = Config::verge().await.data_arc().enable_global_hotkey.unwrap_or(true);
if focused {
#[cfg(target_os = "macos")]
{
use crate::core::hotkey::SystemHotkey;
let _ = hotkey::Hotkey::global()
.register_system_hotkey(SystemHotkey::CmdQ)
.await;
let _ = hotkey::Hotkey::global()
.register_system_hotkey(SystemHotkey::CmdW)
.await;
}
if !is_enable_global_hotkey {
let _ = hotkey::Hotkey::global().init(false).await;
}
return;
}
#[cfg(target_os = "macos")]
{
use crate::core::hotkey::SystemHotkey;
let _ = hotkey::Hotkey::global().unregister_system_hotkey(SystemHotkey::CmdQ);
let _ = hotkey::Hotkey::global().unregister_system_hotkey(SystemHotkey::CmdW);
}
if !is_enable_global_hotkey {
let _ = hotkey::Hotkey::global().reset();
}
});
}
#[cfg(target_os = "macos")]
pub fn handle_window_destroyed() {
use crate::core::hotkey::SystemHotkey;
AsyncHandler::spawn(move || async move {
let _ = hotkey::Hotkey::global().unregister_system_hotkey(SystemHotkey::CmdQ);
let _ = hotkey::Hotkey::global().unregister_system_hotkey(SystemHotkey::CmdW);
let is_enable_global_hotkey = Config::verge().await.data_arc().enable_global_hotkey.unwrap_or(true);
if !is_enable_global_hotkey {
let _ = hotkey::Hotkey::global().reset();
}
});
}
}
#[cfg(feature = "clippy")]
let context = tauri::test::mock_context(tauri::test::noop_assets());
#[cfg(feature = "clippy")]
let app = builder.build(context).unwrap_or_else(|e| {
logging!(error, Type::Setup, "Failed to build Tauri application: {}", e);
std::process::exit(1);
});
#[cfg(not(feature = "clippy"))]
let app = builder.build(tauri::generate_context!()).unwrap_or_else(|e| {
logging!(error, Type::Setup, "Failed to build Tauri application: {}", e);
std::process::exit(1);
});
app.run(|app_handle, e| match e {
tauri::RunEvent::Ready | tauri::RunEvent::Resumed => {
if core::handle::Handle::global().is_exiting() {
return;
}
event_handlers::handle_ready_resumed(app_handle);
}
#[cfg(target_os = "macos")]
tauri::RunEvent::Reopen {
has_visible_windows, ..
} => {
if core::handle::Handle::global().is_exiting() {
return;
}
AsyncHandler::spawn(move || async move {
event_handlers::handle_reopen(has_visible_windows).await;
});
}
tauri::RunEvent::Exit => AsyncHandler::block_on(async {
// Windows session ending currently reaches Tao as WM_ENDSESSION and
// destroys the loop without a preventable ExitRequested event.
if !handle::Handle::global().is_exiting() {
handle::Handle::global().set_is_exiting();
let cleanup_result = feat::clean_session_ending_best_effort().await;
logging!(
info,
Type::System,
"Unpreventable session-ending best-effort cleanup returned - core stopped: {}, all cleanup successful: {}",
cleanup_result.core_stopped,
cleanup_result.all_success
);
}
logging!(info, Type::System, "Application exited");
}),
#[allow(unused_variables)]
tauri::RunEvent::ExitRequested { api, code, .. } => {
if module::lightweight::is_in_lightweight_mode() && !handle::Handle::global().is_exiting() {
api.prevent_exit();
} else if code.is_none() {
api.prevent_exit();
if !handle::Handle::global().is_exiting() {
AsyncHandler::block_on(async {
feat::quit().await;
});
}
}
}
tauri::RunEvent::WindowEvent { label, event, .. } if label == "main" => match event {
tauri::WindowEvent::CloseRequested { .. } => {
event_handlers::handle_window_close(&event);
}
tauri::WindowEvent::Focused(focused) => {
// 兜底:原生取消最小化只触发 Focused、不走 activate_window(macOS)
#[cfg(target_os = "macos")]
if focused {
crate::utils::resolve::window::reload_main_window_if_needed();
}
event_handlers::handle_window_focus(focused);
}
#[cfg(target_os = "macos")]
tauri::WindowEvent::Destroyed => {
event_handlers::handle_window_destroyed();
}
_ => {}
},
_ => {}
});
}