mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-29 10:02:09 +08:00
Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fd6be642e | ||
|
|
433230aedc | ||
|
|
05475c7bb5 | ||
|
|
eb60c21c62 | ||
|
|
4489c9a26f | ||
|
|
98a7932742 | ||
|
|
c678d60964 | ||
|
|
d5bc4ff58b | ||
|
|
b81981162b | ||
|
|
6fa6d7664f | ||
|
|
4967ad347e | ||
|
|
6394f347f5 | ||
|
|
e56711b2db | ||
|
|
3e28b287d1 | ||
|
|
51a4aa80dd | ||
|
|
3953e125b2 | ||
|
|
cbd5ed73a5 | ||
|
|
02e5e0e4a6 | ||
|
|
0498ba3e4f | ||
|
|
f39644ee6b | ||
|
|
488f022c05 | ||
|
|
eefd025806 | ||
|
|
10ca0a5d0c | ||
|
|
324c8c9016 | ||
|
|
02520d7392 | ||
|
|
5b8edbaead | ||
|
|
1ed8050947 | ||
|
|
3d9a79a3fb | ||
|
|
8b88ddb7f2 | ||
|
|
02bd739ab6 | ||
|
|
b6c7af82a7 | ||
|
|
5e6a696087 | ||
|
|
27d8a71d41 | ||
|
|
9dd5aa676b | ||
|
|
4e5ac8683b | ||
|
|
7965711945 | ||
|
|
396d343a3a | ||
|
|
b729e59650 | ||
|
|
a3cc58013b | ||
|
|
cef66cd745 | ||
|
|
3b7bfa10fd | ||
|
|
b238175f18 | ||
|
|
8f00096a9f | ||
|
|
9f498a85d0 | ||
|
|
e014b913d2 | ||
|
|
f38ceae3fb | ||
|
|
089635c8bf | ||
|
|
dc5a8a5ff4 | ||
|
|
476ddb63c3 | ||
|
|
c6b51171a1 | ||
|
|
d4287970bc | ||
|
|
b20fdc9b96 | ||
|
|
5b10331022 | ||
|
|
3ec858eb9f | ||
|
|
3693efcf6c |
@@ -4,11 +4,11 @@
|
||||
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:2": {
|
||||
"version": "24.21.0",
|
||||
"pnpmVersion": "12.11.2"
|
||||
"version": "24.22.1",
|
||||
"pnpmVersion": "12.15.0"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/rust:1": {
|
||||
"version": "1.99.0",
|
||||
"version": "1.99.1",
|
||||
"profile": "default"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/git:1": {},
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Issue Guidelines / Issue 提交指引
|
||||
|
||||
Use these suggestions to make reports easier to assess. State what you observed
|
||||
and what you could not verify; unknown details are fine.
|
||||
|
||||
以下建议有助于维护者评估问题。请说明实际观察到的现象和无法确认的部分;未知信息可以留空。
|
||||
|
||||
- Search existing issues by symptom, conditions, and outcome. Add new evidence
|
||||
to an existing report when it describes the same problem.
|
||||
按现象、条件和结果搜索已有 Issue。若描述的是同一问题,请在原 Issue 中补充新证据。
|
||||
- Keep each issue to one distinct user problem or requested outcome. Split
|
||||
unrelated findings instead of bundling an audit or checklist.
|
||||
每个 Issue 聚焦一个独立的问题或需求结果;无关发现请拆开,不要把审计清单打包成一个 Issue。
|
||||
- For bugs, include the version, operating system, steps, and actual versus
|
||||
expected behavior when known. A root-cause diagnosis is not required. Share
|
||||
only relevant logs and redact tokens or personal information.
|
||||
反馈 Bug 时,尽可能提供版本、操作系统、复现步骤及实际与预期行为。无需先判断根因;只提供相关日志,并遮盖令牌和个人信息。
|
||||
- For feature requests, describe the user task, current limitation, desired
|
||||
outcome, and affected platforms. An implementation proposal is optional.
|
||||
提交功能请求时,请描述用户要完成的任务、当前限制、期望结果和适用平台;实现方案不是必填项。
|
||||
- Separate observations from guesses. An AI or scanner finding is a hypothesis
|
||||
until its affected configuration, concrete impact, and supporting evidence
|
||||
are stated; do not present a list of theoretical risks as confirmed bugs.
|
||||
区分观察结果和推测。AI 或扫描器的发现应先视为假设;请说明受影响的配置、具体影响和支持证据,不要把理论风险清单写成已确认的 Bug。
|
||||
- Do not post secrets, personal data, or sensitive exploit details in a public
|
||||
issue. Ask maintainers how to share sensitive security information safely.
|
||||
请勿在公开 Issue 中发布密钥、个人信息或敏感利用细节;如涉及安全敏感信息,请先询问维护者如何安全提供。
|
||||
- AI assistance is welcome; human contributors do not need to disclose it. If
|
||||
an AI agent or automation creates or co-authors an issue, add a final body line
|
||||
such as `Assisted by: GPT-5.6 High`. Omit effort when the runtime does not
|
||||
report it. Disclosure is transparency only, not evidence for or against issue
|
||||
quality.
|
||||
欢迎使用 AI 辅助,人工提交者无需披露。如果 Issue 由 AI Agent 或自动化创建或共同撰写,请在正文末尾添加披露行,例如 `Assisted by: GPT-5.6 High`。运行环境未提供 effort 时可省略。披露仅用于提高透明度,不作为 Issue 质量的正面或负面证据。
|
||||
@@ -10,6 +10,8 @@ body:
|
||||
value: |
|
||||
## 在提交问题之前,请确认以下事项:
|
||||
|
||||
提交前可参考 [Issue 提交指引](https://github.com/clash-verge-rev/clash-verge-rev/blob/dev/.github/ISSUE_GUIDELINES.md),说明可观察到的问题和证据;不确定的原因请标为猜测。
|
||||
|
||||
1. 请 **确保** 您已经查阅了 [Clash Verge Rev 官方文档](https://clash-verge-rev.github.io/guide/term.html) 以及 [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
||||
2. 请 **确保** [已有的问题](https://github.com/clash-verge-rev/clash-verge-rev/issues?q=is%3Aissue) 中没有人提交过相似 issue,否则请在已有的 issue 下进行讨论
|
||||
3. 请 **务必** 给 issue 填写一个简洁明了的标题,以便他人快速检索
|
||||
@@ -19,6 +21,8 @@ body:
|
||||
|
||||
## Before submitting the issue, please make sure of the following checklist:
|
||||
|
||||
See the [Issue guidelines](https://github.com/clash-verge-rev/clash-verge-rev/blob/dev/.github/ISSUE_GUIDELINES.md) for how to describe observed behavior and evidence; mark uncertain causes as hypotheses.
|
||||
|
||||
1. Please make sure you have read the [Clash Verge Rev official documentation](https://clash-verge-rev.github.io/guide/term.html) and [FAQ](https://clash-verge-rev.github.io/faq/windows.html)
|
||||
2. Please make sure there is no similar issue in the [existing issues](https://github.com/clash-verge-rev/clash-verge-rev/issues?q=is%3Aissue), otherwise please discuss under the existing issue
|
||||
3. Please be sure to fill in a concise and clear title for the issue so that others can quickly search
|
||||
|
||||
@@ -9,12 +9,16 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
## 在提交问题之前,请确认以下事项:
|
||||
可参考 [Issue 提交指引](https://github.com/clash-verge-rev/clash-verge-rev/blob/dev/.github/ISSUE_GUIDELINES.md),描述用户任务、当前限制和期望结果。
|
||||
|
||||
1. 请 **确保** 您已经查阅了 [Clash Verge Rev 官方文档](https://clash-verge-rev.github.io/guide/term.html) 确认软件不存在类似的功能
|
||||
2. 请 **确保** [已有的问题](https://github.com/clash-verge-rev/clash-verge-rev/issues?q=is%3Aissue) 中没有人提交过相似 issue,否则请在已有的 issue 下进行讨论
|
||||
3. 请 **务必** 给issue填写一个简洁明了的标题,以便他人快速检索
|
||||
4. 请 **务必** 先下载 [AutoBuild](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/autobuild) 版本测试,确保该功能还未实现
|
||||
5. 请 **务必** 按照模板规范详细描述问题,否则 issue 将会被关闭
|
||||
## Before submitting the issue, please make sure of the following checklist:
|
||||
See the [Issue guidelines](https://github.com/clash-verge-rev/clash-verge-rev/blob/dev/.github/ISSUE_GUIDELINES.md) for describing the user task, current limitation, and desired outcome.
|
||||
|
||||
1. Please make sure you have read the [Clash Verge Rev official documentation](https://clash-verge-rev.github.io/guide/term.html) to confirm that the software does not have similar functions
|
||||
2. Please make sure there is no similar issue in the [existing issues](https://github.com/clash-verge-rev/clash-verge-rev/issues?q=is%3Aissue), otherwise please discuss under the existing issue
|
||||
3. Please be sure to fill in a concise and clear title for the issue so that others can quickly search
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
## Problem
|
||||
|
||||
What breaks or what is missing. Link the issue if one exists (`Fixes #123`).
|
||||
What breaks or what is missing. Product bug fixes and features require a
|
||||
pre-existing issue; link it here (`Fixes #123` only when this PR fully resolves
|
||||
it). Maintenance work (such as chore, perf, docs, refactor, test, CI/build,
|
||||
dependency, style, routine localization, or release changes) does not require
|
||||
an issue link; link one when it defines the scope. For reverts, link the PR or
|
||||
commit being reverted. For security-sensitive fixes that cannot be tracked
|
||||
publicly, follow maintainer-approved private tracking.
|
||||
|
||||
## Approach
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.21.0'
|
||||
node-version: '24.22.1'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: '1.99.0'
|
||||
toolchain: '1.99.1'
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Add Rust Target
|
||||
@@ -185,7 +185,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.21.0'
|
||||
node-version: '24.22.1'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Pnpm Cache
|
||||
@@ -206,7 +206,7 @@ jobs:
|
||||
|
||||
- name: Add Rust Target
|
||||
run: |
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
|
||||
rustup target list --installed
|
||||
echo "Rust target ${{ matrix.target }} installed."
|
||||
|
||||
@@ -257,7 +257,7 @@ jobs:
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: '1.99.0'
|
||||
toolchain: '1.99.1'
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Add Rust Target
|
||||
@@ -282,7 +282,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.21.0'
|
||||
node-version: '24.22.1'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Pnpm Cache
|
||||
@@ -347,7 +347,7 @@ jobs:
|
||||
|
||||
- name: Add Rust Target
|
||||
run: |
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
|
||||
rustup target list --installed
|
||||
echo "Rust target ${{ matrix.target }} installed."
|
||||
|
||||
@@ -426,7 +426,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.21.0'
|
||||
node-version: '24.22.1'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Pnpm Cache
|
||||
@@ -454,7 +454,7 @@ jobs:
|
||||
|
||||
- name: Add Rust Target
|
||||
run: |
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
|
||||
rustup target list --installed
|
||||
echo "Rust target ${{ matrix.target }} installed."
|
||||
|
||||
@@ -524,7 +524,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.21.0'
|
||||
node-version: '24.22.1'
|
||||
|
||||
- uses: pnpm/action-setup@v6.1.0
|
||||
name: Install pnpm
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
name: Contract Parity
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "src-tauri/**/*.rs"
|
||||
- "crates/**/*.rs"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "src-tauri/Cargo.toml"
|
||||
- "crates/**/Cargo.toml"
|
||||
- "rust-toolchain.toml"
|
||||
- "src-tauri/tauri.conf*.json"
|
||||
- "src/services/bindings.ts"
|
||||
- "scripts/gen-contract.mjs"
|
||||
- "scripts/check-contract-changes.mjs"
|
||||
- "biome.json"
|
||||
- "biome.jsonc"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "pnpm-workspace.yaml"
|
||||
- "scripts/contract-inputs.json"
|
||||
- ".github/workflows/contract-parity.yml"
|
||||
- ".husky/pre-push"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
HUSKY: 0
|
||||
|
||||
jobs:
|
||||
contract-parity:
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
prefix-key: 'v1-rust'
|
||||
key: 'rust-shared-stable-ubuntu-22.04-x86_64-unknown-linux-gnu-contract'
|
||||
workspaces: |
|
||||
. -> target
|
||||
cache-all-crates: true
|
||||
cache-workspace-crates: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.22.1'
|
||||
cache: 'pnpm'
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Provision sidecar placeholders for tauri-build
|
||||
run: |
|
||||
mkdir -p src-tauri/sidecar src-tauri/resources dist
|
||||
touch dist/index.html
|
||||
for bin in clash-verge-service clash-verge-service-install clash-verge-service-uninstall verge-mihomo verge-mihomo-alpha; do
|
||||
touch "src-tauri/sidecar/${bin}-x86_64-unknown-linux-gnu"
|
||||
done
|
||||
|
||||
- name: Check generated contract without changing the checkout
|
||||
run: pnpm gen:contract --check
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.21.0'
|
||||
node-version: '24.22.1'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
|
||||
- name: Install Rust Stable
|
||||
if: github.event.inputs[matrix.input] == 'true'
|
||||
uses: dtolnay/rust-toolchain@1.99.0
|
||||
uses: dtolnay/rust-toolchain@1.99.1
|
||||
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
if: github.event.inputs[matrix.input] == 'true'
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.21.0'
|
||||
node-version: '24.22.1'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Pnpm Cache
|
||||
@@ -129,7 +129,7 @@ jobs:
|
||||
if: github.event.inputs[matrix.input] == 'true'
|
||||
run: |
|
||||
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
|
||||
rustup target list --installed
|
||||
echo "Rust target ${{ matrix.target }} installed."
|
||||
|
||||
|
||||
@@ -2,11 +2,23 @@ name: Frontend Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "src/**"
|
||||
- "**/*.js"
|
||||
- "**/*.ts"
|
||||
- "**/*.tsx"
|
||||
- "**/*.css"
|
||||
- "**/*.scss"
|
||||
- "**/*.json"
|
||||
- "**/*.md"
|
||||
- "pnpm-lock.yaml"
|
||||
- "pnpm-workspace.yaml"
|
||||
- "vite.config.*"
|
||||
- ".github/workflows/frontend-check.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
env:
|
||||
HUSKY: 0
|
||||
@@ -17,46 +29,17 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Check frontend changes
|
||||
id: check_frontend
|
||||
uses: dorny/paths-filter@v4
|
||||
with:
|
||||
filters: |
|
||||
frontend:
|
||||
- 'src/**'
|
||||
- '**/*.js'
|
||||
- '**/*.ts'
|
||||
- '**/*.tsx'
|
||||
- '**/*.css'
|
||||
- '**/*.scss'
|
||||
- '**/*.json'
|
||||
- '**/*.md'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'knip.json'
|
||||
- 'eslint.config.ts'
|
||||
- 'tsconfig.json'
|
||||
- 'vite.config.*'
|
||||
|
||||
- name: Skip if no frontend changes
|
||||
if: steps.check_frontend.outputs.frontend != 'true'
|
||||
run: echo "No frontend changes, skipping frontend checks."
|
||||
|
||||
- name: Install pnpm
|
||||
if: steps.check_frontend.outputs.frontend == 'true'
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
if: steps.check_frontend.outputs.frontend == 'true'
|
||||
with:
|
||||
node-version: '24.21.0'
|
||||
node-version: '24.22.1'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Restore pnpm cache
|
||||
if: steps.check_frontend.outputs.frontend == 'true'
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.pnpm-store
|
||||
@@ -65,94 +48,21 @@ jobs:
|
||||
pnpm-shared-stable-${{ runner.os }}-
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
if: steps.check_frontend.outputs.frontend == 'true'
|
||||
|
||||
- name: Run Prettier check
|
||||
if: steps.check_frontend.outputs.frontend == 'true'
|
||||
run: pnpm format:check
|
||||
|
||||
- name: Run ESLint
|
||||
if: steps.check_frontend.outputs.frontend == 'true'
|
||||
run: pnpm lint
|
||||
|
||||
- name: Run TypeScript typecheck
|
||||
if: steps.check_frontend.outputs.frontend == 'true'
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Run tests
|
||||
if: steps.check_frontend.outputs.frontend == 'true'
|
||||
run: pnpm test
|
||||
|
||||
- name: Run Knip
|
||||
if: steps.check_frontend.outputs.frontend == 'true'
|
||||
run: pnpm knip:check
|
||||
|
||||
- name: Architecture gates (listen/emit only in the event bus, invoke only in services)
|
||||
if: steps.check_frontend.outputs.frontend == 'true'
|
||||
run: node scripts/check-architecture.mjs listen emit invoke
|
||||
|
||||
contract-parity:
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Check contract inputs
|
||||
id: check_inputs
|
||||
uses: dorny/paths-filter@v4
|
||||
with:
|
||||
filters: scripts/contract-inputs.json
|
||||
|
||||
- name: Skip if contract inputs unchanged
|
||||
if: steps.check_inputs.outputs.contract != 'true'
|
||||
run: echo "No contract inputs changed, skipping parity check."
|
||||
|
||||
- name: Install Rust Stable
|
||||
if: steps.check_inputs.outputs.contract == 'true'
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Rust Cache
|
||||
if: steps.check_inputs.outputs.contract == 'true'
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
prefix-key: 'v1-rust'
|
||||
key: 'rust-shared-stable-ubuntu-22.04-x86_64-unknown-linux-gnu-contract'
|
||||
workspaces: |
|
||||
. -> target
|
||||
cache-all-crates: true
|
||||
cache-workspace-crates: true
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.check_inputs.outputs.contract == 'true'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
- name: Install pnpm
|
||||
if: steps.check_inputs.outputs.contract == 'true'
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
if: steps.check_inputs.outputs.contract == 'true'
|
||||
with:
|
||||
node-version: '24.21.0'
|
||||
cache: 'pnpm'
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
if: steps.check_inputs.outputs.contract == 'true'
|
||||
|
||||
- name: Provision sidecar placeholders for tauri-build
|
||||
if: steps.check_inputs.outputs.contract == 'true'
|
||||
run: |
|
||||
mkdir -p src-tauri/sidecar src-tauri/resources dist
|
||||
touch dist/index.html
|
||||
for bin in clash-verge-service clash-verge-service-install clash-verge-service-uninstall verge-mihomo verge-mihomo-alpha; do
|
||||
touch "src-tauri/sidecar/${bin}-x86_64-unknown-linux-gnu"
|
||||
done
|
||||
|
||||
- name: Check generated contract without changing the checkout
|
||||
if: steps.check_inputs.outputs.contract == 'true'
|
||||
run: pnpm gen:contract --check
|
||||
|
||||
@@ -2,6 +2,13 @@ name: Clippy Lint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "src-tauri/**"
|
||||
- "crates/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "rust-toolchain.toml"
|
||||
- ".github/workflows/lint-clippy.yml"
|
||||
workflow_dispatch:
|
||||
env:
|
||||
HUSKY: 0
|
||||
@@ -23,28 +30,6 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Check src-tauri changes
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
id: check_changes
|
||||
uses: dorny/paths-filter@v4
|
||||
with:
|
||||
filters: |
|
||||
rust:
|
||||
- 'src-tauri/**'
|
||||
|
||||
- name: Skip if src-tauri not changed
|
||||
if: github.event_name != 'workflow_dispatch' && steps.check_changes.outputs.rust != 'true'
|
||||
run: echo "No src-tauri changes, skipping clippy lint."
|
||||
|
||||
- name: Continue if src-tauri changed
|
||||
if: github.event_name != 'workflow_dispatch' && steps.check_changes.outputs.rust == 'true'
|
||||
run: echo "src-tauri changed, running clippy lint."
|
||||
|
||||
- name: Manual trigger - always run
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
echo "Manual trigger detected: skipping changes check and running clippy."
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
@@ -79,7 +64,7 @@ jobs:
|
||||
run: cargo clippy-all
|
||||
|
||||
- name: Run Windows security tests
|
||||
if: matrix.os == 'windows-latest' && (github.event_name == 'workflow_dispatch' || steps.check_changes.outputs.rust == 'true')
|
||||
if: matrix.os == 'windows-latest'
|
||||
working-directory: ./src-tauri
|
||||
run: cargo test --all-features --target ${{ matrix.target }}
|
||||
|
||||
|
||||
@@ -204,7 +204,7 @@ jobs:
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: '1.99.0'
|
||||
toolchain: '1.99.1'
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Add Rust Target
|
||||
@@ -230,7 +230,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.21.0'
|
||||
node-version: '24.22.1'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
@@ -244,7 +244,7 @@ jobs:
|
||||
|
||||
- name: Add Rust Target
|
||||
run: |
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
|
||||
rustup target list --installed
|
||||
echo "Rust target ${{ matrix.target }} installed."
|
||||
|
||||
@@ -311,7 +311,7 @@ jobs:
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: '1.99.0'
|
||||
toolchain: '1.99.1'
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Add Rust Target
|
||||
@@ -331,7 +331,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.21.0'
|
||||
node-version: '24.22.1'
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
@@ -389,7 +389,7 @@ jobs:
|
||||
|
||||
- name: Add Rust Target
|
||||
run: |
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
|
||||
rustup target list --installed
|
||||
echo "Rust target ${{ matrix.target }} installed."
|
||||
|
||||
@@ -446,7 +446,7 @@ jobs:
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: '1.99.0'
|
||||
toolchain: '1.99.1'
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Add Rust Target
|
||||
@@ -466,7 +466,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.21.0'
|
||||
node-version: '24.22.1'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
@@ -487,7 +487,7 @@ jobs:
|
||||
|
||||
- name: Add Rust Target
|
||||
run: |
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
|
||||
rustup target list --installed
|
||||
echo "Rust target ${{ matrix.target }} installed."
|
||||
|
||||
@@ -547,7 +547,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.21.0'
|
||||
node-version: '24.22.1'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
@@ -573,7 +573,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.21.0'
|
||||
node-version: '24.22.1'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
@@ -638,7 +638,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.21.0'
|
||||
node-version: '24.22.1'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
|
||||
@@ -6,6 +6,10 @@ name: Check Formatting
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "src-tauri/**"
|
||||
- "**/*.rs"
|
||||
- ".github/workflows/rustfmt.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -19,25 +23,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Check Rust changes
|
||||
id: check_rust
|
||||
uses: dorny/paths-filter@v4
|
||||
with:
|
||||
filters: |
|
||||
rust:
|
||||
- 'src-tauri/**'
|
||||
- '**/*.rs'
|
||||
|
||||
- name: Skip if no Rust changes
|
||||
if: steps.check_rust.outputs.rust != 'true'
|
||||
run: echo "No Rust changes, skipping rustfmt."
|
||||
|
||||
- name: install Rust stable and rustfmt
|
||||
if: steps.check_rust.outputs.rust == 'true'
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt
|
||||
|
||||
- name: run cargo fmt
|
||||
if: steps.check_rust.outputs.rust == 'true'
|
||||
run: cargo fmt --manifest-path ./src-tauri/Cargo.toml --all -- --check
|
||||
|
||||
@@ -22,13 +22,13 @@ jobs:
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: '1.99.0'
|
||||
toolchain: '1.99.1'
|
||||
targets: x86_64-pc-windows-msvc
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.21.0'
|
||||
node-version: '24.22.1'
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.21.0'
|
||||
node-version: '24.22.1'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.21.0'
|
||||
node-version: '24.22.1'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.21.0'
|
||||
node-version: '24.22.1'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
|
||||
+3
-3
@@ -1,3 +1,3 @@
|
||||
nodejs 24.21.0
|
||||
pnpm 12.11.2
|
||||
rust 1.99.0
|
||||
nodejs 24.22.1
|
||||
pnpm 12.15.0
|
||||
rust 1.99.1
|
||||
|
||||
@@ -18,22 +18,25 @@ These rules apply to every change, whether human- or agent-authored. They match
|
||||
the ownership evidence the AI-slop review evaluates (see
|
||||
[`pr-ai-slop-review.md`](.github/workflows/pr-ai-slop-review.md)).
|
||||
|
||||
1. **Issue first.** Non-trivial changes require a pre-existing issue describing
|
||||
the problem. If none exists, ask the maintainers to open or approve one
|
||||
before implementing.
|
||||
1. **Issue first.** Product bug fixes and features require a pre-existing issue;
|
||||
self-scoped maintenance work does not. Follow the PR template for linkage and
|
||||
revert references. If a required issue is missing, ask maintainers to open or
|
||||
approve one before implementing. For security-sensitive fixes that cannot be
|
||||
tracked publicly, follow maintainer-approved private tracking.
|
||||
2. **Scope discipline.** Every changed file must be justifiable from the linked
|
||||
issue. No drive-by refactors, renames, formatting churn, or dependency bumps
|
||||
unrelated to the problem being fixed.
|
||||
issue or the stated PR scope. No drive-by refactors, renames, formatting
|
||||
churn, or dependency bumps unrelated to the work.
|
||||
3. **Author accountability.** AI assistance is welcome, but the contributor owns
|
||||
the result: understand the change, describe the problem and approach in your
|
||||
own words, and verify the change against the reported behavior before
|
||||
submitting.
|
||||
4. **Tests are justified, not default.** Do not add tests, test scaffolding, or
|
||||
speculative defensive code unless the linked issue demands them. When a test
|
||||
is genuinely necessary — it reproduces the reported regression or guards
|
||||
behavior whose breakage would otherwise go unnoticed — keep it minimal and
|
||||
state in the PR body why it is needed. Bulk test files and defensive
|
||||
programming for hypothetical failure modes are PR bloat, not rigor.
|
||||
speculative defensive code unless the linked issue or stated task demands
|
||||
them. When a test is genuinely necessary — it reproduces the reported
|
||||
regression or guards behavior whose breakage would otherwise go unnoticed —
|
||||
keep it minimal and state in the PR body why it is needed. Bulk test files
|
||||
and defensive programming for hypothetical failure modes are PR bloat, not
|
||||
rigor.
|
||||
5. **Comments state constraints, not narration.** Write a comment only for a
|
||||
non-obvious constraint the code cannot express; never restate what the code
|
||||
does.
|
||||
@@ -44,13 +47,12 @@ the ownership evidence the AI-slop review evaluates (see
|
||||
real evidence instead: reproduction steps, failure output, targeted tests.
|
||||
8. **Minimal diffs.** Match the surrounding code's style, naming, and comment
|
||||
density. Do not introduce new dependencies or restructure working code unless
|
||||
the issue demands it.
|
||||
9. **Disclose AI automation.** When an agent produces or co-produces a change,
|
||||
append a footer line to the PR body with the model and effort used (e.g.
|
||||
`Assisted by: GPT-5.6 High`). The PR template intentionally omits this line —
|
||||
the agent adds it itself, humans are not asked to declare anything. Effort may
|
||||
be omitted when the runtime does not report it. Disclosure is transparency
|
||||
only; it does not substitute for any rule above.
|
||||
the linked issue or stated task demands it.
|
||||
9. **Disclose AI automation.** When an agent creates or co-creates a PR or Issue,
|
||||
append a final body line with the model and effort used (e.g.
|
||||
`Assisted by: GPT-5.6 High`). Effort may be omitted when the runtime does not
|
||||
report it. Human contributors are not asked to disclose AI assistance.
|
||||
Disclosure is transparency only and carries no positive or negative weight.
|
||||
10. **Compiled workflows.** The AI-slop review policy in
|
||||
[pr-ai-slop-review.md](.github/workflows/pr-ai-slop-review.md) is compiled:
|
||||
after editing it, run `gh aw compile` and commit the regenerated
|
||||
@@ -59,8 +61,10 @@ the ownership evidence the AI-slop review evaluates (see
|
||||
[`template/Changelog.md`](template/Changelog.md): one line per
|
||||
user-visible change, no internals.
|
||||
|
||||
## Pull Request Shape
|
||||
## Issue and PR Workflows
|
||||
|
||||
Describe three things, briefly: the problem (with issue link), why this approach
|
||||
solves it, and what changed. See
|
||||
[`PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md).
|
||||
When drafting, triaging, splitting, or implementing from a community issue, use
|
||||
the conditional checklist in [`.github/ISSUE_GUIDELINES.md`](.github/ISSUE_GUIDELINES.md).
|
||||
Do not make GitHub issue changes without explicit authorization. For PRs, follow
|
||||
the directly loaded [PR template](.github/PULL_REQUEST_TEMPLATE.md); do not
|
||||
duplicate its prompts here.
|
||||
|
||||
Generated
+473
-343
File diff suppressed because it is too large
Load Diff
+5
-14
@@ -49,15 +49,7 @@ split-debuginfo = "unpacked"
|
||||
[patch.crates-io]
|
||||
# Backport Windows menu command ID recycling on the muda 0.20 compatibility branch.
|
||||
muda = { git = "https://github.com/wonfen/muda.git", rev = "547de49352791653fbdfa785f20f3c7ece8468e2" }
|
||||
# tauri-specta has no tauri 3 release yet; ride the tauri team's port and the
|
||||
# unpublished specta rc.26 line it pins. Drop all six entries once both ship
|
||||
# on crates.io.
|
||||
tauri-specta = { git = "https://github.com/tauri-apps/tauri-specta", branch = "tauri-v3" }
|
||||
specta = { git = "https://github.com/specta-rs/specta.git", rev = "2757fa8899926dce7da999780c7edf2dc08a732a" }
|
||||
specta-macros = { git = "https://github.com/specta-rs/specta.git", rev = "2757fa8899926dce7da999780c7edf2dc08a732a" }
|
||||
specta-serde = { git = "https://github.com/specta-rs/specta.git", rev = "2757fa8899926dce7da999780c7edf2dc08a732a" }
|
||||
specta-typescript = { git = "https://github.com/specta-rs/specta.git", rev = "2757fa8899926dce7da999780c7edf2dc08a732a" }
|
||||
specta-util = { git = "https://github.com/specta-rs/specta.git", rev = "2757fa8899926dce7da999780c7edf2dc08a732a" }
|
||||
|
||||
[workspace.dependencies]
|
||||
clash-verge-draft = { path = "crates/clash-verge-draft" }
|
||||
clash-verge-media-unlock = { path = "crates/clash-verge-media-unlock" }
|
||||
@@ -69,14 +61,13 @@ tracing-estuary = { git = "https://github.com/Tunglies/tracing-estuary.git", bra
|
||||
tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" }
|
||||
tracing = "0.1.44"
|
||||
|
||||
tauri = { version = "3.0.0-alpha.4" }
|
||||
tauri-runtime-wry = "3.0.0-alpha.4"
|
||||
tauri-plugin-clipboard-manager = "3.0.0-alpha.2"
|
||||
tauri = { version = "2.12.4" }
|
||||
tauri-plugin-clipboard-manager = "2.5.0"
|
||||
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
|
||||
anyhow = "1.0.104"
|
||||
chrono = "0.4.45"
|
||||
criterion = { version = "0.8.2", features = ["async_tokio"] }
|
||||
tokio = { version = "1.53.2", features = [
|
||||
tokio = { version = "1.53.3", features = [
|
||||
"rt-multi-thread",
|
||||
"macros",
|
||||
"time",
|
||||
@@ -97,7 +88,7 @@ rust_iso3166 = "0.2.0"
|
||||
smartstring = { version = "1.0.1" }
|
||||
|
||||
serde = { version = "1.0.229" }
|
||||
serde_json = { version = "1.0.151" }
|
||||
serde_json = { version = "1.0.154" }
|
||||
serde_yaml_ng = { version = "0.10.0" }
|
||||
bitflags = { version = "2.13.2" }
|
||||
socket2 = "0.6.5"
|
||||
|
||||
+15
-1
@@ -3,19 +3,33 @@
|
||||
<details>
|
||||
<summary><strong> 🐞 修复问题 </strong></summary>
|
||||
|
||||
- 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置的问题
|
||||
- 修复规则扩展包含空列表项时,「编辑规则」界面报错的问题
|
||||
- 修复托盘测速与代理页行为不一致的问题,移除托盘「测试延迟」入口
|
||||
- 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置
|
||||
- 修复 首页 网站测速失败后卡片一直显示测试中
|
||||
- 修复 代理 页展开分组后不显示最新测速结果,需切换节点才刷新
|
||||
- 修复 代理 在全局模式下无法展开节点组
|
||||
- 修复 自定义 CSS 无法修改编辑器字体和字号的问题
|
||||
|
||||
**🐧 Linux**
|
||||
|
||||
- Linux:修复 deb/rpm 安装包错误包含多余的 `frontend-contract` 可执行文件,导致下游重新打包失败的问题
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> ✨ 新增功能 </strong></summary>
|
||||
|
||||
- 新增虚拟网卡设置的「DNS 模式」选项,支持配置 mihomo 系统 DNS 行为
|
||||
- 新增「IP 信息」自动刷新开关,关闭后仍可手动刷新
|
||||
- 新增 Muse AI 解锁检测,针对 Muse AI 特有的 Cloudflare 人机验证增加「Risky」状态标记
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||
|
||||
- 优化「链式代理」:开启前需确认网速影响提示,空面板新增「关闭」按钮
|
||||
|
||||
**🖥️ Windows**
|
||||
|
||||
|
||||
@@ -70,8 +70,6 @@ tray:
|
||||
global: عام
|
||||
profiles: الملفات التعريفية
|
||||
proxies: وكلاء
|
||||
testLatency: اختبار التأخير
|
||||
testingLatency: جارٍ اختبار التأخير…
|
||||
systemProxy: وكيل النظام
|
||||
tunMode: وضع TUN
|
||||
closeAllConnections: إغلاق كل الاتصالات
|
||||
|
||||
@@ -70,8 +70,6 @@ tray:
|
||||
global: Global
|
||||
profiles: Profile
|
||||
proxies: Proxy
|
||||
testLatency: Latenz testen
|
||||
testingLatency: Latenz wird getestet…
|
||||
systemProxy: Systemproxy
|
||||
tunMode: TUN-Modus
|
||||
closeAllConnections: Alle Verbindungen schließen
|
||||
|
||||
@@ -70,8 +70,6 @@ tray:
|
||||
global: Global
|
||||
profiles: Profiles
|
||||
proxies: Proxies
|
||||
testLatency: Test Latency
|
||||
testingLatency: Testing latency…
|
||||
systemProxy: System Proxy
|
||||
tunMode: TUN Mode
|
||||
closeAllConnections: Close All Connections
|
||||
|
||||
@@ -70,8 +70,6 @@ tray:
|
||||
global: Global
|
||||
profiles: Perfiles
|
||||
proxies: Proxies
|
||||
testLatency: Probar latencia
|
||||
testingLatency: Probando latencia…
|
||||
systemProxy: Proxy del sistema
|
||||
tunMode: Modo TUN
|
||||
closeAllConnections: Cerrar todas las conexiones
|
||||
|
||||
@@ -70,8 +70,6 @@ tray:
|
||||
global: سراسری
|
||||
profiles: پروفایلها
|
||||
proxies: پروکسیها
|
||||
testLatency: آزمایش تأخیر
|
||||
testingLatency: در حال آزمایش تأخیر…
|
||||
systemProxy: پروکسی سیستم
|
||||
tunMode: حالت TUN
|
||||
closeAllConnections: بستن همه اتصالها
|
||||
|
||||
@@ -70,8 +70,6 @@ tray:
|
||||
global: Global
|
||||
profiles: Profil
|
||||
proxies: Proksi
|
||||
testLatency: Uji Latensi
|
||||
testingLatency: Menguji latensi…
|
||||
systemProxy: Proksi Sistem
|
||||
tunMode: Mode TUN
|
||||
closeAllConnections: Tutup Semua Koneksi
|
||||
|
||||
@@ -70,8 +70,6 @@ tray:
|
||||
global: グローバル
|
||||
profiles: プロファイル
|
||||
proxies: プロキシ
|
||||
testLatency: 遅延テスト
|
||||
testingLatency: 遅延をテスト中…
|
||||
systemProxy: システムプロキシ
|
||||
tunMode: TUN モード
|
||||
closeAllConnections: すべての接続を閉じる
|
||||
|
||||
@@ -70,8 +70,6 @@ tray:
|
||||
global: 글로벌
|
||||
profiles: 프로필
|
||||
proxies: 프록시
|
||||
testLatency: 지연 테스트
|
||||
testingLatency: 지연 테스트 중…
|
||||
systemProxy: 시스템 프록시
|
||||
tunMode: TUN 모드
|
||||
closeAllConnections: 모든 연결 닫기
|
||||
|
||||
@@ -70,8 +70,6 @@ tray:
|
||||
global: Global
|
||||
profiles: Perfis
|
||||
proxies: Proxies
|
||||
testLatency: Test Latency
|
||||
testingLatency: Testing latency…
|
||||
systemProxy: Proxy do sistema
|
||||
tunMode: Modo TUN
|
||||
closeAllConnections: Fechar todas as conexões
|
||||
|
||||
@@ -70,8 +70,6 @@ tray:
|
||||
global: Глобальный
|
||||
profiles: Профили
|
||||
proxies: Прокси
|
||||
testLatency: Проверить задержку
|
||||
testingLatency: Проверка задержки…
|
||||
systemProxy: Системный прокси
|
||||
tunMode: Режим TUN
|
||||
closeAllConnections: Закрыть все соединения
|
||||
|
||||
@@ -70,8 +70,6 @@ tray:
|
||||
global: Küresel
|
||||
profiles: Profiller
|
||||
proxies: Vekil'ler
|
||||
testLatency: Gecikmeyi Test Et
|
||||
testingLatency: Gecikme test ediliyor…
|
||||
systemProxy: Sistem Vekil'i
|
||||
tunMode: TUN Modu
|
||||
closeAllConnections: Tüm Bağlantıları Kapat
|
||||
|
||||
@@ -70,8 +70,6 @@ tray:
|
||||
global: Глобаль
|
||||
profiles: Профильләр
|
||||
proxies: Проксилар
|
||||
testLatency: Кичектерүне тикшерү
|
||||
testingLatency: Кичектерү тикшерелә…
|
||||
systemProxy: Системалы прокси
|
||||
tunMode: TUN режимы
|
||||
closeAllConnections: Барлык тоташуларны ябу
|
||||
|
||||
@@ -70,8 +70,6 @@ tray:
|
||||
global: 全局
|
||||
profiles: 订阅
|
||||
proxies: 代理
|
||||
testLatency: 测试延迟
|
||||
testingLatency: 正在测试延迟…
|
||||
systemProxy: 系统代理
|
||||
tunMode: TUN 模式
|
||||
closeAllConnections: 关闭所有连接
|
||||
|
||||
@@ -70,8 +70,6 @@ tray:
|
||||
global: 全域
|
||||
profiles: 訂閱
|
||||
proxies: 代理
|
||||
testLatency: 測試延遲
|
||||
testingLatency: 正在測試延遲…
|
||||
systemProxy: 系統代理
|
||||
tunMode: 虛擬網路介面卡模式
|
||||
closeAllConnections: 關閉所有連線
|
||||
|
||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
specta = { version = "=2.0.0-rc.26", features = ["derive", "function"] }
|
||||
specta = { version = "=2.0.0-rc.25", features = ["derive", "function"] }
|
||||
clash-verge-logging = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
@@ -11,6 +11,7 @@ mod chatgpt;
|
||||
mod claude;
|
||||
mod disney_plus;
|
||||
mod gemini;
|
||||
mod muse;
|
||||
mod netflix;
|
||||
mod prime_video;
|
||||
mod spotify;
|
||||
@@ -30,6 +31,7 @@ enum UnlockCheck {
|
||||
ChatGptWeb,
|
||||
Claude,
|
||||
Gemini,
|
||||
MuseAi,
|
||||
YouTubePremium,
|
||||
BahamutAnime,
|
||||
Netflix,
|
||||
@@ -46,6 +48,7 @@ impl UnlockCheck {
|
||||
Self::ChatGptWeb,
|
||||
Self::Claude,
|
||||
Self::Gemini,
|
||||
Self::MuseAi,
|
||||
Self::YouTubePremium,
|
||||
Self::BahamutAnime,
|
||||
Self::Netflix,
|
||||
@@ -66,6 +69,7 @@ impl UnlockCheck {
|
||||
Self::ChatGptWeb => chatgpt::CHATGPT_WEB_NAME,
|
||||
Self::Claude => claude::CLAUDE_NAME,
|
||||
Self::Gemini => gemini::GEMINI_NAME,
|
||||
Self::MuseAi => muse::MUSE_AI_NAME,
|
||||
Self::YouTubePremium => youtube::YOUTUBE_PREMIUM_NAME,
|
||||
Self::BahamutAnime => bahamut::BAHAMUT_ANIME_NAME,
|
||||
Self::Netflix => netflix::NETFLIX_NAME,
|
||||
@@ -83,6 +87,7 @@ impl UnlockCheck {
|
||||
Self::ChatGptWeb => chatgpt::check_chatgpt(client).await,
|
||||
Self::Claude => claude::check_claude(client).await,
|
||||
Self::Gemini => gemini::check_gemini(client).await,
|
||||
Self::MuseAi => muse::check_muse_ai(client).await,
|
||||
Self::YouTubePremium => youtube::check_youtube_premium(client).await,
|
||||
Self::BahamutAnime => bahamut::check_bahamut_anime(client).await,
|
||||
Self::Netflix => netflix::check_netflix(client).await,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
use reqwest::Client;
|
||||
|
||||
use crate::utils::{classify_restricted_status, get_trace_location};
|
||||
|
||||
use super::UnlockItem;
|
||||
|
||||
pub(crate) const MUSE_AI_NAME: &str = "Muse AI";
|
||||
|
||||
const MUSE_URL: &str = "https://muse.ai";
|
||||
const CLOUDFLARE_TRACE_URL: &str = "https://www.cloudflare.com/cdn-cgi/trace";
|
||||
|
||||
// The aymh ("Are You a Human") gateway answers plain HTTP requests with a
|
||||
// generic Meta error page that always contains the Facebook logo asset.
|
||||
const AYMH_ERROR_MARKERS: &[&str] = &["Sorry, something went wrong", "static.facebook.com/images/logos"];
|
||||
|
||||
pub(super) async fn check_muse_ai(client: &Client) -> UnlockItem {
|
||||
// muse.ai itself is served by Vercel, so resolve the exit region through
|
||||
// the generic Cloudflare trace endpoint instead of a service-specific one.
|
||||
let region = get_trace_location(client, CLOUDFLARE_TRACE_URL)
|
||||
.await
|
||||
.map(|code| code.trim().to_ascii_uppercase())
|
||||
.map(|code| UnlockItem::region_label(&code));
|
||||
|
||||
let Ok(response) = client.get(MUSE_URL).send().await else {
|
||||
return UnlockItem::checked(MUSE_AI_NAME, "Failed (Network Connection)", region);
|
||||
};
|
||||
|
||||
let status = response.status();
|
||||
let landed_on_aymh = response.url().host_str() == Some("auth.muse.ai");
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
|
||||
// Every region is redirected to the aymh anti-bot gateway before the app
|
||||
// loads, and a non-browser HTTP client cannot finish the JavaScript
|
||||
// challenge. It returns the Meta error page, so reaching this point only
|
||||
// proves the exit can connect to Muse's auth infrastructure; availability
|
||||
// cannot be verified over plain HTTP and is reported as `Risky`.
|
||||
let behind_anti_bot = landed_on_aymh || AYMH_ERROR_MARKERS.iter().any(|marker| body.contains(marker));
|
||||
|
||||
let result = if behind_anti_bot {
|
||||
"Risky"
|
||||
} else if status.is_success() {
|
||||
"Yes"
|
||||
} else {
|
||||
classify_restricted_status(status).unwrap_or("Failed")
|
||||
};
|
||||
|
||||
UnlockItem::checked(MUSE_AI_NAME, result, region)
|
||||
}
|
||||
@@ -1,3 +1,15 @@
|
||||
//! Linux-only termination signal bridge.
|
||||
//!
|
||||
//! Upstream Tauri delivers session-end and termination events to the app on
|
||||
//! Windows and macOS, but has no equivalent on Linux: a desktop session may
|
||||
//! end without any window or run event reaching the process. Linux builds
|
||||
//! keep this crate to listen for SIGTERM, SIGINT and SIGHUP and forward them
|
||||
//! to the app quit flow, so system proxy and TUN cleanup still runs before
|
||||
//! the process dies. Non-Linux builds compile this crate to nothing and must
|
||||
//! not depend on it.
|
||||
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
use std::sync::{
|
||||
OnceLock,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
@@ -5,20 +17,13 @@ use std::sync::{
|
||||
|
||||
use clash_verge_logging::{Type, logging};
|
||||
|
||||
#[cfg(unix)]
|
||||
mod unix;
|
||||
#[cfg(windows)]
|
||||
mod windows;
|
||||
|
||||
pub(crate) static RUNTIME: OnceLock<Option<tokio::runtime::Runtime>> = OnceLock::new();
|
||||
pub(crate) static SHUTDOWN_LATCH: ShutdownLatch = ShutdownLatch::new();
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ShutdownOutcome {
|
||||
Committed,
|
||||
Canceled,
|
||||
}
|
||||
|
||||
/// Serializes signal-triggered cleanups: a signal arriving while a quit
|
||||
/// attempt is still in flight is dropped instead of overlapping it.
|
||||
pub(crate) struct ShutdownLatch {
|
||||
is_cleaning_up: AtomicBool,
|
||||
}
|
||||
@@ -36,17 +41,18 @@ impl ShutdownLatch {
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn finish(&self, outcome: ShutdownOutcome) {
|
||||
if matches!(outcome, ShutdownOutcome::Canceled) {
|
||||
self.is_cleaning_up.store(false, Ordering::Release);
|
||||
}
|
||||
fn finish(&self) {
|
||||
self.is_cleaning_up.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns the Linux signal loop on a dedicated runtime so it survives the
|
||||
/// teardown of any app-owned runtime. The handler runs once per accepted
|
||||
/// signal; the quit flow it invokes owns the exit-or-cancel decision.
|
||||
pub fn register<F, Fut>(f: F)
|
||||
where
|
||||
F: Fn() -> Fut + Send + Sync + 'static,
|
||||
Fut: Future<Output = ShutdownOutcome> + Send + 'static,
|
||||
Fut: Future<Output = ()> + Send + 'static,
|
||||
{
|
||||
RUNTIME.get_or_init(|| match tokio::runtime::Runtime::new() {
|
||||
Ok(rt) => Some(rt),
|
||||
@@ -61,36 +67,22 @@ where
|
||||
}
|
||||
});
|
||||
|
||||
#[cfg(unix)]
|
||||
unix::register(f);
|
||||
|
||||
#[cfg(windows)]
|
||||
windows::register(f);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ShutdownLatch, ShutdownOutcome};
|
||||
use super::ShutdownLatch;
|
||||
|
||||
#[test]
|
||||
fn canceled_shutdown_releases_latch_and_permits_next_attempt() {
|
||||
fn finished_attempt_releases_latch_for_the_next_signal() {
|
||||
let latch = ShutdownLatch::new();
|
||||
|
||||
assert!(latch.try_begin());
|
||||
assert!(!latch.try_begin());
|
||||
|
||||
latch.finish(ShutdownOutcome::Canceled);
|
||||
latch.finish();
|
||||
|
||||
assert!(latch.try_begin());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committed_shutdown_retains_latch_and_suppresses_repeats() {
|
||||
let latch = ShutdownLatch::new();
|
||||
|
||||
assert!(latch.try_begin());
|
||||
latch.finish(ShutdownOutcome::Committed);
|
||||
|
||||
assert!(!latch.try_begin());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use tokio::signal::unix::{SignalKind, signal};
|
||||
|
||||
use crate::{RUNTIME, SHUTDOWN_LATCH, ShutdownOutcome};
|
||||
use crate::{RUNTIME, SHUTDOWN_LATCH};
|
||||
|
||||
pub fn register<F, Fut>(f: F)
|
||||
where
|
||||
F: Fn() -> Fut + Send + Sync + 'static,
|
||||
Fut: Future<Output = ShutdownOutcome> + Send + 'static,
|
||||
Fut: Future<Output = ()> + Send + 'static,
|
||||
{
|
||||
if let Some(Some(rt)) = RUNTIME.get() {
|
||||
rt.spawn(async move {
|
||||
@@ -60,7 +60,8 @@ where
|
||||
}
|
||||
logging!(info, Type::SystemSignal, "Caught signal {}", signal_name);
|
||||
|
||||
SHUTDOWN_LATCH.finish(f().await);
|
||||
f().await;
|
||||
SHUTDOWN_LATCH.finish();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use tokio::signal::windows;
|
||||
|
||||
use crate::{RUNTIME, SHUTDOWN_LATCH, ShutdownOutcome};
|
||||
|
||||
pub fn register<F, Fut>(f: F)
|
||||
where
|
||||
F: Fn() -> Fut + Send + Sync + 'static,
|
||||
Fut: Future<Output = ShutdownOutcome> + Send + 'static,
|
||||
{
|
||||
if let Some(Some(rt)) = RUNTIME.get() {
|
||||
rt.spawn(async move {
|
||||
let mut ctrl_c = match windows::ctrl_c() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
logging!(error, Type::SystemSignal, "Failed to register Ctrl+C: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut ctrl_close = match windows::ctrl_close() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
logging!(error, Type::SystemSignal, "Failed to register Ctrl+Close: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut ctrl_shutdown = match windows::ctrl_shutdown() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
logging!(error, Type::SystemSignal, "Failed to register Ctrl+Shutdown: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut ctrl_logoff = match windows::ctrl_logoff() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
logging!(error, Type::SystemSignal, "Failed to register Ctrl+Logoff: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
loop {
|
||||
let signal_name;
|
||||
tokio::select! {
|
||||
_ = ctrl_c.recv() => {
|
||||
signal_name = "Ctrl+C";
|
||||
}
|
||||
_ = ctrl_close.recv() => {
|
||||
signal_name = "Ctrl+Close";
|
||||
}
|
||||
_ = ctrl_shutdown.recv() => {
|
||||
signal_name = "Ctrl+Shutdown";
|
||||
}
|
||||
_ = ctrl_logoff.recv() => {
|
||||
signal_name = "Ctrl+Logoff";
|
||||
}
|
||||
}
|
||||
|
||||
if !SHUTDOWN_LATCH.try_begin() {
|
||||
logging!(
|
||||
info,
|
||||
Type::SystemSignal,
|
||||
"Already shutting down, ignoring repeated signal: {}",
|
||||
signal_name
|
||||
);
|
||||
continue;
|
||||
}
|
||||
logging!(info, Type::SystemSignal, "Caught Windows signal: {}", signal_name);
|
||||
|
||||
SHUTDOWN_LATCH.finish(f().await);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
logging!(
|
||||
error,
|
||||
Type::SystemSignal,
|
||||
"register shutdown signal failed, RUNTIME is not available"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ edition = "2024"
|
||||
rust-version = "1.91"
|
||||
|
||||
[dependencies]
|
||||
specta = { version = "=2.0.0-rc.26", features = ["derive", "function"] }
|
||||
specta = { version = "=2.0.0-rc.25", features = ["derive", "function"] }
|
||||
tauri = { workspace = true }
|
||||
tauri-plugin-clipboard-manager = { workspace = true }
|
||||
parking_lot = { workspace = true }
|
||||
@@ -15,7 +15,7 @@ serde = { workspace = true, features = ["derive"] }
|
||||
sysinfo = { version = "0.39.6", features = ["network", "system"] }
|
||||
|
||||
[target.'cfg(not(windows))'.dependencies]
|
||||
libc = "0.2.190"
|
||||
libc = "0.2.191"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
deelevate = { workspace = true }
|
||||
|
||||
+18
-17
@@ -14,7 +14,7 @@
|
||||
"build:fast": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build -- --profile fast-release",
|
||||
"tauri": "tauri",
|
||||
"web:dev": "vite",
|
||||
"web:build": "tsc --noEmit && vite build",
|
||||
"web:build": "tsrs -p . --noEmit && vite build",
|
||||
"web:serve": "vite preview",
|
||||
"prebuild": "node scripts/prebuild.mjs",
|
||||
"updater": "node scripts/updater.mjs",
|
||||
@@ -32,7 +32,7 @@
|
||||
"i18n:types": "node scripts/generate-i18n-keys.mjs",
|
||||
"gen:contract": "node scripts/gen-contract.mjs",
|
||||
"arch:check": "node scripts/check-architecture.mjs listen emit invoke",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck": "tsrs -p . --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"knip:check": "knip",
|
||||
@@ -54,13 +54,13 @@
|
||||
"@mui/icons-material": "^9.4.0",
|
||||
"@mui/material": "^9.4.0",
|
||||
"@tanstack/react-virtual": "^3.14.13",
|
||||
"@tauri-apps/api": "3.0.0-alpha.2",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^3.0.0-alpha.2",
|
||||
"@tauri-apps/plugin-dialog": "^3.0.0-alpha.3",
|
||||
"@tauri-apps/plugin-fs": "^3.0.0-alpha.2",
|
||||
"@tauri-apps/plugin-http": "~3.0.0-alpha.2",
|
||||
"@tauri-apps/plugin-opener": "3.0.0-alpha.2",
|
||||
"@tauri-apps/plugin-updater": "3.0.0-alpha.2",
|
||||
"@tauri-apps/api": "2.12.4",
|
||||
"@tauri-apps/plugin-clipboard-manager": "2.5.0",
|
||||
"@tauri-apps/plugin-dialog": "2.8.3",
|
||||
"@tauri-apps/plugin-fs": "2.7.1",
|
||||
"@tauri-apps/plugin-http": "2.8.3",
|
||||
"@tauri-apps/plugin-opener": "2.7.2",
|
||||
"@tauri-apps/plugin-updater": "2.14.0",
|
||||
"ahooks": "^3.10.0",
|
||||
"cidr-block": "^2.3.0",
|
||||
"compare-versions": "^6.1.1",
|
||||
@@ -75,11 +75,11 @@
|
||||
"monaco-yaml": "^5.5.1",
|
||||
"nanoid": "^6.0.1",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1",
|
||||
"react-error-boundary": "6.1.6",
|
||||
"react-hook-form": "^7.88.0",
|
||||
"react-i18next": "17.0.15",
|
||||
"react-i18next": "17.1.8",
|
||||
"react-markdown": "10.1.0",
|
||||
"react-router": "^8.4.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
@@ -95,7 +95,7 @@
|
||||
"@biomejs/biome": "^2.5.14",
|
||||
"@eslint-react/eslint-plugin": "^5.20.5",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tauri-apps/cli": "3.0.0-alpha.4",
|
||||
"@tauri-apps/cli": "2.12.3",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^24.13.6",
|
||||
"@types/react": "19.3.0",
|
||||
@@ -103,7 +103,7 @@
|
||||
"@types/validator": "^13.15.10",
|
||||
"@vitejs/plugin-legacy": "^8.2.3",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"@wdio/tauri-service": "1.4.0",
|
||||
"@wdio/tauri-service": "1.5.0",
|
||||
"adm-zip": "^0.6.1",
|
||||
"axios": "^1.20.0",
|
||||
"cli-color": "^2.0.4",
|
||||
@@ -123,17 +123,18 @@
|
||||
"https-proxy-agent": "^9.1.0",
|
||||
"husky": "^9.1.7",
|
||||
"jiti": "^2.7.0",
|
||||
"knip": "6.39.0",
|
||||
"knip": "6.43.0",
|
||||
"lint-staged": "^17.5.1",
|
||||
"sass": "^1.104.1",
|
||||
"tar": "^7.5.22",
|
||||
"terser": "^5.51.2",
|
||||
"tsrs": "0.20.0-ts7.1.0-beta",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.70.0",
|
||||
"vite": "^8.3.0",
|
||||
"vite-plugin-svgr": "^5.2.0",
|
||||
"vitest": "^5.0.1",
|
||||
"webdriverio": "10.0.0",
|
||||
"webdriverio": "10.1.0",
|
||||
"ws": "8.22.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
@@ -146,5 +147,5 @@
|
||||
]
|
||||
},
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@12.11.2"
|
||||
"packageManager": "pnpm@12.15.0"
|
||||
}
|
||||
|
||||
Generated
+612
-732
File diff suppressed because it is too large
Load Diff
+8
-12
@@ -10,15 +10,11 @@ allowBuilds:
|
||||
unrs-resolver: true
|
||||
minimumReleaseAgeExclude:
|
||||
- dayjs@1.11.21
|
||||
- '@tauri-apps/cli-darwin-arm64@3.0.0-alpha.4'
|
||||
- '@tauri-apps/cli-darwin-x64@3.0.0-alpha.4'
|
||||
- '@tauri-apps/cli-linux-arm-gnueabihf@3.0.0-alpha.4'
|
||||
- '@tauri-apps/cli-linux-arm64-gnu@3.0.0-alpha.4'
|
||||
- '@tauri-apps/cli-linux-arm64-musl@3.0.0-alpha.4'
|
||||
- '@tauri-apps/cli-linux-riscv64-gnu@3.0.0-alpha.4'
|
||||
- '@tauri-apps/cli-linux-x64-gnu@3.0.0-alpha.4'
|
||||
- '@tauri-apps/cli-linux-x64-musl@3.0.0-alpha.4'
|
||||
- '@tauri-apps/cli-win32-arm64-msvc@3.0.0-alpha.4'
|
||||
- '@tauri-apps/cli-win32-ia32-msvc@3.0.0-alpha.4'
|
||||
- '@tauri-apps/cli-win32-x64-msvc@3.0.0-alpha.4'
|
||||
- '@tauri-apps/cli@3.0.0-alpha.4'
|
||||
- "@tsrs/darwin-arm64@0.20.0-ts7.1.0-beta"
|
||||
- "@tsrs/darwin-x64@0.20.0-ts7.1.0-beta"
|
||||
- "@tsrs/linux-arm64-gnu@0.20.0-ts7.1.0-beta"
|
||||
- "@tsrs/linux-arm64-musl@0.20.0-ts7.1.0-beta"
|
||||
- "@tsrs/linux-x64-gnu@0.20.0-ts7.1.0-beta"
|
||||
- "@tsrs/linux-x64-musl@0.20.0-ts7.1.0-beta"
|
||||
- "@tsrs/win32-x64@0.20.0-ts7.1.0-beta"
|
||||
- tsrs@0.20.0-ts7.1.0-beta
|
||||
|
||||
@@ -45,6 +45,17 @@
|
||||
"matchManagers": ["npm"],
|
||||
"groupName": "npm dependencies"
|
||||
},
|
||||
{
|
||||
"description": "Update Tauri npm packages and Cargo crates together so frontend and backend stay in sync",
|
||||
"matchPackageNames": [
|
||||
"@tauri-apps/**",
|
||||
"@wdio/tauri-service",
|
||||
"tauri",
|
||||
"tauri-*"
|
||||
],
|
||||
"groupName": "tauri",
|
||||
"addLabels": ["tauri"]
|
||||
},
|
||||
{
|
||||
"description": "Group all GitHub Actions updates into a single PR",
|
||||
"matchManagers": ["github-actions"],
|
||||
@@ -68,6 +79,13 @@
|
||||
"matchPackageNames": ["typescript"],
|
||||
"allowedVersions": "<7.0.0 || >=7.1.0"
|
||||
},
|
||||
{
|
||||
"description": "Review tsrs bumps alone; the version encodes the pinned TypeScript release",
|
||||
"matchPackageNames": ["tsrs", "/^@tsrs//"],
|
||||
"groupName": "tsrs",
|
||||
"rangeStrategy": "pin",
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"description": "Prevent project from supply chain attack",
|
||||
"matchDatasources": ["npm"],
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "1.99.0"
|
||||
channel = "1.99.1"
|
||||
components = ["rustfmt", "clippy"]
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"pnpm-lock.yaml",
|
||||
"pnpm-workspace.yaml",
|
||||
"scripts/contract-inputs.json",
|
||||
".github/workflows/frontend-check.yml",
|
||||
".github/workflows/contract-parity.yml",
|
||||
".husky/pre-push"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ try {
|
||||
'run',
|
||||
'-p',
|
||||
'clash-verge',
|
||||
'--bin',
|
||||
'--example',
|
||||
'frontend-contract',
|
||||
'--locked',
|
||||
'-q',
|
||||
|
||||
@@ -434,12 +434,13 @@ for (let iteration = 0; iteration < repeat && !interrupted; iteration++) {
|
||||
devicePixelRatio,
|
||||
])
|
||||
run.viewport = { dom: viewport, driver: await browser.getWindowRect() }
|
||||
const driverScale = run.viewport.driver.width / 1000
|
||||
assert(
|
||||
run.viewport.driver.width / viewport[2] === 1000 &&
|
||||
run.viewport.driver.height / viewport[2] === 700 &&
|
||||
viewport[0] === 1000 &&
|
||||
viewport[0] === 1000 &&
|
||||
viewport[1] > 0 &&
|
||||
viewport[1] <= 700,
|
||||
viewport[1] <= 700 &&
|
||||
(driverScale === 1 || driverScale === viewport[2]) &&
|
||||
run.viewport.driver.height / driverScale === 700,
|
||||
`unexpected window dimensions: ${JSON.stringify(run.viewport)}`,
|
||||
)
|
||||
assert(
|
||||
|
||||
+29
-27
@@ -23,33 +23,30 @@ tauri-dev = ["dep:tauri-plugin-devtools"]
|
||||
tokio-trace = ["clash-verge-logging/tokio-trace"]
|
||||
chrome-trace = ["clash-verge-logging/chrome-trace"]
|
||||
clippy = ["tauri/test"]
|
||||
# The perf-harness feature and its main.rs wiring are parked until
|
||||
# tauri-plugin-wdio-webdriver publishes a tauri 3 release; restore both
|
||||
# from git history together with the crate dependency.
|
||||
perf-harness = ["dep:tauri-plugin-wdio-webdriver"]
|
||||
|
||||
[package.metadata.bundle]
|
||||
identifier = "io.github.clash-verge-rev.clash-verge-rev"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "3.0.0-alpha.3", features = [] }
|
||||
tauri-build = { version = "2.7.3", features = [] }
|
||||
|
||||
[dependencies]
|
||||
clash-verge-draft = { workspace = true }
|
||||
clash-verge-media-unlock = { workspace = true }
|
||||
clash-verge-logging = { workspace = true }
|
||||
clash-verge-signal = { workspace = true }
|
||||
clash-verge-i18n = { workspace = true }
|
||||
clash-verge-limiter = { workspace = true }
|
||||
tauri-plugin-clash-verge-sysinfo = { workspace = true }
|
||||
tauri-plugin-clipboard-manager = { workspace = true }
|
||||
tauri = { workspace = true, features = [
|
||||
"protocol-asset",
|
||||
"devtools",
|
||||
"tracing",
|
||||
"tray-icon",
|
||||
"image-ico",
|
||||
"image-png",
|
||||
] }
|
||||
tauri-runtime-wry = { workspace = true, features = ["devtools"] }
|
||||
parking_lot = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
@@ -75,50 +72,55 @@ sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs.git", branch
|
||||
"guard",
|
||||
] }
|
||||
network-interface = { version = "2.0.5", features = ["serde"] }
|
||||
tauri-plugin-shell = "3.0.0-alpha.2"
|
||||
tauri-plugin-opener = "3.0.0-alpha.2"
|
||||
tauri-plugin-dialog = "3.0.0-alpha.3"
|
||||
tauri-plugin-fs = "3.0.0-alpha.2"
|
||||
tauri-plugin-process = "3.0.0-alpha.2"
|
||||
tauri-plugin-deep-link = "3.0.0-alpha.2"
|
||||
tauri-plugin-window-state = "3.0.0-alpha.2"
|
||||
zip = "9.0.1"
|
||||
tauri-plugin-shell = "2.5.0"
|
||||
tauri-plugin-opener = "2.7.2"
|
||||
tauri-plugin-dialog = "2.8.3"
|
||||
tauri-plugin-fs = "2.7.1"
|
||||
tauri-plugin-process = "2.4.0"
|
||||
tauri-plugin-deep-link = "2.6.2"
|
||||
tauri-plugin-window-state = "2.5.0"
|
||||
zip = "9.0.4"
|
||||
flate2 = "1.1.10"
|
||||
reqwest_dav = "0.3.3"
|
||||
aes-gcm = { version = "0.11.1" }
|
||||
base64 = "0.23.1"
|
||||
minisign-verify = "0.3.0"
|
||||
sha2 = "0.11"
|
||||
sha2 = "0.11.1"
|
||||
getrandom = "0.4.3"
|
||||
futures = "0.3.34"
|
||||
gethostname = "1.1.0"
|
||||
scopeguard = "1.2.0"
|
||||
tauri-plugin-notification = "3.0.0-alpha.2"
|
||||
tauri-plugin-notification = "2.7.0"
|
||||
tokio-stream = "0.1.19"
|
||||
backon = { version = "1.6.0", features = ["tokio-sleep"] }
|
||||
tauri-plugin-http = "3.0.0-alpha.2"
|
||||
tauri-plugin-http = "2.8.3"
|
||||
tracing = { workspace = true }
|
||||
tauri-plugin-devtools = { version = "3.0.0-alpha.0", optional = true }
|
||||
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "feat/tauri-v3" }
|
||||
tauri-plugin-devtools = { version = "2.2.2", optional = true }
|
||||
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", rev = "7a03d16f4d39b1bb885b7babe66b9e153e34b615" }
|
||||
async-trait = "0.1.92"
|
||||
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.6", version = "2.7.6", features = [
|
||||
"client",
|
||||
] }
|
||||
arc-swap = "1.9.2"
|
||||
tokio-rustls = "0.26.6"
|
||||
rustls = { version = "0.23.45", features = ["ring"] }
|
||||
rustls = { version = "0.23.46", features = ["ring"] }
|
||||
webpki-roots = "1.0.9"
|
||||
dark-light = "3.0.0"
|
||||
bytes = "1.12.1"
|
||||
tokio-util = { version = "0.7.19", features = ["time"] }
|
||||
specta = { version = "=2.0.0-rc.26", features = ["derive", "function", "serde_json"] }
|
||||
tokio-util = { version = "0.7.20", features = ["time"] }
|
||||
specta = { version = "=2.0.0-rc.25", features = ["derive", "function", "serde_json"] }
|
||||
tauri-specta = { version = "=2.0.0-rc.25", features = ["derive", "typescript"] }
|
||||
specta-typescript = "=0.0.13"
|
||||
specta-typescript = "=0.0.12"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2.190"
|
||||
libc = "0.2.191"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Upstream has no session-end signal support on Linux; see crates/clash-verge-signal.
|
||||
clash-verge-signal = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
tauri-plugin-wdio-webdriver = { version = "=1.5.0", optional = true }
|
||||
objc2 = "0.6.5"
|
||||
objc2-foundation = { version = "0.3.2", features = [
|
||||
"NSString",
|
||||
@@ -150,9 +152,9 @@ windows = { version = "0.62.2", features = ["Win32_Globalization"] }
|
||||
windows-sys.workspace = true
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-autostart = "3.0.0-alpha.2"
|
||||
tauri-plugin-global-shortcut = "3.0.0-alpha.2"
|
||||
tauri-plugin-updater = "3.0.0-alpha.2"
|
||||
tauri-plugin-autostart = "2.7.0"
|
||||
tauri-plugin-global-shortcut = "2.4.1"
|
||||
tauri-plugin-updater = "2.14.0"
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { workspace = true }
|
||||
|
||||
+10
-5
@@ -1,10 +1,9 @@
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows")
|
||||
&& std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc")
|
||||
&& std::env::var_os("CARGO_FEATURE_CLIPPY").is_some()
|
||||
{
|
||||
// Tauri's resource is linked to the app binary, but not to Rust unit-test harnesses.
|
||||
// Give mock-context builds their own manifest so tests can resolve TaskDialogIndirect.
|
||||
// Tauri's resource is linked to the app binary, but not to Rust unit-test harnesses or examples.
|
||||
// Give non-app targets their own manifest so tests and examples can resolve TaskDialogIndirect.
|
||||
let out_dir = std::env::var_os("OUT_DIR").ok_or_else(|| std::io::Error::other("OUT_DIR is not set"))?;
|
||||
let manifest_path = std::path::PathBuf::from(out_dir).join("windows-test-manifest.xml");
|
||||
std::fs::write(
|
||||
@@ -25,8 +24,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
</assembly>
|
||||
"#,
|
||||
)?;
|
||||
println!("cargo:rustc-link-arg=/MANIFEST:EMBED");
|
||||
println!("cargo:rustc-link-arg=/MANIFESTINPUT:{}", manifest_path.display());
|
||||
let manifest_display = manifest_path.display();
|
||||
if std::env::var_os("CARGO_FEATURE_CLIPPY").is_some() {
|
||||
println!("cargo:rustc-link-arg=/MANIFEST:EMBED");
|
||||
println!("cargo:rustc-link-arg=/MANIFESTINPUT:{manifest_display}");
|
||||
} else {
|
||||
println!("cargo:rustc-link-arg-examples=/MANIFEST:EMBED");
|
||||
println!("cargo:rustc-link-arg-examples=/MANIFESTINPUT:{manifest_display}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "clippy")]
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"dialog:allow-open",
|
||||
"global-shortcut:allow-is-registered",
|
||||
"global-shortcut:allow-register",
|
||||
"global-shortcut:allow-register-all",
|
||||
"global-shortcut:allow-unregister",
|
||||
"global-shortcut:allow-unregister-all",
|
||||
"process:allow-exit",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Dev-only target: Tauri ships everything under src/bin/ to /usr/bin in deb/rpm
|
||||
// bundles (PR #8131), so this generator must stay an example, never a bin.
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut args = std::env::args_os().skip(1);
|
||||
let output = args
|
||||
@@ -282,7 +282,9 @@ async fn handle_validation_failure(
|
||||
) -> CmdResult<ValidationOutcome> {
|
||||
logging!(warn, Type::Cmd, "配置验证失败: {}", outcome);
|
||||
discard_and_restore(current_profile).await?;
|
||||
handle_validation_notice(&outcome, ValidationNoticeTarget::Runtime, "运行时配置");
|
||||
if !matches!(outcome, ValidationOutcome::Busy) {
|
||||
handle_validation_notice(&outcome, ValidationNoticeTarget::Runtime, "运行时配置");
|
||||
}
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
@@ -311,9 +313,6 @@ async fn perform_config_update(
|
||||
current_value: Option<&String>,
|
||||
current_profile: Option<&String>,
|
||||
) -> CmdResult<ValidationOutcome> {
|
||||
defer! {
|
||||
CURRENT_SWITCHING_PROFILE.store(false, Ordering::Release);
|
||||
}
|
||||
let update_result = run_profile_config_update_transition(|| CoreManager::global().update_config_forced()).await;
|
||||
|
||||
match update_result {
|
||||
@@ -373,8 +372,6 @@ pub struct ProfilePatch {
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub url: Option<String>,
|
||||
pub selected: Option<Vec<crate::config::PrfSelected>>,
|
||||
#[specta(type = Option<std::collections::BTreeMap<std::string::String, std::string::String>>)]
|
||||
pub latency_test_urls: Option<std::collections::BTreeMap<String, String>>,
|
||||
pub extra: Option<crate::config::PrfExtra>,
|
||||
pub updated: Option<usize>,
|
||||
pub option: Option<PrfOption>,
|
||||
@@ -389,7 +386,6 @@ impl From<ProfilePatch> for PrfItem {
|
||||
file: patch.file,
|
||||
url: patch.url,
|
||||
selected: patch.selected,
|
||||
latency_test_urls: patch.latency_test_urls,
|
||||
extra: patch.extra,
|
||||
updated: patch.updated,
|
||||
option: patch.option,
|
||||
@@ -409,6 +405,9 @@ pub async fn patch_profiles_config(profiles: ProfileSelection) -> CmdResult<Vali
|
||||
logging!(info, Type::Cmd, "当前正在切换配置,放弃请求");
|
||||
return Ok(ValidationOutcome::Busy);
|
||||
}
|
||||
defer! {
|
||||
CURRENT_SWITCHING_PROFILE.store(false, Ordering::Release);
|
||||
}
|
||||
let _profile_write_guard = PROFILE_WRITE_LOCK.lock().await;
|
||||
|
||||
let profiles = IProfiles {
|
||||
|
||||
@@ -108,32 +108,20 @@ fn runtime_group_scopes(config: Option<&Mapping>) -> BTreeMap<String, GroupScope
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_proxy_view() -> CmdResult<ProxyViewV1> {
|
||||
proxy_view(None).await
|
||||
}
|
||||
|
||||
pub(crate) async fn proxy_view(provider_timeout: Option<std::time::Duration>) -> CmdResult<ProxyViewV1> {
|
||||
let runtime = Config::runtime().await;
|
||||
let latest_runtime = runtime.latest_arc();
|
||||
let runtime_group_order = runtime_group_order(latest_runtime.config.as_ref());
|
||||
let group_scopes = runtime_group_scopes(latest_runtime.config.as_ref());
|
||||
|
||||
let mihomo = Handle::mihomo();
|
||||
let (proxies, providers) = tokio::join!(mihomo.get_proxies(), async {
|
||||
match provider_timeout {
|
||||
Some(timeout) => tokio::time::timeout(timeout, mihomo.get_proxy_providers())
|
||||
.await
|
||||
.ok()
|
||||
.and_then(Result::ok),
|
||||
None => mihomo.get_proxy_providers().await.ok(),
|
||||
}
|
||||
});
|
||||
let (proxies, providers) = tokio::join!(mihomo.get_proxies(), mihomo.get_proxy_providers(),);
|
||||
let proxies = proxies.stringify_err()?;
|
||||
|
||||
Ok(ProxyViewBuilder::build(ProxyViewInput {
|
||||
runtime_group_order,
|
||||
group_scopes,
|
||||
proxies,
|
||||
providers,
|
||||
providers: providers.ok(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -53,10 +53,6 @@ pub struct PrfItem {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub selected: Option<Vec<PrfSelected>>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[specta(type = Option<std::collections::BTreeMap<std::string::String, std::string::String>>)]
|
||||
pub latency_test_urls: Option<std::collections::BTreeMap<String, String>>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub extra: Option<PrfExtra>,
|
||||
|
||||
@@ -233,7 +229,6 @@ impl PrfItem {
|
||||
desc: Some(desc),
|
||||
file: Some(file),
|
||||
url: None,
|
||||
latency_test_urls: None,
|
||||
selected: None,
|
||||
extra: None,
|
||||
option: Some(PrfOption {
|
||||
@@ -413,7 +408,6 @@ impl PrfItem {
|
||||
desc: desc.cloned(),
|
||||
file: Some(file),
|
||||
url: Some(url.as_str().into()),
|
||||
latency_test_urls: None,
|
||||
selected: None,
|
||||
extra,
|
||||
option: Some(PrfOption {
|
||||
|
||||
@@ -250,7 +250,6 @@ impl IProfiles {
|
||||
patch!(each, item, file);
|
||||
patch!(each, item, url);
|
||||
patch!(each, item, selected);
|
||||
patch!(each, item, latency_test_urls);
|
||||
patch!(each, item, extra);
|
||||
patch!(each, item, updated);
|
||||
patch!(each, item, option);
|
||||
|
||||
@@ -75,6 +75,7 @@ pub mod tun {
|
||||
"auto-redirect",
|
||||
"auto-detect-interface",
|
||||
"dns-hijack",
|
||||
"dns-mode",
|
||||
"strict-route",
|
||||
"mtu",
|
||||
];
|
||||
|
||||
@@ -24,7 +24,7 @@ pub async fn update_launch() -> Result<()> {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let app_handle = Handle::app_handle();
|
||||
let autostart_manager = app_handle.autostart();
|
||||
let autostart_manager = app_handle.autolaunch();
|
||||
if is_enable {
|
||||
logging_error!(Type::System, "{:?}", autostart_manager.enable());
|
||||
} else {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde::{Serialize, Serializer, ser::SerializeMap as _};
|
||||
use serde::Serialize;
|
||||
use tauri_plugin_mihomo::models::{
|
||||
DelayHistory, Extra, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType,
|
||||
DelayHistory, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType,
|
||||
};
|
||||
|
||||
pub struct ProxyViewInput {
|
||||
@@ -57,28 +57,6 @@ pub struct ProxyCapabilities {
|
||||
pub smux: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, specta::Type)]
|
||||
struct ProxyLatencyHistory<'a> {
|
||||
#[specta(type = Vec<crate::ipc::DelayHistory>)]
|
||||
history: &'a [DelayHistory],
|
||||
}
|
||||
|
||||
fn serialize_proxy_latency_history<S: Serializer>(
|
||||
extra: &HashMap<String, Extra>,
|
||||
serializer: S,
|
||||
) -> Result<S::Ok, S::Error> {
|
||||
let mut map = serializer.serialize_map(Some(extra.len()))?;
|
||||
for (url, extra) in extra {
|
||||
map.serialize_entry(
|
||||
url,
|
||||
&ProxyLatencyHistory {
|
||||
history: &extra.history,
|
||||
},
|
||||
)?;
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProxyGroupView {
|
||||
@@ -98,9 +76,6 @@ pub struct ProxyGroupView {
|
||||
pub test_url: Option<String>,
|
||||
#[specta(type = Vec<crate::ipc::DelayHistory>)]
|
||||
pub history: Vec<DelayHistory>,
|
||||
#[serde(serialize_with = "serialize_proxy_latency_history")]
|
||||
#[specta(type = HashMap<String, ProxyLatencyHistory<'static>>)]
|
||||
pub extra: HashMap<String, Extra>,
|
||||
#[serde(flatten)]
|
||||
pub capabilities: ProxyCapabilities,
|
||||
pub members: Vec<ProxyMemberRef>,
|
||||
@@ -116,9 +91,6 @@ pub struct ProxyNodeView {
|
||||
pub proxy_type: ProxyType,
|
||||
#[specta(type = Vec<crate::ipc::DelayHistory>)]
|
||||
pub history: Vec<DelayHistory>,
|
||||
#[serde(serialize_with = "serialize_proxy_latency_history")]
|
||||
#[specta(type = HashMap<String, ProxyLatencyHistory<'static>>)]
|
||||
pub extra: HashMap<String, Extra>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -284,7 +256,7 @@ impl ProxyViewBuilder {
|
||||
} else {
|
||||
ProxyViewProviderState::Unavailable
|
||||
};
|
||||
let (mut core_groups, core_nodes) = partition_core(proxies, providers.is_some());
|
||||
let (mut core_groups, core_nodes) = partition_core(proxies);
|
||||
let (mut records, core_node_ids) = build_core_records(core_nodes);
|
||||
let (providers, provider_candidates) = build_provider_records(providers, &mut records);
|
||||
let resolver = MemberResolver {
|
||||
@@ -317,14 +289,14 @@ impl ProxyViewBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
fn partition_core(proxies: Proxies, providers_available: bool) -> (BTreeMap<String, Proxy>, BTreeMap<String, Proxy>) {
|
||||
fn partition_core(proxies: Proxies) -> (BTreeMap<String, Proxy>, BTreeMap<String, Proxy>) {
|
||||
let mut groups = BTreeMap::new();
|
||||
let mut nodes = BTreeMap::new();
|
||||
|
||||
for (name, proxy) in proxies.proxies {
|
||||
if proxy.all.is_some() {
|
||||
groups.insert(name, proxy);
|
||||
} else if !providers_available || proxy.provider_name.is_empty() {
|
||||
} else {
|
||||
nodes.insert(name, proxy);
|
||||
}
|
||||
}
|
||||
@@ -431,7 +403,6 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
|
||||
now,
|
||||
test_url,
|
||||
history,
|
||||
extra,
|
||||
udp,
|
||||
xudp,
|
||||
tfo,
|
||||
@@ -464,7 +435,6 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
|
||||
icon,
|
||||
test_url,
|
||||
history,
|
||||
extra,
|
||||
capabilities: ProxyCapabilities {
|
||||
udp,
|
||||
xudp,
|
||||
@@ -483,7 +453,6 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo
|
||||
icon,
|
||||
test_url,
|
||||
history,
|
||||
extra,
|
||||
udp,
|
||||
xudp,
|
||||
tfo,
|
||||
@@ -498,7 +467,6 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo
|
||||
name,
|
||||
proxy_type,
|
||||
history,
|
||||
extra,
|
||||
id,
|
||||
hidden,
|
||||
icon,
|
||||
@@ -552,62 +520,3 @@ fn build_standalone(core_node_ids: &BTreeMap<String, String>) -> Vec<String> {
|
||||
);
|
||||
standalone
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn provider_echo_in_proxies_does_not_override_group_membership() -> anyhow::Result<()> {
|
||||
let view = ProxyViewBuilder::build(ProxyViewInput {
|
||||
runtime_group_order: vec!["A".into(), "B".into()],
|
||||
group_scopes: ["A", "B"]
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
(
|
||||
name.into(),
|
||||
GroupScope {
|
||||
providers: vec![name.into()],
|
||||
exclude_types: vec![],
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
proxies: serde_json::from_value(json!({"proxies": {
|
||||
"A": {"all": ["node"], "type": "Selector"},
|
||||
"B": {"all": ["node"], "type": "Selector"},
|
||||
"node": {"name": "node", "provider-name": "A", "history": [{"time": "2026-10-08T00:00:00Z", "delay": 32}]}
|
||||
}}))?,
|
||||
providers: Some(serde_json::from_value(json!({"providers": {
|
||||
"A": {"vehicleType": "Inline", "proxies": [{"name": "node", "history": [{"time": "2026-10-08T00:00:00Z", "delay": 32}]}]},
|
||||
"B": {"vehicleType": "Inline", "proxies": [{"name": "node", "history": [{"time": "2026-10-08T00:00:00Z", "delay": 123}]}]}
|
||||
}}))?),
|
||||
});
|
||||
for (group, expected) in view.groups.iter().zip([32, 123]) {
|
||||
let ProxyMemberRef::Node { record_id, .. } = &group.members[0] else {
|
||||
anyhow::bail!("Expected node")
|
||||
};
|
||||
let record = &view.records[record_id];
|
||||
assert_eq!(record.history.last().map(|entry| entry.delay), Some(expected));
|
||||
assert!(
|
||||
matches!(&record.source, ProxyNodeSource::Provider { provider_name, .. } if provider_name == &group.name)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[test]
|
||||
fn unavailable_provider_keeps_core_echo_interactable() -> anyhow::Result<()> {
|
||||
let view = ProxyViewBuilder::build(ProxyViewInput {
|
||||
runtime_group_order: vec!["A".into()],
|
||||
group_scopes: BTreeMap::new(),
|
||||
proxies: serde_json::from_value(json!({"proxies": {
|
||||
"A": {"all": ["node"], "type": "Selector"},
|
||||
"node": {"name": "node", "provider-name": "provider"}
|
||||
}}))?,
|
||||
providers: None,
|
||||
});
|
||||
assert!(matches!(&view.groups[0].members[0], ProxyMemberRef::Node { .. }));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
use super::Tray;
|
||||
use crate::{
|
||||
Type, cmd,
|
||||
config::Config,
|
||||
core::{
|
||||
handle,
|
||||
notify::{Refresh, announce},
|
||||
proxy_view::{ProxyMemberRef, ProxyNodeSource},
|
||||
},
|
||||
logging,
|
||||
};
|
||||
|
||||
const TRAY_DELAY_TEST_CONCURRENCY: usize = 10;
|
||||
const TRAY_DEFAULT_LATENCY_URL: &str = "http://cp.cloudflare.com/generate_204";
|
||||
|
||||
fn is_latency_test_url(raw: &str) -> bool {
|
||||
let raw = raw.trim();
|
||||
raw.split_once("://")
|
||||
.is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https"))
|
||||
&& tauri::Url::parse(raw).is_ok_and(|url| url.has_host() && matches!(url.scheme(), "http" | "https"))
|
||||
}
|
||||
|
||||
pub(super) fn test_url(
|
||||
group: &crate::core::proxy_view::ProxyGroupView,
|
||||
verge: &crate::config::IVerge,
|
||||
profiles: &crate::config::IProfiles,
|
||||
) -> String {
|
||||
let custom_url = profiles
|
||||
.current
|
||||
.as_ref()
|
||||
.and_then(|uid| profiles.get_item(uid).ok())
|
||||
.and_then(|profile| profile.latency_test_urls.as_ref())
|
||||
.and_then(|urls| urls.get(group.name.as_str()))
|
||||
.map(|url| url.as_str())
|
||||
.filter(|url| !url.trim().is_empty());
|
||||
let group_url = group.test_url.as_deref().filter(|url| !url.trim().is_empty());
|
||||
let default_url = verge
|
||||
.default_latency_test
|
||||
.as_deref()
|
||||
.filter(|url| !url.trim().is_empty());
|
||||
custom_url
|
||||
.or(group_url)
|
||||
.or(default_url)
|
||||
.filter(|&url| is_latency_test_url(url))
|
||||
.unwrap_or(TRAY_DEFAULT_LATENCY_URL)
|
||||
.trim()
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
static RUNNING: parking_lot::Mutex<std::collections::BTreeSet<String>> =
|
||||
parking_lot::Mutex::new(std::collections::BTreeSet::new());
|
||||
|
||||
pub(super) fn is_running(group_name: &str) -> bool {
|
||||
RUNNING.lock().contains(group_name)
|
||||
}
|
||||
|
||||
pub(super) async fn test_proxy_group_delay(group_name: &str) {
|
||||
if !RUNNING.lock().insert(group_name.to_owned()) {
|
||||
return;
|
||||
}
|
||||
Tray::global().refresh_latency_item(group_name);
|
||||
let _running = scopeguard::guard(group_name, |name| {
|
||||
RUNNING.lock().remove(name);
|
||||
Tray::global().refresh_latency_item(name);
|
||||
});
|
||||
use futures::{StreamExt as _, stream};
|
||||
|
||||
let view = match cmd::proxy::get_proxy_view().await {
|
||||
Ok(view) => view,
|
||||
Err(err) => {
|
||||
logging!(warn, Type::Tray, "Failed to read proxies for tray latency test: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let verge = Config::verge().await.latest_arc();
|
||||
|
||||
let group = view
|
||||
.groups
|
||||
.iter()
|
||||
.chain(view.global.iter())
|
||||
.find(|group| group.name.as_str() == group_name);
|
||||
let Some(group) = group else {
|
||||
logging!(warn, Type::Tray, "Tray latency test group not found: {group_name}");
|
||||
return;
|
||||
};
|
||||
|
||||
let profiles = Config::profiles().await.latest_arc();
|
||||
let url = test_url(group, &verge, &profiles);
|
||||
let timeout = verge
|
||||
.default_latency_timeout
|
||||
.filter(|timeout| *timeout > 0)
|
||||
.unwrap_or(10000) as u32;
|
||||
|
||||
let mut targets: Vec<(std::string::String, Option<std::string::String>)> = Vec::with_capacity(group.members.len());
|
||||
for member in &group.members {
|
||||
match member {
|
||||
ProxyMemberRef::Node { name, record_id } => match view.records.get(record_id).map(|node| &node.source) {
|
||||
Some(ProxyNodeSource::Provider {
|
||||
provider_name,
|
||||
proxy_name,
|
||||
}) => targets.push((proxy_name.clone(), Some(provider_name.clone()))),
|
||||
_ => targets.push((name.clone(), None)),
|
||||
},
|
||||
ProxyMemberRef::Group { name } => targets.push((name.clone(), None)),
|
||||
ProxyMemberRef::Unresolved { .. } => {}
|
||||
}
|
||||
}
|
||||
let mihomo = handle::Handle::mihomo();
|
||||
|
||||
stream::iter(targets.into_iter().map(move |(member, provider)| {
|
||||
let url = url.clone();
|
||||
async move {
|
||||
let result = match provider {
|
||||
Some(provider_name) => {
|
||||
mihomo
|
||||
.healthcheck_node_in_provider(&provider_name, &member, &url, timeout)
|
||||
.await
|
||||
}
|
||||
None => mihomo.delay_proxy_by_name(&member, &url, timeout).await,
|
||||
};
|
||||
if let Err(err) = result {
|
||||
logging!(debug, Type::Tray, "Tray latency test failed: {member} - {err}");
|
||||
}
|
||||
}
|
||||
}))
|
||||
.buffer_unordered(TRAY_DELAY_TEST_CONCURRENCY)
|
||||
.for_each(|()| std::future::ready(()))
|
||||
.await;
|
||||
|
||||
if let Err(err) = cmd::proxy::sync_tray_proxy_selection().await {
|
||||
logging!(warn, Type::Tray, "Failed to refresh tray after latency test: {err}");
|
||||
}
|
||||
|
||||
announce(Refresh::Proxies);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn latency_urls_require_a_valid_http_host_and_port() {
|
||||
for invalid in [
|
||||
"http://?",
|
||||
"http://host:invalid",
|
||||
"http://bad host/",
|
||||
"ftp://localhost",
|
||||
"http:localhost",
|
||||
] {
|
||||
assert!(!is_latency_test_url(invalid), "{invalid}");
|
||||
}
|
||||
for valid in ["http://localhost/", " https://example.com/ ", "HTTP://localhost:8080/"] {
|
||||
assert!(is_latency_test_url(valid), "{valid}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,6 @@ define_menu! {
|
||||
outbound_modes => OUTBOUND_MODES, "tray_outbound_modes", "tray.outboundModes",
|
||||
profiles => PROFILES, "tray_profiles", "tray.profiles",
|
||||
proxies => PROXIES, "tray_proxies", "tray.proxies",
|
||||
test_latency => TEST_LATENCY, "tray_test_latency", "tray.testLatency",
|
||||
system_proxy => SYSTEM_PROXY, "tray_system_proxy", "tray.systemProxy",
|
||||
tun_mode => TUN_MODE, "tray_tun_mode", "tray.tunMode",
|
||||
close_all_connections => CLOSE_ALL_CONNECTIONS, "tray_close_all_connections", "tray.closeAllConnections",
|
||||
|
||||
+165
-391
@@ -1,6 +1,5 @@
|
||||
use crate::config::{IProfilePreview, IVerge};
|
||||
use crate::core::lightweight;
|
||||
use crate::core::proxy_view::{ProxyGroupView, ProxyMemberRef, ProxyNodeSource, ProxyViewProviderState, ProxyViewV1};
|
||||
use crate::core::tray::menu_def::TrayAction;
|
||||
use crate::process::AsyncHandler;
|
||||
use crate::singleton;
|
||||
@@ -15,6 +14,7 @@ use crate::{
|
||||
use clash_verge_limiter::{Limiter, SystemClock, SystemLimiter};
|
||||
use clash_verge_logging::logging_error;
|
||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
|
||||
use tauri_plugin_mihomo::models::Proxies;
|
||||
use tokio::fs;
|
||||
|
||||
use super::handle;
|
||||
@@ -23,13 +23,11 @@ use smartstring::alias::String;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use tauri::Manager as _;
|
||||
use tauri::{
|
||||
AppHandle, DynRuntime,
|
||||
AppHandle, Wry,
|
||||
menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu},
|
||||
};
|
||||
|
||||
mod latency;
|
||||
mod menu_def;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod speed_task;
|
||||
@@ -37,7 +35,7 @@ use menu_def::{MenuIds, MenuTexts};
|
||||
|
||||
// TODO: 是否需要将可变菜单抽离存储起来,后续直接更新对应菜单实例,无需重新创建菜单(待考虑)
|
||||
|
||||
type ProxyMenuItem = (Option<Submenu<DynRuntime>>, Vec<Box<dyn IsMenuItem<DynRuntime>>>);
|
||||
type ProxyMenuItem = (Option<Submenu<Wry>>, Vec<Box<dyn IsMenuItem<Wry>>>);
|
||||
|
||||
const TRAY_CLICK_DEBOUNCE_MS: u64 = 300;
|
||||
pub const TRAY_ID: &str = "clash-verge-rev-tray";
|
||||
@@ -57,25 +55,8 @@ enum IconKind {
|
||||
Tun,
|
||||
}
|
||||
|
||||
struct LatencyMenuItem {
|
||||
item: MenuItem<DynRuntime>,
|
||||
running: bool,
|
||||
proxies: Vec<ProxyLatencyItem>,
|
||||
provider_state: ProxyViewProviderState,
|
||||
}
|
||||
|
||||
struct ProxyLatencyItem {
|
||||
item: CheckMenuItem<DynRuntime>,
|
||||
name: std::string::String,
|
||||
provider_name: Option<std::string::String>,
|
||||
text: std::string::String,
|
||||
}
|
||||
|
||||
pub struct Tray {
|
||||
limiter: SystemLimiter,
|
||||
proxy_hover_limiter: SystemLimiter,
|
||||
menu_update: tokio::sync::Mutex<()>,
|
||||
latency_items: parking_lot::Mutex<HashMap<std::string::String, LatencyMenuItem>>,
|
||||
#[cfg(target_os = "macos")]
|
||||
speed_controller: speed_task::TraySpeedController,
|
||||
}
|
||||
@@ -149,9 +130,6 @@ impl Default for Tray {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
limiter: Limiter::new(Duration::from_millis(TRAY_CLICK_DEBOUNCE_MS), SystemClock),
|
||||
proxy_hover_limiter: Limiter::new(Duration::from_secs(1), SystemClock),
|
||||
menu_update: tokio::sync::Mutex::new(()),
|
||||
latency_items: parking_lot::Mutex::new(HashMap::new()),
|
||||
#[cfg(target_os = "macos")]
|
||||
speed_controller: speed_task::TraySpeedController::new(),
|
||||
}
|
||||
@@ -217,7 +195,6 @@ impl Tray {
|
||||
}
|
||||
|
||||
async fn update_menu_internal(&self, app_handle: &AppHandle, include_proxy_groups: bool) -> Result<()> {
|
||||
let _update = self.menu_update.lock().await;
|
||||
let Some(tray) = app_handle.tray_by_id(TRAY_ID) else {
|
||||
logging!(warn, Type::Tray, "Failed to update tray menu: tray not found");
|
||||
return Ok(());
|
||||
@@ -242,133 +219,29 @@ impl Tray {
|
||||
let profiles_preview = profiles_arc.profiles_preview().unwrap_or_default();
|
||||
let is_lightweight_mode = is_in_lightweight_mode();
|
||||
|
||||
let (menu, latency_items) = create_tray_menu(
|
||||
app_handle,
|
||||
Some(mode.as_str()),
|
||||
*system_proxy,
|
||||
*tun_mode,
|
||||
tun_mode_available,
|
||||
profiles_preview,
|
||||
TrayMenuOptions {
|
||||
is_lightweight_mode,
|
||||
include_proxy_groups,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
tray.set_menu(Some(menu))?;
|
||||
let groups: Vec<_> = latency_items.keys().cloned().collect();
|
||||
*self.latency_items.lock() = latency_items;
|
||||
for group in groups {
|
||||
self.refresh_latency_item(&group);
|
||||
}
|
||||
logging_error!(
|
||||
Type::Tray,
|
||||
tray.set_menu(Some(
|
||||
create_tray_menu(
|
||||
app_handle,
|
||||
Some(mode.as_str()),
|
||||
*system_proxy,
|
||||
*tun_mode,
|
||||
tun_mode_available,
|
||||
profiles_preview,
|
||||
TrayMenuOptions {
|
||||
is_lightweight_mode,
|
||||
include_proxy_groups,
|
||||
},
|
||||
)
|
||||
.await?,
|
||||
))
|
||||
);
|
||||
|
||||
logging!(debug, Type::Tray, "托盘菜单更新成功");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn refresh_proxy_items(&'static self) {
|
||||
let Ok(update) = self.menu_update.try_lock() else {
|
||||
return;
|
||||
};
|
||||
if self.latency_items.lock().is_empty() || handle::Handle::global().is_exiting() {
|
||||
return;
|
||||
}
|
||||
let Ok(Ok(view)) = tokio::time::timeout(
|
||||
Duration::from_secs(1),
|
||||
cmd::proxy::proxy_view(Some(Duration::from_millis(750))),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if self
|
||||
.latency_items
|
||||
.lock()
|
||||
.values()
|
||||
.any(|entry| entry.provider_state != view.provider_state)
|
||||
{
|
||||
// Provider fallback changes member identities; rebuild through the existing queue.
|
||||
drop(update);
|
||||
logging_error!(Type::Tray, cmd::proxy::sync_tray_proxy_selection().await);
|
||||
return;
|
||||
}
|
||||
let verge = Config::verge().await.latest_arc();
|
||||
let profiles = Config::profiles().await.latest_arc();
|
||||
logging_error!(
|
||||
Type::Tray,
|
||||
handle::Handle::app_handle().run_on_main_thread(move || {
|
||||
// Keep rebuilding serialized until these handles have received their snapshot.
|
||||
let _update = update;
|
||||
let mut items = self.latency_items.lock();
|
||||
for (group_name, entry) in items.iter_mut() {
|
||||
let Some(group) = find_group(&view, group_name) else {
|
||||
continue;
|
||||
};
|
||||
let url = latency::test_url(group, &verge, &profiles);
|
||||
let mut members = HashMap::with_capacity(group.members.len());
|
||||
for member in &group.members {
|
||||
members
|
||||
.entry((member_name(member), member_provider(&view, member)))
|
||||
.or_insert(member);
|
||||
}
|
||||
for proxy in &mut entry.proxies {
|
||||
let Some(member) = members.get(&(proxy.name.as_str(), proxy.provider_name.as_deref())) else {
|
||||
continue;
|
||||
};
|
||||
let text = proxy_item_text(&view, member, &url, &verge);
|
||||
let checked = group.now.as_ref() == Some(&proxy.name);
|
||||
if text != proxy.text {
|
||||
match proxy.item.set_text(&text) {
|
||||
Ok(()) => proxy.text = text,
|
||||
Err(err) => logging!(warn, Type::Tray, "Failed to refresh proxy latency: {err}"),
|
||||
}
|
||||
}
|
||||
match proxy.item.is_checked() {
|
||||
Ok(current) if current != checked => {
|
||||
logging_error!(Type::Tray, proxy.item.set_checked(checked))
|
||||
}
|
||||
Err(err) => logging!(warn, Type::Tray, "Failed to read proxy selection: {err}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
fn refresh_latency_item(&self, group_name: &str) {
|
||||
let group_name = group_name.to_owned();
|
||||
logging_error!(
|
||||
Type::Tray,
|
||||
handle::Handle::app_handle().run_on_main_thread(move || {
|
||||
Self::global().refresh_latency_item_on_main_thread(&group_name);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
fn refresh_latency_item_on_main_thread(&self, group_name: &str) {
|
||||
let running = latency::is_running(group_name);
|
||||
let item = {
|
||||
let mut items = self.latency_items.lock();
|
||||
items.get_mut(group_name).and_then(|entry| {
|
||||
if entry.running == running {
|
||||
return None;
|
||||
}
|
||||
entry.running = running;
|
||||
Some(entry.item.clone())
|
||||
})
|
||||
};
|
||||
if let Some(item) = item {
|
||||
let label = if running {
|
||||
clash_verge_i18n::t!("tray.testingLatency")
|
||||
} else {
|
||||
clash_verge_i18n::t!("tray.testLatency")
|
||||
};
|
||||
logging_error!(Type::Tray, item.set_text(label));
|
||||
logging_error!(Type::Tray, item.set_enabled(!running));
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_icon(&self, verge: &IVerge) -> Result<()> {
|
||||
if handle::Handle::global().is_exiting() {
|
||||
logging!(debug, Type::Tray, "应用正在退出,跳过托盘图标更新");
|
||||
@@ -549,12 +422,11 @@ fn create_hotkeys(hotkeys: &Option<Vec<String>>) -> HashMap<&str, &str> {
|
||||
let mut parts = item.split(',');
|
||||
match (parts.next(), parts.next()) {
|
||||
(Some(func), Some(key)) => {
|
||||
// 托盘菜单中的 `accelerator` 属性,在 Linux/Windows 中都不支持小键盘按键的解析
|
||||
if key.to_uppercase().contains("NUMPAD") {
|
||||
None
|
||||
} else {
|
||||
Some((func, key))
|
||||
}
|
||||
// 托盘菜单的 `accelerator` 在 Linux/Windows 下不支持小键盘按键,Windows 下也不支持 Win 键(前端上报为 `CMD`)
|
||||
let uppercase_key = key.to_uppercase();
|
||||
let unsupported =
|
||||
uppercase_key.contains("NUMPAD") || (cfg!(windows) && uppercase_key.contains("CMD"));
|
||||
if unsupported { None } else { Some((func, key)) }
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
@@ -567,7 +439,7 @@ fn create_hotkeys(hotkeys: &Option<Vec<String>>) -> HashMap<&str, &str> {
|
||||
fn create_profile_menu_item(
|
||||
app_handle: &AppHandle,
|
||||
profiles_preview: Vec<IProfilePreview<'_>>,
|
||||
) -> Result<Vec<CheckMenuItem<DynRuntime>>> {
|
||||
) -> Result<Vec<CheckMenuItem<Wry>>> {
|
||||
profiles_preview
|
||||
.into_iter()
|
||||
.map(|profile| {
|
||||
@@ -584,181 +456,102 @@ fn create_profile_menu_item(
|
||||
.collect()
|
||||
}
|
||||
|
||||
const UNTESTED_DELAY_TEXT: &str = "-ms";
|
||||
const TIMEOUT_DELAY_TEXT: &str = "T/O";
|
||||
const TRAY_GROUP_DEPTH_LIMIT: usize = 8;
|
||||
|
||||
const fn member_name(member: &ProxyMemberRef) -> &str {
|
||||
match member {
|
||||
ProxyMemberRef::Node { name, .. }
|
||||
| ProxyMemberRef::Group { name }
|
||||
| ProxyMemberRef::Unresolved { name, .. } => name.as_str(),
|
||||
}
|
||||
}
|
||||
|
||||
fn find_group<'a>(view: &'a ProxyViewV1, name: &str) -> Option<&'a ProxyGroupView> {
|
||||
view.groups
|
||||
.iter()
|
||||
.chain(view.global.iter())
|
||||
.find(|group| group.name.as_str() == name)
|
||||
}
|
||||
|
||||
/// The delay recorded for `url`, falling back to the node's latest measurement under any URL, so a
|
||||
/// node the core tested on its own still shows a value.
|
||||
fn node_delay(view: &ProxyViewV1, record_id: &str, url: &str) -> Option<u16> {
|
||||
view.records
|
||||
.get(record_id)
|
||||
.map(|node| node.extra.get(url).map_or(&node.history, |extra| &extra.history))
|
||||
.and_then(|history| history.last())
|
||||
.map(|history| history.delay)
|
||||
}
|
||||
|
||||
/// A group row shows the delay of the node the group currently selects. The core never writes a
|
||||
/// history for the group itself, so `now` is followed until the chain reaches a node.
|
||||
fn selected_member_delay(view: &ProxyViewV1, group_name: &str, url: &str) -> Option<u16> {
|
||||
let mut current = group_name;
|
||||
for _ in 0..TRAY_GROUP_DEPTH_LIMIT {
|
||||
let Some(group) = find_group(view, current) else { break };
|
||||
let Some(now) = group.now.as_deref().filter(|now| !now.is_empty() && *now != current) else {
|
||||
break;
|
||||
};
|
||||
let Some(member) = group.members.iter().find(|member| member_name(member) == now) else {
|
||||
break;
|
||||
};
|
||||
match member {
|
||||
ProxyMemberRef::Node { record_id, .. } => return node_delay(view, record_id, url),
|
||||
ProxyMemberRef::Group { name } => current = name.as_str(),
|
||||
ProxyMemberRef::Unresolved { .. } => break,
|
||||
}
|
||||
}
|
||||
find_group(view, group_name)
|
||||
.map(|group| group.extra.get(url).map_or(&group.history, |extra| &extra.history))
|
||||
.and_then(|history| history.last())
|
||||
.map(|history| history.delay)
|
||||
}
|
||||
|
||||
fn member_provider<'a>(view: &'a ProxyViewV1, member: &ProxyMemberRef) -> Option<&'a str> {
|
||||
let ProxyMemberRef::Node { record_id, .. } = member else {
|
||||
return None;
|
||||
};
|
||||
match &view.records.get(record_id)?.source {
|
||||
ProxyNodeSource::Provider { provider_name, .. } => Some(provider_name),
|
||||
ProxyNodeSource::Core { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn proxy_item_text(view: &ProxyViewV1, member: &ProxyMemberRef, url: &str, verge: &IVerge) -> std::string::String {
|
||||
let timeout = verge
|
||||
.default_latency_timeout
|
||||
.filter(|timeout| *timeout > 0)
|
||||
.unwrap_or(10000);
|
||||
let delay = match member {
|
||||
ProxyMemberRef::Node { record_id, .. } => node_delay(view, record_id, url),
|
||||
ProxyMemberRef::Group { name } => selected_member_delay(view, name, url),
|
||||
ProxyMemberRef::Unresolved { .. } => None,
|
||||
};
|
||||
let delay_text = delay.map_or_else(
|
||||
|| UNTESTED_DELAY_TEXT.to_owned(),
|
||||
|delay| {
|
||||
if delay == 0 || u32::from(delay) >= timeout as u32 {
|
||||
TIMEOUT_DELAY_TEXT.to_owned()
|
||||
} else {
|
||||
format!("{delay}ms")
|
||||
}
|
||||
},
|
||||
);
|
||||
format!("{} | {delay_text}", member_name(member))
|
||||
}
|
||||
|
||||
fn create_subcreate_proxy_menu_item(
|
||||
app_handle: &AppHandle,
|
||||
proxy_mode: &str,
|
||||
view: Option<ProxyViewV1>,
|
||||
latency_items: &mut HashMap<std::string::String, LatencyMenuItem>,
|
||||
texts: &MenuTexts,
|
||||
verge: &IVerge,
|
||||
profiles: &crate::config::IProfiles,
|
||||
) -> Vec<Submenu<DynRuntime>> {
|
||||
let Some(view) = view else { return Vec::new() };
|
||||
view.groups
|
||||
.iter()
|
||||
.chain(view.global.iter())
|
||||
.filter_map(|group| {
|
||||
if group.hidden.unwrap_or_default() || (proxy_mode == "global") != (group.name == "GLOBAL") {
|
||||
return None;
|
||||
}
|
||||
let url = latency::test_url(group, verge, profiles);
|
||||
let proxies: Vec<ProxyLatencyItem> = group
|
||||
.members
|
||||
.iter()
|
||||
.filter_map(|member| {
|
||||
let name = member_name(member);
|
||||
let text = proxy_item_text(&view, member, &url, verge);
|
||||
let checked = group.now.as_deref() == Some(name);
|
||||
CheckMenuItem::with_id(
|
||||
app_handle,
|
||||
format!("proxy_{}_{}", group.name, name),
|
||||
&text,
|
||||
true,
|
||||
checked,
|
||||
None::<&str>,
|
||||
)
|
||||
.map(|item| ProxyLatencyItem {
|
||||
item,
|
||||
name: name.to_owned(),
|
||||
provider_name: member_provider(&view, member).map(str::to_owned),
|
||||
text,
|
||||
proxy_group_order_map: Option<HashMap<String, usize>>,
|
||||
proxy_nodes_data: Option<Proxies>,
|
||||
) -> Vec<Submenu<Wry>> {
|
||||
let proxy_submenus: Vec<Submenu<Wry>> = {
|
||||
let mut submenus: Vec<(String, usize, Submenu<Wry>)> = Vec::new();
|
||||
|
||||
// TODO: 应用启动时,内核还未启动完全,无法获取代理节点信息
|
||||
if let Some(proxy_nodes_data) = proxy_nodes_data {
|
||||
for (group_name, group_data) in proxy_nodes_data.proxies.iter() {
|
||||
let should_show = match proxy_mode {
|
||||
"global" => group_name == "GLOBAL",
|
||||
_ => group_name != "GLOBAL",
|
||||
} && !group_data.hidden.unwrap_or_default();
|
||||
|
||||
if !should_show {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(all_proxies) = group_data.all.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let now_proxy = group_data.now.as_deref().unwrap_or_default();
|
||||
|
||||
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);
|
||||
|
||||
let delay_text = proxy_nodes_data
|
||||
.proxies
|
||||
.get(proxy_str)
|
||||
.and_then(|h| h.history.last())
|
||||
.map(|h| match h.delay {
|
||||
0 => "-ms".into(),
|
||||
delay if delay >= 10000 => "-ms".into(),
|
||||
_ => format!("{}ms", h.delay),
|
||||
})
|
||||
.unwrap_or_else(|| "-ms".into());
|
||||
|
||||
let display_text = format!("{} | {}", proxy_str, delay_text);
|
||||
|
||||
CheckMenuItem::with_id(app_handle, item_id, display_text, true, is_selected, None::<&str>)
|
||||
.map_err(|e| logging!(warn, Type::Tray, "Failed to create proxy menu item: {}", e))
|
||||
.ok()
|
||||
})
|
||||
.map_err(|e| logging!(warn, Type::Tray, "Failed to create proxy menu item: {e}"))
|
||||
.ok()
|
||||
})
|
||||
.collect();
|
||||
let group_items: Vec<_> = proxies.iter().map(|proxy| proxy.item.clone()).collect();
|
||||
if group_items.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let running = latency::is_running(&group.name);
|
||||
let test_item = MenuItem::with_id(
|
||||
app_handle,
|
||||
format!("{}_{}", MenuIds::TEST_LATENCY, group.name),
|
||||
if running {
|
||||
clash_verge_i18n::t!("tray.testingLatency")
|
||||
.collect();
|
||||
|
||||
if group_items.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let group_display_name = group_name.to_string();
|
||||
|
||||
let group_items_refs: Vec<&dyn IsMenuItem<Wry>> =
|
||||
group_items.iter().map(|item| item as &dyn IsMenuItem<Wry>).collect();
|
||||
|
||||
if let Ok(submenu) = Submenu::with_id_and_items(
|
||||
app_handle,
|
||||
format!("proxy_group_{}", group_name),
|
||||
group_display_name,
|
||||
true,
|
||||
&group_items_refs,
|
||||
) {
|
||||
let insertion_index = submenus.len();
|
||||
submenus.push((group_name.into(), insertion_index, submenu));
|
||||
} else {
|
||||
texts.test_latency.clone()
|
||||
},
|
||||
!running,
|
||||
None::<&str>,
|
||||
)
|
||||
.ok()?;
|
||||
latency_items.insert(
|
||||
group.name.clone(),
|
||||
LatencyMenuItem {
|
||||
item: test_item.clone(),
|
||||
running,
|
||||
proxies,
|
||||
provider_state: view.provider_state,
|
||||
},
|
||||
);
|
||||
let separator = PredefinedMenuItem::separator(app_handle).ok()?;
|
||||
let mut items: Vec<&dyn IsMenuItem<DynRuntime>> = vec![&test_item, &separator];
|
||||
items.extend(group_items.iter().map(|item| item as &dyn IsMenuItem<DynRuntime>));
|
||||
Submenu::with_id_and_items(
|
||||
app_handle,
|
||||
format!("proxy_group_{}", group.name),
|
||||
&group.name,
|
||||
true,
|
||||
&items,
|
||||
)
|
||||
.map_err(|e| logging!(warn, Type::Tray, "Failed to create proxy group submenu: {e}"))
|
||||
.ok()
|
||||
})
|
||||
.collect()
|
||||
logging!(warn, Type::Tray, "Failed to create proxy group submenu: {}", group_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(order_map) = proxy_group_order_map.as_ref() {
|
||||
submenus.sort_by(|(name_a, original_index_a, _), (name_b, original_index_b, _)| {
|
||||
match (order_map.get(name_a), order_map.get(name_b)) {
|
||||
(Some(index_a), Some(index_b)) => index_a.cmp(index_b),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => original_index_a.cmp(original_index_b),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
submenus.into_iter().map(|(_, _, submenu)| submenu).collect()
|
||||
};
|
||||
proxy_submenus
|
||||
}
|
||||
|
||||
fn create_proxy_menu_item(
|
||||
app_handle: &AppHandle,
|
||||
show_proxy_groups_inline: bool,
|
||||
proxy_submenus: Vec<Submenu<DynRuntime>>,
|
||||
proxy_submenus: Vec<Submenu<Wry>>,
|
||||
proxies_text: &str,
|
||||
) -> Result<ProxyMenuItem> {
|
||||
let (proxies_submenu, inline_proxy_items) = if show_proxy_groups_inline {
|
||||
@@ -766,13 +559,13 @@ fn create_proxy_menu_item(
|
||||
None,
|
||||
proxy_submenus
|
||||
.into_iter()
|
||||
.map(|submenu| Box::new(submenu) as Box<dyn IsMenuItem<DynRuntime>>)
|
||||
.map(|submenu| Box::new(submenu) as Box<dyn IsMenuItem<Wry>>)
|
||||
.collect(),
|
||||
)
|
||||
} else if !proxy_submenus.is_empty() {
|
||||
let proxy_submenu_refs: Vec<&dyn IsMenuItem<DynRuntime>> = proxy_submenus
|
||||
let proxy_submenu_refs: Vec<&dyn IsMenuItem<Wry>> = proxy_submenus
|
||||
.iter()
|
||||
.map(|submenu| submenu as &dyn IsMenuItem<DynRuntime>)
|
||||
.map(|submenu| submenu as &dyn IsMenuItem<Wry>)
|
||||
.collect();
|
||||
|
||||
(
|
||||
@@ -799,53 +592,66 @@ async fn create_tray_menu(
|
||||
tun_mode_available: bool,
|
||||
profiles_preview: Vec<IProfilePreview<'_>>,
|
||||
options: TrayMenuOptions,
|
||||
) -> Result<(
|
||||
tauri::menu::Menu<DynRuntime>,
|
||||
HashMap<std::string::String, LatencyMenuItem>,
|
||||
)> {
|
||||
) -> Result<tauri::menu::Menu<Wry>> {
|
||||
let current_proxy_mode = mode.unwrap_or("");
|
||||
|
||||
let mut verge_settings = Config::verge().await.latest_arc();
|
||||
let fetch_proxy_groups =
|
||||
options.include_proxy_groups && verge_settings.tray_proxy_groups_display_mode.as_deref() != Some("disable");
|
||||
|
||||
// Proxy data must remain optional so a stopped Core cannot roll back unrelated settings.
|
||||
let proxy_view = if fetch_proxy_groups {
|
||||
tokio::time::timeout(
|
||||
Duration::from_millis(1000),
|
||||
cmd::proxy::proxy_view(Some(Duration::from_millis(750))),
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(Result::ok)
|
||||
// TODO: should update tray menu again when it was timeout error
|
||||
let (proxy_nodes_data, runtime_proxy_groups_order) = if fetch_proxy_groups {
|
||||
let proxy_nodes_data =
|
||||
tokio::time::timeout(Duration::from_millis(1000), handle::Handle::mihomo().get_proxies())
|
||||
.await
|
||||
.map_or(None, |res| res.ok());
|
||||
|
||||
let runtime = Config::runtime().await.latest_arc();
|
||||
let runtime_proxy_groups_order = runtime.config.as_ref().map(|config| {
|
||||
config
|
||||
.get("proxy-groups")
|
||||
.and_then(|groups| groups.as_sequence())
|
||||
.map(|groups| {
|
||||
groups
|
||||
.iter()
|
||||
.filter_map(|group| group.get("name"))
|
||||
.filter_map(|name| name.as_str())
|
||||
.enumerate()
|
||||
.map(|(index, name)| (name.into(), index))
|
||||
.collect::<HashMap<String, usize>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
});
|
||||
|
||||
(proxy_nodes_data, runtime_proxy_groups_order)
|
||||
} else {
|
||||
None
|
||||
(None, None)
|
||||
};
|
||||
|
||||
if fetch_proxy_groups {
|
||||
verge_settings = Config::verge().await.latest_arc();
|
||||
}
|
||||
|
||||
let profiles_config = Config::profiles().await.latest_arc();
|
||||
|
||||
let tray_proxy_groups_display_mode = verge_settings
|
||||
.tray_proxy_groups_display_mode
|
||||
.as_deref()
|
||||
.unwrap_or("default");
|
||||
let include_proxy_groups = options.include_proxy_groups && tray_proxy_groups_display_mode != "disable";
|
||||
|
||||
let proxy_group_order_map = runtime_proxy_groups_order;
|
||||
|
||||
let show_outbound_modes_inline = verge_settings.tray_inline_outbound_modes.unwrap_or(false);
|
||||
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
|
||||
let hotkeys = create_hotkeys(&verge_settings.hotkeys);
|
||||
|
||||
let profile_menu_items: Vec<CheckMenuItem<DynRuntime>> = create_profile_menu_item(app_handle, profiles_preview)?;
|
||||
let profile_menu_items: Vec<CheckMenuItem<Wry>> = create_profile_menu_item(app_handle, profiles_preview)?;
|
||||
|
||||
let texts = MenuTexts::new();
|
||||
let profile_menu_items_refs: Vec<&dyn IsMenuItem<DynRuntime>> = profile_menu_items
|
||||
let profile_menu_items_refs: Vec<&dyn IsMenuItem<Wry>> = profile_menu_items
|
||||
.iter()
|
||||
.map(|item| item as &dyn IsMenuItem<DynRuntime>)
|
||||
.map(|item| item as &dyn IsMenuItem<Wry>)
|
||||
.collect();
|
||||
|
||||
let open_window = &MenuItem::with_id(
|
||||
@@ -898,9 +704,9 @@ async fn create_tray_menu(
|
||||
outbound_modes_label.as_str(),
|
||||
true,
|
||||
&[
|
||||
rule_mode as &dyn IsMenuItem<DynRuntime>,
|
||||
global_mode as &dyn IsMenuItem<DynRuntime>,
|
||||
direct_mode as &dyn IsMenuItem<DynRuntime>,
|
||||
rule_mode as &dyn IsMenuItem<Wry>,
|
||||
global_mode as &dyn IsMenuItem<Wry>,
|
||||
direct_mode as &dyn IsMenuItem<Wry>,
|
||||
],
|
||||
)?)
|
||||
};
|
||||
@@ -913,17 +719,9 @@ async fn create_tray_menu(
|
||||
&profile_menu_items_refs,
|
||||
)?;
|
||||
|
||||
let mut latency_items = HashMap::new();
|
||||
let (proxies_menu, inline_proxy_items) = if include_proxy_groups {
|
||||
let proxy_sub_menus = create_subcreate_proxy_menu_item(
|
||||
app_handle,
|
||||
current_proxy_mode,
|
||||
proxy_view,
|
||||
&mut latency_items,
|
||||
&texts,
|
||||
&verge_settings,
|
||||
&profiles_config,
|
||||
);
|
||||
let proxy_sub_menus =
|
||||
create_subcreate_proxy_menu_item(app_handle, current_proxy_mode, proxy_group_order_map, proxy_nodes_data);
|
||||
|
||||
match tray_proxy_groups_display_mode {
|
||||
"default" => create_proxy_menu_item(app_handle, false, proxy_sub_menus, &texts.proxies)?,
|
||||
@@ -1013,7 +811,7 @@ async fn create_tray_menu(
|
||||
&texts.more,
|
||||
true,
|
||||
&[
|
||||
copy_env as &dyn IsMenuItem<DynRuntime>,
|
||||
copy_env as &dyn IsMenuItem<Wry>,
|
||||
close_all_connections,
|
||||
restart_clash,
|
||||
restart_app,
|
||||
@@ -1030,13 +828,13 @@ async fn create_tray_menu(
|
||||
|
||||
let separator = &PredefinedMenuItem::separator(app_handle)?;
|
||||
|
||||
let mut menu_items: Vec<&dyn IsMenuItem<DynRuntime>> = vec![open_window, separator];
|
||||
let mut menu_items: Vec<&dyn IsMenuItem<Wry>> = vec![open_window, separator];
|
||||
|
||||
if show_outbound_modes_inline {
|
||||
menu_items.extend_from_slice(&[
|
||||
rule_mode as &dyn IsMenuItem<DynRuntime>,
|
||||
global_mode as &dyn IsMenuItem<DynRuntime>,
|
||||
direct_mode as &dyn IsMenuItem<DynRuntime>,
|
||||
rule_mode as &dyn IsMenuItem<Wry>,
|
||||
global_mode as &dyn IsMenuItem<Wry>,
|
||||
direct_mode as &dyn IsMenuItem<Wry>,
|
||||
]);
|
||||
} else if let Some(ref outbound_modes) = outbound_modes {
|
||||
menu_items.push(outbound_modes);
|
||||
@@ -1056,36 +854,21 @@ async fn create_tray_menu(
|
||||
|
||||
menu_items.extend_from_slice(&[
|
||||
separator,
|
||||
system_proxy as &dyn IsMenuItem<DynRuntime>,
|
||||
tun_mode as &dyn IsMenuItem<DynRuntime>,
|
||||
system_proxy as &dyn IsMenuItem<Wry>,
|
||||
tun_mode as &dyn IsMenuItem<Wry>,
|
||||
separator,
|
||||
lightweight_mode as &dyn IsMenuItem<DynRuntime>,
|
||||
open_dir as &dyn IsMenuItem<DynRuntime>,
|
||||
more as &dyn IsMenuItem<DynRuntime>,
|
||||
lightweight_mode as &dyn IsMenuItem<Wry>,
|
||||
open_dir as &dyn IsMenuItem<Wry>,
|
||||
more as &dyn IsMenuItem<Wry>,
|
||||
separator,
|
||||
quit as &dyn IsMenuItem<DynRuntime>,
|
||||
quit as &dyn IsMenuItem<Wry>,
|
||||
]);
|
||||
|
||||
let menu = tauri::menu::MenuBuilder::new(app_handle).items(&menu_items).build()?;
|
||||
Ok((menu, latency_items))
|
||||
Ok(menu)
|
||||
}
|
||||
|
||||
fn on_tray_icon_event(_tray_icon: &TrayIcon, tray_event: TrayIconEvent) {
|
||||
let refresh_proxies = match tray_event {
|
||||
TrayIconEvent::Enter { .. } => Tray::global().proxy_hover_limiter.check(),
|
||||
TrayIconEvent::Click {
|
||||
button: MouseButton::Left | MouseButton::Right,
|
||||
button_state: MouseButtonState::Down,
|
||||
..
|
||||
} => true,
|
||||
_ => false,
|
||||
};
|
||||
if refresh_proxies {
|
||||
AsyncHandler::spawn(|| async {
|
||||
Tray::global().refresh_proxy_items().await;
|
||||
});
|
||||
}
|
||||
|
||||
if matches!(
|
||||
tray_event,
|
||||
TrayIconEvent::Move { .. } | TrayIconEvent::Leave { .. } | TrayIconEvent::Enter { .. }
|
||||
@@ -1207,15 +990,6 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
|
||||
};
|
||||
feat::toggle_proxy_profile(profile_index.into()).await;
|
||||
}
|
||||
id if id.starts_with(MenuIds::TEST_LATENCY) => {
|
||||
let group_name = id
|
||||
.strip_prefix(MenuIds::TEST_LATENCY)
|
||||
.and_then(|rest| rest.strip_prefix('_'));
|
||||
if let Some(group_name) = group_name {
|
||||
logging!(info, Type::Tray, "Tray menu: test group latency {}", group_name);
|
||||
latency::test_proxy_group_delay(group_name).await;
|
||||
}
|
||||
}
|
||||
id if id.starts_with("proxy_") => {
|
||||
let rest = match id.strip_prefix("proxy_") {
|
||||
Some(r) => r,
|
||||
|
||||
@@ -132,7 +132,7 @@ impl TraySpeedController {
|
||||
|
||||
let app_handle = handle::Handle::app_handle();
|
||||
if let Some(tray) = app_handle.tray_by_id(super::TRAY_ID) {
|
||||
let result = tray.with_inner_blocking(|inner| {
|
||||
let result = tray.with_inner_tray_icon(|inner| {
|
||||
if let Some(status_item) = inner.ns_status_item() {
|
||||
tray_speed::clear_speed_attributed_title(&status_item);
|
||||
}
|
||||
@@ -167,7 +167,7 @@ impl TraySpeedController {
|
||||
fn apply_tray_speed(up: u64, down: u64) {
|
||||
let app_handle = handle::Handle::app_handle();
|
||||
if let Some(tray) = app_handle.tray_by_id(super::TRAY_ID) {
|
||||
let result = tray.with_inner_blocking(move |inner| {
|
||||
let result = tray.with_inner_tray_icon(move |inner| {
|
||||
if let Some(status_item) = inner.ns_status_item() {
|
||||
tray_speed::set_speed_attributed_title(&status_item, up, down);
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ pub async fn open_or_close_dashboard() {
|
||||
logging!(info, Type::Window, "Window toggle result: {result:?}");
|
||||
}
|
||||
|
||||
pub async fn quit() -> clash_verge_signal::ShutdownOutcome {
|
||||
pub async fn quit() {
|
||||
logging!(debug, Type::System, "启动退出流程");
|
||||
// 设置退出标志
|
||||
handle::Handle::global().set_is_exiting();
|
||||
@@ -165,13 +165,11 @@ pub async fn quit() -> clash_verge_signal::ShutdownOutcome {
|
||||
NoticeStatus::AppQuitCoreStopFailed,
|
||||
cleanup_result.stop_error.unwrap_or_default(),
|
||||
);
|
||||
return clash_verge_signal::ShutdownOutcome::Canceled;
|
||||
}
|
||||
|
||||
utils::server::shutdown_embedded_server();
|
||||
let app_handle = handle::Handle::app_handle();
|
||||
app_handle.exit(if cleanup_result.all_success { 0 } else { 1 });
|
||||
clash_verge_signal::ShutdownOutcome::Committed
|
||||
}
|
||||
|
||||
pub async fn clean_async() -> CleanupResult {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use crate::cmd;
|
||||
|
||||
pub fn builder() -> tauri_specta::Builder<tauri::DynRuntime> {
|
||||
pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
tauri_specta::Builder::new()
|
||||
.types(&platform_types())
|
||||
.commands(tauri_specta::collect_commands![
|
||||
tauri_plugin_clash_verge_sysinfo::commands::get_system_info,
|
||||
tauri_plugin_clash_verge_sysinfo::commands::get_app_uptime,
|
||||
tauri_plugin_clash_verge_sysinfo::commands::export_diagnostic_info::<tauri::DynRuntime>,
|
||||
tauri_plugin_clash_verge_sysinfo::commands::export_diagnostic_info::<tauri::Wry>,
|
||||
cmd::probe_listener,
|
||||
cmd::save_proxy_ports,
|
||||
cmd::get_sys_proxy,
|
||||
|
||||
@@ -40,7 +40,7 @@ mod app_init {
|
||||
}
|
||||
|
||||
/// Setup plugins for the Tauri builder
|
||||
pub fn setup_plugins(builder: tauri::Builder) -> tauri::Builder {
|
||||
pub fn setup_plugins(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
|
||||
#[allow(unused_mut)]
|
||||
let mut builder = builder
|
||||
.plugin(tauri_plugin_clash_verge_sysinfo::init())
|
||||
@@ -124,7 +124,7 @@ mod app_init {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn generate_handlers() -> impl Fn(tauri::ipc::Invoke) -> bool + Send + Sync + 'static {
|
||||
pub fn generate_handlers() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send + Sync + 'static {
|
||||
{
|
||||
let builder = crate::ipc::builder();
|
||||
move |invoke| builder.invoke_handler()(invoke)
|
||||
@@ -181,7 +181,7 @@ pub fn run() -> std::process::ExitCode {
|
||||
#[cfg(target_os = "linux")]
|
||||
utils::linux::workarounds::apply_wayland_webkit_fix();
|
||||
|
||||
let builder = app_init::setup_plugins(tauri::Builder::default().runtime(tauri_runtime_wry::Wry::default()))
|
||||
let builder = app_init::setup_plugins(tauri::Builder::default())
|
||||
.setup(|app| {
|
||||
// Logger may not be ready yet, so mirror setup panics to stderr.
|
||||
fn log_setup_panic(stage: &str, panic: Box<dyn std::any::Any + Send>) {
|
||||
@@ -230,6 +230,7 @@ pub fn run() -> std::process::ExitCode {
|
||||
if let Err(panic) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
resolve::resolve_setup_async();
|
||||
resolve::resolve_setup_sync();
|
||||
#[cfg(target_os = "linux")]
|
||||
resolve::init_signal();
|
||||
})) {
|
||||
log_setup_panic("window-core", panic);
|
||||
|
||||
+12
-1
@@ -1,11 +1,22 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use std::process::ExitCode;
|
||||
#[cfg(not(all(feature = "perf-harness", target_os = "macos")))]
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[cfg(all(feature = "perf-harness", target_os = "macos"))]
|
||||
#[path = "../../scripts/perf/app.rs"]
|
||||
mod perf;
|
||||
|
||||
#[cfg(all(feature = "perf-harness", target_os = "macos"))]
|
||||
fn main() -> ExitCode {
|
||||
perf::run()
|
||||
}
|
||||
|
||||
#[cfg(not(all(feature = "perf-harness", target_os = "macos")))]
|
||||
fn main() -> ExitCode {
|
||||
let default_parallelism = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
|
||||
let worker_limit = std::cmp::min(default_parallelism, 8);
|
||||
let worker_limit = std::cmp::min(default_parallelism, 4);
|
||||
let blocking_limit = 2 * worker_limit;
|
||||
|
||||
#[allow(clippy::unwrap_used)]
|
||||
|
||||
@@ -20,7 +20,6 @@ use crate::{
|
||||
utils::{init, server, window_manager::WindowManager},
|
||||
};
|
||||
use clash_verge_logging::{Type, logging, logging_error};
|
||||
use clash_verge_signal;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod dns;
|
||||
@@ -189,6 +188,8 @@ async fn init_silent_updater() {
|
||||
logging!(info, Type::Setup, "Silent updater initialized");
|
||||
}
|
||||
|
||||
// Upstream has no session-end signal support on Linux; see crates/clash-verge-signal.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn init_signal() {
|
||||
logging!(debug, Type::Setup, "Initializing signal handlers...");
|
||||
clash_verge_signal::register(feat::quit);
|
||||
|
||||
@@ -132,10 +132,7 @@ pub fn take_webview_needs_reload() -> bool {
|
||||
/// 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,
|
||||
_termination: &tauri::webview::WebContentProcessTermination,
|
||||
) {
|
||||
pub fn on_web_content_process_terminated(webview: &tauri::Webview) {
|
||||
if handle::Handle::global().is_exiting() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -39,8 +39,6 @@ static INSTANCE_LOCK: OnceLock<std::fs::File> = OnceLock::new();
|
||||
const PAC_INITIAL_AVAILABLE: bool = false;
|
||||
static PAC_AVAILABLE: AtomicBool = AtomicBool::new(PAC_INITIAL_AVAILABLE);
|
||||
static COMMANDS_READY: AtomicBool = AtomicBool::new(false);
|
||||
#[cfg(feature = "verge-dev")]
|
||||
static DEV_QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SingletonDisposition {
|
||||
@@ -164,55 +162,12 @@ fn instance_auth(token: String) -> impl warp::Filter<Extract = (), Error = warp:
|
||||
.untuple_one()
|
||||
}
|
||||
|
||||
#[cfg(feature = "verge-dev")]
|
||||
fn dev_quit_route<F>(
|
||||
token: String,
|
||||
commands_ready: &'static AtomicBool,
|
||||
quit_requested: &'static AtomicBool,
|
||||
trigger: F,
|
||||
) -> impl warp::Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone
|
||||
where
|
||||
F: Fn() + Clone + Send + Sync + 'static,
|
||||
{
|
||||
instance_auth(token)
|
||||
.and(warp::path!("commands" / "dev" / "quit"))
|
||||
.and(warp::post())
|
||||
.and_then(move || {
|
||||
let trigger = trigger.clone();
|
||||
async move {
|
||||
if !commands_ready.load(Ordering::Acquire) {
|
||||
return Ok::<_, warp::Rejection>(warp::reply::with_status(
|
||||
String::from("starting"),
|
||||
warp::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
));
|
||||
}
|
||||
if quit_requested
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_ok()
|
||||
{
|
||||
trigger();
|
||||
}
|
||||
Ok(warp::reply::with_status(
|
||||
String::from("accepted"),
|
||||
warp::http::StatusCode::ACCEPTED,
|
||||
))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "verge-dev")]
|
||||
fn release_dev_quit_latch(quit_requested: &AtomicBool, outcome: clash_verge_signal::ShutdownOutcome) {
|
||||
if matches!(outcome, clash_verge_signal::ShutdownOutcome::Canceled) {
|
||||
quit_requested.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
fn start_embedded_server(listener: tokio::net::TcpListener, token: String) {
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
||||
let _ = SHUTDOWN_SENDER.set(Mutex::new(Some(shutdown_tx)));
|
||||
|
||||
#[cfg(feature = "verge-dev")]
|
||||
let auth = instance_auth(token.clone());
|
||||
let auth = instance_auth(token);
|
||||
#[cfg(not(feature = "verge-dev"))]
|
||||
let auth = instance_auth(token);
|
||||
|
||||
@@ -278,12 +233,6 @@ fn start_embedded_server(listener: tokio::net::TcpListener, token: String) {
|
||||
});
|
||||
|
||||
let commands = auth.clone().and(visible).or(auth.and(scheme)).or(pac);
|
||||
#[cfg(feature = "verge-dev")]
|
||||
let commands = commands.or(dev_quit_route(token, &COMMANDS_READY, &DEV_QUIT_REQUESTED, || {
|
||||
AsyncHandler::spawn(|| async {
|
||||
release_dev_quit_latch(&DEV_QUIT_REQUESTED, crate::feat::quit().await);
|
||||
});
|
||||
}));
|
||||
AsyncHandler::spawn(move || async move {
|
||||
warp::serve(commands)
|
||||
.incoming(listener)
|
||||
|
||||
@@ -27,8 +27,12 @@ const TRAY_STATUS_ITEM_EXTRA_WIDTH: f64 = 30.0;
|
||||
const TRAY_STATUS_ITEM_MIN_LENGTH: f64 = 58.0;
|
||||
const NS_VARIABLE_STATUS_ITEM_LENGTH: f64 = -1.0;
|
||||
|
||||
/// (button_height, mono_advance, attributes).
|
||||
type CachedAttrs = (f64, f64, Retained<NSDictionary<NSString, AnyObject>>);
|
||||
|
||||
thread_local! {
|
||||
static LAST_DISPLAY_STR: RefCell<String> = const { RefCell::new(String::new()) };
|
||||
static CACHED_ATTRS: RefCell<Option<CachedAttrs>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
fn format_tray_speed(up: u64, down: u64) -> String {
|
||||
@@ -74,8 +78,26 @@ fn create_attributed_string(
|
||||
}
|
||||
}
|
||||
|
||||
fn status_item_length_for_speed(attr_str: &NSAttributedString) -> f64 {
|
||||
(attr_str.size().width.ceil() + TRAY_STATUS_ITEM_EXTRA_WIDTH).max(TRAY_STATUS_ITEM_MIN_LENGTH)
|
||||
/// Attributes and per-glyph advance, rebuilt only when the button height changes.
|
||||
fn cached_attributes(button_height: f64) -> (f64, Retained<NSDictionary<NSString, AnyObject>>) {
|
||||
CACHED_ATTRS.with(|cache| {
|
||||
if let Some((height, advance, attrs)) = cache.borrow().as_ref()
|
||||
&& *height == button_height
|
||||
{
|
||||
return (*advance, attrs.clone());
|
||||
}
|
||||
let attrs = build_attributes(button_height);
|
||||
let probe = NSString::from_str("0");
|
||||
let advance = create_attributed_string(&probe, Some(&attrs)).size().width;
|
||||
*cache.borrow_mut() = Some((button_height, advance, attrs.clone()));
|
||||
(advance, attrs)
|
||||
})
|
||||
}
|
||||
|
||||
/// Longest line times the monospaced advance; matches `size().width` for the ASCII-only speed text.
|
||||
fn arithmetic_width(text: &str, advance: f64) -> f64 {
|
||||
let glyphs = text.split('\n').map(str::len).max().unwrap_or(0) as f64;
|
||||
glyphs * advance
|
||||
}
|
||||
|
||||
fn sync_click_target_frame(button: &NSStatusBarButton) {
|
||||
@@ -89,7 +111,7 @@ fn sync_click_target_frame(button: &NSStatusBarButton) {
|
||||
}
|
||||
|
||||
/// `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) {
|
||||
fn apply_status_item_attributed_title(status_item: &NSStatusItem, text: &str, show_speed: bool) {
|
||||
let Some(mtm) = MainThreadMarker::new() else {
|
||||
logging!(warn, Type::Tray, "托盘速率富文本设置跳过:非主线程调用");
|
||||
return;
|
||||
@@ -97,17 +119,19 @@ fn apply_status_item_attributed_title(status_item: &NSStatusItem, text: &NSStrin
|
||||
let Some(button) = status_item.button(mtm) else {
|
||||
return;
|
||||
};
|
||||
let ns_string = NSString::from_str(text);
|
||||
let attr_str = if show_speed {
|
||||
let attrs = build_attributes(button.bounds().size.height);
|
||||
let (advance, attrs) = cached_attributes(button.bounds().size.height);
|
||||
let attrs: &NSDictionary<NSString, AnyObject> = &attrs;
|
||||
let attr_str = create_attributed_string(text, Some(attrs));
|
||||
let length = status_item_length_for_speed(&attr_str);
|
||||
let attr_str = create_attributed_string(&ns_string, Some(attrs));
|
||||
let width = arithmetic_width(text, advance);
|
||||
let length = (width.ceil() + TRAY_STATUS_ITEM_EXTRA_WIDTH).max(TRAY_STATUS_ITEM_MIN_LENGTH);
|
||||
if status_item.length() != length {
|
||||
status_item.setLength(length);
|
||||
}
|
||||
attr_str
|
||||
} else {
|
||||
create_attributed_string(text, None)
|
||||
create_attributed_string(&ns_string, None)
|
||||
};
|
||||
button.setAttributedTitle(&attr_str);
|
||||
sync_click_target_frame(&button);
|
||||
@@ -121,8 +145,7 @@ pub fn set_speed_attributed_title(status_item: &NSStatusItem, up: u64, down: u64
|
||||
return;
|
||||
}
|
||||
*last = speed_text;
|
||||
let ns_string = NSString::from_str(last.as_str());
|
||||
apply_status_item_attributed_title(status_item, &ns_string, true);
|
||||
apply_status_item_attributed_title(status_item, last.as_str(), true);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -130,7 +153,6 @@ pub fn clear_speed_attributed_title(status_item: &NSStatusItem) {
|
||||
LAST_DISPLAY_STR.with(|last| {
|
||||
last.borrow_mut().clear();
|
||||
});
|
||||
let empty = NSString::from_str("");
|
||||
status_item.setLength(NS_VARIABLE_STATUS_ITEM_LENGTH);
|
||||
apply_status_item_attributed_title(status_item, &empty, false);
|
||||
apply_status_item_attributed_title(status_item, "", false);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use clash_verge_logging::{Type, logging};
|
||||
use std::pin::Pin;
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
use tauri::{Manager as _, WebviewWindow};
|
||||
use tauri::{Manager as _, WebviewWindow, Wry};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum WindowOperationResult {
|
||||
@@ -50,7 +50,7 @@ impl WindowManager {
|
||||
handle::Handle::global().set_activation_policy_regular();
|
||||
}
|
||||
|
||||
fn get_main_window_with_state() -> (Option<WebviewWindow>, WindowState) {
|
||||
fn get_main_window_with_state() -> (Option<WebviewWindow<Wry>>, WindowState) {
|
||||
let Some(window) = Self::get_main_window() else {
|
||||
return (None, WindowState::NotExist);
|
||||
};
|
||||
@@ -76,7 +76,7 @@ impl WindowManager {
|
||||
Self::get_main_window_with_state().1
|
||||
}
|
||||
|
||||
pub fn get_main_window() -> Option<WebviewWindow> {
|
||||
pub fn get_main_window() -> Option<WebviewWindow<Wry>> {
|
||||
let app_handle = handle::Handle::app_handle();
|
||||
app_handle.get_webview_window("main")
|
||||
}
|
||||
@@ -147,7 +147,7 @@ impl WindowManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn hide_main_window(window: Option<&WebviewWindow>) -> WindowOperationResult {
|
||||
fn hide_main_window(window: Option<&WebviewWindow<Wry>>) -> WindowOperationResult {
|
||||
logging!(debug, Type::Window, "窗口可见,将隐藏窗口");
|
||||
if let Some(window) = window {
|
||||
match window.close() {
|
||||
@@ -166,7 +166,7 @@ impl WindowManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn activate_existing_main_window(window: Option<&WebviewWindow>) -> WindowOperationResult {
|
||||
fn activate_existing_main_window(window: Option<&WebviewWindow<Wry>>) -> WindowOperationResult {
|
||||
logging!(debug, Type::Window, "窗口存在但被隐藏或最小化,将激活窗口");
|
||||
if let Some(window) = window {
|
||||
Self::activate_window(window)
|
||||
@@ -176,7 +176,7 @@ impl WindowManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn activate_window(window: &WebviewWindow) -> WindowOperationResult {
|
||||
fn activate_window(window: &WebviewWindow<Wry>) -> WindowOperationResult {
|
||||
logging!(debug, Type::Window, "开始激活窗口");
|
||||
#[cfg(target_os = "macos")]
|
||||
Self::set_macos_activation_policy_regular();
|
||||
@@ -233,15 +233,15 @@ impl WindowManager {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_main_window_visible(window: Option<&WebviewWindow>) -> bool {
|
||||
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>) -> bool {
|
||||
pub fn is_main_window_focused(window: Option<&WebviewWindow<Wry>>) -> bool {
|
||||
window.map(|w| w.is_focused().unwrap_or(false)).unwrap_or(false)
|
||||
}
|
||||
|
||||
fn is_main_window_minimized(window: Option<&WebviewWindow>) -> bool {
|
||||
fn is_main_window_minimized(window: Option<&WebviewWindow<Wry>>) -> bool {
|
||||
window.map(|w| w.is_minimized().unwrap_or(false)).unwrap_or(false)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,3 +21,27 @@ These rules supplement the repository's root `AGENTS.md`.
|
||||
- **Command ownership.** `services` owns backend command invocation and transport
|
||||
wrappers. Components and hooks use those boundaries; plugin mutations follow
|
||||
the same intent ownership as backend commands.
|
||||
- **State liveness.** Every `useState`/`useRef` member has a reachable write
|
||||
that differs from its initial value and a read that observes the difference.
|
||||
A value that can only ever hold its initial value is a constant: declare the
|
||||
constant and delete the state. A ref nothing reads is deleted, not kept.
|
||||
- **Surface liveness.** Anything a module exposes — props, parameters, context
|
||||
members, imperative-handle methods — ships with at least one external caller
|
||||
in the same change. "Reserved for future use" fields, `_`-prefixed params
|
||||
that callers still pass, and error or callback channels that every receiver
|
||||
ignores are removed, not maintained.
|
||||
- **Path liveness.** A fallback or branch names the concrete runtime condition
|
||||
that reaches it. If no supported runtime can reach it, the branch is not
|
||||
added — or it replaces the primary path. Constant subexpressions and cleanup
|
||||
of resources that cannot exist yet are simplified away.
|
||||
- **Effect economy.** One user interaction produces one state write and one
|
||||
command. A handler does not re-apply a value its wrapper already applied,
|
||||
and an effect does not re-run a refresh another branch of the same effect
|
||||
just ran.
|
||||
- **Mount economy.** Components that issue commands or subscriptions on mount
|
||||
are mounted only where their UI is reachable. A dialog no rendered control
|
||||
can open stays unmounted, not mounted-and-idle.
|
||||
- **Resource bounds.** Every cache key, timer and subscription a feature
|
||||
allocates is reused or released on refresh; refresh never allocates an
|
||||
unbounded sequence of new keys. Retries re-create per-attempt resources so
|
||||
a retry can actually succeed.
|
||||
|
||||
@@ -22,23 +22,16 @@ interface BaseSplitChipEditorProps {
|
||||
error?: boolean
|
||||
helperText?: ReactNode
|
||||
placeholder?: string
|
||||
rows?: number
|
||||
separator?: string
|
||||
splitPattern?: RegExp
|
||||
defaultMode?: BaseSplitChipEditorMode
|
||||
showModeToggle?: boolean
|
||||
ariaLabel?: string
|
||||
addLabel?: ReactNode
|
||||
emptyLabel?: ReactNode
|
||||
modeLabels?: Partial<Record<BaseSplitChipEditorMode, ReactNode>>
|
||||
renderHeader?: (modeToggle: ReactNode) => ReactNode
|
||||
}
|
||||
|
||||
const DEFAULT_SPLIT_PATTERN = /[,\n;\r]+/
|
||||
|
||||
const splitValue = (value: string, splitPattern: RegExp) =>
|
||||
const splitValue = (value: string) =>
|
||||
value
|
||||
.split(splitPattern)
|
||||
.split(DEFAULT_SPLIT_PATTERN)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -49,35 +42,25 @@ export const BaseSplitChipEditor = ({
|
||||
error = false,
|
||||
helperText,
|
||||
placeholder,
|
||||
rows = 4,
|
||||
separator = ',',
|
||||
splitPattern = DEFAULT_SPLIT_PATTERN,
|
||||
defaultMode = 'visual',
|
||||
showModeToggle = true,
|
||||
ariaLabel,
|
||||
addLabel,
|
||||
emptyLabel,
|
||||
modeLabels,
|
||||
renderHeader,
|
||||
}: BaseSplitChipEditorProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [mode, setMode] = useState<BaseSplitChipEditorMode>(defaultMode)
|
||||
const [mode, setMode] = useState<BaseSplitChipEditorMode>('visual')
|
||||
const [draft, setDraft] = useState('')
|
||||
|
||||
const resolvedLabels = useMemo(
|
||||
() => ({
|
||||
visual: modeLabels?.visual ?? t('shared.editorModes.visualization'),
|
||||
advanced: modeLabels?.advanced ?? t('shared.editorModes.advanced'),
|
||||
add: addLabel ?? t('shared.actions.new'),
|
||||
empty: emptyLabel ?? t('shared.statuses.empty'),
|
||||
visual: t('shared.editorModes.visualization'),
|
||||
advanced: t('shared.editorModes.advanced'),
|
||||
add: t('shared.actions.new'),
|
||||
empty: t('shared.statuses.empty'),
|
||||
}),
|
||||
[t, modeLabels, addLabel, emptyLabel],
|
||||
[t],
|
||||
)
|
||||
|
||||
const values = useMemo(
|
||||
() => splitValue(value, splitPattern),
|
||||
[value, splitPattern],
|
||||
)
|
||||
const values = useMemo(() => splitValue(value), [value])
|
||||
|
||||
const items = useMemo(() => {
|
||||
const counts = new Map<string, number>()
|
||||
@@ -92,7 +75,7 @@ export const BaseSplitChipEditor = ({
|
||||
}, [values])
|
||||
|
||||
const handleAddDraft = () => {
|
||||
const nextValues = splitValue(draft, splitPattern)
|
||||
const nextValues = splitValue(draft)
|
||||
if (!nextValues.length) {
|
||||
return
|
||||
}
|
||||
@@ -113,7 +96,7 @@ export const BaseSplitChipEditor = ({
|
||||
const resolvedAriaLabel =
|
||||
ariaLabel ?? (typeof toggleLabel === 'string' ? toggleLabel : undefined)
|
||||
|
||||
const modeToggle = showModeToggle ? (
|
||||
const modeToggle = (
|
||||
<Tooltip title={toggleLabel}>
|
||||
<IconButton
|
||||
size="small"
|
||||
@@ -128,7 +111,7 @@ export const BaseSplitChipEditor = ({
|
||||
<ToggleIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
) : null
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -202,7 +185,7 @@ export const BaseSplitChipEditor = ({
|
||||
disabled={disabled}
|
||||
size="small"
|
||||
multiline
|
||||
rows={rows}
|
||||
rows={4}
|
||||
sx={{ width: '100%' }}
|
||||
value={value}
|
||||
helperText={helperText}
|
||||
|
||||
@@ -47,10 +47,7 @@ import {
|
||||
useCoreDataStatus,
|
||||
useProxiesData,
|
||||
} from '@/providers/app-data-context'
|
||||
import { syncTrayProxySelection } from '@/services/cmds'
|
||||
import delayManager from '@/services/delay'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { profileTestUrls } from '@/services/proxy-test-url'
|
||||
import {
|
||||
findCurrentGroupMember,
|
||||
getRecord,
|
||||
@@ -599,7 +596,7 @@ export const CurrentProxyCard = () => {
|
||||
[getProfileStorageKey],
|
||||
)
|
||||
|
||||
const { handleSelectChange } = useProxySelection({
|
||||
const { select } = useProxySelection({
|
||||
onSuccess: () => {
|
||||
refreshProxy()
|
||||
},
|
||||
@@ -678,20 +675,6 @@ export const CurrentProxyCard = () => {
|
||||
[proxyView],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedGroup) return
|
||||
const customUrl = currentProfile
|
||||
? profileTestUrls(currentProfile)[selectedGroup.name]
|
||||
: undefined
|
||||
delayManager.setUrl(
|
||||
selectedGroup.name,
|
||||
customUrl?.trim() ||
|
||||
selectedGroup.testUrl?.trim() ||
|
||||
verge?.default_latency_test?.trim() ||
|
||||
'http://cp.cloudflare.com/generate_204',
|
||||
)
|
||||
}, [selectedGroup, currentProfile, verge?.default_latency_test])
|
||||
|
||||
const unsortedProxyOptions = useMemo(
|
||||
() => optionsForGroup(selectedGroup),
|
||||
[optionsForGroup, selectedGroup],
|
||||
@@ -783,15 +766,9 @@ export const CurrentProxyCard = () => {
|
||||
const previousProxy = selectedGroup.now
|
||||
const nextName = option.member.ref.name
|
||||
// The profile selection is the durable source across core restarts and run modes.
|
||||
handleSelectChange(
|
||||
selectedGroup.name,
|
||||
previousProxy!,
|
||||
selectedGroup.fixed!,
|
||||
)({
|
||||
target: { value: nextName },
|
||||
})
|
||||
select(selectedGroup.name, nextName, previousProxy!, selectedGroup.fixed!)
|
||||
},
|
||||
[handleSelectChange, isDirectMode, selectedGroup, unsortedProxyOptions],
|
||||
[isDirectMode, select, selectedGroup, unsortedProxyOptions],
|
||||
)
|
||||
|
||||
const goToProxies = useCallback(() => {
|
||||
@@ -924,12 +901,6 @@ export const CurrentProxyCard = () => {
|
||||
}
|
||||
|
||||
refreshProxy()
|
||||
mutate(() => syncTrayProxySelection(), {
|
||||
id: 'sync-tray-selection',
|
||||
errorNotice: false,
|
||||
}).catch((error) => {
|
||||
console.error('[CurrentProxyCard] Failed to sync tray latency:', error)
|
||||
})
|
||||
})
|
||||
|
||||
const proxyOptions = useMemo(
|
||||
|
||||
@@ -14,7 +14,6 @@ import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useTrafficGraphDataEnhanced } from '@/hooks/use-traffic-monitor'
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
import { debugLog } from '@/utils/debug'
|
||||
import parseTraffic from '@/utils/parse-traffic'
|
||||
import {
|
||||
formatTrafficHourMinute,
|
||||
@@ -23,14 +22,7 @@ import {
|
||||
isSameTrafficData,
|
||||
} from '@/utils/traffic-sampler'
|
||||
|
||||
interface ITrafficItem {
|
||||
up: number
|
||||
down: number
|
||||
timestamp?: number
|
||||
}
|
||||
|
||||
export interface EnhancedCanvasTrafficGraphRef {
|
||||
appendData: (data: ITrafficItem) => void
|
||||
toggleStyle: () => void
|
||||
}
|
||||
|
||||
@@ -142,7 +134,6 @@ export const EnhancedCanvasTrafficGraph = memo(
|
||||
[],
|
||||
)
|
||||
const debounceTimeoutRef = useRef<number | null>(null)
|
||||
const [currentFPS, setCurrentFPS] = useState(GRAPH_CONFIG.targetFPS)
|
||||
|
||||
const colors = useMemo(
|
||||
() => ({
|
||||
@@ -183,8 +174,6 @@ export const EnhancedCanvasTrafficGraph = memo(
|
||||
if (displayData.length === 0) {
|
||||
lastDataTimestampRef.current = 0
|
||||
dataStaleRef.current = false
|
||||
// eslint-disable-next-line @eslint-react/set-state-in-effect
|
||||
setCurrentFPS(GRAPH_CONFIG.targetFPS)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -200,18 +189,11 @@ export const EnhancedCanvasTrafficGraph = memo(
|
||||
}
|
||||
}, [displayData])
|
||||
|
||||
const handleFocusStateChange = useCallback(
|
||||
(focused: boolean) => {
|
||||
isWindowFocusedRef.current = focused
|
||||
const handleFocusStateChange = useCallback((focused: boolean) => {
|
||||
isWindowFocusedRef.current = focused
|
||||
|
||||
if (focused || !pause_render_traffic_stats_on_blur) {
|
||||
setCurrentFPS(GRAPH_CONFIG.targetFPS)
|
||||
}
|
||||
|
||||
scheduleDrawGraphRef.current()
|
||||
},
|
||||
[pause_render_traffic_stats_on_blur],
|
||||
)
|
||||
scheduleDrawGraphRef.current()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
@@ -684,19 +666,13 @@ export const EnhancedCanvasTrafficGraph = memo(
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(getX(0), getY(0))
|
||||
|
||||
if (chartStyle === 'bezier') {
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
const currentX = getX(i)
|
||||
const currentY = getY(i)
|
||||
const nextIndex = Math.min(i + 1, lastIndex)
|
||||
const controlX = (currentX + getX(nextIndex)) / 2
|
||||
const controlY = (currentY + getY(nextIndex)) / 2
|
||||
ctx.quadraticCurveTo(currentX, currentY, controlX, controlY)
|
||||
}
|
||||
} else {
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
ctx.lineTo(getX(i), getY(i))
|
||||
}
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
const currentX = getX(i)
|
||||
const currentY = getY(i)
|
||||
const nextIndex = Math.min(i + 1, lastIndex)
|
||||
const controlX = (currentX + getX(nextIndex)) / 2
|
||||
const controlY = (currentY + getY(nextIndex)) / 2
|
||||
ctx.quadraticCurveTo(currentX, currentY, controlX, controlY)
|
||||
}
|
||||
|
||||
ctx.lineTo(getX(lastIndex), height - padding.bottom)
|
||||
@@ -984,21 +960,7 @@ export const EnhancedCanvasTrafficGraph = memo(
|
||||
setChartStyle((prev) => (prev === 'bezier' ? 'line' : 'bezier'))
|
||||
}, [])
|
||||
|
||||
const appendData = useCallback((data: ITrafficItem) => {
|
||||
debugLog(
|
||||
'[EnhancedCanvasTrafficGraphV2] appendData called (using global data):',
|
||||
data,
|
||||
)
|
||||
}, [])
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
appendData,
|
||||
toggleStyle,
|
||||
}),
|
||||
[appendData, toggleStyle],
|
||||
)
|
||||
useImperativeHandle(ref, () => ({ toggleStyle }), [toggleStyle])
|
||||
|
||||
const getTimeRangeText = useCallback(() => {
|
||||
return t('home.components.traffic.patterns.minutes', {
|
||||
@@ -1142,7 +1104,7 @@ export const EnhancedCanvasTrafficGraph = memo(
|
||||
{t('home.components.traffic.diagnostics', {
|
||||
points: displayData.length,
|
||||
compressed: samplerStats.compressedBufferSize,
|
||||
fps: currentFPS,
|
||||
fps: GRAPH_CONFIG.targetFPS,
|
||||
})}
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -3,10 +3,14 @@ import {
|
||||
ArrowUpwardRounded,
|
||||
CloudDownloadRounded,
|
||||
CloudUploadRounded,
|
||||
ErrorOutlineRounded,
|
||||
LinkRounded,
|
||||
MemoryRounded,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Grid,
|
||||
PaletteColor,
|
||||
Paper,
|
||||
@@ -15,9 +19,9 @@ import {
|
||||
useTheme,
|
||||
} from '@mui/material'
|
||||
import { ReactNode, memo, useMemo, useRef } from 'react'
|
||||
import { ErrorBoundary, FallbackProps } from 'react-error-boundary'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { TrafficErrorBoundary } from '@/components/shared/traffic-error-boundary'
|
||||
import { useConnectionSummaryData } from '@/hooks/use-connection-data'
|
||||
import { useMemoryData } from '@/hooks/use-memory-data'
|
||||
import { useTrafficData } from '@/hooks/use-traffic-data'
|
||||
@@ -127,6 +131,50 @@ const CompactStatCard = memo(
|
||||
// 添加显示名称
|
||||
CompactStatCard.displayName = 'CompactStatCard'
|
||||
|
||||
const fallbackSx = {
|
||||
p: 2,
|
||||
minHeight: 200,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '1px dashed',
|
||||
borderColor: 'error.main',
|
||||
borderRadius: 2,
|
||||
bgcolor: 'error.light',
|
||||
}
|
||||
|
||||
const reloadPage = () => window.location.reload()
|
||||
|
||||
const TrafficFallback = ({ error, resetErrorBoundary }: FallbackProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Box sx={fallbackSx}>
|
||||
<ErrorOutlineRounded color="error" sx={{ fontSize: 48, mb: 2 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('shared.feedback.errors.trafficStats')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{t('shared.feedback.errors.trafficStatsDescription')}
|
||||
</Typography>
|
||||
<Alert severity="error" sx={{ mb: 2, maxWidth: 400 }}>
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: t('shared.feedback.errors.unknown')}
|
||||
</Alert>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button variant="contained" size="small" onClick={resetErrorBoundary}>
|
||||
{t('shared.actions.retry')}
|
||||
</Button>
|
||||
<Button variant="outlined" size="small" onClick={reloadPage}>
|
||||
{t('shared.actions.refreshPage')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const EnhancedTrafficStats = () => {
|
||||
const { t } = useTranslation()
|
||||
const theme = useTheme()
|
||||
@@ -254,10 +302,11 @@ export const EnhancedTrafficStats = () => {
|
||||
}, [t, parsedData, displayMemory])
|
||||
|
||||
return (
|
||||
<TrafficErrorBoundary
|
||||
onError={(error, errorInfo) => {
|
||||
console.error('[EnhancedTrafficStats] 组件错误:', error, errorInfo)
|
||||
}}
|
||||
<ErrorBoundary
|
||||
FallbackComponent={TrafficFallback}
|
||||
onError={(error, errorInfo) =>
|
||||
console.error('[EnhancedTrafficStats]', error, errorInfo)
|
||||
}
|
||||
>
|
||||
<Grid container spacing={1} columns={{ xs: 8, sm: 8, md: 12 }}>
|
||||
{trafficGraph && (
|
||||
@@ -273,6 +322,6 @@ export const EnhancedTrafficStats = () => {
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</TrafficErrorBoundary>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,10 +4,19 @@ import {
|
||||
VisibilityOffOutlined,
|
||||
VisibilityOutlined,
|
||||
} from '@mui/icons-material'
|
||||
import { Box, Button, IconButton, Skeleton, Typography } from '@mui/material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
FormControlLabel,
|
||||
IconButton,
|
||||
Skeleton,
|
||||
Switch,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
|
||||
import { useEffect } from 'foxact/use-abortable-effect'
|
||||
import { useIntersection } from 'foxact/use-intersection'
|
||||
import { useLocalStorage } from 'foxact/use-local-storage'
|
||||
import type { XOR } from 'foxts/ts-xor'
|
||||
import {
|
||||
forwardRef,
|
||||
@@ -99,9 +108,13 @@ const IPInfoCardContainer = forwardRef<HTMLElement, React.PropsWithChildren>(
|
||||
export const IpInfoCard = () => {
|
||||
const { t } = useTranslation()
|
||||
const [showIp, setShowIp] = useState(false)
|
||||
const [autoRefresh, setAutoRefresh] = useLocalStorage(
|
||||
'ip-info-auto-refresh',
|
||||
true,
|
||||
)
|
||||
const appWindow = useMemo(() => getCurrentWebviewWindow(), [])
|
||||
|
||||
// Once intersected, refreshes stay enabled until unmount.
|
||||
// Once intersected, the card stays eligible for refresh until unmount.
|
||||
const [containerRef, hasIntersected, _resetIntersected] = useIntersection({
|
||||
rootMargin: '0px',
|
||||
})
|
||||
@@ -151,6 +164,8 @@ export const IpInfoCard = () => {
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return
|
||||
|
||||
let timer: number | null = null
|
||||
|
||||
// Prefer DOM intersection state; Tauri visibility is unreliable on some platforms.
|
||||
@@ -190,7 +205,7 @@ export const IpInfoCard = () => {
|
||||
if (timer != null) clearInterval(timer)
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
}
|
||||
}, [hasIntersected])
|
||||
}, [autoRefresh, hasIntersected])
|
||||
|
||||
const toggleShowIp = useCallback(() => {
|
||||
setShowIp((prev) => !prev)
|
||||
@@ -364,12 +379,25 @@ export const IpInfoCard = () => {
|
||||
fontSize: '0.7rem',
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption">
|
||||
{t('home.components.ipInfo.labels.autoRefresh')}
|
||||
{countdown.type === 'countdown'
|
||||
? `: ${countdown.remainingSeconds}s`
|
||||
: '...'}
|
||||
</Typography>
|
||||
<FormControlLabel
|
||||
sx={{ m: 0 }}
|
||||
control={
|
||||
<Switch
|
||||
size="small"
|
||||
checked={autoRefresh}
|
||||
onChange={(_, checked) => setAutoRefresh(checked)}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography variant="caption">
|
||||
{t('home.components.ipInfo.labels.autoRefresh')}
|
||||
{autoRefresh &&
|
||||
(countdown.type === 'countdown'
|
||||
? `: ${countdown.remainingSeconds}s`
|
||||
: '...')}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
|
||||
@@ -86,22 +86,17 @@ export const SystemInfoCard = () => {
|
||||
}, [verge, patchVerge])
|
||||
|
||||
const handleRunningModeClick = useCallback(async () => {
|
||||
if (isSidecarMode || (isAdminMode && isSidecarMode)) {
|
||||
if (isSidecarMode) {
|
||||
await installServiceAndRestartCore()
|
||||
await mutateSystemState()
|
||||
}
|
||||
}, [
|
||||
isSidecarMode,
|
||||
isAdminMode,
|
||||
installServiceAndRestartCore,
|
||||
mutateSystemState,
|
||||
])
|
||||
}, [isSidecarMode, installServiceAndRestartCore, mutateSystemState])
|
||||
|
||||
const onCheckUpdate = useLockFn(async () => {
|
||||
try {
|
||||
const result = await triggerCheckUpdate()
|
||||
const info = result.data
|
||||
if (!info) {
|
||||
if (!info?.available) {
|
||||
showNotice.success(
|
||||
'settings.components.verge.advanced.notifications.latestVersion',
|
||||
)
|
||||
@@ -122,18 +117,16 @@ export const SystemInfoCard = () => {
|
||||
|
||||
const runningModeStyle = useMemo(
|
||||
() => ({
|
||||
cursor:
|
||||
isSidecarMode || (isAdminMode && isSidecarMode) ? 'pointer' : 'default',
|
||||
textDecoration:
|
||||
isSidecarMode || (isAdminMode && isSidecarMode) ? 'underline' : 'none',
|
||||
cursor: isSidecarMode ? 'pointer' : 'default',
|
||||
textDecoration: isSidecarMode ? 'underline' : 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
'&:hover': {
|
||||
opacity: isSidecarMode || (isAdminMode && isSidecarMode) ? 0.7 : 1,
|
||||
opacity: isSidecarMode ? 0.7 : 1,
|
||||
},
|
||||
}),
|
||||
[isSidecarMode, isAdminMode],
|
||||
[isSidecarMode],
|
||||
)
|
||||
|
||||
const getModeIcon = () => {
|
||||
|
||||
@@ -87,7 +87,7 @@ export const TestCard = () => {
|
||||
x.uid === uid ? { ...x, ...patch } : x,
|
||||
)
|
||||
|
||||
mutateVerge({ ...verge, test_list: newList }, false)
|
||||
mutateVerge({ ...verge, test_list: newList })
|
||||
},
|
||||
[testList, verge, mutateVerge],
|
||||
)
|
||||
@@ -96,7 +96,7 @@ export const TestCard = () => {
|
||||
(uid: string) => {
|
||||
const newList = testList.filter((x) => x.uid !== uid)
|
||||
patchVerge({ test_list: newList })
|
||||
mutateVerge({ ...verge, test_list: newList }, false)
|
||||
mutateVerge({ ...verge, test_list: newList })
|
||||
},
|
||||
[testList, verge, patchVerge, mutateVerge],
|
||||
)
|
||||
@@ -129,7 +129,7 @@ export const TestCard = () => {
|
||||
newList.splice(newIndex, 0, removed)
|
||||
|
||||
// 优化:先本地更新,再异步 patch,避免UI卡死
|
||||
mutateVerge({ ...verge, test_list: newList }, false)
|
||||
mutateVerge({ ...verge, test_list: newList })
|
||||
const patchFn = () => {
|
||||
try {
|
||||
patchVerge({ test_list: newList })
|
||||
|
||||
@@ -35,10 +35,7 @@ export const LayoutSidebar = (props: LayoutSidebarProps) => {
|
||||
|
||||
const handleMenuOrderOptimisticUpdate = useCallback(
|
||||
(order: string[]) => {
|
||||
mutateVerge(
|
||||
(prev) => (prev ? { ...prev, menu_order: order } : prev),
|
||||
false,
|
||||
)
|
||||
mutateVerge((prev) => (prev ? { ...prev, menu_order: order } : prev))
|
||||
},
|
||||
[mutateVerge],
|
||||
)
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import {
|
||||
ArrowDownwardRounded,
|
||||
ArrowUpwardRounded,
|
||||
ErrorOutlineRounded,
|
||||
MemoryRounded,
|
||||
} from '@mui/icons-material'
|
||||
import type { BoxProps, SvgIconProps, TypographyProps } from '@mui/material'
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { ErrorBoundary } from 'react-error-boundary'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { LightweightTrafficErrorBoundary } from '@/components/shared/traffic-error-boundary'
|
||||
import { useMemoryData } from '@/hooks/use-memory-data'
|
||||
import { useTrafficData } from '@/hooks/use-traffic-data'
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
@@ -17,6 +18,30 @@ import parseTraffic from '@/utils/parse-traffic'
|
||||
|
||||
import { TrafficGraph, type TrafficRef } from './traffic-graph'
|
||||
|
||||
const unavailableSx = {
|
||||
p: 1,
|
||||
minHeight: 60,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'error.light',
|
||||
borderRadius: 1,
|
||||
color: 'error.contrastText',
|
||||
}
|
||||
|
||||
const TrafficUnavailableFallback = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Box sx={unavailableSx}>
|
||||
<ErrorOutlineRounded sx={{ mr: 1, fontSize: 20 }} />
|
||||
<Typography variant="caption">
|
||||
{t('shared.feedback.errors.trafficUnavailable')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const LayoutTraffic = () => {
|
||||
const { t } = useTranslation()
|
||||
const { verge } = useVerge()
|
||||
@@ -75,7 +100,12 @@ export const LayoutTraffic = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<LightweightTrafficErrorBoundary>
|
||||
<ErrorBoundary
|
||||
FallbackComponent={TrafficUnavailableFallback}
|
||||
onError={(error, errorInfo) =>
|
||||
console.error('[LayoutTraffic]', error, errorInfo)
|
||||
}
|
||||
>
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
{trafficGraph && pageVisible && (
|
||||
<div
|
||||
@@ -130,7 +160,6 @@ export const LayoutTraffic = () => {
|
||||
cursor: 'auto',
|
||||
}}
|
||||
color={'disabled'}
|
||||
onClick={async () => {}}
|
||||
>
|
||||
<MemoryRounded {...iconStyle} />
|
||||
<Typography {...valStyle}>{inuse}</Typography>
|
||||
@@ -139,6 +168,6 @@ export const LayoutTraffic = () => {
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</LightweightTrafficErrorBoundary>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export const UpdateButton = (props: Props) => {
|
||||
|
||||
const { updateInfo } = useUpdate()
|
||||
|
||||
if (!updateInfo) return null
|
||||
if (!updateInfo?.available) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { Close, CropSquare, FilterNone, Minimize } from '@mui/icons-material'
|
||||
import { Box, IconButton } from '@mui/material'
|
||||
import {
|
||||
forwardRef,
|
||||
type PointerEvent,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
} from 'react'
|
||||
import { type PointerEvent, useCallback } from 'react'
|
||||
|
||||
import { useWindowControls } from '@/hooks/use-window'
|
||||
import getSystem from '@/utils/get-system'
|
||||
@@ -63,36 +58,9 @@ export const WindowResizeHandles = () => {
|
||||
)
|
||||
}
|
||||
|
||||
export const WindowControls = forwardRef(function WindowControls(props, ref) {
|
||||
export const WindowControls = () => {
|
||||
const OS = getSystem()
|
||||
const {
|
||||
currentWindow,
|
||||
maximized,
|
||||
minimize,
|
||||
close,
|
||||
toggleFullscreen,
|
||||
toggleMaximize,
|
||||
} = useWindowControls()
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
currentWindow,
|
||||
maximized,
|
||||
minimize,
|
||||
close,
|
||||
toggleFullscreen,
|
||||
toggleMaximize,
|
||||
}),
|
||||
[
|
||||
currentWindow,
|
||||
maximized,
|
||||
minimize,
|
||||
close,
|
||||
toggleFullscreen,
|
||||
toggleMaximize,
|
||||
],
|
||||
)
|
||||
const { maximized, minimize, close, toggleMaximize } = useWindowControls()
|
||||
|
||||
// 通过前端对 tauri 窗口进行翻转全屏时会短暂地与系统图标重叠渲染。
|
||||
// 这可能是上游缺陷,保险起见跨平台以窗口的最大化翻转为准。
|
||||
@@ -186,4 +154,4 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) {
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -397,9 +397,9 @@ export const RulesEditorViewer = (props: Props) => {
|
||||
return
|
||||
}
|
||||
|
||||
setPrependSeq(obj?.prepend || [])
|
||||
setAppendSeq(obj?.append || [])
|
||||
setDeleteSeq(obj?.delete || [])
|
||||
setPrependSeq((obj?.prepend || []).filter((rule) => rule !== null))
|
||||
setAppendSeq((obj?.append || []).filter((rule) => rule !== null))
|
||||
setDeleteSeq((obj?.delete || []).filter((rule) => rule !== null))
|
||||
hasLoadedSeqConfigRef.current = true
|
||||
}, [property])
|
||||
|
||||
@@ -417,9 +417,9 @@ export const RulesEditorViewer = (props: Props) => {
|
||||
|
||||
hasLoadedSeqConfigRef.current = true
|
||||
startTransition(() => {
|
||||
setPrependSeq(obj?.prepend ?? [])
|
||||
setAppendSeq(obj?.append ?? [])
|
||||
setDeleteSeq(obj?.delete ?? [])
|
||||
setPrependSeq((obj?.prepend ?? []).filter((rule) => rule !== null))
|
||||
setAppendSeq((obj?.append ?? []).filter((rule) => rule !== null))
|
||||
setDeleteSeq((obj?.delete ?? []).filter((rule) => rule !== null))
|
||||
})
|
||||
setVisualization(true)
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ interface ProxyChainProps {
|
||||
onMarkUnsavedChanges?: () => void
|
||||
mode?: string
|
||||
selectedGroup?: string | null
|
||||
onExitChainMode: () => void
|
||||
}
|
||||
|
||||
interface ProxyChainItemProps {
|
||||
@@ -331,6 +332,7 @@ export const ProxyChain = ({
|
||||
onMarkUnsavedChanges,
|
||||
mode,
|
||||
selectedGroup,
|
||||
onExitChainMode,
|
||||
}: ProxyChainProps) => {
|
||||
const theme = useTheme()
|
||||
const { t } = useTranslation()
|
||||
@@ -696,13 +698,18 @@ export const ProxyChain = ({
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
height: '100%',
|
||||
color: theme.palette.text.secondary,
|
||||
}}
|
||||
>
|
||||
<Typography>{t('proxies.page.chain.empty')}</Typography>
|
||||
<Button size="small" variant="outlined" onClick={onExitChainMode}>
|
||||
{t('shared.actions.close')}
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<DragDropProvider
|
||||
|
||||
@@ -9,7 +9,7 @@ import VisibilityOffRounded from '@mui/icons-material/VisibilityOffRounded'
|
||||
import VisibilityRounded from '@mui/icons-material/VisibilityRounded'
|
||||
import WifiTetheringOffRounded from '@mui/icons-material/WifiTetheringOffRounded'
|
||||
import WifiTetheringRounded from '@mui/icons-material/WifiTetheringRounded'
|
||||
import { Box, IconButton, type SxProps } from '@mui/material'
|
||||
import { Box, IconButton, type SxProps, TextField } from '@mui/material'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import { memo, useEffect, useRef } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
@@ -18,11 +18,10 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
import delayManager from '@/services/delay'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { isValidLatencyTestUrl } from '@/utils/network'
|
||||
import { isValidUrl } from '@/utils/network'
|
||||
|
||||
import { BaseSearchBox, type SearchState } from '../base'
|
||||
|
||||
import { ProxyTestUrlInput } from './proxy-test-url-input'
|
||||
import type { ProxySortType } from './use-filter-sort'
|
||||
import type { HeadState } from './use-head-state'
|
||||
|
||||
@@ -70,10 +69,7 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
delayManager.setUrl(
|
||||
groupName,
|
||||
testUrl?.trim() || url?.trim() || defaultLatencyUrl,
|
||||
)
|
||||
delayManager.setUrl(groupName, testUrl?.trim() || url || defaultLatencyUrl)
|
||||
}, [groupName, testUrl, defaultLatencyUrl, url])
|
||||
|
||||
// 过滤输入是高频操作,且每次都会触发整组代理的重新过滤/排序与虚拟列表重渲染,
|
||||
@@ -116,8 +112,7 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
|
||||
)}
|
||||
|
||||
{textState === 'url' && (
|
||||
<ProxyTestUrlInput
|
||||
name={groupName}
|
||||
<TextField
|
||||
inputRef={inputRef}
|
||||
autoComplete="new-password"
|
||||
hiddenLabel
|
||||
@@ -130,7 +125,7 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onCommit={(testUrl) => onHeadState({ testUrl })}
|
||||
onChange={(e) => onHeadState({ testUrl: e.target.value })}
|
||||
sx={{ flex: '1 1 auto', input: { py: 0.65, px: 1 } }}
|
||||
/>
|
||||
)}
|
||||
@@ -181,7 +176,7 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
|
||||
if (testUrl?.trim() && textState !== 'filter') {
|
||||
onHeadState({ textState: 'url' })
|
||||
}
|
||||
if (testUrl?.trim() && !isValidLatencyTestUrl(testUrl)) {
|
||||
if (testUrl?.trim() && !isValidUrl(testUrl)) {
|
||||
showNotice.warning('proxies.feedback.warnings.invalidTestUrl')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ interface GroupSelectMenuProps {
|
||||
interface ProxyGroupsChainProps {
|
||||
mode: string
|
||||
chainConfigData?: string | null
|
||||
onExitChainMode: () => void
|
||||
availableGroups: any[]
|
||||
activeSelectedGroup: string | null
|
||||
showScrollTop: boolean
|
||||
@@ -305,6 +306,7 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) {
|
||||
const {
|
||||
mode,
|
||||
chainConfigData,
|
||||
onExitChainMode,
|
||||
availableGroups,
|
||||
activeSelectedGroup,
|
||||
showScrollTop,
|
||||
@@ -521,6 +523,7 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) {
|
||||
chainConfigData={chainConfigData}
|
||||
mode={mode}
|
||||
selectedGroup={activeSelectedGroup}
|
||||
onExitChainMode={onExitChainMode}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -23,9 +23,7 @@ import { useProfiles } from '@/hooks/use-profiles'
|
||||
import { useProxySelection } from '@/hooks/use-proxy-selection'
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
import { useProxiesData, useSystemData } from '@/providers/app-data-context'
|
||||
import { syncTrayProxySelection } from '@/services/cmds'
|
||||
import delayManager from '@/services/delay'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import {
|
||||
isInteractableMember,
|
||||
resolveMember,
|
||||
@@ -44,6 +42,7 @@ import {
|
||||
ProxyGroupNavigator,
|
||||
} from './proxy-group-navigator'
|
||||
import { ProxyRender } from './proxy-render'
|
||||
import type { HeadState } from './use-head-state'
|
||||
import {
|
||||
PROXY_GROUP_HEADER_SENSORS,
|
||||
useProxyGroupHeaderLayout,
|
||||
@@ -70,6 +69,7 @@ interface Props {
|
||||
mode: string
|
||||
isChainMode?: boolean
|
||||
chainConfigData?: string | null
|
||||
onExitChainMode: () => void
|
||||
}
|
||||
|
||||
function useEmptyRenderList() {
|
||||
@@ -90,11 +90,11 @@ function useProxyRenderState(
|
||||
) {
|
||||
const { verge } = useVerge()
|
||||
const { proxyView } = useProxiesData()
|
||||
const { renderList, onProxies, onHeadState } = useRenderList(
|
||||
mode,
|
||||
isChainMode,
|
||||
activeSelectedGroup,
|
||||
)
|
||||
const {
|
||||
renderList,
|
||||
onProxies,
|
||||
onHeadState: setListHeadState,
|
||||
} = useRenderList(mode, isChainMode, activeSelectedGroup)
|
||||
const scrollPositionKey = useMemo(
|
||||
() =>
|
||||
isChainMode
|
||||
@@ -103,6 +103,14 @@ function useProxyRenderState(
|
||||
[activeSelectedGroup, isChainMode, mode],
|
||||
)
|
||||
|
||||
const onHeadState = useCallback(
|
||||
(groupName: string, patch: Partial<HeadState>) => {
|
||||
if (patch.open) onProxies()
|
||||
setListHeadState(groupName, patch)
|
||||
},
|
||||
[onProxies, setListHeadState],
|
||||
)
|
||||
|
||||
const timeout = verge?.default_latency_timeout || 10000
|
||||
|
||||
const handleCheckAll = useStableCallback(
|
||||
@@ -135,12 +143,6 @@ function useProxyRenderState(
|
||||
console.error(`[ProxyGroups] 延迟测试出错,组: ${groupName}`, error)
|
||||
} finally {
|
||||
onProxies()
|
||||
mutate(() => syncTrayProxySelection(), {
|
||||
id: 'sync-tray-selection',
|
||||
errorNotice: false,
|
||||
}).catch((error) => {
|
||||
console.error('[ProxyGroups] Failed to sync tray latency:', error)
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -190,8 +192,9 @@ function useProxyRenderState(
|
||||
function ChainProxyGroups(props: {
|
||||
mode: string
|
||||
chainConfigData?: string | null
|
||||
onExitChainMode: () => void
|
||||
}) {
|
||||
const { mode, chainConfigData } = props
|
||||
const { mode, chainConfigData, onExitChainMode } = props
|
||||
const { proxyView } = useProxiesData()
|
||||
const [selectedGroup, setSelectedGroup] = useState<string | null>(null)
|
||||
|
||||
@@ -346,6 +349,7 @@ function ChainProxyGroups(props: {
|
||||
<ProxyGroupsChain
|
||||
mode={mode}
|
||||
chainConfigData={chainConfigData}
|
||||
onExitChainMode={onExitChainMode}
|
||||
availableGroups={availableGroups}
|
||||
activeSelectedGroup={activeSelectedGroup}
|
||||
showScrollTop={showScrollTop}
|
||||
@@ -458,7 +462,7 @@ function NormalProxyGroups(props: { mode: string }) {
|
||||
}
|
||||
}, [handleScroll])
|
||||
|
||||
const { handleProxyGroupChange } = useProxySelection({
|
||||
const { select } = useProxySelection({
|
||||
onSuccess: () => {
|
||||
onProxies()
|
||||
},
|
||||
@@ -473,9 +477,9 @@ function NormalProxyGroups(props: { mode: string }) {
|
||||
if (!['Selector', 'URLTest', 'Fallback'].includes(group.type)) return
|
||||
if (!isInteractableMember(member)) return
|
||||
|
||||
handleProxyGroupChange(group, { name: member.ref.name })
|
||||
select(group.name, member.ref.name, group.now!, group.fixed!)
|
||||
},
|
||||
[handleProxyGroupChange],
|
||||
[select],
|
||||
)
|
||||
|
||||
const handleLocation = useStableCallback((group: ProxyGroupView) => {
|
||||
@@ -604,7 +608,7 @@ function NormalProxyGroups(props: { mode: string }) {
|
||||
/>
|
||||
</DragDropProvider>
|
||||
|
||||
{mode === 'rule' && (
|
||||
{(mode === 'rule' || mode === 'global') && (
|
||||
<ProxyGroupNavigator
|
||||
proxyGroupNames={proxyGroupNames}
|
||||
onGroupLocation={handleGroupLocationByName}
|
||||
@@ -617,7 +621,7 @@ function NormalProxyGroups(props: { mode: string }) {
|
||||
}
|
||||
|
||||
export const ProxyGroups = (props: Props) => {
|
||||
const { mode, isChainMode = false, chainConfigData } = props
|
||||
const { mode, isChainMode = false, chainConfigData, onExitChainMode } = props
|
||||
const { profiles, isLoading: isProfilesLoading } = useProfiles()
|
||||
const { isProxyViewPending } = useProxiesData()
|
||||
const { isRunningModePending } = useSystemData()
|
||||
@@ -639,7 +643,11 @@ export const ProxyGroups = (props: Props) => {
|
||||
return <ProxyEmptyState reason={listState.reason} />
|
||||
case 'render':
|
||||
return isChainMode ? (
|
||||
<ChainProxyGroups mode={mode} chainConfigData={chainConfigData} />
|
||||
<ChainProxyGroups
|
||||
mode={mode}
|
||||
chainConfigData={chainConfigData}
|
||||
onExitChainMode={onExitChainMode}
|
||||
/>
|
||||
) : (
|
||||
<NormalProxyGroups mode={mode} />
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
SortByAlphaRounded,
|
||||
SortRounded,
|
||||
} from '@mui/icons-material'
|
||||
import { Box, IconButton, type SxProps } from '@mui/material'
|
||||
import { Box, IconButton, TextField, type SxProps } from '@mui/material'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -20,9 +20,8 @@ import { useVerge } from '@/hooks/use-verge'
|
||||
import delayManager from '@/services/delay'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { debugLog } from '@/utils/debug'
|
||||
import { isValidLatencyTestUrl } from '@/utils/network'
|
||||
import { isValidUrl } from '@/utils/network'
|
||||
|
||||
import { ProxyTestUrlInput } from './proxy-test-url-input'
|
||||
import type { ProxySortType } from './use-filter-sort'
|
||||
import type { HeadState } from './use-head-state'
|
||||
|
||||
@@ -73,10 +72,7 @@ export const ProxyHead = ({
|
||||
'http://cp.cloudflare.com/generate_204'
|
||||
|
||||
useEffect(() => {
|
||||
delayManager.setUrl(
|
||||
groupName,
|
||||
testUrl?.trim() || url?.trim() || defaultLatencyUrl,
|
||||
)
|
||||
delayManager.setUrl(groupName, testUrl?.trim() || url || defaultLatencyUrl)
|
||||
}, [groupName, testUrl, defaultLatencyUrl, url])
|
||||
|
||||
return (
|
||||
@@ -101,7 +97,7 @@ export const ProxyHead = ({
|
||||
debugLog(`[ProxyHead] 使用自定义测试URL: ${testUrl}`)
|
||||
onHeadState({ textState: 'url' })
|
||||
}
|
||||
if (testUrl?.trim() && !isValidLatencyTestUrl(testUrl)) {
|
||||
if (testUrl?.trim() && !isValidUrl(testUrl)) {
|
||||
showNotice.warning('proxies.feedback.warnings.invalidTestUrl')
|
||||
return
|
||||
}
|
||||
@@ -192,8 +188,7 @@ export const ProxyHead = ({
|
||||
)}
|
||||
|
||||
{textState === 'url' && (
|
||||
<ProxyTestUrlInput
|
||||
name={groupName}
|
||||
<TextField
|
||||
autoComplete="new-password"
|
||||
autoFocus={autoFocus}
|
||||
hiddenLabel
|
||||
@@ -202,7 +197,7 @@ export const ProxyHead = ({
|
||||
size="small"
|
||||
variant="outlined"
|
||||
placeholder={t('proxies.page.placeholders.delayCheckUrl')}
|
||||
onCommit={(testUrl) => onHeadState({ testUrl })}
|
||||
onChange={(e) => onHeadState({ testUrl: e.target.value })}
|
||||
sx={{ ml: 0.5, flex: '1 1 auto', input: { py: 0.65, px: 1 } }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { TextField, type TextFieldProps } from '@mui/material'
|
||||
import { useEffect, useEffectEvent, useRef, useState } from 'react'
|
||||
|
||||
import { useProfiles } from '@/hooks/use-profiles'
|
||||
|
||||
type Props = Omit<TextFieldProps, 'value' | 'onChange'> & {
|
||||
value: string
|
||||
onCommit: (url: string) => void
|
||||
}
|
||||
|
||||
export const ProxyTestUrlInput = ({ value, name, ...props }: Props) => {
|
||||
const { profiles } = useProfiles()
|
||||
return (
|
||||
<DraftTestUrlInput
|
||||
key={JSON.stringify([profiles?.current, name])}
|
||||
name={name}
|
||||
value={value}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DraftTestUrlInput = ({ value, onCommit, ...props }: Props) => {
|
||||
const [draft, setDraft] = useState<string | null>(null)
|
||||
const pendingRef = useRef<string | null>(null)
|
||||
const commitOnUnmount = useEffectEvent(() => {
|
||||
if (pendingRef.current !== null && pendingRef.current !== value)
|
||||
onCommit(pendingRef.current)
|
||||
})
|
||||
useEffect(() => () => commitOnUnmount(), [])
|
||||
|
||||
return (
|
||||
<TextField
|
||||
{...props}
|
||||
value={draft ?? value}
|
||||
onChange={(event) => {
|
||||
pendingRef.current = event.target.value
|
||||
setDraft(event.target.value)
|
||||
}}
|
||||
onBlur={() => {
|
||||
const next = pendingRef.current
|
||||
pendingRef.current = null
|
||||
setDraft(null)
|
||||
if (next !== null && next !== value) onCommit(next)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
const input = event.target as HTMLInputElement
|
||||
input.blur()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -13,17 +13,11 @@ const node = (memberIndex: number, delay: number, name = `${memberIndex}`) =>
|
||||
kind: 'node',
|
||||
ref: { kind: 'node', name, recordId: `${memberIndex}` },
|
||||
node: {
|
||||
recordId: `${memberIndex}`,
|
||||
history: [{ delay }],
|
||||
extra: {
|
||||
'http://cp.cloudflare.com/generate_204': {
|
||||
history: [{ time: new Date(0).toISOString(), delay }],
|
||||
},
|
||||
},
|
||||
source: { kind: 'provider', providerName: `${memberIndex}` },
|
||||
},
|
||||
},
|
||||
}) as unknown as ResolvedMemberOccurrence
|
||||
}) as ResolvedMemberOccurrence
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
@@ -42,9 +36,7 @@ test('matches the previous comparator for cached, fallback and sentinel delays',
|
||||
for (const cached of [false, true]) {
|
||||
const group = `sort-${cached}`
|
||||
if (cached) {
|
||||
values.forEach((delay, i) =>
|
||||
delayManager.setDelay(list[i].member, group, delay),
|
||||
)
|
||||
values.forEach((delay, i) => delayManager.setDelay(`${i}`, group, delay))
|
||||
}
|
||||
for (const timeout of [10000, 20, 0, NaN]) {
|
||||
const expected = list
|
||||
@@ -74,7 +66,7 @@ test('reads each occurrence once and observes cache updates and expiry on the ne
|
||||
node(3, 25),
|
||||
]
|
||||
const cachedOrder = [list[0], list[2], list[1], list[3]]
|
||||
delayManager.setDelay(list[0].member, 'expiry', 1)
|
||||
delayManager.setDelay('0', 'expiry', 1)
|
||||
const get = vi.spyOn(delayManager, 'getDelayFix')
|
||||
expect(filterSort(list, 'expiry', '', 1)).toEqual(cachedOrder)
|
||||
expect(get).toHaveBeenCalledTimes(list.length)
|
||||
@@ -88,7 +80,7 @@ test('reads each occurrence once and observes cache updates and expiry on the ne
|
||||
list[3],
|
||||
list[0],
|
||||
])
|
||||
delayManager.setDelay(list[0].member, 'expiry', 2)
|
||||
delayManager.setDelay('0', 'expiry', 2)
|
||||
expect(filterSort(list, 'expiry', '', 1)).toEqual(cachedOrder)
|
||||
get.mockClear()
|
||||
filterSort([], 'expiry', '', 1)
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { useCallback, useEffect, useReducer } from 'react'
|
||||
|
||||
import { useProfiles } from '@/hooks/use-profiles'
|
||||
import {
|
||||
profileTestUrls,
|
||||
commitProfileTestUrl,
|
||||
} from '@/services/proxy-test-url'
|
||||
|
||||
import { ProxySortType } from './use-filter-sort'
|
||||
|
||||
@@ -36,45 +32,22 @@ export const DEFAULT_STATE: HeadState = {
|
||||
}
|
||||
|
||||
type HeadStateAction =
|
||||
| { type: 'reset'; profile: string }
|
||||
| { type: 'replace'; profile: string; payload: Record<string, HeadState> }
|
||||
| { type: 'reset' }
|
||||
| { type: 'replace'; payload: Record<string, HeadState> }
|
||||
| { type: 'update'; groupName: string; patch: Partial<HeadState> }
|
||||
| { type: 'urls'; urls: Record<string, string> }
|
||||
|
||||
interface LoadedHeadState {
|
||||
profile: string | null
|
||||
heads: Record<string, HeadState>
|
||||
}
|
||||
|
||||
function headStateReducer(
|
||||
state: LoadedHeadState,
|
||||
state: Record<string, HeadState>,
|
||||
action: HeadStateAction,
|
||||
): LoadedHeadState {
|
||||
): Record<string, HeadState> {
|
||||
switch (action.type) {
|
||||
case 'reset':
|
||||
return { profile: action.profile, heads: {} }
|
||||
return {}
|
||||
case 'replace':
|
||||
return { profile: action.profile, heads: action.payload }
|
||||
case 'urls': {
|
||||
let heads = state.heads
|
||||
for (const [name, testUrl] of Object.entries(action.urls)) {
|
||||
if (heads[name]?.testUrl === testUrl) continue
|
||||
heads = {
|
||||
...heads,
|
||||
[name]: { ...DEFAULT_STATE, ...heads[name], testUrl },
|
||||
}
|
||||
}
|
||||
return heads === state.heads ? state : { ...state, heads }
|
||||
}
|
||||
return action.payload
|
||||
case 'update': {
|
||||
const prev = state.heads[action.groupName] || DEFAULT_STATE
|
||||
return {
|
||||
...state,
|
||||
heads: {
|
||||
...state.heads,
|
||||
[action.groupName]: { ...prev, ...action.patch },
|
||||
},
|
||||
}
|
||||
const prev = state[action.groupName] || DEFAULT_STATE
|
||||
return { ...state, [action.groupName]: { ...prev, ...action.patch } }
|
||||
}
|
||||
default:
|
||||
return state
|
||||
@@ -82,42 +55,30 @@ function headStateReducer(
|
||||
}
|
||||
|
||||
export function useHeadStateNew() {
|
||||
const { profiles, current: profile } = useProfiles()
|
||||
const { profiles } = useProfiles()
|
||||
const current = profiles?.current || ''
|
||||
|
||||
const [{ profile: loadedProfile, heads: state }, dispatch] = useReducer(
|
||||
headStateReducer,
|
||||
{ profile: null, heads: {} },
|
||||
)
|
||||
const [state, dispatch] = useReducer(headStateReducer, {})
|
||||
|
||||
useEffect(() => {
|
||||
const urls = profile ? profileTestUrls(profile) : {}
|
||||
if (loadedProfile === current) {
|
||||
dispatch({ type: 'urls', urls })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(
|
||||
localStorage.getItem(HEAD_STATE_KEY) || '{}',
|
||||
localStorage.getItem(HEAD_STATE_KEY)!,
|
||||
) as HeadStateStorage
|
||||
|
||||
const value = data?.[current] || {}
|
||||
for (const [name, testUrl] of Object.entries(urls)) {
|
||||
value[name] = { ...DEFAULT_STATE, ...value[name], testUrl }
|
||||
}
|
||||
const value = data[current] || {}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
dispatch({ type: 'replace', profile: current, payload: value })
|
||||
dispatch({ type: 'replace', payload: value })
|
||||
} else {
|
||||
dispatch({ type: 'reset', profile: current })
|
||||
dispatch({ type: 'reset' })
|
||||
}
|
||||
} catch {
|
||||
dispatch({ type: 'reset', profile: current })
|
||||
dispatch({ type: 'reset' })
|
||||
}
|
||||
}, [current, loadedProfile, profile])
|
||||
}, [current])
|
||||
|
||||
useEffect(() => {
|
||||
if (loadedProfile !== current) return
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
const item = localStorage.getItem(HEAD_STATE_KEY)
|
||||
@@ -133,16 +94,13 @@ export function useHeadStateNew() {
|
||||
})
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [state, current, loadedProfile])
|
||||
}, [state, current])
|
||||
|
||||
const setHeadState = useCallback(
|
||||
(groupName: string, obj: Partial<HeadState>) => {
|
||||
dispatch({ type: 'update', groupName, patch: obj })
|
||||
if (current && obj.testUrl !== undefined) {
|
||||
void commitProfileTestUrl(current, groupName, obj.testUrl)
|
||||
}
|
||||
},
|
||||
[current],
|
||||
[],
|
||||
)
|
||||
|
||||
return [state, setHeadState] as const
|
||||
|
||||
@@ -30,10 +30,8 @@ export const useProxyGroupHeaderLayout = () => {
|
||||
if (canceled || !operation.source || !operation.target) return
|
||||
|
||||
const position = toolsOnLeft ? 'right' : 'left'
|
||||
mutateVerge(
|
||||
(prev) =>
|
||||
prev ? { ...prev, proxy_group_tools_position: position } : prev,
|
||||
false,
|
||||
mutateVerge((prev) =>
|
||||
prev ? { ...prev, proxy_group_tools_position: position } : prev,
|
||||
)
|
||||
try {
|
||||
await patchVerge({ proxy_group_tools_position: position })
|
||||
|
||||
@@ -116,7 +116,6 @@ const virtualGroup = (members: ProxyGroupView['members']): ProxyGroupView => ({
|
||||
mptcp: false,
|
||||
smux: false,
|
||||
history: [],
|
||||
extra: {},
|
||||
members,
|
||||
})
|
||||
|
||||
@@ -212,7 +211,7 @@ export const useRenderList = (
|
||||
if (!proxyView) return []
|
||||
if (isChainMode)
|
||||
return selectedGroup ? [selectedGroup] : [CHAIN_DELAY_GROUP]
|
||||
return mode === 'rule' || mode === 'script'
|
||||
return mode === 'rule' || mode === 'global'
|
||||
? proxyView.groups.map(({ name }) => name)
|
||||
: proxyView.global
|
||||
? [proxyView.global.name]
|
||||
@@ -258,7 +257,7 @@ export const useRenderList = (
|
||||
}))
|
||||
}
|
||||
|
||||
const useRule = mode === 'rule' || mode === 'script'
|
||||
const useRule = mode === 'rule' || mode === 'global'
|
||||
const renderGroups = useRule
|
||||
? proxyView.groups
|
||||
: proxyView.global === null
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
import Visibility from '@mui/icons-material/Visibility'
|
||||
import VisibilityOff from '@mui/icons-material/VisibilityOff'
|
||||
import {
|
||||
TextField,
|
||||
Button,
|
||||
Grid,
|
||||
Stack,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
} from '@mui/material'
|
||||
import { Button, Grid, Stack, TextField } from '@mui/material'
|
||||
import { useLockFn } from 'ahooks'
|
||||
import { useState, useRef, memo, useEffect } from 'react'
|
||||
import { memo, useEffect, useRef } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
import { saveWebdavConfig, createWebdavBackup } from '@/services/cmds'
|
||||
import { createWebdavBackup, saveWebdavConfig } from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { errorDetail, showNotice } from '@/services/notice-service'
|
||||
import {
|
||||
@@ -43,7 +34,6 @@ export const BackupConfigViewer = memo(
|
||||
const { t } = useTranslation()
|
||||
const { verge, mutateVerge } = useVerge()
|
||||
const { webdav_url, webdav_username, webdav_password } = verge || {}
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const usernameRef = useRef<HTMLInputElement>(null)
|
||||
const passwordRef = useRef<HTMLInputElement>(null)
|
||||
const urlRef = useRef<HTMLInputElement>(null)
|
||||
@@ -68,10 +58,6 @@ export const BackupConfigViewer = memo(
|
||||
const webdavStatus = getWebdavStatus(webdavSignature)
|
||||
const shouldAutoInit = webdavStatus !== 'failed'
|
||||
|
||||
const handleClickShowPassword = () => {
|
||||
setShowPassword((prev) => !prev)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!webdav_url || !webdav_username || !webdav_password) {
|
||||
return
|
||||
@@ -127,17 +113,15 @@ export const BackupConfigViewer = memo(
|
||||
errorNotice: false,
|
||||
},
|
||||
)
|
||||
await mutateVerge(
|
||||
(current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
webdav_url: trimmedUrl,
|
||||
webdav_username: trimmedUsername,
|
||||
webdav_password: data.password,
|
||||
}
|
||||
: current,
|
||||
false,
|
||||
await mutateVerge((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
webdav_url: trimmedUrl,
|
||||
webdav_username: trimmedUsername,
|
||||
webdav_password: data.password,
|
||||
}
|
||||
: current,
|
||||
)
|
||||
setWebdavStatus(signature, 'unknown')
|
||||
showNotice.success('settings.modals.backup.messages.webdavConfigSaved')
|
||||
@@ -215,7 +199,7 @@ export const BackupConfigViewer = memo(
|
||||
<Grid size={{ xs: 6 }}>
|
||||
<TextField
|
||||
label={t('shared.labels.password')}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
type="password"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
autoCorrect="off"
|
||||
@@ -223,20 +207,6 @@ export const BackupConfigViewer = memo(
|
||||
spellCheck="false"
|
||||
inputRef={passwordRef}
|
||||
{...register('password')}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
onClick={handleClickShowPassword}
|
||||
edge="end"
|
||||
>
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Button, ButtonGroup } from '@mui/material'
|
||||
|
||||
interface Props {
|
||||
value?: string
|
||||
onChange?: (value: string) => void
|
||||
}
|
||||
|
||||
export const DnsModeSwitch = (props: Props) => {
|
||||
const { value, onChange } = props
|
||||
|
||||
return (
|
||||
<ButtonGroup size="small" sx={{ my: '4px' }}>
|
||||
<Button
|
||||
variant={!value ? 'contained' : 'outlined'}
|
||||
onClick={() => onChange?.('')}
|
||||
sx={{ textTransform: 'capitalize' }}
|
||||
>
|
||||
Auto
|
||||
</Button>
|
||||
<Button
|
||||
variant={value === 'disabled' ? 'contained' : 'outlined'}
|
||||
onClick={() => onChange?.('disabled')}
|
||||
sx={{ textTransform: 'capitalize' }}
|
||||
>
|
||||
Disabled
|
||||
</Button>
|
||||
<Button
|
||||
variant={value === 'native' ? 'contained' : 'outlined'}
|
||||
onClick={() => onChange?.('native')}
|
||||
sx={{ textTransform: 'capitalize' }}
|
||||
>
|
||||
Native
|
||||
</Button>
|
||||
<Button
|
||||
variant={value === 'hijack' ? 'contained' : 'outlined'}
|
||||
onClick={() => onChange?.('hijack')}
|
||||
sx={{ textTransform: 'capitalize' }}
|
||||
>
|
||||
Hijack
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
)
|
||||
}
|
||||
@@ -107,7 +107,7 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
|
||||
showNotice.error(err)
|
||||
}
|
||||
const onChangeData = (patch: Partial<IVergeConfig>) => {
|
||||
mutateVerge({ ...verge, ...patch }, false)
|
||||
mutateVerge({ ...verge, ...patch })
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user