mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-14 21:20:48 +08:00
chore: trim excessive comments
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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"
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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()}`
|
||||
|
||||
@@ -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
@@ -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 '<'
|
||||
if (token === '>') return '>'
|
||||
// Keep supported tags; escape everything else so it displays as text.
|
||||
return allowedTag.test(token)
|
||||
? token
|
||||
: token.replace(/</g, '<').replace(/>/g, '>')
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,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 _};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, "验证已在进行中,跳过新的验证请求");
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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,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");
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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%',
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
) : (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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({})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'))
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user