mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-02 21:12:13 +08:00
Compare commits
65
Commits
2a61b783bf
...
v2.5.9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
160bb4fe8e | ||
|
|
a5b22fa512 | ||
|
|
07ee0a602a | ||
|
|
693f2f7ac7 | ||
|
|
633f166e46 | ||
|
|
261178b6d8 | ||
|
|
0cae907de6 | ||
|
|
94942b9105 | ||
|
|
12f842d038 | ||
|
|
8e9832712a | ||
|
|
719f63b57f | ||
|
|
ef784e459d | ||
|
|
c236f5094e | ||
|
|
bdea31a7ca | ||
|
|
44d50eb67d | ||
|
|
afd9d4b5a0 | ||
|
|
8bf20d199c | ||
|
|
e156ff09ae | ||
|
|
df70146b02 | ||
|
|
de22706f3b | ||
|
|
4b87634da9 | ||
|
|
c9a6cf240d | ||
|
|
8128227302 | ||
|
|
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 | ||
|
|
6e08a77a84 | ||
|
|
27efaf34f4 |
@@ -4,11 +4,11 @@
|
||||
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:2": {
|
||||
"version": "24.21.0",
|
||||
"pnpmVersion": "12.10.1"
|
||||
"version": "26.13.0",
|
||||
"pnpmVersion": "12.16.1"
|
||||
},
|
||||
"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: '26.13.0'
|
||||
|
||||
- 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: '26.13.0'
|
||||
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: '26.13.0'
|
||||
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: '26.13.0'
|
||||
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: '26.13.0'
|
||||
|
||||
- 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: '26.13.0'
|
||||
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: '26.13.0'
|
||||
|
||||
- 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: '26.13.0'
|
||||
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,6 +2,19 @@ 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:
|
||||
@@ -16,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: '26.13.0'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Restore pnpm cache
|
||||
if: steps.check_frontend.outputs.frontend == 'true'
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.pnpm-store
|
||||
@@ -64,103 +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: |
|
||||
contract:
|
||||
- 'src/services/contract.ts'
|
||||
- 'scripts/gen-contract.mjs'
|
||||
- 'src-tauri/src/core/notify/**'
|
||||
- 'src-tauri/src/bin/**'
|
||||
- 'src-tauri/src/lib.rs'
|
||||
- '.github/workflows/frontend-check.yml'
|
||||
|
||||
- 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: Regenerate contract and require empty diff
|
||||
if: steps.check_inputs.outputs.contract == 'true'
|
||||
run: |
|
||||
pnpm gen:contract
|
||||
git diff --exit-code -- src/services/contract.ts
|
||||
|
||||
@@ -2,6 +2,14 @@ name: Clippy Lint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "src-tauri/**"
|
||||
- "crates/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "rust-toolchain.toml"
|
||||
- ".cargo/config.toml"
|
||||
- ".github/workflows/lint-clippy.yml"
|
||||
workflow_dispatch:
|
||||
env:
|
||||
HUSKY: 0
|
||||
@@ -23,28 +31,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 +65,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 }}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
name: Prepare Release
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [edited, closed]
|
||||
branches: [dev]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: Release channel
|
||||
required: true
|
||||
type: choice
|
||||
options: [rc, stable]
|
||||
default: rc
|
||||
release_tag:
|
||||
description: 'Optional tag: vX.Y.Z-rc[.N] for RC, vX.Y.Z for stable'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: prepare-release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
if: >-
|
||||
${{ github.event_name == 'workflow_dispatch' ||
|
||||
(github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.event.pull_request.user.login == 'github-actions[bot]' &&
|
||||
((startsWith(github.event.pull_request.head.ref, 'release/v') &&
|
||||
((github.event.action == 'closed' && github.event.pull_request.merged == true) ||
|
||||
(github.event.changes.body && github.event.pull_request.state == 'open' &&
|
||||
contains(github.event.pull_request.body, '[x] Refresh this PR from latest dev')))) ||
|
||||
(startsWith(github.event.pull_request.head.ref, 'next-dev/v') &&
|
||||
github.event.changes.body && github.event.pull_request.state == 'open' &&
|
||||
(contains(github.event.pull_request.body, '[x] Refresh this PR from latest dev') ||
|
||||
contains(github.event.pull_request.body, '[x] Use the next minor version') !=
|
||||
contains(github.event.changes.body.from, '[x] Use the next minor version'))))) }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Require the dev branch
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
env:
|
||||
SOURCE_REF: ${{ github.ref }}
|
||||
run: |
|
||||
if [[ "$SOURCE_REF" != 'refs/heads/dev' ]]; then
|
||||
echo '::error::Run Prepare Release from the dev branch.'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: refs/heads/dev
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.13.0'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
if: github.event.action != 'closed'
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
if: github.event.action != 'closed'
|
||||
|
||||
- name: Create or refresh version or publication PR
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.release_tag }}
|
||||
RELEASE_CHANNEL: ${{ inputs.channel }}
|
||||
RELEASE_COMMAND: ${{ github.event.action == 'closed' && 'publish-pr' || startsWith(github.event.pull_request.head.ref, 'next-dev/') && 'next-dev' || 'prepare' }}
|
||||
run: node scripts-workflow/release.mjs "$RELEASE_COMMAND"
|
||||
+129
-82
@@ -1,6 +1,9 @@
|
||||
name: Release Build
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [edited]
|
||||
branches: [main]
|
||||
push:
|
||||
# -rc tag 时预览发布, 跳过 telegram 通知、跳过 winget 提交、跳过 latest.json 文件更新
|
||||
tags:
|
||||
@@ -11,75 +14,59 @@ env:
|
||||
RUST_BACKTRACE: short
|
||||
HUSKY: 0
|
||||
concurrency:
|
||||
group: '${{ github.workflow }} - ${{ github.head_ref || github.ref }}'
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
group: release-publication
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
check_tag_version:
|
||||
name: Check Release Tag and package.json Version Consistency
|
||||
prepare_release:
|
||||
name: Approve Snapshot and Prepare Draft Release
|
||||
if: >-
|
||||
${{ github.event_name == 'push' ||
|
||||
(github.event.changes.body &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.event.pull_request.user.login == 'github-actions[bot]' &&
|
||||
startsWith(github.event.pull_request.head.ref, 'publish/v') &&
|
||||
contains(github.event.pull_request.body, '[x] Publish this approved release')) }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: read
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
outputs:
|
||||
release_tag: ${{ steps.source.outputs.release_tag }}
|
||||
source_sha: ${{ steps.source.outputs.source_sha }}
|
||||
channel: ${{ steps.source.outputs.channel }}
|
||||
release_id: ${{ steps.draft.outputs.id }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
- name: Checkout trusted release tooling
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: refs/heads/dev
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check if tag is from main branch
|
||||
run: |
|
||||
TAG_REF="${GITHUB_REF##*/}"
|
||||
echo "Checking if tag $TAG_REF is from main branch..."
|
||||
|
||||
TAG_COMMIT=$(git rev-list -n 1 $TAG_REF)
|
||||
MAIN_COMMITS=$(git rev-list origin/main)
|
||||
|
||||
if echo "$MAIN_COMMITS" | grep -q "$TAG_COMMIT"; then
|
||||
echo "✅ Tag $TAG_REF is from main branch"
|
||||
else
|
||||
echo "❌ Tag $TAG_REF is not from main branch"
|
||||
echo "This release workflow only accepts tags from main branch."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check tag and package.json version
|
||||
run: |
|
||||
TAG_REF="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
|
||||
echo "Current tag: $TAG_REF"
|
||||
|
||||
PKG_VERSION=$(jq -r .version package.json)
|
||||
echo "package.json version: $PKG_VERSION"
|
||||
|
||||
EXPECTED_TAG="v$PKG_VERSION"
|
||||
|
||||
if [[ "$TAG_REF" != "$EXPECTED_TAG" ]]; then
|
||||
echo "❌ Version mismatch:"
|
||||
echo " Git tag : $TAG_REF"
|
||||
echo " package.json : $EXPECTED_TAG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Tag and package.json version are consistent."
|
||||
|
||||
prepare_release:
|
||||
name: Prepare Draft Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [check_tag_version]
|
||||
outputs:
|
||||
release_id: ${{ steps.prepare_release.outputs.id }}
|
||||
steps:
|
||||
- name: Create or update draft release
|
||||
id: prepare_release
|
||||
uses: softprops/action-gh-release@v3
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
name: 'Clash Verge Rev ${{ github.ref_name }}'
|
||||
body: 'Draft release, will be updated later.'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
draft: true
|
||||
prerelease: ${{ contains(github.ref_name, '-rc') }}
|
||||
node-version: '26.13.0'
|
||||
- name: Validate source and publish the approved snapshot
|
||||
id: source
|
||||
env:
|
||||
RELEASE_COMMAND: ${{ github.event_name == 'push' && 'validate-tag' || 'publish' }}
|
||||
run: node scripts-workflow/release.mjs "$RELEASE_COMMAND"
|
||||
|
||||
- name: Create or reuse draft release
|
||||
id: draft
|
||||
env:
|
||||
RELEASE_TAG: ${{ steps.source.outputs.release_tag }}
|
||||
SOURCE_SHA: ${{ steps.source.outputs.source_sha }}
|
||||
run: node scripts-workflow/release.mjs prepare-draft
|
||||
|
||||
update_tag:
|
||||
name: Update tag
|
||||
name: Publish Completed Release
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }}
|
||||
outputs:
|
||||
source_sha: ${{ needs.prepare_release.outputs.source_sha }}
|
||||
channel: ${{ needs.prepare_release.outputs.channel }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
[
|
||||
@@ -91,6 +78,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.prepare_release.outputs.source_sha }}
|
||||
|
||||
- name: Group changelog platforms
|
||||
run: bash ./scripts-workflow/group_platforms.sh
|
||||
@@ -103,7 +92,7 @@ jobs:
|
||||
- name: Set Env
|
||||
run: |
|
||||
echo "BUILDTIME=$(TZ=Asia/Shanghai date)" >> $GITHUB_ENV
|
||||
TAG_REF="${GITHUB_REF##*/}"
|
||||
TAG_REF="$RELEASE_TAG"
|
||||
echo "TAG_NAME=$TAG_REF" >> $GITHUB_ENV
|
||||
VERSION=$(echo "$TAG_REF" | sed 's/^v//')
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
@@ -149,10 +138,16 @@ jobs:
|
||||
Created at ${{ env.BUILDTIME }}.
|
||||
EOF
|
||||
|
||||
- name: Verify release tag still points to the approved source
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SOURCE_SHA: ${{ needs.prepare_release.outputs.source_sha }}
|
||||
run: node scripts-workflow/release.mjs verify-target
|
||||
|
||||
- name: Publish Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PRERELEASE: ${{ contains(github.ref_name, '-rc') }}
|
||||
PRERELEASE: ${{ contains(env.RELEASE_TAG, '-rc') }}
|
||||
run: |
|
||||
release_id="${{ needs.prepare_release.outputs.release_id }}"
|
||||
|
||||
@@ -181,6 +176,8 @@ jobs:
|
||||
release:
|
||||
name: Release Build
|
||||
needs: [prepare_release]
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -200,11 +197,13 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.prepare_release.outputs.source_sha }}
|
||||
|
||||
- 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 +229,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.21.0'
|
||||
node-version: '26.13.0'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
@@ -244,12 +243,11 @@ 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."
|
||||
|
||||
- name: Tauri build
|
||||
# 上游 5.24 修改了 latest.json 的生成逻辑,且依赖 tauri-plugin-update 2.10.0 暂未发布,故锁定在 0.5.23 版本
|
||||
uses: tauri-apps/tauri-action@v1.0.0
|
||||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
@@ -264,11 +262,14 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
with:
|
||||
releaseId: ${{ needs.prepare_release.outputs.release_id }}
|
||||
tagName: ${{ github.ref_name }}
|
||||
tagName: ${{ env.RELEASE_TAG }}
|
||||
releaseBody: 'Draft release, will be updated later.'
|
||||
releaseDraft: true
|
||||
prerelease: ${{ needs.prepare_release.outputs.channel == 'rc' }}
|
||||
releaseCommitish: ${{ needs.prepare_release.outputs.source_sha }}
|
||||
tauriScript: pnpm
|
||||
args: --target ${{ matrix.target }}
|
||||
includeUpdaterJson: true
|
||||
uploadUpdaterJson: ${{ needs.prepare_release.outputs.channel == 'stable' }}
|
||||
|
||||
- name: Attest Windows bundles
|
||||
if: matrix.os == 'windows-latest'
|
||||
@@ -293,6 +294,8 @@ jobs:
|
||||
release-for-linux-arm:
|
||||
name: Release Build for Linux ARM
|
||||
needs: [prepare_release]
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -307,11 +310,13 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.prepare_release.outputs.source_sha }}
|
||||
|
||||
- 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 +336,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.21.0'
|
||||
node-version: '26.13.0'
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
@@ -389,7 +394,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."
|
||||
|
||||
@@ -420,7 +425,7 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release upload "${{ github.ref_name }}" \
|
||||
gh release upload "$RELEASE_TAG" \
|
||||
target/${{ matrix.target }}/release/bundle/deb/*.deb \
|
||||
target/${{ matrix.target }}/release/bundle/rpm/*.rpm \
|
||||
--clobber
|
||||
@@ -428,6 +433,8 @@ jobs:
|
||||
release-for-fixed-webview2:
|
||||
name: Release Build for Fixed WebView2
|
||||
needs: [prepare_release]
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -442,11 +449,13 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.prepare_release.outputs.source_sha }}
|
||||
|
||||
- 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 +475,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.21.0'
|
||||
node-version: '26.13.0'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
@@ -487,7 +496,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."
|
||||
|
||||
@@ -532,22 +541,55 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
$files = Get-ChildItem "target\${{ matrix.target }}\release\bundle\nsis\*setup*" | Select-Object -ExpandProperty FullName
|
||||
gh release upload "${{ github.ref_name }}" $files --clobber
|
||||
gh release upload "$env:RELEASE_TAG" $files --clobber
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
prepare-next-dev:
|
||||
name: Prepare Next Dev Version
|
||||
if: ${{ needs.update_tag.outputs.channel == 'stable' }}
|
||||
needs: [prepare_release, update_tag]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
concurrency:
|
||||
group: prepare-release
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }}
|
||||
SOURCE_SHA: ${{ needs.prepare_release.outputs.source_sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: refs/heads/dev
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.13.0'
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
- name: Prepare version and changelog PR
|
||||
run: |
|
||||
pnpm install --frozen-lockfile --ignore-scripts
|
||||
node scripts-workflow/release.mjs next-dev
|
||||
|
||||
release-update:
|
||||
if: ${{ !contains(github.ref_name, '-rc') }}
|
||||
if: ${{ needs.update_tag.outputs.channel == 'stable' }}
|
||||
name: Release Update
|
||||
runs-on: ubuntu-latest
|
||||
needs: [update_tag]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.update_tag.outputs.source_sha }}
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.21.0'
|
||||
node-version: '26.13.0'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
@@ -563,17 +605,19 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
release-update-for-fixed-webview2:
|
||||
if: ${{ !contains(github.ref_name, '-rc') }}
|
||||
if: ${{ needs.update_tag.outputs.channel == 'stable' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: [update_tag]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.update_tag.outputs.source_sha }}
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.21.0'
|
||||
node-version: '26.13.0'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
@@ -589,7 +633,7 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
submit-to-winget:
|
||||
if: ${{ !contains(github.ref_name, '-rc') }}
|
||||
if: ${{ needs.update_tag.outputs.channel == 'stable' }}
|
||||
name: Submit to Winget
|
||||
runs-on: ubuntu-latest
|
||||
needs: [update_tag, release-update]
|
||||
@@ -597,6 +641,7 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.update_tag.outputs.source_sha }}
|
||||
fetch-depth: 0
|
||||
- name: Get Version
|
||||
run: |
|
||||
@@ -613,7 +658,7 @@ jobs:
|
||||
token: ${{ secrets.WINGET_TOKEN }}
|
||||
|
||||
notify-telegram:
|
||||
if: ${{ !contains(github.ref_name, '-rc') }}
|
||||
if: ${{ needs.update_tag.outputs.channel == 'stable' }}
|
||||
name: Notify Telegram
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
@@ -626,6 +671,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.update_tag.outputs.source_sha }}
|
||||
|
||||
- name: Group changelog platforms
|
||||
run: bash ./scripts-workflow/group_platforms.sh
|
||||
@@ -638,7 +685,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.21.0'
|
||||
node-version: '26.13.0'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
|
||||
@@ -6,6 +6,12 @@ name: Check Formatting
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "src-tauri/**"
|
||||
- "**/*.rs"
|
||||
- "rustfmt.toml"
|
||||
- "rust-toolchain.toml"
|
||||
- ".github/workflows/rustfmt.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -19,25 +25,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: '26.13.0'
|
||||
|
||||
- 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: '26.13.0'
|
||||
|
||||
- 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: '26.13.0'
|
||||
|
||||
- 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: '26.13.0'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
|
||||
@@ -5,4 +5,5 @@ if ! command -v "cargo-make" >/dev/null 2>&1; then
|
||||
cargo install --force cargo-make
|
||||
fi
|
||||
|
||||
node scripts/check-contract-changes.mjs --push "$1" || exit $?
|
||||
cargo make pre-push
|
||||
|
||||
+3
-3
@@ -1,3 +1,3 @@
|
||||
nodejs 24.21.0
|
||||
pnpm 12.10.1
|
||||
rust 1.99.0
|
||||
nodejs 26.13.0
|
||||
pnpm 12.16.1
|
||||
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.
|
||||
|
||||
+19
-6
@@ -28,7 +28,7 @@ Before contributing, you need to set up your development environment. Follow the
|
||||
### Prerequisites
|
||||
|
||||
1. **Install Rust and Node.js**
|
||||
Our project requires both Rust and Node.js. Follow the official installation instructions [here](https://tauri.app/start/prerequisites/).
|
||||
Follow the [Tauri prerequisites](https://tauri.app/start/prerequisites/) for your operating system. Use the Node.js version in [`.tool-versions`](.tool-versions); [`rust-toolchain.toml`](rust-toolchain.toml) selects the Rust version and required components when you run Cargo from this repository.
|
||||
|
||||
### Windows Users
|
||||
|
||||
@@ -51,18 +51,21 @@ rustup set default-host x86_64-pc-windows-msvc
|
||||
|
||||
### Install Node.js Package Manager
|
||||
|
||||
Enable `corepack`:
|
||||
[Corepack is no longer bundled with Node.js starting with Node.js 25](https://nodejs.org/api/corepack.html). Install it globally if it is not already available, then enable it:
|
||||
|
||||
```bash
|
||||
npm install --global corepack
|
||||
corepack enable
|
||||
```
|
||||
|
||||
Run pnpm from this repository so Corepack selects the version pinned by `packageManager` in [`package.json`](package.json).
|
||||
|
||||
### Install Project Dependencies
|
||||
|
||||
Node.js dependencies:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm install --frozen-lockfile
|
||||
```
|
||||
|
||||
Ubuntu-only system packages:
|
||||
@@ -78,14 +81,24 @@ pnpm run prebuild
|
||||
pnpm run prebuild --force # Re-download and overwrite Mihomo core and service binaries
|
||||
```
|
||||
|
||||
### Prepare the Development Service Source
|
||||
|
||||
`pnpm dev` builds the development service from a sibling checkout of [`clash-verge-service-ipc`](https://github.com/clash-verge-rev/clash-verge-service-ipc). The binaries downloaded by `pnpm prebuild` do not replace this source checkout.
|
||||
|
||||
Check the `clash_verge_service_ipc` dependency in [`src-tauri/Cargo.toml`](src-tauri/Cargo.toml), then clone the service repository alongside `clash-verge-rev` and check out the matching tag. If the sibling directory already exists, reuse it and check its version rather than overwriting it. Without this checkout, `pnpm dev` fails when it tries to access `../clash-verge-service-ipc/Cargo.toml`.
|
||||
|
||||
### Run the Development Server
|
||||
|
||||
Run these commands from the root of `clash-verge-rev`:
|
||||
|
||||
```bash
|
||||
pnpm dev # Standard
|
||||
pnpm dev:diff # If an app instance already exists
|
||||
pnpm dev:tauri # Run Tauri development mode
|
||||
pnpm dev # Build the development service and start the desktop app
|
||||
pnpm dev:diff # Alias of pnpm dev
|
||||
pnpm dev:tauri # Run Tauri directly with the tauri-dev feature
|
||||
```
|
||||
|
||||
For frontend-only development, use `pnpm web:dev`. It does not start the Rust backend or build the development service.
|
||||
|
||||
### Build the Project
|
||||
|
||||
Standard build:
|
||||
|
||||
Generated
+234
-254
File diff suppressed because it is too large
Load Diff
+4
-4
@@ -61,13 +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 = "2.12.1" }
|
||||
tauri-plugin-clipboard-manager = "2.4.1"
|
||||
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",
|
||||
@@ -88,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"
|
||||
|
||||
+23
-1
@@ -1,21 +1,43 @@
|
||||
## v2.5.9
|
||||
|
||||
- **Mihomo(Meta) 内核升级至 v1.19.33**
|
||||
|
||||
<details>
|
||||
<summary><strong> 🐞 修复问题 </strong></summary>
|
||||
|
||||
- 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置的问题
|
||||
- 修复规则扩展包含空列表项时,「编辑规则」界面报错的问题
|
||||
- 修复 重启应用后定时自动备份不执行的问题
|
||||
- 修复托盘测速与代理页行为不一致的问题,移除托盘「测试延迟」入口
|
||||
- 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置
|
||||
- 修复 首页 网站测速失败后卡片一直显示测试中
|
||||
- 修复 代理 页展开分组后不显示最新测速结果,需切换节点才刷新
|
||||
- 修复 代理 在全局模式下无法展开节点组
|
||||
- 修复 自定义 CSS 无法修改编辑器字体和字号的问题
|
||||
- 修复 WebDAV 设置清空后无法保存
|
||||
|
||||
**🍎 macOS**
|
||||
|
||||
- macOS:修复 Bigsur 上首页测速和流量图无法显示的问题
|
||||
|
||||
**🐧 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,6 +4,7 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
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)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::utils::{country_code_to_emoji, get_local_date_string};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
|
||||
pub struct UnlockItem {
|
||||
pub name: String,
|
||||
pub status: String,
|
||||
|
||||
@@ -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,6 +5,7 @@ edition = "2024"
|
||||
rust-version = "1.91"
|
||||
|
||||
[dependencies]
|
||||
specta = { version = "=2.0.0-rc.25", features = ["derive", "function"] }
|
||||
tauri = { workspace = true }
|
||||
tauri-plugin-clipboard-manager = { workspace = true }
|
||||
parking_lot = { workspace = true }
|
||||
@@ -14,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 }
|
||||
|
||||
@@ -1,53 +1,46 @@
|
||||
use parking_lot::RwLock;
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Runtime, State, command};
|
||||
use tauri_plugin_clipboard_manager::{ClipboardExt as _, Error};
|
||||
use tauri_plugin_clipboard_manager::ClipboardExt as _;
|
||||
|
||||
use crate::Platform;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, specta::Type)]
|
||||
pub struct SystemInfo {
|
||||
pub system_name: String,
|
||||
pub system_version: String,
|
||||
pub system_kernel_version: String,
|
||||
pub system_arch: String,
|
||||
pub app_version: String,
|
||||
pub app_core_mode: String,
|
||||
pub app_is_admin: bool,
|
||||
}
|
||||
|
||||
impl From<Platform> for SystemInfo {
|
||||
fn from(platform: Platform) -> Self {
|
||||
impl From<&Platform> for SystemInfo {
|
||||
fn from(platform: &Platform) -> Self {
|
||||
Self {
|
||||
system_name: platform.sysinfo.system_name,
|
||||
system_version: platform.sysinfo.system_version,
|
||||
system_kernel_version: platform.sysinfo.system_kernel_version,
|
||||
system_arch: platform.sysinfo.system_arch,
|
||||
app_version: platform.appinfo.app_version,
|
||||
app_core_mode: platform.appinfo.app_core_mode,
|
||||
app_is_admin: platform.appinfo.app_is_admin,
|
||||
system_name: platform.sysinfo.system_name.clone(),
|
||||
system_version: platform.sysinfo.system_version.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub fn get_system_info(state: State<'_, RwLock<Platform>>) -> Result<SystemInfo, Error> {
|
||||
#[specta::specta]
|
||||
pub fn get_system_info(state: State<'_, RwLock<Platform>>) -> Result<SystemInfo, String> {
|
||||
let platform = state.inner().read();
|
||||
Ok(SystemInfo::from(platform.clone()))
|
||||
Ok(SystemInfo::from(&*platform))
|
||||
}
|
||||
|
||||
/// 获取应用的运行时间(毫秒)
|
||||
#[command]
|
||||
pub fn get_app_uptime(state: State<'_, RwLock<Platform>>) -> Result<u128, Error> {
|
||||
#[specta::specta]
|
||||
pub fn get_app_uptime(state: State<'_, RwLock<Platform>>) -> Result<u128, String> {
|
||||
Ok(state.inner().read().appinfo.app_startup_time.elapsed().as_millis())
|
||||
}
|
||||
|
||||
#[command]
|
||||
#[specta::specta]
|
||||
pub fn export_diagnostic_info<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
state: State<'_, RwLock<Platform>>,
|
||||
) -> Result<(), Error> {
|
||||
) -> Result<(), String> {
|
||||
let info = state.inner().read().to_string();
|
||||
let clipboard = app_handle.clipboard();
|
||||
clipboard.write_text(info)
|
||||
clipboard.write_text(info).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
@@ -12,6 +12,92 @@ import pluginUnusedImports from 'eslint-plugin-unused-imports'
|
||||
import globals from 'globals'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
const ipcBoundaryRules = ({
|
||||
allowCommands = false,
|
||||
allowInvoke = false,
|
||||
allowEvents = false,
|
||||
} = {}) => {
|
||||
const paths = [
|
||||
...(!allowInvoke
|
||||
? [
|
||||
{
|
||||
name: '@tauri-apps/api/core',
|
||||
importNames: ['invoke'],
|
||||
allowTypeImports: true,
|
||||
message: 'Invoke backend commands through services/cmds.',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!allowEvents
|
||||
? [
|
||||
{
|
||||
name: '@tauri-apps/api/event',
|
||||
importNames: ['listen', 'emit', 'emitTo', 'once'],
|
||||
allowTypeImports: true,
|
||||
message:
|
||||
'The event bus owns Tauri event subscriptions and emissions.',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
const patterns = allowCommands
|
||||
? []
|
||||
: [
|
||||
{
|
||||
regex: '(^|/)bindings(\\.ts)?$',
|
||||
importNames: ['commands'],
|
||||
allowTypeImports: true,
|
||||
message:
|
||||
'Only services/cmds may import generated command implementations.',
|
||||
},
|
||||
]
|
||||
const modules = [
|
||||
...(!allowInvoke ? ['@tauri-apps/api/core'] : []),
|
||||
...(!allowEvents ? ['@tauri-apps/api/event'] : []),
|
||||
]
|
||||
const syntax = modules.flatMap((name) => [
|
||||
{
|
||||
selector: `ImportExpression[source.value='${name}']`,
|
||||
message:
|
||||
'Use the owned IPC service boundary instead of dynamically importing Tauri APIs.',
|
||||
},
|
||||
{
|
||||
selector: `CallExpression[callee.name='require'][arguments.0.value='${name}']`,
|
||||
message:
|
||||
'Use the owned IPC service boundary instead of requiring Tauri APIs.',
|
||||
},
|
||||
{
|
||||
selector: `TSImportEqualsDeclaration[moduleReference.expression.value='${name}']`,
|
||||
message: 'Use the owned IPC service boundary for Tauri APIs.',
|
||||
},
|
||||
])
|
||||
if (!allowCommands) {
|
||||
syntax.push(
|
||||
{
|
||||
selector: 'ImportExpression[source.value=/bindings(\\.ts)?$/]',
|
||||
message:
|
||||
'Only services/cmds may load generated command implementations.',
|
||||
},
|
||||
{
|
||||
selector:
|
||||
"CallExpression[callee.name='require'][arguments.0.value=/bindings(\\.ts)?$/]",
|
||||
message:
|
||||
'Only services/cmds may load generated command implementations.',
|
||||
},
|
||||
{
|
||||
selector:
|
||||
'TSImportEqualsDeclaration[moduleReference.expression.value=/bindings(\\.ts)?$/]',
|
||||
message:
|
||||
'Only services/cmds may load generated command implementations.',
|
||||
},
|
||||
)
|
||||
}
|
||||
return {
|
||||
'no-restricted-imports': ['error', { paths, patterns }],
|
||||
'no-restricted-syntax': ['error', ...syntax],
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig([
|
||||
pluginESx.configs['flat/restrict-to-es2022'],
|
||||
{
|
||||
@@ -43,6 +129,7 @@ export default defineConfig([
|
||||
'eslint.config.ts',
|
||||
`vite.config.mts`,
|
||||
'scripts/*.mjs',
|
||||
'scripts-workflow/*.mjs',
|
||||
'tests/*.ts',
|
||||
'src/polyfills/*.js',
|
||||
'tests/*.mjs',
|
||||
@@ -152,6 +239,13 @@ export default defineConfig([
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['scripts-workflow/*.mjs'],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
rules: { 'es-x/no-object-groupby': 'off' },
|
||||
},
|
||||
{
|
||||
files: ['scripts/perf/*.{mjs,mts}'],
|
||||
languageOptions: {
|
||||
@@ -162,4 +256,33 @@ export default defineConfig([
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx,js,jsx,mts,mjs}'],
|
||||
rules: ipcBoundaryRules(),
|
||||
},
|
||||
{
|
||||
files: ['src/services/cmds.ts'],
|
||||
rules: ipcBoundaryRules({ allowCommands: true }),
|
||||
},
|
||||
{
|
||||
files: ['src/services/bindings.ts'],
|
||||
rules: ipcBoundaryRules({ allowCommands: true, allowInvoke: true }),
|
||||
},
|
||||
{
|
||||
files: ['src/services/bus/**/*.{ts,tsx}'],
|
||||
rules: ipcBoundaryRules({ allowEvents: true }),
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'src/components/proxy/proxy-chain.tsx',
|
||||
'src/components/proxy/proxy-groups-chain.tsx',
|
||||
'src/pages/proxies.tsx',
|
||||
],
|
||||
rules: {
|
||||
'@typescript-eslint/no-floating-promises': [
|
||||
'error',
|
||||
{ ignoreVoid: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@6/schema.json",
|
||||
"entry": ["src/polyfills/*.js!"],
|
||||
"entry": ["src/polyfills/*.js!", "scripts/check-contract-changes.mjs!"],
|
||||
"project": ["src/**/*.{js,jsx,ts,tsx,sass,scss}!", "scripts/**/*.{js,mjs}!"],
|
||||
"include": [
|
||||
"files",
|
||||
@@ -18,7 +18,7 @@
|
||||
"ignoreDependencies": ["lint-staged"],
|
||||
"ignoreIssues": {
|
||||
"src/types/generated/**": ["exports", "types"],
|
||||
"src/services/contract.ts": ["exports", "types"]
|
||||
"src/services/bindings.ts": ["types"]
|
||||
},
|
||||
"cycles": {
|
||||
"dynamicImports": true
|
||||
|
||||
+21
-21
@@ -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": "2.12.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "2.4.0",
|
||||
"@tauri-apps/plugin-dialog": "2.8.0",
|
||||
"@tauri-apps/plugin-fs": "2.6.0",
|
||||
"@tauri-apps/plugin-http": "2.8.0",
|
||||
"@tauri-apps/plugin-opener": "2.7.0",
|
||||
"@tauri-apps/plugin-updater": "2.13.1",
|
||||
"@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.1",
|
||||
"ahooks": "^3.10.0",
|
||||
"cidr-block": "^2.3.0",
|
||||
"compare-versions": "^6.1.1",
|
||||
@@ -75,16 +75,15 @@
|
||||
"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.9",
|
||||
"react-markdown": "10.1.0",
|
||||
"react-router": "^8.4.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"swr": "^2.5.1",
|
||||
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#main",
|
||||
"types-pac": "^1.0.3",
|
||||
@@ -95,15 +94,15 @@
|
||||
"@biomejs/biome": "^2.5.14",
|
||||
"@eslint-react/eslint-plugin": "^5.20.5",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tauri-apps/cli": "2.12.1",
|
||||
"@tauri-apps/cli": "2.12.3",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^24.13.6",
|
||||
"@types/node": "^26.0.0",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@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",
|
||||
@@ -118,23 +117,24 @@
|
||||
"eslint-plugin-react-refresh": "^0.5.7",
|
||||
"eslint-plugin-sukka": "^10.2.0",
|
||||
"eslint-plugin-unused-imports": "^4.4.1",
|
||||
"glob": "^13.0.6",
|
||||
"glob": "^14.0.0",
|
||||
"globals": "^17.12.0",
|
||||
"https-proxy-agent": "^9.1.0",
|
||||
"husky": "^9.1.7",
|
||||
"jiti": "^2.7.0",
|
||||
"knip": "6.39.0",
|
||||
"knip": "6.44.1",
|
||||
"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",
|
||||
"ws": "8.22.0"
|
||||
"webdriverio": "10.1.0",
|
||||
"ws": "8.22.1"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js,mjs}": [
|
||||
@@ -146,5 +146,5 @@
|
||||
]
|
||||
},
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@12.10.1"
|
||||
"packageManager": "pnpm@12.16.1"
|
||||
}
|
||||
|
||||
Generated
+682
-965
File diff suppressed because it is too large
Load Diff
@@ -10,3 +10,11 @@ allowBuilds:
|
||||
unrs-resolver: true
|
||||
minimumReleaseAgeExclude:
|
||||
- dayjs@1.11.21
|
||||
- "@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"]
|
||||
|
||||
@@ -0,0 +1,796 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { appendFileSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
|
||||
const env = process.env
|
||||
const repository = env.GITHUB_REPOSITORY
|
||||
const refreshLabel = 'Refresh this PR from latest dev'
|
||||
const publishLabel = 'Publish this approved release'
|
||||
const minorLabel = 'Use the next minor version'
|
||||
const checked = (body, label = refreshLabel) =>
|
||||
(body || '')
|
||||
.split('\n')
|
||||
.some((line) => line.toLowerCase() === `- [x] ${label}`.toLowerCase())
|
||||
const versionPattern = '(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)'
|
||||
const rcPattern = new RegExp(`^v${versionPattern}-rc(?:\\.(0|[1-9][0-9]*))?$`)
|
||||
const stablePattern = new RegExp(`^v${versionPattern}$`)
|
||||
const versionFiles = [
|
||||
'package.json',
|
||||
'src-tauri/tauri.conf.json',
|
||||
'src-tauri/Cargo.toml',
|
||||
'Cargo.lock',
|
||||
]
|
||||
const read = (path) => readFileSync(path, 'utf8')
|
||||
const json = (path) => JSON.parse(read(path))
|
||||
const run = (command, args, options = {}) =>
|
||||
execFileSync(command, args, { encoding: 'utf8', ...options }).trim()
|
||||
const git = (...args) => run('git', args)
|
||||
const gitFile = (sha, path) =>
|
||||
execFileSync('git', ['show', `${sha}:${path}`], { encoding: 'utf8' })
|
||||
const isRc = (tag) => tag.match(rcPattern)?.[0] === tag
|
||||
const isRelease = (tag) => isRc(tag) || stablePattern.test(tag)
|
||||
const titleFor = (tag) => `${isRc(tag) ? 'Pre-Release' : 'Release'}: ${tag}`
|
||||
const branchTag = (branch, prefix = 'release/') =>
|
||||
branch.startsWith(prefix) && isRelease(branch.slice(prefix.length))
|
||||
? branch.slice(prefix.length)
|
||||
: undefined
|
||||
const latest = (tags) =>
|
||||
tags.sort((a, b) => a.localeCompare(b, 'en', { numeric: true })).at(-1)
|
||||
const summary = (text) => appendFileSync(env.GITHUB_STEP_SUMMARY, `${text}\n`)
|
||||
const endpoint = (path) =>
|
||||
path === 'graphql' ? path : `repos/${repository}/${path}`
|
||||
const list = (path) =>
|
||||
JSON.parse(run('gh', ['api', '--paginate', '--slurp', endpoint(path)])).flat()
|
||||
|
||||
function api(path, body, method = 'POST') {
|
||||
const response = run(
|
||||
'gh',
|
||||
['api', '--method', method, endpoint(path), '--input', '-'],
|
||||
{
|
||||
input: JSON.stringify(body),
|
||||
},
|
||||
)
|
||||
const result = response ? JSON.parse(response) : null
|
||||
if (result?.errors) throw new Error(JSON.stringify(result.errors))
|
||||
return result
|
||||
}
|
||||
|
||||
const get = (path) => JSON.parse(run('gh', ['api', endpoint(path)]))
|
||||
const canWrite = (login) =>
|
||||
['write', 'maintain', 'admin'].includes(
|
||||
get(`collaborators/${login}/permission`).permission,
|
||||
)
|
||||
|
||||
function output(values) {
|
||||
appendFileSync(
|
||||
env.GITHUB_OUTPUT,
|
||||
Object.entries(values)
|
||||
.map(([key, value]) => `${key}=${value}\n`)
|
||||
.join(''),
|
||||
)
|
||||
}
|
||||
|
||||
function versionPatternFor(path) {
|
||||
if (path.endsWith('.json')) return /("version"\s*:\s*")[^"]+/
|
||||
return path === 'Cargo.lock'
|
||||
? /(\[\[package\]\]\r?\nname = "clash-verge"\r?\nversion = ")[^"]+/
|
||||
: /(^version\s*=\s*")[^"]+/m
|
||||
}
|
||||
|
||||
function validateVersions(tag, sha) {
|
||||
for (const path of versionFiles) {
|
||||
const content = sha ? gitFile(sha, path) : read(path)
|
||||
const version = path.endsWith('.json')
|
||||
? JSON.parse(content).version
|
||||
: content.match(versionPatternFor(path))?.[0].split('"').at(-1)
|
||||
if (`v${version}` !== tag)
|
||||
throw new Error(`${path} version does not match ${tag}.`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateVersionCommit(tag, sha) {
|
||||
validateVersions(tag, sha)
|
||||
const commit = get(`commits/${sha}`)
|
||||
if (!commit.commit.verification.verified || commit.parents.length !== 1)
|
||||
throw new Error('The version commit must be signed and have one parent.')
|
||||
const title = commit.commit.message.split('\n')[0]
|
||||
if (title.replace(/ \(#\d+\)$/, '') !== titleFor(tag))
|
||||
throw new Error('The final version commit has an unexpected title.')
|
||||
const parent = commit.parents[0].sha
|
||||
const changed = git('diff', '--name-only', parent, sha)
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
if (
|
||||
changed.some((path) => !versionFiles.includes(path)) ||
|
||||
git('diff', '--summary', parent, sha)
|
||||
)
|
||||
throw new Error(
|
||||
'The version commit changes files outside the four version files.',
|
||||
)
|
||||
for (const path of versionFiles) {
|
||||
const before = gitFile(parent, path)
|
||||
const after = gitFile(sha, path)
|
||||
if (
|
||||
before.replace(versionPatternFor(path), '$1VERSION') !==
|
||||
after.replace(versionPatternFor(path), '$1VERSION')
|
||||
)
|
||||
throw new Error(`${path} contains changes other than its version.`)
|
||||
}
|
||||
}
|
||||
|
||||
function remoteRefs() {
|
||||
return git('ls-remote', '--heads', '--tags', '--refs', 'origin')
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => line.split('\t'))
|
||||
}
|
||||
|
||||
function fetchBranches() {
|
||||
git(
|
||||
'fetch',
|
||||
'--no-tags',
|
||||
'origin',
|
||||
'+refs/heads/dev:refs/remotes/origin/dev',
|
||||
'+refs/heads/main:refs/remotes/origin/main',
|
||||
)
|
||||
}
|
||||
|
||||
function discardClosedCandidate(branch, base, prefix) {
|
||||
const candidates = list(
|
||||
`pulls?state=all&base=${base}&head=${repository.split('/')[0]}:${branch}&per_page=100`,
|
||||
)
|
||||
const latest = candidates[0]
|
||||
if (!latest || candidates.some((pr) => pr.state === 'open'))
|
||||
throw new Error(
|
||||
`Branch ${branch} is reserved by an active or unknown candidate.`,
|
||||
)
|
||||
const pr = get(`pulls/${latest.number}`)
|
||||
requireBotPr(pr, base, prefix)
|
||||
if (pr.state !== 'closed' || pr.merged)
|
||||
throw new Error(
|
||||
`Close the unmerged candidate before retrying: ${pr.html_url}`,
|
||||
)
|
||||
updateRefs(pr.base.repo.node_id, [
|
||||
{
|
||||
name: `refs/heads/${branch}`,
|
||||
beforeOid: pr.head.sha,
|
||||
afterOid: '0'.repeat(40),
|
||||
},
|
||||
])
|
||||
summary(`Released the unused branch from closed PR: ${pr.html_url}`)
|
||||
}
|
||||
|
||||
function prepare() {
|
||||
let refresh
|
||||
if (env.GITHUB_EVENT_NAME === 'pull_request_target') {
|
||||
const event = json(env.GITHUB_EVENT_PATH)
|
||||
if (checked(event.changes?.body?.from) || !checked(event.pull_request.body))
|
||||
return
|
||||
if (!canWrite(event.sender.login))
|
||||
throw new Error('Refreshing a version PR requires write access.')
|
||||
refresh = get(`pulls/${event.pull_request.number}`)
|
||||
if (refresh.state !== 'open' || !checked(refresh.body)) return
|
||||
requireBotPr(refresh, 'dev', 'release/')
|
||||
}
|
||||
fetchBranches()
|
||||
git('checkout', '--detach', 'origin/dev')
|
||||
const channel = refresh
|
||||
? isRc(branchTag(refresh.head.ref))
|
||||
? 'rc'
|
||||
: 'stable'
|
||||
: env.RELEASE_CHANNEL || 'rc'
|
||||
if (!['rc', 'stable'].includes(channel))
|
||||
throw new Error('Unknown release channel.')
|
||||
const requested = refresh
|
||||
? branchTag(refresh.head.ref)
|
||||
: env.RELEASE_TAG || ''
|
||||
if (
|
||||
requested &&
|
||||
!(channel === 'rc' ? isRc(requested) : stablePattern.test(requested))
|
||||
)
|
||||
throw new Error(
|
||||
channel === 'rc'
|
||||
? 'Expected vX.Y.Z-rc or vX.Y.Z-rc.N.'
|
||||
: 'Expected vX.Y.Z.',
|
||||
)
|
||||
const existing =
|
||||
!refresh &&
|
||||
list('pulls?state=open&base=dev&per_page=100').find(
|
||||
(pr) =>
|
||||
pr.head.repo?.full_name === repository &&
|
||||
pr.user.login === 'github-actions[bot]' &&
|
||||
branchTag(pr.head.ref) &&
|
||||
isRc(branchTag(pr.head.ref)) === (channel === 'rc') &&
|
||||
(!requested || branchTag(pr.head.ref) === requested),
|
||||
)
|
||||
if (existing) {
|
||||
api(`issues/${existing.number}/labels`, { labels: ['release'] })
|
||||
output({ existing_pr: existing.html_url })
|
||||
summary(
|
||||
`Existing version PR: ${existing.html_url}\nClose it before preparing a replacement; its candidate is unchanged.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
const base = (requested || `v${json('package.json').version}`).split(
|
||||
/[-+]/,
|
||||
)[0]
|
||||
if (!stablePattern.test(base))
|
||||
throw new Error(`Invalid base version: ${base}`)
|
||||
const refs = remoteRefs().map(([, ref]) => ref)
|
||||
const tags = refs
|
||||
.filter((ref) => ref.startsWith('refs/tags/'))
|
||||
.map((ref) => ref.slice(10))
|
||||
const reserved = refs
|
||||
.filter((ref) => ref.startsWith('refs/heads/'))
|
||||
.filter((ref) => !refresh || ref !== `refs/heads/${refresh.head.ref}`)
|
||||
.map(
|
||||
(ref) => branchTag(ref.slice(11)) || branchTag(ref.slice(11), 'publish/'),
|
||||
)
|
||||
.filter(Boolean)
|
||||
const sameBase = (tag) => isRc(tag) && tag.startsWith(`${base}-rc`)
|
||||
const occupied = [...tags, ...reserved]
|
||||
const last = latest(occupied.filter(sameBase))
|
||||
const tag =
|
||||
requested ||
|
||||
(channel === 'stable'
|
||||
? base
|
||||
: last
|
||||
? `${base}-rc.${BigInt(last.match(rcPattern)[4] ?? 0) + 1n}`
|
||||
: `${base}-rc`)
|
||||
if (tags.includes(tag)) throw new Error(`Tag ${tag} already exists.`)
|
||||
for (const [prefix, target] of [
|
||||
['release/', 'dev'],
|
||||
['publish/', 'main'],
|
||||
]) {
|
||||
const branch = `${prefix}${tag}`
|
||||
if (refs.includes(`refs/heads/${branch}`) && branch !== refresh?.head.ref)
|
||||
discardClosedCandidate(branch, target, prefix)
|
||||
}
|
||||
const sha = git('rev-parse', 'HEAD')
|
||||
const body = releaseBody(tag, sha, tags, false)
|
||||
summary(body)
|
||||
if (
|
||||
remoteRefs().some(
|
||||
([, ref]) =>
|
||||
ref === `refs/tags/${tag}` || ref === `refs/heads/publish/${tag}`,
|
||||
)
|
||||
)
|
||||
throw new Error(`Tag ${tag} already exists or is reserved for publication.`)
|
||||
if (json('package.json').version !== tag.slice(1))
|
||||
run('node', ['scripts/release-version.mjs', tag])
|
||||
validateVersions(tag)
|
||||
const title = titleFor(tag)
|
||||
const branch = refresh
|
||||
? `release-refresh/${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}`
|
||||
: `release/${tag}`
|
||||
writeVersionPr({ sha, body, refresh, title, branch, files: versionFiles })
|
||||
}
|
||||
|
||||
function releaseBody(tag, sha, tags, publishing) {
|
||||
const previous =
|
||||
(isRc(tag) &&
|
||||
latest(
|
||||
tags.filter(
|
||||
(value) =>
|
||||
isRc(value) &&
|
||||
value.startsWith(`${tag.split('-')[0]}-rc`) &&
|
||||
value !== tag,
|
||||
),
|
||||
)) ||
|
||||
latest(tags.filter((value) => stablePattern.test(value) && value !== tag))
|
||||
const url = `${env.GITHUB_SERVER_URL}/${repository}`
|
||||
let range = sha
|
||||
let comparison = 'No previous release tag'
|
||||
if (previous) {
|
||||
git('fetch', '--no-tags', 'origin', `refs/tags/${previous}`)
|
||||
range = `${git('rev-parse', 'FETCH_HEAD^{commit}')}..${sha}`
|
||||
comparison = `Changes since **${previous}** · [Compare changes](${url}/compare/${previous}...${sha})`
|
||||
}
|
||||
comparison += ` · **${git('rev-list', '--count', range)} commits**`
|
||||
const commits = git('log', '--format=%h %s', range)
|
||||
const groups = Object.groupBy(commits ? commits.split('\n') : [], (line) => {
|
||||
if (/^\S+ chore\(deps\)!?: /i.test(line)) return 'deps'
|
||||
const type = line.match(/^\S+ (\w+)(?:\([^)]*\))?!?: /)?.[1]?.toLowerCase()
|
||||
return type === 'doc' ? 'docs' : type || 'other'
|
||||
})
|
||||
const labels = {
|
||||
fix: 'Fixes',
|
||||
feat: 'Features',
|
||||
perf: 'Performance',
|
||||
revert: 'Reverts',
|
||||
refactor: 'Refactoring',
|
||||
build: 'Build',
|
||||
deps: 'Dependencies',
|
||||
ci: 'CI',
|
||||
docs: 'Documentation',
|
||||
test: 'Tests',
|
||||
chore: 'Maintenance',
|
||||
style: 'Style',
|
||||
other: 'Other',
|
||||
}
|
||||
const names = {
|
||||
macos: 'macOS',
|
||||
windows: 'Windows',
|
||||
ui: 'UI',
|
||||
ci: 'CI',
|
||||
tun: 'TUN',
|
||||
}
|
||||
const order = Object.keys(labels)
|
||||
const rank = (type) =>
|
||||
order.includes(type) ? order.indexOf(type) : order.length
|
||||
const scope =
|
||||
Object.entries(groups)
|
||||
.sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b))
|
||||
.map(([type, lines]) => {
|
||||
const entries = lines.map((line) => {
|
||||
const hash = line.slice(0, line.indexOf(' '))
|
||||
const title = line
|
||||
.slice(hash.length + 1)
|
||||
.replace(/\s*\((?:[\w.-]+\/[\w.-]+)?#\d+\)/g, '')
|
||||
.replace(/(?:[\w.-]+\/[\w.-]+)?#\d+\b/g, '')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim()
|
||||
.replace(
|
||||
/^\w+(?:\(([^)]*)\))?(!)?:\s*/,
|
||||
(_, scope, breaking) =>
|
||||
`${breaking ? 'Breaking: ' : ''}${scope ? `${Object.hasOwn(names, scope) ? names[scope] : scope}: ` : ''}`,
|
||||
)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replace(/[\\`*_[\]]/g, '\\$&')
|
||||
return `- ${title} [#${hash}](${url}/commit/${hash})`
|
||||
})
|
||||
return `### ${Object.hasOwn(labels, type) ? labels[type] : type} · ${lines.length}\n\n${entries.join('\n')}`
|
||||
})
|
||||
.join('\n\n') || 'No new commits.'
|
||||
return `## ${titleFor(tag)}
|
||||
|
||||
Source: \`dev\` · [${sha}](${url}/commit/${sha}) \n${comparison}
|
||||
${
|
||||
publishing
|
||||
? `<!-- release-source:${sha} -->
|
||||
|
||||
After approval, select the checkbox below to publish.
|
||||
Do not use the normal Squash or Rebase merge buttons.
|
||||
|
||||
- [ ] ${publishLabel}`
|
||||
: `
|
||||
- [ ] ${refreshLabel}
|
||||
|
||||
Refreshing requires a new approval.`
|
||||
}
|
||||
|
||||
## Changes
|
||||
|
||||
${scope}
|
||||
`
|
||||
}
|
||||
|
||||
function writeVersionPr({ sha, body, refresh, title, branch, files }) {
|
||||
// Create-only ref and expectedHeadOid prevent replacing another candidate.
|
||||
api('git/refs', { ref: `refs/heads/${branch}`, sha })
|
||||
let pr
|
||||
try {
|
||||
const commit = api('graphql', {
|
||||
query: `mutation($input: CreateCommitOnBranchInput!) {
|
||||
createCommitOnBranch(input: $input) { commit { oid } }
|
||||
}`,
|
||||
variables: {
|
||||
input: {
|
||||
branch: { repositoryNameWithOwner: repository, branchName: branch },
|
||||
expectedHeadOid: sha,
|
||||
message: { headline: title },
|
||||
fileChanges: {
|
||||
additions: files.map((path) => ({
|
||||
path,
|
||||
contents: readFileSync(path).toString('base64'),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!commit.data?.createCommitOnBranch?.commit?.oid)
|
||||
throw new Error('Signed version commit failed.')
|
||||
if (refresh) {
|
||||
const current = get(`pulls/${refresh.number}`)
|
||||
if (
|
||||
current.state !== 'open' ||
|
||||
current.head.sha !== refresh.head.sha ||
|
||||
current.base.ref !== refresh.base.ref ||
|
||||
current.body !== refresh.body
|
||||
)
|
||||
throw new Error(
|
||||
'The version pull request changed while preparing the update.',
|
||||
)
|
||||
const oid = commit.data.createCommitOnBranch.commit.oid
|
||||
updateRefs(refresh.base.repo.node_id, [
|
||||
{
|
||||
name: `refs/heads/${refresh.head.ref}`,
|
||||
beforeOid: refresh.head.sha,
|
||||
afterOid: oid,
|
||||
force: true,
|
||||
},
|
||||
{
|
||||
name: `refs/heads/${branch}`,
|
||||
beforeOid: oid,
|
||||
afterOid: '0'.repeat(40),
|
||||
},
|
||||
])
|
||||
for (const review of list(
|
||||
`pulls/${refresh.number}/reviews?per_page=100`,
|
||||
)) {
|
||||
if (review.state === 'APPROVED')
|
||||
api(
|
||||
`pulls/${refresh.number}/reviews/${review.id}/dismissals`,
|
||||
{
|
||||
message:
|
||||
'The version PR was refreshed from dev; please review again.',
|
||||
},
|
||||
'PUT',
|
||||
)
|
||||
}
|
||||
pr = api(`pulls/${refresh.number}`, { title, body }, 'PATCH')
|
||||
} else {
|
||||
pr = api('pulls', {
|
||||
head: branch,
|
||||
base: 'dev',
|
||||
title,
|
||||
body,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if (refresh) {
|
||||
try {
|
||||
api(`git/refs/heads/${branch}`, {}, 'DELETE')
|
||||
} catch {}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
api(`issues/${pr.number}/labels`, { labels: ['release'] })
|
||||
summary(`Version pull request: ${pr.html_url}`)
|
||||
}
|
||||
|
||||
function updateRefs(repositoryId, refUpdates) {
|
||||
api('graphql', {
|
||||
query: `mutation($input: UpdateRefsInput!) { updateRefs(input: $input) { clientMutationId } }`,
|
||||
variables: { input: { repositoryId, refUpdates } },
|
||||
})
|
||||
}
|
||||
|
||||
function requireBotPr(pr, base, prefix) {
|
||||
const tag = branchTag(pr.head.ref, prefix)
|
||||
if (
|
||||
!tag ||
|
||||
pr.base.ref !== base ||
|
||||
pr.base.repo.full_name !== repository ||
|
||||
pr.head.repo?.full_name !== repository ||
|
||||
pr.user.login !== 'github-actions[bot]'
|
||||
)
|
||||
throw new Error(
|
||||
'Expected a release PR created by this repository workflow.',
|
||||
)
|
||||
return tag
|
||||
}
|
||||
|
||||
function publishPr() {
|
||||
const event = json(env.GITHUB_EVENT_PATH)
|
||||
const pr = get(`pulls/${event.pull_request.number}`)
|
||||
const tag = requireBotPr(pr, 'dev', 'release/')
|
||||
if (!pr.merged || pr.merge_commit_sha !== event.pull_request.merge_commit_sha)
|
||||
throw new Error('Expected the final merged version commit on dev.')
|
||||
const sha = pr.merge_commit_sha
|
||||
fetchBranches()
|
||||
git('fetch', '--no-tags', 'origin', sha)
|
||||
git('merge-base', '--is-ancestor', sha, 'origin/dev')
|
||||
validateVersionCommit(tag, sha)
|
||||
const branch = `publish/${tag}`
|
||||
const existing = list(
|
||||
`pulls?state=open&base=main&head=${repository.split('/')[0]}:${branch}&per_page=100`,
|
||||
)
|
||||
if (existing.length) {
|
||||
const publication = existing[0]
|
||||
requireBotPr(publication, 'main', 'publish/')
|
||||
if (publication.head.sha !== sha)
|
||||
throw new Error(
|
||||
'This tag is already reserved by a different publication snapshot.',
|
||||
)
|
||||
summary(`Existing publication PR: ${publication.html_url}`)
|
||||
return
|
||||
}
|
||||
const refs = remoteRefs()
|
||||
if (refs.some(([, ref]) => ref === `refs/tags/${tag}`))
|
||||
throw new Error(`Tag ${tag} already exists.`)
|
||||
git('merge-base', '--is-ancestor', 'origin/main', sha)
|
||||
const head = refs.find(([, ref]) => ref === `refs/heads/${branch}`)?.[0]
|
||||
if (head && head !== sha)
|
||||
throw new Error('Publication branch already points to another commit.')
|
||||
if (!head) api('git/refs', { ref: `refs/heads/${branch}`, sha })
|
||||
const body = releaseBody(
|
||||
tag,
|
||||
sha,
|
||||
refs
|
||||
.filter(([, ref]) => ref.startsWith('refs/tags/'))
|
||||
.map(([, ref]) => ref.slice(10)),
|
||||
true,
|
||||
)
|
||||
const publication = api('pulls', {
|
||||
head: branch,
|
||||
base: 'main',
|
||||
title: titleFor(tag),
|
||||
body,
|
||||
})
|
||||
api(`issues/${publication.number}/labels`, { labels: ['release'] })
|
||||
summary(`Publication PR: ${publication.html_url}\n\n${body}`)
|
||||
}
|
||||
|
||||
function requireApproval(pr, sha) {
|
||||
const reviews = new Map()
|
||||
for (const review of list(`pulls/${pr.number}/reviews?per_page=100`)) {
|
||||
if (['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(review.state))
|
||||
reviews.set(review.user.login, review)
|
||||
}
|
||||
let approved = false
|
||||
for (const review of reviews.values()) {
|
||||
if (!canWrite(review.user.login)) continue
|
||||
if (review.state === 'CHANGES_REQUESTED')
|
||||
throw new Error('A reviewer with write access has requested changes.')
|
||||
if (review.state === 'APPROVED' && review.commit_id === sha) approved = true
|
||||
}
|
||||
if (!approved)
|
||||
throw new Error(
|
||||
'The current PR head needs approval from a reviewer with write access.',
|
||||
)
|
||||
}
|
||||
|
||||
function releaseOutput(tag, sha) {
|
||||
output({
|
||||
release_tag: tag,
|
||||
source_sha: sha,
|
||||
channel: isRc(tag) ? 'rc' : 'stable',
|
||||
})
|
||||
}
|
||||
|
||||
function publish() {
|
||||
const event = json(env.GITHUB_EVENT_PATH)
|
||||
const tag = requireBotPr(event.pull_request, 'main', 'publish/')
|
||||
if (
|
||||
checked(event.changes?.body?.from, publishLabel) ||
|
||||
!checked(event.pull_request.body, publishLabel)
|
||||
)
|
||||
throw new Error('Publishing requires a new checkbox selection.')
|
||||
if (!canWrite(event.sender.login))
|
||||
throw new Error('Publishing requires write access.')
|
||||
const sha = event.pull_request.head.sha
|
||||
const pr = get(`pulls/${event.pull_request.number}`)
|
||||
requireBotPr(pr, 'main', 'publish/')
|
||||
if (
|
||||
pr.head.sha !== sha ||
|
||||
!checked(pr.body, publishLabel) ||
|
||||
!pr.body.includes(`<!-- release-source:${sha} -->`)
|
||||
)
|
||||
throw new Error(
|
||||
'The publication snapshot changed after the checkbox selection.',
|
||||
)
|
||||
fetchBranches()
|
||||
git('fetch', '--no-tags', 'origin', sha)
|
||||
git('merge-base', '--is-ancestor', sha, 'origin/dev')
|
||||
validateVersionCommit(tag, sha)
|
||||
const marker = `Release workflow run: ${env.GITHUB_RUN_ID}\nSource: ${sha}\nPR: ${pr.number}`
|
||||
const existing = remoteRefs().find(([, ref]) => ref === `refs/tags/${tag}`)
|
||||
if (existing) {
|
||||
const ref = get(`git/ref/tags/${tag}`)
|
||||
const object =
|
||||
ref.object.type === 'tag' && get(`git/tags/${ref.object.sha}`)
|
||||
if (
|
||||
!object ||
|
||||
object.object.type !== 'commit' ||
|
||||
object.object.sha !== sha ||
|
||||
object.message.trim() !== marker ||
|
||||
Number(env.GITHUB_RUN_ATTEMPT) <= 1
|
||||
)
|
||||
throw new Error(
|
||||
'An existing tag can only be recovered by its original release run.',
|
||||
)
|
||||
git('merge-base', '--is-ancestor', sha, 'origin/main')
|
||||
summary(`Resuming ${tag} at ${sha} from this release run.`)
|
||||
} else {
|
||||
if (pr.state !== 'open' || pr.draft)
|
||||
throw new Error('Expected an open, ready publication PR.')
|
||||
requireApproval(pr, sha)
|
||||
const main = git('rev-parse', 'origin/main')
|
||||
git('merge-base', '--is-ancestor', main, sha)
|
||||
git(
|
||||
'-c',
|
||||
'user.name=github-actions[bot]',
|
||||
'-c',
|
||||
'user.email=41898282+github-actions[bot]@users.noreply.github.com',
|
||||
'tag',
|
||||
'--annotate',
|
||||
'--no-sign',
|
||||
'--message',
|
||||
marker,
|
||||
tag,
|
||||
sha,
|
||||
)
|
||||
// GraphQL updateRefs rejects annotated tag objects; Git preserves atomicity.
|
||||
git(
|
||||
'push',
|
||||
'--atomic',
|
||||
`--force-with-lease=refs/heads/main:${main}`,
|
||||
`--force-with-lease=refs/heads/${pr.head.ref}:${sha}`,
|
||||
`--force-with-lease=refs/tags/${tag}:`,
|
||||
'origin',
|
||||
`${sha}:refs/heads/main`,
|
||||
`${sha}:refs/heads/${pr.head.ref}`,
|
||||
`refs/tags/${tag}:refs/tags/${tag}`,
|
||||
)
|
||||
summary(`Fast-forwarded main and tagged ${tag} at approved commit ${sha}.`)
|
||||
}
|
||||
releaseOutput(tag, sha)
|
||||
}
|
||||
|
||||
function validateTag() {
|
||||
const tag = env.GITHUB_REF_NAME
|
||||
if (!isRelease(tag)) throw new Error('Expected a stable or RC release tag.')
|
||||
fetchBranches()
|
||||
const sha = git('rev-parse', `${env.GITHUB_SHA}^{commit}`)
|
||||
const ref = get(`git/ref/tags/${tag}`)
|
||||
const object = ref.object.type === 'tag' && get(`git/tags/${ref.object.sha}`)
|
||||
if (object && object.message.includes('Release workflow run:'))
|
||||
throw new Error(
|
||||
'Resume the original publication run for workflow-owned tags.',
|
||||
)
|
||||
git('fetch', '--no-tags', 'origin', `refs/tags/${tag}`)
|
||||
if (git('rev-parse', 'FETCH_HEAD^{commit}') !== sha)
|
||||
throw new Error('The tag moved after this run started.')
|
||||
for (const branch of ['main', 'dev'])
|
||||
git('merge-base', '--is-ancestor', sha, `origin/${branch}`)
|
||||
validateVersions(tag, sha)
|
||||
releaseOutput(tag, sha)
|
||||
}
|
||||
|
||||
function prepareDraft() {
|
||||
const tag = env.RELEASE_TAG
|
||||
verifyTarget()
|
||||
const release = list('releases?per_page=100').find(
|
||||
(release) => release.tag_name === tag,
|
||||
)
|
||||
if (release && !release.draft)
|
||||
throw new Error('This release is already public.')
|
||||
const draft =
|
||||
release ||
|
||||
api('releases', {
|
||||
tag_name: tag,
|
||||
target_commitish: env.SOURCE_SHA,
|
||||
name: `Clash Verge Rev ${tag}`,
|
||||
body: 'Draft release, will be updated later.',
|
||||
draft: true,
|
||||
prerelease: isRc(tag),
|
||||
})
|
||||
output({ id: draft.id })
|
||||
}
|
||||
|
||||
function verifyTarget(tag = env.RELEASE_TAG, sha = env.SOURCE_SHA) {
|
||||
const ref = get(`git/ref/tags/${tag}`)
|
||||
const target =
|
||||
ref.object.type === 'tag'
|
||||
? get(`git/tags/${ref.object.sha}`).object
|
||||
: ref.object
|
||||
if (target.type !== 'commit' || target.sha !== sha)
|
||||
throw new Error('The release tag no longer points to the validated source.')
|
||||
}
|
||||
|
||||
function nextDev() {
|
||||
let refresh
|
||||
let tag = env.RELEASE_TAG
|
||||
let source = env.SOURCE_SHA
|
||||
let minor = false
|
||||
if (env.GITHUB_EVENT_NAME === 'pull_request_target') {
|
||||
const event = json(env.GITHUB_EVENT_PATH)
|
||||
const oldBody = event.changes?.body?.from
|
||||
const body = event.pull_request.body
|
||||
if (
|
||||
checked(oldBody, minorLabel) === checked(body, minorLabel) &&
|
||||
(checked(oldBody) || !checked(body))
|
||||
)
|
||||
return
|
||||
if (!canWrite(event.sender.login))
|
||||
throw new Error('Changing the next dev version requires write access.')
|
||||
refresh = get(`pulls/${event.pull_request.number}`)
|
||||
if (refresh.state !== 'open') return
|
||||
tag = requireBotPr(refresh, 'dev', 'next-dev/')
|
||||
if (
|
||||
refresh.head.sha !== event.pull_request.head.sha ||
|
||||
refresh.body !== body
|
||||
)
|
||||
throw new Error(
|
||||
'The version update PR changed after the checkbox selection.',
|
||||
)
|
||||
minor = checked(body, minorLabel)
|
||||
source = body.match(/<!-- next-dev-source:([a-f0-9]{40}) -->/)?.[1]
|
||||
}
|
||||
if (!stablePattern.test(tag) || !/^[a-f0-9]{40}$/.test(source))
|
||||
throw new Error('The next dev version requires a stable release snapshot.')
|
||||
verifyTarget(tag, source)
|
||||
const release = get(`releases/tags/${tag}`)
|
||||
if (release.draft || release.prerelease)
|
||||
throw new Error('The stable release must be public before preparing dev.')
|
||||
const branch = `next-dev/${tag}`
|
||||
if (!refresh) {
|
||||
const existing = list(
|
||||
`pulls?state=all&base=dev&head=${repository.split('/')[0]}:${branch}&per_page=100`,
|
||||
)[0]
|
||||
if (existing) {
|
||||
requireBotPr(existing, 'dev', 'next-dev/')
|
||||
summary(`Existing dev version PR: ${existing.html_url}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
fetchBranches()
|
||||
git('fetch', '--no-tags', 'origin', source)
|
||||
git('merge-base', '--is-ancestor', source, 'origin/main')
|
||||
git('merge-base', '--is-ancestor', source, 'origin/dev')
|
||||
git('checkout', '--detach', 'origin/dev')
|
||||
const sha = git('rev-parse', 'HEAD')
|
||||
validateVersions(tag, source)
|
||||
validateVersions(tag, sha)
|
||||
if (read('Changelog.md') !== gitFile(source, 'Changelog.md'))
|
||||
throw new Error(
|
||||
'Changelog.md changed after this release. Preserve the unreleased entries and prepare the dev update manually.',
|
||||
)
|
||||
if (
|
||||
!read('Changelog.md').split('\n').includes(`## ${tag}`) ||
|
||||
read('docs/Changelog.history.md').split('\n').includes(`## ${tag}`)
|
||||
)
|
||||
throw new Error('The released changelog is missing or already archived.')
|
||||
const [major, minorVersion, patch] = tag.slice(1).split('.').map(BigInt)
|
||||
const patchTag = `v${major}.${minorVersion}.${patch + 1n}`
|
||||
const minorTag = `v${major}.${minorVersion + 1n}.0`
|
||||
const nextTag = minor ? minorTag : patchTag
|
||||
run('pnpm', ['release-version', nextTag])
|
||||
run('bash', ['scripts-workflow/bump_changelog.sh'])
|
||||
const changelog = read('Changelog.md')
|
||||
if (!changelog.includes('## v(Version Goes Here)'))
|
||||
throw new Error('The changelog template has no version placeholder.')
|
||||
writeFileSync(
|
||||
'Changelog.md',
|
||||
changelog.replace('## v(Version Goes Here)', `## ${nextTag}`),
|
||||
)
|
||||
validateVersions(nextTag)
|
||||
const title = `Dev: ${nextTag}`
|
||||
const body = `## ${title}
|
||||
|
||||
After [${tag}](${env.GITHUB_SERVER_URL}/${repository}/releases/tag/${tag}).
|
||||
<!-- next-dev-source:${source} -->
|
||||
|
||||
Patch: \`${patchTag}\` · Minor: \`${minorTag}\`
|
||||
|
||||
- [${minor ? 'x' : ' '}] ${minorLabel}
|
||||
- [ ] ${refreshLabel}
|
||||
|
||||
Changing the version or refreshing requires a new approval.
|
||||
`
|
||||
writeVersionPr({
|
||||
sha,
|
||||
body,
|
||||
refresh,
|
||||
title,
|
||||
branch: refresh
|
||||
? `next-dev-refresh/${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}`
|
||||
: branch,
|
||||
files: [...versionFiles, 'Changelog.md', 'docs/Changelog.history.md'],
|
||||
})
|
||||
}
|
||||
|
||||
const commands = {
|
||||
prepare,
|
||||
'next-dev': nextDev,
|
||||
'publish-pr': publishPr,
|
||||
publish,
|
||||
'validate-tag': validateTag,
|
||||
'prepare-draft': prepareDraft,
|
||||
'verify-target': verifyTarget,
|
||||
}
|
||||
const command = commands[process.argv[2]]
|
||||
if (!command) throw new Error('Unknown release command.')
|
||||
command()
|
||||
@@ -1,84 +1,34 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
/**
|
||||
* Architecture grep gates for the frontend contract flow. Select gates via
|
||||
* argv:
|
||||
*
|
||||
* node scripts/check-architecture.mjs listen emit invoke
|
||||
*
|
||||
* - listen/emit: the Tauri event API may only be used under src/services/bus/**
|
||||
* (the event bus). Detected as a bare `listen(`/`emit(` call (member calls
|
||||
* like `this.emit(` are unrelated class methods) or as importing the name
|
||||
* from '@tauri-apps/api/event'.
|
||||
* - invoke: `invoke(` calls may exist only under src/services/**.
|
||||
*/
|
||||
import { ESLint } from 'eslint'
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const SRC = path.join(ROOT, 'src')
|
||||
|
||||
const tauriEventImport = (name) =>
|
||||
new RegExp(
|
||||
`import\\s*\\{[^}]*\\b${name}\\b[^}]*\\}\\s*from\\s*'@tauri-apps/api/event'`,
|
||||
)
|
||||
|
||||
const GATES = {
|
||||
listen: {
|
||||
patterns: [/(?<![.\w])listen\(/, tauriEventImport('listen')],
|
||||
allowed: (file) => file.startsWith('src/services/bus/'),
|
||||
description: 'Tauri listen outside the event bus (src/services/bus)',
|
||||
},
|
||||
emit: {
|
||||
patterns: [/(?<![.\w])emit\(/, tauriEventImport('emit')],
|
||||
allowed: (file) => file.startsWith('src/services/bus/'),
|
||||
description: 'Tauri emit outside the event bus (src/services/bus)',
|
||||
},
|
||||
invoke: {
|
||||
patterns: [/\binvoke\(/],
|
||||
allowed: (file) => file.startsWith('src/services/'),
|
||||
description: 'invoke( outside the services layer (src/services)',
|
||||
},
|
||||
}
|
||||
|
||||
const walk = (dir) => {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||
return entries.flatMap((entry) => {
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) return walk(full)
|
||||
return /\.(ts|tsx|js|jsx|mts|mjs)$/.test(entry.name) ? [full] : []
|
||||
})
|
||||
}
|
||||
|
||||
const selected = process.argv.slice(2)
|
||||
if (selected.length === 0) {
|
||||
const gates = process.argv.slice(2)
|
||||
if (
|
||||
!gates.length ||
|
||||
gates.some((gate) => !['listen', 'emit', 'invoke'].includes(gate))
|
||||
) {
|
||||
console.error('usage: check-architecture.mjs <gate>... (listen|emit|invoke)')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
let violations = 0
|
||||
for (const gate of selected) {
|
||||
const rule = GATES[gate]
|
||||
if (!rule) {
|
||||
console.error(`unknown gate: ${gate}`)
|
||||
process.exit(2)
|
||||
}
|
||||
for (const file of walk(SRC)) {
|
||||
const relative = path.relative(ROOT, file).replaceAll('\\', '/')
|
||||
if (rule.allowed(relative)) continue
|
||||
const lines = fs.readFileSync(file, 'utf8').split('\n')
|
||||
lines.forEach((line, index) => {
|
||||
if (rule.patterns.some((pattern) => pattern.test(line))) {
|
||||
violations += 1
|
||||
console.error(
|
||||
`[arch] ${rule.description}: ${relative}:${index + 1}: ${line.trim()}`,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
// All IPC boundaries use the lint configuration, including when callers request a legacy gate.
|
||||
const eslint = new ESLint({ cwd: ROOT, allowInlineConfig: false })
|
||||
const results = await eslint.lintFiles(['src'])
|
||||
const boundaryRules = new Set([
|
||||
'no-restricted-imports',
|
||||
'no-restricted-syntax',
|
||||
'@typescript-eslint/no-floating-promises',
|
||||
])
|
||||
const violations = results.flatMap((result) =>
|
||||
result.messages
|
||||
.filter((message) => message.fatal || boundaryRules.has(message.ruleId))
|
||||
.map((message) => ({ file: result.filePath, ...message })),
|
||||
)
|
||||
for (const violation of violations) {
|
||||
console.error(
|
||||
`[arch] ${path.relative(ROOT, violation.file)}:${violation.line}: ${violation.message}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (violations > 0) {
|
||||
console.error(`[arch] ${violations} violation(s) found`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('[arch] no violations')
|
||||
if (violations.length) process.exitCode = 1
|
||||
else console.log('[arch] no violations')
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const git = (...args) =>
|
||||
execFileSync('git', args, { cwd: ROOT, encoding: 'utf8' }).trim()
|
||||
const { contract } = JSON.parse(
|
||||
fs.readFileSync(path.join(ROOT, 'scripts/contract-inputs.json'), 'utf8'),
|
||||
)
|
||||
const inputs = contract.map((pattern) => `:(glob)${pattern}`)
|
||||
const args = process.argv.slice(2)
|
||||
if (args.length !== 2 || args[0] !== '--push') {
|
||||
console.error('usage: check-contract-changes.mjs --push <remote>')
|
||||
process.exit(2)
|
||||
}
|
||||
const hasChanges = (...range) =>
|
||||
git('diff', '--name-only', ...range, '--', ...inputs) !== ''
|
||||
const assertCleanInputs = (...range) => {
|
||||
if (
|
||||
hasChanges(...range) ||
|
||||
git('ls-files', '--others', '--exclude-standard', '--', ...inputs)
|
||||
) {
|
||||
throw new Error(
|
||||
'Contract inputs differ from the pushed commit. Commit those changes before pushing.',
|
||||
)
|
||||
}
|
||||
}
|
||||
let relevant = false
|
||||
const head = git('rev-parse', 'HEAD')
|
||||
for (const line of fs
|
||||
.readFileSync(0, 'utf8')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean)) {
|
||||
const fields = line.trim().split(/\s+/)
|
||||
if (
|
||||
fields.length !== 4 ||
|
||||
![fields[1], fields[3]].every((sha) => /^[a-f0-9]{40,64}$/.test(sha))
|
||||
) {
|
||||
throw new Error('Invalid pre-push ref update')
|
||||
}
|
||||
const [, local, , remote] = fields
|
||||
if (/^0+$/.test(local) || local === remote) continue
|
||||
// A new remote branch has no upstream; use its fork point with the remote default branch.
|
||||
const base = /^0+$/.test(remote)
|
||||
? git('merge-base', local, `refs/remotes/${args[1]}/HEAD`)
|
||||
: remote
|
||||
if (!hasChanges(base, local)) continue
|
||||
const commit = git('rev-parse', `${local}^{commit}`)
|
||||
if (commit !== head)
|
||||
throw new Error(
|
||||
'Check out the pushed commit before validating its changed IPC contract.',
|
||||
)
|
||||
assertCleanInputs('HEAD')
|
||||
relevant = true
|
||||
}
|
||||
if (relevant) {
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[path.join(ROOT, 'scripts/gen-contract.mjs'), '--check'],
|
||||
{
|
||||
cwd: ROOT,
|
||||
stdio: 'inherit',
|
||||
},
|
||||
)
|
||||
} else {
|
||||
console.log('[contract] inputs unchanged; skipping Rust generation')
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"contract": [
|
||||
"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"
|
||||
]
|
||||
}
|
||||
+46
-82
@@ -1,91 +1,55 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
/**
|
||||
* Regenerates src/services/contract.ts from the backend wire contract.
|
||||
* Source of truth: `cargo run -p clash-verge --bin frontend-contract` inside
|
||||
* src-tauri, which serializes the same enum variants and event payloads the
|
||||
* app emits at runtime. CI reruns this and fails on a non-empty diff.
|
||||
*/
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const OUT = path.join(ROOT, 'src/services/contract.ts')
|
||||
|
||||
const contract = JSON.parse(
|
||||
const BINDINGS = path.join(ROOT, 'src/services/bindings.ts')
|
||||
const args = process.argv.slice(2)
|
||||
if (args.length > 1 || (args.length === 1 && args[0] !== '--check')) {
|
||||
console.error('usage: gen-contract.mjs [--check]')
|
||||
process.exit(2)
|
||||
}
|
||||
const check = args[0] === '--check'
|
||||
const temporary = check
|
||||
? fs.mkdtempSync(path.join(os.tmpdir(), 'verge-contract-'))
|
||||
: null
|
||||
const output = temporary ? path.join(temporary, 'bindings.ts') : BINDINGS
|
||||
try {
|
||||
execFileSync(
|
||||
'cargo',
|
||||
['run', '-p', 'clash-verge', '--bin', 'frontend-contract', '-q'],
|
||||
{
|
||||
cwd: path.join(ROOT, 'src-tauri'),
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
if (contract.version !== 1) {
|
||||
throw new Error(`unsupported wire contract version: ${contract.version}`)
|
||||
}
|
||||
|
||||
/** TS type per event, derived from the serialized sample payload's JSON shape. */
|
||||
const payloadType = (name, sample) => {
|
||||
if (sample === null) return 'null'
|
||||
if (typeof sample === 'string') return 'string'
|
||||
if (Array.isArray(sample)) {
|
||||
if (name === 'verge://notice-message') return '[NoticeStatus, string]'
|
||||
throw new Error(`unexpected array payload for ${name}`)
|
||||
[
|
||||
'run',
|
||||
'-p',
|
||||
'clash-verge',
|
||||
'--example',
|
||||
'frontend-contract',
|
||||
'--locked',
|
||||
'-q',
|
||||
...(check ? ['--', output] : []),
|
||||
],
|
||||
{ cwd: ROOT, stdio: 'inherit' },
|
||||
)
|
||||
const formatted = execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(ROOT, 'node_modules/@biomejs/biome/bin/biome'),
|
||||
'format',
|
||||
'--stdin-file-path',
|
||||
BINDINGS,
|
||||
],
|
||||
{ cwd: ROOT, input: fs.readFileSync(output) },
|
||||
)
|
||||
if (check) {
|
||||
if (!formatted.equals(fs.readFileSync(BINDINGS))) {
|
||||
console.error(
|
||||
'IPC bindings are out of date. Run pnpm gen:contract and commit the generated file.',
|
||||
)
|
||||
process.exitCode = 1
|
||||
}
|
||||
} else {
|
||||
fs.writeFileSync(BINDINGS, formatted)
|
||||
}
|
||||
if (typeof sample === 'object') {
|
||||
const keys = Object.keys(sample)
|
||||
if (keys.length === 1 && keys[0] === 'uid') return '{ uid: string }'
|
||||
if (name === 'verge://run-state-changed') return 'RunState'
|
||||
throw new Error(`unexpected object payload for ${name}: ${keys.join(',')}`)
|
||||
}
|
||||
throw new Error(`unexpected payload for ${name}`)
|
||||
} finally {
|
||||
if (temporary) fs.rmSync(temporary, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
const needsRunState = contract.events.some(
|
||||
(e) => e.name === 'verge://run-state-changed',
|
||||
)
|
||||
if (!needsRunState)
|
||||
throw new Error('run-state-changed event missing from contract')
|
||||
|
||||
const lines = []
|
||||
lines.push(
|
||||
'// @generated by scripts/gen-contract.mjs from the backend wire contract. DO NOT EDIT.',
|
||||
)
|
||||
lines.push('// Regen: pnpm gen:contract')
|
||||
lines.push('')
|
||||
lines.push("import type { RunState } from './cmds'")
|
||||
lines.push('')
|
||||
lines.push(
|
||||
'/** Wire status strings from NoticeStatus; keep in sync via `pnpm gen:contract`. */',
|
||||
)
|
||||
lines.push('export type NoticeStatus =')
|
||||
for (const status of contract.noticeStatuses) {
|
||||
lines.push(` | ${JSON.stringify(status)}`)
|
||||
}
|
||||
lines.push('')
|
||||
lines.push('/** Event name → payload type for every backend-emitted event. */')
|
||||
lines.push('export interface VergeEventPayloads {')
|
||||
for (const event of contract.events) {
|
||||
const key = JSON.stringify(event.name)
|
||||
lines.push(` ${key}: ${payloadType(event.name, event.sample)}`)
|
||||
}
|
||||
lines.push('}')
|
||||
lines.push('')
|
||||
|
||||
const content = `${lines.join('\n')}\n`
|
||||
fs.writeFileSync(OUT, content)
|
||||
|
||||
// Match the repo's biome style so CI parity (`git diff --exit-code`) is stable.
|
||||
execFileSync(
|
||||
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
|
||||
['exec', 'biome', 'format', '--write', path.relative(ROOT, OUT)],
|
||||
{ cwd: ROOT, stdio: 'ignore' },
|
||||
)
|
||||
|
||||
console.log(
|
||||
`[gen-contract] wrote ${path.relative(ROOT, OUT)}: ${contract.events.length} events, ${contract.noticeStatuses.length} statuses`,
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
+24
-18
@@ -29,13 +29,12 @@ perf-harness = ["dep:tauri-plugin-wdio-webdriver"]
|
||||
identifier = "io.github.clash-verge-rev.clash-verge-rev"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.7.1", 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 }
|
||||
@@ -73,48 +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 = "2.4.0"
|
||||
tauri-plugin-opener = "2.7.0"
|
||||
tauri-plugin-dialog = "2.8.1"
|
||||
tauri-plugin-fs = "2.6.0"
|
||||
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.1"
|
||||
tauri-plugin-deep-link = "2.6.2"
|
||||
tauri-plugin-window-state = "2.5.0"
|
||||
zip = "9.0.1"
|
||||
zip = "9.1.3"
|
||||
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 = "2.5.1"
|
||||
tauri-plugin-notification = "2.7.0"
|
||||
tokio-stream = "0.1.19"
|
||||
backon = { version = "1.6.0", features = ["tokio-sleep"] }
|
||||
tauri-plugin-http = "2.8.0"
|
||||
tauri-plugin-http = "2.8.3"
|
||||
tracing = { workspace = true }
|
||||
tauri-plugin-devtools = { version = "2.2.2", optional = true }
|
||||
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "main" }
|
||||
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"] }
|
||||
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.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.4.0", optional = true }
|
||||
tauri-plugin-wdio-webdriver = { version = "=1.5.0", optional = true }
|
||||
objc2 = "0.6.5"
|
||||
objc2-foundation = { version = "0.3.2", features = [
|
||||
"NSString",
|
||||
@@ -147,8 +153,8 @@ windows-sys.workspace = true
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-autostart = "2.7.0"
|
||||
tauri-plugin-global-shortcut = "2.4.0"
|
||||
tauri-plugin-updater = "2.13.1"
|
||||
tauri-plugin-global-shortcut = "2.4.1"
|
||||
tauri-plugin-updater = "2.14.1"
|
||||
|
||||
[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")]
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// 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
|
||||
.next()
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../src/services/bindings.ts"));
|
||||
if args.next().is_some() {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "expected at most one output path").into());
|
||||
}
|
||||
app_lib::ipc::builder().export(specta_typescript::Typescript::default(), output)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
//! Prints the frontend wire contract as JSON to stdout. Zero new dependencies:
|
||||
//! the payload is derived from the same serializers the app uses at runtime.
|
||||
//! Consumed by scripts/gen-contract.mjs; CI regenerates and diffs the committed
|
||||
//! TypeScript artifact.
|
||||
|
||||
fn main() {
|
||||
println!("{}", app_lib::frontend_wire_contract());
|
||||
}
|
||||
@@ -9,12 +9,14 @@ use smartstring::alias::String;
|
||||
use tauri::{AppHandle, Manager as _, ipc::Channel};
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn open_app_dir() -> CmdResult<()> {
|
||||
let app_dir = dirs::app_home_dir().stringify_err()?;
|
||||
open::that(app_dir).stringify_err()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn open_core_dir() -> CmdResult<()> {
|
||||
let core_dir = tauri::utils::platform::current_exe().stringify_err()?;
|
||||
let core_dir = core_dir.parent().ok_or("failed to get core dir")?;
|
||||
@@ -22,12 +24,14 @@ pub async fn open_core_dir() -> CmdResult<()> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn open_logs_dir() -> CmdResult<()> {
|
||||
let log_dir = dirs::app_logs_dir().stringify_err()?;
|
||||
open::that(log_dir).stringify_err()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn open_devtools(app_handle: AppHandle) {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
if !window.is_devtools_open() {
|
||||
@@ -39,22 +43,26 @@ pub fn open_devtools(app_handle: AppHandle) {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn exit_app() {
|
||||
feat::quit().await;
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn restart_app() -> CmdResult<()> {
|
||||
feat::restart_app().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn install_update(
|
||||
app_handle: AppHandle,
|
||||
version: String,
|
||||
version: std::string::String,
|
||||
on_event: Channel<DownloadEvent>,
|
||||
) -> CmdResult<bool> {
|
||||
let version: String = version.into();
|
||||
SilentUpdater::global()
|
||||
.install_update(&app_handle, &version, |event| {
|
||||
let _ = on_event.send(event);
|
||||
@@ -64,22 +72,32 @@ pub async fn install_update(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn cancel_update_download() {
|
||||
SilentUpdater::global().cancel_download();
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_app_dir() -> CmdResult<String> {
|
||||
#[specta::specta]
|
||||
pub fn get_app_dir() -> CmdResult<std::string::String> {
|
||||
let app_home_dir = dirs::app_home_dir().stringify_err()?.to_string_lossy().into();
|
||||
Ok(app_home_dir)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn download_icon_cache(url: String, name: String) -> CmdResult<String> {
|
||||
feat::download_icon_cache(url, name).await
|
||||
#[specta::specta]
|
||||
pub async fn download_icon_cache(
|
||||
url: std::string::String,
|
||||
name: std::string::String,
|
||||
) -> CmdResult<std::string::String> {
|
||||
let url: String = url.into();
|
||||
let name: String = name.into();
|
||||
feat::download_icon_cache(url, name).await.map(Into::into)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn copy_icon_file(path: String, icon_info: feat::IconInfo) -> CmdResult<String> {
|
||||
feat::copy_icon_file(path, icon_info).await
|
||||
#[specta::specta]
|
||||
pub async fn copy_icon_file(path: std::string::String, icon_info: feat::IconInfo) -> CmdResult<std::string::String> {
|
||||
let path: String = path.into();
|
||||
feat::copy_icon_file(path, icon_info).await.map(Into::into)
|
||||
}
|
||||
|
||||
@@ -4,33 +4,44 @@ use feat::LocalBackupFile;
|
||||
use smartstring::alias::String;
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn create_local_backup() -> CmdResult<()> {
|
||||
feat::create_local_backup().await.stringify_err()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn list_local_backup() -> CmdResult<Vec<LocalBackupFile>> {
|
||||
feat::list_local_backup().await.stringify_err()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_local_backup(filename: String) -> CmdResult<()> {
|
||||
#[specta::specta]
|
||||
pub async fn delete_local_backup(filename: std::string::String) -> CmdResult<()> {
|
||||
let filename: String = filename.into();
|
||||
feat::delete_local_backup(filename).await.stringify_err()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn restore_local_backup(filename: String) -> CmdResult<()> {
|
||||
#[specta::specta]
|
||||
pub async fn restore_local_backup(filename: std::string::String) -> CmdResult<()> {
|
||||
let filename: String = filename.into();
|
||||
feat::restore_local_backup(filename)
|
||||
.await
|
||||
.map_err(|error| super::proxy_aware_error(&error))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn import_local_backup(source: String) -> CmdResult<String> {
|
||||
feat::import_local_backup(source).await.stringify_err()
|
||||
#[specta::specta]
|
||||
pub async fn import_local_backup(source: std::string::String) -> CmdResult<std::string::String> {
|
||||
let source: String = source.into();
|
||||
feat::import_local_backup(source).await.map(Into::into).stringify_err()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn export_local_backup(filename: String, destination: String) -> CmdResult<()> {
|
||||
#[specta::specta]
|
||||
pub async fn export_local_backup(filename: std::string::String, destination: std::string::String) -> CmdResult<()> {
|
||||
let filename: String = filename.into();
|
||||
let destination: String = destination.into();
|
||||
feat::export_local_backup(filename, destination).await.stringify_err()
|
||||
}
|
||||
|
||||
+36
-24
@@ -12,30 +12,33 @@ use crate::{
|
||||
},
|
||||
};
|
||||
use clash_verge_logging::{Type, logging, logging_error};
|
||||
use serde_yaml_ng::Mapping;
|
||||
use smartstring::alias::String;
|
||||
use tokio::fs;
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn copy_clash_env() -> CmdResult {
|
||||
feat::copy_clash_env().await;
|
||||
Ok(())
|
||||
feat::copy_clash_env().await.stringify_err()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_clash_info() -> CmdResult<ClashInfo> {
|
||||
Ok(Config::clash().await.data_arc().get_client_info())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn patch_clash_config(payload: Mapping) -> CmdResult {
|
||||
feat::patch_clash(&payload)
|
||||
#[specta::specta]
|
||||
pub async fn patch_clash_config(payload: crate::ipc::YamlMapping) -> CmdResult {
|
||||
feat::patch_clash(&payload.0)
|
||||
.await
|
||||
.map_err(|error| proxy_aware_coded_error(&error, "CLASH_CONFIG_UPDATE_FAILED"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn patch_clash_mode(payload: String) -> CmdResult {
|
||||
#[specta::specta]
|
||||
pub async fn patch_clash_mode(payload: std::string::String) -> CmdResult {
|
||||
let payload: String = payload.into();
|
||||
feat::change_clash_mode(payload)
|
||||
.await
|
||||
.with_error_code("CLASH_MODE_UPDATE_FAILED")
|
||||
@@ -43,12 +46,15 @@ pub async fn patch_clash_mode(payload: String) -> CmdResult {
|
||||
|
||||
/// Reads the saved mode without depending on strict mihomo `/configs` deserialization.
|
||||
#[tauri::command]
|
||||
pub async fn get_clash_mode() -> CmdResult<Option<String>> {
|
||||
Ok(Config::clash().await.data_arc().get_mode().map(Into::into))
|
||||
#[specta::specta]
|
||||
pub async fn get_clash_mode() -> CmdResult<Option<std::string::String>> {
|
||||
Ok(Config::clash().await.data_arc().get_mode())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<CommandFailure>> {
|
||||
#[specta::specta]
|
||||
pub async fn change_clash_core(clash_core: std::string::String) -> CmdResult<Option<CommandFailure>> {
|
||||
let clash_core: String = clash_core.into();
|
||||
logging!(info, Type::Config, "changing core to {clash_core}");
|
||||
|
||||
match CoreManager::global().change_core(&clash_core).await {
|
||||
@@ -80,6 +86,7 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<CommandFa
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn restart_core() -> CmdResult {
|
||||
logging_error!(Type::Core, profiles_save_file_safe().await);
|
||||
let result = CoreManager::global()
|
||||
@@ -94,6 +101,7 @@ pub async fn restart_core() -> CmdResult {
|
||||
|
||||
/// Replaces the managed core binary atomically instead of letting mihomo overwrite itself.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn upgrade_clash_core(force: bool) -> CmdResult<feat::CoreUpgradeReport> {
|
||||
let report = feat::upgrade_core(force).await.with_error_code("CORE_UPGRADE_FAILED")?;
|
||||
if report.upgraded {
|
||||
@@ -103,7 +111,9 @@ pub async fn upgrade_clash_core(force: bool) -> CmdResult<feat::CoreUpgradeRepor
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn test_delay(url: String) -> CmdResult<u32> {
|
||||
#[specta::specta]
|
||||
pub async fn test_delay(url: std::string::String) -> CmdResult<u32> {
|
||||
let url: String = url.into();
|
||||
let result = match feat::test_delay(url).await {
|
||||
Ok(delay) => delay,
|
||||
Err(e) => {
|
||||
@@ -115,13 +125,14 @@ pub async fn test_delay(url: String) -> CmdResult<u32> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_dns_config(dns_config: Mapping) -> CmdResult {
|
||||
#[specta::specta]
|
||||
pub async fn save_dns_config(dns_config: crate::ipc::YamlMapping) -> CmdResult {
|
||||
use crate::utils::dirs;
|
||||
use tokio::fs;
|
||||
|
||||
let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG);
|
||||
|
||||
let yaml_str = yaml_emitter::to_mihomo_config_string(&dns_config).stringify_err()?;
|
||||
let yaml_str = yaml_emitter::to_mihomo_config_string(&dns_config.0).stringify_err()?;
|
||||
fs::write(&dns_path, yaml_str).await.stringify_err()?;
|
||||
logging!(info, Type::Config, "DNS config saved to {dns_path:?}");
|
||||
|
||||
@@ -129,22 +140,27 @@ pub async fn save_dns_config(dns_config: Mapping) -> CmdResult {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn take_dns_override_notice() -> bool {
|
||||
crate::config::dns::take_dns_override_notice()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn set_dns_override(
|
||||
profile_uid: String,
|
||||
profile_uid: std::string::String,
|
||||
enabled: bool,
|
||||
confirmation: Option<String>,
|
||||
confirmation: Option<std::string::String>,
|
||||
) -> CmdResult<feat::DnsOverrideOutcome> {
|
||||
let profile_uid: String = profile_uid.into();
|
||||
let confirmation = confirmation.map(String::from);
|
||||
feat::set_dns_override(profile_uid, enabled, confirmation)
|
||||
.await
|
||||
.map_err(|error| proxy_aware_coded_error(&error, "DNS_OVERRIDE_UPDATE_FAILED"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn apply_dns_config(apply: bool) -> CmdResult {
|
||||
if apply {
|
||||
let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG);
|
||||
@@ -163,7 +179,7 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult {
|
||||
);
|
||||
})?;
|
||||
|
||||
let patch_config = serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&dns_yaml).stringify_err_log(|e| {
|
||||
serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&dns_yaml).stringify_err_log(|e| {
|
||||
logging!(
|
||||
error,
|
||||
Type::Config,
|
||||
@@ -172,13 +188,6 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult {
|
||||
);
|
||||
})?;
|
||||
|
||||
let mut patch = serde_yaml_ng::Mapping::new();
|
||||
patch.insert("dns".into(), patch_config.into());
|
||||
|
||||
Config::runtime().await.edit_draft(|d| {
|
||||
d.patch_config(&patch);
|
||||
});
|
||||
|
||||
CoreManager::global().update_config_checked().await.map_err(|error| {
|
||||
logging!(error, Type::Config, "failed to apply the config with DNS: {error:#}");
|
||||
proxy_aware_error(&error)
|
||||
@@ -202,20 +211,22 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_dns_config_content() -> CmdResult<Option<String>> {
|
||||
#[specta::specta]
|
||||
pub async fn get_dns_config_content() -> CmdResult<Option<std::string::String>> {
|
||||
use crate::utils::dirs;
|
||||
use tokio::fs;
|
||||
|
||||
let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG);
|
||||
|
||||
match fs::read_to_string(&dns_path).await {
|
||||
Ok(content) => Ok(Some(content.into())),
|
||||
Ok(content) => Ok(Some(content)),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(error) => Err(CommandFailure::plain(error)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn validate_dns_config() -> CmdResult<ValidationOutcome> {
|
||||
let app_dir = dirs::app_home_dir().stringify_err()?;
|
||||
let dns_path = app_dir.join(constants::files::DNS_CONFIG);
|
||||
@@ -232,6 +243,7 @@ pub async fn validate_dns_config() -> CmdResult<ValidationOutcome> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_clash_logs() -> CmdResult<Vec<std::string::String>> {
|
||||
let logs = CoreManager::global().get_clash_logs().await.unwrap_or_default();
|
||||
Ok(logs)
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::core::lightweight;
|
||||
use super::CmdResult;
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn entry_lightweight_mode() -> CmdResult {
|
||||
lightweight::entry_lightweight_mode().await;
|
||||
Ok(())
|
||||
|
||||
@@ -5,11 +5,13 @@ use crate::{
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn probe_listener(request: ListenerProbe) -> CmdResult<ListenerProbeOutcome> {
|
||||
feat::probe_listener(request).await.stringify_err()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn save_proxy_ports(settings: ProxyPortSettings) -> CmdResult<SaveProxyPortsOutcome> {
|
||||
feat::save_proxy_ports(settings)
|
||||
.await
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::time::Duration;
|
||||
use tauri::{command, ipc::Channel};
|
||||
|
||||
#[command]
|
||||
#[specta::specta]
|
||||
pub fn get_unlock_items() -> Vec<UnlockItem> {
|
||||
clash_verge_media_unlock::default_unlock_items()
|
||||
}
|
||||
@@ -26,6 +27,7 @@ async fn create_client() -> Result<Client, String> {
|
||||
}
|
||||
|
||||
#[command]
|
||||
#[specta::specta]
|
||||
pub async fn check_media_unlock(on_complete: Channel<UnlockItem>) -> Result<Vec<UnlockItem>, String> {
|
||||
let client = create_client().await?;
|
||||
Ok(clash_verge_media_unlock::check_media_unlock(&client, |item| {
|
||||
@@ -35,6 +37,7 @@ pub async fn check_media_unlock(on_complete: Channel<UnlockItem>) -> Result<Vec<
|
||||
}
|
||||
|
||||
#[command]
|
||||
#[specta::specta]
|
||||
pub async fn check_media_unlock_item(name: String) -> Result<UnlockItem, String> {
|
||||
let client = create_client().await?;
|
||||
clash_verge_media_unlock::check_media_unlock_item(&client, &name).await
|
||||
|
||||
@@ -4,7 +4,7 @@ use smartstring::alias::String;
|
||||
pub type CmdResult<T = ()> = Result<T, CommandFailure>;
|
||||
|
||||
/// Structured command failure returned to the frontend.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, specta::Type)]
|
||||
pub struct CommandFailure {
|
||||
/// Stable frontend identifier, when classified.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@@ -3,13 +3,12 @@ use crate::cmd::StringifyErr as _;
|
||||
use crate::core::{proxy_control, sysopt::Sysopt};
|
||||
|
||||
use gethostname::gethostname;
|
||||
use network_interface::NetworkInterface;
|
||||
use serde_yaml_ng::Mapping;
|
||||
use sysproxy::{Autoproxy, Sysproxy};
|
||||
use tauri_plugin_clash_verge_sysinfo;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_sys_proxy() -> CmdResult<Mapping> {
|
||||
#[specta::specta]
|
||||
pub async fn get_sys_proxy() -> CmdResult<SystemProxy> {
|
||||
Sysopt::global().wait_idle().await;
|
||||
// With no network service there is no proxy configured anywhere, which reads as disabled.
|
||||
let sys_proxy = match Sysproxy::get_system_proxy() {
|
||||
@@ -17,40 +16,40 @@ pub async fn get_sys_proxy() -> CmdResult<Mapping> {
|
||||
other => other.stringify_err()?,
|
||||
};
|
||||
let Sysproxy {
|
||||
ref host,
|
||||
ref bypass,
|
||||
ref port,
|
||||
ref enable,
|
||||
host,
|
||||
bypass,
|
||||
port,
|
||||
enable,
|
||||
} = sys_proxy;
|
||||
|
||||
let mut map = Mapping::new();
|
||||
map.insert("enable".into(), (*enable).into());
|
||||
map.insert("server".into(), format!("{}:{}", host, port).into());
|
||||
map.insert("bypass".into(), bypass.as_str().into());
|
||||
Ok(map)
|
||||
Ok(SystemProxy {
|
||||
enable,
|
||||
server: format!("{host}:{port}"),
|
||||
bypass,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_auto_proxy() -> CmdResult<Mapping> {
|
||||
#[specta::specta]
|
||||
pub async fn get_auto_proxy() -> CmdResult<AutoProxy> {
|
||||
Sysopt::global().wait_idle().await;
|
||||
let auto_proxy = match Autoproxy::get_auto_proxy() {
|
||||
Err(error) if proxy_control::is_missing_network_service(&error) => Autoproxy::default(),
|
||||
other => other.stringify_err()?,
|
||||
};
|
||||
let Autoproxy { ref enable, ref url } = auto_proxy;
|
||||
let Autoproxy { enable, url } = auto_proxy;
|
||||
|
||||
let mut map = Mapping::new();
|
||||
map.insert("enable".into(), (*enable).into());
|
||||
map.insert("url".into(), url.as_str().into());
|
||||
Ok(map)
|
||||
Ok(AutoProxy { enable, url })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn get_embedded_server_port() -> CmdResult<u16> {
|
||||
crate::utils::server::embedded_server_port().stringify_err()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn get_system_hostname() -> String {
|
||||
match gethostname().into_string() {
|
||||
Ok(name) => name,
|
||||
@@ -62,12 +61,14 @@ pub fn get_system_hostname() -> String {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn get_network_interfaces() -> Vec<String> {
|
||||
tauri_plugin_clash_verge_sysinfo::list_network_interfaces()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_network_interfaces_info() -> CmdResult<Vec<NetworkInterface>> {
|
||||
#[specta::specta]
|
||||
pub fn get_network_interfaces_info() -> CmdResult<Vec<crate::ipc::NetworkInterfaceView>> {
|
||||
use network_interface::{NetworkInterface, NetworkInterfaceConfig as _};
|
||||
|
||||
let names = get_network_interfaces();
|
||||
@@ -77,9 +78,22 @@ pub fn get_network_interfaces_info() -> CmdResult<Vec<NetworkInterface>> {
|
||||
|
||||
for interface in interfaces {
|
||||
if names.contains(&interface.name) {
|
||||
result.push(interface);
|
||||
result.push(interface.into());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, specta::Type)]
|
||||
pub struct SystemProxy {
|
||||
pub enable: bool,
|
||||
pub server: String,
|
||||
pub bypass: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, specta::Type)]
|
||||
pub struct AutoProxy {
|
||||
pub enable: bool,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
+117
-21
@@ -33,6 +33,7 @@ fn profile_import_error(err: &anyhow::Error) -> std::string::String {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_profiles() -> CmdResult<SharedDraft<IProfiles>> {
|
||||
let draft = Config::profiles().await;
|
||||
let data = draft.data_arc();
|
||||
@@ -40,6 +41,7 @@ pub async fn get_profiles() -> CmdResult<SharedDraft<IProfiles>> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn enhance_profiles() -> CmdResult<ValidationOutcome> {
|
||||
match feat::enhance_profiles().await {
|
||||
Ok(outcome) if outcome.is_valid() => {
|
||||
@@ -65,6 +67,7 @@ pub async fn enhance_profiles() -> CmdResult<ValidationOutcome> {
|
||||
|
||||
#[tauri::command]
|
||||
#[tracing::instrument(skip_all, level = "info", fields(url = %help::mask_url(&url), uid = tracing::field::Empty))]
|
||||
#[specta::specta]
|
||||
pub async fn import_profile(url: std::string::String, option: Option<PrfOption>) -> CmdResult {
|
||||
let item = &mut match PrfItem::from_url(&url, None, None, option.as_ref()).await {
|
||||
Ok(it) => it,
|
||||
@@ -94,7 +97,10 @@ pub async fn import_profile(url: std::string::String, option: Option<PrfOption>)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reorder_profile(active_id: String, over_id: String) -> CmdResult {
|
||||
#[specta::specta]
|
||||
pub async fn reorder_profile(active_id: std::string::String, over_id: std::string::String) -> CmdResult {
|
||||
let active_id: String = active_id.into();
|
||||
let over_id: String = over_id.into();
|
||||
match profiles_reorder_safe(&active_id, &over_id).await {
|
||||
Ok(_) => {
|
||||
logging!(info, Type::Cmd, "重新排序配置文件: {} -> {}", active_id, over_id);
|
||||
@@ -114,16 +120,16 @@ pub async fn reorder_profile(active_id: String, over_id: String) -> CmdResult {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_profile(item: PrfItem, file_data: Option<String>) -> CmdResult {
|
||||
#[specta::specta]
|
||||
pub async fn create_profile(item: ProfileCreate, file_data: Option<std::string::String>) -> CmdResult {
|
||||
let item: PrfItem = item.into();
|
||||
let file_data = file_data.map(String::from);
|
||||
match profiles_append_item_with_filedata_safe(&item, file_data).await {
|
||||
Ok(_) => {
|
||||
profiles_save_file_safe()
|
||||
.await
|
||||
.with_error_code("PROFILE_CREATE_FAILED")?;
|
||||
logging_error!(Type::Timer, Timer::global().refresh().await);
|
||||
if let Some(uid) = &item.uid {
|
||||
handle::Handle::notify_profile_changed(uid);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(coded_error("PROFILE_CREATE_FAILED", err)),
|
||||
@@ -131,7 +137,9 @@ pub async fn create_profile(item: PrfItem, file_data: Option<String>) -> CmdResu
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_profile(index: String, option: Option<PrfOption>) -> CmdResult {
|
||||
#[specta::specta]
|
||||
pub async fn update_profile(index: std::string::String, option: Option<PrfOption>) -> CmdResult {
|
||||
let index: String = index.into();
|
||||
match feat::update_profile(&index, option.as_ref(), true).await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => {
|
||||
@@ -142,7 +150,9 @@ pub async fn update_profile(index: String, option: Option<PrfOption>) -> CmdResu
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_profile(index: String) -> CmdResult {
|
||||
#[specta::specta]
|
||||
pub async fn delete_profile(index: std::string::String) -> CmdResult {
|
||||
let index: String = index.into();
|
||||
let profile_write_guard = PROFILE_WRITE_LOCK.lock().await;
|
||||
|
||||
let profiles = Config::profiles().await;
|
||||
@@ -272,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)
|
||||
}
|
||||
|
||||
@@ -301,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 {
|
||||
@@ -313,9 +322,82 @@ async fn perform_config_update(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, serde::Deserialize, specta::Type)]
|
||||
#[serde(default)]
|
||||
pub struct ProfileSelection {
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub current: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, serde::Deserialize, specta::Type)]
|
||||
#[serde(default)]
|
||||
pub struct ProfileCreate {
|
||||
#[serde(rename = "type")]
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub itype: Option<String>,
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub name: Option<String>,
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub desc: Option<String>,
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub url: Option<String>,
|
||||
pub option: Option<PrfOption>,
|
||||
}
|
||||
|
||||
impl From<ProfileCreate> for PrfItem {
|
||||
fn from(item: ProfileCreate) -> Self {
|
||||
Self {
|
||||
itype: item.itype,
|
||||
name: item.name,
|
||||
desc: item.desc,
|
||||
url: item.url,
|
||||
option: item.option,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, serde::Deserialize, specta::Type)]
|
||||
#[serde(default)]
|
||||
pub struct ProfilePatch {
|
||||
#[serde(rename = "type")]
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub itype: Option<String>,
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub name: Option<String>,
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub desc: Option<String>,
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub file: Option<String>,
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub url: Option<String>,
|
||||
pub selected: Option<Vec<crate::config::PrfSelected>>,
|
||||
pub extra: Option<crate::config::PrfExtra>,
|
||||
pub updated: Option<usize>,
|
||||
pub option: Option<PrfOption>,
|
||||
}
|
||||
|
||||
impl From<ProfilePatch> for PrfItem {
|
||||
fn from(patch: ProfilePatch) -> Self {
|
||||
Self {
|
||||
itype: patch.itype,
|
||||
name: patch.name,
|
||||
desc: patch.desc,
|
||||
file: patch.file,
|
||||
url: patch.url,
|
||||
selected: patch.selected,
|
||||
extra: patch.extra,
|
||||
updated: patch.updated,
|
||||
option: patch.option,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tracing::instrument(skip_all, level = "info", fields(target = ?profiles.current))]
|
||||
pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<ValidationOutcome> {
|
||||
#[specta::specta]
|
||||
pub async fn patch_profiles_config(profiles: ProfileSelection) -> CmdResult<ValidationOutcome> {
|
||||
if CURRENT_SWITCHING_PROFILE
|
||||
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
|
||||
.is_err()
|
||||
@@ -323,13 +405,22 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<ValidationO
|
||||
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 {
|
||||
current: profiles.current,
|
||||
items: None,
|
||||
};
|
||||
let target_profile = profiles.current.as_ref();
|
||||
|
||||
let previous_profile = Config::profiles().await.data_arc().current.clone();
|
||||
|
||||
Config::profiles().await.edit_draft(|d| d.patch_config(&profiles));
|
||||
Config::profiles().await.edit_draft(|d| {
|
||||
d.patch_config(&profiles);
|
||||
});
|
||||
|
||||
perform_config_update(target_profile, previous_profile.as_ref())
|
||||
.await
|
||||
@@ -337,15 +428,17 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<ValidationO
|
||||
}
|
||||
|
||||
pub async fn patch_profiles_config_by_profile_index(profile_index: String) -> CmdResult<ValidationOutcome> {
|
||||
let profiles = IProfiles {
|
||||
let profiles = ProfileSelection {
|
||||
current: Some(profile_index),
|
||||
items: None,
|
||||
};
|
||||
patch_profiles_config(profiles).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn patch_profile(index: String, profile: PrfItem) -> CmdResult {
|
||||
#[specta::specta]
|
||||
pub async fn patch_profile(index: std::string::String, profile: ProfilePatch) -> CmdResult {
|
||||
let profile: PrfItem = profile.into();
|
||||
let index: String = index.into();
|
||||
let profiles = Config::profiles().await;
|
||||
let should_refresh_timer = if let Ok(old_profile) = profiles.latest_arc().get_item(&index)
|
||||
&& let Some(new_option) = profile.option.as_ref()
|
||||
@@ -385,7 +478,8 @@ pub async fn patch_profile(index: String, profile: PrfItem) -> CmdResult {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn view_profile(index: String) -> CmdResult {
|
||||
#[specta::specta]
|
||||
pub async fn view_profile(index: std::string::String) -> CmdResult {
|
||||
let profiles = Config::profiles().await;
|
||||
let profiles_ref = profiles.latest_arc();
|
||||
let file = profiles_ref
|
||||
@@ -409,7 +503,8 @@ pub async fn view_profile(index: String) -> CmdResult {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn read_profile_file(index: String) -> CmdResult<String> {
|
||||
#[specta::specta]
|
||||
pub async fn read_profile_file(index: std::string::String) -> CmdResult<std::string::String> {
|
||||
let item = {
|
||||
let profiles = Config::profiles().await;
|
||||
let profiles_ref = profiles.latest_arc();
|
||||
@@ -429,7 +524,7 @@ pub async fn read_profile_file(index: String) -> CmdResult<String> {
|
||||
.join(file.as_str());
|
||||
match tokio::fs::try_exists(&path).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => return Ok(String::new()),
|
||||
Ok(false) => return Ok(std::string::String::new()),
|
||||
Err(err) => {
|
||||
return Err(coded_error(
|
||||
"PROFILE_READ_FAILED",
|
||||
@@ -440,11 +535,12 @@ pub async fn read_profile_file(index: String) -> CmdResult<String> {
|
||||
}
|
||||
|
||||
let data = item.read_file().await.with_error_code("PROFILE_READ_FAILED")?;
|
||||
Ok(data)
|
||||
Ok(data.into())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_next_update_time(uid: String) -> CmdResult<Option<i64>> {
|
||||
#[specta::specta]
|
||||
pub async fn get_next_update_time(uid: std::string::String) -> CmdResult<Option<i64>> {
|
||||
let timer = Timer::global();
|
||||
let next_time = timer.get_next_update_time(&uid).await;
|
||||
Ok(next_time)
|
||||
|
||||
@@ -18,6 +18,7 @@ use std::{
|
||||
|
||||
/// Merges one selection pair into fresh backend state to avoid stale-list overwrites.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn record_selected_node(group_name: String, node: String) -> CmdResult<()> {
|
||||
crate::config::profiles::record_selected_node(&group_name, &node)
|
||||
.await
|
||||
@@ -25,6 +26,7 @@ pub async fn record_selected_node(group_name: String, node: String) -> CmdResult
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn forget_selected_node(group_name: String) -> CmdResult<()> {
|
||||
crate::config::profiles::forget_selected_node(&group_name)
|
||||
.await
|
||||
@@ -104,37 +106,27 @@ 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(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn sync_tray_proxy_selection() -> CmdResult<()> {
|
||||
if TRAY_SYNC_RUNNING
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
|
||||
@@ -1,44 +1,57 @@
|
||||
use super::CmdResult;
|
||||
use crate::{cmd::StringifyErr as _, config::Config, core::CoreManager, utils::yaml_emitter};
|
||||
use anyhow::{Context as _, anyhow};
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use serde_yaml_ng::Mapping;
|
||||
use smartstring::alias::String;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_runtime_config() -> CmdResult<Option<Mapping>> {
|
||||
Ok(Config::runtime().await.latest_arc().config.clone())
|
||||
#[specta::specta]
|
||||
pub async fn get_runtime_config() -> CmdResult<Option<crate::ipc::YamlMapping>> {
|
||||
Ok(Config::runtime()
|
||||
.await
|
||||
.latest_arc()
|
||||
.config
|
||||
.clone()
|
||||
.map(crate::ipc::YamlMapping))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_runtime_yaml() -> CmdResult<String> {
|
||||
#[specta::specta]
|
||||
pub async fn get_runtime_yaml() -> CmdResult<std::string::String> {
|
||||
let runtime = Config::runtime().await;
|
||||
let runtime = runtime.latest_arc();
|
||||
|
||||
let config = runtime.config.as_ref();
|
||||
config
|
||||
.ok_or_else(|| anyhow!("failed to parse config to yaml file"))
|
||||
.and_then(|config| {
|
||||
yaml_emitter::to_mihomo_config_string(config)
|
||||
.context("failed to convert config to yaml")
|
||||
.map(|s| s.into())
|
||||
})
|
||||
.and_then(|config| yaml_emitter::to_mihomo_config_string(config).context("failed to convert config to yaml"))
|
||||
.stringify_err()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_runtime_logs() -> CmdResult<HashMap<String, Vec<(String, String)>>> {
|
||||
Ok(Config::runtime().await.latest_arc().chain_logs.clone())
|
||||
#[specta::specta]
|
||||
pub async fn get_runtime_logs() -> CmdResult<RuntimeLogs> {
|
||||
Ok(RuntimeLogs(Config::runtime().await.latest_arc().chain_logs.clone()))
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, specta::Type)]
|
||||
#[serde(transparent)]
|
||||
pub struct RuntimeLogs(
|
||||
#[specta(type = HashMap<std::string::String, Vec<(std::string::String, std::string::String)>>)]
|
||||
HashMap<String, Vec<(String, String)>>,
|
||||
);
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn take_discarded_keys_notice() -> Option<std::string::String> {
|
||||
crate::enhance::take_discarded_keys_notice().map(Into::into)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn take_discarded_keys_notice() -> Option<String> {
|
||||
crate::enhance::take_discarded_keys_notice()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> CmdResult<String> {
|
||||
#[specta::specta]
|
||||
pub async fn get_runtime_proxy_chain_config(
|
||||
proxy_chain_exit_node: std::string::String,
|
||||
) -> CmdResult<std::string::String> {
|
||||
let runtime = Config::runtime().await;
|
||||
let runtime = runtime.latest_arc();
|
||||
|
||||
@@ -59,7 +72,7 @@ pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> Cm
|
||||
false
|
||||
}
|
||||
}) {
|
||||
proxies_chain.push(proxy.to_owned());
|
||||
proxies_chain.push(proxy);
|
||||
proxy_name = proxy.get("dialer-proxy").map(|x| x.as_str());
|
||||
}
|
||||
|
||||
@@ -68,18 +81,17 @@ pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> Cm
|
||||
.find(|proxy| proxy.get("name").map(|x| x.as_str()) == proxy_name)
|
||||
&& !proxies_chain.is_empty()
|
||||
{
|
||||
proxies_chain.push(entry_proxy.to_owned());
|
||||
proxies_chain.push(entry_proxy);
|
||||
}
|
||||
|
||||
proxies_chain.reverse();
|
||||
|
||||
let mut config: HashMap<String, Vec<serde_yaml_ng::Value>> = HashMap::new();
|
||||
let mut config: HashMap<String, Vec<&serde_yaml_ng::Value>> = HashMap::new();
|
||||
|
||||
config.insert("proxies".into(), proxies_chain);
|
||||
|
||||
yaml_emitter::to_mihomo_config_string(&config)
|
||||
.context("YAML generation failed")
|
||||
.map(|s| s.into())
|
||||
.stringify_err()
|
||||
} else {
|
||||
Err("failed to get proxies or proxy-groups".into())
|
||||
@@ -87,20 +99,13 @@ pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> Cm
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_proxy_chain_config_in_runtime(proxy_chain_config: Option<serde_yaml_ng::Value>) -> CmdResult<()> {
|
||||
match CoreManager::global()
|
||||
#[specta::specta]
|
||||
pub async fn update_proxy_chain_config_in_runtime(
|
||||
proxy_chain_config: Option<crate::ipc::YamlValue>,
|
||||
) -> CmdResult<crate::core::validate::ValidationOutcome> {
|
||||
let proxy_chain_config = proxy_chain_config.map(|value| value.0);
|
||||
CoreManager::global()
|
||||
.update_runtime_config(|d| d.update_proxy_chain_config(proxy_chain_config))
|
||||
.await
|
||||
{
|
||||
Ok(outcome) if outcome.is_valid() => {}
|
||||
Ok(outcome) => logging!(
|
||||
warn,
|
||||
Type::Core,
|
||||
"Failed to apply runtime proxy chain config: {}",
|
||||
outcome
|
||||
),
|
||||
Err(err) => logging!(error, Type::Core, "Failed to apply runtime proxy chain config: {err:#}"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
.stringify_err()
|
||||
}
|
||||
|
||||
@@ -16,7 +16,13 @@ use tokio::fs;
|
||||
|
||||
/// 保存profiles的配置
|
||||
#[tauri::command]
|
||||
pub async fn save_profile_file(index: String, file_data: Option<String>) -> CmdResult<ValidationOutcome> {
|
||||
#[specta::specta]
|
||||
pub async fn save_profile_file(
|
||||
index: std::string::String,
|
||||
file_data: Option<std::string::String>,
|
||||
) -> CmdResult<ValidationOutcome> {
|
||||
let index: String = index.into();
|
||||
let file_data = file_data.map(String::from);
|
||||
let file_data = match file_data {
|
||||
Some(d) => d,
|
||||
None => return Ok(ValidationOutcome::Valid),
|
||||
|
||||
@@ -8,11 +8,11 @@ use crate::{
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, serde::Serialize)]
|
||||
#[derive(Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
|
||||
#[serde(tag = "status", rename_all = "camelCase")]
|
||||
pub enum ServiceInstallOutcome {
|
||||
Installed,
|
||||
#[cfg(any(windows, test))]
|
||||
#[cfg_attr(not(any(windows, test)), allow(dead_code))]
|
||||
Sidecar {
|
||||
reason: String,
|
||||
},
|
||||
@@ -82,11 +82,13 @@ where
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn install_service() -> CmdResult<ServiceInstallOutcome> {
|
||||
execute_service_operation_sync(ServiceStatus::InstallRequired, "SERVICE_INSTALL_FAILED").await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn uninstall_service() -> CmdResult {
|
||||
CoreManager::global()
|
||||
.uninstall_service_and_start_sidecar()
|
||||
@@ -95,16 +97,19 @@ pub async fn uninstall_service() -> CmdResult {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn reinstall_service() -> CmdResult<ServiceInstallOutcome> {
|
||||
execute_service_operation_sync(ServiceStatus::ReinstallRequired, "SERVICE_REINSTALL_FAILED").await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repair_service() -> CmdResult<ServiceInstallOutcome> {
|
||||
execute_service_operation_sync(ServiceStatus::ForceReinstallRequired, "SERVICE_REPAIR_FAILED").await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn continue_with_sidecar() -> CmdResult {
|
||||
crate::core::CoreManager::global()
|
||||
.continue_with_sidecar(None)
|
||||
@@ -113,31 +118,37 @@ pub async fn continue_with_sidecar() -> CmdResult {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn get_sidecar_failure() -> crate::core::notification::SidecarFailureSnapshot {
|
||||
crate::core::notification::sidecar_failure_snapshot()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn take_service_fallback_notice() -> Option<crate::core::service::ServiceFallbackNotice> {
|
||||
crate::core::service::take_service_fallback_notice()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn get_core_startup_error() -> Option<crate::core::manager::CoreFailure> {
|
||||
crate::core::CoreManager::global().get_startup_error()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn take_service_repair_notice() -> bool {
|
||||
crate::core::service::take_service_repair_notice()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn take_service_owner_notice() -> Option<String> {
|
||||
crate::core::service::take_service_owner_notice()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn sync_runtime_providers() {
|
||||
if matches!(*CoreManager::global().get_running_mode(), RunningMode::Service) {
|
||||
request_runtime_provider_sync(timing::RUNTIME_PROVIDER_SETTLE);
|
||||
|
||||
@@ -5,11 +5,13 @@ use crate::core::{
|
||||
|
||||
/// Returns one coherent core/service snapshot instead of independently refreshed state.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_runtime_state() -> Result<RunStateView, String> {
|
||||
Ok(RUN_STATE.settled().await.to_view())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_pending_failures() -> Vec<PendingFailure> {
|
||||
notification::pending_failures()
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ mod platform {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn invoke_uwp_tool() -> CmdResult {
|
||||
platform::invoke_uwp_tool()
|
||||
}
|
||||
|
||||
@@ -9,12 +9,14 @@ use clash_verge_draft::SharedDraft;
|
||||
|
||||
/// 获取Verge配置
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_verge_config() -> CmdResult<SharedDraft<IVerge>> {
|
||||
feat::fetch_verge_config().await.stringify_err()
|
||||
}
|
||||
|
||||
/// 修改Verge配置
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn patch_verge_config(payload: IVerge) -> CmdResult {
|
||||
let operation = system_proxy_operation(&payload);
|
||||
let result = match operation {
|
||||
|
||||
@@ -4,11 +4,18 @@ use crate::{
|
||||
config::{Config, IVerge},
|
||||
core, feat,
|
||||
};
|
||||
use reqwest_dav::list_cmd::ListFile;
|
||||
use smartstring::alias::String;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_webdav_config(url: String, username: String, password: String) -> CmdResult<()> {
|
||||
#[specta::specta]
|
||||
pub async fn save_webdav_config(
|
||||
url: std::string::String,
|
||||
username: std::string::String,
|
||||
password: std::string::String,
|
||||
) -> CmdResult<()> {
|
||||
let url: String = url.into();
|
||||
let username: String = username.into();
|
||||
let password: String = password.into();
|
||||
let patch = IVerge {
|
||||
webdav_url: Some(url),
|
||||
webdav_username: Some(username),
|
||||
@@ -25,22 +32,31 @@ pub async fn save_webdav_config(url: String, username: String, password: String)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn create_webdav_backup() -> CmdResult<()> {
|
||||
feat::create_backup_and_upload_webdav().await.stringify_err()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_webdav_backup() -> CmdResult<Vec<ListFile>> {
|
||||
feat::list_wevdav_backup().await.stringify_err()
|
||||
#[specta::specta]
|
||||
pub async fn list_webdav_backup() -> CmdResult<Vec<crate::ipc::WebDavFile>> {
|
||||
feat::list_wevdav_backup()
|
||||
.await
|
||||
.map(|files| files.into_iter().map(Into::into).collect())
|
||||
.stringify_err()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_webdav_backup(filename: String) -> CmdResult<()> {
|
||||
#[specta::specta]
|
||||
pub async fn delete_webdav_backup(filename: std::string::String) -> CmdResult<()> {
|
||||
let filename: String = filename.into();
|
||||
feat::delete_webdav_backup(filename).await.stringify_err()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn restore_webdav_backup(filename: String) -> CmdResult<()> {
|
||||
#[specta::specta]
|
||||
pub async fn restore_webdav_backup(filename: std::string::String) -> CmdResult<()> {
|
||||
let filename: String = filename.into();
|
||||
feat::restore_webdav_backup(filename)
|
||||
.await
|
||||
.map_err(|error| super::proxy_aware_error(&error))
|
||||
|
||||
@@ -308,7 +308,7 @@ impl IClashTemp {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)]
|
||||
pub struct ClashInfo {
|
||||
/// clash core port
|
||||
pub mixed_port: u16,
|
||||
@@ -364,14 +364,14 @@ fn test_clash_info() {
|
||||
assert_eq!(get_case(8888, "192.168.1.1:80800"), get_result(8888, "127.0.0.1:9097"));
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct IClashExternalControllerCors {
|
||||
pub allow_origins: Option<Vec<String>>,
|
||||
pub allow_private_network: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct IClash {
|
||||
pub mixed_port: Option<u16>,
|
||||
@@ -387,7 +387,7 @@ pub struct IClash {
|
||||
pub external_controller_cors: Option<IClashExternalControllerCors>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct IClashTUN {
|
||||
pub enable: Option<bool>,
|
||||
@@ -397,7 +397,7 @@ pub struct IClashTUN {
|
||||
pub dns_hijack: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct IClashDNS {
|
||||
pub enable: Option<bool>,
|
||||
@@ -414,7 +414,7 @@ pub struct IClashDNS {
|
||||
pub nameserver_policy: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct IClashFallbackFilter {
|
||||
pub geoip: Option<bool>,
|
||||
|
||||
@@ -106,10 +106,11 @@ impl DnsOverrideState {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize, specta::Type)]
|
||||
pub struct ProfileDnsSettings {
|
||||
pub enabled: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub confirmation: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -27,30 +27,32 @@ pub(super) fn normalize_profile_home_url(raw: &str) -> Option<String> {
|
||||
Some(url.to_string().into())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Default, specta::Type)]
|
||||
#[serde(default)]
|
||||
pub struct PrfItem {
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub uid: Option<String>,
|
||||
|
||||
/// enum value: remote | local | script | merge
|
||||
#[serde(rename = "type")]
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub itype: Option<String>,
|
||||
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub name: Option<String>,
|
||||
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub file: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub desc: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub url: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub selected: Option<Vec<PrfSelected>>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub latency_test_urls: Option<std::collections::BTreeMap<String, String>>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub extra: Option<PrfExtra>,
|
||||
|
||||
@@ -60,19 +62,24 @@ pub struct PrfItem {
|
||||
pub option: Option<PrfOption>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub home: Option<String>,
|
||||
|
||||
#[serde(skip)]
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub file_data: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)]
|
||||
#[serde(default)]
|
||||
pub struct PrfSelected {
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub name: Option<String>,
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub now: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Copy, Deserialize, Serialize)]
|
||||
#[derive(Default, Debug, Clone, Copy, Deserialize, Serialize, specta::Type)]
|
||||
pub struct PrfExtra {
|
||||
pub upload: u64,
|
||||
pub download: u64,
|
||||
@@ -80,9 +87,11 @@ pub struct PrfExtra {
|
||||
pub expire: u64,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)]
|
||||
#[serde(default)]
|
||||
pub struct PrfOption {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub user_agent: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -105,15 +114,15 @@ pub struct PrfOption {
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub allow_auto_update: Option<bool>,
|
||||
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub merge: Option<String>,
|
||||
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub script: Option<String>,
|
||||
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub rules: Option<String>,
|
||||
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub proxies: Option<String>,
|
||||
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub groups: Option<String>,
|
||||
}
|
||||
|
||||
@@ -220,7 +229,6 @@ impl PrfItem {
|
||||
desc: Some(desc),
|
||||
file: Some(file),
|
||||
url: None,
|
||||
latency_test_urls: None,
|
||||
selected: None,
|
||||
extra: None,
|
||||
option: Some(PrfOption {
|
||||
@@ -400,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 {
|
||||
|
||||
@@ -48,8 +48,10 @@ const SELECTED_NODES_SETTLE_INTERVAL: Duration = Duration::from_secs(1);
|
||||
const SELECTED_NODES_FIRST_PASS_BUDGET: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Define the `profiles.yaml` schema
|
||||
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Default, Debug, Clone, Deserialize, Serialize, specta::Type)]
|
||||
#[serde(default)]
|
||||
pub struct IProfiles {
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub current: Option<String>,
|
||||
|
||||
pub items: Option<Vec<PrfItem>>,
|
||||
@@ -248,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);
|
||||
|
||||
@@ -11,26 +11,30 @@ use serde::{Deserialize, Serialize};
|
||||
use smartstring::alias::String;
|
||||
|
||||
/// ### `verge.yaml` schema
|
||||
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Default, Debug, Clone, Deserialize, Serialize, specta::Type)]
|
||||
#[serde(default)]
|
||||
pub struct IVerge {
|
||||
/// silent | error | warn | info | debug | trace
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub app_log_level: Option<String>,
|
||||
|
||||
/// Maximum app log size in KiB.
|
||||
pub app_log_max_size: Option<u64>,
|
||||
|
||||
pub app_log_max_count: Option<usize>,
|
||||
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub language: Option<String>,
|
||||
|
||||
/// `light` or `dark` or `system`
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub theme_mode: Option<String>,
|
||||
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub tray_event: Option<String>,
|
||||
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub env_type: Option<String>,
|
||||
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub start_page: Option<String>,
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub startup_script: Option<String>,
|
||||
|
||||
pub traffic_graph: Option<bool>,
|
||||
@@ -48,18 +52,23 @@ pub struct IVerge {
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub tray_icon: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub menu_icon: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[specta(type = Option<Vec<std::string::String>>)]
|
||||
pub menu_order: Option<Vec<String>>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub proxy_group_tools_position: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub notice_position: Option<String>,
|
||||
|
||||
pub collapse_navbar: Option<bool>,
|
||||
@@ -84,39 +93,43 @@ pub struct IVerge {
|
||||
pub enable_dns_settings: Option<bool>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
#[specta(type = std::collections::BTreeMap<std::string::String, super::dns::ProfileDnsSettings>)]
|
||||
pub profile_dns_settings: std::collections::BTreeMap<String, super::dns::ProfileDnsSettings>,
|
||||
|
||||
pub use_default_bypass: Option<bool>,
|
||||
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub system_proxy_bypass: Option<String>,
|
||||
|
||||
pub proxy_guard_duration: Option<u64>,
|
||||
|
||||
pub proxy_auto_config: Option<bool>,
|
||||
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub pac_file_content: Option<String>,
|
||||
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub proxy_host: Option<String>,
|
||||
|
||||
pub theme_setting: Option<IVergeTheme>,
|
||||
|
||||
#[specta(type = Option<Vec<std::string::String>>)]
|
||||
pub web_ui_list: Option<Vec<String>>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub clash_core: Option<String>,
|
||||
|
||||
/// format: {func},{key}
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[specta(type = Option<Vec<std::string::String>>)]
|
||||
pub hotkeys: Option<Vec<String>>,
|
||||
|
||||
pub enable_global_hotkey: Option<bool>,
|
||||
|
||||
#[specta(type = Option<crate::ipc::JsonValue>)]
|
||||
pub home_cards: Option<serde_json::Value>,
|
||||
|
||||
pub auto_close_connection: Option<bool>,
|
||||
|
||||
pub auto_check_update: Option<bool>,
|
||||
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub default_latency_test: Option<String>,
|
||||
|
||||
pub default_latency_timeout: Option<i16>,
|
||||
@@ -171,6 +184,7 @@ pub struct IVerge {
|
||||
skip_serializing_if = "Option::is_none",
|
||||
default
|
||||
)]
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub webdav_url: Option<String>,
|
||||
|
||||
#[serde(
|
||||
@@ -179,6 +193,7 @@ pub struct IVerge {
|
||||
skip_serializing_if = "Option::is_none",
|
||||
default
|
||||
)]
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub webdav_username: Option<String>,
|
||||
|
||||
#[serde(
|
||||
@@ -187,12 +202,14 @@ pub struct IVerge {
|
||||
skip_serializing_if = "Option::is_none",
|
||||
default
|
||||
)]
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub webdav_password: Option<String>,
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub enable_tray_speed: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub tray_proxy_groups_display_mode: Option<String>,
|
||||
pub tray_inline_outbound_modes: Option<bool>,
|
||||
|
||||
@@ -209,27 +226,41 @@ pub struct IVerge {
|
||||
pub enable_external_controller: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Default, Debug, Clone, Deserialize, Serialize, specta::Type)]
|
||||
#[serde(default)]
|
||||
pub struct IVergeTestItem {
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub uid: Option<String>,
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub name: Option<String>,
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub icon: Option<String>,
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Default, Debug, Clone, Deserialize, Serialize, specta::Type)]
|
||||
#[serde(default)]
|
||||
pub struct IVergeTheme {
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub primary_color: Option<String>,
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub secondary_color: Option<String>,
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub primary_text: Option<String>,
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub secondary_text: Option<String>,
|
||||
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub info_color: Option<String>,
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub error_color: Option<String>,
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub warning_color: Option<String>,
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub success_color: Option<String>,
|
||||
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub font_family: Option<String>,
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub css_injection: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ pub mod tun {
|
||||
"auto-redirect",
|
||||
"auto-detect-interface",
|
||||
"dns-hijack",
|
||||
"dns-mode",
|
||||
"strict-route",
|
||||
"mtu",
|
||||
];
|
||||
|
||||
@@ -109,7 +109,7 @@ impl AutoBackupManager {
|
||||
{
|
||||
*self.settings.write() = settings;
|
||||
}
|
||||
let _ = self.settings_tx.send(settings);
|
||||
let _ = self.settings_tx.send_replace(settings);
|
||||
self.maybe_start_runner(settings);
|
||||
Ok(())
|
||||
}
|
||||
@@ -119,7 +119,7 @@ impl AutoBackupManager {
|
||||
{
|
||||
*self.settings.write() = settings;
|
||||
}
|
||||
let _ = self.settings_tx.send(settings);
|
||||
let _ = self.settings_tx.send_replace(settings);
|
||||
self.maybe_start_runner(settings);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ pub(crate) fn proxy_listener_keys() -> impl Iterator<Item = &'static str> {
|
||||
PROXY_LISTENERS.iter().map(|(key, _, _)| *key)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProxyPortSettings {
|
||||
pub mixed_port: u16,
|
||||
@@ -48,27 +48,27 @@ pub struct ProxyPortSettings {
|
||||
pub tproxy: ToggleableProxyPort,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize)]
|
||||
#[derive(Clone, Copy, Debug, Deserialize, specta::Type)]
|
||||
pub struct ToggleableProxyPort {
|
||||
pub enabled: bool,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ListenerProbe {
|
||||
pub address: String,
|
||||
pub transports: Vec<ListenerTransport>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Hash, Serialize)]
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Hash, Serialize, specta::Type)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ListenerTransport {
|
||||
Tcp,
|
||||
Udp,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, specta::Type)]
|
||||
#[serde(tag = "status", rename_all = "camelCase")]
|
||||
pub enum ListenerProbeOutcome {
|
||||
Available,
|
||||
@@ -77,7 +77,7 @@ pub enum ListenerProbeOutcome {
|
||||
Indeterminate { message: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, specta::Type)]
|
||||
#[serde(tag = "status", rename_all = "camelCase")]
|
||||
pub enum SaveProxyPortsOutcome {
|
||||
Saved,
|
||||
|
||||
@@ -57,7 +57,7 @@ fn claim_core_readiness_generation(state: &AtomicU64, captured_generation: u64)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, serde::Serialize, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, Debug, serde::Serialize, PartialEq, Eq, specta::Type)]
|
||||
pub enum RunningMode {
|
||||
Service,
|
||||
Sidecar,
|
||||
@@ -75,7 +75,7 @@ impl fmt::Display for RunningMode {
|
||||
}
|
||||
|
||||
/// Why the Core is not running, kept until a Core starts again.
|
||||
#[derive(Clone, Debug, serde::Serialize, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, serde::Serialize, PartialEq, Eq, specta::Type)]
|
||||
#[serde(tag = "kind", content = "detail", rename_all = "camelCase")]
|
||||
pub enum CoreFailure {
|
||||
StartFailed(String),
|
||||
|
||||
@@ -52,10 +52,7 @@ impl Handle {
|
||||
/// Sent on every transition, so the frontend does not have to poll to notice that the Core
|
||||
/// stopped or that the Service came back.
|
||||
pub fn notify_run_state(state: &crate::core::runstate::RunStateView) {
|
||||
let Ok(state) = serde_json::to_value(state) else {
|
||||
return;
|
||||
};
|
||||
Self::send_event(FrontendEvent::RunStateChanged { state });
|
||||
Self::send_event(FrontendEvent::RunStateChanged { state: state.clone() });
|
||||
}
|
||||
|
||||
pub fn notify_profile_changed(profile_id: &String) {
|
||||
|
||||
@@ -3,9 +3,8 @@ pub use announce::{Refresh, announce};
|
||||
pub mod desktop;
|
||||
pub mod handle;
|
||||
pub mod notification;
|
||||
pub use notification::frontend_wire_contract;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, specta::Type)]
|
||||
pub enum NoticeStatus {
|
||||
#[serde(rename = "info")]
|
||||
Info,
|
||||
@@ -83,6 +82,7 @@ impl NoticeStatus {
|
||||
/// Every variant in golden order. The wire-contract export and the golden
|
||||
/// test both derive their variant list from here; expected strings stay
|
||||
/// hand-written in the test so renames cannot slip through.
|
||||
#[cfg(test)]
|
||||
pub const ALL: [Self; 34] = [
|
||||
Self::Info,
|
||||
Self::ImportSubUrlOk,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::NoticeStatus;
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use parking_lot::Mutex;
|
||||
#[cfg(test)]
|
||||
use serde_json::json;
|
||||
use smartstring::alias::String;
|
||||
use std::sync::Arc;
|
||||
@@ -31,7 +32,7 @@ pub enum FrontendEvent<'a> {
|
||||
uid: &'a String,
|
||||
},
|
||||
RunStateChanged {
|
||||
state: serde_json::Value,
|
||||
state: crate::core::runstate::RunStateView,
|
||||
},
|
||||
PendingFailuresChanged,
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -42,7 +43,7 @@ pub enum FrontendEvent<'a> {
|
||||
|
||||
/// Operation associated with a pending failure.
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum FailedOperation {
|
||||
SystemProxyEnable,
|
||||
@@ -72,12 +73,14 @@ impl FailedOperation {
|
||||
}
|
||||
|
||||
/// Latest unresolved failure for one stable code.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PendingFailure {
|
||||
/// Stable code used as the table key.
|
||||
#[specta(type = std::string::String)]
|
||||
pub code: String,
|
||||
/// Full diagnostic context chain.
|
||||
#[specta(type = std::string::String)]
|
||||
pub detail: String,
|
||||
pub operation: FailedOperation,
|
||||
/// Monotonic identity for repeated failures under the same code.
|
||||
@@ -241,9 +244,10 @@ pub fn what_was_asked() -> FailedOperation {
|
||||
static PENDING_FAILURES: LazyLock<FailureTable> = LazyLock::new(FailureTable::default);
|
||||
|
||||
/// A removal has a revision too, so a delayed read cannot restore a resolved failure.
|
||||
#[derive(Debug, Default, Clone, serde::Serialize)]
|
||||
#[derive(Debug, Default, Clone, serde::Serialize, specta::Type)]
|
||||
pub struct SidecarFailureSnapshot {
|
||||
pub revision: u64,
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
@@ -327,24 +331,7 @@ impl NotificationSystem {
|
||||
}
|
||||
|
||||
fn serialize_event(event: FrontendEvent) -> (&'static str, Result<serde_json::Value, serde_json::Error>) {
|
||||
match event {
|
||||
FrontendEvent::RefreshClash => ("verge://refresh-clash-config", Ok(json!("yes"))),
|
||||
FrontendEvent::RefreshVerge => ("verge://refresh-verge-config", Ok(json!("yes"))),
|
||||
FrontendEvent::RefreshProfiles => ("verge://refresh-profiles", Ok(json!("yes"))),
|
||||
FrontendEvent::RefreshProxyConfig => ("verge://refresh-proxy-config", Ok(serde_json::Value::Null)),
|
||||
FrontendEvent::NoticeMessage { status, message } => (
|
||||
"verge://notice-message",
|
||||
serde_json::to_value((status, message.as_ref())),
|
||||
),
|
||||
FrontendEvent::ProfileChanged { current_profile_id } => ("profile-changed", Ok(json!(current_profile_id))),
|
||||
FrontendEvent::TimerUpdated { profile_index } => ("verge://timer-updated", Ok(json!(profile_index))),
|
||||
FrontendEvent::ProfileUpdateStarted { uid } => ("profile-update-started", Ok(json!({ "uid": uid }))),
|
||||
FrontendEvent::ProfileUpdateCompleted { uid } => ("profile-update-completed", Ok(json!({ "uid": uid }))),
|
||||
FrontendEvent::RunStateChanged { state } => ("verge://run-state-changed", Ok(state)),
|
||||
FrontendEvent::PendingFailuresChanged => ("verge://pending-failures-changed", Ok(serde_json::Value::Null)),
|
||||
#[cfg(target_os = "linux")]
|
||||
FrontendEvent::ThemeChanged { theme } => ("tauri://theme-changed", serde_json::to_value(theme)),
|
||||
}
|
||||
serialize_frontend_event(event)
|
||||
}
|
||||
|
||||
pub(crate) fn send_event(app_handle: AppHandle, event: FrontendEvent) {
|
||||
@@ -381,20 +368,21 @@ mod wire_tests {
|
||||
}
|
||||
|
||||
/// One sample per `FrontendEvent` variant, paired with its wire name and payload.
|
||||
/// Shared by the wire golden test and the contract export so the two cannot drift.
|
||||
#[cfg(test)]
|
||||
fn sample_frontend_events(uid: &String) -> Vec<(FrontendEvent<'_>, &'static str, serde_json::Value)> {
|
||||
let state = crate::core::runstate::RunState {
|
||||
health: Default::default(),
|
||||
pending: None,
|
||||
sidecar_allowed: false,
|
||||
mode: crate::core::manager::RunningMode::NotRunning,
|
||||
is_admin: false,
|
||||
op_in_flight: false,
|
||||
}
|
||||
.to_view();
|
||||
vec![
|
||||
(
|
||||
FrontendEvent::RefreshClash,
|
||||
"verge://refresh-clash-config",
|
||||
json!("yes"),
|
||||
),
|
||||
(
|
||||
FrontendEvent::RefreshVerge,
|
||||
"verge://refresh-verge-config",
|
||||
json!("yes"),
|
||||
),
|
||||
(FrontendEvent::RefreshProfiles, "verge://refresh-profiles", json!("yes")),
|
||||
(FrontendEvent::RefreshClash, "verge://refresh-clash-config", json!(null)),
|
||||
(FrontendEvent::RefreshVerge, "verge://refresh-verge-config", json!(null)),
|
||||
(FrontendEvent::RefreshProfiles, "verge://refresh-profiles", json!(null)),
|
||||
(
|
||||
FrontendEvent::RefreshProxyConfig,
|
||||
"verge://refresh-proxy-config",
|
||||
@@ -414,11 +402,9 @@ fn sample_frontend_events(uid: &String) -> Vec<(FrontendEvent<'_>, &'static str,
|
||||
json!("profile"),
|
||||
),
|
||||
(
|
||||
FrontendEvent::RunStateChanged {
|
||||
state: json!({"running": true}),
|
||||
},
|
||||
FrontendEvent::RunStateChanged { state: state.clone() },
|
||||
"verge://run-state-changed",
|
||||
json!({"running": true}),
|
||||
serde_json::to_value(state).unwrap_or(serde_json::Value::Null),
|
||||
),
|
||||
(
|
||||
FrontendEvent::PendingFailuresChanged,
|
||||
@@ -445,24 +431,41 @@ fn sample_frontend_events(uid: &String) -> Vec<(FrontendEvent<'_>, &'static str,
|
||||
]
|
||||
}
|
||||
|
||||
/// Machine-readable form of the frontend wire contract: every event name with a
|
||||
/// serialized sample payload, plus every notice status string. The sample
|
||||
/// payload's JSON shape (string / null / array / object) is what the frontend
|
||||
/// generator maps to TypeScript types. `ThemeChanged` is a Tauri built-in
|
||||
/// linux-only window event, not part of this contract.
|
||||
pub fn frontend_wire_contract() -> serde_json::Value {
|
||||
let uid = String::from("sample");
|
||||
let events: Vec<_> = sample_frontend_events(&uid)
|
||||
.into_iter()
|
||||
.map(|(event, _, _)| {
|
||||
let (name, payload) = NotificationSystem::serialize_event(event);
|
||||
json!({ "name": name, "sample": payload.unwrap_or(serde_json::Value::Null) })
|
||||
})
|
||||
.collect();
|
||||
let statuses: Vec<_> = NoticeStatus::ALL
|
||||
.iter()
|
||||
// A unit variant with a string rename cannot fail to serialize.
|
||||
.filter_map(|status| serde_json::to_value(status).ok())
|
||||
.collect();
|
||||
json!({ "version": 1, "events": events, "noticeStatuses": statuses })
|
||||
macro_rules! frontend_events {
|
||||
($( $field:ident: $payload:ty = $name:literal, $pattern:pat => $value:expr; )*) => {
|
||||
#[derive(serde::Serialize, specta::Type)]
|
||||
pub struct VergeEventPayloads {
|
||||
$(#[serde(rename = $name)] pub $field: $payload,)*
|
||||
}
|
||||
|
||||
fn serialize_frontend_event(event: FrontendEvent) -> (&'static str, Result<serde_json::Value, serde_json::Error>) {
|
||||
match event {
|
||||
$($pattern => {
|
||||
let payload: $payload = $value;
|
||||
($name, serde_json::to_value(payload))
|
||||
},)*
|
||||
#[cfg(target_os = "linux")]
|
||||
FrontendEvent::ThemeChanged { theme } => ("tauri://theme-changed", serde_json::to_value(theme)),
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
frontend_events! {
|
||||
refresh_clash: () = "verge://refresh-clash-config", FrontendEvent::RefreshClash => ();
|
||||
refresh_verge: () = "verge://refresh-verge-config", FrontendEvent::RefreshVerge => ();
|
||||
refresh_profiles: () = "verge://refresh-profiles", FrontendEvent::RefreshProfiles => ();
|
||||
refresh_proxy: () = "verge://refresh-proxy-config", FrontendEvent::RefreshProxyConfig => ();
|
||||
notice: (NoticeStatus, std::string::String) = "verge://notice-message", FrontendEvent::NoticeMessage { status, ref message } => (status, message.to_string());
|
||||
profile_changed: std::string::String = "profile-changed", FrontendEvent::ProfileChanged { current_profile_id } => current_profile_id.to_string();
|
||||
timer_updated: std::string::String = "verge://timer-updated", FrontendEvent::TimerUpdated { profile_index } => profile_index.to_string();
|
||||
profile_started: ProfileUpdatePayload = "profile-update-started", FrontendEvent::ProfileUpdateStarted { uid } => ProfileUpdatePayload { uid: uid.to_string() };
|
||||
profile_completed: ProfileUpdatePayload = "profile-update-completed", FrontendEvent::ProfileUpdateCompleted { uid } => ProfileUpdatePayload { uid: uid.to_string() };
|
||||
run_state: crate::core::runstate::RunStateView = "verge://run-state-changed", FrontendEvent::RunStateChanged { state } => state;
|
||||
pending_failures: () = "verge://pending-failures-changed", FrontendEvent::PendingFailuresChanged => ();
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, specta::Type)]
|
||||
pub struct ProfileUpdatePayload {
|
||||
pub uid: std::string::String,
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
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 {
|
||||
@@ -20,7 +20,7 @@ pub struct GroupScope {
|
||||
|
||||
pub struct ProxyViewBuilder;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProxyViewV1 {
|
||||
pub schema_version: u8,
|
||||
@@ -34,21 +34,21 @@ pub struct ProxyViewV1 {
|
||||
pub providers: Vec<ProxyProviderView>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, specta::Type)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ProxyViewOrderSource {
|
||||
Runtime,
|
||||
Fallback,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, specta::Type)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ProxyViewProviderState {
|
||||
Ready,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, specta::Type)]
|
||||
pub struct ProxyCapabilities {
|
||||
pub udp: bool,
|
||||
pub xudp: bool,
|
||||
@@ -57,13 +57,13 @@ pub struct ProxyCapabilities {
|
||||
pub smux: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProxyGroupView {
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
#[specta(type = String)]
|
||||
pub proxy_type: ProxyType,
|
||||
pub alive: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub now: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -74,23 +74,23 @@ pub struct ProxyGroupView {
|
||||
pub icon: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub test_url: Option<String>,
|
||||
#[specta(type = Vec<crate::ipc::DelayHistory>)]
|
||||
pub history: Vec<DelayHistory>,
|
||||
pub extra: HashMap<String, Extra>,
|
||||
#[serde(flatten)]
|
||||
pub capabilities: ProxyCapabilities,
|
||||
pub members: Vec<ProxyMemberRef>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProxyNodeView {
|
||||
pub record_id: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
#[specta(type = String)]
|
||||
pub proxy_type: ProxyType,
|
||||
pub alive: bool,
|
||||
#[specta(type = Vec<crate::ipc::DelayHistory>)]
|
||||
pub history: Vec<DelayHistory>,
|
||||
pub extra: HashMap<String, Extra>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -104,7 +104,7 @@ pub struct ProxyNodeView {
|
||||
pub source: ProxyNodeSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, specta::Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum ProxyNodeSource {
|
||||
Core {
|
||||
@@ -119,7 +119,7 @@ pub enum ProxyNodeSource {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, specta::Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum ProxyMemberRef {
|
||||
Group {
|
||||
@@ -136,7 +136,7 @@ pub enum ProxyMemberRef {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, specta::Type)]
|
||||
pub enum ProxyMemberUnresolvedReason {
|
||||
#[serde(rename = "missing")]
|
||||
Missing,
|
||||
@@ -146,7 +146,7 @@ pub enum ProxyMemberUnresolvedReason {
|
||||
ProviderUnavailable,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProxyProviderView {
|
||||
pub name: String,
|
||||
@@ -158,7 +158,7 @@ pub struct ProxyProviderView {
|
||||
pub proxy_record_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, specta::Type)]
|
||||
pub enum ProxyProviderVehicleType {
|
||||
#[serde(rename = "HTTP")]
|
||||
Http,
|
||||
@@ -168,7 +168,7 @@ pub enum ProxyProviderVehicleType {
|
||||
Inline,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, specta::Type)]
|
||||
pub struct ProxySubscriptionInfo {
|
||||
pub upload: i64,
|
||||
pub download: i64,
|
||||
@@ -256,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 {
|
||||
@@ -289,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);
|
||||
}
|
||||
}
|
||||
@@ -402,9 +402,7 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
|
||||
icon,
|
||||
now,
|
||||
test_url,
|
||||
alive,
|
||||
history,
|
||||
extra,
|
||||
udp,
|
||||
xudp,
|
||||
tfo,
|
||||
@@ -431,14 +429,12 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
|
||||
ProxyGroupView {
|
||||
name,
|
||||
proxy_type,
|
||||
alive,
|
||||
now,
|
||||
fixed,
|
||||
hidden,
|
||||
icon,
|
||||
test_url,
|
||||
history,
|
||||
extra,
|
||||
capabilities: ProxyCapabilities {
|
||||
udp,
|
||||
xudp,
|
||||
@@ -456,9 +452,7 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo
|
||||
hidden,
|
||||
icon,
|
||||
test_url,
|
||||
alive,
|
||||
history,
|
||||
extra,
|
||||
udp,
|
||||
xudp,
|
||||
tfo,
|
||||
@@ -472,9 +466,7 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo
|
||||
record_id,
|
||||
name,
|
||||
proxy_type,
|
||||
alive,
|
||||
history,
|
||||
extra,
|
||||
id,
|
||||
hidden,
|
||||
icon,
|
||||
@@ -528,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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,13 +14,13 @@ pub enum ServiceHealth {
|
||||
}
|
||||
|
||||
impl ServiceHealth {
|
||||
const fn kind(&self) -> &'static str {
|
||||
const fn kind(&self) -> ServiceHealthKind {
|
||||
match self {
|
||||
Self::Unknown => "unknown",
|
||||
Self::Ready => "ready",
|
||||
Self::NotInstalled => "notInstalled",
|
||||
Self::VersionMismatch => "versionMismatch",
|
||||
Self::Unavailable(_) => "unavailable",
|
||||
Self::Unknown => ServiceHealthKind::Unknown,
|
||||
Self::Ready => ServiceHealthKind::Ready,
|
||||
Self::NotInstalled => ServiceHealthKind::NotInstalled,
|
||||
Self::VersionMismatch => ServiceHealthKind::VersionMismatch,
|
||||
Self::Unavailable(_) => ServiceHealthKind::Unavailable,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ impl ServiceHealth {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PendingAction {
|
||||
Install,
|
||||
@@ -118,11 +118,11 @@ impl RunState {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RunStateView {
|
||||
pub mode: RunningMode,
|
||||
pub service: &'static str,
|
||||
pub service: ServiceHealthKind,
|
||||
pub service_unavailable_reason: Option<String>,
|
||||
pub pending_action: Option<PendingAction>,
|
||||
pub sidecar_allowed: bool,
|
||||
@@ -232,7 +232,7 @@ mod tests {
|
||||
|
||||
let view = run_state.to_view();
|
||||
|
||||
assert_eq!(view.service, "unavailable");
|
||||
assert_eq!(view.service, ServiceHealthKind::Unavailable);
|
||||
assert_eq!(view.service_unavailable_reason.as_deref(), Some("socket refused"));
|
||||
assert_eq!(view.pending_action, Some(PendingAction::Reinstall));
|
||||
assert!(view.tun_capable, "elevation alone makes TUN possible");
|
||||
@@ -244,7 +244,7 @@ mod tests {
|
||||
fn a_healthy_view_reports_no_unavailable_reason() {
|
||||
let view = state(ServiceHealth::Ready, false, false).to_view();
|
||||
|
||||
assert_eq!(view.service, "ready");
|
||||
assert_eq!(view.service, ServiceHealthKind::Ready);
|
||||
assert_eq!(view.service_unavailable_reason, None);
|
||||
assert!(view.service_usable);
|
||||
assert!(!view.service_needs_attention);
|
||||
@@ -350,3 +350,13 @@ mod tests {
|
||||
assert!(settled.tun_should_be_disabled(true));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ServiceHealthKind {
|
||||
Unknown,
|
||||
Ready,
|
||||
NotInstalled,
|
||||
VersionMismatch,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ static PENDING_SERVICE_REPAIR_NOTICE: AtomicBool = AtomicBool::new(false);
|
||||
static PENDING_SERVICE_OWNER_NOTICE: Mutex<Option<String>> = Mutex::new(None);
|
||||
|
||||
/// Why the Service was unavailable when the core fell back to Sidecar.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, specta::Type)]
|
||||
#[serde(tag = "kind", content = "reason", rename_all = "camelCase")]
|
||||
pub enum ServiceFallbackNotice {
|
||||
Unavailable,
|
||||
|
||||
@@ -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",
|
||||
|
||||
+149
-363
@@ -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;
|
||||
@@ -28,7 +28,6 @@ use tauri::{
|
||||
menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu},
|
||||
};
|
||||
|
||||
mod latency;
|
||||
mod menu_def;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod speed_task;
|
||||
@@ -56,25 +55,8 @@ enum IconKind {
|
||||
Tun,
|
||||
}
|
||||
|
||||
struct LatencyMenuItem {
|
||||
item: MenuItem<Wry>,
|
||||
running: bool,
|
||||
proxies: Vec<ProxyLatencyItem>,
|
||||
provider_state: ProxyViewProviderState,
|
||||
}
|
||||
|
||||
struct ProxyLatencyItem {
|
||||
item: CheckMenuItem<Wry>,
|
||||
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,
|
||||
}
|
||||
@@ -148,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(),
|
||||
}
|
||||
@@ -216,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(());
|
||||
@@ -241,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, "应用正在退出,跳过托盘图标更新");
|
||||
@@ -548,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,
|
||||
}
|
||||
@@ -583,175 +456,96 @@ 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,
|
||||
proxy_group_order_map: Option<HashMap<String, usize>>,
|
||||
proxy_nodes_data: Option<Proxies>,
|
||||
) -> Vec<Submenu<Wry>> {
|
||||
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,
|
||||
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<Wry>> = vec![&test_item, &separator];
|
||||
items.extend(group_items.iter().map(|item| item as &dyn IsMenuItem<Wry>));
|
||||
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(
|
||||
@@ -798,38 +592,54 @@ async fn create_tray_menu(
|
||||
tun_mode_available: bool,
|
||||
profiles_preview: Vec<IProfilePreview<'_>>,
|
||||
options: TrayMenuOptions,
|
||||
) -> Result<(tauri::menu::Menu<Wry>, 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");
|
||||
@@ -909,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)?,
|
||||
@@ -1063,25 +865,10 @@ async fn create_tray_menu(
|
||||
]);
|
||||
|
||||
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 { .. }
|
||||
@@ -1160,7 +947,15 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
|
||||
logging!(error, Type::Tray, "Failed to close all connections from tray: {err}");
|
||||
}
|
||||
}
|
||||
MenuIds::COPY_ENV => feat::copy_clash_env().await,
|
||||
MenuIds::COPY_ENV => {
|
||||
if let Err(err) = feat::copy_clash_env().await {
|
||||
logging!(
|
||||
error,
|
||||
Type::Tray,
|
||||
"Failed to copy proxy environment variables from tray: {err:#}"
|
||||
);
|
||||
}
|
||||
}
|
||||
MenuIds::CONF_DIR => {
|
||||
let _ = cmd::open_app_dir().await;
|
||||
}
|
||||
@@ -1195,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,
|
||||
|
||||
@@ -34,7 +34,7 @@ impl SilentUpdater {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[derive(Serialize, Deserialize, specta::Type)]
|
||||
struct UpdateCacheMeta {
|
||||
version: String,
|
||||
downloaded_at: String,
|
||||
@@ -214,7 +214,7 @@ fn nsis_language_id(app_language: &str) -> &'static str {
|
||||
}
|
||||
|
||||
/// Same wire shape as the updater plugin's JS `DownloadEvent`.
|
||||
#[derive(Clone, Serialize)]
|
||||
#[derive(Clone, Serialize, specta::Type)]
|
||||
#[serde(tag = "event", content = "data")]
|
||||
pub enum DownloadEvent {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user