chore: trim excessive comments

This commit is contained in:
wonfen
2026-08-19 07:27:43 +08:00
parent 42b91b6294
commit 807f9a206d
103 changed files with 293 additions and 2517 deletions
@@ -19,21 +19,12 @@ jobs:
- name: Check if version or source changed, or assets already exist
id: check
run: |
# # 仅用于测试逻辑,手动触发自动跳过
# if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
# echo "should_run=skip" >> $GITHUB_OUTPUT
# echo "🟡 手动触发,跳过 should_run 检查"
# exit 0
# fi
# 确保有 HEAD~1
if ! git rev-parse HEAD~1 > /dev/null 2>&1; then
echo "should_run=true" >> $GITHUB_OUTPUT
echo "🟢 没有前一个提交,默认需要构建"
exit 0
fi
# 版本号变更判断
CURRENT_VERSION=$(jq -r '.version' package.json)
PREVIOUS_VERSION=$(git show HEAD~1:package.json | jq -r '.version' 2>/dev/null || echo "")
@@ -43,7 +34,6 @@ jobs:
exit 0
fi
# 检查 src 变更(排除常见产物与缓存)
SRC_DIFF=$(git diff --name-only HEAD~1 HEAD -- src/ | grep -Ev '^src/(dist|build|node_modules|\.next|\.cache)' || true)
TAURI_DIFF=$(git diff --name-only HEAD~1 HEAD -- src-tauri/ | grep -Ev '^src-tauri/(target|node_modules|dist|\.cache)' || true)
@@ -53,16 +43,13 @@ jobs:
exit 0
fi
# 找到最后一个修改 Tauri 相关文件的 commit
echo "🔍 查找最后一个 Tauri 相关变更的 commit..."
LAST_TAURI_COMMIT=""
for commit in $(git rev-list HEAD --max-count=50); do
# 检查此 commit 是否修改了 Tauri 相关文件
CHANGED_FILES=$(git show --name-only --pretty=format: $commit | tr '\n' ' ')
HAS_TAURI_CHANGES=false
# 检查各个模式
if echo "$CHANGED_FILES" | grep -q "src/" && echo "$CHANGED_FILES" | grep -qvE "src/(dist|build|node_modules|\.next|\.cache)"; then
HAS_TAURI_CHANGES=true
elif echo "$CHANGED_FILES" | grep -qE "src-tauri/(src|Cargo\.(toml|lock)|tauri\..*\.conf\.json|build\.rs|capabilities)"; then
@@ -84,21 +71,18 @@ jobs:
echo "📝 最后 Tauri 相关 commit: $LAST_TAURI_COMMIT"
echo "📝 当前 commit: $CURRENT_COMMIT"
# 检查 autobuild release 是否存在
AUTOBUILD_RELEASE_EXISTS=$(gh release view "autobuild" --json id -q '.id' 2>/dev/null || echo "")
if [ -z "$AUTOBUILD_RELEASE_EXISTS" ]; then
echo "should_run=true" >> $GITHUB_OUTPUT
echo "🟢 没有 autobuild release,需构建"
else
# 检查 latest.json 是否存在
LATEST_JSON_EXISTS=$(gh release view "autobuild" --json assets -q '.assets[] | select(.name == "latest.json") | .name' 2>/dev/null || echo "")
if [ -z "$LATEST_JSON_EXISTS" ]; then
echo "should_run=true" >> $GITHUB_OUTPUT
echo "🟢 没有 latest.json,需构建"
else
# 下载并解析 latest.json 检查版本和 commit hash
echo "📥 下载 latest.json 检查版本..."
LATEST_JSON_URL=$(gh release view "autobuild" --json assets -q '.assets[] | select(.name == "latest.json") | .browser_download_url' 2>/dev/null)
@@ -109,7 +93,6 @@ jobs:
LATEST_VERSION=$(echo "$LATEST_JSON_CONTENT" | jq -r '.version' 2>/dev/null || echo "")
echo "📦 最新 autobuild 版本: $LATEST_VERSION"
# 从版本字符串中提取 commit hash (格式: X.Y.Z+autobuild.MMDD.commit)
LATEST_COMMIT=$(echo "$LATEST_VERSION" | sed -n 's/.*+autobuild\.[0-9]\{4\}\.\([a-f0-9]*\)$/\1/p' || echo "")
echo "📝 最新 autobuild commit: $LATEST_COMMIT"
-5
View File
@@ -203,7 +203,6 @@ jobs:
- name: Add Rust Target
run: |
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
rustup target add ${{ matrix.target }} --toolchain 1.95.0 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
@@ -230,7 +229,6 @@ jobs:
prerelease: true
tauriScript: pnpm
args: --target ${{ matrix.target }}
# includeUpdaterJson: true
autobuild-arm-linux:
name: Autobuild ARM Linux
@@ -346,7 +344,6 @@ jobs:
- name: Add Rust Target
run: |
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
rustup target add ${{ matrix.target }} --toolchain 1.95.0 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
@@ -454,7 +451,6 @@ jobs:
- name: Add Rust Target
run: |
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
rustup target add ${{ matrix.target }} --toolchain 1.95.0 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
@@ -470,7 +466,6 @@ jobs:
with:
tauriScript: pnpm
args: --target ${{ matrix.target }}
# includeUpdaterJson: true
- name: Rename
run: |
@@ -62,7 +62,6 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Force build if requested
if [ "${{ inputs.force_build }}" == "true" ]; then
echo "🚀 Force build requested"
echo "should_run=true" >> $GITHUB_OUTPUT
@@ -84,25 +83,20 @@ jobs:
exit 0
fi
# Use get_latest_tauri_commit.bash to find the latest Tauri-related commit
echo "🔍 Finding last commit with Tauri-related changes using script..."
# Make script executable
chmod +x scripts-workflow/get_latest_tauri_commit.bash
# Get the latest Tauri-related commit hash (full hash)
LAST_TAURI_COMMIT_FULL=$(./scripts-workflow/get_latest_tauri_commit.bash)
if [[ $? -ne 0 ]] || [[ -z "$LAST_TAURI_COMMIT_FULL" ]]; then
echo "❌ Failed to get Tauri-related commit, using current commit"
LAST_TAURI_COMMIT_FULL=$(git rev-parse HEAD)
fi
# Get short hash for display and version tagging
LAST_TAURI_COMMIT=$(git rev-parse --short "$LAST_TAURI_COMMIT_FULL")
echo "📝 Last Tauri-related commit: $LAST_TAURI_COMMIT"
# Generate autobuild version using autobuild-latest format
CURRENT_BASE_VERSION=$(echo "$CURRENT_VERSION" | sed -E 's/-(alpha|beta|rc)(\.[0-9]+)?//g' | sed -E 's/\+[a-zA-Z0-9.-]+//g')
MONTH=$(TZ=Asia/Shanghai date +%m)
DAY=$(TZ=Asia/Shanghai date +%d)
@@ -111,11 +105,9 @@ jobs:
echo "🏷️ Autobuild version: $AUTOBUILD_VERSION"
echo "📝 Last Tauri commit: $LAST_TAURI_COMMIT"
# Set outputs for other jobs to use
echo "last_tauri_commit=$LAST_TAURI_COMMIT" >> $GITHUB_OUTPUT
echo "autobuild_version=$AUTOBUILD_VERSION" >> $GITHUB_OUTPUT
# Check if autobuild release exists
echo "🔍 Checking autobuild release and latest.json..."
AUTOBUILD_RELEASE_EXISTS=$(gh release view "${{ env.TAG_NAME }}" --json id -q '.id' 2>/dev/null || echo "")
@@ -123,14 +115,12 @@ jobs:
echo "✅ No autobuild release exists, build needed"
echo "should_run=true" >> $GITHUB_OUTPUT
else
# Check if latest.json exists in the release
LATEST_JSON_EXISTS=$(gh release view "${{ env.TAG_NAME }}" --json assets -q '.assets[] | select(.name == "latest.json") | .name' 2>/dev/null || echo "")
if [ -z "$LATEST_JSON_EXISTS" ]; then
echo "✅ No latest.json found in autobuild release, build needed"
echo "should_run=true" >> $GITHUB_OUTPUT
else
# Download and parse latest.json to check version and commit hash
echo "📥 Downloading latest.json to check version..."
LATEST_JSON_URL="https://github.com/clash-verge-rev/clash-verge-rev/releases/download/autobuild/latest.json"
@@ -140,7 +130,6 @@ jobs:
LATEST_VERSION=$(echo "$LATEST_JSON_CONTENT" | jq -r '.version' 2>/dev/null || echo "")
echo "📦 Latest autobuild version: $LATEST_VERSION"
# Extract commit hash from version string (format: X.Y.Z+autobuild.MMDD.commit)
LATEST_COMMIT=$(echo "$LATEST_VERSION" | sed -n 's/.*+autobuild\.[0-9]\{4\}\.\([a-f0-9]*\)$/\1/p' || echo "")
echo "📝 Latest autobuild commit: $LATEST_COMMIT"
-13
View File
@@ -52,20 +52,15 @@ jobs:
CURRENT_VERSION=$(cat package.json | jq -r '.version')
echo "📦 Current version: $CURRENT_VERSION"
# Find the last commit that changed Tauri-related files
echo "🔍 Finding last commit with Tauri-related changes..."
# Define patterns for Tauri-related files
TAURI_PATTERNS="src/ src-tauri/src src-tauri/Cargo.toml Cargo.lock src-tauri/tauri.*.conf.json src-tauri/build.rs src-tauri/capabilities"
# Get the last commit that changed any of these patterns (excluding build artifacts)
LAST_TAURI_COMMIT=""
for commit in $(git rev-list HEAD --max-count=50); do
# Check if this commit changed any Tauri-related files
CHANGED_FILES=$(git show --name-only --pretty=format: $commit | tr '\n' ' ')
HAS_TAURI_CHANGES=false
# Check each pattern
if echo "$CHANGED_FILES" | grep -q "src/" && echo "$CHANGED_FILES" | grep -qvE "src/(dist|build|node_modules|\.next|\.cache)"; then
HAS_TAURI_CHANGES=true
elif echo "$CHANGED_FILES" | grep -qE "src-tauri/(src|Cargo\.(toml|lock)|tauri\..*\.conf\.json|build\.rs|capabilities)"; then
@@ -86,7 +81,6 @@ jobs:
echo "📝 Last Tauri-related commit: $LAST_TAURI_COMMIT"
echo "📝 Current commit: $(git rev-parse --short HEAD)"
# Generate autobuild version for consistency
CURRENT_BASE_VERSION=$(echo "$CURRENT_VERSION" | sed -E 's/-(alpha|beta|rc)(\.[0-9]+)?//g' | sed -E 's/\+[a-zA-Z0-9.-]+//g')
MONTH=$(TZ=Asia/Shanghai date +%m)
DAY=$(TZ=Asia/Shanghai date +%d)
@@ -94,7 +88,6 @@ jobs:
echo "🏷️ Current autobuild version: $AUTOBUILD_VERSION"
# Set outputs for other jobs to use
echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
echo "last_tauri_commit=$LAST_TAURI_COMMIT" >> $GITHUB_OUTPUT
echo "autobuild_version=$AUTOBUILD_VERSION" >> $GITHUB_OUTPUT
@@ -113,7 +106,6 @@ jobs:
TAG_NAME: ${{ env.TAG_NAME }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
# Use values from check_current_version job
CURRENT_AUTOBUILD_VERSION="${{ needs.check_current_version.outputs.autobuild_version }}"
LAST_TAURI_COMMIT="${{ needs.check_current_version.outputs.last_tauri_commit }}"
CURRENT_VERSION="${{ needs.check_current_version.outputs.current_version }}"
@@ -124,7 +116,6 @@ jobs:
echo "🏷️ Target tag: $TAG_NAME"
echo "🔍 Dry run mode: $DRY_RUN"
# Check if release exists
RELEASE_EXISTS=$(gh release view "$TAG_NAME" --json id -q '.id' 2>/dev/null || echo "")
if [ -z "$RELEASE_EXISTS" ]; then
@@ -134,7 +125,6 @@ jobs:
echo "✅ Found release '$TAG_NAME'"
# Get all assets
echo "📋 Getting list of all assets..."
assets=$(gh release view "$TAG_NAME" --json assets -q '.assets[].name' || true)
@@ -146,12 +136,10 @@ jobs:
echo "📋 Found assets:"
echo "$assets" | sed 's/^/ - /'
# Count assets to keep and delete
ASSETS_TO_KEEP=""
ASSETS_TO_DELETE=""
for asset in $assets; do
# Keep assets that match current autobuild version or are non-versioned files (like latest.json)
if [[ "$asset" == *"$CURRENT_AUTOBUILD_VERSION"* ]] || [[ "$asset" == "latest.json" ]]; then
ASSETS_TO_KEEP="$ASSETS_TO_KEEP$asset\n"
else
@@ -189,7 +177,6 @@ jobs:
FAILED_COUNT=0
for asset in $assets; do
# Skip assets that should be kept
if [[ "$asset" == *"$CURRENT_AUTOBUILD_VERSION"* ]] || [[ "$asset" == "latest.json" ]]; then
continue
fi
-7
View File
@@ -1,9 +1,6 @@
name: Release Build
on:
# ! 为了避免重复发布版本,应当通过独特 git tag 触发。
# ! 不再使用 workflow_dispatch 触发。
# workflow_dispatch:
push:
# -rc tag 时预览发布, 跳过 telegram 通知、跳过 winget 提交、跳过 latest.json 文件更新
tags:
@@ -14,7 +11,6 @@ env:
RUST_BACKTRACE: short
HUSKY: 0
concurrency:
# only allow per workflow per commit (and not pr) to run at a time
group: '${{ github.workflow }} - ${{ github.head_ref || github.ref }}'
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
@@ -245,7 +241,6 @@ jobs:
- name: Add Rust Target
run: |
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
rustup target add ${{ matrix.target }} --toolchain 1.95.0 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
@@ -391,7 +386,6 @@ jobs:
- name: Add Rust Target
run: |
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
rustup target add ${{ matrix.target }} --toolchain 1.95.0 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
@@ -490,7 +484,6 @@ jobs:
- name: Add Rust Target
run: |
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
rustup target add ${{ matrix.target }} --toolchain 1.95.0 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
-16
View File
@@ -41,19 +41,3 @@ jobs:
- name: run cargo fmt
if: steps.check_rust.outputs.rust == 'true'
run: cargo fmt --manifest-path ./src-tauri/Cargo.toml --all -- --check
# taplo:
# name: taplo (.toml files)
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v6
# - name: install Rust stable
# uses: dtolnay/rust-toolchain@stable
# - name: install taplo-cli
# uses: taiki-e/install-action@v2.68.8
# with:
# tool: taplo-cli
# - run: taplo fmt --check --diff
+12 -36
View File
@@ -7,7 +7,6 @@ use std::sync::atomic::{
use tokio::sync::Notify;
pub type SharedDraft<T> = Arc<Box<T>>;
// (committed_snapshot, optional_draft_snapshot)
type DraftData<T> = (SharedDraft<T>, Option<SharedDraft<T>>);
// Retry one scheduler turn before parking on Notify for long async updates.
const DATA_MODIFY_FAST_RETRY_YIELDS: usize = 1;
@@ -17,7 +16,6 @@ struct DraftInner<T> {
data: Mutex<DraftData<T>>,
data_modifying: AtomicBool,
data_modify_notify: Notify,
/// Held by the one [`DraftTransaction`] currently allowed to stage a change.
claimed: AtomicBool,
}
@@ -30,7 +28,7 @@ impl Drop for DataModifyPermit<'_> {
}
}
/// Draft 管理:committed 与 optional draft 都以 Arc<Box<T>> 存储。
/// Copy-on-write committed data with an optional draft.
#[derive(Debug, Clone)]
pub struct Draft<T> {
inner: Arc<DraftInner<T>>,
@@ -48,24 +46,21 @@ impl<T: Clone> Draft<T> {
}),
}
}
/// 以 Arc<Box<T>> 的形式获取当前“已提交(正式)”数据的快照(零拷贝,仅 clone Arc)
/// Returns the committed snapshot without cloning `T`.
#[inline]
pub fn data_arc(&self) -> SharedDraft<T> {
let guard = self.inner.data.lock();
Arc::clone(&guard.0)
}
/// 获取当前(草稿若存在则返回草稿,否则返回已提交)的快照
/// 这也是零拷贝:只 clone Arc,不 clone T
/// Returns the draft when present, otherwise the committed snapshot.
#[inline]
pub fn latest_arc(&self) -> SharedDraft<T> {
let guard = self.inner.data.lock();
guard.1.clone().unwrap_or_else(|| Arc::clone(&guard.0))
}
/// 通过闭包以可变方式编辑草稿(在闭包中我们给出 &mut T)
/// - 延迟拷贝:如果只有这一个 Arc 引用,则直接修改,不会克隆 T;
/// - 若草稿被其他读者共享,Arc::make_mut 会做一次 T.clone(最小必要拷贝)。
/// Edits the draft, cloning `T` only when the snapshot is shared.
#[inline]
pub fn edit_draft<F, R>(&self, f: F) -> R
where
@@ -79,7 +74,7 @@ impl<T: Clone> Draft<T> {
result
}
/// 将草稿提交到已提交位置(替换),并清除草稿
/// Commits and clears the draft.
#[inline]
pub fn apply(&self) {
let mut guard = self.inner.data.lock();
@@ -88,15 +83,14 @@ impl<T: Clone> Draft<T> {
}
}
/// 丢弃草稿(如果存在)
/// Discards the draft.
#[inline]
pub fn discard(&self) {
let mut guard = self.inner.data.lock();
guard.1 = None;
}
/// 异步地以拥有 Box<T> 的方式修改已提交数据:将克隆一次已提交数据到本地,
/// 异步闭包返回新的 Box<T>(替换已提交数据)和业务返回值 R。
/// Optimistically replaces committed data after an asynchronous edit.
#[inline]
pub async fn with_data_modify<F, Fut, R>(&self, f: F) -> Result<R, anyhow::Error>
where
@@ -211,20 +205,9 @@ impl std::fmt::Display for DraftBusy {
impl std::error::Error for DraftBusy {}
/// Commits a set of draft layers together, or rolls them all back.
/// Exclusively claims several draft layers and rolls them all back unless committed.
///
/// Rolls back when dropped, so an early return — a `?`, a `bail!`, a panic — cannot leave a
/// change half-applied across layers. Committing is the explicit act, because forgetting to
/// commit costs a retry while forgetting to roll back leaves the app describing a state it is
/// not in.
///
/// Beginning one takes exclusive staging rights over every layer, and overlapping with another
/// transaction is refused rather than allowed to interleave. That is not caution, it is the
/// only honest option: a layer holds exactly one draft, so a second writer's `edit_draft`
/// overwrites the first writer's staged value *in place*. There is no version of "roll back
/// only my edit" that could be implemented — rolling back would discard a change the other
/// writer is about to commit, and it did: the other writer's `apply` then found no draft, did
/// nothing at all, and still reported success while its side effect had already happened.
/// A layer has one draft slot, so concurrent transactions cannot be isolated and are rejected.
#[must_use = "a transaction that is dropped without committing rolls back"]
pub struct DraftTransaction<'a> {
layers: Vec<&'a dyn DraftLayer>,
@@ -232,10 +215,7 @@ pub struct DraftTransaction<'a> {
}
impl<'a> DraftTransaction<'a> {
/// Claim `layers` and begin a transaction over them; they are committed in the order given.
///
/// Fails if any layer is already claimed, having released the ones taken so far, so a
/// refused attempt leaves nothing held.
/// Claims all layers or releases any partial claim.
pub fn begin(layers: Vec<&'a dyn DraftLayer>) -> Result<Self, DraftBusy> {
for (index, layer) in layers.iter().enumerate() {
if layer.try_claim() {
@@ -254,7 +234,7 @@ impl<'a> DraftTransaction<'a> {
})
}
/// Commit every layer. Consumes the transaction so it cannot be committed twice.
/// Commits every layer in the supplied order.
pub fn commit(mut self) {
for layer in &self.layers {
layer.apply_draft();
@@ -262,11 +242,7 @@ impl<'a> DraftTransaction<'a> {
self.committed = true;
}
/// Roll every layer back now, rather than whenever this value happens to drop.
///
/// Needed where recovery does more than forget the drafts — restoring files, or
/// regenerating them from the committed configuration. Those read the layers, so the
/// rollback has to have already happened, not be pending on a later drop.
/// Rolls back immediately when recovery must read the committed layers next.
pub fn rollback(self) {
drop(self);
}
-5
View File
@@ -62,7 +62,6 @@ impl fmt::Display for Type {
#[macro_export]
macro_rules! logging {
// 不带 print 参数的版本(默认不打印)
($level:ident, $type:expr, $($arg:tt)*) => {
log::$level!(target: "app", "{} {}", $type, format_args!($($arg)*))
};
@@ -70,14 +69,12 @@ macro_rules! logging {
#[macro_export]
macro_rules! logging_error {
// Handle Result<T, E>
($type:expr, $expr:expr) => {
if let Err(err) = $expr {
log::error!(target: "app", "[{}] {}", $type, err);
}
};
// Handle formatted message: always print to stdout and log as error
($type:expr, $fmt:literal $(, $arg:expr)*) => {
log::error!(target: "app", "[{}] {}", $type, format_args!($fmt $(, $arg)*));
};
@@ -113,14 +110,12 @@ impl<'a> ModuleFilter<'a> {
return true;
};
// 优先检查排除(白名单)
if let Some(excludes) = &self.exclude
&& excludes.iter().any(|e| module.starts_with(e))
{
return true;
}
// 再检查阻止(黑名单)
!self.block.iter().any(|b| module.starts_with(b))
}
}
+1 -3
View File
@@ -8,17 +8,15 @@ pub fn get_local_date_string() -> String {
pub fn country_code_to_emoji(country_code: &str) -> String {
let uc = country_code.to_ascii_uppercase();
// 长度校验:仅允许 2 或 3
match uc.len() {
2 => {
// 校验是否是合法 alpha2
if rust_iso3166::from_alpha2(&uc).is_none() {
return String::new();
}
alpha2_to_emoji(&uc)
}
3 => {
// 转换并校验 alpha3
// Regional indicators require the alpha-2 form.
match rust_iso3166::from_alpha3(&uc) {
Some(c) => {
let alpha2 = c.alpha2.to_ascii_uppercase();
@@ -159,14 +159,7 @@ pub fn is_current_app_handle_admin<R: Runtime>(app: &tauri::AppHandle<R>) -> boo
#[inline]
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::<R>::new("clash_verge_sysinfo")
// TODO 现在 crate 还不是真正的 tauri 插件,必须由主 lib 自行注册
// TODO 从 clash-verge 中迁移获取系统信息的 commnand 并实现优雅 structure.field 访问
// .invoke_handler(tauri::generate_handler![
// commands::get_system_info,
// commands::get_app_uptime,
// commands::app_is_admin,
// commands::export_diagnostic_info,
// ])
// TODO: move command registration here once this crate becomes a complete Tauri plugin.
.setup(move |app, _api| {
let app_version = app.package_info().version.to_string();
let is_admin = is_binary_admin();
-3
View File
@@ -1,7 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
# bump_changelog.sh
# - prepend ./Changelog.md to ./docs/Changelog.history.md
# - overwrite ./Changelog.md with ./template/Changelog.md
@@ -38,7 +37,6 @@ if [ -f "$HISTORY" ]; then
echo "Backed up $HISTORY -> $BACKUP_DIR/Changelog.history.md.bak.$bak_ts"
fi
# Prepend current Changelog.md content to top of docs/Changelog.history.md
tmpfile=$(mktemp)
{
cat "$CHANGELOG"
@@ -52,7 +50,6 @@ tmpfile=$(mktemp)
mv "$tmpfile" "$HISTORY"
echo "Prepended $CHANGELOG -> $HISTORY"
# Overwrite Changelog.md with template
cp "$TEMPLATE" "$CHANGELOG"
echo "Overwrote $CHANGELOG with $TEMPLATE"
+1 -12
View File
@@ -1,7 +1,6 @@
#!/bin/bash
# 获取最近一个和 Tauri 相关的改动的 commit hash
# This script finds the latest commit that modified Tauri-related files
# Print the latest commit that modified Tauri-related files.
# Tauri 相关文件的模式
TAURI_PATTERNS=(
@@ -24,7 +23,6 @@ EXCLUDE_PATTERNS=(
".git/"
)
# 构建 git log 的路径过滤参数
PATHS=""
for pattern in "${TAURI_PATTERNS[@]}"; do
if [[ -e "$pattern" ]]; then
@@ -32,25 +30,16 @@ for pattern in "${TAURI_PATTERNS[@]}"; do
fi
done
# 如果没有找到相关路径,返回错误
if [[ -z "$PATHS" ]]; then
echo "Error: No Tauri-related paths found in current directory" >&2
exit 1
fi
# 获取最新的 commit hash
# 使用 git log 查找最近修改了 Tauri 相关文件的提交
LATEST_COMMIT=$(git log --format="%H" -n 1 -- $PATHS)
# 验证是否找到了 commit
if [[ -z "$LATEST_COMMIT" ]]; then
echo "Error: No commits found for Tauri-related files" >&2
exit 1
fi
# 输出结果
echo "$LATEST_COMMIT"
# 如果需要更多信息,可以取消注释以下行
# echo "Latest Tauri-related commit: $LATEST_COMMIT"
# git show --stat --oneline "$LATEST_COMMIT"
-18
View File
@@ -3,19 +3,12 @@ import fs from 'fs/promises'
import path from 'path'
import { promisify } from 'util'
/**
* 为Alpha版本重命名版本号
*/
const execPromise = promisify(exec)
/**
* 标准输出HEAD hash
*/
async function getLatestCommitHash() {
try {
const { stdout } = await execPromise('git rev-parse HEAD')
const commitHash = stdout.trim()
// 格式化,只截取前7位字符
const formathash = commitHash.substring(0, 7)
console.log(`Found the latest commit hash code: ${commitHash}`)
return formathash
@@ -24,31 +17,20 @@ async function getLatestCommitHash() {
}
}
/**
* @param string 传入格式化后的hash
* 将新的版本号写入文件 package.json
*/
async function updatePackageVersion(newVersion) {
// 获取内容根目录
const _dirname = process.cwd()
const packageJsonPath = path.join(_dirname, 'package.json')
try {
// 读取文件
const data = await fs.readFile(packageJsonPath, 'utf8')
const packageJson = JSON.parse(data)
// 获取键值替换
let result = packageJson.version.replace('alpha', newVersion)
// 检查当前版本号是否已经包含了 alpha- 后缀
if (!packageJson.version.includes(`alpha-`)) {
// 如果只有 alpha 而没有 alpha-,则替换为 alpha-newVersion
result = packageJson.version.replace('alpha', `alpha-${newVersion}`)
} else {
// 如果已经是 alpha-xxx 格式,则更新 xxx 部分
result = packageJson.version.replace(/alpha-[^-]*/, `alpha-${newVersion}`)
}
console.log('[INFO]: Current version is: ', result)
packageJson.version = result
// 写入版本号
await fs.writeFile(
packageJsonPath,
JSON.stringify(packageJson, null, 2),
+8 -47
View File
@@ -13,14 +13,7 @@ import { extract } from 'tar'
import { resolveServiceRelease } from './service-release.mjs'
import { log_debug, log_error, log_info, log_success } from './utils.mjs'
/**
* Prebuild script with optimization features:
* 1. Skip downloading mihomo core if it already exists (unless --force is used)
* 2. Cache version information for 1 hour to avoid repeated version checks
* 3. Use file hash to detect changes and skip unnecessary chmod/copy operations
* 4. Use --force or -f flag to force re-download and update all resources
*
*/
/** Prepares platform resources, caching versions and unchanged files unless `--force` is used. */
const cwd = process.cwd()
const TEMP_DIR = path.join(cwd, 'node_modules/.verge')
@@ -76,9 +69,7 @@ const SIDECAR_DIR = path.join(cwd, 'src-tauri', 'sidecar')
// Linux service binaries are bundled as externalBin sidecars (see tauri.linux.conf.json)
const SERVICE_DIR = platform === 'linux' ? SIDECAR_DIR : RESOURCES_DIR
// =======================
// Version Cache
// =======================
// Version cache
async function loadVersionCache() {
try {
if (fs.existsSync(VERSION_CACHE_FILE)) {
@@ -114,9 +105,7 @@ async function setCachedVersion(key, version) {
await saveVersionCache(cache)
}
// =======================
// Hash Cache & File Hash
// =======================
// File hash cache
async function calculateFileHash(filePath) {
try {
const fileBuffer = await fsp.readFile(filePath)
@@ -170,9 +159,7 @@ async function updateHashCache(targetPath) {
}
}
// =======================
// Meta maps (stable & alpha)
// =======================
// Mihomo release maps
const META_ALPHA_VERSION_URL =
'https://github.com/MetaCubeX/mihomo/releases/download/Prerelease-Alpha/version.txt'
const META_ALPHA_URL_PREFIX = `https://github.com/MetaCubeX/mihomo/releases/download/Prerelease-Alpha`
@@ -211,9 +198,7 @@ const META_MAP = {
'linux-loong64': 'mihomo-linux-loong64',
}
// =======================
// Fetch latest versions
// =======================
// Release discovery
async function getLatestAlphaVersion() {
if (!FORCE) {
const cached = await getCachedVersion('META_ALPHA_VERSION')
@@ -280,9 +265,6 @@ async function getLatestReleaseVersion() {
}
}
// =======================
// Validate availability
// =======================
if (!META_MAP[`${platform}-${arch}`]) {
throw new Error(`clash meta unsupported platform "${platform}-${arch}"`)
}
@@ -290,9 +272,6 @@ if (!META_ALPHA_MAP[`${platform}-${arch}`]) {
throw new Error(`clash meta alpha unsupported platform "${platform}-${arch}"`)
}
// =======================
// Build meta objects
// =======================
function clashMetaAlpha() {
const name = META_ALPHA_MAP[`${platform}-${arch}`]
const isWin = platform === 'win32'
@@ -319,9 +298,6 @@ function clashMeta() {
}
}
// =======================
// download helper (增强:status + magic bytes)
// =======================
async function downloadFile(url, outPath) {
const options = {}
const httpProxy =
@@ -338,7 +314,6 @@ async function downloadFile(url, outPath) {
})
if (!response.ok) {
const body = await response.text().catch(() => '')
// 将 body 写到文件以便排查(可通过临时目录查看)
await fsp.mkdir(path.dirname(outPath), { recursive: true })
await fsp.writeFile(outPath, body)
throw new Error(`Failed to download ${url}: status ${response.status}`)
@@ -347,7 +322,6 @@ async function downloadFile(url, outPath) {
const buf = Buffer.from(await response.arrayBuffer())
await fsp.mkdir(path.dirname(outPath), { recursive: true })
// 简单 magic 字节检查
if (url.endsWith('.gz') || url.endsWith('.tgz')) {
if (!(buf[0] === 0x1f && buf[1] === 0x8b)) {
await fsp.writeFile(outPath, buf)
@@ -368,9 +342,6 @@ async function downloadFile(url, outPath) {
log_success(`download finished: ${url}`)
}
// =======================
// resolveSidecar (支持 zip / tgz / gz)
// =======================
async function resolveSidecar(binInfo) {
const { name, targetFile, zipFile, exeFile, downloadURL } = binInfo
const sidecarPath = path.join(SIDECAR_DIR, targetFile)
@@ -397,11 +368,9 @@ async function resolveSidecar(binInfo) {
log_debug(`"${name}" entry: ${entry.entryName}`)
})
zip.extractAllTo(tempDir, true)
// 尝试按 exeFile 重命名,否则找第一个可执行文件
if (fs.existsSync(tempExe)) {
await fsp.rename(tempExe, sidecarPath)
} else {
// 搜索候选
const files = await fsp.readdir(tempDir)
const candidate = files.find(
(f) =>
@@ -419,7 +388,6 @@ async function resolveSidecar(binInfo) {
await extract({ cwd: tempDir, file: tempZip })
const files = await fsp.readdir(tempDir)
log_debug(`"${name}" extracted files:`, files)
// 优先寻找给定 exeFile 或已知前缀
let extracted = files.find(
(f) =>
f === path.basename(exeFile) ||
@@ -432,7 +400,6 @@ async function resolveSidecar(binInfo) {
await fsp.chmod(sidecarPath, 0o755)
log_success(`tgz processed: "${name}"`)
} else {
// .gz
const readStream = fs.createReadStream(tempZip)
const writeStream = fs.createWriteStream(sidecarPath)
await new Promise((resolve, reject) => {
@@ -496,7 +463,7 @@ async function resolveResource(binInfo) {
log_success(`${file} finished`)
}
// SimpleSC.dll (win plugin)
// Windows NSIS plugin
const resolvePlugin = async () => {
const url =
'https://nsis.sourceforge.io/mediawiki/images/e/ef/NSIS_Simple_Service_Plugin_Unicode_1.30.zip'
@@ -524,7 +491,6 @@ const resolvePlugin = async () => {
await fsp.cp(tempDll, pluginPath, { recursive: true, force: true })
log_success(`unzip finished: "SimpleSC"`)
} else {
// 如果 dll 名称不同,尝试找到 dll
const files = await fsp.readdir(tempDir)
const dll = files.find((f) => f.toLowerCase().endsWith('.dll'))
if (dll) {
@@ -542,7 +508,7 @@ const resolvePlugin = async () => {
}
}
// service chmod (保留并使用 glob)
// Service executable permissions
const resolveServicePermission = async () => {
const serviceExecutables = [
'clash-verge-service*',
@@ -578,9 +544,7 @@ const resolveServicePermission = async () => {
}
}
// =======================
// Other resource resolvers (service, mmdb, geosite, geoip, enableLoopback)
// =======================
// Other resources
const SERVICE_BINARIES = [
'clash-verge-service',
'clash-verge-service-install',
@@ -698,9 +662,6 @@ const resolveUnSetDnsScript = () =>
localPath: path.join(cwd, 'scripts/unset_dns.sh'),
})
// =======================
// Tasks
// =======================
const tasks = [
{
name: 'verge-mihomo-alpha',
-5
View File
@@ -1,4 +1,3 @@
// scripts/publish-version.mjs
import { execFileSync, spawn } from 'child_process'
import { existsSync, readFileSync } from 'fs'
import path from 'path'
@@ -17,7 +16,6 @@ if (!versionArg) {
process.exit(1)
}
// 1. 调用 release-version.mjs
const runRelease = () =>
new Promise((resolve, reject) => {
const child = spawn('node', [scriptPath, versionArg], { stdio: 'inherit' })
@@ -27,7 +25,6 @@ const runRelease = () =>
})
})
// 2. 判断是否需要打 tag
function isSemver(version) {
return /^v?\d+\.\d+\.\d+(-(alpha|beta|rc)(\.\d+)?)?(\+[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*)?$/i.test(
version,
@@ -57,7 +54,6 @@ async function run() {
let tag = null
if (versionArg === 'alpha') {
// 读取 release-version.mjs 写入后的版本
tag = normalizeVersion(readPackageVersion())
} else if (isSemver(versionArg)) {
// 1.2.3、v1.2.3、1.2.3-beta.1 或 1.2.3+build.1
@@ -67,7 +63,6 @@ async function run() {
if (tag) {
validateTag(tag)
// 打 tag 并推送
try {
execFileSync('git', ['tag', tag], { stdio: 'inherit' })
execFileSync('git', ['push', 'origin', tag], { stdio: 'inherit' })
+1 -61
View File
@@ -20,13 +20,6 @@
* pnpm release-version autobuild-latest
* pnpm release-version deploytest
*
* The script will:
* - Validate and normalize the version argument
* - Update the version field in package.json
* - Update the version field in src-tauri/Cargo.toml
* - Update the version field in src-tauri/tauri.conf.json
*
* Errors are logged and the process exits with code 1 on failure.
*/
import { execSync } from 'child_process'
@@ -35,10 +28,6 @@ import path from 'path'
import { program } from 'commander'
/**
* 获取当前 git 短 commit hash
* @returns {string}
*/
function getGitShortCommit() {
try {
return execSync('git rev-parse --short HEAD').toString().trim()
@@ -48,10 +37,6 @@ function getGitShortCommit() {
}
}
/**
* 获取最新 Tauri 相关提交的短 hash
* @returns {string}
*/
function getLatestTauriCommit() {
try {
const fullHash = execSync(
@@ -73,13 +58,7 @@ function getLatestTauriCommit() {
}
}
/**
* 生成短时间戳(格式:MMDD)或带 commit(格式:MMDD.cc39b27)
* 使用 Asia/Shanghai 时区
* @param {boolean} withCommit 是否带 commit
* @param {boolean} useTauriCommit 是否使用 Tauri 相关的 commit(仅当 withCommit 为 true 时有效)
* @returns {string}
*/
/** Generates `MMDD`, optionally followed by a commit, in the Asia/Shanghai timezone. */
function generateShortTimestamp(withCommit = false, useTauriCommit = false) {
const now = new Date()
@@ -102,41 +81,22 @@ function generateShortTimestamp(withCommit = false, useTauriCommit = false) {
return `${month}${day}`
}
/**
* 验证版本号格式
* @param {string} version
* @returns {boolean}
*/
function isValidVersion(version) {
return /^v?\d+\.\d+\.\d+(-(alpha|beta|rc)(\.\d+)?)?(\+[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*)?$/i.test(
version,
)
}
/**
* 标准化版本号
* @param {string} version
* @returns {string}
*/
function normalizeVersion(version) {
return version.startsWith('v') ? version : `v${version}`
}
/**
* 提取基础版本号(去掉所有 -tag 和 +build 部分)
* @param {string} version
* @returns {string}
*/
function getBaseVersion(version) {
let base = version.replace(/-(alpha|beta|rc)(\.\d+)?/i, '')
base = base.replace(/\+[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*/g, '')
return base
}
/**
* 更新 package.json 版本号
* @param {string} newVersion
*/
async function updatePackageVersion(newVersion) {
const _dirname = process.cwd()
const packageJsonPath = path.join(_dirname, 'package.json')
@@ -165,10 +125,6 @@ async function updatePackageVersion(newVersion) {
}
}
/**
* 更新 Cargo.toml 版本号
* @param {string} newVersion
*/
async function updateCargoVersion(newVersion) {
const _dirname = process.cwd()
const cargoTomlPath = path.join(_dirname, 'src-tauri', 'Cargo.toml')
@@ -197,10 +153,6 @@ async function updateCargoVersion(newVersion) {
}
}
/**
* 更新 tauri.conf.json 版本号
* @param {string} newVersion
*/
async function updateTauriConfigVersion(newVersion) {
const _dirname = process.cwd()
const tauriConfigPath = path.join(_dirname, 'src-tauri', 'tauri.conf.json')
@@ -216,7 +168,6 @@ async function updateTauriConfigVersion(newVersion) {
tauriConfig.version,
)
// 使用完整版本信息,包含build metadata
tauriConfig.version = versionWithoutV
await fs.writeFile(
@@ -233,9 +184,6 @@ async function updateTauriConfigVersion(newVersion) {
}
}
/**
* 获取当前版本号
*/
async function getCurrentVersion() {
const _dirname = process.cwd()
const packageJsonPath = path.join(_dirname, 'package.json')
@@ -249,9 +197,6 @@ async function getCurrentVersion() {
}
}
/**
* 主函数
*/
async function main(versionArg) {
if (!versionArg) {
console.error('Error: Version argument is required')
@@ -274,16 +219,11 @@ async function main(versionArg) {
const baseVersion = getBaseVersion(currentVersion)
if (versionArg.toLowerCase() === 'autobuild') {
// 格式: 2.3.0+autobuild.1004.cc39b27
// 使用 Tauri 相关的最新 commit hash
newVersion = `${baseVersion}+autobuild.${generateShortTimestamp(true, true)}`
} else if (versionArg.toLowerCase() === 'autobuild-latest') {
// 格式: 2.3.0+autobuild.1004.a1b2c3d (使用最新 Tauri 提交)
const latestTauriCommit = getLatestTauriCommit()
newVersion = `${baseVersion}+autobuild.${generateShortTimestamp()}.${latestTauriCommit}`
} else if (versionArg.toLowerCase() === 'deploytest') {
// 格式: 2.3.0+deploytest.1004.cc39b27
// 使用 Tauri 相关的最新 commit hash
newVersion = `${baseVersion}+deploytest.${generateShortTimestamp(true, true)}`
} else {
newVersion = `${baseVersion}-${versionArg.toLowerCase()}`
-9
View File
@@ -1,6 +1,5 @@
#!/bin/bash
# 验证IPv4地址格式
function is_valid_ipv4() {
local ip=$1
local IFS='.'
@@ -18,7 +17,6 @@ function is_valid_ipv4() {
return 0
}
# 验证IPv6地址格式
function is_valid_ipv6() {
local ip=$1
if [[ ! $ip =~ ^([0-9a-fA-F]{0,4}:){1,7}[0-9a-fA-F]{0,4}$ ]] &&
@@ -28,27 +26,21 @@ function is_valid_ipv6() {
return 0
}
# 验证IP地址是否为有效的IPv4或IPv6
function is_valid_ip() {
is_valid_ipv4 "$1" || is_valid_ipv6 "$1"
}
# 检查参数
[ $# -lt 1 ] && echo "Usage: $0 <IP address>" && exit 1
! is_valid_ip "$1" && echo "$1 is not a valid IP address." && exit 1
# 获取网络接口和硬件端口
nic=$(route -n get default | grep "interface" | awk '{print $2}')
# 从网络服务列表中获取硬件端口
hardware_port=$(networksetup -listnetworkserviceorder | awk -v dev="$nic" '
/^\([0-9]+\) /{port=$0; sub(/^\([0-9]+\) /, "", port)}
/\(Hardware Port:/{interface=$NF;sub(/\)/, "", interface); if (interface == dev) {print port; exit}}
')
# 获取当前DNS设置
original_dns=$(networksetup -getdnsservers "$hardware_port")
# 检查当前DNS设置是否有效
is_valid_dns=false
for ip in $original_dns; do
ip=$(echo "$ip" | tr -d '[:space:]')
@@ -58,7 +50,6 @@ for ip in $original_dns; do
fi
done
# 更新DNS设置
if [ "$is_valid_dns" = false ]; then
echo "empty" >.original_dns.txt
else
+1 -12
View File
@@ -32,7 +32,6 @@ async function sendTelegramNotification() {
log_info(`Target channel: ${chatId}`)
log_info(`Download URL: ${downloadUrl}`)
// 读取发布说明和下载地址
let releaseContent
try {
releaseContent = readFileSync('release.txt', 'utf-8')
@@ -42,7 +41,6 @@ async function sendTelegramNotification() {
releaseContent = '更多新功能现已支持,详细更新日志请查看发布页面。'
}
// Markdown 转换为 HTML
function convertMarkdownToTelegramHTML(content) {
// Strip stray HTML tags and markdown bold from heading text
const cleanHeading = (text) =>
@@ -88,20 +86,13 @@ async function sendTelegramNotification() {
.replace(/<br\s*\/?>/g, '\n')
}
// Strip HTML tags not supported by Telegram and escape stray angle brackets
function sanitizeTelegramHTML(content) {
// Telegram supports: b, strong, i, em, u, ins, s, strike, del,
// a, code, pre, blockquote, tg-spoiler, tg-emoji
const allowedTag =
/^<\/?(?:b|strong|i|em|u|ins|s|strike|del|a|code|pre|blockquote|tg-spoiler|tg-emoji)(?:\s[^<>]*)?>$/i
// Match a well-formed tag (name first, no nested angle brackets) OR a lone
// '<'/'>'. Matching lone brackets is what keeps a stray '<' in the changelog
// (e.g. "原始配置 < Merge") from greedily swallowing the next real tag and
// leaving its closing tag orphaned ("Unexpected end tag").
// Match tags or lone brackets separately so stray changelog text cannot swallow a real tag.
return content.replace(/<\/?[a-z][^<>]*>|[<>]/gi, (token) => {
if (token === '<') return '&lt;'
if (token === '>') return '&gt;'
// Keep supported tags; escape everything else so it displays as text.
return allowedTag.test(token)
? token
: token.replace(/</g, '&lt;').replace(/>/g, '&gt;')
@@ -118,7 +109,6 @@ async function sendTelegramNotification() {
const releaseTag = isAutobuild ? 'autobuild' : `v${version}`
const content = `<b>🎉 <a href="https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/${releaseTag}">Clash Verge Rev v${version}</a> ${releaseTitle}</b>\n\n${formattedContent}`
// 发送到 Telegram
try {
await axios.post(
`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`,
@@ -144,7 +134,6 @@ async function sendTelegramNotification() {
}
}
// 执行函数
sendTelegramNotification().catch((error) => {
log_error('脚本执行失败:', error)
process.exit(1)
-17
View File
@@ -6,8 +6,6 @@ const UPDATE_TAG_NAME = 'updater'
const UPDATE_JSON_FILE = 'update-fixed-webview2.json'
const UPDATE_JSON_PROXY = 'update-fixed-webview2-proxy.json'
/// generate update.json
/// upload to update tag's release asset
async function resolveUpdater() {
if (process.env.GITHUB_TOKEN === undefined) {
throw new Error('GITHUB_TOKEN is required')
@@ -22,7 +20,6 @@ async function resolveUpdater() {
page: 1,
})
// get the latest publish tag
const tag = tags.find((t) => t.name.startsWith('v'))
console.log(tag)
@@ -48,33 +45,27 @@ async function resolveUpdater() {
const promises = latestRelease.assets.map(async (asset) => {
const { name, browser_download_url } = asset
// win64 url
if (name.endsWith('x64_fixed_webview2-setup.exe')) {
updateData.platforms['windows-x86_64'].url = browser_download_url
}
// win64 signature
if (name.endsWith('x64_fixed_webview2-setup.exe.sig')) {
const sig = await getSignature(browser_download_url)
updateData.platforms['windows-x86_64'].signature = sig
}
// win32 url
if (name.endsWith('x86_fixed_webview2-setup.exe')) {
updateData.platforms['windows-x86'].url = browser_download_url
updateData.platforms['windows-i686'].url = browser_download_url
}
// win32 signature
if (name.endsWith('x86_fixed_webview2-setup.exe.sig')) {
const sig = await getSignature(browser_download_url)
updateData.platforms['windows-x86'].signature = sig
updateData.platforms['windows-i686'].signature = sig
}
// win arm url
if (name.endsWith('arm64_fixed_webview2-setup.exe')) {
updateData.platforms['windows-aarch64'].url = browser_download_url
}
// win arm signature
if (name.endsWith('arm64_fixed_webview2-setup.exe.sig')) {
const sig = await getSignature(browser_download_url)
updateData.platforms['windows-aarch64'].signature = sig
@@ -84,8 +75,6 @@ async function resolveUpdater() {
await Promise.allSettled(promises)
console.log(updateData)
// maybe should test the signature as well
// delete the null field
Object.entries(updateData.platforms).forEach(([key, value]) => {
if (!value.url) {
console.log(`[Error]: failed to parse release for "${key}"`)
@@ -93,8 +82,6 @@ async function resolveUpdater() {
}
})
// 生成一个代理github的更新文件
// 使用 https://hub.fastgit.xyz/ 做github资源的加速
const updateDataNew = JSON.parse(JSON.stringify(updateData))
Object.entries(updateDataNew.platforms).forEach(([key, value]) => {
@@ -105,13 +92,11 @@ async function resolveUpdater() {
}
})
// update the update.json
const { data: updateRelease } = await github.rest.repos.getReleaseByTag({
...options,
tag: UPDATE_TAG_NAME,
})
// delete the old assets
for (const asset of updateRelease.assets) {
if (asset.name === UPDATE_JSON_FILE) {
await github.rest.repos.deleteReleaseAsset({
@@ -127,7 +112,6 @@ async function resolveUpdater() {
}
}
// upload new assets
await github.rest.repos.uploadReleaseAsset({
...options,
release_id: updateRelease.id,
@@ -143,7 +127,6 @@ async function resolveUpdater() {
})
}
// get the signature file content
async function getSignature(url) {
const response = await fetch(url, {
method: 'GET',
+2 -40
View File
@@ -2,17 +2,13 @@ import { context, getOctokit } from '@actions/github'
import { resolveUpdateLog, resolveUpdateLogDefault } from './updatelog.mjs'
// Add stable update JSON filenames
const UPDATE_TAG_NAME = 'updater'
const UPDATE_JSON_FILE = 'update.json'
const UPDATE_JSON_PROXY = 'update-proxy.json'
// Add alpha update JSON filenames
const ALPHA_TAG_NAME = 'updater-alpha'
const ALPHA_UPDATE_JSON_FILE = 'update.json'
const ALPHA_UPDATE_JSON_PROXY = 'update-proxy.json'
/// generate update.json
/// upload to update tag's release asset
async function resolveUpdater() {
if (process.env.GITHUB_TOKEN === undefined) {
throw new Error('GITHUB_TOKEN is required')
@@ -21,7 +17,6 @@ async function resolveUpdater() {
const options = { owner: context.repo.owner, repo: context.repo.repo }
const github = getOctokit(process.env.GITHUB_TOKEN)
// Fetch all tags using pagination
let allTags = []
let page = 1
const perPage = 100
@@ -35,7 +30,6 @@ async function resolveUpdater() {
allTags = allTags.concat(pageTags)
// Break if we received fewer tags than requested (last page)
if (pageTags.length < perPage) {
break
}
@@ -46,12 +40,9 @@ async function resolveUpdater() {
const tags = allTags
console.log(`Retrieved ${tags.length} tags in total`)
// More flexible tag detection with regex patterns
const stableTagRegex = /^v\d+\.\d+\.\d+$/ // Matches vX.Y.Z format
// const preReleaseRegex = /^v\d+\.\d+\.\d+-(alpha|beta|rc|pre)/i; // Matches vX.Y.Z-alpha/beta/rc format
const preReleaseRegex = /^(alpha|beta|rc|pre)$/i // Matches exact alpha/beta/rc/pre tags
const stableTagRegex = /^v\d+\.\d+\.\d+$/
const preReleaseRegex = /^(alpha|beta|rc|pre)$/i
// Get the latest stable tag and pre-release tag
const stableTag = tags.find((t) => stableTagRegex.test(t.name))
const preReleaseTag = tags.find((t) => preReleaseRegex.test(t.name))
@@ -63,18 +54,15 @@ async function resolveUpdater() {
)
console.log()
// Process stable release
if (stableTag) {
await processRelease(github, options, stableTag, false)
}
// Process pre-release if found
if (preReleaseTag) {
await processRelease(github, options, preReleaseTag, true)
}
}
// Process a release (stable or alpha) and generate update files
async function processRelease(github, options, tag, isAlpha) {
if (!tag) return
@@ -129,67 +117,55 @@ async function processRelease(github, options, tag, isAlpha) {
const promises = release.assets.map(async (asset) => {
const { name, browser_download_url } = asset
// Process all the platform URL and signature data
// win64 url
if (name.endsWith('x64-setup.exe')) {
updateData.platforms['windows-x86_64'].url = browser_download_url
updateData.platforms['windows-x86_64-nsis'].url = browser_download_url
}
// win64 signature
if (name.endsWith('x64-setup.exe.sig')) {
const sig = await getSignature(browser_download_url)
updateData.platforms['windows-x86_64'].signature = sig
updateData.platforms['windows-x86_64-nsis'].signature = sig
}
// win32 url
if (name.endsWith('x86-setup.exe')) {
updateData.platforms['windows-x86'].url = browser_download_url
updateData.platforms['windows-x86-nsis'].url = browser_download_url
updateData.platforms['windows-i686-nsis'].url = browser_download_url
}
// win32 signature
if (name.endsWith('x86-setup.exe.sig')) {
const sig = await getSignature(browser_download_url)
updateData.platforms['windows-x86'].signature = sig
updateData.platforms['windows-x86-nsis'].signature = sig
updateData.platforms['windows-i686-nsis'].signature = sig
}
// win arm url
if (name.endsWith('arm64-setup.exe')) {
updateData.platforms['windows-aarch64'].url = browser_download_url
updateData.platforms['windows-aarch64-nsis'].url = browser_download_url
}
// win arm signature
if (name.endsWith('arm64-setup.exe.sig')) {
const sig = await getSignature(browser_download_url)
updateData.platforms['windows-aarch64'].signature = sig
updateData.platforms['windows-aarch64-nsis'].signature = sig
}
// darwin url (intel)
if (name.endsWith('.app.tar.gz') && !name.includes('aarch')) {
updateData.platforms['darwin-x86_64'].url = browser_download_url
updateData.platforms['darwin-x86_64-app'].url = browser_download_url
}
// darwin signature (intel)
if (name.endsWith('.app.tar.gz.sig') && !name.includes('aarch')) {
const sig = await getSignature(browser_download_url)
updateData.platforms['darwin-x86_64'].signature = sig
updateData.platforms['darwin-x86_64-app'].signature = sig
}
// darwin url (aarch)
if (name.endsWith('aarch64.app.tar.gz')) {
updateData.platforms['darwin-aarch64'].url = browser_download_url
updateData.platforms['darwin-aarch64-app'].url = browser_download_url
}
// darwin signature (aarch)
if (name.endsWith('aarch64.app.tar.gz.sig')) {
const sig = await getSignature(browser_download_url)
updateData.platforms['darwin-aarch64'].signature = sig
updateData.platforms['darwin-aarch64-app'].signature = sig
}
// Linux x86
if (name.endsWith('i386.deb')) {
updateData.platforms['linux-x86'].url = browser_download_url
updateData.platforms['linux-x86-deb'].url = browser_download_url
@@ -213,7 +189,6 @@ async function processRelease(github, options, tag, isAlpha) {
updateData.platforms['linux-i686-rpm'].signature = sig
}
// Linux x86_64
if (name.endsWith('amd64.deb')) {
updateData.platforms['linux-x86_64'].url = browser_download_url
updateData.platforms['linux-x86_64-deb'].url = browser_download_url
@@ -231,7 +206,6 @@ async function processRelease(github, options, tag, isAlpha) {
updateData.platforms['linux-x86_64-rpm'].signature = sig
}
// Linux aarch64
if (name.endsWith('arm64.deb')) {
updateData.platforms['linux-aarch64'].url = browser_download_url
updateData.platforms['linux-aarch64-deb'].url = browser_download_url
@@ -249,7 +223,6 @@ async function processRelease(github, options, tag, isAlpha) {
updateData.platforms['linux-aarch64-rpm'].signature = sig
}
// Linux armv7
if (name.endsWith('armhf.deb')) {
updateData.platforms['linux-armv7'].url = browser_download_url
updateData.platforms['linux-armv7-deb'].url = browser_download_url
@@ -271,8 +244,6 @@ async function processRelease(github, options, tag, isAlpha) {
await Promise.allSettled(promises)
console.log(updateData)
// maybe should test the signature as well
// delete the null field
Object.entries(updateData.platforms).forEach(([key, value]) => {
if (!value.url) {
console.log(`[Error]: failed to parse release for "${key}"`)
@@ -280,7 +251,6 @@ async function processRelease(github, options, tag, isAlpha) {
}
})
// Generate a proxy update file for accelerated GitHub resources
const updateDataNew = JSON.parse(JSON.stringify(updateData))
Object.entries(updateDataNew.platforms).forEach(([key, value]) => {
@@ -292,7 +262,6 @@ async function processRelease(github, options, tag, isAlpha) {
}
})
// Get the appropriate updater release based on isAlpha flag
const releaseTag = isAlpha ? ALPHA_TAG_NAME : UPDATE_TAG_NAME
console.log(
`Processing ${isAlpha ? 'alpha' : 'stable'} release:`,
@@ -303,7 +272,6 @@ async function processRelease(github, options, tag, isAlpha) {
let updateRelease
try {
// Try to get the existing release
const response = await github.rest.repos.getReleaseByTag({
...options,
tag: releaseTag,
@@ -313,7 +281,6 @@ async function processRelease(github, options, tag, isAlpha) {
`Found existing ${releaseTag} release with ID: ${updateRelease.id}`,
)
} catch (error) {
// If release doesn't exist, create it
if (error.status === 404) {
console.log(
`Release with tag ${releaseTag} not found, creating new release...`,
@@ -332,16 +299,13 @@ async function processRelease(github, options, tag, isAlpha) {
`Created new ${releaseTag} release with ID: ${updateRelease.id}`,
)
} else {
// If it's another error, throw it
throw error
}
}
// File names based on release type
const jsonFile = isAlpha ? ALPHA_UPDATE_JSON_FILE : UPDATE_JSON_FILE
const proxyFile = isAlpha ? ALPHA_UPDATE_JSON_PROXY : UPDATE_JSON_PROXY
// Delete existing assets with these names
for (const asset of updateRelease.assets) {
if (asset.name === jsonFile) {
await github.rest.repos.deleteReleaseAsset({
@@ -357,7 +321,6 @@ async function processRelease(github, options, tag, isAlpha) {
}
}
// Upload new assets
await github.rest.repos.uploadReleaseAsset({
...options,
release_id: updateRelease.id,
@@ -390,7 +353,6 @@ async function processRelease(github, options, tag, isAlpha) {
}
}
// get the signature file content
async function getSignature(url) {
const response = await fetch(url, {
method: 'GET',
-12
View File
@@ -4,14 +4,12 @@ use crate::{cmd::StringifyErr as _, feat, utils::dirs};
use smartstring::alias::String;
use tauri::{AppHandle, Manager as _};
/// 打开应用程序所在目录
#[tauri::command]
pub async fn open_app_dir() -> CmdResult<()> {
let app_dir = dirs::app_home_dir().stringify_err()?;
open::that(app_dir).stringify_err()
}
/// 打开核心所在目录
#[tauri::command]
pub async fn open_core_dir() -> CmdResult<()> {
let core_dir = tauri::utils::platform::current_exe().stringify_err()?;
@@ -19,20 +17,17 @@ pub async fn open_core_dir() -> CmdResult<()> {
open::that(core_dir).stringify_err()
}
/// 打开日志目录
#[tauri::command]
pub async fn open_logs_dir() -> CmdResult<()> {
let log_dir = dirs::app_logs_dir().stringify_err()?;
open::that(log_dir).stringify_err()
}
/// 打开网页链接
#[tauri::command]
pub fn open_web_url(url: String) -> CmdResult<()> {
open::that(url.as_str()).stringify_err()
}
/// 打开/关闭开发者工具
#[tauri::command]
pub fn open_devtools(app_handle: AppHandle) {
if let Some(window) = app_handle.get_webview_window("main") {
@@ -44,45 +39,38 @@ pub fn open_devtools(app_handle: AppHandle) {
}
}
/// 退出应用
#[tauri::command]
pub async fn exit_app() {
feat::quit().await;
}
/// 重启应用
#[tauri::command]
pub async fn restart_app() -> CmdResult<()> {
feat::restart_app().await;
Ok(())
}
/// 获取便携版标识
#[tauri::command]
pub fn get_portable_flag() -> bool {
*dirs::PORTABLE_FLAG.get().unwrap_or(&false)
}
/// 获取应用目录
#[tauri::command]
pub fn get_app_dir() -> CmdResult<String> {
let app_home_dir = dirs::app_home_dir().stringify_err()?.to_string_lossy().into();
Ok(app_home_dir)
}
/// 获取当前自启动状态
#[tauri::command]
pub fn get_auto_launch_status() -> CmdResult<bool> {
autostart::get_launch_status().stringify_err()
}
/// 下载图标缓存
#[tauri::command]
pub async fn download_icon_cache(url: String, name: String) -> CmdResult<String> {
feat::download_icon_cache(url, name).await
}
/// 复制图标文件
#[tauri::command]
pub async fn copy_icon_file(path: String, icon_info: feat::IconInfo) -> CmdResult<String> {
feat::copy_icon_file(path, icon_info).await
-6
View File
@@ -3,25 +3,21 @@ use crate::{cmd::StringifyErr as _, feat};
use feat::LocalBackupFile;
use smartstring::alias::String;
/// Create a local backup
#[tauri::command]
pub async fn create_local_backup() -> CmdResult<()> {
feat::create_local_backup().await.stringify_err()
}
/// List local backups
#[tauri::command]
pub async fn list_local_backup() -> CmdResult<Vec<LocalBackupFile>> {
feat::list_local_backup().await.stringify_err()
}
/// Delete local backup
#[tauri::command]
pub async fn delete_local_backup(filename: String) -> CmdResult<()> {
feat::delete_local_backup(filename).await.stringify_err()
}
/// Restore local backup
#[tauri::command]
pub async fn restore_local_backup(filename: String) -> CmdResult<()> {
feat::restore_local_backup(filename)
@@ -29,13 +25,11 @@ pub async fn restore_local_backup(filename: String) -> CmdResult<()> {
.map_err(|error| super::proxy_aware_error(&error))
}
/// Import local backup into the app's backup directory
#[tauri::command]
pub async fn import_local_backup(source: String) -> CmdResult<String> {
feat::import_local_backup(source).await.stringify_err()
}
/// Export local backup to a user selected destination
#[tauri::command]
pub async fn export_local_backup(filename: String, destination: String) -> CmdResult<()> {
feat::export_local_backup(filename, destination).await.stringify_err()
+1 -30
View File
@@ -16,20 +16,17 @@ use serde_yaml_ng::Mapping;
use smartstring::alias::String;
use tokio::fs;
/// 复制Clash环境变量
#[tauri::command]
pub async fn copy_clash_env() -> CmdResult {
feat::copy_clash_env().await;
Ok(())
}
/// 获取Clash信息
#[tauri::command]
pub async fn get_clash_info() -> CmdResult<ClashInfo> {
Ok(Config::clash().await.data_arc().get_client_info())
}
/// 修改Clash配置
#[tauri::command]
pub async fn patch_clash_config(payload: Mapping) -> CmdResult {
feat::patch_clash(&payload)
@@ -37,10 +34,6 @@ pub async fn patch_clash_config(payload: Mapping) -> CmdResult {
.map_err(|error| proxy_aware_coded_error(&error, "CLASH_CONFIG_UPDATE_FAILED"))
}
/// 修改Clash模式
///
/// 将 `change_clash_mode` 的失败上抛给前端,使前端 `catch` 能真正感知后端 PATCH 失败
/// 并提示用户(此前命令始终返回 `Ok(())`,吞掉了后端错误)。
#[tauri::command]
pub async fn patch_clash_mode(payload: String) -> CmdResult {
feat::change_clash_mode(payload)
@@ -48,16 +41,12 @@ pub async fn patch_clash_mode(payload: String) -> CmdResult {
.with_error_code("CLASH_MODE_UPDATE_FAILED")
}
/// 获取当前 Clash 模式(容错读取)
///
/// 直接读取已保存的 clash 配置中的 `mode`,绕开 mihomo `/configs` 的严格
/// `BaseConfig` 反序列化,作为主页 mode 显示的兜底来源。
/// Reads the saved mode without depending on strict mihomo `/configs` deserialization.
#[tauri::command]
pub async fn get_clash_mode() -> CmdResult<Option<String>> {
Ok(Config::clash().await.data_arc().get_mode().map(Into::into))
}
/// 切换Clash核心
#[tauri::command]
pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<CommandFailure>> {
logging!(info, Type::Config, "changing core to {clash_core}");
@@ -66,7 +55,6 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<CommandFa
Ok(_) => {
logging_error!(Type::Core, profiles_save_file_safe().await);
// 切换内核后重启内核
match CoreManager::global().restart_core().await {
Ok(_) => {
logging!(info, Type::Core, "core changed and restarted to {clash_core}");
@@ -92,7 +80,6 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<CommandFa
}
}
/// 启动核心
#[tauri::command]
pub async fn start_core() -> CmdResult {
let result = CoreManager::global()
@@ -105,7 +92,6 @@ pub async fn start_core() -> CmdResult {
result
}
/// 关闭核心
#[tauri::command]
pub async fn stop_core() -> CmdResult {
logging_error!(Type::Core, profiles_save_file_safe().await);
@@ -119,7 +105,6 @@ pub async fn stop_core() -> CmdResult {
result
}
/// 重启核心
#[tauri::command]
pub async fn restart_core() -> CmdResult {
logging_error!(Type::Core, profiles_save_file_safe().await);
@@ -133,7 +118,6 @@ pub async fn restart_core() -> CmdResult {
result
}
/// 测试URL延迟
#[tauri::command]
pub async fn test_delay(url: String) -> CmdResult<u32> {
let result = match feat::test_delay(url).await {
@@ -146,16 +130,13 @@ pub async fn test_delay(url: String) -> CmdResult<u32> {
Ok(result)
}
/// 保存DNS配置到单独文件
#[tauri::command]
pub async fn save_dns_config(dns_config: Mapping) -> CmdResult {
use crate::utils::dirs;
use tokio::fs;
// 获取DNS配置文件路径
let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG);
// 保存DNS配置到文件
let yaml_str = yaml_emitter::to_mihomo_config_string(&dns_config).stringify_err()?;
fs::write(&dns_path, yaml_str).await.stringify_err()?;
logging!(info, Type::Config, "DNS config saved to {dns_path:?}");
@@ -163,11 +144,9 @@ pub async fn save_dns_config(dns_config: Mapping) -> CmdResult {
Ok(())
}
/// 应用或撤销DNS配置
#[tauri::command]
pub async fn apply_dns_config(apply: bool) -> CmdResult {
if apply {
// 读取DNS配置文件
let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG);
if !dns_path.exists() {
@@ -179,23 +158,19 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult {
logging!(error, Type::Config, "Failed to read DNS config: {e}");
})?;
// 解析DNS配置
let patch_config = serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&dns_yaml).stringify_err_log(|e| {
logging!(error, Type::Config, "Failed to parse DNS config: {e}");
})?;
logging!(info, Type::Config, "Applying DNS config from file");
// 创建包含DNS配置的patch
let mut patch = serde_yaml_ng::Mapping::new();
patch.insert("dns".into(), patch_config.into());
// 应用DNS配置到运行时配置
Config::runtime().await.edit_draft(|d| {
d.patch_config(&patch);
});
// 应用新配置
CoreManager::global().update_config_checked().await.map_err(|error| {
logging!(error, Type::Config, "failed to apply the config with DNS: {error:#}");
proxy_aware_error(&error)
@@ -203,7 +178,6 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult {
logging!(info, Type::Config, "DNS config successfully applied");
} else {
// 当关闭DNS设置时,重新生成配置(不加载DNS配置文件)
logging!(info, Type::Config, "DNS settings disabled, regenerating config");
CoreManager::global().update_config_checked().await.map_err(|error| {
@@ -218,7 +192,6 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult {
Ok(())
}
/// 检查DNS配置文件是否存在
#[tauri::command]
pub fn check_dns_config_exists() -> CmdResult<bool> {
use crate::utils::dirs;
@@ -228,7 +201,6 @@ pub fn check_dns_config_exists() -> CmdResult<bool> {
Ok(dns_path.exists())
}
/// 获取DNS配置文件内容
#[tauri::command]
pub async fn get_dns_config_content() -> CmdResult<String> {
use crate::utils::dirs;
@@ -244,7 +216,6 @@ pub async fn get_dns_config_content() -> CmdResult<String> {
Ok(content)
}
/// 验证DNS配置文件
#[tauri::command]
pub async fn validate_dns_config() -> CmdResult<ValidationOutcome> {
let app_dir = dirs::app_home_dir().stringify_err()?;
-8
View File
@@ -8,7 +8,6 @@ use serde_yaml_ng::Mapping;
use sysproxy::{Autoproxy, Sysproxy};
use tauri_plugin_clash_verge_sysinfo;
/// get the system proxy
#[tauri::command]
pub async fn get_sys_proxy() -> CmdResult<Mapping> {
logging!(debug, Type::Network, "异步获取系统代理配置");
@@ -38,7 +37,6 @@ pub async fn get_sys_proxy() -> CmdResult<Mapping> {
Ok(map)
}
/// 获取自动代理配置
#[tauri::command]
pub async fn get_auto_proxy() -> CmdResult<Mapping> {
Sysopt::global().wait_idle().await;
@@ -64,28 +62,22 @@ pub fn get_embedded_server_port() -> CmdResult<u16> {
crate::utils::server::embedded_server_port().stringify_err()
}
/// 获取系统主机名
#[tauri::command]
pub fn get_system_hostname() -> String {
// 获取系统主机名,处理可能的非UTF-8字符
match gethostname().into_string() {
Ok(name) => name,
Err(os_string) => {
// 对于包含非UTF-8的主机名,使用调试格式化
let fallback = format!("{os_string:?}");
// 去掉可能存在的引号
fallback.trim_matches('"').to_string()
}
}
}
/// 获取网络接口列表
#[tauri::command]
pub fn get_network_interfaces() -> Vec<String> {
tauri_plugin_clash_verge_sysinfo::list_network_interfaces()
}
/// 获取网络接口详细信息
#[tauri::command]
pub fn get_network_interfaces_info() -> CmdResult<Vec<NetworkInterface>> {
use network_interface::{NetworkInterface, NetworkInterfaceConfig as _};
+1 -22
View File
@@ -39,7 +39,6 @@ pub async fn get_profiles() -> CmdResult<SharedDraft<IProfiles>> {
Ok(data)
}
/// 增强配置文件
#[tauri::command]
pub async fn enhance_profiles() -> CmdResult<ValidationOutcome> {
match feat::enhance_profiles().await {
@@ -64,12 +63,10 @@ pub async fn enhance_profiles() -> CmdResult<ValidationOutcome> {
}
}
/// 导入配置文件
#[tauri::command]
pub async fn import_profile(url: std::string::String, option: Option<PrfOption>) -> CmdResult {
logging!(info, Type::Cmd, "[导入订阅] 开始导入: {}", help::mask_url(&url));
// 直接依赖 PrfItem::from_url 自身的超时/重试逻辑,不再使用 tokio::time::timeout 包裹
let item = &mut match PrfItem::from_url(&url, None, None, option.as_ref()).await {
Ok(it) => {
logging!(info, Type::Cmd, "[导入订阅] 下载完成,开始保存配置");
@@ -102,7 +99,6 @@ pub async fn import_profile(url: std::string::String, option: Option<PrfOption>)
Ok(())
}
/// 调整profile的顺序
#[tauri::command]
pub async fn reorder_profile(active_id: String, over_id: String) -> CmdResult {
match profiles_reorder_safe(&active_id, &over_id).await {
@@ -117,8 +113,6 @@ pub async fn reorder_profile(active_id: String, over_id: String) -> CmdResult {
}
}
/// 创建新的profile
/// 创建一个新的配置文件
#[tauri::command]
pub async fn create_profile(item: PrfItem, file_data: Option<String>) -> CmdResult {
match profiles_append_item_with_filedata_safe(&item, file_data).await {
@@ -127,7 +121,6 @@ pub async fn create_profile(item: PrfItem, file_data: Option<String>) -> CmdResu
.await
.with_error_code("PROFILE_CREATE_FAILED")?;
logging_error!(Type::Timer, Timer::global().refresh().await);
// 发送配置变更通知
if let Some(uid) = &item.uid {
logging!(info, Type::Cmd, "[创建订阅] 发送配置变更通知: {}", uid);
handle::Handle::notify_profile_changed(uid);
@@ -138,7 +131,6 @@ pub async fn create_profile(item: PrfItem, file_data: Option<String>) -> CmdResu
}
}
/// 更新配置文件
#[tauri::command]
pub async fn update_profile(index: String, option: Option<PrfOption>) -> CmdResult {
match feat::update_profile(&index, option.as_ref(), true, true, true).await {
@@ -150,7 +142,6 @@ pub async fn update_profile(index: String, option: Option<PrfOption>) -> CmdResu
}
}
/// 删除配置文件
#[tauri::command]
pub async fn delete_profile(index: String) -> CmdResult {
let profile_write_guard = PROFILE_WRITE_LOCK.lock().await;
@@ -209,7 +200,6 @@ pub async fn delete_profile(index: String) -> CmdResult {
Ok(())
}
/// 执行配置更新并处理结果
async fn restore_previous_profile(prev_profile: &String) -> CmdResult<()> {
logging!(info, Type::Cmd, "尝试恢复到之前的配置: {}", prev_profile);
let restore_profiles = IProfiles {
@@ -322,7 +312,6 @@ async fn perform_config_update(
}
}
/// 修改profiles的配置
#[tauri::command]
pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<ValidationOutcome> {
if CURRENT_SWITCHING_PROFILE
@@ -338,7 +327,6 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<ValidationO
logging!(info, Type::Cmd, "开始修改配置文件,目标profile: {:?}", target_profile);
// 保存当前配置,以便在验证失败时恢复
let previous_profile = Config::profiles().await.data_arc().current.clone();
logging!(info, Type::Cmd, "当前配置: {:?}", previous_profile);
@@ -349,7 +337,6 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<ValidationO
.map_err(|error| coded_error("PROFILE_SWITCH_FAILED", error))
}
/// 根据profile name修改profiles
pub async fn patch_profiles_config_by_profile_index(profile_index: String) -> CmdResult<ValidationOutcome> {
logging!(info, Type::Cmd, "切换配置到: {}", profile_index);
@@ -360,10 +347,8 @@ pub async fn patch_profiles_config_by_profile_index(profile_index: String) -> Cm
patch_profiles_config(profiles).await
}
/// 修改某个profile item的
#[tauri::command]
pub async fn patch_profile(index: String, profile: PrfItem) -> CmdResult {
// 保存修改前检查是否有更新 update_interval
let profiles = Config::profiles().await;
let should_refresh_timer = if let Ok(old_profile) = profiles.latest_arc().get_item(&index)
&& let Some(new_option) = profile.option.as_ref()
@@ -377,8 +362,7 @@ 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.
// Prevent an in-flight restore from overwriting a newer UI or chain selection.
let records_a_selection = profile.selected.is_some();
profiles_patch_item_safe(&index, &profile)
@@ -389,14 +373,12 @@ pub async fn patch_profile(index: String, profile: PrfItem) -> CmdResult {
profiles::supersede_selected_activation();
}
// 如果更新间隔或允许自动更新变更,异步刷新定时器
if should_refresh_timer {
crate::process::AsyncHandler::spawn(move || async move {
logging!(info, Type::Timer, "Timer update settings changed, refreshing timer...");
if let Err(e) = crate::core::Timer::global().refresh().await {
logging!(error, Type::Timer, "Failed to refresh timer: {}", e);
} else {
// 刷新成功后发送自定义事件,不触发配置重载
crate::core::handle::Handle::notify_timer_updated(&index);
}
});
@@ -405,7 +387,6 @@ pub async fn patch_profile(index: String, profile: PrfItem) -> CmdResult {
Ok(())
}
/// 查看配置文件
#[tauri::command]
pub async fn view_profile(index: String) -> CmdResult {
let profiles = Config::profiles().await;
@@ -430,7 +411,6 @@ pub async fn view_profile(index: String) -> CmdResult {
help::open_file(path).with_error_code("PROFILE_OPEN_FAILED")
}
/// 读取配置文件内容
#[tauri::command]
pub async fn read_profile_file(index: String) -> CmdResult<String> {
let item = {
@@ -466,7 +446,6 @@ pub async fn read_profile_file(index: String) -> CmdResult<String> {
Ok(data)
}
/// 获取下一次更新时间
#[tauri::command]
pub async fn get_next_update_time(uid: String) -> CmdResult<Option<i64>> {
let timer = Timer::global();
+1 -11
View File
@@ -16,15 +16,7 @@ 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.
/// Merges one selection pair into fresh backend state to avoid stale-list overwrites.
#[tauri::command]
pub async fn record_selected_node(group_name: String, node: String) -> CmdResult<()> {
crate::config::profiles::record_selected_node(&group_name, &node)
@@ -32,7 +24,6 @@ pub async fn record_selected_node(group_name: String, node: String) -> CmdResult
.stringify_err()
}
/// Forget the persisted node selection for a proxy group in the current profile.
#[tauri::command]
pub async fn forget_selected_node(group_name: String) -> CmdResult<()> {
crate::config::profiles::forget_selected_node(&group_name)
@@ -76,7 +67,6 @@ pub async fn get_proxy_view() -> CmdResult<ProxyViewV1> {
}))
}
/// 同步托盘和GUI的代理选择状态
#[tauri::command]
pub async fn sync_tray_proxy_selection() -> CmdResult<()> {
if TRAY_SYNC_RUNNING
-6
View File
@@ -6,13 +6,11 @@ use serde_yaml_ng::Mapping;
use smartstring::alias::String;
use std::collections::{HashMap, HashSet};
/// 获取运行时配置
#[tauri::command]
pub async fn get_runtime_config() -> CmdResult<Option<Mapping>> {
Ok(Config::runtime().await.latest_arc().config.clone())
}
/// 获取运行时YAML配置
#[tauri::command]
pub async fn get_runtime_yaml() -> CmdResult<String> {
let runtime = Config::runtime().await;
@@ -29,13 +27,11 @@ pub async fn get_runtime_yaml() -> CmdResult<String> {
.stringify_err()
}
/// 获取运行时存在的键
#[tauri::command]
pub async fn get_runtime_exists() -> CmdResult<HashSet<String>> {
Ok(Config::runtime().await.latest_arc().exists_keys.clone())
}
/// 获取运行时日志
#[tauri::command]
pub async fn get_runtime_logs() -> CmdResult<HashMap<String, Vec<(String, String)>>> {
Ok(Config::runtime().await.latest_arc().chain_logs.clone())
@@ -72,7 +68,6 @@ pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> Cm
.find(|proxy| proxy.get("name").map(|x| x.as_str()) == proxy_name)
&& !proxies_chain.is_empty()
{
// 添加第一个节点
proxies_chain.push(entry_proxy.to_owned());
}
@@ -91,7 +86,6 @@ pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> Cm
}
}
/// 更新运行时链式代理配置
#[tauri::command]
pub async fn update_proxy_chain_config_in_runtime(proxy_chain_config: Option<serde_yaml_ng::Value>) -> CmdResult<()> {
match CoreManager::global()
+1 -6
View File
@@ -3,17 +3,12 @@ use crate::core::{
runstate::{RUN_STATE, RunStateView},
};
/// The whole Run State in one call.
///
/// Replaces the separate running-mode and service-install-state commands: they were three
/// caches on three refresh intervals describing one fact, which the frontend then had to keep
/// coherent by hand.
/// Returns one coherent core/service snapshot instead of independently refreshed state.
#[tauri::command]
pub async fn get_runtime_state() -> Result<RunStateView, String> {
Ok(RUN_STATE.settled().await.to_view())
}
/// Return unresolved failures without consuming them.
#[tauri::command]
pub async fn get_pending_failures() -> Vec<PendingFailure> {
notification::pending_failures()
-3
View File
@@ -1,6 +1,5 @@
use crate::cmd::CmdResult;
/// Platform-specific implementation for UWP functionality
#[cfg(windows)]
mod platform {
use crate::cmd::CmdResult;
@@ -12,7 +11,6 @@ mod platform {
}
}
/// Stub implementation for non-Windows platforms
#[cfg(not(windows))]
mod platform {
use super::CmdResult;
@@ -23,7 +21,6 @@ mod platform {
}
}
/// Command exposed to Tauri
#[tauri::command]
pub async fn invoke_uwp_tool() -> CmdResult {
platform::invoke_uwp_tool()
-5
View File
@@ -7,7 +7,6 @@ use crate::{
use reqwest_dav::list_cmd::ListFile;
use smartstring::alias::String;
/// 保存 WebDAV 配置
#[tauri::command]
pub async fn save_webdav_config(url: String, username: String, password: String) -> CmdResult<()> {
let patch = IVerge {
@@ -25,25 +24,21 @@ pub async fn save_webdav_config(url: String, username: String, password: String)
Ok(())
}
/// 创建 WebDAV 备份并上传
#[tauri::command]
pub async fn create_webdav_backup() -> CmdResult<()> {
feat::create_backup_and_upload_webdav().await.stringify_err()
}
/// 列出 WebDAV 上的备份文件
#[tauri::command]
pub async fn list_webdav_backup() -> CmdResult<Vec<ListFile>> {
feat::list_wevdav_backup().await.stringify_err()
}
/// 删除 WebDAV 上的备份文件
#[tauri::command]
pub async fn delete_webdav_backup(filename: String) -> CmdResult<()> {
feat::delete_webdav_backup(filename).await.stringify_err()
}
/// 从 WebDAV 恢复备份文件
#[tauri::command]
pub async fn restore_webdav_backup(filename: String) -> CmdResult<()> {
feat::restore_webdav_backup(filename)
+3 -21
View File
@@ -68,12 +68,11 @@ impl Config {
Self::global().await.runtime_config.clone()
}
/// Serialize transactions that share configuration draft layers.
/// Serializes transactions sharing configuration draft layers.
pub(crate) async fn lock_config_write() -> MutexGuard<'static, ()> {
CONFIG_WRITE_LOCK.lock().await
}
/// 初始化订阅
pub async fn init_config() -> Result<()> {
Self::init_config_before_window().await?;
Self::init_runtime_config().await
@@ -140,14 +139,12 @@ impl Config {
{
let profiles = Self::profiles().await.data_arc();
// Logging error internally
let _ = profiles.cleanup_orphaned_files().await;
}
Ok(())
}
// Ensure "Merge" and "Script" profile items exist, adding them if missing.
async fn ensure_default_profile_items() -> Result<()> {
let profiles = Self::profiles().await;
Self::ensure_default_profile_items_for(&profiles).await
@@ -175,7 +172,6 @@ impl Config {
}
async fn generate_and_validate() -> Result<Option<(&'static str, String)>> {
// 生成运行时配置
if let Err(err) = Self::generate().await {
let error_msg: String = err.to_string().into();
logging!(error, Type::Config, "生成运行时配置失败: {}", error_msg);
@@ -186,18 +182,14 @@ impl Config {
}
logging!(info, Type::Config, "生成运行时配置成功");
// 生成运行时配置文件并验证
let config_result = Self::generate_file(ConfigType::Run).await;
if config_result.is_ok() {
// 验证配置文件
logging!(info, Type::Config, "开始验证配置");
match CoreConfigValidator::global().validate_config_outcome().await {
Ok(outcome) if outcome.is_valid() => {
logging!(info, Type::Config, "配置验证成功");
// 前端没有必要知道验证成功的消息,也没有事件驱动
// Some(("config_validate::success", String::new()))
Ok(None)
}
Ok(outcome) => {
@@ -238,7 +230,6 @@ impl Config {
let runtime = Self::runtime().await;
let runtime_lastest = runtime.latest_arc();
// Fall back to committed config if runtime config is missing
let runtime_data = runtime.data_arc();
let config = runtime_lastest
.config
@@ -259,8 +250,7 @@ impl Config {
let (mut config, exists_keys, logs) = enhance::enhance(profiles).await?;
sanitize_tunnels_proxy(&mut config);
// Applied here rather than in the Merge Config: this is the only place the Core's config
// is built, and `config.yaml` must stay unaware so the next launch asks for the original.
// Apply only to generated core config so the saved choice survives the next launch.
if let Some(port) = MixedPort::session_fallback() {
config.insert(MIXED_PORT_KEY.into(), port.into());
}
@@ -300,8 +290,7 @@ impl Config {
}
}
// 升级草稿为正式数据,并写入文件。避免用户行为丢失。
// 仅在应用退出、重启、关机监听事件启用
/// Commits drafts during exit/restart/shutdown so user changes are not lost.
pub async fn apply_all_and_save_file() {
logging!(info, Type::Config, "save all draft data");
let save_clash_task = AsyncHandler::spawn(|| async {
@@ -329,7 +318,6 @@ impl Config {
}
fn sanitize_tunnels_proxy(config: &mut Mapping) {
// 检查是否存在 tunnels
if !config
.get("tunnels")
.and_then(|v| v.as_sequence())
@@ -338,7 +326,6 @@ fn sanitize_tunnels_proxy(config: &mut Mapping) {
return;
}
// 在需要时,收集可用目标(proxies + proxy-groups + 内建)
let mut valid: HashSet<String> = HashSet::with_capacity(64);
collect_names(config, "proxies", &mut valid);
collect_names(config, "proxy-groups", &mut valid);
@@ -350,7 +337,6 @@ fn sanitize_tunnels_proxy(config: &mut Mapping) {
return;
};
// 修改 tunnels:删除无效 proxy
for item in tunnels {
let Some(tunnel) = item.as_mapping_mut() else { continue };
@@ -368,7 +354,6 @@ fn sanitize_tunnels_proxy(config: &mut Mapping) {
}
}
// tunnels 存在且至少有一条 tunnel 的 proxy 需要校验时才返回 true
fn tunnels_need_validation(tunnels: &[Value]) -> bool {
tunnels.iter().any(|item| {
item.as_mapping()
@@ -411,11 +396,8 @@ mod tests {
fn test_prfitem_from_merge_size() {
let merge_item = PrfItem::from_merge(Some("Merge".into())).expect("Failed to create merge item in test");
let prfitem_size = mem::size_of_val(&merge_item);
// Boxed version
let boxed_merge_item = Box::new(merge_item);
let box_prfitem_size = mem::size_of_val(&boxed_merge_item);
// The size of Box<T> is always pointer-sized (usually 8 bytes on 64-bit)
// assert_eq!(box_prfitem_size, mem::size_of::<Box<PrfItem>>());
assert!(box_prfitem_size < prfitem_size);
}
-10
View File
@@ -15,41 +15,33 @@ tokio::task_local! {
static ENCRYPTION_ACTIVE: Cell<bool>;
}
/// Encrypt data
pub fn encrypt_data(data: &str) -> Result<String, Box<dyn std::error::Error>> {
let encryption_key = get_encryption_key()?;
let cipher = Aes256Gcm::new_from_slice(&encryption_key)?;
// Generate random nonce
let mut nonce = [0u8; NONCE_LENGTH];
getrandom::fill(&mut nonce)?;
// Encrypt data
let ciphertext = cipher
.encrypt((&nonce).into(), data.as_bytes())
.map_err(|e| format!("Encryption failed: {e}"))?;
// Concatenate nonce and ciphertext and encode them in base64
let mut combined = nonce.to_vec();
combined.extend(ciphertext);
Ok(STANDARD.encode(combined))
}
/// Decrypt data
pub fn decrypt_data(encrypted: &str) -> Result<String, Box<dyn std::error::Error>> {
let encryption_key = get_encryption_key()?;
let cipher = Aes256Gcm::new_from_slice(&encryption_key)?;
// Decode from base64
let data = STANDARD.decode(encrypted)?;
if data.len() < NONCE_LENGTH {
return Err("Invalid encrypted data".into());
}
// Separate nonce and ciphertext
let (nonce, ciphertext) = data.split_at(NONCE_LENGTH);
let nonce: &[u8; NONCE_LENGTH] = nonce.try_into()?;
// Decrypt data
let plaintext = cipher
.decrypt(nonce.into(), ciphertext)
.map_err(|e| format!("Decryption failed: {e}"))?;
@@ -57,7 +49,6 @@ pub fn decrypt_data(encrypted: &str) -> Result<String, Box<dyn std::error::Error
String::from_utf8(plaintext).map_err(|e| e.into())
}
/// Serialize encrypted function
pub fn serialize_encrypted<T, S>(value: &T, serializer: S) -> Result<S::Ok, S::Error>
where
T: Serialize,
@@ -72,7 +63,6 @@ where
}
}
/// Deserialize decrypted function
pub fn deserialize_encrypted<'a, D, T>(deserializer: D) -> Result<T, D::Error>
where
T: for<'de> Deserialize<'de> + Default,
+6 -23
View File
@@ -1,8 +1,5 @@
//! Resolving the Mixed Port.
//!
//! Two questions, deliberately kept apart because the answers can differ:
//! [`MixedPort::desired`] — what we configured, answerable before the Core exists;
//! [`MixedPort::effective`] — what the Core reports it is serving on.
//! Resolves the configured [`MixedPort::desired`] separately from the Core's live
//! [`MixedPort::effective`] value.
use std::sync::atomic::{AtomicU16, Ordering};
@@ -14,18 +11,12 @@ use crate::core::handle::Handle;
/// The port mihomo listens on when nothing else is configured.
pub const DEFAULT_MIXED_PORT: u16 = 7897;
/// The port a startup fallback moved this session to, or 0 for none.
///
/// Never persisted: the user's port stays in `verge.yaml` and `config.yaml` so the next launch
/// asks for it again. Persisting it instead made a once-blocked port climb on every launch.
/// A startup fallback for this session only; persistence would make the port climb across launches.
static SESSION_FALLBACK: AtomicU16 = AtomicU16::new(0);
/// Resolving the Mixed Port.
pub struct MixedPort;
impl MixedPort {
/// Read by [`Self::desired`] and by runtime config generation — the two places that decide
/// what the Core is asked to listen on.
pub(crate) fn session_fallback() -> Option<u16> {
match SESSION_FALLBACK.load(Ordering::Acquire) {
0 => None,
@@ -41,12 +32,7 @@ impl MixedPort {
SESSION_FALLBACK.store(0, Ordering::Release);
}
/// The Mixed Port the app has configured. Safe before the Core exists.
///
/// Reads the draft layer, so this can name a port the Core has not moved to yet — which
/// matters because the PAC endpoint hands it outside the app. Safe only because
/// `feat::listener::save_proxy_ports` closes PAC across the staging (`core_starting` /
/// `core_start_settled`); any other path staging a listener port must do the same.
/// Returns the configured port. Paths staging it must close PAC until the Core catches up.
pub async fn desired() -> u16 {
let selected = Config::verge().await.latest_arc().verge_mixed_port;
// `get_mixed_port` already falls back to the default when the Merge Config is silent.
@@ -54,9 +40,7 @@ impl MixedPort {
resolve_desired(Self::session_fallback(), selected, merged)
}
/// The Mixed Port the Core is actually serving on.
///
/// Costs a round-trip, so use it on user-triggered paths, not per-request ones.
/// Returns the Core's live port; reserve this round-trip for user-triggered paths.
pub async fn effective() -> u16 {
let desired = Self::desired().await;
resolve_effective(
@@ -67,8 +51,7 @@ impl MixedPort {
}
}
/// Fallback first — it is the only value not written down, so the files still name the port the
/// user asked for while this session serves another.
/// The session-only fallback outranks persisted choices.
const fn resolve_desired(session_fallback: Option<u16>, selected: Option<u16>, merged: u16) -> u16 {
match (session_fallback, selected) {
(Some(port), _) | (None, Some(port)) => port,
+1 -46
View File
@@ -20,45 +20,34 @@ use tauri::Url;
pub struct PrfItem {
pub uid: Option<String>,
/// profile item type
/// enum value: remote | local | script | merge
#[serde(rename = "type")]
pub itype: Option<String>,
/// profile name
pub name: Option<String>,
/// profile file
pub file: Option<String>,
/// profile description
#[serde(skip_serializing_if = "Option::is_none")]
pub desc: Option<String>,
/// source url
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
/// selected information
#[serde(skip_serializing_if = "Option::is_none")]
pub selected: Option<Vec<PrfSelected>>,
/// subscription user info
#[serde(skip_serializing_if = "Option::is_none")]
pub extra: Option<PrfExtra>,
/// updated time
pub updated: Option<usize>,
/// some options of the item
#[serde(skip_serializing_if = "Option::is_none")]
pub option: Option<PrfOption>,
/// profile web page url
#[serde(skip_serializing_if = "Option::is_none")]
pub home: Option<String>,
/// the file data
#[serde(skip)]
pub file_data: Option<String>,
}
@@ -79,32 +68,23 @@ pub struct PrfExtra {
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct PrfOption {
/// for `remote` profile's http request
/// see issue #13
#[serde(skip_serializing_if = "Option::is_none")]
pub user_agent: Option<String>,
/// for `remote` profile
/// use system proxy
#[serde(skip_serializing_if = "Option::is_none")]
pub with_proxy: Option<bool>,
/// for `remote` profile
/// use self proxy
#[serde(skip_serializing_if = "Option::is_none")]
pub self_proxy: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub update_interval: Option<u64>,
/// for `remote` profile
/// HTTP request timeout in seconds
/// default is 60 seconds
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout_seconds: Option<u64>,
/// for `remote` profile
/// disable certificate validation
/// default is `false`
#[serde(skip_serializing_if = "Option::is_none")]
pub danger_accept_invalid_certs: Option<bool>,
@@ -151,8 +131,7 @@ impl PrfOption {
}
impl PrfItem {
/// From partial item
/// must contain `itype`
/// Builds an item from a partial value that must include `itype`.
pub async fn from(item: &Self, file_data: Option<String>) -> Result<Self> {
if item.itype.is_none() {
bail!("type should not be null");
@@ -183,8 +162,6 @@ impl PrfItem {
}
}
/// ## Local type
/// create a new item from name/desc
pub async fn from_local(
name: String,
desc: String,
@@ -250,8 +227,6 @@ impl PrfItem {
})
}
/// ## Remote type
/// create a new item from url
pub async fn from_url(
url: &str,
name: Option<&String>,
@@ -271,7 +246,6 @@ impl PrfItem {
let mut proxies = option.and_then(|o| o.proxies.clone());
let mut groups = option.and_then(|o| o.groups.clone());
// 选择代理类型
let proxy_type = if self_proxy {
ProxyType::Localhost
} else if with_proxy {
@@ -282,7 +256,6 @@ impl PrfItem {
let url = fix_dirty_url(url)?;
// 使用网络管理器发送请求
let resp = match NetworkManager::new()
.get_with_interrupt(
url.as_str(),
@@ -307,7 +280,6 @@ impl PrfItem {
let header = resp.headers();
// parse the Subscription UserInfo
let extra;
'extra: {
for (k, v) in header.iter() {
@@ -330,7 +302,6 @@ impl PrfItem {
extra = None;
}
// parse the Content-Disposition
let filename = match header.get("Content-Disposition") {
Some(value) => {
let filename = format!("{value:?}");
@@ -380,10 +351,8 @@ impl PrfItem {
.unwrap_or_else(|| filename.map(|s| s.into()).unwrap_or_else(|| "Remote File".into()));
let data = resp.text_with_charset()?;
// process the charset "UTF-8 with BOM"
let data = data.trim_start_matches('\u{feff}');
// check the data whether the valid yaml format
let yaml = serde_yaml_ng::from_str::<Mapping>(data).context("the remote profile data is invalid yaml")?;
if !yaml.contains_key("proxies") && !yaml.contains_key("proxy-providers") {
@@ -441,8 +410,6 @@ impl PrfItem {
})
}
/// ## Merge type (enhance)
/// create the enhanced item by using `merge` rule
pub fn from_merge(uid: Option<String>) -> Result<Self> {
let (id, template) = if let Some(uid) = uid {
(uid, tmpl::ITEM_MERGE.into())
@@ -461,8 +428,6 @@ impl PrfItem {
})
}
/// ## Script type (enhance)
/// create the enhanced item by using javascript quick.js
pub fn from_script(uid: Option<String>) -> Result<Self> {
let id = if let Some(uid) = uid {
uid
@@ -480,7 +445,6 @@ impl PrfItem {
})
}
/// ## Rules type (enhance)
pub fn from_rules() -> Result<Self> {
let uid = help::get_uid("r").into();
let file = format!("{uid}.yaml").into(); // yaml ext
@@ -495,7 +459,6 @@ impl PrfItem {
})
}
/// ## Proxies type (enhance)
pub fn from_proxies() -> Result<Self> {
let uid = help::get_uid("p").into();
let file = format!("{uid}.yaml").into(); // yaml ext
@@ -510,7 +473,6 @@ impl PrfItem {
})
}
/// ## Groups type (enhance)
pub fn from_groups() -> Result<Self> {
let uid = help::get_uid("g").into();
let file = format!("{uid}.yaml").into(); // yaml ext
@@ -525,7 +487,6 @@ impl PrfItem {
})
}
/// get the file data
pub async fn read_file(&self) -> Result<String> {
let file = self
.file
@@ -538,7 +499,6 @@ impl PrfItem {
Ok(content.into())
}
/// save the file data
pub async fn save_file(&self, data: String) -> Result<()> {
let file = self
.file
@@ -552,27 +512,22 @@ impl PrfItem {
}
impl PrfItem {
/// 获取current指向的订阅的merge
pub fn current_merge(&self) -> Option<&String> {
self.option.as_ref().and_then(|o| o.merge.as_ref())
}
/// 获取current指向的订阅的script
pub fn current_script(&self) -> Option<&String> {
self.option.as_ref().and_then(|o| o.script.as_ref())
}
/// 获取current指向的订阅的rules
pub fn current_rules(&self) -> Option<&String> {
self.option.as_ref().and_then(|o| o.rules.as_ref())
}
/// 获取current指向的订阅的proxies
pub fn current_proxies(&self) -> Option<&String> {
self.option.as_ref().and_then(|o| o.proxies.as_ref())
}
/// 获取current指向的订阅的groups
pub fn current_groups(&self) -> Option<&String> {
self.option.as_ref().and_then(|o| o.groups.as_ref())
}
+18 -123
View File
@@ -30,19 +30,11 @@ use tokio::{fs, task::JoinHandle};
pub(crate) static PROFILE_WRITE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
/// Regex to check profile file names, eg.
/// R12345678.yaml (remote)
/// L12345678.yaml (local)
/// m12345678.yaml (merge)
/// s12345678.js (script)
/// r12345678.yaml (rules)
/// p12345678.yaml (proxies)
/// g12345678.yaml (groups)
/// Matches generated profile files such as `R12345678.yaml` or `s12345678.js`.
#[allow(clippy::unwrap_used)]
static REGEX_PROFILE_FILE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"^(?:[RLmrpg][a-zA-Z0-9]+\.yaml|s[a-zA-Z0-9]+\.js)$").unwrap());
// activate selected nodes task handle
static ACTIVATE_SELECTED_TASK: LazyLock<Mutex<Option<JoinHandle<()>>>> = LazyLock::new(|| Mutex::new(None));
static ACTIVATE_SELECTED_GENERATION: AtomicU64 = AtomicU64::new(0);
@@ -50,29 +42,18 @@ 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.
/// Bounds retries while provider-backed groups finish loading.
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.
/// Bounds startup waiting; remaining selections continue restoring in the background.
const SELECTED_NODES_FIRST_PASS_BUDGET: Duration = Duration::from_secs(3);
/// Define the `profiles.yaml` schema
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
pub struct IProfiles {
/// same as PrfConfig.current
pub current: Option<String>,
/// profile list
pub items: Option<Vec<PrfItem>>,
}
@@ -116,7 +97,6 @@ macro_rules! patch {
}
impl IProfiles {
// Helper to find and remove an item by uid from the items vec, returning its file name (if any).
fn take_item_file_by_uid(items: &mut Vec<PrfItem>, target_uid: Option<&str>) -> Option<String> {
let index = items.iter().position(|item| item.uid.as_deref() == target_uid)?;
items.remove(index).file
@@ -178,12 +158,10 @@ impl IProfiles {
self.current.as_ref()
}
/// get items ref
pub const fn get_items(&self) -> Option<&Vec<PrfItem>> {
self.items.as_ref()
}
/// find the item by the uid
pub fn get_item(&self, uid: impl AsRef<str>) -> Result<&PrfItem> {
let uid_str = uid.as_ref();
@@ -200,17 +178,12 @@ impl IProfiles {
bail!("failed to get the profile item \"uid:{}\"", uid_str);
}
/// append new item
/// if the file_data is some
/// then should save the data to file
pub async fn append_item(&mut self, item: &mut PrfItem) -> Result<()> {
let uid = &item.uid;
if uid.is_none() {
bail!("the uid should not be null");
}
// save the file data
// move the field value after save
if let Some(file_data) = item.file_data.take() {
if item.file.is_none() {
bail!("the file should not be null");
@@ -242,7 +215,6 @@ impl IProfiles {
Ok(())
}
/// reorder items
pub async fn reorder(&mut self, active_id: &String, over_id: &String) -> Result<()> {
let mut items = self.items.take().unwrap_or_default();
let mut old_index = None;
@@ -267,7 +239,6 @@ impl IProfiles {
self.save_file().await
}
/// update the item value
pub async fn patch_item(&mut self, uid: &String, item: &PrfItem) -> Result<()> {
if let Some(file) = &item.file {
Self::validate_profile_file(file)?;
@@ -312,14 +283,12 @@ impl IProfiles {
Ok(())
}
/// be used to update the remote item
/// only patch `updated` `extra` `file_data`
/// Updates fields returned by a remote profile refresh.
pub async fn update_item(&mut self, uid: &String, item: &mut PrfItem) -> Result<()> {
if self.items.is_none() {
self.items = Some(vec![]);
}
// find the item
let _ = self.get_item(uid)?;
if let Some(items) = self.items.as_mut() {
@@ -331,14 +300,11 @@ impl IProfiles {
each.updated = item.updated;
each.home = item.home.to_owned();
each.option = PrfOption::merge(each.option.as_ref(), item.option.as_ref());
// save the file data
// move the field value after save
if let Some(file_data) = item.file_data.take() {
let file = each.file.take();
let file =
file.unwrap_or_else(|| item.file.take().unwrap_or_else(|| format!("{}.yaml", uid).into()));
// the file must exists
each.file = Some(file.clone());
let path = dirs::app_profiles_dir()?.join(file.as_str());
@@ -434,12 +400,10 @@ impl IProfiles {
}
}
/// 判断profile是否是current指向的
pub fn is_current_profile_index(&self, index: &String) -> bool {
self.current.as_ref() == Some(index)
}
/// 获取所有的profiles(uid,名称, 是否为 current)
pub fn profiles_preview(&self) -> Option<Vec<IProfilePreview<'_>>> {
self.items.as_ref().map(|items| {
items
@@ -457,7 +421,6 @@ impl IProfiles {
})
}
/// 通过 uid 获取名称
pub fn get_name_by_uid(&self, uid: &String) -> Option<&String> {
if let Some(items) = &self.items {
for item in items {
@@ -480,10 +443,8 @@ impl IProfiles {
return Ok(());
}
// 如果 items 为空(例如 profiles.yaml 解析失败导致
// IProfiles::new() 返回 default),此时无法判断哪些文件是活跃的,
// 跳过清理以避免误删用户正在使用的订阅配置文件。
// See: https://github.com/clash-verge-rev/clash-verge-rev/issues/7577
// An empty list may mean loading failed, so it is not safe evidence that every file is orphaned.
// https://github.com/clash-verge-rev/clash-verge-rev/issues/7577
if self.items.as_ref().is_none_or(|v| v.is_empty()) {
logging!(
warn,
@@ -493,13 +454,10 @@ impl IProfiles {
return Ok(());
}
// 获取所有 active profile 的文件名集合
let active_files = self.get_all_active_files();
// 添加全局扩展配置文件到保护列表
let protected_files = self.get_protected_global_files();
// 扫描 profiles 目录下的所有文件
let mut total_files = 0;
let mut deleted_files = 0;
let mut failed_deletions = 0;
@@ -517,13 +475,11 @@ impl IProfiles {
if let Some(file_name) = path.file_name().and_then(|n| n.to_str())
&& Self::is_profile_file(file_name)
{
// 检查是否为全局扩展文件
if protected_files.contains(file_name) {
logging!(debug, Type::Config, "保护全局扩展配置文件: {file_name}");
continue;
}
// 检查是否为活跃文件
if !active_files.contains(file_name) {
match path.to_path_buf().remove_if_exists().await {
Ok(_) => {
@@ -557,7 +513,6 @@ impl IProfiles {
Ok(())
}
/// 不删除全局扩展配置
fn get_protected_global_files(&self) -> HashSet<String> {
let mut protected_files = HashSet::new();
@@ -567,23 +522,19 @@ impl IProfiles {
protected_files
}
/// 获取所有 active profile 关联的文件名
fn get_all_active_files(&self) -> HashSet<&str> {
let mut active_files: HashSet<&str> = HashSet::new();
if let Some(items) = &self.items {
for item in items {
// 收集所有类型 profile 的文件
if let Some(file) = &item.file {
active_files.insert(file);
}
// 对于主 profile 类型(remote/local),还需要收集其关联的扩展文件
if let Some(itype) = &item.itype
&& (itype == "remote" || itype == "local")
&& let Some(option) = &item.option
{
// 收集关联的扩展文件
if let Some(merge_uid) = &option.merge
&& let Ok(merge_item) = self.get_item(merge_uid)
&& let Some(file) = &merge_item.file
@@ -625,13 +576,12 @@ impl IProfiles {
active_files
}
/// 检查文件名是否符合 profile 文件的命名规则
fn is_profile_file(filename: &str) -> bool {
REGEX_PROFILE_FILE.is_match(filename)
}
}
// 特殊的Send-safe helper函数,完全避免跨await持有guard
// These helpers avoid holding a parking_lot guard across `await`.
use crate::config::Config;
pub async fn profiles_append_item_with_filedata_safe(item: &PrfItem, file_data: Option<String>) -> Result<()> {
@@ -814,16 +764,7 @@ 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 a newer selection is written, or before a candidate Core transition whose caller
/// will reactivate whichever Profiles snapshot ultimately commits.
/// Cancels restoration so it cannot overwrite a newer selection or profile snapshot.
pub fn supersede_selected_activation() {
ACTIVATE_SELECTED_GENERATION.fetch_add(1, Ordering::AcqRel);
}
@@ -924,12 +865,7 @@ 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.
/// Records a backend-made selection so the next core start restores it.
pub async fn record_selected_node(group_name: &str, node: &str) -> Result<()> {
let group_name = String::from(group_name);
let node = String::from(node);
@@ -970,7 +906,6 @@ pub async fn record_selected_node(group_name: &str, node: &str) -> Result<()> {
.await?;
if recorded {
// Newer than anything an activation still in flight captured.
supersede_selected_activation();
handle::Handle::refresh_profiles();
}
@@ -1056,12 +991,7 @@ 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.
/// Returns recorded selections whose groups have not reached the requested node.
fn unsettled_selections(selected: &[PrfSelected], proxies: &Proxies) -> Vec<String> {
selected
.iter()
@@ -1077,16 +1007,7 @@ fn unsettled_selections(selected: &[PrfSelected], proxies: &Proxies) -> Vec<Stri
.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.
/// Retries selections while provider-backed groups finish loading.
async fn settle_pending_selections(selected: &[PrfSelected], completed: &mut HashMap<String, String>, generation: u64) {
let deadline = Instant::now() + SELECTED_NODES_SETTLE_DEADLINE;
loop {
@@ -1133,11 +1054,7 @@ async fn settle_pending_selections(selected: &[PrfSelected], completed: &mut Has
}
}
/// 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.
/// Releases the first-pass waiter even when restoration exits early.
struct FirstPassSignal(Option<tokio::sync::oneshot::Sender<()>>);
impl FirstPassSignal {
@@ -1212,8 +1129,7 @@ async fn activate_selected_nodes_worker(
}
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.
// Remaining selections depend on provider loading and continue in the background.
first_pass_done.notify();
settle_pending_selections(&selected, &mut completed_activations, generation).await;
return Ok(());
@@ -1235,14 +1151,9 @@ async fn activate_selected_nodes_worker(
/// 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 records after a fully loaded 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.
/// Preserve records during startup while provider-backed groups may still be absent.
KeepRecords,
}
@@ -1253,18 +1164,7 @@ pub fn activate_selected_nodes() -> Result<()> {
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.
/// Restores available selections before the system proxy points at the core, then retries the rest.
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)
@@ -1287,8 +1187,7 @@ fn activate_selected_nodes_with(repair: SelectionRepair) -> tokio::sync::oneshot
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.
// Dropping this releases the startup waiter on every early exit.
let first_pass = FirstPassSignal(Some(first_pass_sender));
if let Some(previous_task) = previous_task {
let _ = previous_task.await;
@@ -1298,11 +1197,7 @@ fn activate_selected_nodes_with(repair: SelectionRepair) -> tokio::sync::oneshot
}
let result = async {
// 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.
// A draft may be a profile switch that has not passed validation yet.
let profiles = Config::profiles().await.data_arc();
let current = profiles.get_current().context("no current profile running")?.clone();
let selected = profiles
+2 -51
View File
@@ -9,8 +9,7 @@ const PATCH_CONFIG_INNER: [&str; 5] = ["allow-lan", "ipv6", "log-level", "unifie
#[derive(Default, Clone)]
pub struct IRuntime {
pub config: Option<Mapping>,
// 记录在订阅中(包括merge和script生成的)出现过的keys
// 这些keys不一定都生效
// Keys seen in the profile pipeline, including merge and script output.
pub exists_keys: HashSet<String>,
// TODO 或许可以用 FixMap 来存储以提升效率
pub chain_logs: HashMap<String, Vec<(String, String)>>,
@@ -22,7 +21,6 @@ impl IRuntime {
Self::default()
}
// 这里只更改 allow-lan | ipv6 | log-level | tun | tunnels
#[inline]
pub fn patch_config(&mut self, patch: &Mapping) {
let config = if let Some(config) = self.config.as_mut() {
@@ -56,54 +54,7 @@ impl IRuntime {
}
}
/// 更新链式代理配置
///
/// 该函数更新 `proxies` 和 `proxy-groups` 配置,并处理链式代理的修改或(传入 None )删除。
///
/// 配置示例:
///
/// ```json
/// {
/// "proxies": [
/// {
/// "name": "入口节点",
/// "type": "xxx",
/// "server": "xxx",
/// "port": "xxx",
/// "ports": "xxx",
/// "password": "xxx",
/// "skip-cert-verify": "xxx"
/// },
/// {
/// "name": "hop_node_1_xxxx",
/// "type": "xxx",
/// "server": "xxx",
/// "port": "xxx",
/// "ports": "xxx",
/// "password": "xxx",
/// "skip-cert-verify": "xxx",
/// "dialer-proxy": "入口节点"
/// },
/// {
/// "name": "出口节点",
/// "type": "xxx",
/// "server": "xxx",
/// "port": "xxx",
/// "ports": "xxx",
/// "password": "xxx",
/// "skip-cert-verify": "xxx",
/// "dialer-proxy": "hop_node_1_xxxx"
/// }
/// ],
/// "proxy-groups": [
/// {
/// "name": "proxy_chain",
/// "type": "select",
/// "proxies": ["出口节点"]
/// }
/// ]
/// }
/// ```
/// Rebuilds `dialer-proxy` links from an ordered proxy chain, or removes them for `None`.
#[inline]
pub fn update_proxy_chain_config(&mut self, proxy_chain_config: Option<Value>) {
let config = if let Some(config) = self.config.as_mut() {
+1 -69
View File
@@ -12,175 +12,127 @@ use smartstring::alias::String;
/// ### `verge.yaml` schema
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
pub struct IVerge {
/// app log level
/// silent | error | warn | info | debug | trace
pub app_log_level: Option<String>,
/// app log max size in KB
/// Maximum app log size in KiB.
pub app_log_max_size: Option<u64>,
/// app log max count
pub app_log_max_count: Option<usize>,
// i18n
pub language: Option<String>,
/// `light` or `dark` or `system`
pub theme_mode: Option<String>,
/// tray click event
pub tray_event: Option<String>,
/// copy env type
pub env_type: Option<String>,
/// start page
pub start_page: Option<String>,
/// startup script path
pub startup_script: Option<String>,
/// enable traffic graph default is true
pub traffic_graph: Option<bool>,
/// show memory info (only for Clash Meta)
pub enable_memory_usage: Option<bool>,
/// enable group icon
#[serde(skip_serializing_if = "Option::is_none")]
pub enable_group_icon: Option<bool>,
/// pause render traffic stats on blur
pub pause_render_traffic_stats_on_blur: Option<bool>,
/// common tray icon
#[serde(skip_serializing_if = "Option::is_none")]
pub common_tray_icon: Option<bool>,
/// tray icon
#[cfg(target_os = "macos")]
#[serde(skip_serializing_if = "Option::is_none")]
pub tray_icon: Option<String>,
/// menu icon
#[serde(skip_serializing_if = "Option::is_none")]
pub menu_icon: Option<String>,
/// menu order
#[serde(skip_serializing_if = "Option::is_none")]
pub menu_order: Option<Vec<String>>,
/// toast / notice position on screen
#[serde(skip_serializing_if = "Option::is_none")]
pub notice_position: Option<String>,
/// collapse navigation bar
pub collapse_navbar: Option<bool>,
/// sysproxy tray icon
pub sysproxy_tray_icon: Option<bool>,
/// tun tray icon
pub tun_tray_icon: Option<bool>,
/// clash tun mode
pub enable_tun_mode: Option<bool>,
/// can the app auto startup
pub enable_auto_launch: Option<bool>,
/// not show the window on launch
pub enable_silent_start: Option<bool>,
/// set system proxy
pub enable_system_proxy: Option<bool>,
/// enable proxy guard
pub enable_proxy_guard: Option<bool>,
/// enable bypass format check
pub enable_bypass_check: Option<bool>,
/// enable dns settings - this controls whether dns_config.yaml is applied
pub enable_dns_settings: Option<bool>,
/// always use default bypass
pub use_default_bypass: Option<bool>,
/// set system proxy bypass
pub system_proxy_bypass: Option<String>,
/// proxy guard duration
pub proxy_guard_duration: Option<u64>,
/// use pac mode
pub proxy_auto_config: Option<bool>,
/// pac script content
pub pac_file_content: Option<String>,
/// proxy host address
pub proxy_host: Option<String>,
/// theme setting
pub theme_setting: Option<IVergeTheme>,
/// web ui list
pub web_ui_list: Option<Vec<String>>,
/// clash core path
#[serde(skip_serializing_if = "Option::is_none")]
pub clash_core: Option<String>,
/// hotkey map
/// format: {func},{key}
#[serde(skip_serializing_if = "Option::is_none")]
pub hotkeys: Option<Vec<String>>,
/// enable global hotkey
pub enable_global_hotkey: Option<bool>,
/// 首页卡片设置
/// 控制首页各个卡片的显示和隐藏
pub home_cards: Option<serde_json::Value>,
/// 切换代理时自动关闭连接
pub auto_close_connection: Option<bool>,
/// 是否自动检查更新
pub auto_check_update: Option<bool>,
/// 默认的延迟测试连接
pub default_latency_test: Option<String>,
/// 默认的延迟测试超时时间
pub default_latency_timeout: Option<i16>,
/// 是否自动检测当前节点延迟
pub enable_auto_delay_detection: Option<bool>,
/// 自动检测当前节点延迟的间隔(分钟)
pub auto_delay_detection_interval_minutes: Option<u64>,
/// 是否使用内部的脚本支持,默认为真
pub enable_builtin_enhanced: Option<bool>,
/// proxy 页面布局 列数
pub proxy_layout_column: Option<u8>,
/// 测试站列表
pub test_list: Option<Vec<IVergeTestItem>>,
/// 日志清理
/// 0: 不清理; 1: 1天;2: 7天; 3: 30天; 4: 90天
pub auto_log_clean: Option<i32>,
/// Enable scheduled automatic backups
pub enable_auto_backup_schedule: Option<bool>,
/// Automatic backup interval in hours
pub auto_backup_interval_hours: Option<u64>,
/// Create backups automatically when critical configs change
pub auto_backup_on_change: Option<bool>,
/// verge 的各种 port 用于覆盖 clash 的各种 port
@@ -206,7 +158,6 @@ pub struct IVerge {
pub verge_http_enabled: Option<bool>,
/// WebDAV 配置 (加密存储)
#[serde(
serialize_with = "serialize_encrypted",
deserialize_with = "deserialize_encrypted",
@@ -215,7 +166,6 @@ pub struct IVerge {
)]
pub webdav_url: Option<String>,
/// WebDAV 用户名 (加密存储)
#[serde(
serialize_with = "serialize_encrypted",
deserialize_with = "deserialize_encrypted",
@@ -224,7 +174,6 @@ pub struct IVerge {
)]
pub webdav_username: Option<String>,
/// WebDAV 密码 (加密存储)
#[serde(
serialize_with = "serialize_encrypted",
deserialize_with = "deserialize_encrypted",
@@ -236,26 +185,20 @@ pub struct IVerge {
#[cfg(target_os = "macos")]
pub enable_tray_speed: Option<bool>,
// pub enable_tray_icon: Option<bool>,
/// show proxy groups directly on tray root menu
#[serde(skip_serializing_if = "Option::is_none")]
pub tray_proxy_groups_display_mode: Option<String>,
/// show outbound modes directly on tray root menu
pub tray_inline_outbound_modes: Option<bool>,
/// 自动进入轻量模式
pub enable_auto_light_weight_mode: Option<bool>,
/// 自动进入轻量模式的延迟(分钟)
pub auto_light_weight_minutes: Option<u64>,
/// 启用代理页面自动滚动
pub enable_hover_jump_navigator: Option<bool>,
/// 代理页面自动滚动延迟(毫秒)
pub hover_jump_navigator_delay: Option<u64>,
/// 启用外部控制器
pub enable_external_controller: Option<bool>,
}
@@ -284,10 +227,8 @@ pub struct IVergeTheme {
}
impl IVerge {
/// 有效的clash核心名称
pub const VALID_CLASH_CORES: &'static [&'static str] = &["verge-mihomo", "verge-mihomo-alpha"];
/// 验证并修正配置文件中的clash_core值
pub async fn validate_and_fix_config() -> Result<()> {
let config_path = dirs::verge_path()?;
let mut config = match help::read_yaml::<Self>(&config_path).await {
@@ -319,7 +260,6 @@ impl IVerge {
needs_fix = true;
}
// 修正后保存配置
if needs_fix {
logging!(info, Type::Config, "正在保存修正后的配置文件...");
help::save_yaml(&config_path, &config, Some("# Clash Verge Config")).await?;
@@ -333,7 +273,6 @@ impl IVerge {
Ok(())
}
/// 配置修正后重新加载配置
async fn reload_config_after_fix(updated_config: Self) -> Result<()> {
logging!(
info,
@@ -359,7 +298,6 @@ impl IVerge {
match dirs::verge_path() {
Ok(path) => match help::read_yaml::<Self>(&path).await {
Ok(mut config) => {
// compatibility
if let Some(start_page) = config.start_page.clone()
&& start_page == "/home"
{
@@ -440,7 +378,6 @@ impl IVerge {
webdav_password: None,
#[cfg(target_os = "macos")]
enable_tray_speed: Some(false),
// enable_tray_icon: Some(true),
tray_proxy_groups_display_mode: Some("default".into()),
tray_inline_outbound_modes: Some(false),
enable_global_hotkey: Some(true),
@@ -453,13 +390,10 @@ impl IVerge {
}
}
/// Save IVerge App Config
pub async fn save_file(&self) -> Result<()> {
help::save_yaml(&dirs::verge_path()?, &self, Some("# Clash Verge Config")).await
}
/// patch verge config
/// only save to file
#[allow(clippy::cognitive_complexity)]
pub fn patch_config(&mut self, patch: &Self) {
macro_rules! patch {
@@ -546,7 +480,6 @@ impl IVerge {
patch!(webdav_password);
#[cfg(target_os = "macos")]
patch!(enable_tray_speed);
// patch!(enable_tray_icon);
patch!(tray_proxy_groups_display_mode);
patch!(tray_inline_outbound_modes);
patch!(enable_auto_light_weight_mode);
@@ -556,7 +489,6 @@ impl IVerge {
patch!(enable_external_controller);
}
/// 获取日志等级
pub fn get_log_level(&self) -> LevelFilter {
if let Some(level) = self.app_log_level.as_ref() {
match level.to_lowercase().as_str() {
+20 -94
View File
@@ -22,36 +22,23 @@ use std::{
};
use tauri_plugin_mihomo::Error as MihomoError;
/// What came back from asking the Service to stage a runtime.
///
/// Separated from the decision below so the decision stays a pure function of it. The variants are
/// the four ways an attempt can end, and what distinguishes them is how much they let us conclude:
/// a refusal is the Service's verdict on this bundle, while silence says nothing at all.
/// A staging result separated from the policy that acts on it.
#[derive(Debug, Clone, PartialEq, Eq)]
enum StageAttempt {
/// The Service that owns the running Core predates staging.
Unsupported,
/// The Service answered, and its answer was a refusal about the bundle itself — the kind a
/// fresh start would run into again, because it would materialise the same bundle.
/// Restarting would present the same rejected bundle.
RefusedTheBundle(String),
/// The Service answered with a refusal about something other than the bundle: the session, the
/// protocol, the request. Starting is how several of those get resolved, so it is not a reason
/// to leave the Core where it is.
/// A new service session may resolve this refusal.
RefusedForAnotherReason(String),
/// The request did not come back. The Service is authoritative about its own runtime, so
/// without an answer nothing may be assumed about what it did or did not write.
/// The service may have staged the bundle without delivering its reply.
Unanswered(String),
Answered(StageRuntimeOutcome),
}
/// How the Core should be made to pick up a configuration.
#[derive(Debug, PartialEq, Eq)]
enum ConfigApplication {
/// Point the running Core at this path.
ReloadFrom(String),
/// Stop the Core and start it again from a freshly materialised runtime.
ReplaceCore,
/// Leave the Core alone and report why the configuration cannot be applied.
Fail(String),
}
@@ -63,21 +50,8 @@ impl Drop for ConfigUpdateGuard<'_> {
}
}
/// Decide how to apply a configuration, given how staging went.
///
/// Two branches avoid replacing the Core, for opposite reasons. One is success. The other is a
/// refusal *about the bundle*: the Service inspected it and would not take it, and a fresh start
/// materialises the same bundle — so it would fail the same way, having stopped a Core that was
/// working.
///
/// The distinction matters more than it looks. A refusal about the session is not a refusal about
/// the bundle: starting proposes a new session rather than presenting the old one, and the restart
/// path is also what notices that ownership was lost and clears the system proxy. Treating every
/// non-zero code alike would skip that.
///
/// Everything else — an older Service, a request that never came back, or a Service that asked to
/// be restarted — replaces the Core, which is what this did before staging existed and what the
/// Service guarantees is still safe after every way of declining.
/// Avoids replacing a working core only when the service staged successfully or rejected the
/// bundle itself; session-level failures may be repaired by replacement.
fn plan_config_application(attempt: &StageAttempt) -> ConfigApplication {
match attempt {
StageAttempt::Answered(StageRuntimeOutcome::Staged { config_path }) => {
@@ -112,7 +86,6 @@ fn plan_config_application(attempt: &StageAttempt) -> ConfigApplication {
}
}
/// One round trip to the Service, classified but not judged.
async fn ask_to_stage(path: &std::path::Path) -> StageAttempt {
match crate::core::service::stage_runtime_by_service(path).await {
Ok(StageRequest::Answered(outcome)) => StageAttempt::Answered(outcome),
@@ -128,21 +101,8 @@ async fn ask_to_stage(path: &std::path::Path) -> StageAttempt {
}
}
/// Ask once, and ask again only when the first attempt came back silent.
///
/// Silence is the one answer that has to be resolved rather than acted on. The Service commits the
/// generation before it replies, so a reply that never arrives can leave `config.yaml` already
/// replaced while this side concludes nothing happened — and the Service restarts a Core that dies
/// later from exactly that file. Treating silence as "nothing happened" is therefore a guess, and
/// the expensive half of it is silent: the Core keeps running the previous configuration until
/// something restarts it, and then it does not.
///
/// Asking again is cheap precisely where it matters. Staging plans against the manifest it wrote,
/// so a bundle that already landed copies nothing and answers in milliseconds; and because the
/// Service holds its lifecycle lock for the whole operation, a second ask that overtakes a first
/// one still in flight waits for it rather than interleaving with it. `confirm_within` is what
/// keeps that wait from being the caller's problem — past it, the answer has stopped being worth
/// more than the fallback, which is where a second silence goes anyway.
/// Confirms one silent request because the service may commit before its reply is lost.
/// Re-staging an already committed bundle is idempotent and bounded by `confirm_within`.
async fn stage_with_confirmation<Ask, Fut>(confirm_within: Duration, ask: Ask) -> StageAttempt
where
Ask: Fn() -> Fut,
@@ -326,10 +286,7 @@ impl CoreManager {
self.validate_and_apply(transaction).await
}
/// Validate the staged Runtime Config and hand it to the Core, committing only if both work.
///
/// Takes the transaction rather than opening one, so it covers the staging its callers did.
/// Every way out of here other than the last line rolls that staging back.
/// Validates and applies the caller's transaction, committing only on success.
async fn validate_and_apply(&self, transaction: DraftTransaction<'_>) -> Result<ValidationOutcome> {
let outcome = CoreConfigValidator::global().validate_config_outcome().await?;
if !outcome.is_valid() {
@@ -342,15 +299,7 @@ impl CoreManager {
Ok(ValidationOutcome::Valid)
}
/// Hand the generated configuration to the Core.
///
/// Says nothing about drafts: whether the staged Runtime Config is kept follows from
/// whether this succeeded, and the caller's transaction decides that.
///
/// Both modes have the same shape — put the configuration where the Core can read it, ask the
/// Core to reload, restart it if that did not work. Only the first step differs: in Sidecar
/// mode the Core already reads the app's own directory, while in Service mode the Service has
/// to materialise the configuration into the directory it started the Core in.
/// Applies a generated configuration through the active core owner.
async fn apply_config(&self, path: PathBuf) -> Result<()> {
if matches!(*self.get_running_mode(), RunningMode::Service) {
let _lifecycle = self.lifecycle_lock.lock().await;
@@ -364,18 +313,12 @@ impl CoreManager {
self.reload_or_restart(path).await
}
/// Apply a configuration in Service mode, staging it in place when that is possible.
///
/// Every way of not staging leads to the same place it always led: stop the Core and start it
/// again from a freshly materialised runtime. That is what makes declining safe — the Service
/// leaves the runtime it could not stage exactly as the running Core left it.
///
/// Applies through the service, replacing the core when in-place staging is unavailable.
/// Caller must hold `lifecycle_lock`.
async fn apply_config_by_service(&self, path: &std::path::Path) -> Result<()> {
match plan_config_application(&self.attempt_staging(path).await) {
ConfigApplication::Fail(message) => {
// The Service looked at this bundle and would not take it. Replacing the Core would
// hand it the same bundle, so the outage would buy nothing.
// Replacement would hand the service the same rejected bundle.
logging!(
warn,
Type::Core,
@@ -402,7 +345,6 @@ impl CoreManager {
Ok(())
}
/// Ask the Service to stage the runtime, reporting the attempt rather than judging it.
async fn attempt_staging(&self, path: &std::path::Path) -> StageAttempt {
if !crate::core::service::active_service_supports_runtime_staging() {
return StageAttempt::Unsupported;
@@ -466,12 +408,7 @@ 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.
/// Exhaustively maps every service rejection into the policy under test.
fn declined(reason: StageRejection) -> StageAttempt {
match &reason {
StageRejection::CoreNotRunning
@@ -490,9 +427,7 @@ 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.
// A watchdog restart mid-staging can leave a generation that no manifest describes.
StageRejection::CoreRestarted,
] {
assert_eq!(
@@ -514,9 +449,7 @@ mod tests {
#[test]
fn a_refusal_about_the_bundle_leaves_the_core_alone() {
// Replacing the core would materialise the same bundle the Service just rejected, so the
// outage buys nothing: the configuration update fails either way, and this way the user's
// proxy is still working when it does.
// Keep the working core when replacement would present the same rejected bundle.
assert_eq!(
plan_config_application(&StageAttempt::RefusedTheBundle("runtime asset is unavailable".into())),
ConfigApplication::Fail("runtime asset is unavailable".into())
@@ -525,9 +458,7 @@ mod tests {
#[test]
fn a_refusal_about_anything_else_still_replaces_the_core() {
// A stale session is the case that matters: starting proposes a new one, so it is the cure
// rather than a repeat of the failure — and the restart path is also what notices ownership
// was lost and clears the system proxy.
// A new session can repair stale ownership and clear the inherited system proxy.
assert_eq!(
plan_config_application(&StageAttempt::RefusedForAnotherReason("owner session is stale".into())),
ConfigApplication::ReplaceCore
@@ -559,8 +490,7 @@ mod tests {
#[test]
fn a_request_that_never_came_back_makes_the_core_be_replaced() {
// Not knowing what the service did is the one case where reloading would be a guess: the
// staged path might not exist, or might hold the previous configuration.
// Silence gives no trustworthy path to reload.
assert_eq!(
plan_config_application(&StageAttempt::Unanswered("connection reset".into())),
ConfigApplication::ReplaceCore
@@ -569,8 +499,7 @@ mod tests {
#[tokio::test]
async fn an_answer_is_taken_at_its_word_and_not_asked_for_twice() {
// Staging is only idempotent in what it writes, not in what it costs: a second ask after a
// perfectly good answer would re-send the whole bundle for nothing.
// Do not pay for a confirmation after a conclusive answer.
for answer in [
staged(),
StageAttempt::Answered(StageRuntimeOutcome::RestartRequired {
@@ -593,9 +522,7 @@ mod tests {
#[tokio::test]
async fn silence_is_resolved_by_asking_again_rather_than_assumed() {
// The case this exists for: the service committed the generation and the reply was lost.
// Asking again reaches a service that has already done the work, so the second answer is
// the truth the first one failed to deliver — and it keeps the core off the replace path.
// Confirm a committed generation whose first reply was lost.
let asks = Cell::new(0);
let outcome = stage_with_confirmation(CONFIRM_WITHIN, || {
asks.set(asks.get() + 1);
@@ -639,8 +566,7 @@ mod tests {
#[tokio::test(start_paused = true)]
async fn a_second_ask_that_keeps_working_is_not_waited_out() {
// A service still copying assets is why the deadline exists: the answer would be worth
// having, but not at the cost of leaving the user's configuration unapplied indefinitely.
// Do not wait indefinitely for a service still copying assets.
let asks = Cell::new(0);
let started = tokio::time::Instant::now();
let outcome = stage_with_confirmation(CONFIRM_WITHIN, || async {
+2 -33
View File
@@ -24,15 +24,12 @@ enum StartupDecision {
Wait,
}
/// Proxy action captured before a controlled stop.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ProxyStopIntent {
Clear,
/// Let the service overwrite the inherited proxy state.
HandOverToService,
}
/// Select whether a controlled stop clears or hands over proxy state.
const fn proxy_stop_intent(is_macos: bool, running_mode: RunningMode, decision: StartupDecision) -> ProxyStopIntent {
match (is_macos, running_mode, decision) {
(true, RunningMode::Sidecar, StartupDecision::Service) => ProxyStopIntent::HandOverToService,
@@ -280,14 +277,10 @@ where
start_sidecar().await
}
/// sidecar→service 交接结果
#[cfg(target_os = "windows")]
enum HandoffOutcome {
/// 服务尚未就绪
NotReady,
/// 已完成或无需交接
Done,
/// 交接失败并已回退
Failed,
}
@@ -408,7 +401,6 @@ impl CoreManager {
run_sidecar_termination_transition(proxy_control::clear, || self.stop_core_by_sidecar_unprepared()).await
}
/// Replaces a Service-owned core while the caller holds `lifecycle_lock`.
pub(super) async fn replace_service_core_with_config(&self, config_file: &Path) -> Result<()> {
run_service_config_replacement_transition(
cfg!(target_os = "macos"),
@@ -457,14 +449,11 @@ impl CoreManager {
Ok(())
}
/// 调用者须已持有 `lifecycle_lock`。
async fn start_core_inner(&self) -> Result<()> {
// 退出中不再启动新内核。
if Handle::global().is_exiting() {
return Ok(());
}
// 已有内核运行时保持幂等,重启请走 restart_core。
if !matches!(*self.get_running_mode(), RunningMode::NotRunning) {
logging!(
info,
@@ -479,7 +468,6 @@ impl CoreManager {
self.rollback_failed_start().await;
return Ok(());
}
// 等待服务期间可能进入退出;未真正启动时回滚状态。
if Handle::global().is_exiting() {
self.core_stopped();
return Ok(());
@@ -491,15 +479,12 @@ impl CoreManager {
StartupDecision::Wait => Ok(()),
};
// No startup failure may leave a locally reported running mode. PAC is
// still fail-closed, and any service owner monitor will exit after this
// transition instead of treating an unconfirmed core as running.
// A failed start must not leave a locally reported running mode.
if result.is_err() {
self.rollback_failed_start().await;
return result;
}
// 回退 sidecar 后,后台等待服务就绪再交接
#[cfg(target_os = "windows")]
if matches!(*self.get_running_mode(), RunningMode::Sidecar) {
self.spawn_service_handoff_watcher().await;
@@ -513,12 +498,10 @@ impl CoreManager {
self.controlled_stop_core_inner().await
}
/// 调用者须已持有 `lifecycle_lock`。
pub(crate) async fn controlled_stop_core_inner(&self) -> Result<()> {
self.controlled_stop_core_with_intent(ProxyStopIntent::Clear).await
}
/// 调用者须已持有 `lifecycle_lock`。
async fn controlled_stop_core_with_intent(&self, proxy_intent: ProxyStopIntent) -> Result<()> {
let running_mode = *self.get_running_mode();
run_controlled_stop_transition(
@@ -532,7 +515,6 @@ impl CoreManager {
.await
}
/// Capture handoff intent before stopping; startup state may change afterward.
async fn proxy_stop_intent(&self) -> ProxyStopIntent {
let running_mode = *self.get_running_mode();
if !cfg!(target_os = "macos") || !matches!(running_mode, RunningMode::Sidecar) {
@@ -541,7 +523,6 @@ impl CoreManager {
proxy_stop_intent(cfg!(target_os = "macos"), running_mode, self.prepare_startup().await)
}
/// 调用者须已持有 `lifecycle_lock`,且已完成受控代理清理。
async fn stop_core_unprepared_inner(&self) -> Result<()> {
CLASH_LOGGER.clear_logs().await;
match *self.get_running_mode() {
@@ -565,7 +546,6 @@ impl CoreManager {
}
pub(crate) async fn restart_core_during_config_update(&self) -> Result<()> {
// 持锁覆盖 stop+start,避免生命周期操作插入。
let _life = self.lifecycle_lock.lock().await;
logging!(info, Type::Core, "Restarting core");
let proxy_intent = self.proxy_stop_intent().await;
@@ -583,7 +563,6 @@ impl CoreManager {
.await
}
/// Switch the core binary and reload configuration.
pub async fn change_core(&self, clash_core: &String) -> Result<()> {
if !IVerge::VALID_CLASH_CORES.contains(&clash_core.as_str()) {
anyhow::bail!("invalid clash core: {clash_core}");
@@ -644,7 +623,6 @@ impl CoreManager {
let _ = tokio::time::timeout(timing::SERVICE_WAIT_MAX, wait).await;
}
/// 在窗口内等待服务就绪,再从 sidecar 交接到 service
#[cfg(target_os = "windows")]
async fn spawn_service_handoff_watcher(&self) {
use crate::constants::timing;
@@ -652,14 +630,12 @@ impl CoreManager {
use std::sync::atomic::Ordering;
use std::time::Instant;
// 仅 TUN 模式需要服务交接
let needs_service = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false)
&& !Config::tun_suppressed_for_session();
if !needs_service {
return;
}
// 单实例,避免并发交接
if self.handoff_watcher_running.swap(true, Ordering::AcqRel) {
return;
}
@@ -684,14 +660,11 @@ impl CoreManager {
}
tokio::time::sleep(timing::SERVICE_HANDOFF_INTERVAL).await;
// 模式已变更时退出
if !matches!(*manager.get_running_mode(), RunningMode::Sidecar) {
break;
}
match manager.try_handoff_sidecar_to_service().await {
// 已交接或无需交接
HandoffOutcome::Done => break,
// 已回退 sidecar,停止重试
HandoffOutcome::Failed => {
logging!(warn, Type::Core, "handoff attempt failed; staying in sidecar mode");
break;
@@ -705,21 +678,18 @@ impl CoreManager {
#[cfg(target_os = "windows")]
async fn refresh_service_readiness_for_handoff() -> bool {
// 主动刷新服务状态,避免缓存状态阻止交接
if SERVICE_MANAGER.confirm_ready().await.is_err() {
return false;
}
crate::core::runstate::RUN_STATE.state().service_usable()
}
/// 服务就绪后停止 sidecar,再以 service 重启内核
#[cfg(target_os = "windows")]
async fn try_handoff_sidecar_to_service(&self) -> HandoffOutcome {
if !Self::refresh_service_readiness_for_handoff().await {
return HandoffOutcome::NotReady;
}
// 先抢 config 锁;失败则让位给正在进行的更新。
if !self.try_start_config_update() {
return HandoffOutcome::NotReady;
}
@@ -727,10 +697,9 @@ impl CoreManager {
self.finish_config_update();
}
// 再取 lifecycle 锁;锁序固定为 config→lifecycle。
// Lock order is config then lifecycle.
let _life = self.lifecycle_lock.lock().await;
// 持锁后复检运行模式和 TUN 状态
if !matches!(*self.get_running_mode(), RunningMode::Sidecar)
|| !Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false)
|| Config::tun_suppressed_for_session()
+4 -25
View File
@@ -20,22 +20,8 @@ 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.
/// Restores profile selections before callers enable the system proxy.
/// The bounded first pass continues in the background, and later calls supersede earlier ones.
async fn restore_selected_nodes(&self) {
if PROFILE_SELECTIONS_PENDING_COMMIT
.try_with(|pending| *pending)
@@ -469,8 +455,7 @@ mod tests {
time::{Duration, Instant},
};
// 起一个长命子进程用于验证 Job Object 的生命周期绑定。
// 直接使用 System32 下的 ping.exe,避免 cmd 中间层。
// Use ping directly as a long-lived process without a cmd.exe intermediary.
fn spawn_long_lived() -> Result<Child> {
let child = Command::new("ping")
.args(["-n", "999", "127.0.0.1"])
@@ -480,7 +465,6 @@ mod tests {
Ok(child)
}
// 在超时内轮询子进程是否退出,返回是否已退出。
fn wait_until_exited(child: &mut Child, timeout: Duration) -> Result<bool> {
let deadline = Instant::now() + timeout;
loop {
@@ -494,21 +478,17 @@ mod tests {
}
}
// 成功路径:进程被分配进 Job Object 后仍存活;drop Job 句柄触发
// KILL_ON_JOB_CLOSE,进程应在超时内被 OS 终止。
#[test]
fn job_kills_child_on_handle_drop() -> Result<()> {
let mut child = spawn_long_lived()?;
let job = create_and_assign_sidecar_job(child.id())?;
// 分配后进程应仍在运行。
assert!(
child.try_wait()?.is_none(),
"child should still be running after being assigned to the job"
);
// 关闭 Job 句柄,OS 应连带终止其成员进程。
drop(job);
assert!(
@@ -519,10 +499,9 @@ mod tests {
Ok(())
}
// 失败路径:对一个不存在的 PID 调用时 OpenProcess 应失败,函数返回 Err。
#[test]
fn returns_err_for_invalid_pid() {
// PID 必须为 4 的倍数且极不可能存在;0xFFFF_FFFC 对应不到真实进程。
// Windows PIDs are multiples of four; this one is effectively impossible.
let result = create_and_assign_sidecar_job(0xFFFF_FFFC);
assert!(result.is_err(), "expected Err for a non-existent PID");
}
-16
View File
@@ -41,7 +41,6 @@ pub enum SysproxyFailure {
}
impl SysproxyFailure {
/// Stable frontend error code.
#[inline]
pub const fn code(&self) -> &'static str {
match self {
@@ -52,7 +51,6 @@ impl SysproxyFailure {
}
}
/// Find a classification using `anyhow`'s context-aware downcast.
#[inline]
pub fn from_chain(error: &anyhow::Error) -> Option<&Self> {
error.downcast_ref::<Self>()
@@ -83,7 +81,6 @@ impl std::error::Error for SysproxyFailure {}
pub struct SystemProxyStateUnknown;
impl SystemProxyStateUnknown {
/// Whether this marker appears in the error chain.
#[inline]
pub fn is_in(error: &anyhow::Error) -> bool {
error.downcast_ref::<Self>().is_some()
@@ -98,7 +95,6 @@ impl std::fmt::Display for SystemProxyStateUnknown {
impl std::error::Error for SystemProxyStateUnknown {}
/// Whether the error carries a frontend-mappable classification.
#[inline]
fn carries_a_mappable_code(error: &anyhow::Error) -> bool {
SysproxyFailure::from_chain(error).is_some()
@@ -120,7 +116,6 @@ pub fn rollback_failure(caused_by: anyhow::Error, rollback: anyhow::Error) -> an
combined
}
/// Classify local macOS proxy failures without replacing their source chain.
fn classify_local_failure(error: anyhow::Error) -> anyhow::Error {
if !was_refused_locally(&error) {
return error;
@@ -128,7 +123,6 @@ fn classify_local_failure(error: anyhow::Error) -> anyhow::Error {
error.context(SysproxyFailure::PrivilegeRequired)
}
/// Whether macOS refused a local proxy write.
fn was_refused_locally(error: &anyhow::Error) -> bool {
cfg!(target_os = "macos")
&& error.chain().any(|cause| {
@@ -141,7 +135,6 @@ fn was_refused_locally(error: &anyhow::Error) -> bool {
/// Distinguish a misplaced sidecar from a missing privileged service.
async fn classify_local_apply_failure(error: anyhow::Error) -> anyhow::Error {
// Preserve any existing, more specific classification.
if SysproxyFailure::from_chain(&error).is_some() || !was_refused_locally(&error) {
return error;
}
@@ -153,7 +146,6 @@ async fn classify_local_apply_failure(error: anyhow::Error) -> anyhow::Error {
error.context(refusal_classification(probed))
}
/// Bound the diagnostic service probe.
const SERVICE_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
/// Classify a refusal from a fresh service probe only.
@@ -206,7 +198,6 @@ impl ServiceProxyOperations {
self.run_service_operation(operation).await
}
/// Invalidate the guard generation and wait for cancellation.
async fn cancel_and_drain<F, Fut, T>(&self, cancel: F) -> (u64, T)
where
F: FnOnce() -> Fut,
@@ -217,7 +208,6 @@ impl ServiceProxyOperations {
(generation, cancelled)
}
/// Record under the operation lock only for the current guard generation.
async fn record_if_current<Record>(&self, captured_generation: u64, record: Record) -> bool
where
Record: FnOnce() -> bool,
@@ -339,12 +329,10 @@ async fn current_service_proxy_config(verge: &IVerge) -> Result<MacosProxyConfig
if verge.proxy_auto_config.unwrap_or_default() {
return service_proxy_config(verge, 0, server::embedded_server_port()?);
}
// Configured: this builds the proxy settings handed to the service alongside a start.
let mixed_port = MixedPort::desired().await;
service_proxy_config(verge, mixed_port, 0)
}
/// Return whether a classified failure remains pending.
pub fn is_reportable(error: &anyhow::Error) -> bool {
is_reportable_given(error, notification::has_pending_failure)
}
@@ -549,10 +537,8 @@ pub async fn refresh_guard() -> Result<()> {
Ok(())
}
/// Consecutive failures allowed before the guard stops.
const GUARD_FAILURES_BEFORE_STOPPING: u32 = 3;
/// Report that the current guard stopped.
async fn report_guard_stopped(captured_generation: u64, error: &anyhow::Error) {
let stopped = anyhow::Error::new(SysproxyFailure::GuardStopped {
detail: format!("{error:#}"),
@@ -569,7 +555,6 @@ async fn report_guard_stopped(captured_generation: u64, error: &anyhow::Error) {
}
}
/// Stop the local guard and report whether it drained.
pub async fn stop_guard() -> bool {
SERVICE_PROXY_OPERATIONS
.cancel_and_drain(|| Sysopt::global().stop_proxy_guard())
@@ -593,7 +578,6 @@ mod tests {
use std::task::Poll;
use tokio::sync::Barrier;
/// Proxy state maintained by a running guard.
fn guarded_proxy() -> MacosProxyConfig {
MacosProxyConfig::Global {
host: "127.0.0.1".to_owned(),
+6 -52
View File
@@ -1,9 +1,4 @@
//! The seam between Run State and the machine it observes.
//!
//! Two adapters satisfy it: [`RealEnv`] talks to the Service over IPC and to the platform's
//! service registry, [`FakeEnv`] replays scripted answers. Because the store is generic over
//! this trait rather than reaching for globals, the state machine can be exercised without
//! a running app, an installed Service, or elevation.
//! Adapters between Run State and the real or scripted machine it observes.
use anyhow::{Context as _, Result};
@@ -12,41 +7,21 @@ use super::probe::ServiceVersionReply;
/// Everything Run State needs from outside itself.
pub trait RunStateEnv: Send + Sync + 'static {
/// Ask the Service which protocol it speaks. Fails when it cannot be reached at all.
fn probe_service_version(&self) -> impl Future<Output = Result<ServiceVersionReply>> + Send;
/// Whether the platform's service registry has a trusted record of an installation.
///
/// Fails when the registry itself could not be inspected, which is different from
/// "no installation" and is treated as unavailable rather than not-installed.
///
/// Async so blocking systemd and Windows SCM checks can run off the runtime workers.
/// Returns an error when installation evidence cannot be inspected, not when it is absent.
fn trusted_install_evidence(&self) -> impl Future<Output = Result<bool>> + Send;
/// Whether this app process is running elevated.
fn is_elevated(&self) -> bool;
/// Open or close the local PAC endpoint.
///
/// Derived from the Running Mode, never set independently: a PAC script that points at a
/// proxy port nothing is listening on is worse than no PAC at all.
/// Keeps the PAC endpoint derived from Running Mode.
fn set_pac_available(&self, available: bool);
/// Publish the Run State to observers outside the store.
///
/// Two of them today: the diagnostic snapshot, which reports the Running Mode, and the
/// frontend, which gets the whole thing pushed rather than polling for it.
fn publish(&self, state: &RunState);
/// Carry out a privileged operation against the Service.
///
/// Blocking and platform-specific — SCM, systemd, launchd, elevation prompts — which is
/// exactly why it sits behind the seam: the state machine around it can then be exercised
/// without installing anything.
fn run_privileged(&self, action: PendingAction) -> Result<()>;
}
/// The production adapter: real IPC, real platform registry, real elevation check.
#[derive(Debug, Default, Clone, Copy)]
pub struct RealEnv;
@@ -67,9 +42,7 @@ impl RunStateEnv for RealEnv {
}
fn is_elevated(&self) -> bool {
// Deliberately non-panicking, unlike `Handle::app_handle()`: Run State is read from
// startup paths that run before the app handle exists, and "not yet known" must
// degrade to "not elevated" rather than abort the process.
// Startup may read Run State before the app handle exists; fail closed instead of panicking.
crate::APP_HANDLE
.get()
.is_some_and(tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin)
@@ -80,17 +53,11 @@ impl RunStateEnv for RealEnv {
}
fn publish(&self, state: &RunState) {
// Reconciling capability is a reaction to the new state, not part of publishing it, so
// it is spawned: a transition must not wait on a config write it triggered. It is also
// spawned *before* the app-handle gate below, because a Core that stops and starts
// before the app handle exists still needs reconciling; the task re-reads the Run
// State itself rather than closing over this snapshot.
// Reconciliation must run before the app handle exists and must not block this transition.
crate::process::AsyncHandler::spawn(|| async {
crate::feat::reconcile_tun_availability().await;
});
// Non-panicking for the same reason as `is_elevated`: the Core can stop and start
// before the app handle exists, and a missed notification is better than an abort.
let Some(app_handle) = crate::APP_HANDLE.get() else {
return;
};
@@ -115,10 +82,7 @@ mod fake {
use super::{PendingAction, RunState, RunStateEnv, ServiceVersionReply};
use crate::core::manager::RunningMode;
/// A scripted stand-in for the machine.
///
/// Defaults to the least capable environment — no Service, no elevation — so a test only
/// has to say what it needs. Outbound effects are recorded rather than performed.
/// A fail-closed scripted machine that records outbound effects.
#[derive(Debug)]
pub struct FakeEnv {
version_replies: Mutex<Vec<Result<ServiceVersionReply, String>>>,
@@ -152,7 +116,6 @@ mod fake {
Self::default()
}
/// An installed, reachable, protocol-compatible Service.
#[must_use]
pub fn service_ready(self) -> Self {
self.with_evidence(true).always_replying(Ok(ServiceVersionReply {
@@ -162,7 +125,6 @@ mod fake {
}))
}
/// An installed Service that answers with an incompatible protocol.
#[must_use]
pub fn service_version_mismatch(self) -> Self {
self.with_evidence(true).always_replying(Ok(ServiceVersionReply {
@@ -172,14 +134,12 @@ mod fake {
}))
}
/// Registered with the platform but not answering.
#[must_use]
pub fn service_unreachable(self) -> Self {
self.with_evidence(true)
.always_replying(Err("ipc transport refused".to_owned()))
}
/// The platform registry itself could not be inspected.
#[must_use]
pub fn evidence_unavailable(mut self) -> Self {
self.evidence = Err("registry probe failed".to_owned());
@@ -210,38 +170,32 @@ mod fake {
self.replying(vec![reply])
}
/// How many times the Service was probed — asserts that retries actually retried.
#[must_use]
pub fn probe_count(&self) -> usize {
*self.probe_count.lock()
}
/// The last PAC availability the store asked for, or `None` if it never asked.
#[must_use]
pub fn pac_available(&self) -> Option<bool> {
*self.pac_available.lock()
}
/// Every Running Mode published outward, in order.
#[must_use]
pub fn published_modes(&self) -> Vec<RunningMode> {
self.published.lock().iter().map(|state| state.mode).collect()
}
/// Every Run State published outward, in order.
#[must_use]
pub fn published(&self) -> Vec<RunState> {
self.published.lock().clone()
}
/// Make every privileged operation fail with `reason`.
#[must_use]
pub fn privileged_operations_fail(self, reason: &str) -> Self {
*self.privileged_outcome.lock() = Err(reason.to_owned());
self
}
/// Every privileged operation actually attempted, in order.
#[must_use]
pub fn privileged_actions(&self) -> Vec<PendingAction> {
self.privileged_actions.lock().clone()
+2 -21
View File
@@ -3,19 +3,13 @@
use crate::core::manager::RunningMode;
/// Last observed Service state.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum ServiceHealth {
/// Not probed yet.
#[default]
Unknown,
/// Installed, reachable, and speaking a compatible protocol.
Ready,
/// No trusted installation evidence on this machine.
NotInstalled,
/// Installed but speaking an incompatible protocol; needs reinstall.
VersionMismatch,
/// Installed but unusable, with the reason we recorded.
Unavailable(String),
}
@@ -38,7 +32,6 @@ impl ServiceHealth {
}
}
/// Requested privileged Service operation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub enum PendingAction {
@@ -48,7 +41,6 @@ pub enum PendingAction {
ForceReinstall,
}
/// Snapshot used for Core availability decisions.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunState {
pub health: ServiceHealth,
@@ -61,26 +53,22 @@ pub struct RunState {
}
impl RunState {
/// Whether the last probe observed a ready Service; ignores in-flight operations.
#[must_use]
pub const fn service_ready(&self) -> bool {
matches!(self.health, ServiceHealth::Ready)
}
/// Whether the Service is ready and not mid-operation.
#[must_use]
pub const fn service_usable(&self) -> bool {
self.service_ready() && !self.op_in_flight
}
/// Whether elevation or a usable Service can support TUN.
#[must_use]
pub const fn tun_capable(&self) -> bool {
self.is_admin || self.service_usable()
}
/// Whether the user must resolve a pending request or a present-but-unusable Service.
/// In-flight, absent, and accepted-Sidecar states need no decision.
/// In-flight, absent, and accepted-Sidecar states require no user decision.
#[must_use]
pub const fn service_needs_attention(&self) -> bool {
if self.op_in_flight {
@@ -98,7 +86,6 @@ impl RunState {
)
}
/// Whether enabled TUN is certainly unusable and may be disabled.
/// Unknown, in-flight, and attention-required states are inconclusive.
#[must_use]
pub const fn tun_should_be_disabled(&self, tun_enabled: bool) -> bool {
@@ -108,14 +95,12 @@ impl RunState {
!matches!(self.health, ServiceHealth::Unknown) && !self.op_in_flight && !self.service_needs_attention()
}
/// Whether startup must suppress TUN for a confirmed absent Service.
/// Present-but-broken Services remain in the repair flow.
/// Present-but-broken services remain in the repair flow rather than suppressing TUN.
#[must_use]
pub const fn startup_tun_should_be_disabled(&self, tun_enabled: bool) -> bool {
matches!(self.health, ServiceHealth::NotInstalled) && self.tun_should_be_disabled(tun_enabled)
}
/// Frontend view with derived availability decisions.
#[must_use]
pub fn to_view(&self) -> RunStateView {
RunStateView {
@@ -133,7 +118,6 @@ impl RunState {
}
}
/// Frontend Run State.
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RunStateView {
@@ -159,20 +143,17 @@ pub(super) struct StoredService {
}
impl StoredService {
/// Record an observation, clearing any request it answers.
pub fn observe(&mut self, health: ServiceHealth) {
self.health = health;
self.pending = None;
self.sidecar_allowed = false;
}
/// Record a requested privileged operation, preserving the last observation.
pub const fn request(&mut self, action: PendingAction) {
self.pending = Some(action);
self.sidecar_allowed = false;
}
/// Record that the user accepted Sidecar for this session.
pub const fn allow_sidecar(&mut self) {
self.pending = None;
self.sidecar_allowed = true;
+25 -134
View File
@@ -1,15 +1,7 @@
//! Run State — the single answer to "how is the Core running, and what backs it".
//! The single answer to how the Core runs and what backs it.
//!
//! This module owns Service Health, Running Mode, Pending Action and the privileged-operation
//! lock. It deliberately does **not** own starting, stopping or restarting the Core — those
//! stay in `CoreManager`, which reports transitions in and reads snapshots back. Merging the
//! two would leave no point at which the state is consistent, since a snapshot taken
//! mid-restart would describe a transition rather than a state.
//!
//! Reads come in three flavours that differ only in *freshness* — [`RunStateStore::state`]
//! (cached), [`RunStateStore::settled`] (waits for any in-flight operation) and
//! [`RunStateStore::probe`] (forces live IPC). What the answer *means* is decided by methods
//! on [`RunState`], so no caller writes its own availability formula.
//! Core lifecycle stays in `CoreManager`; this module owns its state, service health, pending
//! actions, and the privileged-operation lock. Reads differ only in freshness.
mod env;
mod health;
@@ -45,11 +37,7 @@ use probe::{CurrentServiceProbe, classify_service_health, probe_outcome};
/// The process-wide Run State, observing the real machine.
pub static RUN_STATE: Lazy<RunStateStore<RealEnv>> = Lazy::new(|| RunStateStore::new(RealEnv));
/// Why a wait for Service readiness ended without a ready Service.
///
/// The two are not interchangeable: an unreachable Service told us nothing, so the caller
/// decides whether silence means unavailable; a rejected one already updated health and must
/// not have that verdict overwritten with a vaguer one.
/// Distinguishes silence from a reply that updated Service health with a rejection.
#[derive(Debug)]
pub enum ReadyWaitError {
/// No readable reply within the attempts budget.
@@ -66,11 +54,7 @@ impl std::fmt::Display for ReadyWaitError {
}
}
/// The service-owned state together with the counters that version it.
///
/// They share one lock so a reader can never see a change without its version bump.
/// `generation` tracks visible changes; `observation` also tracks assertions that invalidate
/// older health probes without changing visible state.
/// Service state and revisions share one lock so observations cannot overtake their version bump.
#[derive(Debug, Default)]
struct VersionedService {
service: StoredService,
@@ -79,13 +63,11 @@ struct VersionedService {
}
impl VersionedService {
/// Record a visible change and invalidate older health probes.
const fn bump(&mut self) {
self.generation = self.generation.wrapping_add(1);
self.observation = self.observation.wrapping_add(1);
}
/// Reserve a revision that invalidates older health probes.
const fn next_observation(&mut self) -> u64 {
self.observation = self.observation.wrapping_add(1);
self.observation
@@ -113,33 +95,19 @@ impl<E: RunStateEnv> RunStateStore<E> {
}
}
// ─────────────────────────── reads: freshness only ───────────────────────────
/// The last known Run State, without waiting and without probing.
///
/// May be stale while a privileged operation is in flight — the snapshot says so via
/// [`RunState::op_in_flight`], and [`RunState::service_usable`] accounts for it.
/// It reports when a privileged operation may make the snapshot stale.
pub fn state(&self) -> RunState {
let service = self.service.lock().service.clone();
self.snapshot(service)
}
/// The Run State once any in-flight privileged operation has finished.
///
/// The snapshot is the decision, not a separate check before it. Reading the slot twice —
/// once to decide whether to wait, once to build the snapshot — leaves a window in which an
/// operation claims it, and the snapshot then *describes* that operation instead of waiting
/// for it. `prepare_startup` reads this: it would see a requested install as a reason not to
/// start, where waiting would have told it whether the install worked.
/// Returns one snapshot taken after any operation visible in that snapshot has settled.
pub async fn settled(&self) -> RunState {
loop {
let notified = self.operation_done.notified();
tokio::pin!(notified);
// Register as a waiter *before* reading the state. `notify_waiters` wakes only
// those already registered and leaves no permit behind, so without this an
// operation finishing between the read and the await is a wakeup lost for good
// — and this future would then wait for some unrelated later operation, or
// forever.
// Register before reading; `notify_waiters` leaves no permit for late waiters.
notified.as_mut().enable();
let state = self.state();
@@ -150,11 +118,7 @@ impl<E: RunStateEnv> RunStateStore<E> {
}
}
/// Probe the Service now, record what came back, and return the fresh Run State.
///
/// A *transport* failure is not an observation: the Service may simply be restarting, so
/// the last confirmed health survives and the caller decides whether to retry. Only a
/// reply we could read and reject updates health.
/// Records readable replies; transport failures preserve the last confirmed health.
pub async fn probe(&self) -> Result<RunState> {
let reply = self
.env
@@ -164,11 +128,7 @@ impl<E: RunStateEnv> RunStateStore<E> {
self.record_reply(&reply)
}
/// Probe until the Service answers, giving it `attempts` tries `interval` apart.
///
/// Only an unreachable Service is worth retrying. A reply we could read is the Service's
/// answer — retrying an incompatible version twenty times would just delay telling the
/// user to reinstall — so the wait ends on the first readable reply either way.
/// Retries transport failures but stops at the first readable reply, including a rejection.
pub async fn await_ready(&self, attempts: usize, interval: Duration) -> Result<RunState, ReadyWaitError> {
let mut last_error = None;
for attempt in 0..attempts {
@@ -186,7 +146,6 @@ impl<E: RunStateEnv> RunStateStore<E> {
})))
}
/// Record a readable reply as an observation, and report whether it was acceptable.
fn record_reply(&self, reply: &ServiceVersionReply) -> Result<RunState> {
match classify_service_version_reply(reply) {
ServiceVersionCheck::Ready => {
@@ -200,10 +159,7 @@ impl<E: RunStateEnv> RunStateStore<E> {
}
}
/// Work out the Service's health from scratch: platform evidence first, then a live probe.
///
/// Platform evidence that cannot be inspected is *unavailable*, not *absent* — assuming
/// "not installed" there would offer the user an install that is already there.
/// Detects health from platform evidence and then live IPC; unknown evidence is not absence.
pub async fn detect_service_health(&self) -> ServiceHealth {
let has_marker = match self.env.trusted_install_evidence().await {
Ok(exists) => exists,
@@ -234,26 +190,18 @@ impl<E: RunStateEnv> RunStateStore<E> {
}
}
// ─────────────────────────── writes ───────────────────────────
/// Probe and record Service health unless a newer observation arrived first.
///
/// Reserving before I/O prevents slower probes from overwriting later observations, including
/// assertions that leave visible state unchanged. Returns the health that remains recorded.
/// Records detected health unless a newer observation arrived during I/O.
pub async fn observe_current_health(&self) -> ServiceHealth {
let reservation = self.reserve_health_observation();
let health = self.detect_service_health().await;
self.commit_reserved_observation(health, reservation)
}
/// Reserve the revision for a health probe.
fn reserve_health_observation(&self) -> u64 {
self.service.lock().next_observation()
}
/// Commit `health` only if `reservation` is still latest; otherwise return the newer health.
///
/// The comparison and write share one lock. A successful commit reuses its reserved revision.
/// Commits only the latest reserved observation.
fn commit_reserved_observation(&self, health: ServiceHealth, reservation: u64) -> ServiceHealth {
let mut state = self.service.lock();
if state.observation != reservation {
@@ -276,7 +224,6 @@ impl<E: RunStateEnv> RunStateStore<E> {
health
}
/// Record an observation about the Service, clearing any request it answers.
pub fn observe(&self, health: ServiceHealth) {
let mut state = self.service.lock();
if state.service.health == health && state.service.pending.is_none() && !state.service.sidecar_allowed {
@@ -290,11 +237,7 @@ impl<E: RunStateEnv> RunStateStore<E> {
self.announce();
}
/// Record an authorised privileged operation and return whether it displaced the session's
/// Sidecar allowance.
///
/// Reading and clearing the allowance in one transition lets the caller safely restore it if
/// the operation fails.
/// Records an action and returns the Sidecar allowance it displaced for possible rollback.
pub fn request_action(&self, action: PendingAction) -> bool {
let mut state = self.service.lock();
let displaced = state.service.sidecar_allowed;
@@ -310,9 +253,7 @@ impl<E: RunStateEnv> RunStateStore<E> {
displaced
}
/// Accept Sidecar for the rest of this app session without any eligibility check.
///
/// For builds and paths where Sidecar is the decided outcome rather than a fallback.
/// Accepts Sidecar when it is the predetermined outcome rather than a fallback.
pub fn accept_sidecar(&self) {
let mut state = self.service.lock();
if state.service.sidecar_allowed && state.service.pending.is_none() {
@@ -326,10 +267,7 @@ impl<E: RunStateEnv> RunStateStore<E> {
self.announce();
}
/// Restore a displaced Sidecar allowance unless one already exists or the Service is ready.
///
/// The check and update are atomic so a restored allowance cannot shadow a newly ready
/// Service. Returns whether the allowance was restored.
/// Atomically restores an allowance unless Sidecar is already allowed or the Service is ready.
pub fn restore_sidecar_allowance(&self) -> bool {
let mut state = self.service.lock();
if state.service.sidecar_allowed {
@@ -348,9 +286,7 @@ impl<E: RunStateEnv> RunStateStore<E> {
true
}
/// Revoke an allowance after Sidecar startup fails without changing Service health.
///
/// Returns whether the allowance was revoked.
/// Revokes a failed Sidecar allowance without changing Service health.
pub fn withdraw_sidecar_allowance(&self) -> bool {
let mut state = self.service.lock();
if !state.service.sidecar_allowed {
@@ -363,10 +299,7 @@ impl<E: RunStateEnv> RunStateStore<E> {
true
}
/// Accept Sidecar for the rest of this app session.
///
/// Rejected while an operation is in flight, or from a state where the Service is
/// already usable or is mid-privileged-operation other than an install.
/// Accepts Sidecar only while the Service is unusable and no conflicting operation is active.
pub fn allow_sidecar_for_session(&self) -> Result<()> {
let mut state = self.service.lock();
if self.operation_running.load(Ordering::Acquire) {
@@ -390,10 +323,7 @@ impl<E: RunStateEnv> RunStateStore<E> {
Ok(())
}
/// Require the Service to be installed before the Core starts, when it is simply absent.
///
/// A no-op from any other state, mirroring the rule that an already-requested or
/// already-usable Service must not be downgraded to "needs installing".
/// Requests installation only for a confirmed absent Service.
pub fn require_install_for_session(&self) -> Result<()> {
let mut state = self.service.lock();
if self.operation_running.load(Ordering::Acquire) {
@@ -414,11 +344,7 @@ impl<E: RunStateEnv> RunStateStore<E> {
Ok(())
}
/// Carry out a privileged operation and record what it did to the Service.
///
/// A successful uninstall records an absent Service. Other successes rely on the caller's
/// readiness check. Failures re-probe the Service so the request is retired and health
/// reflects the machine's current state.
/// Runs an operation, recording uninstall success or re-probing after failure.
pub async fn perform(&self, action: PendingAction) -> Result<()> {
let outcome = self.env.run_privileged(action);
match &outcome {
@@ -436,56 +362,32 @@ impl<E: RunStateEnv> RunStateStore<E> {
outcome
}
// ─────────────────────────── running mode ───────────────────────────
/// The Running Mode, shared so hot callers such as the tray do not allocate.
pub fn mode_arc(&self) -> Arc<RunningMode> {
Arc::clone(&self.mode.load())
}
/// The Core is now running, and serving, in `mode`.
pub fn core_started(&self, mode: RunningMode) {
self.enter_mode(mode);
}
/// The Core is no longer running.
pub fn core_stopped(&self) {
self.enter_mode(RunningMode::NotRunning);
}
/// A start attempt is under way: the Core is not serving yet, whatever the mode says.
///
/// Closes the PAC endpoint without disturbing the Running Mode, so a handover cannot
/// hand out a PAC script for a proxy port that is between owners.
///
/// Every caller must pair this with [`Self::core_start_settled`]. A start attempt that
/// never happens — a candidate rejected before anything was stopped — otherwise leaves PAC
/// closed for a Core that is still serving, and nothing else would ever reopen it: PAC is
/// re-derived only when the Running Mode changes, and that is exactly what did not happen.
/// Closes PAC during a start or handover without changing the last running mode.
/// Every call must be paired with [`Self::core_start_settled`].
pub fn core_starting(&self) {
self.env.set_pac_available(false);
}
/// The start attempt is over, however it ended: PAC goes back to following the Running Mode.
///
/// Idempotent and safe on every path, because it re-derives rather than restores. After a
/// start that succeeded the mode is already running and this confirms PAC open; after one
/// that was abandoned the mode never moved and this reopens PAC for the Core that kept
/// serving; after one that failed and stopped the Core the mode says NotRunning and PAC
/// stays shut.
/// Idempotently re-derives PAC availability after a start attempt settles.
pub fn core_start_settled(&self) {
let running = !matches!(**self.mode.load(), RunningMode::NotRunning);
self.env.set_pac_available(running);
}
/// Move to `mode` and re-derive everything that follows from it.
///
/// PAC availability and the outward mirror are derived here and nowhere else, so they
/// cannot drift from the Running Mode the way ten hand-paired call sites could.
///
/// PAC closes *before* the mode changes and opens *after*, so whatever a concurrent
/// reader catches mid-transition is the closed state: being refused a PAC script beats
/// being handed one that points at a proxy port between owners.
/// Moves to `mode`, closing PAC before the transition and reopening it only afterward.
fn enter_mode(&self, mode: RunningMode) {
let running = !matches!(mode, RunningMode::NotRunning);
if !running {
@@ -498,8 +400,6 @@ impl<E: RunStateEnv> RunStateStore<E> {
self.announce();
}
// ─────────────────────────── privileged operations ───────────────────────────
/// Claim the privileged-operation slot, releasing it when the guard drops.
///
/// Only one privileged operation may be in flight; [`Self::settled`] waits on this.
@@ -514,31 +414,22 @@ impl<E: RunStateEnv> RunStateStore<E> {
Ok(OperationGuard { store: self })
}
/// Whether a privileged operation is in flight right now.
///
/// Production code reads this through [`RunState::op_in_flight`]; this accessor exists for
/// tests that assert on the slot without taking a snapshot.
#[cfg(test)]
pub fn operation_in_flight(&self) -> bool {
self.operation_running.load(Ordering::Acquire)
}
/// The environment this store observes, so tests can assert on the effects it recorded.
#[cfg(test)]
pub const fn env(&self) -> &E {
&self.env
}
/// Bumped on every state change; lets tests assert that a no-op really was a no-op.
#[cfg(test)]
pub fn generation_count(&self) -> u64 {
self.service.lock().generation
}
/// Tell the outside world the Run State moved.
///
/// Called after every mutation and never while the state lock is held, so a publisher that
/// reads back the state cannot deadlock against the writer that triggered it.
/// Publishes after dropping the state lock so observers may safely read it back.
fn announce(&self) {
self.env.publish(&self.state());
}
+5 -25
View File
@@ -1,20 +1,8 @@
//! Deciding when the Service has stopped being ours.
//!
//! While the Core runs under the Service, the app polls the Service for owner status. Three
//! things can go wrong — we cannot read a reply, the Service says someone else owns it, or it
//! answers but describes a Core that is gone — and each needs a different response. This is
//! the decision, separated from the polling and from the recovery it triggers.
//!
//! Recovery *actions* deliberately live with the Core lifecycle rather than here: this module
//! decides that the Service can no longer be trusted, and `CoreManager` is what tears the Core
//! down in response.
//! Classifies service ownership samples; `CoreManager` performs any recovery it requests.
use clash_verge_service_ipc::ServiceLifecycleState;
/// How many consecutive bad samples we tolerate before acting.
///
/// Service restarts and slow status calls are normal; acting on the first one would tear down
/// a working proxy for a blip.
/// Tolerates transient service restarts and slow status calls.
const SUSTAINED_SAMPLES: u8 = 3;
/// Why the app stopped trusting the Service that was running its Core.
@@ -49,11 +37,7 @@ pub enum OwnerSample {
pub enum OwnerStep {
/// Nothing is wrong, or not wrong for long enough yet.
Continue,
/// Unreadable for long enough that it might be real. Check whether the Core's own endpoint
/// still answers, then report back via [`OwnerWatch::resolve_transport`].
///
/// A Service we cannot reach while the Core is still serving is a broken status channel,
/// not a lost Core — tearing down the proxy there would be the cure causing the disease.
/// Check the Core endpoint before treating a sustained status-channel failure as lost ownership.
VerifyTransport,
/// Stop trusting the Service and recover.
Recover(OwnerRecoveryReason),
@@ -75,8 +59,7 @@ impl OwnerWatch {
}
}
/// Whether this sample warrants a log line: only the moment a run becomes sustained, so a
/// permanently broken Service does not fill the log at the poll interval.
/// True only when a failure first becomes sustained, to avoid repeated logs.
#[must_use]
pub const fn just_became_sustained(&self) -> bool {
self.unreadable_samples == SUSTAINED_SAMPLES
@@ -120,10 +103,7 @@ impl OwnerWatch {
}
}
/// Report what the transport check found after [`OwnerStep::VerifyTransport`].
///
/// If the Core's own endpoint still answers, only the status channel is broken: the run is
/// reset so the next stretch of silence has to earn its own verdict.
/// Resets the failure run when only the status channel is unavailable.
pub const fn resolve_transport(&mut self, owner_endpoint_available: bool) -> OwnerStep {
if owner_endpoint_available {
self.unreadable_samples = 0;
+7 -32
View File
@@ -61,13 +61,8 @@ pub(crate) async fn collect_runtime_bundle(config_file: &Path, core_path: &Path)
})
}
/// Rewrite one provider section's paths to service-side destinations, and record what each
/// destination is going to hold.
///
/// The two outputs are not the same thing. `assets` are files the app owns and the service copies;
/// `remote_providers` are files the *core* downloads, which the service never writes and only
/// needs to recognise — a download cache is reusable exactly while the url that produced it is
/// unchanged, and the service has no other way to tell.
/// Rewrites provider paths and records copied assets separately from core-downloaded providers.
/// Remote URLs let the service decide whether a core download cache remains reusable.
fn collect_provider_assets(
config: &mut Value,
section: &str,
@@ -91,28 +86,13 @@ fn collect_provider_assets(
let Some(raw_path) = provider.get("path").and_then(Value::as_str) else {
continue;
};
// The Core decides what a provider is by its `type`, so anything else here would disagree
// with it. Keying off the presence of a `url` used to do that: a `file` provider carrying
// a stray `url` was treated as remote, its local file left uncopied, and the Core then
// could not find what the rewritten path pointed at.
//
// Disagreeing the other way is just as bad, though, and it is why neither branch may fail
// the whole bundle over one odd provider. Measured against mihomo v1.19.26: it accepts a
// `http` provider with no url, an `inline` provider carrying both a url and a path that
// does not exist, and a `file` provider whose path is missing. Refusing any of those here
// would turn a configuration the Core is willing to load into one that cannot be applied
// at all — including at start, since this is where a Service-mode start gets its bundle.
// A provider we cannot classify keeps its path unrewritten and is simply not declared.
// Match mihomo's `type` classification and leave malformed-but-accepted providers untouched.
let is_remote = provider.get("type").and_then(Value::as_str) == Some("http");
let url = provider.get("url").and_then(Value::as_str).map(str::to_owned);
let destination = match (is_remote, url) {
(true, Some(url)) => {
let destination = provider_destination(config_root, raw_path)?;
// Remote providers take their destination out of circulation as much as local ones
// do: the Service refuses a bundle where one path is claimed both by a file it must
// copy and by a file the Core downloads, because the two would overwrite each
// other. Two providers naming the same file *and* the same source are not that —
// they are one file with two names for it, which the Service folds.
// Reserve remote destinations too; copied and downloaded files must not collide.
match remote_providers
.iter()
.find(|declared| declared.destination == destination)
@@ -281,9 +261,7 @@ mod tests {
);
assert!(bundle.assets.iter().any(|asset| asset.destination == "Country.mmdb"));
assert!(!bundle.assets.iter().any(|asset| asset.destination.contains("remote")));
// A remote provider is not copied, but it must still be declared: the Service decides
// whether the Core's download cache is reusable by comparing the url that produced it,
// and an undeclared provider is one it can only ever discard.
// Remote declarations preserve service-side cache reuse.
assert_eq!(
bundle
.remote_providers
@@ -305,9 +283,7 @@ mod tests {
#[tokio::test]
async fn two_names_for_one_remote_file_are_folded_rather_than_refused() -> anyhow::Result<()> {
// The Service folds an identical repeat, so refusing it here would block a configuration
// mihomo itself accepts — and this is also where a Service-mode *start* gets its bundle,
// so the refusal would stop the core coming up at all.
// Identical repeats are one service-side file and are safe to fold.
let root = std::env::temp_dir().join(format!("clash-verge-bundle-dup-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root)?;
@@ -351,8 +327,7 @@ mod tests {
#[tokio::test]
async fn an_odd_provider_is_left_alone_instead_of_failing_the_whole_bundle() -> anyhow::Result<()> {
// Measured against mihomo v1.19.26: it accepts a `http` provider with no url, and a
// non-http provider whose path does not exist. Neither may stop the rest being materialised.
// Odd providers accepted by mihomo must not block the rest of the bundle.
let root = std::env::temp_dir().join(format!("clash-verge-bundle-odd-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root)?;
+17 -70
View File
@@ -39,12 +39,8 @@ use std::{
static OWNER_MONITOR_GENERATION: AtomicU64 = AtomicU64::new(0);
static ACTIVE_SERVICE_SESSION: Lazy<Mutex<Option<ActiveServiceSession>>> = Lazy::new(|| Mutex::new(None));
/// The Service session that owns the running Core, and what that Service can do.
///
/// The capability is learned once, when the Core is started, and discarded with the session
/// rather than cached globally — it describes *the Service instance that owns this Core*, which
/// is the only scope where it is safe to act on. A Service upgraded underneath a running Core
/// does not silently gain abilities the Core it owns was not started under.
/// Capabilities of the service session that owns the running Core.
/// They are discarded with that session rather than cached across service upgrades.
#[derive(Clone)]
struct ActiveServiceSession {
proof: OwnerSessionProof,
@@ -65,10 +61,7 @@ pub(crate) fn active_service_session() -> Result<OwnerSessionProof> {
.context("service owner session is not active")
}
/// Whether the Service that started the running Core can stage a runtime in place.
///
/// False for every reason that is not "yes": no session, an older Service, or a protocol query
/// that did not come back. All of them mean the same thing to the caller — take the slow path.
/// Returns false unless the active service session explicitly supports in-place staging.
pub(crate) fn active_service_supports_runtime_staging() -> bool {
ACTIVE_SERVICE_SESSION
.lock()
@@ -80,10 +73,7 @@ pub(crate) fn clear_active_service_session() {
ACTIVE_SERVICE_SESSION.lock().take();
}
/// Ask the Service whether it speaks the staging half of the protocol.
///
/// A failure here is not a failure to start: it only costs the fast path, so it is reported as
/// "no" and logged rather than propagated.
/// Probes staging support without failing startup when the fast path is unavailable.
async fn probe_runtime_staging_support() -> bool {
match clash_verge_service_ipc::get_version().await {
Ok(response) if response.code == 0 => response
@@ -130,11 +120,7 @@ pub enum ServiceStatus {
}
impl ServiceStatus {
/// Flatten a Run State snapshot into the legacy single-slot status.
///
/// Precedence mirrors what the single slot used to hold: a requested action shadows an
/// accepted Sidecar, which shadows the last observation. Kept until the frontend seam
/// moves to `RunState` wholesale.
/// Flattens Run State using legacy precedence: action, Sidecar allowance, then health.
fn from_run_state(state: &RunState) -> Self {
if let Some(action) = state.pending {
return match action {
@@ -240,11 +226,7 @@ pub(crate) fn trusted_service_evidence() -> Result<bool> {
macos_service_install_marker_exists().context("failed to inspect launchd service registration")
}
/// Legacy façade over [`RUN_STATE`].
///
/// Holds no state of its own: Service Health, the requested action and the privileged-operation
/// lock all live in `core::runstate`. This type survives only so that existing call sites keep
/// compiling while the seam moves; it is retired once they read `RunState` directly.
/// Stateless legacy façade over [`RUN_STATE`] retained for existing call sites.
pub struct ServiceManager;
#[cfg(any(all(target_os = "macos", feature = "verge-dev"), test))]
@@ -812,9 +794,7 @@ fn force_reinstall_service() -> Result<()> {
})
}
/// Dispatch a privileged operation to the platform implementation.
///
/// Blocking work, so it is handed to a blocking thread: the caller is async.
/// Dispatches a privileged platform operation on a blocking thread.
pub(crate) fn run_privileged_service_action(action: PendingAction) -> Result<()> {
let (operation, label): (fn() -> Result<()>, &'static str) = match action {
PendingAction::Install => (install_service, "install service"),
@@ -825,11 +805,7 @@ pub(crate) fn run_privileged_service_action(action: PendingAction) -> Result<()>
tokio::task::block_in_place(operation).with_context(|| format!("{label} failed"))
}
/// Describe what the Service should hold, for the Core binary this app would start.
///
/// Shared by starting and staging so the two cannot disagree about the binary or about how the
/// configuration's provider paths are rewritten — a staged bundle naming a different core is
/// exactly what makes the Service refuse to stage.
/// Builds the same runtime bundle description for both service start and staging.
async fn collect_service_runtime_bundle(config_file: &Path) -> Result<RuntimeBundle> {
let verge_config = Config::verge().await;
let clash_core = verge_config.latest_arc().get_valid_clash_core();
@@ -840,13 +816,7 @@ async fn collect_service_runtime_bundle(config_file: &Path) -> Result<RuntimeBun
collect_runtime_bundle(config_file, &bin_path).await
}
/// What asking the Service to stage a runtime produced.
///
/// A refusal carries its code because only some refusals say anything about starting. "This bundle
/// names an asset I will not accept" does: a fresh start materialises the same bundle and is
/// refused the same way, so stopping a working Core would add an outage to a failure that already
/// happened. "You do not own this Core" does not — `start_clash` proposes a new session rather than
/// presenting the old one, so starting is exactly how that gets resolved.
/// A staging response whose refusal code tells callers whether a fresh start can help.
pub(super) enum StageRequest {
Refused { code: u16, message: CompactString },
Answered(StageRuntimeOutcome),
@@ -859,11 +829,7 @@ impl StageRequest {
}
}
/// Have the Service make the running Core's runtime match `config_file`, without restarting it.
///
/// `Err` means the request did not get an answer. A refusal, and `RestartRequired`, both come back
/// as `Ok`: neither is a failure of this function, and only the caller can decide what to do about
/// them.
/// Requests in-place staging. `Err` means no answer; refusals are returned for caller policy.
pub(super) async fn stage_runtime_by_service(config_file: &Path) -> Result<StageRequest> {
let session = active_service_session()?;
let credentials = current_owner_credentials()?;
@@ -1141,10 +1107,7 @@ fn start_owner_monitor() {
});
}
/// Ask the Service who owns it, flattening every unusable answer into one sample.
///
/// A transport error, an error code and an empty payload are the same thing to the watch:
/// we did not learn anything. Only the log line distinguishes them.
/// Samples ownership, treating every unusable reply as unreadable.
async fn read_owner_sample() -> OwnerSample {
let response = match current_owner_credentials() {
Ok(credentials) => clash_verge_service_ipc::get_status(&credentials).await,
@@ -1270,11 +1233,8 @@ async fn recover_after_owner_loss_while_locked(reason: OwnerRecoveryReason) {
}
}
/// Wait for a freshly installed or repaired Service to answer.
///
/// Silence for the whole budget *is* an observation here — the Service had its window and
/// never spoke — unlike a single failed probe. A readable but rejected reply already recorded
/// its own verdict, which must not be flattened into "unavailable".
/// Waits for a repaired service, preserving readable rejection details but classifying sustained
/// silence as unavailable.
async fn wait_for_service_ipc() -> Result<()> {
const CONTEXT: &str = "service IPC did not become available";
let config = ServiceManager::config();
@@ -1354,9 +1314,7 @@ impl ServiceManager {
}
async fn apply_service_status(&self, status: ServiceStatus) -> Result<()> {
// Derived from the caller's own argument, not read back out of the store: an
// observation racing in between would clear the pending action and silently turn a
// user-authorised install into a no-op.
// Use the caller's action; a racing observation may clear the stored pending action.
let Some(action) = requested_action(&status) else {
self.set_status(status.clone());
return report_non_actionable_status(status);
@@ -1377,9 +1335,7 @@ impl ServiceManager {
}
}
/// Run the full action workflow and restore the session's previous Sidecar allowance on failure.
///
/// This includes the readiness wait: an action has not landed until the Service responds.
/// Runs through readiness and restores a displaced Sidecar allowance on failure.
async fn run_action_restoring_sidecar<E: RunStateEnv>(
store: &RunStateStore<E>,
was_allowed: bool,
@@ -1428,11 +1384,7 @@ fn report_non_actionable_status(status: ServiceStatus) -> Result<()> {
Ok(())
}
/// Run a privileged operation while holding the Run State operation slot.
///
/// The slot is released — and `settled` waiters woken — before `post_operation` runs, so a
/// post-operation refresh such as the tray menu observes the final state rather than a
/// state still flagged as in-flight.
/// Releases the Run State operation slot before post-operation observers refresh.
async fn run_operation_and_then<E, Post, PostFuture>(
store: &RunStateStore<E>,
operation: impl Future<Output = Result<()>>,
@@ -1451,12 +1403,7 @@ where
post_operation().await
}
/// Apply a legacy single-slot status to a Run State, splitting it back into observation and
/// request. The inverse of [`ServiceStatus::from_run_state`].
/// The privileged operation a status is asking for, if any.
///
/// A pure function of the status so that a caller can decide what to run without reading the
/// store back and racing an observation.
/// Maps a legacy status to its requested action without racing a store reread.
const fn requested_action(status: &ServiceStatus) -> Option<PendingAction> {
match status {
ServiceStatus::InstallRequired => Some(PendingAction::Install),
-22
View File
@@ -156,7 +156,6 @@ impl Tray {
logging!(info, Type::Tray, "System tray created successfully");
}
Err(e) => {
// Don't return error, let application continue running without tray
logging!(
warn,
Type::Tray,
@@ -167,7 +166,6 @@ impl Tray {
Ok(())
}
/// 更新托盘点击行为
pub async fn update_click_behavior(&self) -> Result<()> {
if handle::Handle::global().is_exiting() {
logging!(debug, Type::Tray, "应用正在退出,跳过托盘点击行为更新");
@@ -187,7 +185,6 @@ impl Tray {
Ok(())
}
/// 更新托盘菜单
pub async fn update_menu(&self) -> Result<()> {
if handle::Handle::global().is_exiting() {
logging!(debug, Type::Tray, "应用正在退出,跳过托盘菜单更新");
@@ -245,7 +242,6 @@ impl Tray {
Ok(())
}
/// 更新托盘图标
pub async fn update_icon(&self, verge: &IVerge) -> Result<()> {
if handle::Handle::global().is_exiting() {
logging!(debug, Type::Tray, "应用正在退出,跳过托盘图标更新");
@@ -278,7 +274,6 @@ impl Tray {
Ok(())
}
/// 更新托盘提示
pub async fn update_tooltip(&self) -> Result<()> {
if handle::Handle::global().is_exiting() {
logging!(debug, Type::Tray, "应用正在退出,跳过托盘提示更新");
@@ -309,7 +304,6 @@ impl Tray {
}
}
// Get localized strings before using them
let sys_proxy_text = clash_verge_i18n::t!("tray.tooltip.systemProxy");
let tun_text = clash_verge_i18n::t!("tray.tooltip.tun");
let profile_text = clash_verge_i18n::t!("tray.tooltip.profile");
@@ -413,7 +407,6 @@ impl Tray {
allow
}
/// 根据配置统一更新托盘速率采集任务状态(macOS)
#[cfg(target_os = "macos")]
pub fn update_speed_task(&self, enable_tray_speed: bool) {
self.speed_controller.update_task(enable_tray_speed);
@@ -476,7 +469,6 @@ fn create_subcreate_proxy_menu_item(
// TODO: 应用启动时,内核还未启动完全,无法获取代理节点信息
if let Some(proxy_nodes_data) = proxy_nodes_data {
for (group_name, group_data) in proxy_nodes_data.proxies.iter() {
// Filter groups based on mode and hidden flag
let should_show = match proxy_mode {
"global" => group_name == "GLOBAL",
_ => group_name != "GLOBAL",
@@ -492,14 +484,12 @@ fn create_subcreate_proxy_menu_item(
let now_proxy = group_data.now.as_deref().unwrap_or_default();
// Create proxy items
let group_items: Vec<CheckMenuItem<Wry>> = all_proxies
.iter()
.filter_map(|proxy_str| {
let is_selected = *proxy_str == now_proxy;
let item_id = format!("proxy_{}_{}", group_name, proxy_str);
// Get delay for display
let delay_text = proxy_nodes_data
.proxies
.get(proxy_str)
@@ -565,7 +555,6 @@ fn create_proxy_menu_item(
proxy_submenus: Vec<Submenu<Wry>>,
proxies_text: &str,
) -> Result<ProxyMenuItem> {
// 创建代理主菜单
let (proxies_submenu, inline_proxy_items) = if show_proxy_groups_inline {
(
None,
@@ -660,9 +649,7 @@ async fn create_tray_menu(
let profile_menu_items: Vec<CheckMenuItem<Wry>> = create_profile_menu_item(app_handle, profiles_preview)?;
// Pre-fetch all localized strings
let texts = MenuTexts::new();
// Convert to references only when needed
let profile_menu_items_refs: Vec<&dyn IsMenuItem<Wry>> = profile_menu_items
.iter()
.map(|item| item as &dyn IsMenuItem<Wry>)
@@ -842,7 +829,6 @@ async fn create_tray_menu(
let separator = &PredefinedMenuItem::separator(app_handle)?;
// 动态构建菜单项
let mut menu_items: Vec<&dyn IsMenuItem<Wry>> = vec![open_window, separator];
if show_outbound_modes_inline {
@@ -857,7 +843,6 @@ async fn create_tray_menu(
menu_items.extend_from_slice(&[separator, profiles]);
// 如果有代理节点,添加代理节点菜单
match tray_proxy_groups_display_mode {
"default" => {
menu_items.extend(proxies_menu.iter().map(|item| item as &dyn IsMenuItem<_>));
@@ -898,7 +883,6 @@ fn on_tray_icon_event(_tray_icon: &TrayIcon, tray_event: TrayIconEvent) {
..
} = tray_event
{
// 添加防抖检查,防止快速连击
#[allow(clippy::use_self)]
if !Tray::global().should_handle_tray_click() {
return;
@@ -942,12 +926,10 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
AsyncHandler::spawn(|| async move {
match event.id.as_ref() {
mode @ (MenuIds::RULE_MODE | MenuIds::GLOBAL_MODE | MenuIds::DIRECT_MODE) => {
// Removing the the "tray_" prefix and "_mode" suffix
if let Some(stripped) = mode.strip_prefix("tray_")
&& let Some(final_mode) = stripped.strip_suffix("_mode")
{
logging!(info, Type::ProxyMode, "Switch Proxy Mode To: {}", final_mode);
// 错误已在 change_clash_mode 内部记录,此处显式忽略返回值
let _ = feat::change_clash_mode(final_mode.into()).await;
}
}
@@ -1004,7 +986,6 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
feat::toggle_proxy_profile(profile_index.into()).await;
}
id if id.starts_with("proxy_") => {
// proxy_{group_name}_{proxy_name}
let rest = match id.strip_prefix("proxy_") {
Some(r) => r,
None => return,
@@ -1019,8 +1000,5 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
logging!(debug, Type::Tray, "Unhandled tray menu event: {:?}", event.id);
}
}
// We dont expected to refresh tray state here
// as the inner handle function (SHOULD) already takes care of it
});
}
+2 -12
View File
@@ -8,12 +8,9 @@ use std::time::Duration;
use tauri::async_runtime::JoinHandle;
use tauri_plugin_mihomo::models::WsConnectionId;
/// 托盘速率流异常后的重连间隔。
const TRAY_SPEED_RETRY_DELAY: Duration = Duration::from_secs(1);
/// 托盘速率流在此时间内收不到有效数据时,触发重连并降级到 0/0。
const TRAY_SPEED_STALE_TIMEOUT: Duration = Duration::from_secs(5);
/// macOS 托盘速率任务控制器。
#[derive(Clone)]
pub struct TraySpeedController {
speed_task: Arc<Mutex<Option<JoinHandle<()>>>>,
@@ -42,13 +39,11 @@ impl TraySpeedController {
}
}
/// 启动托盘速率采集后台任务(基于 `/traffic` WebSocket 流)。
fn start_task(&self) {
if handle::Handle::global().is_exiting() {
return;
}
// 关键步骤:托盘不可用时不启动速率任务,避免无效连接重试。
if !Self::has_main_tray() {
logging!(warn, Type::Tray, "托盘不可用,跳过启动托盘速率任务");
return;
@@ -111,7 +106,7 @@ impl TraySpeedController {
break;
}
// Stale 分支在内层 loop 中已重置为 0/0;此处兜底 Closed 分支(流被远端关闭)。
// `Stale` already reset the display; this covers remote close.
Self::apply_tray_speed(0, 0);
tokio::time::sleep(TRAY_SPEED_RETRY_DELAY).await;
}
@@ -122,17 +117,12 @@ impl TraySpeedController {
*guard = Some(task);
}
/// 停止托盘速率采集后台任务并清除速率显示。
fn stop_task(&self) {
// 取出任务句柄,与 speed_connection_id 一同传入清理任务。
let task = self.speed_task.lock().take();
let speed_connection_id = Arc::clone(&self.speed_connection_id);
AsyncHandler::spawn(move || async move {
// 关键步骤:先等待 abort 完成,再断开 WebSocket 连接。
// 若直接 abort 后立即 disconnect,任务可能已通过 take 取走 connection_id
// 但尚未完成断开,导致 connection_id 丢失、连接泄漏。
// await task handle 可保证原任务已退出,connection_id 不再被占用。
// Await abort before disconnect so the task cannot consume and lose the connection ID.
if let Some(task) = task {
task.abort();
let _ = task.await;
+10 -53
View File
@@ -34,8 +34,6 @@ impl SilentUpdater {
}
}
// ─── Disk Cache ───────────────────────────────────────────────────────────────
#[derive(Serialize, Deserialize)]
struct UpdateCacheMeta {
version: String,
@@ -95,10 +93,7 @@ impl SilentUpdater {
}
}
// ─── Version Comparison ───────────────────────────────────────────────────────
/// Returns true if version `a` <= version `b` using semver-like comparison.
/// Strips leading 'v', splits on '.', handles pre-release suffixes.
/// Compares numeric version components after stripping `v` and prerelease suffixes.
fn version_lte(a: &str, b: &str) -> bool {
let parse = |v: &str| -> Vec<u64> {
v.trim_start_matches('v')
@@ -127,12 +122,7 @@ fn version_lte(a: &str, b: &str) -> bool {
true // equal
}
/// Map the app's resolved UI language code to an NSIS installer language ID.
///
/// The Windows installer only ships SimpChinese, English and Russian
/// (`tauri.windows.conf.json` `nsis.languages`). Languages without a matching
/// NSIS translation fall back to English; Traditional Chinese ("zhtw") is
/// mapped to SimpChinese so Chinese users don't get an English installer.
/// Maps UI language to one of the three NSIS translations, defaulting to English.
#[cfg(target_os = "windows")]
fn nsis_language_id(app_language: &str) -> &'static str {
match app_language {
@@ -142,12 +132,8 @@ fn nsis_language_id(app_language: &str) -> &'static str {
}
}
// ─── Startup Install & Cache Management ─────────────────────────────────────
impl SilentUpdater {
/// Called at app startup. If a cached update exists and is newer than the current version,
/// attempt to install it immediately (before the main app initializes).
/// Returns true if install was triggered (app should relaunch), false otherwise.
/// Installs a newer cached update before normal startup, if the user confirms.
pub async fn try_install_on_startup(&self, app_handle: &tauri::AppHandle) -> bool {
let current_version = env!("CARGO_PKG_VERSION");
@@ -178,14 +164,12 @@ impl SilentUpdater {
current_version
);
// Ask user for confirmation — they can skip and use the app normally.
// The cache is preserved so next launch will ask again.
// Preserve the cache when skipped so the next launch asks again.
if !Self::ask_user_to_install(app_handle, cached_version).await {
logging!(info, Type::System, "User skipped update install, starting normally");
return false;
}
// Read cached bytes
let bytes = match Self::read_cache_bytes() {
Ok(b) => b,
Err(e) => {
@@ -199,14 +183,8 @@ impl SilentUpdater {
}
};
// Need a fresh Update object from the server to call install().
// This is a lightweight HTTP request (< 1s), not a re-download.
//
// The updater is built with the app's current language forwarded as
// `/LANG=<NSIS-lang-id>`. On Windows the NSIS installer parses this in
// `.onInit` (see `packages/windows/installer.nsi`) and sets `$LANGUAGE`
// directly, skipping its language-selection dialog so the update starts
// without prompting. Other platforms don't run an installer.
// Refresh metadata without re-downloading. Windows receives `/LANG` to suppress the
// NSIS language dialog; see `packages/windows/installer.nsi`.
let updater_builder = app_handle.updater_builder();
#[cfg(target_os = "windows")]
let updater_builder = {
@@ -245,8 +223,7 @@ impl SilentUpdater {
}
};
// Verify the server's version matches the cached version.
// If server now has a newer version, our cached bytes are stale.
// A changed server version invalidates the cached bytes.
if update.version != *cached_version {
logging!(
info,
@@ -262,11 +239,9 @@ impl SilentUpdater {
let version = update.version.clone();
logging!(info, Type::System, "Installing cached update v{version} at startup...");
// Show splash window so user knows the app is updating, not frozen
Self::show_update_splash(app_handle, &version);
// install() is sync and may hang (known bug #2558), so run with a timeout.
// On Windows, NSIS takes over the process so install() may never return — that's OK.
// `install()` may hang (#2558); on Windows NSIS can take over without returning.
let install_result = tokio::task::spawn_blocking({
let bytes = bytes.clone();
let update = update.clone();
@@ -305,7 +280,6 @@ impl SilentUpdater {
}
};
// Close splash window if install failed and app continues normally
if !success {
Self::close_update_splash(app_handle);
}
@@ -314,11 +288,7 @@ impl SilentUpdater {
}
}
// ─── User Confirmation Dialog ────────────────────────────────────────────────
impl SilentUpdater {
/// Show a native dialog asking the user to install or skip the update.
/// Returns true if user chose to install, false if they chose to skip.
async fn ask_user_to_install(app_handle: &tauri::AppHandle, version: &str) -> bool {
use tauri_plugin_dialog::{DialogExt as _, MessageDialogButtons, MessageDialogKind};
@@ -343,12 +313,8 @@ impl SilentUpdater {
}
}
// ─── Update Splash Window ────────────────────────────────────────────────────
impl SilentUpdater {
/// Show a small centered splash window indicating update is being installed.
/// Injects HTML via eval() after window creation so it doesn't depend on any
/// external file in the bundle.
/// Uses injected HTML so the splash has no bundled-file dependency.
fn show_update_splash(app_handle: &tauri::AppHandle, version: &str) {
use tauri::{WebviewUrl, WebviewWindowBuilder};
@@ -405,7 +371,7 @@ impl SilentUpdater {
"#
);
// Retry eval a few times — the webview may not be ready immediately
// The new webview may not accept evaluation immediately.
std::thread::spawn(move || {
for i in 0..10 {
std::thread::sleep(std::time::Duration::from_millis(100 * (i + 1)));
@@ -418,7 +384,6 @@ impl SilentUpdater {
logging!(info, Type::System, "Update splash window shown");
}
/// Close the update splash window (e.g. after install failure).
fn close_update_splash(app_handle: &tauri::AppHandle) {
use tauri::Manager as _;
if let Some(window) = app_handle.get_webview_window("update-splash") {
@@ -428,8 +393,6 @@ impl SilentUpdater {
}
}
// ─── Background Check and Download ───────────────────────────────────────────
impl SilentUpdater {
async fn check_and_download(&self, app_handle: &tauri::AppHandle) -> Result<()> {
let is_portable = *dirs::PORTABLE_FLAG.get().unwrap_or(&false);
@@ -535,8 +498,6 @@ impl SilentUpdater {
mod tests {
use super::*;
// ─── version_lte tests ──────────────────────────────────────────────────
#[test]
fn test_version_equal() {
assert!(version_lte("2.4.7", "2.4.7"));
@@ -565,10 +526,8 @@ mod tests {
#[test]
fn test_version_with_prerelease() {
// "2.4.8-alpha" → numeric part is still "2.4.8"
assert!(version_lte("2.4.7", "2.4.8-alpha"));
assert!(version_lte("2.4.8-alpha", "2.4.8"));
// Both have same numeric part, so equal → true
assert!(version_lte("2.4.8-alpha", "2.4.8-beta"));
}
@@ -579,8 +538,6 @@ mod tests {
assert!(version_lte("2.4.0", "2.4"));
}
// ─── Cache metadata tests ───────────────────────────────────────────────
#[test]
fn test_cache_meta_serialize_roundtrip() {
let meta = UpdateCacheMeta {
+4 -33
View File
@@ -133,16 +133,13 @@ impl CoreConfigValidator {
}
impl CoreConfigValidator {
/// 检查文件是否为脚本文件
async fn is_script_file(path: &str) -> Result<bool> {
// 1. 先通过扩展名快速判断
if has_ext(path, "yaml") || has_ext(path, "yml") {
return Ok(false); // YAML文件不是脚本文件
return Ok(false);
} else if has_ext(path, "js") {
return Ok(true); // JS文件是脚本文件
return Ok(true);
}
// 2. 读取文件内容
let content = match fs::read_to_string(path).await {
Ok(content) => content,
Err(err) => {
@@ -151,13 +148,11 @@ impl CoreConfigValidator {
}
};
// 3. 检查是否存在明显的YAML特征
let has_yaml_features = content.contains(": ")
|| content.contains("#")
|| content.contains("---")
|| content.lines().any(|line| line.trim().starts_with("- "));
// 4. 检查是否存在明显的JS特征
let has_js_features = content.contains("function ")
|| content.contains("const ")
|| content.contains("let ")
@@ -168,16 +163,11 @@ impl CoreConfigValidator {
|| content.contains("export ")
|| content.contains("import ");
// 5. 决策逻辑
if has_yaml_features && !has_js_features {
// 只有YAML特征,没有JS特征
return Ok(false);
} else if has_js_features && !has_yaml_features {
// 只有JS特征,没有YAML特征
return Ok(true);
} else if has_yaml_features && has_js_features {
// 两种特征都有,需要更精细判断
// 优先检查是否有明确的JS结构特征
if content.contains("function main")
|| content.contains("module.exports")
|| content.contains("export default")
@@ -185,24 +175,21 @@ impl CoreConfigValidator {
return Ok(true);
}
// 检查冒号后是否有空格(YAML的典型特征)
let yaml_pattern_count = content.lines().filter(|line| line.contains(": ")).count();
if yaml_pattern_count > 2 {
return Ok(false); // 多个键值对格式,更可能是YAML
return Ok(false);
}
}
// 默认情况:无法确定时,假设为非脚本文件(更安全)
// Unknown files take the safer YAML validation path.
logging!(debug, Type::Validate, "无法确定文件类型,默认当作YAML处理: {}", path);
Ok(false)
}
/// 只进行文件语法检查,不进行完整验证
async fn validate_file_syntax_outcome(config_path: &str) -> Result<ValidationOutcome> {
logging!(info, Type::Validate, "开始检查文件: {}", config_path);
// 读取文件内容
let content = match fs::read_to_string(config_path).await {
Ok(content) => content,
Err(err) => {
@@ -211,7 +198,6 @@ impl CoreConfigValidator {
return Ok(ValidationOutcome::invalid_from_message(error_msg));
}
};
// 对YAML文件尝试解析,只检查语法正确性
logging!(info, Type::Validate, "进行YAML语法检查");
match serde_yaml_ng::from_str::<serde_yaml_ng::Value>(&content) {
Ok(_) => {
@@ -226,9 +212,7 @@ impl CoreConfigValidator {
}
}
/// 验证脚本文件语法
async fn validate_script_file_outcome(path: &str) -> Result<ValidationOutcome> {
// 读取脚本内容
let content = match fs::read_to_string(path).await {
Ok(content) => content,
Err(err) => {
@@ -240,7 +224,6 @@ impl CoreConfigValidator {
logging!(debug, Type::Validate, "验证脚本文件: {}", path);
// 使用boa引擎进行基本语法检查
use boa_engine::{Context, Source};
let mut context = Context::default();
@@ -258,7 +241,6 @@ impl CoreConfigValidator {
Ok(_) => {
logging!(debug, Type::Validate, "脚本语法验证通过: {}", path);
// 检查脚本是否包含main函数
if !content.contains("function main")
&& !content.contains("const main")
&& !content.contains("let main")
@@ -278,12 +260,10 @@ impl CoreConfigValidator {
}
}
/// 验证指定的配置文件
pub async fn validate_config_file_outcome(
config_path: &str,
is_merge_file: Option<bool>,
) -> Result<ValidationOutcome> {
// 检查程序是否正在退出,如果是则跳过验证
if handle::Handle::global().is_exiting() {
logging!(info, Type::Core, "应用正在退出,跳过验证");
return Ok(ValidationOutcome::Skipped {
@@ -291,13 +271,11 @@ impl CoreConfigValidator {
});
}
// 检查文件是否存在
if !std::path::Path::new(config_path).exists() {
let error_msg: String = format!("File not found: {config_path}").into();
return Ok(ValidationOutcome::invalid_from_message(error_msg));
}
// 如果是合并文件且不是强制验证,执行语法检查但不进行完整验证
if is_merge_file.unwrap_or(false) {
logging!(info, Type::Validate, "检测到Merge文件,仅进行语法检查: {}", config_path);
return Self::validate_file_syntax_outcome(config_path).await;
@@ -306,7 +284,6 @@ impl CoreConfigValidator {
let is_script = match Self::is_script_file(config_path).await {
Ok(result) => result,
Err(err) => {
// 如果无法确定文件类型,尝试使用Clash内核验证
logging!(warn, Type::Validate, "无法确定文件类型: {}, 错误: {}", config_path, err);
return Self::validate_config_internal_outcome(config_path).await;
}
@@ -322,14 +299,11 @@ impl CoreConfigValidator {
return Self::validate_script_file_outcome(config_path).await;
}
// 对YAML配置文件使用Clash内核验证
logging!(info, Type::Validate, "使用Clash内核验证配置文件: {}", config_path);
Self::validate_config_internal_outcome(config_path).await
}
/// 内部验证配置文件的实现
async fn validate_config_internal_outcome(config_path: &str) -> Result<ValidationOutcome> {
// 检查程序是否正在退出,如果是则跳过验证
if handle::Handle::global().is_exiting() {
logging!(info, Type::Validate, "应用正在退出,跳过验证");
return Ok(ValidationOutcome::Skipped {
@@ -347,7 +321,6 @@ impl CoreConfigValidator {
let app_dir_str = dirs::path_to_str(&app_dir)?;
logging!(info, Type::Validate, "验证目录: {}", app_dir_str);
// 使用子进程运行clash验证配置
let command = app_handle.shell().sidecar(clash_core.as_str()).map_err(|error| {
anyhow::anyhow!("failed to build validation command for core {clash_core:?}: {error:#}")
})?;
@@ -363,7 +336,6 @@ impl CoreConfigValidator {
let stderr = &output.stderr;
let stdout = &output.stdout;
// 检查进程退出状态和错误输出
let error_keywords = ["FATA", "fatal", "Parse config error", "level=fatal"];
let has_error = !status.success() || contains_any_keyword(stderr, &error_keywords);
@@ -399,7 +371,6 @@ impl CoreConfigValidator {
}
}
/// 验证运行时配置
pub async fn validate_config_outcome(&self) -> Result<ValidationOutcome> {
if !self.try_start() {
logging!(info, Type::Validate, "验证已在进行中,跳过新的验证请求");
-36
View File
@@ -28,42 +28,6 @@ pub enum ChainSupport {
ClashMetaAlpha,
}
// impl From<&PrfItem> for Option<ChainItem> {
// fn from(item: &PrfItem) -> Self {
// let itype = item.itype.as_ref()?.as_str();
// let file = item.file.clone()?;
// let uid = item.uid.clone().unwrap_or("".into());
// let path = dirs::app_profiles_dir().ok()?.join(file);
// if !path.exists() {
// return None;
// }
// match itype {
// "script" => Some(ChainItem {
// uid,
// data: ChainType::Script(fs::read_to_string(path).ok()?),
// }),
// "merge" => Some(ChainItem {
// uid,
// data: ChainType::Merge(help::read_mapping(&path).ok()?),
// }),
// "rules" => Some(ChainItem {
// uid,
// data: ChainType::Rules(help::read_seq_map(&path).ok()?),
// }),
// "proxies" => Some(ChainItem {
// uid,
// data: ChainType::Proxies(help::read_seq_map(&path).ok()?),
// }),
// "groups" => Some(ChainItem {
// uid,
// data: ChainType::Groups(help::read_seq_map(&path).ok()?),
// }),
// _ => None,
// }
// }
// }
// Helper trait to allow async conversion
pub trait AsyncChainItemFrom {
async fn from_async(item: &PrfItem) -> Option<ChainItem>;
+2 -14
View File
@@ -521,12 +521,10 @@ fn merge_default_config(
continue;
}
}
// 处理 external-controller 键的开关逻辑
if key.as_str() == Some("external-controller") {
if enable_external_controller {
config.insert(key, value);
} else {
// 如果禁用了外部控制器,设置为空字符串
config.insert(key, "".into());
}
} else {
@@ -656,7 +654,7 @@ fn ensure_fake_ip_range6(dns: &mut Mapping) {
.map(|m| m == "fake-ip")
.unwrap_or(true);
// 缺失或为空字符串(可能来自手动编辑的 YAML)时都需要补充
// Hand-edited YAML may leave the key present but empty.
let range6_missing = dns
.get("fake-ip-range6")
.and_then(|v| v.as_str())
@@ -702,10 +700,8 @@ async fn apply_dns_settings(mut config: Mapping, enable_dns_settings: bool) -> M
config
}
/// Enhance mode
/// 返回最终订阅、该订阅包含的键、和script执行的结果
/// Returns the enhanced profile, its original keys, and script logs.
pub async fn enhance(profiles: &IProfiles) -> Result<(Mapping, HashSet<String>, HashMap<String, ResultLog>)> {
// gather config values
let cfg_vals = get_config_values().await;
let ConfigValues {
clash_config,
@@ -722,7 +718,6 @@ pub async fn enhance(profiles: &IProfiles) -> Result<(Mapping, HashSet<String>,
tproxy_enabled,
} = cfg_vals;
// collect profile items
let profile = collect_profile_items(profiles).await?;
let config = profile.config;
let merge_item = profile.merge_item;
@@ -736,11 +731,9 @@ pub async fn enhance(profiles: &IProfiles) -> Result<(Mapping, HashSet<String>,
let result_map = HashMap::new();
// 顺序项先于手动覆盖。
let config = process_seq_items(config, rules_item, proxies_item, groups_item);
let exists_keys = use_keys(&config).collect::<Vec<_>>();
// merge default clash config
let config = merge_default_config(
config,
clash_config,
@@ -753,15 +746,12 @@ pub async fn enhance(profiles: &IProfiles) -> Result<(Mapping, HashSet<String>,
tproxy_enabled,
);
// app 生成项先于手动覆盖。
let config = apply_builtin_scripts(config, clash_core, enable_builtin).await;
let config = use_tun(config, enable_tun);
let config = apply_dns_settings(config, enable_dns_settings).await;
// 手动覆盖前锁定 app 权威字段,覆盖后由同一个值恢复。
let authoritative = AuthoritativeFields::capture(&config, enable_dns_settings);
// 全局手动覆盖。
let (config, exists_keys, result_map) = process_global_items(
config,
exists_keys,
@@ -772,11 +762,9 @@ pub async fn enhance(profiles: &IProfiles) -> Result<(Mapping, HashSet<String>,
)
.await;
// 当前 profile 手动覆盖。
let (config, exists_keys, result_map) =
process_profile_items(config, exists_keys, result_map, merge_item, script_item, &profile_name).await;
// 手动覆盖后恢复 app 权威字段。
let config = authoritative.enforce(config);
let config = ensure_lan_bind_address(config);
-7
View File
@@ -18,7 +18,6 @@ pub fn use_tun(mut config: Mapping, enable: bool) -> Mapping {
});
if enable {
// 读取DNS配置
let dns_key = Value::from("dns");
let dns_val = config.get(&dns_key);
let mut dns_val = dns_val.map_or_else(Mapping::new, |val| {
@@ -27,13 +26,11 @@ pub fn use_tun(mut config: Mapping, enable: bool) -> Mapping {
let ipv6_key = Value::from("ipv6");
let ipv6_val = config.get(&ipv6_key).and_then(|v| v.as_bool()).unwrap_or(false);
// 检查现有的 enhanced-mode 设置
let current_mode = dns_val
.get(Value::from("enhanced-mode"))
.and_then(|v| v.as_str())
.unwrap_or("fake-ip");
// 只有当 enhanced-mode 是 fake-ip 或未设置时才修改 DNS 配置
if current_mode == "fake-ip" || !dns_val.contains_key(Value::from("enhanced-mode")) {
revise!(dns_val, "enable", true);
revise!(dns_val, "ipv6", ipv6_val);
@@ -46,7 +43,6 @@ pub fn use_tun(mut config: Mapping, enable: bool) -> Mapping {
revise!(dns_val, "fake-ip-range", "198.18.0.1/16");
}
// 当启用 IPv6 时,补充 IPv6 的 fake-ip 范围
if ipv6_val && !dns_val.contains_key(Value::from("fake-ip-range6")) {
revise!(dns_val, "fake-ip-range6", "fdfe:dcba:9876::1/64");
}
@@ -60,17 +56,14 @@ pub fn use_tun(mut config: Mapping, enable: bool) -> Mapping {
}
}
// 当TUN启用时,将修改后的DNS配置写回
revise!(config, "dns", dns_val);
} else {
// TUN未启用时,仅恢复系统DNS,不修改配置文件中的DNS设置
#[cfg(target_os = "macos")]
AsyncHandler::spawn(move || async move {
crate::utils::resolve::dns::restore_public_dns().await;
});
}
// 更新TUN配置
revise!(tun_val, "enable", enable);
revise!(config, "tun", tun_val);
+4 -22
View File
@@ -24,15 +24,13 @@ pub struct LocalBackupFile {
pub content_length: u64,
}
/// Load restored verge.yaml from disk, merge back WebDAV creds, save, and sync memory.
/// Also reload other restored configs so restarts won't overwrite them.
/// Reloads restored configs into memory while preserving WebDAV credentials.
async fn finalize_restored_verge_config(
webdav_url: Option<String>,
webdav_username: Option<String>,
webdav_password: Option<String>,
) -> Result<()> {
// Do NOT silently fallback to defaults; a broken/missing verge.yaml means restore failed.
// Propagate the error so the UI/user can react accordingly.
// A broken restored `verge.yaml` is a restore failure, not a reason to load defaults.
let mut restored = help::read_yaml::<IVerge>(&verge_path()?).await?;
restored.webdav_url = webdav_url;
restored.webdav_username = webdav_username;
@@ -59,8 +57,7 @@ async fn finalize_restored_verge_config(
});
verge_draft.apply();
// Ensure side-effects (flags, tray, sysproxy, hotkeys, auto-backup refresh, etc.) run.
// Use not_save_file = true to avoid extra I/O (we already persisted the restored file).
// Run configuration side effects without rewriting the already-restored file.
if let Err(err) = super::patch_verge(&restored, true).await {
logging!(error, Type::Backup, "Failed to apply restored verge config: {err:#?}");
// Propagate unknown proxy state; ordinary side-effect failures stay logged.
@@ -71,7 +68,6 @@ async fn finalize_restored_verge_config(
Ok(())
}
/// Create a backup and upload to WebDAV
pub async fn create_backup_and_upload_webdav() -> Result<()> {
let (file_name, temp_file_path) = backup::create_backup().await.map_err(|err| {
logging!(error, Type::Backup, "Failed to create backup: {err:#?}");
@@ -83,7 +79,6 @@ pub async fn create_backup_and_upload_webdav() -> Result<()> {
.await
{
logging!(error, Type::Backup, "Failed to upload to WebDAV: {err:#?}");
// 上传失败时重置客户端缓存
backup::WebDavClient::global().reset();
return Err(err);
}
@@ -95,7 +90,6 @@ pub async fn create_backup_and_upload_webdav() -> Result<()> {
Ok(())
}
/// List WebDAV backups
pub async fn list_wevdav_backup() -> Result<Vec<ListFile>> {
backup::WebDavClient::global().list().await.map_err(|err| {
logging!(error, Type::Backup, "Failed to list WebDAV backup files: {err:#?}");
@@ -103,7 +97,6 @@ pub async fn list_wevdav_backup() -> Result<Vec<ListFile>> {
})
}
/// Delete WebDAV backup
pub async fn delete_webdav_backup(filename: String) -> Result<()> {
backup::WebDavClient::global().delete(filename).await.map_err(|err| {
logging!(error, Type::Backup, "Failed to delete WebDAV backup file: {err:#?}");
@@ -111,7 +104,6 @@ pub async fn delete_webdav_backup(filename: String) -> Result<()> {
})
}
/// Restore WebDAV backup
pub async fn restore_webdav_backup(filename: String) -> Result<()> {
let verge = Config::verge().await;
let verge_data = verge.latest_arc();
@@ -130,19 +122,16 @@ pub async fn restore_webdav_backup(filename: String) -> Result<()> {
err
})?;
// extract zip file
let value = backup_storage_path.clone();
let file = AsyncHandler::spawn_blocking(move || std::fs::File::open(&value)).await??;
let mut zip = zip::ZipArchive::new(file)?;
let _profile_write = crate::config::profiles::PROFILE_WRITE_LOCK.lock().await;
zip.extract(app_home_dir()?)?;
let res = finalize_restored_verge_config(webdav_url, webdav_username, webdav_password).await;
// Finally remove the temp file (attempt cleanup even if finalize fails)
let _ = backup_storage_path.remove_if_exists().await;
res
}
/// Create a backup and save to local storage
pub async fn create_local_backup() -> Result<()> {
create_local_backup_with_namer(|name| name.to_string().into())
.await
@@ -164,7 +153,6 @@ where
if let Err(err) = move_file(temp_file_path.clone(), target_path.clone()).await {
logging!(error, Type::Backup, "Failed to move local backup file: {err:#?}");
// 清理临时文件
if let Err(clean_err) = temp_file_path.remove_if_exists().await {
logging!(
warn,
@@ -178,7 +166,6 @@ where
Ok(final_name)
}
/// Import an existing backup file into the local backup directory
pub async fn import_local_backup(source: String) -> Result<String> {
let source_path = PathBuf::from(source.as_str());
if !source_path.exists() {
@@ -206,7 +193,6 @@ pub async fn import_local_backup(source: String) -> Result<String> {
let target_path = backup_dir.join(file_name);
if target_path == source_path {
// Already located in the backup directory
return Ok(file_name.to_string().into());
}
@@ -233,7 +219,7 @@ async fn move_file(from: PathBuf, to: PathBuf) -> Result<()> {
match fs::rename(&from, &to).await {
Ok(_) => Ok(()),
Err(rename_err) => {
// Attempt copy + remove as fallback, covering cross-device moves
// Rename can fail across filesystems; fall back to copy then remove.
logging!(
warn,
Type::Backup,
@@ -250,7 +236,6 @@ async fn move_file(from: PathBuf, to: PathBuf) -> Result<()> {
}
}
/// List local backups
pub async fn list_local_backup() -> Result<Vec<LocalBackupFile>> {
let backup_dir = local_backup_dir()?;
if !backup_dir.exists() {
@@ -286,7 +271,6 @@ pub async fn list_local_backup() -> Result<Vec<LocalBackupFile>> {
Ok(backups)
}
/// Delete local backup
pub async fn delete_local_backup(filename: String) -> Result<()> {
let backup_dir = local_backup_dir()?;
let target_path = backup_dir.join(filename.as_str());
@@ -298,7 +282,6 @@ pub async fn delete_local_backup(filename: String) -> Result<()> {
Ok(())
}
/// Restore local backup
pub async fn restore_local_backup(filename: String) -> Result<()> {
let backup_dir = local_backup_dir()?;
let target_path = backup_dir.join(filename.as_str());
@@ -324,7 +307,6 @@ pub async fn restore_local_backup(filename: String) -> Result<()> {
Ok(())
}
/// Export local backup file to user selected destination
pub async fn export_local_backup(filename: String, destination: String) -> Result<()> {
let backup_dir = local_backup_dir()?;
let source_path = backup_dir.join(filename.as_str());
+1 -11
View File
@@ -23,7 +23,6 @@ static TLS_CONFIG: Lazy<Arc<rustls::ClientConfig>> = Lazy::new(|| {
Arc::new(config)
});
/// Restart the Clash core
pub async fn restart_clash_core() {
match CoreManager::global().restart_core().await {
Ok(_) => {
@@ -37,10 +36,8 @@ pub async fn restart_clash_core() {
}
}
/// Restart the application
pub async fn restart_app() {
logging!(debug, Type::System, "启动重启应用流程");
// 设置退出标志
handle::Handle::global().set_is_exiting();
Config::apply_all_and_save_file().await;
@@ -84,14 +81,10 @@ fn after_change_clash_mode() {
});
}
/// Change Clash mode (rule/global/direct/script)
///
/// mihomo `/configs` PATCH 失败时返回 `Err`,以便命令层把失败上抛给前端。
/// (此前该函数吞掉错误并始终视为成功,导致 UI 误判"切换成功"、看似"切不动"。)
/// Propagates mihomo PATCH failures so the frontend can roll back its optimistic mode.
pub async fn change_clash_mode(mode: String) -> Result<(), String> {
let mut mapping = Mapping::new();
mapping.insert(Value::from("mode"), Value::from(mode.as_str()));
// Convert YAML mapping to JSON Value
let json_value = serde_json::json!({
"mode": mode
});
@@ -101,12 +94,10 @@ pub async fn change_clash_mode(mode: String) -> Result<(), String> {
return Err(err.to_string().into());
}
// 更新订阅
let clash = Config::clash().await;
clash.edit_draft(|d| d.patch_config(&mapping));
clash.apply();
// 分离数据获取和异步调用
let clash_data = clash.data_arc();
if clash_data.save_config().await.is_ok() {
handle::Handle::refresh_clash();
@@ -184,7 +175,6 @@ pub async fn test_delay(url: String) -> anyhow::Result<u32> {
let _ = stream.read(&mut buf).await?;
}
// frontend treats 0 as timeout
Ok((start.elapsed().as_millis() as u32).max(1))
})
.await
+2 -15
View File
@@ -14,13 +14,11 @@ use clash_verge_logging::{Type, logging};
use std::env;
use tauri_plugin_clipboard_manager::ClipboardExt as _;
/// Toggle system proxy, returning `None` when it does not change.
pub async fn toggle_system_proxy() -> Option<bool> {
let verge = Config::verge().await;
let current = verge.latest_arc().enable_system_proxy.unwrap_or(false);
let auto_close_connection = verge.latest_arc().auto_close_connection.unwrap_or(false);
// 如果当前系统代理即将关闭,且自动关闭连接设置为true,则关闭所有连接
if current
&& auto_close_connection
&& let Err(err) = handle::Handle::mihomo().close_all_connections().await
@@ -42,7 +40,6 @@ pub async fn toggle_system_proxy() -> Option<bool> {
.await;
match patch_result {
// Patch processing refreshes every caller.
Ok(_) => Some(requested),
Err(err) => {
logging!(error, Type::ProxyMode, "{err:#}");
@@ -52,7 +49,6 @@ pub async fn toggle_system_proxy() -> Option<bool> {
}
}
/// Notify only when a classified failure remains pending.
async fn report_toggle_failure(error: &anyhow::Error) {
let recorded = proxy_control::is_reportable(error);
@@ -69,8 +65,6 @@ const fn toggle_operation(requested: bool) -> FailedOperation {
}
}
/// Toggle TUN mode on/off
/// Returns the updated toggle state
pub async fn toggle_tun_mode(not_save_file: Option<bool>) -> bool {
let current = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false);
let enable = !current;
@@ -86,11 +80,7 @@ pub async fn toggle_tun_mode(not_save_file: Option<bool>) -> bool {
{
Ok(_) => {
handle::Handle::refresh_verge();
// Read back rather than returning what was asked for: patching TUN reconciles it
// afterwards, and where TUN cannot work that reconciliation turns it straight off
// again. This path is not gated on availability — it is the global hotkey — so
// reporting the request would tell the caller TUN is on moments before the notice
// saying it was disabled.
// Reconciliation may immediately disable unavailable TUN; report the resulting state.
Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false)
}
Err(err) => {
@@ -100,7 +90,6 @@ pub async fn toggle_tun_mode(not_save_file: Option<bool>) -> bool {
}
}
/// Copy proxy environment variables to clipboard
pub async fn copy_clash_env() {
let env_ip = env::var("CLASH_VERGE_REV_IP").ok();
let verge_cfg = Config::verge().await.latest_arc();
@@ -109,9 +98,7 @@ pub async fn copy_clash_env() {
.unwrap_or_else(|| verge_cfg.proxy_host.as_deref().unwrap_or("127.0.0.1"));
let app_handle = handle::Handle::app_handle();
// The user is about to paste this into a shell, so it has to be the port the Core is
// really on — and this path is user-triggered, so a round-trip is affordable. It also
// used to fall back to a hardcoded 7897, ignoring the Merge Config entirely.
// Clipboard output must use the core's live port, including merge-config overrides.
let port = MixedPort::effective().await;
let http_proxy = format!("http://{ip}:{port}");
let socks5_proxy = format!("socks5://{ip}:{port}");
+2 -26
View File
@@ -6,27 +6,20 @@ use tauri_plugin_mihomo::models::WsConnectionId;
use tokio::sync::mpsc;
use tokio::time::Instant;
/// Mihomo WebSocket 流的有界队列容量,避免异常场景下内存无限增长。
/// Bounds memory use if the consumer falls behind the WebSocket.
const MIHOMO_WS_STREAM_BUFFER_SIZE: usize = 8;
/// 断开 Mihomo WebSocket 连接时使用的关闭码(RFC 6455 标准正常关闭)。
const MIHOMO_WS_STREAM_CLOSE_CODE: u64 = 1000;
/// `/traffic` 即时速率事件(字节/秒)。
#[derive(Debug, Clone, Copy)]
pub struct TrafficSpeedEvent {
pub up: u64,
pub down: u64,
}
/// Mihomo WebSocket 流消费状态。
pub enum StreamConsumeState<T> {
/// 收到一条业务事件。
Event(T),
/// 连接关闭或消息流结束。
Closed,
/// 在超时时间内未收到有效事件,需要重连。
Stale,
/// 上层请求退出消费循环。
ExitRequested,
}
@@ -34,13 +27,9 @@ enum InternalWsEvent<T> {
Data(T),
}
/// Mihomo WebSocket 订阅句柄(通用事件流)。
pub struct MihomoWsEventStream<T> {
/// 当前订阅连接 ID,用于主动断开。
pub connection_id: WsConnectionId,
/// 当前订阅消息接收器。
receiver: mpsc::Receiver<InternalWsEvent<T>>,
/// 最近一次收到有效事件的时间戳。
last_valid_event_at: Instant,
}
@@ -61,19 +50,15 @@ fn parse_traffic_event(data: &[u8]) -> Option<InternalWsEvent<TrafficSpeedEvent>
fn try_send_internal_event<T>(message_tx: &mpsc::Sender<InternalWsEvent<T>>, event: InternalWsEvent<T>) {
if let Err(err) = message_tx.try_send(event) {
match err {
// 队列满时丢弃本次事件,下一次事件会继续覆盖更新。
// A later real-time sample supersedes one dropped from a full queue.
tokio::sync::mpsc::error::TrySendError::Full(_) => {}
// 任务已结束时通道可能关闭,忽略即可。
tokio::sync::mpsc::error::TrySendError::Closed(_) => {}
}
}
}
/// 建立 `/traffic` WebSocket 订阅(通用流)。
pub async fn connect_traffic_stream() -> Result<MihomoWsEventStream<TrafficSpeedEvent>> {
// 使用有界 mpsc 通道承接回调事件,限制消息积压上限。
let (message_tx, message_rx) = mpsc::channel::<InternalWsEvent<TrafficSpeedEvent>>(MIHOMO_WS_STREAM_BUFFER_SIZE);
// 建立 Mihomo `/traffic` WebSocket 订阅。
let connection_id = handle::Handle::mihomo()
.ws_traffic({
let message_tx = message_tx.clone();
@@ -93,11 +78,6 @@ pub async fn connect_traffic_stream() -> Result<MihomoWsEventStream<TrafficSpeed
}
impl<T> MihomoWsEventStream<T> {
/// 等待下一次可用事件或结束状态。
///
/// # Arguments
/// * `stale_timeout` - 无有效事件超时时间
/// * `should_exit` - 上层退出判定函数
pub async fn next_event<F>(&mut self, stale_timeout: Duration, should_exit: F) -> StreamConsumeState<T>
where
F: Fn() -> bool,
@@ -132,10 +112,6 @@ impl<T> MihomoWsEventStream<T> {
}
}
/// 断开指定 Mihomo WebSocket 连接。
///
/// # Arguments
/// * `connection_id` - 目标连接 ID
pub async fn disconnect_connection(connection_id: WsConnectionId) {
if let Err(err) = handle::Handle::mihomo()
.disconnect(connection_id, Some(MIHOMO_WS_STREAM_CLOSE_CODE))
+1 -16
View File
@@ -24,7 +24,6 @@ pub static PROFILE_YAML: &str = "profiles.yaml";
/// Marks that the one-shot raise of too-short auto-update intervals has already run.
pub static UPDATE_INTERVAL_MIGRATED: &str = ".update-interval-migrated";
/// init portable flag
pub fn init_portable_flag() -> Result<()> {
use tauri::utils::platform::current_exe;
@@ -40,7 +39,6 @@ pub fn init_portable_flag() -> Result<()> {
Ok(())
}
/// get the verge app home dir
pub fn app_home_dir() -> Result<PathBuf> {
use tauri::utils::platform::current_exe;
@@ -54,7 +52,7 @@ pub fn app_home_dir() -> Result<PathBuf> {
return Ok(PathBuf::from(app_dir).join(".config").join(APP_ID));
}
// 避免在Handle未初始化时崩溃
// Directory helpers can run before the Tauri handle is initialized.
let app_handle = handle::Handle::app_handle();
match app_handle.path().data_dir() {
@@ -88,9 +86,7 @@ pub fn preinit_app_data_dir() -> Result<PathBuf> {
Ok(root.join(APP_ID))
}
/// get the resources dir
pub fn app_resources_dir() -> Result<PathBuf> {
// 避免在Handle未初始化时崩溃
let app_handle = handle::Handle::app_handle();
match app_handle.path().resource_dir() {
@@ -102,12 +98,10 @@ pub fn app_resources_dir() -> Result<PathBuf> {
}
}
/// profiles dir
pub fn app_profiles_dir() -> Result<PathBuf> {
Ok(app_home_dir()?.join("profiles"))
}
/// icons dir
pub fn app_icons_dir() -> Result<PathBuf> {
Ok(app_home_dir()?.join("icons"))
}
@@ -131,29 +125,24 @@ pub fn find_target_icons(target: &str) -> Result<Option<String>> {
icon_path.map(|path| path_to_str(&path).map(|s| s.into())).transpose()
}
/// logs dir
pub fn app_logs_dir() -> Result<PathBuf> {
Ok(app_home_dir()?.join("logs"))
}
/// service logs dir
#[cfg(target_os = "macos")]
pub fn service_logs_root_dir() -> Result<PathBuf> {
Ok(app_home_dir()?.join("service-logs"))
}
/// service logs dir
#[cfg(not(target_os = "macos"))]
pub fn service_logs_root_dir() -> Result<PathBuf> {
app_logs_dir()
}
// latest verge log
pub fn app_latest_log() -> Result<PathBuf> {
Ok(app_logs_dir()?.join("latest.log"))
}
/// local backups dir
pub fn local_backup_dir() -> Result<PathBuf> {
let dir = app_home_dir()?.join(BACKUP_DIR);
fs::create_dir_all(&dir)?;
@@ -222,18 +211,14 @@ pub fn get_encryption_key() -> Result<Vec<u8>> {
let key_path = app_dir.join(".encryption_key");
if key_path.exists() {
// Read existing key
fs::read(&key_path).map_err(|e| anyhow::anyhow!("Failed to read encryption key: {}", e))
} else {
// Generate and save new key
let mut key = vec![0u8; 32];
getrandom::fill(&mut key)?;
// Ensure directory exists
if let Some(parent) = key_path.parent() {
fs::create_dir_all(parent).map_err(|e| anyhow::anyhow!("Failed to create key directory: {}", e))?;
}
// Save key
fs::write(&key_path, &key).map_err(|e| anyhow::anyhow!("Failed to save encryption key: {}", e))?;
Ok(key)
}
+3 -27
View File
@@ -9,7 +9,6 @@ use std::{
str::FromStr,
};
/// read data from yaml as struct T
pub async fn read_yaml<T: DeserializeOwned>(path: &PathBuf) -> Result<T> {
if !tokio::fs::try_exists(path).await.unwrap_or(false) {
bail!("file not found \"{}\"", path.display());
@@ -20,7 +19,6 @@ pub async fn read_yaml<T: DeserializeOwned>(path: &PathBuf) -> Result<T> {
Ok(with_encryption(|| async { serde_yaml_ng::from_str::<T>(&yaml_str) }).await?)
}
/// read mapping from yaml
pub async fn read_mapping(path: &PathBuf) -> Result<Mapping> {
if !tokio::fs::try_exists(path).await.unwrap_or(false) {
bail!("file not found \"{}\"", path.display());
@@ -30,7 +28,6 @@ pub async fn read_mapping(path: &PathBuf) -> Result<Mapping> {
.await
.with_context(|| format!("failed to read the file \"{}\"", path.display()))?;
// YAML语法检查
match serde_yaml_ng::from_str::<serde_yaml_ng::Value>(&yaml_str) {
Ok(mut val) => {
val.apply_merge()
@@ -52,13 +49,10 @@ pub async fn read_mapping(path: &PathBuf) -> Result<Mapping> {
}
}
/// read mapping from yaml fix #165
pub async fn read_seq_map(path: &PathBuf) -> Result<SeqMap> {
read_yaml(path).await
}
/// save the data to the file
/// can set `prefix` string to add some comments
pub async fn save_yaml<T: Serialize + Sync>(path: &PathBuf, data: &T, prefix: Option<&str>) -> Result<()> {
let data_str = with_encryption(|| async { serde_yaml_ng::to_string(data) }).await?;
@@ -90,14 +84,11 @@ const ALPHABET: [char; 62] = [
'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
];
/// generate the uid
pub fn get_uid(prefix: &str) -> String {
let id = nanoid!(11, &ALPHABET);
format!("{prefix}{id}")
}
/// parse the string
/// xxx=123123; => 123123
pub fn parse_str<T: FromStr>(target: &str, key: &str) -> Option<T> {
target.split(';').map(str::trim).find_map(|s| {
let mut parts = s.splitn(2, '=');
@@ -108,18 +99,13 @@ pub fn parse_str<T: FromStr>(target: &str, key: &str) -> Option<T> {
})
}
/// Mask sensitive parts of a subscription URL for safe logging.
/// Examples:
/// - `https://example.com/api/v1/clash?token=abc123` → `https://example.com/api/v1/clash?token=***`
/// - `https://example.com/abc123def456ghi789/clash` → `https://example.com/***/clash`
/// Masks query values and token-like path segments for logging.
pub fn mask_url(url: &str) -> String {
// Split off query string
let (path_part, query_part) = match url.find('?') {
Some(pos) => (&url[..pos], Some(&url[pos + 1..])),
None => (url, None),
};
// Extract scheme+host prefix (everything up to the first '/' after "://")
let host_end = path_part
.find("://")
.and_then(|scheme_end| {
@@ -134,7 +120,6 @@ pub fn mask_url(url: &str) -> String {
let mut result = scheme_and_host.to_owned();
// Mask path segments that look like tokens (longer than 16 chars)
if !path.is_empty() {
let masked: Vec<&str> = path
.split('/')
@@ -143,7 +128,6 @@ pub fn mask_url(url: &str) -> String {
result.push_str(&masked.join("/"));
}
// Keep query param keys, mask values
if let Some(query) = query_part {
result.push('?');
let masked_query: Vec<String> = query
@@ -159,11 +143,7 @@ pub fn mask_url(url: &str) -> String {
result
}
/// Mask all URLs embedded in an error/log string for safe logging.
///
/// Scans the string for `http://` or `https://` and replaces each URL
/// (terminated by whitespace or `)`, `]`, `"`, `'`) with its masked form.
/// Text between URLs is copied verbatim.
/// Masks every HTTP(S) URL embedded in a log string while preserving surrounding text.
pub fn mask_err(err: &str) -> String {
let mut result = String::with_capacity(err.len());
let mut remaining = err;
@@ -194,9 +174,8 @@ pub fn mask_err(err: &str) -> String {
result
}
/// get the last part of the url, if not found, return empty string
pub fn get_last_part_and_decode(url: &str) -> Option<String> {
let path = url.split('?').next().unwrap_or(""); // Splits URL and takes the path part
let path = url.split('?').next().unwrap_or("");
let segments: Vec<&str> = path.split('/').collect();
let last_segment = segments.last()?;
@@ -207,7 +186,6 @@ pub fn get_last_part_and_decode(url: &str) -> Option<String> {
)
}
/// open file
pub fn open_file(path: PathBuf) -> Result<()> {
open::that_detached(path.as_os_str())?;
Ok(())
@@ -245,7 +223,6 @@ pub fn linux_elevator() -> String {
match Command::new("which").arg("pkexec").output() {
Ok(output) => {
if !output.stdout.is_empty() {
// Convert the output to a string slice
if let Ok(path) = std::str::from_utf8(&output.stdout) {
path.trim().to_string()
} else {
@@ -260,7 +237,6 @@ pub fn linux_elevator() -> String {
}
#[cfg(target_os = "windows")]
/// copy the file to the dist path and return the dist path
pub fn snapshot_path(original_path: &Path) -> Result<PathBuf> {
let temp_dir = original_path
.parent()
+3 -24
View File
@@ -1,4 +1,3 @@
// #[cfg(not(feature = "tracing"))]
use crate::{
config::{Config, IClashTemp, IProfiles, IVerge},
constants,
@@ -43,7 +42,6 @@ async fn delete_snapshot_logs(log_dir: &Path) -> Result<()> {
}
// TODO flexi_logger 提供了最大保留天数,或许我们应该用内置删除log文件
/// 删除log文件
pub async fn delete_log() -> Result<()> {
let log_dir = dirs::app_logs_dir()?;
let service_log_dir = dirs::service_log_dir()?;
@@ -63,7 +61,6 @@ pub async fn delete_log() -> Result<()> {
verge.auto_log_clean.unwrap_or(0)
};
// 1: 1天, 2: 7天, 3: 30天, 4: 90天
let day = match auto_log_clean {
1 => 1,
2 => 7,
@@ -74,7 +71,6 @@ pub async fn delete_log() -> Result<()> {
logging!(info, Type::Setup, "try to delete log files, day: {}", day);
// %Y-%m-%d to NaiveDateTime
let parse_time_str = |s: &str| {
let sa: Vec<&str> = s.split('-').collect();
if sa.len() != 4 {
@@ -320,14 +316,12 @@ async fn migrate_legacy_macos_logs() -> Result<()> {
Ok(())
}
/// 初始化DNS配置文件
pub(super) async fn init_dns_config() -> Result<()> {
use serde_yaml_ng::Value;
// 创建DNS子配置
let dns_config = serde_yaml_ng::Mapping::from_iter([
("enable".into(), Value::Bool(true)),
// 与前端 DNS 默认一致,并供 dns.ipv6 锁定使用
// Must match the frontend default used for authoritative `dns.ipv6`.
("ipv6".into(), Value::Bool(true)),
("listen".into(), Value::String(":53".into())),
("enhanced-mode".into(), Value::String("fake-ip".into())),
@@ -410,13 +404,11 @@ pub(super) async fn init_dns_config() -> Result<()> {
),
]);
// 获取默认DNS和host配置
let default_dns_config = serde_yaml_ng::Mapping::from_iter([
("dns".into(), Value::Mapping(dns_config)),
("hosts".into(), Value::Mapping(serde_yaml_ng::Mapping::new())),
]);
// 检查DNS配置文件是否存在
let app_dir = dirs::app_home_dir()?;
let dns_path = app_dir.join(constants::files::DNS_CONFIG);
@@ -428,7 +420,6 @@ pub(super) async fn init_dns_config() -> Result<()> {
Ok(())
}
/// 确保目录结构存在
async fn ensure_directories() -> Result<()> {
let directories = [
("app_home", dirs::app_home_dir()?),
@@ -449,7 +440,6 @@ async fn ensure_directories() -> Result<()> {
Ok(())
}
/// 初始化配置文件
async fn initialize_config_files() -> Result<()> {
if let Ok(path) = dirs::clash_path()
&& !path.exists()
@@ -481,7 +471,6 @@ async fn initialize_config_files() -> Result<()> {
logging!(info, Type::Setup, "Created profiles config at {:?}", path);
}
// 验证并修正verge配置
IVerge::validate_and_fix_config()
.await
.map_err(|e| anyhow::anyhow!("Failed to validate verge config: {}", e))?;
@@ -489,14 +478,8 @@ async fn initialize_config_files() -> Result<()> {
Ok(())
}
/// Initialize all the config files
/// before tauri setup
/// Initializes configuration required before Tauri setup.
pub async fn init_config() -> Result<()> {
// We do not need init_portable_flag here anymore due to lib.rs will to the things
// let _ = dirs::init_portable_flag();
// We do not need init_log here anymore due to resolve will to the things
#[cfg(target_os = "macos")]
migrate_legacy_macos_logs().await?;
@@ -514,8 +497,7 @@ pub async fn init_config() -> Result<()> {
Ok(())
}
/// initialize app resources
/// after tauri setup
/// Initializes application resources after Tauri setup.
pub async fn init_resources() -> Result<()> {
let app_dir = dirs::app_home_dir()?;
let res_dir = dirs::app_resources_dir()?;
@@ -529,8 +511,6 @@ pub async fn init_resources() -> Result<()> {
let file_list = ["Country.mmdb", "geoip.dat", "geosite.dat"];
// copy the resource file
// if the source file is newer than the destination file, copy it over
for file in file_list.iter() {
let src_path = res_dir.join(file);
let dest_path = app_dir.join(file);
@@ -559,7 +539,6 @@ pub async fn init_resources() -> Result<()> {
Ok(())
}
/// initialize url scheme
#[cfg(target_os = "windows")]
pub fn init_scheme() -> Result<()> {
use tauri::utils::platform::current_exe;
+9 -37
View File
@@ -4,17 +4,15 @@ use tauri::webview::PageLoadEvent;
use tauri::{Theme, WebviewWindow};
use crate::{config::Config, core::handle, utils::resolve::window_script::build_window_initial_script};
use clash_verge_logging::{Type, logging_error};
// logging 仅在 macOS 的渲染进程恢复逻辑中使用
#[cfg(target_os = "macos")]
use clash_verge_logging::logging;
use clash_verge_logging::{Type, logging_error};
const DARK_BACKGROUND_COLOR: Color = Color(46, 48, 61, 255); // #2E303D
const LIGHT_BACKGROUND_COLOR: Color = Color(245, 245, 245, 255); // #F5F5F5
const DARK_BACKGROUND_HEX: &str = "#2E303D";
const LIGHT_BACKGROUND_HEX: &str = "#F5F5F5";
// 定义默认窗口尺寸常量
const DEFAULT_WIDTH: f64 = 940.0;
const DEFAULT_HEIGHT: f64 = 700.0;
@@ -46,7 +44,6 @@ fn restore_default_size_if_needed(window: &WebviewWindow) {
logging_error!(Type::Window, window.center());
}
/// 构建新的 WebView 窗口
pub async fn build_new_window() -> Result<WebviewWindow, String> {
let app_handle = handle::Handle::app_handle();
@@ -112,7 +109,7 @@ pub async fn build_new_window() -> Result<WebviewWindow, String> {
Ok(window) => {
logging_error!(Type::Window, window.set_background_color(Some(background_color)));
restore_default_size_if_needed(&window);
// 全新窗口的页面即为最新状态,丢弃旧窗口遗留的待重载标记,避免多余 reload
// A new page supersedes any reload marker left by the old window.
#[cfg(target_os = "macos")]
take_webview_needs_reload();
Ok(window)
@@ -121,40 +118,19 @@ pub async fn build_new_window() -> Result<WebviewWindow, String> {
}
}
/// 渲染进程死亡、页面待重载标记(macOS)
///
/// 渲染进程在窗口不可见时被系统终止后置位,由下次激活窗口的路径取走并执行 reload。
/// Defers recovery of a terminated hidden main webview until its next activation.
#[cfg(target_os = "macos")]
static WEBVIEW_NEEDS_RELOAD: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// 取出并清除"页面待重载"标记
///
/// # Returns
/// * `bool` - 渲染进程是否曾在窗口不可见时被系统终止、页面需要 reload
#[cfg(target_os = "macos")]
pub fn take_webview_needs_reload() -> bool {
WEBVIEW_NEEDS_RELOAD.swap(false, std::sync::atomic::Ordering::SeqCst)
}
/// WebView 渲染进程被系统终止后的恢复处理(macOS)
///
/// macOS 在内存压力下可能杀掉 WKWebView 的 WebContent 渲染进程:
/// 1. 页面内容层消失,窗口打开后表现为白屏;
/// 2. 前端 JS 状态随之丢失,无法调用 `ws_disconnect` 清理 Mihomo WebSocket 订阅,
/// 孤儿订阅持续把大于 1KB 的 payload(如 `/connections` 全量快照)塞进 tauri 的
/// `ChannelDataIpcQueue`,且没有存活的页面来取走,导致主进程内存无限增长。
///
/// 恢复策略:
/// * 窗口可见(前台被杀的罕见场景)——立即 reload 恢复页面;
/// * 窗口隐藏/最小化(托盘常驻的常见场景)——只置位待重载标记,等用户下次打开
/// 窗口时再 reload。系统正是因内存压力才杀掉不可见窗口的渲染进程,此时立即
/// 重建渲染进程既浪费内存,也可能形成"系统杀→拉起→再杀"的循环。
///
/// 注意:应用层注册 `on_web_content_process_terminate` 后会覆盖 tauri-runtime-wry
/// 的默认自动 reload 行为,因此页面死亡状态会一直保持到我们主动 reload。
///
/// # Arguments
/// * `webview` - 渲染进程被终止的 WebView
/// macOS may kill hidden WebContent under memory pressure. Clean orphaned Mihomo subscriptions,
/// reload visible webviews immediately, and defer a hidden main-window reload until activation.
/// Registering this callback replaces Tauri's default automatic reload.
#[cfg(target_os = "macos")]
pub fn on_web_content_process_terminated(webview: &tauri::Webview) {
if handle::Handle::global().is_exiting() {
@@ -171,18 +147,16 @@ pub fn on_web_content_process_terminated(webview: &tauri::Webview) {
let window = webview.window();
let is_user_visible = window.is_visible().unwrap_or(false) && !window.is_minimized().unwrap_or(false);
// 懒重载标记仅供主窗口;其它 webview(update-splash)无消费通道,不可见时直接 reload 兜底
// Only the main window has a path that consumes the deferred marker.
let is_main_window = webview.label() == "main";
let reload_now = is_user_visible || !is_main_window;
if !reload_now {
// 主窗口不可见:置标记,延迟到下次 activate_window / reload_main_window_if_needed 再 reload
WEBVIEW_NEEDS_RELOAD.store(true, std::sync::atomic::Ordering::SeqCst);
logging!(info, Type::Window, "窗口不可见,页面将在下次打开窗口时重载");
}
// 清理全部 Mihomo WS 订阅,阻断 ChannelDataIpcQueue 泄漏(托盘速率任务约 1s 后自重连)。
// reload 必须与清理同任务、排在其后,否则清理可能误清重载后新页面的订阅(竞态)。
// Clean before reload so cleanup cannot remove subscriptions created by the new page.
let webview = webview.clone();
crate::process::AsyncHandler::spawn(move || async move {
if let Err(err) = handle::Handle::mihomo().clear_all_ws_connections().await {
@@ -196,9 +170,7 @@ pub fn on_web_content_process_terminated(webview: &tauri::Webview) {
});
}
/// 消费待重载标记并 reload 主窗口(macOS)。
/// 兜底原生取消最小化(Dock 缩略图/调度中心/窗口菜单)只触发 Focused(true)、不走
/// activate_window 的情况。与 activate_window 共用 swap 标记,先取先得、不重复 reload。
/// Consumes the shared marker for native unminimize paths that bypass `activate_window`.
#[cfg(target_os = "macos")]
pub fn reload_main_window_if_needed() {
if !take_webview_needs_reload() {
+1 -16
View File
@@ -1,19 +1,4 @@
/// Macro to generate singleton pattern for structs
///
/// Usage:
/// ```rust,ignore
/// use crate::utils::singleton::singleton;
///
/// struct MyStruct {
/// value: i32,
/// }
/// impl MyStruct {
/// fn new() -> Self {
/// MyStruct { value: 0 }
/// }
/// }
/// singleton!(MyStruct, INSTANCE);
/// ```
/// Implements `global()` using the supplied `OnceCell` static.
#[macro_export]
macro_rules! singleton {
($struct_name:ty, $instance_name:ident) => {
+1 -12
View File
@@ -1,10 +1,6 @@
//! 网络速率格式化工具
/// 速率显示升档阈值:保证显示值不超过三位数(显示层约定,与换算基数无关)
/// Promote units before the rendered value exceeds three digits.
const SPEED_DISPLAY_THRESHOLD: f64 = 1000.0;
/// 速率展示单位顺序
const SPEED_UNITS: [&str; 5] = ["B/s", "K/s", "M/s", "G/s", "T/s"];
/// 预计算 1024 的幂次方,避免运行时重复计算 pow
const SCALES: [f64; 5] = [
1.0,
1024.0,
@@ -13,10 +9,6 @@ const SCALES: [f64; 5] = [
1024.0 * 1024.0 * 1024.0 * 1024.0,
];
/// 将字节/秒格式化为可读速率字符串
///
/// # Arguments
/// * `bytes_per_sec` - 每秒字节数
pub fn format_bytes_per_second(bytes_per_sec: u64) -> String {
if bytes_per_sec < SPEED_DISPLAY_THRESHOLD as u64 {
return format!("{bytes_per_sec}B/s");
@@ -47,7 +39,6 @@ mod tests {
fn format_handles_byte_boundaries() {
assert_eq!(format_bytes_per_second(0), "0B/s");
assert_eq!(format_bytes_per_second(999), "999B/s");
// 1000 >= SPEED_DISPLAY_THRESHOLD,升档为 K/s(保证不超过三位数)
assert_eq!(format_bytes_per_second(1000), "1.0K/s");
assert_eq!(format_bytes_per_second(1024), "1.0K/s");
}
@@ -55,7 +46,6 @@ mod tests {
#[test]
fn format_handles_decimal_and_integer_rules() {
assert_eq!(format_bytes_per_second(9 * 1024), "9.0K/s");
// 9.999 K/s:rounded_1dp = 10.0,不满足 < 10,应显示整数 "10K/s"
assert_eq!(format_bytes_per_second(10 * 1024 - 1), "10K/s");
assert_eq!(format_bytes_per_second(10 * 1024), "10K/s");
assert_eq!(format_bytes_per_second(123 * 1024), "123K/s");
@@ -63,7 +53,6 @@ mod tests {
#[test]
fn format_handles_unit_promotion_after_rounding() {
// 999.5 K/s 四舍五入为 1000,≥ SPEED_DISPLAY_THRESHOLD,升档为 1.0M/s
assert_eq!(format_bytes_per_second(999 * 1024 + 512), "1.0M/s");
assert_eq!(format_bytes_per_second(1024 * 1024), "1.0M/s");
assert_eq!(format_bytes_per_second(1536 * 1024), "1.5M/s");
-8
View File
@@ -1,6 +1,3 @@
//! Some config file template
/// template for new a profile item
pub const ITEM_LOCAL: &str = "# Profile Template for Clash Verge
proxies: []
@@ -10,7 +7,6 @@ proxy-groups: []
rules: []
";
/// enhanced profile
pub const ITEM_MERGE: &str = "# Profile Enhancement Merge Template for Clash Verge
profile:
@@ -21,7 +17,6 @@ pub const ITEM_MERGE_EMPTY: &str = "# Profile Enhancement Merge Template for Cla
";
/// enhanced profile
pub const ITEM_SCRIPT: &str = "// Define main function (script entry)
function main(config, profileName) {
@@ -29,7 +24,6 @@ function main(config, profileName) {
}
";
/// enhanced profile
pub const ITEM_RULES: &str = "# Profile Enhancement Rules Template for Clash Verge
prepend: []
@@ -39,7 +33,6 @@ append: []
delete: []
";
/// enhanced profile
pub const ITEM_PROXIES: &str = "# Profile Enhancement Proxies Template for Clash Verge
prepend: []
@@ -49,7 +42,6 @@ append: []
delete: []
";
/// enhanced profile
pub const ITEM_GROUPS: &str = "# Profile Enhancement Groups Template for Clash Verge
prepend: []
+5 -46
View File
@@ -1,7 +1,4 @@
//! macOS 托盘速率富文本渲染模块
//!
//! 通过 objc2 调用 NSAttributedString 实现托盘速率的富文本显示,
//! 支持等宽字体、跟随菜单栏外观配色、两行定宽布局。
//! macOS tray speed rendering via `NSAttributedString`.
use std::cell::RefCell;
@@ -17,36 +14,24 @@ use objc2_app_kit::{
};
use objc2_foundation::{NSAttributedString, NSDictionary, NSNumber, NSString};
/// 富文本渲染使用的字号(适配两行在托盘栏的高度)
const TRAY_FONT_SIZE: f64 = 9.5;
/// 两行文本的固定行高,避免不同菜单栏高度/缩放下使用系统默认行高导致裁剪
/// Fixed metrics prevent clipping across menu-bar scales.
const TRAY_LINE_HEIGHT: f64 = 10.0;
/// 两行文本的行间距
const TRAY_LINE_SPACING: f64 = 0.0;
/// 两行文本整体行高倍数(用于进一步压缩文本块高度)
const TRAY_LINE_HEIGHT_MULTIPLE: f64 = 1.00;
/// 文本块段前偏移(用于将两行文本整体下移)
const TRAY_PARAGRAPH_SPACING_BEFORE: f64 = 0.0;
/// 基线基准位移按字体上沿比例生成(避免硬编码常量)
const TRAY_BASELINE_OFFSET_GLYPH_HEIGHT_RATIO: f64 = 3.0;
/// Tauri tray-icon 将图标缩放为 18pt;这里额外预留图标、图文间距与系统内边距
/// Tauri uses an 18pt icon; reserve room for the icon and system padding.
const TRAY_STATUS_ITEM_EXTRA_WIDTH: f64 = 30.0;
/// 典型 6 字符速率文本的最小宽度,避免 0B/s 等短文本让状态项反复收缩
/// Avoid status-item width jitter for short values such as `0B/s`.
const TRAY_STATUS_ITEM_MIN_LENGTH: f64 = 58.0;
/// AppKit 的 NSVariableStatusItemLength。清空速率标题后恢复系统按图标自适应
const NS_VARIABLE_STATUS_ITEM_LENGTH: f64 = -1.0;
thread_local! {
static LAST_DISPLAY_STR: RefCell<String> = const { RefCell::new(String::new()) };
}
/// 将上行/下行速率格式化为两行定宽文本
///
/// # Arguments
/// * `up` - 上行速率(字节/秒)
/// * `down` - 下行速率(字节/秒)
fn format_tray_speed(up: u64, down: u64) -> String {
// 上行放在第一行,下行放在第二行;通过上下布局表达方向,不再显示箭头字符。
let up_str = format_bytes_per_second(up);
let down_str = format_bytes_per_second(down);
format!("{:>6}\n{:>6}", up_str, down_str)
@@ -54,11 +39,8 @@ fn format_tray_speed(up: u64, down: u64) -> String {
fn build_attributes(button_height: f64) -> Retained<NSDictionary<NSString, AnyObject>> {
unsafe {
// 等宽系统字体,确保数字不跳动
let font = NSFont::monospacedSystemFontOfSize_weight(TRAY_FONT_SIZE, NSFontWeightRegular);
// 与 NSStatusBarButton 原生标题一致,并按每块屏幕的菜单栏外观动态解析颜色
let color = NSColor::controlTextColor();
// 段落样式:右对齐,保证定宽视觉一致
let para_style = NSMutableParagraphStyle::new();
para_style.setAlignment(NSTextAlignment::Right);
para_style.setLineBreakMode(NSLineBreakMode::ByClipping);
@@ -83,11 +65,6 @@ fn build_attributes(button_height: f64) -> Retained<NSDictionary<NSString, AnyOb
}
}
/// 创建带属性的富文本
///
/// # Arguments
/// * `text` - 富文本字符串内容
/// * `attrs` - 可选富文本属性字典(None 表示用默认属性)
fn create_attributed_string(
text: &NSString,
attrs: Option<&NSDictionary<NSString, AnyObject>>,
@@ -111,15 +88,7 @@ fn sync_click_target_frame(button: &NSStatusBarButton) {
}
}
/// 在主线程下设置 NSStatusItem 按钮的标题内容
///
/// 依赖 Tauri `with_inner_tray_icon` 保证回调在主线程执行;
/// 若意外在非主线程调用,`MainThreadMarker::new()` 返回 `None` 并记录警告。
///
/// # Arguments
/// * `status_item` - macOS 托盘 NSStatusItem 引用
/// * `text` - 标题字符串内容
/// * `show_speed` - 是否以速率富文本样式绘制;false 时清空为普通空标题
/// `with_inner_tray_icon` should run this on the main thread; fail safely if it does not.
fn apply_status_item_attributed_title(status_item: &NSStatusItem, text: &NSString, show_speed: bool) {
let Some(mtm) = MainThreadMarker::new() else {
logging!(warn, Type::Tray, "托盘速率富文本设置跳过:非主线程调用");
@@ -141,12 +110,6 @@ fn apply_status_item_attributed_title(status_item: &NSStatusItem, text: &NSStrin
sync_click_target_frame(&button);
}
/// 将速率以富文本形式设置到 NSStatusItem 的按钮上
///
/// # Arguments
/// * `status_item` - macOS 托盘 NSStatusItem 引用
/// * `up` - 上行速率(字节/秒)
/// * `down` - 下行速率(字节/秒)
pub fn set_speed_attributed_title(status_item: &NSStatusItem, up: u64, down: u64) {
let speed_text = format_tray_speed(up, down);
LAST_DISPLAY_STR.with(|last| {
@@ -160,10 +123,6 @@ pub fn set_speed_attributed_title(status_item: &NSStatusItem, up: u64, down: u64
});
}
/// 清除 NSStatusItem 按钮上的富文本速率显示
///
/// # Arguments
/// * `status_item` - macOS 托盘 NSStatusItem 引用
pub fn clear_speed_attributed_title(status_item: &NSStatusItem) {
LAST_DISPLAY_STR.with(|last| {
last.borrow_mut().clear();
+2 -37
View File
@@ -6,39 +6,25 @@ use std::pin::Pin;
use std::time::Duration;
use tauri::{Manager as _, WebviewWindow, Wry};
/// 窗口操作结果
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum WindowOperationResult {
/// 窗口已显示并获得焦点
Shown,
/// 窗口已隐藏
Hidden,
/// 创建了新窗口
Created,
/// 摧毁了窗口
Destroyed,
/// 操作失败
Failed,
/// 无需操作
NoAction,
}
/// 窗口状态
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum WindowState {
/// 窗口可见且有焦点
VisibleFocused,
/// 窗口可见但无焦点
VisibleUnfocused,
/// 窗口最小化
Minimized,
/// 窗口隐藏
Hidden,
/// 窗口不存在
NotExist,
}
// 窗口操作防抖机制
const WINDOW_OPERATION_DEBOUNCE_MS: u64 = 625;
static WINDOW_OPERATION_LIMITER: Lazy<Limiter> = Lazy::new(|| {
Limiter::new(
@@ -55,7 +41,6 @@ fn should_handle_window_operation() -> bool {
allow
}
/// 统一的窗口管理器
pub struct WindowManager;
impl WindowManager {
@@ -112,15 +97,12 @@ impl WindowManager {
}
}
/// 获取主窗口实例
pub fn get_main_window() -> Option<WebviewWindow<Wry>> {
let app_handle = handle::Handle::app_handle();
app_handle.get_webview_window("main")
}
/// 智能显示主窗口
pub async fn show_main_window() -> WindowOperationResult {
// 防抖检查
if !should_handle_window_operation() {
return WindowOperationResult::NoAction;
}
@@ -161,7 +143,6 @@ impl WindowManager {
}
}
/// 切换主窗口显示状态(显示/隐藏)
pub async fn toggle_main_window() -> WindowOperationResult {
if !should_handle_window_operation() {
return WindowOperationResult::NoAction;
@@ -178,10 +159,8 @@ impl WindowManager {
}
}
// 窗口不存在时创建新窗口
async fn handle_not_exist_toggle() -> WindowOperationResult {
logging!(info, Type::Window, "窗口不存在,将创建新窗口");
// 由于已经有防抖保护,直接调用内部方法
if Self::create_window(true).await {
WindowOperationResult::Created
} else {
@@ -189,7 +168,6 @@ impl WindowManager {
}
}
// 隐藏主窗口
fn hide_main_window(window: Option<&WebviewWindow<Wry>>) -> WindowOperationResult {
logging!(info, Type::Window, "窗口可见,将隐藏窗口");
if let Some(window) = window {
@@ -209,7 +187,6 @@ impl WindowManager {
}
}
// 激活已存在的主窗口
fn activate_existing_main_window(window: Option<&WebviewWindow<Wry>>) -> WindowOperationResult {
logging!(info, Type::Window, "窗口存在但被隐藏或最小化,将激活窗口");
if let Some(window) = window {
@@ -220,14 +197,12 @@ impl WindowManager {
}
}
/// 激活窗口(取消最小化、显示、设置焦点)
fn activate_window(window: &WebviewWindow<Wry>) -> WindowOperationResult {
logging!(info, Type::Window, "开始激活窗口");
#[cfg(target_os = "macos")]
Self::set_macos_activation_policy_regular();
// 渲染进程曾被系统终止:先 reload,并把 show+focus 交给 on_page_load(Finished),
// 内容就绪再显示,避免白屏闪烁。reload 成功才 defer,失败则走下方直接显示。
// After renderer termination, defer show/focus until reload finishes to avoid a white flash.
#[allow(unused_mut)]
let mut defer_show_to_page_load = false;
#[cfg(target_os = "macos")]
@@ -241,7 +216,6 @@ impl WindowManager {
let mut operations_successful = true;
// 1. 如果窗口最小化,先取消最小化
if window.is_minimized().unwrap_or(false) {
logging!(info, Type::Window, "窗口已最小化,正在取消最小化");
if let Err(e) = window.unminimize() {
@@ -250,7 +224,6 @@ impl WindowManager {
}
}
// 2/3. 显示 + 焦点(reload 分支跳过,交给 on_page_load)
if !defer_show_to_page_load {
if let Err(e) = window.show() {
logging!(warn, Type::Window, "显示窗口失败: {}", e);
@@ -264,11 +237,9 @@ impl WindowManager {
#[cfg(target_os = "windows")]
{
// Windows 尝试额外的激活方法
if let Err(e) = window.set_always_on_top(true) {
logging!(debug, Type::Window, "设置置顶失败(非关键错误): {}", e);
}
// 立即取消置顶
if let Err(e) = window.set_always_on_top(false) {
logging!(debug, Type::Window, "取消置顶失败(非关键错误): {}", e);
}
@@ -283,23 +254,19 @@ impl WindowManager {
}
}
/// 检查窗口是否可见
pub fn is_main_window_visible(window: Option<&WebviewWindow<Wry>>) -> bool {
window.map(|w| w.is_visible().unwrap_or(false)).unwrap_or(false)
}
/// 检查窗口是否有焦点
pub fn is_main_window_focused(window: Option<&WebviewWindow<Wry>>) -> bool {
window.map(|w| w.is_focused().unwrap_or(false)).unwrap_or(false)
}
/// 检查窗口是否最小化
pub fn is_main_window_minimized(window: Option<&WebviewWindow<Wry>>) -> bool {
window.map(|w| w.is_minimized().unwrap_or(false)).unwrap_or(false)
}
/// 创建新窗口,防抖避免重复调用
/// 窗口创建后保持隐藏,由前端 index.html 在 overlay 渲染后调用 show,避免主题闪烁
/// Keep new windows hidden until the frontend overlay renders, avoiding a theme flash.
pub fn create_window(should_create: bool) -> Pin<Box<dyn Future<Output = bool> + Send>> {
Box::pin(async move {
logging!(info, Type::Window, "开始创建主窗口, should_create={}", should_create);
@@ -325,7 +292,6 @@ impl WindowManager {
})
}
/// 摧毁窗口
pub fn destroy_main_window() -> WindowOperationResult {
if let Some(window) = Self::get_main_window() {
let _ = window.destroy();
@@ -340,7 +306,6 @@ impl WindowManager {
WindowOperationResult::Failed
}
/// 获取详细的窗口状态信息
fn get_window_status_info() -> String {
let (window, state) = Self::get_main_window_with_state();
let is_visible = Self::is_main_window_visible(window.as_ref());
+2 -18
View File
@@ -63,12 +63,9 @@ export const ClashModeCard = () => {
const { isCoreDataPending } = useCoreDataStatus()
const { refreshClashConfig } = useAppRefreshers()
// 点击后到后端确认前的乐观模式,使按钮立即响应
const [optimisticMode, setOptimisticMode] = useState<ClashMode | null>(null)
// 主源:mihomo /configs 的实时 mode(最准,但依赖严格反序列化,可能整体失败)
const controllerMode = toClashMode(clashConfig?.mode)
// 主源不可用时,启用两个容错兜底来源
const needFallback = !controllerMode
const { data: runtimeConfig, isPending: isRuntimeConfigPending } =
useRuntimeConfig(needFallback)
@@ -78,9 +75,7 @@ export const ClashModeCard = () => {
isPending: isBackendModePending,
refetch: refetchBackendMode,
} = useClashMode(needFallback)
// backendMode(已保存 clash 配置)在每次切换时都会被 change_clash_mode 同步更新,
// 而 runtimeMode(生成的运行时配置)在 API 切换后不会刷新、可能陈旧,
// 因此优先用 backendMode,避免陈旧 runtime mode 遮住新值。
// Saved config is refreshed on mode changes; runtime config may be stale.
const fallbackMode = toClashMode(backendMode) ?? runtimeMode
const resolvedMode = controllerMode ?? fallbackMode
@@ -92,37 +87,29 @@ export const ClashModeCard = () => {
? '\u00A0'
: t('home.components.clashMode.errors.communication')
// 切换模式的处理函数:乐观更新 + 真实失败回滚并提示
const onChangeMode = useLockFn(async (mode: ClashMode) => {
if (mode === currentMode) return
if (verge?.auto_close_connection) {
closeAllConnections()
}
// 乐观置为目标模式,按钮立即反映点击
setOptimisticMode(mode)
try {
// patchClashMode 现在会在后端 PATCH 失败时 reject
await patchClashMode(mode)
} catch (error) {
// 真实失败:回滚乐观状态并提示用户
setOptimisticMode(null)
showNotice.error(error)
return
}
// 成功:写穿主源缓存,使实时 mode 立即反映新值——即使随后的 /configs refetch
// 失败(TanStack 会保留旧 data),controllerMode 也不会再压过新值导致闪回。
// 若主源从未成功过(old 为 undefined)则保持不动,改由兜底来源反映。
// Write through the live cache to avoid flashing the old mode during refetch.
setCacheData<BaseConfig>(['getClashConfig'], (old) =>
old ? { ...old, mode } : old,
)
// 刷新主源与兜底源以与后端对齐,待数据落地后再清除乐观状态
await Promise.allSettled([refreshClashConfig(), refetchBackendMode()])
setOptimisticMode(null)
})
// 按钮样式
const buttonStyles = (mode: ClashMode) => ({
cursor: 'pointer',
px: 2,
@@ -159,7 +146,6 @@ export const ClashModeCard = () => {
: {},
})
// 描述样式
const descriptionStyles = {
width: '95%',
textAlign: 'center',
@@ -176,7 +162,6 @@ export const ClashModeCard = () => {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', width: '100%' }}>
{/* 模式选择按钮组 */}
<Stack
direction="row"
spacing={1}
@@ -209,7 +194,6 @@ export const ClashModeCard = () => {
))}
</Stack>
{/* 说明文本区域 */}
<Box
sx={{
width: '100%',
+1 -22
View File
@@ -62,7 +62,6 @@ import {
import { debugLog } from '@/utils/debug'
import { compareByDelay, DEFAULT_DELAY_TIMEOUT } from '@/utils/delay'
// 本地存储的键名
const STORAGE_KEY_GROUP = 'clash-verge-selected-proxy-group'
const STORAGE_KEY_SORT_TYPE = 'clash-verge-proxy-sort-type'
@@ -70,7 +69,6 @@ const AUTO_CHECK_DEFAULT_INTERVAL_MINUTES = 5
const AUTO_CHECK_INITIAL_DELAY_MS = 100
const PROXY_MENU_MAX_HEIGHT = 500
// 代理节点信息接口
interface ProxyOption {
memberIndex: number
member: ResolvedProxyMember
@@ -81,8 +79,6 @@ interface ProxyOption {
type OpenSelect = 'group' | 'proxy' | null
// 排序类型: 默认 | 按延迟 | 按字母
function convertDelayColor(
delayValue: number,
): 'success' | 'warning' | 'error' | 'primary' | 'default' {
@@ -449,7 +445,6 @@ export const CurrentProxyCard = () => {
[getProfileStorageKey],
)
// 统一代理选择器
const { handleSelectChange } = useProxySelection({
onSuccess: () => {
refreshProxy()
@@ -460,12 +455,10 @@ export const CurrentProxyCard = () => {
},
})
// 判断模式
const mode = clashConfig?.mode?.toLowerCase() || 'rule'
const isGlobalMode = mode === 'global'
const isDirectMode = mode === 'direct'
// Sorting type state
const [sortType, setSortType] = useState<ProxySortType>(() => {
const savedSortType = localStorage.getItem(STORAGE_KEY_SORT_TYPE)
return savedSortType ? (Number(savedSortType) as ProxySortType) : 0
@@ -474,7 +467,6 @@ export const CurrentProxyCard = () => {
const [selectedGroupName, setSelectedGroupName] = useState('')
const [openSelect, setOpenSelect] = useState<OpenSelect>(null)
const delayButtonRef = useRef<HTMLButtonElement>(null)
// Sorting reads delays from a store outside React; this hands them over as a value.
const delays = useGroupDelays(selectedGroupName || null)
const autoCheckInProgressRef = useRef(false)
@@ -618,8 +610,7 @@ 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.
// The profile selection is the durable source across core restarts and run modes.
handleSelectChange(
selectedGroup.name,
previousProxy,
@@ -631,7 +622,6 @@ export const CurrentProxyCard = () => {
[handleSelectChange, isDirectMode, selectedGroup, unsortedProxyOptions],
)
// 导航到代理页面
const goToProxies = useCallback(() => {
navigate('/proxies')
}, [navigate])
@@ -645,7 +635,6 @@ export const CurrentProxyCard = () => {
? delayManager.getDelayFix(currentMember, selectedGroupName)
: -1
// 信号图标(增加非空校验)
const signalInfo =
currentProxy && selectedGroupName
? getSignalIcon(currentDelay, t)
@@ -728,14 +717,12 @@ export const CurrentProxyCard = () => {
autoDelayEnabled,
])
// 排序类型变更
const handleSortTypeChange = useCallback(() => {
const newSortType = ((sortType + 1) % 3) as ProxySortType
setSortType(newSortType)
localStorage.setItem(STORAGE_KEY_SORT_TYPE, newSortType.toString())
}, [sortType])
// 延迟测试
const handleCheckDelay = useLockFn(async () => {
const groupName = selectedGroupName
if (!groupName || isDirectMode) return
@@ -782,7 +769,6 @@ export const CurrentProxyCard = () => {
[handleCheckDelay],
)
// 计算要显示的代理选项(增加非空校验)
const proxyOptions = useMemo(
() =>
isDirectMode
@@ -802,7 +788,6 @@ export const CurrentProxyCard = () => {
],
)
// 获取排序图标
const getSortIcon = (): React.ReactElement => {
switch (sortType) {
case 1:
@@ -814,7 +799,6 @@ export const CurrentProxyCard = () => {
}
}
// 获取排序提示文本
const getSortTooltip = (): string => {
switch (sortType) {
case 0:
@@ -890,7 +874,6 @@ export const CurrentProxyCard = () => {
<Box sx={{ py: 4, height: 24 }} />
) : currentProxy || (!isDirectMode && selectedGroup) ? (
<Box>
{/* 代理节点信息显示 */}
<Box
sx={{
display: 'flex',
@@ -935,7 +918,6 @@ export const CurrentProxyCard = () => {
sx={{ mr: 0.5 }}
/>
)}
{/* 节点特性 */}
{currentProxy?.udp && (
<Chip size="small" label="UDP" variant="outlined" />
)}
@@ -954,7 +936,6 @@ export const CurrentProxyCard = () => {
</Box>
</Box>
{/* 显示延迟 */}
{currentProxy && !isDirectMode && (
<Chip
size="small"
@@ -963,7 +944,6 @@ export const CurrentProxyCard = () => {
/>
)}
</Box>
{/* 代理组选择器 */}
<FormControl
fullWidth
variant="outlined"
@@ -991,7 +971,6 @@ export const CurrentProxyCard = () => {
</Select>
</FormControl>
{/* 代理节点选择器 */}
<PersistentProxySelect
label={t('home.components.currentProxy.labels.proxy')}
groupName={selectedGroupName}
@@ -22,22 +22,19 @@ import {
formatTrafficName,
} from '@/utils/traffic-sampler'
// 流量数据项接口
interface ITrafficItem {
up: number
down: number
timestamp?: number
}
// 对外暴露的接口
export interface EnhancedCanvasTrafficGraphRef {
appendData: (data: ITrafficItem) => void
toggleStyle: () => void
}
type TimeRange = 1 | 5 | 10 // 分钟
type TimeRange = 1 | 5 | 10
// 悬浮提示数据接口
interface TooltipData {
x: number
y: number
@@ -45,21 +42,21 @@ interface TooltipData {
downSpeed: string
timestamp: string
visible: boolean
dataIndex: number // 添加数据索引用于高亮
highlightY: number // 高亮Y轴位置
dataIndex: number
highlightY: number
}
const MAX_POINTS = 300
const TARGET_FPS = 15 // 降低帧率减少闪烁
const TARGET_FPS = 15
const LINE_WIDTH_UP = 2.5
const LINE_WIDTH_DOWN = 2.5
const LINE_WIDTH_GRID = 0.5
const ALPHA_GRADIENT = 0.15 // 降低渐变透明度
const ALPHA_GRADIENT = 0.15
const ALPHA_LINE = 0.9
const PADDING_TOP = 16
const PADDING_RIGHT = 16 // 增加右边距确保时间戳完整显示
const PADDING_BOTTOM = 32 // 进一步增加底部空间给时间轴和统计信息
const PADDING_LEFT = 35 // 增加左边距为Y轴标签留出空间
const PADDING_RIGHT = 16
const PADDING_BOTTOM = 32
const PADDING_LEFT = 35
const GRAPH_CONFIG = {
maxPoints: MAX_POINTS,
@@ -116,10 +113,6 @@ const displayDataReducer = (
): ITrafficDataPoint[] =>
isSameTrafficData(current, payload) ? current : payload
/**
* 稳定版Canvas流量图表组件
* 修复闪烁问题,添加时间轴显示
*/
export const EnhancedCanvasTrafficGraph = memo(
function EnhancedCanvasTrafficGraph({
ref,
@@ -130,11 +123,9 @@ export const EnhancedCanvasTrafficGraph = memo(
const pause_render_traffic_stats_on_blur =
verge.verge?.pause_render_traffic_stats_on_blur ?? true
// 使用增强版全局流量数据管理
const { dataPoints, requestRange, samplerStats } =
useTrafficGraphDataEnhanced()
// 基础状态
const [timeRange, setTimeRange] = useState<TimeRange>(10)
const [chartStyle, setChartStyle] = useState<'bezier' | 'line'>('bezier')
@@ -142,7 +133,6 @@ export const EnhancedCanvasTrafficGraph = memo(
typeof document !== 'undefined' ? !document.hidden : true
const isDocumentVisibleRef = useRef(initialFocusState)
// 悬浮提示状态
const [tooltipData, setTooltipData] = useState<TooltipData>({
x: 0,
y: 0,
@@ -155,7 +145,6 @@ export const EnhancedCanvasTrafficGraph = memo(
})
const tooltipDataRef = useRef<TooltipData>(tooltipData)
// Canvas引用和渲染状态
const canvasRef = useRef<HTMLCanvasElement>(null)
const hoverCanvasRef = useRef<HTMLCanvasElement>(null)
const drawFrameRef = useRef<number | undefined>(undefined)
@@ -170,7 +159,6 @@ export const EnhancedCanvasTrafficGraph = memo(
const lastDataTimestampRef = useRef<number>(0)
const dataStaleRef = useRef<boolean>(false)
// 当前显示的数据缓存
const [displayData, dispatchDisplayData] = useReducer(
displayDataReducer,
[],
@@ -178,7 +166,6 @@ export const EnhancedCanvasTrafficGraph = memo(
const debounceTimeoutRef = useRef<number | null>(null)
const [currentFPS, setCurrentFPS] = useState(GRAPH_CONFIG.targetFPS)
// 主题颜色配置
const colors = useMemo(
() => ({
up: theme.palette.secondary.main,
@@ -190,17 +177,15 @@ export const EnhancedCanvasTrafficGraph = memo(
[theme],
)
// 更新显示数据(防抖处理)
const updateDisplayData = useCallback((newData: ITrafficDataPoint[]) => {
if (debounceTimeoutRef.current !== null) {
window.clearTimeout(debounceTimeoutRef.current)
}
debounceTimeoutRef.current = window.setTimeout(() => {
dispatchDisplayData(newData)
}, 50) // 50ms防抖
}, 50)
}, [])
// 监听数据变化
useEffect(() => {
updateDisplayData(dataPoints)
@@ -274,7 +259,6 @@ export const EnhancedCanvasTrafficGraph = memo(
}
}, [handleFocusStateChange])
// Y轴坐标计算 - 线性映射
const calculateY = useCallback(
(
value: number,
@@ -331,7 +315,6 @@ export const EnhancedCanvasTrafficGraph = memo(
[computeYScale, displayData],
)
// 鼠标悬浮处理 - 计算最近的数据点
const handleMouseMove = useCallback(
(event: React.MouseEvent<HTMLElement>) => {
if (displayData.length === 0) return
@@ -361,7 +344,6 @@ export const EnhancedCanvasTrafficGraph = memo(
const effectiveWidth = rect.width - padding.left - padding.right
if (effectiveWidth <= 0) return
// 计算最接近的数据点索引
const relativeMouseX = mouseX - padding.left
const ratio = Math.max(
0,
@@ -373,16 +355,13 @@ export const EnhancedCanvasTrafficGraph = memo(
const dataPoint = displayData[dataIndex]
// 格式化流量数据
const [upValue, upUnit] = parseTraffic(dataPoint.up)
const [downValue, downUnit] = parseTraffic(dataPoint.down)
// 格式化时间戳
const timeStr = dataPoint.timestamp
? formatTrafficName(dataPoint.timestamp)
: t('home.components.traffic.unknownTime')
// 计算数据点对应的Y坐标位置(用于高亮)
const { topValue: tvH, bottomValue: bvH } = yScale
const upY = calculateY(dataPoint.up, rect.height, tvH, bvH)
const downY = calculateY(dataPoint.down, rect.height, tvH, bvH)
@@ -423,7 +402,6 @@ export const EnhancedCanvasTrafficGraph = memo(
[displayData, calculateY, yScale, t],
)
// 鼠标离开处理
const handleMouseLeave = useCallback(() => {
pendingMousePositionRef.current = null
@@ -437,10 +415,8 @@ export const EnhancedCanvasTrafficGraph = memo(
)
}, [])
// 获取智能Y轴刻度(三刻度系统:最小值、中间值、最大值)
const getYAxisTicks = useCallback(
(topValue: number, bottomValue: number, height: number) => {
// 格式化流量数值
const formatTrafficValue = (bytes: number): string => {
if (bytes === 0) return '0'
if (bytes < 1024) return `${Math.round(bytes)}B`
@@ -450,13 +426,11 @@ export const EnhancedCanvasTrafficGraph = memo(
const padding = GRAPH_CONFIG.padding
// 强制显示三个刻度:底部、中间、顶部
const topY = padding.top + 10 // 避免与顶部时间范围按钮重叠
const bottomY = height - padding.bottom - 5 // 避免与底部时间轴重叠
const topY = padding.top + 10
const bottomY = height - padding.bottom - 5
const middleY = (topY + bottomY) / 2
const middleValue = (bottomValue + topValue) / 2
// 创建三个固定位置的刻度
const ticks = [
{
value: bottomValue,
@@ -480,7 +454,6 @@ export const EnhancedCanvasTrafficGraph = memo(
[],
)
// 绘制Y轴刻度线和标签
const drawYAxis = useCallback(
(
ctx: CanvasRenderingContext2D,
@@ -497,13 +470,12 @@ export const EnhancedCanvasTrafficGraph = memo(
ctx.save()
ticks.forEach((tick, index) => {
const isBottomTick = index === 0 // 最底部的刻度
const isTopTick = index === ticks.length - 1 // 最顶部的刻度
const isBottomTick = index === 0
const isTopTick = index === ticks.length - 1
// 绘制水平刻度线,只绘制关键刻度线
if (isBottomTick || isTopTick) {
ctx.strokeStyle = colors.grid
ctx.lineWidth = isBottomTick ? 0.8 : 0.4 // 底部刻度线稍粗
ctx.lineWidth = isBottomTick ? 0.8 : 0.4
ctx.globalAlpha = isBottomTick ? 0.25 : 0.15
ctx.beginPath()
@@ -512,7 +484,6 @@ export const EnhancedCanvasTrafficGraph = memo(
ctx.stroke()
}
// 绘制Y轴标签
ctx.fillStyle = colors.text
ctx.font =
"8px -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif"
@@ -520,7 +491,6 @@ export const EnhancedCanvasTrafficGraph = memo(
ctx.textAlign = 'right'
ctx.textBaseline = 'middle'
// 为标签添加更清晰的背景(仅在必要时)
if (tick.label !== '0') {
const labelWidth = ctx.measureText(tick.label).width
ctx.globalAlpha = 0.15
@@ -533,7 +503,6 @@ export const EnhancedCanvasTrafficGraph = memo(
)
}
// 绘制标签文字
ctx.globalAlpha = 0.9
ctx.fillStyle = colors.text
ctx.fillText(tick.label, padding.left - 4, tick.y)
@@ -544,38 +513,36 @@ export const EnhancedCanvasTrafficGraph = memo(
[colors.grid, colors.text, colors.background, getYAxisTicks],
)
// 获取时间范围对应的最佳时间显示策略
const getTimeDisplayStrategy = useCallback(
(timeRangeMinutes: TimeRange) => {
switch (timeRangeMinutes) {
case 1: // 1分钟:更密集的时间标签,显示 MM:SS
case 1:
return {
maxLabels: 6, // 减少到6个,更适合短时间
maxLabels: 6,
formatTime: formatTrafficMinuteSecond,
intervalSeconds: 10, // 每10秒一个标签,更合理
minPixelDistance: 35, // 减少间距,允许更多标签
intervalSeconds: 10,
minPixelDistance: 35,
}
case 5: // 5分钟:中等密度,显示 HH:MM
case 5:
return {
maxLabels: 6, // 6个标签比较合适
maxLabels: 6,
formatTime: formatTrafficHourMinute,
intervalSeconds: 30, // 约30秒间隔
minPixelDistance: 38, // 减少间距,允许更多标签
intervalSeconds: 30,
minPixelDistance: 38,
}
case 10: // 10分钟:标准密度,显示 HH:MM
case 10:
default:
return {
maxLabels: 8, // 保持8个
maxLabels: 8,
formatTime: formatTrafficHourMinute,
intervalSeconds: 60, // 1分钟间隔
minPixelDistance: 40, // 减少间距,允许更多标签
intervalSeconds: 60,
minPixelDistance: 40,
}
}
},
[],
)
// 绘制时间轴
const drawTimeAxis = useCallback(
(
ctx: CanvasRenderingContext2D,
@@ -597,22 +564,18 @@ export const EnhancedCanvasTrafficGraph = memo(
"10px -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif"
ctx.globalAlpha = 0.7
// 根据数据长度和时间范围智能选择显示间隔
const targetLabels = Math.min(strategy.maxLabels, data.length)
const step = Math.max(1, Math.floor(data.length / (targetLabels - 1)))
// 使用策略中定义的最小像素间距
const minPixelDistance = strategy.minPixelDistance || 45
const actualStep = Math.max(
step,
Math.ceil((data.length * minPixelDistance) / effectiveWidth),
)
// 收集要显示的时间点
const timePoints: Array<{ index: number; x: number; label: string }> =
[]
// 添加第一个时间点
if (data.length > 0 && data[0].timestamp) {
timePoints.push({
index: 0,
@@ -621,7 +584,6 @@ export const EnhancedCanvasTrafficGraph = memo(
})
}
// 添加中间的时间点
for (
let i = actualStep;
i < data.length - actualStep;
@@ -638,12 +600,10 @@ export const EnhancedCanvasTrafficGraph = memo(
})
}
// 添加最后一个时间点(如果不会与前面的重叠)
if (data.length > 1 && data[data.length - 1].timestamp) {
const lastX = width - padding.right
const lastPoint = timePoints[timePoints.length - 1]
// 确保最后一个标签与前一个标签有足够间距
if (!lastPoint || lastX - lastPoint.x >= minPixelDistance) {
timePoints.push({
index: data.length - 1,
@@ -653,16 +613,12 @@ export const EnhancedCanvasTrafficGraph = memo(
}
}
// 绘制时间标签
timePoints.forEach((point, index) => {
if (index === 0) {
// 第一个标签左对齐
ctx.textAlign = 'left'
} else if (index === timePoints.length - 1) {
// 最后一个标签右对齐
ctx.textAlign = 'right'
} else {
// 中间标签居中对齐
ctx.textAlign = 'center'
}
@@ -674,7 +630,6 @@ export const EnhancedCanvasTrafficGraph = memo(
[colors.text, timeRange, getTimeDisplayStrategy],
)
// 绘制网格线
const drawGrid = useCallback(
(ctx: CanvasRenderingContext2D, width: number, height: number) => {
const padding = GRAPH_CONFIG.padding
@@ -686,7 +641,6 @@ export const EnhancedCanvasTrafficGraph = memo(
ctx.lineWidth = GRAPH_CONFIG.lineWidth.grid
ctx.globalAlpha = 0.7
// 水平网格线
const horizontalLines = 4
for (let i = 1; i <= horizontalLines; i++) {
const y = padding.top + (effectiveHeight / (horizontalLines + 1)) * i
@@ -696,7 +650,6 @@ export const EnhancedCanvasTrafficGraph = memo(
ctx.stroke()
}
// 垂直网格线
const verticalLines = 6
for (let i = 1; i <= verticalLines; i++) {
const x = padding.left + (effectiveWidth / (verticalLines + 1)) * i
@@ -711,7 +664,6 @@ export const EnhancedCanvasTrafficGraph = memo(
[colors.grid],
)
// 绘制流量线条
const drawTrafficLine = useCallback(
(
ctx: CanvasRenderingContext2D,
@@ -736,7 +688,6 @@ export const EnhancedCanvasTrafficGraph = memo(
ctx.save()
// 绘制渐变填充
if (withGradient && chartStyle === 'bezier') {
const gradient = ctx.createLinearGradient(
0,
@@ -777,7 +728,6 @@ export const EnhancedCanvasTrafficGraph = memo(
ctx.fill()
}
// 绘制主线条
ctx.beginPath()
ctx.strokeStyle = color
ctx.lineWidth = GRAPH_CONFIG.lineWidth.up
@@ -846,7 +796,6 @@ export const EnhancedCanvasTrafficGraph = memo(
[syncCanvasSize],
)
// 主绘制函数
const drawGraph = useCallback(() => {
const canvas = canvasRef.current
if (!canvas || displayData.length === 0) {
@@ -864,16 +813,12 @@ export const EnhancedCanvasTrafficGraph = memo(
const { topValue, bottomValue } = yScale
// 绘制Y轴刻度线(背景层)
drawYAxis(ctx, cssWidth, cssHeight, topValue, bottomValue)
// 绘制网格
drawGrid(ctx, cssWidth, cssHeight)
// 绘制时间轴
drawTimeAxis(ctx, cssWidth, cssHeight, displayData)
// 绘制下载线(背景层)
drawTrafficLine(
ctx,
displayData,
@@ -886,7 +831,6 @@ export const EnhancedCanvasTrafficGraph = memo(
bottomValue,
)
// 绘制上传线(前景层)
drawTrafficLine(
ctx,
displayData,
@@ -937,15 +881,13 @@ export const EnhancedCanvasTrafficGraph = memo(
ctx.strokeStyle = colors.text
ctx.lineWidth = 1
ctx.globalAlpha = 0.6
ctx.setLineDash([4, 4]) // 虚线效果
ctx.setLineDash([4, 4])
// 绘制垂直指示线
ctx.beginPath()
ctx.moveTo(dataX, padding.top)
ctx.lineTo(dataX, cssHeight - padding.bottom)
ctx.stroke()
// 绘制水平指示线(高亮Y轴位置)
ctx.beginPath()
ctx.moveTo(padding.left, currentTooltip.highlightY)
ctx.lineTo(cssWidth - padding.right, currentTooltip.highlightY)
@@ -1048,7 +990,6 @@ export const EnhancedCanvasTrafficGraph = memo(
}
}, [])
// 切换时间范围
const handleTimeRangeClick = useCallback((event: React.MouseEvent) => {
event.stopPropagation()
setTimeRange((prev) => {
@@ -1056,12 +997,10 @@ export const EnhancedCanvasTrafficGraph = memo(
})
}, [])
// 切换图表样式
const toggleStyle = useCallback(() => {
setChartStyle((prev) => (prev === 'bezier' ? 'line' : 'bezier'))
}, [])
// 兼容性方法
const appendData = useCallback((data: ITrafficItem) => {
debugLog(
'[EnhancedCanvasTrafficGraphV2] appendData called (using global data):',
@@ -1069,7 +1008,6 @@ export const EnhancedCanvasTrafficGraph = memo(
)
}, [])
// 暴露方法给父组件
useImperativeHandle(
ref,
() => ({
@@ -1079,7 +1017,6 @@ export const EnhancedCanvasTrafficGraph = memo(
[appendData, toggleStyle],
)
// 获取时间范围文本
const getTimeRangeText = useCallback(() => {
return t('home.components.traffic.patterns.minutes', {
time: timeRange,
@@ -1125,7 +1062,6 @@ export const EnhancedCanvasTrafficGraph = memo(
/>
)}
{/* 控制层覆盖 */}
<Box
sx={{
position: 'absolute',
@@ -1136,14 +1072,13 @@ export const EnhancedCanvasTrafficGraph = memo(
pointerEvents: 'none',
}}
>
{/* 时间范围按钮 */}
<Box
component="div"
onClick={handleTimeRangeClick}
sx={{
position: 'absolute',
top: 6,
left: 40, // 向右移动,避免与Y轴最大值标签重叠
left: 40,
fontSize: '11px',
fontWeight: 'bold',
color: 'text.secondary',
@@ -1161,7 +1096,6 @@ export const EnhancedCanvasTrafficGraph = memo(
{getTimeRangeText()}
</Box>
{/* 图例 */}
<Box
sx={{
position: 'absolute',
@@ -1194,7 +1128,6 @@ export const EnhancedCanvasTrafficGraph = memo(
</Box>
</Box>
{/* 样式指示器 */}
<Box
sx={{
position: 'absolute',
@@ -1212,7 +1145,6 @@ export const EnhancedCanvasTrafficGraph = memo(
)}
</Box>
{/* 数据统计指示器(左下角) */}
<Box
sx={{
position: 'absolute',
@@ -1231,7 +1163,6 @@ export const EnhancedCanvasTrafficGraph = memo(
})}
</Box>
{/* 悬浮提示框 */}
{tooltipData.visible && (
<Box
sx={{
+4 -38
View File
@@ -24,7 +24,6 @@ import { useQuery } from '@/services/query-client'
import { EnhancedCard } from './enhanced-card'
// 定义刷新时间(秒)
const IP_REFRESH_SECONDS = 300
const COUNTDOWN_TICK_INTERVAL = 5_000
const IP_INFO_CACHE_KEY = 'cv_ip_info_cache'
@@ -54,7 +53,6 @@ const InfoItem = memo(({ label, value }: { label: string; value?: string }) => (
</Box>
))
// 获取国旗表情
const getCountryFlag = (countryCode: string | undefined) => {
if (!countryCode) return ''
const codePoints = countryCode
@@ -97,16 +95,12 @@ const IPInfoCardContainer = forwardRef<HTMLElement, React.PropsWithChildren>(
},
)
// IP信息卡片组件
export const IpInfoCard = () => {
const { t } = useTranslation()
const [showIp, setShowIp] = useState(false)
const appWindow = useMemo(() => getCurrentWebviewWindow(), [])
// track ip info card has been in viewport or not
// hasIntersected default to false, and will be true once the card is in viewport
// and will never be false again afterwards (unless resetIntersected is called or
// the component is unmounted)
// Once intersected, refreshes stay enabled until unmount.
const [containerRef, hasIntersected, _resetIntersected] = useIntersection({
rootMargin: '0px',
})
@@ -118,7 +112,6 @@ export const IpInfoCard = () => {
const { data: ipInfo, error, isLoading, refetch: mutate } = useIPInfo()
// function useEffectEvent
const onCountdownTick = useEffectEvent(async () => {
const now = Date.now()
const ts = ipInfo?.lastFetchTs
@@ -131,35 +124,22 @@ export const IpInfoCard = () => {
if (remaining <= 0) {
if (
// has intersected at least once
// this avoids unncessary revalidation if user never scrolls down,
// then we will only load initially once.
hasIntersected &&
// is online
navigator.onLine &&
// there is no ongoing revalidation already scheduled
countdown.type !== 'revalidating' &&
// window is visible
(await appWindow.isVisible())
) {
setCountdown({ type: 'revalidating' })
// we do not care about the result of mutate here. after mutate is done,
// simply wait for next interval tick with `setCountdown({ type: "countdown", ... })`
try {
await mutate()
} finally {
// in case mutate throws error, we still need to reset the countdown state
setCountdown({
type: 'countdown',
remainingSeconds: IP_REFRESH_SECONDS,
})
}
} else {
// do nothing. we even skip "setCountdown" to reduce re-renders
//
// but the remaining time still <= 0, and setInterval is not stopped, this
// callback will still be regularly triggered, as soon as the window is visible
// or network online again, we mutate() immediately in the following tick.
// Keep the expired state so the next eligible tick refreshes immediately.
}
} else {
setCountdown({
@@ -169,14 +149,10 @@ export const IpInfoCard = () => {
}
})
// Countdown / refresh scheduler — updates UI every 1s and triggers immediate revalidation when expired
useEffect(() => {
let timer: number | null = null
// Do not add document.hidden check here as it is not reliable in Tauri.
//
// Thank god IntersectionObserver is a DOM API that relies on DOM/webview
// instead of Tauri, which is reliable enough.
// Prefer DOM intersection state; Tauri visibility is unreliable on some platforms.
if (hasIntersected) {
console.debug(
'IP info card has entered the viewport, starting the countdown interval.',
@@ -188,25 +164,17 @@ export const IpInfoCard = () => {
)
}
// This will fire when the window is minimized or restored
document.addEventListener('visibilitychange', onVisibilityChange)
// Tauri's visibility change detection is actually broken on some platforms:
// https://github.com/tauri-apps/tauri/issues/10592
//
// It is working on macOS though (tested).
// So at least we should try to pause countdown on supported platforms to
// reduce power consumption.
// Best-effort power saving; see https://github.com/tauri-apps/tauri/issues/10592.
function onVisibilityChange() {
if (document.hidden) {
console.debug('Document hidden, pause the interval')
// Pause the timer
if (timer != null) {
clearInterval(timer)
timer = null
}
} else if (hasIntersected) {
console.debug('Document visible, resume the interval')
// Resume the timer only when previous one is cleared
if (timer == null) {
timer = window.setInterval(onCountdownTick, COUNTDOWN_TICK_INTERVAL)
}
@@ -274,7 +242,6 @@ export const IpInfoCard = () => {
overflow: 'hidden',
}}
>
{/* 左侧:国家和IP地址 */}
<Box sx={{ width: '40%', overflow: 'hidden' }}>
<Box
sx={{
@@ -358,7 +325,6 @@ export const IpInfoCard = () => {
/>
</Box>
{/* 右侧:组织、ISP和位置信息 */}
<Box sx={{ width: '60%', overflow: 'auto' }}>
<InfoItem
label={t('home.components.ipInfo.labels.isp')}
-15
View File
@@ -32,7 +32,6 @@ export const SystemInfoCard = () => {
const { isAdminMode, isSidecarMode, mutateSystemState } = useSystemState()
const { installServiceAndRestartCore } = useServiceInstaller()
// 自动检查更新逻辑(lastCheckUpdate 由 useUpdate 统一管理)
const { checkUpdate: triggerCheckUpdate, lastCheckUpdate } = useUpdate(true)
const [osInfo, setOsInfo] = useState('')
@@ -42,7 +41,6 @@ export const SystemInfoCard = () => {
[lastCheckUpdate],
)
// 初始化系统信息
useEffect(() => {
getSystemInfo()
.then((info) => {
@@ -61,7 +59,6 @@ export const SystemInfoCard = () => {
.catch(console.error)
}, [])
// 如果启用了自动检查更新但没有记录,设置当前时间并延迟检查
useEffect(() => {
if (!verge?.auto_check_update) return
if (readLastCheckTime() !== null) return
@@ -73,12 +70,10 @@ export const SystemInfoCard = () => {
return () => window.clearTimeout(timeoutId)
}, [verge?.auto_check_update, triggerCheckUpdate])
// 导航到设置页面
const goToSettings = useCallback(() => {
navigate('/settings')
}, [navigate])
// 切换自启动状态
const toggleAutoLaunch = useCallback(async () => {
if (!verge) return
try {
@@ -88,7 +83,6 @@ export const SystemInfoCard = () => {
}
}, [verge, patchVerge])
// 点击运行模式处理,Sidecar或纯管理员模式允许安装服务
const handleRunningModeClick = useCallback(async () => {
if (isSidecarMode || (isAdminMode && isSidecarMode)) {
await installServiceAndRestartCore()
@@ -101,7 +95,6 @@ export const SystemInfoCard = () => {
mutateSystemState,
])
// 检查更新
const onCheckUpdate = useLockFn(async () => {
try {
const result = await triggerCheckUpdate()
@@ -119,16 +112,13 @@ export const SystemInfoCard = () => {
}
})
// 是否启用自启动
const autoLaunchEnabled = useMemo(
() => verge?.enable_auto_launch || false,
[verge],
)
// 运行模式样式
const runningModeStyle = useMemo(
() => ({
// Sidecar或纯管理员模式允许安装服务
cursor:
isSidecarMode || (isAdminMode && isSidecarMode) ? 'pointer' : 'default',
textDecoration:
@@ -143,10 +133,8 @@ export const SystemInfoCard = () => {
[isSidecarMode, isAdminMode],
)
// 获取模式图标和文本
const getModeIcon = () => {
if (isAdminMode) {
// 判断是否为组合模式(管理员+服务)
if (!isSidecarMode) {
return (
<>
@@ -184,10 +172,8 @@ export const SystemInfoCard = () => {
}
}
// 获取模式文本
const getModeText = () => {
if (isAdminMode) {
// 判断是否同时处于服务模式
if (!isSidecarMode) {
return t('home.components.systemInfo.badges.adminServiceMode')
}
@@ -199,7 +185,6 @@ export const SystemInfoCard = () => {
}
}
// 只有当verge存在时才渲染内容
if (!verge) return null
return (
+1 -10
View File
@@ -17,12 +17,10 @@ import parseTraffic from '@/utils/parse-traffic'
import { TrafficGraph, type TrafficRef } from './traffic-graph'
// setup the traffic
export const LayoutTraffic = () => {
const { t } = useTranslation()
const { verge } = useVerge()
// whether hide traffic graph
const trafficGraph = verge?.traffic_graph ?? true
const displayMemory = verge?.enable_memory_usage ?? true
@@ -36,7 +34,6 @@ export const LayoutTraffic = () => {
response: { data: memory },
} = useMemoryData({ enabled: displayMemory && pageVisible })
// 监听数据变化,为图表添加数据点
useEffect(() => {
if (trafficRef.current) {
trafficRef.current.appendData({
@@ -48,7 +45,6 @@ export const LayoutTraffic = () => {
}
}, [traffic])
// 使用parseTraffic统一处理转换,保持与首页一致的显示格式
const [up, upUnit] = parseTraffic(traffic?.up || 0)
const [down, downUnit] = parseTraffic(traffic?.down || 0)
const [inuse, inuseUnit] = parseTraffic(memory?.inuse || 0)
@@ -96,7 +92,6 @@ export const LayoutTraffic = () => {
{...boxStyle}
sx={{
...boxStyle.sx,
// opacity: traffic?.is_fresh ? 1 : 0.6,
}}
>
<ArrowUpwardRounded
@@ -114,7 +109,6 @@ export const LayoutTraffic = () => {
{...boxStyle}
sx={{
...boxStyle.sx,
// opacity: traffic?.is_fresh ? 1 : 0.6,
}}
>
<ArrowDownwardRounded
@@ -134,12 +128,9 @@ export const LayoutTraffic = () => {
sx={{
...boxStyle.sx,
cursor: 'auto',
// opacity: memory?.is_fresh ? 1 : 0.6,
}}
color={'disabled'}
onClick={async () => {
// isDebug && (await gc());
}}
onClick={async () => {}}
>
<MemoryRounded {...iconStyle} />
<Typography {...valStyle}>{inuse}</Typography>
-26
View File
@@ -102,7 +102,6 @@ const ProfileItemBase = (props: ProfileItemProps) => {
const loadingCache = useLoadingCache()
const setLoadingCache = useSetLoadingCache()
// 新增状态:是否显示下次更新时间
const [showNextUpdate, setShowNextUpdate] = useState(false)
const showNextUpdateRef = useRef(false)
const [nextUpdateTime, setNextUpdateTime] = useState('')
@@ -126,7 +125,6 @@ const ProfileItemBase = (props: ProfileItemProps) => {
const { uid, name = 'Profile', extra, updated = 0, option } = itemData
// 获取下次更新时间的函数
const fetchNextUpdateTimeCallback = useCallback(
async (forceRefresh = false) => {
if (
@@ -136,9 +134,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
try {
debugLog(`尝试获取配置 ${itemData.uid} 的下次更新时间`)
// 如果需要强制刷新,先触发Timer.refresh()
if (forceRefresh) {
// 这里可以通过一个新的API来触发刷新,但目前我们依赖patch_profile中的刷新
debugLog(`强制刷新定时器任务`)
}
@@ -149,13 +145,11 @@ const ProfileItemBase = (props: ProfileItemProps) => {
const nextUpdateDate = dayjs(nextUpdate * 1000)
const now = dayjs()
// 如果已经过期,显示"更新失败"
if (nextUpdateDate.isBefore(now)) {
setNextUpdateTime(
t('profiles.components.profileItem.status.lastUpdateFailed'),
)
} else {
// 否则显示剩余时间
const diffMinutes = nextUpdateDate.diff(now, 'minute')
if (diffMinutes < 60) {
@@ -197,7 +191,6 @@ const ProfileItemBase = (props: ProfileItemProps) => {
)
const fetchNextUpdateTime = useLockFn(fetchNextUpdateTimeCallback)
// 切换显示模式的函数
const toggleUpdateTimeDisplay = (e: React.MouseEvent) => {
e.stopPropagation()
@@ -212,7 +205,6 @@ const ProfileItemBase = (props: ProfileItemProps) => {
showNextUpdateRef.current = showNextUpdate
}, [showNextUpdate])
// 当组件加载或更新间隔变化时更新下次更新时间
useEffect(() => {
if (showNextUpdate) {
fetchNextUpdateTime()
@@ -224,7 +216,6 @@ const ProfileItemBase = (props: ProfileItemProps) => {
updated,
])
// 页面统一订阅定时器事件,这里只响应当前配置的更新信号
useEffect(() => {
if (timerUpdateRevision === 0 || !showNextUpdateRef.current) return
@@ -247,9 +238,6 @@ const ProfileItemBase = (props: ProfileItemProps) => {
fetchNextUpdateTime()
}, [completedUpdateRevision, fetchNextUpdateTime])
// local file mode
// remote file mode
// remote file mode
const hasUrl = !!itemData.url
const hasExtra = !!extra // only subscription url has extra info
const hasHome = !!itemData.home // only subscription url has home page
@@ -265,7 +253,6 @@ const ProfileItemBase = (props: ProfileItemProps) => {
const loading = loadingCache.has(itemData.uid)
// interval update fromNow field
const [, forceRefresh] = useReducer((value: number) => value + 1, 0)
useEffect(() => {
if (!hasUrl) return
@@ -275,7 +262,6 @@ const ProfileItemBase = (props: ProfileItemProps) => {
const handler = () => {
const now = Date.now()
const lastUpdate = updated * 1000
// 大于一天的不管
if (now - lastUpdate >= 24 * 36e5) return
const wait = now - lastUpdate >= 36e5 ? 30e5 : 5e4
@@ -394,7 +380,6 @@ const ProfileItemBase = (props: ProfileItemProps) => {
setAnchorEl(null)
setLoading(true)
// 根据类型设置初始更新选项
const option: Partial<IProfileOption> = {}
if (type === 0) {
option.with_proxy = false
@@ -410,15 +395,11 @@ const ProfileItemBase = (props: ProfileItemProps) => {
}
try {
// 调用后端更新(后端会自动处理回退逻辑)
const payload = Object.keys(option).length > 0 ? option : undefined
await updateProfile(itemData.uid, payload)
// 更新成功,刷新列表
void mutateProfiles()
} catch {
// 更新完全失败(包括后端的回退尝试)
// 不需要做处理,后端会通过事件通知系统发送错误
} finally {
setLoading(false)
}
@@ -522,7 +503,6 @@ const ProfileItemBase = (props: ProfileItemProps) => {
handler: () => {
setAnchorEl(null)
if (batchMode) {
// If in batch mode, just toggle selection instead of showing delete confirmation
if (onSelectionChange) {
onSelectionChange()
}
@@ -584,7 +564,6 @@ const ProfileItemBase = (props: ProfileItemProps) => {
handler: () => {
setAnchorEl(null)
if (batchMode) {
// If in batch mode, just toggle selection instead of showing delete confirmation
if (onSelectionChange) {
onSelectionChange()
}
@@ -640,7 +619,6 @@ const ProfileItemBase = (props: ProfileItemProps) => {
<ProfileBox
aria-selected={selected}
onClick={(e) => {
// 如果正在激活中,阻止重复点击
if (activating) {
e.preventDefault()
e.stopPropagation()
@@ -734,7 +712,6 @@ const ProfileItemBase = (props: ProfileItemProps) => {
</Typography>
</Box>
{/* only if has url can it be updated */}
{hasUrl && (
<IconButton
title={t('shared.actions.refresh')}
@@ -750,7 +727,6 @@ const ProfileItemBase = (props: ProfileItemProps) => {
disabled={loading}
onClick={(e) => {
e.stopPropagation()
// 如果正在激活或加载中,阻止更新操作
if (activating || loading) {
return
}
@@ -761,7 +737,6 @@ const ProfileItemBase = (props: ProfileItemProps) => {
</IconButton>
)}
</Box>
{/* the second line show url's info or description */}
<Box sx={boxStyle}>
{
<>
@@ -824,7 +799,6 @@ const ProfileItemBase = (props: ProfileItemProps) => {
</>
}
</Box>
{/* the third line show extra info or last updated time */}
{hasExtra ? (
<Box sx={{ ...boxStyle, fontSize: 14 }}>
<span title={t('shared.labels.usedTotal')}>
+1 -17
View File
@@ -31,8 +31,6 @@ export interface ProfileViewerRef {
edit: (item: IProfileItem) => void
}
// create or edit the profile
// remote / local
type ProfileViewerProps = Props & { ref?: Ref<ProfileViewerRef> }
// 同后端 constants::profile::MIN_UPDATE_INTERVAL
@@ -45,7 +43,6 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
const [loading, setLoading] = useState(false)
const { profiles } = useProfiles()
// file input
const fileDataRef = useRef<string | null>(null)
const { control, watch, setValue, reset, handleSubmit, getValues } =
@@ -94,7 +91,6 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
handleSubmit(async (form) => {
setLoading(true)
try {
// 基本验证
if (!form.type) {
throw new Error(t('profiles.modals.profileForm.errors.typeRequired'))
}
@@ -102,7 +98,6 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
throw new Error(t('profiles.modals.profileForm.errors.urlRequired'))
}
// 处理表单数据
const option = form.option ? { ...form.option } : undefined
if (option?.timeout_seconds) {
option.timeout_seconds = +option.timeout_seconds
@@ -123,16 +118,14 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
const isRemote = form.type === 'remote'
const isUpdate = openType === 'edit'
// 判断是否是当前激活的配置
const isActivating = isUpdate && form.uid === (profiles?.current ?? '')
// 保存原始代理设置以便回退成功后恢复
// Preserve proxy settings when the remote retry succeeds through another route.
const originalOptions = {
with_proxy: form.option?.with_proxy,
self_proxy: form.option?.self_proxy,
}
// 执行创建或更新操作,本地配置不需要回退机制
if (!isRemote) {
if (openType === 'new') {
await createProfile(item, fileDataRef.current)
@@ -145,9 +138,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
await patchProfile(form.uid, item)
}
} else {
// 远程配置使用回退机制
try {
// 尝试正常操作
if (openType === 'new') {
await createProfile(item, fileDataRef.current)
} else {
@@ -159,12 +150,10 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
await patchProfile(form.uid, item)
}
} catch {
// 首次创建/更新失败,尝试使用自身代理
showNotice.info(
'profiles.modals.profileForm.feedback.notifications.creationRetry',
)
// 使用自身代理的配置
const retryItem = {
...item,
option: {
@@ -174,7 +163,6 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
},
}
// 使用自身代理再次尝试
if (openType === 'new') {
await createProfile(retryItem, fileDataRef.current)
} else {
@@ -185,7 +173,6 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
}
await patchProfile(form.uid, retryItem)
// 编辑模式下恢复原始代理设置
await patchProfile(form.uid, { option: originalOptions })
}
@@ -195,12 +182,10 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
}
}
// 成功后的操作
setOpen(false)
setTimeout(() => reset(), 500)
fileDataRef.current = null
// 优化:UI先关闭,异步通知父组件
setTimeout(() => {
onChange(isActivating)
}, 0)
@@ -359,7 +344,6 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
control={control}
render={({ field }) => {
const interval = Number(field.value)
// 0 和空值表示不自动更新,不提醒
const tooFrequent =
Number.isFinite(interval) &&
interval > 0 &&
-18
View File
@@ -26,7 +26,6 @@ import { useAppRefreshers, useProxiesData } from '@/providers/app-data-context'
import { showNotice } from '@/services/notice-service'
import parseTraffic from '@/utils/parse-traffic'
// 样式化组件 - 类型框
const TypeBox = styled(Box)<{ component?: React.ElementType }>(({ theme }) => ({
display: 'inline-block',
border: '1px solid #ccc',
@@ -39,7 +38,6 @@ const TypeBox = styled(Box)<{ component?: React.ElementType }>(({ theme }) => ({
lineHeight: 1.25,
}))
// 解析过期时间
const parseExpire = (expire?: number) => {
if (!expire) return '-'
return dayjs(expire * 1000).format('YYYY-MM-DD')
@@ -54,15 +52,12 @@ export const ProviderButton = () => {
const providers = proxyView?.providers ?? []
const providerUnavailable = proxyView?.providerState === 'unavailable'
// 更新单个代理提供者
const updateProvider = useLockFn(async (name: string) => {
try {
// 设置更新状态
setUpdating((prev) => ({ ...prev, [name]: true }))
await updateProxyProvider(name)
// 刷新数据
await refreshProxy()
showNotice.success(
@@ -77,22 +72,18 @@ export const ProviderButton = () => {
message: String(err),
})
} finally {
// 清除更新状态
setUpdating((prev) => ({ ...prev, [name]: false }))
}
})
// 更新所有代理提供者
const updateAllProviders = useLockFn(async () => {
try {
// 获取所有provider的名称
const allProviders = providers.map(({ name }) => name)
if (allProviders.length === 0) {
showNotice.info('proxies.feedback.notifications.provider.none')
return
}
// 设置所有provider为更新中状态
const newUpdating = allProviders.reduce(
(acc, key) => {
acc[key] = true
@@ -102,19 +93,15 @@ export const ProviderButton = () => {
)
setUpdating(newUpdating)
// 改为串行逐个更新所有provider
for (const name of allProviders) {
try {
await updateProxyProvider(name)
// 每个更新完成后更新状态
setUpdating((prev) => ({ ...prev, [name]: false }))
} catch (err) {
console.error(`更新 ${name} 失败`, err)
// 继续执行下一个,不中断整体流程
}
}
// 刷新数据
await refreshProxy()
showNotice.success('proxies.feedback.notifications.provider.allUpdated')
@@ -123,7 +110,6 @@ export const ProviderButton = () => {
message: String(err),
})
} finally {
// 清除所有更新状态
setUpdating({})
}
})
@@ -190,7 +176,6 @@ export const ProviderButton = () => {
: null
const isUpdating = updating[key]
// 订阅信息
const sub = provider.subscriptionInfo
const hasSubInfo = !!sub
const upload = sub?.upload || 0
@@ -198,7 +183,6 @@ export const ProviderButton = () => {
const total = sub?.total || 0
const expire = sub?.expire || 0
// 流量使用进度
const progress =
total > 0
? Math.min(
@@ -272,7 +256,6 @@ export const ProviderButton = () => {
}
secondary={
<>
{/* 订阅信息 */}
{hasSubInfo && (
<>
<Box
@@ -296,7 +279,6 @@ export const ProviderButton = () => {
</span>
</Box>
{/* 进度条 */}
<LinearProgress
variant="determinate"
value={progress}
@@ -23,14 +23,7 @@ interface ProxyListStateInput {
const isSubscriptionProfile = (item: { type?: string }): boolean =>
item.type === 'remote' || item.type === 'local'
/**
* What the proxy page should show.
*
* Deliberately does *not* answer "is there anything to render". That question is answered by
* the list itself, in `hasRenderableItems`, because predicting it from beside the renderer is
* how the two came to disagree. This decides only the things knowable without building a list:
* direct mode, still-loading, and having no usable subscription to render from.
*/
/** Decides only pre-render states; the built list owns whether it has renderable content. */
export type ProxyListState =
| { kind: 'direct' }
| { kind: 'loading' }
@@ -67,12 +60,7 @@ export const resolveProxyListState = ({
return { kind: 'render' }
}
/**
* Why a list that turned out to be empty is empty.
*
* Only reachable once the renderer has built its list and found nothing, so it explains an
* observed emptiness rather than predicting one.
*/
/** Explains an empty list only after the renderer observes it. */
export const resolveEmptyListReason = ({
runningMode,
isProxyViewError,
+2 -24
View File
@@ -65,11 +65,6 @@ interface Props {
chainConfigData?: string | null
}
/**
* The empty state to draw when the render list turns out to contain nothing.
*
* Shared by both list components so the observation and its explanation stay together.
*/
function useEmptyRenderList() {
const { isProxyViewError } = useProxiesData()
const { runningMode } = useSystemData()
@@ -103,7 +98,6 @@ function useProxyRenderState(
const timeout = verge?.default_latency_timeout || 10000
// 测全部延迟
const handleCheckAll = useStableCallback(
useLockFn(async (groupName: string) => {
debugLog(`[ProxyGroups] 开始测试所有延迟,组: ${groupName}`)
@@ -133,8 +127,6 @@ function useProxyRenderState(
} catch (error) {
console.error(`[ProxyGroups] 延迟测试出错,组: ${groupName}`, error)
} finally {
// Re-sorting is no longer poked from here: the delay store announces that the test
// settled and the render list recomputes from that.
onProxies()
}
}),
@@ -254,7 +246,6 @@ function ChainProxyGroups(props: {
const virtualItems = virtualizer.getVirtualItems()
const activeStickyIndex = activeStickyIndexRef.current
// 从 localStorage 恢复滚动位置
useLayoutEffect(() => {
if (renderList.length === 0) return
const node = parentRef.current
@@ -335,7 +326,6 @@ function ChainProxyGroups(props: {
}
})
// The list is built; whether it holds anything is now an observation, not a guess.
if (!hasRenderableItems(renderList)) return emptyList
return (
@@ -376,11 +366,9 @@ function NormalProxyGroups(props: { mode: string }) {
} = useProxyRenderState(mode, false, null)
const emptyList = useEmptyRenderList()
const renderFirstRef = useRef(true)
// 恢复滚动位置期间设为 true,避免程序化滚动触发的 scroll 事件把中间值写回存储
// Do not persist intermediate positions produced while restoring virtual scroll.
const isRestoringRef = useRef(false)
// 目前无法使用 StickyVirtualList 的 initialOffset 值设置初始化,具体原因需排查
// 从 localStorage 恢复滚动位置
useLayoutEffect(() => {
if (renderList.length === 0) return
if (!renderFirstRef.current) return
@@ -388,15 +376,12 @@ function NormalProxyGroups(props: { mode: string }) {
if (!node) return
const savedPosition = getScrollPosition()
// 未保存过位置或位置为 0(顶部)时无需恢复
if (!savedPosition) {
renderFirstRef.current = false
return
}
// 虚拟列表初始使用预估高度,真实高度测量完成后总高度才会稳定。
// 尤其是过滤后节点数变少时,预估总高度常常不足以一次性滚动到目标位置,
// 因此跨帧重试,直到到达目标位置(或内容确实不够高)为止。
// Retry across frames until virtual-list measurements can reach the saved offset.
isRestoringRef.current = true
let rafId = 0
let attempts = 0
@@ -436,7 +421,6 @@ function NormalProxyGroups(props: { mode: string }) {
const handleScroll = useCallback(
(event: Event) => {
// 恢复位置过程中产生的滚动不写回存储,避免中间的钳制值覆盖真实位置
if (isRestoringRef.current) return
const target = event.target as HTMLElement | null
const nextScrollTop = target?.scrollTop ?? 0
@@ -480,7 +464,6 @@ function NormalProxyGroups(props: { mode: string }) {
[handleProxyGroupChange],
)
// 滚到对应的节点
const handleLocation = useStableCallback((group: ProxyGroupView) => {
if (!group) return
const { name, now } = group
@@ -501,7 +484,6 @@ function NormalProxyGroups(props: { mode: string }) {
}
})
// 定位到指定的代理组
const handleGroupLocationByName = useCallback(
(groupName: string) => {
const index = renderList.findIndex(
@@ -525,7 +507,6 @@ function NormalProxyGroups(props: { mode: string }) {
return Array.from(new Set(names))
}, [renderList])
// 点击代理组改变展开状态,先滚动到sticky的代理组位置,再收起展开状态
const handleGroupToggle = useCallback(
async (group: ProxyGroupView) => {
const index = renderList.findIndex(
@@ -589,7 +570,6 @@ function NormalProxyGroups(props: { mode: string }) {
[handleChangeProxy, handleCheckAll, onHeadState, handleLocation],
)
// The list is built; whether it holds anything is now an observation, not a guess.
if (!hasRenderableItems(renderList)) return emptyList
return (
@@ -605,7 +585,6 @@ function NormalProxyGroups(props: { mode: string }) {
renderItem={renderProxyItem}
/>
{/* 代理组导航栏 */}
{mode === 'rule' && (
<ProxyGroupNavigator
proxyGroupNames={proxyGroupNames}
@@ -640,7 +619,6 @@ export const ProxyGroups = (props: Props) => {
case 'empty':
return <ProxyEmptyState reason={listState.reason} />
case 'render':
// Whether there is anything to draw is the list's own answer, given below.
return isChainMode ? (
<ChainProxyGroups mode={mode} chainConfigData={chainConfigData} />
) : (
-11
View File
@@ -5,7 +5,6 @@ import { compileStringMatcher } from '@/utils/search-matcher'
import type { ResolvedMemberOccurrence } from './use-render-list'
// default | delay | alphabet
export type ProxySortType = 0 | 1 | 2
export type ProxySearchState = {
@@ -27,16 +26,9 @@ export function filterSort(
return sp
}
/**
* 可以通过延迟数/节点类型 过滤
*/
const regex1 = /delay([=<>])(\d+|timeout|error)/i
const regex2 = /type=(.*)/i
/**
* filter the proxy
* according to the regular conditions
*/
function filterProxies(
proxies: ResolvedMemberOccurrence[],
groupName: string,
@@ -90,9 +82,6 @@ function filterProxies(
return proxies.filter(({ member }) => compiled.matcher(member.ref.name))
}
/**
* sort the proxy
*/
function sortProxies(
proxies: ResolvedMemberOccurrence[],
groupName: string,
+2 -8
View File
@@ -5,21 +5,18 @@ import { hasRenderableItems, type IRenderItem } from './use-render-list'
const group = (hidden = false) =>
({ name: 'Proxies', hidden }) as unknown as IRenderItem['group']
/** A group header row. On its own it is only content when the group is visible. */
const header = (hidden = false): IRenderItem => ({
type: 0,
key: 'header',
group: group(hidden),
})
/** A member row — in chain mode the list is made entirely of these. */
const member = (key: string): IRenderItem => ({
type: 2,
key,
group: group(),
})
/** A column of members, which is how the chain list renders at column counts above one. */
const memberColumn = (key: string): IRenderItem => ({
type: 4,
key,
@@ -32,9 +29,7 @@ describe('hasRenderableItems', () => {
})
test('chain mode is answered by the chain list itself', () => {
// This is the case the old prediction got wrong in both directions: it asked whether any
// Selector or URLTest group existed, which is unrelated to what the chain list is built
// from. Here there are rows, so there is content, whatever groups happen to exist.
// Rendered rows, not group types, determine whether chain mode is empty.
expect(hasRenderableItems([member('chain:a')])).toBe(true)
expect(hasRenderableItems([memberColumn('chain-col:a')])).toBe(true)
})
@@ -54,8 +49,7 @@ describe('hasRenderableItems', () => {
})
test('rows inside a hidden but expanded group are still rows on screen', () => {
// Truthfulness over tidiness: the user can see these, so calling the page empty would be
// a lie. The old predicate said "empty" here.
// Visible member rows mean the page is not empty even without a visible group header.
expect(hasRenderableItems([header(true), member('member:a')])).toBe(true)
})
})
-14
View File
@@ -24,7 +24,6 @@ import { updateRuleProvider } from 'tauri-plugin-mihomo-api'
import { useAppRefreshers, useRulesData } from '@/providers/app-data-context'
import { showNotice } from '@/services/notice-service'
// 辅助组件 - 类型框
const TypeBox = styled(Box)<{ component?: React.ElementType }>(({ theme }) => ({
display: 'inline-block',
border: '1px solid #ccc',
@@ -44,18 +43,14 @@ export const ProviderButton = () => {
const { refreshRules, refreshRuleProviders } = useAppRefreshers()
const [updating, setUpdating] = useState<Record<string, boolean>>({})
// 检查是否有提供者
const hasProviders = Object.keys(ruleProviders || {}).length > 0
// 更新单个规则提供者
const updateProvider = useLockFn(async (name: string) => {
try {
// 设置更新状态
setUpdating((prev) => ({ ...prev, [name]: true }))
await updateRuleProvider(name)
// 刷新数据
await refreshRules()
await refreshRuleProviders()
@@ -71,22 +66,18 @@ export const ProviderButton = () => {
message: String(err),
})
} finally {
// 清除更新状态
setUpdating((prev) => ({ ...prev, [name]: false }))
}
})
// 更新所有规则提供者
const updateAllProviders = useLockFn(async () => {
try {
// 获取所有provider的名称
const allProviders = Object.keys(ruleProviders || {})
if (allProviders.length === 0) {
showNotice.info('rules.feedback.notifications.provider.none')
return
}
// 设置所有provider为更新中状态
const newUpdating = allProviders.reduce(
(acc, key) => {
acc[key] = true
@@ -96,19 +87,15 @@ export const ProviderButton = () => {
)
setUpdating(newUpdating)
// 改为串行逐个更新所有provider
for (const name of allProviders) {
try {
await updateRuleProvider(name)
// 每个更新完成后更新状态
setUpdating((prev) => ({ ...prev, [name]: false }))
} catch (err) {
console.error(`更新 ${name} 失败`, err)
// 继续执行下一个,不中断整体流程
}
}
// 刷新数据
await refreshRules()
await refreshRuleProviders()
@@ -118,7 +105,6 @@ export const ProviderButton = () => {
message: String(err),
})
} finally {
// 清除所有更新状态
setUpdating({})
}
})
+1 -18
View File
@@ -132,7 +132,6 @@ function parseList(str: string): string[] {
.filter(Boolean)
}
// 默认DNS配置
const DEFAULT_DNS_CONFIG = {
enable: true,
listen: ':53',
@@ -251,13 +250,12 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
hosts: '',
})
// 用于YAML编辑模式, 初始化标记:阻止 updateValuesFromYaml 在配置加载前误执行
// Prevent YAML-to-form sync before configuration loads.
const [yamlContent, setYamlContent] = useReducer(
(_: string, next: string) => next,
'{ isInitValue: true }',
)
// 从配置对象更新表单值
const updateValuesFromConfig = useCallback((config: any) => {
if (!config) return
@@ -388,7 +386,6 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
setYamlContent(yaml.dump(config, { forceQuotes: true }))
}, [generateDnsConfig, values.hosts])
// 重置为默认值
const resetToDefaults = useCallback(() => {
setValues({
enable: DEFAULT_DNS_CONFIG.enable,
@@ -425,7 +422,6 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
updateYamlFromValues()
}, [updateYamlFromValues])
// 从YAML更新表单值
const updateValuesFromYaml = useCallback(() => {
try {
const parsedYaml = yaml.load(yamlContent) as any
@@ -503,14 +499,11 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
[initDnsConfig],
)
// 生成DNS配置对象
// 处理保存操作
const onSave = useLockFn(async () => {
try {
let config: Record<string, any>
if (visualization) {
// 使用表单值生成配置
config = {}
const dnsConfig = generateDnsConfig()
@@ -523,7 +516,6 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
config.hosts = hosts
}
} else {
// 使用YAML编辑器的值
const parsedConfig = yaml.load(yamlContent)
if (typeof parsedConfig !== 'object' || parsedConfig === null) {
throw new Error(t('settings.modals.dns.errors.invalid'))
@@ -531,10 +523,8 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
config = parsedConfig as Record<string, any>
}
// 保存配置
await invoke('save_dns_config', { dnsConfig: config })
// 验证配置
const validation = await invoke<ValidationOutcome>(
'validate_dns_config',
{},
@@ -547,7 +537,6 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
: 'Configuration validation skipped'
let cleanErrorMsg = errorMsg
// 提取关键错误信息
if (errorMsg.includes('level=error')) {
const errorLines = errorMsg
.split('\n')
@@ -575,7 +564,6 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
return
}
// 如果DNS开关当前是打开的,则需要应用新的DNS配置
if (clash?.dns?.enable) {
await invoke('apply_dns_config', { apply: true })
mutateClash()
@@ -588,12 +576,10 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
}
})
// YAML编辑器内容变更处理
const handleYamlChange = (value?: string) => {
setYamlContent(value || '')
}
// 处理表单值变化
const handleChange = (field: string) => (event: any) => {
const value =
event.target.type === 'checkbox'
@@ -606,7 +592,6 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
[field]: value,
}
// 当可视化编辑模式下的值变化时,自动更新YAML
if (visualization) {
setTimeout(() => {
updateYamlFromValues()
@@ -667,7 +652,6 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
onCancel={() => setOpen(false)}
onOk={onSave}
>
{/* Warning message */}
<Typography
variant="body2"
color="warning.main"
@@ -1053,7 +1037,6 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
/>
</Item>
{/* Hosts 配置部分 */}
<Typography
variant="subtitle1"
sx={{ mt: 3, mb: 0, fontWeight: 'bold' }}
@@ -9,30 +9,23 @@ import { useClash } from '@/hooks/use-clash'
import { restartCore } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
// 定义开发环境的URL列表
// 这些URL在开发模式下会被自动包含在允许的来源中
// 在生产环境中,这些URL会被过滤掉
// 这样可以确保在生产环境中不会意外暴露开发环境的URL
// Development origins must never be persisted into production configuration.
const DEV_URLS = [
'tauri://localhost',
'http://tauri.localhost',
'http://localhost:3000',
]
// 获取完整的源列表,包括开发URL
const getFullOrigins = (origins: string[]) => {
// 合并现有源和开发URL,并去重
const allOrigins = [...origins, ...DEV_URLS]
const uniqueOrigins = [...new Set(allOrigins)]
return uniqueOrigins
}
// 过滤基础URL(确保后续添加)
const filterBaseOriginsForUI = (origins: string[]) => {
return origins.filter((origin: string) => !DEV_URLS.includes(origin.trim()))
}
// 统一使用的按钮样式
const buttonStyle = {
borderRadius: '8px',
textTransform: 'none',
@@ -47,7 +40,6 @@ const buttonStyle = {
},
}
// 添加按钮样式
const addButtonStyle = {
...buttonStyle,
backgroundColor: '#4CAF50',
@@ -57,7 +49,6 @@ const addButtonStyle = {
},
}
// 删除按钮样式
const deleteButtonStyle = {
...buttonStyle,
backgroundColor: '#FF5252',
@@ -85,7 +76,6 @@ export const HeaderConfiguration = forwardRef<ClashHeaderConfigingRef>(
const lastKeyRef = useRef(0) // 用于生成唯一的key
// CORS配置状态管理
const [corsConfig, setCorsConfig] = useState<{
allowPrivateNetwork: boolean
allowOrigins: AllowOriginItem[]
@@ -101,7 +91,6 @@ export const HeaderConfiguration = forwardRef<ClashHeaderConfigingRef>(
}
})
// 处理CORS配置变更
const handleCorsConfigChange = (
key: 'allowPrivateNetwork' | 'allowOrigins',
value: boolean | AllowOriginItem[],
@@ -112,7 +101,6 @@ export const HeaderConfiguration = forwardRef<ClashHeaderConfigingRef>(
}))
}
// 添加新的允许来源
const handleAddOrigin = () => {
lastKeyRef.current += 1
handleCorsConfigChange('allowOrigins', [
@@ -121,24 +109,20 @@ export const HeaderConfiguration = forwardRef<ClashHeaderConfigingRef>(
])
}
// 更新允许来源列表中的某一项
const handleUpdateOrigin = (index: number, value: string) => {
const newOrigins = [...corsConfig.allowOrigins]
newOrigins[index] = { ...newOrigins[index], value }
handleCorsConfigChange('allowOrigins', newOrigins)
}
// 删除允许来源列表中的某一项
const handleDeleteOrigin = (index: number) => {
const newOrigins = [...corsConfig.allowOrigins]
newOrigins.splice(index, 1)
handleCorsConfigChange('allowOrigins', newOrigins)
}
// 保存配置请求
const { loading, run: saveConfig } = useRequest(
async () => {
// 保存时使用完整的源列表(包括开发URL)
const fullOrigins = getFullOrigins(
corsConfig.allowOrigins.map((origin) => origin.value),
)
@@ -40,18 +40,15 @@ export function GuardState<T>(props: Props<T>) {
childProps[valueProps] = value
childProps[onChangeProps] = async (...args: any[]) => {
// 多次操作无效
if (lockRef.current) return
lockRef.current = true
try {
const newValue = onFormat ? onFormat(...args) : (args[0] as T)
// 先在ui上响应操作
onChange(newValue)
const now = Date.now()
// save the old value
if (waitTime <= 0 || now - lastRef.current >= waitTime) {
saveRef.current = value
}
@@ -62,14 +59,12 @@ export function GuardState<T>(props: Props<T>) {
await onGuard(newValue, value!)
lockRef.current = false
} else {
// debounce guard
clearTimeout(timeRef.current)
timeRef.current = setTimeout(async () => {
try {
await onGuard(newValue, saveRef.current!)
} catch (err: any) {
// 状态回退
onChange(saveRef.current!)
onCatch(err)
} finally {
@@ -78,7 +73,6 @@ export function GuardState<T>(props: Props<T>) {
}, waitTime)
}
} catch (err: any) {
// 状态回退
onChange(saveRef.current!)
onCatch(err)
lockRef.current = false
@@ -191,7 +191,6 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
const { systemProxyAddress } = useSystemData()
const displayedMixedPort = useDisplayedMixedPort()
// 为当前状态计算系统代理地址
const getSystemProxyAddress = useMemo(() => {
const isPacMode = value.pac ?? false
@@ -250,14 +249,11 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
close: () => setOpen(false),
}))
// 获取网络接口和主机名
const fetchNetworkInterfaces = async () => {
try {
// 获取系统网络接口信息
const interfaces = await getNetworkInterfacesInfo()
const ipAddresses: string[] = []
// 从interfaces中提取IPv4和IPv6地址
interfaces.forEach((iface) => {
iface.addr.forEach((address) => {
if (address.V4 && address.V4.ip) {
@@ -269,7 +265,6 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
})
})
// 获取当前系统的主机名
let hostname = ''
try {
hostname = await getSystemHostname()
@@ -278,12 +273,9 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
console.error('获取主机名失败:', err)
}
// 构建选项列表
const options = ['127.0.0.1', 'localhost']
// 确保主机名添加到列表,即使它是空字符串也记录下来
if (hostname) {
// 如果主机名不是localhost或127.0.0.1,则添加它
if (hostname !== 'localhost' && hostname !== '127.0.0.1') {
hostname = hostname + '.local'
options.push(hostname)
@@ -295,16 +287,13 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
debugLog('主机名为空')
}
// 添加IP地址
options.push(...ipAddresses)
// 去重
const uniqueOptions = Array.from(new Set(options))
debugLog('最终选项列表:', uniqueOptions)
setHostOptions(uniqueOptions)
} catch (error) {
console.error('获取网络接口失败:', error)
// 失败时至少提供基本选项
setHostOptions(['127.0.0.1', 'localhost'])
}
}
@@ -325,7 +314,6 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
return
}
// 修改验证规则,允许IP和主机名
const ipv4Regex =
/^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/
const ipv6Regex =
@@ -369,7 +357,6 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
let pacContent = value.pac_content
if (pacContent) {
pacContent = pacContent.replace(/%proxy_host%/g, value.proxy_host)
// 将 mixed-port 转换为字符串
const mixedPortStr = displayedMixedPort.toString()
pacContent = pacContent.replace(/%mixed-port%/g, mixedPortStr)
}
@@ -378,7 +365,6 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
patch.pac_file_content = pacContent
}
// 处理IPv6地址,如果是IPv6地址但没有被方括号包围,则添加方括号
let proxyHost = value.proxy_host
if (
ipv6Regex.test(proxyHost) &&
@@ -392,7 +378,6 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
patch.proxy_host = proxyHost
}
// 判断是否需要重置系统代理
const needResetProxy =
value.pac !== proxy_auto_config ||
proxyHost !== proxy_host ||
@@ -402,7 +387,6 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
Promise.resolve().then(async () => {
try {
// 乐观更新本地状态
if (Object.keys(patch).length > 0) {
mutateVerge({ ...verge, ...patch }, false)
}
@@ -413,7 +397,6 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
try {
await invalidateProxyState()
// 如果需要重置代理且代理当前启用
if (needResetProxy && enabled) {
const [currentSysProxy, currentAutoProxy] = await Promise.all([
getSystemProxy(),
@@ -439,7 +422,6 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
console.error('配置保存失败:', err)
mutateVerge()
showNotice.error(err)
// setOpen(true);
}
})
})
@@ -587,7 +569,6 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
setValue((v) => ({
...v,
use_default: e,
// 当取消选择use_default且当前bypass为空时,填充默认值
bypass: nextBypass,
}))
return
@@ -697,6 +678,5 @@ const FlexBox = styled('div')`
.label {
flex: none;
//width: 85px;
}
`
@@ -20,10 +20,6 @@ interface State {
showDetails: boolean
}
/**
* 流量统计专用错误边界组件
* 处理图表和流量统计组件的错误,提供优雅的降级体验
*/
export class TrafficErrorBoundary extends Component<Props, State> {
private retryCount = 0
private maxRetries = 3
@@ -39,7 +35,6 @@ export class TrafficErrorBoundary extends Component<Props, State> {
}
static getDerivedStateFromError(error: Error): Partial<State> {
// 更新状态以显示降级UI
return { hasError: true, error }
}
@@ -51,17 +46,14 @@ export class TrafficErrorBoundary extends Component<Props, State> {
errorInfo,
})
// 调用错误回调
if (this.props.onError) {
this.props.onError(error, errorInfo)
}
// 发送错误到监控系统(如果有的话)
this.reportError(error, errorInfo)
}
private reportError = (error: Error, errorInfo: ErrorInfo) => {
// 这里可以集成错误监控服务
const errorReport = {
message: error.message,
stack: error.stack,
@@ -72,8 +64,6 @@ export class TrafficErrorBoundary extends Component<Props, State> {
}
console.error('[TrafficErrorBoundary] 错误报告:', errorReport)
// TODO: 发送到错误监控服务
// sendErrorReport(errorReport);
}
private handleRetry = () => {
@@ -104,12 +94,10 @@ export class TrafficErrorBoundary extends Component<Props, State> {
render() {
if (this.state.hasError) {
// 如果提供了自定义降级组件,使用它
if (this.props.fallbackComponent) {
return this.props.fallbackComponent
}
// 默认错误UI
return (
<TrafficErrorFallback
error={this.state.error}
@@ -129,9 +117,6 @@ export class TrafficErrorBoundary extends Component<Props, State> {
}
}
/**
* 错误降级UI组件
*/
interface TrafficErrorFallbackProps {
error: Error | null
errorInfo: ErrorInfo | null
@@ -283,10 +268,6 @@ const TrafficErrorFallback: React.FC<TrafficErrorFallbackProps> = ({
)
}
/**
* 轻量级流量统计错误边界
* 用于小型流量显示组件,提供最小化的错误UI
*/
export const LightweightTrafficErrorBoundary: React.FC<{
children: ReactNode
}> = ({ children }) => {
+3 -26
View File
@@ -4,23 +4,7 @@ import delayManager, { type DelaySnapshot } from '@/services/delay'
const NO_DELAYS: DelaySnapshot = { of: () => -1 }
/**
* The delays a list sorts by, as a value it can depend on.
*
* Delays live in a module-level store outside React, so a memo that reads them has nothing
* React can see and goes stale — which is why the two places that sorted by delay each grew
* their own way of forcing a recompute, and why both missed the single-proxy path.
*
* `useSyncExternalStore` rather than an effect plus state: the value is read during render
* while the store notifies from an animation frame, so a settle landing between the two would
* otherwise be dropped with nothing left to re-trigger it. It also keeps the identity stable
* between settles, which a snapshot rebuilt per render would not — that would re-sort on
* every render, which is worse than the hack it replaces.
*
* The store announces when a test *settles*, not on every measurement: a list re-sorting on
* each result would reshuffle for the length of a batch, moving rows out from under the
* pointer. Per-proxy displays stay live on their own subscription and are unaffected.
*/
/** Exposes the external delay store to React, updating sort order only after a test settles. */
export const useGroupDelays = (group: string | null): DelaySnapshot => {
const subscribe = useCallback(
(onSettle: () => void) =>
@@ -35,18 +19,11 @@ export const useGroupDelays = (group: string | null): DelaySnapshot => {
return useSyncExternalStore(subscribe, read, read)
}
/**
* The delays for several groups at once, keyed by group name.
*
* The map's identity changes whenever any of them settles, but each group's own entry keeps
* its identity unless that group settled — so a per-group cache is not thrown away wholesale
* because a neighbouring group finished a test.
*/
/** Preserves unchanged group snapshot identities when another group settles. */
export const useGroupsDelays = (
groups: readonly string[],
): ReadonlyMap<string, DelaySnapshot> => {
// Joined so the subscription tracks membership rather than the array identity, which a
// list rebuilds on every render.
// Subscribe by group membership; callers rebuild the array on every render.
const groupKey = groups.join(' ')
const subscribe = useCallback(
+2 -7
View File
@@ -69,11 +69,7 @@ describe('useRecordSelection', () => {
})
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.
// Pair-wise writes let the backend merge concurrent choices against fresh state.
record('Proxy', 'Node A')
record('Fallback', 'Node C')
@@ -91,8 +87,7 @@ describe('useRecordSelection', () => {
})
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.
// Persistence failure cannot roll back a core selection that already succeeded.
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
recordSelectedNode.mockRejectedValueOnce(new Error('no profile'))
+2 -18
View File
@@ -3,21 +3,8 @@ import { useCallback } from 'react'
import { forgetSelectedNode, 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.
* Persists every group selection because core-local state is not durable across all run modes.
* Send only the changed pair so concurrent selections merge against fresh backend state.
*/
export const useRecordSelection = () => {
return useCallback(async (groupName: string, proxyName: string) => {
@@ -29,9 +16,6 @@ export const useRecordSelection = () => {
}, [])
}
/**
* Forget the persisted node selection for a group after its runtime fixed node is released.
*/
export const useForgetSelection = () => {
return useCallback(async (groupName: string) => {
try {
+2 -4
View File
@@ -15,7 +15,6 @@ import {
useQuery,
} from '@/services/query-client'
// 系统代理状态检测统一逻辑
export const useSystemProxyState = () => {
const { verge, mutateVerge } = useVerge()
const { sysproxy } = useSystemData()
@@ -33,7 +32,6 @@ export const useSystemProxyState = () => {
const { proxy_auto_config, proxy_host } = verge ?? {}
// OS 实际状态:enable + 地址匹配本应用
const indicator = (() => {
const host = proxy_host || '127.0.0.1'
if (proxy_auto_config) {
@@ -46,7 +44,7 @@ export const useSystemProxyState = () => {
}
})()
// "最后一次生效"模式:快速连续点击时,只执行最终状态
// Coalesce rapid clicks so only the latest requested state is applied.
const pendingRef = useRef<boolean | null>(null)
const busyRef = useRef(false)
@@ -82,7 +80,7 @@ export const useSystemProxyState = () => {
(prev) => (prev ? { ...prev, enable_system_proxy: confirmed } : prev),
false,
)
// Queued requests assumed the failed state was reached.
// Queued requests were based on a state that never landed.
pendingRef.current = null
throw error
} finally {
+3 -12
View File
@@ -9,10 +9,7 @@ import { useVisibility } from './use-visibility'
export const runStateQueryKey = ['getRuntimeState'] as const
/**
* Until the first snapshot arrives, assume the least capable environment: no service, no
* elevation, nothing asked of the user. Guessing "ready" here would flash a usable TUN toggle.
*/
/** Fail closed until the first snapshot so TUN never flashes as available. */
const unknownRunState: RunState = {
mode: 'NotRunning',
service: 'unknown',
@@ -26,13 +23,7 @@ const unknownRunState: RunState = {
serviceNeedsAttention: false,
}
/**
* The Run State: how the core is running and what backs it.
*
* One query key, kept fresh by `verge://run-state-changed` rather than polling. Every derived
* answer is computed in Rust and travels with the snapshot, so there is exactly one definition
* of "TUN can work" in the app.
*/
/** Event-driven run state; Rust owns all derived availability decisions. */
export function useSystemState() {
const pageVisible = useVisibility()
@@ -43,7 +34,7 @@ export function useSystemState() {
} = useQuery({
queryKey: runStateQueryKey,
queryFn: getRuntimeState,
// A safety net only: transitions are pushed, so this is not the primary path.
// A safety net only; transitions normally arrive by event.
refetchInterval: pageVisible ? 30000 : false,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
-6
View File
@@ -19,15 +19,10 @@ export const updateLastCheckTime = (timestamp?: number): number => {
return now
}
// --- useUpdate hook ---
export const useUpdate = (enabled: boolean = true) => {
const { verge } = useVerge()
const { auto_check_update } = verge || {}
// Determine if we should check for updates
// If enabled is explicitly false, don't check
// Otherwise, respect the auto_check_update setting (or default to true if null/undefined for manual triggers)
const shouldCheck = enabled && auto_check_update !== false
const fetchUpdate = async () => {
@@ -52,7 +47,6 @@ export const useUpdate = (enabled: boolean = true) => {
return { data }
}
// Shared last check timestamp
const { data: lastCheckUpdate } = useQuery({
queryKey: [LAST_CHECK_KEY],
queryFn: readLastCheckTime,
+2 -6
View File
@@ -34,17 +34,13 @@ export const useLayoutEvents = (
'getAutotemProxy',
])
},
// The Run State is pushed, not polled: every transition carries the new snapshot, so it
// is written straight into the cache instead of triggering a fetch.
// Transitions carry the full run-state snapshot, so write it directly to cache.
'verge://run-state-changed': (payload) => {
void setCacheDataAsync<RunState>(runStateQueryKey, payload)
},
'verge://notice-message': handleNotice,
},
// The Run State is the one thing here that arrives *only* by event, so it is the one
// thing a missed event leaves stale — until the next transition, which during startup can
// be a long time. Re-read once the listener is live, so the gap between the first read
// and the subscription cannot outlive the subscription.
// Re-read event-only state after subscribing to close the initial race window.
() => revalidateKeys(['getRuntimeState']),
)
}, [handleNotice])
+4 -33
View File
@@ -54,7 +54,6 @@ import {
deleteProfile,
enhanceProfiles,
getProfiles,
//restartCore,
getRuntimeLogs,
importProfile,
reorderProfile,
@@ -121,7 +120,6 @@ interface ProfileSwitchRequest {
force: boolean
}
// 记录profile切换状态
const debugProfileSwitch = (action: string, profile: string, extra?: any) => {
const timestamp = new Date().toISOString().substring(11, 23)
debugLog(`[Profile-Debug][${timestamp}] ${action}: ${profile}`, extra || '')
@@ -146,7 +144,6 @@ const ProfilePage = () => {
Map<string, number>
>(() => new Map())
// Batch selection states
const [batchMode, setBatchMode] = useState(false)
const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(
() => new Set(),
@@ -223,18 +220,14 @@ const ProfilePage = () => {
}
}, [addListener, mutateProfiles])
// 添加紧急恢复功能
const onEmergencyRefresh = useLockFn(async () => {
debugLog('[紧急刷新] 开始强制刷新所有数据')
try {
// 只失效 profiles 相关 query,不影响 WS 订阅、IP 缓存等其他 query
await revalidateQueries([['getProfiles'], ['getRuntimeLogs']])
// 强制重新获取配置数据
await mutateProfiles()
// 等待状态稳定后增强配置
await new Promise((resolve) => setTimeout(resolve, 500))
await onEnhance(false)
@@ -263,7 +256,6 @@ const ProfilePage = () => {
const viewerRef = useRef<ProfileViewerRef>(null)
const configRef = useRef<DialogRef>(null)
// distinguish type
const profileItems = useMemo(() => {
const items = profiles.items || []
@@ -278,7 +270,6 @@ const ProfilePage = () => {
const onImport = async () => {
if (!url) return
// 校验url是否为http/https
if (!/^https?:\/\//i.test(url)) {
showNotice.error('profiles.page.feedback.errors.invalidUrl')
return
@@ -291,7 +282,6 @@ const ProfilePage = () => {
await performRobustRefresh()
}
try {
// 尝试正常导入
await importProfile(url)
await handleImportSuccess('shared.feedback.notifications.importSuccess')
} catch (initialErr) {
@@ -305,7 +295,6 @@ const ProfilePage = () => {
showNotice.info('profiles.page.feedback.notifications.importRetry')
try {
// 使用自身代理尝试导入
await importProfile(url, {
with_proxy: false,
self_proxy: true,
@@ -314,7 +303,6 @@ const ProfilePage = () => {
'shared.feedback.notifications.importWithClashProxy',
)
} catch (retryErr) {
// 回退导入也失败
showNotice.error(
'profiles.page.feedback.notifications.importFail',
retryErr,
@@ -326,8 +314,7 @@ const ProfilePage = () => {
}
}
// 强化的刷新策略
// maxRetries 设为 1:useProfiles 内部 useQuery 已配置 retry:3,业务层只需 1 次额外重试
// `useProfiles` already retries three times; add only one business-level retry.
const performRobustRefresh = async () => {
let retryCount = 0
const maxRetries = 1
@@ -337,10 +324,8 @@ const ProfilePage = () => {
try {
debugLog(`[导入刷新] 第${retryCount + 1}次尝试刷新配置数据`)
// 强制刷新,绕过所有缓存
await mutateProfiles()
// 等待状态稳定
await new Promise((resolve) =>
setTimeout(resolve, baseDelay * (retryCount + 1)),
)
@@ -356,10 +341,8 @@ const ProfilePage = () => {
}
}
// 所有重试失败后的最后尝试
console.warn(`[导入刷新] 常规刷新失败,尝试清除缓存重新获取`)
try {
// 清除缓存并重新获取
await fetchCacheData(['getProfiles'], getProfiles)
await onEnhance(false)
showNotice.error(
@@ -566,7 +549,6 @@ const ProfilePage = () => {
}
})
// 更新所有订阅
const loadingCache = useLoadingCache()
const setLoadingCache = useSetLoadingCache()
const setLoadingProfiles = useCallback(
@@ -643,7 +625,6 @@ const ProfilePage = () => {
await Promise.allSettled(Array.from({ length: active }, worker))
} finally {
setLoadingProfiles(uids, false)
// 避免长时间批量更新后列表数据过晚刷新
void mutateProfiles()
}
},
@@ -664,11 +645,9 @@ const ProfilePage = () => {
if (text) setUrl(text)
}
// Batch selection functions
const toggleBatchMode = () => {
setBatchMode(!batchMode)
if (!batchMode) {
// Entering batch mode - clear previous selections
setSelectedProfiles(new Set())
}
}
@@ -701,11 +680,11 @@ const ProfilePage = () => {
const getSelectionState = () => {
if (selectedProfiles.size === 0) {
return 'none' // 无选择
return 'none'
} else if (selectedProfiles.size === profileItems.length) {
return 'all' // 全选
return 'all'
} else {
return 'partial' // 部分选择
return 'partial'
}
}
@@ -713,7 +692,6 @@ const ProfilePage = () => {
if (selectedProfiles.size === 0) return
try {
// Get all currently activating profiles
const currentActivating =
profiles.current && selectedProfiles.has(profiles.current)
? [profiles.current]
@@ -721,7 +699,6 @@ const ProfilePage = () => {
setActivatings((prev) => [...new Set([...prev, ...currentActivating])])
// Delete all selected profiles
for (const uid of selectedProfiles) {
await deleteProfile(uid)
}
@@ -729,12 +706,10 @@ const ProfilePage = () => {
await mutateProfiles()
await mutateLogs()
// If any deleted profile was current, enhance profiles
if (currentActivating.length > 0) {
await onEnhance(false)
}
// Clear selections and exit batch mode
setSelectedProfiles(new Set())
setBatchMode(false)
@@ -835,7 +810,6 @@ const ProfilePage = () => {
)}
</>
) : (
// Batch mode header
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<IconButton
size="small"
@@ -995,8 +969,6 @@ const ProfilePage = () => {
onSave={async (prev, curr) => {
if (prev !== curr && profiles.current === item.uid) {
await onEnhance(false)
// await restartCore();
// Notice.success(t("settings.feedback.notifications.clash.restartSuccess"), 1000);
}
}}
onDelete={() => {
@@ -1053,7 +1025,6 @@ const ProfilePage = () => {
ref={viewerRef}
onChange={async (isActivating) => {
mutateProfiles()
// 只有更改当前激活的配置时才触发全局重新加载
if (isActivating) {
await onEnhance(false)
}
+2 -20
View File
@@ -2,14 +2,7 @@
import { render, screen } from '@testing-library/react'
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
/**
* Stub the Tauri boundary and nothing above it.
*
* Everything the provider mounts — SWR queries, the config hooks, the event subscription,
* the mihomo plugin — goes through `invoke` or `listen`, so replacing just those two leaves
* all the real application code running. A test that stubbed the hooks instead could not
* catch what this one exists to catch.
*/
// Stub only the Tauri boundary so the real provider tree still renders.
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn(async () => undefined),
Channel: class {},
@@ -22,7 +15,6 @@ vi.mock('@tauri-apps/api/event', () => ({
import { AppDataProvider } from './app-data-provider'
beforeEach(() => {
// The provider's queries poll; keep them from firing during the assertion.
vi.useFakeTimers({ shouldAdvanceTime: true })
})
@@ -30,14 +22,7 @@ afterEach(() => {
vi.useRealTimers()
})
/**
* Mounting the provider is the whole test.
*
* A hook inside `AppDataProvider` that reads a context `AppDataProvider` itself supplies
* throws during render and blanks the entire UI. That shipped once. `typecheck`, `lint`,
* `knip` and `web:build` all passed with it present, and so did an independent review —
* nothing but rendering can see it.
*/
// Rendering catches a provider reading its own context, which static checks cannot detect.
test('AppDataProvider mounts and renders its children', () => {
render(
<AppDataProvider>
@@ -49,9 +34,6 @@ test('AppDataProvider mounts and renders its children', () => {
})
test('AppDataProvider does not consume a context it provides', () => {
// The failure mode is specific enough to name: `useCtx` throws
// "<hook> must be used within AppDataProvider" when a provider reads its own context,
// which is indistinguishable at the type level from a legitimate call site.
const errors: unknown[] = []
const consoleError = vi
.spyOn(console, 'error')
+3 -26
View File
@@ -83,13 +83,11 @@ export async function getClashInfo() {
return invoke<IClashInfo | null>('get_clash_info')
}
// Fault-tolerant current proxy mode read (does not depend on mihomo /configs
// strict BaseConfig deserialization); used as a fallback for the home mode card.
// Fallback mode read independent of strict mihomo `/configs` deserialization.
export async function getClashMode() {
return invoke<string | null>('get_clash_mode')
}
// Get runtime config which controlled by verge
export async function getRuntimeConfig() {
return invoke<IConfigData | null>('get_runtime_config')
}
@@ -126,17 +124,11 @@ export async function syncTrayProxySelection() {
return invoke<void>('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.
*/
/** Sends one selection pair so the backend can merge against current profile state. */
export async function recordSelectedNode(groupName: string, node: string) {
return invoke<void>('record_selected_node', { groupName, node })
}
/** Forget the persisted node selection for one proxy group in the current profile. */
export async function forgetSelectedNode(groupName: string) {
return invoke<void>('forget_selected_node', { groupName })
}
@@ -373,7 +365,6 @@ export async function listLocalBackup() {
return invoke<ILocalBackupFile[]>('list_local_backup')
}
// 获取当前运行模式
export type RunningMode = 'Service' | 'Sidecar' | 'NotRunning'
type ServiceHealth =
@@ -389,12 +380,7 @@ type PendingServiceAction =
| 'reinstall'
| 'forceReinstall'
/**
* How the core is running and what backs it, as one consistent snapshot.
*
* The derived answers travel with it — `tunCapable`, `serviceUsable`,
* `serviceNeedsAttention` — so nothing here is recomputed from the raw fields.
*/
/** Consistent core/service snapshot with backend-derived availability flags. */
export interface RunState {
mode: RunningMode
service: ServiceHealth
@@ -412,40 +398,31 @@ export const getRuntimeState = async () => {
return invoke<RunState>('get_runtime_state')
}
/** Operation associated with a pending failure. */
export type FailedOperation =
| 'systemProxyEnable'
| 'systemProxyDisable'
| 'systemProxyRestore'
| 'systemProxyGuard'
/** Latest unresolved failure for one stable code. */
export interface PendingFailure {
/** Stable table key. */
code: string
/** Full diagnostic chain. */
detail: string
operation: FailedOperation
/** Identity for repeated failures under the same code. */
sequence: number
}
/** Read unresolved failures without consuming them. */
export const getPendingFailures = async () => {
return invoke<PendingFailure[]>('get_pending_failures')
}
// 获取应用运行时间
export const getAppUptime = async () => {
return invoke<number>('get_app_uptime')
}
// 安装系统服务
export const installService = async () => {
return invoke<void>('install_service')
}
// 卸载系统服务
export const uninstallService = async () => {
return invoke<void>('uninstall_service')
}
+5 -16
View File
@@ -1,10 +1,6 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
/**
* The delay API is the only thing stubbed. What is under test is when the manager announces
* that a group has settled — the granularity that decides whether a sorted list re-orders
* once per test or once per result.
*/
// Stub only the delay API; these tests cover group-settle notification granularity.
vi.mock('tauri-plugin-mihomo-api', () => ({
delayProxyByName: vi.fn(async () => ({ delay: 120 })),
healthcheckNodeInProvider: vi.fn(async () => ({ delay: 120 })),
@@ -26,7 +22,6 @@ const node = (name: string) =>
},
}) as unknown as ResolvedProxyMember
/** The manager coalesces notifications onto a frame; give it one. */
const nextFrame = () => new Promise((resolve) => setTimeout(resolve, 0))
let settles = 0
@@ -45,8 +40,7 @@ afterEach(() => {
describe('group settle notifications', () => {
test('a single test settles the group once', async () => {
// The bug this fixes: a single-proxy retest updated that proxy's own display but never
// told the list to re-sort, so a group sorted by delay kept a stale order.
// A single-proxy retest must invalidate delay sorting.
await delayManager.checkDelay(node('a') as never, 'g', 5000)
await nextFrame()
@@ -54,9 +48,7 @@ describe('group settle notifications', () => {
})
test('a batch settles the group once, however many proxies it covered', async () => {
// The granularity that matters. Announcing per result would re-sort continuously for
// the length of the test, moving rows out from under the pointer — and the symptom
// would be jumpy scrolling, which nothing else here could assert.
// A batch announces once to avoid reordering rows as individual results arrive.
const proxies = Array.from({ length: 20 }, (_, index) => node(`n${index}`))
await delayManager.checkListDelay(proxies as never, 'g', 5000, 4)
@@ -75,8 +67,7 @@ describe('group settle notifications', () => {
})
test('two listeners on one group both hear it', async () => {
// Group listeners used to be one-per-group, so a second view silently replaced the
// first one's subscription.
// Multiple views must not replace one another's group subscriptions.
let second = 0
const stop = delayManager.addGroupListener('g', () => {
second += 1
@@ -91,9 +82,7 @@ describe('group settle notifications', () => {
})
test('a single test inside a running batch waits for the batch', async () => {
// The window the batch counter exists for: the per-row delay button and "test all" hold
// separate locks, so a user can start one inside the other. Announcing then would sort a
// half-measured group, which is the reordering this design is meant to prevent.
// Nested batches must wait for the outermost settle before announcing.
const proxies = Array.from({ length: 6 }, (_, index) => node(`n${index}`))
const batch = delayManager.checkListDelay(proxies as never, 'g', 5000, 2)
+11 -61
View File
@@ -13,7 +13,6 @@ import {
import { debugLog } from '@/utils/debug'
import { classifyDelay, DEFAULT_DELAY_TIMEOUT } from '@/utils/delay'
/** A group's delays, handed to sorting as a value it can depend on. */
export type DelaySnapshot = {
of: (member: ResolvedProxyMember) => number
}
@@ -32,22 +31,16 @@ class DelayManager {
private cache = new Map<string, DelayUpdate>()
private urlMap = new Map<string, string>()
// 每个节点的监听
private listenerMap = new Map<string, (update: DelayUpdate) => void>()
// 每个分组的监听
private groupListenerMap = new Map<string, Set<() => void>>()
/// A stable handle per group, replaced when that group settles. Consumers compare its
/// identity, so it must not be rebuilt on every read.
// Consumers compare snapshot identity; replace it only when the group settles.
private groupSnapshots = new Map<string, DelaySnapshot>()
/// Keyed by the joined group names a consumer asked for; cleared whenever any group
/// settles, so the map identity changes while unaffected groups keep theirs.
private groupSetSnapshots = new Map<
string,
ReadonlyMap<string, DelaySnapshot>
>()
/// Batches in flight per group. A single test that lands inside one must not announce:
/// sorting from a half-measured group is the reordering this design exists to avoid.
// Suppress sort notifications until every measurement in a group batch settles.
private activeBatches = new Map<string, number>()
private pendingItemUpdates = new Map<string, DelayUpdate[]>()
@@ -126,27 +119,14 @@ class DelayManager {
private queueGroupNotification(group: string) {
if ((this.activeBatches.get(group) ?? 0) > 0) return
// Dropped so the next read builds a fresh identity. Only this group's snapshot changes;
// the set-level map is rebuilt too, but its other entries keep their identities.
// Invalidate the set wrapper while retaining unaffected per-group identities.
this.groupSnapshots.delete(group)
this.groupSetSnapshots.clear()
this.pendingGroupUpdates.add(group)
this.scheduleGroupFlush()
}
/**
* A handle to this group's delays whose identity changes only when a test settles.
*
* Read during render and compared by identity, so it is cached rather than rebuilt: a
* fresh object per read would make every consumer recompute on every render.
*/
/**
* The delays for a set of groups, keyed by group name.
*
* Cached so its identity is stable between settles, while each group's own snapshot keeps
* its identity unless *that* group settled — which is what lets a per-group cache survive
* a test in a neighbouring group.
*/
/** Cached set snapshot for `useSyncExternalStore` identity comparisons. */
groupsDelays(groupKey: string): ReadonlyMap<string, DelaySnapshot> {
const cached = this.groupSetSnapshots.get(groupKey)
if (cached) return cached
@@ -159,6 +139,7 @@ class DelayManager {
return snapshots
}
/** Cached group snapshot whose identity changes only when that group settles. */
groupDelays(group: string): DelaySnapshot {
const existing = this.groupSnapshots.get(group)
if (existing) return existing
@@ -180,7 +161,6 @@ class DelayManager {
debugLog(
`[DelayManager] 获取测试URL,组: ${group}, URL: ${url || '未设置'}`,
)
// 如果未设置URL,返回默认URL
return url || 'http://cp.cloudflare.com/generate_204'
}
@@ -198,16 +178,7 @@ class DelayManager {
this.listenerMap.delete(key)
}
/**
* Called when a delay test for `group` has settled: once per single test, and once for a
* whole batch however many proxies it covered.
*
* "Settled" rather than "changed" on purpose. Per-proxy display updates are already live
* through `setListener`; this exists for the things that must not move while results are
* still arriving, chiefly sort order.
*/
/// Returns its own unsubscribe, so two views may watch the same group without one
/// silently replacing the other's listener.
/** Multiple views may subscribe independently; notifications occur only after settle. */
addGroupListener(group: string, listener: () => void): () => void {
const listeners = this.groupListenerMap.get(group) ?? new Set()
listeners.add(listener)
@@ -278,13 +249,11 @@ class DelayManager {
}
if (details?.history && details.history.length > 0) {
// 0ms以error显示
return details.history[details.history.length - 1].delay || 1e6
}
return -1
}
// 统一延迟测试检测
async unifiedDelayCheck(
name: string,
url: string,
@@ -296,12 +265,7 @@ class DelayManager {
return delayProxyByName(name, url, timeout)
}
/**
* Test one proxy, then tell the group its ordering may have changed.
*
* The announcement is the point at which a sorted list is allowed to re-sort. It is
* deliberately *not* made per result inside a batch — see `checkListDelay`.
*/
/** A single test may notify immediately; a batch defers notification until it settles. */
async checkDelay(
member: InteractableProxyMember,
group: string,
@@ -328,7 +292,6 @@ class DelayManager {
`[DelayManager] 开始测试延迟,代理: ${name}, 组: ${group}, 超时: ${timeout}ms`,
)
// 先将状态设置为测试中
this.setDelay(name, group, -2)
const startTime = Date.now()
@@ -337,18 +300,15 @@ class DelayManager {
const url = this.getUrl(group)
debugLog(`[DelayManager] 调用API测试延迟,代理: ${name}, URL: ${url}`)
// 设置超时处理, delay = 0 为超时
const timeoutPromise = new Promise<ProxyDelay>((resolve) => {
setTimeout(() => resolve({ delay: 0 }), timeout)
})
// 使用Promise.race来实现超时控制
const result = await Promise.race([
this.unifiedDelayCheck(apiName, url, timeout, providerName),
timeoutPromise,
])
// 确保至少显示500ms的加载动画
const elapsedTime = Date.now() - startTime
if (elapsedTime < 500) {
await new Promise((resolve) => setTimeout(resolve, 500 - elapsedTime))
@@ -360,7 +320,6 @@ class DelayManager {
return this.setDelay(name, group, delay, { elapsed })
} catch (error) {
// 确保至少显示500ms的加载动画
await new Promise((resolve) => setTimeout(resolve, 500))
console.error(`[DelayManager] 延迟测试出错,代理: ${name}`, error)
const delay = 1e6 // error
@@ -381,7 +340,6 @@ class DelayManager {
)
const names = proxies.map((member) => member.ref.name)
this.activeBatches.set(group, (this.activeBatches.get(group) ?? 0) + 1)
// 设置正在延迟测试中
names.forEach((name) => {
this.setDelay(name, group, -2)
})
@@ -395,34 +353,28 @@ class DelayManager {
const currName = currMember.ref.name
try {
// 确保API调用前状态为测试中
this.setDelay(currName, group, -2)
// 添加一些随机延迟,避免所有请求同时发出和返回
// Stagger requests so a batch does not hit the core at once.
if (index > 1) {
// 第一个不延迟,保持响应性
await new Promise((resolve) =>
setTimeout(resolve, Math.random() * 200),
)
}
// Measured without announcing: a sorted list that re-ordered on every result would
// reshuffle continuously for the length of the test, with rows moving out from under
// the pointer. The group is told once, below, when the batch has settled.
// Do not reorder a list around the pointer while batch results are arriving.
await this.measureDelay(currMember, group, timeout)
} catch (error) {
console.error(
`[DelayManager] 批量测试单个代理出错,代理: ${currName}`,
error,
)
// 设置为错误状态
this.setDelay(currName, group, 1e6)
}
return help()
}
// 限制并发数,避免发送太多请求
const actualConcurrency = Math.min(concurrency, names.length, 10)
debugLog(`[DelayManager] 实际并发数: ${actualConcurrency}`)
@@ -434,8 +386,7 @@ class DelayManager {
try {
await Promise.all(promiseList)
} finally {
// In a `finally` so a throw cannot leave the group unannounced: the proxies would sit
// at -2 and the order would stay stale with nothing later to repair it.
// Always release the batch and notify; otherwise failures leave stale sort state.
const remaining = (this.activeBatches.get(group) ?? 1) - 1
if (remaining > 0) {
this.activeBatches.set(group, remaining)
@@ -474,8 +425,7 @@ class DelayManager {
case 'error':
return 'error.main'
case 'measured':
// How a measurement is graded is this widget's own decision; the thresholds differ
// from the signal icon's on purpose, because a colour has fewer steps than four bars.
// Colour and signal bars intentionally use different grading thresholds.
if (delay >= 400) return 'warning.main'
if (delay >= 250) return 'primary.main'
return 'success.main'
+3 -28
View File
@@ -2,36 +2,20 @@ import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import type { RunState } from './cmds'
/**
* Every event pushed to the frontend, and what it carries.
*
* The names were string literals on both sides of the IPC seam, so renaming one was a silent
* no-op rather than a compile error. Declaring them here does not make the Rust side check
* itself, but it does mean the frontend has exactly one place to look and one payload type
* per name instead of a `listen<T>` guess at each call site.
*/
/** Centralizes frontend event names and payload types for the unchecked Rust IPC seam. */
interface VergeEvents {
/** The core's own configuration changed. */
'verge://refresh-clash-config': string
/** The app's configuration changed. */
'verge://refresh-verge-config': string
/** The set of profiles changed. */
'verge://refresh-profiles': string
/** The active profile's proxies changed. */
'verge://refresh-proxy-config': null
/** A backend message for the user: `[status, message]`. */
'verge://notice-message': [string, string]
/** A profile's auto-update timer was rescheduled. */
'verge://timer-updated': string
/** How the core is running changed; carries the whole snapshot. */
'verge://run-state-changed': RunState
/** Pending failures changed; reread the table. */
'verge://pending-failures-changed': null
/** Which profile is active changed. */
'profile-changed': string
'profile-update-started': { uid?: string }
'profile-update-completed': { uid?: string }
/** Frontend to frontend: the home card asks the test page to run every test. */
'verge://test-all': null
}
@@ -42,17 +26,8 @@ type VergeEventHandlers = {
}
/**
* Subscribe to backend events, returning the teardown.
*
* Registration is asynchronous, so a subscription can resolve after the caller has already
* torn down; that is tracked here rather than at each call site, where it was implemented
* four different ways and omitted in one of them.
*
* `onSubscribed` runs once every listener is live. Anything whose state arrives only by event
* needs it: between the first read and the listener being registered there is a gap, and a
* transition landing in it is not queued anywhere — it is simply missed, and the next event is
* whenever the backend happens to move again. Re-reading at that point closes the gap without
* going back to polling for it.
* Returns synchronous teardown for asynchronous registrations. `onSubscribed` lets callers
* reread event-only state after all listeners are live, closing the initial race window.
*/
export const subscribeVergeEvents = (
handlers: VergeEventHandlers,
+2 -27
View File
@@ -17,14 +17,11 @@ interface NoticeItem {
readonly duration: number
readonly message?: ReactNode
readonly i18n?: NoticeTranslationDescriptor
/** Stable classified failure code. */
readonly code?: string
/** Requested operation when known. */
readonly operation?: FailedOperation
timerId?: ReturnType<typeof setTimeout>
}
/** Failures owned by the recovery dialog. */
const CODES_A_DIALOG_REPORTS = new Set<string>([
'SYSPROXY_PRIVILEGE_REQUIRED',
'SYSPROXY_SIDECAR_WHILE_SERVICE_READY',
@@ -61,7 +58,6 @@ const DEFAULT_DURATIONS: Readonly<Record<NoticeType, number>> = {
}
const TRANSLATION_KEY_PATTERN = /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+$/
/** Localized explanation for each stable failure code. */
const CODED_ERROR_TRANSLATION_KEYS: Readonly<Record<string, TranslationKey>> = {
CLASH_CONFIG_UPDATE_FAILED:
'settings.feedback.errors.clash.configUpdateFailed',
@@ -106,7 +102,6 @@ interface ParsedNoticeExtras {
params?: Record<string, unknown>
raw?: unknown
duration?: number
/** Code extracted before translation parameters are flattened. */
nestedCode?: string
}
@@ -166,7 +161,6 @@ function parseNoticeExtras(extras: NoticeExtra[]): ParsedNoticeExtras {
}
}
/** Replace structured failures in translation params with readable detail. */
function readableParams(
source: Record<string, unknown>,
): Record<string, unknown> {
@@ -178,7 +172,6 @@ function readableParams(
)
}
/** Find a classified failure nested in translation parameters. */
function nestedFailureCode(
source?: Record<string, unknown>,
): string | undefined {
@@ -243,10 +236,8 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
return proto === Object.prototype || proto === null
}
/** Maximum code points rendered in a notice. */
const MAX_RENDERED_TEXT_CHARS = 500
/** Bound resolved display text by code point and append an ellipsis when truncated. */
export function boundNoticeText(text: string): string {
let end = 0
let counted = 0
@@ -267,7 +258,6 @@ function createRawDescriptor(message: string): NoticeTranslationDescriptor {
}
}
/** Messages longer than any real translation key are treated as raw text. */
const MAX_TRANSLATION_KEY_CHARS = 200
function isLikelyTranslationKey(key: string) {
@@ -309,13 +299,9 @@ function extractDisplayText(input: unknown): string | undefined {
}
}
/** What a failed Tauri command reports. */
export interface CommandFailure {
/** Stable identifier for the kind of failure, when the backend could classify it. */
code?: string
/** Requested operation when known by the command. */
operation?: FailedOperation
/** Human-readable diagnostic detail. */
detail: string
}
@@ -328,19 +314,16 @@ function isCommandFailure(input: unknown): input is CommandFailure {
)
}
/** Extract display text from a structured or legacy failure. */
export function errorDetail(input: unknown): string {
return extractDisplayText(input) ?? ''
}
/** Parse a classified command failure for notice rendering. */
function parseCommandFailure(input: unknown) {
if (!isCommandFailure(input) || !input.code) return undefined
const explanation = CODED_ERROR_TRANSLATION_KEYS[input.code]
return {
translationKey: explanation ?? 'shared.feedback.errors.operationFailed',
/** Whether this code has a specific explanation. */
explained: explanation !== undefined,
detail: input.detail.trim() || undefined,
}
@@ -513,16 +496,8 @@ function failureOperation(input: unknown): FailedOperation | undefined {
}
/**
* Shows a global notice; `showNotice.success / error / info` are the usual entry points.
*
* - `message`: i18n key string, `{ key, params }`, ReactNode, Error/any value (message is extracted)
* - `extras` parsed left-to-right: first plain object is i18n params; next value is raw payload; first number overrides duration (ms, 0 = persistent; defaults: success 3000 / info 5000 / error 8000)
* - Returns a notice id for manual closing via `hideNotice(id)`
*
* @example showNotice.success("profiles.page.feedback.notifications.batchDeleted");
* @example showNotice.error(err); // pass an Error directly
* @example showNotice.error("shared.feedback.notifications.common.refreshFailed", err); // Simply pass an Error directly; but we recommend using { err } with i18n key and placeholders.
* @example showNotice.error("profiles.page.feedback.errors.invalidUrl", { url }, 4000);
* Shows a global notice. Extras are parsed as i18n params, raw payload, then duration;
* returns an ID accepted by `hideNotice`.
*/
export const showNotice: ShowNotice = Object.assign(baseShowNotice, {
success: (message: NoticeContent, ...extras: NoticeExtra[]) =>

Some files were not shown because too many files have changed in this diff Show More