mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-08 02:52:07 +08:00
chore: sync declarative effects with dev
This commit is contained in:
+8
-17
@@ -8,12 +8,12 @@ These rules supplement the repository's root `AGENTS.md`.
|
||||
`core::notify::announce` after successful persistence and commit. This keeps
|
||||
failed transactions from publishing state. Independent transactions retain
|
||||
their boundaries and announce via `after_commit`; runtime-only profile
|
||||
activation announces after successful application. New cross-layer
|
||||
exceptions require a rationale in the PR and an update to this file.
|
||||
2. **Register every new `IVerge` field.** Add one row, including an explicit
|
||||
`NoEffect` where appropriate, to the exhaustive registry and preserve its
|
||||
platform attributes. Keep the serialized-field coverage test passing. Manual
|
||||
field-to-flag chains hide missing effects and are forbidden. No exceptions.
|
||||
activation announces after successful application. Cross-layer operations
|
||||
must preserve these ownership and transaction boundaries.
|
||||
2. **Register every new `IVerge` field.** Register the field in the
|
||||
exhaustive effect registry, including an explicit `NoEffect` where
|
||||
appropriate, and preserve its platform attributes. Manual field-to-flag
|
||||
chains hide missing effects and are forbidden.
|
||||
3. **Use `utils::retry` for bounded retries.** Do not introduce handwritten retry
|
||||
loops, including `loop` plus `sleep`; preserve attempt budgets, delays and
|
||||
terminal errors. Permanent watchers are the exception and must document
|
||||
@@ -21,17 +21,8 @@ These rules supplement the repository's root `AGENTS.md`.
|
||||
a retry policy.
|
||||
4. **Keep notifications typed.** Send notice statuses through `NoticeStatus` and
|
||||
use `Arc<str>` for notice message payloads. Preserve the event names and
|
||||
status strings pinned by the golden tests. Status additions or changes
|
||||
require frontend and backend changes in the same PR and an updated golden
|
||||
list; this protects the wire contract. No exceptions.
|
||||
|
||||
Known dead wire entries, recorded here and intentionally kept (no tracking
|
||||
issue): the `config_validate::*` statuses whose frontend handlers never
|
||||
receive a backend send (`core_change`, `stdout_error`, `yaml_key_error`,
|
||||
`yaml_error`, `merge_key_error`, `merge_error`), and `set_config::ok`,
|
||||
sent by the backend with no frontend handler. Each variant carries its own
|
||||
comment in `NoticeStatus`. Removal requires a same-PR frontend change and a
|
||||
golden-list update.
|
||||
status strings as a shared wire contract. Contract changes must keep
|
||||
frontend consumers and backend producers aligned.
|
||||
5. **Preserve lock order.** The order is `config_write` before
|
||||
`config_update_in_progress` before `lifecycle_lock`. Patch callers must use
|
||||
the executor's guarded claim instead of independently combining these locks;
|
||||
|
||||
+16
-16
@@ -29,7 +29,7 @@ perf-harness = ["dep:tauri-plugin-wdio-webdriver"]
|
||||
identifier = "io.github.clash-verge-rev.clash-verge-rev"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.6.3", features = [] }
|
||||
tauri-build = { version = "2.7.1", features = [] }
|
||||
|
||||
[dependencies]
|
||||
clash-verge-draft = { workspace = true }
|
||||
@@ -74,13 +74,13 @@ sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs.git", branch
|
||||
"guard",
|
||||
] }
|
||||
network-interface = { version = "2.0.5", features = ["serde"] }
|
||||
tauri-plugin-shell = "2.3.6"
|
||||
tauri-plugin-opener = "2.5.5"
|
||||
tauri-plugin-dialog = "2.7.3"
|
||||
tauri-plugin-fs = "2.5.2"
|
||||
tauri-plugin-process = "2.3.1"
|
||||
tauri-plugin-deep-link = "2.4.10"
|
||||
tauri-plugin-window-state = "2.4.1"
|
||||
tauri-plugin-shell = "2.4.0"
|
||||
tauri-plugin-opener = "2.7.0"
|
||||
tauri-plugin-dialog = "2.8.1"
|
||||
tauri-plugin-fs = "2.6.0"
|
||||
tauri-plugin-process = "2.4.0"
|
||||
tauri-plugin-deep-link = "2.6.1"
|
||||
tauri-plugin-window-state = "2.5.0"
|
||||
zip = "8.6.0"
|
||||
flate2 = "1.1.10"
|
||||
reqwest_dav = "0.3.3"
|
||||
@@ -91,19 +91,19 @@ getrandom = "0.4.3"
|
||||
futures = "0.3.34"
|
||||
gethostname = "1.1.0"
|
||||
scopeguard = "1.2.0"
|
||||
tauri-plugin-notification = "2.4.0"
|
||||
tauri-plugin-notification = "2.5.1"
|
||||
tokio-stream = "0.1.19"
|
||||
backon = { version = "1.6.0", features = ["tokio-sleep"] }
|
||||
tauri-plugin-http = "2.6.1"
|
||||
tauri-plugin-http = "2.8.0"
|
||||
tracing = { workspace = true }
|
||||
tauri-plugin-devtools = { version = "2.2.1", optional = true }
|
||||
tauri-plugin-devtools = { version = "2.2.2", optional = true }
|
||||
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "main" }
|
||||
async-trait = "0.1.92"
|
||||
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.6", version = "2.7.6", features = [
|
||||
"client",
|
||||
] }
|
||||
arc-swap = "1.9.2"
|
||||
tokio-rustls = "0.26.5"
|
||||
tokio-rustls = "0.26.6"
|
||||
rustls = { version = "0.23.45", features = ["ring"] }
|
||||
webpki-roots = "1.0.9"
|
||||
dark-light = "3.0.0"
|
||||
@@ -111,7 +111,7 @@ bytes = "1.12.1"
|
||||
tokio-util = { version = "0.7.19", features = ["time"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2.189"
|
||||
libc = "0.2.190"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
tauri-plugin-wdio-webdriver = { version = "=1.4.0", optional = true }
|
||||
@@ -146,9 +146,9 @@ windows = { version = "0.62.2", features = ["Win32_Globalization"] }
|
||||
windows-sys.workspace = true
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-autostart = "2.5.1"
|
||||
tauri-plugin-global-shortcut = "2.3.2"
|
||||
tauri-plugin-updater = "2.12.0"
|
||||
tauri-plugin-autostart = "2.7.0"
|
||||
tauri-plugin-global-shortcut = "2.4.0"
|
||||
tauri-plugin-updater = "2.13.1"
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { workspace = true }
|
||||
|
||||
@@ -901,6 +901,7 @@ Section Install
|
||||
|
||||
nsExec::Exec 'netsh int tcp res'
|
||||
|
||||
!include "Win\RestartManager.nsh"
|
||||
!insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
|
||||
!insertmacro CheckAllVergeProcesses
|
||||
|
||||
|
||||
+57
-30
@@ -4,7 +4,7 @@ use crate::{
|
||||
config::Config,
|
||||
core::{
|
||||
handle::Handle,
|
||||
proxy_view::{ProxyViewBuilder, ProxyViewInput, ProxyViewV1},
|
||||
proxy_view::{GroupScope, ProxyViewBuilder, ProxyViewInput, ProxyViewV1},
|
||||
tray::Tray,
|
||||
},
|
||||
process::AsyncHandler,
|
||||
@@ -12,7 +12,7 @@ use crate::{
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use serde_yaml_ng::Mapping;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
collections::{BTreeMap, HashSet},
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
@@ -50,11 +50,65 @@ fn runtime_group_order(config: Option<&Mapping>) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Mirrors Mihomo: `include-all`/`include-all-providers` replace `use` with every provider, sorted.
|
||||
fn runtime_group_scopes(config: Option<&Mapping>) -> BTreeMap<String, GroupScope> {
|
||||
let Some(config) = config else {
|
||||
return BTreeMap::new();
|
||||
};
|
||||
let mut all_providers = config
|
||||
.get("proxy-providers")
|
||||
.and_then(|providers| providers.as_mapping())
|
||||
.into_iter()
|
||||
.flat_map(|providers| providers.keys())
|
||||
.filter_map(|name| name.as_str())
|
||||
.map(str::to_owned)
|
||||
.collect::<Vec<_>>();
|
||||
all_providers.sort();
|
||||
let flag = |group: &Mapping, key: &str| group.get(key).and_then(|value| value.as_bool()) == Some(true);
|
||||
|
||||
config
|
||||
.get("proxy-groups")
|
||||
.and_then(|groups| groups.as_sequence())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|group| group.as_mapping())
|
||||
.filter_map(|group| {
|
||||
let name = group.get("name")?.as_str()?.to_owned();
|
||||
let providers = if flag(group, "include-all") || flag(group, "include-all-providers") {
|
||||
all_providers.clone()
|
||||
} else {
|
||||
group
|
||||
.get("use")
|
||||
.and_then(|providers| providers.as_sequence())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|provider| provider.as_str())
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
};
|
||||
let exclude_types = group
|
||||
.get("exclude-type")
|
||||
.and_then(|types| types.as_str())
|
||||
.filter(|types| !types.is_empty())
|
||||
.map(|types| types.split('|').map(str::to_owned).collect())
|
||||
.unwrap_or_default();
|
||||
Some((
|
||||
name,
|
||||
GroupScope {
|
||||
providers,
|
||||
exclude_types,
|
||||
},
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_proxy_view() -> CmdResult<ProxyViewV1> {
|
||||
let runtime = Config::runtime().await;
|
||||
let latest_runtime = runtime.latest_arc();
|
||||
let runtime_group_order = runtime_group_order(latest_runtime.config.as_ref());
|
||||
let group_scopes = runtime_group_scopes(latest_runtime.config.as_ref());
|
||||
|
||||
let mihomo = Handle::mihomo();
|
||||
let (proxies, providers) = tokio::join!(mihomo.get_proxies(), mihomo.get_proxy_providers(),);
|
||||
@@ -62,6 +116,7 @@ pub async fn get_proxy_view() -> CmdResult<ProxyViewV1> {
|
||||
|
||||
Ok(ProxyViewBuilder::build(ProxyViewInput {
|
||||
runtime_group_order,
|
||||
group_scopes,
|
||||
proxies,
|
||||
providers: providers.ok(),
|
||||
}))
|
||||
@@ -110,31 +165,3 @@ async fn run_tray_sync_loop() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used)]
|
||||
mod tests {
|
||||
use serde_yaml_ng::Value;
|
||||
|
||||
use super::runtime_group_order;
|
||||
|
||||
#[test]
|
||||
fn runtime_order_keeps_first_non_empty_non_global_name() {
|
||||
let config: Value = serde_yaml_ng::from_str(
|
||||
r#"
|
||||
proxy-groups:
|
||||
- name: Beta
|
||||
- name: ""
|
||||
- name: GLOBAL
|
||||
- name: " Alpha "
|
||||
- name: Beta
|
||||
"#,
|
||||
)
|
||||
.expect("parse runtime");
|
||||
|
||||
assert_eq!(
|
||||
runtime_group_order(config.as_mapping()),
|
||||
["Beta".to_owned(), " Alpha ".to_owned()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -823,7 +823,12 @@ impl CoreManager {
|
||||
return;
|
||||
}
|
||||
|
||||
if crate::core::runstate::RUN_STATE.state().service_usable() {
|
||||
let state = crate::core::runstate::RUN_STATE.state();
|
||||
// Waiting cannot bring up a service nothing will start.
|
||||
if state.service_usable()
|
||||
|| matches!(&state.health, crate::core::runstate::ServiceHealth::Unavailable(reason)
|
||||
if crate::core::service::is_not_auto_started(reason))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -245,7 +245,7 @@ impl CoreManager {
|
||||
pub(crate) fn invalidate_core_readiness(&self) {
|
||||
let _ = self
|
||||
.core_readiness_state
|
||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
|
||||
.try_update(Ordering::AcqRel, Ordering::Acquire, |current| {
|
||||
active_core_readiness_generation(current).map(inactive_core_readiness_state)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,10 +7,17 @@ use tauri_plugin_mihomo::models::{
|
||||
|
||||
pub struct ProxyViewInput {
|
||||
pub runtime_group_order: Vec<String>,
|
||||
pub group_scopes: BTreeMap<String, GroupScope>,
|
||||
pub proxies: Proxies,
|
||||
pub providers: Option<ProxyProviders>,
|
||||
}
|
||||
|
||||
/// The providers a group draws from (Mihomo order) and its `exclude-type` entries.
|
||||
pub struct GroupScope {
|
||||
pub providers: Vec<String>,
|
||||
pub exclude_types: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct ProxyViewBuilder;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
@@ -167,15 +174,41 @@ pub struct ProxySubscriptionInfo {
|
||||
pub expire: i64,
|
||||
}
|
||||
|
||||
/// Proxy name -> `(provider name, record id)` in provider order.
|
||||
type ProviderCandidates = BTreeMap<String, Vec<(String, String)>>;
|
||||
|
||||
struct MemberResolver<'a> {
|
||||
group_names: BTreeSet<String>,
|
||||
group_scopes: &'a BTreeMap<String, GroupScope>,
|
||||
records: &'a BTreeMap<String, ProxyNodeView>,
|
||||
core_node_ids: &'a BTreeMap<String, String>,
|
||||
provider_candidates: &'a BTreeMap<String, Vec<String>>,
|
||||
provider_candidates: &'a ProviderCandidates,
|
||||
provider_available: bool,
|
||||
}
|
||||
|
||||
impl MemberResolver<'_> {
|
||||
fn resolve(&self, name: String) -> ProxyMemberRef {
|
||||
impl<'a> MemberResolver<'a> {
|
||||
/// Same-name records in the order Mihomo lists them in `group`: `use` order, after `exclude-type`.
|
||||
fn scoped_candidates(&self, group: &str, name: &str) -> Vec<&'a String> {
|
||||
let (Some(scope), Some(candidates)) = (self.group_scopes.get(group), self.provider_candidates.get(name)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
scope
|
||||
.providers
|
||||
.iter()
|
||||
.flat_map(|provider| candidates.iter().filter(move |(owner, _)| owner == provider))
|
||||
.map(|(_, record_id)| record_id)
|
||||
.filter(|record_id| {
|
||||
!self.records.get(*record_id).is_some_and(|node| {
|
||||
scope
|
||||
.exclude_types
|
||||
.iter()
|
||||
.any(|excluded| excluded.eq_ignore_ascii_case(node.proxy_type.as_str()))
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn resolve(&self, name: String, scoped: Option<&String>) -> ProxyMemberRef {
|
||||
if self.group_names.contains(&name) {
|
||||
ProxyMemberRef::Group { name }
|
||||
} else if let Some(record_id) = self.core_node_ids.get(&name) {
|
||||
@@ -189,16 +222,17 @@ impl MemberResolver<'_> {
|
||||
reason: ProxyMemberUnresolvedReason::ProviderUnavailable,
|
||||
}
|
||||
} else {
|
||||
match self.provider_candidates.get(&name).map(Vec::as_slice) {
|
||||
Some([record_id]) => ProxyMemberRef::Node {
|
||||
let candidates = self.provider_candidates.get(&name).map_or(&[][..], Vec::as_slice);
|
||||
match (scoped, candidates) {
|
||||
(Some(record_id), _) | (None, [(_, record_id)]) => ProxyMemberRef::Node {
|
||||
name,
|
||||
record_id: record_id.clone(),
|
||||
},
|
||||
None => ProxyMemberRef::Unresolved {
|
||||
(None, []) => ProxyMemberRef::Unresolved {
|
||||
name,
|
||||
reason: ProxyMemberUnresolvedReason::Missing,
|
||||
},
|
||||
Some(_) => ProxyMemberRef::Unresolved {
|
||||
(None, _) => ProxyMemberRef::Unresolved {
|
||||
name,
|
||||
reason: ProxyMemberUnresolvedReason::Ambiguous,
|
||||
},
|
||||
@@ -211,6 +245,7 @@ impl ProxyViewBuilder {
|
||||
pub fn build(input: ProxyViewInput) -> ProxyViewV1 {
|
||||
let ProxyViewInput {
|
||||
runtime_group_order,
|
||||
group_scopes,
|
||||
proxies,
|
||||
providers,
|
||||
} = input;
|
||||
@@ -224,6 +259,8 @@ impl ProxyViewBuilder {
|
||||
let (providers, provider_candidates) = build_provider_records(providers, &mut records);
|
||||
let resolver = MemberResolver {
|
||||
group_names: core_groups.keys().cloned().collect(),
|
||||
group_scopes: &group_scopes,
|
||||
records: &records,
|
||||
core_node_ids: &core_node_ids,
|
||||
provider_candidates: &provider_candidates,
|
||||
provider_available: provider_state == ProxyViewProviderState::Ready,
|
||||
@@ -291,7 +328,7 @@ fn build_core_records(
|
||||
fn build_provider_records(
|
||||
providers: Option<ProxyProviders>,
|
||||
records: &mut BTreeMap<String, ProxyNodeView>,
|
||||
) -> (Vec<ProxyProviderView>, BTreeMap<String, Vec<String>>) {
|
||||
) -> (Vec<ProxyProviderView>, ProviderCandidates) {
|
||||
let providers = providers
|
||||
.map(|providers| providers.providers.into_iter().collect::<BTreeMap<_, _>>())
|
||||
.unwrap_or_default();
|
||||
@@ -322,7 +359,7 @@ fn build_provider_records(
|
||||
candidates
|
||||
.entry(proxy_name.clone())
|
||||
.or_insert_with(Vec::new)
|
||||
.push(record_id.clone());
|
||||
.push((provider_name.clone(), record_id.clone()));
|
||||
records.insert(
|
||||
record_id.clone(),
|
||||
build_node(
|
||||
@@ -373,6 +410,20 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
|
||||
proxy_type,
|
||||
..
|
||||
} = proxy;
|
||||
// The n-th occurrence of a name in `all` is the n-th scoped candidate.
|
||||
let mut seen = BTreeMap::<String, (usize, Vec<&String>)>::new();
|
||||
let members = all
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|member| {
|
||||
let (occurrence, scoped) = seen
|
||||
.entry(member.clone())
|
||||
.or_insert_with(|| (0, resolver.scoped_candidates(&name, &member)));
|
||||
let picked = scoped.get(*occurrence).or_else(|| scoped.first()).copied();
|
||||
*occurrence += 1;
|
||||
resolver.resolve(member, picked)
|
||||
})
|
||||
.collect();
|
||||
|
||||
ProxyGroupView {
|
||||
name,
|
||||
@@ -391,11 +442,7 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
|
||||
mptcp,
|
||||
smux,
|
||||
},
|
||||
members: all
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|name| resolver.resolve(name))
|
||||
.collect(),
|
||||
members,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,303 +522,3 @@ fn build_standalone(core_node_ids: &BTreeMap<String, String>) -> Vec<String> {
|
||||
);
|
||||
standalone
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use tauri_plugin_mihomo::models::{
|
||||
Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, SubScriptionInfo, VehicleType,
|
||||
};
|
||||
|
||||
use super::{
|
||||
ProxyMemberRef, ProxyMemberUnresolvedReason, ProxyNodeSource, ProxyViewBuilder, ProxyViewInput,
|
||||
ProxyViewOrderSource, ProxyViewProviderState,
|
||||
};
|
||||
|
||||
fn node(name: &str) -> Proxy {
|
||||
Proxy {
|
||||
name: name.to_owned(),
|
||||
proxy_type: ProxyType::Shadowsocks,
|
||||
alive: true,
|
||||
udp: true,
|
||||
..Proxy::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn group(name: &str, members: &[&str]) -> Proxy {
|
||||
Proxy {
|
||||
name: format!("ignored-{name}"),
|
||||
proxy_type: ProxyType::Selector,
|
||||
alive: true,
|
||||
all: Some(members.iter().map(|name| (*name).to_owned()).collect()),
|
||||
..Proxy::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn ordered_input(reverse_insert: bool) -> ProxyViewInput {
|
||||
let entries = [
|
||||
("Zulu", group("Zulu", &["node-b", "node-b"])),
|
||||
("Alpha", group("Alpha", &["node-a"])),
|
||||
("node-b", node("wrong-node-b")),
|
||||
("node-a", node("wrong-node-a")),
|
||||
("A-before-direct", node("wrong-before-direct")),
|
||||
("DIRECT", node("wrong-direct")),
|
||||
("REJECT", node("wrong-reject")),
|
||||
];
|
||||
let mut proxies = HashMap::new();
|
||||
if reverse_insert {
|
||||
proxies.extend(entries.into_iter().rev().map(|(name, proxy)| (name.to_owned(), proxy)));
|
||||
} else {
|
||||
proxies.extend(entries.into_iter().map(|(name, proxy)| (name.to_owned(), proxy)));
|
||||
}
|
||||
|
||||
ProxyViewInput {
|
||||
runtime_group_order: vec!["Zulu".into(), "Alpha".into()],
|
||||
proxies: Proxies { proxies },
|
||||
providers: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn providers_fixture(entries: Vec<(&str, VehicleType, &[&str])>) -> ProxyProviders {
|
||||
let providers = entries
|
||||
.into_iter()
|
||||
.map(|(provider_key, vehicle_type, names)| {
|
||||
let provider = ProxyProvider {
|
||||
name: format!("ignored-{provider_key}"),
|
||||
vehicle_type,
|
||||
proxies: names.iter().map(|name| node(name)).collect(),
|
||||
..ProxyProvider::default()
|
||||
};
|
||||
(provider_key.to_owned(), provider)
|
||||
})
|
||||
.collect();
|
||||
|
||||
ProxyProviders { providers }
|
||||
}
|
||||
|
||||
fn input_with_members_and_duplicate_providers() -> ProxyViewInput {
|
||||
let proxies = HashMap::from([("Group".to_owned(), group("ignored", &["duplicate"]))]);
|
||||
ProxyViewInput {
|
||||
runtime_group_order: vec![],
|
||||
proxies: Proxies { proxies },
|
||||
providers: Some(providers_fixture(vec![
|
||||
("a", VehicleType::HTTP, &["duplicate"]),
|
||||
("b", VehicleType::File, &["duplicate"]),
|
||||
])),
|
||||
}
|
||||
}
|
||||
|
||||
fn input_with_provider_member(providers: Option<ProxyProviders>) -> ProxyViewInput {
|
||||
let proxies = HashMap::from([("Group".to_owned(), group("ignored", &["provider-only"]))]);
|
||||
ProxyViewInput {
|
||||
runtime_group_order: vec![],
|
||||
proxies: Proxies { proxies },
|
||||
providers,
|
||||
}
|
||||
}
|
||||
|
||||
fn input_with_provider_duplicates() -> ProxyViewInput {
|
||||
ProxyViewInput {
|
||||
runtime_group_order: vec![],
|
||||
proxies: Proxies::default(),
|
||||
providers: Some(providers_fixture(vec![
|
||||
("z-provider", VehicleType::HTTP, &["z-node"]),
|
||||
("unsupported", VehicleType::Compatible, &["ignored"]),
|
||||
("a-provider", VehicleType::File, &["same", "same"]),
|
||||
])),
|
||||
}
|
||||
}
|
||||
|
||||
fn serde_input_with_provider() -> ProxyViewInput {
|
||||
let provider = ProxyProvider {
|
||||
name: "ignored-object-name".to_owned(),
|
||||
vehicle_type: VehicleType::HTTP,
|
||||
updated_at: None,
|
||||
subscription_info: Some(SubScriptionInfo {
|
||||
upload: 1,
|
||||
download: 2,
|
||||
total: 3,
|
||||
expire: 4,
|
||||
}),
|
||||
..ProxyProvider::default()
|
||||
};
|
||||
|
||||
ProxyViewInput {
|
||||
runtime_group_order: vec![],
|
||||
proxies: Proxies::default(),
|
||||
providers: Some(ProxyProviders {
|
||||
providers: HashMap::from([("provider-key".to_owned(), provider)]),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordering_and_core_records_are_deterministic() {
|
||||
let first = ProxyViewBuilder::build(ordered_input(false));
|
||||
let second = ProxyViewBuilder::build(ordered_input(true));
|
||||
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(first.order_source, ProxyViewOrderSource::Runtime);
|
||||
assert_eq!(
|
||||
first.groups.iter().map(|group| group.name.as_str()).collect::<Vec<_>>(),
|
||||
["Zulu", "Alpha"]
|
||||
);
|
||||
assert_eq!(first.direct.as_deref(), Some("c:1"));
|
||||
assert_eq!(first.standalone, ["c:1", "c:2", "c:0", "c:3", "c:4"]);
|
||||
assert_eq!(first.records["c:0"].name, "A-before-direct");
|
||||
assert_eq!(first.records["c:1"].name, "DIRECT");
|
||||
assert_eq!(first.records["c:4"].name, "node-b");
|
||||
assert_eq!(first.groups[0].members.len(), 2);
|
||||
assert!(matches!(
|
||||
&first.groups[0].members[0],
|
||||
ProxyMemberRef::Node { name, .. } if name == "node-b"
|
||||
));
|
||||
assert_eq!(first.groups[0].members[0], first.groups[0].members[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_and_unmatched_groups_are_stable() {
|
||||
let mut input = ordered_input(false);
|
||||
input.runtime_group_order = vec![];
|
||||
let view = ProxyViewBuilder::build(input);
|
||||
assert_eq!(view.order_source, ProxyViewOrderSource::Fallback);
|
||||
assert_eq!(
|
||||
view.groups.iter().map(|group| group.name.as_str()).collect::<Vec<_>>(),
|
||||
["Alpha", "Zulu"]
|
||||
);
|
||||
|
||||
let mut input = ordered_input(false);
|
||||
input.runtime_group_order = vec!["Missing".into()];
|
||||
let view = ProxyViewBuilder::build(input);
|
||||
assert_eq!(view.order_source, ProxyViewOrderSource::Fallback);
|
||||
assert_eq!(
|
||||
view.groups.iter().map(|group| group.name.as_str()).collect::<Vec<_>>(),
|
||||
["Alpha", "Zulu"]
|
||||
);
|
||||
|
||||
let mut input = ordered_input(false);
|
||||
input.runtime_group_order = vec!["Zulu".into(), "Zulu".into()];
|
||||
let view = ProxyViewBuilder::build(input);
|
||||
assert_eq!(
|
||||
view.groups.iter().map(|group| group.name.as_str()).collect::<Vec<_>>(),
|
||||
["Zulu", "Alpha"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_and_member_resolution_follow_the_required_priority() {
|
||||
let mut proxies = HashMap::new();
|
||||
proxies.insert(
|
||||
"GLOBAL".into(),
|
||||
group("wrong", &["Nested", "core", "provider-only", "missing"]),
|
||||
);
|
||||
proxies.insert("Nested".into(), group("wrong", &[]));
|
||||
proxies.insert("core".into(), node("wrong"));
|
||||
let providers = providers_fixture(vec![
|
||||
(
|
||||
"a",
|
||||
VehicleType::HTTP,
|
||||
&["Nested", "core", "provider-only", "duplicate"],
|
||||
),
|
||||
("b", VehicleType::File, &["duplicate"]),
|
||||
]);
|
||||
|
||||
let view = ProxyViewBuilder::build(ProxyViewInput {
|
||||
runtime_group_order: vec![],
|
||||
proxies: Proxies { proxies },
|
||||
providers: Some(providers),
|
||||
});
|
||||
let members = &view.global.as_ref().expect("GLOBAL").members;
|
||||
|
||||
assert!(matches!(members[0], ProxyMemberRef::Group { ref name } if name == "Nested"));
|
||||
assert!(matches!(members[1], ProxyMemberRef::Node { ref name, .. } if name == "core"));
|
||||
assert!(matches!(members[2], ProxyMemberRef::Node { ref name, .. } if name == "provider-only"));
|
||||
assert!(matches!(
|
||||
members[3],
|
||||
ProxyMemberRef::Unresolved {
|
||||
reason: ProxyMemberUnresolvedReason::Missing,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert!(!view.groups.iter().any(|group| group.name == "GLOBAL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambiguous_and_provider_unavailable_are_not_reported_as_missing() {
|
||||
let ready = ProxyViewBuilder::build(input_with_members_and_duplicate_providers());
|
||||
assert!(matches!(
|
||||
ready.groups[0].members[0],
|
||||
ProxyMemberRef::Unresolved {
|
||||
reason: ProxyMemberUnresolvedReason::Ambiguous,
|
||||
..
|
||||
}
|
||||
));
|
||||
|
||||
let unavailable = ProxyViewBuilder::build(input_with_provider_member(None));
|
||||
assert!(matches!(
|
||||
unavailable.groups[0].members[0],
|
||||
ProxyMemberRef::Unresolved {
|
||||
reason: ProxyMemberUnresolvedReason::ProviderUnavailable,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert_eq!(unavailable.provider_state, ProxyViewProviderState::Unavailable);
|
||||
assert!(unavailable.providers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn providers_preserve_identity_duplicates_and_member_order() {
|
||||
let view = ProxyViewBuilder::build(input_with_provider_duplicates());
|
||||
assert_eq!(
|
||||
view.providers
|
||||
.iter()
|
||||
.map(|provider| provider.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["a-provider", "z-provider"]
|
||||
);
|
||||
assert_eq!(view.providers[0].proxy_record_ids, ["p:0:0", "p:0:1"]);
|
||||
assert_eq!(view.records["p:0:0"].name, "same");
|
||||
assert_eq!(view.records["p:0:1"].name, "same");
|
||||
assert_eq!(
|
||||
view.records["p:0:0"].source,
|
||||
ProxyNodeSource::Provider {
|
||||
provider_name: "a-provider".into(),
|
||||
proxy_name: "same".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_successful_response_with_only_unsupported_providers_is_ready_and_empty() {
|
||||
let view = ProxyViewBuilder::build(ProxyViewInput {
|
||||
runtime_group_order: vec![],
|
||||
proxies: Proxies::default(),
|
||||
providers: Some(providers_fixture(vec![(
|
||||
"unsupported",
|
||||
VehicleType::Compatible,
|
||||
&["ignored"],
|
||||
)])),
|
||||
});
|
||||
|
||||
assert_eq!(view.provider_state, ProxyViewProviderState::Ready);
|
||||
assert!(view.providers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_matches_the_v1_wire_contract() {
|
||||
let view = ProxyViewBuilder::build(serde_input_with_provider());
|
||||
let json = serde_json::to_value(view).expect("serialize view");
|
||||
|
||||
assert_eq!(json["schemaVersion"], 1);
|
||||
assert_eq!(json["orderSource"], "fallback");
|
||||
assert_eq!(json["providerState"], "ready");
|
||||
assert!(json["global"].is_null());
|
||||
assert!(json["direct"].is_null());
|
||||
assert!(json["providers"][0].get("vehicleType").is_some());
|
||||
assert!(json["providers"][0].get("updatedAt").is_none());
|
||||
assert_eq!(json["providers"][0]["subscriptionInfo"]["upload"], 1);
|
||||
assert!(json["providers"][0]["subscriptionInfo"].get("Upload").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,11 +29,11 @@ impl RunStateEnv for RealEnv {
|
||||
async fn probe_service_version(&self) -> Result<ServiceVersionReply> {
|
||||
// The IPC client would otherwise back off for about two minutes before failing.
|
||||
#[cfg(windows)]
|
||||
if tokio::task::spawn_blocking(crate::core::service::service_stopped)
|
||||
if let Some(reason) = tokio::task::spawn_blocking(crate::core::service::service_stop_reason)
|
||||
.await
|
||||
.context("service status probe did not finish")??
|
||||
{
|
||||
anyhow::bail!("the Windows service is not running");
|
||||
anyhow::bail!(reason);
|
||||
}
|
||||
let response = clash_verge_service_ipc::get_version().await?;
|
||||
let core = if response.code == 0
|
||||
|
||||
@@ -50,6 +50,15 @@ static PENDING_SERVICE_OWNER_NOTICE: Mutex<Option<String>> = Mutex::new(None);
|
||||
pub enum ServiceFallbackNotice {
|
||||
Unavailable,
|
||||
CoreRejected(String),
|
||||
NotAutoStarted,
|
||||
}
|
||||
|
||||
/// The release installer registers AutoStart.
|
||||
const SERVICE_NOT_AUTO_STARTED: &str = "the Windows service is stopped and no longer starts with Windows";
|
||||
|
||||
pub(crate) fn is_not_auto_started(reason: &str) -> bool {
|
||||
// Detection prefixes its own context to the reason.
|
||||
reason.contains(SERVICE_NOT_AUTO_STARTED)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -62,6 +71,8 @@ pub(crate) fn take_service_fallback_notice() -> Option<ServiceFallbackNotice> {
|
||||
let reason = PENDING_SERVICE_FALLBACK_NOTICE.lock().take()?;
|
||||
Some(if reason.starts_with(CORE_REJECTED_PREFIX) {
|
||||
ServiceFallbackNotice::CoreRejected(reason)
|
||||
} else if is_not_auto_started(&reason) {
|
||||
ServiceFallbackNotice::NotAutoStarted
|
||||
} else {
|
||||
ServiceFallbackNotice::Unavailable
|
||||
})
|
||||
@@ -267,7 +278,7 @@ fn open_registered_service() -> Result<Option<windows_service::service::Service>
|
||||
let manager = WindowsServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
|
||||
match manager.open_service(
|
||||
clash_verge_service_ipc::WINDOWS_SERVICE_NAME,
|
||||
ServiceAccess::QUERY_STATUS,
|
||||
ServiceAccess::QUERY_STATUS | ServiceAccess::QUERY_CONFIG,
|
||||
) {
|
||||
Ok(service) => Ok(Some(service)),
|
||||
Err(WindowsServiceError::Winapi(error)) if error.raw_os_error() == Some(ERROR_SERVICE_DOES_NOT_EXIST) => {
|
||||
@@ -282,21 +293,34 @@ pub(crate) fn trusted_service_evidence() -> Result<bool> {
|
||||
Ok(open_registered_service()?.is_some())
|
||||
}
|
||||
|
||||
/// Whether IPC cannot succeed until the service is started again. A service that is starting, or
|
||||
/// that the SCM has not started yet this boot, is left to the IPC retries, which wait for it.
|
||||
/// Why IPC cannot succeed until the service is started again, if it cannot. A starting service,
|
||||
/// or an AutoStart one the SCM has yet to start this boot, is left to the IPC retries.
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn service_stopped() -> Result<bool> {
|
||||
use windows_service::service::{ServiceExitCode, ServiceState};
|
||||
pub(crate) fn service_stop_reason() -> Result<Option<&'static str>> {
|
||||
use windows_service::service::{ServiceExitCode, ServiceStartType, ServiceState};
|
||||
|
||||
const ERROR_SERVICE_NEVER_STARTED: u32 = 1077;
|
||||
const NOT_RUNNING: &str = "the Windows service is not running";
|
||||
let Some(service) = open_registered_service()? else {
|
||||
return Ok(true);
|
||||
return Ok(Some(NOT_RUNNING));
|
||||
};
|
||||
let status = service
|
||||
.query_status()
|
||||
.context("failed to query Windows service status")?;
|
||||
Ok(status.current_state == ServiceState::Stopped
|
||||
&& status.exit_code != ServiceExitCode::Win32(ERROR_SERVICE_NEVER_STARTED))
|
||||
if status.current_state != ServiceState::Stopped {
|
||||
return Ok(None);
|
||||
}
|
||||
// The development channel registers an on-demand start.
|
||||
if !cfg!(feature = "verge-dev")
|
||||
&& service
|
||||
.query_config()
|
||||
.context("failed to query Windows service configuration")?
|
||||
.start_type
|
||||
!= ServiceStartType::AutoStart
|
||||
{
|
||||
return Ok(Some(SERVICE_NOT_AUTO_STARTED));
|
||||
}
|
||||
Ok((status.exit_code != ServiceExitCode::Win32(ERROR_SERVICE_NEVER_STARTED)).then_some(NOT_RUNNING))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
@@ -477,7 +477,9 @@ impl Timer {
|
||||
Ok(_) => {
|
||||
logging!(debug, Type::Timer, "timer task completed for uid: {}", uid);
|
||||
}
|
||||
Err(e) => logging_error!(Type::Timer, "Failed to update profile uid {}: {e:#}", uid),
|
||||
Err(e) => {
|
||||
logging_error!(Type::Timer, "Failed to update profile uid {}: {e:#}", uid);
|
||||
}
|
||||
},
|
||||
|| Self::emit_update_event(uid, false),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user