diff --git a/Cargo.lock b/Cargo.lock index 81faefb35..03dc9fee5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1241,8 +1241,8 @@ dependencies = [ [[package]] name = "clash_verge_service_ipc" -version = "2.6.0" -source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc.git?branch=feat%2Fstage-runtime#9fae5797ad65b8a3a2ba16eacee1e606770ae57b" +version = "2.6.1" +source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc.git?tag=v2.6.1#10edb52ec071520f14ce78654e401e4246127d75" dependencies = [ "anyhow", "compact_str", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ff385894b..1dafd955d 100755 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -94,8 +94,7 @@ tauri-plugin-devtools = { version = "2.1.0" } tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "refactor-use-reqwest-ipc" } clash_verge_logger = { git = "https://github.com/clash-verge-rev/clash-verge-logger" } async-trait = "0.1.91" -# Tracks a branch until the protocol change it carries is tagged; move this to v2.6.0 then. -clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", branch = "feat/stage-runtime", version = "2.6.0", features = [ +clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.6.1", version = "2.6.1", features = [ "client", ] } arc-swap = "1.9.2" diff --git a/src-tauri/src/cmd/profile.rs b/src-tauri/src/cmd/profile.rs index 88aa7ad49..55c1c8a62 100644 --- a/src-tauri/src/cmd/profile.rs +++ b/src-tauri/src/cmd/profile.rs @@ -361,10 +361,18 @@ pub async fn patch_profile(index: String, profile: PrfItem) -> CmdResult { false }; + // A selection written from the UI or the chain proxy is newer than anything a restore still + // in flight captured; without this it would be pushed back to the older node moments later. + let records_a_selection = profile.selected.is_some(); + profiles_patch_item_safe(&index, &profile) .await .with_error_code("PROFILE_UPDATE_FAILED")?; + if records_a_selection { + profiles::supersede_selected_activation(); + } + // 如果更新间隔或允许自动更新变更,异步刷新定时器 if should_refresh_timer { crate::process::AsyncHandler::spawn(move || async move { diff --git a/src-tauri/src/cmd/proxy.rs b/src-tauri/src/cmd/proxy.rs index 2e23c7e8d..d4e1ec097 100644 --- a/src-tauri/src/cmd/proxy.rs +++ b/src-tauri/src/cmd/proxy.rs @@ -16,6 +16,22 @@ use std::{ sync::atomic::{AtomicBool, Ordering}, }; +/// Record which node a group is on, in the current profile. +/// +/// Takes the group and the node rather than the whole selection list, and merges them into the +/// profile on this side. The frontend used to send the list it had rendered, which made two +/// selections made before that list refreshed into one overwriting the other: both were built +/// from the same stale array, and the later write dropped the earlier group's choice. Since a +/// core start re-applies whatever the profile holds, the dropped one then came back on restart. +/// +/// The tray already recorded this way. Now there is one way. +#[tauri::command] +pub async fn record_selected_node(group_name: String, node: String) -> CmdResult<()> { + crate::config::profiles::record_selected_node(&group_name, &node) + .await + .stringify_err() +} + static TRAY_SYNC_RUNNING: AtomicBool = AtomicBool::new(false); static TRAY_SYNC_PENDING: AtomicBool = AtomicBool::new(false); diff --git a/src-tauri/src/config/profiles.rs b/src-tauri/src/config/profiles.rs index edb74b173..e8eea22f9 100644 --- a/src-tauri/src/config/profiles.rs +++ b/src-tauri/src/config/profiles.rs @@ -22,7 +22,7 @@ use std::{ LazyLock, atomic::{AtomicU64, Ordering}, }, - time::Duration, + time::{Duration, Instant}, }; use tauri_plugin_mihomo::models::{Proxies, ProxyType}; use tokio::{fs, task::JoinHandle}; @@ -47,6 +47,21 @@ static ACTIVATE_SELECTED_GENERATION: AtomicU64 = AtomicU64::new(0); // lock acquisition, connection-pool waiting, and local-socket connection establishment. const MIHOMO_OPERATION_TIMEOUT: Duration = Duration::from_secs(10); const SELECTED_NODES_RECHECK_DELAY: Duration = Duration::from_secs(1); +/// How long a restore keeps trying to put back selections the core has not loaded yet. +/// +/// A provider-backed group is present but empty until its provider finishes loading, which on a +/// cold start is exactly when restoring runs. Bounded rather than indefinite because a record +/// naming a node the profile genuinely no longer has looks identical from here, and would +/// otherwise be retried for the life of the process. +const SELECTED_NODES_SETTLE_DEADLINE: Duration = Duration::from_secs(30); +/// How often a restore looks again while waiting for those groups. +const SELECTED_NODES_SETTLE_INTERVAL: Duration = Duration::from_secs(1); +/// How long a start waits for the selections that *can* be put back before carrying on. +/// +/// Covers a healthy core answering one query and a handful of selects. Past that the start +/// proceeds: the remaining groups need the core to finish loading, which is not something a +/// start can usefully wait for. +const SELECTED_NODES_FIRST_PASS_BUDGET: Duration = Duration::from_secs(3); /// Define the `profiles.yaml` schema #[derive(Default, Debug, Clone, Deserialize, Serialize)] @@ -751,6 +766,21 @@ fn reconcile_selected_nodes( plan } +/// Abandon any activation still in flight. +/// +/// An activation reads the profile, then polls the core until its groups are readable, then puts +/// its selections. A choice the user makes inside that window is newer than what the activation +/// captured, so letting it finish would push the core back to the older node — and the profile, +/// already holding the newer one, would then disagree with the core. Bumping the generation is +/// how an activation is told it has been overtaken. +/// +/// Called *after* the profile is written, which is the only placement that needs to exist: an +/// activation that read the older profile is cancelled by this, and one starting afterwards reads +/// the newer one and needs no cancelling. +pub fn supersede_selected_activation() { + ACTIVATE_SELECTED_GENERATION.fetch_add(1, Ordering::AcqRel); +} + fn is_activation_current(generation: u64) -> bool { ACTIVATE_SELECTED_GENERATION.load(Ordering::Acquire) == generation } @@ -847,6 +877,59 @@ async fn update_tray_after_activation(generation: u64) { } } +/// Record which node a group is on, in the current profile. +/// +/// The counterpart of the frontend's `useRecordSelection`, for the selections the backend makes +/// on the user's behalf — the tray is the one that does. What the profile holds is what +/// [`activate_selected_nodes`] re-applies when a core starts, so a selection it never learned +/// about is one the next start silently undoes. +pub async fn record_selected_node(group_name: &str, node: &str) -> Result<()> { + let group_name = String::from(group_name); + let node = String::from(node); + let recorded = Config::profiles() + .await + .with_data_modify(move |mut profiles| async move { + let Some(current) = profiles.current.clone() else { + return Ok((profiles, false)); + }; + let Some(item) = profiles + .items + .as_mut() + .and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(¤t))) + else { + return Ok((profiles, false)); + }; + + let mut selected = item.selected.clone().unwrap_or_default(); + match selected + .iter_mut() + .find(|entry| entry.name.as_ref() == Some(&group_name)) + { + Some(entry) => { + if entry.now.as_ref() == Some(&node) { + return Ok((profiles, false)); + } + entry.now = Some(node); + } + None => selected.push(PrfSelected { + name: Some(group_name), + now: Some(node), + }), + } + item.selected = Some(selected); + profiles.save_file().await?; + Ok((profiles, true)) + }) + .await?; + + if recorded { + // Newer than anything an activation still in flight captured. + supersede_selected_activation(); + handle::Handle::refresh_profiles(); + } + Ok(()) +} + async fn persist_reconciled_selected( profile_uid: &String, original_selected: &[PrfSelected], @@ -887,10 +970,104 @@ async fn persist_reconciled_selected( Ok(()) } +/// The recorded selections the core is not currently on. +/// +/// Deliberately "is the core on it" rather than "did we send a select": a group whose provider +/// has not loaded is present but empty, a `select` can fail while the core is still settling, and +/// a group that was already correct never produces an activation at all. Only the running state +/// tells those apart. +fn unsettled_selections(selected: &[PrfSelected], proxies: &Proxies) -> Vec { + selected + .iter() + .filter_map(|item| { + let (Some(group_name), Some(node)) = (&item.name, &item.now) else { + return None; + }; + match proxies.proxies.get(group_name.as_str()) { + Some(group) if group.now.as_deref() == Some(node.as_str()) => None, + _ => Some(group_name.clone()), + } + }) + .collect() +} + +/// Keep putting back the selections the core could not be moved to yet. +/// +/// The single re-check a profile switch needs is enough there, because a switch happens once the +/// configuration is loaded. A core start is the opposite case: the groups a restore is trying to +/// put back may not exist yet. Without this 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. +/// +/// Only ever reached with [`SelectionRepair::KeepRecords`]: a profile switch is entitled to +/// conclude that a group it cannot see is gone, so it has nothing to wait for. +async fn settle_pending_selections(selected: &[PrfSelected], completed: &mut HashMap, generation: u64) { + let deadline = Instant::now() + SELECTED_NODES_SETTLE_DEADLINE; + loop { + tokio::time::sleep(SELECTED_NODES_SETTLE_INTERVAL).await; + if !is_activation_current(generation) { + return; + } + let Ok(snapshot) = fetch_proxies_with_timeout().await else { + // Unreachable core: the deadline still applies, so this cannot spin forever. + if Instant::now() >= deadline { + return; + } + continue; + }; + if !is_activation_current(generation) { + return; + } + + let pending = unsettled_selections(selected, &snapshot); + if pending.is_empty() { + return; + } + if Instant::now() >= deadline { + logging!( + warn, + Type::Config, + "gave up putting back {} selected node(s) the core never loaded: {}", + pending.len(), + pending.iter().map(String::as_str).collect::>().join(", ") + ); + return; + } + + let plan = reconcile_selected_nodes(selected, None, &snapshot); + if apply_activations(&plan.activations, completed, generation) + .await + .is_none() + { + return; + } + if is_activation_current(generation) { + handle::Handle::refresh_clash(); + } + } +} + +/// Fires once the selections that could be applied have been. +/// +/// Dropping it without firing releases the waiter too: every way the worker can end early is a +/// way of saying "nothing more is coming", and a start that waits forever for one of them would +/// be a worse failure than the one this exists to prevent. +struct FirstPassSignal(Option>); + +impl FirstPassSignal { + fn notify(&mut self) { + if let Some(sender) = self.0.take() { + let _ = sender.send(()); + } + } +} + async fn activate_selected_nodes_worker( profile_uid: String, selected: Vec, generation: u64, + repair: SelectionRepair, + mut first_pass_done: FirstPassSignal, ) -> Result<()> { let first_snapshot = fetch_proxies_with_timeout().await?; if !is_activation_current(generation) { @@ -948,6 +1125,14 @@ async fn activate_selected_nodes_worker( return Ok(()); } + if repair == SelectionRepair::KeepRecords { + // Everything the core could be moved to has been. Whoever is waiting to point traffic + // here may go; what is left needs the core to finish loading and cannot be waited on. + first_pass_done.notify(); + settle_pending_selections(&selected, &mut completed_activations, generation).await; + return Ok(()); + } + if plan.repaired_count > 0 && is_activation_current(generation) { logging!( info, @@ -961,13 +1146,64 @@ async fn activate_selected_nodes_worker( Ok(()) } +/// Whether an activation may also prune the records it cannot match. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum SelectionRepair { + /// Drop records whose group or node the core does not have. Only safe once the configuration + /// they belong to is known to be fully loaded, which is true right after a profile switch. + Prune, + /// Apply what can be applied and leave the records alone. + /// + /// What a core start needs. A provider-backed group can be empty or absent for seconds after + /// the core comes up, and a record pruned on that evidence is gone from `profiles.yaml` for + /// good — the provider finishing later cannot bring it back. + KeepRecords, +} + pub fn activate_selected_nodes() -> Result<()> { + // The first-pass signal is for callers that wait; a profile switch does not, and dropping + // the receiver simply makes the send a no-op. + drop(activate_selected_nodes_with(SelectionRepair::Prune)); + Ok(()) +} + +/// Put the profile's selections back before anything is pointed at the core. +/// +/// Awaited, unlike a profile switch's activation, because the caller enables the system proxy as +/// soon as this returns: a proxy pointed at a core still sitting on the first entry of every +/// group is the outcome restoring exists to prevent. Bounded so that a core which will not answer +/// delays a start rather than blocking it — the selections keep being retried either way, and +/// being proxied through the wrong node beats not being proxied at all. +/// +/// TUN is deliberately not covered, because it cannot be: it is configured in the core's own +/// file, so it is carrying traffic from the moment the process starts, before anything here could +/// run. 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. +pub async fn restore_selected_nodes() { + let first_pass = activate_selected_nodes_with(SelectionRepair::KeepRecords); + if tokio::time::timeout(SELECTED_NODES_FIRST_PASS_BUDGET, first_pass) + .await + .is_err() + { + logging!( + warn, + Type::Config, + "starting without having put the selected nodes back yet; still trying" + ); + } +} + +fn activate_selected_nodes_with(repair: SelectionRepair) -> tokio::sync::oneshot::Receiver<()> { logging!(info, Type::Config, "starting activating selected nodes"); let mut active_task = ACTIVATE_SELECTED_TASK.lock(); let generation = ACTIVATE_SELECTED_GENERATION.fetch_add(1, Ordering::AcqRel) + 1; let previous_task = active_task.take(); + let (first_pass_sender, first_pass_done) = tokio::sync::oneshot::channel(); let handle = tokio::spawn(async move { + // Held for the whole task so that every exit — superseded, no profile, an error — drops + // it and releases anyone waiting. Only the worker fires it deliberately. + let first_pass = FirstPassSignal(Some(first_pass_sender)); if let Some(previous_task) = previous_task { let _ = previous_task.await; } @@ -976,7 +1212,12 @@ pub fn activate_selected_nodes() -> Result<()> { } let result = async { - let profiles = Config::profiles().await.latest_arc(); + // Committed, not the draft. A profile switch stages its target before validating + // it, and a switch that then fails discards that draft — but an activation which had + // already read it would go on to apply the rejected profile's selections to the one + // still running. The switch that succeeds commits before it activates, so this is the + // same value there. + let profiles = Config::profiles().await.data_arc(); let current = profiles.get_current().context("no current profile running")?.clone(); let selected = profiles .get_item(¤t) @@ -991,7 +1232,7 @@ pub fn activate_selected_nodes() -> Result<()> { } return Ok(()); } - activate_selected_nodes_worker(current, selected, generation).await + activate_selected_nodes_worker(current, selected, generation, repair, first_pass).await } .await; @@ -1007,7 +1248,7 @@ pub fn activate_selected_nodes() -> Result<()> { }); *active_task = Some(handle); drop(active_task); - Ok(()) + first_pass_done } #[cfg(test)] @@ -1042,6 +1283,115 @@ mod tests { } } + #[test] + fn a_group_the_core_has_not_loaded_is_still_unsettled() { + // The regression this pins. A provider-backed group is present but empty until its + // provider loads, which on a cold start is exactly when restoring runs. Reconciling + // produces no activation for it, so a restore that stopped after one re-check reported + // success having left the group on the first entry of its `proxies:` list. + let saved = vec![selected("provider-group", "saved")]; + let unloaded = proxies(vec![("provider-group", &[], None)]); + + assert!( + reconcile_selected_nodes(&saved, Some(&unloaded), &unloaded) + .activations + .is_empty(), + "nothing can be activated while the group is empty" + ); + assert_eq!( + unsettled_selections(&saved, &unloaded), + vec![String::from("provider-group")], + "so the restore must keep looking rather than call it done" + ); + } + + #[test] + fn a_group_the_core_is_already_on_is_settled() { + let saved = vec![selected("Proxy", "Node A")]; + let loaded = proxies(vec![("Proxy", &["Node A", "Node B"], Some("Node A"))]); + + assert!( + unsettled_selections(&saved, &loaded).is_empty(), + "a group already on its node needs nothing, even though it produces no activation" + ); + } + + #[test] + fn a_group_on_the_wrong_node_is_unsettled() { + // Covers a `select` that failed transiently: the group is loaded, the node exists, and + // the core is simply not on it. Retrying is the only thing that fixes that. + let saved = vec![selected("Proxy", "Node A")]; + let wrong = proxies(vec![("Proxy", &["Node A", "Node B"], Some("Node B"))]); + + assert_eq!(unsettled_selections(&saved, &wrong), vec![String::from("Proxy")]); + } + + #[test] + fn a_record_without_a_group_or_node_is_not_waited_on() { + // A malformed record cannot be satisfied by waiting, and holding the settle loop open + // for it would delay giving up on everything else. + let malformed = vec![ + PrfSelected { + name: Some("Proxy".into()), + now: None, + }, + PrfSelected { + name: None, + now: Some("Node A".into()), + }, + ]; + + assert!(unsettled_selections(&malformed, &proxies(vec![])).is_empty()); + } + + #[test] + fn a_group_missing_from_both_snapshots_is_dropped_from_the_records() { + // This is the pruning a profile switch wants and a core start must not do: the record is + // gone from the plan, and `persist_reconciled_selected` writes the plan back. Pinned here + // because it is why `SelectionRepair` exists — the predicate is right, the question is + // only who is entitled to act on it. + let saved = vec![selected("provider-group", "saved")]; + let empty = proxies(vec![]); + + let plan = reconcile_selected_nodes(&saved, Some(&empty), &empty); + + assert!( + plan.selected.is_empty(), + "a group absent from both looks invalid, so the record is dropped" + ); + assert_eq!(plan.repaired_count, 1, "and dropping it counts as a repair"); + } + + #[test] + fn a_group_missing_from_only_the_second_snapshot_is_kept() { + // Absent once is not evidence: only a group that was already absent when the first + // snapshot was taken is treated as gone. + let saved = vec![selected("provider-group", "saved")]; + let had_it = proxies(vec![("provider-group", &["saved"], Some("saved"))]); + let lost_it = proxies(vec![]); + + let plan = reconcile_selected_nodes(&saved, Some(&had_it), &lost_it); + + assert_eq!(plan.selected, saved); + assert_eq!(plan.repaired_count, 0); + } + + #[test] + fn superseding_an_activation_makes_the_one_in_flight_stand_down() { + // What a selection made during a start relies on: the restore that is still polling the + // core has to notice it has been overtaken, or it will push the older node back. + let generation = ACTIVATE_SELECTED_GENERATION.load(Ordering::Acquire) + 1; + ACTIVATE_SELECTED_GENERATION.store(generation, Ordering::Release); + assert!(is_activation_current(generation)); + + supersede_selected_activation(); + + assert!( + !is_activation_current(generation), + "an activation that has been overtaken must stop before it puts anything" + ); + } + #[test] fn keeps_valid_selection_and_activates_when_needed() { let saved = vec![selected("group", "saved")]; diff --git a/src-tauri/src/core/manager/config.rs b/src-tauri/src/core/manager/config.rs index 898123a15..94cc74ebb 100644 --- a/src-tauri/src/core/manager/config.rs +++ b/src-tauri/src/core/manager/config.rs @@ -404,6 +404,22 @@ mod tests { ); } + /// Wrap a rejection as the answer the Service would have sent. + /// + /// The `match` is the point, not the wrapping: it is exhaustive, so a new way for the Service + /// to decline cannot be added upstream without this failing to compile — which is what sends + /// whoever adds it to the test below to say what it should lead to. `CoreRestarted` reached + /// production without one, and inherited its verdict silently. + fn declined(reason: StageRejection) -> StageAttempt { + match &reason { + StageRejection::CoreNotRunning + | StageRejection::CorePathChanged + | StageRejection::RuntimeUnwritable { .. } + | StageRejection::CoreRestarted => {} + } + StageAttempt::Answered(StageRuntimeOutcome::RestartRequired { reason }) + } + #[test] fn a_service_that_declined_makes_the_core_be_replaced() { for reason in [ @@ -412,11 +428,13 @@ mod tests { StageRejection::RuntimeUnwritable { detail: "held open".to_owned(), }, + // The service's own watchdog can restart the core mid-staging, which leaves the + // generation holding a mixture no manifest describes. Rebuilding it from nothing is + // the only answer that does not guess at what is in there. + StageRejection::CoreRestarted, ] { - let attempt = StageAttempt::Answered(StageRuntimeOutcome::RestartRequired { reason: reason.clone() }); - assert_eq!( - plan_config_application(&attempt), + plan_config_application(&declined(reason.clone())), ConfigApplication::ReplaceCore, "declining is an outcome, not an error: {reason:?} must fall back, not fail" ); diff --git a/src-tauri/src/core/manager/state.rs b/src-tauri/src/core/manager/state.rs index d0722533f..db2bcae5e 100644 --- a/src-tauri/src/core/manager/state.rs +++ b/src-tauri/src/core/manager/state.rs @@ -18,6 +18,29 @@ use tauri_plugin_mihomo::MihomoExt as _; use tauri_plugin_shell::ShellExt as _; const SIDECAR_READINESS_ATTEMPTS: usize = 30; + +impl CoreManager { + /// A core process is up: put back the node selections the user made. + /// + /// mihomo restores these itself, from the `cache.db` it keeps in the directory it was started + /// against — but only when `profile.store-selected` is on and that file survived, and neither + /// is something the app can assume. The setting comes from a merge template a user is free to + /// replace, and a service older than the durable runtime generation hands every start a + /// directory nothing has ever run in. The app holds the same selections in the profile, so it + /// is the one that can say for certain. + /// + /// Awaited, and deliberately here rather than beside the proxy: the caller enables the system + /// proxy as soon as the start returns, and pointing it at a core still on the first entry of + /// every group is what this exists to prevent. The wait is bounded — what cannot be put back + /// yet keeps being retried in the background — so a core that will not answer delays a start + /// instead of blocking it. + /// + /// Repeat calls supersede each other, so every start path may call this without coordinating. + async fn restore_selected_nodes(&self) { + crate::config::profiles::restore_selected_nodes().await; + } +} + const SIDECAR_READINESS_INTERVAL: std::time::Duration = std::time::Duration::from_millis(100); const SIDECAR_READINESS_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(400); @@ -160,6 +183,7 @@ impl CoreManager { self.set_running_child_sidecar(child); let core_readiness_generation = self.mark_core_ready(); self.core_started(RunningMode::Sidecar); + self.restore_selected_nodes().await; AsyncHandler::spawn(move || async move { while let Some(event) = rx.recv().await { @@ -250,6 +274,7 @@ impl CoreManager { Ok(()) => { self.mark_core_ready(); self.core_started(RunningMode::Service); + self.restore_selected_nodes().await; return Ok(()); } Err(e) => { @@ -274,6 +299,7 @@ impl CoreManager { service::run_core_by_service(config_file).await?; self.mark_core_ready(); self.core_started(RunningMode::Service); + self.restore_selected_nodes().await; Ok(()) } } diff --git a/src-tauri/src/feat/profile.rs b/src-tauri/src/feat/profile.rs index 3d299f6cf..1b410b7fd 100644 --- a/src-tauri/src/feat/profile.rs +++ b/src-tauri/src/feat/profile.rs @@ -17,6 +17,23 @@ pub async fn toggle_proxy_profile(profile_index: String) { ); } +/// Tell the profile which node this group is on now. +/// +/// The core is already switched by the time this runs, so failing to record is not a reason to +/// report the switch as failed — but it is worth a log line, because what the profile holds is +/// what gets re-applied the next time a core starts. +async fn record_switched_node(group_name: &str, proxy_name: &str) { + if let Err(error) = crate::config::profiles::record_selected_node(group_name, proxy_name).await { + logging!( + warn, + Type::Tray, + "切换代理成功但未能记录到配置: {} -> {}, 错误: {error:#}", + group_name, + proxy_name + ); + } +} + pub async fn switch_proxy_node(group_name: &str, proxy_name: &str) { match handle::Handle::mihomo() .select_node_for_group(group_name, proxy_name) @@ -24,6 +41,7 @@ pub async fn switch_proxy_node(group_name: &str, proxy_name: &str) { { Ok(_) => { logging!(info, Type::Tray, "切换代理成功: {} -> {}", group_name, proxy_name); + record_switched_node(group_name, proxy_name).await; let _ = handle::Handle::app_handle().emit("verge://refresh-proxy-config", ()); let _ = tray::Tray::global().update_menu().await; return; @@ -46,6 +64,7 @@ pub async fn switch_proxy_node(group_name: &str, proxy_name: &str) { { Ok(_) => { logging!(info, Type::Tray, "代理切换回退成功: {} -> {}", group_name, proxy_name); + record_switched_node(group_name, proxy_name).await; let _ = tray::Tray::global().update_menu().await; } Err(err) => { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b9e7cd038..4a89f6048 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -164,6 +164,7 @@ mod app_init { 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, diff --git a/src/components/home/current-proxy-card.tsx b/src/components/home/current-proxy-card.tsx index b40f796c9..4dcf2a22d 100644 --- a/src/components/home/current-proxy-card.tsx +++ b/src/components/home/current-proxy-card.tsx @@ -382,21 +382,16 @@ export const CurrentProxyCard = () => { } const previousProxy = selectedGroup.now const nextName = option.member.ref.name + // Recorded like any other selection, global mode included: what the profile holds is + // what gets re-applied when a core starts, so a choice it never learns about is undone. handleSelectChange( selectedGroup.name, previousProxy, - isGlobalMode, )({ target: { value: nextName }, }) }, - [ - handleSelectChange, - isDirectMode, - isGlobalMode, - selectedGroup, - unsortedProxyOptions, - ], + [handleSelectChange, isDirectMode, selectedGroup, unsortedProxyOptions], ) // 导航到代理页面 diff --git a/src/components/proxy/proxy-chain.tsx b/src/components/proxy/proxy-chain.tsx index 177148d37..fe10c4556 100644 --- a/src/components/proxy/proxy-chain.tsx +++ b/src/components/proxy/proxy-chain.tsx @@ -43,6 +43,7 @@ import { import { TooltipIcon } from '@/components/base' import { useRuntimeConfig } from '@/hooks/use-clash' +import { useRecordSelection } from '@/hooks/use-record-selection' import { useAppRefreshers, useProxiesData } from '@/providers/app-data-context' import { updateProxyChainConfigInRuntime } from '@/services/cmds' import { @@ -259,6 +260,7 @@ export const ProxyChain = ({ const { refreshProxy } = useAppRefreshers() const { data: runtimeConfig } = useRuntimeConfig(true) const [isConnecting, setIsConnecting] = useState(false) + const recordSelection = useRecordSelection() const markUnsavedChanges = useCallback(() => { onMarkUnsavedChanges?.() }, [onMarkUnsavedChanges]) @@ -379,10 +381,12 @@ export const ProxyChain = ({ if (targetGroup) { try { await selectNodeForGroup(targetGroup, 'DIRECT') + recordSelection(targetGroup, 'DIRECT') } catch { if (currentProxyChain.length >= 1) { try { await selectNodeForGroup(targetGroup, currentProxyChain[0].name) + recordSelection(targetGroup, currentProxyChain[0].name) } catch { // ignore } @@ -440,6 +444,9 @@ export const ProxyChain = ({ const targetGroup = mode === 'global' ? 'GLOBAL' : selectedGroup await selectNodeForGroup(targetGroup || 'GLOBAL', lastNode.name) + // The chain moves the group like any other selection, so the profile has to learn about + // it: what the profile holds is what gets re-applied the next time the core starts. + recordSelection(targetGroup || 'GLOBAL', lastNode.name) localStorage.setItem('proxy-chain-group', targetGroup || 'GLOBAL') localStorage.setItem('proxy-chain-exit-node', lastNode.name) @@ -461,6 +468,7 @@ export const ProxyChain = ({ proxyView, selectedGroup, onUpdateChain, + recordSelection, ]) // 处理链式代理配置数据 diff --git a/src/hooks/use-proxy-selection.ts b/src/hooks/use-proxy-selection.ts index cc956f844..08658e6b0 100644 --- a/src/hooks/use-proxy-selection.ts +++ b/src/hooks/use-proxy-selection.ts @@ -5,7 +5,7 @@ import { selectNodeForGroup, } from 'tauri-plugin-mihomo-api' -import { useProfiles } from '@/hooks/use-profiles' +import { useRecordSelection } from '@/hooks/use-record-selection' import { useVerge } from '@/hooks/use-verge' import { syncTrayProxySelection } from '@/services/cmds' import { debugLog } from '@/utils/debug' @@ -37,12 +37,11 @@ interface ProxyChangeRequest { groupName: string proxyName: string previousProxy?: string - skipConfigSave: boolean } // 代理选择 Hook export const useProxySelection = (options: ProxySelectionOptions = {}) => { - const { current, patchCurrent } = useProfiles() + const recordSelection = useRecordSelection() const { verge } = useVerge() const pendingRequestRef = useRef(null) const isProcessingRef = useRef(false) @@ -65,36 +64,16 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => { }) }, []) - const persistSelection = useCallback( - (groupName: string, proxyName: string, skipConfigSave: boolean) => { - if (!current || skipConfigSave) return - - const selected = current.selected ? [...current.selected] : [] - const index = selected.findIndex((item) => item.name === groupName) - - if (index < 0) { - selected.push({ name: groupName, now: proxyName }) - } else { - selected[index] = { name: groupName, now: proxyName } - } - - patchCurrent({ selected }).catch((error) => { - console.error('[ProxySelection] 保存代理选择失败:', error) - }) - }, - [current, patchCurrent], - ) - const executeChange = useCallback( async (request: ProxyChangeRequest) => { - const { groupName, proxyName, previousProxy, skipConfigSave } = request + const { groupName, proxyName, previousProxy } = request debugLog(`[ProxySelection] 代理切换: ${groupName} -> ${proxyName}`) try { await selectNodeForGroup(groupName, proxyName) onSuccess?.() syncTraySelection() - persistSelection(groupName, proxyName, skipConfigSave) + recordSelection(groupName, proxyName) debugLog( `[ProxySelection] 代理和状态同步完成: ${groupName} -> ${proxyName}`, ) @@ -114,7 +93,7 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => { onError?.(error) } }, - [config, onError, onSuccess, persistSelection, syncTraySelection], + [config, onError, onSuccess, recordSelection, syncTraySelection], ) const flushChangeQueue = useCallback(async () => { @@ -136,17 +115,11 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => { }, [executeChange]) const changeProxy = useCallback( - ( - groupName: string, - proxyName: string, - previousProxy?: string, - skipConfigSave: boolean = false, - ) => { + (groupName: string, proxyName: string, previousProxy?: string) => { pendingRequestRef.current = { groupName, proxyName, previousProxy, - skipConfigSave, } void flushChangeQueue() }, @@ -154,14 +127,10 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => { ) const handleSelectChange = useCallback( - ( - groupName: string, - previousProxy?: string, - skipConfigSave: boolean = false, - ) => + (groupName: string, previousProxy?: string) => (event: { target: { value: string } }) => { const newProxy = event.target.value - changeProxy(groupName, newProxy, previousProxy, skipConfigSave) + changeProxy(groupName, newProxy, previousProxy) }, [changeProxy], ) diff --git a/src/hooks/use-record-selection.test.tsx b/src/hooks/use-record-selection.test.tsx new file mode 100644 index 000000000..506cd7cba --- /dev/null +++ b/src/hooks/use-record-selection.test.tsx @@ -0,0 +1,59 @@ +// @vitest-environment jsdom +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useRecordSelection } from './use-record-selection' + +const recordSelectedNode = vi.hoisted(() => vi.fn(() => Promise.resolve())) + +vi.mock('@/services/cmds', () => ({ recordSelectedNode })) + +const record = (groupName: string, proxyName: string) => { + const { result } = renderHook(() => useRecordSelection()) + result.current(groupName, proxyName) +} + +beforeEach(() => { + recordSelectedNode.mockClear() +}) + +describe('useRecordSelection', () => { + it('sends only the group and the node', () => { + record('Proxy', 'Node A') + + expect(recordSelectedNode).toHaveBeenCalledWith('Proxy', 'Node A') + }) + + it('does not derive anything from a rendered selection list', () => { + // The regression this pins. It used to build the whole list from the profile it had + // rendered, so two selections made before that list refreshed were both derived from the + // same stale snapshot and the later one dropped the earlier group. Since a core start + // re-applies whatever the profile holds, the dropped choice came back on the next restart. + // Each call now carries one group, and the backend merges against the current profile. + record('Proxy', 'Node A') + record('Fallback', 'Node C') + + expect(recordSelectedNode).toHaveBeenNthCalledWith(1, 'Proxy', 'Node A') + expect(recordSelectedNode).toHaveBeenNthCalledWith(2, 'Fallback', 'Node C') + }) + + it('is stable across renders, so callers may hold it in a dependency array', () => { + const { result, rerender } = renderHook(() => useRecordSelection()) + const first = result.current + + rerender() + + expect(result.current).toBe(first) + }) + + it('reports a failed write without throwing at the caller', async () => { + // Recording runs after the core has already been switched; failing the switch over it would + // be wrong, so the caller is never made to handle it. + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + recordSelectedNode.mockRejectedValueOnce(new Error('no profile')) + + expect(() => record('Proxy', 'Node A')).not.toThrow() + + await vi.waitFor(() => expect(consoleError).toHaveBeenCalled()) + }) +}) diff --git a/src/hooks/use-record-selection.ts b/src/hooks/use-record-selection.ts new file mode 100644 index 000000000..d0cd11edd --- /dev/null +++ b/src/hooks/use-record-selection.ts @@ -0,0 +1,28 @@ +import { useCallback } from 'react' + +import { recordSelectedNode } from '@/services/cmds' + +/** + * Record which node a group is on, in the profile. + * + * The core keeps its own copy in `cache.db` and restores it when it starts, but neither half of + * that is something the app can rely on: `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. So the app re-applies what the profile says once + * the core is up. + * + * Which makes this the rule: a selection the profile does not know about is one the next start + * will undo. Every path that moves a group has to come through here. + * + * Only the group and the node are sent. Sending the whole selection list — which is what this + * did while it built the array from `useProfiles().current` — made two selections made before + * that list refreshed into one overwriting the other, because both were derived from the same + * stale snapshot. The merge happens on the backend, against whatever the profile holds by then. + */ +export const useRecordSelection = () => { + return useCallback((groupName: string, proxyName: string) => { + recordSelectedNode(groupName, proxyName).catch((error) => { + console.error('[Selection] 保存代理选择失败:', error) + }) + }, []) +} diff --git a/src/services/cmds.ts b/src/services/cmds.ts index d0414dc60..93b25e797 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -125,6 +125,16 @@ export async function syncTrayProxySelection() { return invoke('sync_tray_proxy_selection') } +/** + * Record which node a group is on, in the current profile. + * + * Group and node, not the whole selection list: the merge happens in the backend against the + * profile as it stands, so two selections made in quick succession cannot overwrite each other. + */ +export async function recordSelectedNode(groupName: string, node: string) { + return invoke('record_selected_node', { groupName, node }) +} + export async function getProxyView(): Promise { const view = await invoke('get_proxy_view') if (view.schemaVersion !== 1) {